yudaocode/SpringBoot-Labs · error · Exception

余额不足

Error message

余额不足

What it means

In the Seata multi-datasource lab, AccountServiceImpl.reduceBalance throws plain java.lang.Exception('余额不足' — insufficient balance) when the SQL UPDATE that decrements the user's balance affects 0 rows. The generic Exception propagates through the Seata AT participant, marking the global transaction for rollback so the order-side and product-side changes are compensated. Throwing a checked Exception (rather than a runtime one) forces callers to declare throws Exception — a tutorial simplification.

Source

Thrown at lab-52/lab-52-multiple-datasource/src/main/java/cn/iocoder/springboot/lab52/seatademo/service/impl/AccountServiceImpl.java:37

    @Autowired
    private AccountDao accountDao;

    @Override
    @DS(value = "account-ds")
    @Transactional(propagation = Propagation.REQUIRES_NEW) // 开启新事物
    public void reduceBalance(Long userId, Integer price) throws Exception {
        logger.info("[reduceBalance] 当前 XID: {}", RootContext.getXID());

        // 检查余额
        checkBalance(userId, price);

        logger.info("[reduceBalance] 开始扣减用户 {} 余额", userId);
        // 扣除余额
        int updateCount = accountDao.reduceBalance(price);
        // 扣除成功
        if (updateCount == 0) {
            logger.warn("[reduceBalance] 扣除用户 {} 余额失败", userId);
            throw new Exception("余额不足");
        }
        logger.info("[reduceBalance] 扣除用户 {} 余额成功", userId);
    }

    private void checkBalance(Long userId, Integer price) throws Exception {
        logger.info("[checkBalance] 检查用户 {} 余额", userId);
        Integer balance = accountDao.getBalance(userId);
        if (balance < price) {
            logger.warn("[checkBalance] 用户 {} 余额不足,当前余额:{}", userId, balance);
            throw new Exception("余额不足");
        }
    }

}

View on GitHub (pinned to 6c12efaed0)

Solutions

  1. Retry with a smaller price/top up the user's balance — the error itself is correct behavior guarding against overdraft.
  2. Move the sufficiency check into the UPDATE (WHERE balance >= #{price}) so the check and decrement are atomic, then map updateCount==0 to a typed InsufficientBalanceException.
  3. Throw a business (RuntimeException) type instead of checked Exception so the service signature stays clean and Seata's rollback-by-exception logic is explicit.
  4. Verify the datasource is wrapped in DataSourceProxy and undelUndo logs appear, confirming the AT participant actually registers branch transactions.

Example fix

// before: checked Exception + TOCTOU check
public void reduceBalance(Long userId, Integer price) throws Exception {
    checkBalance(userId, price);
    int updateCount = accountDao.reduceBalance(price);
    if (updateCount == 0) throw new Exception("余额不足");
}

// after: atomic conditional update + typed business exception
public void reduceBalance(Long userId, Integer price) {
    int updated = accountDao.reduceBalanceIfEnough(userId, price);
    // UPDATE account SET balance = balance - #{price} WHERE id = #{userId} AND balance >= #{price}
    if (updated == 0) throw new InsufficientBalanceException(userId);
}
Defensive patterns

Strategy: validation

Validate before calling

// Caller-side: verify sufficient balance atomically in SQL, avoiding the TOCTOU window:
// UPDATE account SET balance = balance - #{price} WHERE id = #{userId} AND balance >= #{price}
int ok = accountDao.reduceBalanceIfEnough(userId, price);
if (ok == 0) { /* business rejection, no exception needed */ }

Try / catch

// Orchestrator (order service) around the account call:
try {
    accountService.reduceBalance(userId, price);
} catch (Exception e) {
    logger.warn("balance rollback triggered: {}", e.getMessage());
    throw new OrderFailedException("INSUFFICIENT_BALANCE", e);
}

Prevention

When it happens

Trigger: POST /account/reduce-balance (userId, price) where the balance UPDATE fails: either balance < price (blocked earlier by checkBalance) or a concurrent purchase already drained the balance between the check and the update, making the conditional UPDATE (balance >= price guard) match 0 rows.

Common situations: Classic check-then-act race in money demos: two concurrent purchases both pass checkBalance, the first UPDATE wins, the second gets updateCount==0 and throws. Also hit when the SQL's WHERE clause doesn't include the balance>=price guard, letting the balance go negative. Seata-specific: if the datasource is not proxied by Seata's DataSourceProxy, the throw happens but no global rollback follows.

Related errors


AI-assisted analysis of yudaocode/SpringBoot-Labs@6c12efaed0 (2026-08-14). Data as JSON: /api/errors/7694cef793ef4f95. Report an issue: GitHub.