缓存实战 p99 800ms → 190ms · 可落地
● 缓存实战

把热路径 p99 从 800ms 降到 190ms

同一条读路径,不改业务逻辑,只动数据访问层。下面每一节都给一个能直接带走的东西——判据、代码、对照表或清单。

190ms
p99 延迟(原 800ms)
94%
缓存命中率
数据库负载下降
0
业务逻辑改动

先量化,再决定要不要缓存

缓存不是默认动作。先确认这条路径同时满足下面几条,否则收益小、风险大(脏数据)。把它当成一个准入判据:

  • 读远多于写(读写比 ≥ 10:1),且同一份数据会被反复读到。
  • 数据可容忍短暂陈旧(秒级到分钟级),或你有明确的失效策略。
  • 该路径在火焰图 / 慢查询里确实是瓶颈,不是凭感觉。
  • 单次计算或查询成本明显高于一次缓存读取。
带走:四条都满足才加缓存;缺一条先别加。

加缓存:cache-aside(旁路缓存)

读时先查缓存,命中即返回;未命中再查库、回填、返回。写时让缓存失效,下次读自然回填。这是最不容易出错的模式。

cache.ts · 读路径
// cache-aside:命中直接返回,未命中回填
async function read(id) {
  const hit = await cache.get(id)
  if (hit) return hit            // ← 命中即返回,避开 DB
  const row = await db.query(id)
  await cache.set(id, row, { ttl: 300 })
  return row
}
带走:写操作只做 cache.del(id),不要在写时回填——并发下回填会写入旧值。

前后对比

同一条路径,加缓存并预热之后的实测变化:

改造前

  • p99 800ms
  • 每次请求都打数据库
  • 发布后冷缓存尖刺
  • DB CPU 长期 70%+

改造后

  • p99 190ms
  • 命中率 94%,仅未命中查库
  • 启动预热,无尖刺
  • DB CPU 降到 ~18%

冷启动尖刺:最容易被忽略的一段

每次发布都会清空进程内缓存,重启后的第一波流量全部落到数据库,p99 会瞬间弹回去。解决办法是在接流量前先把热点键预热。

现象:发布后 1–2 分钟内 p99 飙升,随后回落。根因是缓存空了,不是代码慢。
深入:为什么不靠"自然回填"扛过去
自然回填要等真实流量逐个 miss 才填,这段时间数据库承受的是满负荷且重复的查询;高峰发布时往往直接把库打挂。预热是在启动钩子里主动拉一批已知热点键,把第一波 miss 提前消化掉。
带走:在启动钩子里预热 Top-N 热点键,再开始接收流量。

决策对照:什么场景用什么

场景策略失效方式
读多写少、可短暂陈旧cache-aside + TTL写时 del,TTL 兜底
强一致、写后必须立即可见写穿透 write-through写时同步更新缓存
热点集中、QPS 极高本地缓存 + 远端两级本地短 TTL,远端长 TTL
偶发突发、防击穿单飞 single-flight同键并发只查一次库
带走:默认从 cache-aside + TTL 起步,遇到具体问题再升级,不要一上来就两级缓存。

上线清单

合并前对着过一遍:

  • 读路径走 cache-aside;写路径只失效不回填。
  • 每个键都有 TTL 兜底,不存在永不过期的键。
  • 启动钩子预热 Top-N 热点键。
  • 未命中查库加单飞,防止并发击穿。
  • 缓存挂了能降级直查库,不是整条路径报错。
  • 命中率、p99、DB 负载三个指标都接了监控。