直接用 $inc 更新点赞数在高并发下会卡住,因 MongoDB 文档级写锁导致热点文档更新串行化,引发 WriteConflict、操作等待和延迟毛刺,本质是单点写入瓶颈。

为什么直接用 $inc 更新点赞数在高并发下会卡住?
因为 MongoDB 的写锁(尤其在 WiredTiger 引擎下是文档级,但热点文档仍会排队)会让所有对同一文档的 $inc 请求串行执行。比如一条热门帖子的 likes: 123456 被每秒上千次更新,实际吞吐受限于单文档写入延迟,还可能触发大量写冲突重试。
常见错误现象:WriteConflict 错误增多、db.currentOp() 显示大量等待 write 状态的 operation、监控里 opLatency 写入毛刺明显升高。
这不是靠加索引或调优能解决的根本问题——本质是「单点写入瓶颈」。
按比例更新(采样更新)怎么落地?
核心思路:不每次点赞都写库,而是以概率决定是否落库,再用系数反推真实值。适合对实时精度要求不高、但要扛住峰值流量的场景(比如微博热搜页的点赞数展示)。
实操建议:
- 在应用层生成随机数,例如 Python 中
random.random() 表示 1% 概率写入;对应地,最终展示时把读到的 <code>likes乘以 100 - 必须把采样率固化进业务逻辑,不能动态改——否则历史数据无法对齐;建议存为配置项如
LIKE_SAMPLING_RATE = 0.01 - 注意时序错乱:用户刚点完赞,立刻刷新看到数字没变,需配合前端本地缓存 + 后端异步补偿(比如用 Redis 记一次临时计数,定时合并)
- 避免在事务中使用该策略——采样逻辑必须在事务外判断,否则会扩大事务范围
分片计数器模式怎么设计才不翻车?
把一个逻辑计数器拆成 N 个物理子计数器(比如按 user_id % 100 分片),每次点赞随机选一个子计数器更新,读取时汇总。这是真正能线性扩展的方案。
关键细节:
- 子计数器文档结构建议为:
{ _id: "post_123_shard_42", count: 123 },用复合_id确保唯一性和查询效率 - 分片数别盲目设大——100 是较稳妥起点;超过 1000 会导致聚合查询(
$sum)成为新瓶颈,尤其当分片数 > 单次find()返回上限时需分批拉取 - 务必给子计数器加 TTL 索引(如果允许误差):比如
db.counters.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 }),防止冷数据堆积 - 慎用
$inc+upsert组合——高频下可能因竞争导致重复插入,推荐先findOneAndUpdate带upsert: true,再捕获WriteResult.upsertedCount判断是否新建
Redis + MongoDB 混合方案的实际取舍点
多数生产环境不会只用纯 MongoDB 方案。更常见的做法是:Redis 做实时计数缓冲,MongoDB 定期落盘归档。
典型链路:
- 点赞请求先写
INCR post:123:likes,同时用EXPIRE设 10 分钟过期(防内存爆炸) - 后台任务每分钟执行一次
GET post:123:likes,与 MongoDB 当前值做差值,用$inc合并更新(此时已是低频批量写) - Redis 不做持久化,所以必须容忍断电后丢失最近几分钟计数——这恰恰是业务可接受的
- 不要用 Redis 的
SETNX做去重:点赞本身允许重复(用户可反复点),去重要靠业务层幂等键(如user_id + post_id + timestamp_floor)
最容易被忽略的是时钟漂移:如果 Redis 和 MongoDB 所在机器时间不同步,会导致定时任务漏合并或重复合并。上线前务必校验 ntpq -p 或用 chrony 对齐。


















