第十六章 异步机制:如何避免单线程模型的阻塞 ?
- Redis 的网络 IO 和键值对读写是由主线程完成的。
- 如果在主线程上执行的操作消耗的时间太长,就会引起主线程阻塞。
Redis 实例有哪些阻塞点 ?
与 Redis 实例交互的对象有哪些 ?
- 客户端:网络 IO,键值对增删改查操作,数据库操作;
- 磁盘:生成 RDB 快照,记录 AOF 日志,AOF 日志重写;
- 主从节点:主库生成、传输 RDB 文件,从库接收 RDB 文件、清空数据库、加载 RDB 文件;
- 切片集群实例:向其他实例传输哈希槽信息,数据迁移。
和客户端交互时的阻塞点
- Redis 主线程执行的主要任务是:键值对的增删改查操作,
- 所以复杂度高的增删改查操作肯定会阻塞 Redis。
Redis 实例和客户端交互有哪些阻塞点呢 ?
- 集合全量查询和聚合操作会阻塞 Redis
- 删除包含大量元素的集合的操作会阻塞 Redis
- 清空数据库操作会阻塞 Redis
为什么删除操作还会阻塞 Redis ?
- 删除操作的本质是要释放键值对占用的内存空间。
- 在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。
- 这个过程本身需要一定时间,而且会阻塞当前
释放内存
的应用程序。 - 如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的
阻塞
。
什么时候会释放大量内存呢 ?
删除大量键值对数据的时候,最典型的就是删除包含了大量元素的集合,也称为 bigkey
删除。
和磁盘交互时的阻塞点
- 生成 RDB 快照文件和执行 AOF 日志重写操作由子进程完成,不阻塞主线程。
- 如果有大量的写操作需要记录在 AOF 日志中,并同步写回的话,就会
阻塞
主线程,耗时大概 1~2 ms。
主从节点交互时的阻塞点
- 主库在复制的过程中,创建和传输 RDB 文件都是由子进程来完成的,不会阻塞主线程。
- 从库接收RDB文件后,需要使用 FLUSHDB 命令清空当前数据库,这样会
阻塞
从库的Redis 主线程。 - 从库在清空当前数据库后,还需要把 RDB 文件加载到内存,这个过程的快慢和 RDB 文件的大小密切相关,RDB 文件越大,加载过程越慢,这样也会
阻塞
从库的Redis 主线程。
切片集群实例交互时的阻塞点
- 当没有 bigkey 时,切片集群的各实例在进行交互时不会阻塞主线程。
- 如果你使用了 Redis Cluster 方案,而且同时正好迁移的是 bigkey 的话,就会造成主线程的
阻塞
,因为 Redis Cluster 使用了同步迁移。
总结有哪些阻塞点 ?
- 集合全量查询和聚合操作;
- bigkey 删除;
- 清空数据库;
- AOF 日志同步写;
- 从库加载 RDB 文件。
如果在主线程中执行这些操作,必然会导致主线程长时间无法服务其他请求。为了避免阻塞式操作,Redis 提供了
异步线程
机制。所谓的异步线程机制,就是指,Redis 会启动一些子线程,然后把一些任务交给这些子线程,让它们在后台完成,而不再由主线程来执行这些任务。
使用
异步线程机制
执行操作,可以避免阻塞主线程。
哪些阻塞点可以异步执行 ?
异步执行 对操作的要求 ?
- 主线程接收到操作 1 后,因为操作 1 并不用给客户端返回具体的数据,所以,主线程可以把它交给后台子线程来完成,同时只要给客户端返回一个“OK”结果就行。
- 在子线程执行操作 1 的时候,客户端又向 Redis 实例发送了操作 2,而此时,客户端是需要使用操作 2 返回的数据结果的,如果操作 2 不返回结果,那么,客户端将一直处于等待状态。
- 在这个例子中,操作 1 就不算关键路径上的操作,因为它不用给客户端返回具体数据,所以可以由后台子线程
异步执行
。 - 而操作 2 需要把结果返回给客户端,它就是关键路径上的操作,所以主线程必须立即把这个操作执行完。
哪些阻塞点可以异步执行呢 ? 哪些算关键路径操作,哪些不算呢 ?
- 读操作是典型的关键路径操作,因为它需要把结果返回给客户端,所以它不能进行异步操作。
- 删除操作并不需要给客户端返回具体的数据结果,所以不算是关键路径操作,可以使用后台子线程异步执行删除操作。
- 对于AOF同步写来说,为了保证数据可靠性,Redis 实例需要保证 AOF 日志中的操作记录已经落盘,这个操作虽然需要实例等待,但它并不会返回具体的数据结果给实例。
- 所以,我们也可以启动一个子线程来执行 AOF 日志的同步写,而不用让主线程等待 AOF 日志的写完成。
- 对于从库加载 RDB 文件来说,从库要想对客户端提供数据存取服务,就必须把 RDB 文件加载完成。
- 所以,这个操作也属于
关键路径
上的操作,我们必须让从库的主线程
来执行。
- 所以,这个操作也属于
Redis 实现的异步子线程机制具体是怎么执行呢 ?
- Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责
- AOF 日志写操作
- 键值对删除
- 文件关闭的异步执行
- 主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到
任务队列
中,然后给客户端返回一个完成信息,表明删除已经完成。 - 但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为
惰性删除
(lazy free)。此时,删除或清空操作不会阻塞
主线程,这就避免了对主线程的性能影响。 - 和惰性删除类似,当 AOF 日志配置成
everysec
选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这样主线程就不用一直等待 AOF 日志写完了。
异步的键值对删除和数据库清空操作是
Redis 4.0
后提供的功能,Redis 也提供了新的命令来执行这两个操作:
- 键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用
UNLINK
命令。 - 清空数据库:可以在
FLUSHDB
和FLUSHALL
命令后加上ASYNC
选项,这样就可以让后台子线程异步地清空数据库,如下所示:
FLUSHDB ASYNC
FLUSHALL AYSNC
对于
集合全量查询和聚合操作
、从库加载 RDB 文件
这两个无法使用异步操作
来完成的阻塞点,有以下建议:
- 集合全量查询和聚合操作:可以使用 SCAN 命令,分批读取数据,再在客户端进行聚合计算;
- 从库加载 RDB 文件:把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以较快的速度加载。
Redis 的写操作(例如 SET、HSET、SADD 等)是在关键路径上吗 ?
需要客户端根据业务需要来区分:
- 如果客户端依赖操作返回值的不同,进而需要处理不同的业务逻辑,那么HSET和SADD操作算关键路径,而SET操作不算关键路径。
- 因为HSET和SADD操作,如果field或member不存在时,Redis结果会返回1,否则返回0。而SET操作返回的结果都是OK,客户端不需要关心结果有什么不同。
- 如果客户端不关心返回值,只关心数据是否写入成功,那么SET/HSET/SADD不算关键路径,多次执行这些命令都是幂等的,这种情况下可以放到异步线程中执行。
什么时候 Redis 才会真正的异步释放内存 ?
lazy free机制:Redis收到键值对删除和清空数据库的指令时,主线线程会把这个操作封装成一个任务,放入任务队列中,然后给客户端返回一个完成信息,但实际上,这个删除还没有执行,需要等待后台子线程从任务队列中读取到这个任务后,才开始实际删除键值对,并释放相应的内存空间。
但是 lazy-free 是4.0新增功能,默认关闭。开启这个配置后, 除了 replica-lazy-flush
之外,其他情况都只是可能
去异步释放key的内存,并不是每次必定
异步释放内存的。
是否会真正异步释放内存,这和key的类型、编码方式、元素数量都有关系!!!
- 当Hash/Set底层采用哈希表存储(非ziplist/int编码存储)时,并且元素数量超过64个
- 当ZSet底层采用跳表存储(非ziplist编码存储)时,并且元素数量超过64个
- 当List链表节点数量超过64个(注意,不是元素数量,而是链表节点的数量,List的实现是在每个节点包含了若干个元素的数据,这些元素采用ziplist存储)
只有以上这些情况,在删除key释放内存时,才会真正放到异步线程中执行,其他情况一律还是在主线程中操作。
也就是说String(不管内存占用多大)、List(少量元素)、Set(int编码存储)、Hash/ZSet(ziplist编码存储)这些情况下的key在释放内存时,依旧在主线程中操作。