04-Redis 场景 常见阻塞原因

本章适用于以下问题:

  • 可能导致 Redis 阻塞的情况,这些情况也是影响 Redis 性能的关键因素
  • Redis 变慢了,怎么排查
  • Redis 延迟突然增大,如何进行排查

使用了复杂的命令、O(n) 命令

Redis 中的大部分命令都是 O(1) 时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:

  • KEYS *:会返回所有符合规则的 key。
  • HGETALL:会返回一个 Hash 中所有的键值对。
  • LRANGE:会返回 List 中指定范围内的元素。
  • SMEMBERS:返回 Set 中的所有元素。
  • SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。

由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCANSSCANZSCAN 代替。

除了这些 O(n) 时间复杂度的命令可能会导致阻塞之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:

  • ZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
  • ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
  • ……

首先建议查看一下 Redis 的慢日志,Redis 提供了慢日志命令的统计功能。

设置 Redis 的慢日志阈值,只有超过阈值的命令才会被记录,这里的单位是微妙,例如设置慢日志的阈值为5毫秒,同时设置只保留最近 1000 条慢日志记录:

1
2
3
4
# 命令执行超过5毫秒记录慢日志
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近1000条慢日志
CONFIG SET slowlog-max-len 1000

然后执行 SLOWLOG get 5 查询最近5条慢日志:

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> SLOWLOG get 5
1) 1) (integer) 32693 # 慢日志ID
2) (integer) 1593763337 # 执行时间
3) (integer) 5299 # 执行耗时(微妙)
4) 1) "LRANGE" # 具体执行的命令和参数
2) "user_list_2000"
3) "0"
4) "-1"
...

解决方案就是,不使用这些复杂度较高的命令,并且一次不要获取太多的数据,每次尽量操作少量的数据,让 Redis 可以及时处理返回。

SAVE 创建 RDB 快照

Redis 提供了两个命令来生成 RDB 快照文件:

  • save: 同步保存操作,会阻塞 Redis 主线程;
  • bgsave: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。

默认情况下,Redis 默认配置会使用 bgsave 命令。如果手动使用 save 命令生成 RDB 快照文件的话,就会阻塞主线程。

如果你的 Redis 开启了自动生成 RDB 和 AOF重写 功能,那么有可能在后台生成 RDB 和 AOF重写 时导致 Redis 的访问延迟增大,而等这些任务执行完毕后,延迟情况消失。

遇到这种情况,一般就是执行生成 RDB 和 AOF重写 任务导致的。

生成 RDB 和 AOF 都需要父进程 fork 出一个子进程进行数据的持久化,在 fork 执行过程中,父进程需要拷贝内存页表给子进程,如果整个实例内存占用很大,那么需要拷贝的内存页表会比较耗时,此过程会消耗大量的 CPU 资源,在完成 fork 之前,整个实例会被阻塞住,无法处理任何请求,如果此时 CPU 资源紧张,那么 fork 的时间会更长,甚至达到秒级。这会严重影响 Redis 的性能。

我们可以执行 info 命令,查看最后一次 fork 执行的耗时 latest_fork_usec,单位微秒。这个时间就是整个实例阻塞无法处理请求的时间。

除了因为备份的原因生成 RDB 之外,在主从节点第一次建立数据同步时,主节点也会生成 RDB 文件给从节点进行一次全量同步,这时也会对 Redis 产生性能影响。

要想避免这种情况,我们需要规划好数据备份的周期,建议在从节点上执行备份,而且最好放在低峰期执行。如果对于丢失数据不敏感的业务,那么不建议开启 AOF 和 AOF重写 功能。

另外,fork 的耗时也与系统有关,如果把 Redis 部署在虚拟机上,那么这个时间也会增大。所以使用 Redis 时建议部署在物理机上,降低 fork 的影响。

AOF

AOF 日志记录阻塞

Redis AOF 持久化机制是在执行完命令之后再记录日志,这和关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复)不同。

AOF 记录日志过程

为什么是在执行完命令之后记录日志呢?

  • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
  • 在命令执行完之后再记录,不会阻塞当前的命令执行。

这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):

  • 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
  • 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)

AOF 刷盘阻塞

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式(fsync策略),它们分别是:

  1. appendfsync always:主线程调用 write 执行写操作后,后台线程(aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。
  2. appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程(aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsyncfsync间隔为 1 秒)
  3. appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsyncfsync 的时机由操作系统决定)。

当后台线程(aof_fsync 线程)调用 fsync 函数同步 AOF 文件时,需要等待,直到写入完成。当磁盘压力太大的时候,会导致 fsync 操作发生阻塞,主线程调用 write 函数时也会被阻塞。fsync 完成后,主线程执行 write 才能成功返回。

当然,对于有些业务场景,对丢失数据并不敏感,也可以不开启 AOF。

AOF 重写阻塞

  1. fork 出一条子线程来将文件重写,在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。
  2. 当子线程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。
  3. 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

阻塞就是出现在第 2 步的过程中,将缓冲区中新数据写到新文件的过程中会产生阻塞

相关阅读:Redis AOF 重写阻塞问题分析

大 Key

如果查询慢日志发现,并不是复杂度较高的命令导致的,例如都是 SET、DELETE 操作出现在慢日志记录中,那么你就要怀疑是否存在 Redis 写入了大 key 的情况。

如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:

  • string 类型的 value 超过 1MB
  • 复合类型(列表、哈希、集合、有序集合等)的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。

大 key 造成的阻塞问题如下:

  • 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。

查找大 key

当我们在使用 Redis 自带的 --bigkeys 参数查找大 key 时,最好选择在从节点上执行该命令,因为主节点上执行时,会阻塞主节点。

1
redis-cli -h $host -p $port --bigkeys -i 0.01

需要注意的是当我们在线上实例进行大 key 扫描时,Redis 的 QPS 会突增,为了降低扫描过程中对 Redis 的影响,我们需要控制扫描的频率,使用 -i 参数控制即可,它表示扫描过程中每次扫描的时间间隔,单位是秒。

这个命令的原理是:Redis 在内部执行 scan 命令,遍历所有 key,然后针对不同类型的 key 执行 strlen、llen、hlen、scard、zcard 来获取字符串的长度以及容器类型(list/dict/set/zset)的元素个数。

  • 我们还可以使用 SCAN 命令来查找大 key;
  • 通过分析 RDB 文件来找出 big key,这种方案的前提是 Redis 采用的是 RDB 持久化。网上有现成的工具:
    • redis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
    • rdb_bigkeys:Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。

删除大 key

删除操作的本质是要释放键值对占用的内存空间。

释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。

所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

删除大 key 时建议采用分批次删除和异步删除的方式进行。

清空数据库

清空数据库和上面 bigkey 删除也是同样道理,flushdbflushall 也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点。

集群扩容

Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。

在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是同步操作。

执行迁移时,两端的 Redis 均会进入时长不等的阻塞状态,对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。

Swap(内存交换)

什么是 Swap? Swap 直译过来是交换的意思,Linux 中的 Swap 常被称为内存交换或者交换分区。类似于 Windows 中的虚拟内存,就是当内存不足的时候,把一部分硬盘空间虚拟成内存使用,从而解决内存容量不足的情况。因此,Swap 分区的作用就是牺牲硬盘,增加内存,解决 VPS 内存不够用或者爆满的问题。

Swap 对于 Redis 来说是非常致命的,Redis 保证高性能的一个重要前提是所有的数据在内存中。如果操作系统把 Redis 使用的部分内存换出硬盘,由于内存与硬盘的读写速度差几个数量级,会导致发生交换后的 Redis 性能急剧下降。

识别 Redis 发生 Swap 的检查方法如下:

1、查询 Redis 进程号

1
2
redis-cli -p 6383 info server | grep process_id
process_id: 4476

2、根据进程号查询内存交换信息

1
2
3
4
5
6
7
cat /proc/4476/smaps | grep Swap
Swap: 0kB
Swap: 0kB
Swap: 4kB
Swap: 0kB
Swap: 0kB
.....

如果交换量都是 0KB 或者个别的是 4KB,则正常。

预防内存交换的方法:

  • 保证机器充足的可用内存
  • 确保所有 Redis 实例设置最大可用内存(maxmemory),防止极端情况 Redis 内存不可控的增长
  • 降低系统使用 swap 优先级,如echo 10 > /proc/sys/vm/swappiness

CPU 竞争

Redis 是典型的 CPU 密集型应用,不建议和其他多核 CPU 密集型服务部署在一起。当其他进程过度消耗 CPU 时,将严重影响 Redis 的吞吐量。

可以通过 redis-cli --stat 获取当前 Redis 使用情况。通过 top 命令获取进程对 CPU 的利用率等信息 通过 info commandstats 统计信息分析出命令不合理开销时间,查看是否是因为高算法复杂度或者过度的内存优化问题。

集中过期

有时你会发现,平时在使用 Redis 时没有延时比较大的情况,但在某个时间点突然出现一波延时,而且报慢的时间点很有规律,例如某个整点,或者间隔多久就会发生一次。

如果出现这种情况,就需要考虑是否存在大量 key 集中过期的情况。如果有大量的 key 在某个固定时间点集中过期,在这个时间点访问 Redis 时,就有可能导致延迟增加。

Redis 的过期策略采用主动过期+懒惰过期两种策略:

主动过期: Redis 内部维护一个定时任务,默认每隔 100 毫秒会从过期字典中随机取出 20 个 key,删除过期的 key,如果过期 key 的比例超过了 25%,则继续获取 20 个 key,删除过期的 key,循环往复,直到过期 key 的比例下降到 25% 或者这次任务的执行耗时超过了 25 毫秒,才会退出循环。
懒惰过期: 只有当访问某个 key 时,才判断这个 key 是否已过期,如果已经过期,则从实例中删除。

注意,Redis 的主动过期的定时任务,也是在 Redis 主线程中执行的,也就是说如果在执行主动过期的过程中,出现了需要大量删除过期 key 的情况,那么在业务访问时,必须等这个过期任务执行结束,才可以处理业务请求。此时就会出现,业务访问延时增大的问题,最大延迟为 25 毫秒。

而且这个访问延迟的情况,不会记录在慢日志里。慢日志中只记录真正执行某个命令的耗时,Redis 主动过期策略执行在操作命令之前,如果操作命令耗时达不到慢日志阈值,它是不会计算在慢日志统计中的,但我们的业务却感到了延迟增大。

此时你需要检查你的业务,是否真的存在集中过期的代码,一般集中过期使用的命令是 expireat 或 pexpireat 命令,在代码中搜索这个关键字就可以了。

如果你的业务确实需要集中过期掉某些 key,又不想导致 Redis 发生抖动,有什么优化方案?

解决方案是,在集中过期时增加一个随机时间,把这些需要过期的 key 的时间打散即可。伪代码可以这么写:

1
2
# 在过期时间点之后的5分钟内随机过期掉
redis.expireat(key, expire_time + random(300))

这样 Redis 在处理过期时,不会因为集中删除 key 导致压力过大,阻塞主线程。

另外,除了业务使用需要注意此问题之外,还可以通过运维手段来及时发现这种情况。

做法是我们需要把 Redis 的各项运行数据监控起来,执行 info 可以拿到所有的运行数据,在这里我们需要重点关注 expired_keys 这一项,它代表整个实例到目前为止,累计删除过期 key 的数量。

我们需要对这个指标监控,当在很短时间内这个指标出现突增时,需要及时报警出来,然后与业务报慢的时间点对比分析,确认时间是否一致,如果一致,则可以认为确实是因为这个原因导致的延迟增大。

实例内存达到上限

有时我们把 Redis 当做纯缓存使用,就会给实例设置一个内存上限 maxmemory,然后开启 LRU 淘汰策略。

当实例的内存达到了 maxmemory 后,你会发现之后的每次写入新的数据,有可能变慢了。

导致变慢的原因是,当 Redis 内存达到 maxmemory 后,每次写入新的数据之前,必须先踢出一部分数据,让内存维持在 maxmemory 之下。

这个踢出旧数据的逻辑也是需要消耗时间的,而具体耗时的长短,要取决于配置的淘汰策略:

  • allkeys-lru:不管 key 是否设置了过期,淘汰最近最少访问 的key。
  • volatile-lru:只淘汰最近最少访问并设置过期的 key。
  • allkeys-random:不管 key 是否设置了过期,随机淘汰。
  • volatile-random:只随机淘汰有设置过期的 key。
  • allkeys-ttl:不管key是否设置了过期,淘汰即将过期的 key。
  • noeviction:不淘汰任何 key,满容后再写入直接报错。
  • allkeys-lfu:不管 key 是否设置了过期,淘汰访问频率最低的 key(4.0+支持)。
  • volatile-lfu:只淘汰访问频率最低的过期 key(4.0+支持)。

具体使用哪种策略,需要根据业务场景来决定。

我们最常使用的一般是 allkeys-lru 或 volatile-lru 策略,它们的处理逻辑是,每次从实例中随机取出一批 key(可配置),然后淘汰一个最少访问的 key,之后把剩下的 key 暂存到一个池子中,继续随机取出一批 key,并与之前池子中的 key 比较,再淘汰一个最少访问的 key。以此循环,直到内存降到 maxmemory 之下。

如果使用的是 allkeys-random 或 volatile-random 策略,那么就会快很多,因为是随机淘汰,那么就少了比较 key 访问频率时间的消耗了,随机拿出一批 key 后直接淘汰即可,因此这个策略要比上面的LRU策略执行快一些。

但以上这些逻辑都是在访问 Redis 时,真正命令执行之前执行的,也就是它会影响我们访问 Redis 时执行的命令。

另外,如果此时 Redis 实例中有存储大 key,那么在淘汰大 key 释放内存时,这个耗时会更加久,延迟更大,这需要我们格外注意。

如果你的业务访问量非常大,并且必须设置 maxmemory 限制实例的内存上限,同时面临淘汰 key 导致延迟增大的的情况,要想缓解这种情况,除了上面说的避免存储大 key、使用随机淘汰策略之外,也可以考虑拆分实例的方法来缓解,拆分实例可以把一个实例淘汰 key 的压力分摊到多个实例上,可以在一定程度降低延迟。

绑定 CPU

很多时候,我们在部署服务时,为了提高性能,降低程序在使用多个 CPU 时上下文切换的性能损耗,一般会采用进程绑定 CPU 的操作。

但在使用 Redis 时,我们不建议这么干,原因如下。

绑定 CPU 的 Redis,在进行数据持久化时,fork 出的子进程,子进程会继承父进程的 CPU 使用偏好,而此时子进程会消耗大量的 CPU 资源进行数据持久化,子进程会与主进程发生 CPU 争抢,这也会导致主进程的 CPU 资源不足访问延迟增大。

所以在部署 Redis 进程时,如果需要开启 RD B和 AOF重写 机制,一定不能进行 CPU 绑定操作!

网卡负载过高

如果以上产生性能问题的场景,你都规避掉了,而且 Redis 也稳定运行了很长时间,但在某个时间点之后开始,访问 Redis 开始变慢了,而且一直持续到现在,这种情况是什么原因导致的?

之前我们就遇到这种问题,特点就是从某个时间点之后就开始变慢,并且一直持续。这时你需要检查一下机器的网卡流量,是否存在网卡流量被跑满的情况。

网卡负载过高,在网络层和 TCP 层就会出现数据发送延迟、数据丢包等情况。Redis 的高性能除了内存之外,就在于网络IO,请求量突增会导致网卡负载变高。

如果出现这种情况,你需要排查这个机器上的哪个 Redis 实例的流量过大占满了网络带宽,然后确认流量突增是否属于业务正常情况,如果属于那就需要及时扩容或迁移实例,避免这个机器的其他实例受到影响。

运维层面,我们需要对机器的各项指标增加监控,包括网络流量,在达到阈值时提前报警,及时与业务确认并扩容。

网络问题

连接拒绝、网络延迟,网卡软中断等网络问题也可能会导致 Redis 阻塞。

参考


04-Redis 场景 常见阻塞原因
https://flepeng.github.io/interview-41-数据库-41-Redis-04-Redis-场景-常见阻塞原因/
作者
Lepeng
发布于
2020年8月8日
许可协议