ARTICLE DETAIL

资讯详情

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

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

乐观锁与悲观锁的业务实现——库存系统中的并发控制、表设计与事务边界 文章目录每日一句正能量前言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欢迎 点赞✍评论⭐收藏欢迎指正
返回列表