Redis 作为内存数据库,拥有极高的性能,单个实例的 QPS 通常可达到 10 万左右。但在实际使用过程中,偶尔会出现访问延迟显著增大的情况。如果不了解 Redis 的内部实现原理,排查问题时往往难以定位根源。

很多时候,Redis 访问延迟变大是由使用不当或运维配置不合理导致的。本文将分析 Redis 使用过程中常见的延迟问题,并提供相应的定位方法与解决方案。

使用复杂度高的命令

如果在使用 Redis 时发现访问延迟突然增大,建议首先查看 Redis 的慢日志(Slow Log)。Redis 提供了慢日志统计功能,通过配置阈值,可以记录执行耗时较长的命令。

首先设置慢日志阈值,只有超过该阈值的命令才会被记录(单位为微秒)。例如,设置阈值为 5 毫秒,并保留最近 1000 条记录:

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

设置完成后,所有执行延迟大于 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) 或更高的命令,例如 SORTSUNIONZUNIONSTORE,或者在执行 O(N) 命令时操作的数据量过大,都会导致 Redis 处理耗时增加。

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

解决方案:避免使用高复杂度命令,且单次操作不要获取过多数据。尽量分批操作少量数据,确保 Redis 能及时响应。

存储 BigKey

如果查询慢日志发现,耗时命令并非高复杂度命令(例如多为 SETDELETE 操作),则需要怀疑是否存在写入 BigKey 的情况。

Redis 在写入数据时需要分配内存,删除数据时需要释放内存。如果一个 Key 写入的数据非常大,分配内存和释放内存的过程都会比较耗时

需要检查业务代码,评估写入数据量的大小,避免单个 Key 存入过大的数据量。

如何扫描 BigKey

Redis 提供了扫描 BigKey 的方法:

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

该命令可以扫描整个实例中 Key 大小的分布情况,并按类型维度展示。

注意:在线上实例进行 BigKey 扫描时,Redis 的 QPS 可能会突增。为了降低影响,需控制扫描频率,使用 -i 参数设置每次扫描的时间间隔(单位:秒)。

该命令的原理是内部执行 SCAN 遍历所有 Key,然后针对不同类型执行 STRLENLLENHLENSCARDZCARD 来获取长度或元素个数。对于容器类型的 Key,只能扫描出元素最多的 Key,但元素最多不一定占用内存最多,这一点需注意。不过,该命令通常能让我们对实例中 Key 的分布情况有清晰的了解。

针对 BigKey 问题,Redis 官方在 4.0 版本推出了 lazyfree 机制,用于异步释放 BigKey 的内存,降低对性能的影响。即便如此,仍不建议使用 BigKey,因为在集群迁移过程中,BigKey 也会影响迁移性能。

集中过期

有时你会发现,平时使用 Redis 没有明显延时,但在某个时间点突然出现一波延迟,且报慢的时间点很有规律(例如某个整点,或固定间隔)。

如果出现这种情况,需要考虑是否存在大量 Key 集中过期的情况。

如果有大量 Key 在某个固定时间点集中过期,访问 Redis 时可能导致延迟增加。Redis 的过期策略采用主动过期 + 懒惰过期两种策略:

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

注意,Redis 的主动过期定时任务是在主线程中执行的。如果在执行主动过期过程中需要大量删除过期 Key,业务请求必须等待该任务结束才能处理,从而导致业务访问延时增大(最大延迟可达 25 毫秒)。

此外,这种访问延迟不会记录在慢日志里。慢日志只记录真正执行命令的耗时,而主动过期策略执行在命令操作之前。如果命令本身耗时未达阈值,不会计入慢日志,但业务端却会感知到延迟。

此时需检查业务代码,是否存在集中过期的逻辑。一般集中过期使用的命令是 EXPIREATPEXPIREAT,可在代码中搜索相关关键字。

优化方案

如果业务确实需要集中过期某些 Key,又不想导致 Redis 抖动,建议在过期时间上增加随机值,将过期时间打散

伪代码示例:

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

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

运维监控

除了业务优化,还可以通过运维手段及时发现。执行 INFO 命令可获取运行数据,重点关注 expired_keys 指标(累计删除过期 Key 的数量)。

对该指标进行监控,当短时间内该指标突增时及时报警,并与业务报慢的时间点对比。如果一致,则可确认是集中过期导致的延迟。

实例内存达到上限

如果将 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-lruvolatile-lru。其逻辑是:每次随机取出一批 Key,淘汰一个最少访问的 Key,将剩余 Key 暂存池中,继续随机取出一批比较,循环直到内存降至 maxmemory 之下。

如果使用 allkeys-randomvolatile-random 策略,速度会快很多,因为随机淘汰少了比较访问频率的消耗。

以上逻辑均在命令执行之前进行,会影响访问延迟。另外,如果实例中存在 BigKey,淘汰 BigKey 释放内存时耗时会更久,需格外注意。

如果业务访问量非常大且必须设置 maxmemory,除了避免 BigKey、使用随机淘汰策略外,也可以考虑拆分实例,将淘汰压力分摊到多个实例上,以降低延迟。

Fork 耗时严重

如果开启了自动生成 RDB 和 AOF 重写功能,后台生成快照时可能导致访问延迟增大,任务结束后延迟消失。

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

具体原理可参考:Redis 持久化是如何做的?RDB 和 AOF 对比分析

执行 INFO 命令可查看最后一次 fork 执行的耗时 latest_fork_usec(单位:微秒)。这个时间即实例阻塞无法处理请求的时间。

除了备份原因,主从节点第一次建立数据同步时,主节点也会生成 RDB 文件给从节点进行全量同步,同样会影响性能。

解决方案

  1. 规划好数据备份周期,建议在从节点上执行备份,且最好放在低峰期。
  2. 对于丢失数据不敏感的业务,不建议开启 AOF 和 AOF 重写功能。
  3. fork 耗时与系统有关,部署在虚拟机上时间会增大。建议将 Redis 部署在物理机上,降低 fork 影响。

绑定 CPU

部署服务时,为了提高性能降低上下文切换损耗,有时会采用进程绑定 CPU 的操作。但在使用 Redis 时,不建议这样做

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

因此,部署 Redis 进程时,如果开启 RDB 和 AOF 重写机制,一定不能进行 CPU 绑定操作。

AOF 配置不合理

除了 fork 耗时,如果开启 AOF 机制但策略设置不合理,也会导致性能问题。

开启 AOF 后,Redis 会将写入命令实时写入文件,但过程是先写入内存,超过阈值或达到一定时间后才真正写入磁盘。AOF 提供了 3 种刷盘机制:

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

当使用 appendfsync always 时,每处理一次写命令都会写入磁盘,且该操作在主线程中执行。磁盘 IO 成本远高于内存,如果写入量大,机器磁盘 IO 会非常高,拖慢 Redis 性能,因此不建议使用。

推荐使用 appendfsync everysec,在最坏情况下只丢失 1 秒数据,但能保持较好的访问性能。当然,对数据丢失不敏感的业务场景,也可以不开启 AOF。

使用 Swap

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

操作系统提供 Swap 机制,目的是在内存不足时将部分数据换到磁盘缓冲。但内存数据被换到磁盘后,读取速度远慢于内存。对于 Redis 这种高性能内存数据库,内存被换到磁盘上的操作时间是无法接受的。

需检查机器内存使用情况,确认是否因内存不足导致使用 Swap。如果确实使用到了 Swap,要及时整理内存空间,释放足够内存供 Redis 使用,并释放 Redis 的 Swap。

释放 Swap 通常需重启实例。为避免影响业务,一般先进行主从切换,释放旧主节点 Swap 并重启,待数据同步完成后再切换回主节点。

因此,需提前预防:对 Redis 机器的内存和 Swap 使用情况进行监控,在内存不足或使用 Swap 时及时报警处理。

网卡负载过高

如果以上场景都已规避,Redis 稳定运行很长时间后,从某个时间点开始访问变慢且持续至今,需检查机器网卡流量。

特点是从某个时间点之后开始变慢,并且一直持续。 此时需检查是否存在网卡流量被跑满的情况。

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

解决方案:

  1. 排查哪个实例流量过大占满带宽,确认是否属于业务正常情况。
  2. 如果是正常业务增长,需及时扩容或迁移实例,避免影响同机器其他实例。
  3. 运维层面需增加网络流量监控,达到阈值时提前报警。

总结

以上总结了 Redis 中常见的可能导致延迟增大甚至阻塞的场景,涉及业务使用与运维配置两方面。

要保证 Redis 高性能运行,涉及 CPU、内存、网络、磁盘以及操作系统特性的方方面面:

  • 开发人员:需了解 Redis 运行机制,如命令时间复杂度、数据过期策略、淘汰策略等,使用合理命令并结合业务优化。
  • 运维人员:需了解数据持久化、操作系统 fork 原理、Swap 机制等,合理规划容量,预留资源,并做好完善监控。
说明:本文内容主要基于 Redis 3.0 及以上版本,部分特性(如 lazyfreeLFU 策略)需 Redis 4.0 及以上版本支持。