> 写路径上的"刷新缓存"如果要读库,它在高频写下是放大器而不是缓存
点赞这类高频小写操作,最容易写成这样:
结论先行¶
点赞这类高频小写操作,最容易写成这样:
await likeRepo.add(postId, userId);
await cacheService.cachePost(postId, await this.getPost(postId)); // ← 问题在这
意图是"改完之后把缓存刷新一下",看起来很负责。实际发生的是:
getPost()本身就是缓存未命中才读库的那个函数—— 但刚写完,缓存里的值已经被认为是旧的,于是它必然去读库;- 每一次点赞都变成一次完整的文档读 + 一次完整的文档写回缓存;
- 写得越频繁,读库越多。 缓存在它最该省事的那个负载下, 贡献的是额外的数据库流量。
正确形状是增量更新,不是重新加载:
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 的伪装形态。
这三条共同的形状:在"每条数据"的粒度上做了本该在"每批"粒度上做的事。
怎么用¶
写路径上碰缓存时,按顺序问:
- 我是在失效,还是在重新填充? 默认只做失效(
DEL)。 重新填充只在"确定马上会被读、且读者等不起一次未命中"时才做。 - 我改了几个字段,写回了几个字段? 不一致就说明该用增量操作
(
HINCRBY/ZINCRBY/SETRANGE),不是整对象SET。 - 这个刷新动作会读数据库吗? 会的话,它和写频率同阶增长。 把它画进容量模型,而不是当成"顺手刷一下"。
- 批量接口里有没有
for ... await? 有就并行化,并用allSettled让单个失败不污染整批。 - 一个能直接加的监控:把数据库读 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 收敛的地方,不是报在还能跑通的地方。