核心目标 :理解 TTL 过期删除的两种机制(惰性 + 定期)及其代价;掌握
maxmemory八种淘汰策略的语义与选型;会用MEMORY USAGE/MEMORY DOCTOR/--bigkeys/INFO memory定位大 key 与内存问题;理解"淘汰导致的缓存雪崩"风险。前置知识 :完成 Part 2(键空间与 TTL)与 Part 3(每键固定开销 ≈ 50-60 字节)。
验证环境:Redis 8.10.0(cygwin 移植版,主实例 6379 做过期/内存实验,隔离实例 6380 做淘汰策略实验)、redis-py 8.1.0、Python 3.11.6、Windows 11。最后复核日期:2026-08-07。
0. 本篇问题场景:两个"明明设置了却还是出事"
- 内存还在涨 :每个键都设了 TTL,为什么
INFO memory的used_memory还是居高不下? - 缓存雪崩:某个时间点大量缓存同时失效,数据库瞬间被打挂------"过期时间"本身怎么成了事故源?
第一个问题的答案在"过期删除的懒惰性":TTL 到点 ≠ 立即释放内存。第二个问题的答案在"淘汰/过期的同步性":没有打散的过期时间会让热点同时消失。本篇把过期、淘汰、内存三者串成一条线。
1. 过期机制:TTL 到点后发生了什么
1.1 两种删除策略
Redis 对过期键的删除不是到点立刻删除,而是两条路径配合:
| 策略 | 机制 | 代价 |
|---|---|---|
| 惰性删除 | 每次访问键时检查是否过期,过期则删除 | 过期但从未再被访问的键会一直占内存 |
| 定期删除 | 后台任务(serverCron,hz 频率)抽样扫描过期键并删除 |
扫描有成本,且是抽样不是全量 |
惰性删除保证"读到的永远是活的";定期删除兜底"没人访问的过期键也要清理"。两者都不是"到点秒删"。
1.2 实测一:惰性删除
text
$ redis-cli SET lazy_key "v" EX 1
OK
$ redis-cli GET lazy_key # 1 秒内:v
v
(等待 2 秒)
$ redis-cli GET lazy_key # 已过期,访问时被惰性删除
(空)
$ redis-cli EXISTS lazy_key
0
1.3 实测二:定期删除(activeExpireCycle)
写入 1 万个 2 秒 TTL 的键后不做任何访问 ,观察 INFO stats 的 expired_keys 与 dbsize:
text
写入 10000 个 2 秒 TTL 键, dbsize: 10000
(等待 5 秒,期间无任何客户端访问)
expired_keys: 1 -> 10001 (定期删除实际删除了 10000 个;起始值 1 来自此前惰性删除实验的计数)
5 秒后 dbsize: 0
结论 :定期删除是真实生效的------即使没人访问,过期键也会被后台抽样扫描清理。但它是抽样 + 分批 的:如果同一时刻到期键特别多(秒杀场景、批量设置相同 TTL),单次扫描清不完,used_memory 会在一段时间内"虚高"。这就是"设置了 TTL 内存还在涨"的机制解释:删除是异步的、有节奏的,不是即时的。
1.4 过期键对持久化与复制的影响
- RDB:生成快照时已过期的键不写入;加载时已过期的键被跳过;
- AOF :键过期后追加一条
DEL,保证重放后一致; - 主从复制 :过期删除只由主库发起 ,从库不主动删,靠主库同步
DEL(Part 10 展开)------从库读到已过期但未收到 DEL 的键时返回空值(replica-serve-stale-data相关)。
2. maxmemory 与八种淘汰策略
2.1 什么是淘汰
当 used_memory 达到 maxmemory 上限时,Redis 按 maxmemory-policy 决定"拒绝写"还是"淘汰键腾空间"。八种策略按两个维度划分:
| 维度 | 含义 |
|---|---|
allkeys-* |
从所有键里挑淘汰对象 |
volatile-* |
只从设置了 TTL 的键里挑;没有可淘汰的 TTL 键时退化为 noeviction |
lru / lfu / random / ttl |
淘汰依据:最近最少用 / 最不经常用 / 随机 / 剩余 TTL 最短 |
2.2 实测一:noeviction------写满即拒绝
隔离实例 maxmemory 16mb、maxmemory-policy noeviction,写入 4 个 4MB 键后:
text
写 big:0 (4MB): True
写 big:1 (4MB): True
写 big:2 (4MB): True
写 big:3 (4MB): True
写 big:4 报错: command not allowed when used memory > 'maxmemory'.
evicted_keys: 0
noeviction 是"宁可拒绝写入也不丢数据"------适合把 Redis 当数据存储的场景;缓存场景用它会在写满时直接报错,影响业务。
2.3 实测二:allkeys-lru------淘汰最久未用
同一配置改 allkeys-lru,先写 4 个 4MB 键,访问 big:1 激活它,再继续写 2 个:
text
访问 big:1(激活 LRU): aaaaa
继续写 big:4、big:5 后 evicted_keys: 3
存活: ['big:1', 'big:4', 'big:5']
淘汰: ['big:0', 'big:2', 'big:3']
LRU 语义完全符合预期:被访问过的 big:1 存活,最久未用的 big:0/2/3 被淘汰。
2.4 实测三:volatile-lru------只动带 TTL 的键
写入 3 个带 TTL 的键 + 1 个永久键(permanent),继续写 2 个带 TTL 键:
text
继续写 2 个带 TTL 键后 evicted_keys: 2
存活: ['permanent', 'volatile:3', 'volatile:4']
淘汰: ['volatile:0', 'volatile:1', 'volatile:2']
permanent 是否存活: 1
永久键在内存压力下毫发无损 ------volatile-* 系列绝不碰没有 TTL 的键。注意它的陷阱:如果带 TTL 的键很少或都被访问过,可能"无键可淘汰",退化为 noeviction 直接报错。
2.5 LRU 的近似实现与 LFU
严格 LRU 需要维护全量访问时间排序,成本太高。Redis 用近似 LRU :lru 字段(Part 3 的 24 bit)记录访问时间,淘汰时抽样 若干键(默认 maxmemory-samples 5)淘汰其中最旧的。代价是"近似"------抽样可能漏掉真正的冷数据。
LFU(allkeys-lfu/volatile-lfu)记录的是访问频次 而非最近时间:lru 字段的低 8 位存频次、高 16 位存衰减时间,频次会随时间衰减。适合"访问频率比访问时间更能代表热度"的场景(如热点商品)。
选型决策树:
text
Redis 承载的是数据(不能丢)?→ noeviction + 监控告警
缓存场景?
├─ 所有键都有 TTL?→ volatile-lru / volatile-ttl
├─ 有永久热点键? → allkeys-lru(避免永久键占满)
└─ 频率比时间重要? → lfu 系列
无法接受任何淘汰? → 扩容 maxmemory / 缩键
3. 淘汰的连锁反应:缓存雪崩
淘汰不是免费的 :被淘汰的键下次访问会回源数据库。三种常见事故:
| 事故 | 触发 | 后果 |
|---|---|---|
| 缓存雪崩 | 大量键同一时刻过期/被淘汰 | 同时回源,数据库打挂 |
| 缓存穿透 | 查询不存在的 key | 每次都回源(Part 8 详讲) |
| 缓存击穿 | 单个热点 key 过期瞬间 | 高并发同时重建(Part 8 详讲) |
本系列的防偏原则:淘汰/过期参数要与回源能力匹配 ------maxmemory 设得过小 + allkeys-lru,会在流量高峰把整批缓存"洗掉",雪崩随之而来。缓解手段(Part 8 完整展开):过期时间加随机抖动、多级缓存、限流降级、热点预加载。
4. 内存分析与大 key 治理
4.1 四个工具
text
$ redis-cli MEMORY USAGE key # 单键内存(value 部分)
$ redis-cli MEMORY USAGE key 0 # 含 key 名
$ redis-cli MEMORY DOCTOR # 内存健康体检
$ redis-cli --bigkeys # 采样扫描最大的 key
实测 --bigkeys 在 2 万小键 + 1 个 5MB 大键 + 1000 个 List 上的输出:
text
[04.55%] Biggest string found so far "big:value" with 5000000 bytes
-------- summary -------
Biggest list found "list:333" has 200 items
Biggest string found "big:value" has 5000000 bytes
4.2 INFO memory 关键指标
text
used_memory:20,689,174 # 数据实际占用(不含碎片)
used_memory_human:19.73M
used_memory_peak_human:367.87M # 历史峰值------分配器不归还峰值内存
mem_fragmentation_ratio:1.00 # 碎片率:>1.5 需关注
maxmemory_human:0B # 0 = 未设上限
maxmemory_policy:noeviction
used_memory_peak 是最容易被忽略的指标:分配器(jemalloc/malloc)在内存峰值后通常不会把空间归还给 OS ------即使数据删了,RSS 也回不到峰值以下。MEMORY DOCTOR 的实测提示正是这一点:
text
Sam, I detected a few issues in this Redis instance memory implants:
* Peak memory: In the past this instance used more than 150% the memory that
is currently using. The allocator is normally not able to release memory
after a peak ...
4.3 大 key 的四类危害
| 危害 | 机制 |
|---|---|
| 阻塞 | 单线程下大 value 的读写/序列化占用命令执行时间 |
| 带宽 | GET/LRANGE 全量传输大对象,拖慢客户端与网络 |
| 主从延迟 | 大 key 的增量同步传输放大延迟(Part 10) |
| 集群倾斜 | 大 key 所在分片内存/流量失衡(Part 11) |
治理手段 :--bigkeys/--memkeys 定期扫描 → 拆 key(按字段/按桶)→ 压缩序列化 → 必要的大对象移出 Redis 存对象存储。
5. 内存优化手段
5.1 键粒度是最大的杠杆
Part 3 实测:每个小键固定开销 ≈ 55.6 字节,与 value 大小无关。所以:
text
10 万个小键(value 1B) ≈ 5.6 MB,其中 98% 是包装开销
同样的数据合并成一个 Hash 的 10 万个字段,内存能省 10 倍以上。优化顺序:先看键数量,再看 value 大小------"把多个字段拆成独立 String 键"往往是内存翻倍的开端。
5.2 小对象编码参数
Part 4 的 listpack/intset 编码把大量小对象压进紧凑结构。相关参数(CONFIG GET 实测本机默认):
text
hash-max-listpack-entries 512
set-max-intset-entries 512
set-max-listpack-entries 128
zset-max-listpack-entries 128
调大这些阈值可以推迟 hashtable/skiplist 升级、保持紧凑编码------但代价是字段级读写变慢(listpack 更新要移动内存)。这是"内存 vs CPU"的权衡,不是越大越好。
5.3 碎片与共享
mem_fragmentation_ratio(used_memory_rss / used_memory)> 1.5 时考虑activedefrag yes或重启(分配器归还峰值内存,见 4.2);- 小整数共享(Part 3 实测本机 8.10 未生效)------版本相关,不可依赖。
6. 版本与环境差异
| 差异点 | 官方 7.4 | 本机 8.10.0(cygwin 移植版) | 影响 |
|---|---|---|---|
| 过期删除机制 | 惰性 + 定期(hz=10) | 一致 | 本机 hz:10 实测(Part 1) |
maxmemory 策略 |
八种 | 八种,行为一致 | 本文三个实验均为本机实测 |
| 共享整数对象 | 0-9999 | 实测未生效(Part 3) | 依赖共享省内存的结论需复核版本 |
--bigkeys |
支持 | 支持 | 输出格式一致 |
淘汰/过期行为在 7.x/8.x 上稳定,本文实验数据可直接复用。
7. 测试与验收
- 建议测试:过期后
GET返回 None(惰性)、批量短 TTL 后dbsize归零(定期)、maxmemory写满的三种策略行为(隔离实例)、--bigkeys能发现构造的大 key; - 淘汰实验必须在隔离实例做,不要在生产/共享实例上压内存。
本篇验收清单:
- 能解释"TTL 到点 ≠ 立即释放内存"并说出两种删除策略;
- 能说出
noeviction/allkeys-lru/volatile-lru的语义差异,并解释为什么 volatile 系列可能退化为 noeviction; - 能解释近似 LRU 与 LFU 的差异及
maxmemory-samples的作用; - 会用
MEMORY USAGE/MEMORY DOCTOR/--bigkeys/INFO memory四件套定位内存问题; - 能解释"键数量比键大小更费内存"及 Hash 合并的优化思路;
- 能设计 maxmemory 与淘汰策略的选型(数据 vs 缓存 vs 热点)。
8. 常见误区
- "TTL 到点内存立刻释放"------惰性 + 定期两种机制都有延迟(§1)。
- "volatile-lru 永不报错"------没有带 TTL 的键可淘汰时退化为 noeviction,照样 OOM(§2.4)。
- "allkeys-lru 会把我的永久数据删掉"------会!它是缓存策略,承载业务数据时用 noeviction 或 volatile 系列。
- "删了数据 RSS 就会降"------分配器不归还峰值内存,重启才可能回收(§4.2)。
- "maxmemory 越大越好"------上限越接近物理内存越危险:峰值 + 碎片 + COW 会让实例 OOM 崩溃。
- "
--bigkeys能发现所有大 key" ------它是采样扫描,漏检率随数据量上升;精确分析用MEMORY USAGE或离线分析 RDB。
9. 本篇小结
回到开篇两个问题:
- "设置了 TTL 内存还在涨" :过期删除是惰性 + 定期两条异步路径,批量过期时清理有节奏;叠加"分配器不归还峰值内存",内存下降总是慢于直觉------需要看
expired_keys、used_memory_peak而不是只看used_memory。 - "缓存雪崩从哪来" :淘汰/过期策略没有与回源能力匹配------
maxmemory过小、过期时间无抖动、热点无预加载,都是雪崩的引信。
本篇建立的治理模型:过期管"键的生命周期",淘汰管"内存上限的兜底",优化管"每键成本"------三层配合,Redis 才能在有限内存里稳定运转。
下一篇进入并发与架构篇:Part 7:事务、Pipeline 与 Lua 脚本,回答"Redis 的原子性到底是什么",用秒杀扣库存把 MULTI/WATCH/Lua 串起来。
10. 官方资料
- Key expiration:https://redis.io/docs/latest/develop/operate/oss_and_stack/management/redis-keyspace-notifications/
- Eviction policies:https://redis.io/docs/latest/operate/oss_and_stack/management/eviction/
- Memory optimization:https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/
MEMORY命令族:https://redis.io/docs/latest/commands/memory-doctors/