不能直接用 MongoDB 做实时点赞计数,因其在万级写入下易引发文档锁竞争、写放大、主从延迟,导致计数不准、响应毛刺及 CPU 过载;需改用桶模式+近似值、独立集合存储关系、异步幂等落盘等方案。

为什么不能直接用 MongoDB 做实时点赞计数
因为 updateOne({contentId: 123}, {$inc: {likeCount: 1}}) 在每秒万级写入时,会迅速触发文档锁竞争、WiredTiger 写放大和主从延迟,导致计数不准、响应毛刺甚至 primary 节点 CPU 拉满。MongoDB 不是 Redis,它不擅长高频原子计数——尤其当这个计数还要支撑排行榜、首页热榜等强实时场景时。
真实踩坑现象包括:likeCount 突然回退、分页查用户点赞列表时漏数据、find({userId: 1, contentId: 1}) 命中率低且慢(缺索引或写入未刷盘)。
- 写入密集场景下,单文档频繁更新会引发
Document too large to fit in memory或WriteConflict错误 - 如果把所有点赞关系都存进一个大数组(如
likedBy: [1001,1002,...]),很快会触达 16MB 文档上限 - 不做分片键设计,
contentId热点会导致 chunk 迁移失衡,部分 shard 成为瓶颈
用“桶模式 + 近似值”替代精确计数
对“内容被点赞总数”这类指标,业务上其实不需要毫秒级精确——用户看到“10.2w”和“102347”没有感知差异,但后者会让数据库多扛 90% 的写压力。所以采用近似值策略:内存中累加,达到阈值(如 +1000)再批量落库。
具体实现是用一个轻量状态管理器(比如 Node.js 的 Map 或 Redis 的 HINCRBY),按 contentId 分桶缓存增量;同时在 MongoDB 中只存“快照值”,每小时/每 5000 次更新同步一次。
- 阈值不是固定值,应按内容热度动态调整:冷门内容设为 100,头部内容设为 5000
- 必须加定时补偿任务,防止服务重启后丢失未落库增量(例如用
setInterval每 30s 扫描内存中超过 60s 未刷新的 key) - 避免在应用层做
if (count % 1000 === 0) updateDB()—— 这种逻辑在集群部署下会重复提交
点赞关系存储:用独立集合 + 复合索引,别嵌套
用户 A 是否点过内容 B,这是个典型的“多对多查询”,必须拆成独立集合 user_content_like,字段至少包含 userId、contentId、createdAt、status(1=有效,0=取消)。嵌入式设计(如把 likedContentIds: [] 放进 user 文档)在高并发取消/重点赞时极易产生写冲突和数组越界错误。
关键索引必须建全:db.user_content_like.createIndex({userId: 1, status: 1, createdAt: -1}) 支撑“查某用户最近点赞了啥”;db.user_content_like.createIndex({contentId: 1, status: 1}) 支撑“查内容被谁点过”。漏掉 status 会导致已取消的点赞仍参与分页统计。
- 不要用
_id当复合查询条件——它默认是 ObjectId,排序和范围查询效率远低于整型userId/contentId - 如果业务要求“7 天内可取消”,可在 TTL 索引上加
expireAfterSeconds: 604800,但需配合应用层软删除,否则无法追溯历史行为 - 分片键建议选
{userId: "hashed"},而非contentId——因为用户行为天然分散,而内容 ID 是典型热点
异步落盘怎么写才不丢数据
核心原则:写缓存(Redis)和发消息(Kafka/RabbitMQ)必须在同一个事务性上下文中完成,不能先写 Redis 成功、再发 MQ 失败。推荐用“本地消息表”或“事务性发件箱”模式——即点赞成功后,把落库动作作为一条记录插入 outbox_events 集合,并由独立消费者轮询执行。
示例流程:insertOne({type: "LIKE", userId: 1001, contentId: 2002, ts: new Date()}) → 消费者读到后执行 updateOne({contentId: 2002}, {$inc: {likeCount: 1}}) → 成功则标记事件为 done。
- 消费者必须幂等:同一条
outbox_events._id重复处理不能导致计数翻倍(可用upsert+$setOnInsert控制) - 千万别用
setTimeout延迟写库——进程崩溃就彻底丢失 - 监控重点不是“有没有发消息”,而是
outbox_events中status: "pending"的积压量,超 100 条就要告警
真正难的不是怎么写代码,而是判断哪些数据可以近似、哪些必须精确,以及在缓存失效、消息堆积、分片迁移时,如何让业务方感知不到底层波动。这些边界条件,往往比模型本身更消耗工程精力。









