钱包、积分、预付卡、商户结算账户,所有涉及余额的系统底层都是账户系统。它的核心要求只有一条,每一分钱的进出都有据可查,余额永远等于所有流水的代数和。这是金融级的一致性要求,差一分钱都不行。
很多人把账户系统理解成在用户表里存一个 balance 字段,加减一下就是了。这种理解在玩具系统里成立,到了资金系统里会出大事。余额不是凭空维护的数字,它是所有进出流水的求和结果。如果余额脱离流水独立维护,一旦对不上就无从追溯,因为没有任何可验证的依据。账户系统的严谨性建立在流水、余额、对账这三者的铁三角关系上,缺一不可。
账户、流水、余额的三要素
账户系统有三个核心概念。账户是容器,代表某个用户或商户的余额归属。流水是明细,每一笔进出的不可篡改记录。余额是流水的求和,账户当前还剩多少钱。
这三者的关系是账户系统的根基。余额必须能从流水重算出来,这是可信性的来源。任何时刻,把一个账户的所有流水加起来,结果必须等于这个账户的余额。如果对不上,说明系统出 bug 了,要么流水丢了、要么余额算错了。这种自验证能力是资金系统和普通业务系统的根本区别。
图:账户系统铁三角——余额是所有流水的代数和,任何时候都能从流水重算验证,这是资金系统可信性的根基
复式记账,资金守恒的数学保证
记账方式有两种,单式和复式。单式记账只记一方账户,用户 A 扣了 50 元,就记一条用户 A 账户减 50。简单,但无法验证资金去向。用户 A 少了 50,这 50 元去哪了,单式记账回答不了。
复式记账每笔业务同时记两方,有借必有贷,借贷必相等。用户 A 扣 50 元,必然对应某个账户增加 50 元,比如平台收入账户、手续费账户。两边的金额永远相等,这就是资金守恒的数学保证。任何对账不平,都能通过借贷平衡原理追溯到具体哪笔账记错了,不是借方漏记就是贷方多记。
资金系统必须用复式记账。这不是风格选择,是正确性的硬要求。单式记账做不到自验证,资金一旦对不上就无从查起,资金系统绝不能用。我接触过的所有资金相关系统,从支付公司的清结算到银行的内部账务,无一例外全是复式记账。
流水的不可篡改性
流水是资金系统的账本,一旦写入,不可修改不可删除。这条原则很多人不理解,觉得记错了还不能改吗,是不是太死板。原因在于审计。资金流水是监管和财务审计的依据,任何一次修改都会破坏审计轨迹,一旦被篡改过,整套账本的可信度就崩塌了。
那记错了怎么办。靠红冲,也就是反向冲销。原始流水不删,新写一条等额反向的流水把它冲掉,再写一条正确的流水。这样三条流水,错误的原始、红冲的冲销、正确的更正,全部保留在账上,审计时能完整还原这次更正的全过程。
不可篡改加红冲,是资金账本既严谨又能修正错误的设计。它牺牲了一点直接性,换来的是完整的审计轨迹。
并发扣减,账户系统的性能难点
并发扣减是账户系统真正的工程难点。一个账户余额 100 元,两个请求同时来各扣 60 元,怎么保证不超扣。
朴素做法是先查余额再扣,查到余额 100 够扣,然后更新。这种做法在并发下必崩。两个请求同时查到余额 100,都判断够扣,都去更新成 40,结果是扣了两次 60 却只扣了 40,平台倒贴 20 元。这种超扣在真实资金系统里就是资损。
正确做法是带条件的原子扣减,把判断和扣减放进一条 SQL。
-- 带条件原子扣减,余额充足才扣,否则 affected=0UPDATE accountSET balance = balance - #{amount}, version = version + 1WHERE account_id = #{id} AND balance >= #{amount}这条语句把余额够不够的判断和扣减动作合并成数据库层面的一个原子操作。两个并发请求同时执行时,数据库的行锁保证它们串行化。第一个请求扣成功,余额变成 40,第二个请求发现 40 不够 60,affected=0,应用层就知道余额不足扣减失败。这样无论多少并发,都不会超扣。
version 字段是乐观锁的补充,配合 balance >= amount 双保险。实际生产里,单账户高频并发是少数热点账户的问题,绝大多数账户用条件更新就足够了。
图:并发扣减对比——朴素先查再扣会超扣致资损;带 balance≥amount 条件的原子 UPDATE 借行锁串行化,天然并发安全
热点账户的性能瓶颈
某些账户会被高频访问,比如平台总账、热门主播的打赏账户,单行更新成为瓶颈。所有扣减都抢同一行的锁,TPS 上不去。
应对热点账户有几种思路。子账户拆分,把一个账户拆成 N 个子账户,并发扣减分散到不同子账户上,查询总余额时汇总。异步入账,流水实时写保证不丢,余额异步汇总更新,牺牲一点实时性换吞吐。缓冲合并,短时间内的多笔小额扣减合并成一笔,减少更新次数。
这几种方案各有取舍。子账户拆分增加了余额查询的复杂度,要汇总多个子账户。异步入账带来一致性风险,下面专门讲。缓冲合并要小心,合并期间单笔扣减的可见性会延迟。热点账户优化是进阶能力,前提是先把正确性做扎实。
流水写了但余额没及时更新,查询余额可能不准。异步入账必须有兜底,要么查询时用上次快照余额加上未汇总流水实时算出,要么接受短暂不一致但严格保证最终一致加对账兜底。没有兜底的异步入账,迟早会让用户查到错误余额。
流水表的设计
流水表是账户系统的核心存储,设计要严谨。金额必须用整数,单位是分,绝不用浮点数。浮点数有精度误差,资金系统里 0.1 加 0.2 不等于 0.3 这种事是灾难。整数分完美规避了这个问题。
流水号要全局唯一,作为幂等键防重。同一个业务请求重试多次,只能产生一条流水,靠流水号去重。业务单号字段关联原始业务,比如订单号、退款单号,支持从流水追溯到业务。对方账户字段记录这笔流水的对手方,复式记账需要。时间戳记录精确到毫秒的发生时间。备注字段记录业务说明。
查询层面,流水表按账户分区或者建好账户加时间的联合索引,单账户流水查询高效。流水量大时还要考虑分表,按时间或按账户分。
余额的存储策略
余额怎么存有两种思路。实时计算,每次查余额都把该账户所有流水 SUM 一遍,准确但慢,流水量一大就不可行。快照加增量,定期存余额快照,查询时用最近快照加上快照之后的流水求和,快又准。
生产实践用快照加增量。比如每小时把当前余额存为快照,查询余额时取最近一个快照,加上快照之后的流水,得到当前余额。这样查询复杂度只取决于快照之后的流水数,而不是全部历史流水。同时快照提供了验证手段,重算应等于快照值,校验不过就是 bug。
对账,账户系统的每日体检
每日对账是账户系统的标配。遍历所有账户,用流水重算余额,和记录的余额比对。任何不一致都是 bug 信号,必须追根因。对账不平是资金系统最严重的告警,比服务报错还严重。
图:每日对账流程——用流水重算余额与记录值比对,不平即最高级别告警,必须定位异常流水并红冲更正,绝不直接改数字
对账的频率取决于资金量。小额高频的账户系统可以每日对账,大额低频的可以更频繁。对账发现差异后,不能简单改个数字抹平,必须定位到具体的异常流水,搞清楚是流水丢了、记错了、还是余额算错了,修正后留痕。
跨境账户会涉及多币种,每个币种独立账户,币种之间转换走专门的换汇流水,汇率波动带来汇兑损益,需要独立的汇兑账户记录。这部分跨境清结算那篇会详细讲。
正确性永远优先于性能
账户系统的严谨性体现在几条不能破的规矩上。复式记账保证资金守恒,流水不可篡改保证审计可信,带条件原子扣减保证并发安全,快照加增量保证查询性能,每日对账保证持续可信。
这几条里,正确性的几条是底线,绝对不能为性能牺牲。热点账户的优化,拆分、异步、缓冲合并,是进阶手段,但只能在保证正确性的前提下做。我见过为了提升热点账户吞吐直接把余额更新改成异步、却不做任何兜底的系统,上线一周用户余额就开始乱跳,差点酿成提现事故。
开发账户系统时,顺序是先设计流水表和记账规则,把复式记账和原子扣减做扎实,再考虑性能优化。金额用整数、流水有业务关联、热点账户专项优化,这三条工程实践让账户系统既严谨又高效。但记住,正确性永远优先。一个慢但准的账户系统是可用的,一个快但会算错的账户系统是灾难。







