一、 事务的局限性
首先,我们需要厘清事务的核心作用。
大多数关系型数据库(如 MySQL/PostgreSQL)的事务主要保证 ACID 中的 A(原子性):
原子性:一系列操作要么全部成功,要么全部失败。
但它默认 并不能完全解决并发下的 I(隔离性) 问题(多个并发事务之间互不干扰)。
典型的"读-改-写"死局
假设我们有一个扣减库存的业务逻辑,代码如下(伪代码):
typescript
// 开启事务
await prisma.$transaction(async (tx) => {
// 1. 读:查询当前库存
const product = await tx.product.findUnique({ where: { id: 1 } });
// 2. 算:内存判断
if (product.stock <= 0) throw new Error('库存不足');
// 3. 写:更新库存
await tx.product.update({
where: { id: 1 },
data: { stock: product.stock - 1 }
});
});
并发场景: 如果 A 和 B 两个用户同时进来:
- A 读到库存为 1。
- B 读到库存为 1(因为 A 没锁数据)。
- A 减 1 写入 0。
- B 减 1 写入 0。 结果: 卖出了两件商品,库存只减了 1。这就是典型的 丢失更新(Lost Update)。
二、 解决方案一:单指令原子化(推荐)
如果你的业务逻辑非常简单,仅仅是数值的增减,最好的办法是完全绕过"读-改-写"的步骤,直接利用数据库自身的原子能力。
typescript
// 不需要查出来计算,直接推给数据库去算
await prisma.product.update({
where: { id: 1 },
data: {
stock: { decrement: 1 } // 原子操作:UPDATE product SET stock = stock - 1 ...
}
});
优点 :性能最高,不需要复杂的锁机制。 缺点:只能处理简单逻辑。如果需要判断"用户等级"、"限购数量"等复杂业务,单条 SQL 无法满足。
三、 解决方案二:悲观锁(Pessimistic Locking)
当逻辑复杂必须分步执行,且是写多(高频并发写入) 的场景时,我们需要悲观锁。
通俗理解
"公共厕所的坑位(数据)有限且竞争激烈,为了防止我在里面办事的时候被别人推门而入,我进门的第一件事就是先把门反锁。直到我办完事出来,下一个人才能进去。"
⚠️ 关键前提:必须配合事务
悲观锁是依赖数据库事务(Transaction)的。
- 只有在
BEGIN...COMMIT/ROLLBACK之间,锁才生效。 - 一旦事务提交或回滚,锁会自动解除。如果不开启事务,SQL 语句执行完的瞬间锁就释放了,没有任何意义。
实现方式
在 SQL 中使用 SELECT ... FOR UPDATE。
typescript
// 必须在事务中进行!
await prisma.$transaction(async (tx) => {
// 1. 查并加锁(注意:这里必须使用原生 SQL 或特定 ORM 方法)
// 这行代码执行后,数据库会给这行数据挂上一把"排他锁"
const result = await tx.$queryRaw`SELECT * FROM "Product" WHERE id = ${id} FOR UPDATE`;
if (result[0].stock <= 0) throw new Error('库存不足');
// 2. 更新
await tx.product.update({ ... });
// 3. 事务结束 -> 锁自动释放
});
🔒 锁的阻塞机制(重点)
假设事务 A 拿到了锁,且还没提交,此时事务 B 如果想操作同一行数据,会遭遇以下情况:
| 事务 B 的操作 | 结果 | 说明 |
|---|---|---|
普通查询 SELECT * ... |
✅ 通行 | 不阻塞 。基于 MVCC 机制,B 会读到 A 加锁之前的历史快照。虽然读不到最新修改,但不会被卡住。 |
悲观锁查询 SELECT ... FOR UPDATE |
❌ 阻塞 | 排队等待。B 也想申请锁,但锁在 A 手里,必须等 A 提交或回滚。 |
修改操作 UPDATE / DELETE |
❌ 阻塞 | 排队等待。修改数据需要获取写锁,必须等 A 释放锁。 |
适用场景
- 写多读多、强一致性要求高(如秒杀、银行转账)。
- 代价:性能较差,容易降低系统吞吐量,处理不当可能导致死锁。
四、 解决方案三:乐观锁(Optimistic Locking)
当逻辑复杂必须分步执行,且是读多写少(低频并发写入) 的场景时,我们使用乐观锁。
实现方式
通常在数据库表中增加一个 version 字段。
typescript
// 1. 读数据(不加锁)
const product = await repo.findOne(id); // 假设此时 version = 1
// 2. 内存计算
const newStock = product.stock - 1;
// 3. 更新(带版本号条件) - CAS (Compare And Swap)
const result = await repo.update(
// 只有当数据库里的 version 依然等于我读到的 1 时,才更新
{ id: 1, version: 1 },
{ stock: newStock, version: 2 }
);
// 4. 判断结果
if (result.affected === 0) {
// 说明在第1步和第3步之间,有人修改了数据
throw new ConflictException('手慢了,数据已被修改');
}
失败后的策略
乐观锁更新失败后,有两种常见处理方式:
- Fast Fail(快速失败):直接报错返回给用户(如:"当前内容已被编辑,请刷新")。适合用户编辑表单的场景。
- Retry(自动重试):代码里写个循环,重新查一遍最新数据再试一次。适合库存扣减、积分累加等场景。
五、 总结与选型指南
在处理"多原子化操作"(即无法用单条 SQL 解决的复杂业务)时,我们需要做如下决策:
| 维度 | 悲观锁 (Pessimistic) | 乐观锁 (Optimistic) |
|---|---|---|
| 机制 | 先锁后干 (SELECT FOR UPDATE) | 干完再查 (Version check) |
| 并发性能 | 低 (串行排队) | 高 (并行奔跑) |
| 冲突处理 | 阻塞等待 | 报错或重试 |
| 适用场景 | 高并发写、强竞争 (秒杀、支付) | 低并发写、读多写少 (编辑资料、CMS后台) |
| 生活类比 | 只有一个厕所坑位,进去就反锁门,后面的人排队。 | 开放式文档编辑,提交时发现版本落后,需要合并或重写。 |
🚀 极速判断心法:决策流程图
-
写代码前问一句: 会有两个人同时操作这条数据吗?
- No -> 随便写。
- Yes -> 继续往下。
-
能一条 SQL 更新吗? (比如
increment)- Yes -> 用它!(原子操作,最安全)
- No -> 继续往下。
-
判断业务场景:
- 单纯多步写 (如:先 Insert A,再 Update B)
- 👉 纯事务 (保证原子性)。
- 先读后写 (如:Check-then-Act)
- 👉 上锁 + 事务 。根据竞争程度二选一:
- 冲突高/后果严重 (如库存、金额) -> 悲观锁 (
For Update)。 - 冲突低/后果轻微 (如提示重试) -> 乐观锁 (
version)。
- 冲突高/后果严重 (如库存、金额) -> 悲观锁 (
- 👉 上锁 + 事务 。根据竞争程度二选一:
- 单纯多步写 (如:先 Insert A,再 Update B)