乐观锁还是原子更新:并发写的四种方案

一、什么时候该上乐观锁

先说结论:乐观锁不是"更好的锁",它只是在冲突概率低的时候,比悲观锁便宜得多的一种做法。判断标准只有两条——同一行数据会不会被两个请求同时改,以及改的频率高不高。如果答案是"会,而且很频繁",用乐观锁会不断失败重试,反而比 SELECT ... FOR UPDATE 慢。

我之前给一个库存扣减功能换过三种方案,踩坑记录很典型:最初的写法是先查再改,两条语句之间没有任何保护,压测时 100 件库存卖出了 103 件;换成悲观锁之后数据准了,但商品详情页被拖到 2 秒;最后改成乐观锁加库存字段原子自减,既准确又没增加等待。这三种写法的差别,其实就是下面要讲的三种思路。

先区分三个容易混的概念,后面所有讨论都建立在这上面:

  • 悲观锁:先锁住再改,数据库帮你挡住并发。行锁、表锁、FOR UPDATE 都属于这一类,代价是持锁期间别人只能等。
  • 乐观锁:不锁,改的时候带一个"我拿到的是第几版"的条件,条件不成立就说明被别人改过,本次失败。
  • 原子操作:干脆不读出来,直接让数据库在一条语句里算完,例如 UPDATE ... SET stock = stock - 1 WHERE stock >= 1。

乐观锁和原子操作经常被混为一谈。区别在于:原子操作只适用于"加加减减"这种不需要读旧值的场景;如果新值依赖旧值(比如"状态从 A 改成 B,并把审核人写进去"),就需要乐观锁来保证这个读-改-写是完整的。

二、版本号方案的完整写法

最经典的乐观锁实现是给表加一个 version 字段,每次更新都带上旧版本号,成功后版本号自增。表结构大概是这样:

ALTER TABLE goods
  ADD COLUMN version INT UNSIGNED NOT NULL DEFAULT 0,
  ADD COLUMN stock INT UNSIGNED NOT NULL DEFAULT 0;

-- 读取
SELECT id, stock, version FROM goods WHERE id = 1001;
-- 假设返回 stock=10, version=7

-- 更新:必须带 version 条件,并且让 version 自增
UPDATE goods
   SET stock = 9, version = version + 1
 WHERE id = 1001 AND version = 7;

关键点在第 4 步执行后检查影响行数。绝大多数人踩的第一个坑就是:只看了 UPDATE 有没有报错,没看 rowCount()。如果影响行数是 0,说明这条数据在这几毫秒里已经被别人改过,本次更新什么都没发生,你必须重读或让用户重试,而不是以为更新成功了。

PHP 里判断影响行数要用 rowCount(),不要用 PDO 的 errorInfo():

$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT stock, version FROM goods WHERE id = ? FOR UPDATE');
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

$upd = $pdo->prepare('UPDATE goods SET stock = ?, version = version + 1 WHERE id = ? AND version = ?');
$upd->execute([$row['stock'] - 1, $id, $row['version']]);

if ($upd->rowCount() === 0) {
    $pdo->rollBack();
    throw new RuntimeException('数据已被其他操作修改,请重试');
}
$pdo->commit();

注意那段代码里我同时用了 FOR UPDATE,这在只改一行的场景下其实多余,但在"读出来、算一下、再改"跨度较长(比如中间还要查另一张表做校验)时是必要的。乐观锁管的是"这条数据是不是我读到的那一版",不保证你读完之后它不被别人读走,所以当你的决策依赖其他数据的一致性时,还是得靠事务加悲观锁。

三、版本号方案的三个真实坑

坑一:ABA 问题。版本号自增能避免这个问题,因为 A→B→A 之后版本号已经变了两次。但如果用的是"用状态值当条件"(比如 WHERE status = 'pending'),两次来回改回 pending 就会骗过检查。凡是需要防止 ABA 的地方,老老实实用自增整数,不要用业务字段兼职。

坑二:所有写路径都要维护版本号。只要有一处代码更新了这行数据却没带 version = version + 1,版本号就停在原地,后续所有乐观锁检查都会失效。这个问题非常隐蔽:表面上乐观锁还在跑,实际上已经变成了"无条件更新"。跑一次统计就能查出来:

-- 找出版本号长时间不动的行(结合 updated_at)
SELECT id, version, updated_at
  FROM goods
 WHERE updated_at < NOW() - INTERVAL 7 DAY
   AND version = 0
 LIMIT 50;

坑三:重试次数没有上限。乐观锁冲突后必须重试,但如果在高并发下无脑 while(true),会形成"越冲突越重试、越重试越冲突"的雪崩。正确做法是给重试设上限(一般 3 次),超过就返回失败让上层处理,并且重试之间加一个短的随机退避(比如 20 到 80 毫秒),避免所有失败请求在同一时刻再次撞车。

四、库存扣减:该用原子 UPDATE 而不是乐观锁

库存是最典型的"数量只能减不能算"的场景,这里用版本号是绕远路。直接把判断条件写进 WHERE,让数据库在一次原子操作里完成"检查 + 扣减":

UPDATE goods
   SET stock = stock - 1, sold = sold + 1
 WHERE id = 1001 AND stock >= 1;
-- rowCount() = 1 表示扣成功,0 表示库存不足

这条语句在 InnoDB 里会对匹配的行加排他锁,天然串行,不需要任何额外机制。它的价值在于把"读、判断、写"压成了一条语句,中间没有可以让别人插进来的窗口。压测时 200 并发扣 100 件库存,结果就是 100 成功、100 失败,一件不多一件不少。

但有一个容易被忽略的前提:必须在事务里,并且要能接受"影响行数为 0 不一定是库存不足"。行被删除、ID 写错,都会返回 0。要区分这两种情况,得再查一次并明确返回错误码,别把两种情况统一报成"库存不足",否则排查时会一头雾水。

还有一种写法是用 SELECT ... FOR UPDATE 先读再改,这在需要判断多个条件(例如"库存够、用户未超限购、优惠券可用")时更合适,因为你可以把三步判断放在同一个事务里。但它要求所有相关查询都走索引,否则行锁会升级成间隙锁甚至表锁,这是它最常见的性能事故来源。

五、订单状态流转:条件更新比版本号更直观

订单状态机是乐观锁的第二大应用场景。用户点了两次"支付",回调也可能重复推送,如果直接 UPDATE orders SET status = 'paid',第二次执行会覆盖第一次的结果,还可能重复发货。

正确写法是把"前置状态"写进 WHERE:

UPDATE orders
   SET status = 'paid', paid_at = NOW()
 WHERE id = 8891 AND status = 'unpaid';

-- rowCount() = 0:说明状态已经不是 unpaid(可能已支付、可能已取消)
-- 这不是错误,而是幂等保护,直接当成功返回即可

这种"条件更新"本质就是乐观锁,只是把版本号换成了业务状态。它比版本号可读性好得多,日志里一眼就能看出"因为状态已经不是 unpaid,所以本次没有改动"。建议凡是状态机变更一律用这个模式,即使表上已经有 version 字段。

配合幂等键效果更好:在订单上存一个唯一的 pay_no(支付平台返回的交易号),把"重复回调"挡在更前面。这样即使两个回调同时到达,第二个会被唯一索引直接拒绝,连状态判断都省了。

六、Redis 分布式锁:只有跨进程才需要

很多人在单机 MySQL 场景下也上 Redis 锁,属于过度设计——数据库行锁已经足够。Redis 锁真正有用的地方是操作跨越了多个进程或多台机器,比如两台机器同时给同一个用户发报表。

最简写法是 SET key value NX PX 30000:NX 保证只有一个人能拿到,PX 给锁设过期时间防止死锁,value 存一个随机串用于安全释放。释放时必须用 Lua 脚本比对再删除,不能用 DEL——否则可能把别人的锁删掉:

if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else
  return 0
end

三个必须知道的限制:过期时间必须大于业务最长执行时间,否则会出现在锁失效后修改数据的经典事故;锁不保证"持有期间数据一致",只保证"同一时刻只有一个人在改";主从切换时锁可能丢失,对一致性要求极高的场景要请上 Redlock 或干脆用数据库。

七、四种方案怎么选

把上面的内容压成一张判断表:

场景推荐方案理由
库存扣减、计数原子 UPDATE一条语句完成,无需读旧值
状态机流转条件更新(状态当条件)可读性最好,天然幂等
读-改-写跨多步版本号乐观锁 + 有限重试不锁,冲突时才失败
跨进程/跨机器互斥Redis 锁数据库锁管不到别的系统

再补一条实践经验:能改成原子操作就不要用乐观锁,能用条件更新就不要用版本号。每引入一个 version 字段,就多了一处需要在所有写路径维护的东西,而维护不到位是这类方案最常见的失效原因。方案越简单,出问题的路径越少。

八、冲突率怎么观测

乐观锁一旦上线,就必须埋点统计冲突率,否则你无法判断它到底是"正常在起作用"还是"高并发下已经变成重试风暴"。最直接的做法是在应用层记录两个数:尝试次数和失败次数,按分钟聚合后看比值。

-- 用 MySQL 自身的行锁统计做粗粒度参考
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits';
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time_avg';

这两个值持续上升,说明行锁竞争在加剧,通常是长事务或缺少索引导致的。而如果你的应用日志里"版本冲突"每分钟超过几十次,就说明这条数据被改得太频繁,要么缩小锁粒度(拆表、按用户分片),要么改成队列串行处理。

还有一个更实用的观测点:每个业务动作的平均重试次数。健康状态下这个数接近 1.0;一旦涨到 1.5 以上,说明冲突已经影响到用户体验,需要立刻看是哪个代码路径在集中更新同一行。

九、上线前的检查清单

  1. 确认表上已有 version 字段且默认值 0,并 grep 全项目所有 UPDATE 该表的位置,逐处确认都带上了版本自增。
  2. 确认所有乐观锁更新的返回值都检查了 rowCount(),没有遗漏。
  3. 确认重试次数有上限,并且带随机退避。
  4. 确认库存类字段用的是原子更新,而不是"查出来减一再写回"。
  5. 用并发脚本压一次,比对"成功次数"与"数据实际变化量"是否严格一致。
  6. 加监控:冲突次数、平均重试次数、行锁等待时间。

十、小结

乐观锁不是银弹,它是"低冲突场景下最便宜的并发保护"。真正决定成败的不是版本号本身,而是三件事:所有写路径都维护版本、每次都检查影响行数、重试要有上限。绝大多数线上"超卖""重复发货"事故,根子都在第一件事没做全。选方案时优先考虑更简单的形态——原子 UPDATE 和条件更新能覆盖的场景,就别上版本号;只有跨进程互斥才考虑 Redis 锁。

Last modification:November 1st, 2026 at 08:24 pm

Leave a Comment