耗时:约6h
写作背景:读完《Redis设计与实现》第二版 这本书后,感觉收获很多,特花时间组织思绪,默写梳理学到的知 识。这本书基于Redis3.0介绍,目前Redis版本更新至Redis7.x,部分与最新版本有些许出入,整体基本一致。日后阅读学习基于更新版本的Redis书籍后,再进行一次梳理。
1.1 基本数据结构
Redis使用五大基本数据对象实现对全部功能的支持。
数据对象 | 编码(REDIS_ENCODING_*) | 说明 |
---|---|---|
字符串 | INT | 整数 |
EMBSTR | embstr编码的SDS | |
RAW | SDS | |
列表 | ZIPLIST | 压缩列表 (存在连锁更新的问题,但触发可能性小) |
LINKEDLIST | 双端链表 | |
哈希 | ZIPLIST | |
HT | ||
集合 | INTSET | 整数集合 |
HT | ||
有序集合 | ZIPLIST | |
SKIPLIST (实际代表SKIPLIST + HT) | 跳表 + 字典 |
- 字符串
- 当可以使用整数表示字符串时,即使用INT;
- 如果浮点数可以用long double表示,或者字节数小于39B,则使用EMBSTR,与RAW很像,只是一次分配内存,使得内存连续紧凑;(实际上embstr可看做是只读的,出现写操作也会转化为RAW);
- 不满足以上条件时,使用RAW;
- 列表
- 当元素大小小于64B,元素数小于512个,则使用ZIPLIST;
- 否则使用LINKEDLIST;
- 哈希
- 当元素大小小于64B,键值对数小于512个,使用ZIPLIST;
- 否则使用HT;
- 字典使用两个哈希表来支持,方便扩缩容rehash,而且rehash是渐进式rehash;
- 集合
- 当元素均为整数,元素数小于512个,则使用INTSET;(整数集合自身可根据整数位数进行升级调整,最多支持uint64,否则转换为HT)
- 否则使用HT;
- 有序集合
- 当元素大小小于64B,元素数小于512个(所以实际相当于可存储256个有序元素),则使用ZIPLIST;
- 否则使用SKIPLIST,跳表用来维持有序,支持范围型操作;字典用来O(1)支持指定元素操作。
1.2 客户端
- 以redisClient表示一个客户端,其中包含客户端状态,输入缓冲区(SDS),输出缓冲区(固定大小char[] + 可变大小list对象),客户端名称(没有名字则使用ip:port作为名字)等;
- 客户端状态flag是一个整数,每一位都代表一个状态的开关,通过逻辑运算开关(这种做法在Redis内部广泛使用);
- 输入缓冲区大于1G时,关闭客户端(实际存储超过一定长度,也会把输入缓冲区释放重建);
- 输出缓冲区有两个,固定大小可配置,默认16KB;可变大小使用list对象实现,理论大小无限,实际存在软性限制和硬性限制:
- 超过硬性限制,释放输出缓冲区,并关闭客户端;
- 超过软性限制,未超过硬性限制,则进入观察状态,如果持续一段时间(可配置)不再超过软性限制,则退出观察状态,否则释放 + 关闭;
- 除了类似redis-cli这样的客户端,实际还存在没有网络功能的伪客户端(Lua客户端,AOF文件载入客户端),因为Redis服务器仅能从客户端接收并处理命令,所以需要伪客户端来支持。
- 所有客户端以链表存储并在redisServer中管理。
1.3 服务器
- 以redisServer表示一个服务器,其中包含数据库数组(默认16个,可配置),客户端链表,持久化标识,时间缓存等;
- 服务器启动流程:
- 初始化服务器状态,配置默认的参数值,比如数据库数量,大多都是一些整数,浮点数值,初始化唯一一个数据结构——命令表;
- 载入配置文件;
- 初始化数据机构,包括Lua环境,慢查询日志,共享对象(固定的回复,0~9999的SDS),I/O模块,打开AOF文件,进程信号处理器等;
- 还原数据库状态(载入RDB文件或者AOF文件,优先载入AOF文件);
- 周期检查事件:serverCron,每秒执行10次,即每100ms执行一次,执行的动作包括:
- 更新时间缓存(s,ms),用于对时间要求精度不高的场景:键的空转时长,普通日志等,;
- 更新LRU时间,用于计算键的空转时长;
- 计算每秒执行命令数。内部有一个大小为16的环形数组,每个槽位都是一次每秒执行命令数结果,(当前时间 – 上一次执行时间)/ (当前命令总数 – 上一次命令总数)计算新的槽位值,然后整体求和取平均数;
- 更新内存峰值:检查当前内存占用,然后与维护值比较,取较大者;
- 检查进程信号:shutdown等;
- 判断是否将AOF缓冲区内容写入AOF文件中;
- 管理客户端:
- 检查客户端空转时长,时间超过阈值(可配置)则关闭客户端;
- 检查客户端输入缓冲区,超过一定长度,则释放缓冲区,并重新创建;
- 管理服务器:
- 删除过期键——定期策略:依次遍历所有数据库,每个数据库随机检查一部分键是否过期,并记录检查进度,下一次检查从当前数据库继续检查,而不是从头遍历;
- 判断是否达到定期保存条件(bgsave):redisServer中保存了dirty,lastsave;
- 检查是否需要AOF重写;
- 更新serverCron执行次数等;
- ……
1.4 数据库
- 以redisDb表示一个数据库,存储着键空间(字典),过期字典等;
- 对键执行命令时,先在过期字典中查询:
- 如果找到了,则取过期时间,判断是否已到达该时间,则删除,返回回复;如果没到达,再去键空间找键;
- 如果没找到,则可能是永久键,也可能是键不存在,所以去键空间找键;
- 单机状态下,可以选择任意数据库,但集群状态下,默认且只能选择0号数据库。
1.5 事件
1.5.1 文件事件
来自客户端的命令都被抽象成文件事件,并被分排到与套接字相关联的事件处理器中进行处理。
- 单线程处理;
- I/O多路复用程序维护了一个存储套接字的队列,若套接字存在事件,放入队列,并传给分排器一个套接字直至完全处理后再传入下一个;
- 来自套接字的事件被分为两种,可读事件和可写事件;
- 从客户端连接建立到命令处理全过程:
- 服务器有一个监听套接字,与连接应答处理器关联,当与新客户端建立连接以后,在服务器创建一个新的套接字与该客户端通信,并与命令请求处理器相关联;
- 服务器收到客户端的协议格式命令,套接字变为可读状态,命令请求处理器将请求的命令解析出来并放入redisClient中;
- 接着命令执行器开始工作:
- 命令执行器查找命令表(全局的),将对应的redisCommand查找出来;
- 命令执行预备工作:
- 判断redisCommand是否存在;
- 核验是否满足redisCommand的配置,比如命令状态,参数数量等;
- 是否达到maxMemory,如果达到,内存回收是否成功;
- 如果监视器链表不为空,则把命令发送给那些监视器客户端;
- …….;
- 执行命令实现函数;
- 命令执行后续工作:
- 判断是否需要写入慢查询日志;
- 更新redisCommand参数,本次命令耗时,调用次数;
- 追加AOF缓冲区;
- 如果是主从架构,命令传播给从服务器;
- 等待套接字进入可写状态,然后与命令回复处理器关联,将回复写入客户端输出缓冲区,如果一次写不完,分多次写;写完后,解除与命令回复处理器的关联。
1.5.2 时间事件
比如serverCron,是一个周期事件。集群和哨兵状态下,还有独有的周期事件。
- 时间事件分为定时事件和周期事件,每个时间事件关联一个时间事件处理器,通过处理后的返值,判断时间事件接下来的行为;
- 如果是一个特殊标识,则认识到是一个定时事件,将该事件删除;
- 如果不是,则是该事件的周期时间,将其更新到下一次到达时间中;
- 时间事件存储在一个无序链表中,即没有按到达时间排序,仅头插进去;
- 时间事件和文件事件还有配合,在一次事件循环中,先处理文件事件,再处理时间事件;阻塞等待文件事件的超时时间由最近到达时间的时间事件的剩余时间决定,所以无需担心因阻塞等待文件事件而不执行时间事件;
- 无序链表存储时间事件(花费时间遍历链表),以及文件事件,时间事件的先后处理顺序(不会中断正在执行的事件),都可能错过准确的执行开始时间,导致时间事件的执行开始时间并不准确。
1.6 持久化
bgsave,save,rewriteaof,只有在进行bgsave时接收到rewriteaof会推迟rewriteaof执行,其他情况都会直接拒绝后来的命令。
1.6.1 RDB
为当前数据库状态(包括所有数据库)生成一个快照。
- 生成RDB时,会过滤过期键;
- save使用主线程阻塞生成RDB;
- bgsave会创建一个子进程来进行该操作;
- 载入RDB时,两种情况:
- 主服务器/单机状态:检查过期键;
- 从服务器:不作判断,全部接受;
- RDB进行过程中,无需处理在这期间的写命令,因为RDB本身只保存某个时间点的快照,在下一次快照之间都不关心。
1.6.2 AOF
只保存协议格式的写命令。
- AOF不会检查他记录的键是否已过期,只有触发对过期键的删除操作才会记录进AOF文件;
- AOF文件载入优先级比RDB高;
- AOF分为三个步骤:追加,写入,同步;
- 追加:写入AOF缓冲区;
- 写入:AOF缓冲区写入AOF文件;
- 同步:写入内容落盘
- always:每条命令执行完都同步;
- everysec:每秒同步一次;
- no:无固定周期,落盘时机由操作系统控制;
- 当AOF文件大小达到阈值或增长百分比达到阈值,则触发重写;
- 重写期间将写命令保存至AOF重写缓冲区,即双写AOF缓冲区和AOF重写缓冲区,在创建新AOF文件期间,继续对旧AOF文件写入。
1.7 主从复制
主从服务器,将彼此均视为客户端。
- 主从复制全过程:
- 从服务器接收到 slaveof 命令,将master ip 和port均保存在redisServer中;
- 创建套接字与主服务器建立连接,套接字与复制处理器关联;
- 从服务器发送ping命令,检查网络连通性,如果回复超时,则销毁套接字重建连接;
- 身份认证;
- 从服务器把自身监听的端口号发送给主服务器;
- 开始尝试进行同步操作:
- 从服务器向主服务器发送 自身保存的 主服务器ID(没有则为 -1)和自身的复制偏移量;
- 主服务器对比服务器ID,如果不同,则进行完全重同步(主服务器生成一个RDB文件,以及主服务器ID发送给从服务器,期间接收的写命令存储在RDB缓冲区中,RDB文件传送完成后,将缓冲区内容发送给从服务器);
- 在replication_backlog中寻找是否有接收的复制偏移量,如果没有,则进行完全重同步;
- 不进行完全重同步,则进行部分重同步;
- 开始命令传播。
1.8 哨兵
- 哨兵节点启动过程:
- 初始化服务器状态;
- 使用sentinel专用代码,比如:周期serverCron,命令表(比单机命令表少了很多命令);
- 初始化sentinel状态(sentinelState);
- 载入sentinel配置文件,并初始化sentinelState中的master链表;
- 与非sentinel节点建立两条连接:命令连接(命令通信)和订阅连接(发布订阅机制,因为没有客户端订阅频道时,频道消息会直接丢弃,所以必须保持订阅连接)。
- 周期操作:
- sentinel先与主节点通信,每10s发送info命令,获取主节点信息,并获得其从节点信息,与从节点建立两条连接;
- sentinel每2s发送频道消息(八参数),借助主节点,与其他sentinel节点分享自己的信息,并与新的sentinel节点建立命令连接;
- sentinel每秒向主节点发送ping命令,如果主节点超过时间阈值(可配置)都没有回复,则该sentinel节点主观下线该主节点,给其状态开启主管下线标识;然后向其他sentinel节点发送命令,确认主节点情况,得到回复【目标sentinel是否认为主观下线,局部领头sentinel的ID,配置纪元】。当sentinel发现一定数量(可配置)的sentinel节点都认为主观下线,则该sentinel判定主节点客观下线;所以当一个sentinel判定某主节点客观下线,另一个sentinel不一定认为其客观下线;
- 值得一提的是,判断主节点主观下线的时间阈值也被用来判断sentinel和从节点是否主观下线;
- 选举领头sentinel:判定主节点客观下线后,开始选举操作
- 认为客观下线的sentinel开始向其他没有认为客观下线的sentinel拉票;
- 拉票先到先得,同一个配置纪元内,不会重复投票;
- sentinel获得超过半数以上投票(包括自己票),即成为领头sentinel;
- 故障转移
- 排除已经确定下线的从节点;
- 排除在一定时间(可配置)内未与领头sentinel通信过的从节点;
- 排除超过 time(主节点主观下线时间阈值) * 10 时间内未与主节点通信过的从节点;
- 选取优先级最高,复制偏移量最大,ID最小的从节点作为新主节点;
- 将主节点变为从节点的信息维护在领头sentinel中,当主节点重新上线时,向其发送slaveof命令使其成为新主节点的从节点。
1.9 集群
- 集群基于Gossip协议进行通信,ping,pong,meet消息构成了该协议,此外还有fail消息,pubsub消息;
- 集群节点的输入/输出缓冲区都是SDS对象;
- 五种消息作用:
- 通过meet消息,将新节点添加到集群中;
- 集群中的某节点为新节点创建相应的clusterNode来维护,然后向新节点发送meet;
- 新节点收到后,返回pong,并创建跟他打招呼的节点的clusterNode;
- 集群节点收到pong后,回复ping;
- 新节点加入到集群中;
- 经过一段时间后,集群中其它节点也会知道新节点的信息;
- 集群节点之后通过ping消息,将新节点信息传播给其他节点;
- ping消息是心跳包;
- 集群节点每秒从随机挑选的5个节点中,选择未通信时间最长的节点发送ping消息,即心跳包;此外,当某节点与该节点未通信时间超过设定阈值一半时,也会向其发送ping,以防止长时间随机选择不到某节点;
- pong消息是ping的回复,也是故障转移完成后,新主节点通知给其他节点的手段(广播pong消息);
- fail消息在某主节点被判定客观下线时广播给其他节点,进入故障转移阶段;
- 集群使用消息通信,所以不能直接发送写命令,所以当向一个节点发送pubsub消息,该节点在频道发送消息,然后将该命令广播给其他节点,其他节点收到后也在频道发送消息;
- 通过meet消息,将新节点添加到集群中;
- 哈希槽
- 当哈希槽均被分配时,集群从下线状态转换为上线状态;
- 总共16384个槽位;
- clusterState中使用一个clusterNode* []维护了每个槽位被哪个节点所管理;
- clusterState中还使用一个有序集合来维护键和槽的对应关系;
- 在clusterNode内部使用char[2048]维护自己所管理的槽位信息;
- 重定向
- 因为键被分片保存在集群中的不同节点中,所以客户端想要操作的键不一定在其访问的目标节点上,所以此时需要给客户端回复重定向消息,以找到真正保存此键的节点;
- clusterState中维护了一个导入字典,一个迁出字典;
- 客户端可能收到两种重定向消息,在集群模式下,客户端会隐藏重定向消息,不会展示给用户:
- moved(两种情况)
- 集群平稳运行,只是单纯重定向位置;
- 当哈希槽正在迁移,客户端直接向迁移的目标节点访问正在迁移的槽位上的键(无论是否真的存在);
- 这是因为迁移中的目标节点只有收到asking命令才会强制查看正在迁移中的槽,而只有收到ask消息的客户端才会再访问目标节点前发送一条asking命令;
- ask
- 哈希槽正在从源节点向目标节点迁移,客户端访问哈希槽上已经迁移过去的键;
- moved(两种情况)
- 下线检测
- 由于节点间每秒发送ping消息进行通信和检测状态,所以当某节点超过一定时间阈值(可配置)后,发现超时的节点将会给超时节点打上主观下线标记,然后照常发送ping消息给其他节点来同步主观下线的节点;
- 当收到某主节点报告的超时报告后,将其记录下来,包括报告者和报告时间(过久报告的将会移除);
- 当有主节点发现有超过集群中半数以上的主节点报告某节点主观下线后,判定为客观下线状态并标记,立刻广播fail消息,通知其它节点;
- 故障转移
- 当客观下线主节点的从节点收到fail消息后,使用slaveof no one,解除主从关系;
- 从节点发送消息开始拉票;
- 当主节点收到拉票消息后,如果在此次配置纪元尚未投票,则向此从节点投票(FIFO);
- 从节点收到半数以上主节点投票后,得知自己是主节点;
- 新主节点撤销旧主节点所负责的槽位,并由自己负责;
- 向集群中其它节点广播pong消息,通知其他节点其为新的主节点。
1.10 发布订阅
- 内部维护了一个频道字典,键为频道名,值为订阅该频道的客户端链表(尾插),当某频道没有订阅后,从字典中删除;
- 内部还维护了一个模式链表,节点是订阅该模式的客户端以及订阅的模式;
- 向某频道发送消息时,若该频道与某模式相匹配,订阅模式的客户端也会收到消息。
1.11 事务
- 使用multi开启事务后,客户端的命令(除事务相关事务)被命令请求处理器处理后,会被放入一个命令队列中;
- 监视在命令入队期间键是否改变由redisServer的一个监视字典支持实现,键为键名,值为监视这个键的客户端,其他客户端对被监视键执行写操作后,对监视该键的客户端开启DIRTY_CAS标记
- 当收到exec后,如果发现客户端标记DIRTY_CAS开启,则放弃执行整个事务;
- 在redis2.6.5之前,事务仍会执行,但会跳过改变的被监视键的命令。
1.12 Lua脚本
- Lua环境初始化过程
- 创建基础lLua环境;
- 载入函数库;
- 创建全局Redis表格,以支持在Lua脚本中通过redis.call或redis.pcall使用redis命令;
- 使用内置随机函数替换库中随机函数,以保证在一样的脚本在不同的机器上执行结果相同;
- 创建排序辅助函数,以保证每次获取乱序的结果相同;
- 创建错误报告辅助函数,以打印更详细的错误信息;
- 保护全局变量,以防止脚本添加全局变量,但不能防止被修改;
- 将Lua环境与redisServer中的lua字段相关联;
- 执行Lua脚本
- redisServer维护了一个lua脚本字典,键为脚本的sha1散列值,值为脚本内容;
- 使用eval,script load都能向这个字典中添加新的键值对;
- evalsha1也就是用参数sha1值从字典中取出相应的脚本来执行;
- 脚本执行过程,以eval为例:
- 向lua脚本字典添加新键值对;
- 在lua环境中创建一共新函数,函数名是前缀 f_ + 脚本sha1值,函数体是脚本内容。这个函数会一直保留至环境被释放,也就是说,再次执行该脚本,可以直接在lua环境中找到并执行;
- 如果配置的话,接着为这个函数创建一个超时钩子函数,当超时以后,会开始周期检查,监视是否有shutdown命令,可以使脚本停止执行;
- 执行脚本函数;
- 执行完后,执行清理操作;
- 向客户端发送回复;
- 脚本复制
- 在主从架构下,需要保证脚本传播给了全部从节点,这样才能保证从节点也能顺利执行evalsha命令。redisServer为了实现脚本复制功能,维护了一个luascript_cache字典,类似复制偏移量的概念。
- 其他脚本执行命令入参都有脚本内容,只有evalsha需要有脚本缓存才能成功执行。所以当执行evalsha时发现luascript_cache没有,则会转换为eval命令,然后命令传播给子节点,然后添加到luascript_cache字典中;
- 当有新的从节点加入后,luascript_cache字典会清空;
1.1.3 慢查询日志
- 固定大小队列(list对象),满足先入先出;
- 达到一定容量大小后,丢弃最旧的日志
1.1.3 监视器
- 客户端成为监视器后,在其他客户端命令执行之前,被发送到监视器客户端。
留言