结论:UPDATE ... WHERE 带原始值校验是最轻量可控的MySQL CAS乐观锁实现;它要求明确旧值且字段具业务唯一性,通过WHERE校验旧值防止丢失更新,失败需重试而非报错。

直接说结论:用 UPDATE ... WHERE 带原始值校验的语句,是 MySQL 中最轻量、最可控的 CAS 乐观锁落地方式;它不依赖额外字段,但要求你明确知道“旧值”且该字段具备唯一性或强业务约束(如余额、库存数)。
为什么不能只写 UPDATE account SET balance = balance - 100 WHERE id = 123
这条语句看似能扣钱,但它不是 CAS。它不校验“当前余额是否还是你读出来的那个数”,只要记录存在就执行减法——多个并发请求可能基于同一初始余额(比如 1000)各自减 100,最终变成 700,而不是预期的 800,造成超扣。本质是丢失更新(Lost Update)。
常见错误现象:
- 用户 A 和 B 同时查到余额为
1000,都发起扣100请求 - 两条
UPDATE全部成功,影响行数都是 1 - 最终余额变成
800(而非正确的900),差额100被静默吞掉
UPDATE ... WHERE balance = ? 是最简 CAS 实现
这才是真正的比较并交换:把“读到的旧余额”作为 WHERE 条件,只有数据库当前值等于它,才允许更新。
实操建议:
- 应用层必须先
SELECT balance FROM account WHERE id = 123,拿到当前值(比如1000) - 然后执行:
UPDATE account SET balance = 900 WHERE id = 123 AND balance = 1000;
- 检查
ROW_COUNT()(MySQL 返回的影响行数):等于 1 表示成功;等于 0 表示失败(余额已被他人改过) - 失败时不要直接报错,应重试(最多 3 次)或降级为悲观锁
注意点:
面向 CRM 系统的 MySQL 8 数据库模式设计。当用户需要为客户关系管理(CRM)系统设计、评审、优化或生成数据库模式时,应使用此项技能。
- 该方式要求
balance字段在业务上“不会被其他无关逻辑修改”,否则校验容易误失败 - 如果表有多个写入方(比如同时走财务系统和订单系统),需确保所有路径都遵守同一套 CAS 协议
- 避免在 WHERE 中混用函数或表达式(如
balance + 0),会破坏索引使用,也增加判断歧义
什么时候该换用 version 字段?
当你无法可靠获取“旧值”,或者一个记录有多个可变字段(比如既要扣余额,又要改状态、更新时间戳),用单个字段做 CAS 就不可靠了——因为 WHERE 条件只能校验一个旧值,而你实际关心的是“整行没被别人动过”。
这时加 version 字段更稳:
- 建表时加
version INT DEFAULT 0 - 读数据时一起查出
version(如SELECT balance, version FROM account WHERE id = 123) - 更新时:
UPDATE account SET balance = 900, version = version + 1 WHERE id = 123 AND version = 5;
- 同样靠影响行数判断成败;失败则重读 + 重算 + 重试
优势:
- 与具体业务字段解耦,
balance被谁改、怎么改都不影响校验逻辑 - 天然支持多字段联合更新的原子性保障
- 便于审计:每次变更都有版本递增痕迹
容易被忽略的关键细节
CAS 不是银弹,这几个点不处理好,照样出问题:
-
UPDATE语句必须在事务内执行(哪怕只是单条),否则在READ-COMMITTED隔离级别下,WHERE 条件可能读到旧快照,导致误判 - 重试逻辑不能无限循环,要设上限(如 3 次)+ 退避(如 10ms 随机延迟),否则高并发下可能引发雪崩式重试
- 如果扣减失败后需要通知用户,返回的错误信息不能暴露原始余额(安全合规要求),而应统一提示“操作太慢,请重试”
- MyBatis 等 ORM 框架自动生成的
UPDATE通常不带 CAS 条件,必须手写SQL或显式配置where子句










