
乐观锁实战原理、实现方式与 12 个业务案例适用场景MySQL 8.0 InnoDB · Java Spring / MyBatis-Plus · 库存、订单状态、后台编辑、缓存写侧本文讲清乐观锁是什么、怎么写、和悲观锁怎么选并结合智慧书城Factory_Test_Demo及常见业务给出12 个可落地的案例。前言面试里「乐观锁 vs 悲观锁」经常被问但很多人只会背定义悲观锁先锁住再改SELECT ... FOR UPDATE乐观锁先改改的时候校验「数据有没有被别人动过」真正写代码时乐观锁往往不是一个注解那么简单。它可以是显式version字段WHERE version ?状态字段 CASWHERE status PENDING数值条件 CASWHERE stock ?本项目大量在用三种本质相同用一次原子 UPDATE 的 WHERE 条件代替长时间占锁。一、乐观锁的核心思想1.1 经典流程带 version1. SELECT id, stock, version FROM product WHERE id 1; → 读到 stock10, version5 2. 业务计算stock 应变为 9 3. UPDATE product SET stock 9, version version 1 WHERE id 1 AND version 5; 4. 若 affected rows 0 → 说明期间别人已更新version 已不是 5 → 重试或返回「请刷新后重试」没有在步骤 13 之间对行加长时间排他锁冲突在步骤 3 由数据库一次性判定。1.2 和悲观锁对比维度悲观锁FOR UPDATE乐观锁冲突时等待阻塞直接失败 / 重试读多写少容易排队通常更省锁写冲突极高秒杀单行热点瓶颈大量失败重试不如 Redis实现复杂度较低需处理重试、失败提示典型 SQL先 SELECT FOR UPDATE 再 UPDATE一条条件 UPDATE 或 version UPDATE1.3 三种常见实现形态形态WHERE 条件典型场景A. version 列AND version ?后台编辑同一条记录、通用实体更新B. 状态 CASAND status 旧状态订单状态机、券核销C. 数值 CASAND stock ?/AND booked_count capacity库存、名额、余额B、C 有时不叫「乐观锁」但和 version 乐观锁解决的是同一类问题避免基于过期读去做覆盖写。二、案例 1商品库存扣减数值 CAS场景购书支付时扣sale_stock不能扣成负数。本项目BookMapper.deductSaleStockUPDATEbookSETsale_stocksale_stock-#{qty}, updated_at NOW()WHEREid#{id} AND sale_stock #{qty};// 影响行数 0 → 库存不足或并发已被扣完if(mapper.deductSaleStock(bookId,quantity)0){throwTradeException.outOfStock();}点说明是否 version无 version 列靠sale_stock qty保证原子为何够UPDATE 单行原子条件不满足则 0 行与悲观锁本项目 pay 前还有FOR UPDATE读 book属于悲观 条件 UPDATE双保险变体带 versionUPDATEbookSETsale_stocksale_stock-1,versionversion1WHEREid1001ANDsale_stock0ANDversion7;适合需要在应用层明确感知「版本冲突」、做自动重试的场景。三、案例 2用户余额扣款数值 CAS场景余额支付不能透支。UPDATEauth_userSETbalancebalance-#{amount}, updated_at NOW()WHEREid#{id} AND balance #{amount};并发结果两笔支付同时扣 80余额 100仅一笔成功另一笔 affected0 → 余额不足无需 FOR UPDATE 长时间占行适合冲突不算极高的支付场景注意转账A 减 B 加需同一事务内更新两行并固定 id 顺序防死锁见案例 8。四、案例 3订单状态流转状态 CAS场景仅PENDING_PAY可支付防止重复支付或已取消订单被支付。本项目TradeOrderMapper.markPaidUPDATEtrade_orderSETstatusPAID,paid_atNOW(),updated_atNOW()WHEREid#{id} AND status PENDING_PAY AND user_id #{userId};if(orderMapper.markPaid(orderId,userId)0){throwTradeException.invalidStatus();}点说明本质状态机乐观锁status即「业务版本」vs 先查再改少一次 SELECT且 UPDATE 原子幂等已 PAID 时再调 pay → 0 行可判无效状态其他状态流转示例-- 取消仅待支付可取消UPDATEtrade_orderSETstatusCANCELLED,cancelled_atNOW()WHEREid?ANDstatusPENDING_PAY;-- 借阅确认仅 APPLIED 可确认UPDATEborrow_orderSETstatusCONFIRMED,borrow_atNOW()WHEREid?ANDstatusAPPLIED;五、案例 4优惠券核销状态 CAS场景同一张券只能核销一次防止重复下单重复使用。UPDATEuser_couponSETstatusUSED,used_atNOW(),trade_order_id?,updated_atNOW()WHEREid?ANDstatusUNUSEDANDuser_id?;并发结果两笔订单同时用同一张券仅一笔 UPDATE 成功第二笔affected0 → 券不可用这是券维度的乐观锁比「先 SELECT 看 UNUSED 再 UPDATE」更安全。六、案例 5优惠券模板发放上限数值 CAS场景签到赠券 / 秒杀发券模板total_count有限时不能超发。UPDATEcoupon_templateSETissued_countissued_count1,updated_atNOW()WHEREid#{id}ANDstatus1AND(total_count0ORissued_counttotal_count);点说明total_count 0表示不限量affected0已发完或模板下架七、案例 6预约时段名额数值 CAS version 递增场景某时段capacity30已约booked_count不能超过容量。表结构本项目reservation_time_slot含version字段UPDATEreservation_time_slotSETbooked_countbooked_count1,versionversion1,updated_atNOW()WHEREid#{id}ANDbooked_countcapacity;释放名额UPDATEreservation_time_slotSETbooked_countbooked_count-1,versionversion1,updated_atNOW()WHEREid#{id}ANDbooked_count0;点说明主要防超卖booked_count capacityversion每次变更 1便于审计、可扩展为严格AND version?与座位锁座位级防超卖仍靠reservation_order唯一占用 行锁时段名额是另一维度八、案例 7运营后台编辑冲突经典 version场景管理员 A、B 同时打开「图书编辑」页后提交的不能悄悄覆盖先提交的。8.1 表结构ALTERTABLEbookADDCOLUMNversionINTNOTNULLDEFAULT0;8.2 读GET/api/admin/books/1{id:1,title:Java核心技术,price:119,version:12}8.3 写UPDATEbookSETtitle?,price?,versionversion1,updated_atNOW()WHEREid1ANDversion12;if(bookMapper.updateWithVersion(book)0){thrownewBookstoreException(4008,数据已被他人修改请刷新后重试);}体验前端收到 4008提示刷新页面重新编辑8.4 MyBatis-PlusVersionTableName(book)publicclassBook{VersionprivateIntegerversion;}updateById(book)时 MP 自动WHERE id? AND version?并version1。九、案例 8转账双账户 固定加锁顺序场景A 转 100 给 B。可用悲观锁也可用条件 UPDATE 乐观风格STARTTRANSACTION;-- 先扣款条件 CASUPDATEaccountSETbalancebalance-100,versionversion1WHEREid100ANDbalance100ANDversion5;-- 影响行数0则回滚UPDATEaccountSETbalancebalance100,versionversion1WHEREid200ANDversion3;INSERTINTOtransfer_log(...)VALUES(...);COMMIT;注意两行更新任一失败要整事务回滚悲观锁写法按 id 排序FOR UPDATE两行再改见 MySQL锁与业务场景实战博客 场景 2十、案例 9文章点赞 / 阅读量高并发计数场景点赞数 1允许极少量误差时可 Redis INCR要精确时用 DBUPDATEarticleSETlike_countlike_count1,versionversion1WHEREid100ANDversion7;高冲突时方案 A乐观锁 客户端重试 3 次方案 BRedis 聚合定时刷 DB方案 C单独计数表 行锁热点行仍痛乐观锁适合冲突不算极端的计数秒杀级热点应 Redis。十一、案例 10工单抢单状态 CAS SKIP LOCKED 对比场景多个客服抢同一待处理工单。乐观锁风格UPDATEticketSETstatusPROCESSING,assignee_id1001,updated_atNOW()WHEREid9001ANDstatusOPEN;-- 0 行 → 已被别人抢走悲观锁风格MySQL 8.0SELECTidFROMticketWHEREstatusOPENORDERBYidLIMIT1FORUPDATESKIP LOCKED;UPDATEticketSETstatusPROCESSING,assignee_id?WHEREid?;选型说明乐观 UPDATE实现简单抢不到就换下一条SKIP LOCKED批量拉取任务更高效十二、案例 11缓存写侧 version CAS防旧值覆盖场景慢线程读 DB 旧数据回填 Redis覆盖管理员刚写入的新缓存。见 数据库与缓存高一致性业务场景实现方案 方案 D// Redis 中存{price:100,version:13}Lua 仅当newVersion oldVersion才 SET。点说明version 在 DBUPDATE 时version1version 在缓存写侧 CAS不是每次读都对账本质跨存储的乐观并发控制十三、案例 12秒杀抢券Redis 原子 DB 唯一约束兜底场景P4 秒杀规划。DB 乐观锁扛不住万级 QPS但落库阶段仍可用乐观思想-- 消费者落库时INSERTINTOseckill_order(user_id,activity_id,status,idempotency_key)VALUES(1,100,PROCESSING,uuid);-- uk_seckill_user_activity (user_id, activity_id) 冲突 → 已参与层次手段抢名额Redis Lua 原子 decr sadd一人一单DB UNIQUE重试幂等idempotency_key UNIQUE这是分层乐观热点在 RedisDB 用约束做最终一致性兜底。十四、Java 层重试与异常设计14.1 通用重试模板publicTTupdateWithRetry(SupplierIntegerupdate,intmaxRetries){for(inti0;imaxRetries;i){introwsupdate.get();if(rows0){return...;}// 重新 SELECT 最新 version 后再组装 UPDATE}thrownewOptimisticLockException(更新冲突请重试);}14.2 何时重试、何时直接失败场景建议后台编辑不重试提示用户刷新库存扣减0 行 → 直接「库存不足」不盲目重试内部批处理可重试 23 次 随机退避支付 / 核销不重试0 行即业务失败14.3 错误码示例智慧书城风格code含义4008数据版本冲突请刷新后台编辑4204库存不足购书4102券不可用核销 CAS 失败十五、常见错误错误 1有 version 但 UPDATE 不带 version 条件-- ❌ 丢失版本校验UPDATEbookSETtitle?,versionversion1WHEREid1;-- ✅UPDATEbookSETtitle?,versionversion1WHEREid1ANDversion12;错误 2先 SELECT 再 UPDATE不用条件 UPDATE// ❌ 两步之间可能被插队Bookbmapper.selectById(id);b.setSaleStock(b.getSaleStock()-1);mapper.updateById(b);// ✅mapper.deductSaleStock(id,1);错误 3把「影响行数0」都当成乐观锁冲突原因处理version 冲突提示刷新 / 重试stock 不足提示售罄status 不对提示状态不允许要区分业务失败与并发冲突。错误 4高冲突场景硬用乐观锁 无限重试秒杀 1000 人抢 10 个名额 → 990 次无效重试打爆 DB。应Redis 前置过滤。错误 5Version 与手写 SQL 混用MyBatis-Plus 的updateById带 version自定义Update若不写 version 条件会绕过乐观锁。十六、本项目中的乐观锁实践地图模块实现方式文件/表购书扣库存数值 CASsale_stock qtyBookMapper.deductSaleStock余额支付数值 CASbalance amountAuthUserMapper.deductBalance订单支付状态 CASPENDING_PAYTradeOrderMapper.markPaid券核销状态 CASUNUSEDUserCouponMapper.markUsed券模板限量数值 CASissued_count total_countCouponTemplateMapper时段名额数值 CAS versionReservationTimeSlotMapper.tryReserve座位防超卖悲观锁FOR UPDATEReservationService与乐观锁互补缓存防覆盖version CAS方案 D高一致性文档结论本项目大量用的是条件 UPDATE状态/数值 CAS这是乐观锁思想在业务里最常用的形态reservation_time_slot.version为严格 version 乐观锁预留了扩展点。十七、选型决策树需要更新一行数据怕并发覆盖 │ ├─ 冲突频率极高秒杀 │ └─ 是 → Redis Lua / MQDB 唯一约束兜底 │ ├─ 是状态流转 │ └─ 是 → WHERE status 旧状态案例 3、4 │ ├─ 是库存/余额/名额 │ └─ 是 → WHERE stock/balance/count 条件案例 1、2、6 │ ├─ 是后台同条记录编辑 │ └─ 是 → version 列 MP Version案例 7 │ └─ 冲突高且必须强一致顺序执行 └─ 是 → SELECT FOR UPDATE悲观锁见幂等行锁博客十八、面试答法30 秒 / 2 分钟30 秒版乐观锁假设冲突少更新时通过 WHERE 条件判断数据是否被改过。常见三种version 字段、状态 CAS、库存数值 CAS。冲突时 UPDATE 影响行数为 0业务决定重试还是失败。秒杀级热点用 Redis不用 DB 乐观锁硬扛。十九、相关文档MySQL锁与业务场景实战博客 — FOR UPDATE、SKIP LOCKED、场景选型幂等与行级锁实战博客 — 预约座位悲观锁 幂等键InnoDB锁机制实战博客 — 行锁、间隙锁原理数据库与缓存高一致性业务场景实现方案 — 缓存写侧 version CASP4秒杀业务流程与技术栈 — Redis 原子 DB 兜底智慧书城项目业务价值点 — 项目并发控制总览二十、小结要点内容本质用原子 UPDATE 的 WHERE 条件避免基于过期读覆盖写三种形态version 列、状态 CAS、数值 CAS成功判定affected rows 0失败处理区分库存不足、状态不对、版本冲突不适用秒杀热点、必须阻塞等待的场景本项目支付链大量使用条件 UPDATE座位用悲观锁P4 秒杀用 Redis乐观锁不是「不用锁」而是把冲突判定推迟到 UPDATE 那一瞬间——写对了一样能保证数据正确。