成为全栈·Node 后端篇·点赞系统:幂等点赞与计数原子增减

成为全栈·Node 后端篇·点赞系统:幂等点赞与计数原子增减

"点个赞"看着就是个数字 +1。可真要做对,至少有三个问题要回答:同一个人能不能重复点?网络抖动手滑点了两次算几次?并发一来,计数会不会漂?

这一篇对照真实的 src/services/likes.ts,讲清点赞怎么做到幂等 + 原子计数 :为什么用 ON CONFLICT DO NOTHING 挡重复、取消时为什么要把计数下限夹在 0、以及"点赞数"为什么宁可存冗余字段,也不每次 COUNT 一遍。

一、点赞不是简单的 +1 字段

如果只在 articles 表加个 like_count,然后"谁点赞就 UPDATE + 1",会立刻出问题:

  • 重复点赞:同一个用户点 N 次,计数 +N,显然不对------点赞应该是"赞过 / 没赞过"的二元态,不是累加器。
  • 并发竞态 :两个请求同时 SELECT like_countUPDATE + 1,后到的会覆盖先到的,计数少算。
  • 取消变负:取消点赞时若没查清楚"到底赞过没",可能把计数减成负数。

所以正确建模是两张表协作 :一张 likes 关联表记录"谁赞了哪篇"(天然去重 + 可查态),一张 articles.likeCount 冗余字段存"当前总赞数"(读快)。下面看真实代码怎么串起来。

二、数据模型:关联表 + 冗余计数

schema.tslikes 表有 uniq_like 唯一索引 (userId, articleId)------这正是一道硬约束:同一个用户对同一篇文章,关联表里最多一行。它既是"防重复点赞"的物理保障,也是后面幂等逻辑的依赖。

articles.likeCountinteger default 0)是去规范化的冗余计数 :理论上 COUNT(likes WHERE articleId=?) 随时能算出来,但我们把它冗余存着,换"读时零计算"的速度。这个取舍就是 P-49 的核心,第五节细讲。

三、P-44:幂等点赞,并发双发亦仅 +1

likeArticle 是点赞的核心,真实实现非常讲究:

ts 复制代码
// src/services/likes.ts --- likeArticle
export const likeArticle = async (
  userId: number,
  articleId: number,
): Promise<{ liked: true; likeCount: number }> => {
  await requireLikeArticle(articleId);
  const db = getDb();
  // DB 层幂等:唯一约束 ON CONFLICT DO NOTHING,并发双发不会撞约束 500。
  const res = await db
    .insert(likes)
    .values({ userId, articleId, createdAt: new Date() })
    .onConflictDoNothing()
    .run();
  // 仅当本次确实新增一行时才原子 +1,避免应用层读改写竞态导致的计数漂移。
  if (res.changes > 0) {
    await db
      .update(articles)
      .set({ likeCount: sql`like_count + 1` })
      .where(eq(articles.id, articleId))
      .run();
  }
  const fresh = (
    await db
      .select({ likeCount: articles.likeCount })
      .from(articles)
      .where(eq(articles.id, articleId))
      .limit(1)
      .all()
  )[0];
  if (!fresh) throw new AppError(ErrCode.INTERNAL, 500);
  return { liked: true, likeCount: fresh.likeCount };
};

精妙在三点:

  1. ON CONFLICT DO NOTHING :靠 uniq_like 唯一索引,重复点赞时插入被静默忽略,不会抛唯一约束错误(不会 500)。
  2. res.changes > 0 才 +1 :数据库告诉你"这次到底插进去了没有"。插进去了(changes=1)才给 like_count + 1;没插进去(重复点赞,changes=0)就不动计数。这就彻底消除了"应用层先 SELECT 再 UPDATE"的竞态------计数的增减,完全由"数据库这次有没有真正新增行"这一个事实驱动。
  3. 原子 like_count + 1+1 在 SQL 表达式里完成(sql\like_count + 1``),不依赖先读出旧值,并发下不会丢更新。

所以"网络抖动点了两次"或"前端重复发请求",结果都是赞数只 +1,且绝不报错------这就是幂等的教科书实现。

四、P-44:幂等取消,下限夹 0 防负数

unlikeArticle(取消点赞)对称但更小心:

ts 复制代码
// src/services/likes.ts --- unlikeArticle
export const unlikeArticle = async (
  userId: number,
  articleId: number,
): Promise<{ liked: false; likeCount: number }> => {
  await requireLikeArticle(articleId);
  const db = getDb();
  const existing = (
    await db
      .select({ id: likes.id })
      .from(likes)
      .where(and(eq(likes.userId, userId), eq(likes.articleId, articleId)))
      .limit(1)
      .all()
  )[0];
  if (existing) {
    await db.delete(likes).where(eq(likes.id, existing.id)).run();
    // 原子下限夹 0:-1 永不产生负数,且并发下读数陈旧也不漂移。
    await db
      .update(articles)
      .set({ likeCount: sql`CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END` })
      .where(eq(articles.id, articleId))
      .run();
  }
  const fresh = (
    await db
      .select({ likeCount: articles.likeCount })
      .from(articles)
      .where(eq(articles.id, articleId))
      .limit(1)
      .all()
  )[0];
  if (!fresh) throw new AppError(ErrCode.INTERNAL, 500);
  return { liked: false, likeCount: fresh.likeCount };
};

要点:

  • 先确认赞过再取消existing 不存在(用户本来就没赞)就不动计数直接返回 liked:false,幂等------取消一个不存在的点赞,不该把赞数减成负数,也不该报错。
  • CASE WHEN like_count > 0 THEN ... - 1 ELSE 0 END :这是防负数的关键。即使因为某种并发导致读数陈旧,赞数也永远不会低于 0 。数学上赞数本就非负,用 CASE 把这条不变量焊死在 SQL 里,比应用层 if (count > 0) count-- 更可靠(后者仍有竞态窗口)。

五、P-49:为什么存冗余 likeCount,而非每次 COUNT

回到 articles.likeCount 这个冗余字段。它明显违反了"别冗余存储可计算数据"的朴素教条,但我们故意这么干,理由(P-49)很实在:

  • 读多写少 :文章详情页每次都要显示赞数,是高频读;点赞/取消是低频写。如果每次读都 SELECT COUNT(*) FROM likes WHERE articleId=?,热文(几万赞)每次渲染都做一次全表聚合,纯属浪费。
  • 冗余计数把"写时的一次小开销"换成"读时的零开销" :点赞时多维护一个 like_count 字段,换来所有读者瞬时拿到赞数。对内容产品,这个 tradeoff 几乎总是划算的。
  • 何时不冗余 :如果某业务点赞极少被展示、或需要"实时精确跨维度统计",那就别冗余,老老实实聚合。是否冗余,取决于读/写比------这正是 P-49 想传达的"规模与访问模式意识"。

顺带,这和阅读量:时间桶去重与原子计数的阅读量 viewCount 是同一个思路:把"高频展示的聚合值"冗余成一个字段,写时维护,读时直取。两处设计同源,可见"冗余计数"是本项目的通用手艺。

六、点赞态查询与"我的点赞"

除了点赞/取消,还有两个读端点:

  • GET /articles/:id/like/status (公开,optionalAuthMiddleware):返回 { liked, likeCount }。匿名用户 liked=false,但 likeCount 照样给(公开信息);登录用户则查 likes 表里有没有自己的行,决定 liked 真假。前端据此切换"已赞/未赞"高亮,不用维护本地状态。
  • GET /me/likes (需登录):返回"我点赞过的文章"列表,按点赞时间倒序,且只列 published (已软删/未发布的赞过的文章不出现)。注意它返回的是 ArticleSummary[]裸数组 (契约规定 data 为裸数组,不是带 list/total 的分页信封)------这是少数几个刻意不走标准分页信封的端点之一,路由层注释专门标注了这点,提醒别套错结构。

七、薄路由:登录才能赞,状态可匿名看

routes/likes.ts 把鉴权收在路由层,干干净净:

ts 复制代码
likesRoute.post('/articles/:id/like', authMiddleware, ...);        // 必须登录
likesRoute.delete('/articles/:id/like', authMiddleware, ...);      // 必须登录
likesRoute.get('/articles/:id/like/status', optionalAuthMiddleware, ...); // 可匿名
likesRoute.get('/me/likes', authMiddleware, ...);                 // 必须登录

点赞/取消/我的列表都要求登录(authMiddleware)------匿名不能点赞,这是内容互动的常识闸。唯独"点赞态"用 optionalAuthMiddleware:匿名也允许查(只是 liked=false),前端无需为"看一眼赞数"而强制登录。路由层只做"鉴权 + 取参",所有幂等/原子逻辑在 services/likes.ts,完全符合薄路由纪律。

八、和点赞与阅读量防刷的对比

把点赞和阅读量:时间桶去重与原子计数的阅读量放一起看,能看清两种"防作弊"思路的差异:

阅读量 viewCount 点赞 likeCount
防重复手段 24h 时间桶去重(同一身份同天一次) 关联表唯一约束(同一身份永远一次)
语义 热度可多次累计(隔天再计) 二元态(赞过/没赞过)
计数维护 去重记录插成功才 +1 INSERT 成功(changes>0)才 +1 / DELETE 成功才 -1
并发 唯一索引兜底不重复 +1、不 500 ON CONFLICT DO NOTHING 不重复、不 500

两者都用了"数据库唯一约束当锁 + 靠 changes/冲突判断是否真改动 "这个核心套路,只是阅读量允许"时间窗口后重计"、点赞是"终身一次"。底层哲学一致:让数据库告诉你"这次到底改了没",而不是应用层自己猜,计数就永远不会漂。

十、重试与并发下的真实时序

把上面逻辑放进两个具体场景,幂等的价值就一目了然。

场景|用户连点两下赞(或前端重试发两次 POST /like

  • 请求 1:INSERT likes(userId, articleId) → 唯一索引放行,changes=1like_count 由 0 变 1 → 返回 { liked:true, likeCount:1 }
  • 请求 2:INSERT 同一行 → ON CONFLICT DO NOTHING 静默忽略,changes=0不 +1 → 返回 { liked:true, likeCount:1 }

两次都成功,但赞数只 +1。对照"如果没做幂等"(先查有没有、没有再插、再 +1):两次请求可能都查到"还没赞",都插入、都 +1,赞数变成 2------这就是典型的重复计数 bug。幂等把"用户意图"和"数据库事实"对齐:你点了几次不重要,数据库里"有没有这行"才是唯一真相。

场景|取消再取消

  • 第一次取消:existing 存在 → 删行 + CASElike_count 1→0 → { liked:false, likeCount:0 }
  • 第二次取消:existing 不存在 → 直接返回 { liked:false, likeCount:0 },赞数纹丝不动、永不为负。

无论用户怎么乱点,"赞数 ≥ 0"和"每人每篇至多一赞"这两条不变量都焊死在 SQL 里,应用层再怎么抖都不破。

十一、为什么 /me/likes 返回裸数组

前面提过 GET /me/likes 返回的是 ArticleSummary[]裸数组 ,不走标准分页信封(没有 list/total 包裹)。这不是疏忽,是契约的刻意约定:会员中心的"我的点赞"是一个不定长收藏夹式列表 ,前端通常整页拉取、本地做无限滚动或简单渲染,不需要后端强加分页结构。路由注释里专门标注"契约 data 为裸数组",就是提醒实现者和后续维护者:这个端点故意打破标准信封,别为了"统一"而强行套分页壳 ------统一的代价若是制造无意义的空 total 字段,那就不该统一。这也是一种纪律:结构服务于消费方,而非反过来。

十二、小结

点赞系统是把"二元态 + 高频计数"做对的范例:

  1. 两张表协作likes 关联表(唯一索引防重复、可查态)+ articles.likeCount 冗余计数(读快)。
  2. P-44 幂等点赞ON CONFLICT DO NOTHING + changes>0 才原子 +1;并发双发仅 +1、不 500、不竞态漂移。
  3. P-44 幂等取消 :先查 existing 再删;CASE WHEN like_count>0 THEN -1 ELSE 0 下限夹 0,永不为负。
  4. P-49 冗余计数 :读多写少场景下,冗余 likeCount 换读时零聚合;访问模式决定要不要冗余。
  5. 读端点/like/status 公开(匿名 liked=false),/me/likes 只列 published、返回裸数组。
  6. 薄路由 :点赞/取消/我的列表 authMiddleware;状态查询 optionalAuthMiddleware
  7. 与阅读量同源:都用"唯一约束当锁 + 靠 changes 判真改动",只是点赞是终身一次、阅读量按天重计。

下一篇({{LINK:M1-30}})我们聊"通知系统":文章发布、评论通过这类事件,怎么变成用户收得到的站内信,以及通知和前面点赞/评论的联动。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
故作春风3 小时前
elpis-core 核心从入门到理解
后端·架构·node.js
万敏3 小时前
Vue3 全栈实战第九周:Node.js + Express 后端从零搭建实战记录
vue.js·node.js·全栈
爱吃红星柚8 小时前
【学习】Elpis 抽离与发布 npm 包
前端·前端框架·node.js
梦帮科技9 小时前
Next.js 16 模块化单体实战:App Router、领域模块与 Route Handler 分层
css·数据结构·链表·正则表达式·node.js·json·html5
梦帮科技10 小时前
从 Guest 到 Platinum:Prompt 配额、API Key 与访问控制的安全闭环
javascript·git·架构·node.js·reactjs·html5·visual studio
秋秋小事10 小时前
node prisma+postgreSQL中的查询与过滤
node.js
右耳朵猫AI10 小时前
Node.js周刊2026W37 | 三处进程崩溃修复、fs 内置 glob、Workers 模块注册表、Vitest 5.0
javascript·后端·node.js
右耳朵猫AI10 小时前
Web前端周刊2026W37 | Shopify 转原生、React 编译 Rust 化、Vitest 5.0、Rslib 1.0
前端·javascript·react.js·typescript·node.js
秋秋小事10 小时前
node prisma中的crud
node.js