当两个请求同时读到同一条数据、各自修改后再写回时,后提交的那次写入会悄悄覆盖前一次的结果,这就是典型的丢失更新问题。电商扣库存、账户余额变动、订单状态流转这类场景几乎都会碰到。解决并发写冲突的主流方案就是乐观锁与悲观锁,下面结合Prisma这个流行的Node.js ORM,看看它们分别怎么落地。

为什么Prisma没有直接提供锁API
Prisma在设计上对底层数据库做了抽象,而不同数据库对锁的支持差异很大:MySQL的SELECT ... FOR UPDATE、PostgreSQL的FOR UPDATE NOWAIT、SQL Server的锁提示语法各不相同,因此Prisma没有提供统一的一等锁接口。但这并不代表做不到:乐观锁完全可以用Prisma自身的查询能力实现,悲观锁则可以借助$queryRaw下发原生SQL来完成。
另外,Prisma的交互式事务$transaction支持传入隔离级别,这在悲观锁方案中很关键。MySQL的InnoDB在REPEATABLE READ下配合行锁可以保证事务内的读取一致性;PostgreSQL的READ COMMITTED虽然默认不能防丢更新,但加上FOR UPDATE后就能锁定目标行。理解这些数据库层的实际行为,才能正确地用Prisma实现锁,否则容易写出看似加锁、实际毫无保护的代码。
用version字段实现乐观锁
乐观锁的思路是提交时才校验冲突,最常见的实现是版本号字段。先在schema中给需要并发控制的表加一个version字段:
model Product {
id Int @id @default(autoincrement())
stock Int
version Int @default(1)
@@map("products")
}关键点在于更新时必须用updateMany而不是update。updateMany允许把version作为where条件,如果版本号已被别人改过,受影响行数会返回0,从而检测到冲突;而update的where只接受唯一键,无法附加version条件,这一点是很多初学者踩过的坑。
async function deductStock(productId, quantity) {
for (let i = 0; i < 3; i++) {
// 读取当前记录
const product = await prisma.product.findUnique({
where: { id: productId },
});
if (!product || product.stock < quantity) {
throw new Error('库存不足');
}
// 带version条件的条件更新
const result = await prisma.product.updateMany({
where: { id: productId, version: product.version },
data: {
stock: product.stock - quantity,
version: { increment: 1 },
},
});
if (result.count === 1) {
return true; // 更新成功
}
// count为0说明版本号已被其他请求改过,进入重试
}
throw new Error('并发冲突,重试次数已用尽');
}上面代码把计算后的stock直接写回,依赖version校验兜底。也可以写得 stricter一些,例如where: { id: productId, stock: { gte: quantity }, version: product.version },把库存校验也放进更新条件,进一步收紧安全性。重试次数要根据业务QPS合理设置,冲突频繁时重试本身会放大数据库压力,这时就该考虑悲观锁了。
另一种不需要version字段的做法是纯条件更新:扣库存时只写where: { id: productId, stock: { gte: quantity } },用updateMany返回的行数判断成败。这本质上是把业务约束下推到SQL的WHERE子句,思路与乐观锁一脉相承,实现更简单,适合单字段约束场景;version方案则适合多个字段需要整体一致性快照的复杂更新。
借助事务与原生SQL实现悲观锁
悲观锁假设冲突一定会发生,读取时就锁住记录,其他事务只能排队等待。在Prisma中需要把原生SQL放进交互式事务里执行:
const [updated] = await prisma.$transaction(
async (tx) => {
// 锁定目标行,直到事务结束才释放
const rows = await tx.$queryRaw`
SELECT id, stock FROM products WHERE id = ${productId} FOR UPDATE
`;
const product = rows[0];
if (!product || product.stock < quantity) {
throw new Error('库存不足');
}
// 在锁保护下安全更新
return tx.product.update({
where: { id: productId },
data: { stock: product.stock - quantity },
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
timeout: 10000,
}
);FOR UPDATE会对匹配的行加排他锁,事务提交或回滚后才释放,期间其他事务执行同样的锁定查询会被阻塞,从而实现串行化访问。Prisma模板字符串里的参数是预编译的,能有效防止SQL注入。还有一个容易忽略的细节:$queryRaw返回的字段名是数据库列名而非模型字段名,PostgreSQL中还会被转为小写,取值时别写错属性。
锁的粒度是这里的核心问题。WHERE条件如果没走索引,MySQL可能把扫描过的行都锁住,甚至触发间隙锁,导致并发能力急剧下降,所以务必确保锁查询的条件列上有索引。同时要固定加锁顺序,比如多个事务都按id升序依次锁定,否则两个事务互相持有对方需要的锁就会死锁。给事务设置合理的timeout也很重要,超时自动回滚可以避免某个慢事务把连接池拖垮。
两种锁如何选择
两者的差异可以从几个维度对比:
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 读多写少场景 | 非常合适,冲突少重试少 | 浪费锁资源,吞吐受限 |
| 写冲突频繁 | 重试风暴,成功率低 | 排队串行,更稳定 |
| 事务持有时间 | 无长事务 | 锁持有到事务结束 |
| 死锁风险 | 无 | 存在,需控制加锁顺序 |
| 实现复杂度 | 低,纯Prisma API | 中,需要原生SQL |
实践中还可以混合使用:低冲突路径走乐观锁快速提交,检测到连续冲突后自动降级为悲观锁串行处理。而对于秒杀这类极端场景,更彻底的做法是把扣减逻辑下推为一条原子UPDATE语句,直接在数据库端完成判断和扣减,绕开应用层的读改写流程,性能和正确性都会更好。
无论选哪种方案,都建议配套做好监控:乐观锁要统计重试率和最终失败率,悲观锁要关注锁等待时间和死锁日志。这些指标能直接反映当前策略是否匹配业务的并发特征,也是后续扩容和调优时最有价值的依据。