凌晨两点的告警:Redis used_memory 撞到了 maxmemory
我们的 Redis 是 4.0.2,主从各 4G。某天凌晨收到告警,used_memory 冲到 3.9G,然后业务开始大面积报:
redis.clients.jedis.exceptions.JedisDataException: OOM command not allowed when used memory > 'maxmemory'.
登上机器看:
$ redis-cli info memory
# Memory
used_memory:4089446400
used_memory_human:3.81G
used_memory_rss:4261412864
used_memory_peak:4089446400
maxmemory:4294967296
maxmemory_policy:noeviction
maxmemory_human:4.00G
mem_fragmentation_ratio:1.04
注意到 maxmemory_policy:noeviction,这是默认值:内存满了不淘汰,直接拒绝写命令。业务写不进去,读还能用,所以监控上只看到一片写失败。
我当时的疑问是:我明明给所有 key 都设了过期时间,为什么还会满?
设了过期时间不等于会准时被删
先看实际数据:
$ redis-cli dbsize
(integer) 8421336
$ redis-cli info keyspace
db0:keys=8421336,expires=8421336,avg_ttl=3600000
840 万个 key,全部带过期时间,平均 TTL 一小时。但 avg_ttl 这个数字掩盖了分布问题。我写了个脚本抽样:
$ redis-cli --scan --pattern 'sess:*' --count 1000 | head -5 | xargs -I{} redis-cli ttl {}
(integer) -2
(integer) -2
(integer) -2
(integer) -1
(integer) 3540
TTL 返回 -2 表示 key 已不存在,-1 表示存在但没设过期时间。这里混进了 -1,说明有部分 key 忘了设 TTL(是另一个同事加的永久配置项)。但更主要的问题是那些 -2——它们还占着空间,只是被标记为已过期。
这就得说 Redis 的过期删除策略了。它用了两种配合,而不是定时删除:
惰性删除
客户端访问某个 key 时,Redis 才顺手检查它是否过期,过期就删掉再返回 nil。好处是不浪费 CPU,坏处是如果一个过期 key 再也没人访问,它就永远躺在那儿。源码里这个函数叫 expireIfNeeded,在 lookupKeyRead 里被调用。
定期删除
Redis 每隔一段时间(由 hz 配置决定,默认 10,即每秒 10 次)执行 activeExpireCycle,从设置了过期时间的 key 集合里随机抽一部分检查:
- 每次随机取 20 个 key(常量
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP); - 删掉其中已过期的;
- 如果这 20 个里过期比例超过 25%,就再抽 20 个,循环继续;
- 为了避免阻塞主线程,每次执行有时间上限,默认不超过 25ms(Redis 内部按 CPU 时间算)。
因为是随机抽样加时间限制,所以过期 key 的清理是渐进的,一定存在滞后。写入速度超过清理速度时,内存就一路涨上去。我们那次是因为运营做了个活动,写 QPS 从平时的 3000 涨到了 1.2 万。
八种淘汰策略怎么选
maxmemory-policy 一共 8 个可选项。Redis 3.x 只有前 6 个,后两个 LFU 相关的是 4.0 才加的,我们当时正好在 4.0,用得上。
| 策略 | 淘汰范围 | 淘汰依据 | 3.x 支持 |
|---|---|---|---|
| noeviction | 不淘汰 | 写命令直接报 OOM | 是 |
| volatile-lru | 只挑设了过期时间的 key | 最近最少使用 | 是 |
| allkeys-lru | 所有 key | 最近最少使用 | 是 |
| volatile-random | 只挑设了过期时间的 key | 随机 | 是 |
| allkeys-random | 所有 key | 随机 | 是 |
| volatile-ttl | 只挑设了过期时间的 key | 剩余 TTL 最短的先走 | 是 |
| volatile-lfu | 只挑设了过期时间的 key | 访问频率最低 | 否(4.0+) |
| allkeys-lfu | 所有 key | 访问频率最低 | 否(4.0+) |
几个判断依据:
- 拿 Redis 当缓存用,数据丢了能从 DB 重建:选
allkeys-lru。这是最常见的场景,也是我最后选的。 - 同时存缓存和持久化数据(比如有些配置型 key 不能丢):选
volatile-lru,只淘汰带过期时间的那部分。前提是你能保证关键 key 都没设 TTL——这就是前面那个-1的来源,得先清理掉误设的永久 key。 - 希望快过期的先走:
volatile-ttl。适合那种"越新越有价值"的数据。 - 有明确的热点,且冷热长期稳定:
allkeys-lfu。它能避免 LRU 的一个老问题:一个被批量扫描一次的数据会把真热点挤出去。我们的商品详情缓存后来换成了 LFU,命中率从 91.3% 提到 94.7%。
关于 LRU:Redis 用的是近似 LRU
这点我一开始理解错了。Redis 并没有维护一个严格的 LRU 链表(那太占内存),而是在 redisObject 的 lru 字段里存了 24 位的最近访问时间戳(单位是秒级的 LRU_CLOCK),淘汰时随机抽 maxmemory-samples 个样本,挑其中最久没被访问的删掉。
# redis.conf
maxmemory-samples 5
默认 5 个。样本越大越接近真实 LRU,但 CPU 消耗也越大。Redis 官方有张对比图,samples 设为 10 时已经非常接近理论 LRU,再往上收益就很小了。我调到 10 之后压测了一轮,淘汰精度确实提升,CPU 基本没变化,就留着了。
我的配置和处理过程
改配置不用重启,可以在线改:
$ redis-cli config set maxmemory-policy allkeys-lru
OK
$ redis-cli config set maxmemory-samples 10
OK
$ redis-cli config get maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
别忘了同步改 redis.conf,不然重启后配置会丢:
$ redis-cli config rewrite
然后处理那些历史遗留的永久 key。用 scan(千万别用 keys *,会阻塞)找出没设 TTL 的:
$ redis-cli --scan --pattern 'config:*' --count 1000 | while read key; do
ttl=$(redis-cli ttl "$key")
if [ "$ttl" -eq -1 ]; then
echo "$key"
fi
done
查出来 3271 个,确认可以重建后补上过期时间:
$ redis-cli expire "config:rate:limit" 86400
(integer) 1
效果:
| 指标 | 处理前 | 处理后 24h |
|---|---|---|
| used_memory | 3.81G(贴顶) | 2.6G 稳定 |
| 写失败次数 / 天 | 约 4.2 万 | 0 |
| 缓存命中率 | 88.1% | 93.5% |
| evicted_keys | 0(noeviction) | 约 12 万 / 天 |
evicted_keys 从 0 变成 12 万,看着吓人,其实是正常的:说明淘汰机制在工作,被淘汰的都是冷数据,命中率反而升了,因为腾出来的空间装下了更多热数据。
后来加的几条规矩
maxmemory不要设成机器内存的 100%。我们有台机器因为used_memory_rss比used_memory高(碎片),加上 fork 子进程做 RDB 时的 copy-on-write,实际占用超过了 maxmemory。一般留 30% 左右余量。- 所有写入必须带 TTL,代码 review 时重点看这一条。我写了个小工具定期扫 TTL 为 -1 的 key 发到群里。
- 监控除了
used_memory,一定要加evicted_keys和expired_keys的曲线。淘汰数突然飙升往往是缓存穿透或者热key 变化的前兆。 - 关注
mem_fragmentation_ratio。我们那台一度到了 1.8,说明碎片严重,后来在低峰期做了次主从切换重启解决的。Redis 4.0 可以用activedefrag yes开自动碎片整理。
那次事故之后我才明白一件事:给 key 设过期时间,只是告诉 Redis"这个数据可以删",而不是"这个数据会被立刻删掉"。中间那段滞后,才是内存被打满的原因。