一个缓存用了统一 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. 自测
- 三种机制的差别在"谁触发失效"这一点上,具体表现为什么?
- TTL 的两个陷阱分别是什么?加抖动解决的是哪一个?
- 逻辑过期为什么能保证读延迟稳定?它引入了什么新的风险,靠什么手段可见?
- 为什么"事务提交前删缓存"会造成脏读?正确顺序是什么?
- 多级缓存中,本地缓存为什么无法被直接删除?三种组合各有什么代价?
- 判断"该用哪种过期机制"的那个问题是什么?