alibaba/spring-cloud-alibaba · error · BusinessException
reduce balance failed
Error message
reduce balance failed
What it means
Thrown inside AccountServiceImpl.reduceBalance (a @Transactional, Seata branch transaction) when accountMapper.reduceBalance(...) returns 0 affected rows. The underlying UPDATE is `UPDATE account SET money = money - #{price} ... WHERE user_id = #{userId} AND money >= ${price}`, so a 0-row result means no row matched at UPDATE time. Because the preceding checkBalance(userId, price) already confirmed sufficient funds, a 0 count here signals a concurrency/race: another committed transaction deducted the balance in the window between the read-check and the write, OR the userId has no row in the account table. It is an optimistic-update guard encoded in SQL.
Source
Thrown at spring-cloud-alibaba-examples/integrated-example/integrated-account/src/main/java/com/alibaba/cloud/integration/account/service/impl/AccountServiceImpl.java:54
@Service
public class AccountServiceImpl implements AccountService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private AccountMapper accountMapper;
@Override
@Transactional
public void reduceBalance(String userId, Integer price) throws BusinessException {
logger.info("[reduceBalance] currenet XID: {}", RootContext.getXID());
checkBalance(userId, price);
Timestamp updateTime = new Timestamp(System.currentTimeMillis());
int updateCount = accountMapper.reduceBalance(userId, price, updateTime);
if (updateCount == 0) {
throw new BusinessException("reduce balance failed");
}
}
@Override
public Result<?> getRemainAccount(String userId) {
Integer balance = accountMapper.getBalance(userId);
if (balance == null) {
return Result.failed("wrong userId,please check the userId");
}
return Result.success(balance);
}
private void checkBalance(String userId, Integer price) throws BusinessException {
Integer balance = accountMapper.getBalance(userId);
if (balance < price) {
throw new BusinessException("no enough balance");
}
}View on GitHub (pinned to 115d590110)
Solutions
- Treat it as expected under concurrency: the BusinessException rolls back the local @Transactional branch and Seata aborts the global transaction; verify your test does not issue overlapping deductions for the same userId.
- Confirm the account table has a row for the userId (SELECT money FROM account WHERE user_id=?) before retrying, and re-seed money if needed.
- If retries are legitimate, add idempotency/retry-with-backoff at the order orchestration layer rather than hammering the same userId concurrently.
- Note the SQL uses `money >= ${price}` (string interpolation); keep price integer-controlled to avoid SQL injection and ensure the numeric comparison is correct.
Example fix
// before
int updateCount = accountMapper.reduceBalance(userId, price, updateTime);
if (updateCount == 0) {
throw new BusinessException("reduce balance failed");
}
// after: distinguish 'not found' vs 'concurrent lose' for clearer diagnostics
Integer balance = accountMapper.getBalance(userId);
if (balance == null) {
throw new BusinessException("account not found: " + userId);
}
if (balance < price) {
throw new BusinessException("no enough balance (concurrent deduction)");
}
int updateCount = accountMapper.reduceBalance(userId, price, updateTime);
if (updateCount == 0) {
throw new BusinessException("reduce balance failed (optimistic lock lost, please retry)");
} Defensive patterns
Strategy: validation
Validate before calling
// Before calling reduceBalance, re-read the live balance and ensure the deduction is within funds.
Integer balance = accountMapper.getBalance(userId);
if (balance == null) {
throw new BusinessException("account not found: " + userId);
}
if (balance < price) {
throw new BusinessException("no enough balance");
}
// Expect reduceBalance to still possibly return 0 under concurrency; treat that as a retry signal. Try / catch
// In the order orchestrator, catch BusinessException from the account branch and map to a retry or a user-facing 'please retry' result.
try {
accountService.reduceBalance(accountDTO);
} catch (BusinessException e) {
if ("reduce balance failed".equals(e.getMessage())) {
// optimistic-lock loss; retry with backoff or return a retryable result
}
throw e;
} Prevention
- Avoid issuing overlapping concurrent deductions for the same userId in tests.
- Keep price integer-controlled; the SQL uses ${price} string interpolation.
- Seed account money generously and confirm the userId row exists.
When it happens
Trigger: Two concurrent order requests for the same userId reduce the balance past the threshold between checkBalance() and the UPDATE; the second UPDATE matches 0 rows. Also reproduced when reduceBalance is invoked for a userId that has no account row (checkBalance would instead NPE on null, but a race where the row is deleted between read and write yields 0 rows).
Common situations: Load/stress tests of the Seata integrated example firing parallel createOrder calls; concurrent HTTP requests to the order endpoint for one user; a manually seeded account table where money equals exactly the price and a concurrent request wins the deduction.
Related errors
AI-assisted analysis of alibaba/spring-cloud-alibaba@115d590110 (2026-08-14).
Data as JSON: /api/errors/6880a33873345c8c.
Report an issue: GitHub.