服务器初始化主流程(见图9-2) 可以简要分为7个步骤: ①初始化配置, 包括用户可配置的参数, 以及命令表的初始化; ②加载并解析配
置文件; ③初始化服务端内部变量, 其中就包括数据库; ④创建事件循环eventLoop; ⑤创建socket并启动监听; ⑥创建文件事件与时间事件;
⑦开启事件循环。 下面详细介绍步骤①~步骤④, 至于步骤⑤~步骤⑦
将会在9.2.2节介绍

高性能主线,包括线程模型、数据结构、持久化、网络框架;
高可靠主线,包括主从复制、哨兵机制;
高可扩展主线,包括数据分片、负载均衡

Redis 能够在实际业务场景中得到广泛的应用,就是得益于支持多样
化类型的 value

一般而言,内存键值数据库(例如 Redis)采用哈希表作为索引,很大一部分原因在于,
其键值数据基本都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表
O(1) 的操作复杂度相匹配。

一方面,这是因为它是内存数据库,
所有操作都在内存上完成,内存的访问速度本身就很快。另一方面,这要归功于它的数据
结构

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。哈希表的最大好
处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算
键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素

当你往 Redis 中
写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在
的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞

Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希
桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

哈希冲突链上的元素只能通过指针逐一查找再操作。如果
哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链
过长,进而导致这个链上的元素查找耗时长,效率降低

Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐
增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个
桶中的冲突

Redis 开始执行 rehash,这个过程分为三步:

  1. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;

  2. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;

  3. 释放哈希表 1 的空间

    但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都
    迁移完,会造成 Redis 线程阻塞,无法服务其他请求

Redis 采用了渐进式 rehash。
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求
时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝
到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的
entries

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同
的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的
偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束

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

有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳
表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位

可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符
合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)

第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,Hash 类
型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等。这些操
作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操
作,所以它们的复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、
SREM、SRANDMEMBER 复杂度也是 O(1)

第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash
类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List
类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,
我们应该尽量避免

不过,Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和
ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于
HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻

第三,统计操作,是指集合类型对集合中所有元素个数的记录,例如 LLEN 和 SCARD。这
类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数
据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作

第四,例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头
和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操
作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂
度也只有 O(1),可以实现快速操作
集合类型的范围操作,因为要遍历底层数据结构,复
杂度通常是 O(N)。这里,我的建议是:用其他命令来替代,例如可以用 SCAN 来代替,
避免在 Redis 内部产生费时的全集合遍历操作
双向链表和压缩列
表的操作复杂度都是 O(N)。因此,我的建议是:因地制宜地使用 List 类型。例如,既然
它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而不是作为一个可以随
机读写的集合

至于问题答案,采用压缩列表或者是整数集合,都是数据量比较小的情况,所以一次能够
分配到足够大的内存,而压缩列表和整数集合本身的数据结构也是线性的,对cpu的缓存
更友好一些,所以真正的执行的时间因为高速缓存的关系,速度更快

我们通常说,Redis 是单线程,主要是指 Redis 的网络 IO
和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。
但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执
行的

Redis 为什么用单线程

系统中通常会存在被多线程同时访问的
共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共
享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开

而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统
代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式

单线程 Redis 为什么那么快?

一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希
表和跳表,这是它实现高性能的一个重要原因。另一方面,就是 Redis 采用了多路复用机
制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。接下来,我们就
重点学习下多路复用机制

Redis 的持久化主要有两大机制,即 AOF 日志和 RDB 快照

AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入
内存,然后才记录日志

而 AOF 里记
录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的

而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志
中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处
是,可以避免出现记录错误命令的情况

AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操
作。

那么这个命令和相应的数 据就有丢失的风险

但可能会给下一个操作带来阻塞风险。这是因 为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就
会导致写盘很慢

到这里,我们就可以根据系统对高性能和高可靠性的要求,来选择使用哪种写回策略了。
总结一下就是:想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择
Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择
Everysec 策略

这里的“性能问题”,主要在于以下三个方面:一是,文件系统本身对文件大小有限制,
无法保存过大的文件;二是,如果文件太大,之后再往里面追加命令记录的话,效率也会
变低;三是,如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如
果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用

Redis 根据数据库的现状创建一个新的 AOF 文
件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写

和 AOF 日志由主线程写回不同,重写过程是由后台线程 bgrewriteaof 来完成的,这也是
为了避免阻塞主线程,导致数据库性能下降

“一个拷贝”就是指,每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此
时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的
最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数
据写成操作,记入重写日志

因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指
正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。这样一来,即使宕机了,这
个 AOF 日志的操作仍然是齐全的,可以用于恢复。
而第二处日志,就是指新的 AOF 重写日志。这个操作也会被写到重写日志的缓冲区。这
样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日
志记录的这些最新操作也会写入新的 AOF 文件,以保证数据库最新状态的记录。此时,我
们就可以用新的 AOF 文件替代旧文件了

但是,也正因为记录的是操作命令,而不是实际的数据,所以,用 AOF 方法进行故障恢复
的时候,需要逐一把操作日志都执行一遍。如果操作日志非常多,Redis 就会恢复得很缓
慢,影响到正常使用

所谓内存快照,
就是指内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一
张照片就能把朋友一瞬间的形象完全记下来

和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我
们可以直接把 RDB 文件读入内存,很快地完成恢复。听起来好像很不错,但内存快照也并
不是最优选项。为什么这么说呢
对哪些数据做快照?这关系到快照的执行效率问题;
做快照时,数据还能被增删改吗?这关系到 Redis 是否被阻塞,能否同时正常处理请

Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就
是说,把内存中的所有数据都记录到磁盘中

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave
save:在主线程中执行,会导致阻塞;
bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是
Redis RDB 文件生成的默认配置

所以这个时候,Redis 就会借助操作系统提
供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作

简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。
bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和
bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),
那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本
数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据

虽然 bgsave 执行时不阻塞主线程,但是,如果频繁地执行全量
快照,也会带来两方面的开销

一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带
宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环

另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后
不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越
大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了

虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把
握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,
又会产生额外开销,那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做
到尽量少丢数据呢

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一
定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作

这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF
日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出
现文件过大的情况了,也可以避免重写开销

实际上,Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分 离的方式
读操作:主库、从库都可以接收;
写操作:首先到主库执行,然后,主库将写操作同步给从库

并集
SUNIONSTORE user:id user:id user:id:20200803
差集
SDIFFSTORE user:new user:id:20200804 user:id
交集
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804

Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计
算,会导致 Redis 实例阻塞。所以,我给你分享一个小建议:你可以从主从集群中选择一
个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统
计,这样就可以规避阻塞主库实例和其他从库实例的风险了

所以,在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显
示,建议你优先考虑使用 Sorted Set

16 | 异步机制:如何避免单线程模型的阻塞?
Redis 实例有哪些阻塞点?

客户端:网络 IO,键值对增删改查操作,数据库操作
磁盘:生成 RDB 快照,记录 AOF 日志,AOF 日志重写
主从节点:主库生成、传输 RDB 文件,从库接收 RDB 文件、清空数据库、加载 RDB 文件
切片集群实例:向其他实例传输哈希槽信息,数据迁移

  1. 和客户端交互时的阻塞点
    Redis 使用了 IO 多路复用机制,网络 IO 不是导致 Redis 阻塞的因素

复杂度高的增删改查操作肯定会阻塞 Redis

这里有一个最基本的标准,就是看操作的复杂度是否为 O(N)

Redis 中涉及集合的操作复杂度通常为 O(N),我们要在使用时重视起来。例如集合元素全
量查询操作 HGETALL、SMEMBERS,以及集合的聚合统计操作,例如求交、并和差集。
这些操作可以作为 Redis 的第一个阻塞点:集合全量查询和聚合操作

其实,删除操作的本质是要释放键值对占用的内存空间。你可不要小瞧内存的释放过程。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统
需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过
程本身需要一定时间,而且会阻塞当前释放内存的应用程序,所以,如果一下子释放了大
量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞

那么,什么时候会释放大量内存呢?其实就是在删除大量键值对数据的时候,最典型的就
是删除包含了大量元素的集合,也称为 bigkey 删除

bigkey 删除操作就是 Redis 的第二个阻塞点

清空数据库(例如 FLUSHDB 和 FLUSHALL 操作)必然也是一个潜在的阻塞风险,因为它涉及到
删除和释放所有的键值对。所以,这就是 Redis 的第三个阻塞点:清空数据库

  1. 和磁盘交互时的阻塞点

Redis 直接记录 AOF 日志时,会根据不同的写回策略对数据做落盘保存。一个同步
写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录在 AOF 日志中,并同
步写回的话,就会阻塞主线程了。这就得到了 Redis 的第四个阻塞点了:AOF 日志同步
写。

  1. 主从节点交互时的阻塞点

在主从集群中,主库需要生成 RDB 文件,并传输给从库。主库在复制的过程中,创建和传
输 RDB 文件都是由子进程来完成的,不会阻塞主线程。但是,对于从库来说,它在接收了
RDB 文件后,需要使用 FLUSHDB 命令清空当前数据库,这就正好撞上了刚才我们分析的
第三个阻塞点。
此外,从库在清空当前数据库后,还需要把 RDB 文件加载到内存,这个过程的快慢和
RDB 文件的大小密切相关,RDB 文件越大,加载过程越慢,所以,加载 RDB 文件就成为
了 Redis 的第五个阻塞点。

  1. 切片集群实例交互时的阻塞点

不过,哈希槽的信息量不大,而数据迁移是渐进式执行的,所以,一般来说,这两类操作对 Redis 主线程的阻塞风险不大

当没有 bigkey 时,切片
集群的各实例在进行交互时不会阻塞主线程,就可以了

集合全量查询和聚合操作;
bigkey 删除;
清空数据库;
AOF 日志同步写;
从库加载 RDB 文件

所谓的异步线程机制,就是指,Redis 会启动一些
子线程,然后把一些任务交给这些子线程,让它们在后台完成,而不再由主线程来执行这
些任务。使用异步线程机制执行操作,可以避免阻塞主线程

哪些阻塞点可以异步执行?

如果一个操作能被异步执行,就意味着,它并不是 Redis 主线程的关键路径上的操作

对于 Redis 来说,读操作是典型的关键路径操作,因为客户端发送了读操作之后,就会等
待读取的数据返回,以便进行后续的数据处理。而 Redis 的第一个阻塞点“集合全量查询
和聚合操作”都涉及到了读操作,所以,它们是不能进行异步操作了

我们再来看看删除操作。删除操作并不需要给客户端返回具体的数据结果,所以不算是关
键路径操作。而我们刚才总结的第二个阻塞点“bigkey 删除”,和第三个阻塞点“清空数
据库”,都是对数据做删除,并不在关键路径上。因此,我们可以使用后台子线程来异步
执行删除操作。

对于第四个阻塞点“AOF 日志同步写”来说,为了保证数据可靠性,Redis 实例需要保证
AOF 日志中的操作记录已经落盘,这个操作虽然需要实例等待,但它并不会返回具体的数
据结果给实例。所以,我们也可以启动一个子线程来执行 AOF 日志的同步写,而不用让主
线程等待 AOF 日志的写完成

最后,我们再来看下“从库加载 RDB 文件”这个阻塞点。从库要想对客户端提供数据存取
服务,就必须把 RDB 文件加载完成。所以,这个操作也属于关键路径上的操作,我们必须
让从库的主线程来执行

异步的子线程机制
会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别
由它们负责 AOF 日志写操作、键值对删除以及文件关闭的异步执行

主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库
的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回
一个完成信息,表明删除已经完成

但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始
实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除
(lazy free)

和惰性删除类似,当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封
装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这
样主线程就不用一直等待 AOF 日志写完了

这里有个地方需要你注意一下,异步的键值对删除和数据库清空操作是 Redis 4.0 后提供
的功能,Redis 也提供了新的命令来执行这两个操作

键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删
除时,我建议你使用 UNLINK 命令。
清空数据库:可以在 FLUSHDB 和 FLUSHALL 命令后加上 ASYNC 选项,这样就可以让
后台子线程异步地清空数据库

不过,异步删除操作是 Redis 4.0 以后才有的功能,如果你使用的是 4.0 之前的版本,当
你遇到 bigkey 删除时,我给你个小建议:先使用集合类型提供的 SCAN 命令读取数据,
然后再进行删除。因为用 SCAN 命令可以每次只读取一部分数据并进行删除,这样可以避
免一次性删除大量 key 给主线程带来的阻塞。
例如,对于 Hash 类型的 bigkey 删除,你可以使用 HSCAN 命令,每次从 Hash 集合中
获取一部分键值对(例如 200 个),再使用 HDEL 删除这些键值对,这样就可以把删除压
力分摊到多次操作中,那么,每次删除操作的耗时就不会太长,也就不会阻塞主线程了。
最后,我想再提一下,集合全量查询和聚合操作、从库加载 RDB 文件是在关键路径上,无
法使用异步操作来完成。对于这两个阻塞点,我也给你两个小建议

集合全量查询和聚合操作:可以使用 SCAN 命令,分批读取数据,再在客户端进行聚合
计算;
从库加载 RDB 文件:把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以
较快的速度加载

17 | 为什么CPU结构也会影响Redis的性能?

L1、L2 缓存中的指令和数据的访问速度很快,所以,充分利用 L1、L2 缓存,可以有效
缩短应用程序的执行时间;
在 NUMA 架构下,如果应用程序从一个 Socket 上调度到另一个 Socket 上,就可能会
出现远端内存访问的情况,这会直接增加应用程序的执行时间

我们先简单地总结下刚刚学习的内容。在 CPU 多核的场景下,用 taskset 命令把 Redis 实
例和一个核绑定,可以减少 Redis 实例在不同核上被来回调度执行的开销,避免较高的尾
延迟;在多 CPU 的 NUMA 架构下,如果你对网络中断程序做了绑核操作,建议你同时把
Redis 实例和网络中断程序绑在同一个 CPU Socket 的不同核上,这样可以避免 Redis 跨
Socket 访问内存中的网络数据的时间开销。
不过,“硬币都是有两面的”,绑核也存在一定的风险。接下来,我们就来了解下它的潜
在风险点和解决方案