引子:为什么每一笔钱都要被”数两遍”
在一家支付公司上线第一天,工程师最先感到不安的不是延迟、不是 QPS,而是一个看似简单的问题:“昨天收了多少钱?”。把订单系统、账务系统、网关回调日志、银行入账流水四张表放在一起,几乎没有一次能对得上。差几毛的是四舍五入、差几千的是重复回调、差几万的是系统时间跨天、差上百万的是有人把测试环境打到了生产通道。
对账(Reconciliation)在金融工程中的地位,类似于分布式系统里的”心跳 + 一致性校验”:它不改变业务本身,却是业务能被信任的前提。会计上有一句老话——账实相符、账账相符、账证相符,三者缺一不可。工程师最容易忽略的是第三条:“账”和”凭证”之间还要能互相指回。只有当资金方(银行、卡组织、清算机构)、平台方(支付网关、账务中心)、业务方(订单、营销、分账)的独立路径都指向同一个事实时,这笔钱才算真正”清楚”。
本文是【金融科技工程】系列第 23 篇,面向三类读者:
- 支付 / 账务系统工程师:想把对账从”一个脚本每天早上跑一次”升级成平台化能力。
- 清算与财务中台负责人:需要在 T+1 对账、T+0 实时对账、月结总账之间找到工程权衡。
- 风控与审计:希望理解”差错”是如何被发现、归类、平账的,以便建立有效的二线防御。
前文 《清算 vs 结算 vs 资金归集》 讲了资金流的时点,《复式记账工程化》 讲了会计语义,《支付网关设计》 讲了通道侧的幂等与补单。对账是它们的”收口”:所有异步、补偿、分片、跨系统失败,最终都要在对账表里得到一个布尔值结论——平 / 不平。
一、对账的金融本质
1.1 账实、账账、账证
会计教材里的”三相符”是对账工程的北极星:
| 维度 | 含义 | 工程表现 |
|---|---|---|
| 账实相符 | 账面金额 = 实物 / 资金实际余额 | 平台备付金账户余额 = 银行实际存款余额 |
| 账账相符 | 总账 = 明细账之和;一方账 = 对方账 | 总账科目余额 = 分户账累加;商户应收 = 平台应付 |
| 账证相符 | 账簿记录 = 原始凭证 | 分录的 voucher_id
能回指到交易流水、清算文件行号 |
在数字化场景里,“实物”退化为资金方权威视图:银行的存款证明、卡组织的清算文件、清算机构的结算报告。只要平台内账务和这些权威视图对得上,就近似于”账实相符”。
1.2 独立路径交叉校验
对账之所以有效,核心是独立路径。同一笔支付,至少可以从三条互不依赖的路径得到记录:
- 交易路径:用户下单 → 订单系统 → 支付网关请求。
- 通道路径:支付网关 → 银行 / 卡组织 → 清算文件。
- 资金路径:备付金户实际入账 → 银行流水 → 财务账。
每条路径都可能出错(系统 Bug、网络超时、人为误操作),但三条路径同时以同样的方式出错的概率极低。对账就是把三条路径在同一把”锚”(订单号、金额、日期)下聚合,观察是否一致。这和分布式系统里的多数派读写本质相同,只是仲裁者从 Quorum 变成了”银行 / 清算所”。
1.3 一个最小心智模型
事实 F 平台记账 A 通道记账 B
│ │ │
▼ ▼ ▼
用户支付 100 order=100,paid bank_in=100
ledger:+100 file:+100
\ /
\ /
对账:A ≡ B ≡ F ?
若 A = B = F,平账;任一对不上,差错。“差错”不是失败,而是未解释的差异(Unexplained Difference)。工程目标不是追求零差异,而是让每一条差异都有可追溯、可解释、可收敛的闭环。
二、对账的分层:从系统内到清算机构
一个完整的对账体系是分层的。把它摊开,可以看到至少四层:
flowchart TB
subgraph L1["L1 业务系统内对账"]
A1[订单系统] <--> A2[账务系统]
A2 <--> A3[资金调度]
end
subgraph L2["L2 与通道对账"]
B1[支付网关] <--> B2[银行 / 支付宝 / 微信 / 卡组织]
end
subgraph L3["L3 与清算机构对账"]
C1[清算中心] <--> C2[银联 / 网联 / 中证登 / CCP]
end
subgraph L4["L4 总账与分户账"]
D1[总账 GL] <--> D2[分户账 Sub-Ledger]
D1 <--> D3[备付金实账户]
end
L1 --> L2 --> L3 --> L4
2.1 业务系统内对账
业务内对账解决的是同一家公司内部不同系统之间的一致性。典型三对:
- 订单 ↔︎ 账务:订单状态为
PAID的订单,必须在账务系统里有一条金额相等的分录。 - 账务 ↔︎ 资金调度:账务分录中记为”应收商户 X 一万元”,资金调度系统应有对应的”打款 X 一万元”指令。
- 账务 ↔︎ 风控冻结:风控冻结金额 = 可用余额 - 实际可动用余额。
业内常用做法是事件日志重放对账:账务侧消费订单事件流,若某订单事件在 N 分钟内没有对应账务结果,即进入”掉单”流程。这种对账是近实时的,数据源为内部消息总线(Kafka / RocketMQ),频率可到分钟级。
2.2 与通道对账
通道对账是整个体系中规则最多、格式最杂的一层。平台接多少通道,就要维护多少种对账协议。常见如:
| 通道 | 文件类型 | 获取方式 | 时点 |
|---|---|---|---|
| 银联 | 8583 清算对账文件 / ACS | 文件下载 | T+1 日终 |
| 网联 | 定长文本 / CSV | SFTP | T+1 |
| 支付宝 | CSV(开放平台) | HTTPS 接口 | T+1 |
| 微信支付 | CSV / gzip | HTTPS 接口 | T+1 |
| Visa / Mastercard | EP747 / TC57 / IPM | MFT 专线 | T+1 或 T+2 |
| SWIFT | MT940 / MT950 | SWIFT 网络 | 日终 |
| ISO 20022 | camt.052 / camt.053 / camt.054 | API / 文件 | 实时 + 日终 |
通道对账的关键挑战:对方是主。平台只能以通道文件为准;当差异出现,需要平台自己查自己,而不能反过来要求通道”改账”。
2.3 与清算机构对账
清算机构(Central Counterparty / CSD)对账是”机构间对账”。对象包括:
- 银联 / 网联:支付清算;
- 上清所 / 中证登(CSDC):债券、股票登记结算;
- CCP(LCH、Eurex Clearing、上期所清算中心):衍生品中央对手方;
- 外汇清算:CLS。
这一层的对账文件往往采用国际标准:ISO
20022 取代了旧的 MT 报文,camt.053
为客户对账单(End of Day Statement),semt.017
为证券结算通知,colr.003 为抵押品对账。详见 《支付与结算中的报文标准》
中 ISO 迁移一节。
2.4 总账与分户账对账
这是会计口径上的对账,也是财务最看重的一环:
- 总账(General Ledger, GL):按科目(资产、负债、所有者权益、收入、成本)汇总的科目余额。
- 分户账(Sub-Ledger):按客户、商户、账户分户的明细。
原则:总账科目余额 =
对应分户账余额之和。这是审计每月月结必过的一关。工程上通过定时任务在月末做一次全量
SUM(balance)
比对,一旦不平,基本是分录记错科目、账户漏挂科目、精度四舍五入累积三类原因。
三、对账文件:格式、传输与时点
3.1 文件格式速览
定长文本:银行最传统的格式。每条记录按字节位置切分,如交易号
1-20、金额 21-32(12
位带符号小数)、状态
33-34。优点是解析快、体积稳定;缺点是字段变更要版本协调。
20260420000000012345 000000010000C201
20260420000000012346 000000020050D201
CSV:支付宝、微信、抖音支付最常见。第一行表头、UTF-8、逗号分隔。大多带 BOM,且会在行末留空格。
订单号,商户号,金额,手续费,状态,完成时间
2026042000001,8000000001,100.00,0.60,SUCCESS,2026-04-20 10:23:45
ISO 8583 清算文件:银联 UnionPay
File(UPF)和 Visa Base II 属于这类。报文结构是 MTI + 位图 +
字段,文件里一行是一条”清算报文”,比在线 8583 授权报文多了
DE 62、DE 72(私有字段)承载对账专用信息。
SWIFT MT940 / MT950:银行账户日对账单。
:20:STMT20260420
:25:6100123456789
:28C:113/1
:60F:C260419CNY123456,78
:61:2604200420DN10000,00NTRFREF12345
:86:WIRE FROM ACME CO LTD
:62F:C260420CNY113456,78
:20:报文引用号;:25:账号;:60F:期初余额;:61:逐笔流水;:62F:期末余额。
ISO 20022 camt.053 XML:
<BkToCstmrStmt>
<Stmt>
<Id>STMT20260420</Id>
<CreDtTm>2026-04-20T23:59:00+08:00</CreDtTm>
<Acct><Id><Othr><Id>6100123456789</Id></Othr></Id></Acct>
<Bal>
<Tp><CdOrPrtry><Cd>OPBD</Cd></CdOrPrtry></Tp>
<Amt Ccy="CNY">123456.78</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Dt><Dt>2026-04-19</Dt></Dt>
</Bal>
<Ntry>
<Amt Ccy="CNY">10000.00</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<Sts><Cd>BOOK</Cd></Sts>
<BookgDt><Dt>2026-04-20</Dt></BookgDt>
<AcctSvcrRef>REF12345</AcctSvcrRef>
</Ntry>
</Stmt>
</BkToCstmrStmt>XML 相对 MT 冗余但可扩展,未来 CIPS、CNAPS2、TARGET2、Fedwire ISO 20022 迁移后将普遍采用。
3.2 传输方式
- SFTP / MFT(Managed File Transfer):最普遍。银行与企业通过 IBM Sterling、Axway MFT、开源 MinIO + SFTP 前置传输。
- 专线 + 文件网关:银联、网联、CIPS 采用专线 + 前置机的方式,平台程序访问前置机本地目录。
- API 下载:支付宝、微信开放平台提供 HTTPS + 签名下载对账文件。
- SWIFT FIN / FileAct:跨境场景,托管于 SWIFT Alliance Access。
- 对象存储:近年新通道(如部分互联网银行)通过预签名 URL + S3 兼容对象存储发放。
无论何种传输方式,文件到达后必须做三件事:校验文件名日期、校验签名 / MD5、校验行数与汇总金额。这三者任一失败都要拒收,避免”半个文件进入对账”引起二次差错。
3.3 时点:T+1 与 T+0
- T+1 日终对账:最主流。通道侧 00:00 切日,次日 07:00–10:00 产出文件,平台侧在上班前跑完匹配。财务核心逻辑仍然按 T+1 日终结算。
- T+0 准实时对账:通过对账接口 / 流式清算文件分片获得。优势在于资金差异暴露更快,但匹配基准不稳定(通道方状态可能仍在流转),需要容忍”暂时不平”。
- 月结 / 年结:针对总账、分户账、备付金监管报送。
一般建议:过程对账靠近实时(发现异常),最终对账以 T+1 为准(产生凭证、平账)。两者互补,不矛盾。
四、三态匹配:长款、短款、不一致
对账的所有结论本质只有四种:
| 状态 | 定义 | 原因示例 |
|---|---|---|
| 匹配(Matched) | 平台与对方都有,金额 / 状态一致 | 正常 |
| 长款(Over / Surplus) | 平台有、对方无 | 平台误记、通道漏传、下账时间跨日 |
| 短款(Short / Deficit) | 平台无、对方有 | 平台漏记、网关异步通知丢失、系统故障掉单 |
| 不一致(Mismatched) | 双方都有,但金额 / 币种 / 状态 / 手续费不符 | 费率计算错、汇率口径不同、退款部分成功 |
术语注意:在财务口径里,“长款”与”短款”有时指资金盈余
/ 短缺(账实差异),有时指平台应收 /
应付差。工程表里一般只记录两种:PLATFORM_ONLY、CHANNEL_ONLY、AMOUNT_DIFF、STATUS_DIFF,把”站在谁的角度”留到报表层再定义。
4.1 差错优先级
不是所有差异都同等紧急。工程上按金额量级 × 时间衰减 × 业务影响分级:
- P0:单笔 ≥ 100 万或累计 ≥ 1000 万;或状态不一致导致”钱在用户那边但账上没扣”。
- P1:单笔 1 万–100 万;日内可追查。
- P2:<1 万的金额差,多为手续费、四舍五入累积,T+3 内处理。
P0 需在当日内关账前解决;否则月结将把差错带入下月。
五、匹配算法
5.1 一对一:按订单号 / 流水号
最理想、也最常见。平台生成全局唯一
out_trade_no,通道文件里带回;按这列做
INNER JOIN
即可。关键点是通道侧字段映射:
- 支付宝:
商户订单号; - 微信:
商户订单号; - 银联:
商户订单号(DE 43)+系统参考号(DE 37, RRN); - Visa / Mastercard:
Merchant Reference+Transaction Identifier。
不同通道字段宽度不同,建议在网关出账时就把
out_trade_no 限制在 32 位 ASCII
数字字母内,避免跨通道被截断。
5.2 一对多 / 多对一:聚合匹配
一对多:平台一笔订单被分成多笔清算(批量结算、分账退款)。典型见于保险分保、证券拆单、聚合支付的分润。 多对一:平台多笔订单被合并为一笔清算(每日净额结算,Net Settlement)。跨境代理行、部分 ACH 系统常见。
匹配思路:按通道给出的批次号 / 清算号聚合,先做”批次内一对一”匹配,再做”批次合计对账”。
-- 平台端按清算批次聚合
SELECT settle_batch_id,
SUM(amount) AS platform_total,
COUNT(*) AS platform_cnt
FROM platform_orders
WHERE settle_date = '2026-04-20'
GROUP BY settle_batch_id;
-- 通道端同批次汇总
SELECT batch_no,
SUM(amount) AS channel_total,
COUNT(*) AS channel_cnt
FROM channel_file
WHERE file_date = '2026-04-20'
GROUP BY batch_no;
-- 对账结果
SELECT p.settle_batch_id,
p.platform_total, c.channel_total,
p.platform_cnt, c.channel_cnt
FROM platform_agg p
FULL OUTER JOIN channel_agg c
ON p.settle_batch_id = c.batch_no
WHERE p.platform_total <> c.channel_total
OR p.platform_cnt <> c.channel_cnt;5.3 模糊匹配:时间窗口 + 金额容差
当流水号对不上(老系统、线下 POS、人工补单)时,只能靠”金额 + 时间”近似匹配:
- 时间窗口:±N 秒 / 分钟;
- 金额容差:绝对差 ≤ 分;或比例差 ≤ 万分之一(处理汇率换算);
- 候选冲突:同一时间窗口内金额相同的交易,需要二次用”卡号末四位 / 终端号 / 商户号”消歧。
模糊匹配只能作为人工辅助工具,不能写入平账凭证。自动执行的模糊匹配一旦误判,会造成”自己把自己账做平”的假象,审计会追责。
5.4 状态机匹配
有些通道会在一天内对同一笔交易产生多条记录(授权 → 撤销 → 退款 → 争议 → 调整)。匹配时需要按最终态聚合:
AUTH → CAPTURE → REFUND_PART → CHARGEBACK → REPRESENTMENT
常见做法是把通道明细按 (out_trade_no)
分组,按时间顺序回放状态机,得到一个”终态净额”,再与平台终态比对。
六、对账引擎架构
6.1 标准流水线
flowchart LR
F[文件网关 / API 抓取] --> P[解析 Parser]
P --> L[落库 Stage 表]
L --> M[匹配 Matcher]
M --> D[差异写入 Diff 表]
D --> H[差错处理 Handler]
H --> A[调账 / 补偿]
A --> R[对账报告 / 凭证]
R --> G[总账过账]
每个节点都必须可重跑、可幂等:
- 文件网关:按
(通道, 日期, 文件名)去重,支持断点续传; - Parser:对每一行生成确定性
row_hash,重跑时按row_hash覆盖写; - Matcher:按
(通道, 日期)为幂等键; - Handler:差错单 ID 幂等,避免重复调账。
6.2 数据模型示例
-- 通道对账文件原始明细
CREATE TABLE recon_channel_detail (
id BIGINT PRIMARY KEY,
channel VARCHAR(16) NOT NULL,
file_date DATE NOT NULL,
file_name VARCHAR(128) NOT NULL,
row_no INT NOT NULL,
out_trade_no VARCHAR(64),
channel_ref VARCHAR(64),
amount DECIMAL(18,2),
fee DECIMAL(18,2),
currency CHAR(3),
status VARCHAR(16),
trade_time DATETIME,
row_hash CHAR(64) NOT NULL,
UNIQUE KEY uk (channel, file_date, row_hash),
KEY k_trade (out_trade_no)
);
-- 平台端对账快照
CREATE TABLE recon_platform_snapshot (
id BIGINT PRIMARY KEY,
channel VARCHAR(16),
trade_date DATE,
out_trade_no VARCHAR(64) NOT NULL,
amount DECIMAL(18,2),
fee DECIMAL(18,2),
currency CHAR(3),
status VARCHAR(16),
UNIQUE KEY uk (channel, trade_date, out_trade_no)
);
-- 差错单
CREATE TABLE recon_diff (
id BIGINT PRIMARY KEY,
channel VARCHAR(16),
trade_date DATE,
diff_type ENUM('PLATFORM_ONLY','CHANNEL_ONLY','AMOUNT_DIFF','STATUS_DIFF'),
out_trade_no VARCHAR(64),
platform_amount DECIMAL(18,2),
channel_amount DECIMAL(18,2),
diff_amount DECIMAL(18,2),
state ENUM('OPEN','INVESTIGATING','ADJUSTED','CLOSED','WAIVED'),
owner VARCHAR(32),
created_at DATETIME,
updated_at DATETIME,
remark TEXT
);6.3 大数据量:Spark / Flink 分区对账
单日十亿级交易,传统 MySQL FULL OUTER JOIN
扛不住。常见升级路径:
- Hive + Spark 批处理:按
(channel, trade_date, hash(out_trade_no)%1024)分桶,FULL OUTER JOIN在桶内执行,资源需求降到单桶百万级。 - Flink 流对账:双流
interval join,Key 为out_trade_no,窗口 ±2 小时,超时未匹配落入差错流。 - OLAP 侧辅助:ClickHouse、Doris 做对账结果聚合查询,避免差错检索穿透事务库。
Spark 代码骨架:
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("recon").getOrCreate()
p = spark.read.parquet("s3://recon/platform/dt=20260420")
c = spark.read.parquet("s3://recon/channel/dt=20260420/ch=wechat")
joined = p.alias("p").join(
c.alias("c"),
on="out_trade_no",
how="full_outer",
)
diff = joined.select(
F.coalesce("p.out_trade_no", "c.out_trade_no").alias("out_trade_no"),
F.when(F.col("c.out_trade_no").isNull(), "PLATFORM_ONLY")
.when(F.col("p.out_trade_no").isNull(), "CHANNEL_ONLY")
.when(F.col("p.amount") != F.col("c.amount"), "AMOUNT_DIFF")
.when(F.col("p.status") != F.col("c.status"), "STATUS_DIFF")
.otherwise("MATCHED").alias("diff_type"),
F.col("p.amount").alias("platform_amount"),
F.col("c.amount").alias("channel_amount"),
)
diff.filter("diff_type <> 'MATCHED'") \
.write.mode("overwrite") \
.parquet("s3://recon/diff/dt=20260420/ch=wechat")Flink SQL 流对账片段:
CREATE TABLE platform_stream (
out_trade_no STRING,
amount DECIMAL(18,2),
status STRING,
event_time TIMESTAMP(3),
WATERMARK FOR event_time AS event_time - INTERVAL '5' MINUTE
) WITH ('connector'='kafka', 'topic'='platform_paid', ...);
CREATE TABLE channel_stream (
out_trade_no STRING,
amount DECIMAL(18,2),
status STRING,
event_time TIMESTAMP(3),
WATERMARK FOR event_time AS event_time - INTERVAL '5' MINUTE
) WITH ('connector'='kafka', 'topic'='channel_cleared', ...);
-- 2 小时窗口双流匹配
INSERT INTO recon_matched
SELECT p.out_trade_no, p.amount, c.amount, p.status, c.status
FROM platform_stream p
JOIN channel_stream c
ON p.out_trade_no = c.out_trade_no
AND c.event_time BETWEEN p.event_time - INTERVAL '2' HOUR
AND p.event_time + INTERVAL '2' HOUR;Flink 侧未在窗口内匹配到的流水,由侧输出(Side Output)送入差错通道。
七、Python / Go 双边对账示例
7.1 Python:最小可用对账器
读取两个 CSV,输出长款、短款、不一致。
import csv
from collections import namedtuple
from decimal import Decimal
Row = namedtuple("Row", "out_trade_no amount status")
def load(path):
with open(path, newline="", encoding="utf-8") as f:
rdr = csv.DictReader(f)
return {
r["out_trade_no"]: Row(
r["out_trade_no"],
Decimal(r["amount"]),
r["status"],
)
for r in rdr
}
def reconcile(platform_csv, channel_csv):
p = load(platform_csv)
c = load(channel_csv)
platform_only, channel_only, amount_diff, status_diff = [], [], [], []
for k, pv in p.items():
cv = c.get(k)
if cv is None:
platform_only.append(pv)
elif pv.amount != cv.amount:
amount_diff.append((pv, cv))
elif pv.status != cv.status:
status_diff.append((pv, cv))
for k, cv in c.items():
if k not in p:
channel_only.append(cv)
return {
"platform_only": platform_only,
"channel_only": channel_only,
"amount_diff": amount_diff,
"status_diff": status_diff,
}
if __name__ == "__main__":
r = reconcile("platform.csv", "channel.csv")
for k, v in r.items():
print(f"== {k} ({len(v)}) ==")
for item in v[:5]:
print(item)生产版本还要补:币种归一化、分部分全额退款的状态回放、按
batch_id
聚合匹配、结果入库、差错单生成、重跑幂等。
7.2 Go:按通道并行对账
package main
import (
"encoding/csv"
"fmt"
"math/big"
"os"
"sync"
)
type row struct {
OutTradeNo string
Amount *big.Rat
Status string
}
func load(path string) (map[string]row, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
r := csv.NewReader(f)
recs, err := r.ReadAll()
if err != nil {
return nil, err
}
m := make(map[string]row, len(recs))
for i, rec := range recs {
if i == 0 {
continue
}
amt := new(big.Rat)
amt.SetString(rec[1])
m[rec[0]] = row{rec[0], amt, rec[2]}
}
return m, nil
}
type diff struct {
Kind string
Key string
P, C *big.Rat
}
func reconcile(p, c map[string]row) []diff {
var out []diff
for k, pv := range p {
cv, ok := c[k]
if !ok {
out = append(out, diff{"PLATFORM_ONLY", k, pv.Amount, nil})
continue
}
if pv.Amount.Cmp(cv.Amount) != 0 {
out = append(out, diff{"AMOUNT_DIFF", k, pv.Amount, cv.Amount})
} else if pv.Status != cv.Status {
out = append(out, diff{"STATUS_DIFF", k, pv.Amount, cv.Amount})
}
}
for k, cv := range c {
if _, ok := p[k]; !ok {
out = append(out, diff{"CHANNEL_ONLY", k, nil, cv.Amount})
}
}
return out
}
func main() {
channels := []string{"wechat", "alipay", "unionpay"}
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(ch string) {
defer wg.Done()
p, _ := load("platform_" + ch + ".csv")
c, _ := load("channel_" + ch + ".csv")
d := reconcile(p, c)
fmt.Printf("[%s] diffs=%d\n", ch, len(d))
}(ch)
}
wg.Wait()
}Go 版本使用 math/big.Rat
避免浮点误差。生产还需考虑:GC
压力(按分片处理,不要一次性装入
map)、差错单入库、下游通知。
八、差错处理:从发现到闭环
8.1 差错生命周期
stateDiagram-v2
[*] --> OPEN: 对账引擎发现差异
OPEN --> INVESTIGATING: 运营认领
INVESTIGATING --> REQUERY: 向通道查单
REQUERY --> INVESTIGATING: 查单返回
INVESTIGATING --> ADJUSTED: 调账单已制
INVESTIGATING --> WAIVED: 金额过小 / 合理差异
ADJUSTED --> CLOSED: 复核通过 / 财务入账
WAIVED --> CLOSED
CLOSED --> [*]
核心状态:OPEN → INVESTIGATING → {ADJUSTED | WAIVED} → CLOSED。
REQUERY:对掉单类差异,需向通道发起查单 API(支付宝alipay.trade.query、微信pay/transactions/out-trade-no/{no}),根据返回结果决定重推通知还是发起冲正。WAIVED:合理差异(如小额手续费四舍五入、跨日切点),在限额内可由运营直接豁免,但需留痕。ADJUSTED:需要发起调账单,见下节。
8.2 常见差错场景
| 场景 | 典型原因 | 处理动作 |
|---|---|---|
| 掉单(平台无、通道有) | 异步通知丢失、平台宕机 | 查单后补记入账 |
| 长款(平台有、通道无) | 网关超时后用户取消、测试数据 | 查单,确认通道确无则冲销 |
| 金额不符 | 汇率口径、手续费费率变更 | 调账至应收 / 应付差额 |
| 状态不符 | 通道已退款、平台未收到退款通知 | 以通道为准更新平台状态 |
| 币种不符 | 多币种通道未按下单币种清算 | 按当日中间价调整,差额挂”汇兑损益” |
| 重复收单 | 用户重试 + 网关未幂等 | 保留首笔,其余通道原路退款 |
8.3 以谁为准
经验原则:
- 资金真实到账 > 通道报文 > 平台内部记录;
- 涉及用户体验的状态(已支付 / 已退款)以通道为准;
- 涉及资金核对的金额以银行实际入账为准;
- 涉及费率、分账等内部规则的以平台为准,通道端只认总额。
工程上意味着,差错处理流水线里大多数动作方向是”向通道靠拢”,平台主动修正自己。
8.4 查单(Requery)与冲正(Reversal)
- 查单:幂等、只读。通道返回终态后,平台按终态更新账务。建议实现指数退避 + 幂等键,避免短期雷同请求。
- 冲正:有方向的”取消”。银联 8583 有
0400冲正报文;ISO 20022 有pacs.007 / pacs.004。平台发起冲正时需锁定原交易,避免用户二次点击支付时再占用已冲正资金。 - 退单(Refund):与冲正不同,退单是在原交易基础上建立一笔新交易,属于正常业务流;冲正则是”当作没发生”。
九、调账与冲销:会计层面的收口
9.1 调账(Adjustment)流程
调账是所有差错的资金结论。它是一张特殊的凭证,目的不是业务结果,而是让账面和事实重新一致。
flowchart LR
D[差错单 OPEN] --> M[制单人 创建调账单]
M --> V[复核人 审核]
V -->|通过| A[过账 GL]
V -->|驳回| M
A --> C[差错单 CLOSED]
工程要点:
- 双录 /
双人审核:制单人与复核人不得同一人(Segregation of
Duties,SoD)。系统强制校验
created_by <> reviewed_by。 - 审计日志不可删除:调账单、审核记录、附件(查单截图、邮件)全量保留,月结审计必调。
- 限额分层:单笔金额越大,审核层级越高(主管 → 财务总监 → CFO)。
- 分录对称:调账分录必须遵循复式记账,借贷相等(见 复式记账)。
9.2 冲销与红冲
冲销(Reverse):在会计账上登记一笔与原分录方向相反、金额相同的分录,使两笔合计为 0。
红冲:中国会计特色做法,对原错误凭证登记一笔相同方向但金额为负数(用红字显示)的凭证冲掉,再补一笔正确分录。红冲保留了错误记录痕迹,便于审计追溯。
二者选择一般看规则:
- 错误发生在本期,且金额小:直接冲销;
- 跨期错误,或金额重大:红冲 + 补录;
- 未过账的凭证:直接作废,不走冲销流程。
9.3 调账单数据模型
CREATE TABLE adjustment (
id BIGINT PRIMARY KEY,
diff_id BIGINT NOT NULL,
voucher_id BIGINT, -- 过账后生成的凭证号
debit_account VARCHAR(32) NOT NULL,
credit_account VARCHAR(32) NOT NULL,
amount DECIMAL(18,2) NOT NULL,
currency CHAR(3) NOT NULL,
reason_code VARCHAR(32) NOT NULL,
remark TEXT,
created_by VARCHAR(32) NOT NULL,
reviewed_by VARCHAR(32),
state ENUM('DRAFT','REVIEWING','APPROVED','POSTED','REJECTED') NOT NULL,
created_at DATETIME,
posted_at DATETIME,
CHECK (created_by <> reviewed_by)
);reason_code
要做成受控字典(如
CHANNEL_MISSING、AMOUNT_DIFF_FEE、FX_ROUNDING),不允许自由文本,否则审计报告难以聚合。
十、实时对账:时效与一致的工程权衡
日终对账是”慢且对”,实时对账是”快但可能抖动”。真实系统往往两条路并行:
| 维度 | 过程对账(实时 / 准实时) | 最终对账(T+1 日终) |
|---|---|---|
| 输入 | Kafka 事件流 / 通道查询 API | 通道对账文件 |
| 延迟 | 秒 – 分钟 | 小时 – 日 |
| 目标 | 快速发现异常、触发补单 | 财务结论、生成凭证 |
| 容忍态 | 允许”暂不平”(状态流转中) | 必须平账 |
| 引擎 | Flink / Kafka Streams | Spark / Hive |
| 结果 | 告警、自动重试 | 差错单、调账、月结 |
典型协作:过程对账在几分钟内发现一笔订单 10 分钟未收到通道回执,触发查单;若查单确认通道已到账,补记平台账务,避免差错进入 T+1 文件;若查单显示通道未到账,记录为”在途”,留给 T+1 再判。
实时对账的坑:
- “暂时不平”太多导致告警噪声:需要按状态稳定性分层(终态才告警)。
- 时钟偏差:通道与平台的事件时间偏差可能达数十秒,窗口必须加倍。
- 幂等重放:事件反压 +
重放会引起重复匹配,Key
必须稳定(
out_trade_no + status)。 - 状态机方向性:不能用”最后一条事件”覆盖早期事件,必须按业务终态合并。
十一、案例:某电商平台十亿笔 / 日对账方案
某头部电商支付中台在 2025 年”双 11”峰值日清算笔数超过 10 亿,覆盖 6 家收单通道、3 家清算机构、若干跨境渠道。公开技术分享可见其技术博客与 QCon 演讲,以下为工程要点综述(金额、QPS 为示例级别,非特定数据):
1. 分层:过程对账(Flink) + 日终对账(Spark on K8s) + 月结总账(核心账务 + 大数据冷备对账)。
2. 分片:按
(channel, trade_date, hash(out_trade_no) % 1024)
划分 1024 桶,每桶独立 Spark Job,单桶百万级。整个日终对账 2
小时内完成。
3. 差错分级:系统自动把差错按”金额 + 通道 + 类型”打分,P0 实时电话告警;P1 企业 IM;P2 日报。
4. 查单风暴治理:掉单自动触发查单,通道侧 QPS 有限,引入令牌桶 + 批量 API + 优先级队列(大额优先)。
5. 对账文件自愈:对账文件 MD5 / 行数校验失败自动重下三次,仍失败通知通道侧并降级为实时对账结果兜底。
6. 差错闭环指标:以 “差错金额 / 交易金额”、“差错单 24h 关闭率”、“调账复核合规率” 为核心 KPI,不用”差错数”,避免被单笔小额刷量。
7. 存储:明细落 TiDB + 对象存储双写;Spark 直接读对象存储 Parquet,避免对交易库 OLTP 造成压力。
国际对标案例:
- Stripe:对账面向商户提供
Reports API,平台内部有 Double-Entry Ledger 服务(对外分享在 Stripe Engineering Blog),按事件流驱动对账,配合每日文件做终态校验。 - Adyen:使用 Reconciliation Reports 与 Dispute Reports,按交易生命周期多文件分发,平台按文件类型路由到不同对账引擎。
- PayPal:通过 IPN(Instant Payment Notification)+ SFTP Report 构成过程对账 + 日终对账组合。
十二、工程坑点
- 跨日切点:通道方与平台方对”一天”的定义不同(00:00 vs 业务日 08:00),会把一部分交易打到相邻两天的文件。必须对每日头尾 2 小时做”跨日追踪”。
- 时区陷阱:跨境通道文件常为 UTC / GMT,本地业务按 CST 或 JST。文件日期、交易时间必须全部归一到 UTC,再按业务日展示。
- 金额精度:通道文件有以分、以元、以厘为单位的;部分日元、韩元无小数位;加密货币有 18 位小数。入库统一为”最小单位整数”(见 钱的建模)。
- 重复文件:通道重传同日文件、不同文件名。必须以
(channel, date, file_hash)去重,避免把重传当增量。 - 状态覆盖顺序:最终态(
SUCCESS / REFUNDED / CHARGEBACK)必须按 LWW 规则小心处理,不能用事件到达顺序覆盖业务终态。 - 分户账漂移:商户户关户、清户、换户号时容易错串,对账前要先核对户主表快照。
- 手续费口径:通道文件中的
fee有”含税 / 不含税”、“按笔 / 按日聚合”、“T+1 返还”多种口径,差一项就会导致每日微额差异。 - 退款链路:支付宝、微信退款多为异步,且有”资金原路退回 vs 商户余额退回”两条路径,对账时需要分别处理。
- 调账幂等:差错单重跑、复核重提,调账最多创建一条。用
diff_id做唯一索引。
十三、选型建议与落地清单
规模与选型:
- 日交易 < 100 万笔:MySQL / PostgreSQL + 定时任务 + 简单 Python / Go 脚本即可。
- 100 万 – 1 亿笔:引入 Spark / Flink,Stage 表用 TiDB 或分库分表 MySQL。
- > 1 亿笔:Lakehouse(Hudi / Iceberg)+ Spark;实时对账上 Flink;差错管理独立平台化。
最小落地清单(6 项):
- 对账文件网关:统一接入、签名校验、MD5 / 行数校验、去重。
- Stage 表 + 解析器:每通道一套 Parser,统一落 Stage 表,保留原始字段。
- 匹配引擎:至少支持一对一、一对多、多对一三种模式。
- 差错单系统:状态机、分级、SLA、通知;差错单必须能”关联凭证”。
- 调账平台:双录审核、分层限额、凭证过账、报表留痕。
- 对账报表:每日差错金额、差错率、关闭率、调账件数、超时单数,面向运营与财务。
合规检查项:
- 所有调账留痕至少 10 年(符合《会计档案管理办法》)。
- 差错处理流程符合内控要求:制单人、复核人分离;大额分层审批。
- 对账系统与交易系统数据库物理隔离或至少权限隔离,防止运营直接改交易表”修数平账”。
十四、深入主题:几个容易被忽略的工程细节
14.1 对账中的”在途资金”
在途资金(In-Transit Funds)是指已经离开一方账户、尚未到达另一方账户的那部分资金。典型场景:
- 跨境代理行之间,SWIFT 报文已发、对方 nostro 户尚未贷记;
- 网联转接,发卡行已扣款、收单行尚未入账;
- 证券结算 T+1,卖出已记”应收证券款”、资金 T+1 才到。
在途资金在对账口径里必须单独挂一个过渡户(Suspense Account / Clearing Account)。每日对账时:
过渡户期初 + 今日净增 - 今日净减 = 过渡户期末
一旦过渡户余额随时间单调增长而不收敛,说明存在”沉淀”——通常是漏记入账、结算方掉包、或对账匹配失效。工程上每日扫描过渡户,凡余额持续超过 T+2 的单据必须立案调查。
14.2 结转(Roll-Forward)与试算平衡
“试算平衡”(Trial Balance)是会计月结前的一道关口:所有科目的借方之和等于贷方之和。从对账视角看,它是账账相符的极限检验。
一个健康的对账系统应当在日终生成以下三张表:
| 报表 | 含义 | 平衡关系 |
|---|---|---|
| 日交易汇总 | 当日新增借贷 | Σ debit = Σ credit |
| 结转汇总 | 昨日期末 + 今日净变动 = 今日期末 | 每科目独立成立 |
| 试算平衡 | 所有科目期末借贷总计 | Σ debit_balance = Σ credit_balance |
其中”结转”要求每日每科目都必须生成一条期末余额快照,失败时不能覆盖——这是审计要求的”账簿连续性”。工程实现常用”余额快照表”(account_balance_snapshot(account_id, biz_date, balance)),每日凌晨跑批生成,不允许修改,只允许补发。
14.3 时间切片与锚点
对账文件的”一天”是什么?不同通道给出不同答案:
- 支付宝、微信:按北京时间自然日;
- 银联:按清算日(T+1 文件覆盖 T 日的交易);
- Visa:按中央处理日(Central Processing Day),可能跨周末合并;
- SWIFT:按Value Date(起息日),与实际发生日可能不同;
- CLS:按Settlement Cycle Date,全球统一口径。
工程上建议在 Stage 表中为每条记录同时保存:
{
trade_time_utc, // 真实发生时间
channel_file_date, // 通道文件给出的日期
platform_biz_date, // 平台业务日
value_date, // 会计起息日
}
对账时以 channel_file_date 与
platform_biz_date
的约定映射为准,避免跨日误差被掩盖。跨时区对账必须在每条记录上带上原始时区字段,不要只留
UTC——某些监管报送要求按”当地时间”回溯。
14.4 商户对账(B2B2C 场景)
对 C 端支付聚合平台,对账还有一层对下游商户的角色——商户对账单。商户会质问:“为什么我后台看到 100 笔成功,但你们只结算了 98 笔?”这里的差异通常来自:
- 冻结:风控冻结 / 争议款(Chargeback Hold)未释放;
- 手续费:费率变更跨日;
- 退款抵扣:当日退款金额直接从结算金额中抵扣;
- 分账:部分金额划给其他分账方;
- 币种换算:多币种按 T+1 中间价统一折算。
工程上需要给商户提供结算单(Settlement Statement),明细展示”毛交易额 − 手续费 − 退款 − 冻结 − 分账 = 结算金额”,每一项都可点击下钻到明细。否则商户的客服工单会压垮财务团队。
14.5 对账与数据治理
对账本身是数据治理的一部分。它天然回答了三个治理问题:
- 数据完整性:文件是否全量到达(行数校验、金额汇总校验);
- 数据准确性:平台记录与外部事实是否一致(匹配结果);
- 数据及时性:T+1 文件是否按约定时间到达(SLA 监控)。
因此在大公司里,对账系统往往与数据质量平台(DQ)共用一套指标体系:完整率、准确率、及时率 三维 SLO。对账引擎每天不仅产出差错单,也产出”数据质量日报”供数据团队使用。
十五、自检清单:上线前 30 条
以下清单可作为对账系统评审时的检查底稿:
文件与接入
- 是否为每个通道明确了文件获取方式、时点、重试策略?
- 文件命名是否按
{channel}_{date}_{seq}.{ext}等可预测规则? - 是否实现 MD5 / SHA256 校验?签名校验?
- 文件落地是否双写对象存储 + 本地,保留至少 180 天?
- 丢文件时是否有降级方案(实时对账兜底、次日重发)?
解析与入库
- 每条原始记录是否有
row_hash,支持重跑幂等? - 字段精度是否按通道口径做了归一化?
- 币种、时区是否全局统一到 UTC + ISO 4217?
- Stage 表是否分区(按 channel、date)?
- 是否保留”原始字段 + 解析字段”两列,便于审计回溯?
匹配与差错
- 一对一匹配的 key 是否稳定(平台
out_trade_no跨通道唯一)? - 聚合匹配是否覆盖”批次内金额 + 笔数”双校验?
- 差错单是否按差错类型拆分,state 机规范?
- 差错单 SLA 是否分级设定并告警?
- 差错单是否能关联原始文件行、原始事件、调账单、凭证?
调账与过账
- 调账是否强制双录(
created_by <> reviewed_by)? - 调账限额是否按金额分级审批?
- 调账分录是否进入账务系统主链路,过总账?
- 调账是否有回滚流程(
REJECT/ 撤销)? - 调账凭证是否进入月结报表?
实时能力
- 过程对账是否只对终态告警,避免抖动?
- 查单 / 冲正是否实现幂等 + 指数退避?
- 实时引擎(Flink)是否按 Key 稳定分区?
- 在途资金过渡户是否有独立监控?
合规与审计
- 所有变更操作是否有不可变审计日志?
- 档案保留是否满足监管年限?
- 权限是否遵循最小化、SoD(Segregation of Duties)?
- 是否提供只读”审计视图”给外部审计师?
运营与报表
- 每日差错金额、差错率、关闭率是否上 BI 看板?
- 是否为商户 / 机构提供可自助下载的对账单(PDF / CSV)?
十六、FAQ
Q1:为什么不直接用数据库的外键约束来替代对账?
对账解决的是跨信任边界的一致性。通道与平台属于不同法律主体,不可能共享数据库;即便在同一家公司内部,订单与账务也常常跨库跨机房,分布式事务代价太高。对账用”独立路径 + 事后核对”来换取系统解耦,是金融架构的一种基本权衡。
Q2:实时对账能彻底替代日终对账吗?
不能。实时对账基于事件流,处理的是”过程态”;日终对账基于通道最终发来的权威文件,处理的是”终态”。即便实时对账覆盖率 99.99%,剩下的 0.01% 依然需要日终文件做兜底确权,否则差异会在月结时集中爆发。
Q3:对账和幂等是什么关系?
幂等(第 5 篇)让”同一事件处理多次”不会产生多笔账。它是对账之前的必要条件:没有幂等,重复通知、重放事件会让平台账面出现真实的重复记录,对账时会把这些当作”长款”。但幂等不保证”事件一定会到达”——这部分靠对账补齐。两者互为补充,缺一不可。
Q4:小公司有必要搭一整套对账平台吗?
初创阶段每日交易几千笔的时候,一个 Python 脚本 + 邮件通知就够用。核心价值是把对账视为第一等工程资产——写日志、留归档、分差错类型。等规模到百万级再做平台化升级即可。过早平台化反而会让开发团队把精力耗在流程而非业务上。
Q5:跨境对账最难的点是什么?
三件事:时差、汇率、报文多样性。时差让”同一天”变得不可定义;汇率让金额匹配必须带容差;报文多样性(MT、ISO、自研格式)让每接入一家代理行就要写一套 Parser。跨境对账平台常用 ISO 20022 作为内部统一中间格式,所有通道 Parser 产出统一模型,下游匹配、差错、调账不再感知源格式。
十七、小结
对账是金融工程里最朴素也最严苛的活儿。它对工程师的要求和分布式一致性很像:先承认会出错,再设计如何发现错、如何定位错、如何收敛错。所谓”金融级”,不是永远不出问题,而是每一分钱都有据可查、可解释、可追溯。
做好对账这件事,需要把会计三相符、独立路径校验、状态机思维、流批融合、数据治理、合规留痕六者糅合在一起。本文尝试给出一套从文件格式、匹配算法、引擎架构到差错处理与调账流程的完整参考,希望读者在搭建自己的对账平台时能少走几圈弯路。
下一篇 《金融级可靠性》 将把视角从”数据一致性”切换到”系统可靠性”,讨论两地三中心、单元化、RPO/RTO 与灰度发布等话题。
十八、与本系列其他文章的联系
- 《复式记账工程化》:对账的”账”在这里定义。
- 《账务数据库设计》:Stage 表与分户账的存储选型。
- 《幂等、事务与一致性》:补单与冲正的幂等语义。
- 《支付网关设计》:通道回调与查单接口。
- 《清算 vs 结算 vs 资金归集》:清算文件的上游语义。
- 《央行支付系统》:CNAPS / CIPS 对账文件口径。
- 《实时风控引擎》:Flink 流式能力在对账中的复用。
十九、参考资料
- ISO 20022:https://www.iso20022.org/
- SWIFT MT940 / MT950 规范:SWIFT User Handbook(需授权访问)
- PBOC《非银行支付机构客户备付金存管办法》:http://www.pbc.gov.cn/
- 银联业务规范:中国银联官网技术文档
- 支付宝开放平台对账接口:https://opendocs.alipay.com/
- 微信支付对账单 API:https://pay.weixin.qq.com/docs/
- Stripe Engineering — Online migrations at scale & Ledger service 分享:https://stripe.com/blog/engineering
- Adyen Reconciliation Reports 文档:https://docs.adyen.com/
- Apache Spark、Apache Flink 官方文档
- 《会计档案管理办法》(财政部、国家档案局令第 79 号)
下一篇:《金融级可靠性:两地三中心、单元化、RPO/RTO、灰度》
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【金融科技工程】07 卡组织收单链路:银联/Visa/Mastercard、ISO 8583、ISO 20022 迁移
一笔刷卡交易从 POS/网关到发卡行再到清算的全链路剖析:授权、认证(3DS)、清算、结算、争议与对账;ISO 8583 报文拆解、BIN/PAN/Token、EMV 3DS、PCI DSS 与 HSM;附 Python/Go 构造 0100 报文示例。
【金融科技工程】08:支付宝、微信支付接入:服务商模式、预授权、分账、退款、对账
从工程师视角梳理支付宝开放平台与微信支付的商户接入路径、签名体系、预授权、分账、退款、对账单下载与三方对账,结合服务商模式下的二清合规边界与真实坑点。
【金融科技工程】央行支付系统:CNAPS、CIPS、Fedwire、TARGET2、SWIFT gpi
梳理世界主要经济体的央行大额与小额支付基础设施,覆盖中国 CNAPS/CIPS/网联、美国 Fedwire/FedNow、欧元区 T2/T2S/SEPA、以及 SWIFT、UPI、PIX,工程者如何接入与落地。
【金融科技工程】十九:实时风控引擎——规则、特征、模型、决策流与 Flink/Spark
系统拆解支付与交易场景下的实时风控引擎:三层防线、规则引擎(Drools/Aviator/CEL)、特征平台、画像、图风控、ML 打分、决策编排与 Champion-Challenger,辅以 Go 代码与分层架构 SVG。