事务进阶:幂等、乐观并发与 savepoints
本教程共 54 篇 · 第 27 篇 · 更新于 2026-08-11 · 约 5 分钟阅读
本节目标:学会用幂等与乐观并发应对重试和并发覆盖,认识事务错误码,掌握 v7.5 的嵌套事务。
两个事务解决不了的问题
上一章的事务保证一组操作全成功或全失败。但两个问题它管不了:操作失败后能不能安全重跑?两个人同时改同一条数据,会不会互相覆盖?这一章讲三种对策:幂等(idempotent)、乐观并发控制(optimistic concurrency control)、嵌套事务保存点(savepoint)。
幂等:跑多少次,结果都一样
幂等的定义:用相同参数执行同一逻辑,跑一次和跑一万次,数据库的最终效果相同。不幂等的例子:往没有唯一约束的 User 表反复 upsert 同一个邮箱,每次都会插入新行,数据越跑越多。给邮箱加上 @unique 之后,upsert 变成「存在就更新、不存在才插入」,重复执行效果一致:
await prisma.user.upsert({
where: { email: "alice@example.com" },
update: { name: "Alice" },
create: { email: "alice@example.com", name: "Alice" },
});
批量插入用 createMany 配合 skipDuplicates:
await prisma.user.createMany({
data: users,
skipDuplicates: true, // 重复的唯一键直接跳过
});
种子脚本、支付回调、消息重试这些可能重复执行的场景,尽量设计成幂等操作,重放不会产生副作用。
乐观并发控制:版本号检测
两个人同时抢最后一个座位:A 读到座位空着,B 也读到空着;A 先占下,B 随后覆盖了 A。悲观方案是加锁,但高并发下锁有额外开销。乐观并发控制不加锁,用一个版本列检测「读到的数据是否已经过期」。
先给模型加 version 字段,再改成「带版本条件的更新」:
const seat = await prisma.seat.findFirst({
where: { claimedBy: null },
});
// 假设读到的 seat.version === 0
const result = await prisma.seat.updateMany({
where: { id: seat.id, version: seat.version },
data: {
claimedBy: userId,
version: { increment: 1 },
},
});
if (result.count === 0) {
throw new Error("座位已被抢占,请刷新重试");
}
关键在 where 里的 version 条件。数据库里的版本号已经被别人改成 1,第二次 updateMany 匹配 0 行,count 为 0,说明读到的数据过期了。这时重试整个流程,或者直接告诉用户冲突。
这里用 updateMany 而不是 update 是刻意的:update 找不到记录会抛 P2025 错误,而 updateMany 把「没匹配上」变成 count 为 0 的结果,冲突检测写起来更干净。
事务错误码与重试
两个与事务强相关的错误码:
| 错误码 | 含义 | 处理 |
|---|---|---|
| P2028 | 事务 API 错误(常见于超时被中止) | 检查事务用法;部分超时场景可重试 |
| P2034 | 写冲突或死锁(常见于 Serializable) | 可重试 |
重试要带上限,只对瞬态错误重试:
const MAX_RETRIES = 5;
let retries = 0;
while (retries < MAX_RETRIES) {
try {
await prisma.$transaction(
[
prisma.user.deleteMany({ where: { /* 条件 */ } }),
prisma.post.createMany({ data: /* 数据 */ }),
],
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
break;
} catch (error) {
if (error.code === "P2034") {
retries++;
continue;
}
throw error; // 其他错误直接抛给上层
}
}
重试次数到上限还没成功,就返回失败让上层处理,避免无限循环拖垮数据库。
长事务的禁忌
事务内别调外部 API。事务开着,等于占着连接、锁着行;外部 API 慢一点,锁就多持一会儿,其他请求全部排队:
// 不好:事务里等外部 API
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
const extra = await fetch("https://api.example.com/enrich"); // 阻塞!
await tx.profile.create({ data: { userId: user.id, ...extra } });
});
把外部调用挪到事务之前,数据先备齐,事务只做数据库操作:
const extra = await fetch("https://api.example.com/enrich");
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
await tx.profile.create({ data: { userId: user.id, ...extra } });
});
日志、审计这类非关键写入也别塞进事务。事务只包业务关键操作,快进快出。
v7.5 嵌套事务:savepoints
交互式事务内部还能再开嵌套事务。嵌套事务基于保存点(savepoint)实现:内层失败只回滚到自己的保存点,外层已经完成的操作不受影响。这是 v7.5.0 新增的特性,仅 SQL 数据库可用。
await prisma.$transaction(async (tx) => {
await tx.user.create({ data: { email: "alice@example.com" } });
try {
await tx.$transaction(async (innerTx) => {
await innerTx.post.create({ data: { title: "临时帖" } });
throw new Error("内层失败");
});
} catch {
// 内层回滚:帖子没创建,用户保留
}
});
内层抛错后,post 没有创建,但外层创建的 user 保留。适合「主流程必须成功、附属操作尽力而为」的场景,比如创建订单时顺便记一条统计,统计失败不影响订单。嵌套事务同样受 timeout 约束,别层层套用滥用。
Note嵌套事务 savepoints 是 v7.5.0 新特性,仅 SQL 数据库支持。MongoDB 在 v7 不支持,留 v6.19。
参考来源
- Prisma 官方文档:Transactions and batch queries(Idempotent APIs / Optimistic concurrency control)
- Prisma 官方文档:Transactions and batch queries(嵌套事务 savepoints,v7.5.0 新增)
- Dev.to:Interactive Transactions in Prisma: A Developer’s Guide
- Tech Insider:Prisma ORM Tutorial(Step 10:Transactions, Optimistic Concurrency, and Savepoints)