Redis源码解析
数据结构模块
键值对字符串
-
char* 的不足:
-
操作效率低:获取长度需遍历,O(N)复杂度
-
二进制不安全:无法存储包含 \0 的数据
SDS 的优势:
-
操作效率高:获取长度无需遍历,O(1)复杂度(通过len和alloc,快速获取字符长度大小以及跳转到字符串末尾)
-
二进制安全:因单独记录长度字段,所以可存储包含 \0 的数据
-
兼容 C 字符串函数,可直接使用字符串 API
-
紧凑型内存设计(按照字符串类型,len和alloc使用不同的类型节约内存,并且关闭内存对齐来达到内存高效利用,在redis中除了sds,intset和ziplist也有类似的目底)
-
避免频繁的内存分配。除了sds部分类型存在预留空间,sds设计了sdsfree和sdsclear两种字符串清理函数,其中sdsclear,只是修改len为0以及buf为'\0',并不会实际释放内存,避免下次使用带来的内存开销
-
-
Redis 在操作 SDS 时,为了避免频繁操作字符串时,每次「申请、释放」内存的开销,还做了这些优化:
-
内存预分配:SDS 扩容,会多申请一些内存(小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容)
-
多余内存不释放:SDS 缩容,不释放多余的内存,下次使用可直接复用这些内存
这种策略,是以多占一些内存的方式,换取「追加」操作的速度。 这个内存预分配策略,详细逻辑可以看 sds.c 的 sdsMakeRoomFor 函数。
-
-
SDS 字符串在 Redis 内部模块实现中也被广泛使用,在 Redis server 和客户端的实现中,找到使用 SDS 字符串的地方很多:
- Redis 中所有 key 的类型就是 SDS(详见 db.c 的 dbAdd 函数)
- Redis Server 在读取 Client 发来的请求时,会先读到一个缓冲区中,这个缓冲区也是 SDS(详见 server.h 中 struct client 的 querybuf 字段)
- 写操作追加到 AOF 时,也会先写到 AOF 缓冲区,这个缓冲区也是 SDS (详见 server.h 中 struct client 的 aof_buf 字段)
Hash表
-
Redis 中的 dict 数据结构,采用「链式哈希」的方式存储,当哈希冲突严重时,会开辟一个新的哈希表,翻倍扩容,并采用「渐进式 rehash」的方式迁移数据
-
所谓「渐进式 rehash」是指,把很大块迁移数据的开销,平摊到多次小的操作中,目的是降低主线程的性能影响
-
Redis 中凡是需要 O(1) 时间获取 k-v 数据的场景,都使用了 dict 这个数据结构,也就是说 dict 是 Redis 中重中之重的「底层数据结构」
-
dict 封装好了友好的「增删改查」API,并在适当时机「自动扩容、缩容」,这给上层数据类型(Hash/Set/Sorted Set)、全局哈希表的实现提供了非常大的便利
-
例如,Redis 中每个 DB 存放数据的「全局哈希表、过期key」都用到了 dict:
// server.h typedef struct redisDb { dict *dict; // 全局哈希表,数据键值对存在这 dict *expires; // 过期 key + 过期时间 存在这 ... }
-
「全局哈希表」在触发渐进式 rehash 的情况有 2 个:
- 增删改查哈希表时:每次迁移 1 个哈希桶( dict.c 中的 _dictRehashStep 函数)
- 定时 rehash:如果 dict 一直没有操作,无法渐进式迁移数据,那主线程会默认每间隔 100ms 执行一次迁移操作。这里一次会以 100 个桶为基本单位迁移数据,并限制如果一次操作耗时超时 1ms 就结束本次任务,待下次再次触发迁移(dict.c 的 dictRehashMilliseconds 函数)
-
dict 在负载因子超过 1 时(used: bucket size >= 1),会触发 rehash。但如果 Redis 正在 RDB 或 AOF rewrite,为避免父进程大量写时复制,会暂时关闭触发 rehash。但这里有个例外,如果负载因子超过了 5(哈希冲突已非常严重),依旧会强制做 rehash(重点)
-
dict 在 rehash 期间,查询旧哈希表找不到结果,还需要在新哈希表查询一次
-
SipHash 哈希算法是在 Redis 4.0 才开始使用的,3.0-4.0 使用的是 MurmurHash2 哈希算法,3.0 之前是 DJBX33A 哈希算法
-
redis的dict结构核心就是链式hash,其原理其实和JDK的HashMap类似(JDK1.7之前的版本,1.8开始是红黑树或链表),这里就有一个问题为什么Redis要使用链式而不引入红黑树呢,或者直接使用红黑树?
- hash冲突不使用红黑树:redis需要高性能,如果hash冲突使用红黑树,红黑树和链表的转换会引起不必要的开销(hash冲突不大的情况下红黑树其实比链表沉重,还会浪多余的空间)
- dict不采用红黑树:在负载因子较低,hash冲突较低的情况下,hash表的效率O(1)远远高于红黑树
- 当采用渐进式rehash的时候,以上问题都可以解决
-
何为渐进式rehash?本质原理是什么?当内存使用变小会缩容吗?
- 渐进式rehash的本质是分治思想,通过把大任务划分成一个个小任务,每个小任务只执行一小部分数据,最终完成整个大任务的过程
- 渐进式rehash可以在不影响运行中的redis使用来完成整改hash表的扩容(每次可以控制只执行1ms)
- 初步判定会,因为dictResize中用于计算hash表大小的minimal就是来源于实际使用的大小,并且htNeedsResize方法中(used*100/size < HASHTABLE_MIN_FILL)来判断是否触发缩容来节约内存,而缩容也是渐进式rehash
-
渐进式rehash怎么去执行?
在了解渐进式rehash之前,我们需要了解一个事情,就是正在运行执行任务的redis,其实本身就是一个单线程的死循环(不考虑异步以及其他fork的场景),其循环的方法为aeMain(),位于ae.c文件中,在这个循环中每次执行都会去尝试执行已经触发的时间事件和文件事件,而渐进式rehash的每个小任务就是位于redis,serverCron时间事件中,redis每次循环的时候其实都会经过如下所示的调用流程:
- serverCron -> updateDictResizePolicy (先判断是否能执行rehash,当AOF重写等高压力操作时候不执行)
- serverCron -> databasesCron -> incrementallyRehash -> dictRehashMilliseconds -> dictRehash (dictRehashMilliseconds默认要求每次rehash最多只能执行1ms)
通过这种方式最终完成整改hash表的扩容
SDS
-
要想理解 Redis 数据类型的设计,必须要先了解 redisObject。 Redis 的 key 是 String 类型,但 value 可以是很多类型(String/List/Hash/Set/ZSet等),所以 Redis 要想存储多种数据类型,就要设计一个通用的对象进行封装,这个对象就是 redisObject。
// server.h typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; int refcount; void *ptr; } robj;
其中,最重要的 2 个字段:
-
type:面向用户的数据类型(String/List/Hash/Set/ZSet等)
-
encoding:每一种数据类型,可以对应不同的底层数据结构来实现(SDS/ziplist/intset/hashtable/skiplist等)
例如 String,可以用 embstr(嵌入式字符串,redisObject 和 SDS 一起分配内存),也可以用 rawstr(redisObject 和 SDS 分开存储)实现。
又或者,当用户写入的是一个「数字」时,底层会转成 long 来存储,节省内存。
同理,Hash/Set/ZSet 在数据量少时,采用 ziplist 存储,否则就转为 hashtable 来存。
所以,redisObject 的作用在于:
-
为多种数据类型提供统一的表示方式
-
同一种数据类型,底层可以对应不同实现,节省内存
-
支持对象共享和引用计数,共享对象存储一份,可多次使用,节省内存
redisObject 更像是连接「上层数据类型」和「底层数据结构」之间的桥梁。
-
-
关于 String 类型的实现,底层对应 3 种数据结构:
- embstr:小于 44 字节,嵌入式存储,redisObject 和 SDS 一起分配内存,只分配 1 次内存
- rawstr:大于 44 字节,redisObject 和 SDS 分开存储,需分配 2 次内存
- long:整数存储(小于 10000,使用共享对象池存储,但有个前提:Redis 没有设置淘汰策略,详见 object.c 的 tryObjectEncoding 函数)
-
ziplist 的特点:
- 连续内存存储:每个元素紧凑排列,内存利用率高
- 变长编码:存储数据时,采用变长编码(满足数据长度的前提下,尽可能少分配内存)
- 寻找元素需遍历:存放太多元素,性能会下降(适合少量数据存储)
- 级联更新:更新、删除元素,会引发级联更新(因为内存连续,前面数据膨胀/删除了,后面要跟着一起动)
List、Hash、Set、ZSet 底层都用到了 ziplist。
-
intset 的特点:
- Set 存储如果都是数字,采用 intset 存储
- 变长编码:数字范围不同,intset 会选择 int16/int32/int64 编码(intset.c 的 _intsetValueEncoding 函数)
- 有序:intset 在存储时是有序的,这意味着查找一个元素,可使用「二分查找」(intset.c 的 intsetSearch 函数)
- 编码升级/降级:添加、更新、删除元素,数据范围发生变化,会引发编码长度升级或降级
-
为什么SDS 判断是否使用嵌入式字符串的条件是 44 字节
jemalloc 分配内存机制,jemalloc 为了减少分配的内存空间大小不是2的幂次,在每次分配内存的时候都会返回2的幂次的空间大小,比如我需要分配5字节空间,jemalloc 会返回8字节,15字节会返回16字节。其常见的分配空间大小有: 8, 16, 32, 64, ..., 2kb, 4kb, 8kb。
但是这种方式也可能会造成,空间的浪费,比如我需要33字节,结果给我64字节,为了解决这个问题jemalloc将内存分配划分为,小内存(small_class)和大内存(large_class)通过不同的内存大小使用不同阶级策略,比如小内存允许存在48字节等方式。
嵌入式字符串会把 redisObject 和 SDS 一起分配内存,那在存储时结构是这样的:
- redisObject:16 个字节
- SDS:sdshdr8(3 个字节)+ SDS 字符数组(N 字节 + \0 结束符 1 个字节)
Redis 规定嵌入式字符串最大以 64 字节存储,所以 N = 64 - 16(redisObject) - 3(sdshr8) - 1(\0), N = 44 字节。
-
redis为了充分提高内存利用率,从几个方面入手:
-
淘汰不在使用的内存空间
-
紧凑型的内存设计
- 设计实现了SDS
- 设计实现了ziplist
- 设计实现了intset
- 搭配redisObject
- 设计了嵌入式字符串
-
实例内存共享
- 设计了共享对象(共享内存大部是常量实例)
-
有序集合
-
ZSet 当数据比较少时,采用 ziplist 存储,每个 member/score 元素紧凑排列,节省内存
-
当数据超过阈值(zset-max-ziplist-entries、zset-max-ziplist-value)后,转为 hashtable + skiplist 存储,降低查询的时间复杂度
-
hashtable 存储 member->score 的关系,所以 ZSCORE 的时间复杂度为 O(1)
-
skiplist 是一个「有序链表 + 多层索引」的结构,把查询元素的复杂度降到了 O(logN),服务于 ZRANGE/ZREVRANGE 这类命令
-
skiplist 的多层索引,采用「随机」的方式来构建,也就是说每次添加一个元素进来,要不要对这个元素建立「多层索引」?建立「几层索引」?都要通过「随机数」的方式来决定
-
每次随机一个 0-1 之间的数,如果这个数小于 0.25(25% 概率),那就给这个元素加一层指针,持续随机直到大于 0.25 结束,最终确定这个元素的层数(层数越高,概率越低,且限制最多 64 层,详见 t_zset.c 的 zslRandomLevel 函数)
-
这个预设「概率」决定了一个跳表的内存占用和查询复杂度:概率设置越低,层数越少,元素指针越少,内存占用也就越少,但查询复杂会变高,反之亦然。这也是 skiplist 的一大特点,可通过控制概率,进而控制内存和查询效率
-
skiplist 新插入一个节点,只需修改这一层前后节点的指针,不影响其它节点的层数,降低了操作复杂度(相比平衡二叉树的再平衡,skiplist 插入性能更优)
-
关于 Redis 的 ZSet 为什么用 skiplist 而不用平衡二叉树实现的问题,原因是:
- skiplist 更省内存:25% 概率的随机层数,可通过公式计算出 skiplist 平均每个节点的指针数是 1.33 个,平衡二叉树每个节点指针是 2 个(左右子树)
- skiplist 遍历更友好:skiplist 找到大于目标元素后,向后遍历链表即可,平衡树需要通过中序遍历方式来完成,实现也略复杂
- skiplist 更易实现和维护:扩展 skiplist 只需要改少量代码即可完成,平衡树维护起来较复杂
-
在使用跳表和哈希表相结合的双索引机制时,在获得高效范围查询和单点查询的同时,有哪些不足之处?
这种发挥「多个数据结构」的优势,来完成某个功能的场景,最大的特点就是「空间换时间」,所以内存占用多是它的不足。
不过也没办法,想要高效率查询,就得牺牲内存,鱼和熊掌不可兼得。
不过 skiplist 在实现时,Redis 作者应该也考虑到这个问题了,就是上面提到的这个「随机概率」,Redis 后期维护可以通过调整这个概率,进而达到「控制」查询效率和内存平衡的结果。当然,这个预设值是固定写死的,不可配置,应该是 Redis 作者经过测试和权衡后的设定,我们这里只需要知晓原理就好。
-
redis作为一款优化到极致的中间件,不会单纯使用一种数据类型去实现一个功能,而会根据当前的情况选择最合适的数据结构,比如zset就是dict + skiplist,甚至当元素较少的时候zsetAdd方法会优先选择ziplist而不直接使用skiplist,以到达节约内存的效果(当小key泛滥的时候很有效果),当一种数据结构存在不足的情况下,可以通过和其它数据结构搭配来弥补自身的不足(软件设计没有银弹,只有最合适)
-
redis仰仗c语言指针的特性,通过层高level数组实现的skiplist从内存和效率上来说都是非常优秀的,我对比了JDK的ConcurrentSkipListMap的实现(使用了大量引用和频繁的new操作),指针的优势无疑显现出来了
-
skiplist的随机率层高。既保证每层的数量相对为下一层的一半,又保证了代码执行效率
quicklist,listpack
-
ziplist 设计的初衷就是「节省内存」,在存储数据时,把内存利用率发挥到了极致:
- 数字按「整型」编码存储,比直接当字符串存内存占用少
- 数据「长度」字段,会根据内容的大小选择最小的长度编码
- 甚至对于极小的数据,干脆把内容直接放到了「长度」字段中(前几个位表示长度,后几个位存数据)
-
但 ziplist 的劣势也很明显:
- 寻找元素只能挨个遍历,存储过长数据,查询性能很低
- 每个元素中保存了「上一个」元素的长度(为了方便反向遍历),这会导致上一个元素内容发生修改,长度超过了原来的编码长度,下一个元素的内容也要跟着变,重新分配内存,进而就有可能再次引起下一级的变化,一级级更新下去,频繁申请内存
-
想要缓解 ziplist 的问题,比较简单直接的方案就是,多个数据项,不再用一个 ziplist 来存,而是分拆到多个 ziplist 中,每个 ziplist 用指针串起来,这样修改其中一个数据项,即便发生级联更新,也只会影响这一个 ziplist,其它 ziplist 不受影响,这种方案就是 quicklist
qucklist: ziplist1(也叫quicklistNode) <-> ziplist2 <-> ziplist3 <-> ...
-
List 数据类型底层实现,就是用的 quicklist,因为它是一个链表,所以 LPUSH/LPOP/RPUSH/RPOP 的复杂度是 O(1)
-
List 中每个 ziplist 节点可以存的元素个数/总大小,可以通过 list-max-ziplist-size 配置:
- 正数:ziplist 最多包含几个数据项
- 负数:取值 -1 ~ -5,表示每个 ziplist 存储最大的字节数,默认 -2,每个ziplist 8KB
ziplist 超过上述配置,添加新元素就会新建 ziplist 插入到链表中。
-
List 因为更多是两头操作,为了节省内存,还可以把中间的 ziplist「压缩」,具体可看 list-compress-depth 配置项,默认配置不压缩
-
要想彻底解决 ziplist 级联更新问题,本质上要修改 ziplist 的存储结构,也就是不要让每个元素保存「上一个」元素的长度即可,所以才有了 listpack
-
listpack 每个元素项不再保存上一个元素的长度,而是优化元素内字段的顺序,来保证既可以从前也可以向后遍历
-
listpack 是为了替代 ziplist 为设计的,但因为 List/Hash/ZSet 都严重依赖 ziplist,所以这个替换之路很漫长,目前只有 Stream 数据类型用到了 listpack。set底层是intset和dict实现的,并没有使用到ziplist。
Stream使用了Radix Tree
作为有序索引,Radix Tree 也能提供范围查询,和 B+ 树、跳表相比,你觉得 Radix Tree 有什么优势和不足么?
-
Radix Tree 优势
- 本质上是前缀树,所以存储有「公共前缀」的数据时,比 B+ 树、跳表节省内存
- 没有公共前缀的数据项,压缩存储,value 用 listpack 存储,也可以节省内存
- 查询复杂度是 O(K),只与「目标长度」有关,与总数据量无关
- 这种数据结构也经常用在搜索引擎提示、文字自动补全等场景
Stream 在存消息时,推荐使用默认自动生成的「时间戳+序号」作为消息 ID,不建议自己指定消息 ID,这样才能发挥 Radix Tree 公共前缀的优势。
-
Radix Tree 不足
- 如果数据集公共前缀较少,会导致内存占用多
- 增删节点需要处理其它节点的「分裂、合并」,跳表只需调整前后指针即可
- B+ 树、跳表范围查询友好,直接遍历链表即可,Radix Tree 需遍历树结构
- 实现难度高比 B+ 树、跳表复杂
每种数据结构都是在面对不同问题场景下,才被设计出来的,结合各自场景中的数据特点,使用优势最大的数据结构才是正解。
B+树和跳跃表有什么关联?
- B+树和跳跃表这两种数据结构在本身设计上是有亲缘关系的,其实如果把B+树拉直来看不难发现其结构和跳跃表很相似,甚至B+树的父亲结点其实类似跳跃表的level层级。
- 在当前计算机硬件存储设计上,B+树能比跳表存储更大量级的数据,因为跳表需要通过增加层高来提高索引效率,而B+树只需要增加树的深度。此外B+树同一叶子的连续性更加符合当代计算机的存储结构。然而跳表的层高具有随机性,当层高较大的时候磁盘插入会带来一定的开销,且不利于分块。
为什么Redis不使用B+树呢而选择跳表呢?
答:因为数据有序性的实现B+树不如跳表,跳表的时间性能是优于B+树的(B+树不是二叉树,二分的效率是比较高的)。此外跳表最低层就是一条链表,对于需要实现范围查询的功能是比较有利的,而且Redis是基于内存设计的,无需考虑海量数据的场景。
事件驱动框架和执行模型模块
Redis server启动后
- redis的整体启动流程是按照 【初始化默认配置】->【解析启动命令】->【初始化server】->【初始化并启动事件驱动框架】 进行
- 整个运行中的redis其实就是一个永不停歇的while循环,位于aeMain中(运行中的事件驱动框架)
- 在事件驱动框架中有两个钩子函数 beforeSleep 和 aftersleep,在每次while循环中都会触发这两个函数,后面用来实现事件触发的效果
Redis 启动流程,主要的工作有:
- 初始化前置操作(设置时区、随机种子)
- 初始化 Server 的各种默认配置(server.c 的 initServerConfig 函数),默认配置见 server.h 中的 CONFIG_DEFAULT_XXX,比较典型的配置有:
- 默认端口
- 定时任务频率
- 数据库数量
- AOF 刷盘策略
- 淘汰策略
- 数据结构转换阈值
- 主从复制参数
- 加载配置启动参数,覆盖默认配置(config.c 的 loadServerConfig 函数):
- 解析命令行参数
- 解析配置文件
- 初始化 Server(server.c 的 initServer 函数),例如会初始化:
- 处理请求
- 处理定时任务
- 启动 3 类后台线程(server.c 的 InitServerLast 函数),协助主线程工作(异步释放 fd、AOF 每秒刷盘、lazyfree)。
- 初始化并启动事件驱动框架(启动事件循环)(ae.c 的 aeMain 函数)
epoll
-
单线程服务器模型,面临的最大的问题就是,一个线程如何处理多个客户端请求?解决这种问题的办法就是「IO 多路复用」。它本质上是应用层不用维护多个客户端的连接状态,而是把它们「托管」给了操作系统,操作系统维护这些连接的状态变化,之后应用层只管问操作系统,哪些 socket 有数据可读/可写就好了,大大简化了应用层的复杂度
-
IO 多路复用机制要想高效使用,一般还需要把 socket 设置成「非阻塞」模式,即 socket 没有数据可读/可写时,应用层去 read/write socket 也不会阻塞住(内核会返回指定错误,应用层可继续重试),这样应用层就可以去处理其它业务逻辑,不会阻塞影响性能
-
为什么 Redis 要使用「单线程」处理客户端请求?本质上是因为,Redis 操作的是内存,操作内存数据是极快的,所以 Redis 的瓶颈不在 CPU,优化的重点就在网络 IO 上,高效的 IO 多路复用机制,正好可以满足这种需求,模型简单,性能也极高
-
但成也萧何败也萧何,因为 Redis 处理请求是「单线程」,所以如果有任意请求在 Server 端发生耗时(例如操作 bigkey,或一次请求数据过多),就会导致后面的请求发生「排队」,业务端就会感知到延迟增大,性能下降
-
基于此,Redis 又做了很多优化:一些耗时的操作,不再放在主线程处理,而是丢到后台线程慢慢执行。例如,异步关闭 fd,异步释放内存、后台 AOF 刷盘这些操作。所以 Redis Server 其实是「多线程」的,只不过最核心的处理请求逻辑是单线程的,这点一定要区分开
-
redis为了满足各种系统实现了多套IO多路复用,分别有:epoll,select,evport,kqueue
-
redis在IO多路复用的代码实现进行了抽象,通过同一实现了aeApiState,aeApiCreate,aeApiResize,aeApiFree等等方法(类比接口)实现了多套IO复用,方便在编译期间切换(文件:ae_epoll.c,ae_evport.c,ae_kqueue.c,ae_select.c)
-
在 Redis 事件驱动框架代码中,分别使用了 Linux 系统上的 select 和 epoll 两种机制,为什么 Redis 没有使用 poll 这一机制?
首先要明确一点,select 并不是只有 Linux 才支持的,Windows 平台也支持。
而 Redis 针对不同操作系统,会选择不同的 IO 多路复用机制来封装事件驱动框架,具体代码见 ae.c。
// ae.c #ifdef HAVE_EVPORT #include "ae_evport.c" // Solaris #else #ifdef HAVE_EPOLL #include "ae_epoll.c" // Linux #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" // MacOS #else #include "ae_select.c" // Windows #endif #endif #endif
仔细看上面的代码逻辑,先判断了 Solaris/Linux/MacOS 系统,选择对应的多路复用模型,最后剩下的系统都用 select 模型。
所以我理解,select 并不是为 Linux 服务的,而是在 Windows 下使用的。 因为 epoll 性能优于 select 和 poll,所以 Linux 平台下,Redis 直接会选择 epoll。而 Windows 不支持 epoll 和 poll,所以会用 select 模型。
Reactor模型
-
为了高效处理网络 IO 的「连接事件」、「读事件」、「写事件」,演化出了 Reactor 模型
-
Reactor 模型主要有 reactor、acceptor、handler 三类角色:
- reactor:分配事件
- acceptor:接收连接请求
- handler:处理业务逻辑
-
Reactor 模型又分为 3 类:
- 单 Reactor 单线程:accept -> read -> 处理业务逻辑 -> write 都在一个线程
- 单 Reactor 多线程:accept/read/write 在一个线程,处理业务逻辑在另一个线程
- 多 Reactor 多线程 / 进程:accept 在一个线程/进程,read/处理业务逻辑/write 在另一个线程/进程
-
Redis 6.0 以下版本,属于单 Reactor 单线程模型,监听请求、读取数据、处理请求、写回数据都在一个线程中执行,这样会有 3 个问题:
- 单线程无法利用多核
- 处理请求发生耗时,会阻塞整个线程,影响整体性能
- 并发请求过高,读取/写回数据存在瓶颈
-
针对问题 3,Redis 6.0 进行了优化,引入了 IO 多线程,把读写请求数据的逻辑,用多线程处理,提升并发性能,但处理请求的逻辑依旧是单线程处理
-
除了 Redis,你还了解什么软件系统使用了 Reactor 模型吗?
Netty、Memcached 采用多 Reactor 多线程模型。
Nginx 采用多 Reactor 多进程模型,不过与标准的多 Reactor 多进程模型有些许差异。Nginx 的主进程只用来初始化 socket,不会 accept 连接,而是由子进程 accept 连接,之后这个连接的所有处理都在子进程中完成。
Redis事件
- Redis 事件循环主要处理两类事件:文件事件、时间事件
- 文件事件包括:client 发起新连接、client 向 server 写数据、server 向 client 响应数据
- 时间事件:Redis 的各种定时任务(主线程中执行)
- Redis 在启动时,会创建 aeEventLoop,初始化 epoll 对象,监听端口,之后会注册文件事件、时间事件:
- 文件事件:把 listen socket fd 注册到 epoll 中,回调函数是 acceptTcpHandler(新连接事件)
- 时间事件:把 serverCron 函数注册到 aeEventLoop 中,并指定执行频率
- Redis Server 启动后,会启动一个死循环,持续处理事件(ae.c 的 aeProcessEvents 函数)
- 有文件事件(网络 IO),则优先处理。例如,client 到 server 的新连接,会调用 acceptTcpHandler 函数,之后会注册读事件 readQueryFromClient 函数,client 发给 server 的数据,都会在这个函数处理,这个函数会解析 client 的数据,找到对应的 cmd 函数执行
- cmd 逻辑执行完成后,server 需要写回数据给 client,会先把响应数据写到对应 client 的 内存 buffer 中,在下一次处理 IO 事件之前,Redis 会把每个 client 的 buffer 数据写到 client 的 socket 中,给 client 响应
- 如果响应给 client 的数据过多,则会分多次发送,待发送的数据会暂存到 buffer,然后会向 epoll 注册回调函数 sendReplyToClient,待 socket 可写时,继续调用回调函数向 client 写回剩余数据
- 在这个死循环中处理每次事件时,都会先检查一下,时间事件是否需要执行,因为之前已经注册好了时间事件的回调函数 + 执行频率,所以在执行 aeApiPoll 时,timeout 就是定时任务的周期,这样即使没有 IO 事件,epoll_wait 也可以正常返回,此时就可以执行一次定时任务 serverCron 函数,这样就可以在一个线程中就完成 IO 事件 + 定时任务的处理
单线程
-
很多人认为 Redis 是单线程,这个描述是不准确的。准确来说 Redis 只有在处理「客户端请求」比如
接收客户端请求
、解析请求
和进行数据读写
等操作时,是单线程的。但整个 Redis Server 并不是单线程的,还有后台线程,比如文件关闭
、AOF 同步写
和惰性删除
在辅助处理一些工作。 -
Redis 选择单线程处理请求,是因为 Redis 操作的是「内存」,加上设计了「高效」的数据结构,所以操作速度极快,利用 「IO 多路复用」机制,单线程依旧可以有非常高的性能。
-
但如果一个请求发生耗时,单线程的缺点就暴露出来了,后面的请求都要「排队」等待,所以 Redis 在启动时会启动一些「后台线程」来辅助工作,目的是把耗时的操作,放到后台处理,避免主线程操作耗时影响整体性能
-
关闭 fd、AOF 刷盘、释放 key 的内存,这些耗时操作,都可以放到后台线程中处理,对主逻辑没有任何影响
-
后台线程处理这些任务,就相当于一个消费者,生产者(主线程)把耗时任务丢到队列中(链表),消费者不停轮询这个队列,拿出任务就去执行对应的方法即可:
- BIO_CLOSE_FILE:close(fd) 文件关闭后台任务。
- BIO_AOF_FSYNC:fsync(fd) AOF 日志同步写回后台任务
- BIO_LAZY_FREE:free(obj) / free(dict) / free(skiplist) 惰性删除后台任务
-
后台线程有3个,后台进程只有RDB和AOF rewrite时才会fork子进程。
-
Redis 后台任务使用 bio_job 结构体来描述,该结构体用了三个指针变量来表示任务参数
struct bio_job { time_t time; void *arg1, *arg2, *arg3; //传递给任务的参数 };
如果我们创建的任务,所需要的参数大于 3 个,最直接的方法就是,使用指针数组,因为指针数组本身就是一个个指针,可以通过index的顺序标记参数的含义类型,通过index就能快速获取不同的参数对应的指针这样就可以传递任意数量参数了。因为这里 Redis 的后台任务都比较简单,最多 3 个参数就足够满足需求,所以 job 直接写死了 3 个参数变量,这样做的好处是维护起来简单直接
-
Redis是一个多进程多线程的程序: 通过这篇文章也能很清晰的认识到,在Redis中不但有fork的方式创建进程,也有通过pthread_create的方式创建线程,二者都能起到异步执行任务的效果
-
fork是一个沉重的方案:除了以守护进程的方式启动时候会进行fork,bgsave也会进行fork。但是fork比thread的代价大的多,fork出来的子进程会复制一份父进程的虚拟地址表(虚拟内存技术,子进程复制父进程的地址表,复用原有的地址空间,当某个地址上的数据涉及修改的时候才会把数据复制一份到自己的地址空间)从而也可能会导致出现写时复制等内存高损耗的开销。
-
Thread需要解决并发问题:多线程虽然资源开销没有fork那么沉重,但是由于多线程的地址空间都属于同一个进程(线程属于进程),那么必然要解决并发问题。然而Redis的设计很巧妙,无论是bioInit的bioProcessBackgroundJobs使用分type的方式让每给线程依次执行列表上的任务,还是initThreadedIO使用信号量的方式控制线程的协调,都能避开内存共享带来的并发问题,从而即享受了多线程的优势,又避免了多线程的劣势。
Redis 6.0多IO线程
- Redis 6.0 之前,处理客户端请求是单线程,这种模型的缺点是,只能用到「单核」CPU。如果并发量很高,那么在读写客户端数据时,容易引发性能瓶颈,所以 Redis 6.0 引入了多 IO 线程解决这个问题
- 配置文件开启 io-threads N 后,Redis Server 启动时,会启动 N - 1 个 IO 线程(主线程也算一个 IO 线程),这些 IO 线程执行的逻辑是 networking.c 的 IOThreadMain 函数。但默认只开启多线程「写」client socket,如果要开启多线程「读」,还需配置 io-threads-do-reads = yes
- Redis 在读取客户端请求时,判断如果开启了 IO 多线程,则把这个 client 放到 clients_pending_read 链表中(postponeClientRead 函数),之后主线程在处理每次事件循环之前,把链表数据轮询放到 IO 线程的链表(io_threads_list)中
- 同样地,在写回响应时,是把 client 放到 clients_pending_write 中(prepareClientToWrite 函数),执行事件循环之前把数据轮询放到 IO 线程的链表(io_threads_list)中
- 主线程把 client 分发到 IO 线程时,自己也会读写客户端 socket(主线程也要分担一部分读写操作),之后「等待」所有 IO 线程完成读写,再由主线程「串行」执行后续逻辑
- 每个 IO 线程,不停地从 io_threads_list 链表中取出 client,并根据指定类型读、写 client socket
- IO 线程在处理读、写 client 时有些许差异,如果 write_client_pedding < io_threads * 2,则直接由「主线程」负责写,不再交给 IO 线程处理,从而节省 CPU 消耗
- Redis 官方建议,服务器最少 4 核 CPU 才建议开启 IO 多线程,4 核 CPU 建议开 2-3 个 IO 线程,8 核 CPU 开 6 个 IO 线程,超过 8 个线程性能提升不大
- Redis 官方表示,开启多 IO 线程后,性能可提升 1 倍。当然,如果 Redis 性能足够用,没必要开 IO 线程
分布式锁的原子性保证
-
无论是 IO 多路复用,还是 Redis 6.0 的多 IO 线程,Redis 执行具体命令的主逻辑依旧是「单线程」的
-
执行命令是单线程,本质上就保证了每个命令必定是「串行」执行的,前面请求处理完成,后面请求才能开始处理
-
所以 Redis 在实现分布式锁时,内部不需要考虑加锁问题,直接在主线程中判断 key 是否存在即可,实现起来非常简单
-
如果将命令处理过程中的命令执行也交给多 IO 线程执行,除了对原子性会有影响,还会有什么好处和坏处?
好处:
- 每个请求分配给不同的线程处理,一个请求处理慢,并不影响其它请求
- 请求操作的 key 越分散,性能会变高(并行处理比串行处理性能高)
- 可充分利用多核 CPU 资源
坏处:
- 操作同一个 key 需加锁,加锁会影响性能,如果是热点 key,性能下降明显
- 多线程上下文切换存在性能损耗
- 多线程开发和调试不友好
缓存模块
LRU
- 实现一个严格的 LRU 算法,需要额外的内存构建 LRU 链表,同时维护链表也存在性能开销,Redis 对于内存资源和性能要求极高,所以没有采用严格 LRU 算法,而是采用「近似」LRU 算法实现数据淘汰策略
- 触发数据淘汰的时机,是每次处理「请求」时判断的。也就是说,执行一个命令之前,首先要判断实例内存是否达到 maxmemory,是的话则先执行数据淘汰,再执行具体的命令
- 淘汰数据时,会「持续」判断 Redis 内存是否下降到了 maxmemory 以下,不满足的话会继续淘汰数据,直到内存下降到 maxmemory 之下才会停止
- 可见,如果发生大量淘汰的情况,那么处理客户端请求就会发生「延迟」,影响性能
- Redis 计算实例内存时,不会把「主从复制」的缓冲区计算在内,也就是说不管一个实例后面挂了多少个从库,主库不会把主从复制所需的「缓冲区」内存,计算到实例内存中,即这部分内存增加,不会对数据淘汰产生影响
- 但如果 Redis 内存已达到 maxmemory,要谨慎执行 MONITOR 命令,因为 Redis Server 会向执行 MONITOR 的 client 缓冲区填充数据,这会导致缓冲区内存增长,进而引发数据淘汰
- 键值对的 LRU 时钟值,不是直接通过调用 getLRUClock 函数来获取,本质上是为了「性能」。 Redis 这种对性能要求极高的数据库,在系统调用上的优化也做到了极致。 获取机器时钟本质上也是一个「系统调用」,对于 Redis 这种动不动每秒上万的 QPS,如果每次都触发一次系统调用,这么频繁的操作也是一笔不小的开销。 所以,Redis 用一个定时任务(serverCron 函数),以固定频率触发系统调用获取机器时钟,然后把机器时钟挂到 server 的全局变量下,这相当于维护了一个「本地缓存」,当需要获取时钟时,直接从全局变量获取即可,节省了大量的系统调用开销。
LFU
-
LFU 是在 Redis 4.0 新增的淘汰策略,它涉及的巧妙之处在于,其复用了 redisObject 结构的 lru 字段,把这个字段「一分为二」,保存最后访问时间和访问次数
-
key 的访问次数不能只增不减,它需要根据时间间隔来做衰减,才能达到 LFU 的目的
-
每次在访问一个 key 时,会「懒惰」更新这个 key 的访问次数:先衰减访问次数,再更新访问次数
-
衰减访问次数,会根据时间间隔计算,间隔时间越久,衰减越厉害
-
因为 redisObject lru 字段宽度限制,这个访问次数是有上限的(8 bit 最大值 255),所以递增访问次数时,会根据「当前」访问次数和「概率」的方式做递增,访问次数越大,递增因子越大,递增概率越低
-
Redis 实现的 LFU 算法也是「近似」LFU,是在性能和内存方面平衡的结果
-
LFU 算法在初始化键值对的访问次数时,会将访问次数设置为 LFU_INIT_VAL,默认值是 5 次。如果 LFU_INIT_VAL 设置为 1,会发生什么情况?
LFU_INIT_VAL的初始值为5主要是避免,刚刚创建的对象被立马淘汰,而需要经历一个衰减的过程后才会被淘汰。
如果开启了 LFU,那在写入一个新 key 时,需要初始化访问时间、访问次数(createObject 函数),如果访问次数初始值太小,那这些新 key 的访问次数,很有可能在短时间内就被「衰减」为 0,那就会面临马上被淘汰的风险。新 key 初始访问次数 LFU_INIT_VAL = 5,就是为了避免一个 key 在创建后,不会面临被立即淘汰的情况发生。
-
纯粹的LFU算法会累计历史的访问次数,然而在高QPS的情况下可能会出现以下几个问题:
- 运行横跨高峰期和低峰期,不同时期存储的数据不一致,可能会导致部分高峰期产生的数据不容易被淘汰,甚至可能永远淘汰不掉(因为在高峰获得一个较高的count值,在计算淘汰的时候仍然存在)
- 需要long乃至更大的值去存储count。对于高频访问的数据如果需要统计每一次的调用,可能需要使用更大的空间去存储,还需要考虑溢出的问题。
- 可能存在,每次淘汰掉的几乎是刚刚创建的新数据。
为了解决这些问题,Redis实现了一个近似LFU算法,并做出了以下改进:
- count有上限值255。(避免高频数据获得一个较大的count值,还能节省空间)
- count值是会随着时间衰减。(不再访问的数据更加容易被淘汰,高16位记录上一次访问时间戳-分钟,低8位记录count)
- 刚刚创建的数据count值不为0。(避免刚刚创建的数据被淘汰)
- count值累加是概率随机的。(避免高峰期数据都能一下就能累加到255,其中概率能人为调整)
Lazy Free
-
lazy-free 是 4.0 新增的功能,默认是关闭的,需要手动开启
-
开启 lazy-free 时,有多个「子选项」可以控制,分别对应不同场景下,是否开启异步释放内存:
- lazyfree-lazy-expire:key 在过期删除时尝试异步释放内存
- lazyfree-lazy-eviction:内存达到 maxmemory 并设置了淘汰策略时尝试异步释放内存
- lazyfree-lazy-server-del:执行 RENAME/MOVE 等命令或需要覆盖一个 key 时,Redis 内部删除旧 key 尝试异步释放内存
- replica-lazy-flush:主从全量同步,从库清空数据库时异步释放内存
-
即使开启了 lazy-free,但如果执行的是 DEL 命令,则还是会同步释放 key 内存,只有使用 UNLINK 命令才「可能」异步释放内存
-
Redis 6.0 版本新增了一个新的选项 lazyfree-lazy-user-del,打开后执行 DEL 就与 UNLINK 效果一样了
-
最关键的一点,开启 lazy-free 后,除 replica-lazy-flush 之外,其它选项都只是「可能」异步释放 key 的内存,并不是说每次释放 key 内存都是丢到后台线程的
-
开启 lazy-free 后,Redis 在释放一个 key 内存时,首先会评估「代价」,如果代价很小,那么就直接在「主线程」操作了,「没必要」放到后台线程中执行(不同线程传递数据也会有性能消耗)
-
什么情况才会真正异步释放内存?这和 key 的类型、编码方式、元素数量都有关系(详见 lazyfreeGetFreeEffort 函数)
- 当 Hash/Set 底层采用哈希表存储(非 ziplist/int 编码存储)时,并且元素数量超过 64 个
- 当 ZSet 底层采用跳表存储(非 ziplist 编码存储)时,并且元素数量超过 64 个
- 当 List 链表节点数量超过 64 个(注意,不是元素数量,而是链表节点的数量,List 底层实现是一个链表,链表每个节点是一个 ziplist,一个 ziplist 可能有多个元素数据)
只有满足以上条件,在释放 key 内存时,才会真正放到「后台线程」中执行,其它情况一律还是在主线程操作。
也就是说 String(不管内存占用多大)、List(少量元素)、Set(int 编码存储)、Hash/ZSet(ziplist 编码存储)这些情况下的 key,在释放内存时,依旧在「主线程」中操作。
-
可见,即使打开了 lazy-free,String 类型的 bigkey,在删除时依旧有「阻塞」主线程的风险。所以,即便 Redis 提供了 lazy-free,还是不建议在 Redis 存储 bigkey
-
Redis 在释放内存「评估」代价时,不是看 key 的内存大小,而是关注释放内存时的「工作量」有多大。从上面分析可以看出,如果 key 内存是连续的,释放内存的代价就比较低,则依旧放在「主线程」处理。如果 key 内存不连续(包含大量指针),这个代价就比较高,这才会放在「后台线程」中执行
-
freeMemoryIfNeeded 函数在使用后台线程,删除被淘汰数据的过程中,主线程是否仍然可以处理外部请求?
肯定是可以继续处理请求的。 主线程决定淘汰这个 key 之后,会先把这个 key 从「全局哈希表」中剔除,然后评估释放内存的代价,如果符合条件,则丢到「后台线程」中执行「释放内存」操作。
之后就可以继续处理客户端请求,尽管后台线程还未完成释放内存,但因为 key 已被全局哈希表剔除,所以主线程已查询不到这个 key 了,对客户端来说无影响。
可靠性保证模块
生成和解读RDB文件
- RDB 文件是 Redis 的数据快照,以「二进制」格式存储,相比 AOF 文件更小,写盘和加载时间更短
- RDB 在执行 SAVE / BGSAVE 命令、定时 BGSAVE、主从复制时产生
- RDB 文件包含文件头、数据部分、文件尾
- 文件头主要包括 Redis 的魔数、RDB 版本、Redis 版本、RDB 创建时间、键值对占用的内存大小等信息
- 文件数据部分包括整个 Redis 数据库中存储的所有键值对信息
- 数据库信息:db 编号、db 中 key 的数量、过期 key 的数量、键值数据
- 键值数据:过期标识、时间戳(绝对时间)、键值对类型、key 长度、key、value 长度、value
- 文件尾保存了 RDB 的结束标记、文件校验值
- RDB 存储的数据,为了压缩体积,还做了很多优化:
- 变长编码存储键值对数据
- 用操作码标识不同的内容
- 可整数编码的内容使用整数类型紧凑编码
AOF重写
-
AOF 记录的是每个命令的「操作历史」,随着时间增长,AOF 文件会越来越大,所以需要 AOF 重写来「瘦身」,减小文件体积
-
AOF 重写时,会扫描整个实例中的数据,把数据以「命令 + 键值对」的格式,写到 AOF 文件中
-
触发 AOF 重写的时机有 4 个:
- 执行 bgrewriteaof 命令
- 手动打开 AOF 开关(config set appendonly yes)
- 从库加载完主库 RDB 后(AOF 被启动的前提下)
- 定时触发:AOF 文件大小比例超出阈值、AOF 文件大小绝对值超出阈值(AOF 被启动的前提下)
这 4 个时机,都不能有 RDB 子进程,否则 AOF 重写会延迟执行。
-
AOF 重写期间会禁用 rehash,不让父进程调整哈希表大小,目的是父进程「写时复制」拷贝大量内存页面
-
为什么 Redis 源码中在有 RDB 子进程运行时,不会启动 AOF 重写子进程?
无论是生成 RDB 还是 AOF 重写,都需要创建子进程,然后把实例中的所有数据写到磁盘上,这个过程中涉及到两块:
- CPU:写盘之前需要先迭代实例中的所有数据,在这期间会耗费比较多的 CPU 资源,两者同时进行,CPU 资源消耗大
- 磁盘:同样地,RDB 和 AOF 重写,都是把内存数据落盘,在这期间 Redis 会持续写磁盘,如果同时进行,磁盘 IO 压力也会较大
整体来说都是为了资源考虑,所以不会让它们同时进行。
-
AOF 重写是在子进程中执行,但在此期间父进程还会接收写操作,为了保证新的 AOF 文件数据更完整,所以父进程需要把在这期间的写操作缓存下来,然后发给子进程,让子进程追加到 AOF 文件中
-
因为需要父子进程传输数据,所以需要用到操作系统提供的进程间通信机制,这里 Redis 用的是「管道」,管道只能是一个进程写,另一个进程读,特点是单向传输
-
AOF 重写时,父子进程用了 3 个管道,分别传输不同类别的数据:
- 父进程传输数据给子进程的管道:发送 AOF 重写期间新的写操作
- 子进程完成重写后通知父进程的管道:让父进程停止发送新的写操作
- 父进程确认收到子进程通知的管道:父进程通知子进程已收到通知
-
AOF 重写的完整流程是:父进程 fork 出子进程,子进程迭代实例所有数据,写到一个临时 AOF 文件,在写文件期间,父进程收到新的写操作,会先缓存到 buf 中,之后父进程把 buf 中的数据,通过管道发给子进程,子进程写完 AOF 文件后,会从管道中读取这些命令,再追加到 AOF 文件中,最后 rename 这个临时 AOF 文件为新文件,替换旧的 AOF 文件,重写结束
主从复制
-
Redis 主从复制分为 4 个阶段:
- 初始化
- 建立连接
- 主从握手
- 数据传输(全量/增量复制)
-
主从复制流程由于是是「从库」发起的,所以重点要看从库的执行流程
-
从库发起复制的方式有 3 个:
- 执行 slaveof / replicaof 命令
- 配置文件配置了主库的 ip port
- 启动实例时指定了主库的 ip port
-
建议从 slaveof / replicaof 命令跟源码进去,来看整个主从复制的流程(入口在 replication.c 的 replicaofCommand 函数)
-
从库执行这个命令后,会先在 server 结构体上,记录主库的 ip port,然后把 server.repl_state 从 REPL_STATE_NONE 改为 REPL_STATE_CONNECT,「复制状态机」启动
-
随后从库会在定时任务(server.c 的 serverCron 函数)中会检测 server.repl_state 的状态,然后向主库发起复制请求(replication.c 的 replicationCron 函数),进入复制流程(replication.c 的 connectWithMaster 函数)
-
从库会与主库建立连接(REPL_STATE_CONNECTING),注册读事件(syncWithMaster 函数),之后主从进入握手认证阶段,从库会告知主库自己的 ip port 等信息,在这期间会流转多个状态(server.h 中定义的复制状态):
#define REPL_STATE_RECEIVE_PONG 3 /* Wait for PING reply */ #define REPL_STATE_SEND_AUTH 4 /* Send AUTH to master */ #define REPL_STATE_RECEIVE_AUTH 5 /* Wait for AUTH reply */ #define REPL_STATE_SEND_PORT 6 /* Send REPLCONF listening-port */ #define REPL_STATE_RECEIVE_PORT 7 /* Wait for REPLCONF reply */ #define REPL_STATE_SEND_IP 8 /* Send REPLCONF ip-address */ #define REPL_STATE_RECEIVE_IP 9 /* Wait for REPLCONF reply */ #define REPL_STATE_SEND_CAPA 10 /* Send REPLCONF capa */ #define REPL_STATE_RECEIVE_CAPA 11 /* Wait for REPLCONF reply */
-
完成握手后,从库向主库发送 PSYNC 命令和自己的 offset,首先尝试「增量同步」,如果 offset = -1,主库返回 FULLRESYNC 表示「全量同步」数据,否则返回 CONTINUE 增量同步
-
如果是全量同步,主库会先生成 RDB,从库等待,主库完成 RDB 后发给从库,从库接收 RDB,然后清空实例数据,加载 RDB,之后读取主库发来的「增量」数据
-
如果是增量同步,从库只需接收主库传来的增量数据即可
-
当一个实例是主库时,为什么不需要使用状态机来实现主库在主从复制时的流程流转?
因为复制数据的发起方是从库,从库要求复制数据会经历多个阶段(发起连接、握手认证、请求数据),而主库只需要「被动」接收从库的请求,根据需要「响应数据」即可完成整个流程,所以主库不需要状态机流转。
哨兵
- 哨兵和 Redis 实例是一套代码,只不过哨兵会根据启动参数(redis-sentinel 或 redis-server --sentinel),设置当前实例为哨兵模式(server.sentinel_mode = 1),然后初始化哨兵相关数据
- 哨兵模式的实例,只能执行一部分命令(ping、sentinel、subscribe、unsubscribe、psubscribe、punsubscribe、publish、info、role、client、shutdown、auth),其中 sentinel、publish、info、role 都是针对哨兵专门实现的
- 之后哨兵会初始化各种属性,例如哨兵实例 ID、用于故障切换的当前纪元、监听的主节点、正在执行的脚本数量、与其他哨兵实例发送的 IP 和端口号等信息
- 启动哨兵后,会检查配置文件是否可写(不可写直接退出,哨兵需把监控的实例信息写入配置文件)、是否配置了哨兵 ID(没配置随机生成一个)
- 最后哨兵会在监控的 master 实例的 PubSub(+monitor 频道)发布一条消息,表示哨兵开始监控 Redis 实例
- 哨兵后续会通过 PubSub 的方式,与主从库、其它哨兵实例进行通信
哨兵选举-Raft
-
Redis 为了实现故障自动切换,引入了一个外部「观察者」检测实例的状态,这个观察者就是「哨兵」
-
但一个哨兵检测实例,有可能因为网络原因导致「误判」,所以需要「多个」哨兵共同判定
-
多个哨兵共同判定出实例故障后(主观下线、客观下线),会进入故障切换流程,切换时需要「选举」出一个哨兵「领导者」进行操作
-
这个选举的过程,就是「分布式共识」,即多个哨兵通过「投票」选举出一个都认可的实例当领导者,由这个领导者发起切换,这个选举使用的算法是 Raft 算法
-
严格来说,Raft 算法的核心流程是这样的:
- 集群正常情况下,Leader 会持续给 Follower 发心跳消息,维护 Leader 地位
- 如果 Follower 一段时间内收不到 Leader 心跳消息,则变为 Candidate 发起选举
- Candidate 先给自己投一票,然后向其它节点发送投票请求
- Candidate 收到超过半数确认票,则提升为新的 Leader,新 Leader 给其它 Follower 发心跳消息,维护新的 Leader 地位
- Candidate 投票期间,收到了 Leader 心跳消息,则自动变为 Follower
- 投票结束后,没有超过半数确认票的实例,选举失败,会再次发起选举
-
哨兵实例执行的周期性函数 sentinelTimer 的最后,修改 server.hz 的目的是什么?
server.hz 表示执行定时任务函数 serverCron 的频率,哨兵在最后修改 server.hz 增加一个随机值,是为了避免多个哨兵以「相同频率」执行,引发每个哨兵同时发起选举,进而导致没有一个哨兵能拿到多数投票,领导者选举失败的问题。适当打散执行频率,可以有效降低选举失败的概率
-
一个哨兵检测判定主库故障,这个过程是「主观下线」,另外这个哨兵还会向其它哨兵询问(发送 sentinel is-master-down-by-addr 命令),多个哨兵都检测主库故障,数量达到配置的 quorum 值,则判定为「客观下线」
-
首先判定为客观下线的哨兵,会发起选举,让其它哨兵给自己投票成为「领导者」,成为领导者的条件是,拿到超过「半数」的确认票 + 超过预设的 quorum 阈值的赞成票
-
投票过程中会比较哨兵和主库的「纪元」(主库纪元 < 发起投票哨兵的纪元 + 发起投票哨兵的纪元 > 其它哨兵的纪元),保证一轮投票中一个哨兵只能投一次票
Pub/Sub
- 哨兵是通过 master 的 PubSub 发现其它哨兵的:每个哨兵向 master 的 PubSub(sentinel:hello 频道)发布消息,同时也会订阅这个频道,这样每个哨兵就能拿到其它哨兵的 IP、端口等信息
- 每个哨兵有了其它哨兵的信息后,在判定 Redis 实例状态时,就可以互相通信、交换信息,共同判定实例是否真的故障
- 哨兵判定 Redis 实例故障、发起切换时,都会向 master 的 PubSub 的频道发布消息
- 客户端可以订阅 master 的 PubSub,感知到哨兵工作到了哪个状态节点,从而作出自己的反应
- PubSub 的实现,其实就是 Redis 在内存中维护了一个「发布-订阅」映射表,订阅者执行 SUBSCRIBE 命令,Redis 会把订阅者加入到指定频道的「链表」下。发布者执行 PUBLISH,Redis 就找到这个映射表中这个频道的所有「订阅者」,把消息「实时转发」给这些订阅者
Redis Cluster模块
Gossip协议的实现
- 多个节点组成一个分布式系统,它们之间需要交换数据,可以采用中心化的方式(依赖第三方系统,例如ZK),也可以采用非中心化(分布式协议,例如 Gossip)的方式
- Redis Cluster 采用非中心化的方式 Gossip 协议,实现多个节点之间信息交换
- 集群中的每个实例,会按照固定频率,从集群中「随机」挑选部分实例,发送 PING 消息(自身实例状态、已知部分实例信息、slots 分布),用来交换彼此状态信息
- 收到 PING 的实例,会响应 PONG 消息,PONG 消息和 PING 消息格式一样,包含了自身实例状态、已知部分实例信息、slots 分布
- 这样经过几次交换后,集群中每个实例都能拿到其它实例的状态信息
- 即使有节点状态发生变化(新实例加入、节点故障、数据迁移),也可以通过 Gossip 协议的 PING-PONG 消息完成整个集群状态在每个实例上的同步
MOVED、ASK
- cluster 模式的 Redis,在执行命令阶段,需要判断 key 是否属于本实例,不属于会给客户端返回请求重定向的信息
- 判断 key 是否属于本实例,会先计算 key 所属的 slot,再根据 slot 定位属于哪个实例
- 找不到 key 所属的实例,或者操作的多个 key 不在同一个 slot,则会给客户端返回错误;ke y 正在做数据迁出,并且访问的这个 key 不在本实例中,会给客户端返回 ASK,让客户端去目标节点再次查询一次(临时重定向);key 所属的 slot 不是本实例,而是其它节点,会给客户端返回 MOVED,告知客户端 key 不在本实例,以后都去目标节点查询(永久重定向)
Redis Cluster数据迁移
- Redis Cluster 因为是多个实例共同组成的集群,所以当集群中有节点下线、新节点加入、数据不均衡时,需要做数据迁移,把某些实例中的数据,迁移到其它实例上
- 数据迁移分为 5 个阶段
- 标记迁入、迁出节点
- 获取待迁出的 keys
- 源节点实际迁移数据
- 目的节点处理迁移数据
- 标记迁移结果
- 获取待迁出的 keys 会用 CLUSTER GETKEYSINSLOT 命令,可返回指定 slot 下的 keys
- 从源节点迁出数据,会调用 MIGRATE 命令,该命令可指定一批 key,迁移到目标 Redis 实例。迁移时,源节点会把 key-value 序列化,然后传输给目标节点
- 目标节点收到源节点发来的数据后,会执行 RESTORE 命令逻辑,校验序列化的数据格式是否正确,正确则解析数据,把数据添加到实例中