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
- Retry with a smaller price/top up the user's balance — the error itself is correct behavior guarding against overdraft.
- 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.
- Throw a business (RuntimeException) type instead of checked Exception so the service signature stays clean and Seata's rollback-by-exception logic is explicit.
- 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
- Use atomic conditional UPDATEs for money mutations instead of check-then-update.
- Throw typed runtime business exceptions instead of checked Exception.
- Confirm Seata DataSourceProxy wraps the datasource so failures roll back globally.
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.