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

  1. 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.
  2. 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.
  3. If retries are legitimate, add idempotency/retry-with-backoff at the order orchestration layer rather than hammering the same userId concurrently.
  4. 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

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.