You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

167 lines
15 KiB
Markdown

2 years ago
# 16 | 异步机制:如何避免单线程模型的阻塞?
你好,我是蒋德钧。
Redis之所以被广泛应用很重要的一个原因就是它支持高性能访问。也正因为这样我们必须要重视所有可能影响Redis性能的因素例如命令操作、系统配置、关键机制、硬件配置等不仅要知道具体的机制尽可能避免性能异常的情况出现还要提前准备好应对异常的方案。
所以从这节课开始我会用6节课的时间介绍影响Redis性能的5大方面的潜在因素分别是
* Redis内部的阻塞式操作
* CPU核和NUMA架构的影响
* Redis关键系统配置
* Redis内存碎片
* Redis缓冲区。
这节课我们就先学习了解下Redis内部的阻塞式操作以及应对的方法。
在[第3讲](https://time.geekbang.org/column/article/270474)中我们学习过Redis的网络IO和键值对读写是由主线程完成的。那么如果在主线程上执行的操作消耗的时间太长就会引起主线程阻塞。但是Redis既有服务客户端请求的键值对增删改查操作也有保证可靠性的持久化操作还有进行主从复制时的数据同步操作等等。操作这么多究竟哪些会引起阻塞呢
别着急,接下来,我就带你分门别类地梳理下这些操作,并且找出阻塞式操作。
## Redis实例有哪些阻塞点
Redis实例在运行时要和许多对象进行交互这些不同的交互就会涉及不同的操作下面我们来看看和Redis实例交互的对象以及交互时会发生的操作。
* **客户端**网络IO键值对增删改查操作数据库操作
* **磁盘**生成RDB快照记录AOF日志AOF日志重写
* **主从节点**主库生成、传输RDB文件从库接收RDB文件、清空数据库、加载RDB文件
* **切片集群实例**:向其他实例传输哈希槽信息,数据迁移。
为了帮助你理解我再画一张图来展示下这4类交互对象和具体的操作之间的关系。
![](https://static001.geekbang.org/resource/image/6c/22/6ce8abb76b3464afe1c4cb3bbe426922.jpg)
接下来,我们来逐个分析下在这些交互对象中,有哪些操作会引起阻塞。
**1.和客户端交互时的阻塞点**
网络IO有时候会比较慢但是Redis使用了IO多路复用机制避免了主线程一直处在等待网络连接或请求到来的状态所以网络IO不是导致Redis阻塞的因素。
键值对的增删改查操作是Redis和客户端交互的主要部分也是Redis主线程执行的主要任务。所以复杂度高的增删改查操作肯定会阻塞Redis。
那么怎么判断操作复杂度是不是高呢这里有一个最基本的标准就是看操作的复杂度是否为O(N)。
Redis中涉及集合的操作复杂度通常为O(N)我们要在使用时重视起来。例如集合元素全量查询操作HGETALL、SMEMBERS以及集合的聚合统计操作例如求交、并和差集。这些操作可以作为Redis的**第一个阻塞点:集合全量查询和聚合操作**。
除此之外,集合自身的删除操作同样也有潜在的阻塞风险。你可能会认为,删除操作很简单,直接把数据删除就好了,为什么还会阻塞主线程呢?
其实删除操作的本质是要释放键值对占用的内存空间。你可不要小瞧内存的释放过程。释放内存只是第一步为了更加高效地管理内存空间在应用程序释放内存时操作系统需要把释放掉的内存块插入一个空闲内存块的链表以便后续进行管理和再分配。这个过程本身需要一定时间而且会阻塞当前释放内存的应用程序所以如果一下子释放了大量内存空闲内存块链表操作时间就会增加相应地就会造成Redis主线程的阻塞。
那么什么时候会释放大量内存呢其实就是在删除大量键值对数据的时候最典型的就是删除包含了大量元素的集合也称为bigkey删除。为了让你对bigkey的删除性能有一个直观的印象我测试了不同元素数量的集合在进行删除操作时所消耗的时间如下表所示
![](https://static001.geekbang.org/resource/image/94/53/94bc8cf9yy5c34a6445434a15b1e9653.jpg)
从这张表里,我们可以得出三个结论:
1. 当元素数量从10万增加到100万时4大集合类型的删除时间的增长幅度从5倍上升到了近20倍
2. 集合元素越大,删除所花费的时间就越长;
3. 当删除有100万个元素的集合时最大的删除时间绝对值已经达到了1.98sHash类型。Redis的响应时间一般在微秒级别所以一个操作达到了近2s不可避免地会阻塞主线程。
经过刚刚的分析,很显然,**bigkey删除操作就是Redis的第二个阻塞点**。删除操作对Redis实例性能的负面影响很大而且在实际业务开发时容易被忽略所以一定要重视它。
既然频繁删除键值对都是潜在的阻塞点了那么在Redis的数据库级别操作中清空数据库例如FLUSHDB和FLUSHALL操作必然也是一个潜在的阻塞风险因为它涉及到删除和释放所有的键值对。所以这就是**Redis的第三个阻塞点清空数据库**。
**2.和磁盘交互时的阻塞点**
我之所以把Redis与磁盘的交互单独列为一类主要是因为磁盘IO一般都是比较费时费力的需要重点关注。
幸运的是Redis开发者早已认识到磁盘IO会带来阻塞所以就把Redis进一步设计为采用子进程的方式生成RDB快照文件以及执行AOF日志重写操作。这样一来这两个操作由子进程负责执行慢速的磁盘IO就不会阻塞主线程了。
但是Redis直接记录AOF日志时会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是12ms如果有大量的写操作需要记录在AOF日志中并同步写回的话就会阻塞主线程了。这就得到了Redis的**第四个阻塞点了AOF日志同步写**。
**3.主从节点交互时的阻塞点**
在主从集群中主库需要生成RDB文件并传输给从库。主库在复制的过程中创建和传输RDB文件都是由子进程来完成的不会阻塞主线程。但是对于从库来说它在接收了RDB文件后需要使用FLUSHDB命令清空当前数据库这就正好撞上了刚才我们分析的**第三个阻塞点。**
此外从库在清空当前数据库后还需要把RDB文件加载到内存这个过程的快慢和RDB文件的大小密切相关RDB文件越大加载过程越慢所以**加载RDB文件就成为了Redis的第五个阻塞点**。
**4.切片集群实例交互时的阻塞点**
最后当我们部署Redis切片集群时每个Redis实例上分配的哈希槽信息需要在不同实例间进行传递同时当需要进行负载均衡或者有实例增删时数据会在不同的实例间进行迁移。不过哈希槽的信息量不大而数据迁移是渐进式执行的所以一般来说这两类操作对Redis主线程的阻塞风险不大。
不过如果你使用了Redis Cluster方案而且同时正好迁移的是bigkey的话就会造成主线程的阻塞因为Redis Cluster使用了同步迁移。我将在第33讲中向你介绍不同切片集群方案对数据迁移造成的阻塞的解决方法这里你只需要知道当没有bigkey时切片集群的各实例在进行交互时不会阻塞主线程就可以了。
好了你现在已经了解了Redis的各种关键操作以及其中的阻塞式操作我们来总结下刚刚找到的五个阻塞点
* 集合全量查询和聚合操作;
* bigkey删除
* 清空数据库;
* AOF日志同步写
* 从库加载RDB文件。
如果在主线程中执行这些操作必然会导致主线程长时间无法服务其他请求。为了避免阻塞式操作Redis提供了异步线程机制。所谓的异步线程机制就是指Redis会启动一些子线程然后把一些任务交给这些子线程让它们在后台完成而不再由主线程来执行这些任务。使用异步线程机制执行操作可以避免阻塞主线程。
不过,这个时候,问题来了:这五大阻塞式操作都可以被异步执行吗?
## 哪些阻塞点可以异步执行?
在分析阻塞式操作的异步执行的可行性之前,我们先来了解下异步执行对操作的要求。
如果一个操作能被异步执行就意味着它并不是Redis主线程的关键路径上的操作。我再解释下关键路径上的操作是啥。这就是说客户端把请求发送给Redis后等着Redis返回数据结果的操作。
这么说可能有点抽象,我画一张图片来解释下。
![](https://static001.geekbang.org/resource/image/f1/61/f196035e3d2ba65257b211ed436b0b61.jpg)
主线程接收到操作1后因为操作1并不用给客户端返回具体的数据所以主线程可以把它交给后台子线程来完成同时只要给客户端返回一个“OK”结果就行。在子线程执行操作1的时候客户端又向Redis实例发送了操作2而此时客户端是需要使用操作2返回的数据结果的如果操作2不返回结果那么客户端将一直处于等待状态。
在这个例子中操作1就不算关键路径上的操作因为它不用给客户端返回具体数据所以可以由后台子线程异步执行。而操作2需要把结果返回给客户端它就是关键路径上的操作所以主线程必须立即把这个操作执行完。
对于Redis来说**读操作是典型的关键路径操作**因为客户端发送了读操作之后就会等待读取的数据返回以便进行后续的数据处理。而Redis的第一个阻塞点“集合全量查询和聚合操作”都涉及到了读操作所以它们是不能进行异步操作了。
我们再来看看删除操作。删除操作并不需要给客户端返回具体的数据结果所以不算是关键路径操作。而我们刚才总结的第二个阻塞点“bigkey删除”和第三个阻塞点“清空数据库”都是对数据做删除并不在关键路径上。因此我们可以使用后台子线程来异步执行删除操作。
对于第四个阻塞点“AOF日志同步写”来说为了保证数据可靠性Redis实例需要保证AOF日志中的操作记录已经落盘这个操作虽然需要实例等待但它并不会返回具体的数据结果给实例。所以我们也可以启动一个子线程来执行AOF日志的同步写而不用让主线程等待AOF日志的写完成。
最后我们再来看下“从库加载RDB文件”这个阻塞点。从库要想对客户端提供数据存取服务就必须把RDB文件加载完成。所以这个操作也属于关键路径上的操作我们必须让从库的主线程来执行。
对于Redis的五大阻塞点来说除了“集合全量查询和聚合操作”和“从库加载RDB文件”其他三个阻塞点涉及的操作都不在关键路径上所以我们可以使用Redis的异步子线程机制来实现bigkey删除清空数据库以及AOF日志同步写。
那么Redis实现的异步子线程机制具体是怎么执行呢
## 异步的子线程机制
Redis主线程启动后会使用操作系统提供的pthread\_create函数创建3个子线程分别由它们负责AOF日志写操作、键值对删除以及文件关闭的异步执行。
主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。
但实际上这个时候删除还没有执行等到后台子线程从任务队列中读取任务后才开始实际删除键值对并释放相应的内存空间。因此我们把这种异步删除也称为惰性删除lazy free。此时删除或清空操作不会阻塞主线程这就避免了对主线程的性能影响。
和惰性删除类似当AOF日志配置成everysec选项后主线程会把AOF写日志操作封装成一个任务也放到任务队列中。后台子线程读取任务后开始自行写入AOF日志这样主线程就不用一直等待AOF日志写完了。
下面这张图展示了Redis中的异步子线程执行机制你可以再看下加深印象。
![](https://static001.geekbang.org/resource/image/ae/69/ae004728bfe6d3771c7424e4161e7969.jpg)
这里有个地方需要你注意一下异步的键值对删除和数据库清空操作是Redis 4.0后提供的功能Redis也提供了新的命令来执行这两个操作。
* 键值对删除当你的集合类型中有大量元素例如有百万级别或千万级别元素需要删除时我建议你使用UNLINK命令。
* 清空数据库可以在FLUSHDB和FLUSHALL命令后加上ASYNC选项这样就可以让后台子线程异步地清空数据库如下所示
```
FLUSHDB ASYNC
FLUSHALL AYSNC
```
## 小结
这节课我们学习了Redis实例运行时的4大类交互对象客户端、磁盘、主从库实例、切片集群实例。基于这4大类交互对象我们梳理了会导致Redis性能受损的5大阻塞点包括集合全量查询和聚合操作、bigkey删除、清空数据库、AOF日志同步写以及从库加载RDB文件。
在这5大阻塞点中bigkey删除、清空数据库、AOF日志同步写不属于关键路径操作可以使用异步子线程机制来完成。Redis在运行时会创建三个子线程主线程会通过一个任务队列和三个子线程进行交互。子线程会根据任务的具体类型来执行相应的异步操作。
不过异步删除操作是Redis 4.0以后才有的功能如果你使用的是4.0之前的版本当你遇到bigkey删除时我给你个小建议先使用集合类型提供的SCAN命令读取数据然后再进行删除。因为用SCAN命令可以每次只读取一部分数据并进行删除这样可以避免一次性删除大量key给主线程带来的阻塞。
例如对于Hash类型的bigkey删除你可以使用HSCAN命令每次从Hash集合中获取一部分键值对例如200个再使用HDEL删除这些键值对这样就可以把删除压力分摊到多次操作中那么每次删除操作的耗时就不会太长也就不会阻塞主线程了。
最后我想再提一下集合全量查询和聚合操作、从库加载RDB文件是在关键路径上无法使用异步操作来完成。对于这两个阻塞点我也给你两个小建议。
* 集合全量查询和聚合操作可以使用SCAN命令分批读取数据再在客户端进行聚合计算
* 从库加载RDB文件把主库的数据量大小控制在2~4GB左右以保证RDB文件能以较快的速度加载。
## 每课一问
按照惯例我给你提一个小问题我们今天学习了关键路径上的操作你觉得Redis的写操作例如SET、HSET、SADD等是在关键路径上吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你帮我分享给更多人,我们下节课见。