AI 建议给库存扣减加 `synchronized`,为什么多实例部署后仍会超卖
库存扣减是很多系统里最容易被低估的问题之一。刚开始做商品下单、优惠券领取、报名名额、兑换资格或限量活动时库存逻辑通常很简单publicvoiddeductStock(LongskuId,intquantity){StockstockstockRepository.findBySkuId(skuId);if(stock.getAvailable()quantity){thrownewBusinessException(库存不足);}stock.setAvailable(stock.getAvailable()-quantity);stockRepository.save(stock);}当测试时发现两个请求同时进来偶尔会扣成负数很多人会立刻想到加锁。于是 AI 也很容易给出这样的建议publicsynchronizedvoiddeductStock(LongskuId,intquantity){StockstockstockRepository.findBySkuId(skuId);if(stock.getAvailable()quantity){thrownewBusinessException(库存不足);}stock.setAvailable(stock.getAvailable()-quantity);stockRepository.save(stock);}本地运行后问题似乎解决了。两个线程同时请求时后一个线程会等待前一个线程结束。库存从 1 扣成 0 后第二个请求就会得到“库存不足”。于是很容易得出结论给库存扣减加synchronized就行了。但系统一旦部署成多个实例问题很快会重新出现。例如实例 A 库存剩余 1 收到请求 1 进入 synchronized 实例 B 库存同样剩余 1 收到请求 2 也进入自己的 synchronized两个实例都有自己的 JVM、自己的内存、自己的锁对象。它们并不知道对方正在执行什么。最终可能发生实例 A读取库存 1 实例 B读取库存 1 实例 A扣减为 0 实例 B也扣减为 0 两个订单都显示成功如果库存本来只有 1 件系统却卖出了 2 件。这就是“单机锁看起来有效多实例后仍然超卖”的根源。问题不在synchronized本身而在于它保护的范围只存在于当前 JVM 进程内。一、先搞清楚synchronized 锁住的到底是什么synchronized的作用范围可以简单理解为一个进程 ↓ 一块 JVM 内存 ↓ 同一个锁对象 ↓ 同一个实例中的多个线程它能解决的是单个实例内部的线程竞争。例如一个应用实例 ↓ 线程 A 扣库存 线程 B 扣库存 ↓ 两者使用同一个锁对象 ↓ 可以按顺序执行但它不能天然覆盖多个应用实例、多个容器副本、多台机器、定时任务与接口请求同时修改、消息消费者与人工操作同时扣减、其他系统直接写数据库、批处理任务绕过当前 Java 代码。假设部署结构是负载均衡 ├── 应用实例 A ├── 应用实例 B ├── 应用实例 C └── 应用实例 D用户请求可能被分发到任意实例。即使每个实例里的deductStock()都加了synchronized也只是实例 A 内部串行、实例 B 内部串行、实例 C 内部串行、实例 D 内部串行并不是所有请求全局串行。所以库存问题首先要问的不是“要不要加锁”而是竞争到底发生在哪个范围、谁有权决定库存还够不够、所有扣减路径是否最终都会经过同一个约束点。二、真正危险的不是“没有锁”而是“先查再改”库存超卖的核心通常来自下面这段读写分离逻辑StockstockstockRepository.findBySkuId(skuId);if(stock.getAvailable()quantity){thrownewBusinessException(库存不足);}stock.setAvailable(stock.getAvailable()-quantity);stockRepository.save(stock);它的执行过程大致是读取库存判断库存是否足够计算新库存写回数据库。在并发场景里这四步之间存在空隙。例如库存为 1请求 A读取库存 1 请求 B读取库存 1 请求 A判断 1 1允许扣减 请求 B判断 1 1允许扣减 请求 A写入库存 0 请求 B写入库存 0两个请求都认为自己合法。最终数据库库存没有变成负数但业务已经发生超卖因为系统成功创建了两笔订单却只扣掉了一件库存。这类问题尤其容易被忽略因为开发者看到的数据库结果是available 0表面没有异常但订单数量和库存变化已经不一致了。因此库存扣减不能只依赖“先查库存再决定是否更新”。更关键的是让数据库在同一次更新中完成库存是否足够与库存扣减。三、更可靠的基础方式使用条件更新对于最基础的库存扣减可以把判断条件直接放进 SQLUPDATEsku_stockSETavailableavailable-:quantity,updated_atCURRENT_TIMESTAMPWHEREsku_id:skuIdANDavailable:quantity;然后检查受影响行数publicvoiddeductStock(LongskuId,intquantity){intaffectedstockRepository.deductIfEnough(skuId,quantity);if(affected0){thrownewBusinessException(库存不足);}}这段逻辑的关键是如果库存足够扣减成功影响行数为 1如果库存不足不更新影响行数为 0。数据库把检查和扣减放在同一个原子操作中。请求 A 与请求 B 即使同时到达也只能有一个请求成功。库存为 1 时请求 AUPDATE ... available 1 请求 BUPDATE ... available 1 其中一个先执行成功 available 1 → 0 另一个执行时 available 1 不成立 影响行数 0这时系统可以明确告诉第二个请求库存不足。相比先查再改这种方式更接近库存真正需要的约束只有在库存仍然满足数量要求时扣减才能发生。四、不要误以为“库存不为负”就等于没有超卖有些实现会写成UPDATEsku_stockSETavailableavailable-:quantityWHEREsku_id:skuId;然后在 Java 中补一个判断if(stock.getAvailable()0){thrownewBusinessException(库存不足);}这种方式仍然不可靠。因为判断发生在更新之后甚至可能已经写入了负数库存。更重要的是库存系统里还有一种容易被忽略的错误库存没有变负但同一份库存被成功分配给多个业务请求。例如当前库存为 1请求 A 创建订单成功请求 B 创建订单成功最终库存为 0。如果系统只是“最后一次写入覆盖前一次写入”数据库不会出现负数但两笔订单都成功了。所以库存系统不能只观察库存字段是否小于 0还要观察库存变化记录是否完整、订单是否有唯一扣减凭证、同一笔业务是否被重复扣减、库存扣减成功是否与订单状态一致。库存不是一个普通数字它通常代表一种有限资源的分配资格。五、库存扣减还要考虑同一请求会不会重复到达即使条件更新写对了仍然可能出现重复扣减。例如用户点击提交后网络变慢客户端提交订单 ↓ 数据库已经扣减库存 ↓ 响应在网络中丢失 ↓ 客户端以为失败 ↓ 再次提交同一笔请求或者异步任务重试库存扣减成功后续步骤超时任务被重新执行再次尝试扣库存。如果每次请求都直接扣减deductStock(skuId,quantity);同一笔订单可能被扣两次。因此库存扣减还需要一个业务幂等标识例如orderId reservationId requestId businessKey可以建立库存预占记录CREATETABLEstock_reservation(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_idBIGINTNOTNULL,sku_idBIGINTNOTNULL,quantityINTNOTNULL,statusVARCHAR(32)NOTNULL,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL,UNIQUEKEYuk_order_sku(order_id,sku_id));处理逻辑变成先尝试创建预占记录如果该订单和商品组合已经存在说明可能是重复请求直接返回已有结果或进入状态核对。这样系统才有机会区分这是一次新的库存申请还是同一笔业务的重复提交。六、库存预占、正式扣减、释放库存不是同一个动作很多新手会把库存处理理解成下单成功扣库存。但真实流程里经常至少有三种状态状态含义可用库存还没有被任何订单占用预占库存已经为某笔订单保留但订单尚未完成已扣减库存最终确认消耗不再可用例如支付前订单可能需要先预占可用库存 10 ↓ 订单创建预占 2 ↓ 可用库存 8 预占库存 2如果订单超时取消预占库存 2 ↓ 释放库存 ↓ 可用库存恢复为 10如果支付成功预占库存 2 ↓ 确认消耗 ↓ 已扣减库存增加 2如果没有明确状态而是简单地“下单就扣、取消就加”很容易出现取消任务重复执行库存加回两次支付回调延迟到达订单已经释放库存超时关单和支付成功同时发生同一个订单被多次补偿人工修复时无法知道库存为什么变化。因此库存状态不是为了让表结构更复杂而是为了让每次变化都有明确业务含义。七、让 AI 先拆清楚库存状态和并发范围再生成锁代码如果只问 AI库存扣减并发有问题帮我加锁。它很可能给出synchronized或者ReentrantLock这些方案在单机场景中并不一定错误但它们不足以回答系统是否存在多个实例、库存是否由数据库作为最终事实来源、是否有异步任务或人工操作同时修改库存、是否存在重复请求、订单创建与库存扣减是否需要统一状态、取消、超时、退款时库存如何释放、缓存中的库存展示和数据库库存是否允许短暂不一致、哪些失败可以重试、哪些必须人工核验。更有效的提问方式是你是 Java 库存并发控制评审助手。 场景 系统支持商品下单库存可能被多个接口请求、 延迟重试任务、超时取消任务和人工后台操作同时修改。 当前库存保存在数据库中 应用部署为多个实例 同一订单可能因网络超时重复提交。 请不要只给 synchronized 或分布式锁代码。 请输出 1. 库存扣减的最终约束应放在哪一层 2. 数据库条件更新应如何设计 3. 如何识别同一订单的重复扣减 4. 可用库存、预占库存、已确认扣减应如何建模 5. 取消订单、支付成功、超时关闭并发发生时如何处理 6. 哪些动作可以重试哪些需要幂等键 7. 至少 8 个并发与补偿测试场景 8. 哪些业务规则必须由产品或运营确认。这类输入的重点不是让 AI 生成更多锁代码而是先拆清楚锁的范围、最终约束位置、库存状态、重复请求、补偿路径和人工确认边界。对刚开始使用 ChatGPT Plus 做代码解释、并发方案分析和测试设计的开发者来说工具接入准备不只是会不会生成一段锁代码还包括是否能够先讲清楚库存由谁决定、异常后如何恢复、重复请求如何识别以及哪些状态必须留痕。第一次把 AI 工具纳入开发工作流时建议把使用说明、异常处理和信息留存方式一起整理相关准备项可按实际需要参考gpt328com八、至少覆盖这些库存并发测试库存逻辑不能只测“库存够时能下单”。至少要覆盖测试场景预期结果库存为 1两个请求同时购买只有一个成功多实例同时扣同一 SKU总成功数量不超过库存同一订单重复提交不重复预占或重复扣减条件更新影响行数为 0返回库存不足或冲突结果订单创建后支付失败库存按规则释放超时取消与支付成功同时发生最终状态只能有一个确定结果释放任务重复执行不会重复加库存数据库短暂异常后重试不会生成重复库存记录人工调整库存与用户下单并发有明确优先级和审计记录缓存展示滞后不影响数据库最终库存约束例如库存为 1 时同时提交两个请求TestvoidshouldAllowOnlyOneOrderWhenStockIsOne()throwsException{LongskuIdcreateSkuWithStock(1);ExecutorServiceexecutorExecutors.newFixedThreadPool(2);FutureBooleanfirstexecutor.submit(()-tryCreateOrder(skuId,order-a));FutureBooleansecondexecutor.submit(()-tryCreateOrder(skuId,order-b));intsuccessCount0;if(first.get())successCount;if(second.get())successCount;assertEquals(1,successCount);assertEquals(0,stockRepository.getAvailable(skuId));}还应验证重复请求不会重复预占TestvoidshouldNotReserveStockTwiceForSameOrder(){LongskuIdcreateSkuWithStock(10);stockService.reserve(order-1001,skuId,2);stockService.reserve(order-1001,skuId,2);assertEquals(8,stockRepository.getAvailable(skuId));assertEquals(1,reservationRepository.countByOrderId(order-1001));}这些测试不是为了证明“线程能跑”而是为了确认在同一份有限资源被争抢时系统不会因为请求重试、实例扩容、任务补偿或状态延迟而把同一份库存多次分配出去。九、上线后要观察什么库存问题最危险的地方是它可能在系统日志里看起来完全正常。因此建议至少记录stock_deduct_success_total stock_deduct_insufficient_total stock_reservation_created_total stock_reservation_duplicate_total stock_release_success_total stock_release_duplicate_total stock_negative_detected_total stock_order_mismatch_total stock_compensation_pending_total重点观察库存不足比例是否突然异常同一订单是否出现多次库存预占库存释放次数是否远高于订单取消次数某个 SKU 是否频繁出现重复扣减库存变化是否能追溯到订单、任务或人工操作是否出现订单成功数量大于库存变化数量是否存在长期未确认、未释放的预占记录人工库存调整是否绕过了审计链路。如果发现“库存不为负但用户投诉超卖”不要只检查字段值更应该对账初始库存 人工补充 - 已确认扣减 - 有效预占 已确认释放 当前可用库存只要这个关系对不上说明系统中至少有一条库存变化没有被正确记录。十、结语synchronized并不是没有价值。在单机工具、低并发任务或临时保护某段内存状态时它可以发挥作用。但对于库存这种跨实例、跨请求、可能被重试和补偿的有限资源它通常不是最终答案。真正可靠的库存控制需要明确最终库存约束放在哪里多实例请求如何竞争同一份资源条件更新是否能保证扣减原子性同一笔订单如何避免重复扣减预占、确认、释放是否有独立状态超时、取消、支付回调并发时谁拥有最终决定权所有库存变化是否能被追溯和对账哪些异常能够自动恢复哪些必须人工确认。AI 可以帮助你生成条件 SQL、预占记录、状态机草稿和测试清单。但真正需要工程设计决定的是哪一层拥有库存的最终裁决权哪一次变化代表真实资源分配以及出现异常后系统如何证明这份库存没有被多卖、少卖或重复归还。可靠的库存扣减不是让每个请求都尽快返回成功而是让有限资源在并发、重试和补偿发生时仍然只被分配一次并且每一次变化都能解释清楚。