Redis变慢定位

Redis变慢定位

Redis 作为内存数据库,拥有非常高的性能,单个实例的 QPS 能够达到 10W 左右。但我们在使用 Redis 时,经常时不时会出现访问延迟很大的情况,如果你不知道 Redis 的内部实现原理,在排查问题时就会一头雾水。

很多时候,Redis 出现访问延迟变大,都与我们的使用不当或运维不合理导致的。

Redis变慢常见原因

使用复杂度高的命令

如果在使用 Redis 时,发现访问延迟突然增大,如何进行排查?首先,第一步,建议你去查看一下 Redis 的慢日志。Redis 提供了慢日志命令的统计功能,我们通过以下设置,就可以查看有哪些命令在执行时延迟比较大。

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

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

设置完成之后,所有执行的命令如果延迟大于 5 毫秒,都会被 Redis 记录下来,我们执行 SLOWLOG get 5 查询最近5条慢日志:

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" 2) 1) (integer) 32692 2) (integer) 1593763337 3) (integer) 5044 4) 1) "GET" 2) "book_price_1000"

通过查看慢日志记录,我们就可以知道在什么时间执行哪些命令比较耗时,如果你的业务经常使用 O(N) 以上复杂度的命令,例如 sort、sunion、zunionstore,或者在执行 O(N) 命令时操作的数据量比较大,这些情况下 Redis 处理数据时就会很耗时。

如果你的服务请求量并不大,但 Redis 实例的 CPU 使用率很高,很有可能是使用了复杂度高的命令导致的。

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

存储bigkey

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

Redis 在写入数据时,需要为新的数据分配内存,当从 Redis 中删除数据时,它会释放对应的内存空间。如果一个 key 写入的数据非常大,Redis 在分配内存时也会比较耗时。同样的,当删除这个 key 的数据时,释放内存也会耗时比较久。

你需要检查你的业务代码,是否存在写入 bigkey 的情况,需要评估写入数据量的大小,业务层应该避免一个 key 存入过大的数据量。那么有没有什么办法可以扫描现在 Redis 中是否存在 bigkey 的数据吗?Redis 也提供了扫描 bigkey 的方法:

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

使用上面的命令就可以扫描出整个实例 key 大小的分布情况,它是以类型维度来展示的。

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

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

而对于容器类型的 key,只能扫描出元素最多的 key,但元素最多的 key 不一定占用内存最多,这一点需要我们注意下。不过使用这个命令一般我们是可以对整个实例中 key 的分布情况有比较清晰的了解。

针对 bigkey 的问题,Redis 官方在 4.0 版本推出了 lazy-free 的机制,用于异步释放 bigkey 的内存,降低对 Redis 性能的影响。即使这样,我们也不建议使用 bigkey,bigkey 在集群的迁移过程中,也会影响到迁移的性能。

集中过期

有时你会发现,平时在使用 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 的时间打散即可。伪代码可以这么写:

# 在过期时间点之后的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 实例中有存储 bigkey,那么在淘汰 bigkey 释放内存时,这个耗时会更加久,延迟更大,这需要我们格外注意。

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

fork耗时严重

如果你的 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 的影响。

绑定CPU

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

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

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

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

AOF配合不合理

上面提到了,当执行 AOF 文件重写时会因为 fork 执行耗时导致 Redis 延迟增大,除了这个之外,如果开启 AOF 机制,设置的策略不合理,也会导致性能问题。

开启 AOF 后,Redis 会把写入的命令实时写入到文件中,但写入文件的过程是先写入内存,等内存中的数据超过一定阈值或达到一定时间后,内存中的内容才会被真正写入到磁盘中。

AOF 为了保证文件写入磁盘的安全性,提供了 3 种刷盘机制:

  • appendfsync always:每次写入都刷盘,对性能影响最大,占用磁盘 IO 比较高,数据安全性最高。
  • appendfsync everysec:1 秒刷一次盘,对性能影响相对较小,节点宕机时最多丢失 1 秒的数据。
  • appendfsync no:按照操作系统的机制刷盘,对性能影响最小,数据安全性低,节点宕机丢失数据取决于操作系统刷盘机制。

当使用第一种机制 appendfsync always 时,Redis 每处理一次写命令,都会把这个命令写入磁盘,而且这个操作是在主线程中执行的。

内存中的的数据写入磁盘,这个会加重磁盘的 IO 负担,操作磁盘成本要比操作内存的代价大得多。如果写入量很大,那么每次更新都会写入磁盘,此时机器的磁盘 IO 就会非常高,拖慢 Redis 的性能,因此我们不建议使用这种机制。

与第一种机制对比,appendfsync everysec 会每隔 1 秒刷盘,而 appendfsync no 取决于操作系统的刷盘时间,安全性不高。因此我们推荐使用 appendfsync everysec 这种方式,在最坏的情况下,只会丢失 1 秒的数据,但它能保持较好的访问性能。

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

使用Swap

如果你发现 Redis 突然变得非常慢,每次访问的耗时都达到了几百毫秒甚至秒级,那此时就检查 Redis 是否使用到了 Swap,这种情况下 Redis 基本上已经无法提供高性能的服务。

我们知道,操作系统提供了 Swap 机制,目的是为了当内存不足时,可以把一部分内存中的数据换到磁盘上,以达到对内存使用的缓冲。

但当内存中的数据被换到磁盘上后,访问这些数据就需要从磁盘中读取,这个速度要比内存慢太多!

尤其是针对 Redis 这种高性能的内存数据库来说,如果 Redis 中的内存被换到磁盘上,对于 Redis 这种性能极其敏感的数据库,这个操作时间是无法接受的。

我们需要检查机器的内存使用情况,确认是否确实是因为内存不足导致使用到了 Swap。

如果确实使用到了 Swap,要及时整理内存空间,释放出足够的内存供 Redis 使用,然后释放 Redis 的 Swap,让 Redis 重新使用内存。

释放 Redis 的 Swap 过程通常要重启实例,为了避免重启实例对业务的影响,一般先进行主从切换,然后释放旧主节点的 Swap,重新启动服务,待数据同步完成后,再切换回主节点即可。

可见,当 Redis 使用到 Swap 后,此时的 Redis 的高性能基本被废掉,所以我们需要提前预防这种情况。

我们需要对 Redis 机器的内存和 Swap 使用情况进行监控,在内存不足和使用到 Swap 时及时报警出来,及时进行相应的处理。

网卡负载过高

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

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

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

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

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

总结

定位 Redis 变慢问题,需要我们从各个方面综合考虑,除了以上常见的方法之外,我们还可以使用 info 命令,查看 Redis 的各方面的性能指标,尤其是 memory 和 stats。