Redis
{Back to Index}
Table of Contents
1 概述
Figure 1: 技术分布
1.1 底层数据结构
Figure 2: 数据类型和底层数据结构对应关系
Figure 3: 不同数据结构的时间复杂度
1.1.1 压缩列表
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes
、 zltail
和 zllen
,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend
,表示列表结束。
Figure 4: 压缩列表结构
在压缩列表中,如果要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)
。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N)
了。
1.1.2 跳表 1
1.1.3 重哈希(rehash)
Redis 使用了一个哈希表来保存所有键值对,因为这个哈希表保存了所有的键值对,可以把它称为全局哈希表。当往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,也就是指,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。
Redis 解决哈希冲突的方式,就是链式哈希。链式哈希就是指 同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。
如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。所以,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
- 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍
- 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中
- 释放哈希表 1 的空间。
第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求,为了避免这个问题,Redis 采用了 渐进式 rehash 。 就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries 。这样就 把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
Figure 5: 渐进式重哈希过程
在 rehash 被触发后,即使没有收到新请求,Redis 也会定时执行一次 rehash 操作(通过定时任务),而且,每次执行时长不会超过 1ms,以免对其他任务造成影响。 [所谓的定时任务,就是按照一定频率(例如每 100ms/ 次)执行的任务]
1.1.3.1 重哈希对 SCAN 的影响 2
在使用 SCAN 命令时,不会漏 bey ,但可能会得到重复的 key (遍历时遇到缩容重哈希)
1.1.3.1.1 扩容情况
遇到扩容时不会漏 key 是因为 Redis 在 SCAN 遍历全局哈希表时,采用 高位进位法 的方式遍历哈希桶,通过这种算法遍历可以保证 遍历时就算遇到扩容也不会重复。
关于这点,我的理解是不管使用什么方式遍历哈希桶,在遇到扩容的时候都不会漏 key ,使用高位进位法只是为了达到不重复的目的。
Figure 6: 高位进位法
Figure 7: 对比扩容/缩容前后的遍历顺序
从上图看出,假设当前有 8 个槽,使用高位进位法的遍历顺序为: 0, 4, 2, 6, 1, 5, 3, 7 ,即使遍历到某个槽时,发现已完成扩容,则继续按照扩容后的顺序继续遍历即可,不会发生重复遍历的情况,非常巧妙。
1.1.3.1.2 缩容情况
遇到缩容时 SCAN 会得到重复的 key ,比如假设正要遍历 6 号槽,发现已完成缩容,这时就要跳到上一层的 2 号槽继续遍历,而 2 号槽可能已经包含原 2 号槽的数据,再次遍历就造成重复了。
1.1.3.1.3 渐进式 rehash 情况 3
操作处于 rehash 中的 Redis ,需要同时访问新旧两个数组结构(全局哈希表)。如果在旧数组下面找不到元素,还需要去新数组下面寻找。
如果这时候做 SCAN 也是一样的,正常遍历当前槽,如果发现开始渐进式 reharsh 了, 则将当前槽的数据和新表中的对应槽(扩容是 2 个新槽,缩容是 1 个新槽)里的数据做 融合 后再返回。
1.2 单线程模型
通常说,Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。
但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由 额外的线程执行的。
1.2.1 IO 瓶颈
Redis 单线程处理 IO 请求性能瓶颈主要包括 2 个方面:
- 任意一个请求一旦发生耗时,都会影响整体性能,后面的请求都要等前面这个耗时请求处理完成,才能被处理到。耗时的操作包括以下几种:
- 操作 bigkey :写入一个 bigkey 在分配内存时需要消耗更多的时间,同样,删除 bigkey 释放内存同样会产生耗时
- 使用复杂度过高的命令:例如
SORT/SUNION/ZUNIONSTORE
,或者O(N)
命令,但是 N 很大,例如lrange key 0 -1
一次查询全量数据 - 大量 key 集中过期:Redis的 过期机制也是在主线程中执行的 ,大量 key 集中过期会导致处理一个请求时,耗时都在删除过期 key ,耗时变长
- 淘汰策略: 淘汰策略也是在主线程执行的 ,当内存超过 Redis 内存上限后,每次写入都需要淘汰一些 key ,也会造成耗时变长
- AOF 刷盘开启 always 机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢 Redis 的性能
- 主从全量同步生成 RDB:虽然采用 fork 子进程生成数据快照,但 fork 这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久
- 并发量非常大时,单线程读写客户端 IO 数据存在性能瓶颈,虽然采用 IO 多路复用机制,但是读写客户端数据依旧是同步 IO ,只能单线程依次读取客户端的数据,无法利用到 CPU 多核
- 针对问题 1 ,需要业务人员去规避,另一方面 Redis 在 4.0 推出了 lazy-free 机制,把 bigkey 释放内存的耗时操作放在了异步线程中执行,降低对主线程的影响
- 针对问题 2 ,Redis 在 6.0 推出了多线程,可以在高并发场景下利用 CPU 多核多线程读写客户端数据,进一步提升性能,当然,只是针对客户端的读写是并行的, 每个命令的真正操作依旧是单线程的。
2 持久化 (若 REDIS 仅用来做缓存,可以不使用持久化)
2.1 RDB (一般使用已足够,追求极致用主从复制)
Figure 8: bgsave 运作流程
2.1.1 优点
REDIS 会 FORK 一个子进程来进行持久化工作( 父进程不执行磁盘 I/O ),先将数据写入到一个临时文件中,待持久化过程结束后,再用这个临时文件替换上次持久化好的文件(该文件比 AOF 生成的文件更紧凑)。
2.1.2 缺点
最后一次持久化后的数据可能丢失 ,而且如果数据量大的话 FORK 过程需要使用较多的 CPU 时间。
2.1.3 触发条件
- 配置文件中 SNAPSHOTTING 部分
- 手动 SAVE ,BGSAVE
- SHUTDOWN
2.2 AOF (如果启用,则启动时忽略 RDB 文件)
以 日志的形式 来记录每个 写 操作,将执行过的所有写指令记录下来,只许追加文件不可以改写文件,REDIS 启动之初会读取该文件重新构建数据。
Figure 9: AOF 工作流程
2.2.1 优点
数据完整性更好,支持后台自动 重写 AOF 文件。
2.2.2 缺点
文件较大,使用 fsync 占用 I/O 资源。
2.2.3 追加同步策略
2.2.3.1 appendfsync
always
同步操作,每次发生数据变更会被立即记录到磁盘,性能较差但数据完整性比较好
everysecond
异步操作,每秒记录,如果一秒内宕机,有数据丢失
no
操作系统决定何时 fsync
2.2.3.2 no-appendfsync-on-rewrite
等后台重写 AOF 操作和主进程写 AOF 文件同时进行时,由于前者往往会涉及大量磁盘操作,这样就会造成主进程在写 AOF 文件的时候出现阻塞的情形。
如果该参数设置为 no ,是最安全的方式,不会丢失数据,但是要忍受阻塞的问题。
如果设置为 yes ,就相当于将 appendfsync
设置为 no ,这说明并没有执行磁盘操作,只是写入了缓冲区,因此这样并不会造成阻塞(因为没有竞争磁盘),但是如果这个时候 REDIS 宕机,就会丢失数据。
2.2.4 AOF 文件重写机制
AOF 采用文件追加方式,文件会越来越大。为避免出现此种情况,新增了重写机制。
当 AOF 文件的大小超过所设定的阈值时,REDIS 就会 FORK 出子进程来将文件重写(先写临时文件最后再重覆盖),只保留可以恢复数据的最小指令集。
重写的原理为遍历新进程的内存中数据,每条记录有一条的 Set 语句。
重写 AOF 文件的操作,并没有读取旧的 AOF 文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 AOF 文件,这点和快照有点类似。
2.2.4.1 触发时机
REDIS 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次重写后大小的一倍(auto-aof-rewrite-percentage 100
)
且文件大于 64M (auto-aof-rewrite-min-size 64mb
) 时触发。
可以使用命令 BGREWRITEAOF 手动触发重写。
2.2.4.2 阻塞风险
Redis 主线程 fork 创建 bgrewriteaof 子进程时,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作 进程控制块 (Process Control Block,简称为 PCB )。内核要把主线程的 PCB 内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。而且,在拷贝过程中,子进程要拷贝父进程的页表,这个过程的耗时和 Redis 实例的内存大小有关。如果 Redis 实例内存大,页表就会大,fork 执行时间就会长,这就会给主线程带来阻塞风险。
BGREWRITEAOF 子进程会和主线程共享内存。当主线程收到新写或修改的操作时, 主线程会申请新的内存空间 ,用来保存新写或修改的数据,如果操作的是 bigkey,也就是数据量大的集合类型数据,那么,主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。
2.3 持久化策略参考
RDB 更适合用于备份数据的目的,可以只在 Slave 上保存 RDB 文件,15 分钟备份一次即可,即只保留 save 900 1
这条规则。
如果使用 AOF,虽然好处是在最恶劣情况下也只会丢失不超过两秒的数据,但代价一是带来了持续的 IO ,
二是将重写过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。
只要硬盘许可,应该尽量减少 AOF rewrite 的频率,AOF 重写的基础大小默认值 64M ,可以设到 5G 以上。
默认超过原大小 100% 大小时重写也可以改为适当的值。
如果不使用 AOF ,仅靠 Master-Slave Replication 实现高可用性也可以。能省掉一大笔 IO 也减少了重写时带来的系统波动。 代价是如果 Master/Slave 同时宕机,会丢失十几分钟的数据,启动脚本需要使用 Master/Slave 中的较新的 RDB 文件。
3 主从复制 (Replication ,Master 写,Slave 读)
通常说,Redis 具有高可靠性,其实,这里有两层含义:一是 数据尽量少丢失 ,二是 服务尽量少中断 。
AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是提供主从模式,增加副本冗余量,同时保证数据副本一致。
主从集群一般是采用读写分离模式 ,当主库故障后,客户端仍然可以把读请求发送给从库,让从库服务。但是,对于写请求操作,客户端就无法执行了。
3.1 全量复制
Figure 11: 全量复制流程
3.2 部分复制
Figure 12: 部分复制流程
repl_backlog_size
这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:
缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小
在实际应用中,考虑到可能存在一些突发的请求压力,通常需要把这个缓冲空间扩大一倍,即:
repl_backlog_size = 缓冲空间大小 * 2
这也就是 repl_backlog_size
的最终值。
3.3 repl_backlog_buffer vs replication_buffer
3.3.1 repl_backlog_buffer
为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量同步带来的性能开销。
如果从库断开时间太久, repl_backlog_buffer
环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能进行一次全量同步,
所以 repl_backlog_size
配置尽量大一些,可以降低主从断开后全量同步的概率。
当在 repl_backlog_buffer
中找主从差异的数据后,如何发给从库呢?这就用到了 replication buffer
。
3.3.2 replication_buffer
Redis 和客户端通信也好,和从库通信也好,Redis 都需要给分配一个内存 buffer 进行数据交互,客户端是一个 client ,从库也是一个 client 。 每个 client 连上 Redis 后,Redis 都会分配一个 client buffer ,所有数据交互都是通过这个 buffer 进行的:
Redis 先把数据写到这个 buffer 中,然后再把 buffer 中的数据发到 client socket 中再通过网络发送出去,这样就完成了数据交互。
所以主从在增量同步时,从库作为一个 client ,也会分配一个 buffer ,只不过这个 buffer 专门用来传播用户的写命令到从库,保证主从数据一致,
通常把它叫做 replication buffer
。
这个 buffer 是有限制的。 如果主从在传播命令时,因为某些原因从库处理得非常慢,那么主库上的这个 buffer 就会持续增长,消耗大量的内存资源,甚至 OOM 。
所以 Redis 提供了 client-output-buffer-limit
参数限制这个 buffer 的大小,如果超过限制,主库会强制断开这个 client 的连接,
也就是说从库处理慢导致主库内存 buffer 的积压达到限制后,主库会强制断开从库的连接,此时主从复制会中断。
中断后如果从库再次发起复制请求,那么此时可能会导致恶性循环,引发复制风暴,这种情况需要格外注意。
3.4 哨兵集群
哨兵是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行,但不提供读写服务。哨兵主要任务有三个:
- 监控,判断主从库是否下线
- 选出新主库
- 通知从库执行 REPLICAOF ,与新主库同步;并通知客户端,与新主库连接
3.4.1 哨兵间发现
在配置哨兵的信息时,只需要用到下面的这个配置项,设置主库的 IP 和端口:
sentinel monitor <master-name> <ip> <port> <quorum>
之后,通过 Redis 提供的 pub/sub 机制,哨兵实例之间完成相互发现:
Figure 13: 哨兵实例之间通过消息机制相互发现
3.4.2 哨兵与从节点连接
Figure 14: 哨兵与从节点建立连接
3.4.3 客户端更新集群信息
客户端需要访问主从库时,不能直接写死主从库的地址了,而是需要从哨兵集群中获取最新的地址( sentinel get-master-addr-by-name
命令),
这样当实例异常时,哨兵切换后或者客户端断开重连,都可以从哨兵集群中拿到最新的实例地址。
3.4.3.1 事件通知
客户端可以 从哨兵订阅消息 ,不同频道包含了主从库切换过程中的不同关键事件:
Figure 15: 事件与 Channel
3.4.4 客观下线
Figure 16: 判断客观下线流程
当哨兵标记客观下线后,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。
这个过程称为 Leader 选举 ,类似于 RAFT 协议。
在投票过程中,任何一个想成为 Leader 的哨兵,要满足 两个条件 :
- 拿到半数以上的赞成票
- 拿到的票数同时还需要大于等于哨兵配置文件中的
quorum
值
需要注意的是,如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader , 必须获得 2 票 ,而不是 1 票。
所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此, 通常至少会配置 3 个哨兵实例。
4 分片集群
在使用 RDB 进行持久化时,Redis 会 fork 子进程来完成, fork 操作的用时和 Redis 的数据量是正相关的 ,而 fork 在执行时会阻塞主线程。
数据量越大,fork 操作造成的主线程阻塞的时间越长。
所以,在使用 RDB 对数据进行持久化时,数据量较大,后台运行的子进程在 fork 创建时会阻塞主线程,导致 Redis 响应变慢。此时可以考虑组建切片集群。
4.1 数据(槽)迁移
Figure 17: 槽迁移流程
4.1.1 MOVED/ASK
Figure 18: 键迁移过程中请求数据的流程
注意,Redis Cluster 的数据迁移是 同步的 ,迁移一个 key 会 同时阻塞源节点和目标节点 ,迁移过程中会有性能问题。
4.2 下线节点
Figure 19: 节点下线流程
5 缓存优化
5.1 一致性问题
5.1.1 缓存更新策略
当一个系统引入缓存时,需要面临最大的问题就是,如何保证缓存和后端数据库的一致性问题,最常见的 3 个解决方案分别是 Cache Aside ,Read/Write Throught 和 Write Back 缓存更新策略。
5.1.1.1 Cache Aside 策略
即只读缓存模式。读操作命中缓存直接返回,否则从后端数据库加载到缓存再返回。写操作直接更新数据库,然后删除缓存。
这种策略的优点是一切以后端数据库为准,可以 保证缓存和数据库的一致性 。缺点是写操作会让缓存失效,再次读取时需要从数据库中加载,影响性能。这种策略是在开发软件时最常用的,在使用 Memcached 或 Redis 时一般都采用这种方案。
5.1.1.2 Read/Write Throught 策略
应用层读写只需要操作缓存 ,不需要关心后端数据库。
应用层在操作缓存时,缓存层会自动从数据库中加载或写回到数据库中,这种策略的优点是,对于应用层的使用非常友好,只需要操作缓存即可,缺点是需要缓存层支持和后端数据库的联动。
5.1.1.3 Write Back 策略
写操作只写缓存 ,而读操作如果命中缓存则直接返回,否则需要从数据库中加载到缓存中,在加载之前,如果缓存已满,则先把需要淘汰的缓存数据写回到后端数据库中,再把对应的数据放入到缓存中。
这种策略的优点是,写操作飞快(只写缓存),缺点是如果数据还未来得及写入后端数据库,系统发生异常会导致缓存和数据库的不一致。这种策略经常使用在操作系统 Page Cache 中,或者应对大量写操作的数据库引擎中。
5.1.2 异常处理
除了上述那些策略,还需要考虑的问题是操作缓存或数据库发生异常时如何处理?例如缓存操作成功,数据库操作失败,或者反过来,都有可能会产生不一致的情况。
比较简单的解决方案是,根据业务设计好更新缓存和数据库的先后顺序来降低影响,或者给缓存设置较短的有效期来降低不一致的时间。
如果需要严格保证缓存和数据库的一致性,即 保证两者操作的原子性 ,这就涉及到分布式事务问题了,常见的解决方案就是两阶段提交,三阶段提交,TCC,消息队列等方式来保证了,方案也会比较复杂,一般用在对于一致性要求较高的业务场景中。
具体来说,可以把要删除的缓存值或者是要更新的数据库值 暂存到消息队列中 。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。如果能够成功地删除或更新,就要把这些值从消息队列中去除,以免重复操作,这样,可以保证数据库和缓存的数据一致了。否则的话,还需要再次进行重试。如果重试超过的一定次数,还是没有成功,就需要向业务层发送报错信息了。
5.2 穿透优化
- 缓存空对象
- 布隆过滤器
5.3 雪崩优化
- 使用集群保证高可用
- 后端使用隔离组件进行限流或降级
5.4 热点优化
Figure 20: 热点数据产生的问题
Figure 21: 使用锁进行缓存重建
Figure 22: 应用层判断数据是否过期
6 事务
使用事务可以一次执行多个命令,本质是一组命令的集合。 一个事务中的所有命令都会序列化,按顺序地串行化执行而 不会被其它命令插入 。
注意, RDB 快照不会在事务执行时执行。
6.1 pipeline
pipeline 本质是一种 客户端 缓冲方案,即将多个命令通过一次 send()
系统调用发送出去。
6.1.1 Python pipline API
#! /usr/bin/env python3 # -*- coding: utf-8 -*- import unittest import uuid import redis import inspect r = redis.Redis(host='localhost', port=46379, db=0) def key(): caller = inspect.stack()[1][3] return f'{caller}_{uuid.uuid4().hex[0:5]}' class UnitTest(unittest.TestCase): def test_pipeline(self): k = key() pipe = r.pipeline(transaction=False) self.assertEqual([True, b'v'], pipe.set(k, 'v').get(k).execute()) if __name__ == '__main__': unittest.main(verbosity=2)
Figure 23: Python pipeline API 不同 transaction 参数的抓包区别
6.2 MULTI/EXEC (非原子性)
REDIS 接到这些命令并 不会立即执行 ,而是放到等待执行的事务队列里面,直到收到 EXEC 指令才一起执行。
本质上,MULTI/EXEC 只是用于保证一系列的命令不间断的执行,即把多个命令 串联 起来,而且并 不支持回退 ,因此不是原子性的。
同一个事务中如果有一条命令执行失败, 前后的命令仍然会被执行到 ,并不会回滚。
6.2.1 WATCH (乐观锁)
通过 WATCH 命令在事务执行之前监控了多个 key ,当收到 EXEC 指令时,如果发现在 WATCH 之后有任何 key 的值发生了变化,整个事务都将被放弃执行。
一旦执行了 EXEC ,之前加的监控锁都会被解除监控。
7 CAP 简述
一个分布式系统里面,节点组成的网络本来应该是连通的。然而可能因为一些故障,使得有些节点之间不连通了,整个网络就分成了几块区域。
数据就散布在了这些不连通的区域中。这就叫分区。
当一个数据项只在一个节点中保存,那么分区出现后,和这个节点不连通的部分就访问不到这个数据了。这时分区就是 无法容忍 的。
提高分区容忍性的办法就是一个数据项复制到多个节点上,那么出现分区之后,这一数据项就可能分布到各个区里。 容忍性 就提高了。 然而,要把数据复制到多个节点,就会带来 一致性 的问题,就是多个节点上面的数据可能是不一致的。要保证一致,每次写操作就都要等待全部节点写成功,而这等待又会带来 可用性 的问题。
总的来说,数据存在的节点越多,分区容忍性越高, 但要复制更新的数据就越多,一致性就越难保证。 为了保证一致性,更新所有节点数据所需要的时间就越长,可用性就会降低。