ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从乐观锁到MVCC:并发控制实战与避坑指南

从乐观锁到MVCC:并发控制实战与避坑指南 1. 乐观锁不是锁先弄清楚它在并发控制里的真实位置很多新人第一次接触乐观锁都会陷入一个误区以为它和锁一样是数据库或代码里某个可以lock()、unlock()的机制。我当年也干过这种事——在代码里搜Lock关键字试图找到乐观锁的API结果当然是搜了个寂寞。乐观锁的真实身份是一种并发控制策略本质上是对数据一致性的检测-补救机制。它不像悲观锁那样在操作数据前就SELECT ... FOR UPDATE把行锁住让其他人只能排队等着。乐观锁的出发点是假设绝大多数情况下数据不会被并发修改所以不加物理锁只在提交更新时检查你改的这条数据还是不是你当初读到的那个版本。这个检查版本的动作才是一切乐观锁实现的核心。1.1 一句话讲透乐观锁的定义边界严谨一点说乐观锁Optimistic Locking是一种基于数据版本校验的并发控制策略。它的工作流程分三步读取数据同时记下当前版本号version或时间戳timestamp或者干脆记下数据的原始快照。业务逻辑处理此时完全不加锁所有线程都在自由地读和算。执行更新时UPDATE语句的WHERE条件里带上步骤1读到的版本号同时把版本号1。如果UPDATE影响的行数是1说明期间没人动过这条数据这次提交成功如果影响行数是0说明别人已经抢先改了它这次提交失败需要业务层决定是否重试。注意到没有乐观锁的整个生命周期里数据库层面没有任何一把锁被持有。它真正干的事是有条件更新CASCompare And Swap把并发冲突这个风险从操作前预防挪到了提交时检测。用生活化的方式理解乐观锁就像图书馆的座位预约。你坐下时在纸条上写了10:00占了这座位离开时如果纸条还是你写的10:00说明没人来赶你你可以正常走如果纸条被人撕了重写说明这期间有人动过你就不能继续按原来的计划写作业了——得重新规划。1.2 为什么没人抢的假设在特定业务里是合理的你一定会问既然乐观锁会在更新时失败那为什么不直接上悲观锁一次搞定答案在于业务的冲突概率和锁的成本。拿一个典型的例子说用户浏览文章详情页页面上显示阅读量1。这个操作的特点是——读多写少且同一篇文章同一秒内几乎不会有两个人同时精确地读又写同一条记录。如果此时用悲观锁每个读者进来都要SELECT FOR UPDATE锁住文章行后面的读者全部阻塞。明明是一次轻量级的统计自增硬生生变成了串行队列数据库的连接池很快就被排队的请求拖垮。而乐观锁在这种场景下的表现就很舒服读数据是普通的SELECT不阻塞任何人只有真正执行UPDATE articles SET read_count read_count 1 WHERE id ? AND read_count ?那一刻才需要关注冲突。由于文章阅读量并发冲突概率极低绝大多数UPDATE一次性就成功了。所以乐观锁的核心适用面是并发冲突概率低、且一次冲突的代价回滚重试远小于持续持有锁的代价的场景。反过来如果冲突率一高乐观锁会陷入大量更新失败、大量重试的窘境性能和体验反而比悲观锁更差。这个度在哪里后面第4节我会专门展开讲。2. 数据库底层的乐观并发控制MVCC的版本链与可见性判断很多人把乐观锁等同于在表里加个version字段这其实只看到了最表层的用法。如果面试官问你InnoDB的乐观锁底层依赖什么机制你只答版本号字段就显得太浅了。真正支撑乐观锁思想的底层是MVCCMulti-Version Concurrency Control多版本并发控制——它让数据库的乐观读成为可能。2.1 隐藏列与undo log版本链MySQL InnoDB的每行数据其实还藏着几个隐藏列平时SELECT *看不到但它们一直在默默工作DB_TRX_ID最后一次修改这一行的事务ID。DB_ROLL_PTR回滚指针指向这行数据在undo log里的上一个版本。DB_ROW_ID隐藏主键如果没有显式主键InnoDB会用它来组织行结构。当一行数据被修改时InnoDB不会直接把旧数据抹掉而是把修改前的数据写入undo log然后通过回滚指针把这些新旧版本串成一条版本链。新版本在链头旧版本在链尾。想象一个场景事务A把goods表的某个库存字段从100改成90这行数据的DB_TRX_ID变成A的事务IDDB_ROLL_PTR指向一条undo log——记录着100这个旧值如果紧接着事务B又把90改成80那么版本链头是80B改往下是90A改再往下是100原始。这条版本链的意义在于让读操作可以读到过去。事务C在A和B都还没提交时去读这行数据它不会因为数据被改就阻塞等待而是沿着版本链找到对C可见的那个版本——可能是100也可能是90取决于C的快照创建时间。这就是MVCC下读不加锁的理论基础也是乐观锁能够先随便读、提交时再校验的前提。2.2 ReadView的可见性规则才是判断冲突的依据有了版本链还得有规则来决定当前事务到底应该看到哪个版本否则版本再多也是乱的。这个规则由**ReadView读视图**来执行。ReadView在事务第一次执行快照读普通SELECT时生成里面主要记录了四类信息m_ids当前活跃的、尚未提交的事务ID列表。min_trx_id活跃事务里最小的那个事务ID。max_trx_id当前系统里下一个要分配的事务ID即未来事务的起点。creator_trx_id生成这个ReadView的事务自己的ID。当读操作走到某行会顺着版本链逐个判断版本的事务ID事务ID情况可见性trx_id min_trx_id该版本在ReadView生成前已提交可见trx_id max_trx_id该版本由未来事务生成不可见min_trx_id trx_id max_trx_id若在m_ids活跃列表中则不可见不在列表中则已提交可见trx_id creator_trx_id自己的修改当然可见这个判断过程本质上就是一种乐观的读取裁决——并不需要等锁只需要在内存里做几次比较就能从版本链中选出正确的那份快照。理解MVCC之后你对乐观锁的认知应该升级一层乐观锁的版本字段本质上是你手动维护的一个可见性标记。数据库用隐藏的DB_TRX_ID和版本链实现了内部的一致性读而你的业务字段版本号则是把这种一致性延伸到跨表、跨服务乃至微服务场景的手段。当你在业务表上ALTER TABLE加一列version时其实是在自己搭建一条简化版的版本链。3. 代码里落地乐观锁的三种主流写法理论说透了下面直接进入怎么写。乐观锁没有统一的SDK它是一套思想落地方式很多。我按项目里见到的频率从高到低讲三种版本号字段、CAS条件更新、时间戳/标记类方案。3.1 版本号字段最常用也最容易写错这是最典型的乐观锁实现也是我推荐的新手首选方案。给表加一列版本号例如ALTER TABLE goods ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号;然后更新时走两步-- 步骤1查询记住version SELECT id, stock, version FROM goods WHERE id 100; -- 步骤2更新带上version条件并递增version UPDATE goods SET stock stock - 1, version version 1 WHERE id 100 AND version 1;注意步骤2的执行结果需要检查affected_rows。如果返回1更新成功返回0说明version已经不是1了有别人抢先提交过这时候你的更新静默失效——但业务数据没坏只需要重试或提示用户。我见过很多人在这里写错常见错误有两类。第一类是更新时忘了把version version 1写进SET子句。只写了WHERE version ?但没递增版本号结果第二次更新时WHERE id100 AND version1依然成立乐观锁等于被突破版本号形同虚设。第二类是把版本号当成重试口号。更新失败后代码里重新SELECT一条新版本号回来继续改——如果新版本号还是被别的线程抢先改了就继续循环。这本身没问题但如果循环没有上限在高冲突场景下会变成死循环般的重试风暴。重试上限和退避策略一定要有我后面第5节会给出具体代码。3.2 CAS条件更新一条SQL完成检查和修改CAS写法不需要额外的版本号字段直接把业务字段的当前值当成校验条件。最经典的例子是库存扣减UPDATE goods SET stock stock - 1 WHERE id 100 AND stock 8;这条SQL的意思只有当库存还是我刚才读到的8时才减1如果此刻库存已经是7说明有人先扣了一单WHERE stock 8不成立影响行数返回0业务层重新查询再做决定。相比版本号方案CAS省掉了一列但它的逻辑等价于以业务字段本身作为版本号。这种写法有两个注意点条件字段必须是真实发生变更的字段或者至少是变更前后差异明显的字段。如果一个用户把收货地址从A改成A没有变化那么WHERE address A仍然成立CAS检测不到并发——这就是为什么通常推荐用版本号而非业务值来检测冲突因为业务值可能相同。CAS条件字段本身不要参与运算。比如UPDATE goods SET stock stock - 1 WHERE id 100 AND stock - 1 7这种写法是反模式它把条件建立在计算后的结果上可读性极差且容易引发索引失效。多数国产数据库中间件如ShardingSphere提供乐观锁内置支持其实底层的SQL改写逻辑也是帮你把版本号自动拼进UPDATE ... WHERE ... AND version ?主动权仍然在业务SQL设计者手里。3.3 时间戳方案与Java层的AtomicStampedReference版本号字段存整数还有一种变体是存update_time时间戳。原理一样更新时WHERE update_time 原值同时把update_time更新为当前时间。时间戳方案有一个坑如果业务系统允许同一毫秒内两条事务并发修改时间戳粒度不够会漏判。所以强烈建议用整数版本号而不是时间戳——版本号无歧义、递增单调、不依赖系统时钟。如果你用Java写并发代码想在线程内部模拟乐观锁可以关注java.util.concurrent.atomic.AtomicStampedReference。普通AtomicReference的CAS有个著名的ABA问题后文会细讲而AtomicStampedReference额外维护了一个版本标记stamp每次修改都更新stampCAS时对比的是(引用, 标记)二元组能有效区分值从A变成B又变回A的情况。AtomicStampedReferenceInteger stock new AtomicStampedReference(8, 0); int[] stampHolder new int[1]; Integer current stock.get(stampHolder); // 业务处理... boolean success stock.compareAndSet(current, current - 1, stampHolder[0], stampHolder[0] 1);这其实就是把数据库版本号的思想搬到了内存对象上。虽然我们平时很少在Java内存里去实现乐观锁但理解这份代码能帮你更通透地理解版本标记这个核心概念——它不依赖数据是否相等而依赖数据是否经历过修改过程。4. 深入ABA问题与乐观锁的边界条件前面提到ABA问题这是所有CAS类机制都绕不开的一道坎。搞清楚它你对乐观锁的理解深度会明显超过会用version字段的那批人。4.1 ABA问题为什么会破坏业务正确性ABA问题是指共享变量从A变为B再变回A。单看数据的当前值一切如常但如果把取值过程作为一个完整事件来看已经发生过一次中间修改。数据库表里最经典的ABA场景是余额扣减充值的组合操作。假设账户余额是100事务T1读取余额100准备扣减50。事务T2读取余额100先扣减50——余额变成50。事务T3读取余额50又充值50——余额变回100。此时事务T1执行UPDATE account SET balance balance - 50 WHERE id ? AND balance 100条件成立扣款成功余额变50。问题来了T1以为自己对着从未变化过的100做扣减但它看到的100已经不是最初那个100了——中间发生过扣减和充值。如果业务规则要求每笔扣款前余额必须持续满足某项状态ABA就可能导致错误。为什么版本号能解决ABA因为版本号指向的是修改的次数而不是值的大小。余额变回100但版本号从1递增到3T1拿着版本号1去做WHERE id? AND version1直接失败——它敏锐地发现自己的数据快照已经过期了。所以结论很清晰用版本号实现乐观锁天然免疫ABA问题用业务字段值做CAS条件则有可能踩中ABA。4.2 哪些场景乐观锁不该用与悲观锁的选型对照乐观锁不是银弹。我梳理了一个简单的决策模型你可以直接对照自己的业务判断维度倾向乐观锁倾向悲观锁并发冲突概率低5%的写冲突率高热点数据频繁并发写单次冲突成本可接受轻量重试重试代价极高如跨系统对账操作耗时短事务秒级以内长事务涉及多步骤业务编排一致性要求允许短暂的重试延迟必须严格串行不允许一次失败典型场景阅读量、点赞、购物车数量库存秒杀、账户余额扣减、票务锁座重点解释一下库存秒杀。很多人以为秒杀就是高并发写多想当然用乐观锁。但秒杀的热点SKU一旦开抢同一时刻可能有数千个请求一起去扣同一条库存记录。乐观锁的后果就是第一个请求成功后续几千个请求全部affected_rows 0然后陷入疯狂重试——每个重试都带着一次新的SELECT和一次新的UPDATE数据库连接被打满响应延迟直线上升。这种场景悲观锁反而是更稳的选择SELECT ... FOR UPDATE把行锁住让扣减动作串行虽然吞吐量下降但至少数据库不会被重试风暴打垮。或者更极致一点直接走Redis预扣减异步落库根本不让并发落到数据库行上。我对选型的一句话体会是乐观锁适合你算准了没人跟你抢的场景悲观锁适合你明确知道大家都会来抢的场景。基于冲突概率来做选择而不是因为某个技术更高级。5. 电商扣库存完整实战从伪代码到可运行实现理论说再多不如一段能跑起来的代码。我用一个用户下单扣减商品库存的案例把乐观锁的完整落地过程串一遍。这个案例我刻意保留了一些业务细节而不是只给一个光秃秃的SQL。5.1 事务边界与先查再改的窗口期问题假设商品表结构如下CREATE TABLE goods ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL, stock INT NOT NULL, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id) );第一版代码有问题的写法Transactional public boolean createOrder(Long goodsId, Integer buyCount) { // 1.查询 Goods goods goodsMapper.selectById(goodsId); // 2.判断库存 if (goods.getStock() buyCount) { return false; } // 3.扣库存 int rows goodsMapper.deductStock(goodsId, goods.getStock(), buyCount); // 4.创建订单 orderMapper.insert(new Order(goodsId, buyCount)); return rows 1; }对应的Mapper SQLupdate iddeductStock UPDATE goods SET stock stock - #{buyCount}, version version 1 WHERE id #{id} AND version #{oldVersion} /update问题出在哪步骤1的SELECT和步骤3的UPDATE之间隔着一段Java代码的执行时间这期间如果另一个事务抢先扣了库存步骤3会因为version不匹配而返回0——这本身是乐观锁在正常工作。但请注意步骤3返回0后方法继续执行了步骤4插入了一笔订单。结果就是订单创建成功了库存却没扣成功——这是严重的业务错误。这就是乐观锁落地中常被忽略的坑校验、扣减、订单插入必须处于同一个事务边界并且扣减失败要立刻抛出异常回滚。修正后的代码Transactional public boolean createOrder(Long goodsId, Integer buyCount) { Goods goods goodsMapper.selectById(goodsId); if (goods.getStock() buyCount) { return false; } int rows goodsMapper.deductStock(goodsId, goods.getStock(), buyCount); if (rows 0) { // 关键抛异常让事务回滚或者自定义积分类通知上层重试 throw new OptimisticLockException(库存已被其他订单更新请重试); } orderMapper.insert(new Order(goodsId, buyCount)); return true; }再往深一步说先查后改其实还有窗口期风险极端的并发下两个事务同时SELECT到同一版本号都执行UPDATE。乐观锁的行为是一个成功、一个失败这是预期的但如果你的UPDATE条件不是版本号而是stock buyCount两个事务可能同时通过判断然后在数据库行锁的串行化下先执行成功的把库存扣成负数——这是CAS条件没约束够造成的越界。我推荐的做法是在UPDATE语句里同时带上stock的下限判断把WHERE id ? AND version ? AND stock buyCount作为完整条件这样即使版本失效库存约束也不会被突破。5.2 失败重试策略的两种可靠设计乐观锁更新失败后是否自动重试、怎么重试直接关系到线上表现。我见过两种踏实的设计方案一有限次数重试适合读多写少for (int retry 0; retry 3; retry) { try { return createOrder(goodsId, buyCount); } catch (OptimisticLockException ex) { // 退避一下避免极端情况下立即重试再次撞车 Thread.sleep(randomRetryMillis(retry)); } } return false;注意randomRetryMillis要带一点随机性不要所有失败请求都在同一毫秒重试否则会形成重试洪峰。第一次重试可以退避20-50ms之后逐次翻倍最多重试3次就放弃。方案二版本号库存汇总兜底适合高价值业务如果扣减涉及的钱款或核心资产不建议无脑自动重试。更好的做法是把失败记录写入重试表或消息队列由异步任务去慢慢地、串行地处理。这种重试注解比如Retryable很容易写但一定要加上最大重试次数和告警通知否则深夜静默失败没人发现。另外补充一个连接池层面的心得乐观锁在高冲突率场景下的重试会“吃掉”数据库连接池。每次重试都重新开启事务、重新SELECT、重新UPDATE连接释放比正常流程慢得多。如果一定要走乐观锁重试的路线前端限流、后端熔断要一起配上不能孤军奋战。6. 我踩过的乐观锁坑位清单与复盘最后一部分把我过去踩过的、以及帮别人排查过的乐观锁坑位列一个清单。这些细节书本上很少写但线上事故往往就出在这些地方。6.1 更新语句漏掉version字段导致锁形同虚设这是最高频的低级错误。有的人在SET子句中写了stock stock - 1却漏写version version 1有的人在WHERE里用version oldVersion但oldVersion是后端接口时由前端传参的——用户在页面上打开商品详情时拿到version磨蹭了5分钟才下单此时version早被别人改了于是所有正常用户都下单失败。这算乐观锁的误伤。我的习惯是版本号永远由后端在一次数据库操作内读取并校验绝不让前端持有version超过秒级时间。页面上的版本号只能用来做展示层提示不能作为并发控制的依据。前端传过来的version只应该当成一个参考值到了后端要以最新的SELECT结果为准。6.2 高并发下的重试风暴与数据库连接池耗尽有一个真实案例某活动页的领取优惠券接口用了乐观锁券池是一个热点行并发一上来99%的请求都走失败重试。每个重试线程都占用一个数据库连接等待SELECT和UPDATE往返连接池瞬间被打满紧接着其他依赖数据库的接口全部雪崩。复盘下来有两个教训热点行的并发写用乐观锁本身就是错误选型。券池的扣减频率极高冲突概率远超乐观锁的舒适区应该改为行级悲观锁或Redis原子扣减。重试必须限流。如果重试逻辑没有最大次数退避熔断乐观锁会把并发压力放大数倍——原本1万次请求可能变成3万次SQL执行。给重试入口加上信号量限制如Semaphore限制同时重试的线程数很有必要。6.3 前端领域的乐观锁编辑冲突提示的另一种姿势乐观锁不只在数据库里用。你的前端项目做多人协同时也会遇到两个人同时编辑同一份文档的冲突问题。我在一个知识库项目里用过一次典型的乐观锁思路每条文档记录有一个revision字段编辑页打开时读取revision保存提交时验证revision是否还是打开时的那个值如果不是提示用户文档已被他人修改请手动合并。// 保存前先提交revision让后端校验 const resp await api.save({ id: doc.id, revision: this.localRevision, content: this.editorContent, }); if (resp.status 409) { // 冲突 this.showConflictDialog(); // 弹出对比合并界面 }这在不用WebSocket实时同步的情况下是一个成本低、体验尚可的冲突处理方案。虽然它不像CRDT或OT那样能做到无感知自动合并但绝大多数内部工具场景已经够用了。逻辑上和数据库乐观锁完全同构读取时记版本、提交时校验版本、冲突时引导重试或人工处理。6.4 乐观锁与数据库隔离级别的关系一个易被忽略的细节最后提一个进阶层面的点。乐观锁的有效性依赖事务隔离级别吗这里多说两句MVCC在READ COMMITTED和REPEATABLE READ下快照读的行为不同。在READ COMMITTED下每次SELECT都会获取新的快照这意味着两个事务先后读到同一行时后读的事务可能看到已提交的新版本版本号对不上——这会让乐观锁的先读后写步骤变得更加不稳定冲突率上升。在REPEATABLE READ下事务的快照是第一次读到数据时固定的整个事务内多次读取版本号保持一致乐观锁的判断更稳定。所以在MySQL默认的REPEATABLE READ隔离级别下乐观锁配合MVCC使用是最顺的。如果项目里把隔离级别调成了READ COMMITTED一定要重新审视乐观锁的重试策略是否需要调整因为同样的代码在两种隔离级别下的冲突概率并不一样。这个坑很隐蔽我一次压测时发现版本冲突率莫名升高排查了半天SQL和索引最后才发现是某次全局配置变更把隔离级别动了。以此为戒乐观锁不是加上就完事它受周围数据库环境的影响远比你想象的大。我个人在实际项目中通常默认把乐观锁版本号放在每张核心业务表里——需要时直接拿来用成本不过是一列加一次更新时多写一个1。但每个用乐观锁的团队都必须清楚它本质上是赌并发冲突不会发生的策略赌输了要有计划赌赢了也别觉得理所当然。上线前压测一下真实的冲突率和重试率比在工位上纠结各种理论都管用。
返回列表