Theory · Redis · Memory
两套独立机制各管一摊 —— TTL 到期靠惰性+定期回收,内存超限靠 8 种策略近似 LRU/LFU 淘汰
定时/惰性/定期三策略取其二:惰性保延迟、定期控内存泄漏;从库不主动删、靠主库 DEL 同步
maxmemory 超限触发;近似 LRU 用 24bit 时钟 + 随机采样 + 淘汰池,LFU 用 8bit 对数计数器 + 分钟衰减
mem_fragmentation_ratio 判断健康度;activedefrag 用 jemalloc 元数据低延迟搬迁整理
Two Mechanisms
| 维度 | 过期删除(expiry) | 内存淘汰(eviction) |
|---|---|---|
| 触发条件 | key 的 TTL 到期(db->expires 字典记录绝对毫秒时间戳) | used_memory 超过 maxmemory(写命令前检查) |
| 作用对象 | 仅设置了过期时间的 key | 按策略:全部 key 或仅带 TTL 的 key |
| 删除方式 | 惰性删除 + 定期删除(本页后展开) | 按 maxmemory-policy 采样淘汰 |
| 无 TTL 的影响 | 永不过期(内存泄漏风险源头) | volatile-* 系列无键可淘汰时退化为 noeviction |
| 主从行为 | 从库不主动删,主库删除后同步 DEL | 官方文档:该限制只对主库生效(副本跟随删除) |
Expiry Strategies
| 策略 | 做法 | 问题 | Redis 的取舍 |
|---|---|---|---|
| 定时删除 | 每个 key 设过期时建定时器,到期立即删 | 大量定时器抢 CPU;创建/管理开销大 | 不采用(内存友好但 CPU 不友好) |
| 惰性删除 | 访问 key 时检查 expires 字典,已过期才删并返回 nil | 从不被访问的过期 key 永远占内存 | 采用(CPU 友好;配合定期补漏) |
| 定期删除 | 周期性随机采样过期字典,删掉命中的过期 key,限时限量 | 单次做得太重阻塞、太轻漏删 | 采用(SLOW/FAST 双模式 + 时间预算,下一页) |
读/写 key → expireIfNeeded 检查 → 已过期:同步删(或按 lazyfree-lazy-expire 异步删)→ 返回不存在。另外 RDB/AOF 重写、键空间通知、STATS 的 expired_keys 计数都挂在这条路径上。
只用惰性:冷 key 过期不删 → 内存持续涨(面试常问"过期 key 会不会泄漏"的答案)。只用定期:采样覆盖不到的 key 仍要靠访问兜底。二者互补:定期批量回收 + 惰性兜底准确。
activeExpireCycle · src/expire.c
// src/expire.c · 关键常量(7.x/8.x) // 每轮采样键数 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP = 20 // 可接受的"过期键占比"——超过则加速 ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALENESS = 25% // SLOW:每周期 CPU 时间预算占比 ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC = 25% // FAST:单次硬上限(微秒) ACTIVE_EXPIRE_CYCLE_FAST_DURATION = 1000 // 每个库的采样循环: do { if (expireIfNeeded(...)) // 删 20 个采样键中的过期键 while (过期比例 > 25% && 时间预算未用完);
| 模式 | 运行位置与约束 |
|---|---|
| SLOW | 每轮事件循环的 beforeSleep 执行;时间预算 = CPU 的 25% ÷ hz(hz 默认 10,即单次约 2.5ms 量级)——渐进式吃掉固定配额 |
| FAST | serverCron 每周期执行;硬上限 1ms;仅当 SLOW 落后(上轮过期比例 >10% 或过期键数量超预期)时才启动 |
Expiry × Replication
若从库独立执行过期删除,主从时钟偏差、命令到达顺序差异会造成数据不一致(主库还在用的 key 从库已删)。所以官方设计:过期删除的决策权在主库——主库惰性/定期删除后,把 DEL 作为写命令同步给从库与 AOF。
旧版本从库读已过期 key 会返回旧值(物理未删)。3.2 起从库读命令也会检查 TTL:已过期的 key 逻辑上视为不存在(返回 nil),但物理删除仍等主库 DEL 传来——"逻辑过期 + 物理跟随"的两层模型。
| 追问 | 答案 |
|---|---|
| 主库挂了,从库升主,过期键会瞬间涌出吗? | 不会:expires 字典随全量 RDB 同步(生成时未过期的键 + 过期时间戳一起带过去),升主后走正常的惰性+定期删除 |
| Lua 脚本里读"已过期但未删"的 key? | 主库上脚本执行前也会走 expireIfNeeded——主库视角一致;从库(只读脚本)按 3.2+ 逻辑过期返回不存在 |
| 怎么观察过期删除情况? | INFO stats 的 expired_keys(已删数);INFO keyspace 各库 keys 与带 TTL 的 expires 数对比,判断"死键积压" |
Expiry × RDB / AOF
| 文件 | 生成时 | 加载/恢复时 | 追加行为 |
|---|---|---|---|
| RDB | BGSAVE 时已过期的键不写入(rdbSave 检查 expires) | 加载时跳过已过期键(从库加载例外:主从同步场景不跳过,保留过期时间戳由主从机制接管) | —(RDB 是快照无追加) |
| AOF | — | 重放时按时间戳判断,已过期键重建后被惰性删除 | 键被惰性/定期删除时追加一条 DEL(lazyfree-lazy-expire 开启时同步 UNLINK 语义);AOF 重写时已过期键不写入 |
| 复制流 | — | — | 主库的过期删除以 DEL 写命令传播给从库——与 AOF 同源同序,保证主从一致 |
每次 BGSAVE 都相当于对过期键做了一次物理压缩——长期不重启的实例,RDB 体积里也不会积累死键。这也是"定期快照对内存友好"的一个冷门佐证。
AOF 不在写入时过滤过期键(写命令时键还活着),而是在键真正被删时补一条 DEL——所以 AOF 重放结束时,残留的过期键由过期机制兜底删除,恢复出的数据集与宕机前一致。
maxmemory-policy
| 策略 | 淘汰范围与依据 | 适用 |
|---|---|---|
| noeviction(默认) | 不淘汰;写命令报 OOM error(读命令不受影响) | 数据不可丢的存储型用法 |
| allkeys-lru | 全体 key,最久未访问优先 | 缓存默认推荐(官方:帕累托分布下接近最优) |
| allkeys-lfu | 全体 key,访问频率最低优先(4.0+) | 访问频次差异大、有突发扫描流量 |
| allkeys-random | 全体 key 随机 | 访问近似均匀分布时 |
| volatile-lru | 仅带 TTL 的 key,最久未访问优先 | 缓存 key 与持久 key 混存一个实例 |
| volatile-lfu | 仅带 TTL 的 key,频率最低优先 | 同上 + 频次敏感 |
| volatile-random | 仅带 TTL 的 key,随机 | 同上 |
| volatile-ttl | 仅带 TTL 的 key,剩余存活时间最短优先 | 业务能按 TTL 语义合理区分重要性 |
① maxmemory 0 = 不限(64 位系统默认;32 位隐式 3GB);② volatile-* 在没有 TTL key 时退化为 noeviction——只设 maxmemory 不给 key 设 TTL 的实例别选 volatile 系;③ 淘汰与持久化缓冲:复制/AOF 缓冲不计入 maxmemory 判定(mem_not_counted_for_evict)。
LFU 系(volatile/allkeys-lfu)4.0 引入。8.6 起新增 allkeys-lrm / volatile-lrm(least recently modified:只在写时更新时间戳,读不刷新——适合"想保留常读数据、淘汰久未改数据"的场景)。面试答 8 种是经典口径,LRM 属于加分补充。
Approximated LRU · src/evict.c
Design Trade-off
维护全局双向链表:每个 key 额外 2 个指针(16B);每次读都要摘链 + 头插(把"读"变成写,缓存行失效、锁竞争);百万 key 的链表操作对内存带宽的挤占远超 3 字节时钟字段。Redis 的选择:每个对象只用 lru:24(3 字节),随机采样近似。
官方文档的对比实验(power-law 访问分布):2.8 无池版本与真 LRU 有差距;3.0 引入淘汰池后 5 采样的近似已"virtually equivalent";10 采样几乎完全重合。结论:精度损失在真实访问分布下不可观测,省下的内存却能装更多数据。
| 追问:访问时间怎么算 | 答案 |
|---|---|
| lru 字段什么时候刷新? | 对象被读或写时(lookupKey 内 touch);但注意:被共享的整数对象(0–9999)不刷新——共享对象-clock 失真是官方接受的代价 |
| 24bit 秒级时钟会不会回绕? | 约 194 天回绕一次;计算 idle 时若 obj.lru > 当前时钟则按回绕补偿(源码 estimationOfIdleTime 的兜底分支) |
| LFU 模式下这 3 字节去哪了? | 同一字段语义复用:16bit 分钟时间戳 + 8bit 对数计数器(下一页)——没有为 LFU 增加任何存储 |
LFU · 4.0+ · Morris Counter
Tuning · lru vs lfu
想更细腻区分高频 key(如计数值差异大的业务):调大 factor(100 → 100 万次才 143);想更快"降温":调小 decay-time(0 永不衰减只适合访问模式恒定的场景)。官方默认 factor=10 / decay=1 是通用起点,生产上用 OBJECT FREQ key 观察实际分布再调。
周期性批量任务(报表/预热)会瞬间抬高一批 key 的计数并长期驻留——LFU 记"频率"不记"最近",靠 decay 缓解但分钟粒度较粗;而 LRU 天然偏向最近访问。突发爬虫扫库:LFU 完胜(扫过的冷 key 计数极低被秒杀;LRU 反而刚把它们摸热)。
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 典型缓存(帕累托访问) | allkeys-lru | 官方默认推荐,实现最简、表现稳定 |
| 有明显"一次性扫描/回溯"流量 | allkeys-lfu | 扫描 key 计数低,立即被淘汰,不冲掉热数据 |
| 缓存 + 永不过期的配置/元数据混存 | volatile-lru/lfu | 只动 TTL key;注意"无 TTL 键退化为 noeviction"的坑 |
| 业务能显式表达"谁先死" | volatile-ttl | 剩余 TTL 最短优先,与业务语义对齐 |
OBJECT FREQ key(LFU 计数值,仅 lfu 策略下可用)、OBJECT IDLETIME key(空闲秒数,仅 lru 语义;注意查询本身会刷新 lru,LFU 模式才不污染观察)。Eviction Flow · performEvictions
Fragmentation · INFO memory
mem_fragmentation_ratio = used_memory_rss / used_memory(OS 实际分配 ÷ Redis 自报)。官方理想区间 1.0–1.5:>1.5 碎片偏高(频繁增删改 + 删除大 key 的宿命);<1 说明用了 swap——比碎片严重得多的红色警报(Redis 内存被换出到磁盘)。
成因:分配器按大小类(size-class)分配 + key 的生死交错,页内空洞无法还给 OS。
原理一句话:利用 jemalloc 的 arena/bin 元数据找出"碎片率高的页",把存活数据搬迁到连续空间再归还页——搬迁以微秒级步长分摊在事件循环里(active-defrag-cycle-min/max 限 CPU 1%–75%),不阻塞服务。依赖 jemalloc(默认编译带)。
开启:CONFIG SET activedefrag yes(默认 no)。触发阈值:active-defrag-ignore-bytes 100mb、threshold-lower 10%(碎片比 RSS 超 10% 起步)、threshold-upper 100% 全力整理。
| 手段对比 | 说明 |
|---|---|
| activedefrag(在线) | 不重启、不复制数据,代价是少量 CPU;适合大实例无法重启的生产库 |
| 重启加载 RDB/AOF(离线) | 最彻底的"碎片整理"——重启后数据从文件紧凑重建(这也是恢复速度快的另一个收益) |
| 预防:减少交错生死 | TTL 打散(加随机抖动避免同刻批量过期)、避免大 value 反复写删——治本 |
Interview QA · 1/2
定时删除(每个 key 挂定时器)CPU 不友好,不采用;惰性删除(访问时 expireIfNeeded 检查)+ 定期删除(activeExpireCycle 限时限量的随机采样删除)组合——CPU 与内存的平衡,冷 key 靠定期补漏。
SLOW 挂 beforeSleep,每轮预算为 CPU 25% ÷ hz(hz 默认 10);FAST 挂 serverCron,硬上限 1ms,仅在 SLOW 落后时启动。每轮采样 20 个键,过期占比 >25% 就立刻再来一轮(自适应加速),否则收手。
不主动删:删除决策权在主库,主库删除后以 DEL 同步(避免时钟偏差不一致)。从库读侧 3.2 起也检查 TTL——已过期键逻辑上返回 nil(逻辑过期),物理删除等主库 DEL。
RDB:BGSAVE 时不写已过期键,加载时跳过——快照自清洁。AOF:写入时不过滤(当时键还活着),键真被删时追加 DEL;AOF 重写时过滤。主从同步场景从库加载 RDB 不过滤(要保留过期时间戳)。
db->expires 字典:key → 绝对毫秒时间戳(EXPIRE 会换算);PERSIST 删除该条。因存绝对时间,主从时钟偏差影响的是"什么时候该删"的判断,这正是从库不主动删的原因。7.0 起 EXPIRE 支持 NX/XX/GT/LT 选项;7.4 起 hash field 级 TTL(HEXPIRE)。
定期删除会因占比>25% 进入加速模式,但集中失效仍可能造成延迟毛刺与(淘汰策略下)连锁淘汰。避免:TTL 加随机抖动(基础值 ± 随机偏移);避开热点写入同一批相同 TTL;监控 expired_keys 曲线。
只靠惰性删除:从不被访问的过期 key 永不回收——泄漏。定期删除的采样回收正是兜底机制;二者组合下"过期但未删"只是短暂状态。运维可对比 INFO keyspace 的 keys 与 expires 数、观察 expired_keys 增长判断积压。
过期键的内存释放交给 BIO_LAZY_FREE 后台线程(删除决策仍是主线程),防过期风暴时批量 free 卡主线程;默认 no,大 value 场景建议 yes。与 UNLINK 的 user-del 开关同属 lazyfree 配置族(thread-model deck)。
Interview QA · 2/2
noeviction(默认,写报 OOM)+ allkeys 系(lru/lfu/random)+ volatile 系(lru/lfu/random/ttl)。规律:allkeys 全库找牺牲者;volatile 只动带 TTL 的 key,无 TTL 键时退化为 noeviction。8.6 起另有 lrm(最近未修改)作补充。
每对象 24bit 秒级时钟(3 字节);淘汰时随机采样 maxmemory-samples(默认 5)个 key,按 idle 维护容量 16 的淘汰池,取池首淘汰。不用真 LRU:全局双向链表每对象 2 指针 + 每次读都写链表;官方实验证明 5 采样+池在 power-law 分布下几乎等价。
官方口径:5 是精度/成本平衡点;10 接近真 LRU(文档原话"very close to the theoretical performance"),代价是每次淘汰更多采样 CPU。可 CONFIG SET 在线调整并用命中率(keyspace_hits/misses)对比验证。
高 16 位 ldt(分钟时间戳),低 8 位 logc。访问时先衰减(闲置每 decay-time 分钟减 1,默认 1)再按概率 P=1/(counter×factor+1) 增长(factor 默认 10);初值 5,0 表示从未访问,上限 255 ≈ 100 万次访问饱和。
官方表格:100 hits → 10;1000 → 18;100K → 142;1M → 255 饱和。factor 越大低频分辨率越低、高频区分越细;factor=0 时 100 次就到 104。能报这组数说明真读过 eviction 文档而非背二手结论。
官方口径:客户端执行"会增加数据"的命令前检查 used_memory,超限先淘汰再执行(读命令不触发)。被淘汰 key 以 DEL 写命令传播给从库、写入 AOF——淘汰是主从一致的写事件,不是本地私刑。
INFO memory:ratio = used_memory_rss / used_memory,理想 1.0–1.5;>1.5 碎片偏高;<1 表示 OS 把 Redis 内存换出到 swap——比碎片更严重的警报(延迟暴涨)。成因是分配器 size-class 与 key 生死交错的页内空洞。
4.0+:利用 jemalloc arena/bin 元数据找碎片页,把存活数据微步搬迁到连续页并归还(步长分摊在事件循环,active-defrag-cycle 限 CPU 1%–75%),不阻塞服务。ratio 持续 >1.5 且无法重启时开启;治本靠 TTL 抖动与减少大 value 反复写删。
Related · Cross-links
data-structures · 对象系统与底层数据结构(redisObject.lru:24 字段的出处;编码切换影响单 key 内存)
cache-patterns · 缓存模式与一致性(TTL 设计与淘汰策略是缓存架构的运行时半边)
persistence · RDB / AOF / 混合持久化(过期键在 RDB/AOF 中的处理、COW 与 maxmemory 联动)
thread-model · 单线程模型(lazyfree 线程、beforeSleep 里的 SLOW 过期)
MySQL MVCC:Redis 惰性删除 ↔ InnoDB delete-mark + purge——"先标记后回收"的同构
Kafka Internals:Kafka 日志保留(时间/大小)↔ Redis 过期+淘汰;都把"删除"设计成后台渐进动作
GMP 调度器:Go GC 的 mark 后台化 ↔ Redis 限时限量清理——共同哲学:不给主路径添堵
OS · 页面置换:Clock / 双链 / MGLRU——Redis 采样 LRU 的内核侧同族
References
本 deck 全部结论可溯源至下列一手材料——数字与版本结论以原文为准。
| redis.io/docs/latest/develop/reference/eviction/ | 8 种策略清单、volatile 退化行为、近似 LRU 与 pool、LFU 数值表(100→10 / 100K→142 / 1M→255)、LRM(8.6+) |
| redis.io/docs/latest/commands/info/(memory/stats 段) | mem_fragmentation_ratio、expired_keys、evicted_keys、mem_not_counted_for_evict |
| github.com/redis/redis · src/evict.c | EVPOOL_SIZE=16、estimationOfIdleTime(回绕兜底)、LFULogIncr / LFUDecrAndReturn、performEvictions |
| github.com/redis/redis · src/expire.c | activeExpireCycle 常量:LOOKUPS_PER_LOOP=20、ACCEPTABLE_STALENESS=25%、SLOW_TIME_PERC=25%、FAST_DURATION=1000μs |
| github.com/redis/redis · redis.conf(8.2) | maxmemory-samples 5、lfu-log-factor 10、lfu-decay-time 1、hz 10、activedefrag no 与阈值组、lazyfree-lazy-* 默认 no |
| redis.io/docs/latest/operate/oss_and_stack/management/persistence/ | 过期键与 RDB(生成/加载过滤)、AOF(DEL 追加与重写过滤)的交互 |
| redis.io/docs replication · 3.2 release notes | 主从过期删除权在主库;3.2 起副本读已过期键返回 nil(逻辑过期) |
| antirez 博客《Random notes on improving Redis's LRU approximation》(2014) | 采样 + pool 的设计动机与实验(3.0 引入池的原始出处) |