人人都会AI编程

16.5 悲观锁与乐观锁的实现与选型

更新时间:2026-07-10

在高并发环境下,当多个事务同时操作同一行数据时,如何保证数据不被错误覆盖,是每个开发者都要面对的问题。MySQL 提供了两种经典的解决思路:悲观锁乐观锁。它们不是数据库内置的某种特殊语法,而是两种不同的编程策略,配合 InnoDB 的行锁和事务机制来实现。

理解它们的区别,并能在合适的场景做出正确的选择,是写出健壮业务代码的基本功。

16.5.1 悲观锁:先锁住,再操作

核心思想:悲观锁假定每次操作都会发生冲突,所以在读取数据时就主动加上锁,确保自己操作完成之前,别人无法修改这条数据。这就像你去食堂打饭,先把饭盘按在自己面前,再放心地盛菜。

在 MySQL 中,悲观锁主要通过 SELECT ... FOR UPDATE 实现。

实现步骤(以扣减库存为例)

-- 开启事务
START TRANSACTION;

-- 查询当前库存,同时对这行数据加排他锁(X锁)
SELECT stock FROM product WHERE id = 1 FOR UPDATE;

-- 在应用层计算新库存
-- new_stock = stock - 1

-- 更新库存
UPDATE product SET stock = new_stock WHERE id = 1;

-- 提交事务,释放锁
COMMIT;

当第一个事务执行 SELECT ... FOR UPDATE 后,其他事务如果也尝试对同一行执行 SELECT ... FOR UPDATE 或执行修改该行的 UPDATE,都会被阻塞,直到第一个事务提交或回滚。

关键细节

  • FOR UPDATE 加的是排他锁(X 锁),连读都不允许其他事务加锁。如果只是想防止其他事务写,但允许别人读,可以使用 SELECT ... FOR SHARE(MySQL 8.0 新增,等价于之前的 LOCK IN SHARE MODE),它加的是共享锁(S 锁)。
  • 悲观锁必须在事务内部使用,且要锁定明确的行。如果 WHERE 条件没有命中索引,InnoDB 可能会退化为锁住所有扫描过的行甚至间隙,导致大面积阻塞,这是线上事故的常见原因。
  • 事务要尽可能短小:先查询,计算,立即更新提交。绝对不要在 FOR UPDATE 之后等待用户输入,或调用外部慢速接口,否则锁会被长时间持有,系统吞吐量急剧下降。

适用场景

  • 数据竞争激烈,冲突概率高,例如秒杀扣库存、抢红包。
  • 业务逻辑复杂,需要先查询再决定如何修改,且不希望中间被其他事务干扰。
  • 对一致性要求极高,不容忍任何形式的覆盖。

16.5.2 乐观锁:先操作,提交时检查冲突

核心思想:乐观锁假定冲突很少发生,所以你尽管读取数据、进行计算,等到真正写回数据库时,再检查一下这行数据有没有被别人动过。如果动过,就放弃这次修改,重新尝试或抛出错误。这就像你记下一个文件的最后修改时间,编辑完后保存时,检查时间没变才允许覆盖,否则提示文件已被他人修改。

在 MySQL 中,乐观锁通常通过版本号(version)时间戳(timestamp)字段来实现。

实现步骤(以用户余额转账为例)

表结构需要额外添加一个版本字段:

ALTER TABLE account ADD COLUMN version INT NOT NULL DEFAULT 0;

实际业务操作:

-- 先查询余额和当前版本号(不加任何锁)
SELECT balance, version FROM account WHERE user_id = 1;
-- 假设得到 balance = 100, version = 5

-- 在应用层计算新余额
-- new_balance = balance - 20

-- 更新时带上版本号条件,并同时递增版本号
UPDATE account 
SET balance = new_balance, version = version + 1 
WHERE user_id = 1 AND version = 5;

执行完 UPDATE 后,检查受影响的行数:

  • 如果 affected_rows = 1,说明版本号没有变,更新成功。
  • 如果 affected_rows = 0,说明版本号已经不是 5 了,意味着在读取和更新之间,有另一个事务修改了这行数据。此时你需要根据业务策略处理:可以重试(重新查询、计算、更新),也可以抛出异常让用户稍后再试。

关键细节

  • 乐观锁的核心是 CAS(Compare And Swap) 思想,由应用层自己控制冲突逻辑,数据库本身不加额外的锁。
  • 版本号字段必须在每次修改时递增,而且必须原子性地与业务数据一起更新,因此天然是事务安全的。
  • 如果使用时间戳字段,要注意时钟回拨和高并发下时间精度不够导致的问题,推荐用版本号整数。
  • 重试逻辑要加上次数或时间上限,避免死循环。

适用场景

  • 读多写少,冲突概率低,比如用户资料更新、文档编辑。
  • 事务不希望长时间持有锁,想尽量快进快出。
  • 业务允许提交失败后重试,或可以友好地提示用户“数据已被修改”。
  • 系统架构要求减少数据库锁竞争,提高整体并发能力。

16.5.3 悲观锁 vs 乐观锁对比

| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心机制 | 数据库行锁(X/S 锁) | 应用层版本号校验 |
| 加锁时机 | 读取时立即加锁 | 更新时检查版本 |
| 冲突处理 | 阻塞等待或死锁 | 失败重试或直接返回错误 |
| 并发性能 | 冲突多时等待较多,吞吐量降低 | 冲突少时几乎无锁开销,吞吐量高 |
| 实现复杂度 | 依赖数据库,SQL 简单,但要注意死锁 | 需要改造表结构,增加版本字段和重试逻辑 |
| 适用场景 | 高冲突、长事务(但事务要短) | 低冲突、读多写少、高性能吞吐 |
| 死锁风险 | 有,且需要显式处理 | 无 |
| 数据一致性 | 强一致 | 最终一致,需要业务容忍短暂不一致 |

16.5.4 真实选型建议

没有银弹式的“最佳方案”,只有最适合场景的选择。以下是几条实战经验:

  1. 热点数据的并发写入,优先考虑悲观锁

比如秒杀下单的库存扣减,多个线程几乎同时减库存,冲突概率很高。用乐观锁会导致大量重试,白白消耗 CPU 和数据库连接。直接用 SELECT ... FOR UPDATE 排队执行,虽然串行化,但逻辑清晰且稳定。配合 16.4 节提到的热点行更新优化(将库存拆分到多条记录),能进一步提升并发。

  1. 用户资料更新、订单状态流转,用乐观锁更合适

这类场景读多写少,用户并不会在毫秒级内频繁修改同一个信息,冲突概率极低。不加锁的查询响应快,用户体验好。前端拿到版本号,提交时带上版本校验,是比较通用的设计模式。

  1. 长业务逻辑,避免使用悲观锁

如果事务中包含计算密集、调用外部接口等耗时操作,千万不能用 FOR UPDATE 一直占用数据库锁。应该用乐观锁,或者把数据先查出来,在应用层做复杂处理,最终用版本号更新的方式提交。

  1. 能重试的业务用乐观锁,不能重试的用悲观锁

乐观锁失败后需要业务层自行重试。如果业务特性决定了重试成本很高(比如转账操作需要记录流水,重试会导致流水重复),那悲观锁的“一次成功”特性更稳妥。

  1. 混合使用也未尝不可

同一个系统里,不同业务场景可以使用不同的锁策略。例如,商品库存扣减用悲观锁,商品基础信息修改用乐观锁。不要被一种模式框死,根据数据竞争的特性灵活选择。

最后,无论选择哪种锁,都务必在代码里处理好异常和超时。悲观锁可能会抛出死锁异常(1213),乐观锁需要判断 affected_rows == 0 的情况。线上环境的可靠性,往往就体现在这些细枝末节的处理上。