version字段必须是INT UNSIGNED或BIGINT UNSIGNED且默认值为0;不能用TIMESTAMP或自增ID模拟,否则并发更新会漏判;MySQL无原生乐观锁,全靠应用层在UPDATE中显式校验version值。

MySQL乐观锁version字段怎么设计才有效
直接说结论:version 字段必须是 INT UNSIGNED 或 BIGINT UNSIGNED,且初始值为 0;不能用 TIMESTAMP 或自增ID模拟,否则并发更新时会漏判。MySQL本身不提供原生乐观锁语法,全靠应用层在 UPDATE 语句中显式校验 version 值。
常见错误是把 version 设为 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP——这根本不是乐观锁,而是时间戳覆盖,两个事务同时读取、同时提交时,后提交者会无感知覆盖前者的修改。
-
version字段加INDEX(非必需但推荐),避免全表扫描 - 建表时明确指定
version INT UNSIGNED NOT NULL DEFAULT 0,别用NULL - 不要和业务字段(如
updated_at)混用同一列,语义和更新时机完全不同
扣减余额的UPDATE语句必须带version条件
核心逻辑就这一条 SQL:UPDATE account SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ?。它必须原子执行:检查旧 version 是否未变,再扣钱、再升版。如果 ROW_COUNT() 返回 0,说明已被其他事务抢先更新,当前操作失败。
注意:不能写成两步(先 SELECT 再 UPDATE),中间任何延迟都可能被并发写覆盖;也不能漏掉 AND version = ? 条件,否则退化为普通更新。
- 参数顺序要严格对应:扣减金额、账户ID、期望的旧
version值 - 务必检查执行结果的受影响行数,
0行 ≠ 成功,而是冲突失败 - 别在 SQL 里写
balance >= ?做余额校验——那是业务逻辑,应由应用层在重试前判断,SQL 只负责“扣或不扣”,不负责“能不能扣”
Java应用中怎么安全重试version冲突
冲突不是异常,而是正常业务流。典型做法是捕获 ROW_COUNT == 0 后,重新查最新数据(含新 version 和 balance),再尝试扣减。关键点在于:重试必须有上限,且不能无限循环。
比如用户发起 100 元扣减,第一次读到 balance=200, version=5,UPDATE 返回 0 行 → 说明别人已改过。此时必须重新 SELECT * FROM account WHERE id = ? 拿到最新状态,再判断是否还能扣(比如余额是否还够),然后用新 version 再试一次。
- 重试次数建议 ≤ 3 次,超过则抛业务异常(如
OptimisticLockException) - 每次重试前加
Thread.sleep(1)避免自旋竞争,但别用Random等复杂退避——简单线性等待更可控 - 不要在重试时缓存旧的
balance值,必须每次都从 DB 读,否则可能基于过期余额做错误决策
为什么不用SELECT FOR UPDATE而选version方案
因为 SELECT FOR UPDATE 是悲观锁,在高并发扣减场景下容易引发锁等待甚至死锁,尤其当事务里还有其他非索引条件或关联查询时。而 version 方案无锁、无等待,吞吐更高,适合“读多写少+写冲突低”的账户场景(如电商下单、积分抵扣)。
但它不适合强一致性要求极高的金融核心账务——那里需要最终一致+对账补偿,而不是靠重试掩盖问题。另外,version 对 DDL 不敏感,加字段、改注释不影响逻辑;但一旦有人绕过 ORM 直接执行裸 UPDATE 忘了 version 条件,乐观锁就彻底失效。
- 上线前必须审计所有写
account表的代码路径,确保每处UPDATE都带version校验 - 测试时用
mysqlslap或 JMeter 模拟 50+ 并发扣减,观察冲突率和平均重试次数 - 监控项要包括:单次事务内
UPDATE account的ROW_COUNT = 0比例,持续高于 5% 就得查是不是有长事务或慢查询拖累了版本更新节奏


















