redis 概览

Redis全景图包括“:
“两大维度”: 系统维度和应用维度;
“三大主线”也就是指高性能、高可靠和高可扩展(可以简称为“三高”):
- 高性能主线,包括线程模型、数据结构、持久化、网络框架;
- 高可靠主线,包括主从复制、哨兵机制;
- 高可扩展主线,包括数据分片、负载均衡。
Redis 问题画像

从simpleKV到redis
大体来说,一个键值数据库包括了访问框架、索引模块、操作模块和存储模块:
- 访问框架:函数库调用/socket通讯(I/O 模型设计?)。例如,RocksDB 以动态链接库的形式使用,而 Memcached 和 Redis 则是通过网络框架访问。
- 索引模块:场景索引类型有哈希表、B+ 树、字典树等。例如,Memcached 和 Redis 采用哈希表作为 key-value 索引,而 RocksDB 则采用跳表作为内存中 key-value 的索引。
- 操作模块:GET/SCAN,PUT/DELETE会分配和释放内存,由分配器完成。
- 存储模块:glibc 的 malloc 和 free (处理随机的大小内存块分配时会有内存碎片问题), 将数据持久化落盘。

impleKV 和 Redis 对比:

redis 数据结构
5 大数据类型: String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)
6 种底层数据结构:简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。
它们和数据类型的对应关系如下图所示:


集合常见操作的复杂度:
- 单元素操作是基础:指每一种集合类型对单个数据实现的增删改查操作,如Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等,复杂度都是O(1);
- 范围操作非常耗时:指集合类型中的遍历操作,可以返回集合中的所有数据,如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS、List 类型的 LRANGE 和 ZSet 类型的 ZRANGE等,复杂度都是O(N);
- 统计操作通常高效:指集合类型对集合中所有元素个数的记录,如 LLEN 和 SCARD,复杂度只有 O(1);
- 例外情况只有几个:指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,复杂度也是O(1)。
键和值用什么结构组织?
为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。
一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
哈希表的最大好处就是可以用 O(1) 的时间复杂度来快速查找到键值对——只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素:

随数据增加,当两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中,就可能出现哈希冲突。
Redis使用链式哈希
来解决哈希冲突。
链式哈希就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接,但链表查询效率不高。
此时,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表,基本rehash过程:
哈希表1(默认) -> 哈希表2(开启使用,更大容量) -> 哈希表1拷贝到哈希表2 -> 释放哈希表1留作下一次 rehash 扩容备用
但数据量太大,若哈希表一次性拷贝,会造成 Redis 线程阻塞,无法服务其他请求。
所以Redis 采用了渐进式 rehash。即在哈希表拷贝时,Redis 仍然正常处理客户端请求,每处理一个请求时,会从哈希表1依次拷贝一个索引上的元素到哈希表2,避免了一次性大量拷贝的开销。
ps: 在 rehash 被触发后,即使没有收到新请求,Redis 也会定时执行一次 rehash 操作,而且,每次执行时长不会超过 1ms,以免对其他任务造成影响。

redis 线程
Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。同时使用多路复用的 IO 模型,避免网络 IO 操作阻塞。
所谓的IO多路复用指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。
下图就是基于多路复用的 Redis IO 模型,此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。
正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

redis 持久化
目前,Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。如果 Redis 发生了宕机,可以分别通过回放日志和重新读入 RDB 文件的方式恢复数据,从而保证尽量少丢失数据,提升可靠性。
AOF
AOF(全称Append Only File),即写后日志,先执行指令,把数据写入内存,然后才记录日志。因此每执行一条更改数据指令都会追加到AOF文件中,在一定程度上会降低性能。
AOF 三种回收策略:
配置项 | 写回机制 | 优点 | 缺点 |
---|---|---|---|
Always | 同步写回 | 可靠性高,数据基本不丢失 | 每个写指令都要落盘,性能影响较大 |
EverySec | 每秒写回 | 性能适中 | 宕机时丢失1秒内的数据 |
No | 操作系统控制的写回 | 性能好 | 宕机时丢失数据较多 |
AOF 重写:
为了避免日志文件过大,Redis 提供了 AOF 重写机制,直接根据数据库里数据的最新状态,生成这些数据的插入命令,作为新日志。
AOF重写过程可概括为:“一个拷贝,两处日志”。
一个拷贝:每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。
两处日志:
第一处日志指正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。
第二处日志指新的 AOF 重写日志,也会被写到重写日志的缓冲区。
AOF非阻塞的重写过程如下图:

PS:
有两个配置项在控制AOF重写的触发时机:
- auto-aof-rewrite-min-size: 表示运行AOF重写时文件的最小大小,默认为64MB
- auto-aof-rewrite-percentage: 这个值的计算方法是:当前AOF文件大小和上一次重写后AOF文件大小的差值,再除以上一次重写后AOF文件大小。也就是当前AOF文件比上一次重写后AOF文件的增量大小,和上一次重写后AOF文件大小的比值。
AOF文件大小同时超出上面这两个配置项时,会触发AOF重写。
一份简单的AOF配置文件:
1 | # appendonly参数开启AOF持久化 |
RDB
RDB(全称Redis DataBase),即内存快照。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。
对 Redis 来说就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。
Redis 提供了两个命令来生成 RDB 文件,分别是 save
和 bgsave
。
- save:在主线程中执行,会导致阻塞;
- bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。
Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作,原理大致是:
主线程 fork 出 bgsave 子进程后,bgsave 子进程实际是复制了主线程的页表,这些页表保存了在执行 bgsave 命令时,主线程的所有数据块在内存中的物理地址。bgsave 子进程生成 RDB 时,就可以根据页表读取这些数据,再写入磁盘中。如果此时主线程进行新的写操作,会把新数据写到一个新的物理地址中,并修改自己的页表映射,但不影响bgsave保存的原页表的映射关系,即实现写时复制。
一份简单的RDB配置文件:
1 | ################################ SNAPSHOTTING ################################# |
PS:
关于 AOF 和 RDB 的选择问题的三点建议:
- 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择 (Redis 4.0提出混用);
- 如果允许分钟级别的数据丢失,可以只使用 RDB;
- 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。
redis 数据同步
Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。
- 读操作:主库、从库都可以接收;
- 写操作:首先到主库执行,然后,主库将写操作同步给从库。
主从同步基本有三种模式:全量复制、基于长连接的命令传播,以及增量复制。
全量复制
从库首次复制无法避免需要进行全量复制,一个redis实例在几GB级别较合适。
在主库同步数据给从库时不会被阻塞,仍然可写,为保证数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
这个repl buffer用于每个client专门传播用户写命令到从库,一旦超过buffer大小,主库会强行断开这个client,redis可以通过client-output-buffer-limit
参数限制这个buffer的大小。
如果从库过多,会给主库带来压力导致请求过慢,此时可以通过主-从-从
模式,将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。
基于长连接的命令传播
一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这期间如果遇到了网络断连,就需要使用到增量复制。
增量复制
当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。
repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
一般来说这两个位置偏移量基本相等,但如果从库读取较慢,就有可能还没读取就被主库新写覆盖了,导致数据不一致。
为了避免这种情况,可以调整repl_backlog_size
参数来控制这个环形缓冲区大小。一般缓冲空间的计算公式是:缓冲空间大小 = 2 * (主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小)。
关于replication buffer 和 repl_backlog_buffer 的区别:
- replication buffer 是主从库在进行全量复制时,主库上用于和从库连接的客户端的 buffer,每个从库客户端对应一个;
- repl_backlog_buffer 是为了支持从库增量复制,主库上用于持续保存写操作的一块专用 buffer,所有从库共享。
PS:
如果由于各种原因repl_backlog_buffer
被主库写满(覆盖)了,那么从库就需要重新进行全量复制,所以尽量配置大一点,可以降低主从断开后全量同步的概率,避免额外的性能开销。
redis 哨兵机制
当redis主库挂了,按照主从库模式下的读写分离要求,此时不能执行写操作了。
为了提供正常的读写服务,redis需要有一个运行在特殊模式下的进程来负责监听、选主、通知和主从切换,这就是redis的哨兵机制。
监听
所谓监听,就是判断主库的下线状态。哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。
当哨兵发现主库或从库对 PING 命令的响应超时了,那么哨兵就会先把它标记为“主观下线”。但是如果仅仅是一个哨兵,可能由于在集群网络压力较大、网络拥塞,或者是主库本身压力较大的原因出现误判,一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。
所以通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。
哨兵集群多数实例(这个票数可通过配置项 quorum
来设置)达成共识,主库才会被标记为“客观下线”。
选主
首先判断从库在线状态,然后是网络连接状态。
此时可以通过配置项down-after-milliseconds * 10
来判断。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。
最后依次按照优先级、复制进度、ID 号大小再对筛选后的从库进行打分,选出新主库。
通知
通知包括哨兵与哨兵、哨兵与从库、哨兵与客户端之间的通讯。
哨兵之间是通过 Redis 提供的 pub/sub 机制,也就是发布/订阅机制来相互发现的。
哨兵与从库试过通过向主库发送 INFO 命令,主库返回从库列表信息建立连接。
哨兵与客户端也是通过 pub/sub 机制,客户端订阅哨兵一些频道来获取消息。
场景的频道:

主从切换
检查到主观主库下线后,需要通过“Leader选举”来确认哨兵的Leader,执行主从切换。
在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。
PS: 要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值 down-after-milliseconds 要一致。
哨兵实例是不是越多越好呢?如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处?
哨兵实例越多,误判率会越低,但是在判定主库下线和选举 Leader 时,实例需要拿到的赞成票数也越多,等待所有哨兵投完票的时间可能也会相应增加,主从库切换的时间也会变长,客户端容易堆积较多的请求操作,可能会导致客户端请求溢出,从而造成请求丢失。如果业务层对 Redis 的操作有响应时间要求,就可能会因为新主库一直没有选定,新操作无法执行而发生超时报警。
调大 down-after-milliseconds 后,可能会导致这样的情况:主库实际已经发生故障了,但是哨兵过了很长时间才判断出来,这就会影响到 Redis 对业务的可用性。
redis 切片集群
切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。从 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。Redis Cluster 方案中就规定了数据和实例的对应规则。简单说就是采用哈希槽来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。
那么 哈希槽是怎么被映射到具体的实例上的呢?
- 使用
cluster create
命令创建集群,均分哈希槽。 - 使用
cluster meet
手动创建实例连接,再通过cluster addslots
命令手动分配哈希槽(需要把槽分满)。
切片集群请求键值的流程:
创建集群 –> 实例间同步哈希槽信息 –> 发给客户端 –> 客户端缓存在本地 –> 请求键值 –> 计算出所在分槽 –> 对应分槽实例发送请求
但集群的实例增减,或者是为了实现负载均衡而进行的数据重新分布,会导致哈希槽和实例的映射关系发生变化。
为了让客户端感知,Redis Cluster 方案提供了一种重定向机制,这个重定向指当客户端发送请求时,如果根据本地缓存算出来的实例请求发现没有数据,则响应一个MOVED
命令:
1 | GET hello:key |
MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 192.168.1.5 这个实例上,即已经迁移到新实例了,客户端就可以直接请求新实例。
但如果在迁移过程请求,客户端就会收到一条 ASK
报错信息:
1 | GET hello:key |
ASK 命令表示客户端请求的键值对所在的哈希槽 13320,在 192.168.1.5 这个实例上,但是这个哈希槽正在迁移。此时,客户端需要给新实例发送 ASKING
命令,然后再发送操作命令。
和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。