{"record":{"id":"7694cef793ef4f95","repo":"yudaocode/SpringBoot-Labs","slug":"error-7694ce","errorCode":null,"errorMessage":"余额不足","messagePattern":"余额不足","errorType":"http","errorClass":"Exception","httpStatus":500,"severity":"error","filePath":"lab-52/lab-52-multiple-datasource/src/main/java/cn/iocoder/springboot/lab52/seatademo/service/impl/AccountServiceImpl.java","lineNumber":37,"sourceCode":"    @Autowired\n    private AccountDao accountDao;\n\n    @Override\n    @DS(value = \"account-ds\")\n    @Transactional(propagation = Propagation.REQUIRES_NEW) // 开启新事物\n    public void reduceBalance(Long userId, Integer price) throws Exception {\n        logger.info(\"[reduceBalance] 当前 XID: {}\", RootContext.getXID());\n\n        // 检查余额\n        checkBalance(userId, price);\n\n        logger.info(\"[reduceBalance] 开始扣减用户 {} 余额\", userId);\n        // 扣除余额\n        int updateCount = accountDao.reduceBalance(price);\n        // 扣除成功\n        if (updateCount == 0) {\n            logger.warn(\"[reduceBalance] 扣除用户 {} 余额失败\", userId);\n            throw new Exception(\"余额不足\");\n        }\n        logger.info(\"[reduceBalance] 扣除用户 {} 余额成功\", userId);\n    }\n\n    private void checkBalance(Long userId, Integer price) throws Exception {\n        logger.info(\"[checkBalance] 检查用户 {} 余额\", userId);\n        Integer balance = accountDao.getBalance(userId);\n        if (balance < price) {\n            logger.warn(\"[checkBalance] 用户 {} 余额不足，当前余额:{}\", userId, balance);\n            throw new Exception(\"余额不足\");\n        }\n    }\n\n}\n","sourceCodeStart":19,"sourceCodeEnd":52,"githubUrl":"https://github.com/yudaocode/SpringBoot-Labs/blob/6c12efaed06d12907a0f40dd2ad1f7020aec8798/lab-52/lab-52-multiple-datasource/src/main/java/cn/iocoder/springboot/lab52/seatademo/service/impl/AccountServiceImpl.java#L19-L52","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: checked Exception + TOCTOU check\npublic void reduceBalance(Long userId, Integer price) throws Exception {\n    checkBalance(userId, price);\n    int updateCount = accountDao.reduceBalance(price);\n    if (updateCount == 0) throw new Exception(\"余额不足\");\n}\n\n// after: atomic conditional update + typed business exception\npublic void reduceBalance(Long userId, Integer price) {\n    int updated = accountDao.reduceBalanceIfEnough(userId, price);\n    // UPDATE account SET balance = balance - #{price} WHERE id = #{userId} AND balance >= #{price}\n    if (updated == 0) throw new InsufficientBalanceException(userId);\n}","handlingStrategy":"validation","validationCode":"// Caller-side: verify sufficient balance atomically in SQL, avoiding the TOCTOU window:\n// UPDATE account SET balance = balance - #{price} WHERE id = #{userId} AND balance >= #{price}\nint ok = accountDao.reduceBalanceIfEnough(userId, price);\nif (ok == 0) { /* business rejection, no exception needed */ }","typeGuard":null,"tryCatchPattern":"// Orchestrator (order service) around the account call:\ntry {\n    accountService.reduceBalance(userId, price);\n} catch (Exception e) {\n    logger.warn(\"balance rollback triggered: {}\", e.getMessage());\n    throw new OrderFailedException(\"INSUFFICIENT_BALANCE\", e);\n}","preventionTips":["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."],"tags":["seata","distributed-transaction","at-mode","business-rule","concurrency"],"backgroundTag":null,"analyzedSha":"6c12efaed06d12907a0f40dd2ad1f7020aec8798","analyzedAt":"2026-08-14T13:06:31.500Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}