土法炼钢兴趣小组的算法知识备份

【金融科技工程】11 清算 vs 结算 vs 资金归集:T+0/T+1、Netting、PvP/DvP

文章导航

分类入口
architecturefintech
标签入口
#clearing#settlement#netting#rtgs#dns#pvp#dvp#payout#cash-pooling#acquirer#cnaps-ibps

目录

引子

写支付系统写到第 11 篇,我越来越发现一个现象:每个工程师都说”我在做清结算”,但没有两个工程师口中的”清结算”是同一件事

这五件事在英文里分别是 Clearing / Netting / Reconciliation + Payout / DvP Clearing / Cash Pooling,在中文里全部挤在”清结算”这三个字里。再加上”结算”这个词,在口语里既可以指”资金真实打到账上”(真正的 Settlement),也可以指”记一笔应收应付账”(其实是会计确认,Accounting),混乱程度再上一个台阶。

本文的目标,就是把这三个高度混淆的概念——清算(Clearing)、结算(Settlement)、资金归集(Cash Pooling)——做一次工程视角的严格区分,并落到真实系统里:

  1. 三者在金融基础设施中的边界在哪里;
  2. RTGS / DNS / 混合 DNS 三种清算范式的取舍;
  3. PvP、DvP 如何用”同时性”消除对手方风险;
  4. 商户扫码付款到 T+1 到账的完整资金生命周期;
  5. 清分引擎如何实现:“一笔钱进来,拆成商户本金 + 平台抽成 + 服务商返佣 + 税费”;
  6. 数据模型:从 transactionspayout_orders 的四层流转;
  7. 实时清算(网联 IBPS、UPI、PIX、FedNow)在打破 T+1 批处理时,工程上要重做哪些地方。

读者画像:支付平台、清分平台、聚合支付、交易所、财资系统(TMS)、跨境结算等方向的后端工程师与架构师。前置知识建议读过本系列 《支付系统全景》《复式记账工程化》


一、三个词的工程定义

1.1 先给三个精确定义

中文 英文 一句话定义 产出物 是否动钱
清算 Clearing 确认双方债权债务,计算应收应付净额 清算指令 / 轧差结果
结算 Settlement 真实的资金划拨,使账户余额发生变化 账户借贷记 / 终局性
资金归集 Cash Pooling 集团内部把分散账户的资金集中到主账户统一管理 归集指令 / 主账户余额 是(内部)

三者的关系:Clearing 是”算账”,Settlement 是”付钱”,Cash Pooling 是”搬家”。

举一个商户扫码场景的例子:

  1. 消费者付 100 元给商户,微信支付先从消费者零钱扣 100,记到微信商户备付金账户(这是”收款”);
  2. 清算(Clearing):次日 T+1,微信清分引擎算出”商户 A 本日应收 100 元 − 手续费 0.6 元 = 净额 99.4 元”;
  3. 结算(Settlement):微信通过备付金托管行向商户对公账户发起一笔网银跨行转账,99.4 元真实到账;
  4. 资金归集(Cash Pooling):商户公司财务把旗下 500 个门店对公账户余额,每天 23 点归集到总部主账户做统一现金管理。

步骤 2 只是算,不碰钱;步骤 3 才是真的钱动;步骤 4 与前面完全解耦,是企业集团内部的现金管理动作。

1.2 为什么容易混

历史原因。在纸票时代,清算所(Clearing House)既做轧差计算又做资金划拨,所以两件事放在一个机构、一个批次里做,很多老系统把这两步合在一张表、一个流程里。直到电子化、RTGS 出现以后,两者才需要被明确分开:

这篇文章大部分篇幅都在讲 DNS 模式,因为它决定了”为什么商户收款是 T+1 到账”。

1.3 术语地图

概念 英文 关键区分
清算 Clearing 算净额,不动钱
结算 Settlement 真实划拨,终局性
净额结算 Net Settlement 轧差后只结一次差额
总额结算 Gross Settlement 逐笔结算,不轧差
实时结算 Real-Time Settlement 指令到达即结算
延迟结算 Deferred Settlement 指令先清算,统一时点结算
结算终局性 Settlement Finality 结算不可撤销、不可逆转
头寸 Position 某参与方在某清算机构的净应收/应付
备付金 Reserve / Float 支付机构代客户保管的待结算资金
轧差 Netting 双边/多边抵消相反方向的债权债务

二、Gross vs Net:两种清算范式

2.0 最小例子:为什么要轧差

考虑三家银行 A、B、C 之间一日内的跨行支付(单位:亿元):

A → B  30       B → A  25       C → A  10
A → C  15       B → C  20       C → B   5

不轧差(Gross):总共 6 条指令,总划转 105 亿,每家都要备足流动性。

双边轧差(Bilateral Netting)

多边轧差(Multilateral Netting)

最终只需要央行账户做 “A→清算所 10、B→清算所 10、清算所→C 20” 三笔记账,流动性占用从 105 亿降到 20 亿。这就是 DNS 的核心价值。

2.1 RTGS:实时全额结算

RTGS(Real-Time Gross Settlement):每笔指令独立处理,实时、全额、终局性结算。全球主流大额支付系统都采用 RTGS:

系统 国家/地区 清算范式 单笔门槛 运营时间
CNAPS 大额(HVPS) 中国 RTGS 无硬门槛,默认大额 工作日 8:30–17:00
Fedwire 美国 RTGS 无门槛 接近 22 小时
TARGET2 / T2 欧元区 RTGS 无门槛 24×5
CHAPS 英国 RTGS 无门槛 工作日 6:00–18:00
CIPS 一期 中国跨境人民币 混合(RTGS + DNS) 无门槛 24×5

特点:

2.2 DNS:延迟净额结算

DNS(Deferred Net Settlement):白天累积指令,按约定时点做多边轧差,只结一次净额。小额零售支付系统都采用 DNS:

系统 国家/地区 轧差频率 典型业务
CNAPS 小额(BEPS) 中国 每 30 分钟一次 小额定期代收付
网联 IBPS 中国 准实时逐笔 + 日终净额 支付宝/微信跨行
ACH 美国 每日 3 次 工资、账单
SEPA SCT 欧元区 每日多次 欧元零售转账
FPS(Faster Payments) 英国 每日 3 次 零售转账

特点:

2.3 混合:Delayed Net Settlement 的现代形态

RTGS 吞吐低、DNS 有风险,所以现代系统走向”高频小额多边轧差 + 接近终局性”的混合路线:

工程上记住一条:轧差频率越高 → 越像 RTGS;越低 → 越省流动性但风险越大。这是个光谱,不是二元选择。


三、PvP 与 DvP:同时性消除对手方风险

Clearing 解决”算多少钱”,Settlement 解决”把钱打过去”,但还有一类经典风险:一方已付、另一方未交付(赫斯塔特风险,Herstatt Risk)。解决方式是让两笔资产”同时”易手。

3.1 PvP(Payment versus Payment):外汇交易

典型代表:CLS(Continuous Linked Settlement),全球最主要的外汇 PvP 机构。流程:

  1. 参与行 A(卖 USD 买 EUR)、B(卖 EUR 买 USD)分别把 USD/EUR 存入 CLS 在美联储/ECB 的账户;
  2. CLS 同时借记 A 的 USD 子账户、贷记 B 的 USD 子账户;同时借记 B 的 EUR、贷记 A 的 EUR;
  3. 如果任何一腿失败,整笔交易回滚。

效果:不存在”A 付了美元、B 没付欧元”的中间状态。CLS 上线前,赫斯塔特风险是外汇市场的核心系统性风险;上线后,这个风险被极大消除。

3.2 DvP(Delivery versus Payment):证券交易

BIS 定义了三种 DvP 模型:

模型 证券结算 资金结算 代表
DvP-1 总额逐笔 总额逐笔 Euroclear、DTCC CNS(部分)
DvP-2 总额逐笔 净额批处理 历史上部分欧洲市场
DvP-3 净额批处理 净额批处理 中证登 A 股、DTCC CNS

中国 A 股 T+1 结算是典型的 DvP-3:日终由中证登做多边净额轧差,证券和资金同时过户。这篇不展开,《证券登记结算》 会专门讲。

3.3 工程启示

PvP/DvP 对支付工程师的启示不是让你去实现一个 CLS,而是:当你设计任何”两方换资产”的场景时,要想到如何保证同时性。对应到 SaaS 场景:


四、清算周期:T+N 的来龙去脉

4.1 T+N 的定义

T 指交易日(Trade Date),T+N 指交易日后第 N 个工作日资金到账。T+0 当日到账,T+1 次工作日到账,T+2 两个工作日后到账。注意两点:

4.2 T+N 越来越短

历史趋势:

市场 2000 年前 2010 年代 2024+
美股 T+5→T+3 T+3→T+2(2017) T+1(2024-05-28)
欧股 T+3 T+2(2014) T+1(2027 规划)
A 股(股票) T+1 T+1 T+1(证券 T+1、资金 T+1)
商户收单 T+3 / 周结 T+1 为主 T+0 / D+0 可选
零售跨行转账 T+1 批处理 实时(网联/FPS) 实时

推动力:资本效率、降低对手方风险、技术进步。工程成本:清算窗口从”一天”压缩到”几分钟”,所有原本跑在晚上的批任务都要重写成准实时流。

4.3 商户结算的 T+N 生态

周期 典型场景
D+0 / T+0 秒到服务(商户付手续费上浮),扫码收银机实时秒到零钱
T+1 主流商户结算,周一到周五工作日
T+7 平台分润(防退款、防争议)
T+30 信用卡月账单、部分 SaaS 订阅账单
T+2 跨境 SWIFT 跨境代理行、Adyen 跨币种结算
T+N 逐步付 高风险商户(游戏、直播)预留冻结期

T+N 的本质是风险冻结期:留出时间应对退款、拒付(chargeback)、反欺诈复核;周期越短,平台风险越高,手续费越贵。


五、商户结算的资金流

5.1 备付金账户与二清红线

先看一张典型聚合支付的资金路径:

flowchart LR
    U[消费者账户] -->|1. 支付| C[收单行/支付机构备付金账户]
    C -->|2. 日终清算| N[清算机构<br>网联/CUP]
    N -->|3. 跨行净额结算| B[备付金托管行]
    B -->|4. T+1 结算| M[商户对公账户]
    AGG[聚合支付服务商] -.只做技术对接.-> C
    style AGG stroke-dasharray: 5 5

关键词:

5.2 一笔扫码的完整生命周期

sequenceDiagram
    autonumber
    participant C as 消费者
    participant WX as 微信支付
    participant 备付 as 备付金托管行
    participant 清算 as 网联 IBPS
    participant 商行 as 商户开户行
    participant M as 商户
    Note over C,M: T 日 14:30 消费者扫码付 100 元
    C->>WX: 扫码授权 100 元
    WX->>WX: 风控、扣消费者零钱
    WX->>备付: 记入商户 A 可结算余额<br>(应收 100,手续费 0.6)
    WX-->>C: 支付成功
    WX-->>M: 商户通知:收款 100 元(可结算 99.4)
    Note over WX,清算: T 日 23:00 日终清分
    WX->>WX: 清分引擎按商户汇总<br>生成结算单
    WX->>清算: T+1 08:00 发起跨行结算指令
    Note over 清算,商行: T+1 上午 多边轧差 + 头寸结算
    清算->>备付: 借记 微信备付金
    清算->>商行: 贷记 商户开户行
    商行->>M: 贷记 商户对公账户 99.4 元
    M-->>M: 收到银行到账短信

对工程师的启示:“商户 T+1 到账”不是一个单体服务能完成的事,它横跨支付机构、清算机构、两家商业银行,每一步都有自己的 SLA 和失败模式。本系列 《对账系统工程》 会专门讲如何对这条链路做端到端对账。

5.3 失败场景

失败点 现象 处理
清分引擎漏算一笔 商户少收 次日补算,发起补结算单
商户银行账户异常 银行退汇 挂”结算待处理”,联系商户更新账户后重发
备付金不足 极少见,但出现过 暂停结算,申请流动性支持
节假日未考虑 T+1 实际 T+3 在结算日历里标注工作日
退款/拒付 已结算又撤回 从下一期结算中扣回或独立发起退款

六、清分引擎的工程实现

前面说清算是”算谁欠谁多少”,这里我们把它拆成实际的代码和数据结构。

6.1 输入与输出

输入:
  - 交易流水表(transactions):当日全量成功交易
  - 清分规则:按商户、按渠道、按 ISV 层级配置
  - 费率模板:固定 + 百分比 + 阶梯
  - 日历:工作日、冻结日
  - 退款、争议:需要从可结算中扣减

输出:
  - 清算记录(clearing_records):每个参与方每日净额
  - 结算指令(settlement_instructions):待发送给支付通道的打款包
  - 结算单(statements):给每个商户/ISV 的对账明细
  - 差错报告(exceptions):待人工处理

6.2 清分维度:一笔钱怎么拆

一笔 100 元交易,真实拆分可能是:

商户 A 实收本金:     96.00
  - 平台抽成:         3.00 → 平台主体账户
  - ISV 返佣:         0.50 → ISV 分润账户
  - 收单手续费:       0.60 → 支付机构
  - 渠道网络费:       0.06 → 卡组织/清算机构
  - 代扣代缴税费:     0.00 → 税务代收(跨境 VAT/GST 场景)
检查:96.00 + 3.00 + 0.50 + 0.60 + 0.06 = 100.16 ≠ 100 × ? 

实际工程里拆分规则一定要满足守恒(sum = 原始金额),任何四舍五入的余数必须明确归属(通常归平台或商户,配置决定)。本系列 《钱的建模》 详细讨论过 minor unit 与舍入。

6.3 规则 DSL

平台要支持产品同学/BD 自己配置清分规则,工程上通常选其中一种:

方案 A:规则引擎(Drools、Aviator、LiteFlow)。优点灵活,缺点调试难、性能有坑。 方案 B:声明式 DSL + JSON/YAML 配置。推荐给 80% 的中小平台。

# 清分规则示例
rule_id: "merchant_A_default_2026"
merchant_id: "M000001"
effective: "2026-01-01"
splits:
  - name: platform_fee
    recipient: "ACC_PLATFORM"
    formula: "amount * 0.006"          # 千六
    rounding: half_up
    min: 0.01
  - name: isv_rebate
    recipient: "ACC_ISV_{{isv_id}}"
    formula: "amount * 0.001"          # ISV 千一返佣
    condition: "isv_id != null"
  - name: merchant_net
    recipient: "ACC_MERCHANT_{{merchant_id}}"
    formula: "amount - platform_fee - isv_rebate"
    is_primary: true                    # 主商户本金(承担舍入余数)

方案 C:SQL/表达式列。所有规则落成一列表达式,清分时用表达式引擎批量求值——对 BI 和财务最友好。

6.4 批处理还是流式

T+1 场景下的典型批处理:

23:00  冻结当日交易流水快照(从 MySQL/TiDB 抽到数仓)
23:10  按商户分片执行清分规则(Spark/Flink 批)
23:40  生成 clearing_records
23:50  按结算账户汇总 → settlement_instructions
00:00  生成对账单(发邮件/推送)
08:00  下发打款指令到支付通道(银企直连/代付 API)

实时清算(T+0)下改流式:每笔交易成功后立即触发清分(单笔执行一次规则),直接入 clearing_records,定时(分钟级)合并推给支付通道。核心是把清分规则做成纯函数 + 幂等,可批可流。

6.5 差错处理

漏算、重算是清分引擎的日常:

原则:永远不要在数据库里 DELETE 清算/结算记录,全部走”作废+反向”。这是金融可追溯性的底线。


七、数据模型

四层流转:交易 → 清算 → 结算指令 → 打款单

-- 1. 原始交易流水(支付系统主表)
CREATE TABLE transactions (
    txn_id          VARCHAR(32) PRIMARY KEY,
    merchant_id     VARCHAR(32) NOT NULL,
    amount          DECIMAL(20,4) NOT NULL,
    currency        CHAR(3) NOT NULL,
    status          VARCHAR(16) NOT NULL,   -- SUCCESS/REFUNDED/...
    channel         VARCHAR(32),             -- WECHAT/ALIPAY/UNIONPAY
    paid_at         TIMESTAMP,
    clearing_status VARCHAR(16) DEFAULT 'PENDING', -- PENDING/CLEARED/VOIDED
    INDEX idx_merchant_paid (merchant_id, paid_at)
);

-- 2. 清算记录(算完的净额,每个参与方一行)
CREATE TABLE clearing_records (
    clearing_id     VARCHAR(32) PRIMARY KEY,
    batch_id        VARCHAR(32) NOT NULL,    -- 清算批次,如 "20260422-T1"
    txn_id          VARCHAR(32) NOT NULL,    -- 对应交易
    recipient_id    VARCHAR(32) NOT NULL,    -- 收款方(商户/平台/ISV)
    recipient_type  VARCHAR(16) NOT NULL,    -- MERCHANT/PLATFORM/ISV/FEE
    amount          DECIMAL(20,4) NOT NULL,  -- 可为负(退款)
    currency        CHAR(3) NOT NULL,
    rule_id         VARCHAR(32),
    cleared_at      TIMESTAMP NOT NULL,
    status          VARCHAR(16) DEFAULT 'ACTIVE', -- ACTIVE/VOIDED
    INDEX idx_batch_recipient (batch_id, recipient_id),
    UNIQUE KEY uk_txn_recipient_type (txn_id, recipient_type, recipient_id)
);

-- 3. 结算指令(按收款方 + 收款账户汇总,一行=一笔打款)
CREATE TABLE settlement_instructions (
    instruction_id   VARCHAR(32) PRIMARY KEY,
    batch_id         VARCHAR(32) NOT NULL,
    recipient_id     VARCHAR(32) NOT NULL,
    recipient_account VARCHAR(64) NOT NULL,  -- 收款银行账户
    recipient_bank   VARCHAR(32) NOT NULL,
    amount           DECIMAL(20,4) NOT NULL, -- 本批净额
    currency         CHAR(3) NOT NULL,
    scheduled_at     TIMESTAMP NOT NULL,     -- 计划发送时间
    status           VARCHAR(16) DEFAULT 'PENDING', -- PENDING/SENT/SUCCESS/FAILED
    INDEX idx_batch (batch_id),
    INDEX idx_scheduled (scheduled_at, status)
);

-- 4. 打款单(真实调用通道后的结果,带通道流水号)
CREATE TABLE payout_orders (
    payout_id        VARCHAR(32) PRIMARY KEY,
    instruction_id   VARCHAR(32) NOT NULL,
    channel          VARCHAR(32) NOT NULL,   -- 银企直连通道
    channel_txn_id   VARCHAR(64),            -- 银行返回流水
    amount           DECIMAL(20,4) NOT NULL,
    status           VARCHAR(16) NOT NULL,   -- INIT/SUBMITTED/SUCCESS/FAILED
    submitted_at     TIMESTAMP,
    confirmed_at     TIMESTAMP,
    error_code       VARCHAR(32),
    error_msg        VARCHAR(255),
    FOREIGN KEY (instruction_id) REFERENCES settlement_instructions(instruction_id),
    INDEX idx_status (status, submitted_at)
);

关键约束:

  1. clearing_records(txn_id, recipient_type, recipient_id) 唯一:一笔交易对某方只能清算一次,强幂等;
  2. settlement_instructionsclearing_records(batch_id, recipient_id)SUM,可以从下游反推上游;
  3. payout_orderssettlement_instructions 是 1 对多(通道失败重试会产生多条 payout),最终只有一条 SUCCESS 的 payout 生效;
  4. 跨四层的核心审计查询:给定一笔 txn,能追出对应的所有 clearing、instruction、payout,反之亦然。

7.1 与账务系统的关系

这里要特别明确一个常见误解:清算 ≠ 记账,结算 ≠ 记账

动作 谁做 存在哪
算净额 清分引擎 clearing_records
真实划拨 支付通道(银行/网联) 外部银行账户变化
记账 账务系统 journal_entries / balances

清算和结算是业务流程,账务是财务视角的记录。完整的工程做法是:

前后端分离的意思是:清分引擎不直接动账务表,而是把事件发到账务系统(或输出对账文件),由账务系统按自己的节奏入账。这样规则变更、差错补算不会污染总账。细节见 《复式记账工程化》


八、资金归集:企业财资的第三件事

8.1 定义

资金归集(Cash Pooling):集团把下属子公司、门店、项目的分散账户余额,定期集中到总部主账户的动作。对立面是分散账户各管各的现金流。

两种主要模式:

模式 中文 机制 税务影响
Zero Balance Account (ZBA) 零余额账户 每日日终把子账户余额扫到主账户,子账户归零 内部借贷关系,需签协议
Notional Pooling 名义归集 账户余额物理上不动,银行虚拟汇总计算利息 不涉及资金流动,无利息税

跨境归集还要叠加 CTA(Cross-border Two-way RMB)、FDI/ODI 限额、外管局备案等,复杂度陡增。

8.2 和清结算的边界

为什么把资金归集放在这篇?因为工程语境里有三种”你以为是同一个”的产品,其实都叫 Cash Pooling 系统:

  1. 企业 TMS(Treasury Management System)里的归集:服务集团财务,接银企直连拉多账户余额、下发归集指令;
  2. 支付平台商户结算里的”归集账户”:把多个门店、多个收单通道的应结余额先归集到商户的一个主账户,再统一打款——这是支付机构提供的功能;
  3. 虚拟账户(Virtual Account)产品:银行提供给企业的”一实多虚”账户,虚拟账户收款、物理主账户结算,本质是内建 Cash Pooling。

这三个系统在底层都做同一件事:把离散的账户余额聚合到一个可控主账户。它和 Clearing/Settlement 的区别:Clearing 处理外部对手方的债权债务,Cash Pooling 只处理企业内部账户之间的资金搬家。

8.3 工程要点


九、实时化:T+1 批处理正在消亡

9.1 全球实时支付基建

过去五年,几乎每个主要经济体都上线了自己的 实时零售支付系统(Instant Payment System, IPS)

系统 地区 上线 典型到账时间 运营时段
网联 IBPS 中国 2017 秒级 24×7
UPI 印度 2016 秒级 24×7
PIX 巴西 2020 秒级 24×7
FedNow 美国 2023 秒级 24×7
FPS / RCS 英国 2008 / 2027 秒级 24×7
TIPS 欧元区 2018 秒级 24×7
PromptPay 泰国 2017 秒级 24×7
FAST 新加坡 2014 秒级 24×7

这些系统对清结算工程的冲击:

  1. 清算频率从日终变成逐笔:原来 T+1 批处理,现在每笔都要算净额/结算;
  2. 24×7 运营:传统”日切”(day-cutover)概念被打破——系统没有”停业时间”;
  3. 流动性管理 24 小时化:央行对参与方的流动性设计必须支持周末/节假日;
  4. 终局性要求秒级:不能再用”白天先算晚上结”的 DNS,而是要接近 RTGS 的实时结算。

9.2 工程侧的改造清单

如果你在做一个老支付系统的 T+0 改造,这些是必改项:

参考本系列 《金融级可靠性》 24×7 相关章节。


九·补、清分引擎的工程实现补完

9.补.1 清分核心伪代码

下面是一个可以直接照抄改写的清分主循环(Python 伪代码),覆盖守恒校验、舍入归属、幂等、作废。

from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
from typing import List

Q4 = Decimal("0.0001")

@dataclass
class Split:
    name: str
    recipient_id: str
    recipient_type: str
    amount: Decimal
    rule_id: str
    is_primary: bool = False

def clear_one_txn(txn, rules) -> List[Split]:
    """对单笔交易应用规则集,返回拆分结果。保证 sum(splits) == txn.amount。"""
    splits: List[Split] = []
    used = Decimal("0")

    # 1. 先算所有非 primary 拆分
    for r in rules:
        if r.is_primary:
            continue
        if not r.match(txn):
            continue
        raw = r.formula(txn)                      # 例如 txn.amount * 0.006
        amt = raw.quantize(Q4, rounding=ROUND_HALF_UP)
        if r.min is not None and amt < r.min:
            amt = r.min
        splits.append(Split(r.name, r.recipient(txn), r.recipient_type,
                            amt, r.rule_id, is_primary=False))
        used += amt

    # 2. 主商户本金 = 原金额 - 其它拆分(承担所有舍入余数)
    primary_rule = next(r for r in rules if r.is_primary)
    primary_amt = (txn.amount - used).quantize(Q4, rounding=ROUND_HALF_UP)
    if primary_amt < 0:
        raise ClearingError(f"negative primary amount for txn={txn.id}")
    splits.append(Split(primary_rule.name, primary_rule.recipient(txn),
                        primary_rule.recipient_type, primary_amt,
                        primary_rule.rule_id, is_primary=True))

    # 3. 守恒校验
    total = sum(s.amount for s in splits)
    if total != txn.amount:
        raise ClearingError(
            f"conservation violated: txn={txn.amount} splits={total}")
    return splits

def persist(txn, splits, batch_id):
    """幂等写入 clearing_records。唯一键 (txn_id, recipient_type, recipient_id)。"""
    with tx_begin():
        existing = db.query(
            "SELECT 1 FROM clearing_records "
            "WHERE txn_id=%s AND status='ACTIVE'", (txn.id,))
        if existing:
            return   # 已清分,天然幂等
        for s in splits:
            db.execute(
                "INSERT INTO clearing_records "
                "(clearing_id, batch_id, txn_id, recipient_id, recipient_type, "
                " amount, currency, rule_id, cleared_at, status) "
                "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,NOW(),'ACTIVE')",
                (uuid(), batch_id, txn.id, s.recipient_id, s.recipient_type,
                 s.amount, txn.currency, s.rule_id))
        db.execute(
            "UPDATE transactions SET clearing_status='CLEARED' "
            "WHERE txn_id=%s AND clearing_status='PENDING'", (txn.id,))

要点:

  1. 主(primary)拆分承担舍入余数,避免 “分润 + 本金 ≠ 原交易” 的守恒性 bug;
  2. clear_one_txn 是纯函数,批处理 / 流处理共用同一份代码;
  3. persist 是幂等入口,唯一键保证重入不会翻倍;
  4. 守恒校验作为最后一道闸门,失败直接阻断(而不是记 warning 继续跑,否则差错会被雪藏)。

9.补.2 作废与反向重算

规则变更后必须重算历史数据时,正确姿势:

-- A. 找出需要重算的批次
SELECT DISTINCT batch_id FROM clearing_records
WHERE rule_id = 'merchant_A_default_2025' AND cleared_at >= '2026-04-01';

-- B. 对每个 batch,先作废旧记录
UPDATE clearing_records SET status='VOIDED', voided_at=NOW(),
       voided_reason='rule_v2_recompute'
WHERE batch_id = :b AND rule_id='merchant_A_default_2025';

-- C. 生成反向清算记录(给总账抹平)
INSERT INTO clearing_records (..., amount, ..., status, source_clearing_id)
SELECT clearing_id_new, batch_id, txn_id, recipient_id, recipient_type,
       -amount, currency, 'REVERSAL', NOW(), 'ACTIVE', clearing_id
FROM clearing_records
WHERE batch_id = :b AND status='VOIDED'
  AND voided_reason='rule_v2_recompute';

-- D. 按新规则重新清分
CALL rerun_clearing(:b, 'merchant_A_default_2026');

-- E. 差额结算:如果新净额 > 旧净额 → 补结算;< → 下批次抵扣

关键:A/B/C 三步必须在一个审计批次里完成,否则总账会临时对不上。

9.补.3 结算指令的聚合与拆单

clearing_recordssettlement_instructions 的聚合:

INSERT INTO settlement_instructions
  (instruction_id, batch_id, recipient_id, recipient_account,
   recipient_bank, amount, currency, scheduled_at, status)
SELECT
  UUID(), cr.batch_id, cr.recipient_id,
  acc.account_no, acc.bank_code,
  SUM(cr.amount), cr.currency,
  next_biz_day(cr.batch_id) + INTERVAL 8 HOUR,
  'PENDING'
FROM clearing_records cr
JOIN settlement_accounts acc
  ON acc.recipient_id = cr.recipient_id AND acc.is_default = 1
WHERE cr.batch_id = :b AND cr.status='ACTIVE'
GROUP BY cr.batch_id, cr.recipient_id, acc.account_no,
         acc.bank_code, cr.currency
HAVING SUM(cr.amount) > 0;

坑点:



补充、清结算状态机与端到端追溯

补.1 交易—清算—结算状态机

一笔交易从”用户扫码”到”商户账户到账”,在平台侧经历多个状态:

stateDiagram-v2
    [*] --> PAYING: 用户发起支付
    PAYING --> PAID: 通道成功
    PAYING --> FAILED: 通道拒绝
    PAID --> CLEARED: 清分引擎批处理完成
    PAID --> REFUNDED: 用户申请退款
    CLEARED --> SETTLING: 下发银行代付指令
    CLEARED --> REFUNDED: 延迟退款(清算后)
    SETTLING --> SETTLED: 银行回执成功
    SETTLING --> SETTLE_FAILED: 银行回执失败
    SETTLE_FAILED --> SETTLING: 重试/改账户
    REFUNDED --> CLEARED: 退款完成后继续参与下期净额
    SETTLED --> [*]
    FAILED --> [*]

每个状态迁移必须有数据库唯一事件记录(who/when/why),以便审计和复现。

补.2 追溯查询:一笔钱的全链路

给业务侧、财务侧、合规侧一个统一的 “透视” SQL 视图:

CREATE VIEW v_money_trace AS
SELECT
    t.txn_id,
    t.merchant_id,
    t.amount        AS txn_amount,
    t.paid_at,
    cr.clearing_id,
    cr.recipient_id AS cr_recipient,
    cr.amount       AS cr_amount,
    cr.status       AS cr_status,
    si.instruction_id,
    si.amount       AS si_amount,
    si.status       AS si_status,
    po.payout_id,
    po.channel,
    po.channel_txn_id,
    po.status       AS po_status,
    po.confirmed_at
FROM transactions t
LEFT JOIN clearing_records cr
    ON cr.txn_id = t.txn_id AND cr.status='ACTIVE'
LEFT JOIN settlement_instructions si
    ON si.batch_id = cr.batch_id AND si.recipient_id = cr.recipient_id
LEFT JOIN payout_orders po
    ON po.instruction_id = si.instruction_id AND po.status='SUCCESS';

这个视图有两个设计哲学:

  1. LEFT JOIN:任何一层失败,其它层的数据仍然可见,便于定位断点;
  2. 只 JOIN SUCCESS 的 payout:如果一笔 instruction 做过 3 次重试,只展示最终成功那条,避免前端误解。

补.3 每日自检 SQL

给运营团队的每日自检脚本(跑在 T+1 早晨,发邮件):

-- 1. 有多少 PAID 但未 CLEARED 的交易(可能漏算)
SELECT DATE(paid_at) AS d, COUNT(*), SUM(amount)
FROM transactions
WHERE status='SUCCESS' AND clearing_status='PENDING'
  AND paid_at < CURRENT_DATE - INTERVAL 1 DAY
GROUP BY DATE(paid_at);

-- 2. 有多少 CLEARED 但未生成 instruction 的
SELECT cr.batch_id, COUNT(*) FROM clearing_records cr
LEFT JOIN settlement_instructions si
  ON si.batch_id=cr.batch_id AND si.recipient_id=cr.recipient_id
WHERE cr.status='ACTIVE' AND si.instruction_id IS NULL
  AND cr.cleared_at < CURRENT_DATE - INTERVAL 1 DAY
GROUP BY cr.batch_id;

-- 3. 有多少 instruction 卡在 SENT 超过 12 小时未确认
SELECT COUNT(*) FROM settlement_instructions
WHERE status='SENT' AND scheduled_at < NOW() - INTERVAL 12 HOUR;

-- 4. 当日守恒校验:sum(transactions) 是否等于 sum(clearing_records by batch)
SELECT cr.batch_id, SUM(cr.amount) AS sum_splits,
       (SELECT SUM(amount) FROM transactions
        WHERE txn_id IN (SELECT txn_id FROM clearing_records
                         WHERE batch_id=cr.batch_id AND status='ACTIVE'))
        AS sum_txn
FROM clearing_records cr
WHERE cr.status='ACTIVE' AND cr.batch_id=:today_batch
GROUP BY cr.batch_id
HAVING sum_splits <> sum_txn;

第 4 条必须返回空集。返回非空就是 P0:清分引擎吐出的净额不守恒,必须立即停止下发结算指令。


十、真实案例速览

10.1 支付宝商户结算

10.2 微信支付商户结算

10.3 快钱、拉卡拉分账

10.4 Stripe payout

10.5 Adyen settlement


十一、工程坑点

  1. 把清算规则写死在结算服务里——规则变更要改代码,BI 无法复核。务必规则与执行分离,规则独立版本化。
  2. 清分幂等靠 INSERT IGNORE——看似工作正常,但改规则重算时静默跳过,数据永远是旧值。正确做法:唯一键 + 显式”作废+重插”流程。
  3. 结算金额和清算金额不对齐——分钱分到一半加了一条新规则没同步到主商户本金公式,出现分润合计 ≠ 原金额。必须在清分引擎里加守恒校验,不过就直接失败阻断。
  4. T+N 日历没把海外节假日考虑进去——跨境结算 T+2 撞上美国 Memorial Day 直接变 T+4,客户投诉。日历要按币种/清算机构分别维护。
  5. 用一个单体结算服务跑所有商户——头部商户一次结算数百万笔,尾部 10 万商户各自几十笔,单体服务做 CPU 分配极难。应按商户/批次分片。
  6. 结算下发到银行通道没做限流——银企直连有 QPS 限制,批量下发打爆通道导致部分指令丢失。限流 + 分批 + 断点续传是必须。
  7. 退款不从下期结算扣,而是等商户回款——80% 商户余额不够,产生大量呆账。正确做法:退款即刻冻结商户可结算余额,不足再走代偿流程。
  8. 二清边界模糊——技术服务费、营销返佣、代收代付全部走了自有账户,一旦被监管盯上直接注销牌照。资金路径必须持牌直通,任何”过个手再分”都要重新审视。
  9. 备付金客户出金与支付结算混在一起——支付备付金不是商户可随意划转的资金,商户出金/提现必须独立账户、独立流程。
  10. 对账数据来自清分引擎自身——自己对自己,永远对得平。对账源必须是外部的银行流水 / 通道回单,不能用本地表做”假对账”。

十二、落地清单

清结算平台从 0 到 1 的最小可行路径:

  1. 把三个词讲清:在架构评审前,先把 Clearing/Settlement/Cash Pooling 的边界在文档里画死,避免跨团队扯皮;
  2. 沉淀交易流水表:任何清分的前提是唯一权威的交易流水源;不要让每个业务方自己维护交易表;
  3. 规则可配置:从 YAML/DB 配置起步,后续再考虑规则引擎;
  4. 四层数据模型transactions → clearing_records → settlement_instructions → payout_orders 一步到位,不要为了”简单”合并其中两层;
  5. 幂等与作废:任何清算/结算记录不删不改,只作废
  6. 守恒校验:清分产出必须通过”sum 原交易 = sum 拆分”的自动校验;
  7. 工作日历服务化:独立的 calendar 服务,按币种/清算网络维护;
  8. 批与流统一:清分规则写成纯函数,批处理和流处理共用同一份代码,方便后续 T+0 化;
  9. 端到端对账:从支付通道对回单 → 清算净额 → 结算指令 → 打款回执,四层都要对;
  10. 合规评审:资金路径设计必须过合规评审,二清是生死线;
  11. 异常人工台:差错处理界面与工单流,占开发量至少 30%,别省;
  12. 灰度发布规则变更:清分规则的灰度方式是”双跑比对”——新规则算一遍不入库,与旧规则结果对比通过后再切。

十三、选型建议

场景 建议范式 批/流
聚合支付平台结算 DNS + T+1 批处理(Spark/Flink 批)
SaaS 平台分润 DNS + T+7 批处理
跨境 B2B 大额 RTGS + T+0/T+1 逐笔(带净额轧差模块)
证券交易 DvP-3 + T+1 批处理(中证登完成)
外汇交易 PvP(CLS) 逐笔
实时收单 T+0 Prefunded DNS + 分钟级轧差 流式
集团资金归集 日终 ZBA + 实时 Notional 查询 批处理 + 实时查询
央行 IPS 参与方(如网联直连机构) RTGS-like + 24×7 流式

一句话选型:额度大、少量笔、对终局性敏感 → RTGS;小额、海量、能容忍 T+N → DNS;中间地带 → 现代混合 DNS + 逐笔近实时


参考资料

  1. BIS CPMI, “Principles for Financial Market Infrastructures (PFMI)”, 2012. https://www.bis.org/cpmi/publ/d101.htm
  2. BIS CPMI, “Delivery versus Payment in Securities Settlement Systems”, 1992. https://www.bis.org/cpmi/publ/d06.htm
  3. CLS Bank, “How CLS Settlement Works”. https://www.cls-group.com/
  4. 中国人民银行,《中国支付体系发展报告》年度版。http://www.pbc.gov.cn
  5. 中国人民银行,《非银行支付机构客户备付金存管办法》。
  6. 网联清算公司官网。https://www.nucc.com/
  7. U.S. SEC, “Shortening the Securities Transaction Settlement Cycle” Final Rule (T+1), 2023. https://www.sec.gov/rules/final/2023/34-96930.pdf
  8. Federal Reserve, “FedNow Service”. https://www.frbservices.org/financial-services/fednow
  9. Banco Central do Brasil, “PIX”. https://www.bcb.gov.br/estabilidadefinanceira/pix
  10. European Central Bank, “TARGET2 / T2 / TIPS”. https://www.ecb.europa.eu/paym/target/html/index.en.html
  11. Stripe Docs, “Payouts”. https://docs.stripe.com/payouts
  12. Adyen Docs, “Settlement”. https://docs.adyen.com/reporting/settlement-details-report


十三、补充专题:几个容易被忽视的工程细节

13.1 结算日历服务

每个清算网络都有自己的工作日历,一个跨境平台经常要同时维护十几套:

{
  "calendar_id": "CNY_CNAPS",
  "weekend": ["SAT", "SUN"],
  "holidays_2026": [
    "2026-01-01", "2026-02-16", "2026-02-17", "2026-02-18",
    "2026-04-04", "2026-04-05", "2026-04-06", "2026-05-01",
    "2026-06-19", "2026-10-01", "2026-10-02", "2026-10-03"
  ],
  "half_days": [
    {"date": "2026-12-31", "cutover": "12:00"}
  ]
}

所有涉及”T+N”的地方都必须调用 calendar.add_business_days(trade_date, n, calendar_id),千万不要用 date + timedelta(days=n)

13.2 跨币种清算

平台如果同时收 CNY 和 USD,清分引擎的每条规则必须带币种维度,且同一 recipient 在不同币种下可能有不同账户。一条原始交易只能有一种币种;清分输出的拆分必须保持这一币种,绝不允许规则里做”自动换算”——换汇必须是独立的、显式的交易。

13.3 分账”三方约束”与分账服务商

分账的合规基线:分账的每一方都必须是真实服务的提供者。支付机构(如支付宝、微信)的分账产品一般要求:

这些约束决定了你作为平台方,不能把”分账”当万金油用——比如把员工工资、税费代扣都塞到分账里,会被风控拦掉。工资走代付,税费走税务接口。

13.4 日切(Cut-Over)语义

“日切”这个词在传统批处理系统里有明确含义:在某个时刻(如 23:59:59)冻结当日账本、启动清算批、次日开新账本。工程上两个坑:

  1. 日切期间系统不可用:老式银行核心在日切 30 分钟内所有查询/交易都失败。现代系统要求日切对用户无感知(通过”会计日”与”自然日”解耦);
  2. T 日交易能不能补录:严格做法是日切后 T 日账本只读,补录走 T+1 调整分录;宽松做法允许日切后短时间内补录 T 日账本,但必须有”追溯修改”审计。

实时清算系统打破日切后,你需要一个”会计日(Accounting Date)“字段挂在每条记录上,这个字段由业务自己打,不由”系统时间”隐式决定。

13.5 争议与 Chargeback 对清算的影响

卡支付特有的 Chargeback(拒付)发生在交易完成后 120/180 天内,一旦发生:

清分引擎要内置”预留冻结”规则:

- name: rolling_reserve
  recipient: "ACC_RESERVE_{{merchant_id}}"
  formula: "amount * 0.05"
  release_after_days: 90
  condition: "merchant.risk_level >= 'HIGH'"

释放机制通过独立的”冻结释放批次”执行,释放时生成一条新的 clearing_record(从冻结账户转出到商户账户)。


十四、总结:把三件事焊在脑子里

一句话 清算 结算 资金归集
做什么 算应收应付 真实划拨 企业内部搬钱
产物 净额指令 账户变化 主账户余额
频率 T+0~T+N 跟随清算 日终/实时
风险 对手方净头寸风险 终局性后无风险 跨境税务风险
核心机构 清算所 / CCP 央行 / 商业银行 企业财资/银行

设计系统时,先判断你要建的是哪一件——不要建一个”清结算归集中台”试图一次性搞定三件事,一定会变成烂泥潭。真正的中台是账务系统,它是这三件事共同的数据底座;清算、结算、归集分别是三个独立的业务域,各有自己的数据模型、SLA、合规边界。


上一篇《订阅与计费系统:用量计量、账单、发票、出海 VAT/GST》

下一篇《央行支付系统:CNAPS、CIPS、Fedwire、TARGET2、SWIFT gpi》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

金融科技工程

面向中国工程团队的金融科技系列。从账务底盘、支付、清结算、交易所、风控合规到可靠性与灾备,中国与全球视角并举,讲清楚金融系统在工程落地中的真实挑战。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。


By .