Redis 设了过期时间内存还在涨:删除、淘汰、碎片是三笔账

举报
yd_282927746 发表于 2026/11/11 08:08:42 2026/11/11
【摘要】 设置 EXPIRE 不等于到点就删:惰性删除让冷 key 长期滞留,定期删除只随机抽样 20 个、删掉不足四分之一就停手。附 expired_keys 与 evicted_keys 的区别、8 种淘汰策略怎么选、volatile-* 在无 TTL key 上会完全失效的坑、内存碎片与复制缓冲区,以及排查顺序与五条实践结论。

设了过期时间,内存却一直涨

一个缓存集群,所有 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 量级)

这是抽样而不是遍历。抽样意味着:

  1. 过期 key 占比高时,删得很快(每轮淘汰 25%,几何级下降);
  2. 过期 key 占比低时(比如 1%),抽 20 个可能一个都抽不中,于是一直"空转";
  3. 有单次时间上限,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 坏了,而是三件事叠在一起:

  1. 过期删除是抽样 + 惰性的,冷 key 会长期滞留;
  2. 淘汰策略可能完全没生效(volatile-* + 没有 TTL 的 key);
  3. 内存在数据之外(碎片、复制缓冲、客户端缓冲)。

想清楚这三层,再去看 INFO 里那几个数字,答案基本就摆在那里了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。