欢迎来到“数据灵魂”的保险箱: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: "操作失败" }
}
}
请看这个“死亡螺旋”:
- 第一步成功: 用户的钱还没扣(或者钱扣了但没确认),系统创建了一个
Order,状态是PENDING。这叫“幽灵订单”。 - 第二步失败: 网络抖动,或者库存不够,
updateResult返回 null,或者抛出异常。 - 后果: 程序 catch 住错误,返回“操作失败”。但是!那个
Order已经在数据库里躺平了。此时数据库里有一条孤零零的订单,没有关联的OrderItem,也没有扣减库存。 - 用户体验: 用户看到“操作失败”,以为没买成功。其实你买成功了,钱也没退回来(如果是第三方支付)。这就像你进了餐厅,厨师把菜做出来了,但端上来时打翻了盘子,盘子还在桌子上,菜却没了。
这就是为什么我们需要 Prisma 的事务。我们需要一个原子承诺:如果里面有一行代码跑不通,所有代码都得退回原点。
第二章:Prisma 的 $transaction —— 不仅仅是“包裹”
Prisma 提供了一个非常优雅的 API:$transaction。它不仅仅是一个方法,它是一个“承诺”。
当你调用 $transaction 时,你告诉 Prisma:“嘿,伙计,把这堆操作包起来,给我锁死。”
Prisma 的底层实现非常聪明。它实际上是在底层 SQL 层面调用了数据库原生的 BEGIN TRANSACTION 和 COMMIT / 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 发起请求。这时候,可能发生什么?
- 504 Gateway Timeout: 你的 Server Action 处理了 5 秒(比如你需要查询库存、调用第三方 API),这期间用户不想等了,或者 Nginx 超时了。请求被切断了。
- 浏览器崩溃: 用户的手抖了一下,或者 App 强制关闭了。
- 断网: 公交车隧道里,用户点击提交,网络信号没了。
场景模拟:
用户点击“提交订单”。
- 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。
第四章:实战演练 —— “酒店预订”系统
为了让大家彻底搞懂,我们来做一个稍微复杂点的案例:预订酒店。
这涉及到两个实体:
- Room(房间): 有库存。
- Booking(预订): 记录谁订了哪个房间。
需求:
- 检查房间是否还有空房。
- 创建预订记录。
- 修改房间状态为“已预订”。
代码实现:
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 }
}
}
深入解析这段代码的“防身术”:
-
并发控制:
注意room.status的检查和更新。在booking事务开始时,Prisma 会自动获取该行的共享锁(或者根据数据库配置是行锁)。如果另一个请求(比如另一个用户)也想订这个房间,它会排队等待第一个请求提交或回滚。这完美解决了“超卖”问题。 -
Timeout 配置:
你看到了timeout: 10000。这是一个非常重要的参数。如果数据库负载过高,或者锁竞争极其激烈,事务可能会一直卡住。如果设置了 timeout,超过 10 秒没跑完,Prisma 会抛出PrismaClientTransactionTimeoutError。这能防止你的整个 Server Action 进程被卡死,导致服务器崩溃。 -
错误码处理:
在catch块里,我特别写了if (error.code === 'P2002')。P2002是 Prisma 的唯一约束冲突错误码。比如,同一个用户在短时间内尝试多次提交,或者数据库里有唯一索引冲突。这时候直接返回给用户友好的提示,而不是打印一坨没人看的堆栈信息。
第五章:网络中断后的“回滚”艺术
好了,现在我们回到了最恐怖的场景:网络断了。
如果用户在第二步 room.update 的时候断网了,会发生什么?
- Prisma 客户端: 它会抛出一个错误。具体来说,是
PrismaClientKnownRequestError,代码通常是P2025(Record not found),或者P2022(Field not found),或者仅仅是一个通用的连接错误。 - 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 中建立的安全防线:
- 全盘覆盖: 确保所有涉及数据一致性的操作都放在
prisma.$transaction里。不要把创建和更新分开写,除非你用了额外的补偿逻辑(Saga 模式,那是另一个高级话题了)。 - 错误捕获: 不要让未捕获的异常吞噬掉事务的回滚机制。
try/catch是你的保命符。 - 状态同步: 信任服务器的返回结果,不要让乐观更新凌驾于事务的原子性之上。或者,在乐观更新失败时,引入复杂的状态同步机制。
- 超时与锁: 在高并发下,利用
maxWaitTime和timeout配置,防止数据库锁死你的服务。 - 重试策略: 对于网络抖动,使用轻量级的重试包装器。
最后,送给大家几个“血泪经验”:
- 别在事务里干蠢事: 不要在事务里去
fetch外部 API(比如发送短信、生成 PDF),不要去读写文件系统。这些操作既慢又不可靠,会让你的事务长时间占用数据库锁,导致死锁。 - 保持事务短小: 事务越短越好。你的
$transaction代码块里,应该只有数据库操作。如果有耗时操作,把它移到事务外面,或者异步处理。 - 调试地狱: 事务出问题最难调试,因为你在回滚。如果你在事务里打印了
console.log,你可能会看到日志里有很多“脏数据”。别担心,只要你的代码逻辑是对的,回滚后数据库就是干净的。
希望这篇“讲座”能帮你建立起数据一致性的信心。在这个充满不确定性的网络世界里,用 prisma.$transaction 给你的应用穿上防弹衣,去迎接每一个高并发、高可靠的挑战吧!
如果还有谁敢让你在 React Action 里不写事务就直接 create 然后 update,就把这篇文章甩他脸上。
谢谢大家!