linlinjava/litemall · critical · RuntimeException

商品货品库存减少失败

Error message

商品货品库存减少失败

What it means

In WxOrderService.submit, after the stock-availability check passes, the service calls productService.reduceStock(productId, number), which maps to a conditional SQL UPDATE (litemall_goods_product SET number = number - ? ...). The mapper returns the number of affected rows; 0 means the UPDATE matched no row — the product was deleted, the row is logically deleted, or stock changed underneath between the read and the write. The service treats 0 as fatal with '商品货品库存减少失败' (stock reduction failed). It fires after money-facing state is being written, so the surrounding transaction must roll the whole submission back.

Source

Thrown at litemall-wx-api/src/main/java/org/linlinjava/litemall/wx/service/WxOrderService.java:435

        // 删除购物车里面的商品信息
        if(cartId.equals(0)){
            cartService.clearGoods(userId);
        }else{
            cartService.deleteById(cartId);
        }

        // 商品货品数量减少
        for (LitemallCart checkGoods : checkedGoodsList) {
            Integer productId = checkGoods.getProductId();
            LitemallGoodsProduct product = productService.findById(productId);

            int remainNumber = product.getNumber() - checkGoods.getNumber();
            if (remainNumber < 0) {
                throw new RuntimeException("下单的商品货品数量大于库存量");
            }
            if (productService.reduceStock(productId, checkGoods.getNumber()) == 0) {
                throw new RuntimeException("商品货品库存减少失败");
            }
        }

        // 如果使用了优惠券,设置优惠券使用状态
        if (couponId != 0 && couponId != -1) {
            LitemallCouponUser couponUser = couponUserService.findById(userCouponId);
            couponUser.setStatus(CouponUserConstant.STATUS_USED);
            couponUser.setUsedTime(LocalDateTime.now());
            couponUser.setOrderId(orderId);
            couponUserService.update(couponUser);
        }

        //如果是团购项目,添加团购信息
        if (grouponRulesId != null && grouponRulesId > 0) {
            LitemallGroupon groupon = new LitemallGroupon();
            groupon.setOrderId(orderId);
            groupon.setStatus(GrouponConstant.STATUS_NONE);
            groupon.setUserId(userId);

View on GitHub (pinned to a1ef964a71)

Solutions

  1. Map this failure to the same user-facing out-of-stock response as error [2] (fail the submit with a stock-insufficient code and message), since in practice 0 rows almost always means stock vanished concurrently.
  2. Make the flow atomic: drop the pre-read check and rely solely on the conditional reduceStock rowcount inside the transaction, as shown below — this removes the race window entirely.
  3. If it persists deterministically for one SKU, inspect litemall_goods_product for that id: deleted flag, actual number, and confirm the product still exists.

Example fix

// before
if (productService.reduceStock(productId, checkGoods.getNumber()) == 0) {
    throw new RuntimeException("商品货品库存减少失败");
}

// after - treat as out-of-stock, message tells the user what to do
if (productService.reduceStock(productId, checkGoods.getNumber()) == 0) {
    throw new RuntimeException("下单的商品货品数量大于库存量");
}
Defensive patterns

Strategy: validation

Validate before calling

// atomic guard: rely on the conditional UPDATE's row count as the single check
int updated = productService.reduceStock(productId, checkGoods.getNumber()); // UPDATE ... WHERE number >= #{num} AND deleted = false
if (updated == 0) {
    // out of stock (or row gone) — abort this SKU, do not pre-read product.getNumber()
}

Try / catch

// keep the whole submit inside one @Transactional method; a throw on 0 rows rolls back every prior reduceStock in the same loop
try {
    order = wxOrderService.submit(userId, body);
} catch (RuntimeException e) {
    TransactionAspectSupport.currentTransactionStatus().isRollbackOnly(); // assert rollback
    return ResponseUtil.fail(GOODS_OUT_OF_STOCK, "商品库存不足");
}

Prevention

When it happens

Trigger: Two requests race: both pass the remainNumber check for the last unit, one reduceStock UPDATE wins, the other affects 0 rows; or the SKU row was logically deleted (deleted=1) / admin-removed between the cart read and checkout; or the reduceStock WHERE clause (id match, not-deleted, sufficient number) fails for any reason.

Common situations: Concurrent checkout of the same popular SKU (groupon/flash sale); product taken off-shelf while sitting in a user's cart; DB replication lag in split deployments; tests running without a real DB so the UPDATE returns 0.

Related errors


AI-assisted analysis of linlinjava/litemall@a1ef964a71 (2026-08-14). Data as JSON: /api/errors/3bfa87cc4d5ed4c4. Report an issue: GitHub.