Featured image of post 第三话|跨行清算对账:跟你跑通一个对账流水线,亲手拆解分布式一致性的工程骨架

第三话|跨行清算对账:跟你跑通一个对账流水线,亲手拆解分布式一致性的工程骨架

一笔跨行转账,凭什么断定两边都没记错?建一个简化对账系统:两个银行各自维护借贷账本,模拟结算服务提供第三方数据,日终对账任务逐笔匹配并分级处理差异。对账只发现差异,分级处理才决定动作——这个分界是金融系统能在出错后继续运行的基石。

系列 GYD(Get Your Distributed) 第 3 / 7 篇
主题 分布式对账账务系统Spring BootPostgreSQL借贷记账法

A 从工行转 10,000 元给 B 的建行账户。工行 App 显示扣款成功,建行的到账短信也到了,两边客户看到的都是「成功」。

第二天日终对账跑完,报表上多出一行差异:建行侧这笔交易有两条分录(账本上的记账记录),B 的账户多出一万块。钱只转了一次。那条多出来的分录是哪来的?谁负责把它消掉?能自动消掉吗?

这三个问题,对账系统都得回答。而回答之前,得先知道这笔转账在哪几个地方各记了什么——它不是一个系统写一行数据,是三个互不信任的系统各写各的账。

GYD 这个系列讲分布式系统里各种解决方案的实现方式。上一篇讲了消息队列的可靠投递——一条消息从发出到被处理,中间可能死在三段里。当消息可靠地到达了,新的问题浮现:两边的账还是可能对不上,你怎么发现、怎么分级处理、怎么修正?

对账这件事,本质上不是在检查「账有没有记错」——它是在问一个更根本的问题:在没有全局一致性的分布式环境里,你用什么手段去证明系统的状态是对的?

建一个最小可行的对账流水线:两个银行服务、各自的借贷账本、一个模拟结算服务、一个日终对账 Job。跑起来,亲手看到对账怎么发现差异、怎么分级决策、怎么追加式更正。不追求完整银行核心系统——那太大了——而是聚焦对账最核心的口径、匹配和分级处理。


一、一笔跨行转账,动了三个账本

回到那笔 10,000 元。表面上看,就是工行的一个账户余额减了,建行的一个账户余额加了。但这个简化版本漏掉了最关键的一环:银行间的结算。

先说清一件容易被跳过的事:工行不会用运钞车把一万块现金送到建行。银行之间的「钱」,是它们在央行开的账户里的数字。每家商业银行都在央行有一个结算账户——好比公司里每个部门都在财务总部存了一笔备用金,跨部门的结算由总部在这个池子里挪数字。

所以一笔跨行转账,实际有三个记账主体,各自维护一个独立数据库,互相看不到对方的账:

记账主体账本上记的是什么谁维护
工行核心系统A 的客户存款 + 工行存放央行的钱工行
央行支付系统工行结算账户 + 建行结算账户央行
建行核心系统建行存放央行的钱 + B 的客户存款建行

三幕记账

时间线上分三幕,每一幕各写一组分录。分录(journal entry)就是账本上的一行记账记录,它的约束第二节细讲。

第一幕:A 点转账,工行记账。 工行校验 A 余额充足,在自己的账本上写入两条:A 的客户存款减少一万,工行存放央行的钱减少一万。此时钱还没易手,只是工行单方面记下了「我要付一万出去」。

第二幕:工行把支付指令发给央行,央行记账。 央行检查工行结算账户余额够不够,够则同时改两户:工行的结算账户减一万,建行的结算账户加一万。这一步完成,银行间资金交割就「最终」了——在央行的制度里这叫结算最终性,记入央行账户后不可撤销。

第三幕:央行通知建行,建行记账。 建行收到入账通知,确认 B 账户状态正常,在自己的账本上写入两条:建行存放央行的钱增加一万,B 的客户存款增加一万。之后建行回执、工行给 A 弹「转账成功」——那是通知,不是记账。

三幕走完,四个账户余额发生了变化:A 的存款 −1 万、工行在央行的结算户 −1 万、建行在央行的结算户 +1 万、B 的存款 +1 万。钱没有消失也没有多出,只是欠条链条换了人。

同一个结算账户,在两个账本里性质相反

这里有一处最容易绕晕,说透了后面全通:工行在央行的那个结算账户,在工行账本上是资产,在央行账本上是负债。

就像你的工资卡余额——对你自己记「别人欠我一万」,对银行记「我欠客户一万」。同一笔债,两边各记各的,谁也没错。所以第二幕里,央行记的是「结算存款—工行」减少,因为那是央行欠工行的钱变少了;而第一幕里,工行记的是「存放央行款项」减少,因为那是工行存在别人那里的钱变少了。两笔指的是同一件事,但记在两个不同的数据库里,用两个方向相反的科目名。

对账要跨的就是这种缝:三本账各记各的,同一个事实在不同账本里长得完全不一样。

为什么裁判只能是央行

既然工行和建行各记各的账,为什么不能两边互相对一下就完事?

假设只让两家互对:工行说「我转了」,建行说「我没收到」,信谁?两家都是这笔交易的利益相关方,漏记、错记的一方有动机把责任推给对方。更糟的情况是两边都记错了——比如入账通知丢失导致两边都没入账,互对反而「对平了」,两家错得整整齐齐,谁也不知道出了问题。

央行不一样:它在这笔交易里没有利益瓜葛,而且是资金真正动过的那个地方。第一幕和第三幕都只是各家自己账本上的数字变化,第二幕才是钱真的易手。既然「钱到底转没转成」这个动作只发生在央行账上,这个问题的裁判也只能是央行账。

这就是「两方互对只能发现不一致,三方核对才能判定哪边不一致」的由来:工行建行互对,发现差一万,吵不清谁错;拿出央行记录一看「总部说转了一万」,立刻知道是哪家的小账本没记上。

这条判断抽掉金融外壳也成立——谁是那个动作真正发生的地方,谁的数据就是事实;两边平票时,找一个改它记录对自己没好处的第三方。 支付公司和微信对账以商户平台账单为准,电商和物流对账以仓储出库记录为准,都是同一个道理。

三幕链条会断在哪里

三幕跨越三个数据库、靠异步消息传递,没有任何全局事务能保证三步原子完成。断点至少有三个:

断在哪后果对账看到的现象
第一幕成功、指令没发出去工行扣了 A 的钱,央行和建行都没动静付款行有分录、结算侧无记录
第二幕成功、入账通知丢了钱已到建行在央行的户头,但 B 没到账结算已确认、收款行漏记
通知重复投递、消费端没做好幂等B 到账两万同一笔交易在收款行有重复分录

开篇那个场景就是第三种。记住这三种断裂方式,第四节的差异分类表就不是凭空冒出来的分类,而是这三个断点的直接映射。

demo 的架构

三幕在 demo 里的落点:

模拟结算服务充当那个「央行的角色」,产生每笔转账的清算结果。RabbitMQ 只是传递这些指令的通道——它不代替结算服务的权威性。对账 Job 从两家银行各拉一份数据,再加上自己进程内的结算记录,凑齐三方才能进入真正的匹配逻辑。

有一处和真实世界的差异要说清楚:现实中是「工行主动向央行发指令,央行结算后通知建行」,方向和顺序都固定;demo 里是结算服务生成结果后通过 RabbitMQ 分别投递给两家银行记账,两家都是被动消费。方向不同,但「三方各记各账、靠同一个交易流水号关联」的骨架一致。


二、单方账本的约束:借贷记账法的边界

在讲跨行对账之前,先要弄清楚一件事:一家银行自己的账本,凭什么说自己是「自洽」的?

答案是一套五百多年前发明的记账规则:借贷记账法(Double-Entry Bookkeeping)。

先把三个词落地

上一节说「工行写入两条」,那两条长什么样?要看懂它,得先落地三个词。

科目:账本上的分类抽屉。银行账本上不写「张三」「李四」,而写「客户存款」「存放央行款项」这样的类别,每一类是一个科目,下面再挂具体账户。第一节里工行记的那两条,科目分别是「客户存款」和「存放央行款项」。

资产和负债:「存放央行款项」是资产——钱存在别人那里,是别人欠银行的。「客户存款」是负债——客户存进来的钱是银行欠客户的,客户随时可以取走。你存 1 万,是银行欠你 1 万,这笔钱在银行账上从来不是「银行的钱」。

借和贷:跟借钱、贷款没有任何关系。它们是会计里的两个方向符号,相当于左和右,五百年前意大利商人记账时定下的习惯,沿用至今。方向怎么定,规则只有两条:

增加记减少记
资产借贷
负债贷借

有了这两条,第一幕那两条分录就能自己读通了:客户存款是负债,减少记借;存放央行是资产,减少记贷。第三幕反过来:存放央行是资产,增加记借;客户存款是负债,增加记贷。

借贷记账法做了什么

每笔账务交易,必须至少产生两条分录——一条记借方(Debit),一条记贷方(Credit),这两条的总金额相等、方向相反。

转账那笔 10,000 元,在工行侧产生的分录是:

方向科目金额
借客户存款(负债减少)¥10,000
贷存放央行款项(资产减少)¥10,000

两条分录的金额相等,借贷平衡。把工行所有账务分录的借方加总、贷方加总,两者必须相等——这就是「试算平衡」。

为什么钱必须记成对?因为钱不会凭空出现——它一定是「从某处来、到某处去」。只记「A 少了一万」而不记「这一万去了哪」,账本就没交代这笔钱的去向。借贷成对,等于强制每一笔都写清来源和去向。

这个约束的价值在于:任何一笔账务操作,要么借方和贷方一起写入(交易提交),要么一起不写(交易回滚)。 数据库的原子提交保证了这一点。所以「借记了一笔但忘了记对应的贷方」在正常运行的系统中不会发生——要么都记,要么都不记。

在 demo 里,这个约束落在 LedgerService.postEntries 上——它接收一组分录,校验借贷平衡后在同一事务写入:

// bank/LedgerService.java(节选)
@Transactional
public void postEntries(List<JournalEntry> entries) {
    // 校验借贷平衡:借方合计必须等于贷方合计
    long debitSum = entries.stream()
        .filter(e -> e.getDirection() == Direction.DEBIT)
        .mapToLong(JournalEntry::getAmountCents)
        .sum();
    long creditSum = entries.stream()
        .filter(e -> e.getDirection() == Direction.CREDIT)
        .mapToLong(JournalEntry::getAmountCents)
        .sum();

    if (debitSum != creditSum) {
        throw new UnbalancedException(debitSum, creditSum);
    }

    // 借贷平衡 + 原子提交:所有分录在同一事务中写入
    repo.saveAll(entries);
}

注意两处和直觉不同的地方:

  1. 金额用的是 long(最小货币单位「分」),不是 BigDecimal 也不是浮点。这不是「贬低 DECIMAL」——DECIMAL 本身是精确的定点数,银行系统里也广泛使用。demo 选 long 分是因为它足够表达整数金额、且避免了跨语言序列化时定点数精度不一致的坑。这是工程取舍,不是「浮点不安全所以只能选 long」。
  2. 借贷平衡的校验在应用层完成,PostgreSQL 的普通 CHECK 约束无法跨行校验「借方合计 == 贷方合计」——CHECK 只能约束单行的取值范围。这是数据库约束的边界,需要应用层补上。

借贷平衡 + 原子提交,保证了单方账本不会出现「凭空多了一笔钱或凭空少了一笔钱」。但它的保护范围到此为止。

借贷平衡不能发现的问题

试算平衡验证的是账务的内部自洽性,不是业务正确性。以下四类错误,账都是平的——借贷合计仍然相等:

  1. 整笔漏记:转账根本没入账。借方和贷方都没有,当然平衡。
  2. 整笔重复入账:同一笔转账被记了两次。第一次的借贷平衡,第二次的借贷也平衡,两次合起来仍然平衡。
  3. 记错科目:本该借记客户存款,却借记了另一个负债科目。借贷合计对得上,但客户的实际余额不对。
  4. 两侧金额同时记错:本来该记 10,000 元,两侧都记成了 1,000 元。借贷平衡,金额错误。

这四类正好覆盖第一节那三个断点:指令没发出去是整笔漏记,通知重复投递是整笔重复入账。它们全是异步链条的正常故障模式,而在单方账本上全都是「平的」——这就是为什么必须有跨方核对。

试算平衡就像一道数学题的自检验算——能证明「我的计算过程没有低级算术错误」,但不能证明「我的算式列对了」。要证明「列对了」,需要和外部数据进行比对。这就是对账要解决的。

这张图的核心信息是:不要以为账平了就万事大吉。平了只说明单方账簿没有算术错误。对账就是去验证「平了的那几行,是不是真的对应了一笔正确的交易」。


三、实时处理与周期核对可以并存

在讲对账逻辑之前,需要先澄清一个容易混淆的话题:什么时候对账? 很多人把「清算的实时性」和「对账的时效」混为一谈。

先用一个生活类比把三个词分开。你和同事这个月互相请客:算清楚「他欠我 80 块」是清算,他真的微信转你 80 是结算,月底翻聊天记录和账单确认「确实是 80,不是 180,也没重复算」是对账。

维度在做什么时效
清算(Clearing)计算各方的应收应付义务HVPS(大额支付系统)逐笔实时,BEPS(小额支付系统)批量轧差
结算(Settlement)通过资金账户完成义务清偿大额实时,小额定时净额
对账(Reconciliation)核对各方记录是否一致可以是日终批次、小时批次或准实时

清算和结算发生在交易当时(第一节的第二幕),对账发生在事后。这个时间差就是对账存在的全部理由——当时没人能保证三本账都写对了,只能事后摊开来核。

大额支付系统(HVPS)逐笔实时全额清算,每笔大额转账完成时就已经决定了各家银行的应收应付义务,银行间的结算也是实时的。小额支付系统(BEPS)用批量轧差——工行今天欠建行 80、建行欠工行 50,不必转两次,轧一下只转净差额 30;把一段时间里所有小额交易的借方和贷方分别汇总,只结算这个净额。

但不管清算和结算是实时的还是批量的,对账这件事都不一定跟它们同步。对账的时效是一个独立的工程选择,取决于你愿意在多大延迟内发现差异、差异出现时系统里堆积了多少未核对数据。

网联清算平台的做法很有代表性:它的交易是实时转接的,但对账按小时切分成多个批次,每个批次独立处理。好处是把一天的量打散到 24 小时里分别核对,避免了日终集中处理的性能洪峰,也让差异能在小时内被发现、而不是等到第二天。

我们这个 demo 选择日终批次。 不是因为「银行都是这样做的」,而是因为日终批次是理解对账口径最清晰的模型:定义一个业务日的截止点,拉取那一天的完整数据,逐笔匹配,对所有差异做出分类和处理。至于这个「日」是 24 小时、1 小时还是一刻钟,口径不变,只改调度频率。


四、简化对账流水线的实现

好了,原理讲完,开始跑代码。整个 demo 的结构如下:

服务清单

服务职责对应第一节哪个角色端口
gyd-c03-settlement模拟结算服务:生成每笔转账的清算结果记录央行(第二幕的记账者 + 权威记录源)18080
gyd-c03-bank(bank-a profile)付款行(工行):维护客户存款账本,消费清算指令记账工行(第一幕)18081
gyd-c03-bank(bank-b profile)收款行(建行):维护客户存款账本,消费清算指令记账建行(第三幕)18082
ReconJob(结算服务内的组件)对账任务:定时拉取三方数据,逐笔匹配,分级处理差异事后核对,不属于三幕随结算服务同进程(Spring Scheduler)

三个业务服务各自有独立的 PostgreSQL 数据库,中间通过 RabbitMQ 传递清算指令。对账 Job 随结算服务一起运行——它直接读本进程内的结算记录表(权威数据源),银行侧的分录则通过 REST API 拉取。

数据库表设计

每家银行的账本结构相同:

-- 账务分录表(append-only,已入账的行永不修改)
CREATE TABLE journal_entry (
    id              BIGSERIAL PRIMARY KEY,
    entry_id        VARCHAR(64) NOT NULL UNIQUE,   -- 业务流水号
    trade_id        VARCHAR(64) NOT NULL,           -- 清算指令 ID(关联结算记录)
    account_id      VARCHAR(32) NOT NULL,
    direction       VARCHAR(6) NOT NULL CHECK (direction IN ('DEBIT', 'CREDIT')),
    amount_cents    BIGINT NOT NULL,                -- 金额,最小货币单位(分)
    trade_time      TIMESTAMPTZ NOT NULL,            -- 业务发生时间
    booked_at       TIMESTAMPTZ NOT NULL,               -- 记账时间(应用层 Instant.now() 写入)
    reversal_of     VARCHAR(64)                      -- 若为冲正分录,指向被它抵销的那条原始分录(第六节细讲)
);

CREATE INDEX idx_entry_trade ON journal_entry(trade_id);
CREATE INDEX idx_entry_booked ON journal_entry(booked_at);

模拟结算服务的数据库:

-- 清算结果表
CREATE TABLE settlement_record (
    id              BIGSERIAL PRIMARY KEY,
    trade_id        VARCHAR(64) NOT NULL UNIQUE,   -- 清算指令 ID
    from_bank       VARCHAR(16) NOT NULL,
    to_bank         VARCHAR(16) NOT NULL,
    amount_cents    BIGINT NOT NULL,
    settled_at      TIMESTAMPTZ NOT NULL,           -- 结算完成时间
    status          VARCHAR(16) NOT NULL                  -- 结算状态,SETTLED = 已结算(应用层默认)
);

几个关键设计决策:

  1. 金额用 BIGINT 存分(最小货币单位),不是浮点。浮点确实不能用于金额——多笔累加后的舍入误差会制造假差异。但 DECIMAL 本身是精确的定点数,银行系统里也广泛使用,这里选 BIGINT 分而不是 DECIMAL,是为了让跨语言(Java ↔ SQL ↔ JSON)序列化时金额精度完全一致,减少一个出错面。这是取舍,不是说 DECIMAL 不能用。
  2. entry_id 是业务身份。这是对账匹配的核心——同一个 trade_id 在付款行和收款行各自产生分录,各自的 entry_id 不同,但都关联到同一个 trade_id。对账靠 trade_id 匹配。
  3. reversal_of 链。冲正分录指向被冲正的原始 entry_id,形成完整的更正链路。对账的闭环验收靠这个字段追溯。

对账 Job 的核心逻辑

对账 Job 定时执行(demo 里用 fixedDelay,方便跑起来立刻看到结果),拉取当日的完整数据:

// settlement/recon/ReconJob.java(节选,完整代码见仓库)
@Scheduled(fixedDelayString = "${gyd.c03.recon.schedule-ms:60000}",
        initialDelayString = "${gyd.c03.recon.initial-delay-ms:30000}")
public void run() {
    LocalDate today = LocalDate.now();
    Instant start = today.atStartOfDay(ZoneOffset.UTC).toInstant();
    Instant end = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();

    // 1. 拉取三方数据(银行通过 REST,结算记录直接查本地库)
    List<SettlementRecord> settlements = settlementRepo.findSettledBetween(start, end);
    List<JournalEntry> bankAEntries = fetchBankEntries(bankAUrl, start, end);
    List<JournalEntry> bankBEntries = fetchBankEntries(bankBUrl, start, end);

    // 2. 以 trade_id 为键建立三方索引
    Map<String, List<JournalEntry>> aByTrade = groupByTrade(bankAEntries);
    Map<String, List<JournalEntry>> bByTrade = groupByTrade(bankBEntries);
    Map<String, SettlementRecord> settlementMap = settlements.stream()
            .collect(Collectors.toMap(SettlementRecord::getTradeId, s -> s));

    Set<String> allTradeIds = new HashSet<>(settlementMap.keySet());
    allTradeIds.addAll(aByTrade.keySet());
    allTradeIds.addAll(bByTrade.keySet());

    // 3. 逐笔匹配:结算侧无记录单独判,其余交给 checkBank 按科目核对
    List<ReconDiff> diffs = new ArrayList<>();
    for (String tradeId : allTradeIds) {
        SettlementRecord settlement = settlementMap.get(tradeId);
        List<JournalEntry> aEntries = aByTrade.getOrDefault(tradeId, List.of());
        List<JournalEntry> bEntries = bByTrade.getOrDefault(tradeId, List.of());

        if (settlement == null) {
            // 银行有分录、结算侧查不到已结算记录 → 待查,不自动修正
            if (!aEntries.isEmpty()) {
                diffs.add(new ReconDiff(tradeId, ReconDiffType.NO_SETTLEMENT,
                        Bank.ICBC.code(), entryIds(aEntries), -1, 0));
            }
            if (!bEntries.isEmpty()) {
                diffs.add(new ReconDiff(tradeId, ReconDiffType.NO_SETTLEMENT,
                        Bank.CCB.code(), entryIds(bEntries), -1, 0));
            }
            continue;
        }

        checkBank(aEntries, settlement, Bank.ICBC, tradeId, diffs);
        checkBank(bEntries, settlement, Bank.CCB, tradeId, diffs);
    }

    // 4. 分级处理差异(processDiff 按类型决策)
    for (ReconDiff diff : diffs) {
        processDiff(diff, settlementMap);
    }

    // 5. 闭环验收
    verifyClosedLoop(diffs, settlementMap, start, end);
}

checkBank 里的匹配规则:

  • 结算侧有没有记录,在主循环里单独判,不在 checkBank 里:findSettledBetween 已经过滤掉非 SETTLED 的记录,所以「银行有分录、结算侧查不到」直接标 NO_SETTLEMENT(待查)。checkBank 只负责单侧的逐科目核对。
  • 本行的角色从结算记录的 fromBank / toBank 推导,不写死「工行一定是借方」——反向交易(建行付给工行)时,两个科目的方向会整体反过来。
  • 按科目逐个核对,不是只看一个方向:本行这笔交易应该在「客户存款」和「存放央行款项」上各有一条净分录,预期方向由 account.directionFor(movement) 推导。
  • 金额以结算记录为准(权威数据源)。某科目上没有分录 → MISSING;多于一条净分录 → DUPLICATE;方向与预期不符 → DIRECTION_MISMATCH;金额不符 → AMOUNT_MISMATCH。
  • 只比「净分录」——已被冲正的原始分录不参与匹配,这一步在银行侧的查询里就完成了(findNetByBookedAtBetween)。
  • 金额不作为唯一匹配依据——如果金额匹配但 trade_id 不匹配,这不是同一笔交易。

匹配结果的分类:

结算记录付款行分录收款行分录分类
有、状态=SETTLED有、金额匹配有、金额匹配✅ 平
有、状态=SETTLED无或金额不匹配有付款行漏记/金额偏差
有、状态=SETTLED有无或金额不匹配收款行漏记/金额偏差
有、状态=SETTLED方向与角色不符(金额对得上)同左方向记反(DIRECTION_MISMATCH)
有、状态=SETTLED重复(同一 trade_id 多笔)—付款行重复入账
有、状态=SETTLED—重复(同一 trade_id 多笔)收款行重复入账
无或状态≠SETTLED——待查:结算结果未知
有、状态=SETTLED在途(trade_time 在截止点附近)—等待:数据可能尚未齐备

最后一行的「在途」指交易已经发起、但三幕还没走完的状态——比如第一幕工行记了账,第三幕的入账通知还在路上。这类记录不是错误,只是数据还没齐,下一批次再核。

这里有一处代码与表格的落差,直说:IN_FLIGHT(在途)作为差异类型和处理分支是存在的——processDiff 对它只发 [P2][在途] 告警、不动账,闭环验收也不把它算进失败——但 checkBank 当前并不会主动产出这一类差异。它实际判定的是 MISSING(漏记)、DUPLICATE(重复入账)、DIRECTION_MISMATCH(方向记反)、AMOUNT_MISMATCH(金额不符)四类,主循环另判 NO_SETTLEMENT(结算侧无记录)。主动判定在途需要一套「交易发起时间 vs 对账截止点」的口径规则,属于对账口径设计,留到讲口径的那一篇。


五、差异不是错误:分级处理,不是统一冲正

对账最容易被误解的地方是:发现了差异,不代表知道了问题。 差异只是一个信号——两侧记录不一致。至于不一致的原因,是漏记、多记、记错、还是数据还没到,对账本身无法区分。

回到开篇那个场景:报表上说建行侧多了一条分录。这条信息只告诉你「两边对不上」,它没告诉你该不该动、动哪一条、动了之后钱会不会退回去。

分级决策模型

对账 Job 对差异的处理不是「有一个差异就冲一笔」。它先判断这个差异的类型,再做对应动作:

这个决策树的每一层都依赖一个前提:结算记录是权威数据源。 结算侧说这笔钱该付、该转,那它就是事实。银行分录如果缺了、多了、金额错了,以此为基准修正。

但反过来,如果结算侧没有记录、或状态不是 SETTLED,对账 Job 不做任何自动修正——它记一条 NO_SETTLEMENT 差异,走 [P1][待查] 告警,等下一个批次重新核对。权威源缺位时,宁可什么都不做也不猜。

自动处理 vs 人工裁决

哪一类差异能自动处理?

  1. 漏记 + 结算已确认:结算侧明确有一笔成功的清算结果,但某家银行没有对应的分录。这是最干净的情况——补记即可。补记的「正确动作」完全由结算记录决定(补哪一侧、补多少金额),没有歧义。

哪些只能人工裁决?

  • 重复入账:同一 trade_id 在同一侧出现了多条分录。trade_id 相同说明这是重复,但无法判断「哪一组是正确的那组、哪一组该冲正」——自动冲正必须回答「冲哪一组」,而这个答案对账本身给不出来。开篇那个场景就落在这里:程序知道建行侧多了一条,但不知道该消哪条。
  • 金额不符:两边金额对不上,而且结算结果看不出金额是对是错(结算结果本身的金额也是参与方上报的)。需要回溯原始支付指令确认。
  • 结算侧无记录:可能是延迟、系统故障,也可能是欺诈交易。不自动处理。

区分「能自动处理」和「只能人工裁决」的边界很清晰:当正确的修正动作能由权威数据源唯一确定时,可以自动处理;当存在多个可能的动作、或正确动作无法从数据里推断时,只能挂起等人裁决。 漏记的补记动作由结算记录唯一确定(补、补多少都明确),所以能自动做;重复入账要冲正哪一组、金额不符到底哪边错,都存在歧义,所以只能人工。

一个易混淆的点:本地账务的冲正和外部资金的退回是两件事。 银行发现一笔跨行转账记重了,冲正的是本地账本里的多余分录——这不会自动把钱从对方银行退回来。那笔钱已经通过央行结算,结算最终性成立后不可撤销;要追回得走独立的退款流程,需要对方银行配合。如果资金还没完成结算,另一条路是止付——让央行拦下这笔尚未落地的资金,不再继续结算。对账 Job 只处理本地账务的一致性,不管外部资金的流向。

// settlement/recon/ReconJob.java(节选):补记漏记分录
private void compensateMissing(String bankUrl, ReconDiff diff, SettlementRecord s) {
    try {
        restTemplate.postForEntity(bankUrl + "/api/bank/compensate",
                Map.of("tradeId", diff.tradeId(),
                        "fromBank", s.getFromBank(),
                        "toBank", s.getToBank(),
                        "amountCents", s.getAmountCents(),
                        "settledAt", s.getSettledAt().toString()),
                String.class);
        log.info("[recon] 补记完成: tradeId={}, 银行={}, 科目={}, 金额=¥{}.{}",
                diff.tradeId(), bankLabel(diff.bankCode()), diff.account().label(),
                s.getAmountCents() / 100, s.getAmountCents() % 100);
    } catch (Exception e) {
        // 补记失败不能中断整轮对账:记下来,下一批次重新核出同一条差异再补
        log.error("[recon] 补记失败: tradeId={}, 银行={}", diff.tradeId(), diff.bankCode(), e);
    }
}

补记动作本身在银行侧执行(BankController.compensate),它复用和消费记账相同的 LedgerService,所以补记的分录同样受借贷平衡和幂等约束——补一笔漏记,也是补「一组」分录(客户存款 + 存放央行款项),而不是孤零零补一条。


六、冲正不是删除,是追加式更正

「发现错了,能不能把那条错的分录删掉?」这是很多工程师的第一反应。答案是:不能。 已入账的分录一旦写入,本 demo 的设计是禁止后续修改或删除的。

这不是保守,而是两道硬约束决定的:

  1. 审计追溯:外部审计师需要看到完整的账务变迁历史。原始记录消失,审计就失去了锚点。
  2. 修复幂等:如果允许删除原始记录然后重新写入,对账 Job 重复执行时就会把已修复的数据再次标记为差异,进入死循环。

纸质账本上也是同一个道理:用圆珠笔记的账,写死的字划不掉,记错了只能在下面补记一笔反向的,两笔抵消,但两条笔迹都留在本子上。冲正的做法就是这一条:写一组金额相等、方向相反的新分录,来抵销原分录的影响。 而且关键的是——冲正不是「逐条反」某一条 posting,而是按 trade_id 找到这笔交易的整组分录(客户存款 + 存放央行款项两条),一起反向。

为什么必须整组反向?因为借贷平衡是对「一组分录」的约束。只反其中一条,这一组就不平了,postEntries 的校验会直接把它拒掉。

// bank/LedgerService.java(节选)
@Transactional
public int reverseByTradeId(String tradeId) {
    return reverseEntries(repo.findOriginalByTradeId(tradeId));
}

/** 冲正的实际动作:为一组原始分录生成等额反向分录并写入 */
private int reverseEntries(List<JournalEntry> originals) {
    if (originals.isEmpty()) {
        return 0;
    }

    List<JournalEntry> reversals = new ArrayList<>();
    for (JournalEntry original : originals) {
        String reversalEntryId = "REV-" + original.getEntryId();
        // 已有冲正记录则幂等跳过:重复执行冲正不会写出第二组反向分录
        if (repo.findByEntryId(reversalEntryId) != null) {
            continue;
        }
        Direction revDir = original.getDirection().opposite();
        reversals.add(new JournalEntry(reversalEntryId, original.getTradeId(),
            original.getAccountId(), revDir, original.getAmountCents(),
            original.getTradeTime(), original.getEntryId()));
    }

    // 整组反向分录同样走 postEntries,受借贷平衡校验
    if (!reversals.isEmpty()) postEntries(reversals);
    return reversals.size();
}

冲正的入口有两个:整笔撤销走 reverseByTradeId,人工裁决「只冲多余那组」走 reverseByEntryIds——后者必须由人指定冲哪几条,因为重复入账时一个 trade_id 下有两组分录,整组冲正会把合法的那组也冲掉。两个入口共用 reverseEntries,所以幂等和借贷平衡的行为完全一致。

注意三个要点:

  1. 冲正必须保持借贷平衡。 原分录是借记客户存款 ¥10,000、贷记存放央行款项 ¥10,000,冲正分录是贷记客户存款 ¥10,000(反向)、借记存放央行款项 ¥10,000(反向)。不是孤零零写一条 posting。
  2. reversal_of 指向原始分录。 对账闭环验收时,通过这个链条确认「差异 → 原始分录 → 冲正分录 → 重新核对平了」。
  3. 冲正只更正本地账务。 前面说了,外部支付已经发生了,冲正不改外部事实。

原始分录和冲正分录并排看(以工行付款侧为例,两条原始分录共享同一个 trade_id,但各有独立的 entry_id;trade_id 是 TRD- 加 8 位随机大写十六进制,entry_id 的末尾 CUST / PBOC 是科目标记):

分录类型分录 ID方向科目金额备注
原始E-TRD-2B14449D-ICBC-CUST借客户存款¥10,000正常记账
原始E-TRD-2B14449D-ICBC-PBOC贷存放央行款项¥10,000正常记账
冲正REV-E-TRD-2B14449D-ICBC-CUST贷客户存款¥10,000抵销上面那笔借方
冲正REV-E-TRD-2B14449D-ICBC-PBOC借存放央行款项¥10,000抵销上面那笔贷方

四条分录合在一起,净借贷合计仍然是 0。账平了,原始记录还在,审计师可以看到「发生过什么、怎么修正的」。


七、为什么对账不能替代幂等

对账是事后发现,幂等是事前防护。很多人觉得「有了对账就不需要幂等」,这个想法有一个根本性的问题:对账只能发现「结果不一致」,它不知道「不一致的原因」。

上一篇讲的消费端幂等,防的是「同一条消息被处理两次」;这里的对账,查的是「处理完之后三本账对不对得上」。两者在链路上前后相接:幂等让第一节的第三个断点(重复投递)尽量不发生,对账在它还是发生了之后把差异捞出来。

有业务身份,对账能做的远比「人工裁决」多

这个问题的关键不在于幂等键本身,而在于业务身份是否可追溯。 如果每一笔交易都有一个唯一的 trade_id,并且这个 ID 贯穿了从结算指令、银行分录、到对账匹配的整条链路,那么:

  • 对账可以准确判断两笔相同金额的记录是不是同一笔交易的重复执行(因为 trade_id 相同)。
  • 对账可以区分「两笔金额一样的合法交易」和「同一笔交易记了两次」。

反过来,如果系统里根本没有 trade_id 这样的业务身份,记账时只写了金额、方向和时间戳,那对账面对两条 ¥10,000 的借记分录时,完全无法判断这是一笔重复记账还是两笔独立的合法转账。

但即便有了 trade_id,重复入账的自动修复也不是理所当然的。trade_id 相同只能说明「这是同一笔交易产生了多余的分录」,却不能告诉你「哪一组是正确的那组、哪一组该冲正」。这也是为什么 demo 里对重复入账(DUPLICATE)的处理是标记人工裁决,而不是自动冲正——自动冲正必须回答「冲哪一组」,而这个答案对账本身给不出来。

幂等键的三个必要条件

幂等键(Idempotency Key)只是第一步。要让对账能做到自动修复,还需要三个条件同时满足:

  1. 消费端实际执行唯一性控制。 不是生产端带了一个键就叫幂等——消费端必须在写入前检查这个键是否已经处理过。如果消费端收到带相同键的消息直接再次写入,键就是摆设。
  2. 去重记录与账务写入在同一个事务中。 先检查去重再记账,中间断开事务,竞态条件下仍然可能重复写入。
  3. 同一键携带不同金额时能拒绝冲突。 如果一个键第二次出现时带的金额和第一次不同,系统应该拒绝而不是覆盖。
// bank/LedgerService.java(节选):消费端的幂等写入
@Transactional
public boolean postIfNew(List<JournalEntry> entries) {
    String tradeId = entries.getFirst().getTradeId();
    // 幂等检查:这个 trade_id 已经记过账了?
    if (repo.existsByTradeId(tradeId)) {
        return false; // 幂等跳过,不重复写入
    }
    // 记账:去重检查 + 分录写入在同一事务中完成
    postEntries(entries);
    return true;
}

消费端 BankSettlementConsumer.onSettlement 调用它,收到重复消息时返回 false 直接跳过——这是正常的幂等跳过,不是错误,所以不抛异常、照常 ack。

核心结论:决定对账自动修复能力的,不是有没有幂等键,而是业务身份是否可追溯、以及能否据此确定「正确的动作」。 有可追溯的业务身份,对账可以精准定位差异、判断漏记并补记;但没有业务身份、或虽有身份却无法判断哪边该修正(比如重复入账该冲哪一组),对账就只能标记差异,等着人来看。幂等键是让「不产生重复」成为可能,而对账负责在「已经产生差异」时给出能追溯的处理。


八、从银行对账到分布式系统设计

对账这件事,拆开来看,其实是一个通用分布式系统的设计骨架。把前面几节里出现的核心设计模式抽象出来,它们都对应着分布式系统中反复出现的工程手段。

对账系统中的设计解决的问题分布式系统通用表达
借贷平衡约束保证单方账本不自相矛盾可校验的内部一致性:任何系统都应该有办法自证「状态没乱」,但不等于状态正确
业务身份(trade_id)让对账能准确匹配同一笔交易全局唯一请求标识:跨服务追踪、去重、匹配的基础
追加式更正(冲正)修正错误而不破坏审计链不可变日志 + 补偿操作:Saga 的补偿动作、Event Sourcing 的逆事件
第三方结算数据提供独立的事实来源外部权威数据源:跨系统对账不能只靠两方互对
分级处理决策树不同差异不同动作错误分类 + 分级恢复策略:在途、补记、冲正、待查各有对应的处理方式
对账 Job 的幂等设计重复执行不对数据造成二次伤害可重入任务:修复操作的结果是幂等的

这张表里的每一行,回到第一节的三幕链条上都有具体落点:借贷平衡管住一幕之内的自洽,trade_id 把三幕串成同一笔交易,冲正让改错不毁历史,结算记录提供跨幕的裁判,分级决策决定每种断裂怎么收场。

对账最大的工程价值:分离「发现」和「修复」

很多人把对账理解成「一个定时任务,跑完报一堆差异」。但它的工程价值不在「发现差异」——任何简单的比对脚本都能做到。对账的真正价值在于:

对账确定了「差多少」,分级处理确定了「怎么补回去」。 这两个问题被拆成两个独立的步骤,各自有独立的执行逻辑、独立的幂等保证。

这个分离是金融系统能在出错后继续运行的基石。如果发现差异的同时就「自动修正」,那修正错了怎么办?修正再修正?系统会陷入不可追溯的循环。但把它们分开——对账先给出一个不可变的事实陈述(「2026 年 9 月 14 日的日终核对显示,trade_id=TRD-2B14449D 在工行的客户存款科目上缺失分录」),然后分级处理再给出可追溯的修正动作(「根据结算记录补记分录 entry_id=COMP-TRD-2B14449D-ICBC-CUST」)——每一步的结果都可以被验证、被追溯、被重新评估。

闭环验收:没有重新核对就不算修好

修复动作完成后,不能以「生成了补记分录」或「写入了冲正记录」作为成功标准。真正的成功标准是:对当前这批数据重新跑一遍对账逻辑,差异消失了。

// settlement/recon/ReconJob.java(节选):闭环验收
private void verifyClosedLoop(List<ReconDiff> diffs, Map<String, SettlementRecord> settlementMap,
                              Instant start, Instant end) {
    // 只有「本轮自动处理过」的差异参与闭环。挂起等人工的、等下一批次的不算失败——
    // 它们本来就不该在这一轮被修好,算进失败会让「闭环失败」失去信号意义。
    List<ReconDiff> autoFixed = diffs.stream()
            .filter(d -> d.type() == ReconDiffType.MISSING
                    && settlementMap.containsKey(d.tradeId()))
            .toList();
    if (autoFixed.isEmpty()) {
        log.info("[recon] 本轮无自动处理的差异,跳过闭环验收");
        return;
    }

    // 重新拉取净分录(已被冲正的 journal 不再参与),复用 checkBank 重新核对
    Map<String, List<JournalEntry>> aRecheck = groupByTrade(fetchBankEntries(bankAUrl, start, end));
    Map<String, List<JournalEntry>> bRecheck = groupByTrade(fetchBankEntries(bankBUrl, start, end));

    List<String> remaining = new ArrayList<>();
    for (ReconDiff diff : autoFixed) {
        SettlementRecord s = settlementMap.get(diff.tradeId());
        Bank bank = Bank.fromCode(diff.bankCode());
        List<JournalEntry> now = (bank == Bank.ICBC ? aRecheck : bRecheck)
                .getOrDefault(diff.tradeId(), List.of());

        List<ReconDiff> recheckDiffs = new ArrayList<>();
        boolean ok = checkBank(now, s, bank, diff.tradeId(), recheckDiffs);
        if (!ok) {
            remaining.add(diff.tradeId() + "/" + diff.account().label());
        }
    }

    if (remaining.isEmpty()) {
        log.info("[recon] 闭环通过: {} 条自动处理的差异已重新核对为平", autoFixed.size());
    } else {
        log.error("[recon] 闭环失败: {} 条差异补记后仍不平: {}", remaining.size(), remaining);
    }
}

这里有一个容易被忽略的细节:闭环验收不能用「数一下分录条数对不对」这种简化判断。因为冲正是追加式的,原始分录不会删除,冲正后「原始分录条数」不会变——只有按「净分录」(原始分录中尚未被冲正的部分)重新跑一遍完整的匹配逻辑,才能确认差异真正消失。

闭环验收 + 不可变差异记录,构成了对账系统最底层的安全网:对账能证明自己修好了,而不是「相信」自己修好了。


参考资料

  1. 中国人民银行, 小额支付系统业务处理手续(试行), 央行官网公开文档. https://www.pbc.gov.cn/eportal/fileDir/history_file/files/att_19041_2.pdf —— 冲正、止付与退回流程的官方定义
  2. 金融时报, 网联平台荣获 2019 年度银行科技发展奖特等奖, 2020-09-25. https://www.financialnews.com.cn/if/hydt/202009/t20200925_201999.html —— 网联「三地六中心」架构与小时级多批次对账设计
  3. ACCA, Suspense accounts and error correction, Foundation Level Study Resources. https://www.accaglobal.com/gb/en/student/exam-support-resources/foundation-level-study-resources/ffa/ffa-technical-articles/suspense-accounts-error-correction.html —— 试算平衡无法发现的六类错账
  4. Formance, Transaction Reconciliation at Scale: An Engineering Guide. https://www.formance.com/blog/engineering/transaction-reconciliation-at-scale-for-eng —— 对账前的四项账本不变约束:复式记账、幂等、不可变性、双时态

演示代码:完整实现在 GYD 仓库 c03-reconciliation,PostgreSQL 编排在仓库级 infra/。按各目录 README 的命令跑,不要复制这篇文章里零散的代码块。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。