乐观锁因不加行级锁、避免锁等待与死锁,在读多写少、冲突率低场景(如积分变更)中吞吐量更高;其通过UPDATE的WHERE子句原子校验version实现冲突检测,须严格检查影响行数是否为1,否则视为版本冲突。

乐观锁为什么能比 SELECT ... FOR UPDATE 更适合高并发更新场景
因为乐观锁不加数据库行级锁,避免了锁等待和死锁,尤其在读多写少、冲突概率低的业务中(比如用户积分变更、订单状态轻量更新),吞吐量明显更高。它把“加锁-检查-更新”的串行过程,拆成“读取-计算-带条件更新”三步,把冲突检测下推到 UPDATE 的 WHERE 子句里,由 MySQL 原子执行判断。
典型错误是直接用 version 字段做普通 WHERE version = ? 更新,却不校验影响行数——这会让业务误以为更新成功,实际可能被其他事务抢先改过。
- 必须检查
UPDATE返回的受影响行数是否为 1,否则说明发生了版本冲突 -
version字段建议用BIGINT或无符号INT,避免溢出;初始值从0或1开始均可,但要统一 - 不要在应用层做“重试 N 次 + sleep”,而应结合业务语义决定:是报错、合并变更,还是自动重读重算
如何写出安全可靠的乐观锁 UPDATE 语句
核心是让更新动作具备幂等性和原子性。例如对用户余额表 user_account 做 +100 操作,不能先 SELECT balance, version 再拼 SQL,而应一步到位:
UPDATE user_account SET balance = balance + 100, version = version + 1 WHERE id = 123 AND version = 5;
这条语句只有在当前 version 确实是 5 时才会生效,且 balance 和 version 同时更新,不会出现中间态。
- 所有涉及乐观锁的字段(如
version、updated_at)必须在SET和WHERE中显式使用,禁止用应用层变量拼接值 - 如果业务逻辑需要基于旧值计算新值(比如库存扣减后还要记录日志),必须在重试循环里重新
SELECT当前快照,不能复用第一次读的值 - MySQL 5.7+ 支持
VALUES()函数,但乐观锁场景慎用——它只在 INSERT ON DUPLICATE KEY UPDATE 中有效,不适用于普通 UPDATE
MyBatis 中怎么避免手写重复的乐观锁逻辑
用 <update></update> 标签配合动态 SQL 是最稳妥的方式,别依赖插件或第三方乐观锁拦截器——它们常在复杂嵌套更新或批量操作中失效。
<update id="updateWithVersion" parametertype="map">
UPDATE user_profile
SET nickname = #{nickname}, version = version + 1
WHERE id = #{id} AND version = #{version}
</update>
调用后立刻检查 int rows = mapper.updateWithVersion(params),rows == 0 就代表冲突。Spring 事务中不要试图在同一个事务里反复重试,容易拖长事务时间,加重锁竞争。
- Mapper 接口参数建议封装成 DTO,包含业务字段 +
version,避免用Map导致字段名拼错难排查 - 不要给
version字段加@TableField(fill = FieldFill.UPDATE)这类 MyBatis-Plus 自动填充,它会干扰 WHERE 条件构造 - 批量更新不适用乐观锁,因为无法为每行单独校验版本;真有批量需求,得改用分布式锁或业务层分片重试
什么时候乐观锁反而会让性能更差
当冲突率超过 15%~20%,重试成本(多次查询 + 应用层计算 + 网络往返)就会超过排他锁的等待开销。典型场景包括:秒杀库存扣减、高频计数器自增、同一用户短时间内密集修改个人资料。
- 用
SHOW ENGINE INNODB STATUS查看history list length,如果长期 > 1000,说明 MVCC 版本链过长,乐观锁频繁回滚会加剧 purge 压力 - 监控
Rows_affected和重试次数比例,如果平均重试 ≥ 2 次/请求,就得考虑降级方案 - 某些 ORM(如 Hibernate)默认开启二级缓存,但乐观锁版本号变更后缓存未及时失效,会导致脏读——必须手动配置缓存 key 包含
version
乐观锁不是银弹,它的价值不在“去掉锁”,而在把锁冲突从数据库转移到应用层可控路径上。真正难的是判断冲突后该做什么,而不是怎么写那条 WHERE version = ?。











