钱包余额不是“算”出来的:复式记账、余额快照与流水永不丢失的工程实践
主题:钱包账户模型设计
关键词:钱包系统、复式记账、余额快照、账务流水、ACID、幂等、高并发、对账、Outbox、分库分表
适合读者:中高级后端工程师、支付与交易系统开发者、架构师
一、先说结论:用户余额绝不能靠 SUM(流水) 实时算
很多业务系统在早期都会这样设计钱包:
- 有一张流水表,记录每一次加钱、扣钱
- 查询余额时执行一条 SQL:
SELECT SUM(amount) FROM wallet_flow WHERE account_id = ?
在 Demo 阶段,这没有问题;一旦进入生产环境,它几乎一定会出事。
原因非常直接:
- 流水表是持续增长的事实表,数据量会从百万、千万走到亿级;
- 余额查询是高频操作,账户首页、下单页、提现页、风控链路都会读;
- 一旦每次都依赖流水聚合,查询成本会随着历史数据线性增长;
- 再叠加并发写入、重复请求、回调重试、跨服务调用,账就很容易“不平”。
所以,钱包余额不是“查询时算出来的”,而是“记账时写出来的”。
生产级设计必须同时具备三类数据:
账户表:保存当前余额状态,是高频读取入口流水表:保存每笔账务事实,是审计与追溯基石交易单/分录表:保存业务交易与复式分录关系,是账务平衡依据
真正可靠的钱包系统,本质上不是一个“余额读写模块”,而是一套小型账务系统。
二、为什么很多钱包系统会在高并发下出错
先看几个线上最常见的问题。
1. 余额靠汇总流水,数据库被打穿
大促期间,用户频繁刷新钱包页,订单链路又在不断扣款、退款、返现。如果余额依赖流水聚合,单账户历史 10 万笔流水并不罕见。数据库 CPU 会迅速飙升,RT 失控。
2. 只更新余额,不写流水,问题无法追查
有些系统为了省事,只维护一张余额表:
- 扣 100,直接
balance = balance - 100 - 加 50,直接
balance = balance + 50
这类设计短期看很轻,长期一定失控。因为你根本回答不了这些问题:
- 这笔钱为什么少了?
- 谁在什么时间改过余额?
- 用户申诉时如何定位?
- 账不平时如何修复?
没有流水,就没有审计能力;没有审计能力,就没有资损兜底能力。
3. 流水和余额分开写,双写不一致
更常见的是下面这种“伪完整设计”:
- 插入流水
- 更新余额
或者反过来:
- 更新余额
- 插入流水
如果这两步不在同一个本地事务内,任何一步失败都会留下脏账:
- 流水有了,余额没变
- 余额变了,流水没落
在账务系统里,这不叫“小概率异常”,这叫事故前兆。
4. 没做幂等,重复扣款或重复入账
支付回调、MQ 重试、接口超时重放都可能导致同一笔业务被处理多次。如果没有业务单号幂等保护,用户可能被连续扣两次钱。
5. 并发扣减没有锁,出现超扣
两个请求同时检查余额都通过,然后同时扣减。结果:
- 账户原本 100 元
- 两笔各扣 80 元
- 最终余额变成 -60 元
这类问题不是数据库的错,而是账务模型没有定义清楚并发控制边界。
三、生产级钱包系统的核心原则
如果把钱包系统压缩成几个最关键的设计原则,我会给出下面六条。
1. 流水是事实,余额是快照
- 流水记录“发生了什么”
- 余额记录“现在是多少”
流水是 append-only,不允许更新删除;余额是可变状态,但必须能被流水校验与重建。
2. 必须采用复式记账,不做单边记账
单边记账只能说明“某个账户变了”;复式记账才能说明“钱从哪里来,到哪里去”。
每一笔交易至少对应两条分录:
- 一个账户记借方
- 一个账户记贷方
借贷金额必须相等,系统才是平衡的。
3. 余额表和流水表必须在同一个本地事务内提交
对于单钱包服务、单账务库场景,最佳实践不是“分布式事务”,而是:
- 交易单落库
- 分录流水落库
- 余额快照更新
- Outbox 事件落库
这四步在一个数据库事务里完成。
4. 先保证账务正确,再优化吞吐
账务系统第一优先级永远不是 TPS,而是:
- 不重
- 不漏
- 可追
- 可修
吞吐量要在正确性的前提下做工程化扩展。
5. 任何外部重试都必须以幂等为前提
无论 HTTP 请求、支付回调还是 MQ 消费,都必须围绕 biz_no、request_id、txn_no 建立全链路幂等。
6. 钱包系统必须内建对账与补偿能力
任何“我们事务已经很稳,不需要对账”的说法,都不适用于真实生产环境。外部渠道、异步消息、人工操作、运维脚本都会带来差异。对账不是附加模块,而是账务系统的最后保险丝。
四、先把概念讲透:账户、分录、余额、冻结,到底是什么关系
很多文章把“账户”和“余额”混在一起讲,导致后续设计越来越乱。这里先做一个统一模型。
4.1 账户不是一列余额,而是一组账务状态
一个完整的钱包账户至少包含:
available_balance:可用余额,可消费、可提现frozen_balance:冻结余额,用于提现处理中、担保交易、风控冻结total_balance:逻辑总额,通常等于可用 + 冻结status:账户状态,如正常、冻结、销户
这意味着很多业务动作不是简单加减,而是余额状态迁移。
例如提现:
- 提现申请时:可用余额减少,冻结余额增加
- 提现成功时:冻结余额减少
- 提现失败时:冻结余额减少,可用余额回补
4.2 交易单是业务视角,分录是账务视角
对于“用户提现吗 100 元”这件事:
- 业务系统关心:提现申请单、审核状态、渠道流水号
- 账务系统关心:这 100 元在账户体系里怎么移动
因此至少要分两层建模:
wallet_txn:交易主单,描述业务动作wallet_entry:账务分录,描述借贷变化
一笔交易可以对应多条分录。比如提现成功可能涉及:
- 用户可用账户
- 用户冻结账户
- 平台待出款账户
- 渠道清算账户
4.3 复式记账不是“写两条流水”,而是“保证借贷平衡”
用转账举例:
用户 A 向用户 B 转 100 元,最少需要两条分录:
| entry_no | account_id | direction | amount | 含义 |
|---|---|---|---|---|
| E1 | A | DR | 100 | A 资金流出 |
| E2 | B | CR | 100 | B 资金流入 |
系统校验规则不是“我插了两条记录”,而是:
- 同一
txn_no下所有DR金额之和 - 必须等于所有
CR金额之和
只有这样,账才真正平。
4.4 为什么余额表还要存,而不是每次靠分录重算
因为账务查询有两种完全不同的诉求:
- 审计追溯:可以慢,但必须完整
- 在线查询:必须快,且延迟稳定
分录适合前者,余额快照适合后者。余额表的价值不是替代流水,而是把“计算结果前置化”。
五、一个可落地的钱包系统分层架构
先给出整体结构,再拆解每个模块职责。


396

被折叠的 条评论
为什么被折叠?



