Redis系列 - Redis如何应对变慢
Redis变慢的一个重要原因是主线程阻塞,其他的如过期key的操作,操作系统swap、内存大页也是重要的原因。
主线程阻塞
在讨论如何变慢之前,我们需要知道Redis的哪些操作会阻塞主线程。
在Redis的操作中主要包含以下一个部分:
- 客户端交互:网络IO(不阻塞)、键值对增删改查操作(O(n)的慢查询查询操作阻塞、大批量删除阻塞)、清空数据库操作(阻塞)
- 磁盘交互:生成RDB快照(不阻塞)、记录AOF日志(阻塞)、AOF日志重写(不阻塞)
- 主从节点交互:主库生成、传输RDB文件,从库接收RDB文件(不阻塞)、清空数据库(阻塞)、加载RDB文件(阻塞)
- 切片集群交互:向其他实例传输哈希槽信息,数据迁移(可能会阻塞)
1. 网络IO使用的是多路复用机制,因此不会阻塞
2. O(n)的查询操作和大批量的删除需要遍历全部,可能会阻塞主线程(集合元素全量查询操作 HGETALL、SMEMBERS,以及集合的聚合统计操作,例如求交、并和差集)
3. 清空数据库(例如 FLUSHDB 和 FLUSHALL 操作)必然也是一个潜在的阻塞风险,因为它涉及到删除和释放所有的键值对
4. 生成RDB快照和AOF日志日志重写都可以启用子线程进行操作,因此不会阻塞
5. Redis 直接记录 AOF 日志时,会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录在 AOF 日志中,并同步写回的话,就会阻塞主线程了
6. 从库在清空当前数据库后,还需要把 RDB 文件加载到内存,这个过程的快慢和 RDB 文件的大小密切相关,RDB 文件越大,加载过程越慢,会阻塞主线程
7. 主库生成、传输RDB文件,从库接收RDB文件都是由子进程来完成的,不会阻塞主线程
8. 向其他实例传输哈希槽信息,数据迁移时,如果有bigkey的话可能会阻塞
因此其中有5个操作会时候产生阻塞:
- 集合全量查询(慢查询)和聚合操作 (不可以异步)
- bigkey 删除 (异步)
- 清空数据库 (异步)
- AOF 日志同步写 (异步)
- 从库加载 RDB 文件 (异步)
异步子线程机制(以上的5点除了第一点需要等待Redis返回结果数据的操作不可以异步执行,其他4点不需要返回结果的都可以异步执行):
Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责 AOF 日志写操作、键值对删除以及文件关闭的异步执行。
主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。
但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。
和惰性删除类似,当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这样主线程就不用一直等待 AOF 日志写完了。
Redis 4.0 后提供了异步的键值对删除和数据库清空操作,Redis 也提供了新的命令来执行这两个操作。
- 键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用 UNLINK 命令
- 清空数据库:可以在 FLUSHDB 和 FLUSHALL 命令后加上 ASYNC 选项,这样就可以让后台子线程异步地清空数据库
FLUSHDB ASYNC
FLUSHALL AYSNC
其余4个阻塞操作都可以异步执行来进行优化,那么慢查询应该怎么做呢?
- 用其他高效命令替代:如用SSCAN多次迭代返回替代SMEMBERS
- 当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例
过期 key 操作
Redis 键值对的 key 可以设置过期时间。默认情况下,Redis 每 100 毫秒会删除一些过期 key,具体的算法如下:
- 采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除
- 如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 是 Redis 的一个参数,默认是 20,那么,一秒内基本有 200 个过期 key 会被删除。这一策略对清除过期 key、释放内存空间很有帮助。如果每秒钟删除 200 个过期 key,并不会对 Redis 造成太大影响。
但是,如果触发了上面这个算法的第二条,Redis 就会一直删除以释放内存空间。注意,删除操作是阻塞的(Redis 4.0 后可以用异步线程机制来减少阻塞影响)。所以,一旦该条件触发,Redis 的线程就会一直执行删除,这样一来,就没办法正常服务其他的键值操作了,就会进一步引起其他键值操作的延迟增加,Redis 就会变慢。
解决办法:
你需要根据实际业务的使用需求,决定 EXPIREAT 和 EXPIRE 的过期时间参数。其次,如果一批 key 的确是同时过期,你还可以在 EXPIREAT 和 EXPIRE 的过期时间参数上,加上一个一定大小范围内的随机数,这样,既保证了 key 在一个邻近时间范围内被删除,又避免了同时过期造成的压力。
操作系统swap和内存大页
swap
现代操作系统都实现了“虚拟内存”这一技术,不但在功能上突破了物理内存的限制,使程序可以操纵大于实际物理内存的空间,更重要的是,“虚拟内存”是隔离每个进程的安全保护网,使每个进程都不受其它程序的干扰。
Swap空间的作用可简单描述为:当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间被临时保存到Swap空间中,等到那些程序要运行时,再从Swap中恢复保存的数据到内存中。这样,系统总是在物理内存不够时,才进行Swap交换。
Redis 的操作是直接通过访问内存就能完成,一旦 swap 被触发了,Redis 的请求操作需要等到磁盘数据读写完成才行。而且,和我刚才说的 AOF 日志文件读写使用 fsync 线程不同,swap 触发后影响的是 Redis 主 IO 线程,这会极大地增加 Redis 的响应时间。
触发 swap 的原因主要是物理机器内存不足,对于 Redis 而言,有两种常见的情况:
- Redis 实例自身使用了大量的内存,导致物理机器的可用内存不足;
- 和 Redis 实例在同一台机器上运行的其他进程,在进行大量的文件读写操作。文件读写本身会占用系统内存,这会导致分配给 Redis 实例的内存量变少,进而触发 Redis 发生 swap。
解决办法:
增加机器的内存或者使用 Redis 集群。
内存大页
Linux 内核从 2.6.38 开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的。
虽然内存大页可以给 Redis 带来内存分配方面的收益,但是,不要忘了,Redis 为了提供数据可靠性保证,需要将数据做持久化保存。这个写入过程由额外的线程执行,所以,此时,Redis 主线程仍然可以接收客户端写请求。客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis 就会采用写时复制机制,也就是说,一旦有数据要被修改,Redis 并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。
如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。两者相比,你可以看到,当客户端请求修改或新写入数据较多时,内存大页机制将导致大量的拷贝,这就会影响 Redis 正常的访存操作,最终导致性能变慢。
解决方案:
关闭内存大页
查看内存大页的命令:
grep Huge /proc/meminfo
检查是否关闭
cat /sys/kernel/mm/transparent_hugepage/enabled
关闭内存大页
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag