● 缓存实战
把热路径 p99 从 800ms 降到 190ms
同一条读路径,不改业务逻辑,只动数据访问层。下面每一节都给一个能直接带走的东西——判据、代码、对照表或清单。
190ms
p99 延迟(原 800ms)
94%
缓存命中率
4×
数据库负载下降
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 提前消化掉。
决策对照:什么场景用什么
| 场景 | 策略 | 失效方式 |
|---|---|---|
| 读多写少、可短暂陈旧 | cache-aside + TTL | 写时 del,TTL 兜底 |
| 强一致、写后必须立即可见 | 写穿透 write-through | 写时同步更新缓存 |
| 热点集中、QPS 极高 | 本地缓存 + 远端两级 | 本地短 TTL,远端长 TTL |
| 偶发突发、防击穿 | 单飞 single-flight | 同键并发只查一次库 |
上线清单
合并前对着过一遍:
- 读路径走 cache-aside;写路径只失效不回填。
- 每个键都有 TTL 兜底,不存在永不过期的键。
- 启动钩子预热 Top-N 热点键。
- 未命中查库加单飞,防止并发击穿。
- 缓存挂了能降级直查库,不是整条路径报错。
- 命中率、p99、DB 负载三个指标都接了监控。