示例说明
先查余额再更新是经典的读改写竞态,两个并发扣款可能都读到同一旧值。可串行化隔离让数据库检测到冲突并以特定错误码拒绝提交,这正是重试的信号——业务本身无错,只是调度撞车。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();