3.3 评论与点赞:原子操作与计数器

列表接口稳定之后,产品要求上线互动功能。点赞和评论看起来是最基础的两个接口,但它们引出了整个项目里最难的一个问题:计数器不一致。用户点了个赞,数字从 10 变成 11,刷新之后又变回 10——这种错误用户一眼就能看见,而且会彻底摧毁对数据的信任。这一节记录我们怎么从最朴素的实现一路改到原子计数器方案。

场景:计数器为什么难

先看点赞功能的数据。文章表里有个 likeCount 字段,用户操作明细另存一张 likes 表。点赞要做两件事:写一条明细记录、把文章的计数加一。

第一版实现朴素到没有任何防护:

// 第一版点赞,已废弃
const article = await db.collection('articles').doc(id).get()
const count = article.data.likeCount
await db.collection('articles').doc(id).update({
  likeCount: count + 1
})
await db.collection('likes').add({ articleId: id, userId })

这套代码在测试环境完美无缺,一上生产就出事。某天有篇热门文章被疯狂点赞,监控里它的 likeCount 和明细表里的记录数对不上,差了两百多。原因很经典:读出 10、加一、写回 11 这几步不是原子的,两个并发请求同时读到 10,就都写回 11,丢了一次更新。

并发量小的时候这个 bug 能藏很久,因为绝大多数时候它是对的。问题严重性在于它不可自愈——丢掉的计数永远不会自己长回来,只能靠对账脚本补。

用原子指令替代读改写

云数据库提供了原子操作指令,点赞加一的正确写法是一行:

// 原子递增,正确姿势
const cmd = db.command
await db.collection('articles').doc(id).update({
  likeCount: cmd.inc(1)
})

inc 在数据库层面是原子的,无论多少并发同时执行,每次都一定会真实加一。这就是整个问题的正解:永远不要在应用层读出计数再写回。

取消点赞同理,inc(-1) 即可。原子指令不止 inc 一个,常用的还有几个:

指令 作用 典型场景
inc(n) 原子增减数字 点赞数、阅读数、评论数
push(item) 数组尾部追加 评论列表、最近访问
pop() 数组尾部弹出 队列式处理
addToSet(item) 数组去重追加 标签、已读集合
pull(item) 数组删除元素 取消收藏

push 和 addToSet 的区别值得注意:评论允许重复内容用 push,去重集合必须用 addToSet。有次我在管理标签时用了 push,同一篇文章被写入了三个一模一样的标签,前端渲染出三重复,丑得直接被设计师发群里。

幂等性:重复点击一次只算一次

原子性解决计数准确性之后,还有一类问题:用户手抖连点三下,前端没做防抖,三次请求都到了后端。明细表记了三条、计数加了三次。要解决它得让点赞接口幂等。

方案是用明细记录做唯一约束。点赞前先查一次:

// 幂等点赞
async function likeArticle(id, userId) {
  const cmd = db.command
  const exist = await db.collection('likes').where({
    articleId: id, userId
  }).count()

  if (exist.total > 0) {
    return { code: 0, msg: '已点赞过,幂等返回' }
  }

  await db.collection('likes').add({ articleId: id, userId })
  await db.collection('articles').doc(id).update({
    likeCount: cmd.inc(1)
  })
  return { code: 0, msg: '点赞成功' }
}

这个方案仍有并发漏洞:两个请求同时查 count,都查到 0,就都会写入明细。完全杜绝需要在明细表上建唯一索引(articleId 加 userId 的组合索引),插入重复记录时捕获错误返回幂等。我们项目里退而求其次用了唯一索引,数据库会拒绝第二条插入,云函数里捕获到错误就当幂等返回:

// 唯一索引兜底
try {
  await db.collection('likes').add({ articleId: id, userId })
  await db.collection('articles').doc(id).update({
    likeCount: cmd.inc(1)
  })
} catch (e) {
  // 唯一索引冲突,说明已存在,幂等返回
  return { code: 0, msg: '重复点赞已忽略' }
}

注意这个方案里计数加一仍然有重复风险:如果插入成功后加一失败(比如函数超时),明细在但计数没加。对账脚本兜底是最后一道防线,每天凌晨跑一次,明细数和计数对不上的文章自动修正。定时跑对账的具体做法在下一节定时触发器里展开,它同时承担每日摘要的任务。

两种幂等方案的对比如下:

方案 一致性强度 实现成本 适用场景
先查后写 弱,仍有竞态 低 低频操作
唯一索引兜底 强,数据库层面保证 中,需建索引 点赞收藏关注
前端防抖加按钮锁 减少并发来源 极低 所有写操作必备
对账脚本修正 最终一致 中 任何计数器场景

最终项目是三层叠加:前端按钮锁减少并发、唯一索引保证不重、对账脚本兜底差异。这也是腾讯云实践里好几个案例共同推荐的组合拳。

评论的树形结构

评论的计数难题之外,还有展示难题。产品要支持二级回复,评论天然是树形的。第一版的评论列表是平铺的,所有评论(包括对评论的回复)混在一个列表里,前端拿到后自己拼装父子关系。数据小的时候没关系,评论上千条之后前端拼装开始卡顿,而且分页变得非常别扭——一页二十条里可能一半是某条评论的回复。

最终的方案是拆接口:一级评论走游标分页接口,子评论懒加载。一级评论文档里冗余 replyCount 字段做计数展示:

// 评论表文档结构
{
  _id: 'auto',
  articleId: 'article-001',
  userId: 'user-42',
  content: '写得很有帮助',
  parentId: null,       // 一级评论为 null
  replyCount: 3,        // 冗余的子评论计数
  createTime: 1698200000000
}

子评论用 push 追加到另一个集合,并原子更新 replyCount:

// 回复评论
await db.collection('comments').add({
  articleId, userId, content, parentId, createTime
})
await db.collection('comments').doc(parentId).update({
  replyCount: cmd.inc(1)
})

评论数的展示同样是计数器,也吃同一套幂等加对账组合拳。

踩坑:超时与读放大

计数器场景有一个比较隐蔽的读放大问题。最初文章详情页一次查所有:文章正文、作者信息、点赞数、评论数、当前用户是否已赞。这些数据分布在三张表里,云函数串行查了四次,详情接口耗时 600ms 起步。

解决办法是改并行。云函数里多个无依赖查询用 Promise.all 同时发:

// 并行查询拼装详情
const [article, liked, comments] = await Promise.all([
  db.collection('articles').doc(id).get(),
  db.collection('likes').where({ articleId: id, userId }).count(),
  db.collection('comments').where({ articleId: id, parentId: null })
    .field({ content: true, userId: true, createTime: true })
    .orderBy('createTime', 'desc').limit(20).get()
])

改完详情接口降到 200ms 以内。这个优化对所有后端通用,但 Serverless 里格外值——函数按执行时长计费,缩短耗时直接等于省钱,具体账在监控与成本那一节算。

第二个坑是云函数超时。某次一篇被转发的文章涌入大量点赞,明细写入非常密集,接口偶发超时报错,前端显示点赞失败但其实后端已经执行到加一那步。用户重试一次就重复点赞。超时错误其实是幂等性问题最可怕的来源:调用方不知道失败点在哪里。这也是为什么幂等设计在这个场景里是必需品而不是加分项。

小结

计数器问题的完整解法是三层:数据库原子指令保证单次更新准确、唯一索引保证操作幂等、定时对账兜底最终一致。任何一层单独用都不够,三层叠加才真正稳。核心思想只有一句:永远不要在应用层先读后写计数器,把并发问题交给数据库。

下一节给这套系统装上时间维度——用定时触发器每天凌晨跑对账脚本,顺带生成每日摘要推送给管理员,让计数器不一致的问题在你起床之前就自动修好,这也是内容发布流程里最后一块自动化拼图。

延伸阅读