目录
前言
大家对redis 比较熟悉吧,只要做项目都会用到redis,提高系统的吞吐。小米商城抢购高峰18k的qps,redis 在其中扮演着非常重要的角色。有时我们操作不当,redis 阻塞了,影响了整个业务。我记得2018年的时候,顺丰就出现了一个事故,有同学在线上对redis的一个操作,直接阻塞了,影响公司的整个业务,后面这位同学被辞退了。如果他对redis比较了解的话,也不会出现这样的事故。
redis 为什么会阻塞,与它的本身设计有一定关系。大家都知道redis 是单线程的,这个并完全正确,只是说我们对数据的读写是在主线程中完成的。但RDB,aof 重写,删除都是在子线程完成的。我们说的阻塞就是阻塞的主线程,redis 为什么会这么设计,抽时间我会详细讲解。这篇文章我主要从客户端,磁盘,主从节点,切片集群实例 多方面取阐述这个问题。
客户端交换时的阻塞
客户端的功能,与redis 服务器建立连接就有对网络IO的影响,对数据库的增删改查操作。redis 采用的是多路复用机制,避免了主线程一直处在等待网络连接或请求到来的状态,所以,网络 IO 不是导致 Redis 阻塞的因素。剩下的也就是增删改查对redis 的影响,这部分功能也是redis 主要的任务,复杂度高的肯定会阻塞redis。
那么怎么判断操作复杂度高不高?就是看复杂度的O(N),这个复杂度也可以看作是空间复杂度。N越大复杂度越高,越容易阻塞。我们可以对照redis 官网的命令,进行查看。一般集合的操作都是O(N) 的复杂度。比如HGETALL,SMEMBERS,以及集合的聚合统计操作,交,并,差集。这些操作特殊是集合的全量查询和聚合操作。
我们看了查询,删除会不会造成redis 阻塞了。当然会了,删除操作的本质是释放键值对占用的内存空间。它不仅释放内存空间,在Redis 释放内存时,操作系统会把释放掉的内存块插入到一个空闲内存块链表,以便后续进行管理和分配。这个过程需要时间并且会阻塞当前释放内存的应用程序。元素数量越大,这个操作消耗的时间越多,越容易阻塞,这就是我们所说的bigkey 删除。
还有就是清空数据库的操作例如( FLUSHDB 和 FLUSHALL 操作)必然也是一个潜在的阻塞风险,因为它涉及到删除和释放所有的键值对。
redis 磁盘交换的阻塞
磁盘的操作一直是系统的瓶颈,一次读盘需要经过寻道,旋转,传输 这么复杂的操作。很多数据库都用了各种技术来避免经常操作磁盘,比如mysql 用了WAL技术,关于这方面技术描述,可以阅读我的普通索引和唯一索引详解。redis 最大的卖点还是还性能,它保证的是操作的高效性,就没有用这么复杂的技术。redis 与磁盘操作的功能都是放在子线程操作,这样就避免了主线程的阻塞。redis 生成的 RDB 快照,aof 日志重写。但是有一个文件比较特殊,aof 日志,会根据不同写回策略做落盘保存。一个同步写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录在 AOF 日志中,并同步写回的话,就会阻塞主线程了。大家可以看到redis 磁盘交换的阻塞主要发生在aof 日志同步写。
主从节点交互的阻塞
一般的架构都是一主多从,主节点写,从节点读。主从同步的大概过程是,主节点身材RDB 快照,这个操作上面已经讲过,是通过子线程生成的,不会阻塞。对于从节点来说就是两个步骤,一个是FLUSHDB 清空当前数据库,这个肯定会阻塞,从节点的redis 会回放RDB 的数据,这个操作会阻塞从节点。加载RDB 文件也会阻塞,最终影响的从节点的读。
切片集群交互时的阻塞
使用 Redis Cluster 作为集群方案,当我们增加实例或删除实例时,数据会在不同实例进行迁移。一般会是渐进式的迁移,如果遇到bigkey 会阻塞节点
异步执行的演变
综上所述:我们讲到了几个阻塞点:集合全量查询和聚合操作、bigkey 删除、FLUSHDB 和 FLUSHALL 、AOF 日志同步写,从库加载RDB 文件。这些都是Redis 的性能的瓶颈,这些也一直困扰着redis 的开发人员。随着版本的递进,发生了一些变化。有些已经放在子线程去执行了,就意味着,它并不是 Redis 主线程的关键路径上的操作。
那么什么是主线程的关键路径上的操作:这就是说,客户端把请求发送给 Redis 后,等着 Redis 返回数据结果的操作,比如获取数据,进行接下来的业务。
按照这个定义来说的话:集合全量查询和聚合操作依旧是关键路径上的操作,依旧会阻塞,这个是无法避免的。
bigkey 删除、FLUSHDB 和 FLUSHALL 并不需要给客户端返回具体数据结果,不算关键路径上的操作。它们算不算阻塞点呢,这个其实挺复杂的。这个叫做惰性删除(lazy free),这个功能 Redis 4.0 以后才有的功能,并不是所有的key 都能异步删除。
关于异步删除我需要补充几点,希望大家做个理性的判断:
-
lazy-free是4.0新增的功能,但是默认是关闭的,需要手动开启。
-
手动开启lazy-free时,有4个选项可以控制,分别对应不同场景下,要不要开启异步释放内存机制:
a) lazyfree-lazy-expire:key在过期删除时尝试异步释放内存
b) lazyfree-lazy-eviction:内存达到maxmemory并设置了淘汰策略时尝试异步释放内存
c) lazyfree-lazy-server-del:执行RENAME/MOVE等命令或需要覆盖一个key时,删除旧key尝试异步释放内存
d) replica-lazy-flush:主从全量同步,从库清空数据库时异步释放内存
-
即使开启了lazy-free,如果直接使用DEL命令还是会同步删除key,只有使用UNLINK命令才会可能异步删除key 而 FLUSHDB ASYNC、FLUSHALL AYSNC 才会异步清空库
-
这也是最关键的一点,上面提到开启lazy-free的场景,除了replica-lazy-flush之外,其他情况都只是可能去异步释放key的内存,并不是每次必定异步释放内存的。 开启lazy-free后,Redis在释放一个key的内存时,首先会评估代价,如果释放内存的代价很小,那么就直接在主线程中操作了,没必要放到异步线程中执行(不同线程传递数据也会有性能消耗)。 什么情况才会真正异步释放内存?这和key的类型、编码方式、元素数量都有关系(详细可参考源码中的lazyfreeGetFreeEffort函数):
a) 当Hash/Set底层采用哈希表存储(非ziplist/int编码存储)时,并且元素数量超过64个
b) 当ZSet底层采用跳表存储(非ziplist编码存储)时,并且元素数量超过64个
c) 当List链表节点数量超过64个(注意,不是元素数量,而是链表节点的数量,List的实现是在每个节点包含了若干个元素的数据,这些元素采用ziplist存储) 只有以上这些情况,在删除key释放内存时,才会真正放到异步线程中执行,其他情况一律还是在主线程操作。 也就是说String(不管内存占用多大)、List(少量元素)、Set(int编码存储)、Hash/ZSet(ziplist编码存储)这些情况下的key在释放内存时,依旧在主线程中操作。 可见,即使开启了lazy-free,String类型的bigkey,在删除时依旧有阻塞主线程的风险。所以,即便Redis提供了lazy-free,我建议还是尽量不要在Redis中存储bigkey。
个人理解Redis在设计评估释放内存的代价时,不是看key的内存占用有多少,而是关注释放内存时的工作量有多大。从上面分析基本能看出,如果需要释放的内存是连续的,Redis作者认为释放内存的代价比较低,就放在主线程做。如果释放的内存不连续(大量指针类型的数据),这个代价就比较高,所以才会放在异步线程中去执行。
所以我虽然在以后的版本删除有可能是异步删除,还是不要存储bigkey,对bigkey 进行删除。
如果真的要对bigkey 删除呢,我给你个小建议:先使用集合类型提供的 SCAN 命令读取数据,然后再进行删除。因为用 SCAN 命令可以每次只读取一部分数据并进行删除,这样可以避免一次性删除大量 key 给主线程带来的阻塞。
例如,对于 Hash 类型的 bigkey 删除,你可以使用 HSCAN 命令,每次从 Hash 集合中获取一部分键值对(例如 200 个),再使用 HDEL 删除这些键值对,这样就可以把删除压力分摊到多次操作中,那么,每次删除操作的耗时就不会太长,也就不会阻塞主线程了。
AOF 日志呢,如果 AOF 日志配置成 everysec 选项后,也不会去阻塞,异步执行。
从库加载RDB 文件 呢,这个在从库上需要在主线程执行,这个是不能异步的。为了避免阻塞,在这个地方给大家一个建议:从库加载 RDB 文件:把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以较快的速度加载。
redis 异步执行如何实现的
有些操作需要异步执行,redis 主线程通过一个链表形式的任务队列和子线程进行交互,等到后台子线程从任务队列中读取任务进行操作。
好了就讲到这些了,其他的希望大家补充