首页 / Prisma ORM 入门教程 / 事务进阶:幂等、乐观并发与 savepoints

Prisma ORM 入门教程

事务进阶:幂等、乐观并发与 savepoints

本教程共 54 篇 · 第 27 篇 · 更新于 2026-08-11 · 约 5 分钟阅读

事务幂等乐观并发savepointsP2034重试嵌套事务

本节目标:学会用幂等与乐观并发应对重试和并发覆盖,认识事务错误码,掌握 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)