上一篇讲幂等:一笔钱只能入一次账。这一篇讲它的孪生问题:入的账,和真实发生的钱,一致吗?这个答案不能由系统自己给。我们做多租户小程序 SaaS(Java 21 / Spring Boot / PostgreSQL,27 个业务域)的第二年,财务月底问了一句"渠道结算和咱家账本差了 0.04 元"——为了这四分钱,三个人查了两天流水。从那以后,对账从临时脚本升格为一等公民的系统。
一、为什么"看流水"不算对账
很多系统管自己对账叫"看流水":把当天入账流水拉出来加总,和订单总额比一下,平了就收工。这不叫对账,叫自查自证——用一个自己写的数字去验证另一个自己写的数字。回调处理的 bug、幂等键选错、状态机漏迁移,会同样体现在流水和订单里:两边一起错,照样"平"。
资金系统的信任只能来自交叉验证:拿一个不由你控制的数字——渠道账单——当外部证人。证人和被告不能是同一个人。所以对账的最低配置是两方(账单 ↔ 账本),完整配置是三方:渠道账单、平台订单、内部账本,两两互对。
二、三个对象,三种口径
| 对象 | 它说什么 | 口径特点 |
|---|---|---|
| 第三方账单 | 钱实际发生了什么 | 渠道视角,T+1 落地,含手续费、退款负数行;权威但滞后 |
| 平台订单 | 业务上发生了什么 | 实时,有状态机;但订单成功不等于钱到账 |
| 内部账本 | 我们记了什么 | 资金口径,分科目记;它不是事实,是"记录" |
以谁为基准?钱的真相以账单为准——渠道说没扣就是没扣;业务的事实以订单为准——它驱动补单和客诉;账本是被告:对账的目的不是证明账本"对",而是把它所有"不对"暴露出来。三向对账展开就是三组两两核对:账单↔订单抓掉单与可疑单,订单↔账本抓记账 bug,账单↔账本抓资金口径错误(手续费漏记是重灾区)。
三、分层:日切、汇总、明细、差异
第一层:日切与批次。账单按渠道的自然日切——注意是渠道的日切,不是你的,见踩坑一。每天 T+1 拉取账单文件,落一张批次表(日期、渠道、状态、总额)。批次表是账单的"定稿":拉取成功置 DONE,当天重跑不重复入账。对账任务以批次为单位,天然幂等。
第二层:汇总对,快速失败。先对三个总额:账单收入合计、订单成功合计、账本收入科目合计。三个数都平,这一天 99% 没问题;不平,立刻知道出事,且差额本身就是线索——差值恰好等于一笔退款?半笔手续费?总额核对是 O(1) 的哨兵,不要一上来就逐笔 diff 几十万行明细。
第三层:明细对,逐笔匹配。以渠道交易号为键,三边各建索引做三路归并。要点有二:键用渠道交易号而非自家单号——用户换卡重付,一单对应两笔渠道流水,用自家单号会误判成单边账;容差要显式声明——手续费科目允许分位差,让四舍五入的差落在预算内,而不是碰运气。
第四层:差异处理。差异不是异常,是常态——光是时间差,每天就会产生几十条。所有 diff 一律入差异表(issue 表),带三要素:类型、两侧单号、金额,分类处置:
- 时间差:账单 T+1 落地、订单 T 日产生,跨日必现。挂账,次日批次自动复核,连续 3 天未消才升级告警;
- 金额差:同键不同额。绝不自动处理,直接告警人工介入——金额差几乎必然是口径错误或资损前兆;
- 单边账:账单有、订单无 → 疑似掉单,触发补单(走回调同一入口、幂等键共用);订单有、账单无 → 挂一天等账单,再没有就告警。
四、脱敏伪代码
// 伪代码(Java 风格,已脱敏),单批次三向对账核心循环
public ReconResult reconcile(BillBatch batch) {
Map<String, BillRow> bills = indexByTxnId(batch.billRows()); // 渠道账单
Map<String, PayOrder> orders = indexByTxnId(batch.orders()); // 平台订单
Map<String, LedgerRow> ledger = indexByTxnId(batch.ledgerRows()); // 内部账本
checkTotals(bills, orders, ledger); // 汇总先行:不平则快速失败并记下差额
for (String txnId : union(bills, orders, ledger)) { // 三路归并
Diff d = Diff.of(bills.get(txnId), orders.get(txnId), ledger.get(txnId));
if (d.isBalanced()) continue; // 三边齐全且金额一致(含容差)
issueDao.insert(classify(d)); // 差异分类入库,永不直接修数
}
batch.markDone();
return ReconResult.of(batch);
}
// 差异分类:类型决定处置流程,而不是当场"修"
Issue classify(Diff d) {
if (d.amountMismatch()) return Issue.amount(d); // 金额差:告警+人工
if (d.billOnly()) return Issue.missing(d); // 账单单边:触发补单
if (d.orderOrLedgerOnly() && d.isToday()) return Issue.timing(d); // 时间差:挂账次日复核
return Issue.missing(d); // 跨日仍在:单边告警
}
两条纪律:对账任务只产差异,不修数据——修数永远是补单、退款这类业务动作,各带自身的幂等保护;差异分类的依据是单号在场情况加日期,不夹带金额猜测。这套循环在全库 1241 个测试方法里有一组"埋错用例"盯着:往三边各塞一条错数,断言差异表能各归各类。
五、踩坑清单(现象 → 定位)
- 账单时区。现象:每天固定多出或少掉几笔 23:50 前后的交易,差的都是零头;定位:账单文件按 UTC 切日、本地按 UTC+8,两套日切差出 8 小时的"跨日单";修法:以账单里的账期字段为准,别信文件时间戳。
- 退款跨日。现象:支付单都对上了,退款单天天挂"账单单边",次日复核又不翼而飞;定位:T 日的支付和 T+1 日的退款分属两份账单,订单侧却按支付日圈了一批次;修法:退款按退款账期归批,批次键里带上账单类型。
- 分账回退的负数行。现象:汇总总额恰差一个回退金额,明细对却笔笔全平;定位:解析账单时对金额取了绝对值,负数的回退行被归进收入;修法:账单行保留符号,记哪个科目由行类型字段决定,不由正负猜。
六、三句话总结
对账的信任来自交叉验证:账单是证人,订单是事实,账本是被告——自己证自己不算数。汇总先行快速失败,明细逐笔只产差异不修数,差异分类处置是流程不是补丁。日切以渠道账期为准,退款和负数行是重灾区——三方对得平,账本才配叫账本。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
