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 → 删除缓存 → 延迟一小段时间(如读取一次主从延迟 + 数百毫秒)再删一次(延迟双删),让可能的脏回填被第二次删除清理掉。

三种替代/增强手段:

四、缓存的三类不该用

  1. 写多读少:命中率低,还多一次写开销;
  2. 要求强一致的数据(账户余额):缓存引入了失效窗口;
  3. 本身就是索引能解决的慢查询:先去补索引。

五、必须观测的指标

指标 健康线 说明
命中率 ≥ 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 泄漏情况

自测题

  1. 为什么推荐"先更新数据库再删缓存",而不是反过来?
  2. 延迟双删解决的是哪个时序问题?延迟时间怎么估?
  3. 缓存穿透与缓存击穿的区别是什么?解法为什么不同?
  4. 什么数据你不应该放进缓存?举三个例子并说明原因。
  5. Redis 挂掉时你的降级路径是什么?有验证过吗?

进入 keel 阅读