2829 字
14 分钟
聊聊并发控制:乐观锁、悲观锁与死锁

并发问题不是只有高并发系统才会遇到。只要有多个执行单元同时读写同一份数据,就可能出现结果和预期不一致的情况。这里的执行单元可以是多个用户、多个请求、多个进程、多个定时任务,也可以是同一个服务里的多条异步任务。

常见的并发问题包括:

  • 两个人同时编辑同一条资料,后提交的人覆盖了先提交的人。
  • 两个订单同时扣减最后一件库存,结果都显示购买成功。
  • 一个定时任务还没跑完,下一轮任务又开始执行,重复处理同一批数据。
  • 多个服务同时修改账户余额,最后余额和流水对不上。

这些问题的共同点是:共享数据是可变的,而且多个执行单元同时依赖它的旧状态做决定。

处理并发通常有两条主线:

  • 隔离:让不同执行单元尽量操作不同资源,减少共享数据。
  • 协调:如果必须共享,就用锁、版本号、事务、唯一约束等机制协调读写顺序。

本文主要聊协调里的两种经典策略:乐观锁和悲观锁。

问题从哪里来#

先看一个最常见的“丢失更新”问题。

假设系统里有一条用户资料:

id: 1
name: Alice
phone: 13800000000
version: 1

员工 A 和员工 B 同时打开编辑页,都读到了 version = 1 的数据。

A 把手机号改成 13900000000,提交成功。B 过了一会儿把姓名改成 Alicia,也提交成功。如果系统只是简单地用 B 提交的整份表单覆盖数据库,那么 A 刚刚改过的手机号可能就被旧值覆盖了。

这类问题不一定会立刻报错,但它会悄悄破坏数据。并发控制要解决的就是:当多个操作同时依赖同一份旧数据时,系统应该让谁继续、让谁等待、让谁重试。

悲观锁#

悲观锁的思路很直接:既然冲突可能发生,那就先把资源锁起来,别人等我处理完再说。

在数据库里,典型做法是把读取和修改放进同一个事务,并在读取时加锁:

BEGIN;

SELECT id, stock
FROM products
WHERE id = 1
FOR UPDATE;

UPDATE products
SET stock = stock - 1
WHERE id = 1;

COMMIT;

FOR UPDATE 的含义可以理解为:当前事务准备修改这行数据,在事务提交或回滚之前,其他想修改这行数据的事务要等待。

悲观锁适合下面这些场景:

  • 冲突概率很高,比如热点库存、热门优惠券、账户余额。
  • 冲突代价很大,比如资金、配额、审批状态。
  • 操作不能轻易重试,或者重试会给用户造成明显困扰。
  • 必须保证同一时间只有一个执行单元处理某个资源。

悲观锁的优点是模型简单,冲突在进入核心逻辑前就被挡住了。缺点也明显:锁会降低并发能力,锁持有时间越长,等待队列越长,系统越容易被拖慢。

所以使用悲观锁时,要尽量缩短锁持有时间。不要在拿到锁之后再调用外部服务、等待用户输入、做复杂计算。锁内只做必要的读取、校验和写入。

乐观锁#

乐观锁的思路相反:先假设冲突不常发生,大家都可以读;真正提交时再检查数据有没有被别人改过。

最常见的实现是版本号。每条记录都有一个 version 字段,更新时带上自己读到的版本号:

UPDATE users
SET
  name = 'Alicia',
  phone = '13900000000',
  version = version + 1
WHERE id = 1
  AND version = 1;

如果这条 SQL 影响了 1 行,说明更新成功。如果影响了 0 行,说明在你提交之前,数据已经被别人更新过了。此时系统应该提示用户重新加载,或者尝试自动合并。

乐观锁适合下面这些场景:

  • 冲突概率较低。
  • 读多写少。
  • 用户可以接受“数据已变化,请重新确认”。
  • 业务允许重试。
  • 不希望长时间持有数据库锁。

乐观锁的优点是并发能力好,不会阻塞读取。缺点是冲突会推迟到提交时才暴露,业务上必须处理失败后的流程。

如果只是返回一句“保存失败”,体验通常不够好。更好的做法是告诉用户:

  • 这条数据已经被谁修改过。
  • 修改发生在什么时候。
  • 当前版本和你提交的版本有哪些差异。
  • 是否允许基于最新版本重新应用你的修改。

也就是说,乐观锁不是只加一个 version 字段就结束了。真正影响体验的是冲突发生后的处理流程。

库存场景里的区别#

用库存扣减来看,两种锁的差异会更清楚。

悲观锁会先锁定商品库存行,然后检查库存并扣减:

BEGIN;

SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;

-- 应用层判断 stock > 0

UPDATE products
SET stock = stock - 1
WHERE id = 1;

COMMIT;

同一时间只有一个事务能修改这行库存,逻辑直观,但热点商品会产生等待。

乐观锁则直接用条件更新:

UPDATE products
SET
  stock = stock - 1,
  version = version + 1
WHERE id = 1
  AND stock > 0
  AND version = 7;

更新成功表示抢到了库存;更新失败表示库存不足或版本已变化,需要重新读取后再决定是否重试。

在真实系统里,库存场景常常还会配合“库存预占”或“购物车保留时间”。这其实也是一种隔离:先把库存从公共池里暂时挪到某个订单或用户名下,支付超时再释放。它不一定比锁简单,但业务表达更清晰。

死锁#

悲观锁最典型的问题是死锁。

死锁一般需要几个条件同时成立:

  • 多个执行单元持有不同资源。
  • 每个执行单元都在等待别人手上的资源。
  • 已持有的资源不会主动释放。
  • 等待关系形成了环。

例如:

事务 A:锁住 user:1,等待 order:1
事务 B:锁住 order:1,等待 user:1

A 等 B,B 等 A,双方都无法继续。

处理死锁有两类方式。

第一类是检测和恢复。数据库通常会检测死锁,一旦发现,就选择一个事务回滚,让另一个事务继续执行。应用层要做的是捕获这类错误,并根据业务决定是否重试。

第二类是超时控制。等待锁超过一定时间后直接失败,避免请求无限挂起。超时不是从根上消灭死锁,但能限制故障影响范围。

更重要的是预防。常见做法有:

  • 统一加锁顺序:所有代码都按同样顺序申请资源,比如总是先锁用户,再锁订单。
  • 减少锁范围:只锁真正需要修改的数据,不要顺手锁一大片。
  • 缩短事务时间:事务里不要做网络请求、文件处理、复杂计算。
  • 拆分长流程:把需要用户参与或外部服务参与的流程拆成多个状态转换,不要用一个长事务包住。
  • 让锁有明确边界:知道锁保护的是哪一组资源,而不是“先锁了再说”。

锁粒度#

锁粒度指的是一把锁覆盖多大的范围。

粗粒度锁覆盖范围大,比如“锁整个用户账户”。它的好处是简单,不容易漏掉相关资源;坏处是并发能力差,很多本来互不影响的操作也会互相等待。

细粒度锁覆盖范围小,比如“只锁某一条地址记录”。它的好处是并发能力好;坏处是复杂,容易出现加锁顺序不一致、资源组合不完整、死锁概率上升等问题。

实际系统通常要找一个业务核心。例如用户资料和用户地址经常一起更新,那么可以考虑以用户为核心,把相关修改都收敛到用户维度的并发控制上。这样代码会更清晰:入口统一,版本号统一,加锁顺序也更容易统一。

这件事在乐观锁里同样存在。假设用户表有 version,地址表也有 version,当用户地址变化会影响用户资料摘要时,到底检查哪一个版本?如果每个子资源都各自为战,冲突处理会很混乱。此时更好的方式可能是找出聚合根,让一组强相关资源共享一个版本边界。

不要只依赖锁#

锁很重要,但它不是并发控制的全部。

很多时候,唯一约束、幂等键和状态机也能解决问题,而且更符合业务语义。

例如“同一个用户同一个活动只能领一次券”,与其在应用层先查再加锁,不如在数据库里加唯一约束:

CREATE UNIQUE INDEX uniq_coupon_claim
ON coupon_claims(user_id, campaign_id);

然后直接插入。插入成功表示领取成功,唯一约束冲突表示已经领过。这种方案通常比“先查询是否领过,再决定是否插入”更可靠。

再比如支付回调可能重复到达,就应该使用幂等键和状态机:

pending -> paid -> refunded

如果订单已经是 paid,重复的支付成功回调就不应该再次加余额、发货或发券。

这些手段和锁并不冲突。好的并发设计往往是组合拳:数据库约束兜底,事务保证原子性,锁控制冲突窗口,版本号处理用户编辑冲突,幂等性处理重复请求。

怎么选择#

可以用下面几个问题来判断:

  • 冲突频率高吗?高频冲突更偏向悲观锁、队列化或库存预占。
  • 冲突代价高吗?资金、库存、权限这类场景要更保守。
  • 用户能接受重试吗?能接受就可以考虑乐观锁。
  • 操作耗时长吗?耗时长就不要长时间持有悲观锁。
  • 是否有天然唯一约束?如果有,优先让数据库约束兜底。
  • 是否会跨多个资源?如果会,要先设计统一的加锁顺序或聚合边界。

一句话总结:

  • 悲观锁适合冲突高、代价高、必须串行化的场景。
  • 乐观锁适合冲突低、读多写少、允许重试的场景。
  • 唯一约束和幂等性适合表达“只能发生一次”的业务规则。
  • 锁粒度和加锁顺序决定系统能不能长期稳定运行。

并发控制的目标不是“把所有东西都锁起来”,而是在正确的业务边界上限制冲突,让系统既不乱,也不慢。

聊聊并发控制:乐观锁、悲观锁与死锁
https://wsafight.github.io/personBlog/posts/concurrency-locks/
作者
wsafight
发布于
2022-05-08
许可协议
CC BY-NC-SA 4.0