前言
在 Redis 的实际使用过程中,我们经常会面对以下的场景:
- 在 Redis 上执行同样的命令,为什么有时响应很快,有时却很慢
- 为什么 Redis 执行 GET、SET、DEL 命令耗时也很久
- 为什么我的 Redis 突然慢了一波,之后又恢复正常了
- 为什么我的 Redis 稳定运行了很久,突然从某个时间点开始变慢了
这时我们还是需要一个全面的排障流程,不能无厘头地进行优化;全面的排障流程可以帮助我们找到真正的根因和性能瓶颈,以及实施正确高效的优化方案
这篇文章我们就从可能导致 Redis 延迟的方方面面开始,逐步深入排障深水区,以提供一个「全面」的 Redis 延迟问题排查思路
需要了解的词
- Copy On Write
COW
是一种建立在虚拟内存重映射技术之上的技术,因此它需要MMU
的硬件支持,MMU
会记录当前哪些内存页被标记成只读,当有进程尝试往这些内存页中写数据的时候,MMU
就会抛一个异常给操作系统内核,内核处理该异常时为该进程分配一份物理内存并复制数据到此内存地址,重新向MMU
发出执行该进程的写操作
- 内存碎片
操作系统负责为每个进程分配物理内存,而操作系统中的虚拟内存管理器保管着由内存分配器分配的实际内存映射 如果我们的应用程序需求
1GB
大小的内存,内存分配器将首先尝试找到一个连续的内存段来存储数据;如果找不到连续的段,则分配器必须将进程的数据分成多个段,从而导致内存开销增加
- SWAP
顾名思义,当某进程向OS请求内存发现不足时,OS会把内存中暂时不用的数据交换出去,放在
SWAP分区
中,这个过程称为SWAP OUT
。 当某进程又需要这些数据且OS发现还有空闲物理内存时,又会把SWAP分区
中的数据交换回物理内存中,这个过程称为SWAP IN
,详情可参考这篇文章
- redis 监控指标 合理完善的监控指标无疑能大大助力我们的排障,本篇文章中提到了很多的 redis 监控指标,详情可以参考这篇文章: redis监控指标
排除无关原因
当我们发现从我们的业务服务发起请求到接收到Redis
的回包这条链路慢时,我们需要先排除其它的一些无关Redis
自身的原因,如:
- 业务自身准备请求耗时过长
- 业务服务器到
Redis
服务器之间的网络存在问题,例如网络线路质量不佳,网络数据包在传输时存在延迟、丢包等情况 网络和通信导致的固有延迟: 客户端使用TCP/IP
连接或Unix域连接
连接到Redis,在1 Gbit/s
网络下的延迟约为200 us
,而Unix域Socket
的延迟甚至可低至30 us
,这实际上取决于网络和系统硬件;在网络通信的基础之上,操作系统还会增加了一些额外的延迟(如线程调度、CPU缓存、NUMA
等);并且在虚拟环境中,系统引起的延迟比在物理机上也要高得多 结果就是,即使 Redis 在亚微秒的时间级别上能处理大多数命令,网络和系统相关的延迟仍然是不可避免的 Redis
实例所在的机器带宽不足 /docker
网桥性能问题等
排障事大,但咱也不能冤枉了Redis
;首先我们还是应该把其它因素都排除完了,再把焦点关注在业务服务到 Redis
这条链路上
如以下的火焰图就可以很肯定的说问题出现在 Redis 上了:
在排除无关因素后,如何确认 Redis 是否真的变慢了?
测试流程
排除无关因素后,我们可以按照以下基本步骤来判断某一 Redis 实例是否变慢了:
- 监控并记录一个相对正常的 Redis 实例(相对低负载、key 存储结构简单合理、连接数未满)的相关指标
- 找到认为表现不符合预期的 Redis 实例(如使用该实例后业务接口明显变慢),在相同配置的服务器上监控并记录这个实例的相关指标
- 若表现不符合预期的 Redis 实例的相关指标明显达不到正常 Redis 实例的标准(延迟两倍以上、
OPS
仅为正常实例的 1/3、内存碎片率较高等),即可认为这个 Redis 实例的指标未达到预期
确认是 Redis 实例的某些指标未达到预期后,我们就可以开始逐步分析拆解可能导致 Redis 表现不佳的因素,并确认优化方案了
快速清单
I've little time, give me the checklist
在线上发生故障时,我们都没有那么多时间去深究原因,所以在深入到排障的深水区前,我们可以先从最影响最大的一些问题开始检查,这里是一份「会对redis
基本运行造成严重影响的问题」的 checklist
:
- 确保没有运行阻塞服务器的缓慢命令;使用 Redis 的耗时命令记录功能来检查这一点
- 对于EC2用户,请确保使用基于
HVM
的现代EC2实例,如m3.dium
等,否则,fork()
系统调用带来的延迟太大了 - 禁用透明内存大页。使用
echo never > /sys/kernel/mm/transparent_hugepage/enabled
来禁用它们,然后重新启动Redis进程 - 如果使用的是虚拟机,则可能存在与 Redis 本身无关的固有延迟;使用
redis-cli --intrinsic-latency 100
检查延迟,确认该延迟是否符合预期(注意:您需要在服务器上而不是在客户机上运行此命令) - 启用并使用 Redis 的延迟监控功能,更好的监控 Redis 实例中的延迟事件和原因
导致Redis Latency的具体原因
如果使用我们的快速清单并不能解决实际的延迟问题,我们就得深入 redis 性能排障的深水区,多方面逐步深究其中的具体原因了
使用复杂度过高的命令 / 「大型」命令
要找到这样的命令执行记录,需要使用 Redis 提供的耗时命令统计的功能,查看 Redis 耗时命令之前,我们需要先在redis.conf
中设置耗时命令的阈值;如:设置耗时命令的阈值为 5ms,保留近 500 条耗时命令记录:
# The following time is expressed in microseconds, so 1000000 is equivalent
# to one second. Note that a negative number disables the slow log, while
# a value of zero forces the logging of every command.
slowlog-log-slower-than 10000
# There is no limit to this length. Just be aware that it will consume memory.
# You can reclaim memory used by the slow log with SLOWLOG RESET.
slowlog-max-len 128
或是直接在redis-cli
中使用CONFIG
命令配置:
# 命令执行耗时超过 5 毫秒,记录耗时命令
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近 500 条耗时命令
CONFIG SET slowlog-max-len 500
通过查看耗时命令记录,我们就可以知道在什么时间点,执行了哪些比较耗时的命令
如果应用程序执行的 Redis 命令有以下特点,那么有可能会导致操作延迟变大:
- 经常使用 O(N) 以上复杂度的命令,例如 SORT, SUNION, ZUNIONSTORE 等聚合类命令
- 使用 O(N) 复杂度的命令,但 N 的值非常大
第一种情况导致变慢的原因是 Redis 在操作内存数据时,时间复杂度过高,要花费更多的 CPU 资源
第二种情况导致变慢的原因是 处理「大型」redis 命令(大请求包体 / 大返回包体的redis请求),对于这样的命令来说,虽然其只有两次内核态与用户态的上下文切换,但由于redis是单线程处理回调事件的,所以后续请求很有可能被这一个大型请求阻塞,这时可能需要考虑业务请求拆解
尽量分批执行,以保证redis服务的稳定性
Bigkey
bigkey 一般指包含大量数据或大量成员和列表的 key,如下所示就是一些典型的 bigkey(根据Redis的实际用例和业务场景,bigkey 的定义可能会有所不同):
- value 大小为 5 MB(数据太大)的
String
- 包含 20000 个元素的
List
(列表中的元素数量过多) - 有 10000 个成员的
ZSET
密钥(成员数量过多) - 一个大小为 100 MB的
Hash key
,即便只包含 1000 个成员(key太大)
在上一节的耗时命令查询中,如果我们发现榜首并不是复杂度过高的命令,而是 SET / DEL 等简单命令,这很有可能就是 redis 实例中存在 bigkey 导致的
bigkey 会导致包括但不限于以下的问题:
- Redis 的内存使用量不断增长,最终导致实例
OOM
,或者因为达到最大内存限制而导致写入被阻塞和重要 key 被驱逐 - 访问偏差导致的资源倾斜,bigkey 的存在可能会导致某个 Redis 实例达到性能瓶颈,从而导致整个集群也达到性能瓶颈;在这种情况下,Redis 集群中一个节点的内存使用量通常会因为对 bigkey 的访问需求而远远超过其他节点,而 Redis 集群中数据迁移时有一个最小粒度,这意味着该节点上的 bigkey 占用的内存无法进行 balance
- 由于将 bigkey 请求从 socket 读取到 Redis 占用了几乎所有带宽,Redis 的其它请求都会受到影响
- 删除BigKey时,由于主库长时间阻塞(释放 bigkey 占用的内存)导致同步中断或主从切换
如何定位 bigkey
- 使用redis-cli 提供的
—-bigkeys
参数redis-cli
提供了扫描 bigkey 的 option—-bigkeys
,执行以下命令就可以扫描 redis 实例中 bigkey 的分布情况,以 key 类型维度输出结果: $ redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01 [00.00%] Biggest string found so far ... [98.23%] Biggest string found so far -------- summary ------- Sampled 829675 keys in the keyspace! Total key length in bytes is 10059825 (avg len 12.13) Biggest string found 'key:291880' has 10 bytes Biggest list found 'mylist:004' has 40 items Biggest set found 'myset:2386' has 38 members Biggest hash found 'myhash:3574' has 37 fields Biggest zset found 'myzset:2704' has 42 members 36313 strings with 363130 bytes (04.38% of keys, avg size 10.00) 787393 lists with 896540 items (94.90% of keys, avg size 1.14) 1994 sets with 40052 members (00.24% of keys, avg size 20.09) 1990 hashs with 39632 fields (00.24% of keys, avg size 19.92) 1985 zsets with 39750 members (00.24% of keys, avg size 20.03) 从输出结果我们可以很清晰地看到,每种数据类型所占用的最大内存 / 拥有最多元素的 key 是哪一个,以及每种数据类型在整个实例中的占比和平均大小 / 元素数量 bigkey 扫描实际上是 Redis 执行了 SCAN 命令,遍历整个实例中所有的 key,然后针对 key 的类型,分别执行STRLEN, LLEN, HLEN, SCARD和ZCARD
命令,来获取 String 类型的长度,容器类型(List, Hash, Set, ZSet)的元素个数 ⚠️NOTICE: 当执行 bigkey 扫描时,要注意 2 个问题:- 对线上实例进行 bigkey 扫描时,Redis 的 OPS 会突增,为了降低扫描过程中对 Redis 的影响,最好控制一下扫描的频率,指定
-i
参数即可,它表示扫描过程中每次扫描后休息的时间间隔(秒) - 扫描结果中,对于容器类型(List, Hash, Set, ZSet)的 key,只能扫描出元素最多的 key;但一个 key 的元素多,不一定表示内存占用也多,我们还需要根据业务情况,进一步评估内存占用情况
以下是bigkey 扫描实际用到的命令的时间复杂度:
- 对线上实例进行 bigkey 扫描时,Redis 的 OPS 会突增,为了降低扫描过程中对 Redis 的影响,最好控制一下扫描的频率,指定
- 使用开源的 redis-rdb-tools 通过
redis-rdb-Tools
,我们可以根据自己的标准准确分析 Redis 实例中所有密钥的实际内存使用情况,同时它还可以避免中断在线服务,分析完成后,您可以获得简洁、易于理解的报告redis-rdb-Tools
对rdb
文件的分析是离线的,对在线的 redis 服务没有影响;这无疑是它对比第一种方案最大的优势,但也正是因为是离线分析,其分析结果的实时性可能达不到某些场景下的标准,对大型rdb
文件的分析可能需要较长的时间
针对 bigkey 问题的优化措施:
- 上游业务应避免在不合适的场景写入 bigkey(夸张一点:用
String
存储大型binary file
),如必须使用,可以考虑进行大key拆分
,如:对于 string 类型的 Bigkey,可以考虑拆分成多个 key-value;对于 hash 或者 list 类型,可以考虑拆分成多个 hash 或者 list - 定期清理
HASH key
中的无效数据(使用HSCAN
和HDEL
),避免HASH key
中的成员持续增加带来的 bigkey 问题 - Redis ≥ 4.0中,用
UNLINK
命令替代DEL
,此命令可以把释放 key 内存的操作,放到后台线程中去执行,从而降低对 Redis 的影响 - Redis ≥ 6.0中,可以开启 lazy-free 机制(
lazyfree-lazy-user-del = yes
),在执行 DEL 命令时,释放内存也会放到后台线程中执行 - 针对消息队列 / 生产消费场景的 List, Set 等,设置过期时间或实现定期清理任务,并配置相关监控以及时处理突发情况(如线上流量暴增,下有服务无法消费等产生的消费积压)
即便我们有一系列的解决方案,我们也要尽量避免在实例中存入 bigkey
这是因为 bigkey 在很多场景下,依旧会产生性能问题;例如,bigkey 在分片集群模式下,对于数据的迁移也会有性能影响;以及资源倾斜、数据过期、数据淘汰、透明大页等,都会受到 bigkey 的影响
Hotkey
在讨论 bigkey 时,我们也经常谈到 hotkey ,当访问某个密钥的工作量明显高于其他密钥时,我们可以称之为 hotkey;以下就是一些 hotkey 的例子:
- 在一个 QPS 10w 的 Redis 实例中,只有一个 key 的 QPS 达到了 7000 次
- 拥有数千个成员、总大小为1MB的哈希键每秒会收到大量的HGETALL请求(在这种情况下,我们将其称为热键,因为访问一个键比访问其他键消耗的带宽要大得多)
- 拥有数万个 member 的 ZSET 每秒处理大量的 ZRANGE 请求(
cpu时间
明显高于用于其他 key 请求的cpu时间
。同样,我们可以说这种消耗大量CPU的Key就是HotKey)
hotkey 通常会带来以下的问题:
- hotkey 会导致较高的 CPU 负载,并影响其它请求的处理
- 资源倾斜,对 hotkey 的请求会集中在个别 Redis 节点/机器上,而不是
shard
到不同的 Redis 节点上,导致内存/CPU负载集中在这个别的节点上,Redis 集群利用率不能达到预期 - hotkey 上的流量可能在流量高峰时突然飙升,导致 redis CPU 满载甚至缓存服务崩溃,在缓存场景下导致缓存雪崩,大量的请求会直接命中其它较慢的数据源,最终导致业务不可用等不可接受的后果
如何定位 hotkey:
- 使用
redis-cli
提供的—hotkeys
参数 Redis 从4.0版本开始在redis-cli
中提供 hotkey 参数,以方便实例粒度的 hotkey 分析;它可以返回所有 key 被访问的次数,但需要先将maxmemory policy
设置为allkey-LFU
# Scanning the entire keyspace to find hot keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust. - 使用
monitor
命令 Redis 的monitor
命令可以实时输出 Redis 接收到的所有请求,包括访问时间、客户端IP、命令和key;我们可以短时间执行monitor命令,并将输出重定向到文件;结束后,可以通过对文件中的请求进行分类和分析来找到这段时间的 hotkey**monitor**
命令会消耗大量CPU、内存和网络资源;因此,对于本身就负载较大的 Redis 实例来说,monitor
命令可能会让性能问题进一步恶化;同时,这种异步采集分析方案的时效性较差,分析的准确性依赖于monitor
命令的执行时长;因此,在大多数无法长时间执行该命令的在线场景中,结果的准确性并不好 - 上游服务针对 redis 请求进行监控 所有的 redis 请求都来自于上游服务,上游服务可以在上报时进行相关的指标监控、汇总及分析,以定位 hotkey ;但这样的方式需要上游服务支持,并不独立
针对 hotkey 问题的优化方案:
- 使用
pipeline
在一些非实时的 bigkey 请求场景下,我们可以使用pipeline
来大幅度降低 Redis 实例的 CPU 负载 首先我们要知道,Redis 核心的工作负荷是一个单线程在处理,这里指的是——网络 IO 和命令执行是由一个线程来完成的;而 Redis 6.0 中引入了多线程,在 Redis 6.0 之前,从网络 IO 处理到实际的读写命令处理都是由单个线程完成的,但随着网络硬件的性能提升,Redis 的性能瓶颈有可能会出现在网络 IO 的处理上,也就是说单个主线程处理网络请求的速度跟不上底层网络硬件的速度。针对此问题,Redis 采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度,但多 IO 线程只用于处理网络请求,对于命令处理,Redis 仍然使用单线程处理 而 Redis 6.0 以前的单线程网络 IO 模型的处理具体的负载在哪里呢?虽然 Redis 利用epoll
机制实现 IO 多路复用(即使用epoll
监听各类事件,通过事件回调函数进行事件处理),但 I/O 这一步骤是无法避免且始终由单线程串行处理的,且涉及用户态/内核态的切换,即:- 从
socket
中读取请求数据,会从内核态将数据拷贝到用户态 (read
调用) - 将数据回写到
socket
,会将数据从用户态拷贝到内核态 (write
调用)
高频简单命令请求下,用户态/内核态的切换带来的开销被更加放大,最终会导致
redis-server
cpu
满载→redis-server
OPS
不及预期→上游服务海量请求超时→最终造成类似缓存穿透的结果,这时我们就可以使用pipeline
来处理这样的场景了 redis pipeline 众所周知,redis pipeline
可以让redis-server
一次接收一组指令(在内核态中存入输入缓冲区,收到客户端的Exec
指令再调用read() syscall
)后再执行,减少I/O
(即accept -> read -> write
)次数,在高频可聚合命令的场景下使用pipeline
可以大大减少socket I/O
带来的内核态与用户态之间的上下文切换开销 下面我们进行跑一组基于golang redis
客户端的简单高频命令的Benchmark
测试(不使用pipeline
和使用pipeline
对比),同时使用perf对 Redis 4 实例监控上下文切换次数:- Set without Pipeline(redis 4.0.14) perf stat -p 15537 -e context-switches -a sleep 10 Performance counter stats for process id '15537': 96,301 context-switches 10.001575750 seconds time elapsed
- Set using Pipeline(redis 4.0.14) perf stat -p 15537 -e context-switches -a sleep 10 Performance counter stats for process id '15537': 17 context-switches 10.001722488 seconds time elapsed
可以看到在不使用
pipeline
执行高频简单命令时产生了大量的上下文切换,这无疑会占用大量的cpu时间
另一方面,pipeline
虽然好用,但是每次pipeline
组装的命令个数不能没有节制,否则一次组装pipeline
数据量过大,一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的pipeline
拆分成多次较小的pipeline
来完成,比如可以将pipeline
的总发送大小控制在内核输入输出缓冲区大小之内(内核的输入输出缓冲区大小一般是4K-8K,在不同操作系统中有所差异,可配置修改),同时控制在单个 TCP 报文最大值1460字节之内 最大传输单元(MTU — Maximum Transmission Unit)在以太网中的最大值是1500字节,扣减20个字节的IP
头和20个字节的TCP
头,即1460字节 - 从
- MemCache 当 hotkey 本身可预估,且总大小可控时,我们可以考虑使用
MemCache
直接存储- 省去了 Redis 接入
- 直接的内存读取,保证高性能
- 摆脱带宽限制
但同时它也带来了新的问题:
- 在像
k8s
这样的高可用多实例架构下,多pod
间的同步以及和原始数据库的同步是一个大问题,很有可能导致脏读 - 同样是在多实例的情况下,会带来很多的内存浪费
同时 MemCache 相比于 Redis 也少了很多 feature ,可能不能满足业务需求 FeatureRedisMemCache原生支持不同的数据结构✅❌原生支持持久化✅❌横向扩展(replication)✅❌聚合操作✅❌支持高并发✅✅
- Redis 读写分离
当对 hotkey 的请求仅仅集中在读上时,我们可以考虑读写分离的 Redis 集群方案(很多公有云厂商都有提供),针对 hotkey 的读请求,新增
read-only replica
来承担读流量,原replica
作为热备不提供服务,如下图所示(链式复制架构):
这里我们不展开讲读写分离的其它优势,仅针对读多写少的业务场景来说,使用读写分离的 Redis 提供了更多的选择,业务可以根据场景选择最适合的规格,充分利用每一个read-only replica
的资源,且读写分离架构还有比较好的横向扩容能力、客户端友好等优势
规格QPS带宽1 master8-10万读写10-48 MB1 master 1 read-only replica10万写 10万读20-64 MB1 master 3 read-only replica10万写 30万读40-128 MBn * master m * read-only replican * 100,000 write m * 100,000 read10(m n) MB - 32(m n) MB当然我们也不能忽略读写分离架构的缺点,在有大量写请求的场景中,读写分离架构将不可避免地产生延迟,这很有可能造成脏读,所以读写分离架构不适用于读写负载都较高以及实时性要求较高的场景
Key集中过期
当 Redis 实例表现出的现象是:周期性地在一个小的时间段出现一波延迟高峰时,我们就需要 check 一下是否有大批量的 key 集中过期;那么为什么 key 集中过期会导致 Redis 延迟变大呢?
我们首先来了解一下 Redis 的过期策略是怎样的
Redis 处理过期 key 的方式有两种——被动方式和主动方式
被动方式
key
过期的时候不删除,每次从 Redis 获取key
时检查是否过期,若过期,则删除,返回null
优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key
,所以对CPU时间的占用是比较少的
缺点:若大量的key
在超出超时时间后,很久一段时间内,都没有被获取过,此时的无效缓存是永久暂用在内存中的,那么可能发生内存泄露(无效key
占用了大量的内存)
主动方式
Redis 每100ms
执行以下步骤:
- 抽样检查附加了
TTL
的20个随机key
(环境变量ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
,默认为20) - 删除抽样中所有过期的
key
- 如果超过
25%
的key
过期,重复步骤1
优点:通过限制删除操作的时长和频率,来限制删除操作对CPU时间的占用;同时解决被动方式
中无效key
存留的问题
缺点: 仍然可能有最高达到25%
的无效key
存留;在CPU时间
友好方面,不如被动方式
,主动方式会block住主线程
难点: 需要合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除,这要根据服务器运行情况和实际需求来决定)
如果 Redis 实例配置为上面的主动方式的,当 Redis 中的 key 集中过期时,Redis 需要处理大量的过期 key;这无疑会增加 Redis 的 CPU 负载和内存使用,可能会使 Redis 变慢,特别当 Redis 实例中存在 bigkey 时,这个耗时会更久;而且这个耗时不会被记录在slow log
中
解决方案:
为了避免这种情况,可以考虑以下几种方法:
- 尽量避免 key 集中过期,如果需要批量插入key(如批量插入一批设置了同样
ExpireAt
的key),可以通过额外的小量随机过期时间来打散 key 的过期时间 - 在 Redis 4.0 以上的版本中提供了 lazy-free 选项,当删除过期 key 时,把释放内存的操作放到后台线程中执行,避免阻塞主线程
lazyfree-lazy-expire yes
从监控的角度出发,我们还需要建立对expired_keys
的实时监控和突增告警,以及时发出告警帮助我们定位到业务中的相关问题
触及maxmemory
当我们的 Redis 实例达到了设置的内存上限时,我们也会很明显地感知到 Redis 延迟增大
究其原因,当 Redis 达到 maxmemory 后,如果继续往 Redis 中写入数据,Redis 将会触发内存淘汰策略来清理一些数据以腾出内存空间,这个过程需要耗费一定的 CPU 和内存资源,如果淘汰过程中产生了大量的 Swap 交换或者内存回收,将会导致 Redis 变慢,甚至可能导致 Redis 崩溃
常见的驱逐策略有以下几种:
- noeviction: 不删除策略,达到最大内存限制时,如果需要更多内存,直接返回错误信息;大多数写命令都会导致占用更多的内存(有极少数会例外, 如 DEL )
- allkeys-lru: 所有key通用; 优先删除最长时间未被使用(less recently used ,LRU) 的 key
- volatile-lru: 只限于设置了 expire 的部分; 优先删除最长时间未被使用(less recently used ,LRU) 的 key
- allkeys-random: 所有key通用; 随机删除一部分 key
- volatile-random: 只限于设置了 expire 的部分; 随机删除一部分 key
- volatile-ttl: 只限于设置了 expire 的部分; 优先删除剩余时间(time to live,TTL) 短的key
- volatile-lfu: added in Redis 4, 从设置了expire 的 key 中删除使用频率最低的 key
- allkeys-lfu: added in Redis 4, 从所有 key 中删除使用频率最低的 key
最常用的驱逐策略是allkeys-lru / volatile-lru
⚠️需要注意的是:Redis 的淘汰数据的逻辑与删除过期 key 的一样,也是在命令真正执行之前执行的,也就是说它也会增加我们操作 Redis 的延迟,并且写 OPS 越高,延迟也会越明显
另外,如果 Redis 实例中还存储了 bigkey,那么在淘汰删除 bigkey 释放内存时,也会耗时比较久
解决方案:
为了避免 Redis 达到 maxmemory 后变慢,可以考虑以下几种解决方案:
- 设置合理的 maxmemory,可以根据实际情况设置 Redis 的 maxmemory,避免 Redis 在运行过程中出现内存不足的情况(大白话就是加钱加内存)
- 开启 Redis 的持久化功能,以将 Redis 中的数据持久化到磁盘中,避免数据丢失,并且在 Redis 重启后可以快速地恢复加载数据
- 使用 Redis 的分区功能,将 Redis 中的数据分散到多个 Redis 实例中,以减轻单个 Redis 实例内存淘汰的负载压力
- 与删除过期key一样,针对淘汰key也可以开启
layz-free
,把淘汰 key 释放内存的操作放到后台线程中执行
lazyfree-lazy-eviction yes
持久化耗时
为了保证 Redis 数据的安全性,我们可能会开启后台定时 RDB 和 AOF rewrite 功能
而为了在后台生成RDB
文件,或者在启用AOF
持久化的情况下追加写只读AOF
文件,Redis 都需要fork
一个子进程,fork
操作(在主线程中运行)本身可能会导致延迟
下图分别是AOF持久化
和RDB持久化
的流程图:
在大多数类Unix系统上,fork
的成本都很高,因为它涉及复制与进程相关联的许多对象,尤其是与虚拟内存机制相关联的页表
例如,在Linux/AMD64
系统上,内存被划分为 4kB
的页(如不开启内存大页);而为了将虚拟地址转换为物理地址,每个进程存储了一个页表,该页表包含该进程的地址空间每一页的至少一个指针;一个大小为24 GB
的 Redis 实例就会需要一个24 GB / 4 kB * 8 = 48 MB
的页表
在执行后台持久化时,就需要fork
此实例,也就需要为页表分配和复制48MB
的内存;这无疑会耗费大量CPU时间,特别是在部分虚拟机上,分配和初始化大内存块本身成本就更高
可以看到在Xen
上运行的某些VM
的fork
耗时比在物理机上要高一个数量级到两个数量级
如何查看 fork 耗时:
我们可以在 redis-cli
上执行 INFO 命令,查看 latest_fork_usec
项
INFO latest_fork_usec
# 上一次 fork 耗时,单位为微秒
latest_fork_usec:59477
这个时间就是主进程在 fork
子进程期间,整个实例阻塞无法处理客户端请求的时间;这个时间对于大多数业务来说无疑是不能过高的(如达到秒级)
除了定时的数据持久化会生成 RDB
之外,当主从节点第一次建立数据同步时,主节点也会创建子进程生成 RDB
,然后发给从节点进行一次全量同步,所以,这个过程也会对 Redis 产生性能影响
解决方案:
- 更改持久化模式 如果Redis的持久化模式为
RDB
,我们可以尝试使用AOF
模式来减少持久化的耗时的突增(AOF rewrite 可以是多次的追加写) - 优化写入磁盘的速度 如果 Redis 所在的磁盘写入速度较慢,我们可以尝试将 Redis 迁移到写入速度更快的磁盘上
- 控制 Redis 实例的内存: 用作缓存的 Redis 实例尽量在 10G 以下,执行 fork 的耗时与实例大小有关,实例越大,耗时越久
- 避免虚拟化部署 Redis 实例不要部署在虚拟机上,fork 的耗时也与系统也有关,虚拟机比物理机耗时更久
- 合理配置数据持久化策略 于低峰期在 slave 节点执行 RDB 备份;而对于丢失数据不敏感的业务(例如把 Redis 当做纯缓存使用),可以关闭 AOF 和 AOF rewrite
- 降低主从库全量同步的概率 适当调大
repl-backlog-size
参数,避免主从全量同步
开启内存大页
在上面提到的定时 RDB 和 AOF rewrite持久化功能中,除了fork
本身带来的页表复制的耗时外,还会有内存大页带来的延迟
内存页是用户应用程序向操作系统申请内存的单位,常规的内存页大小是 4KB
,而Linux 内核从 2.6.38 开始,支持了内存大页机制,该机制允许应用程序以 2MB
大小为单位,向操作系统申请内存
在开启内存大页的机器上调用bgsave
或者 bgrewriteaof
fork出子进程后,此时主进程依旧是可以接收写请求的,而此时处理写请求,会采用 Copy On Write(写时复制)的方式操作内存数据(两个进程共享内存大页,仅需复制一份页表
)
在写负载较高的 Redis 实例中,不断处理写命令将导致命令针对几千个内存大页(哪怕只涉及一个内存大页上的一小部分数据更改),导致几乎整个进程内存的COW
,这将造成这些写命令巨大的延迟,以及巨大的额外峰值内存
同样的,如果这个写请求操作的是一个 bigkey,那主进程在拷贝这个 bigkey 内存块时,涉及到的内存大页会更多,时间也会更久,十恶不赦的 bigkey 在这里又一次影响到了性能
无疑在开启AOF / RDB
时,我们需要关闭内存大页
我们可以使用以下命令查看是否开启了内存大页:
代码语言:javascript复制$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
如果该文件的输出为 [always]
或 [madvise]
,则透明大页是启用的;如果输出为 [never]
,则透明大页是禁用的
在Linux系统中,可以使用以下命令来关闭透明大页:
代码语言:javascript复制typescriptCopy code
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
第一行命令将透明大页的使用模式设置为 never
,第二行命令将透明大页的碎片整理模式设置为 never
;这样就可以关闭透明大页了
AOF和磁盘I/O造成的延迟
针对AOF
(Append Only File)持久化策略来说,除了前面提到的fork
子进程追加写文件会带来性能损耗造成延迟
首先我们来详细看一下AOF
的实现原理,AOF基本上依赖两个系统调用来完成其工作;一个是WRITE(2)
,用于将数据写入Append Only
文件,另一个是fDataync(2)
,用于刷新磁盘上的内核文件缓冲区,以确保用户指定的持久性级别,而WRITE(2)和fDatync(2)调用都可能是延迟的来源
对 WRITE(2)
来说,当系统范围的磁盘缓冲区同步正在进行时,或者当输出缓冲区已满并且内核需要刷新磁盘以接受新的写入时,WRITE(2)
都会因此阻塞
对fDataync(2)
来说情况更糟,因为使用了许多内核和文件系统的组合,我们可能需要几毫秒到几秒的时间才能完成fDataync(2)
,特别是在某些其它进程正在执行I/O的情况下;因此,Redis 2.4
之后版本会尽可能在另一个线程执行fDataync(2)
调用
解决方案:
最直接的解决方案当然是从 redis 配置出发,那么有哪些配置会影响到这两个系统调用的执行策略呢
我们可以使用appendfsync
配置,该配置项提供了三种磁盘缓冲区刷新策略
- no
当
appendfsync
被设置为**no**
时,redis 不会再执行fsync
,在这种情况下唯一的延迟来源就只有WRITE(2)
了,但这种情况很少见,除非磁盘无法处理 Redis 接收数据的速度(不太可能),或是磁盘被其他I/O密集型进程严重减慢 这种方案对 Redis 影响最小,但当 Redis 所在的服务器宕机时,会丢失一部分数据,为了数据的安全性,一般我们也不采取这种配置 - everysec
当
appendfsync
被设置为everysec
时,redis 每秒执行一次fsync
,这项工作在非主线程中完成 ⚠️需要注意的是:对于用于追加写入AOF
文件的WRITE(2)
系统调用,如果执行时fsync
仍在进行中,Redis 将使用一个缓冲区将WRITE(2)
调用延迟两秒(因为在Linux
上,如果正在对同一文件进行fsync
,WRITE
就会阻塞);但如果fsync
花费的时间太长,即使fsync
仍在进行中,Redis 最终也会执行WRITE(2)
调用,造成延迟 针对这种情况,Redis 提供了一个配置项,当子进程在追加写入AOF
文件期间,可以让后台子线程不执行刷盘(不触发 fsync 系统调用)操作,也就是相当于在追加写AOF
期间,临时把appendfsync
设置为了no
,配置如下: # AOF rewrite 期间,AOF 后台子线程不进行刷盘操作 # 相当于在这期间,临时把 appendfsync 设置为了 none no-appendfsync-on-rewrite yes 当然,开启这个配置项,在追加写AOF
期间,如果实例发生宕机,就会丢失更多的数据 - always
当
appendfsync
被设置为always
时,每次写入操作时都执行fsync
,完成后才会发送response
回客户端(实际上,Redis 会尝试将同时执行的多个命令聚集到单个fsync中) 在这种模式下,性能通常非常差,如果一定要达到这个持久化的要求并使用这个模式,就需要使用能够在短时间内执行fsync
的高速磁盘
以及文件系统实现
大多数 Redis 用户使用no
或everysec
并且为了最小化AOF
带来的延迟,最好也要避免其他进程在同一系统中执行I/O;当然,使用SSD磁盘
也会有所帮助(加