对不上一分钱,可能丢一个用户:支付对账系统如何发现掉单、长款、短款并自动修复
关键词:支付对账系统、掉单、长款、短款、T+1 对账单、差异比对、自动修复、事务消息、最终一致性、分库分表、高并发
适合读者:中高级后端工程师、支付系统开发者、技术负责人、架构师
一、先说结论:支付成功不等于系统真的“记对了账”
很多团队把支付系统的重点放在“能收款”上,结果上线后才发现,真正难的是“账要永远对得上”。
用户已经收到扣款短信,但订单还卡在“支付中”,这叫掉单。
内部系统记了成功流水,但渠道账单根本没有这笔钱,这叫长款。
渠道成功金额和内部金额不一致,或者渠道有钱、内部少记,这通常会表现为短款或金额差异。
这些问题不会因为你做了幂等、重试、分布式事务就彻底消失。原因很简单:
- 支付回调可能延迟、重复、丢失
- 上游渠道和内部系统之间永远存在网络与时序不确定性
- 高并发下我们追求的是最终一致,而不是瞬时绝对一致
- 任何人工修数、运维补偿、异常发布,都可能把账带偏
所以,支付对账系统不是附属模块,而是支付链路最后一道资金防线。
一套生产级对账系统,至少要解决五件事:
- 稳定获取 T+1 渠道对账单,并完成解析、标准化入库
- 高效比对渠道账单与内部流水,发现掉单、长款、短款、金额不一致
- 用规则和状态机驱动差错处理流程,做到可自动修、可人工接管、可追溯
- 用事务消息或 Outbox 保证“修复结果落库”和“通知下游执行”一致
- 在高并发、海量账单、分库分表场景下,仍然可扩展、可观测、可补偿
本文就围绕这五件事,给出一套可以真正落地的支付对账系统设计。
二、先统一口径:掉单、长款、短款到底是什么意思
不同公司对“长款”“短款”的命名可能略有不同,真正重要的是统一基准。本文统一以渠道清算结果为准。
| 差异类型 | 定义 | 典型表现 | 风险 |
|---|---|---|---|
CHANNEL_ONLY |
渠道有成功流水,内部没有成功记录 | 用户已付款,订单未成功 | 掉单、履约失败、用户投诉 |
INTERNAL_ONLY |
内部有成功记录,渠道没有成功流水 | 系统以为收到了钱,实际没收上来 | 长款、资损、假成功 |
AMOUNT_MISMATCH |
双边都有流水,但金额不同 | 实收金额、优惠、手续费、分账不一致 | 清结算不平、财务差异 |
STATUS_MISMATCH |
双边订单号一致,但状态不同 | 内部成功,渠道关闭;内部关闭,渠道成功 | 状态漂移、补偿冲突 |
DUPLICATE_RECORD |
同一业务单被多次记账 | 回调重放、消费重复、人工补单重复 | 重复发货、重复记账 |
从用户体验看,掉单最致命。
从资金安全看,内部成功但渠道未成功同样危险,因为那意味着系统已经放货、发券、加权益,但钱并没有真正到账。
所以,对账不是单纯“查出不同”,而是要把差异映射成可执行的业务动作。
三、为什么支付链路明明做了幂等,还是必须做对账
很多团队在设计支付链路时已经做了这些能力:
- 支付请求幂等
- 支付回调验签
- MQ 至少一次投递
- 本地事务落库
- 定时补偿查询渠道单
这些都很重要,但仍然不足以替代对账。
因为支付链路解决的是交易执行阶段的一致性控制,而对账系统解决的是跨系统、跨时间窗口的资金事实核验。
可以把两者理解成两层防线:
- 第一层:支付交易链路,尽可能让业务在实时阶段做对
- 第二层:对账链路,在 T+1 或准实时窗口里证明“最终确实做对了”
一个成熟支付系统的共识应该是:
实时链路负责成功率,对账链路负责正确性闭环。
四、支付对账系统的总体架构
先看完整架构图:
这套架构里有五个关键设计点。
1. 渠道账单和内部流水不直接在业务库里对比
生产环境里,支付流水往往已经分库分表,查询维度也未必适合对账,所以一般会有一层对账专用存储。
2. 对账系统不直接写死渠道格式
微信、支付宝、银联、银行直连、海外 PSP 的账单格式都不同,必须通过解析适配层做标准化。
3. 差异发现和差错修复必须解耦
发现差异是批处理问题,修复差异是业务补偿问题,两者节奏、重试策略、权限边界都不同,不能混在一起。
4. 修复结果和下游通知必须保证一致
不然就会出现“差异已经标记修复,但发货没触发”或者“重复触发退款”的新事故。
5. 对账系统本身也必须可补偿
账单下载失败、解析中断、分片任务超时、差异修复失败,都要能重跑、续跑、幂等跑。
五、核心数据模型:不是两张表比一下这么简单
生产级支付对账至少涉及四类核心表。
1. 渠道账单明细表
CREATE TABLE `channel_bill_detail` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`batch_no` VARCHAR(64) NOT NULL COMMENT '对账批次号',
`recon_date` DATE NOT NULL COMMENT '账单日期',
`channel` VARCHAR(32) NOT NULL COMMENT '渠道',
`channel_order_no` VARCHAR(64) NOT NULL COMMENT '渠道订单号',
`channel_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道流水号',
`merchant_order_no` VARCHAR(64) DEFAULT NULL COMMENT '商户订单号',
`trade_status` VARCHAR(32) NOT NULL COMMENT 'SUCCESS/CLOSED/REFUND',
`amount` BIGINT NOT NULL COMMENT '订单金额,单位分',
`fee_amount` BIGINT DEFAULT 0 COMMENT '手续费,单位分',
`settle_amount` BIGINT DEFAULT 0 COMMENT '结算金额,单位分',
`pay_time` DATETIME DEFAULT NULL,
`settle_time` DATETIME DEFAULT NULL,
`raw_line_no` INT NOT NULL COMMENT '原始行号',
`shard_id` INT NOT NULL COMMENT '分片号',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_batch_channel_trade` (`batch_no`, `channel`, `channel_order_no`),
KEY `idx_recon_date_shard` (`recon_date`, `shard_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道账单明细表';
2. 对账专用内部流水表
CREATE TABLE `payment_flow_recon` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`payment_id` BIGINT NOT NULL COMMENT '支付流水主键',
`recon_date` DATE NOT NULL,
`channel` VARCHAR(32) NOT NULL,
`merchant_order_no` VARCHAR(64) NOT NULL,
`channel_order_no` VARCHAR(64) DEFAULT NULL,
`pay_status` VARCHAR(32) NOT NULL COMMENT 'INIT/SUCCESS/CLOSED/REFUND',
`amount` BIGINT NOT NULL COMMENT '支付金额,单位分',
`pay_time` DATETIME DEFAULT NULL,
`biz_type` VARCHAR(32) DEFAULT NULL,
`tenant_id` BIGINT DEFAULT NULL,
`shard_id` INT NOT NULL,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_payment_id` (`payment_id`),
KEY `idx_recon_date_shard` (`recon_date`, `shard_id`),
KEY `idx_channel_order` (`channel`, `channel_order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账专用支付流水表';
3. 差异表
CREATE TABLE `recon_diff` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`batch_no` VARCHAR(64) NOT NULL,
`recon_date` DATE NOT NULL,
`channel` VARCHAR(32) NOT NULL,
`channel_order_no` VARCHAR(64) DEFAULT NULL,
`merchant_order_no` VARCHAR(64) DEFAULT NULL,
`diff_type` VARCHAR(32) NOT NULL COMMENT 'CHANNEL_ONLY/INTERNAL_ONLY/AMOUNT_MISMATCH/STATUS_MISMATCH',
`channel_status` VARCHAR(32) DEFAULT NULL,
`internal_status` VARCHAR(32) DEFAULT NULL,
`channel_amount` BIGINT DEFAULT NULL,
`internal_amount` BIGINT DEFAULT NULL,
`diff_amount` BIGINT DEFAULT NULL,
`risk_level` VARCHAR(16) NOT NULL DEFAULT 'P2',
`status` VARCHAR(32) NOT NULL DEFAULT 'INIT'


213

被折叠的 条评论
为什么被折叠?



