真正起作用的是update语句中带校验的where条件,如where id=123 and stock>=1 and version=10,必须原子执行并检查row_count()是否为1;version非必需,单状态字段(如stock)自身即可作乐观锁条件,但需确保autocommit关闭、索引存在及字段为unsigned类型。

UPDATE 带条件更新才是真正的乐观锁落地方式
很多人误以为“加 version 字段 + SELECT 再 UPDATE”就叫用了乐观锁,其实没用对——真正起作用的是 UPDATE 语句里那个带校验的 WHERE 条件。只要这个条件包含业务约束(比如 stock >= 1)或版本字段(比如 version = @old_version),且整个更新是原子执行的,才算有效。
InnoDB 对这类语句天然加行级排他锁(X 锁),只锁住匹配的那行,不依赖事务外的 SELECT;同时 WHERE 和 SET 在一个原子操作中完成,不存在“读到旧值→判断→写入”的窗口期。
-
UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 123 AND stock >= 1 AND version = 10—— 成功则影响行数为 1,失败为 0 - 应用层必须检查
ROW_COUNT()或驱动返回的受影响行数,不能只看 SQL 是否执行成功 - 不要在 UPDATE 前做
SELECT ... FOR UPDATE,那已经变成悲观锁了,还多一次网络往返
version 字段不是必须的,stock 自身就能当乐观锁条件
库存扣减场景下,stock 本身就是状态凭证,没必要强加 version 字段。直接用 WHERE id = ? AND stock >= ? 更轻量、更直观、更少出错。
比如扣 1 件: UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock >= 1;扣 N 件: UPDATE goods SET stock = stock - N WHERE id = 123 AND stock >= N。只要数据库字段是 UNSIGNED 类型,哪怕并发写入失败也不会出现负数(会报 ER_DATA_OUT_OF_RANGE 错误,可捕获处理)。
- 加
version只在需要追踪“谁改过多少次”或跨多个字段协同更新时才有意义 - 单字段状态变更(如库存、余额、点赞数)用字段自身做条件,语义清晰、索引友好、无额外存储开销
- 如果真要加
version,务必给它建索引,否则WHERE version = ?可能走全表扫描,锁范围失控
容易被忽略的三个坑:autocommit、索引、无符号类型
即使写了正确的 UPDATE 语句,以下三点任一不满足,乐观锁就形同虚设。
-
autocommit = 1(默认):每条语句自成事务,锁在语句结束就释放 → 下一个请求立刻能读到旧值 → 超卖照常发生。必须显式BEGIN+COMMIT,或客户端设置autocommit = 0 - 查询条件没走索引:比如
WHERE status = 1但status列没索引 → InnoDB 可能升级为间隙锁甚至表锁,性能崩,还易触发死锁。确保id是主键或有唯一索引 -
stock不是INT UNSIGNED:超卖时写入负数不会报错,而是静默存入 → 后续WHERE stock >= 1仍可能命中(因负数比较逻辑异常)。必须定义为无符号,让数据库替你兜底
别把乐观锁和重试逻辑混为一谈
乐观锁本身不包含重试。它只负责“这次更新是否生效”,结果只有两种:成功(1 行影响)或失败(0 行影响)。要不要重试、重试几次、间隔多久,是应用层策略,和数据库层无关。
例如用户下单失败,你可以:立即返回“库存不足”;也可以查当前 stock 值后提示“还剩 X 件”;还可以加个简单退避后重试一次(仅限低频关键操作)。但绝不该在数据库里写循环重试逻辑(比如存储过程里 while retry),那会卡住连接、拖垮并发能力。
- 高 QPS 场景(如秒杀)建议失败即返,避免重试放大压力
- 重试必须带最大次数限制和指数退避,防止雪崩
- 所有重试分支都要重新生成新事务,不能复用旧事务上下文











