一、账户系统出问题,是真正的灾难
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日终对账
□ 余额 = 流水汇总
□ 异常自动告警
□ 安全审计
□ 所有操作有日志
□ 敏感操作有审批
□ 数据变更有记录
八、总结
账户系统核心要点:
| 要点 | 说明 |
|---|---|
| 原子性 | 余额更新和流水记录同一事务 |
| 一致性 | 余额 = 流水汇总,每日对账 |
| 幂等性 | 业务订单号去重 |
| 可追溯 | 所有操作有记录 |
| 安全性 | 敏感操作有审批 |
血的教训:
资金安全是底线。账户系统出问题,不是丢数据,是丢信任。
个人观点,仅供参考