2284 字
约 7 分钟
21
S12-A14 · 过期策略的三种做法:TTL、逻辑过期与主动失效

一个缓存用了统一 TTL 三百秒。业务方要求"配置改了要立刻生效",于是把 TTL 降到五秒,命中率从 97% 掉到 60%,数据库压力翻了三倍。真正的问题是:"过期"这件事被当成了一个参数,而它其实是三种不同的机制。

TTL 到期删除、逻辑过期、写时主动失效——三者的失效时机、一致性强度、实现成本完全不同。选错不是配置问题,是模型问题。


1. 三种机制对比

机制 失效时机 一致性 实现成本
TTL 到期删除 时间到了 最终一致(最大 TTL) 最低
逻辑过期 读到过期值再刷新 最终一致(刷新耗时) 中
写时主动失效 数据变更时 强一致(若不失败) 高(依赖写路径完整)
版本号/时间戳校验 每次读校验 强一致 高(多一次比对)

还有一层容易忽略的差别:失效与"重新加载"是两件事。TTL 把它们绑在一起(到期即删、下次读时加载),逻辑过期把它们拆开(到期只标记、异步加载),主动失效则既不删也不加载(只让下次读时重建)。这个拆分程度决定了延迟的稳定性。

关键差别在**"谁触发失效"**:前两种由时间触发,后两种由数据变更触发。时间触发简单但延迟固定;变更触发精确但依赖"每次变更都被捕捉到"。

2. TTL 的两个陷阱

陷阱 机制
集中过期 同一批键同时到期,请求全部回源
TTL 太短 命中率下降,缓存失去意义

第一种是雪崩的经典成因:批量写入时用了相同的 TTL,于是它们在同一秒一起过期。解法是给 TTL 加随机抖动。

// TTL 加抖动:把过期时间打散到窗口内
int base = 300;
int jitter = ThreadLocalRandom.current().nextInt(60);   // 0–59 秒
redis.setex(key, base + jitter, value);
// 效果:原本同一秒到期的 1 万个键,分散到 60 秒内

TTL 的选择还有个二阶效应:TTL 越短,回源请求的方差越大。平均值可能只涨 20%,但峰值会成倍上升,而容量是按峰值配的。所以"把 TTL 调短一点"在容量上从来不是线性的。

第二种是"为了实时把 TTL 设得很短"的常见后果:命中率崩塌后,缓存从"挡住流量"变成"多一跳"。

3. 逻辑过期:用旧值兜住刷新

步骤 行为
存值 值里带 expire_at 时间戳
读到未过期 直接返回
读到已过期 返回旧值,同时异步刷新
刷新失败 继续返回旧值,记录失败计数

它的价值是读路径永远不会等待刷新:延迟稳定,代价是可能返回过期数据。这正好对应"能接受短暂不一致"的场景。

// 逻辑过期:读永远快,刷新异步;必须有人负责"多久没刷新算故障"
Cached v = redis.get(key);
if (v == null) return loadAndSet(key);
if (v.expireAt.isBefore(now)) {
    asyncRefresh(key);          // 不阻塞,失败也不影响本次返回
    staleCounter.increment();
}
return v.value;

逻辑过期还需要一个兜底:刷新失败要有上限。如果连续多次刷新失败,就要从"返回旧值"切换到"返回错误"或"降级数据",否则旧值可能无限期地服务下去。

// 逻辑过期的兜底策略:陈旧程度决定行为
stale_policy:
  within: 5m        # 5 分钟内:照常返回旧值
  beyond: 5m        # 超过:返回旧值但打标(前端可提示)
  beyond: 30m       # 超过:视为不可用,走降级路径
  refresh_retry:
    attempts: 3
    backoff_ms: [200, 800, 2000]

第三行的 staleCounter 很关键:没有这个计数,"一直返回旧值"是不可见的。

4. 主动失效的依赖条件

条件 不满足的后果
所有写路径都失效 部分旧值永久残留
失效在事务提交后 事务回滚后缓存已删,读到旧值重填
失效失败可重试 删除失败导致长期脏读
多级缓存全部失效 本地缓存仍返回旧值

第二项最隐蔽:在事务提交前删缓存,回滚后缓存被旧值重填。正确顺序是提交后删,或提交后延迟一小段再删。

// 正确顺序:先提交事务,再删缓存
tx.execute(() -> repo.update(...));
cache.delete(key);          // 提交后删
// 若必须"删后写",则要配延迟双删或版本号,避免并发重填旧值

5. 多级缓存下的失效传播

层级 失效方式
本地缓存(进程内) 需要广播失效消息
分布式缓存 直接删除
CDN 按 URL 刷新或等 TTL

广播失效本身也需要可靠性设计:消息丢了就等于永久脏读(本地缓存直到自己的 TTL 到期)。所以广播通道要有确认或兜底短 TTL,二者至少有一个。

第一项是"多级缓存一致性"的主要难点:进程内缓存无法被其他进程直接删除,只能通过消息通知或短 TTL 兜底。

// 两级缓存的三种组合
本地短 TTL(如 5s) + 分布式长 TTL   → 简单,最多脏 5 秒
本地 + 广播失效消息                   → 精确,需消息通道
只用分布式缓存                         → 最简单,多一次网络往返

主动失效还有一个常被忽略的落点:批量修改。一次更新一万条记录,如果逐条删缓存,那就是一万次网络往返;如果只删一部分,剩下的就变成脏数据。批量场景通常要配合"按前缀删除"或"版本号整体失效"。

// 批量变更的三种失效方式
逐条删键    :准确,成本 = 记录数 × 一次往返
按前缀删    :需要键设计支持(如 tenant:42:*),一次操作
版本号失效  :键里带版本,改版本即整体失效,旧键靠 TTL 淘汰

6. 排错:五种"缓存不更新"

现象 成因 处理方向
改库后页面不变 写路径未失效 补失效逻辑
部分实例返回旧值 本地缓存未收到广播 加广播或缩短本地 TTL
事务回滚后仍读到新值 提交前删了缓存 改为提交后删
某些键永不过期 写入时未设 TTL 检查写入路径
高峰期命中率骤降 集中过期 TTL 加抖动

第四项在维护脚本里很常见:一次性回填数据时忘了设 TTL,那些键就永久驻留,此后改库也不影响它们(如果该路径没有主动失效)。

7. 边界:什么情况用哪种

场景 建议
数据几乎不变(字典) 长 TTL + 手动刷新
可接受短暂不一致(详情页) TTL + 抖动
读极多写极少且延迟敏感 逻辑过期
强一致要求(余额、库存) 不用缓存,或写时失效 + 版本校验
多级缓存 本地短 TTL + 分布式 TTL

还有一条与实现顺序有关的建议:先选一致性强度,再选机制。倒过来(先选机制再补一致性)通常会得到"看起来很先进但语义不清"的方案——比如在强一致要求的地方用逻辑过期,然后用一堆补丁去掩盖不一致窗口。

判断口径一句话:数据变更后,多久内必须生效。答"几分钟"用 TTL;答"立即"要主动失效;答"不严格但要快"用逻辑过期。


8. 自测

  1. 三种机制的差别在"谁触发失效"这一点上,具体表现为什么?
  2. TTL 的两个陷阱分别是什么?加抖动解决的是哪一个?
  3. 逻辑过期为什么能保证读延迟稳定?它引入了什么新的风险,靠什么手段可见?
  4. 为什么"事务提交前删缓存"会造成脏读?正确顺序是什么?
  5. 多级缓存中,本地缓存为什么无法被直接删除?三种组合各有什么代价?
  6. 判断"该用哪种过期机制"的那个问题是什么?
S12-A14 · 过期策略的三种做法:TTL、逻辑过期与主动失效
https://www.clxhxhhr.top/posts/6427/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。