← 中文资源

简体中文版本

Prisma 交互式事务与重试

用 Prisma 交互式事务包裹读改写序列:可串行化隔离级别下检测写写冲突错误码,按指数退避有界重试,不变量被破坏即回滚。

阅读英文原文

示例说明

先查余额再更新是经典的读改写竞态,两个并发扣款可能都读到同一旧值。可串行化隔离让数据库检测到冲突并以特定错误码拒绝提交,这正是重试的信号——业务本身无错,只是调度撞车。Prisma 的 $transaction 回调里持有 tx 上下文,任何抛错自动回滚,负余额校验因此天然安全。

技术要点

  • Serializable 隔离级别检测写写冲突
  • P2034 错误码是可重试信号
  • 回调内抛错自动触发回滚

代码解读

adjustBalance 的循环最多四次尝试:事务里 findUniqueOrThrow 读余额、校验下限后更新。捕获分支把已知请求错误按错误码过滤,冲突时按 50 毫秒基数指数退避后重试,非冲突错误与次数耗尽直接上抛。$disconnect 收尾释放连接池。

使用建议

重试次数与退避上界要匹配业务时延预算。冲突率高说明热点账户需要队列化或乐观版本号方案。事务内禁止外部 IO 调用,连接占用会放大。

代码示例

import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient({ log: ['warn', 'error'] });

async function adjustBalance(userId: number, delta: number) {
  for (let attempt = 1; attempt <= 4; attempt++) {
    try {
      return await prisma.$transaction(async (tx) => {
        const wallet = await tx.wallet.findUniqueOrThrow({
          where: { userId },
        });

        const next = wallet.balanceCents + delta;
        if (next < 0) {
          throw new Error('balance would go negative');
        }

        return tx.wallet.update({
          where: { userId },
          data: { balanceCents: next },
        });
      }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
    } catch (err) {
      const conflict =
        err instanceof Prisma.PrismaClientKnownRequestError
        && ['P2034'].includes(err.code);

      if (conflict && attempt < 4) {
        await new Promise((r) => setTimeout(r, 50 * 2 ** attempt));
        continue;
      }
      throw err;
    }
  }
  throw new Error('unreachable');
}

const wallet = await adjustBalance(1, -250);
console.log('balance now:', wallet.balanceCents);
await prisma.$disconnect();