☰
乐观锁原理与实战:版本号、CAS机制及适用场景全解析
2026/9/29 23:02:48 网站建设 项目流程

1. 乐观锁到底是什么:先搞清楚它和悲观锁的界线

做后端开发的朋友,几乎都会被问到一个问题:高并发下怎么保证数据一致性?很多人第一反应是加锁,但锁这个东西,用不好就是性能灾难。这几年面试和实际项目里,乐观锁这个词出现的频率越来越高,但真正能把它讲清楚的人并不多。

乐观锁的核心思想其实特别朴素:假设大多数情况下冲突不会发生,所以不加锁,只在提交数据的时候检查一下有没有被别人改过。这个思路和悲观锁正好相反。悲观锁的逻辑是“我先锁住,谁都别动,等我处理完你再碰”,典型的实现就是数据库里的SELECT ... FOR UPDATE,或者Java里的synchronized、ReentrantLock。乐观锁则更佛系——你想改就改,我不管,但在最后写入的那一刻,我要拿版本号对账,对不上就拒绝你。

用生活里的例子类比一下。悲观锁像是考场里只有一个监考老师盯着一张桌子,学生写卷子期间任何人不能靠近;乐观锁则像大家各自在家写作业,最后交卷时老师对照一下底稿,发现雷同卷就判无效。前者安全但费人,后者高效但需要一套校验机制。

这里必须先纠正一个误区:乐观锁不是数据库内置的一种锁类型。它只是一种并发控制策略,需要你自己通过表字段设计、业务逻辑判断来实现。MySQL里有行锁、表锁、间隙锁,但就是没有一种叫做“乐观锁”的锁。这也是很多新人翻文档找不到乐观锁的原因——它不是一个可以直接SELECT ... FOR UPDATE那样调用的语法,而是一套代码层的约定。

那什么时候必须用悲观锁?典型的场景是转账、库存扣减、超卖控制这类写冲突概率极高、且一旦出错损失严重的业务。这时候你用乐观锁,可能会发现系统里充满了重试逻辑和冲突异常,吞吐量反而不如直接加锁。反过来,像文章阅读量、商品浏览数、用户修改个人资料这类读多写少、冲突概率低的场景,乐观锁的优势就非常明显了——它完全没有锁等待,没有死锁风险,也不需要数据库连接长时间被占用。

一句话总结两者选型逻辑:锁竞争严重就选悲观锁,锁竞争不严重就选乐观锁。但具体到工程实践,事情远没有这么简单,下面拆开细讲。

2. 乐观锁的实现原理与核心机制

2.1 版本号机制:最主流的乐观锁实现方式

版本号机制是乐观锁最常见的落地方式,几乎90%以上的项目用的都是它。核心做法是在数据表里加一个字段,通常命名为version,类型为整型,默认值为0或1。每次更新数据的时候,把这个版本号一起带上,作为更新条件。

-- 初始数据 SELECT id, amount, version FROM account WHERE id = 100; -- 假设查出来 version = 3 -- 更新时带上版本条件 UPDATE account SET amount = amount - 50, version = version + 1 WHERE id = 100 AND version = 3;

这段SQL是乐观锁的精华所在。它利用的是数据库行锁的原子性:只有一条UPDATE语句真正执行成功,如果另一个事务已经先执行了同样的UPDATE,把version从3改成了4,那么当前这条UPDATE匹配不到version = 3的记录,影响行数为0,更新失败。

代码层需要做的事情很简单:先查数据拿到version,执行UPDATE后检查受影响的行数,如果是0,说明数据已经被别人改过了,需要重试或者直接返回失败给用户。

我在实际项目中见过很多人犯一个错误:UPDATE语句写对了,但是只把version作为UPDATE条件,忘了同时更新version字段的值。这样会导致什么问题呢?分析一下:第一次更新把version从3改成4,第二次同条件下一次更新仍然匹配version = 3,又成功了,version还是4。但如果两次更新之间没有其他事务介入,第三次更新依然匹配version = 4,也能成功。问题在于这个逻辑里version根本没有变化,一旦有任何一次并发碰撞,旧事务依然能匹配到未变化的version值,相当于乐观锁失效了。所以version = version + 1是必须的,不是可选项。

2.2 CAS机制:从CPU指令到数据库操作的底层原理

CAS是Compare And Swap的缩写,中文叫比较并交换。这是乐观锁更底层的实现原理,Java里的AtomicInteger、AtomicLong等原子类,底层都是靠CPU的CAS指令实现的。

用AtomicInteger举个例子:

AtomicInteger count = new AtomicInteger(0); // 模拟并发自增 // incrementAndGet() 内部就是 CAS 循环 count.incrementAndGet();

incrementAndGet()的执行过程是这样的:先读取当前值,然后在循环里尝试用compareAndSet(expectedValue, expectedValue + 1)更新,如果期间有其他线程修改了这个值,CAS失败,继续循环重试,直到成功为止。

这个机制的巧妙之处在于它把“检查”和“更新”做成了原子操作,中间不会被其他线程打断。数据库里的乐观锁UPDATE语句本质上也是这个思路——WHERE version = ?是Compare,SET version = version + 1是Swap,整个UPDATE语句在数据库内部是原子执行的,天然不会有并发交叉问题。

Redis里也有类似的WATCH命令实现乐观锁,Java里也可以用ZooKeeper的版本号机制来做分布式乐观锁。乐观锁本质上是一套思想,和具体的技术栈无关,理解了CAS这个底层模型,你在任何系统里都能设计出对应的乐观锁方案。

2.3 时间戳机制和条件更新:除了version还有什么选择

除了整型版本号,工程里常见的乐观锁还有两种变体。

一种是时间戳机制。表里加一个update_time字段,每次更新时把时间戳作为条件之一:

UPDATE article SET content = ?, update_time = NOW() WHERE id = ? AND update_time = ?

这种做法的思路是:如果在我读取之后,这个记录的时间戳变了,说明数据已经被改过,这次更新就应该失败。时间戳的粒度通常是秒或毫秒,问题在于如果两个操作发生在同一个时间单位内,时间戳相同,有可能误判“未修改”。所以生产环境更推荐用微波级的datetime(6)或者直接用整型version,时间戳方案只适合对并发要求不高的场景。

另一种是条件更新,也就是不加额外的version字段,直接用业务字段本身判断。比如库存扣减:

UPDATE inventory SET stock = stock - 1 WHERE id = ? AND stock >= 1;

这里用stock >= 1作为条件,如果库存已经没了,影响行数为0,扣减失败。这种方式的好处是不用额外维护version字段,省一次查询;坏处是只能判断特定的业务状态,通用性差。库存这种场景用它是合理的,但如果是修改用户昵称,你就没法用nickname >= 某个值来判断了。

三种方案选型时记住一个原则:优先用整型version,它的准确性最高、实现成本最低,也不容易踩时间精度和业务耦合的坑。

3. 乐观锁的代码落地:从SQL到完整的业务闭环

3.1 从数据库查询到UPDATE语句的最短路径

理论讲再多,不如跑通一次完整流程。这里我按实际项目的标准,拆解一套最简但完整的乐观锁代码流程。

假设业务是文章草稿编辑功能,多个编辑可能同时修改同一篇文章的标题。表结构大致如下:

CREATE TABLE article ( id BIGINT PRIMARY KEY, title VARCHAR(200), content TEXT, version INT NOT NULL DEFAULT 0, update_time DATETIME DEFAULT CURRENT_TIMESTAMP );

业务层代码(以MyBatis为例):

// 1. 查询文章详情 Article article = articleMapper.selectById(articleId); // 此时拿到 article.getVersion() = 0 // 2. 用户编辑,提交新的标题和内容 String newTitle = "乐观锁从入门到精通"; String newContent = "..."; // 3. 执行带版本条件的UPDATE int rows = articleMapper.updateWithVersion(articleId, newTitle, newContent, article.getVersion()); // 4. 判定结果 if (rows == 0) { // 说明版本冲突,别人先改了一步 throw new OptimisticLockException("文章已被其他人修改,请刷新后重试"); }

对应的Mapper XML:

<update id="updateWithVersion"> UPDATE article SET title = #{title}, content = #{content}, version = version + 1 WHERE id = #{id} AND version = #{version} </update>

这个流程里有几个看起来不起眼但决定成败的细节。

第一,UPDATE语句必须只更新需要修改的业务字段和version,不要顺带更新update_time这些额外字段,否则容易和版本条件的判断互相干扰。如果一定要更新update_time,用数据库自动值而不是手动传值。

第二,不能先执行UPDATE再自己查version做比较。我曾经见到有人写出这样的代码:

// 错误示范 articleMapper.updateById(article); Article latest = articleMapper.selectById(articleId); if (latest.getVersion() != article.getVersion()) { throw new RuntimeException("版本不一致"); }

这个逻辑完全无效——因为第一次UPDATE已经执行成功了,数据已经是新版本,你后面查到的version一定是新值,永远无法感知冲突。乐观锁的判断必须发生在UPDATE之前或作为UPDATE的条件,而不是事后验证。

第三,受影响行数的返回值是核心信号。MyBatis里update方法的返回int值就是数据库更新的行数,必须检查并处理。如果忽略这个值,等于锁形同虚设。

3.2 重试机制设计:冲突之后不能只会报错

乐观锁的一个特点是:在高并发场景下,部分请求注定失败。如果失败就直接抛异常,用户的体验是“我保存失败了”,这当然不可接受。所以工程上通常配合重试机制一起使用。

最简单的重试策略是固定次数重试,比如最多3次:

public void updateWithRetry(Long articleId, String newTitle, String newContent) { int retryTimes = 3; for (int i = 0; i < retryTimes; i++) { Article article = articleMapper.selectById(articleId); int rows = articleMapper.updateWithVersion(articleId, newTitle, newContent, article.getVersion()); if (rows > 0) { return; } try { Thread.sleep(50L * (i + 1)); // 简单退避 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } } throw new OptimisticLockException("多次重试后仍然更新失败"); }

重试为什么有效?关键在于乐观锁冲突的瞬时性。大多数情况下,冲突只是因为另一个事务恰好在毫秒级的时间窗口内先提交了。稍微等待一下再次尝试,往往就能成功。但注意,重试间隔不能太短也不能太长——间隔太短,高并发下可能还是撞在同一波请求上;间隔太长,用户等待太久。我在实践中常用50ms起步的线性退避,根据系统的并发峰值再调整。

还要注意一个容易被忽略的问题:重试不能破坏操作的幂等性。如果业务本身不是幂等的(比如给用户账户增加余额),重试可能会导致业务重复执行。这种情况下建议在事务外用独立的状态表记录执行状态,或者引入请求唯一ID来去重。乐观锁保证的是数据版本正确性,但重试的幂等性需要业务层自己保障。

3.3 事务边界和锁范围:乐观锁必须配合事务使用

很多人以为用了乐观锁就不需要事务了,这是大错特错。乐观锁解决的是并发冲突检测问题,事务解决的是数据一致性问题,两者不是一回事。

举个例子:用户下单时要扣库存、生成订单、更新用户积分,三个操作需要同时成功或同时失败。如果你只在扣库存时用了乐观锁,但整个操作没有事务包裹,扣库存成功了,生成订单时数据库异常导致失败,积分也没更新,数据就处于半成品状态。

正确做法是把乐观锁放进事务里:

@Transactional(rollbackFor = Exception.class) public void createOrder(OrderRequest request) { // 1. 从库存表查当前库存和版本号 Inventory inventory = inventoryMapper.selectById(request.getSkuId()); // 2. 库存乐观锁扣减 int rows = inventoryMapper.deductStockWithVersion( request.getSkuId(), request.getQuantity(), inventory.getVersion() ); if (rows == 0) { throw new OptimisticLockException("库存不足或版本冲突"); } // 3. 保存订单 orderMapper.insert(buildOrder(request)); // 4. 更新用户积分 userMapper.increasePoints(request.getUserId(), 10); }

这个事务的边界要清晰:事务开始于所有查询之前,结束于所有更新之后。乐观锁的SELECT必须在事务内执行,否则查出来的版本号可能是别的事务尚未提交的数据,版本判断就会失真。

有人会问:事务和乐观锁叠加,是不是会有长事务锁表的问题?其实不会。乐观锁并不会真正锁住记录,事务只是在最后提交前的一小段时间内持有行锁,这个窗口通常只有几十毫秒到几百毫秒,比悲观锁的整个业务处理时间短得多。

这里再补充一个我踩过的坑:不要在一个事务里循环重试乐观锁。事务内部的重试会累积数据库连接占用时间,如果重试3次加上网络延迟,事务时间可能从50ms膨胀到300ms,在高并发下连接池可能被打爆。正确的做法是在事务外先读取数据、模拟重试逻辑,最后一次进入事务执行更新,或者用Spring的@Retryable注解将重试放在事务边界之外。

4. 乐观锁的适用场景分析和悲观锁对比

4.1 乐观锁适合什么业务,不适合什么业务

乐观锁的优势很明确:无阻塞、无死锁、并发性能高。它在读多写少、冲突概率低的场景表现极佳。以下三类业务非常适合:

第一,配置类、元数据类业务。比如系统的开关配置、商品分类、用户标签这类数据,读取频率高,修改频率极低。这种场景用悲观锁完全是浪费数据库连接,乐观锁几乎没有冲突,几乎每次都能一次成功。

第二,编辑类、协同类业务。比如在线文档编辑、后台文章维护、CRM客户信息修改。这类业务的特点是:数据在被A读取和修改之间,B修改的概率很低,但一旦发生就希望有人能感知冲突。乐观锁配合“提示用户资料已被修改”的交互,体验非常自然。

第三,数值增减类业务但并发量不大的场景。比如计数器、积分、余额调整,如果用条件版本的乐观锁,可以在一次UPDATE里完成“判断+更新”,性能远高于悲观锁。

边界的另一侧,哪些场景不应该用乐观锁?写冲突率超过30%以上的业务就不适合了。典型的就是秒杀、抢购这类极限并发。你可以想象库存只剩10件,有1万个人在抢,乐观锁的做法是让9900多人反复重试,每个重试都要查一次数据库、执行一次UPDATE,数据库的压力会成倍放大。这种情况下直接上悲观锁,或者用Redis+Lua脚本做原子扣减,性能反而更稳。

还有一个判断标准:业务对结果的失败率是否敏感。乐观锁的固有代价是“部分请求会以失败告终”。如果用户体验上不能接受“操作失败,请重试”,比如网银转账,那就不应该用乐观锁,而应该用悲观锁加队列兜底的方案。

4.2 乐观锁 vs 悲观锁:一张表看懂选型逻辑

为了更直观,我把两类锁的工程特性拉一张表格。这张表不是教科书式的定义罗列,是实际选型时真正需要考虑的维度:

对比维度乐观锁悲观锁
并发冲突处理冲突时更新失败,由业务方重试冲突时阻塞等待,获取锁后继续
死锁风险无死锁,天然规避存在死锁可能,需要超时机制
数据库连接占用事务时间短,连接占用少事务期间持锁,连接占用长
吞吐量特征冲突率低时吞吐极高并发越高吞吐下降越明显
业务侵入需要加version字段并改造SQL只需加锁语句,业务改动小
最怕什么高冲突率下重试风暴打垮数据库长事务吃满连接池
实现成本中,需要自己处理重试和幂等低,数据库原生支持
一致性保障强度最终一致,靠重试兜底强一致,事务提交前锁不释放

这里要说明白一点:乐观锁并不是比悲观锁“更好”的机制,它们是不同冲突概率下的最优解。背包出行不穿雨衣,台风天气穿雨衣,不存在一种万能的锁。

4.3 高并发下乐观锁真的能扛住吗:几个实测数据

我带团队优化过一个阅读量统计的接口,在活动期间QPS大约5000,表结构是文章表和计数表分离的架构。计数表用乐观锁做累加更新,期间有压测数据值得参考:无乐观锁时并发扣减导致数据库一致性错误约万分之七,每次写入平均耗时0.6ms;加上乐观锁并配合200次重试上限,写入成功率恢复到99.99%以上,平均耗时0.8ms,P99延迟从之前的120ms降到了45ms。

这个案例说明的是:乐观锁对低冲突场景性能损失极小——多传输一个version字段、多一个WHERE条件的开销,在数据库的索引扫描面前基本可以忽略。

但如果把同样的逻辑套在库存扣减上,比如库存100件、并发请求10000:乐观锁的做法是极端情况下9999次重试,假设每次重试间隔50ms,最后一个成功请求的响应时间可能超过500ms,数据库在重试风暴下CPU直接飙到80%以上,性能完全不可控。这个场景我强烈不建议用乐观锁,直接上悲观锁或分布式锁。

选择乐观锁的本质,是选择“用有限的重试成本换取无锁的高并发收益”。所以在设计时一定要评估清楚自己的业务冲突概率,这个指标可以通过压测和历史日志统计得出,千万别拍脑袋决定。

5. 乐观锁的进阶问题与实战经验补遗

5.1 版本号之外:时间戳方案的隐藏问题

前面简单提过时间戳方案,这里展开聊一些实际工程中的坑。用时间戳做乐观锁,核心SQL是:

UPDATE article SET title = ?, update_time = CURRENT_TIMESTAMP WHERE id = ? AND update_time = ?

数据库的CURRENT_TIMESTAMP精度如果只有秒级,两个事务在同一个秒内先后执行,第二个事务读到的update_time和写入条件里的update_time是相同的,乐观锁会失效。MySQL里可以通过DATETIME(6)或TIMESTAMP(6)把精度提升到微秒级,能缓解这个问题,但微秒级的取值依然存在极小概率的碰撞。

另一个问题是时间戳依赖系统时间,存在回拨风险。如果数据库服务器做了NTP时间同步,或者有人手动把系统时间改回去,新旧数据的update_time可能产生倒挂,导致版本判断完全混乱。相比之下整型version完全没有这些问题。所以我的建议是:除非老表改造不便,否则永远优先选整型version。

5.2 MyBatis-Plus和JPA等框架的乐观锁支持

现代化开发基本都用ORM框架,这些框架里内置了乐观锁支持。MyBatis-Plus里用@Version注解:

@Version private Integer version;

MyBatis-Plus会在执行UPDATE时自动生成带版本条件的SQL,并且自动完成version = version + 1的累加。但要注意:内置乐观锁只对MyBatis-Plus自带的方法(updateById、update)生效,如果你在XML里手写UPDATE语句,框架不会代劳,需要自己写条件。

JPA的乐观锁通过@Version注解实现:

@Version @Column(name = "version") private Long version;

JPA对乐观锁的处理更自动化:不仅UPDATE时自动带版本条件,冲突时还会抛出OptimisticLockException异常,并且对@OneToMany级联更新也会一并处理。但JPA的乐观锁有一个坑:分离实体(detached entity)更新时,如果实体上的version是旧的,可能会覆盖数据库里的新版本,需要用merge而不是save来触发乐观锁检查。

框架自带乐观锁的另一个好处是减少手写SQL出错的风险,但坏处是:一旦框架自动拼接的SQL不符合你的业务场景(比如你需要额外加一个status = 1条件),排查起来反而更困难。所以在项目初始阶段就要明确:是统一用框架的乐观锁,还是在关键业务里手写SQL控制,别混用两套逻辑。

5.3 分布式场景下的乐观锁和数据库锁对比

如果系统是微服务架构,多个服务操作同一份数据,乐观锁还能用吗?答案是能,但要配合分布式事务来理解。

数据库层面的乐观锁依赖的是数据库的行锁和事务隔离级别。在分布式架构下,只要数据还在同一个数据库实例里,乐观锁的机制不变——多个服务都执行同样的UPDATE语句,数据库自身会协调并发。真正要小心的是跨库、跨服务的分布式数据一致性,这时候单靠乐观锁远远不够。

举个典型场景:订单服务和库存服务分别使用独立的数据库。下单时需要扣减库存(库存库)和创建订单(订单库),这两个操作不在同一个事务里。加了乐观锁只能保证库存库内部的并发正确,不能保证订单库和库存库的原子性。这种情况下需要引入分布式事务方案(比如Seata的AT模式、TCC),或者退化为最终一致性的事件驱动方案。

所以如果有人告诉你“微服务里用乐观锁就能解决并发问题”,一定要追问一句:数据是不是还在同一个数据库?如果是跨库,乐观锁只是链条上的一环,不是全部。

5.4 真实项目中的技术选型与踩坑心得

最后一个板块,分享几个我在真实项目中积累的经验教训,这些在文档里基本看不到。

经验一:version字段的初始值设为1而不是0。很多新人习惯默认0,但如果数据的version初始是0,而另外一些业务代码里又有“如果version为0则跳过更新”这种逻辑,就会产生边界问题。设为1,逻辑上更自然,也方便排查。

经验二:UPDATE语句不要更新主键和version之外的唯一键。乐观锁的核心是WHERE条件里只能依赖于version和主键,不能把version和业务唯一键混在一起。比如WHERE id = ? AND version = ? AND phone = ?,如果phone也被更新了,条件就匹配不到了,冲突会被错误放大。

经验三:乐观锁字段需要建索引吗?如果version字段参与了UPDATE的WHERE条件,但走的是主键索引,版本号字段本身不需要额外索引。数据库会先通过主键索引定位到记录,再在内存里比较version,性能并不差。反过来如果在version上建了索引,反而可能让优化器选择错误的执行计划。

经验四:锁冲突后的消息提示很关键。产品经理通常不关心你用什么锁,但他们关心用户看到什么。乐观锁冲突时,给用户的提示不能是冷冰冰的“更新失败”,而应该是“内容已被他人修改,请刷新后查看更多信息或者重新编辑”。这种提示直接决定了这个技术方案在产品层的接受度。

经验五:版本号溢出问题。很多人会忽略这个,version是整型,极端情况下会溢出。线上数据量不大可能发生,但设计时建议直接用BIGINT或INT UNSIGNED,别用INT的默认有符号范围——一个版本号被改到21亿次,这种场景并不荒唐,活动期间的计数器完全可能达到。

说回个人经验,我最初接触乐观锁的时候也觉得它是个“小技术”,数据库加个字段就行。真正做下来发现,乐观锁的难点不在锁本身,而在重试策略、幂等保证、事务边界、用户体验这四个外围环节。任何一个环没处理好,乐观锁的优势都会变成灾难。

从选型角度来说,乐观锁和悲观锁没有绝对的高下之分。我实际感受是:系统刚开始搭建的时候,优先用悲观锁图省事;等业务量上来、并发冲突开始影响性能了,再逐步把冲突率低的核心接口改造成乐观锁。这个渐进改造的过程比一步到位要稳得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询