You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Nextjs+Prisma+MongoDB应用快速点赞点踩的数据库一致性解决方案

解决Next.js+Prisma+MongoDB点赞/点踩并发计数错误的服务端方案

你的问题核心是并发请求下操作的原子性缺失——用户连续操作时,前一个请求的数据库更新还没完成,后一个请求就开始读取旧状态,导致计数错误。以下是几个健壮的服务端解决方案,按落地难度和可靠性排序:

1. 用Prisma事务+MongoDB原子操作保证一致性

这是最直接的修复方案,无需修改现有数据模型,通过事务把用户反应更新、帖子计数更新绑定成一个原子操作,要么全部成功,要么全部失败,避免中间状态被并发请求干扰。

实现代码示例(API路由中)

import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function POST(req: NextRequest) {
  const { postId, userId, reactionType } = await req.json();

  try {
    await prisma.$transaction(async (tx) => {
      // 1. 获取用户当前对该帖子的反应(事务内读取保证是最新状态)
      const user = await tx.user.findUnique({
        where: { id: userId },
        select: { reactions: true }
      });
      const currentReaction = user?.reactions?.[postId];

      // 2. 计算帖子计数的更新逻辑
      let postUpdateData = {};
      let userUpdateData = {};

      if (currentReaction === reactionType) {
        // 取消当前反应:计数减1,移除用户反应记录
        postUpdateData = {
          [currentReaction === 'like' ? 'likes' : 'dislikes']: { decrement: 1 }
        };
        userUpdateData = { reactions: { unset: [postId] } };
      } else {
        if (currentReaction) {
          // 切换反应:旧计数减1,新计数加1,更新用户反应记录
          postUpdateData = {
            [currentReaction === 'like' ? 'likes' : 'dislikes']: { decrement: 1 },
            [reactionType === 'like' ? 'likes' : 'dislikes']: { increment: 1 }
          };
        } else {
          // 新增反应:对应计数加1,添加用户反应记录
          postUpdateData = {
            [reactionType === 'like' ? 'likes' : 'dislikes']: { increment: 1 }
          };
        }
        userUpdateData = { reactions: { [postId]: reactionType } };
      }

      // 3. 执行原子更新
      if (Object.keys(postUpdateData).length > 0) {
        await tx.post.update({ where: { id: postId }, data: postUpdateData });
      }
      if (Object.keys(userUpdateData).length > 0) {
        await tx.user.update({ where: { id: userId }, data: userUpdateData });
      }
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('反应操作失败:', error);
    return NextResponse.json({ success: false, error: '操作失败' }, { status: 500 });
  } finally {
    await prisma.$disconnect();
  }
}

注意事项

  • MongoDB 4.0+才支持事务,需要部署副本集或分片集群。本地开发可以用mongod --replSet rs0初始化副本集,Prisma连接字符串要追加?replicaSet=rs0。
  • 事务会增加一定性能开销,但对于点赞这类低复杂度操作,影响可以忽略。

2. 改用独立的Reaction集合(推荐长期方案)

当前用User的JSON字段存储反应,查询和更新都是整字段操作,扩展性差且容易出现并发问题。改用独立的Reaction集合,配合复合唯一索引,可以从数据模型层面保证用户对同一帖子只能有一个反应,同时更灵活地扩展功能(比如记录反应时间、统计用户互动行为)。

步骤1:修改Prisma Schema

model User {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  // 其他用户字段...
  reactions Reaction[]
}

model Post {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  content   String
  likes     Int      @default(0)
  dislikes  Int      @default(0)
  reactions Reaction[]
}

model Reaction {
  id        String          @id @default(auto()) @map("_id") @db.ObjectId
  userId    String          @db.ObjectId
  postId    String          @db.ObjectId
  type      ReactionType
  user      User            @relation(fields: [userId], references: [id])
  post      Post            @relation(fields: [postId], references: [id])

  @@unique([userId, postId]) // 核心:保证用户对同一帖子只能有一条反应记录
}

enum ReactionType {
  like
  dislike
}

步骤2:API路由实现

import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function POST(req: NextRequest) {
  const { postId, userId, reactionType } = await req.json();

  try {
    await prisma.$transaction(async (tx) => {
      // 查找用户已有的反应(复合唯一索引保证不会重复)
      const existingReaction = await tx.reaction.findUnique({
        where: { userId_postId: { userId, postId } }
      });

      let postUpdateData = {};

      if (existingReaction) {
        if (existingReaction.type === reactionType) {
          // 取消反应:计数减1,删除反应记录
          postUpdateData = {
            [reactionType === 'like' ? 'likes' : 'dislikes']: { decrement: 1 }
          };
          await tx.reaction.delete({ where: { userId_postId: { userId, postId } } });
        } else {
          // 切换反应:旧计数减1,新计数加1,更新反应类型
          postUpdateData = {
            [existingReaction.type === 'like' ? 'likes' : 'dislikes']: { decrement: 1 },
            [reactionType === 'like' ? 'likes' : 'dislikes']: { increment: 1 }
          };
          await tx.reaction.update({
            where: { userId_postId: { userId, postId } },
            data: { type: reactionType }
          });
        }
      } else {
        // 新增反应:计数加1,创建反应记录
        postUpdateData = {
          [reactionType === 'like' ? 'likes' : 'dislikes']: { increment: 1 }
        };
        await tx.reaction.create({ data: { userId, postId, type: reactionType } });
      }

      await tx.post.update({ where: { id: postId }, data: postUpdateData });
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('反应操作失败:', error);
    return NextResponse.json({ success: false, error: '操作失败' }, { status: 500 });
  } finally {
    await prisma.$disconnect();
  }
}

优势

  • 复合唯一索引从数据层面避免了重复反应,无需额外逻辑判断。
  • 反应数据独立存储,便于后续扩展(比如统计用户点赞历史、热门帖子分析)。
  • 事务依然保证计数和反应记录的一致性。

3. 增加幂等性控制(可选补充)

为了防止恶意重复调用API,可以给每个客户端请求生成唯一的operationId,服务端先检查该ID是否已经处理过,避免重复执行操作。

实现思路

  • 新增一个OperationLog集合,存储operationId、userId、postId、status等字段。
  • API接收到请求后,先查询OperationLog,如果该operationId已存在且状态为成功,直接返回成功;如果不存在,执行事务操作,同时记录operationId的状态。

内容的提问来源于stack exchange,提问作者gitaan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.25 17:54:53