
本文介绍一种安全、可控的库存扣减策略:当用户下单数量超过单条库存记录余量时,按订单顺序(如 id_order)遍历多条同商品库存记录,逐条扣减直至满足需求,避免超卖或负库存。
在电商或酒类仓储系统中,同一商品(如 id_wine = 1)可能因来自不同采购/入库订单而分散存储于多条库存记录中(每条对应独立 id_order)。此时若用户一次性下单 5 瓶,而数据库中存在两条记录:qty=4(order 1)和 qty=1(order 2),理想行为应是先扣减第一条 4 瓶,再扣减第二条 1 瓶,而非仅操作第一条导致余量变为 -1——这正是原子性不足与逻辑缺失引发的典型超卖风险。
直接使用 ->decrement() 单次操作无法实现“跨行智能分配”,因此需采用显式事务化分步处理。核心思路是:
- 预校验总可用量(防止无库存下单);
- 按业务优先级排序获取库存记录(如 orderBy(‘id_order’) 或 orderBy(‘created_at’));
- 循环扣减,动态更新待扣数量,遇余量不足则跳转至下一条;
- 全程包裹数据库事务,确保全部成功或全部回滚。
以下是推荐的 laravel 实现(含事务与边界防护):
use IlluminateSupportFacadesDB; $requestedQty = $request->quantita; $wineId = $wine_id; $restaurantId = Auth::user()->id_restaurant; // ✅ 步骤1:预检查总可用库存(防幻读,建议加锁或使用 SELECT FOR UPDATE) $totalAvailable = warehouse::where('id_restaurant', $restaurantId) ->where('id_wine', $wineId) ->where('quantita_restante', '>', 0) ->sum('quantita_restante'); if ($totalAvailable < $requestedQty) { throw new Exception("库存不足:仅剩 {$totalAvailable} 瓶,请求 {$requestedQty} 瓶"); } // ✅ 步骤2:开启事务,按订单顺序获取可扣减记录 DB::transaction(function () use ($restaurantId, $wineId, $requestedQty) { // 加锁确保并发安全(MySQL/PostgreSQL 支持) $stocks = warehouse::where('id_restaurant', $restaurantId) ->where('id_wine', $wineId) ->where('quantita_restante', '>', 0) ->orderBy('id_order') // 或 orderBy('created_at') 依业务规则 ->lockForUpdate() // 关键:防止并发修改 ->get(); $remaining = $requestedQty; foreach ($stocks as $stock) { if ($remaining <= 0) break; $take = min($stock->quantita_restante, $remaining); // 执行原子扣减(等价于 UPDATE ... SET quantita_restante = quantita_restante - ?) $stock->decrement('quantita_restante', $take); $remaining -= $take; } if ($remaining > 0) { // 理论上不会触发(因已预校验 totalAvailable),但作为兜底 throw new Exception("库存状态异常:仍有 {$remaining} 瓶未扣减成功"); } });
⚠️ 关键注意事项:
- 永远不要跳过预校验:仅靠循环扣减无法阻止并发场景下其他请求抢先扣光库存;
- 必须使用 lockForUpdate()(或等效行锁),否则高并发时仍可能产生负库存;
- 若业务要求“优先消耗最早入库批次”,请改用 orderBy(‘created_at’) 替代 id_order;
- 对于超大库存集,可考虑分页处理或引入 redis 预减缓存提升性能;
- 生产环境建议记录扣减日志(warehouse_id, before, after, by_order_id),便于对账与审计。
该方案兼顾数据一致性、业务可解释性与扩展性,是处理分布式库存扣减的稳健实践。