KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 缓存设计:穿透、击穿、雪崩与一致性 — keel 龙骨
这一章回答:缓存该加在哪些层、三个经典问题怎么解、以及缓存和数据库不一致时怎么处理。
这一章回答:缓存该加在哪些层、三个经典问题怎么解、以及缓存和数据库不一致时怎么处理。
缓存是性能手段,同时也是复杂性来源:加了缓存之后,你必须回答"数据什么时候过期"和"两边不一致了怎么办"。 如果一个热点数据可以靠加索引解决,优先加索引而不是加缓存。
一、分层:先问是不是本地缓存就够
| 层 | 延迟量级 | 特点 | 适用 |
|---|---|---|---|
| 进程内缓存(LRU/functools) | 纳秒~微秒 | 无网络开销,但各实例不一致、容量受内存限制 | 配置字典、权限映射、极少变更的热点数据 |
| Redis | 亚毫秒 | 跨实例共享,容量大 | 绝大多数业务缓存 |
| 数据库查询缓存/物化视图 | 毫秒+ | 一致性最好 | 报表类 |
本地缓存的使用要点:容量小 + 短 TTL + 变更时可主动失效。它的最大风险不是容量,而是"某个实例的数据是旧的"。
二、三个经典问题
| 问题 | 成因 | 解法 |
|---|---|---|
| 缓存穿透 | 查询根本不存在的数据,每次都打到 DB | 布隆过滤器拦截 / 为空结果也缓存短 TTL(如 30~60 秒) |
| 缓存击穿 | 单个热点 key 过期瞬间,大量请求同时打到 DB | 互斥锁重建(Redis SET NX)+ 重试等待;或逻辑过期(永不过期 + 后台异步刷新) |
| 缓存雪崩 | 大量 key 在同一时刻集体过期 | TTL 加随机抖动;热点数据永不过期 + 主动刷新;限流与降级兜底 |
热点 key 互斥重建的最小实现:
lock_key = f"rebuild:{key}"
if await redis.set(lock_key, "1", nx=True, ex=10): # 抢到锁的去重建
try:
data = await db_query(key)
await redis.set(key, serialize(data), ex=ttl)
finally:
await redis.delete(lock_key)
else: # 没抢到的稍后重试或返回旧值
await asyncio.sleep(0.05)
return await redis.get(key)
注意一定要给锁加过期时间,防止重建线程崩溃后永久锁死。
三、缓存与数据库的一致性
写入策略的选择:
| 策略 | 顺序 | 风险 |
|---|---|---|
| ❌ 先删缓存再更新 DB | 删缓存 → 改库 | 删完后、改库前有读请求会把旧值回填进缓存 |
| ❌ 先更新缓存再更新 DB | 改缓存 → 改库 | 改库失败则缓存里是错的 |
| ✅ 先更新 DB,再删缓存(Cache Aside) | 改库 → 删缓存 | 仅在极端时序下短暂不一致,且可通过延迟双删进一步收敛 |
推荐做法:更新 DB → 删除缓存 → 延迟一小段时间(如读取一次主从延迟 + 数百毫秒)再删一次(延迟双删),让可能的脏回填被第二次删除清理掉。
三种替代/增强手段:
- TTL 兜底:任何缓存都设过期时间。这样即使删除失败,不一致也有上限窗口;
- 订阅 binlog 失效:用 CDC 监听数据库变更来删缓存,把"删缓存"从应用逻辑里解耦出去(可靠性更高,复杂度也更高);
- 接受短暂不一致:多数业务不需要强一致缓存。明确说明"最多延迟 N 秒"往往比复杂的强一致方案更合理。
四、缓存的三类不该用
- 写多读少:命中率低,还多一次写开销;
- 要求强一致的数据(账户余额):缓存引入了失效窗口;
- 本身就是索引能解决的慢查询:先去补索引。
五、必须观测的指标
| 指标 | 健康线 | 说明 |
|---|---|---|
| 命中率 | ≥ 95%(热点场景) | 低说明 key 设计或 TTL 不合理 |
| 键空间与内存 | 稳定 | 突然增长可能是没有 TTL 的 key 泄漏 |
| 大 key / 热 key | 单一 key 不过大不独热 | 热 key 需要拆分或本地缓存 |
| 逐出速率 | 接近 0 | 频繁逐出说明内存不足或策略不当 |
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一套缓存读写封装 | Cache Aside 实现 + 延迟双删 + 命中率埋点 |
| 三个问题各一次的防御验证 | 穿透攻击下 DB QPS 不飙升;击穿时互斥锁生效;雪崩时 TTL 抖动生效 |
| 一致性验证 | 高频读写交替下,最终数据收敛到库中的最新值(可用延迟双删前后的不一致次数对比) |
| Redis 宕机演练 | 降级到本地缓存 + DB,接口仍可用(延迟上升但不报错) |
完成标志:Redis 完全不可用时,你的服务是"变慢"而不是"挂掉"。
故障注入
| 注入方式 | 观察 |
|---|---|
| 用大量不存在的 key 查询 | DB QPS 是否被放大(无布隆/空值缓存时) |
| 手动删除一个热点 key | 是否有并发请求全部打到 DB(击穿) |
| 让一批 key 同时到 TTL | DB 是否出现尖峰(雪崩) |
| 更新时先删缓存再改库 | 是否出现旧值被回填并长期留存 |
| 忘记给重建锁加过期时间 | 重建失败后该 key 是否永久不可用 |
| 缓存不加 TTL | 内存增长与 key 泄漏情况 |
自测题
- 为什么推荐"先更新数据库再删缓存",而不是反过来?
- 延迟双删解决的是哪个时序问题?延迟时间怎么估?
- 缓存穿透与缓存击穿的区别是什么?解法为什么不同?
- 什么数据你不应该放进缓存?举三个例子并说明原因。
- Redis 挂掉时你的降级路径是什么?有验证过吗?