一、什么时候该上乐观锁
先说结论:乐观锁不是"更好的锁",它只是在冲突概率低的时候,比悲观锁便宜得多的一种做法。判断标准只有两条——同一行数据会不会被两个请求同时改,以及改的频率高不高。如果答案是"会,而且很频繁",用乐观锁会不断失败重试,反而比 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 以上,说明冲突已经影响到用户体验,需要立刻看是哪个代码路径在集中更新同一行。
九、上线前的检查清单
- 确认表上已有
version字段且默认值 0,并 grep 全项目所有UPDATE该表的位置,逐处确认都带上了版本自增。 - 确认所有乐观锁更新的返回值都检查了
rowCount(),没有遗漏。 - 确认重试次数有上限,并且带随机退避。
- 确认库存类字段用的是原子更新,而不是"查出来减一再写回"。
- 用并发脚本压一次,比对"成功次数"与"数据实际变化量"是否严格一致。
- 加监控:冲突次数、平均重试次数、行锁等待时间。
十、小结
乐观锁不是银弹,它是"低冲突场景下最便宜的并发保护"。真正决定成败的不是版本号本身,而是三件事:所有写路径都维护版本、每次都检查影响行数、重试要有上限。绝大多数线上"超卖""重复发货"事故,根子都在第一件事没做全。选方案时优先考虑更简单的形态——原子 UPDATE 和条件更新能覆盖的场景,就别上版本号;只有跨进程互斥才考虑 Redis 锁。