> 写路径上的"刷新缓存"如果要读库,它在高频写下是放大器而不是缓存

Systems & Infrastructure Intermediate 2026-10-09 00:25 2026-10-09
#caching #redis #performance #write-path #invalidation #mongodb

点赞这类高频小写操作,最容易写成这样:

结论先行

点赞这类高频小写操作,最容易写成这样:

await likeRepo.add(postId, userId);
await cacheService.cachePost(postId, await this.getPost(postId));  // ← 问题在这

意图是"改完之后把缓存刷新一下",看起来很负责。实际发生的是:

  1. getPost() 本身就是缓存未命中才读库的那个函数—— 但刚写完,缓存里的值已经被认为是旧的,于是它必然去读库;
  2. 每一次点赞都变成一次完整的文档读 + 一次完整的文档写回缓存;
  3. 写得越频繁,读库越多。 缓存在它最该省事的那个负载下, 贡献的是额外的数据库流量。

正确形状是增量更新,不是重新加载:

await redis.hincrby(`post:${postId}`, 'likeCount', 1);

一次 O(1) 的原子自增,不碰数据库。

为什么

这里有两个独立的错误叠在一起,拆开看才好避免。

第一,把"失效"和"重新填充"合并了。 失效很便宜(删一个 key), 重新填充很贵(读库 + 序列化 + 写回)。合并之后, 每个写操作都替一个还不知道存不存在的未来读者付了钱。 如果这篇帖子接下来没人读,这笔开销纯属浪费; 如果它很热,下一次读请求自己会填充,而且填充的是更新后的值。

第二,改一个计数器却重写整个对象。 点赞只动 likeCount 一个字段, 而 cachePost 写回的是整篇帖子。在 Redis 里用 hash 存, HINCRBY 就只碰那一个字段,连读都不用读。

两个错误合起来的结果是经典的缓存击穿放大:热点帖子每秒几百次点赞, 每一次都去数据库拉一遍完整文档。监控上看到的是"数据库读 QPS 和点赞 QPS 线性相关" ——而这恰恰是缓存应该消除的那种相关性。

同一份审计里的近亲

  • 串行的缓存查找。 getPostsByIds 用 for 循环逐个 await, N 个 ID 就是 N 次串行往返。Promise.allSettled 并行化是一行的事, 而且 allSettled 比 all 更合适——一个缓存未命中不应该让整批失败。
  • N+1 的另一种写法。 先查 ID 列表、再用 $in 查对象, 看起来只有两次查询,但它随 limit 增长,是 N+1 的伪装形态。

这三条共同的形状:在"每条数据"的粒度上做了本该在"每批"粒度上做的事。

怎么用

写路径上碰缓存时,按顺序问:

  1. 我是在失效,还是在重新填充? 默认只做失效(DEL)。 重新填充只在"确定马上会被读、且读者等不起一次未命中"时才做。
  2. 我改了几个字段,写回了几个字段? 不一致就说明该用增量操作 (HINCRBY / ZINCRBY / SETRANGE),不是整对象 SET。
  3. 这个刷新动作会读数据库吗? 会的话,它和写频率同阶增长。 把它画进容量模型,而不是当成"顺手刷一下"。
  4. 批量接口里有没有 for ... await? 有就并行化,并用 allSettled 让单个失败不污染整批。
  5. 一个能直接加的监控:把数据库读 QPS 和写 QPS 画在同一张图上。 两条线平行上涨,就说明某条写路径在读库。

出处

~/Engineering/Timeline-Feed 的分析报告,4.1–4.3(N+1、串行加载、 过度激进的缓存失效)。这个项目是 fan-out-on-write 的 timeline 服务, Node.js + MongoDB + Redis + Kafka,约 2,900 行 TypeScript。 相关:预留不加租约,配额会单调归零——而且每一步决策都是对的,没有任何错误可查、 吞吐上限要报在 spread 收敛的地方,不是报在还能跑通的地方。