{"record":{"id":"6880a33873345c8c","repo":"alibaba/spring-cloud-alibaba","slug":"reduce-balance-failed","errorCode":null,"errorMessage":"reduce balance failed","messagePattern":"reduce balance failed","errorType":"exception","errorClass":"BusinessException","httpStatus":null,"severity":"error","filePath":"spring-cloud-alibaba-examples/integrated-example/integrated-account/src/main/java/com/alibaba/cloud/integration/account/service/impl/AccountServiceImpl.java","lineNumber":54,"sourceCode":"@Service\npublic class AccountServiceImpl implements AccountService {\n\n\tprivate Logger logger = LoggerFactory.getLogger(getClass());\n\n\t@Autowired\n\tprivate AccountMapper accountMapper;\n\n\t@Override\n\t@Transactional\n\tpublic void reduceBalance(String userId, Integer price) throws BusinessException {\n\t\tlogger.info(\"[reduceBalance] currenet XID: {}\", RootContext.getXID());\n\n\t\tcheckBalance(userId, price);\n\n\t\tTimestamp updateTime = new Timestamp(System.currentTimeMillis());\n\t\tint updateCount = accountMapper.reduceBalance(userId, price, updateTime);\n\t\tif (updateCount == 0) {\n\t\t\tthrow new BusinessException(\"reduce balance failed\");\n\t\t}\n\t}\n\n\t@Override\n\tpublic Result<?> getRemainAccount(String userId) {\n\t\tInteger balance = accountMapper.getBalance(userId);\n\t\tif (balance == null) {\n\t\t\treturn Result.failed(\"wrong userId,please check the userId\");\n\t\t}\n\t\treturn Result.success(balance);\n\t}\n\n\tprivate void checkBalance(String userId, Integer price) throws BusinessException {\n\t\tInteger balance = accountMapper.getBalance(userId);\n\t\tif (balance < price) {\n\t\t\tthrow new BusinessException(\"no enough balance\");\n\t\t}\n\t}","sourceCodeStart":36,"sourceCodeEnd":72,"githubUrl":"https://github.com/alibaba/spring-cloud-alibaba/blob/115d5901102009492e05d5ec18c3f79cad4077d0/spring-cloud-alibaba-examples/integrated-example/integrated-account/src/main/java/com/alibaba/cloud/integration/account/service/impl/AccountServiceImpl.java#L36-L72","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before\nint updateCount = accountMapper.reduceBalance(userId, price, updateTime);\nif (updateCount == 0) {\n    throw new BusinessException(\"reduce balance failed\");\n}\n// after: distinguish 'not found' vs 'concurrent lose' for clearer diagnostics\nInteger balance = accountMapper.getBalance(userId);\nif (balance == null) {\n    throw new BusinessException(\"account not found: \" + userId);\n}\nif (balance < price) {\n    throw new BusinessException(\"no enough balance (concurrent deduction)\");\n}\nint updateCount = accountMapper.reduceBalance(userId, price, updateTime);\nif (updateCount == 0) {\n    throw new BusinessException(\"reduce balance failed (optimistic lock lost, please retry)\");\n}","handlingStrategy":"validation","validationCode":"// Before calling reduceBalance, re-read the live balance and ensure the deduction is within funds.\nInteger balance = accountMapper.getBalance(userId);\nif (balance == null) {\n    throw new BusinessException(\"account not found: \" + userId);\n}\nif (balance < price) {\n    throw new BusinessException(\"no enough balance\");\n}\n// Expect reduceBalance to still possibly return 0 under concurrency; treat that as a retry signal.","typeGuard":null,"tryCatchPattern":"// In the order orchestrator, catch BusinessException from the account branch and map to a retry or a user-facing 'please retry' result.\ntry {\n    accountService.reduceBalance(accountDTO);\n} catch (BusinessException e) {\n    if (\"reduce balance failed\".equals(e.getMessage())) {\n        // optimistic-lock loss; retry with backoff or return a retryable result\n    }\n    throw e;\n}","preventionTips":["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."],"tags":["seata","distributed-transaction","concurrency","database","account"],"backgroundTag":null,"analyzedSha":"115d5901102009492e05d5ec18c3f79cad4077d0","analyzedAt":"2026-08-14T04:47:13.900Z","schemaVersion":2},"datasetVersion":"2026-08-14T05:17:29.042Z"}