ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

乐观锁原理与实战:版本号、CAS机制及适用场景全解析

乐观锁原理与实战:版本号、CAS机制及适用场景全解析 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 ?是CompareSET 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 XMLupdate idupdateWithVersion 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数据库的压力会成倍放大。这种情况下直接上悲观锁或者用RedisLua脚本做原子扣减性能反而更稳。还有一个判断标准业务对结果的失败率是否敏感。乐观锁的固有代价是“部分请求会以失败告终”。如果用户体验上不能接受“操作失败请重试”比如网银转账那就不应该用乐观锁而应该用悲观锁加队列兜底的方案。4.2 乐观锁 vs 悲观锁一张表看懂选型逻辑为了更直观我把两类锁的工程特性拉一张表格。这张表不是教科书式的定义罗列是实际选型时真正需要考虑的维度对比维度乐观锁悲观锁并发冲突处理冲突时更新失败由业务方重试冲突时阻塞等待获取锁后继续死锁风险无死锁天然规避存在死锁可能需要超时机制数据库连接占用事务时间短连接占用少事务期间持锁连接占用长吞吐量特征冲突率低时吞吐极高并发越高吞吐下降越明显业务侵入需要加version字段并改造SQL只需加锁语句业务改动小最怕什么高冲突率下重试风暴打垮数据库长事务吃满连接池实现成本中需要自己处理重试和幂等低数据库原生支持一致性保障强度最终一致靠重试兜底强一致事务提交前锁不释放这里要说明白一点乐观锁并不是比悲观锁“更好”的机制它们是不同冲突概率下的最优解。背包出行不穿雨衣台风天气穿雨衣不存在一种万能的锁。4.3 高并发下乐观锁真的能扛住吗几个实测数据我带团队优化过一个阅读量统计的接口在活动期间QPS大约5000表结构是文章表和计数表分离的架构。计数表用乐观锁做累加更新期间有压测数据值得参考无乐观锁时并发扣减导致数据库一致性错误约万分之七每次写入平均耗时0.6ms加上乐观锁并配合200次重试上限写入成功率恢复到99.99%以上平均耗时0.8msP99延迟从之前的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亿次这种场景并不荒唐活动期间的计数器完全可能达到。说回个人经验我最初接触乐观锁的时候也觉得它是个“小技术”数据库加个字段就行。真正做下来发现乐观锁的难点不在锁本身而在重试策略、幂等保证、事务边界、用户体验这四个外围环节。任何一个环没处理好乐观锁的优势都会变成灾难。从选型角度来说乐观锁和悲观锁没有绝对的高下之分。我实际感受是系统刚开始搭建的时候优先用悲观锁图省事等业务量上来、并发冲突开始影响性能了再逐步把冲突率低的核心接口改造成乐观锁。这个渐进改造的过程比一步到位要稳得多。
返回列表