【架构实战】账户系统架构:资金安全是底线
## 一、账户系统出问题,是真正的灾难 2020年,我们支付系统出现一个严重Bug: - 用户A充值100元 - 系统记录:账户余额+100,但流水记录失败 - 用户A查询余额显示100元 - 但后台对账发现异常:账户余额 ≠ 流水汇总 **更严重的问题:** - 用户B提现100元 - 系统扣减余额成功,但提现记录失败 - 用户B余额显示正确,但钱没到账 **影响:** - 用户投诉 - 资金损失 - 监管风险 - 团队追责 从那以后,我们把账户系统作为最核心的系统来设计,**资金安全是底线**。 --- ## 二、账户系统核心模型 ### 2.1 账户模型 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 账户系统核心模型 │ │ │ │ 用户账户(Account) │ │ ├── 账户ID │ │ ├── 用户ID │ │ ├── 账户类型(余额账户、积分账户、押金账户) │ │ ├── 余额 │ │ ├── 冻结金额 │ │ └── 版本号(乐观锁) │ │ │ │ 账户流水(Transaction) │ │ ├── 流水ID │ │ ├── 账户ID │ │ ├── 业务订单号 │ │ ├── 交易类型(充值、提现、消费、退款) │ │ ├── 交易金额 │ │ ├── 交易前余额 │ │ ├── 交易后余额 │ │ └── 交易时间 │ │ │ └──────────────────────────────────────────────────────────────────┘ ``` ### 2.2 账户表设计 ```sql -- 账户主表 CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL COMMENT '账户号', user_id BIGINT NOT NULL COMMENT '用户ID', account_type TINYINT NOT NULL COMMENT '账户类型:1余额 2积分 3押金', balance DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '余额', frozen_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '冻结金额', version INT NOT NULL DEFAULT 0 COMMENT '版本号', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2冻结 3销户', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_account_no (account_no), UNIQUE KEY uk_user_type (user_id, account_type), INDEX idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户主表'; -- 账户流水表 CREATE TABLE account_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, transaction_no VARCHAR(64) NOT NULL COMMENT '流水号', account_no VARCHAR(32) NOT NULL COMMENT '账户号', biz_order_no VARCHAR(64) COMMENT '业务订单号', transaction_type TINYINT NOT NULL COMMENT '交易类型', amount DECIMAL(12,2) NOT NULL COMMENT '交易金额(正数入账,负数出账)', balance_before DECIMAL(12,2) NOT NULL COMMENT '交易前余额', balance_after DECIMAL(12,2) NOT NULL COMMENT '交易后余额', remark VARCHAR(255) COMMENT '备注', create_time DATETIME NOT NULL, UNIQUE KEY uk_transaction_no (transaction_no), INDEX idx_account_no (account_no), INDEX idx_biz_order_no (biz_order_no), INDEX idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户流水表'; ``` --- ## 三、账户核心操作 ### 3.1 充值流程 ```java /** * 充值服务 */ @Service @Slf4j public class RechargeService { @Autowired private AccountMapper accountMapper; @Autowired private TransactionMapper transactionMapper; @Transactional(rollbackFor = Exception.class) public RechargeResult recharge(RechargeRequest request) { // 1. 参数校验 validateRequest(request); // 2. 幂等性检查 if (transactionMapper.existsByBizOrderNo(request.getBizOrderNo())) { return RechargeResult.success("重复充值,已处理"); } // 3. 查询账户 Account account = accountMapper.selectByUserIdAndType( request.getUserId(), AccountType.BALANCE ); if (account == null) { throw new BusinessException("账户不存在"); } // 4. 乐观锁更新余额 BigDecimal balanceBefore = account.getBalance(); BigDecimal balanceAfter = balanceBefore.add(request.getAmount()); int rows = accountMapper.updateBalance( account.getId(), balanceAfter, account.getVersion() ); if (rows == 0) { throw new BusinessException("余额更新失败,请重试"); } // 5. 记录流水 Transaction transaction = new Transaction(); transaction.setTransactionNo(generateTransactionNo()); transaction.setAccountNo(account.getAccountNo()); transaction.setBizOrderNo(request.getBizOrderNo()); transaction.setTransactionType(TransactionType.RECHARGE); transaction.setAmount(request.getAmount()); transaction.setBalanceBefore(balanceBefore); transaction.setBalanceAfter(balanceAfter); transaction.setRemark("用户充值"); transactionMapper.insert(transaction); // 6. 发送充值成功事件 eventPublisher.publish(new RechargeSuccessEvent( request.getUserId(), request.getAmount() )); return RechargeResult.success(transaction.getTransactionNo()); } } ``` ### 3.2 提现流程 ```java /** * 提现服务 */ @Service @Slf4j public class WithdrawService { @Autowired private AccountService accountService; @Autowired private TransactionMapper transactionMapper; @Autowired private WithdrawOrderMapper withdrawOrderMapper; @Transactional(rollbackFor = Exception.class) public WithdrawResult withdraw(WithdrawRequest request) { // 1. 参数校验 validateRequest(request); // 2. 查询账户 Account account = accountService.getAccount( request.getUserId(), AccountType.BALANCE ); // 3. 检查余额 if (account.getBalance().compareTo(request.getAmount()) = #{amount} AND status = 1 """) int freezeAmount( @Param("accountNo") String accountNo, @Param("amount") BigDecimal amount ); } ``` ### 4.2 流水记录完整性 ```java /** * 账户流水服务 */ @Service @Slf4j public class TransactionService { /** * 记录交易流水(本地事务内) */ public void recordTransaction( String accountNo, String bizOrderNo, Integer transactionType, BigDecimal amount ) { // 1. 查询账户当前余额 Account account = accountMapper.selectByAccountNo(accountNo); // 2. 计算交易前后余额 BigDecimal balanceBefore = account.getBalance(); BigDecimal balanceAfter = balanceBefore.add(amount); // 3. 记录流水 Transaction transaction = new Transaction(); transaction.setTransactionNo(generateTransactionNo()); transaction.setAccountNo(accountNo); transaction.setBizOrderNo(bizOrderNo); transaction.setTransactionType(transactionType); transaction.setAmount(amount); transaction.setBalanceBefore(balanceBefore); transaction.setBalanceAfter(balanceAfter); transactionMapper.insert(transaction); log.info("记录流水: transactionNo={}, accountNo={}, amount={}", transaction.getTransactionNo(), accountNo, amount); } /** * 生成流水号(全局唯一) */ private String generateTransactionNo() { // 格式:日期(8位) + 机器ID(4位) + 序列号(12位) String date = DateUtil.format(new Date(), "yyyyMMdd"); String machineId = String.format("%04d", machineIdProvider.getMachineId()); String sequence = String.format("%012d", sequenceGenerator.next()); return date + machineId + sequence; } } ``` ### 4.3 账务一致性校验 ```java /** * 账务一致性校验服务 */ @Service @Slf4j public class AccountReconcileService { @Autowired private AccountMapper accountMapper; @Autowired private TransactionMapper transactionMapper; /** * 账务一致性校验 */ @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 public void reconcile() { log.info("开始账务一致性校验..."); // 1. 查询所有账户 List accounts = accountMapper.selectAll(); for (Account account : accounts) { // 2. 计算流水汇总 BigDecimal sumAmount = transactionMapper.sumAmountByAccountNo( account.getAccountNo() ); // 3. 对比账户余额和流水汇总 if (account.getBalance().compareTo(sumAmount) != 0) { log.error("账务不一致: accountNo={}, balance={}, sumAmount={}", account.getAccountNo(), account.getBalance(), sumAmount); // 4. 记录异常,人工处理 reconcileErrorMapper.insert(new ReconcileError( account.getAccountNo(), account.getBalance(), sumAmount, account.getBalance().subtract(sumAmount) )); // 5. 发送告警 alertService.sendAlert("账务异常", String.format("账户%s余额不一致,差额%.2f", account.getAccountNo(), account.getBalance().subtract(sumAmount))); } } log.info("账务一致性校验完成"); } /** * 账户余额修复(需要审批) */ @Transactional(rollbackFor = Exception.class) public void fixAccountBalance(String accountNo, BigDecimal correctBalance) { Account account = accountMapper.selectByAccountNo(accountNo); // 记录调整流水 BigDecimal adjustAmount = correctBalance.subtract(account.getBalance()); Transaction transaction = new Transaction(); transaction.setTransactionNo(generateTransactionNo()); transaction.setAccountNo(accountNo); transaction.setTransactionType(TransactionType.ADJUST); transaction.setAmount(adjustAmount); transaction.setBalanceBefore(account.getBalance()); transaction.setBalanceAfter(correctBalance); transaction.setRemark("账务修复"); transactionMapper.insert(transaction); // 更新账户余额 accountMapper.updateBalance(account.getId(), correctBalance, account.getVersion()); } } ``` --- ## 五、分布式事务方案 ### 5.1 TCC事务方案 ```java /** * 账户TCC事务 */ @Service public class AccountTccService { /** * Try:冻结余额 */ public boolean tryFreeze(String accountNo, BigDecimal amount) { return accountMapper.freezeAmount(accountNo, amount) > 0; } /** * Confirm:扣减冻结余额 */ public boolean confirmDeduct(String accountNo, BigDecimal amount) { return accountMapper.deductFrozenAmount(accountNo, amount) > 0; } /** * Cancel:解冻余额 */ public boolean cancelFreeze(String accountNo, BigDecimal amount) { return accountMapper.unfreezeAmount(accountNo, amount) > 0; } } /** * 支付TCC事务协调器 */ @Service public class PaymentTccCoordinator { @Autowired private AccountTccService accountTccService; @Autowired private OrderTccService orderTccService; @GlobalTransactional public void pay(Long userId, Long orderId, BigDecimal amount) { // Try阶段 boolean accountTry = accountTccService.tryFreeze(userId, amount); boolean orderTry = orderTccService.tryPay(orderId, amount); if (!accountTry || !orderTry) { throw new BusinessException("Try阶段失败"); } // Confirm阶段(Seata自动提交) // 如果失败,自动执行Cancel } } ``` ### 5.2 本地消息表方案 ```java /** * 本地消息表方案 */ @Service @Slf4j public class LocalMessageService { @Autowired private AccountMapper accountMapper; @Autowired private MessageMapper messageMapper; /** * 充值(本地事务) */ @Transactional(rollbackFor = Exception.class) public void recharge(RechargeRequest request) { // 1. 更新账户余额 accountMapper.updateBalance(...); // 2. 记录流水 transactionMapper.insert(...); // 3. 写入本地消息表(同一事务) LocalMessage message = new LocalMessage(); message.setMessageId(UUID.randomUUID().toString()); message.setTopic("recharge-success"); message.setMessageBody(JSON.toJSONString(request)); message.setStatus(MessageStatus.PENDING); messageMapper.insert(message); } /** * 消息发送任务 */ @Scheduled(fixedDelay = 5000) public void sendMessage() { List messages = messageMapper.selectPending(); for (LocalMessage message : messages) { try { // 发送MQ消息 rocketMQTemplate.send(message.getTopic(), message.getMessageBody()); // 更新状态为已发送 messageMapper.updateStatus(message.getMessageId(), MessageStatus.SENT); } catch (Exception e) { log.error("消息发送失败: {}", message.getMessageId(), e); } } } } ``` --- ## 六、踩坑实录 ### 坑1:余额更新丢失 > **问题**:并发更新余额时,后提交的请求覆盖前一个请求。 **踩坑场景**: - 用户余额100元 - 同时发起两个请求:充值50元、消费30元 - 理想结果:余额120元 - 实际结果:余额可能为80元或150元 **解决方案**:使用乐观锁 ```java @Update(""" UPDATE account SET balance = balance + #{delta}, version = version + 1 WHERE id = #{id} AND version = #{version} """) int updateBalanceWithVersion( @Param("id") Long id, @Param("delta") BigDecimal delta, @Param("version") Integer version ); ``` ### 坑2:流水记录缺失 > **问题**:余额更新成功,但流水记录失败。 **踩坑场景**: 数据库连接池满,流水插入失败,但余额已更新。 **解决方案**:本地事务 + 本地消息表 ```java @Transactional(rollbackFor = Exception.class) public void recharge(RechargeRequest request) { // 同一事务内,要么都成功,要么都失败 accountMapper.updateBalance(...); transactionMapper.insert(...); } ``` ### 坑3:幂等性缺失 > **问题**:同一请求重复处理,导致余额重复增加。 **踩坑场景**: 用户充值请求超时重试,系统处理了两次。 **解决方案**:业务订单号去重 ```java public void recharge(RechargeRequest request) { // 检查是否已处理 if (transactionMapper.existsByBizOrderNo(request.getBizOrderNo())) { return; // 已处理,直接返回 } // 处理充值 ... } ``` ### 坑4:账户并发热点 > **问题**:热门账户(如平台账户)并发量大,成为热点。 **踩坑场景**: 所有用户消费都要扣减平台账户余额,该账户成为热点。 **解决方案**:热点账户分片 ```java // 平台账户分片:account_0, account_1, ..., account_9 String accountNo = "platform_" + (userId % 10); // 查询时汇总 BigDecimal totalBalance = accountMapper.sumBalance("platform_*"); ``` ### 坑5:对账数据量大 > **问题**:历史流水数据量大,对账性能差。 **解决方案**:T+N对账 + 分区表 ```sql -- 流水表按月分区 CREATE TABLE account_transaction_202401 ( ... ) ENGINE=InnoDB; -- 只对最近7天数据对账 SELECT * FROM account_transaction WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); ``` --- ## 七、最佳实践 ### 7.1 账户系统安全检查清单 ``` 账户系统安全检查清单: □ 余额更新 □ 使用乐观锁或悲观锁 □ 本地事务保证原子性 □ 流水记录在同一事务 □ 流水记录 □ 每笔交易都有流水 □ 流水记录余额变化前后值 □ 流水号全局唯一 □ 幂等性 □ 业务订单号去重 □ 流水号唯一索引 □ 对账机制 □ T+1日终对账 □ 余额 = 流水汇总 □ 异常自动告警 □ 安全审计 □ 所有操作有日志 □ 敏感操作有审批 □ 数据变更有记录 ``` --- ## 八、总结 账户系统核心要点: | 要点 | 说明 | |------|------| | 原子性 | 余额更新和流水记录同一事务 | | 一致性 | 余额 = 流水汇总,每日对账 | | 幂等性 | 业务订单号去重 | | 可追溯 | 所有操作有记录 | | 安全性 | 敏感操作有审批 | **血的教训:** > 资金安全是底线。账户系统出问题,不是丢数据,是丢信任。 --- *个人观点,仅供参考*