分布式缓存

分布式缓存

客户端缓存的使用场景:

1、对数据一致性要求不高

2、需要的缓存数量是可控的

3、提升一些并发能力

缓存的基本思想

一是时间局限性原理,即被获取过一次的数据在未来会被多次引用

二是以空间换时间

三是性能成本 Tradeoff,系统性能和开发运行成本之间做取舍

缓存的代价

首先,服务系统中引入缓存,会增加系统的复杂度。

其次,由于缓存相比原始 DB 存储的成本更高,所以系统部署及运行的费用也会更高。

最后,由于一份数据同时存在缓存和 DB 中,甚至缓存内部也会有多个数据副本,多份数据就会存在一致性问题,同时缓存体系本身也会存在可用性问题和分区的问题

缓存读写模式

Cache Aside(旁路缓存)

Read/Write Through(读写穿透)

Write Behind Caching(异步缓存写入)

1、Cache Aside(旁路缓存)

业务端处理所有数据访问细节

2、Read/Write Through(读写穿透)

存储服务代理业务方的读写 cache 和 DB 的操作

微博 Feed 的 Outbox Vector(即用户最新微博列表)就采用这种模式。一些粉丝较少且不活跃的用户发表微博后,Vector 服务会首先查询 Vector Cache,如果 cache 中没有该用户的 Outbox 记录,则不写该用户的 cache 数据,直接更新 DB 后就返回,只有 cache 中存在才会通过 CAS 指令进行更新。

3、Write Behind Caching(异步缓存写入)

该模式的特点是,数据存储的写性能最高,非常适合一些变更特别频繁的业务,特别是可以合并写请求的业务,比如对一些计数业务,一条 Feed 被点赞 1万 次,如果更新 1万 次 DB 代价很大,而合并成一次请求直接加 1万,则是一个非常轻量的操作。但这种模型有个显著的缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。比如系统 Crash、机器宕机时,如果有数据还没保存到 DB,则会存在丢失的风险。所以这种读写模式适合变更频率特别高,但对一致性要求不太高的业务,这样写操作可以异步批量写入 DB,减小 DB 压力。

缓存失效

但在某些场景,一大批数据会被系统主动或被动从 DB 批量加载,然后写入缓存并使用相同的过期时间,到时候一起到期,对这批数据的所有请求,都会出现缓存失效,从而都穿透到 DB,DB 由于查询量太大,就很容易压力大增,请求变慢。

解决方案:相同业务数据写缓存时,在基础过期时间之上,再加一个随机的过期时间,让数据在未来一段时间内慢慢过期,避免瞬时全部过期,对 DB 造成过大压力

缓存穿透

大量访问不存在的 key,都会 cache miss,都会查询 DB,就会导致系统的性能严重退化,影响正常用户的访问。

解决方案:

第一种就是,查询这些不存在的数据时,没查到结果返回 NULL,仍然记录这个 key 到缓存,只是这个 key 对应的 value 是一个特殊设置的值。

问题:

如果特殊访客持续访问大量的不存在的 key,这些 key 即便只存一个简单的默认值,也会占用大量的缓存空间,导致正常 key 的命中率下降

改进措施:

对这些不存在的 key 只存较短的时间,让它们尽快过期;

或者将这些不存在的 key 存在一个独立的公共缓存,从缓存查找时,先查正常的缓存组件,如果 miss,则查一下公共的非法 key 的缓存,如果后者命中,直接返回,否则穿透 DB,如果查出来是空,则回种到非法 key 缓存,否则回种到正常缓存。

第二种方案是,构建一个 BloomFilter 缓存过滤器,记录全量数据,这样访问数据时,可以直接通过 BloomFilter 判断这个 key 是否存在,如果不存在直接返回即可,根本无需查缓存和 DB。10亿 条数据以内最佳,因为 10亿 条数据大概要占用 1.2GB 的内存。

BloomFilter 的算法是,首先分配一块内存空间做 bit 数组,数组的 bit 位初始值全部设为 0,加入元素时,采用 k 个相互独立的 Hash 函数计算,然后将元素 Hash 映射的 K 个位置全部设置为 1。检测 key 时,仍然用这 k 个 Hash 函数计算出 k 个位置,如果位置全部为 1,则表明 key 存在,否则不存在。

BloomFilter 的优势是,全内存操作,性能很高,空间效率非常高/误判率低。平均单条记录占用 1.2 字节可达到 1% 的误判率。平均单条记录每增加 0.6 字节,还可让误判率继续变为之前的 1/10

缓存雪崩

缓存雪崩是指部分缓存节点不可用,导致整个缓存体系甚至甚至服务系统不可用的情况

缓存不支持 rehash时,较多缓存节点不可用时,大量 Cache 访问会失败,进一步访问 DB,而且 DB 可承载的访问量要远比缓存小的多,请求量过大,就很容易造成 DB 过载,大量慢查询,最终阻塞甚至 Crash,从而导致服务异常

缓存支持 rehash 时,也就是使用了一致性 Hash 分布方式。一般情况下,一致性 Hash 分布+rehash 策略可以很好得运行;但在较大的流量洪峰到临之时,如果大流量 key 比较集中,正好在某 1~2 个缓存节点,很容易将这些缓存节点的内存、网卡过载,缓存节点异常 Crash,然后这些异常节点下线,这些大流量 key 请求又被 rehash 到其他缓存节点,进而导致其他缓存节点也被过载 Crash,缓存异常持续扩散,最终导致整个缓存体系异常,无法对外提供服务。

预防缓存雪崩

方案一,对业务 DB 的访问增加读写开关,当发现 DB 请求变慢、阻塞,慢请求超过阀值时,就会关闭读开关,部分或所有读 DB 的请求进行 failfast 立即返回

方案二,对缓存增加多个副本,多个缓存副本尽量部署在不同机架

方案三,对缓存体系进行实时监控,当请求访问的慢速比超过阀值时,及时报警

数据不一致

同一份数据,可能会在 DB 和缓存之中,多个缓存副本中(如果缓存有多个副本)的数据不一致。比如更新 DB 后,写缓存失败。如果系统采用一致性 Hash 分布,同时采用 rehash 自动漂移策略,在节点多次上下线之后,也会产生脏数据。缓存有多个副本时,更新某个副本失败,也会导致这个副本的数据是老数据。

解决方案

第一个方案,cache 更新失败后,可以进行重试,如果重试失败,则将失败的 key 写入队列机服务,待缓存访问恢复后,将这些 key 从缓存删除。这些 key 在再次被查询时,重新从 DB 加载,从而保证数据的一致性。

第二个方案,缓存时间适当调短,让缓存数据及早过期后,然后从 DB 重新加载,确保数据的最终一致性。

第三个方案,不采用 rehash 漂移策略,而采用缓存分层策略,尽量避免脏数据产生。

数据并发竞争(缓存击穿?)

大量并发请求获取相同的数据,而这个数据 key 因为正好过期、被剔除等各种原因在缓存中不存在,然后一起并发查询 DB,请求那个相同的 key,最终导致 DB 压力大增

解决方案

方案一是在Redis缓存层使用全局锁

方案二是,对缓存数据保持多个备份

方案三热点数据永远不过期

Hot key

怎么定位hotkey?

1 客户端收集上报

2 代理层收集上报

https://www.infoq.cn/article/3L3zAQ4H8xpNoM2glSyi

https://github.com/samaritan-proxy/samaritan

3 Redis 数据定时扫描

4 Redis 节点抓包解析

5 对于重要节假日、线上促销活动、集中推送这些提前已知的事情,可以提前评估出可能的热 key 来

6 对于突发事件,无法提前评估,可以通过 Spark对应流任务进行实时分析,及时发现新发布的热点 key

7 对于之前已发出的事情,逐步发酵成为热 key 的,则可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。

解决方案:

一、热 key 分散处理,把热 key 的请求打散(分散为 hotkey#1、hotkey#2、……hotkey#n, n 个 key 分散存在多个缓存节点;也可以 key 的名字不变,对缓存提前进行多副本+多级结合的缓存架构设计),避免一个缓存节点过载

二、业务端还可以使用本地缓存

Big key

部分 Key 的 Value 过大,读写、加载易超时的现象

原因分析:造成这些大 key 慢查询的原因很多。如果这些大 key 占总体数据的比例很小,存 Mc,对应的 slab 较少,导致很容易被频繁剔除,DB 反复加载,从而导致查询较慢???

解决方案:

第一种方案将大 key 分拆为多个 key,尽量减少大 key 的存在

第二种方案,如果数据存在 Redis 中,比如业务数据存 set 格式,大 key 对应的 set 结构有几千几万个元素,这种写入 Redis 时会消耗很长的时间,导致 Redis 卡顿。此时,可以扩展新的数据结构,同时让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入,如下图所示?????

Redis

最大优势是支持丰富的数据类型:string、list、set、sorted set、hash、bitmap、geo、hyperloglog

Redis 高性能

Redis 一般被看作单进程/单线程组件,因为 Redis 的网络 IO 和命令处理,都在核心进程中由单线程处理。Redis 基于 Epoll 事件模型开发,可以进行非阻塞网络 IO,同时由于单线程命令处理,整个处理过程不存在竞争,不需要加锁,没有上下文切换开销,所有数据操作都是在内存中操作,所以 Redis 的性能很高,单个实例即可以达到 10w 级的 QPS。核心线程除了负责网络 IO 及命令处理外,还负责写数据到缓冲,以方便将最新写操作同步到 AOF、slave。

除了主进程,Redis 还会 fork 一个子进程,来进行重负荷任务的处理。Redis fork 子进程主要有 3 种场景。

1 收到 bgrewriteaof 命令时,Redis 调用 fork,构建一个子进程,子进程往临时 AOF文件中,写入重建数据库状态的所有命令,当写入完毕,子进程则通知父进程,父进程把新增的写操作也追加到临时 AOF 文件,然后将临时文件替换老的 AOF 文件,并重命名。

2 收到 bgsave 命令时,Redis 构建子进程,子进程将内存中的所有数据通过快照做一次持久化落地,写入到 RDB 中。

3 当需要进行全量复制时,master 也会启动一个子进程,子进程将数据库快照保存到 RDB 文件,在写完 RDB 快照文件后,master 就会把 RDB 发给 slave,同时将后续新的写指令都同步给 slave。

(更多笔记见笔记本)

string

数值计算、

bitmap:统计任意时间窗口的登录、活跃情况,存用户最近 N 天的登录情况,每天用 1 bit,登录则置 1。个性推荐在社交应用中非常重要,可以对新闻、feed 设置一系列标签,如军事、娱乐、视频、图片、文字等,用 bitmap 来存储这些标签,在对应标签 bit 位上置 1。对用户,也可以采用类似方式,记录用户的多种属性,并可以很方便的根据标签来进行多维度统计。bitmap 位图的重要指令包括:setbit、 getbit、bitcount、bitfield、 bitop、bitpos 等。

List (quicklist)

模拟栈、队列、数组;消息队列;热门 feed 等业务场景,都可以使用 list 数据结构

set 集合

特点是查找、插入、删除特别高效,时间复杂度为 O(1),所以在社交系统中,可以用于存储关注的好友列表,用来判断是否关注,还可以用来做好友推荐使用。另外,还可以利用 set 的唯一性,来对服务的来源业务、来源 IP 进行精确统计。srandmenber,用于抽奖、随机事件

sortedset

可以用有序集合来统计排行榜,实时刷新榜单,还可以用来记录学生成绩,从而轻松获取某个成绩范围内的学生名单,还可以用来对系统统计增加权重值,从而在 dashboard 实时展示。

Geo:

在存储某个位置点时,首先利用 Geohash 算法,将该位置二维的经纬度,映射编码成一维的 52 位整数值,将位置名称、经纬度编码 score 作为键值对,存储到分类 key 对应的 sorted set 中。

需要计算某个位置点 A 附近的人时,首先以指定位置 A 为中心点,以距离作为半径,算出 GEO 哈希 8 个方位的范围, 然后依次轮询方位范围内的所有位置点,只要这些位置点到中心位置 A 的距离在要求距离范围内,就是目标位置点。轮询完所有范围内的位置点后,重新排序即得到位置点 A 附近的所有目标。

redis响应格式

1 simple strings 简单字符串类型,以 + 开头

set k 456

+OK\r\n

2 Redis 错误以 -(减号)开头

bad command

-ERR unknown command `bad`, with args beginning with: `command`,

3 Integer 整数类型以 :开头

incr k

:457\r\n

4 bulk strings 字符串块类型 $ 开头

get k

$3\r\n456\r\n

($3

456)

get not_exist

$-1\r\n

5 Arrays 数组类型以 * 开头

LRANGE l 0 -1

*8\r\n$1\r\n7\r\n$1\r\n6\r\n

(*8

$1

7

$1

6)

Redis架构

Redis 中的事件处理模块,采用的是作者自己开发的 ae 事件驱动模型,可以进行高效的网络 IO 读写、命令执行,以及时间事件处理。

其中,网络 IO 读写处理采用的是 IO 多路复用技术,通过对 evport、epoll、kqueue、select 等进行封装,同时监听多个 socket,并根据 socket 目前执行的任务,来为 socket 关联不同的事件处理器。

当监听端口对应的 socket 收到连接请求后,就会创建一个 client 结构,通过 client 结构来对连接状态进行管理。在请求进入时,将请求命令读取缓冲并进行解析,并存入到 client 的参数列表。

然后根据请求命令找到 对应的redisCommand ,最后根据命令协议,对请求参数进一步的解析、校验并执行。Redis 中时间事件比较简单,目前主要是执行 serverCron,来做一些统计更新、过期 key 清理、AOF 及 RDB 持久化等辅助操作。

事件驱动模型

Redis 是一个事件驱动程序,但和 Memcached 不同的是,Redis 并没有采用 libevent 或 libev 这些开源库,而是直接开发了一个新的事件循环组件。Redis 作者给出的理由是,尽量减少外部依赖,而自己开发的事件模型也足够简洁、轻便、高效,也更易控制。Redis 的事件驱动模型机制封装在 aeEventLoop 等相关的结构体中,网络连接、命令读取执行回复,数据的持久化、淘汰回收 key 等,几乎所有的核心操作都通过 ae 事件模型进行处理。

文件事件,如连接建立、接受请求命令、发送响应等;

时间事件,如 Redis 中定期要执行的统计、key 淘汰、缓冲数据写出、rehash等

文件事件处理       

Redis 的文件事件采用典型的 Reactor 模式进行处理。Redis 文件事件处理机制分为 4 部分:

连接 socket

IO 多路复用程序

文件事件分派器

事件处理器

文件事件是对连接 socket 操作的一个抽象。当端口监听 socket 准备 accept 新连接,或者连接 socket 准备好读取请求、写入响应、关闭时,就会产生一个文件事件。IO 多路复用程序负责同时监听多个 socket,当这些 socket 产生文件事件时,就会触发事件通知,文件分派器就会感知并获取到这些事件。

虽然多个文件事件可能会并发出现,但 IO 多路复用程序总会将所有产生事件的 socket 放入一个队列中,通过这个队列,有序的把这些文件事件通知给文件分派器。

IO多路复用

Redis 封装了 4 种多路复用程序,每种封装实现都提供了相同的 API 实现。编译时,会按照性能和系统平台,选择最佳的 IO 多路复用函数作为底层实现,选择顺序是,首先尝试选择 Solaries 中的 evport,如果没有,就尝试选择 Linux 中的 epoll,否则就选择大多 UNIX 系统都支持的 kqueue,这 3 个多路复用函数都直接使用系统内核内部的结构,可以服务数十万的文件描述符。


如果当前编译环境没有上述函数,就会选择 select 作为底层实现方案。select 方案的性能较差,事件发生时,会扫描全部监听的描述符,事件复杂度是 O(n),并且只能同时服务有限个文件描述符,32 位机默认是 1024 个,64 位机默认是 2048 个,所以一般情况下,并不会选择 select 作为线上运行方案。Redis 的这 4 种实现,分别在 ae_evport、ae_epoll、ae_kqueue 和 ae_select 这 4 个代码文件中。

文件事件收集及派发器

Redis 中的文件事件分派器是 aeProcessEvents 函数。它会首先计算最大可以等待的时间,然后利用 aeApiPoll 等待文件事件的发生。如果在等待时间内,一旦 IO 多路复用程序产生了事件通知,则会立即轮询所有已产生的文件事件,并将文件事件放入 aeEventLoop 中的 aeFiredEvents 结构数组中。每个 fired event 会记录 socket 及 Redis 读写事件类型。


这里会涉及将多路复用中的事件类型,转换为 Redis 的 ae 事件驱动模型中的事件类型。以采用 Linux 中的 epoll 为例,会将 epoll 中的 EPOLLIN 转为 AE_READABLE 类型,将 epoll 中的 EPOLLOUT、EPOLLERR 和 EPOLLHUP 转为 AE_WRITABLE 事件。


aeProcessEvents 在获取到触发的事件后,会根据事件类型,将文件事件 dispatch 派发给对应事件处理函数。如果同一个 socket,同时有读事件和写事件,Redis 派发器会首先派发处理读事件,然后再派发处理写事件。

文件事件处理函数分类

Redis 中文件事件函数的注册和处理主要分为 3 种。

连接处理函数 acceptTcpHandler

Redis 在启动时,在 initServer 中对监听的 socket 注册读事件,事件处理器为 acceptTcpHandler,该函数在有新连接进入时,会被派发器派发读任务。在处理该读任务时,会 accept 新连接,获取调用方的 IP 及端口,并对新连接创建一个 client 结构。如果同时有大量连接同时进入,Redis 一次最多处理 1000 个连接请求。


readQueryFromClient 请求处理函数

连接函数在创建 client 时,会对新连接 socket 注册一个读事件,该读事件的事件处理器就是 readQueryFromClient。在连接 socket 有请求命令到达时,IO 多路复用程序会获取并触发文件事件,然后这个读事件被派发器派发给本请求的处理函数。readQueryFromClient 会从连接 socket 读取数据,存入 client 的 query 缓冲,然后进行解析命令,按照 Redis 当前支持的 2 种请求格式,及 inline 内联格式和 multibulk 字符块数组格式进行尝试解析。解析完毕后,client 会根据请求命令从命令表中获取到对应的 redisCommand,如果对应 cmd 存在。则开始校验请求的参数,以及当前 server 的内存、磁盘及其他状态,完成校验后,然后真正开始执行 redisCommand 的处理函数,进行具体命令的执行,最后将执行结果作为响应写入 client 的写缓冲中。

命令回复处理器 sendReplyToClient

当 redis需要发送响应给client时,Redis 事件循环中会对client的连接socket注册写事件,这个写事件的处理函数就是sendReplyToClient。通过注册写事件,将 client 的socket与 AE_WRITABLE 进行间接关联。当 Client fd 可进行写操作时,就会触发写事件,该函数就会将写缓冲中的数据发送给调用方。

Redis 中的时间事件是指需要在特定时间执行的事件。多个 Redis 中的时间事件构成 aeEventLoop 中的一个链表,供 Redis 在 ae 事件循环中轮询执行。

Redis 当前的主要时间事件处理函数有 2 个:

serverCron

moduleTimerHandler

Redis 中的时间事件分为 2 类:

单次时间,即执行完毕后,该时间事件就结束了。

周期性事件,在事件执行完毕后,会继续设置下一次执行的事件,从而在时间到达后继续执行,并不断重复。

时间事件主要有 5 个属性组成。

事件 ID:Redis 为时间事件创建全局唯一 ID,该 ID 按从小到大的顺序进行递增。

执行时间 when_sec 和 when_ms:精确到毫秒,记录该事件的到达可执行时间。

时间事件处理器 timeProc:在时间事件到达时,Redis 会调用相应的 timeProc 处理事件。

关联数据 clientData:在调用 timeProc 时,需要使用该关联数据作为参数。

链表指针 prev 和 next:它用来将时间事件维护为双向链表,便于插入及查找所要执行的时间事件。

时间事件的处理是在事件循环中的 aeProcessEvents 中进行。执行过程是:

首先遍历所有的时间事件。

比较事件的时间和当前时间,找出可执行的时间事件。

然后执行时间事件的 timeProc 函数。

执行完毕后,对于周期性时间,设置时间新的执行时间;对于单次性时间,设置事件的 ID为 -1,后续在事件循环中,下一次执行 aeProcessEvents 的时候从链表中删除。

 Redis 协议解析及处理

命令读取:

命令执行:

Redis 内部数据结构

RdeisDb

Redis 中所有数据都保存在 DB 中,一个 Redis 默认最多支持 16 个 DB。Redis 中的每个 DB 都对应一个 redisDb 结构,即每个 Redis 实例,默认有 16 个 redisDb。用户访问时,默认使用的是 0 号 DB,可以通过 select $dbID 在不同 DB 之间切换。

redisDb 主要包括 2 个核心 dict 字典、3 个非核心 dict 字典、dbID 和其他辅助属性。2 个核心 dict 包括一个 dict 主字典和一个 expires 过期字典。主 dict 字典用来存储当前 DB 中的所有数据,它将 key 和各种数据类型的 value 关联起来,该 dict 也称 key space。过期字典用来存储过期时间 key,存的是 key 与过期时间的映射。日常的数据存储和访问基本都会访问到 redisDb 中的这两个 dict。

3 个非核心 dict 包括一个字段名叫 blocking_keys 的阻塞 dict,一个字段名叫 ready_keys 的解除阻塞 dict,还有一个是字段名叫 watched_keys 的 watch 监控 dict。

在执行 Redis 中 list 的阻塞命令 blpop、brpop 或者 brpoplpush 时,如果对应的 list 列表为空,Redis 就会将对应的 client 设为阻塞状态,同时将该 client 添加到 DB 中 blocking_keys 这个阻塞 dict。所以该 dict 存储的是处于阻塞状态的 key 及 client 列表。


当有其他调用方在向某个 key 对应的 list 中增加元素时,Redis 会检测是否有 client 阻塞在这个 key 上,即检查 blocking_keys 中是否包含这个 key,如果有则会将这个 key 加入 read_keys 这个 dict 中。同时也会将这个 key 保存到 server 中的一个名叫 read_keys 的列表中。这样可以高效、不重复的插入及轮询。


当 client 使用 watch 指令来监控 key 时,这个 key 和 client 就会被保存到 watched_keys 这个 dict 中。redisDb 中可以保存所有的数据类型,而 Redis 中所有数据类型都是存放在一个叫 redisObject 的结构中。

redisObject

redisObject 由 5 个字段组成。

type:即 Redis 对象的数据类型,目前支持 7 种 type 类型,分别为

OBJ_STRING

OBJ_LIST

OBJ_SET

OBJ_ZSET

OBJ_HASH

OBJ_MODULE

OBJ_STREAM

encoding:Redis 对象的内部编码方式,即内部数据结构类型,目前支持 10 种编码方式包括

OBJ_ENCODING_RAW

OBJ_ENCODING_INT

OBJ_ENCODING_HT

OBJ_ENCODING_ZIPLIST 等。

LRU:存储的是淘汰数据用的 LRU 时间或 LFU 频率及时间的数据。

refcount:记录 Redis 对象的引用计数,用来表示对象被共享的次数,共享使用时加 1,不再使用时减 1,当计数为 0 时表明该对象没有被使用,就会被释放,回收内存。

ptr:它指向对象的内部数据结构。比如一个代表 string 的对象,它的 ptr 可能指向一个 sds 或者一个 long 型整数。

dict

前面讲到,Redis 中的数据实际是存在 DB 中的 2 个核心 dict 字典中的。实际上 dict 也是 Redis 的一种使用广泛的内部数据结构。


Redis 中的 dict,类似于 Memcached 中 hashtable。都可以用于 key 或元素的快速插入、更新和定位。dict 字典中,有一个长度为 2 的哈希表数组,日常访问用 0 号哈希表,如果 0 号哈希表元素过多,则分配一个 2 倍 0 号哈希表大小的空间给 1 号哈希表,然后进行逐步迁移,rehashidx 这个字段就是专门用来做标志迁移位置的。在哈希表操作中,采用单向链表来解决 hash 冲突问题。dict 中还有一个重要字段是 type,它用于保存 hash 函数及 key/value 赋值、比较函数。


dictht 中的 table 是一个 hash 表数组,每个桶指向一个 dictEntry 结构。dictht 采用 dictEntry 的单向链表来解决 hash 冲突问题。


dictht 是以 dictEntry 来存 key-value 映射的。其中 key 是 sds 字符串,value 为存储各种数据类型的 redisObject 结构。


dict 可以被 redisDb 用来存储数据 key-value 及命令操作的辅助信息。还可以用来作为一些 Redis 数据类型的内部数据结构。dict 可以作为 set 集合的内部数据结构。在哈希的元素数超过 512 个,或者哈希中 value 大于 64 字节,dict 还被用作为哈希类型的内部数据结构。

sds

字符串是 Redis 中最常见的数据类型,其底层实现是简单动态字符串即 sds。简单动态字符串本质是一个 char*,内部通过 sdshdr 进行管理。sdshdr 有 4 个字段。len 为字符串实际长度,alloc 当前字节数组总共分配的内存大小。flags 记录当前字节数组的属性;buf 是存储字符串真正的值及末尾一个 \0。


sds 的存储 buf 可以动态扩展或收缩,字符串长度不用遍历,可直接获得,修改和访问都很方便。由于 sds 中字符串存在 buf 数组中,长度由 len 定义,而不像传统字符串遇 0 停止,所以 sds 是二进制安全的,可以存放任何二进制的数据。


简单动态字符串 sds 的获取字符串长度很方便,通过 len 可以直接得到,而传统字符串需要对字符串进行遍历,时间复杂度为 O(n)。


sds 相比传统字符串多了一个 sdshdr,对于大量很短的字符串,这个 sdshdr 还是一个不小的开销。在 3.2 版本后,sds 会根据字符串实际的长度,选择不同的数据结构,以更好的提升内存效率。当前 sdshdr 结构分为 5 种子类型,分别为 sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64。其中 sdshdr5 只有 flags 和 buf 字段,其他几种类型的 len 和 alloc 采用从 uint8_t 到 uint64_t 的不同类型,以节省内存空间。


sds 可以作为字符串的内部数据结构,同时 sds 也是 hyperloglog、bitmap 类型的内部数据结构。

ziplist

为了节约内存,并减少内存碎片,Redis 设计了 ziplist 压缩列表内部数据结构。压缩列表是一块连续的内存空间,可以连续存储多个元素,没有冗余空间,是一种连续内存数据块组成的顺序型内存结构。


ziplist 的结构如图所示,主要包括 5 个部分。

zlbytes 是压缩列表所占用的总内存字节数。

Zltail 尾节点到起始位置的字节数。

Zllen 总共包含的节点/内存块数。

Entry 是 ziplist 保存的各个数据节点,这些数据点长度随意。

Zlend 是一个魔数 255,用来标记压缩列表的结束。

如图所示,一个包含 4 个元素的 ziplist,总占用字节是 100bytes,该 ziplist 的起始元素的指针是 p,zltail 是 80,则第 4 个元素的指针是 P+80。


压缩列表 ziplist 的存储节点 entry 的结构如图,主要有 6 个字段。

prevRawLen 是前置节点的长度;

preRawLenSize 编码 preRawLen 需要的字节数;

len 当前节点的长度;

lensize 编码 len 所需要的字节数;

encoding  当前节点所用的编码类型;

entryData 当前节点数据。


由于 ziplist 是连续紧凑存储,没有冗余空间,所以插入新的元素需要 realloc 扩展内存,所以如果 ziplist 占用空间太大,realloc 重新分配内存和拷贝的开销就会很大,所以 ziplist 不适合存储过多元素,也不适合存储过大的字符串。


因此只有在元素数和 value 数都不大的时候,ziplist 才作为 hash 和 zset 的内部数据结构。其中 hash 使用 ziplist 作为内部数据结构的限制时,元素数默认不超过 512 个,value 值默认不超过 64 字节。可以通过修改配置来调整 hash_max_ziplist_entries 、hash_max_ziplist_value 这两个阀值的大小。


zset 有序集合,使用 ziplist 作为内部数据结构的限制元素数默认不超过 128 个,value 值默认不超过 64 字节。可以通过修改配置来调整 zset_max_ziplist_entries 和 zset_max_ziplist_value 这两个阀值的大小。

quicklist

Redis 在 3.2 版本之后引入 quicklist,用以替换 linkedlist。因为 linkedlist 每个节点有前后指针,要占用 16 字节,而且每个节点独立分配内存,很容易加剧内存的碎片化。而 ziplist 由于紧凑型存储,增加元素需要 realloc,删除元素需要内存拷贝,天然不适合元素太多、value 太大的存储。


而 quicklist 快速列表应运而生,它是一个基于 ziplist 的双向链表。将数据分段存储到 ziplist,然后将这些 ziplist 用双向指针连接。快速列表的结构如图所示。

head、tail 是两个指向第一个和最后一个 ziplist 节点的指针。

count 是 quicklist 中所有的元素个数。

len 是 ziplist 节点的个数。

compress 是 LZF 算法的压缩深度。

快速列表中,管理 ziplist 的是 quicklistNode 结构。quicklistNode 主要包含一个 prev/next 双向指针,以及一个 ziplist 节点。单个 ziplist 节点可以存放多个元素。


快速列表从头尾读写数据很快,时间复杂度为 O(1)。也支持从中间任意位置插入或读写元素,但速度较慢,时间复杂度为 O(n)。快速列表当前主要作为 list 列表的内部数据结构。

zskiplist

跳跃表 zskiplist 是一种有序数据结构,它通过在每个节点维持多个指向其他节点的指针,从而可以加速访问。跳跃表支持平均 O(logN) 和最差 O(n) 复杂度的节点查找。在大部分场景,跳跃表的效率和平衡树接近,但跳跃表的实现比平衡树要简单,所以不少程序都用跳跃表来替换平衡树。


如果 sorted set 类型的元素数比较多或者元素比较大,Redis 就会选择跳跃表来作为 sorted set有序集合的内部数据结构。


跳跃表主要由 zskipList 和节点 zskiplistNode 构成。zskiplist 结构如图,header 指向跳跃表的表头节点。tail 指向跳跃表的表尾节点。length 表示跳跃表的长度,它是跳跃表中不包含表头节点的节点数量。level 是目前跳跃表内,除表头节点外的所有节点中,层数最大的那个节点的层数。


跳跃表的节点 zskiplistNode 的结构如图所示。ele 是节点对应的 sds 值,在 zset 有序集合中就是集合中的 field 元素。score 是节点的分数,通过 score,跳跃表中的节点自小到大依次排列。backward 是指向当前节点的前一个节点的指针。level 是节点中的层,每个节点一般有多个层。每个 level 层都带有两个属性,一个是 forwad 前进指针,它用于指向表尾方向的节点;另外一个是 span 跨度,它是指 forward 指向的节点到当前节点的距离。


如图所示是一个跳跃表,它有 3 个节点。对应的元素值分别是 S1、S2 和 S3,分数值依次为 1.0、3.0 和 5.0。其中 S3 节点的 level 最大是 5,跳跃表的 level 是 5。header 指向表头节点,tail 指向表尾节点。在查到元素时,累加路径上的跨度即得到元素位置。在跳跃表中,元素必须是唯一的,但 score 可以相同。相同 score 的不同元素,按照字典序进行排序。


在 sorted set 数据类型中,如果元素数较多或元素长度较大,则使用跳跃表作为内部数据结构。默认元素数超过 128 或者最大元素的长度超过 64,此时有序集合就采用 zskiplist 进行存储。由于 geo 也采用有序集合类型来存储地理位置名称和位置 hash 值,所以在超过相同阀值后,也采用跳跃表进行存储。


Redis 主要的内部数据结构讲完了,接下来整体看一下,之前讲的 8 种数据类型,具体都是采用哪种内部数据结构来存储的。

首先,对于 string 字符串,Redis 主要采用 sds 来进行存储。而对于 list 列表,Redis 采用 quicklist 进行存储。对于 set 集合类型,Redis 采用 dict 来进行存储。对于 sorted set 有序集合类型,如果元素数小于 128 且元素长度小于 64,则使用 ziplist 存储,否则使用 zskiplist 存储。对于哈希类型,如果元素数小于 512,并且元素长度小于 64,则用 ziplist 存储,否则使用 dict 字典存储。对于 hyperloglog,采用 sds 简单动态字符串存储。对于 geo,如果位置数小于 128,则使用 ziplist 存储,否则使用 zskiplist 存储。最后对于 bitmap,采用 sds 简单动态字符串存储。

淘汰原理

首先我们来学习 Redis 的淘汰原理

系统线上运行中,内存总是昂贵且有限的,在数据总量远大于 Redis 可用的内存总量时,为了最大限度的提升访问性能,Redis 中只能存放最新最热的有效数据。

当 key 过期后,或者 Redis 实际占用的内存超过阀值后,Redis 就会对 key 进行淘汰,删除过期的或者不活跃的 key,回收其内存,供新的 key 使用。Redis 的内存阀值是通过 maxmemory 设置的,而超过内存阀值后的淘汰策略,是通过 maxmemory-policy 设置的,具体的淘汰策略后面会进行详细介绍。Redis 会在 2 种场景下对 key 进行淘汰,第一种是在定期执行 serverCron 时,检查淘汰 key;第二种是在执行命令时,检查淘汰 key。

第一种场景,Redis 定期执行 serverCron 时,会对 DB 进行检测,清理过期 key。清理流程如下。首先轮询每个 DB,检查其 expire dict,即带过期时间的过期 key 字典,从所有带过期时间的 key 中,随机选取 20 个样本 key,检查这些 key 是否过期,如果过期则清理删除。如果 20 个样本中,超过 5 个 key 都过期,即过期比例大于 25%,就继续从该 DB 的 expire dict 过期字典中,再随机取样 20 个 key 进行过期清理,持续循环,直到选择的 20 个样本 key 中,过期的 key 数小于等于 5,当前这个 DB 则清理完毕,然后继续轮询下一个 DB。

在执行 serverCron 时,如果在某个 DB 中,过期 dict 的填充率低于 1%,则放弃对该 DB 的取样检查,因为效率太低。如果 DB 的过期 dict 中,过期 key 太多,一直持续循环回收,会占用大量主线程时间,所以 Redis 还设置了一个过期时间。这个过期时间根据 serverCron 的执行频率来计算,5.0 版本及之前采用慢循环过期策略,默认是 25ms,如果回收超过 25ms 则停止,6.0 非稳定版本采用快循环策略,过期时间为 1ms。

第二种场景,Redis 在执行命令请求时。会检查当前内存占用是否超过 maxmemory 的数值,如果超过,则按照设置的淘汰策略,进行删除淘汰 key 操作。

淘汰方式

Redis 中 key 的淘汰方式有两种,分别是同步删除淘汰和异步删除淘汰。在 serverCron 定期清理过期 key 时,如果设置了延迟过期配置 lazyfree-lazy-expire,会检查 key 对应的 value 是否为多元素的复合类型,即是否是 list 列表、set 集合、zset 有序集合和 hash 中的一种,并且 value 的元素数大于 64,则在将 key 从 DB 中 expire dict 过期字典和主 dict 中删除后,value 存放到 BIO 任务队列,由 BIO 延迟删除线程异步回收;否则,直接从 DB 的 expire dict 和主 dict 中删除,并回收 key、value 所占用的空间。在执行命令时,如果设置了 lazyfree-lazy-eviction,在淘汰 key 时,也采用前面类似的检测方法,对于元素数大于 64 的 4 种复合类型,使用 BIO 线程异步删除,否则采用同步直接删除。

淘汰策略

Redis 提供了 8 种淘汰策略对 key 进行管理,而且还引入基于样本的 eviction pool,来提升剔除的准确性,确保 在保持最大性能 的前提下,剔除最不活跃的 key。eviction pool 主要对 LRU、LFU,以及过期 dict ttl  内存管理策略 生效。处理流程为,当 Redis 内存占用超过阀值后,按策略从主 dict 或者带过期时间的 expire dict 中随机选择 N 个 key,N 默认是 5,计算每个 key 的 idle 值,按 idle 值从小到大的顺序插入 evictionPool 中,然后选择 idle 最大的那个 key,进行淘汰。

选择淘汰策略时,可以通过配置 Redis 的 maxmemory 设置最大内存,并通 maxmemory_policy 设置超过最大内存后的处理策略。如果 maxmemory 设为 0,则表明对内存使用没有任何限制,可以持续存放数据,适合作为存储,来存放数据量较小的业务。如果数据量较大,就需要估算热数据容量,设置一个适当的值,将 Redis 作为一个缓存而非存储来使用。

Redis 提供了 8 种 maxmemory_policy 淘汰策略来应对内存超过阀值的情况。

第一种淘汰策略是 noeviction,它是 Redis 的默认策略。在内存超过阀值后,Redis 不做任何清理工作,然后对所有写操作返回错误,但对读请求正常处理。noeviction 适合数据量不大的业务场景,将关键数据存入 Redis 中,将 Redis 当作 DB 来使用。

第二种淘汰策略是 volatile-lru,它对带过期时间的 key 采用最近最少访问算法来淘汰。使用这种策略,Redis 会从 redisDb 的 expire dict 过期字典中,首先随机选择 N 个 key,计算 key 的空闲时间,然后插入 evictionPool 中,最后选择空闲时间最久的 key 进行淘汰。这种策略适合的业务场景是,需要淘汰的key带有过期时间,且有冷热区分,从而可以淘汰最久没有访问的key。

第三种策略是 volatile-lfu,它对带过期时间的 key 采用最近最不经常使用的算法来淘汰。使用这种策略时,Redis 会从 redisDb 中的 expire dict 过期字典中,首先随机选择 N 个 key,然后根据其 value 的 lru 值,计算 key 在一段时间内的使用频率相对值。对于 lfu,要选择使用频率最小的 key,为了沿用 evictionPool 的 idle 概念,Redis 在计算 lfu 的 Idle 时,采用 255 减去使用频率相对值,从而确保 Idle 最大的 key 是使用次数最小的 key,计算 N 个 key 的 Idle 值后,插入 evictionPool,最后选择 Idle 最大,即使用频率最小的 key,进行淘汰。这种策略也适合大多数 key 带过期时间且有冷热区分的业务场景。

第四种策略是 volatile-ttl,它是对带过期时间的 key 中选择最早要过期的 key 进行淘汰。使用这种策略时,Redis 也会从 redisDb 的 expire dict 过期字典中,首先随机选择 N 个 key,然后用最大无符号 long 值减去 key 的过期时间来作为 Idle 值,计算 N 个 key 的 Idle 值后,插入evictionPool,最后选择 Idle 最大,即最快就要过期的 key,进行淘汰。这种策略适合,需要淘汰的key带过期时间,且有按时间冷热区分的业务场景。


第五种策略是 volatile-random,它是对带过期时间的 key 中随机选择 key 进行淘汰。使用这种策略时,Redis 从 redisDb 的 expire dict 过期字典中,随机选择一个 key,然后进行淘汰。如果需要淘汰的key有过期时间,没有明显热点,主要被随机访问,那就适合选择这种淘汰策略。

第六种策略是 allkey-lru,它是对所有 key,而非仅仅带过期时间的 key,采用最近最久没有使用的算法来淘汰。这种策略与 volatile-lru 类似,都是从随机选择的 key 中,选择最长时间没有被访问的 key 进行淘汰。区别在于,volatile-lru 是从 redisDb 中的 expire dict 过期字典中选择 key,而 allkey-lru 是从所有的 key 中选择 key。这种策略适合,需要对所有 key 进行淘汰,且数据有冷热读写区分的业务场景。

第七种策略是 allkeys-lfu,它也是针对所有 key 采用最近最不经常使用的算法来淘汰。这种策略与 volatile-lfu 类似,都是在随机选择的 key 中,选择访问频率最小的 key 进行淘汰。区别在于,volatile-flu从expire dict 过期字典中选择 key,而 allkeys-lfu 是从主 dict 中选择 key。这种策略适合的场景是,需要从所有的 key 中进行淘汰,但数据有冷热区分,且越热的数据访问频率越高。

最后一种策略是 allkeys-random,它是针对所有 key 进行随机算法进行淘汰。它也是从主 dict 中随机选择 key,然后进行删除回收。如果需要从所有的 key 中进行淘汰,并且 key 的访问没有明显热点,被随机访问,即可采用这种策略。

崩溃恢复

本课时我们主要学习通过 RDB、AOF、混合存储等数据持久化方案来解决如何进行数据恢复的问题。

Redis 持久化是一个将内存数据转储到磁盘的过程。Redis 目前支持 RDB、AOF,以及混合存储三种模式。

RDB

Redis 的 RDB 持久化是以快照的方式将内存数据存储到磁盘。在需要进行 RDB 持久化时,Redis 会将内存中的所有数据以二进制的格式落地,每条数据存储的内容包括过期时间、数据类型、key,以及 value。当 Redis 重启时,如果 appendonly 关闭,则会读取 RDB 持久化生成的二进制文件进行数据恢复。

触发构建 RDB 的场景主要有以下四种。

第一种场景是通过 save 或 bgsave 命令进行主动 RDB 快照构建。它是由调用方调用 save 或 bgsave 指令进行触发的。

第二种场景是利用配置 save m n 来进行自动快照生成。它是指在 m 秒中,如果插入或变更 n 个 key,则自动触发 bgsave。这个配置可以设置多个配置行,以便组合使用。由于峰值期间,Redis 的压力大,变更的 key 也比较多,如果再进行构建 RDB 的操作,会进一步增加机器负担,对调用方请求会有一定的影响,所以线上使用时需要谨慎。

第三种场景是主从复制,如果从库需要进行全量复制,此时主库也会进行 bgsave 生成一个 RDB 快照。

第四种场景是在运维执行 flushall 清空所有数据,或执行 shutdown 关闭服务时,也会触发 Redis 自动构建 RDB 快照。

save 是在主进程中进行 RDB 持久化的,持久化期间 Redis 处于阻塞状态,不处理任何客户请求,所以一般使用较少。而 bgsave 是 fork 一个子进程,然后在子进程中构建 RDB 快照,构建快照的过程不直接影响用户的访问,但仍然会增加机器负载。线上 Redis 快照备份,一般会选择凌晨低峰时段,通过 bgsave 主动触发进行备份。

RDB 快照文件主要由 3 部分组成。

第一部分是 RDB 头部,主要包括 RDB 的版本,以及 Redis 版本、创建日期、占用内存等辅助信息。

第二部分是各个 RedisDB 的数据。存储每个 RedisDB 时,会首先记录当前 RedisDB 的DBID,然后记录主 dict 和 expire dict 的记录数量,最后再轮询存储每条数据记录。存储数据记录时,如果数据有过期时间,首先记录过期时间。如果 Redis 的 maxmemory_policy 过期策略采用 LRU 或者 LFU,还会将 key 对应的 LRU、LFU 值进行落地,最后记录数据的类型、key,以及 value。

第三部部分是 RDB 的尾部。RDB 尾部,首先存储 Redis 中的 Lua 脚本等辅助信息。然后存储 EOF 标记,即值为 255 的字符。最后存 RDB 的 cksum。

至此,RDB 就落地完毕。

RDB 采用二进制方式存储内存数据,文件小,且启动时恢复速度快。但构建 RDB 时,一个快照文件只能存储,构建时刻的内存数据,无法记录之后的数据变更。构建 RDB 的过程,即便在子进程中进行,但仍然属于 CPU 密集型的操作,而且每次落地全量数据,耗时也比较长,不能随时进行,特别是不能在高峰期进行。由于 RDB 采用二进制存储,可读性差,而且由于格式固定,不同版本之间可能存在兼容性问题。

AOF

Redis 的 AOF 持久化是以命令追加的方式进行数据落地的。通过打开 appendonly 配置,Redis 将每一个写指令追加到磁盘 AOF 文件,从而及时记录内存数据的最新状态。这样即便 Redis 被 crash 或异常关闭后,再次启动,也可以通过加载 AOF,来恢复最新的全量数据,基本不会丢失数据。

AOF 文件中存储的协议是写指令的 multibulk 格式,这是 Redis 的标准协议格式,所以不同的 Redis 版本均可解析并处理,兼容性很好。

但是,由于 Redis 会记录所有写指令操作到 AOF,大量的中间状态数据,甚至被删除的过期数据,都会存在 AOF 中,冗余度很大,而且每条指令还需通过加载和执行来进行数据恢复,耗时会比较大。


AOF 数据的落地流程如下。Redis 在处理完写指令后,首先将写指令写入 AOF 缓冲,然后通过 server_cron 定期将 AOF 缓冲写入文件缓冲。最后按照配置策略进行 fsync,将文件缓冲的数据真正同步写入磁盘。

Redis 通过 appendfsync 来设置三种不同的同步文件缓冲策略。

第一种配置策略是 no,即 Redis 不主动使用 fsync 进行文件数据同步落地,而是由操作系统的 write 函数去确认同步时间,在 Linux 系统中大概每 30 秒会进行一次同步,如果 Redis 发生 crash,就会造成大量的数据丢失。

第二种配置策略是 always,即每次将 AOF 缓冲写入文件,都会调用 fsync 强制将内核数据写入文件,安全性最高,但性能上会比较低效,而且由于频繁的 IO 读写,磁盘的寿命会大大降低。

第三种配置策略是 everysec。即每秒通过 BIO 线程进行一次 fsync。这种策略在安全性、性能,以及磁盘寿命之间做较好的权衡,可以较好的满足线上业务需要。

随着时间的推移,AOF 持续记录所有的写指令,AOF 会越来越大,而且会充斥大量的中间数据、过期数据,为了减少无效数据,提升恢复时间,可以定期对 AOF 进行 rewrite 操作。


AOF 的 rewrite 操作可以通过运维执行 bgrewiretaof 命令来进行,也可以通过配置重写策略进行,由 Redis 自动触发进行。当对 AOF 进行 rewrite 时,首先会 fork 一个子进程。子进程轮询所有 RedisDB 快照,将所有内存数据转为 cmd,并写入临时文件。在子进程 rewriteaof 时,主进程可以继续执行用户请求,执行完毕后将写指令写入旧的 AOF 文件和 rewrite 缓冲。子进程将 RedisDB 中数据落地完毕后,通知主进程。主进程从而将 AOF rewite 缓冲数据写入 AOF 临时文件,然后用新的 AOF 文件替换旧的 AOF 文件,最后通过 BIO 线程异步关闭旧的 AOF 文件。至此,AOF 的 rewrite 过程就全部完成了。

AOF 重写的过程,是一个轮询全部 RedisDB 快照,逐一落地的过程。每个 DB,首先通过 select  $db 来记录待落的 DBID。然后通过命令记录每个 key/value。对于数据类型为 SDS 的value,可以直接落地。但如果 value 是聚合类型,则会将所有元素设为批量添加指令,进行落地。


对于 list 列表类型,通过 RPUSH 指令落地所有列表元素。对于 set 集合,会用 SADD 落地所有集合元素。对于 Zset 有序集合,会用 Zadd 落地所有元素,而对于 Hash 会用 Hmset 落地所有哈希元素。如果数据带过期时间,还会通过 pexpireat 来记录数据的过期时间。

AOF 持久化的优势是可以记录全部的最新内存数据,最多也就是 1-2 秒的数据丢失。同时 AOF 通过 Redis 协议来追加记录数据,兼容性高,而且可以持续轻量级的保存最新数据。最后因为是直接通过 Redis 协议存储,可读性也比较好。


AOF 持久化的不足是随着时间的增加,冗余数据增多,文件会持续变大,而且数据恢复需要读取所有命令并执行,恢复速度相对较慢。

混合持久化

Redis 在 4.0 版本之后,引入了混合持久化方式,而且在 5.0 版本后默认开启。前面讲到 RDB 加载速度快,但构建慢,缺少最新数据。AOF 持续追加最新写记录,可以包含所有数据,但冗余大,加载速度慢。混合模式一体化使用 RDB 和 AOF,综合 RDB 和 AOF 的好处。即可包含全量数据,加载速度也比较快。可以使用 aof-use-rdb-preamble 配置来明确打开混合持久化模式。


混合持久化也是通过 bgrewriteaof 来实现的。当启用混合存储后,进行 bgrewriteaof 时,主进程首先依然是 fork 一个子进程,子进程首先将内存数据以 RDB 的二进制格式写入 AOF 临时文件中。然后,再将落地期间缓冲的新增写指令,以命令的方式追加到临时文件。然后再通知主进程落地完毕。主进程将临时文件修改为 AOF 文件,并关闭旧的  AOF 文件。这样主体数据以 RDB 格式存储,新增指令以命令方式追加的混合存储方式进行持久化。后续执行的任务,以正常的命令方式追加到新的 AOF 文件即可。

混合持久化综合了 RDB 和 AOF 的优缺点,优势是包含全量数据,加载速度快。不足是头部的 RDB 格式兼容性和可读性较差。

Redis是如何处理容易超时的系统调用的?

BIO 线程简介

Redis 在运行过程中,不可避免的会产生一些运行慢的、容易引发阻塞的任务,如将内核中的文件缓冲同步到磁盘中、关闭文件,都会引发短时阻塞,还有一些大 key,如一些元素数高达万级或更多的聚合类元素,在删除时,由于所有元素需要逐一释放回收,整个过程耗时也会比较长。而 Redis 的核心处理线程是单进程单线程模型,所有命令的接受与处理、数据淘汰等都在主线程中进行,这些任务处理速度非常快。如果核心单线程还要处理那些慢任务,在处理期间,势必会阻塞用户的正常请求,导致服务卡顿。为此,Redis 引入了 BIO 后台线程,专门处理那些慢任务,从而保证和提升主线程的处理能力。

Redis 的 BIO 线程采用生产者-消费者模型。主线程是生产者,生产各种慢任务,然后存放到任务队列中。BIO 线程是消费者,从队列获取任务并进行处理。如果生产者生产任务过快,队列可用于缓冲这些任务,避免负荷过载或数据丢失。如果消费者处理速度很快,处理完毕后就可以安静的等待,不增加额外的性能开销。再次,有新任务时,主线程通过条件变量来通知 BIO 线程,这样 BIO 线程就可以再次执行任务。

BIO 处理任务     

Redis 启动时,会创建三个任务队列,并对应构建 3 个 BIO 线程,三个 BIO 线程与 3 个任务队列之间一一对应。BIO 线程分别处理如下 3 种任务。

close 关闭文件任务。rewriteaof 完成后,主线程需要关闭旧的 AOF 文件,就向 close 队列插入一个旧 AOF 文件的关闭任务。由 close 线程来处理。

fysnc 任务。Redis 将 AOF 数据缓冲写入文件内核缓冲后,需要定期将系统内核缓冲数据写入磁盘,此时可以向 fsync 队列写入一个同步文件缓冲的任务,由 fsync 线程来处理。

lazyfree 任务。Redis 在需要淘汰元素数大于 64 的聚合类数据类型时,如列表、集合、哈希等,就往延迟清理队列中写入待回收的对象,由 lazyfree 线程后续进行异步回收。

BIO 处理流程

BIO 线程的整个处理流程如图所示。当主线程有慢任务需要异步处理时。就会向对应的任务队列提交任务。提交任务时,首先申请内存空间,构建 BIO 任务。然后对队列锁进行加锁,在队列尾部追加新的 BIO 任务,最后尝试唤醒正在等待任务的 BIO 线程。

BIO 线程启动时或持续处理完所有任务,发现任务队列为空后,就会阻塞,并等待新任务的到来。当主线程有新任务后,主线程会提交任务,并唤醒 BIO 线程。BIO 线程随后开始轮询获取新任务,并进行处理。当处理完所有 BIO 任务后,则再次进入阻塞,等待下一轮唤醒。

如何大幅成倍提升Redis处理性能?

本课时我们主要学习如何通过 Redis 多线程来大幅提升性能,涉及主线程与 IO 线程、命令处理流程,以及多线程方案的优劣等内容。

主线程      

Redis 自问世以来,广受好评,应用广泛。但相比, Memcached 单实例压测 TPS 可以高达百万,线上可以稳定跑 20~40 万而言,Redis 的单实例压测 TPS 不过 10~12 万,线上一般最高也就 2~4 万,仍相差一个数量级。

Redis 慢的主要原因是单进程单线程模型。虽然一些重量级操作也进行了分拆,如 RDB 的构建在子进程中进行,文件关闭、文件缓冲同步,以及大 key 清理都放在 BIO 线程异步处理,但还远远不够。线上 Redis 处理用户请求时,十万级的 client 挂在一个 Redis 实例上,所有的事件处理、读请求、命令解析、命令执行,以及最后的响应回复,都由主线程完成,纵然是 Redis 各种极端优化,巧妇难为无米之炊,一个线程的处理能力始终是有上限的。当前服务器 CPU 大多是 16 核到 32 核以上,Redis 日常运行主要只使用 1 个核心,其他 CPU 核就没有被很好的利用起来,Redis 的处理性能也就无法有效地提升。而 Memcached 则可以按照服务器的 CPU 核心数,配置数十个线程,这些线程并发进行 IO 读写、任务处理,处理性能可以提高一个数量级以上。

IO 线程      

面对性能提升困境,虽然 Redis 作者不以为然,认为可以通过多部署几个 Redis 实例来达到类似多线程的效果。但多实例部署则带来了运维复杂的问题,而且单机多实例部署,会相互影响,进一步增大运维的复杂度。为此,社区一直有种声音,希望 Redis 能开发多线程版本。

因此,Redis 即将在 6.0 版本引入多线程模型,当前代码在 unstable 版本中,6.0 版本预计在明年发版。Redis 的多线程模型,分为主线程和 IO 线程。

因为处理命令请求的几个耗时点,分别是请求读取、协议解析、协议执行,以及响应回复等。所以 Redis 引入 IO 多线程,并发地进行请求命令的读取、解析,以及响应的回复。而其他的所有任务,如事件触发、命令执行、IO 任务分发,以及其他各种核心操作,仍然在主线程中进行,也就说这些任务仍然由单线程处理。这样可以在最大程度不改变原处理流程的情况下,引入多线程。

命令处理流程       

Redis 6.0 的多线程处理流程如图所示。主线程负责监听端口,注册连接读事件。当有新连接进入时,主线程 accept 新连接,创建 client,并为新连接注册请求读事件。

当请求命令进入时,在主线程触发读事件,主线程此时并不进行网络 IO 的读取,而将该连接所在的 client 加入待读取队列中。Redis 的 Ae 事件模型在循环中,发现待读取队列不为空,则将所有待读取请求的 client 依次分派给 IO 线程,并自旋检查等待,等待 IO 线程读取所有的网络数据。所谓自旋检查等待,也就是指主线程持续死循环,并在循环中检查 IO 线程是否读完,不做其他任何任务。只有发现 IO 线程读完所有网络数据,才停止循环,继续后续的任务处理。

一般可以配置多个 IO 线程,比如配置 4~8 个,这些 IO 线程发现待读取队列中有任务时,则开始并发处理。每个 IO 线程从对应列表获取一个任务,从里面的 client 连接中读取请求数据,并进行命令解析。当 IO 线程完成所有的请求读取,并完成解析后,待读取任务数变为 0。主线程就停止循环检测,开始依次执行 IO 线程已经解析的所有命令,每执行完毕一个命令,就将响应写入 client 写缓冲,这些 client 就变为待回复 client,这些待回复 client 被加入待回复列表。然后主线程将这些待回复 client,轮询分配给多个 IO 线程。然后再次自旋检测等待。

然后 IO 线程再次开始并发执行,将不同 client 的响应缓冲写给 client。当所有响应全部处理完后,待回复的任务数变为 0,主线程结束自旋检测,继续处理后续的任务,以及新的读请求。

Redis 6.0 版本中新引入的多线程模型,主要是指可配置多个 IO 线程,这些线程专门负责请求读取、解析,以及响应的回复。通过 IO 多线程,Redis 的性能可以提升 1 倍以上。

多线程方案优劣      

虽然多线程方案能提升1倍以上的性能,但整个方案仍然比较粗糙。首先所有命令的执行仍然在主线程中进行,存在性能瓶颈。然后所有的事件触发也是在主线程中进行,也依然无法有效使用多核心。而且,IO 读写为批处理读写,即所有 IO 线程先一起读完所有请求,待主线程解析处理完毕后,所有 IO 线程再一起回复所有响应,不同请求需要相互等待,效率不高。最后在 IO 批处理读写时,主线程自旋检测等待,效率更是低下,即便任务很少,也很容易把 CPU 打满。整个多线程方案比较粗糙,所以性能提升也很有限,也就 1~2 倍多一点而已。要想更大幅提升处理性能,命令的执行、事件的触发等都需要分拆到不同线程中进行,而且多线程处理模型也需要优化,各个线程自行进行 IO 读写和执行,互不干扰、等待与竞争,才能真正高效地利用服务器多核心,达到性能数量级的提升。

主从复制

Redis 复制原理 

为了避免单点故障,数据存储需要进行多副本构建。同时由于 Redis 的核心操作是单线程模型的,单个 Redis 实例能处理的请求 TPS 有限。因此 Redis 自面世起,基本就提供了复制功能,而且对复制策略不断进行优化。


通过数据复制,Redis 的一个 master 可以挂载多个 slave,而 slave 下还可以挂载多个 slave,形成多层嵌套结构。所有写操作都在 master 实例中进行,master 执行完毕后,将写指令分发给挂在自己下面的 slave 节点。slave 节点下如果有嵌套的 slave,会将收到的写指令进一步分发给挂在自己下面的 slave。通过多个 slave,Redis 的节点数据就可以实现多副本保存,任何一个节点异常都不会导致数据丢失,同时多 slave 可以 N 倍提升读性能。master 只写不读,这样整个 master-slave 组合,读写能力都可以得到大幅提升。


master 在分发写请求时,同时会将写指令复制一份存入复制积压缓冲,这样当 slave 短时间断开重连时,只要 slave 的复制位置点仍然在复制积压缓冲,则可以从之前的复制位置点之后继续进行复制,提升复制效率。

主库 master 和从库 slave 之间通过复制 id 进行匹配,避免 slave 挂到错误的 master。Redis 的复制分为全量同步和增量同步。Redis 在进行全量同步时,master 会将内存数据通过 bgsave 落地到 rdb,同时,将构建 内存快照期间 的写指令,存放到复制缓冲中,当 rdb 快照构建完毕后,master 将 rdb 和复制缓冲队列中的数据全部发送给 slave,slave 完全重新创建一份数据。这个过程,对 master 的性能损耗较大,slave 构建数据的时间也比较长,而且传递 rdb 时还会占用大量带宽,对整个系统的性能和资源的访问影响都比较大。而增量复制,master 只发送 slave 上次复制位置之后的写指令,不用构建 rdb,而且传输内容非常有限,对 master、slave 的负荷影响很小,对带宽的影响可以忽略,整个系统受影响非常小。


在 Redis 2.8 之前,Redis 基本只支持全量复制。在 slave 与 master 断开连接,或 slave 重启后,都需要进行全量复制。在 2.8 版本之后,Redis 引入 psync,增加了一个复制积压缓冲,在将写指令同步给 slave 时,会同时在复制积压缓冲中也写一份。在 slave 短时断开重连后,上报master runid 及复制偏移量。如果 runid 与 master 一致,且偏移量仍然在 master 的复制缓冲积压中,则 master 进行增量同步。

但如果 slave 重启后,master runid 会丢失,或者切换 master 后,runid 会变化,仍然需要全量同步。因此 Redis 自 4.0 强化了 psync,引入了 psync2。在 pysnc2 中,主从复制不再使用 runid,而使用 replid(即复制id) 来作为复制判断依据。同时 Redis 实例在构建 rdb 时,会将 replid 作为 aux 辅助信息存入 rbd。重启时,加载 rdb 时即可得到 master 的复制 id。从而在 slave 重启后仍然可以增量同步。

在 psync2 中,Redis 每个实例除了会有一个复制 id 即 replid 外,还有一个 replid2。Redis 启动后,会创建一个长度为 40 的随机字符串,作为 replid 的初值,在建立主从连接后,会用 master的 replid 替换自己的 replid。同时会用 replid2 存储上次 master 主库的 replid。这样切主时,即便 slave 汇报的复制 id 与新 master 的 replid 不同,但和新 master 的 replid2 相同,同时复制偏移仍然在复制积压缓冲区内,仍然可以实现增量复制。

Redis 复制分析 

在设置 master、slave 时,首先通过配置或者命令 slaveof no one 将节点设置为主库。然后其他各个从库节点,通过 slaveof $master_ip $master_port,将其他从库挂在到 master 上。同样方法,还可以将 slave 节点挂载到已有的 slave 节点上。在准备开始数据复制时,slave 首先会主动与 master 创建连接,并上报信息。具体流程如下。

slave 创建与 master 的连接后,首先发送 ping 指令,如果 master 没有返回异常,而是返回 pong,则说明 master 可用。如果 Redis 设置了密码,slave 会发送 auth $masterauth 指令,进行鉴权。当鉴权完毕,从库就通过 replconf 发送自己的端口及 IP 给 master。接下来,slave 继续通过 replconf 发送 capa eof capa psync2 进行复制版本校验。如果 master 校验成功。从库接下来就通过 psync 将自己的复制 id、复制偏移发送给 master,正式开始准备数据同步。


主库接收到从库发来的 psync 指令后,则开始判断可以进行数据同步的方式。前面讲到,Redis 当前保存了复制 id,replid 和 replid2。如果从库发来的复制 id,与 master 的复制 id(即 replid 和 replid2)相同,并且复制偏移在复制缓冲积压中,则可以进行增量同步。master 发送 continue 响应,并返回 master 的 replid。slave 将 master 的 replid 替换为自己的 replid,并将之前的复制 id 设置为 replid2。之后,master 则可继续发送,复制偏移位置 之后的指令,给 slave,完成数据同步。


如果主库发现从库传来的复制 id 和自己的 replid、replid2 都不同,或者复制偏移不在复制积压缓冲中,则判定需要进行全量复制。master 发送 fullresync 响应,附带 replid 及复制偏移。然后, master 根据需要构建 rdb,并将 rdb 及复制缓冲发送给 slave。


对于增量复制,slave 接下来就等待接受 master 传来的复制缓冲及新增的写指令,进行数据同步。


而对于全量同步,slave 会首先进行,嵌套复制的清理工作,比如 slave 当前还有嵌套的 子slave,则该 slave 会关闭嵌套 子slave 的所有连接,并清理自己的复制积压缓冲。然后,slave 会构建临时 rdb 文件,并从 master 连接中读取 rdb 的实际数据,写入 rdb 中。在写 rdb 文件时,每写 8M,就会做一个 fsync操作, 刷新文件缓冲。当接受 rdb 完毕则将 rdb 临时文件改名为 rdb 的真正名字。


接下来,slave 会首先清空老数据,即删除本地所有 DB 中的数据,并暂时停止从 master 继续接受数据。然后,slave 就开始全力加载 rdb 恢复数据,将数据从 rdb 加载到内存。在 rdb 加载完毕后,slave 重新利用与 master 的连接 socket,创建与 master 连接的 client,并在此注册读事件,可以开始接受 master 的写指令了。此时,slave 还会将 master 的 replid 和复制偏移设为自己的复制 id 和复制偏移 offset,并将自己的 replid2 清空,因为,slave 的所有嵌套 子slave 接下来也需要进行全量复制。最后,slave 就会打开 aof 文件,在接受 master 的写指令后,执行完毕并写入到自己的 aof 中。     


相比之前的 sync,psync2 优化很明显。在短时间断开连接、slave 重启、切主等多种场景,只要延迟不太久,复制偏移仍然在复制积压缓冲,均可进行增量同步。master 不用构建并发送巨大的 rdb,可以大大减轻 master 的负荷和网络带宽的开销。同时,slave 可以通过轻量的增量复制,实现数据同步,快速恢复服务,减少系统抖动。


但是,psync 依然严重依赖于复制缓冲积压,太大会占用过多内存,太小会导致频繁的全量复制。而且,由于内存限制,即便设置相对较大的复制缓冲区,在 slave 断开连接较久时,仍然很容易被复制缓冲积压冲刷,从而导致全量复制。

Redis集群

Redis 集群的分布式方案主要有 3 种。分别是 Client 端分区方案,Proxy 分区方案,以及原生的 Redis Cluster 分区方案。

Client 端分区


Client 端分区方案就是由 Client 决定数据被存储到哪个 Redis 分片,或者由哪个 Redis 分片来获取数据。它的核心思想是通过哈希算法将不同的 key 映射到固定的 Redis 分片节点上。对于单个 key 请求,Client 直接对 key 进行哈希后,确定 Redis 分片,然后进行请求。而对于一个请求附带多个 key 的场景,Client 会首先将这些 key 按哈希分片进行分类,从而将一个请求分拆为多个请求,然后再分别请求不同的哈希分片节点。

Client 通过哈希算法将数据进行分布,一般采用的哈希算法是取模哈希、一致性哈希和区间分布哈希。前两种哈希算法之前的课程已有详细分析,此处不在赘述。对于区间分布哈希,实际是一种取模哈希的变种,取模哈希是哈希并取模计算后,按哈希值来分配存储节点,而区间哈希是在哈希计算后,将哈希划分为多个区间,然后将这些区间分配给存储节点。如哈希后分 1024 个哈希点,然后将 0~511 作为分片 1,将 512~1023 作为分片 2。


对于 Client 端分区,由于 Redis 集群有多个 master 分片,同时每个 master 下挂载多个 slave,每个 Redis 节点都有独立的 IP 和端口。如果 master 异常需要切换 master,或读压力过大需要扩展新的 slave,这些都会涉及集群存储节点的变更,需要 Client 端做连接切换。


为了避免 Client 频繁变更 IP 列表,可以采用 DNS 的方式来管理集群的主从。对 Redis 集群的每个分片的主和从均采用不同 DNS 域名。Client 通过域名解析的方式获取域名下的所有 IP,然后来访问集群节点。由于每个分片 master 下有多个 slave,Client 需要在多个 slave 之间做负载均衡。可以按照权重建立与 slave 之间的连接,然后访问时,轮询使用这些连接依次访问,即可实现按权重访问 slave 节点。

在 DNS 访问模式下,Client 需要异步定时探测主从域名,如果发现 IP 变更,及时与新节点建立连接,并关闭老连接。这样在主库故障需要切换时,或者从库需要增加减少时,任何分片的主从变化,只需运维或管理进程改一下 DNS 下的 IP 列表,业务 Client 端不需要做任何配置变更,即可正常切换访问。


Client 端分区方案的优点在于分区逻辑简单,配置简单,Client 节点之间和 Redis 节点之间均无需协调,灵活性强。而且 Client 直接访问对应 Redis 节点,没有额外环节,性能高效。但该方案扩展不便。在 Redis 端,只能成倍扩展,或者预先分配足够多的分片。在 Client 端,每次分片后,业务端需要修改分发逻辑,并进行重启。

Proxy 端分区      

Proxy 端分区方案是指 Client 发送请求给 Proxy 请求代理组件,Proxy 解析 Client 请求,并将请求分发到正确的 Redis 节点,然后等待 Redis 响应,最后再将结果返回给 Client 端。


如果一个请求包含多个 key,Proxy 需要将请求的多个 key,按分片逻辑分拆为多个请求,然后分别请求不同的 Redis 分片,接下来等待Redis响应,在所有的分拆响应到达后,再进行聚合组装,最后返回给 Client。在整个处理过程中,Proxy 代理首先要负责接受请求并解析,然后还要对 key 进行哈希计算及请求路由,最后还要将结果进行读取、解析及组装。如果系统运行中,主从变更或发生扩缩容,也只需由 Proxy 变更完成,业务 Client 端基本不受影响。


常见的 Proxy 端分区方案有 2 种,第一种是基于 Twemproxy 的简单分区方案,第二种是基于Codis 的可平滑数据迁移的分区方案。

Twemproxy 是 Twitter 开源的一个组件,支持 Redis 和 Memcached 协议访问的代理组件。在讲分布式 Memecached 实战时,我曾经详细介绍了它的原理和实现架构,此处不再赘述。总体而言,Twemproxy 实现简单、稳定性高,在一些访问量不大且很少发生扩缩的业务场景中,可以很好的满足需要。但由于 Twemproxy 是单进程单线程模型的,对包含多个 key 的 mutli 请求,由于需要分拆请求,然后再等待聚合,处理性能较低。而且,在后端 Redis 资源扩缩容,即增加或减少分片时,需要修改配置并重启,无法做到平滑扩缩。而且 Twemproxy 方案默认只有一个代理组件,无管理后端,各种运维变更不够便利。


而 Codis 是一个较为成熟的分布式 Redis 解决方案。对于业务 Client 访问,连接 Codis-proxy 和连接单个 Redis 几乎没有区别。Codis 底层除了会自动解析分发请求之外,还可以在线进行数据迁移,使用非常方便。

Codis 系统主要由 Codis-server、Codis-proxy、Codis-dashboard、Zookeeper 等组成。 

Codis-server 是 Codis 的存储组件,它是基于 Redis 的扩展,增加了 slot 支持和数据迁移功能,所有数据存储在预分配的 1024 个 slot 中,可以按 slot 进行同步或异步数据迁移。

Codis-proxy 处理 Client 请求,解析业务请求,并路由给后端的 Codis-server group。Codis 的每个 server group 相当于一个 Redis 分片,由 1 个 master 和 N 个从库组成。

Zookeeper 用于存储元数据,如 Proxy 的节点,以及数据访问的路由表。除了 Zookeeper,Codis 也支持 etcd 等其他组件,用于元数据的存储和通知。

Codis-dashboard 是 Codis 的管理后台,可用于管理数据节点、Proxy 节点的加入或删除,还可用于执行数据迁移等操作。Dashboard 的各项变更指令通过 Zookeeper 进行分发。

Codis 提供了功能较为丰富的管理后台,可以方便的对整个集群进行监控及运维。      

Proxy 端分区方案的优势,是 Client 访问逻辑和 Redis 分布逻辑解耦,业务访问便捷简单。在资源发生变更或扩缩容时,只用修改数量有限的 Proxy 即可,数量庞大的业务 Client 端不用做调整。

但 Proxy 端分区的方案,访问时请求需要经过 Proxy 中转,访问多跳了一级,性能会存在损耗,一般损耗会达到 5~15% 左右。另外多了一个代理层,整个系统架构也会更复杂。

Redis Cluster 分区      

Redis 社区版在 3.0 后开始引入 Cluster 策略,一般称之为 Redis-Cluster 方案。Redis-Cluster 按 slot 进行数据的读写和管理,一个 Redis-Cluster 集群包含 16384 个 slot。每个 Redis 分片负责其中一部分 slot。在集群启动时,按需将所有 slot 分配到不同节点,在集群系统运行后,按 slot 分配策略,将 key 进行 hash 计算,并路由到对应节点 访问。

随着业务访问模型的变化,Redis 部分节点可能会出现压力过大、访问不均衡的现象,此时可以将 slot 在 Redis 分片节点内部进行迁移,以均衡访问。如果业务不断发展,数据量过大、TPS过高,还可以将 Redis 节点的部分 slot 迁移到新节点,增加 Redis-Cluster 的分片,对整个 Redis 资源进行扩容,已提升整个集群的容量及读写能力。


在启动 Redis 集群时,在接入数据读写前,可以通过 Redis 的 Cluster addslots 将 16384 个 slot 分配给不同的 Redis 分片节点,同时可以用 Cluster delslots 去掉某个节点的 slot,用 Cluster flushslots 清空某个节点的所有 slot 信息,来完成 slot 的调整。

Redis Cluster 是一个去中心化架构,每个节点记录全部 slot 的拓扑分布。这样 Client 如果把 key 分发给了错误的 Redis 节点,Redis 会检查请求 key 所属的 slot,如果发现 key 属于其他节点的 slot,会通知 Client 重定向到正确的 Redis 节点访问。

Redis Cluster 下的不同 Redis 分片节点通过 gossip 协议进行互联,使用 gossip 的优势在于,该方案无中心控制节点,这样,更新不会受到中心节点的影响,可以通过通知任意一个节点来进行管理通知。不足就是元数据的更新会有延时,集群操作会在一定的时延后才会通知到所有Redis。由于 Redis Cluster 采用 gossip 协议进行服务节点通信,所以在进行扩缩容时,可以向集群内任何一个节点,发送 Cluster meet 指令,将新节点加入集群,然后集群节点会立即扩散新节点,到整个集群。meet 新节点操作的扩散,只需要有一条节点链能到达集群各个节点即可,无需 meet 所有集群节点,操作起来比较便利。


在 Redis-Cluster 集群中,key 的访问需要 smart client 配合。Client 首先发送请求给 Redis 节点,Redis 在接受并解析命令后,会对 key 进行 hash 计算以确定 slot 槽位。计算公式是对 key 做 crc16 哈希,然后对 16383 进行按位与操作。如果 Redis 发现 key 对应的 slot 在本地,则直接执行后返回结果。


如果 Redis 发现 key 对应的 slot 不在本地,会返回 moved 异常响应,并附带 key 的 slot,以及该 slot 对应的正确 Redis 节点的 host 和 port。Client 根据响应解析出正确的节点 IP 和端口,然后把请求重定向到正确的 Redis,即可完成请求。为了加速访问,Client 需要缓存 slot 与 Redis 节点的对应关系,这样可以直接访问正确的节点,以加速访问性能。


Redis-Cluster 提供了灵活的节点扩缩容方案,可以在不影响用户访问的情况下,动态为集群增加节点扩容,或下线节点为集群缩容。由于扩容在线上最为常见,我首先来分析一下 Redis-Cluster 如何进行扩容操作。

在准备对 Redis 扩容时,首先准备待添加的新节点,部署 Redis,配置 cluster-enable 为 true,并启动。然后运维人员,通过client连接上一个集群内的 Redis 节点,通过 cluster meet 命令将新节点加入到集群,该节点随后会通知集群内的其他节点,有新节点加入。因为新加入的节点还没有设置任何 slot,所以不接受任何读写操作。

然后,将通过 cluster setslot $slot importing 指令,在新节点中,将目标 slot 设为 importing 导入状态。再将 slot 对应的源节点,通过 cluster setslot $slot migrating 将源节点的 slot 设为 migrating 迁移导出状态。


接下来,就从源节点获取待迁移 slot 的 key,通过 cluster getkeysinslot $slot $count 命令,从 slot 中获取 N 个待迁移的 key。然后通过 migrate 指令,将这些 key 依次逐个迁移或批量一次迁移到目标新节点。对于迁移单个 key,使用指令 migrate $host $port $key $dbid timeout,如果一次迁移多个 key,在指令结尾加上 keys 选项,同时将多个 key 放在指令结尾即可。持续循环前面 2 个步骤,不断获取 slot 里的 key,然后进行迁移,最终将 slot 下的所有数据都迁移到目标新节点。最后通过 cluster setslot 指令将这个 slot 指派给新增节点。setslot 指令可以发给集群内的任意一个节点,这个节点会将这个指派信息扩散到整个集群。至此,slot 就迁移到了新节点。如果要迁移多个 slot,可以继续前面的迁移步骤,最终将所有需要迁移的 slot 数据搬到新节点。

这个新迁移 slot 的节点属于主库,对于线上应用,还需要增加从库,以增加读写能力及可用性,否则一旦主库崩溃,整个分片的数据就无法访问。在节点上增加从库,需要注意的是,不能使用非集群模式下的 slaveof 指令,而要使用 cluster replication,才能完成集群分片节点下的 slave 添加。另外,对于集群模式,slave 只能挂在分片 master 上,slave 节点自身不能再挂载 slave。


缩容流程与扩容流程类似,只是把部分节点的 slot 全部迁移走,然后把这些没有 slot 的节点进行下线处理。在下线老节点之前,需要注意,要用 cluster forget 通知集群,集群节点要,从节点信息列表中,将目标节点移除,同时会将该节点加入到禁止列表,1 分钟之内不允许再加入集群。以防止在扩散下线节点时,又被误加入集群。 

Redis 社区官方在源代码中也提供了 redis-trib.rb,作为 Redis Cluster 的管理工具。该工具用 Ruby 开发,所以在使用前,需要安装相关的依赖环境。redis-trib 工具通过封装前面所述的 Redis 指令,从而支持创建集群、检查集群、添加删除节点、在线迁移 slot 等各种功能。


Redis Cluster 在 slot 迁移过程中,获取key指令以及迁移指令逐一发送并执行,不影响 Client 的正常访问。但在迁移单条或多条 key 时,Redis 节点是在阻塞状态下进行的,也就是说,Redis 在迁移 key 时,一旦开始执行迁移指令,就会阻塞,直到迁移成功或确认失败后,才会停止该 key 的迁移,从而继续处理其他请求。slot 内的 key 迁移是通过 migrate 指令进行的。

在源节点接收到 migrate $host $port $key $destination-db 的指令后,首先 slot 迁移的源节点会与迁移的目标节点建立 socket 连接,第一次迁移,或者迁移过程中,当前待迁移的 DB 与前一次迁移的 DB 不同,在迁移数据前,还需要发送 select $dbid 进行切换到正确的 DB。


然后,源节点会轮询所有待迁移的 key/value。获取 key 的过期时间,并将 value 进行序列化,序列化过程就是将 value 进行 dump,转换为类 rdb 存储的二进制格式。这个二进制格式分 3 部分。第一部分是 value 对象的 type。第二部分是 value 实际的二进制数据;第三部分是当前 rdb 格式的版本,以及该 value 的 CRC64 校验码。至此,待迁移发送的数据准备完毕,源节点向目标节点,发送 restore-asking 指令,将过期时间、key、value 的二进制数据发送给目标节点。然后同步等待目标节点的响应结果。


目标节点对应的client,收到指令后,如果有 select 指令,就首先切换到正确的 DB。接下来读取并处理 resotre-asking 指令,处理 restore-asking 指令时,首先对收到的数据进行解析校验,获取 key 的 ttl,校验 rdb 版本及 value 数据 cc64 校验码,确认无误后,将数据存入 redisDb,设置过期时间,并返回响应。

源节点收到目标节点处理成功的响应后。对于非 copy 类型的 migrate,会删除已迁移的 key。至此,key 的迁移就完成了。migrate 迁移指令,可以一次迁移一个或多个 key。注意,整个迁移过程中,源节点在发送 restore-asking 指令后,同步阻塞,等待目标节点完成数据处理,直到超时或者目标节点返回响应结果,收到结果后在本地处理完毕后序事件,才会停止阻塞,才能继续处理其他事件。所以,单次迁移的 key 不能太多,否则阻塞时间会较长,导致 Redis 卡顿。同时,即便单次只迁移一个 key,如果对应的 value 太大,也可能导致 Redis 短暂卡顿。


在 slot 迁移过程中,不仅其他非迁移 slot 的 key 可以正常访问,即便正在迁移的 slot,它里面的 key 也可以正常读写,不影响业务访问。但由于 key 的迁移是阻塞模式,即在迁移 key 的过程中,源节点并不会处理任何请求,所以在 slot 迁移过程中,待读写的 key 只有三种存在状态。

尚未被迁移,后续会被迁走;

已经被迁移;

这个 key 之前并不存在集群中,是一个新 key。   

slot 迁移过程中,对节点里的 key 处理方式如下。

对于尚未被迁移的 key,即从 DB 中能找到该 key,不管这个 key 所属的 slot 是否正在被迁移,都直接在本地进行读写处理。

对于无法从 DB 中找到 value 的 key,但key所属slot正在被迁移,包括已迁走或者本来不存在的 key 两种状态,Redis 返回 ask 错误响应,并附带 slot 迁移目标节点的 host 和 port。Client 收到 ask 响应后,将请求重定向到 slot 迁移的新节点,完成响应处理。

对于无法从 DB 中找到 value 的 key,且 key 所在的 slot 不属于本节点,说明 Client 发送节点有误,直接返回 moved 错误响应,也附带上 key 对应节点的 host 和 port,由 Client 重定向请求。     

对于 Redis Cluster 集群方案,由社区官方实现,并有 Redis-trib 集群工具,上线和使用起来比较便捷。同时它支持在线扩缩,可以随时通过工具查看集群的状态。但这种方案也存在不少弊端。首先,数据存储和集群逻辑耦合,代码逻辑复杂,容易出错。

其次,Redis 节点要存储 slot 和 key 的映射关系,需要额外占用较多内存,特别是对 value size 比较小、而key相对较大的业务,影响更是明显。

再次,key 迁移过程是阻塞模式,迁移大 value 会导致服务卡顿。而且,迁移过程,先获取 key,再迁移,效率低。最后,Cluster 模式下,集群复制的 slave 只能挂载到 master,不支持 slave 嵌套,会导致 master 的压力过大,无法支持那些,需要特别多 slave、读 TPS 特别大的业务场景。

Redis应对亿级QPS

众所周知,Redis 在线上实际运行时,面对海量数据、高并发访问,会遇到不少问题,需要进行针对性扩展及优化。本课时,我会结合微博在使用 Redis 中遇到的问题,来分析如何在生产环境下对 Redis 进行扩展改造,以应对百万级 QPS。

功能扩展      

对于线上较大流量的业务,单个 Redis 实例的内存占用很容易达到数 G 的容量,对应的 aof 会占用数十 G 的空间。即便每天流量低峰时间,对 Redis 进行 rewriteaof,减少数据冗余,但由于业务数据多,写操作多,aof 文件仍然会达到 10G 以上。

此时,在 Redis 需要升级版本或修复 bug 时,如果直接重启变更,由于需要数据恢复,这个过程需要近 10 分钟的时间,时间过长,会严重影响系统的可用性。面对这种问题,可以对 Redis 扩展热升级功能,从而在毫秒级完成升级操作,完全不影响业务访问。


热升级方案如下,首先构建一个 Redis 壳程序,将 redisServer 的所有属性(包括redisDb、client等)保存为全局变量。然后将 Redis 的处理逻辑代码全部封装到动态连接库 so 文件中。Redis 第一次启动,从磁盘加载恢复数据,在后续升级时,通过指令,壳程序重新加载 Redis 新的 so 文件,即可完成功能升级,毫秒级完成 Redis 的版本升级。而且整个过程中,所有 Client 连接仍然保留,在升级成功后,原有 Client 可以继续进行读写操作,整个过程对业务完全透明。


在 Redis 使用中,也经常会遇到一些特殊业务场景,是当前 Redis 的数据结构无法很好满足的。此时可以对 Redis 进行定制化扩展。可以根据业务数据特点,扩展新的数据结构,甚至扩展新的 Redis 存储模型,来提升 Redis 的内存效率和处理性能。


在微博中,有个业务类型是关注列表。关注列表存储的是一个用户所有关注的用户 uid。关注列表可以用来验证关注关系,也可以用关注列表,进一步获取所有关注人的微博列表等。由于用户数量过于庞大,存储关注列表的 Redis 是作为一个缓存使用的,即不活跃的关注列表会很快被踢出 Redis。在再次需要这个用户的关注列表时,重新从 DB 加载,并写回 Redis。关注列表的元素全部 long,最初使用 set 存储,回种 set 时,使用 sadd 进行批量添加。线上发现,对于关注数比较多的关注列表,比如关注数有数千上万个用户,需要 sadd 上成千上万个 uid,即便分几次进行批量添加,每次也会消耗较多时间,数据回种效率较低,而且会导致 Redis 卡顿。另外,用 set 存关注列表,内存效率也比较低。

于是,我们对 Redis 扩展了 longset 数据结构。longset 本质上是一个 long 型的一维开放数组。可以采用 double-hash 进行寻址。


从 DB 加载到用户的关注列表,准备写入 Redis 前。Client 首先根据关注的 uid 列表,构建成 long 数组的二进制格式,然后通过扩展的 lsset 指令写入 Redis。Redis 接收到指令后,直接将 Client 发来的二进制格式的 long 数组作为 value 值进行存储。

longset 中的 long 数组,采用 double-hash 进行寻址,即对每个 long 值采用 2 个哈希函数计算,然后按 (h1 + n*h2)% 数组长度 的方式,确定 long 值的位置。n 从 0 开始计算,如果出现哈希冲突,即计算的哈希位置,已经有其他元素,则 n 加 1,继续向前推进计算,最大计算次数是数组的长度。

在向 longset 数据结构不断增加 long 值元素的过程中,当数组的填充率超过阀值,Redis 则返回 longset 过满的异常。此时 Client 会根据最新全量数据,构建一个容量加倍的一维 long 数组,再次 lsset 回 Redis 中。


在移动社交平台中,庞大的用户群体,相互之间会关注、订阅,用户自己会持续分享各种状态,另外这些状体数据会被其他用户阅读、评论、扩散及点赞。这样,在用户维度,就有关注数、粉丝数、各种状态行为数,然后用户每发表的一条 feed、状态,还有阅读数、评论数、转发数、表态数等。一方面会有海量 key 需要进行计数,另外一方面,一个 key 会有 N 个计数。在日常访问中,一次查询,不仅需要查询大量的 key,而且对每个 key 需要查询多个计数。

以微博为例,历史计数高达千亿级,而且随着每日新增数亿条 feed 记录,每条记录会产生 4~8 种计数,如果采用 Redis 的计数,仅仅单副本存储,历史数据需要占用 5~6T 以上的内存,每日新增 50G 以上,如果再考虑多 IDC、每个 IDC 部署 1 主多从,占用内存还要再提升一个数量级。由于微博计数,所有的 key 都是随时间递增的 long 型值,于是我们改造了 Redis 的存储结构。

首先采用 cdb 分段存储计数器,通过预先分配的内存数组 Table 存储计数,并且采用 double hash 解决冲突,避免 Redis 实现中的大量指针开销。 然后,通过 Schema 策略支持多列,一个 key id 对应的多个计数可以作为一条计数记录,还支持动态增减计数列,每列的计数内存使用精简到 bit。而且,由于 feed 计数冷热区分明显,我们进行冷热数据分离存储方案,根据时间维度,近期的热数据放在内存,之前的冷数据放在磁盘, 降低机器成本。

关于计数器服务的扩展,后面的案例分析课时,我会进一步深入介绍改造方案。


线上 Redis 使用,不管是最初的 sync 机制,还是后来的 psync 和 psync2,主从复制都会受限于复制积压缓冲。如果 slave 断开复制连接的时间较长,或者 master 某段时间写入量过大,而 slave 的复制延迟较大,slave 的复制偏移量落在 master 的复制积压缓冲之外,则会导致全量复制。

完全增量复制

于是,微博整合 Redis 的 rdb 和 aof 策略,构建了完全增量复制方案。


在完全增量方案中,aof 文件不再只有一个,而是按后缀 id 进行递增,如 aof.00001、aof.00002,当 aof 文件超过阀值,则创建下一个 id 加 1 的文件,从而滚动存储最新的写指令。在 bgsave 构建 rdb 时,rdb 文件除了记录当前的内存数据快照,还会记录 rdb 构建时间,对应 aof 文件的 id 及位置。这样 rdb 文件和其记录 aof 文件位置之后的写指令,就构成一份完整的最新数据记录。

主从复制时,master 通过独立的复制线程向 slave 同步数据。每个 slave 会创建一个复制线程。第一次复制是全量复制,之后的复制,不管 slave 断开复制连接有多久,只要 aof 文件没有被删除,都是增量复制。

第一次全量复制时,复制线程首先将 rdb 发给 slave,然后再将 rdb 记录的 aof 文件位置之后的所有数据,也发送给 slave,即可完成。整个过程不用重新构建 rdb。


后续同步时,slave 首先传递之前复制的 aof 文件的 id 及位置。master 的复制线程根据这个信息,读取对应 aof 文件位置之后的所有内容,发送给 slave,即可完成数据同步。

由于整个复制过程,master 在独立复制线程中进行,所以复制过程不影响用户的正常请求。为了减轻 master 的复制压力,全增量复制方案仍然支持 slave 嵌套,即可以在 slave 后继续挂载多个 slave,从而把复制压力分散到多个不同的 Redis 实例。

集群管理


前面讲到,Redis-Cluster 的数据存储和集群逻辑耦合,代码逻辑复杂易错,存储 slot 和 key 的映射需要额外占用较多内存,对小 value 业务影响特别明显,而且迁移效率低,迁移大 value 容易导致阻塞,另外,Cluster 复制只支持 slave 挂在 master 下,无法支持 需要较多slave、读 TPS 特别大的业务场景。除此之外,Redis 当前还只是个存储组件,线上运行中,集群管理、日常维护、状态监控报警等这些功能,要么没有支持,要么支持不便。

因此我们也基于 Redis 构建了集群存储体系。首先将 Redis 的集群功能剥离到独立系统,Redis 只关注存储,不再维护 slot 等相关的信息。通过新构建的 clusterManager 组件,负责 slot 维护,数据迁移,服务状态管理。

Redis 集群访问可以由 proxy 或 smart client 进行。对性能特别敏感的业务,可以通过 smart client 访问,避免访问多一跳。而一般业务,可以通过 Proxy 访问 Redis。

业务资源的部署、Proxy 的访问,都通过配置中心进行获取及协调。clusterManager 向配置中心注册业务资源部署,并持续探测服务状态,根据服务状态进行故障转移,切主、上下线 slave 等。proxy 和 smart client 从配置中心获取配置信息,并持续订阅服务状态的变化。

你可能感兴趣的:(分布式缓存)