【架构实战】账户系统架构:资金安全是底线

一、账户系统出问题,是真正的灾难

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()) < 0) {
            throw new BusinessException("余额不足");
        }
        
        // 4. 冻结余额
        accountService.freezeAmount(
            account.getAccountNo(), 
            request.getAmount()
        );
        
        // 5. 创建提现订单
        WithdrawOrder order = new WithdrawOrder();
        order.setOrderNo(generateOrderNo());
        order.setUserId(request.getUserId());
        order.setAmount(request.getAmount());
        order.setStatus(WithdrawStatus.PROCESSING);
        withdrawOrderMapper.insert(order);
        
        // 6. 异步处理提现(调用第三方支付)
        withdrawProcessor.process(order);
        
        return WithdrawResult.success(order.getOrderNo());
    }
    
    /**
     * 提现成功回调
     */
    @Transactional(rollbackFor = Exception.class)
    public void withdrawSuccess(String orderNo) {
        WithdrawOrder order = withdrawOrderMapper.selectByOrderNo(orderNo);
        
        // 1. 扣减冻结金额
        accountService.deductFrozenAmount(
            order.getAccountNo(), 
            order.getAmount()
        );
        
        // 2. 记录流水
        accountService.recordTransaction(
            order.getAccountNo(),
            order.getOrderNo(),
            TransactionType.WITHDRAW,
            order.getAmount().negate()
        );
        
        // 3. 更新订单状态
        order.setStatus(WithdrawStatus.SUCCESS);
        withdrawOrderMapper.update(order);
    }
    
    /**
     * 提现失败回调
     */
    @Transactional(rollbackFor = Exception.class)
    public void withdrawFailed(String orderNo) {
        WithdrawOrder order = withdrawOrderMapper.selectByOrderNo(orderNo);
        
        // 1. 解冻余额
        accountService.unfreezeAmount(
            order.getAccountNo(), 
            order.getAmount()
        );
        
        // 2. 更新订单状态
        order.setStatus(WithdrawStatus.FAILED);
        withdrawOrderMapper.update(order);
    }
}

四、资金安全保障

4.1 余额更新原子性

java 复制代码
/**
 * 账户余额更新(乐观锁)
 */
@Mapper
public interface AccountMapper {
    
    /**
     * 更新余额(乐观锁)
     */
    @Update("""
        UPDATE account 
        SET balance = #{newBalance}, 
            version = version + 1,
            update_time = NOW()
        WHERE id = #{id} 
        AND version = #{version}
        AND status = 1
        """)
    int updateBalance(
        @Param("id") Long id,
        @Param("newBalance") BigDecimal newBalance,
        @Param("version") Integer version
    );
    
    /**
     * 冻结金额
     */
    @Update("""
        UPDATE account 
        SET balance = balance - #{amount},
            frozen_amount = frozen_amount + #{amount},
            version = version + 1,
            update_time = NOW()
        WHERE account_no = #{accountNo}
        AND balance >= #{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<Account> 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<LocalMessage> 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日终对账
  □ 余额 = 流水汇总
  □ 异常自动告警

□ 安全审计
  □ 所有操作有日志
  □ 敏感操作有审批
  □ 数据变更有记录

八、总结

账户系统核心要点:

要点 说明
原子性 余额更新和流水记录同一事务
一致性 余额 = 流水汇总,每日对账
幂等性 业务订单号去重
可追溯 所有操作有记录
安全性 敏感操作有审批

血的教训:

资金安全是底线。账户系统出问题,不是丢数据,是丢信任。


个人观点,仅供参考