Redis 性能优化
Redis作为高性能的内存数据库,在大数据量的情况下也会遇到性能瓶颈,日常开发中只有时刻谨记优化铁则,才能使得Redis性能发挥到极致。

1、避免慢查询命令

慢查询命令指的是执行较慢的命令,Redis自身提供了许多的命令,并不是所有的命令都慢,这和命令的操作复杂度有关,因此必须知道Redis不同命令的复杂度。
比如说,Value 类型为 String 时,GET/SET 操作主要就是操作 Redis 的哈希表索引。这个操作复杂度基本是固定的,即 O(1)。但是,当 Value 类型为 Set 时,SORTSUNION/SMEMBERS 操作复杂度分别为 O(N+M*log(M))O(N)。其中,NSet 中的元素个数,MSORT 操作返回的元素个数。这个复杂度就增加了很多。Redis 官方文档中对每个命令的复杂度都有介绍,当需要了解某个命令的复杂度时,可以直接查询。
当发现 Redis 性能变慢时,可以通过 Redis 日志,或者是 latency monitor 工具,查询变慢的请求,根据请求对应的具体命令以及官方文档,确认下是否采用了复杂度高的慢查询命令。
如果确实存在大量的慢查询命令,建议如下两种方式:

  1. 用其他高效的命令代替:比如说,如果需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。
  2. 当需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORTSUNION、SINTER 这些命令,以免拖慢 Redis 实例。

    2、生产环境禁用keys命令

    keys这个命令是最容易忽略的慢查询命令,因为keys命令需要遍历存储的键值对,所以操作延时很高,在生产环境使用很可能导致Redis阻塞;因此不建议在生产环境中使用**keys**命令

    3、keys需要设置过期时间

    Redis作为内存数据库,一切的数据都是在内存中,一旦内存占用过大则会大大影响性能,因此需要对有时间限制的数据需要设置过期时间,这样Redis能够定时的删除过期的数据。

    4. 禁止批量的给keys设置相同的过期时间

    默认情况下,Redis 每 100 毫秒会删除一些过期 key,具体的算法如下:

  3. 采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除;

  4. 如果超过 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 命令设置过期 key 将会触发算法第二条,这就会导致在一秒内存在大量的keys过期。
因此开发中一定要禁止批量的给keys设置过期时间。

5、谨慎选择数据结构

Redis 常用的数据结构一共有五种:stringhashlistsetzset(sorted set)。可以发现,大多数场景下使用 string 都可以去解决问题。但是,这并不一定是最优的选择。下面,简单说明下它们各自的适用场景:

  1. string:单个的缓存结果,不与其他的 KV 之间有联系
  2. hash:一个 Object 包含有很多属性,且这些属性都需要单独存储。注意:这种情况不要使用 string,因为 string 会占据更多的内存
  3. list:一个 Object 包含很多数据,且这些数据允许重复、要求有顺序性
  4. set:一个 Object 包含很多数据,不要求数据有顺序,但是不允许重复
  5. zset:一个 Object 包含很多数据,且这些数据自身还包含一个权重值,可以利用这个权重值来排序

另外Redis还提供了几种的扩展类型,如下:

  1. HyperLogLog:适合用于基数统计,比如PV,UV的统计,存在误差问题,不适合精确统计。
  2. BitMap:适合二值状态的统计,比如签到打卡,要么打卡了,要么未打卡。

    6、检查持久化策略

    Redis4.0之后使用了如下三种持久化策略:

  3. AOF日志:一种采用文件追加的方式将命令记录在日志中的策略,针对同步和异步追加还提供了三个配置项,有兴趣的可以查看官方文档。

  4. RDB快照:以快照的方式,将某一个时刻的内存数据,以二进制的方式写入磁盘。
  5. AOFRDB混用:Redis4.0新增的方式,为了采用两种方式各自的优点,在RDB快照的时间段内使用的AOF日志记录这段时间的操作的命令,这样一旦发生宕机,将不会丢失两段快照中间的数据。

由于写入磁盘有IO性能瓶颈,因此不是将Redis作为数据库的话(可以从后端恢复),建议禁用持久化或者调整持久化策略。

7、采用高速的固态硬盘作为日志写入设备

由于AOF日志的重写对磁盘的压力较大,很可能会阻塞,如果需要使用到持久化,建议使用高速的固态硬盘作为日志写入设备。

8、使用物理机而非虚拟机

由于虚拟机增加了虚拟化软件层,与物理机相比,虚拟机本身就存在性能的开销,可以使用如下命令来分别测试下物理机和虚拟机的基线性能

  1. ./redis-cli --intrinsic-latency 120

测试结果可以知道,使用物理机的基线性能明显比虚拟机的基线性能更好。

9、增加机器内存或者使用Redis集群

物理机器的内存不足将会导致操作系统内存的Swap
内存 swap 是操作系统里将内存数据在内存和磁盘间来回换入和换出的机制,涉及到磁盘的读写,所以,一旦触发 swap,无论是被换入数据的进程,还是被换出数据的进程,其性能都会受到慢速磁盘读写的影响。
Redis 是内存数据库,内存使用量大,如果没有控制好内存的使用量,或者和其他内存需求大的应用一起运行了,就可能受到 swap 的影响,而导致性能变慢
这一点对于 Redis 内存数据库而言,显得更为重要:正常情况下,Redis 的操作是直接通过访问内存就能完成,一旦 swap 被触发了,Redis 的请求操作需要等到磁盘数据读写完成才行。而且,和刚才说的 AOF 日志文件读写使用 fsync 线程不同,swap 触发后影响的是 Redis 主 IO 线程,这会极大地增加 Redis 的响应时间。
因此增加机器的内存或者使用Redis集群能够有效的解决操作系统内存的Swap,提高性能。

10、使用 Pipeline 批量操作数据

Pipeline (管道技术) 是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

11、客户端使用优化

在客户端的使用上除了要尽量使用 Pipeline 的技术外,还需要注意要尽量使用 Redis 连接池,而不是频繁创建销毁 Redis 连接,这样就可以减少网络传输次数和减少了非必要调用指令。

12、使用分布式架构来增加读写速度

Redis 分布式架构有三个重要的手段:

  1. 主从同步
  2. 哨兵模式
  3. Redis Cluster 集群

使用主从同步功能可以把写入放到主库上执行,把读功能转移到从服务上,因此就可以在单位时间内处理更多的请求,从而提升的 Redis 整体的运行速度。
而哨兵模式是对于主从功能的升级,但当主节点奔溃之后,无需人工干预就能自动恢复 Redis 的正常使用。
Redis ClusterRedis 3.0 正式推出的,Redis 集群是通过将数据分散存储到多个节点上,来平衡各个节点的负载压力。
Redis Cluster 采用虚拟哈希槽分区,所有的键根据哈希函数映射到 0 ~ 16383整数槽内,计算公式:slot = CRC16(key) & 16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。这样 Redis 就可以把读写压力从一台服务器,分散给多台服务器了,因此性能会有很大的提升。
在这三个功能中,只需要使用一个就行了,毫无疑问 Redis Cluster 应该是首选的实现方案,它可以把读写压力自动的分担给更多的服务器,并且拥有自动容灾的能力。

13、避免内存碎片

频繁的新增修改会导致内存碎片的增多,因此需要时刻的清理内存碎片。
Redis提供了INFO memory可以查看内存的使用信息,如下:

  1. INFO memory
  2. # Memory
  3. used_memory:1073741736
  4. used_memory_human:1024.00M
  5. used_memory_rss:1997159792
  6. used_memory_rss_human:1.86G
  7. mem_fragmentation_ratio:1.86

这里有一个 mem_fragmentation_ratio 的指标,它表示的就是 Redis 当前的内存碎片率。那么,这个碎片率是怎么计算的呢?其实,就是上面的命令中的两个指标 used_memory_rssused_memory 相除的结果。

  1. mem_fragmentation_ratio = used_memory_rss/ used_memory

used_memory_rss 是操作系统实际分配给 Redis 的物理内存空间,里面就包含了碎片;而 used_memory 是 Redis 为了保存数据实际申请使用的空间。
那么,知道了这个指标,该如何使用呢?在这儿,提供一些经验阈值:

  1. mem_fragmentation_ratio 大于 1 但小于 1.5。这种情况是合理的。这是因为,刚才介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由 Redis 负载决定,也无法限制。所以,存在内存碎片也是正常的。
  2. mem_fragmentation_ratio 大于 1.5 。这表明内存碎片率已经超过了 50%。一般情况下,这个时候,就需要采取一些措施来降低内存碎片率了。

一旦内存碎片率过高了,此时就应该采用手段清理内存碎片。

14、使用复杂度过高的命令

首先,第一步,需要去查看一下 Redis 的慢日志(slowlog)。
Redis 提供了慢日志命令的统计功能,它记录了有哪些命令在执行时耗时比较久。
查看 Redis 慢日志之前,需要设置慢日志的阈值。例如,设置慢日志的阈值为 5 毫秒,并且保留最近 500 条慢日志记录:

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

设置完成之后,所有执行的命令如果操作耗时超过了 5 毫秒,都会被 Redis 记录下来。
此时,可以执行以下命令,就可以查询到最近记录的慢日志:

  1. 127.0.0.1:6379> SLOWLOG get 5
  2. 1) 1) (integer) 32693 # 慢日志ID
  3. 2) (integer) 1593763337 # 执行时间戳
  4. 3) (integer) 5299 # 执行耗时(微秒)
  5. 4) 1) "LRANGE" # 具体执行的命令和参数
  6. 2) "user_list:2000"
  7. 3) "0"
  8. 4) "-1"
  9. 2) 1) (integer) 32692
  10. 2) (integer) 1593763337
  11. 3) (integer) 5044
  12. 4) 1) "GET"
  13. 2) "user_info:1000"
  14. ...

通过查看慢日志,就可以知道在什么时间点,执行了哪些命令比较耗时。
如果应用程序执行的 Redis 命令有以下特点,那么有可能会导致操作延迟变大:

  1. 经常使用 O(N) 以上复杂度的命令,例如 SORT、SUNION、ZUNIONSTORE 聚合类命令;
  2. 使用 O(N) 复杂度的命令,但 N 的值非常大。

第一种情况导致变慢的原因在于,Redis 在操作内存数据时,时间复杂度过高,要花费更多的 CPU 资源。
第二种情况导致变慢的原因在于,Redis 一次需要返回给客户端的数据过多,更多时间花费在数据协议的组装和网络传输过程中。
另外,还可以从资源使用率层面来分析,如果应用程序操作 Redis 的 OPS 不是很大,但 Redis 实例的 CPU 使用率却很高,那么很有可能是使用了复杂度过高的命令导致的。
除此之外都知道,Redis 是单线程处理客户端请求的,如果经常使用以上命令,那么当 Redis 处理客户端请求时,一旦前面某个命令发生耗时,就会导致后面的请求发生排队,对于客户端来说,响应延迟也会变长。
Redis性能优化 - 图1
针对这种情况如何解决呢?
答案很简单,可以使用以下方法优化业务:

  1. 尽量不使用 O(N) 以上复杂度过高的命令,对于数据的聚合操作,放在客户端做;
  2. 执行 O(N) 命令,保证 N 尽量的小(推荐 N <= 300),每次获取尽量少的数据,让 Redis 可以及时处理返回。

    15、操作bigkey

    如果查询慢日志发现,并不是复杂度过高的命令导致的,而都是 SET / DEL 这种简单命令出现在慢日志中,那么就要怀疑实例否写入了 bigkey。
    Redis 在写入数据时,需要为新的数据分配内存,相对应的,当从 Redis 中删除数据时,它会释放对应的内存空间。
    如果一个 key 写入的 value 非常大,那么 Redis 在分配内存时就会比较耗时。同样的,当删除这个 key 时,释放内存也会比较耗时,这种类型的 key 一般称之为 bigkey。
    此时,需要检查业务代码,是否存在写入 bigkey 的情况。需要评估写入一个 key 的数据大小,尽量避免一个 key 存入过大的数据。
    如果已经写入了 bigkey,那有没有什么办法可以扫描出实例中 bigkey 的分布情况呢?
    答案是可以的。
    Redis 提供了扫描 bigkey 的命令,执行以下命令就可以扫描出,一个实例中 bigkey 的分布情况,输出结果是以类型维度展示的:

    1. $ redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
    2. ...
    3. -------- summary -------
    4. Sampled 829675 keys in the keyspace!
    5. Total key length in bytes is 10059825 (avg len 12.13)
    6. Biggest string found 'key:291880' has 10 bytes
    7. Biggest list found 'mylist:004' has 40 items
    8. Biggest set found 'myset:2386' has 38 members
    9. Biggest hash found 'myhash:3574' has 37 fields
    10. Biggest zset found 'myzset:2704' has 42 members
    11. 36313 strings with 363130 bytes (04.38% of keys, avg size 10.00)
    12. 787393 lists with 896540 items (94.90% of keys, avg size 1.14)
    13. 1994 sets with 40052 members (00.24% of keys, avg size 20.09)
    14. 1990 hashs with 39632 fields (00.24% of keys, avg size 19.92)
    15. 1985 zsets with 39750 members (00.24% of keys, avg size 20.03)

    从输出结果可以很清晰地看到,每种数据类型所占用的最大内存 / 拥有最多元素的 key 是哪一个,以及每种数据类型在整个实例中的占比和平均大小 / 元素数量。
    其实,使用这个命令的原理,就是 Redis 在内部执行了 SCAN 命令,遍历整个实例中所有的 key,然后针对 key 的类型,分别执行 STRLEN、LLEN、HLEN、SCARD、ZCARD 命令,来获取 String 类型的长度、容器类型(List、Hash、Set、ZSet)的元素个数。
    当执行这个命令时,要注意 2 个问题:

  3. 对线上实例进行 bigkey 扫描时,Redis 的 OPS 会突增,为了降低扫描过程中对 Redis 的影响,最好控制一下扫描的频率,指定 -i 参数即可,它表示扫描过程中每次扫描后休息的时间间隔,单位是秒;

  4. 扫描结果中,对于容器类型(List、Hash、Set、ZSet)的 key,只能扫描出元素最多的 key。但一个 key 的元素多,不一定表示占用内存也多,还需要根据业务情况,进一步评估内存占用情况。

那针对 bigkey 导致延迟的问题,有什么好的解决方案呢?
这里有两点可以优化:

  1. 业务应用尽量避免写入 bigkey;
  2. 如果使用的 Redis 是 4.0 以上版本,用 UNLINK 命令替代 DEL,此命令可以把释放 key 内存的操作,放到后台线程中去执行,从而降低对 Redis 的影响;
  3. 如果使用的 Redis 是 6.0 以上版本,可以开启 lazy-free 机制(lazyfree-lazy-user-del = yes),在执行 DEL 命令时,释放内存也会放到后台线程中执行。

但即便可以使用方案 2,不建议在实例中存入 bigkey。
这是因为 bigkey 在很多场景下,依旧会产生性能问题。例如,bigkey 在分片集群模式下,对于数据的迁移也会有性能影响,以及后面即将讲到的数据过期、数据淘汰、透明大页,都会受到 bigkey 的影响。

16、集中过期

如果发现,平时在操作 Redis 时,并没有延迟很大的情况发生,但在某个时间点突然出现一波延时,其现象表现为:变慢的时间点很有规律,例如某个整点,或者每间隔多久就会发生一波延迟。
如果是出现这种情况,那么需要排查一下,业务代码中是否存在设置大量 key 集中过期的情况。
如果有大量的 key 在某个固定时间点集中过期,在这个时间点访问 Redis 时,就有可能导致延时变大。
为什么集中过期会导致 Redis 延迟变大?
这就需要了解 Redis 的过期策略是怎样的。
Redis 的过期数据采用被动过期 + 主动过期两种策略:

  1. 被动过期:只有当访问某个 key 时,才判断这个 key 是否已过期,如果已过期,则从实例中删除;
  2. 主动过期:Redis 内部维护了一个定时任务,默认每隔 100 毫秒(1 秒 10 次)就会从全局的过期哈希表中随机取出 20 个 key,然后删除其中过期的 key,如果过期 key 的比例超过了 25%,则继续重复此过程,直到过期 key 的比例下降到 25% 以下,或者这次任务的执行耗时超过了 25 毫秒,才会退出循环。

注意,这个主动过期 key 的定时任务,是在 Redis 主线程中执行的
也就是说如果在执行主动过期的过程中,出现了需要大量删除过期 key 的情况,那么此时应用程序在访问 Redis 时,必须要等待这个过期任务执行结束,Redis 才可以服务这个客户端请求。
此时就会出现,应用访问 Redis 延时变大。
如果此时需要过期删除的是一个 bigkey,那么这个耗时会更久。而且,这个操作延迟的命令并不会记录在慢日志中
因为慢日志中只记录一个命令真正操作内存数据的耗时,而 Redis 主动删除过期 key 的逻辑,是在命令真正执行之前执行的。
所以,此时可以看到,慢日志中没有操作耗时的命令,但应用程序却感知到了延迟变大,其实时间都花费在了删除过期 key 上,这种情况需要尤为注意。
Redis性能优化 - 图2
那遇到这种情况,如何分析和排查?
此时,需要检查业务代码,是否存在集中过期 key 的逻辑。
一般集中过期使用的是 expireat / pexpireat 命令,需要在代码中搜索这个关键字。
排查代码后,如果确实存在集中过期 key 的逻辑存在,但这种逻辑又是业务所必须的,那此时如何优化,同时又不对 Redis 有性能影响呢?
一般有两种方案来规避这个问题:

  1. 集中过期 key 增加一个随机过期时间,把集中过期的时间打散,降低 Redis 清理过期 key 的压力;
  2. 如果使用的 Redis 是 4.0 以上版本,可以开启 lazy-free 机制,当删除过期 key 时,把释放内存的操作放到后台线程中执行,避免阻塞主线程。

第一种方案,在设置 key 的过期时间时,增加一个随机时间,伪代码可以这么写:

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

这样一来,Redis 在处理过期时,不会因为集中删除过多的 key 导致压力过大,从而避免阻塞主线程。
第二种方案,Redis 4.0 以上版本,开启 lazy-free 机制:

  1. # 释放过期 key 的内存,放到后台线程执行
  2. lazyfree-lazy-expire yes

另外,除了业务层面的优化和修改配置之外,还可以通过运维手段及时发现这种情况。
运维层面,需要把 Redis 的各项运行状态数据监控起来,在 Redis 上执行 INFO 命令就可以拿到这个实例所有的运行状态数据。
在这里需要重点关注 expired_keys 这一项,它代表整个实例到目前为止,累计删除过期 key 的数量。
需要把这个指标监控起来,当这个指标在很短时间内出现了突增,需要及时报警出来,然后与业务应用报慢的时间点进行对比分析,确认时间是否一致,如果一致,则可以确认确实是因为集中过期 key 导致的延迟变大。

17、实例内存达到上限

如果 Redis 实例设置了内存上限 maxmemory,那么也有可能导致 Redis 变慢。
把 Redis 当做纯缓存使用时,通常会给这个实例设置一个内存上限 maxmemory,然后设置一个数据淘汰策略。
而当实例的内存达到了 maxmemory 后,可能会发现,在此之后每次写入新数据,操作延迟变大了。
这是为什么?
原因在于,当 Redis 内存达到 maxmemory 后,每次写入新的数据之前,Redis 必须先从实例中踢出一部分数据,让整个实例的内存维持在 maxmemory 之下,然后才能把新数据写进来。
这个踢出旧数据的逻辑也是需要消耗时间的,而具体耗时的长短,要取决于配置的淘汰策略:

  • allkeys-lru:不管 key 是否设置了过期,淘汰最近最少访问的 key;
  • volatile-lru:只淘汰最近最少访问、并设置了过期时间的 key;
  • allkeys-random:不管 key 是否设置了过期,随机淘汰 key;
  • volatile-random:只随机淘汰设置了过期时间的 key;
  • allkeys-ttl:不管 key 是否设置了过期,淘汰即将过期的 key;
  • noeviction:不淘汰任何 key,实例内存达到 maxmeory 后,再写入新数据直接返回错误;
  • allkeys-lfu:不管 key 是否设置了过期,淘汰访问频率最低的 key(4.0+版本支持);
  • volatile-lfu:只淘汰访问频率最低、并设置了过期时间 key(4.0+版本支持)。

具体使用哪种策略,需要根据具体的业务场景来配置。
一般最常使用的是 allkeys-lru / volatile-lru 淘汰策略,它们的处理逻辑是,每次从实例中随机取出一批 key(这个数量可配置),然后淘汰一个最少访问的 key,之后把剩下的 key 暂存到一个池子中,继续随机取一批 key,并与之前池子中的 key 比较,再淘汰一个最少访问的 key。以此往复,直到实例内存降到 maxmemory 之下。
需要注意的是,Redis 的淘汰数据的逻辑与删除过期 key 的一样,也是在命令真正执行之前执行的,也就是说它也会增加操作 Redis 的延迟,而且,写 OPS 越高,延迟也会越明显。
Redis性能优化 - 图3
另外,如果此时 Redis 实例中还存储了 bigkey,那么在淘汰删除 bigkey 释放内存时,也会耗时比较久
看到了么?bigkey 的危害到处都是,这也是前面提醒尽量不存储 bigkey 的原因。
针对这种情况,如何解决呢?
4 个方面的优化建议:

  1. 避免存储 bigkey,降低释放内存的耗时;
  2. 淘汰策略改为随机淘汰,随机淘汰比 LRU 要快很多(视业务情况调整);
  3. 拆分实例,把淘汰 key 的压力分摊到多个实例上;
  4. 如果使用的是 Redis 4.0 以上版本,开启 layz-free 机制,把淘汰 key 释放内存的操作放到后台线程中执行(配置 lazyfree-lazy-eviction = yes)。

    18、fork耗时严重

    为了保证 Redis 数据的安全性,可能会开启后台定时 RDB 和 AOF rewrite 功能。
    但如果发现,操作 Redis 延迟变大,都发生在 Redis 后台 RDB 和 AOF rewrite 期间,那就需要排查,在这期间有可能导致变慢的情况。
    当 Redis 开启了后台 RDB 和 AOF rewrite 后,在执行时,它们都需要主进程创建出一个子进程进行数据的持久化。
    主进程创建子进程,会调用操作系统提供的 fork 函数。
    而 fork 在执行过程中,主进程需要拷贝自己的内存页表给子进程,如果这个实例很大,那么这个拷贝的过程也会比较耗时。
    而且这个 fork 过程会消耗大量的 CPU 资源,在完成 fork 之前,整个 Redis 实例会被阻塞住,无法处理任何客户端请求。
    如果此时 CPU 资源本来就很紧张,那么 fork 的耗时会更长,甚至达到秒级,这会严重影响 Redis 的性能。
    那如何确认确实是因为 fork 耗时导致的 Redis 延迟变大呢?
    可以在 Redis 上执行 INFO 命令,查看 latest_fork_usec 项,单位微秒。

    1. # 上一次 fork 耗时,单位微秒
    2. latest_fork_usec:59477

    这个时间就是主进程在 fork 子进程期间,整个实例阻塞无法处理客户端请求的时间。
    如果发现这个耗时很久,就要警惕起来了,这意味在这期间,整个 Redis 实例都处于不可用的状态。
    除了数据持久化会生成 RDB 之外,当主从节点第一次建立数据同步时,主节点也创建子进程生成 RDB,然后发给从节点进行一次全量同步,所以,这个过程也会对 Redis 产生性能影响。
    Redis性能优化 - 图4
    要想避免这种情况,可以采取以下方案进行优化:

  5. 控制 Redis 实例的内存:尽量在 10G 以下,执行 fork 的耗时与实例大小有关,实例越大,耗时越久;

  6. 合理配置数据持久化策略:在 slave 节点执行 RDB 备份,推荐在低峰期执行,而对于丢失数据不敏感的业务(例如把 Redis 当做纯缓存使用),可以关闭 AOF 和 AOF rewrite;
  7. Redis 实例不要部署在虚拟机上:fork 的耗时也与系统也有关,虚拟机比物理机耗时更久;
  8. 降低主从库全量同步的概率:适当调大 repl-backlog-size 参数,避免主从全量同步。

    19、开启内存大页

    除了上面讲到的子进程 RDB 和 AOF rewrite 期间,fork 耗时导致的延时变大之外,这里还有一个方面也会导致性能问题,这就是操作系统是否开启了内存大页机制
    什么是内存大页?
    应用程序向操作系统申请内存时,是按内存页进行申请的,而常规的内存页大小是 4KB。
    Linux 内核从 2.6.38 开始,支持了内存大页机制,该机制允许应用程序以 2MB 大小为单位,向操作系统申请内存。
    应用程序每次向操作系统申请的内存单位变大了,但这也意味着申请内存的耗时变长。
    这对 Redis 会有什么影响呢?
    当 Redis 在执行后台 RDB 和 AOF rewrite 时,采用 fork 子进程的方式来处理。但主进程 fork 子进程后,此时的主进程依旧是可以接收写请求的,而进来的写请求,会采用 Copy On Write(写时复制)的方式操作内存数据。
    也就是说,主进程一旦有数据需要修改,Redis 并不会直接修改现有内存中的数据,而是先将这块内存数据拷贝出来,再修改这块新内存的数据,这就是所谓的「写时复制」。
    写时复制也可以理解成,谁需要发生写操作,谁就需要先拷贝,再修改。
    这样做的好处是,父进程有任何写操作,并不会影响子进程的数据持久化(子进程只持久化 fork 这一瞬间整个实例中的所有数据即可,不关心新的数据变更,因为子进程只需要一份内存快照,然后持久化到磁盘上)。
    但是请注意,主进程在拷贝内存数据时,这个阶段就涉及到新内存的申请,如果此时操作系统开启了内存大页,那么在此期间,客户端即便只修改 10B 的数据,Redis 在申请内存时也会以 2MB 为单位向操作系统申请,申请内存的耗时变长,进而导致每个写请求的延迟增加,影响到 Redis 性能。
    同样地,如果这个写请求操作的是一个 bigkey,那主进程在拷贝这个 bigkey 内存块时,一次申请的内存会更大,时间也会更久。可见,bigkey 在这里又一次影响到了性能。
    Redis性能优化 - 图5
    那如何解决这个问题?
    很简单,只需要关闭内存大页机制就可以了。
    首先,需要查看 Redis 机器是否开启了内存大页:

    1. $ cat /sys/kernel/mm/transparent_hugepage/enabled
    2. [always] madvise never

    如果输出选项是 always,就表示目前开启了内存大页机制,需要关掉它:

    1. $ echo never > /sys/kernel/mm/transparent_hugepage/enabled

    其实,操作系统提供的内存大页机制,其优势是,可以在一定程序上降低应用程序申请内存的次数。
    但是对于 Redis 这种对性能和延迟极其敏感的数据库来说,希望 Redis 在每次申请内存时,耗时尽量短,所以不建议在 Redis 机器上开启这个机制。

    20、开启AOF

    前面分析了 RDB 和 AOF rewrite 对 Redis 性能的影响,主要关注点在 fork 上。
    其实,关于数据持久化方面,还有影响 Redis 性能的因素,这次重点来看 AOF 数据持久化。
    如果 AOF 配置不合理,还是有可能会导致性能问题。
    当 Redis 开启 AOF 后,其工作原理如下:

  9. Redis 执行写命令后,把这个命令写入到 AOF 文件内存中(write 系统调用);

  10. Redis 根据配置的 AOF 刷盘策略,把 AOF 内存数据刷到磁盘上(fsync 系统调用)。

为了保证 AOF 文件数据的安全性,Redis 提供了 3 种刷盘机制:

  1. appendfsync always:主线程每次执行写操作后立即刷盘,此方案会占用比较大的磁盘 IO 资源,但数据安全性最高;
  2. appendfsync no:主线程每次写操作只写内存就返回,内存数据什么时候刷到磁盘,交由操作系统决定,此方案对性能影响最小,但数据安全性也最低,Redis 宕机时丢失的数据取决于操作系统刷盘时机;
  3. appendfsync everysec:主线程每次写操作只写内存就返回,然后由后台线程每隔 1 秒执行一次刷盘操作(触发 fsync 系统调用),此方案对性能影响相对较小,但当 Redis 宕机时会丢失 1 秒的数据。

下面依次来分析,这几个机制对性能的影响。
如果 AOF 配置为 appendfsync always,那么 Redis 每处理一次写操作,都会把这个命令写入到磁盘中才返回,整个过程都是在主线程执行的,这个过程必然会加重 Redis 写负担。
原因也很简单,操作磁盘要比操作内存慢几百倍,采用这个配置会严重拖慢 Redis 的性能,因此不建议把 AOF 刷盘方式配置为 always。
接着来看 appendfsync no 配置项。
在这种配置下,Redis 每次写操作只写内存,什么时候把内存中的数据刷到磁盘,交给操作系统决定,此方案对 Redis 的性能影响最小,但当 Redis 宕机时,会丢失一部分数据,为了数据的安全性,一般也不采取这种配置。

如果 Redis 只用作纯缓存,对于数据丢失不敏感,采用配置 appendfsync no 也是可以的。

选比较折中的方案 appendfsync everysec 就没问题了吧?
这个方案优势在于,Redis 主线程写完内存后就返回,具体的刷盘操作是放到后台线程中执行的,后台线程每隔 1 秒把内存中的数据刷到磁盘中。
这种方案既兼顾了性能,又尽可能地保证了数据安全,是不是觉得很完美?
采用这种方案也要警惕一下,因为这种方案还是存在导致 Redis 延迟变大的情况发生,甚至会阻塞整个 Redis。
这是为什么?把 AOF 最耗时的刷盘操作,放到后台线程中也会影响到 Redis 主线程?
试想这样一种情况:当 Redis 后台线程在执行 AOF 文件刷盘时,如果此时磁盘的 IO 负载很高,那这个后台线程在执行刷盘操作(fsync 系统调用)时就会被阻塞住。
此时的主线程依旧会接收写请求,紧接着,主线程又需要把数据写到文件内存中(write 系统调用),但此时的后台子线程由于磁盘负载过高,导致 fsync 发生阻塞,迟迟不能返回,那主线程在执行 write 系统调用时,也会被阻塞住,直到后台线程 fsync 执行完成后,主线程执行 write 才能成功返回。
看到了么?在这个过程中,主线程依旧有阻塞的风险。
Redis性能优化 - 图6
所以,尽管 AOF 配置为 appendfsync everysec,也不能掉以轻心,要警惕磁盘压力过大导致的 Redis 有性能问题。
那什么情况下会导致磁盘 IO 负载过大?以及如何解决这个问题呢?
总结了以下几种情况,可以参考进行问题排查:

  1. 子进程正在执行 AOF rewrite,这个过程会占用大量的磁盘 IO 资源;
  2. 有其他应用程序在执行大量的写文件操作,也会占用磁盘 IO 资源;

对于情况1,说白了就是,Redis 的 AOF 后台子线程刷盘操作,撞上了子进程 AOF rewrite!
这怎么办?难道要关闭 AOF rewrite 才行?
幸运的是,Redis 提供了一个配置项,当子进程在 AOF rewrite 期间,可以让后台子线程不执行刷盘(不触发 fsync 系统调用)操作。
这相当于在 AOF rewrite 期间,临时把 appendfsync 设置为了 none,配置如下:

  1. # AOF rewrite 期间,AOF 后台子线程不进行刷盘操作
  2. # 相当于在这期间,临时把 appendfsync 设置为了 none
  3. no-appendfsync-on-rewrite yes

当然,开启这个配置项,在 AOF rewrite 期间,如果实例发生宕机,那么此时会丢失更多的数据,性能和数据安全性,需要权衡后进行选择。
如果占用磁盘资源的是其他应用程序,那就比较简单了,需要定位到是哪个应用程序在大量写磁盘,然后把这个应用程序迁移到其他机器上执行就好了,避免对 Redis 产生影响。
当然,如果对 Redis 的性能和数据安全都有很高的要求,那么建议从硬件层面来优化,更换为 SSD 磁盘,提高磁盘的 IO 能力,保证 AOF 期间有充足的磁盘资源可以使用。

21、绑定CPU

很多时候,在部署服务时,为了提高服务性能,降低应用程序在多个 CPU 核心之间的上下文切换带来的性能损耗,通常采用的方案是进程绑定 CPU 的方式提高性能。
但在部署 Redis 时,如果需要绑定 CPU 来提高其性能,建议仔细斟酌后再做操作。
为什么?
因为 Redis 在绑定 CPU 时,是有很多考究的,如果不了解 Redis 的运行原理,随意绑定 CPU 不仅不会提高性能,甚至有可能会带来相反的效果。
一般现代的服务器会有多个 CPU,而每个 CPU 又包含多个物理核心,每个物理核心又分为多个逻辑核心,每个物理核下的逻辑核共用 L1/L2 Cache。
而 Redis Server 除了主线程服务客户端请求之外,还会创建子进程、子线程。
其中子进程用于数据持久化,而子线程用于执行一些比较耗时操作,例如异步释放 fd、异步 AOF 刷盘、异步 lazy-free 等等。
如果把 Redis 进程只绑定了一个 CPU 逻辑核心上,那么当 Redis 在进行数据持久化时,fork 出的子进程会继承父进程的 CPU 使用偏好。
而此时的子进程会消耗大量的 CPU 资源进行数据持久化(把实例数据全部扫描出来需要耗费 CPU),这就会导致子进程会与主进程发生 CPU 争抢,进而影响到主进程服务客户端请求,访问延迟变大。
这就是 Redis 绑定 CPU 带来的性能问题。
那如何解决这个问题呢?
如果确实想要绑定 CPU,可以优化的方案是,不要让 Redis 进程只绑定在一个 CPU 逻辑核上,而是绑定在多个逻辑核心上,而且,绑定的多个逻辑核心最好是同一个物理核心,这样它们还可以共用 L1/L2 Cache。
当然,即便把 Redis 绑定在多个逻辑核心上,也只能在一定程度上缓解主线程、子进程、后台线程在 CPU 资源上的竞争。
因为这些子进程、子线程还是会在这多个逻辑核心上进行切换,存在性能损耗。
如何再进一步优化?
是否可以让主线程、子进程、后台线程,分别绑定在固定的 CPU 核心上,不让它们来回切换,这样一来,他们各自使用的 CPU 资源互不影响。
其实,这个方案 Redis 官方已经想到了。
Redis 在 6.0 版本已经推出了这个功能,可以通过以下配置,对主线程、后台线程、后台 RDB 进程、AOF rewrite 进程,绑定固定的 CPU 逻辑核心:

  1. # Redis Server 和 IO 线程绑定到 CPU核心 0,2,4,6
  2. server_cpulist 0-7:2
  3. # 后台子线程绑定到 CPU核心 1,3
  4. bio_cpulist 1,3
  5. # 后台 AOF rewrite 进程绑定到 CPU 核心 8,9,10,11
  6. aof_rewrite_cpulist 8-11
  7. # 后台 RDB 进程绑定到 CPU 核心 1,10,11
  8. # bgsave_cpulist 1,10-1

如果使用的正好是 Redis 6.0 版本,就可以通过以上配置,来进一步提高 Redis 性能。
这里需要提醒的是,一般来说,Redis 的性能已经足够优秀,除非对 Redis 的性能有更加严苛的要求,否则不建议绑定 CPU。
从上面的分析也能看出,绑定 CPU 需要对计算机体系结构有非常清晰的了解,否则谨慎操作。
继续分析还有什么场景会导致 Redis 变慢。

22、使用Swap

如果发现 Redis 突然变得非常慢,每次的操作耗时都达到了几百毫秒甚至秒级,那此时就需要检查 Redis 是否使用到了 Swap,在这种情况下 Redis 基本上已经无法提供高性能的服务了。
什么是 Swap?为什么使用 Swap 会导致 Redis 的性能下降?
如果对操作系统有些了解,就会知道操作系统为了缓解内存不足对应用程序的影响,允许把一部分内存中的数据换到磁盘上,以达到应用程序对内存使用的缓冲,这些内存数据被换到磁盘上的区域,就是 Swap。
问题就在于,当内存中的数据被换到磁盘上后,Redis 再访问这些数据时,就需要从磁盘上读取,访问磁盘的速度要比访问内存慢几百倍!
尤其是针对 Redis 这种对性能要求极高、性能极其敏感的数据库来说,这个操作延时是无法接受的。
此时,需要检查 Redis 机器的内存使用情况,确认是否存在使用了 Swap。
可以通过以下方式来查看 Redis 进程是否使用到了 Swap:

  1. # 先找到 Redis 的进程 ID
  2. $ ps -aux | grep redis-server
  3. # 查看 Redis Swap 使用情况
  4. $ cat /proc/$pid/smaps | egrep '^(Swap|Size)'

输出结果如下:

  1. Size: 1256 kB
  2. Swap: 0 kB
  3. Size: 4 kB
  4. Swap: 0 kB
  5. Size: 132 kB
  6. Swap: 0 kB
  7. Size: 63488 kB
  8. Swap: 0 kB
  9. Size: 132 kB
  10. Swap: 0 kB
  11. Size: 65404 kB
  12. Swap: 0 kB
  13. Size: 1921024 kB
  14. Swap: 0 kB
  15. ...

这个结果会列出 Redis 进程的内存使用情况。
每一行 Size 表示 Redis 所用的一块内存大小,Size 下面的 Swap 就表示这块 Size 大小的内存,有多少数据已经被换到磁盘上了,如果这两个值相等,说明这块内存的数据都已经完全被换到磁盘上了。
如果只是少量数据被换到磁盘上,例如每一块 Swap 占对应 Size 的比例很小,那影响并不是很大。如果是几百兆甚至上 GB 的内存被换到了磁盘上,那么就需要警惕了,这种情况 Redis 的性能肯定会急剧下降。
此时的解决方案是:

  1. 增加机器的内存,让 Redis 有足够的内存可以使用;
  2. 整理内存空间,释放出足够的内存供 Redis 使用,然后释放 Redis 的 Swap,让 Redis 重新使用内存。

释放 Redis 的 Swap 过程通常要重启实例,为了避免重启实例对业务的影响,一般会先进行主从切换,然后释放旧主节点的 Swap,重启旧主节点实例,待从库数据同步完成后,再进行主从切换即可。
可见,当 Redis 使用到 Swap 后,此时的 Redis 性能基本已达不到高性能的要求,所以也需要提前预防这种情况。
预防的办法就是,需要对 Redis 机器的内存和 Swap 使用情况进行监控,在内存不足或使用到 Swap 时报警出来,及时处理。

23、碎片整理

Redis 的数据都存储在内存中,当应用程序频繁修改 Redis 中的数据时,就有可能会导致 Redis 产生内存碎片。
内存碎片会降低 Redis 的内存使用率,可以通过执行 INFO 命令,得到这个实例的内存碎片率:

  1. # Memory
  2. used_memory:5709194824
  3. used_memory_human:5.32G
  4. used_memory_rss:8264855552
  5. used_memory_rss_human:7.70G
  6. ...
  7. mem_fragmentation_ratio:1.45

这个内存碎片率是怎么计算的?
很简单,mem_fragmentation_ratio = used_memory_rss / used_memory。
其中 used_memory 表示 Redis 存储数据的内存大小,而 used_memory_rss 表示操作系统实际分配给 Redis 进程的大小。
如果 mem_fragmentation_ratio > 1.5,说明内存碎片率已经超过了 50%,这时就需要采取一些措施来降低内存碎片了。
解决的方案一般如下:

  1. 如果使用的是 Redis 4.0 以下版本,只能通过重启实例来解决;
  2. 如果使用的是 Redis 4.0 版本,它正好提供了自动碎片整理的功能,可以通过配置开启碎片自动整理。

但是,开启内存碎片整理,它也有可能会导致 Redis 性能下降。
原因在于,Redis 的碎片整理工作是也在主线程中执行的,当其进行碎片整理时,必然会消耗 CPU 资源,产生更多的耗时,从而影响到客户端的请求。
所以,当需要开启这个功能时,最好提前测试评估它对 Redis 的影响。
Redis 碎片整理的参数配置如下:

  1. # 开启自动内存碎片整理(总开关)
  2. activedefrag yes
  3. # 内存使用 100MB 以下,不进行碎片整理
  4. active-defrag-ignore-bytes 100mb
  5. # 内存碎片率超过 10%,开始碎片整理
  6. active-defrag-threshold-lower 10
  7. # 内存碎片率超过 100%,尽最大努力碎片整理
  8. active-defrag-threshold-upper 100
  9. # 内存碎片整理占用 CPU 资源最小百分比
  10. active-defrag-cycle-min 1
  11. # 内存碎片整理占用 CPU 资源最大百分比
  12. active-defrag-cycle-max 25
  13. # 碎片整理期间,对于 List/Set/Hash/ZSet 类型元素一次 Scan 的数量
  14. active-defrag-max-scan-fields 1000

需要结合 Redis 机器的负载情况,以及应用程序可接受的延迟范围进行评估,合理调整碎片整理的参数,尽可能降低碎片整理期间对 Redis 的影响。

24、网络带宽过载

如果以上产生性能问题的场景,都规避掉了,而且 Redis 也稳定运行了很长时间,但在某个时间点之后开始,操作 Redis 突然开始变慢了,而且一直持续下去,这种情况又是什么原因导致?
此时需要排查一下 Redis 机器的网络带宽是否过载,是否存在某个实例把整个机器的网路带宽占满的情况。
网络带宽过载的情况下,服务器在 TCP 层和网络层就会出现数据包发送延迟、丢包等情况。
Redis 的高性能,除了操作内存之外,就在于网络 IO 了,如果网络 IO 存在瓶颈,那么也会严重影响 Redis 的性能。
如果确实出现这种情况,需要及时确认占满网络带宽 Redis 实例,如果属于正常的业务访问,那就需要及时扩容或迁移实例了,避免因为这个实例流量过大,影响这个机器的其他实例。
运维层面,需要对 Redis 机器的各项指标增加监控,包括网络流量,在网络流量达到一定阈值时提前报警,及时确认和扩容。

25、其他原因

好了,以上这些方面就是如何排查 Redis 延迟问题的思路和路径。
除了以上这些,还有一些比较小的点,也需要注意一下:

1) 频繁短连接

业务应用,应该使用长连接操作 Redis,避免频繁的短连接。
频繁的短连接会导致 Redis 大量时间耗费在连接的建立和释放上,TCP 的三次握手和四次挥手同样也会增加访问延迟。

2) 运维监控

要想提前预知 Redis 变慢的情况发生,必不可少的就是做好完善的监控。
监控其实就是对采集 Redis 的各项运行时指标,通常的做法是监控程序定时采集 Redis 的 INFO 信息,然后根据 INFO 信息中的状态数据做数据展示和报警。
在写一些监控脚本,或使用开源的监控组件时,也不能掉以轻心。
在写监控脚本访问 Redis 时,尽量采用长连接的方式采集状态信息,避免频繁短连接。同时,还要注意控制访问 Redis 的频率,避免影响到业务请求。
在使用一些开源的监控组件时,最好了解一下这些组件的实现原理,以及正确配置这些组件,防止出现监控组件发生 Bug,导致短时大量操作 Redis,影响 Redis 性能的情况发生。
DBA 在使用一些开源组件时,因为配置和使用问题,导致监控程序频繁地与 Redis 建立和断开连接,导致 Redis 响应变慢。

3)其它程序争抢资源

Redis 机器最好专项专用,只用来部署 Redis 实例,不要部署其他应用程序,尽量给 Redis 提供一个相对「安静」的环境,避免其它程序占用 CPU、内存、磁盘资源,导致分配给 Redis 的资源不足而受到影响。

总结

Redis 的性能问题,既涉及到了业务开发人员的使用方面,也涉及到了 DBA 的运维方面。
作为业务开发人员,需要了解 Redis 的基本原理,例如各个命令执行的时间复杂度、数据过期策略、数据淘汰策略等,从而更合理地使用 Redis 命令,并且结合业务场景进行优化。
作为 DBA 和运维人员,需要了解 Redis 运行机制,例如数据持久化、内存碎片整理、进程绑核配置。除此之外,还需要了解操作系统相关知识,例如写时复制、内存大页、Swap 机制等等。
同时,DBA 在部署 Redis 时,需要提前对进行容量规划,预留足够的机器资源,还要对 Redis 机器和实例做好完善的监控,这样才能尽可能地保证 Redis 的稳定运行。

后记

Redis 的性能问题,涉及到的知识点非常广,几乎涵盖了 CPU、内存、网络、甚至磁盘的方方面面,同时,还需要了解计算机的体系结构,以及操作系统的各种机制。
从资源使用角度来看,包含的知识点如下:

  • CPU 相关:使用复杂度过高命令、数据的持久化,都与耗费过多的 CPU 资源有关;
  • 内存相关:bigkey 内存的申请和释放、数据过期、数据淘汰、碎片整理、内存大页、内存写时复制都与内存息息相关;
  • 磁盘相关:数据持久化、AOF 刷盘策略,也会受到磁盘的影响;
  • 网络相关:短连接、实例流量过载、网络流量过载,也会降低 Redis 性能;
  • 计算机系统:CPU 结构、内存分配,都属于最基础的计算机系统知识;
  • 操作系统:写时复制、内存大页、Swap、CPU 绑定,都属于操作系统层面的知识。