首页 redis 杂谈
文章
X

redis 杂谈

本文主要介绍 Redis 相关的一些理论知识、实践经验和验证实验。 它的全称是 REmote Dictionary Service, 直接翻译过来是远程字典服务。

redis 架构

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构, 如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。 Redis 内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。

Redis 是一种KV存储的非关系数据库,类似的数据库组件可参考此网站nosql-database。 能不能把SQL和NoSQL的特性结合在一起呢?当然可以。所以现在又有了所谓的NewSQL数据库。 NewSQL是指这样一类新式的关系型数据库管理系统,针对OLTP(读-写)工作负载,追求提供和NoSQL系统相同的扩展性能,且仍然保持ACID和SQL等特性(scalable and ACID and (relational and/or sql -access))。 NewSQL 结合了 SQL和 NoSQL 的特性。例如 TiDB (PingCAP)、VoltDB、ScaleDB等。

redis 架构从单机版不断演化成如今的分片集群版。

应用架构演化

  • 单机版:作为热数据的内存KV数据库,分担Mysql非内存式的持久化数据库的压力
    单机版
  • Redis 持久化($DB/AOF):减少数据丢失
    数据持久化
  • 主从复制:多副本
    • 缩短不可用时间:master 宕机,手动把slave提升为master
    • 分摊读压力,提升读性能:让 slave 分担一部分读请求,提升应用整体性能
      主从复制
  • 单哨兵:单哨兵可能因网络问题,对master存活造成误判
    单哨兵
  • 哨兵集群:故障节点自动切换,加快切换时间(相对于故障手动切换而言)
    哨兵集群
  • 分片集群:分摊写压力,提升集群整体性能
    • proxy方案(非官方):多个主从集群分担根据路由规则写压力,一个哨兵集群监测状态。其中路由规则可以在客户端实现(客户端分片), 也可以在服务端实现。对于在服务端实现路由的方案,即为这里所说的proxy方案
    • gossip协议方案(官方):Redis 节点之间通过 Gossip 协议通信,无需部署哨兵集群,可横向扩容
    • proxy加官方集群方案:由各公司自研的proxy,来屏蔽业务迁移至官方集群的复杂性,降低升级成本
      proxy 官方集群 综合方案

更复杂和详细的应用演化之旅可以参考以下文章:

Redis 自身架构演化

Redis 自身架构演化大体和其应用架构演化类似。

  • 单机版:Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。
    单机版 单机版组件图 单机版组件图
  • 集群架构
    集群架构 集群架构 集群架构 集群架构 集群架构 集群架构

redis 模块

模块

线程模型

Redis 6.0 之前为什么使用单线程。 虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型, 但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求, 这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。 Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。

Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据,并不会以多线程的方式处理读请求。 要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。

1
2
3
4
//读请求也使用io多线程
io-threads-do-reads yes 
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。 因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会创建 6 个线程:

  • Redis-server : Redis的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

单线程模型

Redis 6.0 版本之前的单线模式如下图:
单线程模型

Redis 初始化的时候,会做下面这几件事情:

  • 调用 epoll_create() 创建一个 epoll 对象和调用 socket() 创建一个服务器套接字 socket;
  • 调用 bind() 绑定端口和调用 listen() 设置监听该套接字;
  • 将调用 epoll_ctl() 将该监听套接字listen socket 加入到 epoll红黑树柄,同时注册「连接请求」处理函数。

初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
单线程模型

  • 先调用处理发送队列函数,每一次进入 epoll_wait 前都调用 beforesleep看是发送队列里是否有任务, 如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写事件触发后再处理 。
  • 接着,调用 epoll_wait 函数等待事件的到来:
    • 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
      单线程模型
    • 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获的取客户端发送数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存送区等待发;
      单线程模型
    • 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
      单线程模型

在 aeMain 函数中,每次在进入 aeProcessEvents 前都需要先进行 beforesleep 处理。该函数处理了许多工作,其中一项便是:

  • 遍历发送任务队列,并将 client 发送缓存区中的处理结果通过 write 发送到客户端手中;
  • 值得注意的是,发送 write 并不总是能一次性发送完的。假如要发送的结果太大,而系统为每个 socket 设置的发送缓存区又是有限的;
  • 在这种情况下, 判断仍然有未发送完的数据的话,就需要注册一个写事件处理函数到 epoll 上。等待 epoll 发现该 socket 可写的时候再次调用 sendReplyToClient进行发送。

其他详细解读请参考以下文章:

之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

  • 内存操作
  • 高效数据结构
  • I/O多路复用
  • 协议简单
  • 无多线程切换和资源争用开销
  • 处理流程简单高效

多线程模型

Redis6.0版本的多线程处理模型:
多线程模型

主线程工作流程如下:

  • 主线程负责接收建立连接请求,将epoll_wait等待的读事件加入全局待处理列表中;
  • 将等待列表中的读事件平均分给IO线程;
  • 主线程处理其中一部分读事件;
  • 阻塞等待IO线程将分配到的读事件进行命令解析;
  • 当所有IO线程将分配到的读事件都命令解析完毕,主线程负责执行命令,并将回复信息写入客户端对应的回复缓冲区。
  • 将等待等待列表中写事件平均分给IO线程;
  • 主线程处理其中一部分写事件;

从上面的实现机制可以看出,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。 所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。

Redis多线程模型缺陷

随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的程单线模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,于是诞生了Redis6.0多线程模型。

但是 Redis 多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers模型。Redis 的多线程方案中, I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令。所有客户端命令最后还需要回到主线程去执行, 因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

在我看来,Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能

redis 应用

有时候应用一个组件不需要事先知道其原理,只有在深度使用时遇到比较棘手的场景或问题时才会去深度了解。 基于这个观点,我们首先从应用的角度来了解redis。

典型应用场景

数据类型及相关命令

数据类型有哪些可以参考官方文档中文文档

redis 实现原理

了解了基本的应用之后,会遇到对性能和匹配度要求更高的场景,甚至为了自身特殊需要来改造redis,这就需要了解redis的底层原理及其相关理论。

底层数据结构

Redis 目前共有 9 种数据结构:SDS、双向链表、压缩列表(新版本已废弃)、哈希表、跳表、整数集合、quicklist、listpack。
数据结构

底层数据结构是 redis 高性能的一个重要支撑。我们也可以借鉴这些结构,用到其他项目中,或者在它的基础上进行改良。 本章节参考了:

底层数据结构概述

Redis 是一种k-v数据库,那这种键值对是怎么实现的呢?

Redis 是使用了一个「哈希表」保存所有键值对,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。哈希表其实就是一个数组,数组中的元素叫做哈希桶。 哈希桶存放的是指向键值对数据的指针(dictEntry*),这样通过指针就能找到键值对数据, 然后因为键值对的值可以保存字符串对象和集合数据类型的对象,所以键值对的数据结构中并不是直接保存值本身, 而是保存了 void * key 和 void * value 指针,分别指向了实际的键对象和值对象,这样一来,即使值是集合数据,也可以通过 void * value 指针找到。
数据结构

上图中涉及到的数据结构的名字和用途:

  • redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针;
  • dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表1」,「哈希表2」只有在 rehash 的时候才用,具体什么是 rehash,我在本文的哈希表数据结构会讲;
  • ditctht 结构,表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针;
  • dictEntry 结构,表示哈希表节点的结构,结构里存放了 **void * key 和 void * value 指针, key 指向的是 String 对象, 而 value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。

特别说明下,void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示,如下图:

1
2
3
4
5
6
7
8
// redis 6.0.6
typedef struct redisObject {
    unsigned type:4; // 4位=0.5字节
    unsigned encoding:4; // 4位=0.5字节
    unsigned lru:LRU_BITS; // 24位=3字节, 对象最后一次访问时间(秒)
    int refcount; // 32位=4字节,引用计数。0:可被垃圾回收
    void *ptr; // 64位=8字节
} robj;

数据结构

对象结构里包含的成员变量:

  • type: 显式类型,标识该对象是什么类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);
  • encoding:隐式类型,标识该对象使用了哪种底层的数据结构;
  • ptr:指向底层数据结构的指针。

每种显式类型,会根据不同的阈值在不同的隐式类型中切换。

序号类型描述
1int整数
2embstrembstr编码的简单动态字符串(SDS)
3raw简单动态字符串
4ht字典
5linkedlist双端链表
5ziplist压缩列表
5intset整数集合
5skiplist跳跃链表和字典

type和encoding之间的对应关系大致如下(可能存在版本差异),数据使用不同类型存储是有限制条件的, 不同条件下,使用不同的存储格式。

typeencoding
string(1)int:值为整型,取值[-2^63-1, 2^63-1]
 (2)embstr:值不为整型或者整型值不在上述int范围内,且值长度小于等于44个字节
 (3)raw:值超过44个字节(64-16-3-1=44)
listquicklist(3.2版本前对应ziplist
 linkedlist)3.2版本后,list使用quicklist(ziplist和linkedlist组合)
hash(1)ziplist:值个数在hash-max-ziplist-entries范围内或者值长度在hash-max-ziplist-value范围内
 (2)hashtable:超过上述范围
set(1)intset:值为整型,取值[-2^63-1, 2^63-1]
 (2)hashtable:其他情况
zset(1)ziplist:值个数在zset-max-ziplist-entries范围内或者值长度在zset-max-ziplist-value范围内
 (2)skiplist:超过上述情况

我画了一张 Redis 键值对数据库的全景图,你就能清晰知道 Redis 对象和数据结构的关系了: 数据结构 数据结构 数据结构

redis会采用dict来保存全局hash,会有2个dict,dict0和dict1,其中dict1用于扩容用。 redis有扩容和缩容,缩容流程与扩容基本一致。除了什么时候扩会有差异:扩容是负载因子>=1或负载因子>=5;缩容是负载因子小于等于0.1。 扩容时,采用的是渐进式hash。这种思路可以借鉴。

  • 对于增删改查都会将一个元素从dict0放到dict1
    • 对于没有变动的使用定时任务迁移
    • 对于增加只会往dict1放
  • 迁移完成后将dict0置为dict1,同时清空dict1
    数据结构

整体redis的渐近式hash细节步骤可以用到数据库扩容上

SDS(简单动态字符串存储结构)

字符串在 Redis 中是很常用的,键值对中的键是字符串类型,值有时也是字符串类型。

Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串, 而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。

既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的 char* 字符数组存在一些缺陷。 要了解这一点,得先来看看 char* 字符数组的结构。

C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。 在 C 语言里,对字符串操作时,char * 指针只是指向字符数组的起始位置,而字符数组的结尾位置就用“\0”表示,意思是指字符串的结束。 这种字符数组结构有以下不便:

  • 求字符串长度需要遍历计数
  • 不能存放二进制数据(比如音视频数据、压缩编码数据)或者含有”\0”的数据,否则会被截断,无法读取真实完整的字符串
  • 修改字符串时带来的内存重分配次数较多
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

Redis 实现的 SDS 的结构就把上面这些问题解决了,接下来我们一起看看 Redis 是如何解决的。 下图就是 Redis 5.0 的 SDS 的数据结构:
SDS

总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。 当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小(小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容),以满足修改所需的大小。

在扩展 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。 这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。 所以,使用 SDS 即不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。

SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。 Redos 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。 这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。

之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。 除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间, 即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。 比如,sdshdr16 类型的 SDS,默认情况下,编译器会按照 16 字节对齐的方式给变量分配内存,这意味着,即使一个变量的大小不到 16 个字节,编译器也会给它分配 16 个字节。

链表

Redis 的 List 对象的底层实现之一就是链表。C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个链表数据结构。 实际上就是双链表。

Redis 在双链表的基础上封装了 List 结构(把操作和数据绑定,类似面向对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
    //链表节点数量
    unsigned long len;
} list;

list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。
SDS

Redis 的链表实现优点如下:

  • listNode 链表节点的结构里带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表;
  • list 结构因为提供了表头指针 head 和表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1);
  • list 结构因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1);
  • listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值;

链表的缺陷也是有的:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大。

因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。 不过,压缩列表存在性能问题(具体什么问题,下面会说),所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。

然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。

压缩列表

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

但是,压缩列表的缺陷也是有的:

不能保存过多的元素,否则查询效率就会降低; 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。 因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。
压缩列表

  • zlbytes,记录整个压缩列表占用对内存字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。 而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。 压缩列表节点(entry)的构成如下:
压缩列表

  • prevlen,记录了「前一个节点」的长度;
  • encoding,记录了当前节点实际数据的类型以及长度;
  • data,记录了当前节点的实际数据;

当我们往压缩列表中插入数据时,压缩列表就会根据数据是字符串还是整数,以及数据的大小, 会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息, 这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。

分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。 压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关:

  • 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码。
  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码。

压缩列表除了查找复杂度较高之外,还有一个连续更新的问题。 压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。 而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。 前面提到,压缩列表节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:
压缩列表

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。 这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图
压缩列表

因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。 多米诺牌的效应就此开始。
压缩列表

e1 原本的长度在 250~253 之间,因为刚才的扩展空间,此时 e1 的长度就大于等于 254 了,因此原本 e2 保存 e1 的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。 正如扩展 e1 引发了对 e2 扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展… 一直持续到结尾。 这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下……

空间扩展操作也就是重新分配内存,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。 所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。 因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。

虽说如此,Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。 这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。

哈希表

哈希表是一种保存键值对(key-value)的数据结构。 哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value等等。

在讲压缩列表的时候,提到过 Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack)。Hash 对象的另外一个底层实现就是哈希表。 哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。 但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么哈希冲突的可能性也会越高。 解决哈希冲突的方式,有很多种。

Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。

哈希表结构设计

Redis 的哈希表结构如下:

1
2
3
4
5
6
7
8
9
10
typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;  
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希表已有的节点数量
    unsigned long used;
} dictht;

可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
哈希表

哈希表节点的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dictEntry {
    //键值对中的键
    void *key;
 
    //键值对中的值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。

另外,这里还跟你提一下,dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针, 或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间, 因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。

哈希冲突

哈希表实际上是一个数组,数组里多每一个元素就是一个哈希桶。 当一个键值对的键经过 Hash 函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置,也就是第几个哈希桶。

什么是哈希冲突呢? 举个例子,有一个可以存放 8 个哈希桶的哈希表。key1 经过哈希函数计算后,再将「哈希值 % 8 」进行取模计算,结果值为 1,那么就对应哈希桶 1,类似的,key9 和 key10 分别对应哈希桶 1 和桶 6。
哈希冲突

此时,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突。 因此,当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。

Redis 采用了「链式哈希」的方法来解决哈希冲突。 链式哈希是怎么实现的?

实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表, 被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。

还是用前面的哈希冲突例子,key1 和 key9 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key9,形成一个单向链表。
哈希冲突

不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。 要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。 接下来,看看 Redis 是如何实现的 rehash 的。

哈希表结构设计的这一小节,我给大家介绍了 Redis 使用 dictht 结构体表示哈希表。 不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了两个哈希表(ht[2])。

1
2
3
4
5
6
typedef struct dict {
    
    //两个Hash表,交替使用,用于rehash操作
    dictht ht[2]; 
    
} dict;

之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。
哈希冲突

在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。 随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

  • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
  • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

为了方便你理解,我把 rehash 这三个过程画在了下面这张图。 这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
哈希冲突

为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。 渐进式 rehash 步骤如下:

  • 给「哈希表 2」 分配空间;
  • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;
  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间嗲呢,会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。 在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。 比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。

另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作, 这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。

rehash 的触发条件跟 负载因子(load factor) 有关系。 负载因子可以通过下面这个公式计算:
哈希冲突

触发 rehash 操作的条件,主要有两个:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

整数集合

整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不时,就会使用整数集这个数据结构作为底层实现。

整数集合结构设计

整数集合本质上是一块连续内存空间,它的结构定义如下:

1
2
3
4
5
6
7
8
typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组, 但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如:

  • 如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
  • 如果 encoding 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
  • 如果 encoding 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;

不同类型的 contents 数组,意味着数组的大小也会不同。

整数集合的升级操作

整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时, 整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。

整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。 举个例子,假设有一个整数集合里有 3 个类型为 int16_t 的元素。
整数集合

现在,往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作, 首先需要为 contents 数组扩容,在原本空间的大小之上再扩容多 80 位(4x32-3x16=80),这样就能保存下 4 个类型为 int32_t 的元素。
整数集合

扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变,整个转换过程如下:
整数集合

整数集合升级有什么好处呢? 如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,最简单做法就是直接使用 int64_t 类型的数组。 不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况。 整数集合升级就能避免这种情况,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组, 只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。 因此,整数集合升级的好处是节省内存资源。

整数集合支持降级操作吗? 不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素, 整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。

跳表

Redis 只有在 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。 Zset 对象是唯一一个同时使用了两个数据结构来实现的 Redis 对象,这两个数据结构一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。

1
2
3
4
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。

跳表结构设计

链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。

那跳表长什么样呢?我这里举个例子,下图展示了一个层级为 3 的跳表。
跳表

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:

  • L0 层级共有 5 个节点,分别是节点1、2、3、4、5;
  • L1 层级共有 3 个节点,分别是节点 2、3、5;
  • L2 层级只有 1 个节点,也就是节点 3 。

如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。

可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。 那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
 
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

Zset 对象要同时保存元素和元素的权重,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。 每个跳表节点都有一个后向指针,指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。 level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示, 比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。

比如,下面这张图,展示了各个节点的跨度。
跳表

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:

  • L0 层级共有 5 个节点,分别是节点1、2、3、4、5;
  • L1 层级共有 3 个节点,分别是节点 2、3、5;
  • L2 层级只有 1 个节点,也就是节点 3 。

如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。

可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。

那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
 
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

Zset 对象要同时保存元素和元素的权重,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。 每个跳表节点都有一个后向指针,指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。

level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示, 比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。

比如,下面这张图,展示了各个节点的跨度。
跳表

第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针就可以完成了。 跨度实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的, 那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。

举个例子,查找图中节点 3 在跳表中的排位,从头节点开始查找节点 3,查找的过程只经过了一个层(L3),并且层的跨度是 3,所以节点 3 在跳表中的排位是 3。

另外,图中的头节点其实也是 zskiplistNode 跳表节点,只不过头节点的后向指针、权重、元素值都会被用到,所以图中省略了这部分。

问题来了,由谁定义哪个跳表节点是头节点呢?这就介绍「跳表」结构体了,如下所示:

  • 跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
  • 跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
  • 跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量;
1
2
3
4
5
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

跳表节点查询过程

查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时, 会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
  • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针, 然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。 举个例子,下图有个 3 层级的跳表。
跳表

如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:

  • 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
  • 但是该层上的下一个节点是空节点,于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
  • 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
  • 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。

跳表节点层数设置

跳表的相邻两层的节点数量的比例会影响跳表的查询性能。 举个例子,下图的跳表,第二层的节点数量只有 1 个,而第一层的节点数量有 6 个。
跳表

这时,如果想要查询节点 6,那基本就跟链表的查询复杂度一样,就需要在第一层的节点中依次顺序查找,复杂度就是 O(N) 了。所以,为了降低查询复杂度,我们就需要维持相邻层结点数间的关系。 跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。 下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。
跳表

那怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢? 如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。 Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。

具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%), 那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。 这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

quicklist

在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。 其实 quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

在前面讲压缩列表的时候,我也提到了压缩列表的不足,虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计, 如果保存的元素数量增加,或者元素变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降。

quicklist 解决办法,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。 因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于 quicklist 的节点是 quicklistNode。

1
2
3
4
5
6
7
8
9
10
11
typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;      //quicklist的链表头
    //quicklist的链表头
    quicklistNode *tail; 
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;       
    ...
} quicklist;

quicklistNode 的结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct quicklistNode {
    //前一个quicklistNode
    struct quicklistNode *prev;     //前一个quicklistNode
    //下一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    //quicklistNode指向的压缩列表
    unsigned char *zl;              
    //压缩列表的的字节大小
    unsigned int sz;                
    //压缩列表的元素个数
    unsigned int count : 16;        //ziplist中的元素个数 
    ....
} quicklistNode;

可以看到,quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。 但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针 *zl。 我画了一张图,方便你理解 quicklist 数据结构。
quicklist

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素, 如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

listpack

quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了, 压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

我看了 Redis 的 Github,在最新 6.2 发行版本中,Redis Hash 对象、Set 对象的底层数据结构的压缩列表还未被替换成 listpack, 而 Redis 的最新代码(还未发布版本)已经将所有用到压缩列表底层数据结构的 Redis 对象替换成 listpack 数据结构来实现, 估计不久将来,Redis 就会发布一个将压缩列表为 listpack 的发行版本。

listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。 我们先看看 listpack 结构:
listpack

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。 每个 listpack 节点结构如下:
listpack

主要包含三个方面内容:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了, listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候, 不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

rax(前缀树)

rax 是 redis 自己实现的基数树, 它是一种基于存储空间优化的前缀树数据结构, 在 redis 的许多地方都有使用到, 比如:streams这个类型里面的 consumer group(消费者组) 的名称还有集群名称;集群状态下clusterState中的slots_to_keys用rax保存了从槽到key之间的关系。 通常来讲, 一个基数树(前缀树) 看起来如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
 *              (f) ""
 *                \
 *                (o) "f"
 *                  \
 *                  (o) "fo"
 *                    \
 *                  [t   b] "foo"
 *                  /     \
 *         "foot" (e)     (a) "foob"
 *                /         \
 *      "foote" (r)         (r) "fooba"
 *              /             \
 *    "footer" []             [] "foobar"

然而, 当前的代码实现使用了一种非常常见的优化策略, 把只有单个子的节点连续几个节点压缩成一个节点, 这个节点有一个字符串, 不再是只存储单个字符, 上述的结构可以优化成如下结构:

1
2
3
4
5
6
7
 *                  ["foo"] ""
 *                     |
 *                  [t   b] "foo"
 *                  /     \
 *        "foot" ("er")    ("ar") "foob"
 *                 /          \
 *       "footer" []          [] "foobar"

字符串 mygroup1 在 rax 中也是以压缩节点的方式存储的, 可以用如下表示:

1
2
3
 *                  ["mygroup1"] ""
 *                     |
 *                    [] "mygroup1"

第一个节点存储了压缩过的整个字符串 mygroup1, 第二个节点是一个空的叶子节点, 他是一个 key 值, 表示到这个节点之前合起来的字符串存储在了当前的 raxNode中。 rax结构代表一个Rax树:

1
2
3
4
5
typedef struct rax {
  raxNode *head;
  uint64_t numele;
  uint64_t numnodes;
} rax;
  • head:指向rax的头节点;
  • numele:rax元素的个数,即key的个数;
  • numnodes:节点个数。
1
2
3
4
5
6
7
8
9
10
11
12
typedef struct raxNode {
    //节点是否包含key
    uint32_t iskey:1;     /* Does this node contain a key? */
    //节点的值是否为NULL
    uint32_t isnull:1;    /* Associated value is NULL (don't store it). */
    //节点是否被压缩
    uint32_t iscompr:1;   /* Node is compressed. */
    //节点大小
    uint32_t size:29;     /* Number of children, or compressed string len. */
    //节点的实际存储数据
    unsigned char data[];
} raxNode;
  • iskey 表示当前的节点是否为 key 节点,即表示从 Radix Tree 的根节点到当前节点路径上的字符组成的字符串,是否表示了一个完整的 key。这里需要注意的是,当前节点所表示的 key,并不包含该节点自身的内容
  • isnull 表示当前节点是否有存储额外的值(data的指针是否为空)
  • iscompr 表示当前节点是否为压缩的节点
  • size 是子节点数量或者压缩的字符串长度,如果当前节点是压缩节点,该值表示压缩数据的长度;如果是非压缩节点,该值表示该节点指向的子节点个数。
  • data存储数据

在 Radix Tree 中存在两类节点:

  • 第一类节点是非压缩节点,这类节点会包含多个指向不同子节点的指针,以及多个子节点所对应的字符。data 数组包括子节点对应的字符、指向子节点的指针,以及节点表示 key 时对应的 value 指针;
  • 第二类节点是压缩节点,这类节点会包含一个指向子节点的指针,以及子节点所代表的合并的字符串。data 数组包括子节点对应的合并字符串、指向子节点的指针,以及节点为 key 时的 value 指针。

在 raxNode 的实现中,无论是非压缩节点还是压缩节点,其实具有两个特点:

  • 它们所代表的 key,是从根节点到当前节点路径上的字符串,但并不包含当前节点;
  • 它们本身就已经包含了子节点代表的字符或合并字符串。而对于它们的子节点来说, 也都属于非压缩或压缩节点,所以,子节点本身又会保存,子节点的子节点所代表的字符或合并字符串。

可以简单看下这个例子:
radix

这张图上显示了 Radix Tree 最右侧分支的 4 个节点 r、e、d、is 和它们各自的 raxNode 内容。 其中,节点 r、e 和 d 都不代表 key,所以它们的 iskey 值为 0,isnull 值为 1,没有为 value 指针分配空间。

节点 r 和 e 指向的子节点都是单字符节点,所以它们不是压缩节点,iscompr 值为 0。而节点 d 的子节点包含了合并字符串“is”, 所以该节点是压缩节点,iscompr 值为 1。最后的叶子节点 is,它的 raxNode 的 size 为 0,没有子节点指针。 不过,因为从根节点到节点 is 路径上的字符串代表了 key“redis”,所以,节点 is 的 value 指针指向了“redis”对应的 value 数据。

这里,你需要注意的是,为了满足内存对齐的需要,raxNode 会根据保存的字符串长度,在字符串后面填充一些字节,也就是图中的 padding 部分。 下图是字符串 mygroup1 当前所在的 rax 的实际图示:
radix

第一个节点的 iscompr 值为 1, 并且整个字符串 mygroup1 存储在了当前这一个节点中, size 为 8 表示当前节点存储了 8 个 char 字符, iskey为 0, 表示当前的节点不是 key 节点, 我们需要继续往下搜索。

第二个节点的 iskey 为 1, 表示当前的节点为 key 节点, 它表示在到这个节点之前的所有字符串连起来(也就是mygroup1) 存在当前的前缀树中, 也就是说当前的前缀树有包含 mygroup1 这个字符串, isnull 为 0 表示在当前这个 key 节点的 data 尾部存储了一个指针, 这个指针是函数调用者进行存储的, 在当前的情况它是一个指向 streamCG 的指针, 但是实际上他可以是指向任意对象的指针, 比如集群名称或者其他对象。

我们再来插入一个 consumer group 名称到当前的前缀树中:比如执行 XGROUP CREATE mystream mygroup2 $
radix

从上图可知, 第一个节点被裁剪了, 并且它后面插入了一个新的节点, 左下角的节点是原先存在的节点, 右下角的节点也是一个新插入的节点:

1
2
3
4
5
 *                  ["mygroup"] ""
 *                     |
 *                  [1   2] "mygroup"
 *                  /     \
 *      "mygroup1" []     [] "mygroup2"

中间的节点未被压缩(iscompr 这个位没有被设置), data 字段中存储了 size 个字符, 在这些字符之后, 同样会存储 size 个指向与之前字符一一对应的 raxNode 的结构的指针。 底下两个节点的 iskey = 1 并 isnull = 0, 表示当到达任意一个这样的节点时, 当前的字符串是存储在这个前缀树中的, 并且在 data 字段最尾部存储了一个辅助的指针, 这个指针具体指向什么对象取决于调用者。

stream

Stream 会使用 Radix Tree 来保存消息 ID,然后将消息内容保存在 listpack 中,并作为消息 ID 的 value,用 raxNode 的 value 指针指向对应的 listpack。

1
2
3
4
5
6
typedef struct stream {
  rax *rax; /* 存储生产者生产的具体消息,以消息ID为键,消息内容为值存储在rax中,值得注意的是,rax中的一个节点可能存储多个消息*/
  uint64_t length; /*当前stream中的消息个数(不包括已经删除的消息)。*/
  streamID last_id; /* 当前stream中最后插入的消息的ID,stream空时,设置为0。. */
  rax *cgroups; /* 存储了当前stream相关的消费组,rax中: name -> streamCG */
} stream;
  • rax:指向rax的的头节点,存储生产者生产的具体消息,以消息ID为键,消息内容为值存储在rax中;
  • length:stream中消息的个数,不包括已经删除的消息;
  • last_id: 当前stream中最后插入的消息的ID,stream空时,设置为0;
  • cgoups:指向rax的头节点,存储了当前stream相关的消费组。

每个Stream会有多个消费组,每个消费组通过组名称进行唯一标识,同时关联一个streamCG结构,该结构定义如下:

1
2
3
4
5
typedef struct streamCG {
  streamID last_id; // 该消费组已经确认的最后一个消息的ID
  rax *pel; // 该消费组尚未确认的消息,消息ID为键,streamNACK(一个尚未确认的消息)为值;
  rax *consumers; // 该消费组中所有的消费者,消费者的名称为键,streamConsumer(代表一个消费者)为值。
} streamCG;

每个消费者通过streamConsumer唯一标识,该结构如下:

1
2
3
4
5
typedef struct streamConsumer {
  mstime_t seen_time; /* 该消费者最后一次活跃的时间; */
  sds name; /* 消费者的名称*/
  rax *pel; /* 消费者尚未确认的消息,以消息ID为键,streamNACK为值。 */
} streamConsumer;

未确认消息(streamNACK)维护了消费组或者消费者尚未确认的消息,值得注意的是,消费组中的pel的元素与每个消费者的pel中的元素是共享的, 即该消费组消费了某个消息,这个消息会同时放到消费组以及该消费者的pel队列中,并且二者是同一个streamNACK结构。

1
2
3
4
5
6
/* Pending (yet not acknowledged) message in a consumer group. */
typedef struct streamNACK {
    mstime_t delivery_time; /* 该消息最后发送给消费方的时间 */
    uint64_t delivery_count; /*为该消息已经发送的次数(组内的成员可以通过xclaim命令获取某个消息的处理权,该消息已经分给组内另一个消费者但其并没有确认该消息)。*/
    streamConsumer *consumer; /* 该消息当前归属的消费者 */
} streamNACK;

此外,还可以看下迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct streamIterator {
    stream *stream; /*当前迭代器正在遍历的消息流 */
    streamID master_id; /* 消息内容实际存储在listpack中,每个listpack都有一个masterentry(也就是第一个插入的消息),master_id为该消息id */
    uint64_t master_fields_count; /* master entry中field域的个数. */
    unsigned char *master_fields_start; /*master entry field域存储的首地址*/
    unsigned char *master_fields_ptr; /*当listpack中消息的field域与master entry的field域完全相同时,该消息会复用master entry的field域,在我们遍历该消息时,需要记录当前所在的field域的具体位置,master_fields_ptr就是实现这个功能的。 */
    int entry_flags; /* 当前遍历的消息的标志位 */
    int rev; /*当前迭代器的方向 */
    uint64_t start_key[2]; /* 该迭代器处理的消息ID的范围 */
    uint64_t end_key[2]; /* End key as 128 bit big endian. */
    raxIterator ri; /*rax迭代器,用于遍历rax中所有的key. */
    unsigned char *lp; /* 当前listpack指针*/
    unsigned char *lp_ele; /* 当前正在遍历的listpack中的元素, cursor. */
    unsigned char *lp_flags; /* Current entry flags pointer.指向当前消息的flag域 */
    //用于从listpack读取数据时的缓存
    unsigned char field_buf[LP_INTBUF_SIZE];
    unsigned char value_buf[LP_INTBUF_SIZE];
} streamIterator;

存储方式如下图示:
stream

stream 结构体中的 rax 指针,指向了 Radix Tree 的头节点,也就是 rax 结构体。 rax 结构体中的头指针进一步指向了第一个 raxNode。因为我们假设就只有一个 streamID, 暂时没有其他 streamID 和该 streamID 共享前缀,所以,当前这个 streamID 就可以用压缩节点保存。

然后,第一个 raxNode 指向了下一个 raxNode,也是 Radix Tree 的叶子节点。 这个节点的 size 为 0,它的 value 指针指向了实际的消息内容。

streamID可以自己指定,也可以由redis生成,即由每个消息创建时的时间(1970年1月1号至今的毫秒数)以及序号组成,共128位:

1
2
3
4
typedef struct streamID {
  uint64_t ms; /* Unix time in milliseconds. */
  uint64_t seq; /* Sequence number. */
} streamID;

而在消息内容这里,是使用了 listpack 进行保存的。 一个listpack可以存储多个消息,也就是说多个raxNode可能会指向同一个listpack。

每个listpack都有一个master entry,该结构中存储了创建这个listpack时待插入消息的所有field, 这主要是考虑同一个消息流,消息内容通常具有相似性,如果后续消息的field与master entry内容相同, 则不需要再存储其field。master entry中每一个元素都是一个单独的entry (下图省略了listpack每个元素存储时的encoding以及backlen字段)
stream

  • count 为当前listpack中的所有未删除的消息个数;
  • deleted 为当前listpack中所有已经删除的消息个数;
  • num-fields 为下面的field的个数;
  • field-1,…,filed-N 为当前listpack中第一个插入的消息的所有field域;
  • 0 为标识位,在从后向前遍历该listpack的所有消息时使用。

存储一个消息时,如果该消息的field域与master entry的域完全相同,则不需要再次存储field域:
stream

  • flags字段为消息标志位,STREAM_ITEM_FLAG_NONE代表无特殊标识, STREAM_ITEM_FLAG_DELETED代表该消息已经被删除, STREAM_ITEM_FLAG_SAMEFIELDS代表该消息的field域与master entry完全相同;
  • streamID.ms以及streamID.seq为该消息ID减去master entry id之后的值;
  • value域存储了该消息的每个field域对应的内容;
  • lp-count为该消息占用listpack的元素个数,也就是3+N。

消息的field域与master entry不完全相同,存储如下:
stream

  • flags为消息标志位,与上面一致;
  • streamID.ms,streamID.seq为该消息ID减去master entry id之后的值;
  • num-fields为该消息field域的个数;
  • field-value存储了消息的域值对,也就是消息的具体内容;
  • lp-count为该消息占用的listpack的元素个数,也就是4+2N。

添加消息

Redis提供了streamAppendItem函数,用于向stream中添加一个新的消息:

1
2
int streamAppendItem(stream *s, robj **argv, int64_t numfields, 
        streamID *added_id, streamID *use_id)
  • s 为待插入的数据流;
  • argv 为待插入的消息内容,argv[0]为field_1,argv[1]为value_1,依此类推;
  • numfields 为待插入的消息的field的总数;
  • added_id 不为空,并且插入成功时,将新插入的消息id写入added_id以供调用方使用;
  • use_id 为调用方为该消息定义的消息id,该消息id应该大于s中任意一个消息的id。

大概流程如下:

  • 获取rax的最后一个key所在的节点,由于Rax树是按照消息id的顺序存储的,所以最后一个key节点存储了上一次插入的消息;
  • 查看该节点是否可以插入这条新的消息;
  • 如果该节点已经不能再插入新的消息(listpack为空或者已经达到设定的存储最大值),在rax中插入新的节点(以消息id为key,新建listpack为value),并初始化新建的listpack;
  • 如果仍然可以插入消息,则对比插入的消息与listpack中的master消息对应的fields是否完全一致,完全一致则表明该消息可以复用master的field;
  • 将待插入的消息内容插入到新建的listpack中或者原来的rax的最后一个key节点对应的listpack中,这一步主要取决于前2步的结果。

删除消息

streamIteratorRemoveEntry函数用于移除某个消息,值得注意的是,该函数通常只是设置待移除消息的标志位为已删除,并修改master entry的统计信息, 而不会将该消息从所在的listpack中删除。当消息所在的整个listpack的所有消息都已删除时,则会从rax中释放该节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void streamIteratorRemoveEntry(streamIterator *si, streamID *current) {
    unsigned char *lp = si->lp;
    int64_t aux;
    int flags = lpGetInteger(si->lp_flags);
    flags |= STREAM_ITEM_FLAG_DELETED;
    lp = lpReplaceInteger(lp,&si->lp_flags,flags); // 设置消息的标志位

    /* Change the valid/deleted entries count in the master entry. */
    unsigned char *p = lpFirst(lp);
    aux = lpGetInteger(p);
    if (aux == 1) {
        /* 当前Listpack只有待删除消息,可以直接删除节点. */
        lpFree(lp);
        raxRemove(si->stream->rax,si->ri.key,si->ri.key_len,NULL);
    } else {
        /* 修改listpack master enty中的统计信息 */
        lp = lpReplaceInteger(lp,&p,aux-1);
        p = lpNext(lp,p); /* Seek deleted field. */
        aux = lpGetInteger(p);
        lp = lpReplaceInteger(lp,&p,aux+1);
        /* 查看listpack是否有变化(listpack中元素变化导致的扩容缩容) . */
        if (si->lp != lp)
            raxInsert(si->stream->rax,si->ri.key,si->ri.key_len,lp,NULL);
    }
    .....
}

参考文档

redis 源码导读

数据结构除了抽象描述之外,如何实现也是很有参考价值的。了解Redis原理时看C语言代码有困难, 或者想了解Go语言是如何实现Redis功能的,可参考Godis

总体流程图 服务端流程图

redis 通信协议

所谓 协议,本质是一种约定,需要使用者双方来准守,常见于 C/S 通信模式中,比如在浏览器中最常用的 HTTP 应用层通信协议。 通信两端需要某种约定,才能保持正常通信。一端通过约定的格式发送数据,另一端通过约定的格式解析数据,这种约定,取了一个好听的名字 —- 协议。 典型的 HTTP 协议,最本质的原理也是如此。redis 作为一款高性能内存组件,要尽可能将精力花在数据的组织形式上,因此,没有采用开源的一些复杂协议,比如 HTTP,而是简单的自定义一套应用层通信协议。

Redis 客户端 - 服务端通信协议称之为 RESP 协议,全称叫 Redis Serialization Protocol,即 redis 序列化协议。人类易读,相当精巧!

RESP 是一种二进制安全协议,因为编码后的每一个字符串都有前缀来表明其长度,通过长度就能知道数据边界,从而避免越界访问的问题。 值得注意的是,RESP 协议只用于 客户端 - 服务端 之间的交流,redis cluster 各节点之间采用不同的二进制协议(采用 Gossip 协议)进行交流。

我们知道,在传统计算机网络模型中,传输层(TCP / UDP)的上一层便是应用层。应用层协议一般专注于数据的编解码等约定,比如经典的 HTTP 协议。 RESP 协议本质和 HTTP 是一个级别,都属于应用层协议。

在 redis 中,传输层协议使用的是 TCP,服务端从 TCP socket 缓冲区中读取数据,然后经过 RESP 协议解码得到我们的指令。 而写入数据则是相反,服务器先将响应数据使用 RESP 编码,然后将编码后的数据写入 TCP Socket 缓冲区发送给客户端。

RESP2

在 RESP 协议中,第一个字节决定了具体数据类型:

  • 简单字符串:Simple Strings,第一个字节响应 +
  • 错误:Errors,第一个字节响应 -
  • 整型:Integers,第一个字节响应 :
  • 批量字符串:Bulk Strings,第一个字节响应 $
  • 数组:Arrays,第一个字节响应 *

我们来看看一具体的例子,我们一条正常指令PSETEX test_redisson_batch_key8 120000 test_redisson_batch_key=>value:8,经 RESP 协议编码后长这样:

1
2
3
4
5
6
7
8
9
*4
$6
PSETEX
$24
test_redisson_batch_key8
$6
120000
$32
test_redisson_batch_key=>value:8

值得注意的是,在 RESP 协议中的每一部分都是以 \R\N 结尾。

简单字符串

Simple Strings。以 + 为前缀的响应数据,例如:"+OK\r\n"。 以上是字符串 OK,被编码后的格式,总共 5 字节。

这是一种非二进制安全的编码方式,因为, 我们无法确切的知道字符串的长度,只能以 \r\n 来判断,所以编码的字符串中,不能包含 \r 或者 \n 字符。 当然,如果你想要二进制安全字符串,可以选择 Bulk Strings 方式,我们后面会介绍。

错误

RESP 提供了错误类型,和简单字符串非常类似,不过是以 - 开头,基本格式如下:"-Error message\r\n"。 与简单字符串真正不同的之处在于客户端的处理上,对 - 开头的响应,客户端直接以异常情况处理。 我们来看一个是实际例子,当我们的指令或者参数错误,redis 服务端会直接返回异常,如下:

1
2
-ERR unknown command 'helloworld'
-WRONGTYPE Operation against a key holding the wrong kind of value

其中 - 后面的第一个单词,直到第一个空格或换行符,表示返回的错误类型。这只是 Redis 使用的一个惯例,并不是 RESP 错误格式的一部分。 ERR 是通用错误,而 WRONGTYPE 是一个更具体的错误,表示客户端尝试执行错误的数据类型,通常作为一个错误的前缀,它允许客户端在不检查确切错误消息的情况下理解服务器返回的错误类型。

我们在客户端实现的时候,可以针对不同的错误返回不同类型的异常,或者提供一种捕获错误的通用方法,比如,直接将错误名称作为字符串提供给调用者。 然而,这样的特性不应该被认为是至关重要的,因为它很少有用,而且有限的客户端实现可能只是返回一个通用的错误条件,比如 false。

整型

RESP Integers。表示响应的是整数,以 : 开头,比如 :0\r\n 和 :1000\r\n

redis 中很多命令的响应都是整数,比如 INCR, LLEN, 及 LASTSAVE。另外,响应值是一个 64 位的整数。 当然,整形也可以表示 true 或者 false 语义,比如 EXISTS 或者 SISMEMBER 返回 1 表示 true,0 表示 false。 还有其他命令,比如 SADD, SREM, 和 SETNX 返回 1 表示实际执行,反之为 0。

以下命令会响应结果为整数:

1
2
3
4
5
SETNX, DEL, EXISTS, INCR, 
INCRBY, DECR, DECRBY, 
DBSIZE, LASTSAVE, RENAMENX, 
MOVE, LLEN, SADD, SREM, 
SISMEMBER, SCARD.

批量字符串

RESP Bulk Strings。批量回复,是一个大小在 512 Mb 的二进制安全字符串,被编码成:

  • 以 $ 开头,紧跟一个整数代表回复字符串的大小,以 \r\n 结束
  • 随后是实际的字符串数据
  • 最后以 “\r\n” 结尾

以下是示例:

  • 比如 hello 被编码为:”$5\r\nhello\r\n”
  • 一个空字符串被编码为:”$0\r\n\r\n”

另外,对于一些不存在的 value 可以返回 -1 表示 null,也被称为 NULL 批量回复。 客户端库进行实现时,可以将此 -1 处理成空对象,比如 Ruby 将返回 nil,而 C 则返回 NULL

数组

RESP Arrays。数组,对于响应的集合元素,比如 LRANGE 命令,返回的是元素列表,也就是数组形式。编码格式:

  • 以 * 开头表示,紧接着是一个整数,表示数组元素个数,并以 \r\n 结尾。
  • 数组的每个元素的都是 RESP 提供的类型。

以下是示例:

  • 例如,空数组:"*0\r\n"
  • 包含 “hello” 和 “world” 的响应数组(也叫多批量字符串,每一个元素是批量字符串):"*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n"
  • 3个整数的数组是这样的:"*3\r\n:1\r\n:2\r\n:3\r\n"
  • 另外,数组也可以混合类型的。比如以下5个元素中,有4个是整形,一个是 批量字符串:
1
2
3
4
5
6
7
*5\r\n
:1\r\n
:2\r\n
:3\r\n
:4\r\n
$5\r\n
hello\r\n

以上结果为了更加清晰的展示,进行了手动换行。

当然,也同样支持空数组(一般情况下,更习惯使用 Null Bulk String,但由于历史原因,两种方式都存在) 例如,当使用 BLPOP 命令 timeout 时,将返回空数组:"*-1\r\n"

当 redis 返回 NULL 数组时,客户端实现库最好也返回一个空对象, 有助于区别到底是 empty 数组还是产生了其他问题内置数组,如下:

1
2
3
4
5
6
7
8
*2\r\n
*3\r\n
:1\r\n
:2\r\n
:3\r\n
*2\r\n
+Hello\r\n
-World\r\n

同样,为了展示更加清晰,进行了手动换行

该响应结果表示,外层数组包含两个元素,每个元素都是数组。第一个子数组包含 3 个整型数字,第二个子数组包含 1 个简单字符串和一个错误。

数组中的空元素

Null elements in Arrays。数组出现 NULL 元素,这种场景也是很常见的, 比如我们使用 MGET 批量获取 key,当其中一些 key 不存在时,返回的就是 NULL 元素。例如响应结果:

1
2
3
4
5
6
*3\r\n
$5\r\n
hello\r\n
$-1\r\n
$5\r\n
world\r\n

如上响应编码,客户端库解析之后应该是这样:["hello",nil,"world"]

多命令和管道

Multiple commands and pipelining。多命令和管道,redis 中提供了一次发送多条指令的操作,比如 MGET、MSET、pipline,服务端接收并处理后一次性响应。 这种形式就是上面提到的数组,数组里面可以是批量字符串、整数,甚至是 NULL 都可以。 我们先使用 telnet 看看原生响应结果:

1
2
3
4
5
6
7
8
[root@VM-20-17-centos ~]# telnet 127.0.0.1 6379
MGET key1 key2 key3
*3
$6
value1
$6
value2
$-1

我们再使用 redis-cli 看看被客户端解码后的结果:

1
2
3
4
127.0.0.1:6379> MGET key1 key2 key3
1) "value1"
2) "value2"
3) (nil)

内联命令

Inline commands。是这样的,一般情况下我们和 redis 服务端通信都需要一个客户端(比如redis-cli),因为双方都遵循 RESP 协议,数据可以正常编码和解析。 考虑这样一种情况,当你没有任何客户端工具可用时,是否也能正常和服务端通信呢?比如 telnet。 也是可以的,redis 正式通过 内联指令 支持的,咱们来看看例子:

  • 例1,通过 RESP 协议发送指令(由于没有客户端,这里我们手动编码):
1
2
3
4
5
6
7
8
9
10
11
12
[root@VM-20-17-centos ~]# telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
*3       
$3
set
$4   
key1
$5 
world
+OK

我们正常的指令是 set key1 word,经过 RESP 编码之后 *3\r\n$3\r\nset\r\n$4\r\nkey1\r\r$5\r\nworld,redis 服务端解码之后便可得到正常指令。

  • 例2,通过内联操作发送指令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[root@VM-20-17-centos ~]# telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
exists key1
:1
get key1
$1
1
set key1 hello             
+OK
get key1
$5
hello

这里我们直接发送 内联指令 比如 EXISTS key1、GET key1、SET key1 hello 等,无需 RESP 协议编码,服务端仍可正常处理。 值得注意的是,因为没有了统一请求协议中的 * 项来声明参数的数量,所以在 telnet 会话输入命令的时候,必须使用空格来分割各个参数, 服务器在接收到数据之后,会按空格对用户的输入进行解析,并获取其中的命令参数。

高性能 Redis 协议解析器

High performance parser for the Redis protocol,即,高性能 Redis 协议分析器。 RESP 是一款人类易读、简单实现的通信协议,它可以类似于二进制协议的性能实现。 RESP 使用前缀长度来传输批量数据,因此不需要像 JSON 那样,为了查找某个特殊字符而扫描整个数据,也无须对发送至服务器的数据进行转义。

程序可以在对协议文本中的各个字符进行处理的同时, 查找 CR 字符, 并计算出批量回复或多条批量回复的长度, 就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(void) {
    unsigned char *p = "$123\r\n";
    int len = 0;

    p++;
    while(*p != '\r') {
        len = (len*10)+(*p - '0');
        p++;
    }

    /* Now p points at '\r', and the len is in bulk_len. */
    printf("%d\n", len);
    return 0;
}

得到了批量回复或多条批量回复的长度之后, 程序只需调用一次 read 函数, 就可以将回复的正文数据全部读入到内存中, 而无须对这些数据做任何的处理。 在回复最末尾的 CR 和 LF 不作处理,丢弃它们。 Redis 协议的实现性能可以和二进制协议的实现性能相媲美,并且由于 Redis 协议的简单性,大部分高级语言都可以轻易地实现这个协议,这使得客户端软件的 bug 数量大大减少。

参考文档

RESP3

为了照顾老用户,Redis6在兼容 RESP2 的基础上,开始支持 RESP3,但未来会全面切换到RESP3之上。 今天的客户端缓存在基于RESP3才能有更好的实现,可以在同一个连接中运行数据的查询和接收失效消息。 而目前在RESP2上实现的客户端缓存,需要两个客户端连接以转发重定向的形式实现

在Redis6中我们可以使用HELLO命令在RESP2和RESP3协议之间进行切换:

1
2
3
4
#使用RESP2协议
HELLO 2
#使用RESP3协议
HELLO 3

与RESP2兼容

随着Redis版本的不断更新以及功能迭代,RESP V2协议开始渐渐无法满足新的需求, 为了适配在Redis6.0中出现的一些新功能,在它的基础上发展出了全新的下一代RESP3协议。 下面我们先来回顾一下继承自RESP V2的5种数据返回类型,在了解这些类型的局限性后, 再来看看RESP3中新的数据返回类型都在什么地方做出了改进。

请求格式

协议中数据的请求格式与RESP V2完全相同,请求的格式如下:

1
2
3
4
5
6
7
8
*<参数数量> CRLF
$<参数1的字节长度> CRLF
<参数1的数据> CRLF
$<参数2的字节长度> CRLF
<参数2的数据> CRLF
...
$<参数N的字节长度> CRLF
<参数N的数据> CRLF

每行末尾的CRLF转换成程序语言是\r\n,也就是回车加换行。以set name hydra这条命令为例, 转换过程及转换后的结果如下:
RESP3

在了解了发送的协议后,下面对不同类型的回复进行测试。这一过程如何进行模拟呢? 直接在命令行下使用telnet进行连接就可以了,以我本机启动的redis为例, 直接输入telnet 127.0.0.1 6379就可以连接到redis服务了。 之后再将包含换行的指令一次性拷贝到命令行,然后回车,就能够收到来自Redis服务的回复了:
RESP3

下面先来看看继承自RESP V2的5种返回格式,为了统一命名规范, 介绍中均采用RESP3官方文档中的新的名称来替代RESP V2中的旧命名, 例如不再使用旧的批量回复、多条批量回复等类型名称。

Simple string

表示简单字符串回复,它只有一行回复,回复的内容以+作为开头,不允许换行,并以\r\n结束。 有很多指令在执行成功后只会回复一个OK,使用的就是这种格式,能够有效地将传输、解析的开销降到最低。 还是以上面的set指令为例,发送请求

1
2
3
4
5
6
7
*3
$3
set
$4
name
$5
hydra

收到回复:

1
+OK\r\n

Simple error

错误回复,它可以看做简单字符串回复的变种形式,它们之间的格式也非常类似, 区别只有第一个字符是以-作为开头,错误回复的内容通常是错误类型及对错误描述的字符串。 错误回复出现在一些异常的场景,例如当发送了错误的指令、操作数的数量不对时,都会进行错误回复。

发送一条错误的指令:

1
2
3
*1
$8
Dr.Hydra

收到回复,提示错误信息:

1
-ERR unknown command `Dr.Hydra`, with args beginning with:\r\n

Number

整数回复,它的应用也非常广泛,它以:作为开头,以\r\n结束,用于返回一个整数。 例如当执行incr后返回自增后的值,执行llen返回数组的长度, 或者使用exists命令返回的0或1作为判断一个key是否存在的依据,这些都使用了整数回复。

发送一条查看数组长度的指令:

1
2
3
4
5
*2
$4
llen
$7
myarray

收到回复:

1
:4\r\n

Blob string

多行字符串的回复,也被叫做批量回复,在RESP V2中将它称为Bulk String。以$作为开头, 后面是发送的字节长度,然后是\r\n,然后发送实际的数据,最终以\r\n结束。 如果要回复的数据不存在,那么回复长度为-1。

发送一条get命令请求:

1
2
3
4
5
*2
$3
get
$4
name

收到回复:

1
2
$5\r\n
hydra\r\n

Array

可以理解为RESP V2中的多条批量回复,当服务端要返回多个值时,例如返回一些元素的集合时, 就会使用Array。它以*作为开头,后面是返回元素的个数,之后再跟随多个上面的Blob String。

1
2
3
4
5
6
7
8
9
*4
$6
lrange
$7
myarray
$1
0
$2
-1

收到回复,包含了集合中的4个元素:

1
2
3
4
5
6
7
8
9
*4
$1
1
$1
2
$1
2
$2
32

RESP3中新的类型

目前在Redis6.0.X版本中,仍然是默认使用的RESP V2协议,并且在兼容RESP V2的基础上, 也同时也支持开启RESP3。估计在未来的某个版本,Redis可能会全面切换到RESP3, 不过这么做的话对目前的Redis客户端连接工具会有不小的冲击,都需要根据协议内容进行底层通信的改造。

在使用telnet连接到redis服务后,先输入下面的命令来切换到RESP3版本的协议, 至于hello命令的具体返回数据以及数据表示的意义,这里暂且略过,后面会具体来看。

下面我们就来详细看看在RESP3中,除了保留上面的5种旧回复类型外,新增的13种通信返回数据类型, 部分数据类型会配合示例进行演示。为了看起来更加简洁,下面的演示例子发送命令均使用原始命令, 不再转化为协议格式,并且省略数据返回结果中每行结束的\r\n!

Null

新协议中使用下划线字符后跟CR和LF字符来表示空值,也就是用_\r\n来替代原先的单个空值的返回$-1。 例如在使用get命令查找一个不存在的key时,get keyNotExist,不同版本的回包协议返回如下:

  • RESP V2 返回 $-1
  • RESP3 返回 _\r\n

Double

浮点数返回时以逗号开头,格式为 ,\r\n,使用zset score key member获取分数的命令来进行测试。 如执行 zscore fruit apple,返回如下:

  • RESP V2返回时使用的是Bulk String的格式:
1
2
$18
5.6600000000000001
  • RESP3返回格式:,5.6600000000000001\r\n

Boolean

布尔类型的数据返回值,其中true被表示为#t\r\n,而false被表示为#f\r\n。

Blob error

与字符串类型比较相似,它的格式为!\r\n\r\n,但是与简单错误类型一样, 开头使用!表示返回的是一段错误信息描述。例如错误SYNTAX invalid syntax会按照下面的格式返回:

1
2
!21
SYNTAX invalid syntax

Verbatim string

Verbatim string也表示一个字符串格式,与Blob String非常相似,但是使用=开头替换了$, 另外之后的三个字节提供了有关字符串格式的信息,例如txt表示纯文本,mkd表示markdown格式, 第四个字节则固定为 :。这种格式适用于在没有任何转义或过滤的情况下显示给用户。

使用延时事件统计与分析指令进行测试,发送:latency doctor

  • RESP2返回的数据还是Blob String格式:
1
2
$196
Dave, no latency spike was observed during the lifetime of this Redis instance, not in the slightest bit. I honestly think you ought to sit down calmly, take a stress pill, and think things over.
  • RESP V3返回的数据采用了新的格式:
1
2
=200
txt:Dave, no latency spike was observed during the lifetime of this Redis instance, not in the slightest bit. I honestly think you ought to sit down calmly, take a stress pill, and think things over.

Big number

Big number类型用于返回非常大的整数数字,可以表示在有符号64位数字范围内的整数,包括正数或负数, 但是需要注意不能含有小数部分。数据格式为(<big number>\r\n,以左括号开头,示例如下:

1
(3492890328409238509324850943850943825024385

注意,当Big number不可用时,客户端会返回一个字符串格式的数据。

Aggregate data types

与前面我们介绍的给定数据类型的单个值不同,Aggregate data types可以理解为聚合数据类型。 这也是RESP3的一个核心思想,要能够从协议和类型的角度,来描述不同语义的聚合数据类型。

聚合数据类型的格式如下,通常由聚合类型、元素个数以及具体的单一元素构成:

1
2
<aggregate-type-char><numelements><CR><LF>
... numelements other types ...

例如一个包含三个数字的数组[1,2,3]可以表示为:

1
2
3
4
*3
:1
:2
:3

当然聚合数据类型中的元素可以是其他聚合数据类型,例如在数组中也可以嵌套包含其他数组(下面的内容包含了缩进方便理解):

1
2
3
4
5
6
7
*2
    *3
        :1
        $5
        hello
        :2
    #f

上面的聚合数据类型所表示的数据为[[1,”hello”,2],false]。

Map

Map数据类型与数组比较类似,但是以%作为起始,后面是Map中键值对的数量,而不再是单个数据项的数量。 它的数据内容是一个有序的键值对的数组,之后分行显示键值对的key和value,因此后面的数据行一定是偶数行。 先看一下官方文档给出的例子,以下面的Json字符串为例:

1
2
3
4
{
    "first":1,
    "second":2
}

转换为Map类型后格式为下面的形式:

1
2
3
4
5
%2
+first
:1
+second
:2

但是通过实验,Hydra发现了点有意思的东西,当我们发送一条hgetall的命令来请求哈希类型的数据时, 如 hgetall user

  • RESP V2返回的数据仍然使用老的Array格式,符合我们的预期:
1
2
3
4
5
6
7
8
9
*4
$4
name
$5
Hydra
$3
age
$2
18
  • 但是下面RESP3的数据返回却出乎我们的意料,可以看到虽然前面的%2表示使用了Map格式, 但是后面并没有遵循官方文档给出的规范,除了开头的%2以外,其余部分与Array完全相同()。
1
2
3
4
5
6
7
8
9
%2
$4
name
$5
Hydra
$3
age
$2
18

关于实际传输数据与文档中给出示例的出入,Hydra有一点自己的猜测,放在最后总结部分。

Set

Set与Array类型非常相似,但是它的第一个字节使用~替代了*,它是一个无序的数据集合。 还是先看一下官方文档中给出的示例,下面是一个包含了5个元素的集合类型数据,并且其中具体的数据类型可以不同:

1
2
3
4
5
6
~5<CR><LF>
+orange<CR><LF>
+apple<CR><LF>
#t<CR><LF>
:100<CR><LF>
:999<CR><LF>

下面使用SMEMBERS命令获取集合中的所有元素进行测试,如 MEMBERS myset

  • RESP V2返回时仍然使用Array格式:
1
2
3
4
5
6
7
*3
$1
a
$1
c
$1
b
  • RESP3的数据返回情况和Map比较类似,使用~开头,但是没有完全遵从协议中的格式:
1
2
3
4
5
6
7
~3
$1
a
$1
c
$1
b

Attribute

Attribute类型与Map类型非常相似,但是头一个字节使用|来代替了%, Attribute描述的数据内容比较像Map中的字典映射。客户端不应该将这个字典内容看做数据回复的一部分, 而是当做增强回复内容的辅助数据。

在文档中提到,在未来某个版本的Redis中可能会出现这样一个功能,每次执行指令时都会打印访问的key的请求频率, 这个值可能使用一个浮点数表示,那么在执行MGET a b时就可能会收到回复:

1
2
3
4
5
6
7
8
9
10
11
12
|1
    +key-popularity
    %2
        $1
        a
        ,0.1923
        $1
        b
        ,0.0012
*2
    :2039123
    :9543892

在上面的数据回复中,实际中回复的数据应该是[2039123,9543892],但是在前面附加了它们请求的属性, 当读到这个Attribute类型数据后,应当继续读取后面的实际数据。

Push

Push数据类型是一种服务器向客户端发送的异步数据,它的格式与Array类型比较类似,但是以>开头, 接下来的数组中的第一个数据为字符串类型,表示服务器发送给客户端的推送数据是何种类型。 数组中其他的数据也都包含自己的类型,需要按照协议中类型规范进行解析。

简单看一下文档中给出的示例,在执行get key命令后,可能会得到两个有效回复:

1
2
3
4
5
6
7
>4
+pubsub
+message
+somechannel
+this is the message
$9
Get-Reply

在上面的这段回复中需要注意,收到的两个回复中第一个是推送数据的类型,第二个才是真正回复的数据内容。 注意!这里在文档中有一句提示:虽然下面的演示使用的是Simple string格式, 但是在实际数据传输中使用的是Blob string格式。所以盲猜一波,上面的Map和Set也是同样的情况?

这里先简单铺垫一下Push回复类型在redis6中非常重要的一个使用场景客户端缓存client-side caching, 它允许将数据存储在本地应用中,当访问时不再需要访问redis服务端, 但是其他客户端修改数据时需要通知当前客户端作废掉本地应用的客户端缓存,这时候就会用到Push类型的消息。

1
2
client tracking on
get key1

在客户端B中执行: set key1 newValue

这时就会在客户端A中收到Push类型的消息,通知客户端缓存失效。在下面收到的消息中就包含了两部分, 第一部分表示收到的消息类型为invalidate,第二部分则是需要作废的缓存key1:

1
2
3
4
5
6
>2
$10
invalidate
*1
$4
key1

Stream

在前面介绍的类型中,返回的数据字符串一般都具有指定的长度,例如下面这样:

1
2
$1234<CR><LF>
.... 1234 bytes of data here ...<CR><LF>

但是有时候需要将一段不知道长度的字符串数据从客户端传给服务器(或者反向传输)时, 很明显这种格式无法使用,因此需要一种新的格式用来传送不确定长度的数据。

文档中提到,过去在服务端有一个私有扩展的数据格式,规范如下:

1
2
3
$EOF:<40 bytes marker><CR><LF>
... any number of bytes of data here not containing the marker ...
<40 bytes marker>

它以$EOF:作为起始字节,然后是40字节的marker标识符,在\r\n后跟随的是真正的数据,结束后也是40字节的标识符。 标识符以伪随机的方式生成,基本上不会与正常的数据发生冲突。

但是这种格式存在一定的局限性,主要问题就在于生成标识符以及解析标识符上, 由于一些原因使得上面这种格式在实际使用中非常脆弱。因此最终在规范中提出了一种分块编码格式, 举一个简单的例子,当需要发送事先不知道长度的字符串Hello world时:

1
2
3
4
5
6
7
8
$?
;4
Hell
;5
o wor
;2
ld
;0

这种格式以$?开头,表示是一个不知道长度的分块编码格式,后面传输的数据数量没有限制, 在最后以零长度的;0作为结束传输的标识。文档中提到,目前还没有命令会以这个格式来进行数据回复, 但是会在后面的功能模块中实装这个协议。

HELLO

在介绍RESP3的最开始,我们就在telnet中通过hello 3的命令来切换协议到V3版本。这个特殊的命令完成了两件事:

  • 它允许服务器与RESP V2版本向后兼容,也方便以后更加轻松的切换到RESP3
  • hello命令可以返回有关服务器和协议的信息,以供客户端使用

hello命令的格式如下,可以看到除了协议版本号外,还可以指定用户名和密码:

1
HELLO <protocol-version> [AUTH <username> <password>]

hello命令的返回结果是前面介绍过的Map类型,仅仅在客户端和服务器建立连接的时候发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
%7
$6
server
$5
redis
$7
version
$6
6.0.16
$5
proto
:3
$2
id
:18
$4
mode
$10
standalone
$4
role
$6
master
$7
modules
*0

转换为我们可读的Map格式后,可以看到它返回的Redis服务端的一些信息:

1
2
3
4
5
6
7
8
9
{
    "server":"redis",
    "version":"6.0.16",
    "proto":3,
    "id":18,
    "mode":"standalone",
    "role":"master",
    "modules":[]
}

参考文档

gossip

Cluster中的每个节点都维护一份在自己看来当前整个集群的状态,主要包括:

  • 当前集群状态
  • 集群中各节点所负责的slots信息,及其migrate状态
  • 集群中各节点的master-slave状态
  • 集群中各节点的存活状态及不可达投票

redis集群的哈希槽算法解决的是数据的存取问题,不同的哈希槽位于不同的节点上,而不同的节点维护着一份它所认为的当前集群的状态, 同时,Redis集群是去中心化的架构。那么,当集群的状态发生变化时,比如新节点加入、slot迁移、节点宕机、slave提升为新Master等等, 我们希望这些变化尽快被其他节点发现,Redis是如何进行处理的呢?也就是说,Redis不同节点之间是如何进行通信进行维护集群的同步状态呢?

在Redis集群中,不同的节点之间采用gossip协议进行通信,节点之间通讯的目的是为了维护节点之间的元数据信息。 这些元数据就是每个节点包含哪些数据,是否出现故障,通过gossip协议,达到最终数据的一致性。

gossip协议,是基于流行病传播方式的节点或者进程之间信息交换的协议。原理就是在不同的节点间不断地通信交换信息, 一段时间后,所有的节点就都有了整个集群的完整信息,并且所有节点的状态都会达成一致。每个节点可能知道所有其他节点, 也可能仅知道几个邻居节点,但只要这些节可以通过网络连通,最终他们的状态就会是一致的。 Gossip协议最大的好处在于,即使集群节点的数量增加,每个节点的负载也不会增加很多,几乎是恒定的。

Redis集群中节点的通信过程如下:

  • 集群中每个节点都会单独开一个TCP通道,用于节点间彼此通信。
  • 每个节点在固定周期内通过待定的规则选择几个节点发送ping消息
  • 接收到ping消息的节点用pong消息作为响应

使用gossip协议的优点在于将元数据的更新分散在不同的节点上面,降低了压力;但是缺点就是元数据的更新有延时, 可能导致集群中的一些操作会有一些滞后。另外,由于 gossip 协议对服务器时间的要求较高, 时间戳不准确会影响节点判断消息的有效性。而且节点数量增多后的网络开销也会对服务器产生压力, 同时结点数太多,意味着达到最终一致性的时间也相对变长,因此官方推荐最大节点数为1000左右。

redis cluster架构下的每个redis都要开放两个端口号,比如一个是6379,另一个就是加1w的端口号16379。

  • 6379端口号就是redis服务器入口。
  • 16379端口号是用来进行节点间通信的,也就是 cluster bus 的东西,cluster bus 的通信,用来进行故障检测、配置更新、故障转移授权。 cluster bus 用的是一种叫gossip 协议的二进制协议

gossip协议常见的消息类型包含: ping、pong、meet、fail等等。

  • meet:主要用于通知新节点加入到集群中,通过「cluster meet ip port」命令,已有集群的节点会向新的节点发送邀请,加入现有集群。
  • ping:用于交换节点的元数据。每个节点每秒会向集群中其他节点发送 ping 消息,消息中封装了自身节点状态还有其他部分节点的状态数据,也包括自身所管理的槽信息等等。
    • 因为发送ping命令时要携带一些元数据,如果很频繁,可能会加重网络负担。因此,一般每个节点每秒会执行 10 次 ping,每次会选择 5 个最久没有通信的其它节点。
    • 如果发现某个节点通信延时达到了 cluster_node_timeout / 2,那么立即发送 ping,避免数据交换延时过长导致信息严重滞后。比如说, 两个节点之间都 10 分钟没有交换数据了,那么整个集群处于严重的元数据不一致的情况,就会有问题。所以 cluster_node_timeout 可以调节,如果调得比较大,那么会降低 ping 的频率。
    • 每次 ping,会带上自己节点的信息,还有就是带上 1/10 其它节点的信息,发送出去,进行交换。至少包含 3 个其它节点的信息,最多包含 (总节点数 - 2)个其它节点的信息。
  • pong:ping和meet消息的响应,同样包含了自身节点的状态和集群元数据信息。
  • fail:某个节点判断另一个节点 fail 之后,向集群所有节点广播该节点挂掉的消息,其他节点收到消息后标记已下线。

由于Redis集群的去中心化以及gossip通信机制,Redis集群中的节点只能保证最终一致性。例如当加入新节点时(meet), 只有邀请节点和被邀请节点知道这件事,其余节点要等待 ping 消息一层一层扩散。除了 Fail 是立即全网通知的, 其他诸如新节点、节点重上线、从节点选举成为主节点、槽变化等,都需要等待被通知到,也就是Gossip协议是最终一致性的协议。

redis 集群

集群部署方案

在开发测试环境中,我们一般搭建Redis的单实例来应对开发测试需求,但是在生产环境, 如果对可用性、可靠性要求较高,则需要引入Redis的集群方案。虽然现在各大云平台有提供缓存服务可以直接使用, 但了解一下其背后的实现与原理总还是有些必要(比如面试), 本文就一起来学习一下Redis的几种集群方案

主从复制模式

原理:主从复制模式中包含一个主数据库实例(master)与一个或多个从数据库实例(slave),如下图
主从复制

客户端可对主数据库进行读写操作,对从数据库进行读操作,主数据库写入的数据会实时自动同步给从数据库。 具体工作机制为:

  • slave启动后,向master发送SYNC命令,master接收到SYNC命令后通过bgsave保存快照(即上文所介绍的RDB持久化), 并使用缓冲区记录保存快照这段时间内执行的写命令
  • master将保存的快照文件发送给slave,并继续记录执行的写命令
  • slave接收到快照文件后,加载快照文件,载入数据
  • master快照发送完后开始向slave发送缓冲区的写命令,slave接收命令并执行,完成复制初始化
  • 此后master每次执行一个写命令都会同步发送给slave,保持master与slave之间数据的一致性

本示例基于Redis 5.0.3版。redis.conf的主要配置,部署示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
###网络相关###
# bind 127.0.0.1 # 绑定监听的网卡IP,注释掉或配置成0.0.0.0可使任意IP均可访问
protected-mode no # 关闭保护模式,使用密码访问
port 6379  # 设置监听端口,建议生产环境均使用自定义端口
timeout 30 # 客户端连接空闲多久后断开连接,单位秒,0表示禁用
###通用配置###
daemonize yes # 在后台运行
pidfile /var/run/redis_6379.pid  # pid进程文件名
logfile /usr/local/redis/logs/redis.log # 日志文件的位置
###RDB持久化配置###
save 900 1 # 900s内至少一次写操作则执行bgsave进行RDB持久化
save 300 10
save 60 10000
# 如果禁用RDB持久化,可在这里添加 save ""
rdbcompression yes #是否对RDB文件进行压缩,建议设置为no,以(磁盘)空间换(CPU)时间
dbfilename dump.rdb # RDB文件名称
dir /usr/local/redis/datas # RDB文件保存路径,AOF文件也保存在这里
###AOF配置###
appendonly yes # 默认值是no,表示不使用AOF增量持久化的方式,使用RDB全量持久化的方式
appendfsync everysec # 可选值 always, everysec,no,建议设置为everysec
###设置密码###
requirepass 123456 # 设置复杂一点的密码

部署主从复制模式只需稍微调整slave的配置,在redis.conf中添加

1
2
3
replicaof 127.0.0.1 6379 # master的ip,port
masterauth 123456 # master的密码
replica-serve-stale-data no # 如果slave无法与master同步,设置成slave不可读,方便监控脚本发现问题

本示例在单台服务器上配置master端口6379,两个slave端口分别为7001,7002,启动master,再启动两个slave

1
2
3
[root@dev-server-1 master-slave]# redis-server master.conf
[root@dev-server-1 master-slave]# redis-server slave1.conf
[root@dev-server-1 master-slave]# redis-server slave2.conf

进入master数据库,写入一个数据,再进入一个slave数据库,立即便可访问刚才写入master数据库的数据。如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[root@dev-server-1 master-slave]# redis-cli
127.0.0.1:6379> auth 123456
OK
127.0.0.1:6379> set site blog.jboost.cn
OK
127.0.0.1:6379> get site
"blog.jboost.cn"
127.0.0.1:6379> info replication
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=7001,state=online,offset=13364738,lag=1
slave1:ip=127.0.0.1,port=7002,state=online,offset=13364738,lag=0
...
127.0.0.1:6379> exit
[root@dev-server-1 master-slave]# redis-cli -p 7001
127.0.0.1:7001> auth 123456
OK
127.0.0.1:7001> get site
"blog.jboost.cn"

执行info replication命令可以查看连接该数据库的其它库的信息,如上可看到有两个slave连接到master

主从复制的优缺点:

  • 优点:
    • master能自动将数据同步到slave,可以进行读写分离,分担master的读压力
    • master、slave之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以提交查询或更新请求
  • 缺点:
    • 不具备自动容错与恢复功能,master或slave的宕机都可能导致客户端请求失败,需要等待机器重启或手动切换客户端IP才能恢复
    • master宕机,如果宕机前数据没有同步完,则切换IP后会存在数据不一致的问题
    • 难以支持在线扩容,Redis的容量受限于单机配置

Sentinel(哨兵)模式

哨兵模式基于主从复制模式,只是引入了哨兵来监控与自动处理故障。如图
哨兵集群

哨兵顾名思义,就是来为Redis集群站哨的,一旦发现问题能做出相应的应对处理。其功能包括

  • 监控master、slave是否正常运行
  • 当master出现故障时,能自动将一个slave转换为master(大哥挂了,选一个小弟上位)
  • 多个哨兵可以监控同一个Redis,哨兵之间也会自动监控

在配置文件中通过 sentinel monitor 来定位master的IP、端口, 一个哨兵可以监控多个master数据库,只需要提供多个该配置项即可。 哨兵启动后,会与要监控的master建立两条连接:

  • 一条连接用来订阅master的_sentinel_:hello频道与获取其他监控该master的哨兵节点信息
  • 另一条连接定期向master发送INFO等命令获取master本身的信息

与master建立连接后,哨兵会执行三个操作:

  • 定期(一般10s一次,当master被标记为主观下线时,改为1s一次)向master和slave发送INFO命令
  • 定期向master和slave的_sentinel_:hello频道发送自己的信息
  • 定期(1s一次)向master、slave和其他哨兵发送PING命令

发送INFO命令可以获取当前数据库的相关信息从而实现新节点的自动发现。 所以说哨兵只需要配置master数据库信息就可以自动发现其slave信息。 获取到slave信息后,哨兵也会与slave建立两条连接执行监控。 通过INFO命令,哨兵可以获取主从数据库的最新信息,并进行相应的操作,比如角色变更等。

接下来哨兵向主从数据库的_sentinel_:hello频道发送信息与同样监控这些数据库的哨兵共享自己的信息, 发送内容为哨兵的ip端口、运行id、配置版本、master名字、master的ip端口还有master的配置版本。 这些信息有以下用处:

  • 其他哨兵可以通过该信息判断发送者是否是新发现的哨兵,如果是的话会创建一个到该哨兵的连接用于发送PING命令。
  • 其他哨兵通过该信息可以判断master的版本,如果该版本高于直接记录的版本,将会更新
  • 当实现了自动发现slave和其他哨兵节点后,哨兵就可以通过定期发送PING命令定时监控这些数据库和节点有没有停止服务。

如果被PING的数据库或者节点超时(通过 sentinel down-after-milliseconds master-name milliseconds 配置)未回复, 哨兵认为其主观下线(sdown,s就是Subjectively —— 主观地)。 如果下线的是master,哨兵会向其它哨兵发送命令询问它们是否也认为该master主观下线, 如果达到一定数目(即配置文件中的quorum)投票, 哨兵会认为该master已经客观下线(odown,o就是Objectively —— 客观地), 并选举领头的哨兵节点对主从系统发起故障恢复。 若没有足够的sentinel进程同意master下线,master的客观下线状态会被移除, 若master重新向sentinel进程发送的PING命令返回有效回复,master的主观下线状态就会被移除

哨兵认为master客观下线后,故障恢复的操作需要由选举的领头哨兵来执行,选举采用Raft算法:

  • 发现master下线的哨兵节点(我们称他为A)向每个哨兵发送命令,要求对方选自己为领头哨兵
  • 如果目标哨兵节点没有选过其他人,则会同意选举A为领头哨兵
  • 如果有超过一半的哨兵同意选举A为领头,则A当选
  • 如果有多个哨兵节点同时参选领头,此时有可能存在一轮投票无竞选者胜出, 此时每个参选的节点等待一个随机时间后再次发起参选请求,进行下一轮投票竞选,直至选举出领头哨兵

选出领头哨兵后,领头者开始对系统进行故障恢复, 从出现故障的master的从数据库中挑选一个来当选新的master,选择规则如下:

  • 所有在线的slave中选择优先级最高的,优先级可以通过slave-priority配置
  • 如果有多个最高优先级的slave,则选取复制偏移量最大(即复制越完整)的当选
  • 如果以上条件都一样,选取id最小的slave

挑选出需要继任的slave后,领头哨兵向该数据库发送命令使其升格为master, 然后再向其他slave发送命令接受新的master,最后更新数据。 将已经停止的旧的master更新为新的master的从数据库,使其恢复服务后以slave的身份继续运行。

哨兵模式基于前文的主从复制模式。哨兵的配置文件为sentinel.conf,在文件中添加

1
2
3
4
5
sentinel monitor mymaster 127.0.0.1 6379 1 # mymaster定义一个master数据库的名称,后面是master的ip, port,1表示至少需要一个Sentinel进程同意才能将master判断为失效,如果不满足这个条件,则自动故障转移(failover)不会执行
sentinel auth-pass mymaster 123456 # master的密码
sentinel down-after-milliseconds mymaster 5000 # 5s未回复PING,则认为master主观下线,默认为30s
sentinel parallel-syncs mymaster 2  # 指定在执行故障转移时,最多可以有多少个slave实例在同步新的master实例,在slave实例较多的情况下这个数字越小,同步的时间越长,完成故障转移所需的时间就越长
sentinel failover-timeout mymaster 300000 # 如果在该时间(ms)内未能完成故障转移操作,则认为故障转移失败,生产环境需要根据数据量设置该值

一个哨兵可以监控多个master数据库,只需按上述配置添加多套。 分别以26379,36379,46379端口启动三个sentinel

1
2
3
[root@dev-server-1 sentinel]# redis-server sentinel1.conf --sentinel
[root@dev-server-1 sentinel]# redis-server sentinel2.conf --sentinel
[root@dev-server-1 sentinel]# redis-server sentinel3.conf --sentinel

也可以使用redis-sentinel sentinel1.conf 命令启动。 此时集群包含一个master、两个slave、三个sentinel,如图,
哨兵模式

我们来模拟master挂掉的场景,执行 kill -9 3017 将master进程干掉, 进入slave中执行 info replication查看,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[root@dev-server-1 sentinel]# redis-cli -p 7001
127.0.0.1:7001> auth 123456
OK
127.0.0.1:7001> info replication
# Replication
role:slave
master_host:127.0.0.1
master_port:7002
master_link_status:up
master_last_io_seconds_ago:1
master_sync_in_progress:0
# 省略
127.0.0.1:7001> exit
[root@dev-server-1 sentinel]# redis-cli -p 7002
127.0.0.1:7002> auth 123456
OK
127.0.0.1:7002> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=7001,state=online,offset=13642721,lag=1
# 省略

可以看到slave 7002已经成功上位晋升为master(role:master),接收一个slave 7001的连接。 此时查看slave2.conf配置文件,发现replicaof的配置已经被移除了, slave1.conf的配置文件里replicaof 127.0.0.1 6379 被改为 replicaof 127.0.0.1 7002。 重新启动master,也可以看到master.conf配置文件中添加了replicaof 127.0.0.1 7002的配置项, 可见大哥(master)下位后,再出来混就只能当当小弟(slave)了,三十年河东三十年河西。

哨兵模式的优缺点:

  • 优点:
    • 哨兵模式基于主从复制模式,所以主从复制模式有的优点,哨兵模式也有
    • 哨兵模式下,master挂掉可以自动进行切换,系统可用性更高
  • 缺点:
    • 同样也继承了主从模式难以在线扩容的缺点,Redis的容量受限于单机配置
    • 需要额外的资源来启动sentinel进程,实现相对复杂一点,同时slave节点作为备份节点不提供服务

Cluster模式

哨兵模式解决了主从复制不能自动故障转移,达不到高可用的问题,但还是存在难以在线扩容,Redis容量受限于单机配置的问题。 Cluster模式实现了Redis的分布式存储,即每台节点存储不同的内容,来解决在线扩容的问题。如图
redis集群 redis集群

Cluster采用无中心结构,它的特点如下:

  • 所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽
  • 节点的fail是通过集群中超过半数的节点检测失效时才生效
  • 客户端与redis节点直连,不需要中间代理层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可

Cluster模式的具体工作机制:

  • 在Redis的每个节点上,都有一个插槽(slot),取值范围为0-16383
  • 当我们存取key的时候,Redis会根据CRC16的算法得出一个结果,然后把结果对16384求余数, 这样每个key都会对应一个编号在0-16383之间的哈希槽,通过这个值,去找到对应的插槽所对应的节点, 然后直接自动跳转到这个对应的节点上进行存取操作
  • 为了保证高可用,Cluster模式也引入主从复制模式,一个主节点对应一个或者多个从节点, 当主节点宕机的时候,就会启用从节点
  • 当其它主节点ping一个主节点A时,如果半数以上的主节点与A通信超时,那么认为主节点A宕机了。 如果主节点A和它的从节点都宕机了,那么该集群就无法再提供服务了

Cluster模式集群节点最小配置6个节点(3主3从,因为需要半数以上),其中主节点提供读写操作, 从节点作为备用节点,不提供请求,只作为故障转移使用。

本示例基于Redis 5.0.3版。部署步骤如下所述。 Cluster模式的部署比较简单,首先在redis.conf中

1
2
3
4
5
6
7
port 7100 # 本示例6个节点端口分别为7100,7200,7300,7400,7500,7600
daemonize yes # r后台运行
pidfile /var/run/redis_7100.pid # pidfile文件对应7100,7200,7300,7400,7500,7600
cluster-enabled yes # 开启集群模式
masterauth passw0rd # 如果设置了密码,需要指定master密码
cluster-config-file nodes_7100.conf # 集群的配置文件,同样对应7100,7200等六个节点
cluster-node-timeout 15000 # 请求超时 默认15秒,可自行设置

分别以端口7100,7200,7300,7400,7500,7600 启动六个实例(如果是每个服务器一个实例则配置可一样)

1
2
3
[root@dev-server-1 cluster]# redis-server redis_7100.conf
[root@dev-server-1 cluster]# redis-server redis_7200.conf
...

然后通过命令将这个6个实例组成一个3主节点3从节点的集群,

1
redis-cli --cluster create --cluster-replicas 1 127.0.0.1:7100 127.0.0.1:7200 127.0.0.1:7300 127.0.0.1:7400 127.0.0.1:7500 127.0.0.1:7600 -a passw0rd

执行结果如图
redis集群

可以看到 7100, 7200, 7300 作为3个主节点, 分配的slot分别为 0-5460,5461-10922,10923-16383,7600作为7100的slave,7500作为7300的slave, 7400作为7200的slave。 我们连接7100设置一个值

1
2
3
4
5
6
7
8
[root@dev-server-1 cluster]# redis-cli -p 7100 -c -a passw0rd
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
127.0.0.1:7100> set site blog.jboost.cn
-> Redirected to slot [9421] located at 127.0.0.1:7200
OK
127.0.0.1:7200> get site
"blog.jboost.cn"
127.0.0.1:7200>

注意添加 -c 参数表示以集群模式,否则报 (error) MOVED 9421 127.0.0.1:7200 错误, 以 -a 参数指定密码,否则报(error) NOAUTH Authentication required错误。

从上面命令看到key为site算出的slot为9421,落在7200节点上, 所以有Redirected to slot [9421] located at 127.0.0.1:7200,集群会自动进行跳转。 因此客户端可以连接任何一个节点来进行数据的存取。

通过cluster nodes可查看集群的节点信息

1
2
3
4
5
6
7
127.0.0.1:7200> cluster nodes
eb28aaf090ed1b6b05033335e3d90a202b422d6c 127.0.0.1:7500@17500 slave c1047de2a1b5d5fa4666d554376ca8960895a955 0 1584165266071 5 connected
4cc0463878ae00e5dcf0b36c4345182e021932bc 127.0.0.1:7400@17400 slave 5544aa5ff20f14c4c3665476de6e537d76316b4a 0 1584165267074 4 connected
dbbb6420d64db22f35a9b6fa460b0878c172a2fb 127.0.0.1:7100@17100 master - 0 1584165266000 1 connected 0-5460
d4b434f5829e73e7e779147e905eea6247ffa5a2 127.0.0.1:7600@17600 slave dbbb6420d64db22f35a9b6fa460b0878c172a2fb 0 1584165265000 6 connected
5544aa5ff20f14c4c3665476de6e537d76316b4a 127.0.0.1:7200@17200 myself,master - 0 1584165267000 2 connected 5461-10922
c1047de2a1b5d5fa4666d554376ca8960895a955 127.0.0.1:7300@17300 master - 0 1584165268076 3 connected 10923-16383

我们将7200通过 kill -9 pid杀死进程来验证集群的高可用, 重新进入集群执行cluster nodes可以看到7200 fail了,但是7400成了master,重新启动7200, 可以看到此时7200已经变成了slave。

Cluster模式的优缺点:

  • 优点:
    • 无中心架构,数据按照slot分布在多个节点。
    • 集群中的每个节点都是平等的关系,每个节点都保存各自的数据和整个集群的状态。每个节点都和其他所有节点连接, 而且这些连接保持活跃,这样就保证了我们只需要连接集群中的任意一个节点,就可以获取到其他节点的数据。
    • 可线性扩展到1000多个节点,节点可动态添加或删除
    • 能够实现自动故障转移,节点之间通过gossip协议交换状态信息,用投票机制完成slave到master的角色转换
  • 缺点:
    • 客户端实现复杂,驱动要求实现Smart Client,缓存slots mapping信息并及时更新,提高了开发难度。目前仅JedisCluster相对成熟,异常处理还不完善,比如常见的“max redirect exception”
    • 节点会因为某些原因发生阻塞(阻塞时间大于 cluster-node-timeout)被判断下线,这种failover是没有必要的
    • 数据通过异步复制,不保证数据的强一致性
    • slave充当“冷备”,不能缓解读压力
    • 批量操作限制,目前只支持具有相同slot值的key执行批量操作,对mset、mget、sunion等操作支持不友好
    • key事务操作支持有线,只支持多key在同一节点的事务操作,多key分布不同节点时无法使用事务功能
    • 不支持多数据库空间,单机redis可以支持16个db,集群模式下只能使用一个,即db 0

Redis Cluster模式不建议使用pipeline和multi-keys操作,减少max redirect产生的场景。

Redis集群的数据分布算法:哈希槽算法

Redis集群通过分布式存储的方式解决了单节点的海量数据存储的问题,对于分布式存储, 需要考虑的重点就是如何将数据进行拆分到不同的Redis服务器上。 常见的分区算法有hash算法、一致性hash算法

  • 普通hash算法:将key使用hash算法计算之后,按照节点数量来取余,即hash(key)%N。 优点就是比较简单,但是扩容或者摘除节点时需要重新根据映射关系计算,会导致数据重新迁移。
  • 一致性hash算法:为每一个节点分配一个token,构成一个哈希环; 查找时先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。 优点是在加入和删除节点时只影响相邻的两个节点,缺点是加减节点会造成部分数据无法命中, 所以一般用于缓存,而且用于节点量大的情况下,扩容一般增加一倍节点保障数据负载均衡。

Redis集群采用的算法是哈希槽分区算法。 Redis集群中有16384个哈希槽(槽的范围是 0 -16383,哈希槽), 将不同的哈希槽分布在不同的Redis节点上面进行管理,也就是说每个Redis节点只负责一部分的哈希槽。 在对数据进行操作的时候,集群会对使用CRC16算法对key进行计算并对16384取模(slot = CRC16(key)%16383), 得到的结果就是 Key-Value 所放入的槽,通过这个值,去找到对应的槽所对应的Redis节点, 然后直接到这个对应的节点上进行存取操作。

使用哈希槽的好处就在于可以方便的添加或者移除节点,并且无论是添加删除或者修改某一个节点, 都不会造成集群不可用的状态。当需要增加节点时,只需要把其他节点的某些哈希槽挪到新节点就可以了; 当需要移除节点时,只需要把移除节点上的哈希槽挪到其他节点就行了; 哈希槽数据分区算法具有以下几种特点:

  • 解耦数据和节点之间的关系,简化了扩容和收缩难度;
  • 节点自身维护槽的映射关系,不需要客户端代理服务维护槽分区元数据
  • 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景

槽的迁移与指派命令:CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000。 默认情况下,redis集群的读和写都是到master上去执行的,不支持slave节点读和写, 跟Redis主从复制下读写分离不一样,因为redis集群的核心的理念,主要是使用slave做数据的热备, 以及master故障时的主备切换,实现高可用的。Redis的读写分离, 是为了横向任意扩展slave节点去支撑更大的读吞吐量。而redis集群架构下, 本身master就是可以任意扩展的,如果想要支撑更大的读或写的吞吐量, 都可以直接对master进行横向扩展。

代码实现中,clusterNode数据结构的slots属性和numslot属性记录了节点负责处理那些槽: slots属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节, 共包含16384个二进制位。Master节点用bit来标识对于某个槽自己是否拥有,时间复杂度为O(1)。

当收到集群中其他节点发送的信息时,通过将节点槽的指派信息保存在本地的clusterState.slots数组里面, 程序要检查槽i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可, 时间复杂度仅为O(1)。
哈希结构 哈希结构

集群的请求重定向

Redis集群在客户端层面没有采用代理,并且无论Redis 的客户端访问集群中的哪个节点都可以路由到对应的节点上, 下面来看看 Redis 客户端是如何通过路由来调用缓存节点的:

  • MOVED请求
    哈希结构

如上图所示,Redis 客户端通过 CRC16(key)%16383 计算出 Slot 的值,发现需要找“缓存节点1”进行数据操作, 但是由于缓存数据迁移或者其他原因导致这个对应的 Slot 的数据被迁移到了“缓存节点2”上面。 那么这个时候 Redis 客户端就无法从“缓存节点1”中获取数据了。 但是由于“缓存节点1”中保存了所有集群中缓存节点的信息, 因此它知道这个 Slot 的数据在“缓存节点2”中保存, 因此向 Redis 客户端发送了一个 MOVED 的重定向请求。 这个请求告诉其应该访问的“缓存节点2”的地址。Redis 客户端拿到这个地址, 继续访问“缓存节点2”并且拿到数据。
哈希结构 哈希结构

  • ASK请求:
    哈希结构

上面的例子说明了,数据 Slot 从“缓存节点1”已经迁移到“缓存节点2”了, 那么客户端可以直接找“缓存节点2”要数据。那么如果两个缓存节点正在做节点的数据迁移, 此时客户端请求会如何处理呢?

Redis 客户端向“缓存节点1”发出请求,此时“缓存节点1”正向“缓存节点 2”迁移数据, 如果没有命中对应的 Slot,它会返回客户端一个 ASK 重定向请求并且告诉“缓存节点2”的地址。 客户端向“缓存节点2”发送 Asking 命令,询问需要的数据是否在“缓存节点2”上,“缓存节点2”接到消息以后返回数据是否存在的结果。

  • 频繁重定向造成的网络开销的处理:smart客户端

在大部分情况下,可能都会出现一次请求重定向才能找到正确的节点, 这个重定向过程显然会增加集群的网络负担和单次请求耗时。所以大部分的客户端都是smart的。 所谓 smart客户端,就是指客户端本地维护一份hashslot => node的映射表缓存, 大部分情况下,直接走本地缓存就可以找到hashslot => node,不需要通过节点进行moved重定向,

虽然ASK与MOVED都是对客户端的重定向控制,但是有本质区别。 ASK重定向说明集群正在进行slot数据迁移,客户端无法知道迁移什么时候完成, 因此只能是临时性的重定向,客户端不会更新slots缓存。 但是MOVED重定向说明键对应的槽已经明确指定到新的节点,客户端需要更新slots缓存。

集群扩容和缩容

作为分布式部署的缓存节点总会遇到缓存扩容和缓存故障的问题。这就会导致缓存节点的上线和下线的问题。 由于每个节点中保存着槽数据,因此当缓存节点数出现变动时,这些槽数据会根据对应的虚拟槽算法被迁移到其他的缓存节点上。 所以对于redis集群,集群伸缩主要在于槽和数据在节点之间移动。

扩容

  • (1)启动新节点
  • (2)使用cluster meet命令将新节点加入到集群
  • (3)迁移槽和数据:添加新节点后,需要将一些槽和数据从旧节点迁移到新节点
    哈希结构

如上图所示,集群中本来存在“缓存节点1”和“缓存节点2”,此时“缓存节点3”上线了并且加入到集群中。 此时根据虚拟槽的算法,“缓存节点1”和“缓存节点2”中对应槽的数据会应该新节点的加入被迁移到“缓存节点3”上面。

新节点加入到集群的时候,作为孤儿节点是没有和其他节点进行通讯的。 因此需要在集群中任意节点执行 cluster meet 命令让新节点加入进来。假设新节点是 192.168.1.1 5002,老节点是 192.168.1.1 5003, 那么运行以下命令将新节点加入到集群中。

1
192.168.1.1 5003> cluster meet 192.168.1.1 5002

这个是由老节点发起的,有点老成员欢迎新成员加入的意思。新节点刚刚建立没有建立槽对应的数据, 也就是说没有缓存任何数据。如果这个节点是主节点,需要对其进行槽数据的扩容; 如果这个节点是从节点,就需要同步主节点上的数据。总之就是要同步数据。
哈希结构

如上图所示,由客户端发起节点之间的槽数据迁移,数据从源节点往目标节点迁移。

  • (1)客户端对目标节点发起准备导入槽数据的命令,让目标节点准备好导入槽数据。使用命令:cluster setslot {slot} importing {sourceNodeId}
  • (2)之后对源节点发起送命令,让源节点准备迁出对应的槽数据。使用命令:cluster setslot {slot} migrating {targetNodeId}
  • (3)此时源节点准备迁移数据了,在迁移之前把要迁移的数据获取出来。通过命令 cluster getkeysinslot {slot} {count}。Count 表示迁移的 Slot 的个数。
  • (4)然后在源节点上执行,migrate {targetIP} {targetPort} “” 0 {timeout} keys {keys} 命令,把获取的键通过流水线批量迁移到目标节点。
  • (5)重复 3 和 4 两步不断将数据迁移到目标节点。
  • (6)完成数据迁移到目标节点以后,通过 cluster setslot {slot} node {targetNodeId} 命令通知对应的槽被分配到目标节点,并且广播这个信息给全网的其他主节点,更新自身的槽节点对应表。

缩容

  • 迁移槽。
  • 忘记节点。通过命令 cluster forget {downNodeId} 通知其他的节点
    哈希结构

为了安全删除节点,Redis集群只能下线没有负责槽的节点。因此如果要下线有负责槽的master节点,则需要先将它负责的槽迁移到其他节点。 迁移的过程也与上线操作类似,不同的是下线的时候需要通知全网的其他节点忘记自己,此时通过命令 cluster forget {downNodeId} 通知其他的节点。

集群的故障检测与故障转恢复机制

Redis集群的搭建可以分为以下几个部分:

  • 启动节点:将节点以集群模式启动,读取或者生成集群配置文件,此时节点是独立的。
  • 节点握手:节点通过gossip协议通信,将独立的节点连成网络,主要使用meet命令。
  • 槽指派:将16384个槽位分配给主节点,以达到分片保存数据库键值对的效果。

集群搭建好之后,其是如何保证可靠性的呢?

集群故障检测

Redis集群的故障检测是基于gossip协议的,集群中的每个节点都会定期地向集群中的其他节点发送PING消息, 以此交换各个节点状态信息,检测各个节点状态:在线状态、疑似下线状态PFAIL、已下线状态FAIL。

  • 主观下线(pfail):当节点A检测到与节点B的通讯时间超过了cluster-node-timeout 的时候,就会更新本地节点状态,把节点B更新为主观下线。 主观下线并不能代表某个节点真的下线了,有可能是节点A与节点B之间的网络断开了,但是其他的节点依旧可以和节点B进行通讯。
  • 客观下线:

由于集群内的节点会不断地与其他节点进行通讯,下线信息也会通过 Gossip 消息传遍所有节点,因此集群内的节点会不断收到下线报告。

当半数以上的主节点标记了节点B是主观下线时,便会触发客观下线的流程(该流程只针对主节点,如果是从节点就会忽略)。 将主观下线的报告保存到本地的 ClusterNode 的结构fail_reports链表中,并且对主观下线报告的时效性进行检查, 如果超过 cluster-node-timeout*2 的时间,就忽略这个报告,否则就记录报告内容,将其标记为客观下线。

接着向集群广播一条主节点B的Fail 消息,所有收到消息的节点都会标记节点B为客观下线。

集群的故障恢复

当故障节点下线后,如果是持有槽的主节点则需要在其从节点中找出一个替换它,从而保证高可用。此时下线主节点的所有从节点都担负着恢复义务, 这些从节点会定时监测主节点是否进入客观下线状态,如果是,则触发故障恢复流程。 故障恢复也就是选举一个节点充当新的master,选举的过程是基于Raft协议选举方式来实现的。

  • 从节点过滤:检查每个slave节点与master节点断开连接的时间,如果超过了cluster-node-timeout * cluster-slave-validity-factor,那么就没有资格切换成master
  • 投票选举
    • 节点排序:对通过过滤条件的所有从节点进行排序,按照priority、offset、run id排序,排序越靠前的节点,越优先进行选举。
      • priority的值越低,优先级越高
      • offset越大,表示从master节点复制的数据越多,选举时间越靠前,优先进行选举
      • 如果offset相同,run id越小,优先级越高
    • 更新配置纪元:每个主节点会去更新配置纪元(clusterNode.configEpoch),这个值是不断增加的整数。 这个值记录了每个节点的版本和整个集群的版本。 每当发生重要事情的时候(例如:出现新节点,从节点精选)都会增加全局的配置纪元并且赋给相关的主节点, 用来记录这个事件。更新这个值目的是,保证所有主节点对这件“大事”保持一致, 大家都统一成一个配置纪元,表示大家都知道这个“大事”了。
    • 选举投票:
      • 如果一个主节点具有投票权,并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息, 表示这个主节点支持从节点成为新的主节点。每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息, 并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
      • 如果超过(N/2 + 1)数量的master节点都投票给了某个从节点,那么选举通过,这个从节点可以切换成master, 如果在 cluster-node-timeout*2 的时间内从节点没有获得足够数量的票数,本次选举作废, 更新配置纪元,并进行第二轮选举,直到选出新的主节点为止。
      • 在节点排序环节领先的从节点通常会获得更多的票,因为它触发选举的时间更早一些,获得票的机会更大
  • 替换主节点:当满足投票条件的从节点被选出来以后,会触发替换主节点的操作。删除原主节点负责的槽数据, 把这些槽数据添加到自己节点上,并且广播让其他的节点都知道这件事情,新的主节点诞生了。
    • 被选中的从节点执行SLAVEOF NO ONE命令,使其成为新的主节点
    • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
    • 新的主节点对集群进行广播PONG消息,告知其他节点已经成为新的主节点
    • 新的主节点开始接收和处理槽相关的请求

如果集群中某个节点的master和slave节点都宕机了,那么集群就会进入fail状态,因为集群的slot映射不完整。 如果集群超过半数以上的master挂掉,无论是否有slave,集群都会进入fail状态。

集群的运维

集群什么时候不可用?

  • 无主从备份,某一节点宕机
  • 某一节点主从全部宕机
  • 超半数主节点宕机

为什么需要集群?

  • 并发量:通常来说,单台Redis能够执行10万/秒的命令,这个并发基本上能够满足我们所有需求了。 但有时候比如做离线计算,为了更快的得出结果,有时候我们希望超过这个并发,那这个时候单机就不满足我们需求了,就需要集群了。
  • 数据量:通常来说,单台服务器的内存大概在16G-256G之间,前面我们说Redis数据量都是存在内存中的, 那如果实际业务要保存在Redis的数据量超过了单台机器的内存,这个时候最简单的方法是增加服务器内存。 但是单台服务器内存不可能无限制的增加,纵向扩展不了了,便想到如何进行横向扩展。 这时候我们就会想将这些业务数据分散存储在多台Redis服务器中,但是要保证多台Redis服务器能够无障碍的进行内存数据沟通,这也就是Redis集群。

Redis cluster集群模式功能限制

  • key批量操作支持有限。如:MSET``MGET,目前只支持具有相同slot值的key执行批量操作。
  • key事务操作支持有限。支持多key在同一节点上的事务操作,不支持分布在多个节点的事务功能。
  • key作为数据分区的最小粒度,因此不能将一个大的键值对象映射到不同的节点。如:hash、list。
  • 不支持多数据库空间。单机下Redis支持16个数据库,集群模式下只能使用一个数据库空间,即db 0。
  • 复制结构只支持一层,不支持嵌套树状复制结构。
数据迁移问题

Redis集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。在扩缩容的时候,需要进行数据迁移。 而 Redis为了保证迁移的一致性,迁移所有操作都是同步操作,执行迁移时,两端的 Redis均会进入时长不等的阻塞状态,对于小Key, 该时间可以忽略不计,但如果一旦Key的内存使用过大,严重的时候会接触发集群内的故障转移,造成不必要的切换。

带宽消耗问题

Redis集群是无中心节点的集群架构,依靠Gossip协议协同自动化修复集群的状态,但goosip有消息延时和消息冗余的问题, 在集群节点数量过多的时候,goosip协议通信会消耗大量的带宽,主要体现在以下几个方面:

  • 消息发送频率:跟cluster-node-timeout密切相关,当节点发现与其他节点的最后通信时间超过 cluster-node-timeout/2时会直接发送ping消息
  • 消息数据量:每个消息主要的数据占用包含:slots槽数组(2kb)和整个集群1/10的状态数据
  • 节点部署的机器规模:机器的带宽上限是固定的,因此相同规模的集群分布的机器越多,每台机器划分的节点越均匀,则整个集群内整体的可用带宽越高

集群带宽消耗主要分为:读写命令消耗+Gossip消息消耗,因此搭建Redis集群需要根据业务数据规模和消息通信成本做出合理规划:

  • 在满足业务需求的情况下尽量避免大集群,同一个系统可以针对不同业务场景拆分使用若干个集群。
  • 适度提供cluster-node-timeout降低消息发送频率,但是cluster-node-timeout还影响故障转移的速度,因此需要根据自身业务场景兼顾二者平衡
  • 如果条件允许尽量均匀部署在更多机器上,避免集中部署。如果有60个节点的集群部署在3台机器上每台20个节点,这是机器的带宽消耗将非常严重
Pub/Sub广播问题

集群模式下内部对所有publish命令都会向所有节点进行广播,加重带宽负担,所以集群应该避免频繁使用Pub/sub功能

集群倾斜

集群倾斜是指不同节点之间数据量和请求量出现明显差异,这种情况将加大负载均衡和开发运维的难度。因此需要理解集群倾斜的原因

  • 数据倾斜:
    • 节点和槽分配不均
    • 不同槽对应键数量差异过大
    • 集合对象包含大量元素
    • 内存相关配置不一致
  • 请求倾斜:合理设计键,热点大集合对象做拆分或者使用hmget代替hgetall避免整体读取
集群读写分离

集群模式下读写分离成本比较高,直接扩展主节点数量来提高集群性能是更好的选择。

参考文档

redis 故障及调优

Redis 故障诊断及常用运维命令—内存篇

你是否有过这种困扰:我的数据量非常小,但还是报 OOM 错误?

1
2
3
# ⼀个简单set提示内存不⾜
[root@10-186-61-38 redis]# redis-cli -p 9999 set actionsky 1
(error) OOM command not allowed when used memory > 'maxmemory'.

首先我给大家解释下,Redis 的 OOM 分两种。

  • ⼀种是因 Redis 使用内存超出 OS 物理内存,OS 将 Redis 进程杀死。
  • 另⼀种是 Redis 使用内存超过 maxmemory 参数配置,引发 Redis Server 层 OOM。

OOM 是 Redis 最常见的内存故障,它影响很大:

  • 故障发生时,进程并不会退出,能读但无法写入。
  • 配置了 allkeys-lru、allkeys-lfu 等内存淘汰策略场景下,会有大量键失效,导致缓存命中率急剧下降。

原文请参考Redis 故障诊断及常用运维命令—内存篇

Redis 内存消耗划分

简短介绍下 Redis 内存消耗划分情况,为下文诊断提供思路。上图可以总结 Redis 消耗内存分如下几块:

  • 对象内存:理论上占用最大,存储所有业务数据,如字符串类型、哈希类型对象等。
  • 客户端内存:包括输入客户端(查询或写入命令)、输出客户端使用的内存,因为不受 maxmemory 参数控制,这块我们需重点排查。
  • 复制积压缓冲:所有从库客户端共享、保存固定大小的写入命令用于从库失连后数据补偿。
  • Redis 自身内存:存储数据元数据信息、过期键字典等。
  • AOF 缓冲区:AOF 持久化、重写缓冲区,⼀般占用很少,基本不需要关注。
    redis内存

内存 OOM 会导致哪些问题

  • Redis 无法写入,只能读取。
    redis内存

  • Redis 大量键被逐出内存或过期,导致 Redis 查询效率降低(maxmemory-policy 配置为非默认值 noeviction 时)。
    redis内存

OOM 排查

是否数据量太大?

使用 redis-benchmark 持续灌入数据。
redis内存

检查内存使用情况,发生 OOM 状态时 used_memory ⼀定会大于 maxmemory。
redis内存

检查数据对象内存和其它内存使用情况如下图:
redis内存

这里有必要说明下 overhead.total,它包括除数据外 Redis 消耗的所有内存, 比如前面提到的复制缓冲区、客户端输入输出缓冲区等,另外还包括⼀些元数据如 overhead.hashtable, 它是数据库中元数据消耗的内存大小,包括以下三项:

  • 整个数据库是⼀种 hash 表,首先就是这张 hash 表使用的内存。
  • 每⼀个 key-value 对都有⼀个 dictEntry 来记录他们的关系,元信息便包含该 db 中所有 dictEntry 使用的内存。
  • redis 使用 redisObject 来描述 value 所对应的不同数据类型(string、list、hash、set、zset),那么 redisObject 占用的空间也计算在元数据。

大家对这个现象可能有点疑惑,为啥我明明设置 maxmemory 为 1G,你 Redis 只给我存了 990 多 M 数据就满了? 很好理解,根据上面测试可知数据达到⼀定规模后,因需消耗额外的元数据、缓存内存,Redis 最终将超过 maxmemory 而 OOM。

是否客户端输入缓冲区有问题?

制造输入缓冲区压力(防止干扰,先清空数据再压测)
redis内存

1
2
3
# 关键参数解释
-d 表示每个set值的大小,单位为字节
-c 启多少个连接

压测几秒钟后,触发 OOM,
redis内存

检查输入缓冲区内存消耗,能看到客户端输入缓冲区消耗总量为 2.4G左右,远远超过 maxmemory 参数设置。
redis内存

那我如何找到消耗内存量最大的那个连接呢?
redis内存

可通过运行上述检查命令,定位到各客户端输入缓冲区的内存消耗(由大到小排序)。 ⼀般如果定位到有连接异常,可以使用如下命令杀掉。

1
2
3
# 例如杀掉上图中 id=51421 的连接
127.0.0.1:9999> CLIENT KILL ID 51421
(integer) 1

是否复制积压缓冲区有问题?

为测试方便,我直接把复制积压缓冲区配置为 800M。 开启 redis-benchmark 压测进程
redis内存

检查复制积压缓冲区内存消耗,可以看到因为缓冲区设置过大,数据量才存储 190 多 M,Redis 就无法写入了。
redis内存

是否客户端输出缓冲区有问题?

若客户端输出缓冲区太大如何排查?⼀般该场景比较少见,常见于用到了 redis 的 monitor 命令。 注意:monitor 命令功能像 MySQL 的 general-log,能打印 Redis 所有执行的命令。在生产环境极少使用或禁用。

先开启 monitor 命令,
redis内存

通过 redis-benchmark 制造输出缓冲区压力。
redis内存

测试⼀段时间后观察 Redis 内存消耗,
redis内存

此时数据库无法写入,
redis内存

检查输出缓冲区各客户端连接内存消耗、输出缓冲区总消耗内存如下,
redis内存

可以看到输出缓冲区总内存已远大于 maxmemory 限制,此时内存自然就 OOM。

实用命令

上文排查过程有些 Redis 运维命令我认为比较实用,整理如下:

  • 模拟 Redis 压力相关命令
1
2
3
4
5
6
# 1. 持续给Redis灌数据
redis-benchmark -p 9999 -t set -r 100000000 -l
# 2. 模拟输入缓冲区过大
redis-benchmark -p 9999 -q -c 10 -d 102400000 -n 10000000 -r 50000 -t set
# 3. 模拟输出缓冲区过大
redis-benchmark -p 9999 -t get -r 5000000 -n 10000000 -d 100 -c 1000 -P 500 -l
  • 常用 Redis 内存排查命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 快速查看Redis内存是否够用
redis-cli -p 9999 info memory |egrep
'(used_memory_human|maxmemory_human|maxmemory_policy)'
# 2. 检查复制积压缓冲区使用情况
redis-cli -p 9999 memory stats|egrep -A 1
'(total.allocated|replication.backlog)'
# 3. 检查客户端输入缓冲区内存使用总量
redis-cli -p 9999 client list| awk 'BEGIN{sum=0}
{sum+=substr($12,6);sum+=substr($13,11)}END{print sum}'
# 4. 检查客户端输入缓冲区各客户端连接的内存情况
redis-cli -p 9999 client list|awk '{print substr($12,6),$1,$12,$18}'|sort -
nrk1,1 | cut -f1 -d" " --complement
# 5. 检查客户端输出缓冲区内存使用总量
redis-cli -p 9999 client list| awk 'BEGIN{sum=0} {sum+=substr($16,6)}END{print
sum}'
# 6. 检查客户端输出缓冲区各客户端连接的内存使用排序
redis-cli -p 9999 client list|awk '{print substr($16,6),$1,$16,$18}'|sort -
nrk1,1 | cut -f1 -d" " --complement |head -n10
# 7. 检查数据对象使用内存总量
redis-cli -p 9999 memory stats|grep -A 1 'dataset.bytes'
  • 如果大家觉命令执行起来不够方便,我整理了⼀份 Redis 内存检查脚本(篇幅稍长)有兴趣可以移步查阅:
    redis内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#!/bin/bash
# Author:Renzy
# ModifyDate:20210408

if [ $# -ne 1 ];then
    echo -e "\033[31mERROR\033[0m: A port must be provided."
    echo "eg: sh $0 6379"
    exit 1
fi

PORT=$1
CLI_BIN=/data/redis/bin/redis-cli
EXEC="$CLI_BIN -p $PORT "

# Define checking memory available.
checkMem(){
    USED_MEM=`$EXEC memory stats |grep -A 1 total.allocated|tail -n1`
    MAX_MEM=`$EXEC info memory|grep -w maxmemory|awk -F ':' '{print $2}'`
    MEM_POL=`$EXEC info memory|grep -w maxmemory_policy|awk -F ':' '{print $2}'`
    DATA_USE=`$EXEC memory stats|grep -A 1 'dataset.bytes'|tail -n1`
    EXCEPT_DATA=`$EXEC memory stats|grep -A 1 'overhead.total'|tail -n1`
    REPL_USE=`$EXEC memory stats|grep -A 1 'replication.backlog'|tail -n1`
    INOUT_USE=`$EXEC client list| awk 'BEGIN{sum=0} {sum+=substr($12,6);sum+=substr($13,11)}END{print sum}'`
    OUTPUT_USE=`$EXEC client list| awk 'BEGIN{sum=0} {sum+=substr($16,6)}END{print sum}'`

    STATUS=`$EXEC set actionsky 1`
    if [ "$STATUS" = 'OK' ];then
        echo -e "Redis当前内存是否可用: \033\033[32m${STATUS}\033[0m"
    else
        echo -e "Redis当前内存是否可用: \033\033[31m${STATUS}\033[0m"
    fi
    echo "Redis当前内存淘汰策略: $MEM_POL"
    echo "Redis当前已使用的内存(byte): $USED_MEM"
    echo "Redis当前最大内限限制(byte): $MAX_MEM"
    echo "Redis当前数据对象已使用内存(byte): $DATA_USE"
    echo "Redis当前除数据外总内存消耗(byte): $EXCEPT_DATA"
    echo "Redis当前复制积压缓存区消耗(byte): $REPL_USE"
    echo " "
    echo "Redis当前客户端输入缓存总消耗(byte): $INOUT_USE"
    echo "Redis当前客户端输入缓存各连接消耗(TOP10):"
    $EXEC client list|awk '{print substr($12,6),$1,$12,$18}'|sort -nrk1,1 | cut -f1 -d" " --complement
    echo " "
    echo "Redis当前客户端输出缓存总消耗(byte): $OUTPUT_USE"
    echo "Redis当前客户端输出缓存各连接消耗(TOP10):"
    $EXEC client list|awk '{print substr($16,6),$1,$16,$18}'|sort -nrk1,1 | cut -f1 -d" " --complement |head -n10
}

checkMem

缓存作用失效问题

在实际应用过程中,Redis会存在缓存雪崩、缓存击穿和缓存穿透等异常情况,如果忽视这些情况可能会带来灾难性的后果, 下面主要对这些缓存异常和常见处理方案进行相应分析与总结。 参考文档 3大问题!Redis缓存异常及处理方案总结

缓存雪崩

一段时间内本应在redis缓存中处理的大量请求,都发送到了数据库进行处理,导致对数据库的压力迅速增大, 严重时甚至可能导致数据库崩溃,从而导致整个系统崩溃,就像雪崩一样,引发连锁效应,所以叫缓存雪崩

出现上述情况的常见原因主要有以下两点:

  • 大量缓存数据同时过期,导致本应请求到缓存的需重新从数据库中获取数据。
  • redis本身出现故障,无法处理请求,那自然会再请求到数据库那里。

针对大量缓存数据同时过期的情况:

  • 实际设置过期时间时,应当尽量避免大量key同时过期的场景,如果真的有,那就通过随机、微调、均匀设置等方式设置过期时间,从而避免同一时间过期。
  • 添加互斥锁,使得构建缓存的操作不会在同一时间进行。
  • 双key策略,主key是原始缓存,备key为拷贝缓存,主key失效时,可以访问备key,主key缓存失效时间设置为短期,备key设置为长期。
  • 后台更新缓存策略,采用定时任务或者消息队列的方式进行redis缓存更新或移除等。

针对redis本身出现故障的情况:

  • 在预防层面,可以通过主从节点的方式构建高可用的集群,也就是实现主Redis实例挂掉后,能有其他从库快速切换为主库,继续提供服务。
  • 如果事情已经发生了,那就要为了防止数据库被大量的请求搞崩溃,可以采用服务熔断或者请求限流的方法。
    • 当然服务熔断相对粗暴一些,停止服务直到redis服务恢复
    • 请求限流相对温和一些,保证一些请求可以处理,
    • 不是一刀切,不过还是看具体业务情况选择合适的处理方案。
  • 非核心数据:暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或者是错误信息。
  • 核心数据:缓存数据丢失时通过数据库读取。

使用服务降级的方式,只有部分的数据请求会被发送到数据库,则数据库的压力就没有那么大了。

缓存击穿

缓存击穿一般出现在高并发系统中,是大量并发用户同时请求到缓存中没有但数据库中有的数据,也就是同时读缓存没读到数据,又同时去数据库去取数据, 引起数据库压力瞬间增大。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

这种情况其实一般出现的原因就是某个热点数据缓存过期,由于是热点数据,请求并发量又大,所以过期的时候还是会有大量请求同时过来, 来不及更新缓存就全部打到数据库那边了。这就导致数据库压力激增,影响到数据库处理其他的请求。
缓存击穿

针对这种情况有两种常见的处理方案:

  • 简单粗暴的对热点数据不设置过期时间,这样不会过期,自然也就不会出现上述情况了,如果后续想清理,可以通过后台进行清理。
  • 添加互斥锁,即当过期之后,除了请求过来的第一个查询的请求可以获取到锁请求到数据库,并再次更新到缓存中, 其他的会被阻塞住,直到锁被释放,同时新的缓存也被更新上去了,后续请求又会请求到缓存上,这样就不会出现缓存击穿了。

缓存穿透

缓存穿透是指数据既不在redis中,也不在数据库中,这样就导致每次请求过来的时候,在缓存中找不到对应key之后, 每次都还要去数据库再查询一遍,发现数据库也没有,相当于进行了两次无用的查询。 这样请求就可以绕过缓存直接查数据库,如果这个时候有人想恶意攻击系统, 就可以故意使用空值或者其他不存在的值进行频繁请求,那么就会对数据库造成比较大的压力。

这种现象的原因其实很好理解,业务逻辑里面如果用户对某些信息还没有进行相应的操作或者处理, 那对应的存放信息的数据库或者缓存中自然也就没有相应的数据,也就容易出现上述问题。

针对缓存穿透,一般有以下三种处理方案:

  • 非法请求的限制,主要是指参数校验、鉴权校验等,从而一开始就把大量的非法请求拦截在外,这在实际业务开发中是必要的手段。
  • 缓存空值或者默认值,如果从缓存取不到的数据,在数据库中也没有取到,那我们仍然把这个空结果进行缓存, 同时设置一个较短的过期时间。通过这个设置的默认值存放到缓存,这样第二次到缓存中获取就有值了,而不会继续访问数据库,可以防止有大量恶意请求是反复用同一个key进行攻击。
  • 使用布隆过滤器快速判断数据是否存在。那什么是布隆过滤器呢,简单来说,就是可以引入了多个相互独立的哈希函数, 保证在给定的空间和误判率下,完成元素判重。因为我们知道,存在hash碰撞这样一种情况,那如果只使用一个hash函数, 则碰撞冲突的概率明显会变大,那为了减少这种冲突,我们可以多引入几个hash函数, 而布隆过滤器算法的核心思想就是利用多个不同的hash函数来解决这样一种冲突。它的优点是空间效率高, 查询时间短,远超其他算法,而它的缺点就是会存在一定的误识别率,它不能完全保证请求过来的key, 通过布隆过滤器的校验,就一定有这个数据,毕竟理论上还是会存在冲突情况,无论概率多小。 但是,只要没有通过布隆过滤器的校验,那么这个key就一定不存在, 只要利用这一点其实就已经可以过滤掉大部分不存在的key的请求了,在正常场景下已然足够了。

布隆过滤器

布隆过滤器(Bloom Filter)是一个很长的二进制向量数组和一系列随机映射函数, 用于检索一个元素是否在一个集合中。优缺点概括如下:

  • 优点:
    • 时间复杂度低,增加和查询元素的时间复杂度为O(N)(N为hash函数个数,通常情况下比较小)
    • 保密性强,因为布隆过滤器不存储元素本身。
    • 存储空间小。
  • 缺点:
    • 有一定的误判率,但是可以通过调整参数来降低
    • 无法获取元素本身
    • 很难删除元素

布隆过滤器的工作原理就是首先多个无偏hash函数把元素的hash值计算出来, 这些hash值再对二进制数组的长度取模,得到每个hash值在数组中的对应位置, 最后把对应位置的值设为1,完成标记。 如果数据不存在,也就是没有用布隆过滤器标记过,则二进制数组对应位置为零。
布隆过滤器

hash函数再怎么好也无法完全避免hash冲突,也就是说有可能存在多个元素计算hash值为相同的, 取模数组长度后的数组索引也是相同的,这就是误判的原因。

布隆过滤器有以下类似使用场景:

  • 解决Redis缓存穿透问题
  • 做邮件的黑名单过滤
  • 对爬虫网址做过滤,爬过的不在爬
  • 解决新闻推荐过的不再推荐
  • HBase/RocksDB/LevelDB等数据库内置布隆过滤器,用于判断数据是否存在,可以减少数据库的IO请求

redis 集成布隆过滤器

用Redis可以集成布隆过滤器,版本推荐6.x,最低4.x版本, 下载安装插件后在redis.config配置文件中加入redisbloom.so文件的地址并重启。 若是redis集群则每个配置文件中都需要加入redisbloom.so文件的地址。主要指令有:

  • bf.add 添加一个元素
  • bf.exists 判断一个元素是否存在
  • bf.madd 添加多个元素
  • bf.mexists 判断多个元素是否存在

缓存预热

缓存预热就是系统上线前后,将相关的缓存数据直接加载到缓存系统中去,而不依赖用户。 这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据, 这样可以避免那么系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。根据数据不同量级,可以有以下几种做法:

  • 数据量不大:项目启动的时候自动进行加载。
  • 数据量较大:后台定时刷新缓存。
  • 数据量极大:只针对热点数据进行预加载缓存操作

缓存降级

缓存降级是指当缓存失效或缓存服务出现问题时,为了防止缓存服务故障, 导致数据库跟着一起发生雪崩问题,所以也不去访问数据库,但因为一些原因, 仍然想要保证服务还是基本可用的,虽然肯定会是有损服务。 因此,对于不重要的缓存数据,我们可以采取服务降级策略。一般做法有以下两种:

  • 直接访问内存部分的数据缓存。
  • 直接返回系统设置的默认值。

脑裂效应

从名字分析,脑裂现象就是大脑裂开了,一个人如果有两个大脑,就出现了两个决策者, 此时身体就不知道该听谁的了,势必会造成混乱。

对应到 Redis 上,就是指在主从集群中,同时有两个主节点,它们都能接收写请求, 那么什么时候会出现这种情况呢?

就是如果当前主库突然出现暂时性 “失联”,而并不是真的发生了故障, 此时监听的哨兵会自动启动主从切换机制。当这个原始的主库从假故障中恢复后, 又开始处理请求,但是哨兵已经选出了新的主库,这样一来,旧的主库和新主库就会同时存在, 这就是脑裂现象。

脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据, 结果就是不同的客户端会往不同的主节点上写入数据。严重的话,脑裂会进一步导致数据丢失。 也就是说,等到哨兵让原主库和新主库做全量同步后,原主库在切换期间保存的数据就丢失了。

详细点来说,当主从切换后,从库升级为新主库,原主库和新主库会重新进行数据的全量同步, 在这个过程中会发生数据的丢失,过程如下所示:

  • 从服务器 Slave 向主服务器 Master 发送数据同步命令;
  • Master 主服务器接收到同步命令后,保存快照生成 RDB 文件,同时使用缓冲区记录从现在开始执行的所有写命令;
  • Master 主服务器将快照文件(RDB文件)发送给 Slave 从服务器,Slave 从服务器接收到后,清空旧数据,载入新数据;
  • Master 主服务器快照发送完后开始向 Slave 从服务器发送缓冲区的写命令,Slave 从服务器接收命令并执行,完成复制初始化;
  • 此后 Master 主服务器每次执行一个写命令都会同步发送给 Slave 从服务器,保持相互之间数据的一致性;

上述步骤中的关键一步就是,原主库需要清空本地的数据后加载新主库发送的 RDB 文件, 这样一来,原主库在主从切换期间保存的新写数据就丢失了。

Redis脑裂现象是指在分布式环境中,Redis实例的状态发生了分叉,导致不同的实例 中保存的数据不一致。这可能是由于网络问题、硬件故障等原因造成的。

解决Redis脑裂现象的常用方法是:

  • 对于主从模式下的Redis集群,可以使用SLAVEOF NO ONE命令将从节点解除与主节点的关系, 然后手动选举一个新的主节点。
  • 对于哨兵模式下的Redis集群,可以使用哨兵的FAILOVER命令手动进行故障转移,选举新的主节点
  • 对于Redis集群(Redis 3.0及以上版本),可以使用CLUSTER RESET命令重置整个集群, 但这样会导致数据丢失。应在确定原因并解决问题后再使用该命令。

除了上述方法外,还可以考虑以下措施来解决Redis脑裂现象:

  • 增强网络稳定性:通过使用冗余网络、负载均衡等措施来提升网络的稳定性, 减少网络故障对Redis集群的影响。
  • 增强硬件稳定性:可以使用高质量的硬件、进行定期维护、采用冗余设计等措施来提升硬件的稳定性。
  • 增强数据一致性保障:可以使用Redis的复制功能、哨兵机制等来保障数据的一致性。
  • 对于Redis集群,可以使用集群模式下的故障转移机制,在出现故障时自动进行故障转移
  • 可以使用监控工具对Redis集群进行监控,及时发现问题并采取措施。
  • 可以通过定期对Redis集群进行备份,在出现脑裂现象时可以使用备份数据进行恢复。

但是,尽管采取了这些措施,Redis脑裂仍然可能会出现。因此,在设计和部署Redis集群时, 应该考虑如何应对Redis脑裂的情况。常用的方法有:

  • 在设计数据模型时,考虑如何避免或减少数据不一致的情况。
  • 在部署Redis集群时,采用多级复制、哨兵机制等方式来保障数据一致性。
  • 在程序中使用分布式锁或其他同步机制,避免在Redis脑裂时出现并发冲突。
  • 定期对Redis集群进行备份,在出现Redis脑裂时可以使用备份数据进行恢复。
  • 在系统设计时考虑如何应对数据丢失的情况,如使用双写机制、数据版本控制等。

从配置侧如何较为可靠地解决脑裂问题? Redis 中有两个关键的配置项可以解决这个问题, 分别是 min-slaves-to-write(最小从服务器数) 和 min-slaves-max-lag(从连接的最大延迟时间)。

  • min-slaves-to-write 是指主库最少得有 N 个健康的从库存活才能执行写命令。 这个配置虽然不能保证 N 个从库都一定能接收到主库的写操作,但是能避免当没有足够健康的从库时, 主库无法正常写入,以此来避免数据的丢失 ,如果设置为 0 则表示关闭该功能。
  • min-slaves-max-lag 是指从库和主库进行数据复制时的 ACK 消息延迟的最大时间; 可以确保从库在指定的时间内,如果 ACK 时间没在规定时间内,则拒绝写入。

这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒, 否则,主库就不会再接收客户端的请求了。

这样一来,即使原主库是假故障,它在假故障期间也无法响应哨兵发出的心跳测试, 也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。

此时的 min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足, 原主库就会被限制接收客户端请求,客户端也就不能在原主库中写入新数据, 就可以避免脑裂现象的发生了。

数据丢失一定发生了脑裂吗?

数据丢失不一定是发生了脑裂,最常见的原因是主库的数据还没有同步到从库, 结果主库发生了故障,等从库升级为主库后,未同步的数据就丢失了。

如果是这种情况的数据丢失,我们可以通过比对主从库上的复制进度差值来进行判断, 也就是计算 master_repl_offset 和 slave_repl_offset 的差值。 如果从库上的 slave_repl_\offset 小于原主库的 master_repl_offset, 那么,我们就可以认定数据丢失是由数据同步未完成导致的。

而判断是否发生了脑裂,可以采取排查客户端的操作日志的方式。 通过看日志能够发现,在主从切换后的一段时间内,会有客户端仍然在和原主库通信, 并没有和升级的新主库进行交互,这就相当于主从集群中同时有了两个主库。 根据这个迹象,我们就可以判断可能是发生了脑裂。

故障应对思路

我们将其虚拟成一场面试(或者事故复盘)。 针对常见的redis面试问题,怎样才算一个高质量的回答呢,回答思路一般包括:

  • 问题的类型是什么?
  • 通常会在什么场景发生?(通过使用被测系统的具体场景进行描述,会更加真实可信)
  • 发生的根本原因是什么?(redis等使用较广泛的开源软件,普通问题基本都会有相应的解决方案,发生问题的原因一般是因为使用不当导致)
  • 在应用程序和redis服务端都有什么表征?如何确认直接原因?
  • 发生前期要如何避免?(编码时针对问题使用那种方案?测试过程中如何发现该类问题等)
  • 发生后如何进行抢救以及各种方案有什么优缺点等?

故障文档

redis 综合

可参见以下系列文章

我要评论

本文由作者按照 CC BY 4.0 进行授权
文章内容