MySQL乐观锁实战:版本号、时间戳与CAS条件更新的正确写法

发布时间:2026/10/19 5:05:20
MySQL乐观锁实战:版本号、时间戳与CAS条件更新的正确写法 上个月活动大促压测我们一个商品库存模块翻了个跟头一个 SKU 只剩 1 件两台应用节点几乎同时收到“扣减 1 件”的请求两个事务都从 stock 读到 1也都成功执行了UPDATE ... SET stock 0结果同一件商品卖给了两个人。排查后改成了UPDATE goods SET stock stock - 1 WHERE id ? AND stock 1问题才消失。这段带条件的 UPDATE就是乐观锁最常见的形态之一。乐观锁在 MySQL 里的落地方式远不止“加个 version 字段”这一种。这篇文章从实际项目出发拆解三种能直接上线的实现方式版本号字段、时间戳字段、业务条件字段 CAS并把重试逻辑、事务边界、常见挖坑点都讲清楚。适合正在做订单、库存、钱包、票务这类并发更新模块或者被“明明加了乐观锁怎么还会覆盖数据”折磨过的后端工程师。1. 乐观锁根本不是锁先搞懂 CAS 在数据库上是怎么生效的很多人以为乐观锁是应用层逻辑加个字段、在代码里比较一下就行。真正让它生效的是 MySQL 的“当前读”机制。没搞懂这一点后面写 SQL 全是猜。1.1 一次 UPDATE 在 MySQL 里到底做了什么普通SELECT走的是快照读MySQL 默认隔离级别是 REPEATABLE READ事务里第一次读会生成一个快照之后读到的都是这个快照里的历史版本所以快照读不用加锁也不会被别的事务阻塞。但UPDATE不一样它走的是当前读数据库必须拿到该行最新的已提交版本并对命中的记录加行级排他锁。这个锁一直持有到事务提交或回滚。换句话说UPDATE ... WHERE version ?这条语句数据库在执行时要做三件事加锁、读取当前最新值、用最新值去匹配 WHERE 条件然后才决定是否更新。这三件事在同一条 SQL 内部是原子的中间不存在别的会话插进来的窗口。乐观锁的本质是 CASCompare-And-Swap把“比较”和“写入”合并成一条数据库原子的 UPDATE 语句。WHERE 条件是比较SET 是写入。数据库的行锁保证了同一时刻只有一个事务能对这一行做比较和写入。1.2 为什么“先 SELECT 判断再 UPDATE”会失效我先给一段经典的错误示范。初学者最容易写成这样Goods goods goodsMapper.selectById(goodsId); if (goods.getVersion() ! 1) { throw new BusinessException(版本冲突); } goodsMapper.updateById(goods);这段代码有窗口期。selectById读到 version1 之后另一个事务可能已经把这行改成 version2 并提交了。此时你拿着 version1 继续updateById如果更新语句里没有WHERE version 1它依然会基于最新行执行把别人的修改覆盖掉。那“判断版本”就成了一句空话。理解这个问题的关键在于SELECT 和 UPDATE 之间是异步的只有把版本条件写进 UPDATE 的 WHERE 里才能让校验和写入在同一条语句中原子完成。你查出来 version1 不代表此刻数据库里还是 1但UPDATE ... WHERE version 1能保证只有当前版本确实还是 1 时这行才会被更新。1.3 乐观锁和悲观锁的分工边界乐观锁不是万能的。悲观锁SELECT ... FOR UPDATE的思路是提前占位你先把行锁住别人想动就得等。乐观锁的思路是冲突时放弃通过重试再来一次。判断用哪种核心看冲突率冲突率低单行更新频率不高大部分时间不同事务更新不同行乐观锁合适省去锁等待的阻塞读多写少场景下吞吐更高。冲突率高热点行、秒杀、同一行高频更新乐观锁的重试会触发风暴——每个失败事务都要重新读一次快照再重新算一次逻辑数据库压力成倍放大。这时候还不如SELECT ... FOR UPDATE排队执行或者把请求做限流、队列化。我的一般经验是如果实测单行冲突率超过 20%乐观锁基本可以放弃如果低于 5%乐观锁很舒服。2. 版本号字段最主流的实现但重试逻辑写错就全完了版本号是最标准的乐观锁实现方式也最不容易出错。但多数人踩坑恰恰不是 SQL 写错而是重试时把旧值当成新值写了回去。2.1 表结构和一条 SQL先看一个典型的库存表设计CREATE TABLE goods ( id BIGINT PRIMARY KEY, stock INT NOT NULL, version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL );扣减库存的核心 SQLUPDATE goods SET stock stock - 1, version version 1 WHERE id #{id} AND version #{oldVersion};这条 SQL 返回受影响行数1表示更新成功0表示版本冲突。为什么要在 SET 里写version version 1因为如果只WHERE version oldVersion却不在 SET 里增加版本号下一次其他事务就读不到新版本版本号永远不变CAS 就失效了。为什么不直接WHERE stock 0那样也行但它只保护了“库存不为负”这一条约束无法保护更复杂的业务一致性。版本号是通用的能适配任意“基于旧值做判断”的逻辑——余额、状态、层级关系都能套。2.2 重试的正确姿势重新读、重新算、再写这是最容易翻车的环节。我见过不少代码这么写// 错误示范newStock 和 oldVersion 都在循环之前算好 Goods goods goodsMapper.selectById(goodsId); int newStock goods.getStock() - 1; int oldVersion goods.getVersion(); for (int i 0; i 3; i) { int affected goodsMapper.decreaseStock(goodsId, newStock, oldVersion); if (affected 1) { return; } } throw new RetryExhaustedException(扣减失败);这个写法的问题在于第一次执行失败时说明另一个事务已经成功修改过这行了。此时newStock和oldVersion都是过期的快照数据。重试时把它们原样发给数据库WHERE version 匹配不上返回 0就算你侥幸改了一个 SQL 让 version 匹配上也等于把别人刚提交的库存修改覆盖掉。正确做法是把整个“读-计算-写”三段式放进重试循环里for (int i 0; i 3; i) { Goods goods goodsMapper.selectById(goodsId); // 1. 重新读 int newStock goods.getStock() - 1; // 2. 基于最新值计算 int oldVersion goods.getVersion(); int affected goodsMapper.decreaseStock(goodsId, newStock, oldVersion); // 3. 写 if (affected 1) { return; } sleep(randomDelay(10, 50)); // 冲突后随机退避避免两个节点同步重试 } throw new RetryExhaustedException(扣减失败);重试的不是那条 SQL而是整个“读-计算-写”。第一次失败说明你手里的数据已经过期再来一次必须重新读最新版本。2.3 重试上限和冲突率估算重试次数一般建议 3 次上下。每次重试间隔用随机退避1050 毫秒足够别用固定间隔否则多个节点在同一时刻一起重试等于给数据库瞬间制造一批并发。还有一个容易忽略的点每次重试失败都打一条日志记录业务单号、商品 ID、旧版本号、重试次数。你可能会想重试成功不就没事了吗其实每一次重试成功都说明线上发生过一次真实的数据竞争。如果日志量突然上涨说明冲突已经不再是偶发现象该优化业务或者换锁策略了。3. 时间戳字段为什么适合低并发模块以及精度陷阱用时间戳字段做乐观锁原理和 version 完全一样只是把整数版本号换成了更新时间。不少表本来就有update_time字段顺手就拿它做校验省一个字段。实际用下来我只建议在低并发模块这么做。3.1 用 update_time 做乐观锁的写法表结构里需要一个高精度时间字段。如果你用DATETIME或TIMESTAMP建议带上小数秒CREATE TABLE goods ( id BIGINT PRIMARY KEY, stock INT NOT NULL, update_time DATETIME(3) NOT NULL );更新 SQLUPDATE goods SET stock stock - 1, update_time NOW(3) WHERE id #{id} AND update_time #{oldUpdateTime};NOW(3)生成带毫秒精度的当前时间。业务里先在应用层把update_time读出来更新时把它塞进 WHERE。如果另一个事务先改了这行时间戳变了你的 WHERE 就匹配不上返回 0。3.2 和 version 字段相比它差在哪我总结下来有三个短板无法表达“第几次修改”。version 是严格递增的整数每次更新 1天然有序。时间戳只是一个时刻同毫秒内两次不同的事务完全可能落到相同的毫秒数。依赖系统时钟。数据库服务器的时钟一旦回拨或者运维手贱把时间改正时间戳会倒退所有基于它的 CAS 全部失效。重试时容易拿到旧事务的时间戳。如果重试代码没有重新 SELECT 而直接复用第一次查出来的oldUpdateTime版本条件必然匹配失败重试等于白做。所以时间戳方式更适合“更新频率很低、本来就记录更新时间、系统时钟稳定”的场景比如后台配置表、字典表、低频状态表。在这些场景里它比 version 多了一个好处更新后不用靠应用层去维护额外的 version 值直接读 update_time 就是最新版本号。3.3 精度问题是怎么悄悄吞数据的曾经有个秒杀预热项目并发量并不高但上线第一轮压测就发现库存对不上。排查过程很有意思所有扣减 SQL 都返回了影响行数 1没有任何报错库存就是少了。后来把 SQL 日志和数据库 binlog 对上才发现两个事务在同一毫秒内先后更新了同一行。第一个事务执行WHERE update_time ...成功把时间戳更新为新值第二个事务的NOW(3)恰好和第一个事务更新后的NOW(3)落在同一毫秒于是它的 WHERE 匹配到的依然是最新时间戳也被判定为成功。两个事务都觉得自己拿到了最新版本实际第二个事务覆盖的是第一个事务刚提交的数据。version 不会出现这个问题因为整数递增是严格单调的重复的概率为零。如果你的更新频率可能超过每秒几百次时间戳方案基本可以排除。4. 业务条件字段 CAS状态机流转里最顺手的实现第三种实现方式最容易被忽略但在我看来最优雅直接用业务字段本身作为 CAS 条件。库存表的stock ?、订单表的status PENDING都属于这一类。4.1 订单支付的标准 CAS订单支付是最典型的场景。一个订单只能从待支付变成已支付状态本身就是业务约束。两个支付请求同时到达时正确的 SQL 只有一条能执行成功UPDATE orders SET status PAID, pay_time NOW() WHERE order_id #{orderId} AND status PENDING;两个并发事务同时发这条 SQL数据库的行锁会让其中一个先执行、先提交另一个等锁释放后重新评估status PENDING此时状态已经是 PAID匹配不上影响行数为 0。你不需要 version 字段不需要时间戳状态字段本身就是最好的校验条件。这种方式的优点很明显SQL 可读性强业务人员一眼能看懂“只有待支付订单才能被支付”而且不需要额外维护一个和业务无关的版本字段。4.2 库存“带量 CAS”写法库存扣减是另一个常见场景。回到文章开头的那个案例UPDATE goods SET stock stock - #{num} WHERE id #{goodsId} AND stock #{num};这条 SQL 能防超卖的核心机制是行锁串行化两个并发事务同时执行第一个拿到行锁扣减后提交第二个等锁释放后会基于最新的 stock 重新评估stock num如果不足影响行数就是 0。这就完成了一次天然的口径校验不需要额外字段。这里我特别建议用表达式stock stock - #{num}而不是SET stock #{newStock}。表达式更新发生在当前读并获得行锁之后数据库会基于最新值计算而SET stock #{newStock}里的newStock是应用层塞进去的固定值一旦你的应用层读取的是旧快照写回去就会覆盖别人的修改。4.3 警惕 ABA 问题状态绕了一圈回来CAS 的最大风险是 ABA 问题。业务字段不是单调递增的它是可以来回变化的。举一个真实踩过的场景审核流。某条申请单的状态是待审核 → 通过 → 驳回回到待审核。如果有个旧请求在网络里延迟了很久才到达它携带的期望状态是待审核而此刻单据刚好被上一个操作驳回回了待审核这个旧请求会误判为当前状态匹配执行成功——尽管它针对的是上一次流转而不是当前这轮。解决 ABA 的办法通常是再加一层约束状态字段之外加一个 version或者加一个操作流水号。状态 CAS 很难单独扛住“数据绕了一圈又回到原值”的场景。所以如果业务字段可能回绕必须叠加 version 或流水号。4.4 什么时候 CAS 优先于版本号只有当“业务字段的值本身可以作为判断依据”时CAS 才优先。最典型的就是状态机和库存余额这类带约束条件的数值。状态机场景里CAS 是我首选。它字段少、逻辑直观、SQL 可读而且不会出现 version 忘记1这种低级错误。数值范围的场景比如库存余额非负用stock num这种带量 CAS 就足够没必要额外加版本号。但如果是“基于旧值做复杂计算后整体覆盖”的逻辑version 会更稳妥。5. 三种方案在真实业务里踩过的坑含一段完整排查链路乐观锁的坑基本都在重试逻辑、事务边界和框架使用方式上。我挑几个有代表性的展开讲。5.1 重试复用旧数据少扣库存的完整排查过程先说现象。某天线上报表发现一个星标商品库存异常后台显示库存 10用户下了两件订单库存只减了 1。订单流水和库存流水对不上。排查从应用日志开始。日志显示第二个请求的第一次 UPDATE 返回影响行数 0走了重试重试后的 UPDATE 也返回 0但代码没有检查返回值直接继续往下走最终接口返回成功。订单创建了库存扣减却没生效。然后我们回放数据库 binlog 和慢 SQL发现重试时发出的 SQL 是UPDATE goods SET stock 9, version 2 WHERE id 1 AND version 1;stock 的 9 是第一次请求查到的10 - 1的结果version 的 1 也是第一次请求查到的旧版本号。这个 SQL 在第一次执行时因为版本不匹配返回 0重试时还是同样的值依然不可能匹配成功。重试只是重发了一遍必然失败的语句。根因有两个一是重试代码没有重新 SELECT 最新数据把第一次计算出的newStock / oldVersion当成了终值二是调用方没有检查 update 返回值把“执行成功”等同于“更新成功”。修复方式就是 2.2 里的正确写法把“读-计算-写”整体放进循环并且强制检查受影响行数。这类问题的排查经验线上一定要在日志里带上oldVersion / oldUpdateTime / 业务单号 / 重试次数这几个字段否则只能靠翻 SQL 日志和 binlog 猜。5.2 事务边界放错重试把前面的写操作一起卷进来Spring 环境下最隐蔽的坑是重试循环和事务注解的位置。假设 Service 方法上有Transactional循环写在方法内部Transactional public void deduct() { for (int i 0; i 3; i) { try { int affected goodsMapper.decreaseStock(...); if (affected 1) { operationLogMapper.insert(...); // 写日志 return; } } catch (Exception e) { // 吞掉异常继续重试 } } }问题在于如果第一次 update 失败而循环开始的时机已经在事务里执行了其他写操作比如插日志、更新统计Spring 事务在某个子操作抛出异常时会把整个事务标记为 rollback-only。你 catch 住异常继续重试第二次 update 成功了但整个事务提交时可能依然判定为回滚或者部分操作被提交产生和预期不一致的结果。我的建议是把重试循环放在事务外面。每次重试调用一个独立的事务方法PROPAGATION_REQUIRES_NEW失败就整体回滚重试时重新开启新事务。这样不会把上次失败事务里的半截写操作带进成功事务。5.3 MyBatis-Plus 的乐观锁插件被 set 覆盖有些团队用 MyBatis-Plus 的OptimisticLockerInnerInterceptor实现乐观锁。这个插件会自动给updateById(entity)语句拼接version version 1和WHERE version ?。但我在别人的项目里见过一个坑实体里有Version注解更新方法里又手写了 update set 语句把version显式写进去导致插件生成的 version 自增被覆盖版本条件形同虚设。正确打开方式Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }实体类的 version 字段加注解Version private Integer version;更新时传入的 entity 必须带上 version 字段值插件会自动帮你做条件判断和自增。如果你需要写自定义 SQL就老老实实自己SET version version 1别和插件混用两者逻辑很容易互相覆盖。5.4 先删后插的 version“回魂”最后一个坑比较小众但对账修复类系统会遇到业务上经常“逻辑删除一条记录再插入一条新的”。如果这行的主键不变比如用业务主键新插入的记录 version 又从 0 开始那么残留的旧请求如果带的是原来更高的版本号可能匹配不到新记录更麻烦的是如果新记录的 version 又涨回去旧请求也能踩中。解决方式有两种一是物理删除让新记录有全新的主键旧请求携带的主键自然失效二是 version 初始化用当前时间戳的毫秒值或者随机值避免新记录从 0 开始重复。我倾向第二种因为很多系统做了逻辑删除标记物理删除会破坏审计链路。6. 选型判断库存扣减、订单状态、余额变动分别用哪种最后把三种实现方式放到真实的业务场景里做个对比。实现方式额外字段适用场景主要风险重试要求版本号字段version 字段库存、余额等基于旧值计算再覆盖重试复用旧快照循环 2-3 次随机退避时间戳字段高精度 update_time低频配置、后台管理毫秒级重复、时钟回拨低重试成本高业务条件 CAS无状态机流转、库存非负ABA 问题低主要靠行锁串行6.1 库存扣减带量 CAS 优先版本号兜底库存扣减的核心诉求是“不超卖”也就是stock num这个判断。直接用带量 CASUPDATE goods SET stock stock - #{num} WHERE id #{goodsId} AND stock #{num};够用不需要 version。但如果你的库存操作除了扣减还有“设置实际库存为固定值”比如盘点、后台修正这类操作必须基于旧值整体覆盖就得用 version 字段。日常业务里两种方式可以共存扣减走带量 CAS修正库存走 version 校验。6.2 订单状态状态机 CAS 最顺手订单的支付、取消、发货本质都是状态迁移。用状态字段做 CASSQL 最直白UPDATE orders SET status SHIPPED WHERE order_id ? AND status PAID;不需要额外字段。唯一的隐患是 ABA如果状态会回绕比如“审核通过又驳回”那就加一个 version 或者操作流水号辅助判断。6.3 余额变动能用表达式增量就别用 version余额比库存更特殊它涉及双向变动加钱和扣钱是两种操作。如果两个并发请求分别是 100 和 -50用 version 做乐观锁会导致后一个请求总是不必要地失败重试。更好的方式是用表达式增量UPDATE account SET balance balance #{delta}, update_time NOW(3) WHERE account_id #{accountId};balance balance #{delta}是在数据库当前读之后基于最新余额计算的两个并发事务的行锁会天然串行化最终结果一定是 100 100 - 50 150不会丢更新。只有当业务需要“基于用户看到的余额做操作判断”时——比如申请提现前校验用户查询到的 balance 是否为最新——才需要 version 或余额快照约束。6.4 什么时候该放弃乐观锁乐观锁的重试代价决定它的适用范围有限。如果某种更新是热点行高冲突比如大促时的总库存、限量优惠券每次请求都大概率冲突重试带来的额外读和计算会让数据库负载放大好几倍。这时候乐观锁不是优化是负担。我的建议是热点行直接上SELECT ... FOR UPDATE排队执行或者更直接地用计数器、限流、队列把请求承接住让数据库同一时刻只有一个写操作在跑。乐观锁适合的是“冲突偶发、重试几乎不会发生”的业务真到了重试频繁的时候先优化设计而不是调大重试次数。做了这么多年业务系统我接到并发更新需求时的第一反应是先问自己三个问题这一行的数据是只做增量还是要基于旧值做判断冲突概率是高还是低系统能不能接受少量重试这三个问题答完用哪种方案基本不用纠结。版本号用得最多但真正让我省心的反而是状态机里的WHERE status ?字段少、逻辑明。最后分享一个小习惯每次乐观锁冲突后重试成功我都会顺手记一条 WARN 日志。线上如果频繁看到它就去查业务是不是真的需要这么高的并发更新——多数情况下答案是不需要。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询