乐观锁与悲观锁的业务实现——库存系统中的并发控制、表设计与事务边界

发布时间:2026/11/7 14:53:27

乐观锁与悲观锁的业务实现——库存系统中的并发控制、表设计与事务边界 文章目录每日一句正能量前言1. 背景与问题2. 环境与数据3. 复现过程3.1 构造并发测试4. 方案实施4.1 乐观锁版本号控制4.2 JDBC 乐观锁实现4.3 乐观锁重试4.4 MyBatis 乐观锁4.5 JPA / Hibernate 乐观锁4.6 更高效的库存乐观扣减不先查版本4.7 悲观锁SELECT ... FOR UPDATE4.8 JDBC 悲观锁实现4.9 MyBatis 悲观锁4.10 JPA / Hibernate 悲观锁4.11 悲观锁的事务边界4.12 悲观锁超时与异常5. 结果对比无锁版本乐观锁版本悲观锁版本6. 风险与复盘6.1 乐观锁不是“没有锁”6.2 乐观锁冲突率过高会浪费 CPU6.3 悲观锁最怕长事务6.4 FOR UPDATE 的锁范围与索引有关6.5 重试一定要放在正确事务外层6.6 库存扣减不一定需要先 SELECT结语每日一句正能量烟火照团圆灯火映相知家人围坐时笑语化开岁月霜知己相逢处清茶斟满情意长。世间繁华万千终归一盏暖人间风景无限最重是寻常。家人闲坐灯火可亲新年伊始喜乐安宁。前言库存系统最怕的不是“查询慢一点”而是数字看起来没问题业务实际上已经超卖。假设某商品只剩 1 件库存两个请求几乎同时下单。两个线程都读到stock1都判断“库存充足”然后分别完成订单创建。最后数据库里的库存仍然可能是 0但系统已经卖出了 2 件。这种问题本质上不是 SQL 写错而是读取 判断 修改这三个步骤没有被放在正确的并发控制边界里。解决这类问题最常见的两套手段是乐观锁假设冲突不多更新时再检查版本 悲观锁假设冲突会发生读取时就锁住数据两者并没有绝对优劣关键在于业务冲突概率、事务长度、吞吐目标以及失败重试成本。1. 背景与问题先看一个没有并发控制的库存扣减代码TransactionalpublicvoidcreateOrder(longskuId,intquantity){IntegerstockjdbcTemplate.queryForObject(SELECT stock FROM inventory WHERE sku_id ?,Integer.class,skuId);if(stocknull||stockquantity){thrownewIllegalStateException(库存不足);}jdbcTemplate.update(UPDATE inventory SET stock ? WHERE sku_id ?,stock-quantity,skuId);jdbcTemplate.update( INSERT INTO orders(sku_id, quantity, status) VALUES (?, ?, CREATED) ,skuId,quantity);}单线程测试没有问题。但在高并发下可能出现A 读取 stock1 B 读取 stock1 A 判断 1 1 B 判断 1 1 A 更新为 0 B 也更新为 0最终库存0看起来没有出现负数。但实际上订单 A 成功 订单 B 成功已经超卖。这类问题通常被称为“丢失更新”或并发覆盖。2. 环境与数据示例环境JDK 21 Spring Boot 3.3 MySQL 8.0 HikariCP Spring JDBC MyBatis 3.x Hibernate 6 / JPA库存表CREATETABLEinventory(sku_idBIGINTPRIMARYKEY,sku_nameVARCHAR(128)NOTNULL,stockINTNOTNULL,versionBIGINTNOTNULLDEFAULT0,updated_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP);订单表CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,sku_idBIGINTNOTNULL,quantityINTNOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP);测试数据INSERTINTOinventory(sku_id,sku_name,stock,version)VALUES(1001,机械键盘,10,0);这里额外增加version专门用于乐观锁。3. 复现过程3.1 构造并发测试使用 Java 并发工具模拟 20 个请求同时扣减库存。TestvoidconcurrentDeduct()throwsException{intthreads20;ExecutorServicepoolExecutors.newFixedThreadPool(threads);CountDownLatchreadynewCountDownLatch(threads);CountDownLatchstartnewCountDownLatch(1);CountDownLatchdonenewCountDownLatch(threads);AtomicIntegersuccessnewAtomicInteger();AtomicIntegerfailednewAtomicInteger();for(inti0;ithreads;i){pool.submit(()-{try{ready.countDown();start.await();orderService.createOrder(1001L,1);success.incrementAndGet();}catch(Exceptione){failed.incrementAndGet();}finally{done.countDown();}});}ready.await();start.countDown();done.await();System.out.println(successsuccess.get(), failedfailed.get());}如果初始库存为10预期应该是成功 10 失败 10 最终库存 0而无并发控制时可能出现成功 20 失败 0 最终库存 0这正是库存系统最危险的情况数据库数字没有变成负数却已经超卖。4. 方案实施4.1 乐观锁版本号控制乐观锁的基本思想是读取数据时记住 version 更新时要求 version 仍然等于旧值SQLUPDATEinventorySETstockstock-?,versionversion1WHEREsku_id?ANDstock?ANDversion?;如果返回1说明成功。如果返回0说明至少存在一种情况版本冲突 库存不足 记录不存在4.2 JDBC 乐观锁实现先查询库存publicInventoryfind(longskuId){returnjdbcTemplate.queryForObject( SELECT sku_id, stock, version FROM inventory WHERE sku_id ? ,(rs,rowNum)-newInventory(rs.getLong(sku_id),rs.getInt(stock),rs.getLong(version)),skuId);}更新publicbooleandeductOptimistic(longskuId,intquantity,longversion){introwsjdbcTemplate.update( UPDATE inventory SET stock stock - ?, version version 1 WHERE sku_id ? AND stock ? AND version ? ,quantity,skuId,quantity,version);returnrows1;}业务层TransactionalpublicvoidcreateOrderOptimistic(longskuId,intquantity){InventoryinventoryinventoryRepository.find(skuId);if(inventory.stock()quantity){thrownewOutOfStockException();}booleanupdatedinventoryRepository.deductOptimistic(skuId,quantity,inventory.version());if(!updated){thrownewOptimisticConflictException();}orderRepository.insert(skuId,quantity,CREATED);}这里必须强调更新 0 行不是“正常成功”它必须被当成并发冲突或库存条件失败处理。4.3 乐观锁重试高频库存系统通常会允许有限重试。例如publicvoidcreateWithRetry(longskuId,intquantity){intmaxRetry3;for(inti0;imaxRetry;i){try{orderTxService.createOrderOptimistic(skuId,quantity);return;}catch(OptimisticConflictExceptione){if(imaxRetry-1){throwe;}LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(ThreadLocalRandom.current().nextLong(5,20)));}}}这里需要注意重试最好在事务外层不要在一个已经发生冲突、可能被标记回滚的事务里继续执行下一轮。更合理的结构是外层重试循环 内层每次新事务4.4 MyBatis 乐观锁MapperupdateiddeductOptimisticUPDATE inventory SET stock stock - #{quantity}, version version 1 WHERE sku_id #{skuId} AND stock #{quantity} AND version #{version}/updateJavaintrowsinventoryMapper.deductOptimistic(skuId,quantity,version);if(rows!1){thrownewOptimisticConflictException();}不要忽略返回行数。这是 MyBatis 乐观锁实现里最常见的代码错误之一。4.5 JPA / Hibernate 乐观锁JPA 原生支持VersionprivateLongversion;EntityEntityTable(nameinventory)publicclassInventoryEntity{IdColumn(namesku_id)privateLongskuId;privateIntegerstock;VersionprivateLongversion;}ServiceTransactionalpublicvoiddeduct(longskuId,intquantity){InventoryEntityentityrepository.findById(skuId).orElseThrow();if(entity.getStock()quantity){thrownewOutOfStockException();}entity.setStock(entity.getStock()-quantity);}Hibernate 提交时会生成类似UPDATEinventorySETstock?,version?WHEREsku_id?ANDversion?;如果影响行数为 0通常会抛出类似OptimisticLockException或 Hibernate 自己的乐观锁异常。工程上应该把它转换成业务可识别异常catch(OptimisticLockExceptione){thrownewOptimisticConflictException(e);}4.6 更高效的库存乐观扣减不先查版本库存扣减还有一个常见优化UPDATEinventorySETstockstock-1WHEREsku_id?ANDstock1;通过受影响行数判断1 扣减成功 0 库存不足这实际上把检查 扣减变成一个原子 SQL。代码publicbooleandeductAtomic(longskuId){introwsjdbcTemplate.update( UPDATE inventory SET stock stock - 1 WHERE sku_id ? AND stock 1 ,skuId);returnrows1;}对于单纯库存扣减这通常比SELECT version UPDATE version更高效。因此库存场景不能机械地认为“用了 version 才叫乐观并发”。更重要的是把业务条件写进 UPDATE 的 WHERE 通过影响行数做并发裁决。4.7 悲观锁SELECT … FOR UPDATE悲观锁的思路正好相反我假设并发冲突很可能发生 所以在修改前先锁定目标行。SQLSELECTsku_id,stockFROMinventoryWHEREsku_id?FORUPDATE;在事务提交或回滚之前其他事务如果也想对同一行加排他锁通常需要等待。4.8 JDBC 悲观锁实现TransactionalpublicvoidcreateOrderPessimistic(longskuId,intquantity){InventoryinventoryjdbcTemplate.queryForObject( SELECT sku_id, stock, version FROM inventory WHERE sku_id ? FOR UPDATE ,(rs,rowNum)-newInventory(rs.getLong(sku_id),rs.getInt(stock),rs.getLong(version)),skuId);if(inventory.stock()quantity){thrownewOutOfStockException();}introwsjdbcTemplate.update( UPDATE inventory SET stock stock - ? WHERE sku_id ? ,quantity,skuId);if(rows!1){thrownewIllegalStateException(inventory update failed);}orderRepository.insert(skuId,quantity,CREATED);}这里最关键的是SELECT ... FOR UPDATE必须在事务内。如果没有事务SELECT 后立即提交锁很快释放后续 UPDATE 就失去了保护意义。4.9 MyBatis 悲观锁MapperselectidfindForUpdateresultTypeInventorySELECT sku_id, stock, version FROM inventory WHERE sku_id #{skuId} FOR UPDATE/selectServiceTransactionalpublicvoiddeduct(longskuId,intquantity){InventoryinventoryinventoryMapper.findForUpdate(skuId);if(inventory.getStock()quantity){thrownewOutOfStockException();}inventoryMapper.deduct(skuId,quantity);orderMapper.insert(...);}核心仍然是事务边界。4.10 JPA / Hibernate 悲观锁RepositoryLock(LockModeType.PESSIMISTIC_WRITE)Query( select i from InventoryEntity i where i.skuId :skuId )OptionalInventoryEntityfindForUpdate(Param(skuId)LongskuId);ServiceTransactionalpublicvoiddeduct(longskuId,intquantity){InventoryEntityentityrepository.findForUpdate(skuId).orElseThrow();if(entity.getStock()quantity){thrownewOutOfStockException();}entity.setStock(entity.getStock()-quantity);}Hibernate 会根据数据库方言生成对应的加锁 SQL。4.11 悲观锁的事务边界悲观锁最大的问题不是“锁”而是锁持有多久危险代码TransactionalpublicvoidcreateOrder(){Inventoryinventoryrepository.findForUpdate(...);remotePromotionService.check();remotePaymentService.preAuth();repository.deduct(...);}这里网络调用时间都会变成数据库锁持有时间。更合理的方式事务外 校验不需要强一致的远程信息 事务内 FOR UPDATE 校验库存 扣库存 写订单 COMMIT 事务外 后续远程动作锁的事务边界应尽可能短。4.12 悲观锁超时与异常当多个事务竞争同一库存行时可能出现锁等待超时 死锁Spring 常见异常可能被翻译成CannotAcquireLockException DeadlockLoserDataAccessException PessimisticLockingFailureException处理方式不能简单catch(Exceptione){retry();}建议分类if(isDeadlock(e)){retryWithBackoff();}if(isLockTimeout(e)){returnbusy();}throwe;重试必须满足幂等 有限次数 退避 新事务5. 结果对比可以用同一组并发测试比较三种实现。假设初始库存10 并发请求20 每次扣减1无锁版本可能出现成功订单20 失败订单0 最终库存0 实际超卖10乐观锁版本预期首次成功约 10 冲突重试若干 库存不足其余 最终库存0 超卖0特点没有长时间持锁 冲突时通过更新失败重试 高冲突下重试成本会上升悲观锁版本预期成功订单10 库存不足10 最终库存0 超卖0特点同一 SKU 修改被串行化 逻辑简单 高热点下锁等待明显可以把选择原则粗略理解为场景更倾向方案冲突概率低乐观锁高读低写乐观锁热点 SKU 抢购原子 UPDATE 或专门库存方案冲突高且必须串行悲观锁事务很短悲观锁可接受事务含远程调用避免长时间悲观锁6. 风险与复盘6.1 乐观锁不是“没有锁”乐观锁最终的UPDATE仍然会使用数据库自己的并发控制机制。所谓乐观是指应用不提前锁住记录等待而不是数据库完全不加锁。6.2 乐观锁冲突率过高会浪费 CPU如果一个热门 SKU 同时有上千个线程抢读 version 更新失败 再读 再更新失败大量请求会在数据库里做无效工作。因此秒杀类热点库存通常要进一步考虑库存分段 队列串行 Redis 预扣 数据库最终校验而不是无限重试乐观锁。6.3 悲观锁最怕长事务持锁期间进行HTTP 调用 RPC 调用 MQ 同步等待 文件 IO 复杂计算都会放大锁等待。所以悲观锁事务必须尽量短。6.4 FOR UPDATE 的锁范围与索引有关如果查询条件没有合适索引SELECT...FROMinventoryWHEREsku_name?FORUPDATE;数据库可能扫描并锁住比预期更多的数据范围。因此悲观锁 SQL 必须检查索引 执行计划 隔离级别 锁类型不能只看 SQL 语义。6.5 重试一定要放在正确事务外层错误TransactionalpublicvoiddoOrder(){for(...){try{doUpdate();}catch(OptimisticLockExceptione){// 在同一事务继续重试}}}更安全的是外层重试 - 每次调用一个独立事务方法因为某些异常发生后当前事务已经被标记rollback-only继续执行没有意义。6.6 库存扣减不一定需要先 SELECT如果业务只是库存 quantity 时扣减优先考虑UPDATEinventorySETstockstock-?WHEREsku_id?ANDstock?;通过影响行数判断成败。这往往是库存系统里最简单、最可靠、吞吐也很好的并发控制方式。结语乐观锁和悲观锁的区别不应该只背成乐观锁不加锁 悲观锁加锁更准确的理解是乐观锁 允许并发执行 在提交修改时检测冲突。 悲观锁 先获取排他访问权 再执行读取和修改。在库存系统中选择方案时至少要看并发冲突概率 热点程度 事务长度 允许重试次数 数据库锁等待 业务是否必须串行对于大多数普通库存扣减推荐优先评估UPDATEinventorySETstockstock-?WHEREsku_id?ANDstock?;因为它把“判断库存”和“扣减库存”放进了一条原子 SQL。当业务需要读取更多状态并进行条件判断时再考虑version 乐观锁或者SELECT ... FOR UPDATE 悲观锁真正可靠的并发控制不在于选一个听起来更高级的锁而在于让业务条件、SQL 原子性、异常处理和事务边界保持一致。转载自https://blog.csdn.net/u014727709/article/details/165241439欢迎 点赞✍评论⭐收藏欢迎指正
延伸阅读

更多相关文章

2026/10/30 5:44:12

LangChain表达式语言(LCEL)并行执行与性能优化实战

1. LangChain表达式语言(LCEL)核心解析 LCEL作为LangChain框架中的核心编排层,其设计哲学源于对AI应用开发中三个关键痛点的解决:执行效率、代码可维护性和运行时灵活性。与传统编程范式不同,LCEL采用声明式语法描述任务流程,让开…

2026/10/26 0:39:16

鸿蒙TextInput组件键盘弹出控制方案详解

1. 问题现象与场景还原在鸿蒙应用开发中,TextArea和TextInput组件是处理用户文本输入的核心控件。近期不少开发者反馈一个特定场景下的交互问题:当用户点击这两个组件获取光标时,系统键盘会自动弹出,但在某些业务场景下这并不是期…

2026/10/25 16:54:02

Kafka从部署到排障:KRaft模式、消息延迟与消费组Offset实践

兄弟们,Kafka这块的知识点,说难不难,说简单也真不简单。我见过太多人,平时用着没问题,一上生产或者一面试就露馅,知识点全是散的。最近后台问Kafka的人特别多,从“Windows怎么装Kafka”到“Kafk…

2026/11/7 14:49:49

考电工证要求色弱吗?洛阳涧西区应届生这样避坑考前押题

考电工证要求色弱吗?洛阳涧西区应届生这样避坑考前押题 你是不是也在犹豫, 不知道去哪报名怕被中介坑 ?尤其是像你这样刚毕业的工程类学生,想考电工证,但对流程、要求、考试内容都一知半解。**考电工证要求色弱吗?**这是很多准备报名的同学最关心…

2026/11/7 14:49:49

电工证安监网站在哪考?特种作业证怎么考最靠谱?

电工证安监网站在哪考?特种作业证怎么考最靠谱? 你是不是也遇到过这种情况:找工作时面试官问你有没有电工证,你说没考过,结果直接被刷掉?特种作业证是很多行业的“入场券”,尤其在电工、焊工、高处作业等领域, 没有证等于没资格…

2026/11/7 14:49:49

在邯郸电工证怎么复审怎么考

工地太忙根本没时间复习考试?2026河南最新政策下邯郸电工证怎么复审全攻略 工地太忙根本没时间复习考试?2026河南最新政策下,邯郸电工证怎么复审成了很多电工的头等难题。尤其是在建筑、电力施工等行业,电工证是上岗的“门槛证”,但复审流程复杂…

2026/11/7 14:49:49

乐清考高级电工证保姆级教程:从零到拿证的全攻略

乐清考高级电工证保姆级教程:从零到拿证的全攻略 你是不是正在为找工作发愁?特种作业证成了不少行业的“硬门槛”,特别是像电工这种技术性强、风险高的岗位,没有证连面试机会都没有。现在就来聊聊【乐清考高级电工证】的保姆级教程,帮你从报名到拿证,每…

2026/11/7 14:49:49

河北廊坊电工证培训怎么选?考前押题攻略来了别走弯路

河北廊坊电工证培训怎么选?考前押题攻略来了别走弯路 不知道去哪报名怕被中介坑?你不是一个人,河北廊坊电工证培训信息鱼龙混杂,很多人一上来就踩坑,不仅浪费时间,还花了冤枉钱。今天这篇内容专门给你理清思路,讲透考试内容、重点章节、复习方法,还有…

2026/11/5 15:39:06

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/7 14:50:12

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/7 0:03:08

郑州报考避坑指南:电工证和钳工证怎么选?实操考试心里没底怕挂科

郑州报考避坑指南:电工证和钳工证怎么选?实操考试心里没底怕挂科 实操考试心里没底怕挂科?你不是一个人。特别是在郑州这样的城市,很多施工企业负责人和一线作业人员在报考电工证和钳工证时都面临着同样的困惑。到底该考哪个证?哪个更适合自己?哪个证书…

2026/11/7 0:03:08

广西电工证办理流程全解析:外省证怎么转?在哪考最靠谱?

广西电工证办理流程全解析:外省证怎么转?在哪考最靠谱? 之前考的证在外省能不能转过来?这个问题我遇到太多了,今天就拿广西电工证办理流程来具体说说,别再走弯路了。广西电工证的报考、复审、继续教育这些环节,如果你不了解,真的会吃大亏,尤其对于从…

2026/11/7 0:03:08

电气电工证都有哪些保姆级教程

电气电工证都有哪些保姆级教程 你是不是正愁不知道去哪报名,怕被中介坑?别急,这篇保姆级教程专为河南初次报考人员准备,帮你搞清楚 电气电工证都有哪些 ,以及如何避开那些坑。 电气电工证都有哪些?别被五花八门的证书搞懵了…

2026/11/5 15:39:06

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/7 14:50:12

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑