Prisma 事务在 React Actions 中的应用:确保全栈操作在网络中断下的数据原子性与回滚机制

欢迎来到“数据灵魂”的保险箱:Prisma 事务在 React Actions 中的深度实战

大家好,我是你们的老朋友,一个在代码世界里修修补补的“数据库医生”。

今天我们不聊什么枯燥的架构图,也不讲那些让你听了就想打哈欠的教科书定义。我们要聊点带血的——当然,是比喻意义上的血。我们要聊聊当你的 React Action 在处理数据时,网线突然拔了,或者服务器吐了一口老血(502 错误)时,你的数据是不是也跟着一起“魂飞魄散”了?

想象一下这个场景:你在电商平台上买了一张演唱会门票,同时点了外卖。你点击了“支付”。系统扣了你的钱,外卖下单了,但是……那张“灵魂门票”没有出票。

这时候,你会觉得这笔交易是成功的吗?你肯定想报警,或者至少想给那个开发该系统的程序员寄一封刀片。

这就是原子性的悲剧。而在全栈开发中,React Actions 是我们现在的舞台,Prisma 是我们的演员,而事务(Transaction),就是那个把你所有演员都锁在保险箱里的守门人。

今天,我们就来聊聊如何用 Prisma 的事务机制,给 React Actions 穿上一层防弹衣,确保全栈操作的“要么全有,要么全无”。


第一章:幽灵订单与“拔网线”的艺术

首先,我们得理解为什么我们需要这种“防弹衣”。在没有任何保护的情况下,你的数据库操作就像是走在钢丝上,下面是万丈深渊。

让我们看看一个经典的“烂摊子”场景。假设你正在开发一个 Next.js 应用,你有一个 buyProduct 的 React Action。

没有事务的版本(漏洞百出):

// actions.ts
'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'

export async function buyProduct(userId: string, productId: string, quantity: number) {
  try {
    // 第一步:创建订单
    const order = await prisma.order.create({
      data: {
        userId,
        total: 100, // 假设总价100
        status: 'PENDING',
      },
    })

    // 第二步:扣除库存
    // 重点来了!如果这一步出错了怎么办?
    const updateResult = await prisma.product.update({
      where: { id: productId },
      data: { stock: { decrement: quantity } },
    })

    if (!updateResult) {
      throw new Error("库存扣减失败!但这已经是沉没成本了,订单还在。")
    }

    // 第三步:发货(其实就是改个状态)
    await prisma.order.update({
      where: { id: order.id },
      data: { status: 'SHIPPED' },
    })

    revalidatePath(`/product/${productId}`)
    return { success: true, orderId: order.id }

  } catch (error) {
    // 这里只能打补丁,不能回滚!
    console.error("出大事了", error)
    // 可能需要手动去查那个孤儿订单并删除,或者等待用户手动退款
    return { success: false, error: "操作失败" }
  }
}

请看这个“死亡螺旋”:

  1. 第一步成功: 用户的钱还没扣(或者钱扣了但没确认),系统创建了一个 Order,状态是 PENDING。这叫“幽灵订单”。
  2. 第二步失败: 网络抖动,或者库存不够,updateResult 返回 null,或者抛出异常。
  3. 后果: 程序 catch 住错误,返回“操作失败”。但是!那个 Order 已经在数据库里躺平了。此时数据库里有一条孤零零的订单,没有关联的 OrderItem,也没有扣减库存。
  4. 用户体验: 用户看到“操作失败”,以为没买成功。其实你买成功了,钱也没退回来(如果是第三方支付)。这就像你进了餐厅,厨师把菜做出来了,但端上来时打翻了盘子,盘子还在桌子上,菜却没了。

这就是为什么我们需要 Prisma 的事务。我们需要一个原子承诺:如果里面有一行代码跑不通,所有代码都得退回原点。


第二章:Prisma 的 $transaction —— 不仅仅是“包裹”

Prisma 提供了一个非常优雅的 API:$transaction。它不仅仅是一个方法,它是一个“承诺”

当你调用 $transaction 时,你告诉 Prisma:“嘿,伙计,把这堆操作包起来,给我锁死。”

Prisma 的底层实现非常聪明。它实际上是在底层 SQL 层面调用了数据库原生的 BEGIN TRANSACTIONCOMMIT / ROLLBACK

  • COMMIT(提交): 就像你把一张支票放进信封,封死信封,扔进邮筒。一旦投递出去,想追回来很难。数据持久化了。
  • ROLLBACK(回滚): 就像你还没封信封,突然发现写错了,把信纸撕下来揉成团扔进垃圾桶。数据库恢复到事务开始前的状态。

核心代码逻辑:

// 核心公式:prisma.$transaction(异步函数)
await prisma.$transaction(async (tx) => {
  // 在这里,你可以把 prisma 换成 tx
  // 所有的数据库操作都会在这个事务里排队执行
});

现在,让我们用这个公式来重构上面的“幽灵订单”问题。

有事务保护的版本(坚如磐石):

export async function buyProductSafe(userId: string, productId: string, quantity: number) {
  // 这一层 try-catch 是为了捕获事务执行过程中的任何报错
  try {
    // 拿出一张王牌:$transaction
    const order = await prisma.$transaction(async (tx) => {

      // 1. 创建订单
      const newOrder = await tx.order.create({
        data: {
          userId,
          total: 100,
          status: 'PENDING',
        },
      })

      // 2. 扣库存
      // 注意这里用的是 tx 而不是 prisma
      const updatedProduct = await tx.product.update({
        where: { id: productId },
        data: { stock: { decrement: quantity } },
      })

      // 如果 stock < 0,这里会抛出错误,整个事务回滚!
      if (updatedProduct.stock < 0) {
        throw new Error("库存不足,这是一次强制回滚!");
      }

      // 3. 创建订单详情
      await tx.orderItem.create({
        data: {
          orderId: newOrder.id,
          productId,
          quantity,
        },
      })

      return newOrder;
    });

    revalidatePath(`/product/${productId}`)
    return { success: true, orderId: order.id }

  } catch (error) {
    // 错误在这里被捕获,事务已经自动回滚了。
    // 你不需要手动 delete 那个孤儿订单,它根本就不存在。
    console.error("天塌了", error)
    return { success: false, error: error.message }
  }
}

看懂了吗?
当我们在 tx 上下文中执行代码时,Prisma 会在数据库连接上建立一个隔离级别(默认是 Serializable,也就是最严格的隔离级别之一)。这意味着,如果有两个人同时买这同一个商品(并发),Prisma 会像交警一样,给数据库加锁,确保不会出现超卖。

如果库存不足,update 失败抛出异常,$transaction 会立即触发 ROLLBACK。数据库瞬间回到干净的状态。那个幽灵订单?不存在的。它就像从未来过这个世界一样。


第三章:网络中断 —— React Actions 的“断头台”

现在,我们解决了代码逻辑错误。但现实比代码更残酷。现实充满了网络中断

当用户点击按钮,React Action 发起请求。这时候,可能发生什么?

  1. 504 Gateway Timeout: 你的 Server Action 处理了 5 秒(比如你需要查询库存、调用第三方 API),这期间用户不想等了,或者 Nginx 超时了。请求被切断了。
  2. 浏览器崩溃: 用户的手抖了一下,或者 App 强制关闭了。
  3. 断网: 公交车隧道里,用户点击提交,网络信号没了。

场景模拟:

用户点击“提交订单”。

  • React 状态更新为 loading
  • Action 开始执行。prisma.order.create 成功了。
  • 用户的手指离开了按钮。
  • 网络断开。
  • Action 跑到 prisma.product.update 时卡住了。
  • Crash!

此时,React Action 的响应是……“Operation failed”

但是! 数据库里呢?那条 Order 还在那里!用户的状态显示“订单已创建”,但他去查订单列表却找不到(因为订单还没支付成功,或者状态逻辑没跟上)。

这就引出了 React Actions 中一个巨大的痛点:乐观更新(Optimistic UI)与原子性的冲突

很多 React 开发者喜欢乐观更新,觉得用户体验好。比如:

// 伪代码
const [cart, setCart] = useState(...)
async function checkout() {
  setCart([]) // 乐观更新:界面瞬间清空
  try {
     await buyProductSafe(...) // 发起请求
  } catch {
     setCart(initialCart) // 失败了,恢复界面
  }
}

如果使用了乐观更新,一旦 $transaction 因为网络中断而回滚了,但用户的界面已经把购物车清空了,这就变成了“数据幽灵”

用户以为买完了,结果刷新页面,购物车还在。这比没买还糟糕,因为用户会以为已经付款了。

解决方案:拥抱“悲观更新”或者“双重确认”

在高并发、高价值的交易场景下(比如支付、库存扣减),不要依赖乐观更新。直接等 React Action 返回成功再说。

如果一定要乐观更新,那么你必须把 React state 的更新逻辑放入 $transaction 的逻辑中,或者使用一种机制,当事务回滚时,能够自动把 UI 拉回来。

但这太难了,对吧?所以我们现在的策略是:在 React Actions 中,尽量保证原子性,并且利用 Prisma 的错误处理来指导 UI。


第四章:实战演练 —— “酒店预订”系统

为了让大家彻底搞懂,我们来做一个稍微复杂点的案例:预订酒店。

这涉及到两个实体:

  1. Room(房间): 有库存。
  2. Booking(预订): 记录谁订了哪个房间。

需求:

  1. 检查房间是否还有空房。
  2. 创建预订记录。
  3. 修改房间状态为“已预订”。

代码实现:

import { prisma } from '@/lib/prisma'
import { z } from 'zod' // 假设你用了 Zod

const bookingSchema = z.object({
  roomId: z.string(),
  userId: z.string(),
  date: z.date(),
})

export async function bookRoomAction(formData: FormData) {
  const parsed = bookingSchema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) return { error: "数据无效" }

  const { roomId, userId, date } = parsed.data

  try {
    // 这里的 await 是关键
    const booking = await prisma.$transaction(async (tx) => {

      // 1. 检查库存(使用 SELECT FOR UPDATE 实际上 Prisma 会处理,或者用 findUnique)
      const room = await tx.room.findUnique({
        where: { id: roomId },
        // select: { id: true, status: true } 
        // 注意:如果要加锁防止并发,可以用 select,或者利用 update 的条件
      })

      if (!room) throw new Error("房间不存在")
      if (room.status === 'BOOKED') throw new Error("房间已经被订了,别抢了")

      // 2. 创建预订
      const newBooking = await tx.booking.create({
        data: {
          roomId,
          userId,
          checkIn: date,
          status: 'CONFIRMED',
        },
      })

      // 3. 更新房间状态
      // 如果这里失败,上面的 create 也会回滚
      await tx.room.update({
        where: { id: roomId },
        data: { status: 'BOOKED' },
      })

      return newBooking
    }, {
      // 可选的高级配置
      maxWaitTime: 5000, // 最多等5秒获取数据库锁
      timeout: 10000,   // 整个事务最多跑10秒,防止死锁导致服务挂掉
      isolationLevel: 'Serializable' // 默认就是 Serializable,但显式指定更清晰
    })

    revalidatePath('/rooms')
    return { success: true, bookingId: booking.id }

  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === 'P2002') {
        return { error: "操作冲突,请重试" }
      }
    }
    return { error: "预订失败: " + error.message }
  }
}

深入解析这段代码的“防身术”:

  1. 并发控制:
    注意 room.status 的检查和更新。在 booking 事务开始时,Prisma 会自动获取该行的共享锁(或者根据数据库配置是行锁)。如果另一个请求(比如另一个用户)也想订这个房间,它会排队等待第一个请求提交或回滚。这完美解决了“超卖”问题。

  2. Timeout 配置:
    你看到了 timeout: 10000。这是一个非常重要的参数。如果数据库负载过高,或者锁竞争极其激烈,事务可能会一直卡住。如果设置了 timeout,超过 10 秒没跑完,Prisma 会抛出 PrismaClientTransactionTimeoutError。这能防止你的整个 Server Action 进程被卡死,导致服务器崩溃。

  3. 错误码处理:
    catch 块里,我特别写了 if (error.code === 'P2002')P2002 是 Prisma 的唯一约束冲突错误码。比如,同一个用户在短时间内尝试多次提交,或者数据库里有唯一索引冲突。这时候直接返回给用户友好的提示,而不是打印一坨没人看的堆栈信息。


第五章:网络中断后的“回滚”艺术

好了,现在我们回到了最恐怖的场景:网络断了

如果用户在第二步 room.update 的时候断网了,会发生什么?

  1. Prisma 客户端: 它会抛出一个错误。具体来说,是 PrismaClientKnownRequestError,代码通常是 P2025(Record not found),或者 P2022(Field not found),或者仅仅是一个通用的连接错误。
  2. React Action: 它会进入 catch 块。

关键点来了:因为我们在 try...catch 包裹了 prisma.$transaction,Prisma 引擎会自动执行 ROLLBACK。

你不需要写 tx.rollback()。你不需要写 await prisma.order.delete(...)。你不需要写任何清理逻辑。

数据库在极短的时间内(毫秒级),就会抹去刚才那几行 SQL 的痕迹。

但是,你的 React 前端呢?

如果用户界面显示了“预订成功”的 Toast 提示,然后断网了,App 崩溃了。用户重启 App,会看到什么?

场景 A:纯客户端状态管理
用户看到“预订成功”。
用户刷新页面 -> 数据库里没有订单 -> 用户困惑。

场景 B:使用 Zustand/Context 等服务器状态
如果你把服务器状态直接存储在 Context 里。
用户刷新 -> 重新调用 Action -> 服务器发现没订单 -> 状态重置 -> 用户看到空状态。

最佳实践建议:
在 React Actions 中处理事务时,UI 的反馈必须严格遵循服务器的返回值

// 不要这样做!这是自欺欺人
async function handleBooking() {
  const result = await bookRoomAction(formData)
  if (result.success) {
    // 即使网络断了,Prisma 回滚了,这里依然显示成功?
    // 用户刷新后发现没订单,心态崩了。
    showToast("预订成功!")
  }
}

应该这样做:

async function handleBooking() {
  const result = await bookRoomAction(formData)
  // 无论网络是否稳定,都依赖服务器返回的布尔值
  if (result.success) {
    showToast("预订成功!") // 此时数据已在数据库中,稳如泰山
  } else {
    showToast("预订失败,请重试")
  }
}

第六章:嵌套事务与 Savepoints —— 深入底层

Prisma 不仅仅支持简单的 $transaction。它还支持嵌套事务

虽然标准的 SQL 数据库(如 PostgreSQL, MySQL, SQLite)原生不支持嵌套事务(嵌套事务在 SQL 标准里其实是个争议点,PostgreSQL 叫 Savepoint,Oracle 叫子事务),但 Prisma 封装得非常完美。

场景:
你有一个主操作(创建订单),里面包含了一个子操作(发送通知邮件)。

如果你想在邮件发送失败时,回滚主订单,但又不影响其他订单的创建,你就需要 Savepoint。

try {
  await prisma.$transaction(async (tx) => {

    // 创建订单
    const order = await tx.order.create({ ... })

    // 设置一个 Savepoint
    const savepointId = tx.$createSavepoint('booking_savepoint')

    try {
      // 发送邮件(假设这是一个外部 API 调用,很容易失败)
      await sendEmailToUser(order.email) 
      // 假设这里抛出了异常

    } catch (e) {
      // 回滚到 Savepoint
      tx.$rollbackToSavepoint(savepointId)
      // 记录日志,但不回滚整个事务(比如不回滚订单创建,只回滚后续步骤)
      console.log("邮件发送失败,但订单已创建。")
      return order
    }

  })
} catch (e) {
  // 这里只会捕获真正的主事务错误
}

注意:在大多数简单的 React Action 场景下,你不需要用到 Savepoint。除非你的业务逻辑极其复杂,涉及多层嵌套操作,且某些子操作失败不应该导致整个主业务回滚。


第七章:重试机制 —— 给网络一点耐心

网络是有生命的。有时候它只是“卡”了一下,并不是死了。

如果你使用 Prisma 事务,遇到了 PrismaClientInitializationError(连接丢失)或 PrismaClientKnownRequestError(网络超时),直接回滚并告诉用户失败,可能会让用户重试 10 次,结果数据库被锁死了,所有人都报错。

高级技巧:重试包装器

我们可以写一个简单的包装器,在遇到可重试的错误时,自动重试。

import { Prisma } from '@prisma/client'

// 这是一个简单的重试逻辑
async function executeWithRetry<T>(
  fn: () => Promise<T>,
  retries = 3
): Promise<T> {
  let lastError: any

  for (let i = 0; i < retries; i++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error
      // 如果是 Prisma 的连接错误或者网络超时,可以重试
      // P1001, P1002, P1003 等通常是网络相关的错误
      if (
        error instanceof Prisma.PrismaClientInitializationError ||
        error instanceof Prisma.PrismaClientKnownRequestError
      ) {
        console.log(`第 ${i + 1} 次尝试失败,准备重试...`)
        await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))) // 指数退避
      } else {
        // 如果是逻辑错误(比如库存不足 P2002),直接抛出,不重试
        throw error
      }
    }
  }

  throw lastError
}

// 使用
export async function buyProductWithRetry(userId: string, productId: string) {
  try {
    return await executeWithRetry(async () => {
      return await prisma.$transaction(async (tx) => {
        // ... 你的业务逻辑 ...
      })
    })
  } catch (error) {
    // 最终失败
  }
}

这个 executeWithRetry 函数就是给你的数据加上的一层“缓冲垫”。它能处理那些转瞬即逝的网络故障。


第八章:总结与“避坑”指南

好了,各位“数据架构师”,现在我们已经掌握了 Prisma 事务的核心。

让我们回顾一下我们在 React Actions 中建立的安全防线:

  1. 全盘覆盖: 确保所有涉及数据一致性的操作都放在 prisma.$transaction 里。不要把创建和更新分开写,除非你用了额外的补偿逻辑(Saga 模式,那是另一个高级话题了)。
  2. 错误捕获: 不要让未捕获的异常吞噬掉事务的回滚机制。try/catch 是你的保命符。
  3. 状态同步: 信任服务器的返回结果,不要让乐观更新凌驾于事务的原子性之上。或者,在乐观更新失败时,引入复杂的状态同步机制。
  4. 超时与锁: 在高并发下,利用 maxWaitTimetimeout 配置,防止数据库锁死你的服务。
  5. 重试策略: 对于网络抖动,使用轻量级的重试包装器。

最后,送给大家几个“血泪经验”:

  • 别在事务里干蠢事: 不要在事务里去 fetch 外部 API(比如发送短信、生成 PDF),不要去读写文件系统。这些操作既慢又不可靠,会让你的事务长时间占用数据库锁,导致死锁。
  • 保持事务短小: 事务越短越好。你的 $transaction 代码块里,应该只有数据库操作。如果有耗时操作,把它移到事务外面,或者异步处理。
  • 调试地狱: 事务出问题最难调试,因为你在回滚。如果你在事务里打印了 console.log,你可能会看到日志里有很多“脏数据”。别担心,只要你的代码逻辑是对的,回滚后数据库就是干净的。

希望这篇“讲座”能帮你建立起数据一致性的信心。在这个充满不确定性的网络世界里,用 prisma.$transaction 给你的应用穿上防弹衣,去迎接每一个高并发、高可靠的挑战吧!

如果还有谁敢让你在 React Action 里不写事务就直接 create 然后 update,就把这篇文章甩他脸上。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注