Redis常见阻塞场景分析

date
Mar 4, 2024
slug
redis-blocking-scene-analysis
status
Published
tags
编程开发
数据库
summary
Redis虽然是以单线程高性能著称,在一些可能会影响到性能的场景下,redis都做了很多优化来避免主线程受到阻塞,例如开辟子线程来处理。不过即使如此,也依旧有很多场景可能会导致主线程受到阻塞进而影响到系统的吞吐能力,尤其是海量业务场景下,更有可能发生。因此本文想梳理下这些场景,帮助自己在后面的工作能够关注这些场景。
type
Post
Redis虽然是以单线程高性能著称,在一些可能会影响到性能的场景下,redis都做了很多优化来避免主线程受到阻塞,例如开辟子线程来处理。不过即使如此,也依旧有很多场景可能会导致主线程受到阻塞进而影响到系统的吞吐能力,尤其是海量业务场景下,更有可能发生。因此本文想梳理下这些场景,帮助自己在后面的工作能够关注这些场景。

Redis命令导致的阻塞

在使用redis的命令对数据库进行操作时,需要关注这些命令背后的时间复杂度。我们知道redis底层采用了不同的数据结构来存储数据,这些数据结构不是redis提供的那些数据结构(String、Hash、List等),而是这些数据结构底层的数据存储组织方式,例如有动态字符串、双向链表、压缩列表、跳表、哈希表。只有在了解你的命令所操作的数据底层的存储结构,你才好判断其时间复杂度。
因为redis负责执行命令的线程是单线程的,一旦我们的命令执行时间长,势必也影响到后续命令的执行。
  • 简单动态字符串:
    • redis数据结构:String
    • 时间复杂度:O(1)
  • 双向链表:
    • redis数据结构:List
    • 时间复杂度:O(N)
  • 压缩列表
    • redis数据结构:List、Hash、Sorted Set
    • 时间复杂度:O(N)
  • 哈希表:
    • redis数据结构:Hash、 Set
    • 时间复杂度:O(1)
  • 跳表:
    • redis数据结构:Sorted Set
    • 时间复杂度:O(logN)
  • 整数数组:
    • redis数据结构:Set
    • 时间复杂度:O(N)
对于查询操作,除了要关注上述时间复杂度,尽可能的利用组合查询(mget、hmget等),还要关注命令是否是对集合做统计操作的,例如并集、交集等,原先O(N)的操作可能就不止了。
另外一个要注意的指令就是删除指令,这一般会有如下几个影响:
  1. 删除键值后,为了更好的释放内存和管理空间,操作系统会在释放掉的内存中插入一个空闲内存块的链表以便后续进行管理和再分配,这本身就需要一定的时间,可能会阻塞线程。如果是涉及到big key的删除,那这个时间将会更长,因此如果涉及到big key的清理,一定要做好评估。
  1. 有时候我们删除的键值,并不是在一片连续的内存页中,大量的删除这样的数据就会导致严重的内存碎片。虽然redis提供了清理碎片的方式,但是这背后是有代价的。内存数据需要拷贝到新位置,然后释放原有的空间,这都会带来时间开销,进而影响系统的性能。不过redis为我们提供了几个配置操作,来帮助我们减少这个影响:
    1. config set activedefrag yes 开启redis自动内存碎片清理;
    2. active-defrag-ignore-bytes 100mb 内存碎片达到 100MB 时开始清理;
    3. active-defrag-threshold-lower 10 内存碎片空间占操作系统分配给Redis的总空间比例达到 10% 时开始清理;
    4. active-defrag-cycle-min 25 表示自动清理过程所用CPU时间的比例不低于25%,保证清理能正常开展;
    5. active-defrag-cycle-max 75表示自动清理过程所用 CPU 时间的比例不高于75%,一旦超过就停止清理,从而避免在清理时大量的内存拷贝阻塞 Redis,导致响应延迟升高。

Redis日志导致的阻塞

我们知道redis提供了AOF日志和RDB日志来作为系统可靠性保障,保证了系统即使发生宕机,重启后仍能够尽可能的恢复内存中的数据。同时,AOF日志和RDB日志也是作为主从复制、redis集群节点间通信的重要文件。
先说说AOF日志。AOF日志中记录的是每一条对redis的写操作,和其他数据库的日志不同,redis是先执行指令,再写AOF日志的。因为指令操作的是内存,比起日志写文件来说,更加的高效。
对于写日志的时机,redis提供了三种方式:
  1. Always。同步写回。每个写命令执行完,立马同步地将日志写回磁盘;
  1. Everysec,每秒写回。每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
  1. No,操作系统控制的写回。每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
通常我们不会选择Always,虽然可以确保每个命令都得到记录进而实现数据不丢失,但写磁盘这样的重IO操作必然影响redis的性能。
虽然有另外两种写AOF的方式,但它们也都是在主线程上执行的,如果我们对redis有非常频繁的写操作,此时批量写回磁盘,也可能会给redis带来一定的性能影响。
AOF日志会越来越大,而且针对同一个key会记录很多重复的操作命令,为了解决这个情况,redis提供了AOF日志重写,并提供了开辟子进程的方式来执行这个操作。
但是不是就能因此高枕无忧了呢?并非如此,虽然子进程在执行AOF日志重写不会阻塞主进程,但是在这之前,需要先从主进程fork出子进程出来,而这一步却是阻塞的。
除了AOF日志,redis还提供了RDB日志,也就是快照日志,记录的是当下瞬间redis的快照数据。要瞬间记录数据,必然是个很重的操作,因此redis也提供了通过开辟子进程的方式来执行这个过程。所以这里RDB日志也同样会遇到与AOF重写日志一样的问题,主进程fork出子进程,这一步是阻塞的。
这里来详细说下开辟进程时操作系统以及Redis会做的事,便于理解为什么开辟进程会带来阻塞。
父进程在fork出子线程后,子进程能够访问父进程的内存数据,看起来好像是子进程复制了一份父进程的数据出来,实际上并不是如此。在fork时,redis采用的是写时复制,也就是Copy On Write(COW)机制,目的就是为了避免一次性拷贝大量的内存数据给子进程,这会导致长时间的进程阻塞,因此在fork时只拷贝了必要的数据结构,其中有一项就是拷贝内存页表,包含了虚拟内存和物理内存的映射索引表,而这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的,阻塞时间取决于整个实例的内存大小,实例越大,内存页表越大,fork阻塞时间就越久。
在拷贝完成之后,子进程就会与父进程指向相同的内存空间,也就是说此时并没有申请与父进程相同的内存大小。对于fork出子进程的redis来说,此时便可以根据内存中的数据进行AOF日志重写和RDB快照生成。
而当父进程的内存发生写操作时,才开始真正拷贝内存数据。如果主进程需要修改一个已经存在的数据,那这个时候父进程则需要拷贝这部份数据,此时会申请新的内存空间,将数据复制到这个内存空间中,这个复制出来的新的空间也会提供给子进程访问,这样也就避免修改数据时影响AOF日志重写和RDB快照生成。如果此时复制的数据非常大,例如是一个big key,那么此时的复制就会导致进程被阻塞。
以上就是redis日志可能会存在的阻塞点,在日常的监控中也应该去关注。

主从复制

Redis在进行主从复制时,会开辟子进程的方式来进行,不会阻塞到主进程。然而当从库接收到主库发送过来的RDB文件后,需要先清空所有数据,这里清空数据就会遇到之前分析的内存页清理的问题。而完成数据清除之后,则会开始依据RDB文件恢复数据,而这个恢复过程自然也是阻塞的,其阻塞的时间则依据RDB的文件大小,也就是数据大小。

阻塞优化

Redis针对上面提到的键值删除和fork可能引发阻塞的问题,在后续的版本中作出了优化。在主进程启动后,会同时创建三个子进程,分别负责AOF日志写操作,键值对删除以及文件关闭的异步执行。
当收到键值对的删除和清空数据库的操作时,主进程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。但此时这些数据可能并还没有删除,后台子线程会从任务队列中读取任务,才开始实际删除键值对,并释放相应的内存空间,并不会阻塞主进程,这个过程也称为惰性删除。
写AOF日志也同理,当我们配置的是everySec时,主进程会把AOF写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入AOF日志,这样主进程就不用一直等待AOF日志写完了。