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

发布时间:2026/9/14 17:50:14

乐观锁与悲观锁的业务实现——库存系统中的并发控制、表设计与事务边界 文章目录每日一句正能量前言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/9/14 17:50:14

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

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

2026/9/14 17:50:14

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

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

2026/9/14 17:50:14

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

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

2026/9/14 18:25:18

Flutter+OpenHarmony视力保护App开发实践

1. 项目概述这个基于Flutter和OpenHarmony的视力保护提醒App,核心功能是通过四种专业检测方法(色盲、对比度、清晰度、疲劳度)评估用户眼睛健康状况。作为移动端健康管理工具,它解决了现代人长时间使用电子设备导致的视力问题缺乏…

2026/9/14 18:20:17

发票表格检测实战:基于YOLOv8与真实数据集的训练调优

简介:发票表格检测数据集是一份面向YOLO系列目标检测框架的行业数据集,专注于发票文档中表格区域的自动定位与边界框回归,可应用于文档结构识别、财务票据自动化处理、办公文档智能审核以及计算机视觉算法研究等场景。压缩包共1820个文件&…

2026/9/14 2:17:50

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/14 0:03:22

KCF目标跟踪算法与OTB工程实现:毕业设计实战解析

简介:这是一份基于KCF核相关滤波算法、融合尺度池与抗遮挡处理的目标检测跟踪MATLAB完整源码,主要面向计算机相关专业准备毕业设计、课程设计或期末大作业的学生,也适合需要项目实战练习的初学者。源码在OTB数据集上完成验证,能够…

2026/9/14 0:03:22

语音情感识别实战:Keras实现LSTM、CNN、SVM与MLP多模型对比

简介:面向语音情感识别入门与进阶开发者,这份基于Keras的项目源码完整实现了LSTM、CNN、SVM、MLP四种模型,兼容Python3.8与Keras/TensorFlow2环境。压缩包内含49个文件,大小约70.31MB,主体包括Python脚本、yaml/json配…

2026/9/14 11:59:31

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/14 13:53:59

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/14 11:22:57

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

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

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

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