Redis 设了过期时间内存还在涨:删除、淘汰、碎片是三笔账
设了过期时间,内存却一直涨
一个缓存集群,所有 key 都设了 EX 3600,理论上一个小时后内存应该稳定在一个水位。但实际监控上,used_memory 一路涨到 maxmemory,然后开始零星地淘汰、偶尔有请求变慢。
登上去查:
127.0.0.1:6379> dbsize
(integer) 4812331
127.0.0.1:6379> info memory
used_memory_human:7.62G
used_memory_dataset:6.10G
mem_fragmentation_ratio:1.25
127.0.0.1:6379> info stats
expired_keys:12033
evicted_keys:874421
dbsize 481 万,expired_keys 只有 1.2 万。四百万个"过期"的 key,绝大多数从来没被真正删除过。
这就是 Redis 过期机制最容易误解的地方:设置 EXPIRE 不等于到点就删。Redis 用的是"惰性删除 + 定期抽样"的组合,两种方式都可能让过期 key 长期留在内存里。再加上淘汰策略和内存碎片,就出现了"过期了但内存不降"的现象。
一、三种"删除",别混在一起
Redis 里 key 消失有三种完全不同的原因,监控指标也不同:
| 原因 | 触发者 | 指标 |
|---|---|---|
| 过期删除 | 惰性访问 / 定期抽样 | expired_keys |
| 内存淘汰 | 达到 maxmemory |
evicted_keys |
| 主动删除 | DEL / UNLINK / 覆盖写 |
无独立指标 |
两个数字要分清:expired_keys 是"过期被删掉的",evicted_keys 是"没到期但被内存压力赶走的"。前者是正常回收,后者说明内存不够用了。
还有一个隐性的:UNLINK 是异步删除。它把 key 从 keyspace 摘掉,实际内存释放交给后台线程(bio)。所以 used_memory 的下降会滞后,DEL 大 key 更是会卡住主线程(这就是官方推荐用 UNLINK 的原因)。
二、惰性删除:不访问就永远不删
过期字典里记录着每个 key 的到期时间(redisDb.expires)。当客户端访问一个 key 时,Redis 先查到它已过期,于是:删除、返回 nil。
问题很明显:如果一个 key 过期后再也没人访问,它就永远不会被这条路径删掉。
这个设计不是偷懒,而是权衡——如果每个 key 到期都精确触发一次删除,就得为几百万 key 维护定时器,成本远高于收益。惰性删除把成本摊到了访问路径上,代价是"冷 key 会占着内存"。
这也解释了一类经典现象:写入量大的表,过期 key 堆积最多。因为写的时候会产生大量只被写、很少被读的 key(比如一次性的会话、幂等标记),它们过期后没人访问,只能等定期删除来收。
三、定期删除:抽样 20 个,删掉 1/4 就继续
Redis 的 serverCron(默认每 100ms 跑一次)会调用 activeExpireCycle,逻辑大致是:
从设置了过期时间的 key 中随机抽 20 个
删掉其中已过期的
如果这一轮删掉的比例 > 25%,就再来一轮
单次执行有时间上限(默认不超过 CPU 时间的 25%,且不超过 1ms 量级)
这是抽样而不是遍历。抽样意味着:
- 过期 key 占比高时,删得很快(每轮淘汰 25%,几何级下降);
- 过期 key 占比低时(比如 1%),抽 20 个可能一个都抽不中,于是一直"空转";
- 有单次时间上限,CPU 忙的时候会让步。
所以如果你有 400 万个几乎从不被读的过期 key,而活跃 key 有几百万,抽样命中率就会很低——内存迟迟不降是预期行为,不是 bug。
调优参数(redis.conf):
hz 10 # 后台任务频率,10 表示每 100ms 一次
active-expire-effort 1 # 1~10,越高越积极(费 CPU)
hz 调到 100 能让定期删除更勤快,但代价是 CPU 占用上升、延迟抖动变大。不要为了清内存把 hz 拉到 100,那是拿稳定性换内存。
四、淘汰策略:8 种挑一种,选错会删掉不该删的
maxmemory-policy 有 8 个值(Redis 6+):
noeviction 默认。内存满了写命令直接报错 OOM command not allowed
allkeys-lru 在所有 key 里按 LRU 淘汰
allkeys-lfu 在所有 key 里按访问频率淘汰(4.0+)
allkeys-random 随机淘汰
volatile-lru 只在设置了过期时间的 key 里按 LRU 淘汰
volatile-lfu 同上,按 LFU
volatile-random 同上,随机
volatile-ttl 优先淘汰剩余存活时间最短的
选择的关键是先问一句:我的 Redis 里有没有"绝对不能丢"的数据?
- 纯缓存:用
allkeys-lru(或allkeys-lfu,访问频率比访问时间更贴近真实热度)。 - 缓存 + 少量持久数据混用(比如同一个实例里既有缓存也有队列/配置):用
volatile-lru。但这里有个陷阱——如果所有 key 都没设过期时间,volatile-*策略一个都淘汰不掉,会退化成noeviction,写命令开始报错。这个坑非常常见:监控显示内存满了但没有 evicted,应用侧开始OOM command not allowed。 - 队列/消息类:
noeviction,满了就让生产者感知到背压,别让消息悄悄消失。
关于 LRU 的实现也要知道一点:Redis 的 LRU 是近似 LRU,不是链表。它给每个对象记录一个 24 位的访问时间戳,淘汰时随机采样 maxmemory-samples(默认 5)个 key,挑最久没访问的。采样数越大越接近真 LRU,代价是 CPU。
maxmemory-samples 5 在绝大多数场景够用;对命中率敏感的场景可以调到 10,实测命中率能提升几个百分点。
五、内存碎片:used_memory 之外的账
mem_fragmentation_ratio = used_memory_rss / used_memory。
- 大于 1.5:碎片明显,实际占用比数据多 50%;
- 小于 1:说明有部分内存被操作系统换出(swap),性能会断崖式下跌。
碎片产生的原因:频繁增删不同大小的 key,分配器(jemalloc)留下空隙。Redis 4.0 之后有 activedefrag yes 可以在线整理,但它默认关闭,因为整理本身消耗 CPU 和内存带宽。
判断要不要开:fragmentation_ratio 长期 > 1.5 且 CPU 有余量。开了之后观察 active_defrag_running 和 mem_fragmentation_ratio 是否下降。
六、真正"看不见"的两个吃内存大户
① 复制缓冲区(replication backlog)
repl-backlog-size 默认 1MB,但主从延迟大或写入量大时,backlog 会长。每个 replica 还有独立的输出缓冲区,client-output-buffer-limit replica 配置不当的话,一个慢从库就能把主库内存吃光。
② 客户端输出缓冲区
client-output-buffer-limit normal/pubsub/...。一个执行了 KEYS * 或者订阅了大量 channel 的客户端,输出缓冲区会暴涨。这类问题在 INFO clients 里看 client_recent_max_output_buffer。
两个都不在你写的业务数据里,但都在 used_memory 里。
七、排查顺序
按这个顺序走,基本能定位:
# 1. 内存构成
redis-cli info memory | grep -E "used_memory_human|used_memory_dataset|used_memory_overhead|mem_fragmentation_ratio"
# 2. 过期与淘汰,确认是哪种删除
redis-cli info stats | grep -E "expired_keys|evicted_keys|keyspace_hits|keyspace_misses"
# 3. key 数量与过期 key 占比
redis-cli info keyspace # 每个 db 的 keys / expires
redis-cli dbsize
# 4. 找出大 key(生产环境别用 KEYS)
redis-cli --bigkeys
redis-cli --memkeys # 4.0+,按内存排序
# 5. 采样一批 key 看 TTL 分布
redis-cli --scan --count 1000 | head -50 | xargs -I{} redis-cli ttl {}
info keyspace 里 keys=X,expires=Y 特别有用:如果 expires 远小于 keys,说明大部分 key 根本没设过期时间——那内存涨就不是过期机制的问题,而是数据只进不出。
八、几条实践结论
① 过期时间要带随机抖动。
SET key val EX $((3600 + RANDOM % 600))
否则在同一秒写入的一批 key 会在同一秒集体过期,定期删除要在某一刻处理大量 key,造成周期性的 CPU 尖刺和延迟抖动。这跟缓存雪崩是同一个根源。
② 冷 key 不要指望"自然过期"来省内存。
如果业务里会产生大量"写一次就不再读"的 key,要么用短 TTL + 更积极的淘汰策略(allkeys-lfu),要么把这类数据放到别的存储。Redis 的内存是昂贵的。
③ volatile-* 策略必须配合"所有 key 都有 TTL"的前提。
混用没有 TTL 的 key 时,一旦内存达到上限,淘汰会完全失效,直接报错。要么全设 TTL,要么用 allkeys-*。
④ 大 key 用 UNLINK 不用 DEL。
DEL 一个 100MB 的 hash 会阻塞主线程几百毫秒。UNLINK 把释放交给后台线程。
⑤ 别用 KEYS * 排查。
它会阻塞整个实例。用 SCAN 游标遍历,或者 --bigkeys(内部也是 SCAN)。
小结
"设了过期时间内存还在涨"通常不是 Redis 坏了,而是三件事叠在一起:
- 过期删除是抽样 + 惰性的,冷 key 会长期滞留;
- 淘汰策略可能完全没生效(
volatile-*+ 没有 TTL 的 key); - 内存在数据之外(碎片、复制缓冲、客户端缓冲)。
想清楚这三层,再去看 INFO 里那几个数字,答案基本就摆在那里了。
- 点赞
- 收藏
- 关注作者
评论(0)