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

一、 事务的局限性

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

大多数关系型数据库(如 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)。
相关推荐
野犬寒鸦7 小时前
从零起步学习并发编程 || 第一章:初步认识进程与线程
java·服务器·后端·学习
我爱娃哈哈7 小时前
SpringBoot + Flowable + 自定义节点:可视化工作流引擎,支持请假、报销、审批全场景
java·spring boot·后端
李梨同学丶9 小时前
0201好虫子周刊
后端
思想在飞肢体在追9 小时前
Springboot项目配置Nacos
java·spring boot·后端·nacos
Loo国昌11 小时前
【垂类模型数据工程】第四阶段:高性能 Embedding 实战:从双编码器架构到 InfoNCE 损失函数详解
人工智能·后端·深度学习·自然语言处理·架构·transformer·embedding
ONE_PUNCH_Ge12 小时前
Go 语言泛型
开发语言·后端·golang
良许Linux12 小时前
DSP的选型和应用
后端·stm32·单片机·程序员·嵌入式
不光头强12 小时前
spring boot项目欢迎页设置方式
java·spring boot·后端
怪兽毕设13 小时前
基于SpringBoot的选课调查系统
java·vue.js·spring boot·后端·node.js·选课调查系统
学IT的周星星13 小时前
Spring Boot Web 开发实战:第二天,从零搭个“会卖萌”的小项目
spring boot·后端·tomcat