news 2026/10/19 5:05:16

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 内部是原子的,中间不存在别的会话插进来的窗口。

乐观锁的本质是 CAS(Compare-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读到 version=1 之后,另一个事务可能已经把这行改成 version=2 并提交了。此时你拿着 version=1 继续updateById,如果更新语句里没有WHERE version = 1,它依然会基于最新行执行,把别人的修改覆盖掉。那“判断版本”就成了一句空话。

理解这个问题的关键在于:SELECT 和 UPDATE 之间是异步的,只有把版本条件写进 UPDATE 的 WHERE 里,才能让校验和写入在同一条语句中原子完成。你查出来 version=1 不代表此刻数据库里还是 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 );

扣减库存的核心 SQL:

UPDATE 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 次上下。每次重试间隔用随机退避,10~50 毫秒足够,别用固定间隔,否则多个节点在同一时刻一起重试,等于给数据库瞬间制造一批并发。

还有一个容易忽略的点:每次重试失败都打一条日志,记录业务单号、商品 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 );

更新 SQL:

UPDATE 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 字段相比,它差在哪

我总结下来有三个短板:

  1. 无法表达“第几次修改”。version 是严格递增的整数,每次更新 +1,天然有序。时间戳只是一个时刻,同毫秒内两次不同的事务完全可能落到相同的毫秒数。
  2. 依赖系统时钟。数据库服务器的时钟一旦回拨,或者运维手贱把时间改正,时间戳会倒退,所有基于它的 CAS 全部失效。
  3. 重试时容易拿到旧事务的时间戳。如果重试代码没有重新 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这个判断。直接用带量 CAS:

UPDATE goods SET stock = stock - #{num} WHERE id = #{goodsId} AND stock >= #{num};

够用,不需要 version。但如果你的库存操作除了扣减还有“设置实际库存为固定值”(比如盘点、后台修正),这类操作必须基于旧值整体覆盖,就得用 version 字段。日常业务里,两种方式可以共存:扣减走带量 CAS,修正库存走 version 校验。

6.2 订单状态:状态机 CAS 最顺手

订单的支付、取消、发货,本质都是状态迁移。用状态字段做 CAS,SQL 最直白:

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 日志。线上如果频繁看到它,就去查业务是不是真的需要这么高的并发更新——多数情况下答案是不需要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 5:05:03

RHEL 8 GPU加速实时推荐系统:延迟优化与部署实践

如果你正在维护一个实时推荐系统&#xff0c;接口延迟卡在 200ms 上下&#xff0c;业务方天天催着优化&#xff0c;那你大概率会走到这一步&#xff1a;把推荐模型的计算从 CPU 迁到显卡服务器&#xff0c;在 RHEL 8 上把 GPU 算力真正用起来。我近期参与了一次推荐链路延迟优化…

作者头像 李华
网站建设 2026/10/19 5:04:30

AI原生应用安全实践:从提示注入到Agent防护的纵深防御指南

1. AI原生应用的威胁面&#xff1a;为什么安全策略必须换一套逻辑这段时间我一直在处理AI原生应用的安全问题&#xff0c;最大的感受是&#xff1a;传统应用的安全防护思路&#xff0c;搬到AI原生应用上会处处碰壁。传统Web应用的核心防护对象是接口、数据、身份认证这些明确的…

作者头像 李华
网站建设 2026/10/19 5:04:16

Android系统级AI:AppFunctions与MCP机制解析

最近 Android 官方在 AI 这块的动作&#xff0c;说实话有点超出预期。AppFunctions、官方 MCP 支持、再加上系统级 OpenClaw 雏形这套说法&#xff0c;本质上是在讲同一件事&#xff1a;AI 不再满足于在聊天窗口里跟你一问一答&#xff0c;而是要直接伸手打开你手机里的每一个 …

作者头像 李华
网站建设 2026/10/19 5:03:59

算法面试中的O(1)空间奇技:位运算与数学构造实战

1. 这不是炫技&#xff0c;是面试现场的“算法压力测试”实录“折腾了几个算法的奇技淫巧&#xff0c;我把面试官戏弄了一波&#xff0c;哈哈哈”——看到这个标题&#xff0c;别急着笑。我第一次在某技术社区刷到它时&#xff0c;正卡在一道看似简单的“两数之和”变体题上&am…

作者头像 李华
网站建设 2026/10/19 5:03:58

开源CLI代码评审工具:本地化、可审计、模型开放的AI Code Review方案

1. 项目概述&#xff1a;一个真正能落地的开源代码评审工具&#xff0c;不是概念玩具“open-code-review”这个名字乍一听像某个学术论文里的术语&#xff0c;或者某家大厂内部吹过的PPT概念——但其实它指向一个非常具体、非常务实的东西&#xff1a;一个基于命令行&#xff0…

作者头像 李华
网站建设 2026/10/19 5:03:49

极限学习机ELM回归预测:MATLAB实现原理与代码详解

极限学习机这个模型&#xff0c;我从第一次用在数据回归预测上到现在&#xff0c;已经有几年了。它的名气没有深度学习和梯度提升那么大&#xff0c;但在中小规模表格数据上&#xff0c;ELM的性价比高得惊人——训练极快、代码极短、精度不差。如果你也在用MATLAB做回归预测&am…

作者头像 李华