在数据库并发事务控制中,为防止多个事务并发读写导致的数据覆盖或脏写问题,主要采用乐观并发控制与悲观并发控制两种策略。本文介绍两者的核心概念、常用场景,并给出在 Prisma ORM 中的具体实现方案。
version 整数版本号或更新时间戳)。若版本一致则提交修改并将版本递增;若版本不一致,说明记录在此期间已被其他事务修改,当前更新操作返回受影响行数为 0,由应用层决定重试或报错。Prisma 在数据模型中添加版本标识字段并在更新条件中限定版本号来实现乐观并发控制。参考 Prisma 乐观锁官方场景文档。
以电影院选座避免双重预订为例,在数据模型中添加 version 字段:
model Seat {
id Int @id @default(autoincrement())
userId Int?
claimedBy User? @relation(fields: [userId], references: [id])
movieId Int
movie Movie @relation(fields: [movieId], references: [id])
version Int
}
model Movie {
id Int @id @default(autoincrement())
name String @unique
seats Seat[]
}执行带版本校验的更新操作:
const movieName = 'Hidden Figures'
// 查找第一个可用座位
const availableSeat = await prisma.seat.findFirst({
where: {
movie: {
name: movieName,
},
claimedBy: null,
},
})
// 如果没有座位,则抛出错误
if (!availableSeat) {
throw new Error(`Oh no! ${movieName} is all booked.`)
}
// 通过匹配当前 version 执行更新,若该行已被其他事务修改则返回 count 为 0
const seats = await prisma.seat.updateMany({
data: {
claimedBy: userId,
version: {
increment: 1,
},
},
where: {
id: availableSeat.id,
version: availableSeat.version,
},
})
if (seats.count === 0) {
throw new Error('座位已被预订,请重试')
}updateMany 方法在 where 条件中同时匹配主键 id 与读取时的 version。若读取与更新之间发生并发写入,数据库中记录的 version 已发生变化,更新条件不匹配导致返回的 count 为 0,从而避免并发覆盖。SELECT ... FOR UPDATE)或共享锁(如 SELECT ... FOR SHARE)锁定目标数据行。在锁持有期间,其他事务针对同一数据行的加锁读取或更新写入会被阻塞挂起,直至当前事务提交(COMMIT)或回滚(ROLLBACK)释放锁资源。Prisma Client API 当前未直接提供内置的 FOR UPDATE 方法,可通过交互式事务(Interactive Transactions)结合 $queryRaw 执行原生 SQL 加锁查询实现。参考 Prisma 原生 SQL 查询文档。
执行流程如下:
prisma.$transaction(async (tx) => { ... }) 获得独立事务实例。SELECT * FROM "User" WHERE id = 1 FOR UPDATE,锁定目标行并阻塞其他事务的并发加锁操作。示例代码:
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
// 开始交互式事务
await prisma.$transaction(async (tx) => {
// 使用 FOR UPDATE 锁定记录
const user = await tx.$queryRaw`SELECT * FROM "User" WHERE id = 1 FOR UPDATE`;
// 在锁保护下执行更新操作
await tx.user.update({
where: { id: 1 },
data: { status: 'INACTIVE' },
});
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 加锁时机 | 不加数据库物理锁,提交时通过版本号比对 | 读取数据时立即通过数据库加行级/表级锁 |
| 性能与吞吐 | 无锁等待开销,读密集型场景吞吐量高 | 存在锁等待与阻塞,写冲突激烈时排队开销大 |
| 冲突处理 | 冲突发生时快速失败,由应用层决定重试或中断 | 冲突发生时排队等待锁释放,减少因冲突导致的失败 |
| 死锁风险 | 无数据库行级锁持有,无死锁风险 | 多事务交叉加锁时可能引发死锁,需按序加锁或配置超时 |
| 适用场景 | 读多写少、交互周期长、允许重试的场景 | 写多读少、竞争激烈、要求强一致性的场景 |