【后端进阶】并发竞态与锁选型

一、 事务的局限性

首先,我们需要厘清事务的核心作用。

大多数关系型数据库(如 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 两个用户同时进来:

  1. A 读到库存为 1。
  2. B 读到库存为 1(因为 A 没锁数据)。
  3. A 减 1 写入 0。
  4. 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('手慢了,数据已被修改');
}

失败后的策略

乐观锁更新失败后,有两种常见处理方式:

  1. Fast Fail(快速失败):直接报错返回给用户(如:"当前内容已被编辑,请刷新")。适合用户编辑表单的场景。
  2. Retry(自动重试):代码里写个循环,重新查一遍最新数据再试一次。适合库存扣减、积分累加等场景。

五、 总结与选型指南

在处理"多原子化操作"(即无法用单条 SQL 解决的复杂业务)时,我们需要做如下决策:

维度 悲观锁 (Pessimistic) 乐观锁 (Optimistic)
机制 先锁后干 (SELECT FOR UPDATE) 干完再查 (Version check)
并发性能 低 (串行排队) 高 (并行奔跑)
冲突处理 阻塞等待 报错或重试
适用场景 高并发写、强竞争 (秒杀、支付) 低并发写、读多写少 (编辑资料、CMS后台)
生活类比 只有一个厕所坑位,进去就反锁门,后面的人排队。 开放式文档编辑,提交时发现版本落后,需要合并或重写。

🚀 极速判断心法:决策流程图

  1. 写代码前问一句: 会有两个人同时操作这条数据吗?

    • No -> 随便写。
    • Yes -> 继续往下。
  2. 能一条 SQL 更新吗? (比如 increment)

    • Yes -> 用它!(原子操作,最安全)
    • No -> 继续往下。
  3. 判断业务场景:

    • 单纯多步写 (如:先 Insert A,再 Update B)
      • 👉 纯事务 (保证原子性)。
    • 先读后写 (如:Check-then-Act)
      • 👉 上锁 + 事务 。根据竞争程度二选一:
        • 冲突高/后果严重 (如库存、金额) -> 悲观锁 (For Update)。
        • 冲突低/后果轻微 (如提示重试) -> 乐观锁 (version)。
相关推荐
风雨同舟的代码笔记2 小时前
Java并发编程基石:深入解析AQS原理与应用实战
后端
a程序小傲2 小时前
京东Java面试被问:ZGC的染色指针如何实现?内存屏障如何处理?
java·后端·python·面试
vx_bisheyuange2 小时前
基于SpringBoot的老年一站式服务平台
java·spring boot·后端·毕业设计
Tony Bai3 小时前
Jepsen 报告震动 Go 社区:NATS JetStream 会丢失已确认写入
开发语言·后端·golang
bing.shao3 小时前
Golang 之 defer 延迟函数
开发语言·后端·golang
penngo3 小时前
Golang使用Fyne开发桌面应用
开发语言·后端·golang
程序员清风3 小时前
别卷模型了!上下文工程才是大模型应用的王道!
java·后端·面试
逸风尊者3 小时前
开发可掌握的知识:uber H3网格
后端·算法
逸风尊者4 小时前
开发需掌握的知识:MQTT协议
java·后端