Appearance
幂等与对账:资金安全底座
信贷系统的钱要过三道门。幂等保证"一笔钱只生效一次"。对账保证"定期发现所有没做到的"。差错处理保证"发现了能处理完结"。前两道在 star-site 支付系统(payment 服务:mock/支付宝/微信三渠道、HMAC 验签、CAS 幂等)里有一套跑通的生产实现。本文直接引用对照。业务全景见信贷业务全景与领域模型。
幂等的三层防御
资金操作的幂等不是"加个标记",而是三层各挡一类问题,缺一层就有一种事故:
请求 ──▶ ① 请求号去重层(应用内存/Redis, 挡"重试洪峰")
│ 命中 → 直接返回上次结果
▼
② 状态机层(领域不变量, 挡"乱序+重复"的核心层)
│ CAS 失败(0行) → 幂等应答
▼
③ 数据库唯一约束层(最后防线, 挡"一切代码 bug")
DuplicateKey → 转查询返回既有结果- 第一层:请求号去重。每个外部请求携带业务方生成的唯一请求号(requestId/幂等键),服务端先查去重表命中即返回上次结果。解决的是快速去重——挡住用户狂点、上游超时重发、MQ 重投的洪峰。绝大多数重复请求到不了数据库。缺点:去重表要过期清理,与业务写入的事务一致性要单独处理。所以它只能是第一道而非最后一道;
- 第二层:状态机 CAS(核心)。幂等的本质不是"记住处理过哪些请求",而是"第一次处理改变了状态,后续处理的条件不再成立"。放款落借据、还款冲抵、回调入账全部走条件更新;
- 第三层:唯一约束。应用层可以有 bug,数据库不会——唯一索引是幂等的最后防线。
star-site 的对照实现(这套思想在信贷域原样放大):
sql
-- payment 库幂等三键: 应用单号唯一 / 业务在途唯一 / 渠道交易号绑定唯一
UNIQUE KEY `uk_order_no` (`order_no`),
UNIQUE KEY `uk_biz` (`source`, `biz_type`, `biz_id`),
UNIQUE KEY `uk_channel_trade` (`channel`, `channel_trade_no`),
-- 状态机 CAS: 判断压进 WHERE, 行锁串行化并发
UPDATE payment_order SET status = #{newStatus}, ...
WHERE id = #{id} AND deleted = 0 AND status = #{expectStatus}java
// 回调入账: CREATED → SUCCESS 仅一次; 重放/迟到回调 WHERE 不成立, moved=false 幂等应答
boolean moved = orderRepository.casStatus(order.getId(),
PaymentOrder.STATUS_CREATED, PaymentOrder.STATUS_SUCCESS,
callback.getChannelTradeNo(), LocalDateTime.now());
if (!moved) { return ackOf(channelType); } // 不重复入账、不重复发事件三个值得复用的细节:
- 并发安全不靠分布式锁:两个并发回调 UPDATE 同一行。InnoDB 行锁让后者等到锁后 WHERE 已不成立,影响行数 0,天然分出"这次迁移归谁"。
- 单向状态流免版本号防 ABA:支付单/借据状态都只进不退,没有"改回原值"的合法路径。
- 状态与证据原子落地:
paid_at、channel_trade_no随 CAS 一并回填,"支付成功"和"成功的凭证"不可分割。
完整拆解(含 timing attack、原始字节验签、应答协议)见支付回调的HMAC验签与幂等设计。信贷域的同构物:放款回调 = INIT → ACTIVE,还款 = 冲抵分录 + 借据状态迁移,全部同款 CAS。
对账体系:三横一纵
幂等解决单笔正确,对账解决存量正确——它是资金系统的"巡检雷达",分层各管一段:
┌───────────────────────────────────────────────────────┐
│ ① 渠道对账: 我方支付单 vs 渠道账单(支付宝/微信/代扣/银行) │ ← 最外圈, 挡外部不一致
├───────────────────────────────────────────────────────┤
│ ② 内部对账: 业务系统 vs 账务系统 │
│ 借据余额 vs 分户账余额 / 支付单 vs 凭证(逐笔勾对) │ ← 挡内部跨系统不一致
├───────────────────────────────────────────────────────┤
│ ③ 总分核对: 总账科目余额 vs 分户账汇总 │ ← 挡账务自身不一致
├───────────────────────────────────────────────────────┤
│ ④ 余额连续性: 期初 + 当日发生 − 期末 = 0 (逐账户逐日) │ ← 纵切, 状态回放自证
└───────────────────────────────────────────────────────┘- ① 渠道对账:文件级,T+1 拉渠道账单,按渠道交易号逐笔对碰。产出"我有他无(疑似掉单)/他有我无(疑似掉账)/金额不符"三类差异;
- ② 内部对账:准实时(消息驱动)+ T+1(全量)双轨。放款成功事件 → 账务应有放款凭证;借据本金余额 = 本金分户账余额。对不上说明事件丢了或记账漏了。内部对账是跨系统最终一致性的兜底验收。不在这条链路上用强一致事务的原因,见分布式事务解决方案(总纲);
- ③ 总分核对:账务系统内部的日切动作,与试算平衡构成账务自查双保险。详见账务系统设计-复式记账与日切;
- ④ 余额连续性:对每个账户验证
期初余额 + Σ当日发生额 = 期末余额。前三横抓"点",这一纵抓"线"——即使每一笔都对,余额衔接断了(有人直接 UPDATE 过余额)也能抓出来。
对账是只读分析 + 差异工单:对账程序永不直接修数据,只生产差异单,修数走差错处理流程。
工程实现上,对碰算法的核心是按主键排序归并而不是逐条嵌套查询:
我方流水(按 order_no 排序): A B C D E
渠道账单(按 order_no 排序): A B D E F
│ │ │ │ │
双指针归并: ✓ ✓ C单边 ✓ F单边
│ │
我有他无(C=短款疑点) / 他有我无(F=长款疑点)亿级流水的 T+1 对碰在数仓里就是一次 sorted merge join,差异率正常应为 0 或个位数(ppm 级)。差异率本身是核心监控指标:昨天 0、今天 0.1%,不需要逐笔看。直接怀疑批量脚本/渠道账单格式变更,先查面再查点。
差错处理中心:长款、短款、挂账
对账产出的差异进差错池,按资金方向分类处理:
| 类型 | 定义 | 典型成因 | 处理路径 |
|---|---|---|---|
| 长款 | 渠道有钱、我方无账(钱多了) | 掉单:回调丢失、我方处理失败未落库 | 先补账:拉渠道凭证补记入账,挂暂收款确认 |
| 短款 | 我方有账、渠道无钱(钱少了) | 我方误记、渠道账单延迟/在途 | 冲正我方分录或挂"在途",T+1 复对后再定 |
| 金额不符 | 单号对上、金额不等 | 部分退款、币种/费率口径不一 | 挂账待查,人工核对原始凭证 |
| 挂账 | 暂收暂付科目余额 | 以上三类落地前的落脚点 | 计时巡检,超期升级 |
工程上的铁律:
- 资金性差异零容忍自动修:涉及分录的修复必须走人工审批的冲正凭证(红冲+蓝补)。自动修复只允许"补信息"(补状态、补关联单号),不允许"自动改钱";
- 挂账科目要有时限:
暂收挂账余额 > 0 且账龄 > N 天自动升级告警。挂账池是差异的蓄水池,蓄水池没排水就是事故前兆; - 每笔差异处理完成前要回写根因分类(掉单/重复/程序 bug/渠道问题)。月度归因驱动幂等与对账规则迭代。
差异单本身也有生命周期状态机,不允许"开着的差异单"无限期堆积:
DISCOVERED(发现) ──▶ 确认归类 ──▶ PROCESSING(处理中) ──▶ 冲正/补账完成 ──▶ CLOSED(关闭)
│ │ │
│ 复对后消失(在途到账) │ 修数凭证被驳回
▼ ▼ ▼
FALSE_ALARM(误报关闭) REJECTED(退回) 回到 PROCESSING 重修
任一状态停留 > SLA → 升级告警(资金敏感差异 30 分钟)准实时对账 vs T+1
| 维度 | 准实时对账 | T+1 文件对账 |
|---|---|---|
| 数据源 | 交易消息/流水流 | 渠道对账单文件、日切快照 |
| 延迟 | 分钟级 | 次日 |
| 覆盖 | 当日在途交易(增量) | 全量(含准实时没覆盖的边角) |
| 抓什么 | 掉单、重复记账的快速止血 | 资金终态的最终验收 |
| 成本 | 常驻计算(流式聚合) | 批处理一次 |
两者的关系是止血 + 验收。准实时对账在用户投诉前发现"渠道成功 5 分钟了我方还没入账"这类掉单,并自动触发补单。补单流程:重拉渠道查单接口 + 幂等入账。T+1 是兜底全量审计——准实时的规则有遗漏,文件对账是最后一道不可绕过的关卡。只有准实时没有 T+1:文件里才有的信息(渠道手续费、结算批次)没人看。只有 T+1 没有准实时:掉单要捂到第二天才被用户当客诉报上来。
业界实践参照(样本银行A vs 样本银行B)
样本银行A"310"的 1 秒放款,是本文幂等与对账体系的公开极限工况。把三个映射关系对回本文概念:
| 本文概念 | 样本银行A对应实践 | 事实要点 | 可信度 |
|---|---|---|---|
| 幂等三层(毫秒级链路) | 310 授信 1 秒放款 | 决策、立借据、放款出账在近同步链路完成,链路上没有人工核对兜位——重复/乱序请求只能靠状态机与唯一约束消化 | [媒体] |
| 渠道对账的外圈(多机构) | 布谷鸟代销 23 家理财子 | 为 23 家理财子提供申赎预测、代销超 5000 亿——多机构合作意味着对账外圈同时面对多家机构账单,三方勾对是日常形态 | [媒体] |
| 账实核对的底座前提 | 三地五中心 RPO=0 | 每分片 OceanBase 5 副本跨三城(3 副 Paxos 强一致),RPO=0、RTO<1min——已记账分录不因容灾切换丢失 | [官方] |
两点映射解读:
- 1 秒放款对幂等的要求是"没有人工兜位"。传统链路里人工复核天然吸收部分重复/乱序异常。实时链路去掉这道吸收层后,本文第二层(状态机 CAS)成为唯一正确性防线。第三层(唯一约束)是最后防线;
- RPO=0 与对账是互补而非替代。若容灾切换可丢数据,"我有他无"的差异将无法区分"掉单"与"灾难丢失",差异归因失去前提。反过来对账也替代不了 RPO=0(发现丢失时钱已不在)。"不丢"与"发现一切没做到的"分别对应本文的两道门。
样本银行B:样本银行A样本的复杂度在对账外圈的渠道侧(23 家理财子代销)。样本银行B的复杂度在资方侧。联合贷把外圈变成"数百家合作银行按出资比例分账"的对账 [媒体]。这是本文对账体系的绝佳场景对照:
| 本文概念 | 样本银行B对应实践 | 事实要点 | 可信度 |
|---|---|---|---|
| 渠道对账外圈(多机构) | 联合贷出资分账对账 | 每笔支用/还款按出资比例拆给样本银行B与多家合作银行:还款先按冲抵顺序分配、再按份额下推——差异判定天然是多方口径的勾对 | [媒体]/架构解读 |
| 差异规则的参数来源 | 2021 互联网贷款新规 | 合作方出资 ≥30% 的监管红线直接改写分账对账规则——对账引擎的份额参数有监管版本,不只是资方合同版本 | [媒体] |
| 多资方勾对的工程量 | 数百家合作银行 | 数百套对账文件/接口/口径并存;对外标准化的联合贷/银银对接(含出资分账)是其信贷科技团队核心能力之一 | [JD推断]/[技术社区] |
两点映射解读:
- 联合贷对账是"三层记录逐日轧平"的规模化工况。聚合借据层、份额层、账务层之外再叠加每家资方的对账文件。单资方的三方勾对在这里是 N+2 方勾对。文件批次协议的标准化程度(见清结算与资方对接)直接决定差异能不能日清;
- 冲抵顺序 × 份额比例是差异高发区。聚合层按费用→罚息→利息→本金冲抵后再按比例下推。各层分摊尾差的舍入口径不一致就是日常"金额不符"。尾差分摊规则必须写进对账预期,和计息口径一样三处一致(合同/代码/对账)。
追问链
Q1:三层幂等砍掉一层行不行? 砍第一层:重复请求全部打到 DB 行锁上,热点账户(大商户/大客户)吞吐崩。砍第二层:只剩去重表 + 唯一约束,乱序场景(迟到回调、关单并发)没有状态谓词可依,业务正确性失守。砍第三层:前两层任何代码 bug 直接变成资损。三层的分工是性能、正确性、兜底,各不可替代。
Q2:对账发现掉单(长款),自动补单会不会补重? 补单走的是和正常回调完全相同的幂等路径:查单接口拿到渠道终态 → CAS 入账。已入账则 moved=false,直接幂等返回。补单不引入任何新写路径,所以幂等性天然继承。修复动作复用正常路径是差错处理的第一设计原则,另起炉灶的"修数脚本"是二次事故之源。
Q3:为什么对账不能实时做到秒级,非要用 T+1 文件? 渠道的资金事实以它的账单文件为准,账单是渠道自己完成日切、轧差、结算后产出的终态。我方与渠道在途中的中间态(受理成功未结算)本来就不该判差异。T+1 不是技术落后,是"以渠道终态为最终事实"的业务正确性要求。
Q4:借据余额和分户账对不平,信谁? 信账务(分户账+流水)。账务是唯一资金事实源,借据余额是业务侧视图。对不平时先修视图侧或定位漏发的记账事件,而绝不能反向把账务"调平"到业务侧。方向错了,日积月累整个账务就失去了事实源地位。
小结
- 幂等三层:请求号去重挡洪峰、状态机 CAS 保正确、唯一约束做兜底。CAS 把判断压进 WHERE,幂等性来自"状态只允许单向迁移"而非记忆清单;
- 对账三横一纵:渠道对账挡外部、内部对账挡跨系统、总分核对挡账内、余额连续性防状态篡改;
- 差错处理:长款补账、短款冲正、差异挂暂收暂付,自动只补信息、改钱必走人工冲正,挂账有时限巡检;
- 准实时止血 + T+1 验收双轨缺一不可;修复动作复用正常幂等路径。
2026-10 新增(金融信贷域系列)。