对账不是“把两张表比一下”
假设 10 月 30 日平台记录了 12,480 笔支付。次日凌晨渠道发来账单,只有 12,479 笔。少的一笔金额是 1999 分,订单状态是 PAID,但渠道侧没有对应的成功流水。客服看到的是“用户说付了,订单也显示已支付”,财务看到的是“这笔钱没到账”。
错误直觉是“对账就是把平台表和渠道表做一次全连接,不一致的拉出来”。真正难的不是比较,而是比较之前把两边的记录规范化,比较之后把差异分到正确的处置队列。漏单和金额不符的处理人、时效、自动化程度完全不同;把它们混成一张“异常列表”,运维只能逐条人肉判断。
站内 数据一致性与对账 讨论过对账在一致性体系中的位置。本节聚焦工程实现:T+1 与实时两种节奏怎么选,匹配键怎么定,差异分成哪几类,以及每类应该自动处理还是转人工。
两种节奏:T+1 全量与实时流式
T+1 对账拿渠道的日终账单和平台流水做全量比较。它的优势是权威:账单经过渠道清算,金额和状态都是最终值;缺点是延迟,问题最早也要第二天才能发现。实时对账消费渠道的支付事件流,在分钟级发现“平台有单、渠道没成功”这类问题,但它看到的是中间状态,事件可能乱序、重复或延迟。
两者不是替代关系。实时对账负责早发现和止损,T+1 对账负责最终认定和资金结算。只做实时,会漏掉事件丢失却账单有记录的情况;只做 T+1,会在一天里持续接受错误订单。
| 维度 | T+1 全量 | 实时流式 |
|---|---|---|
| 数据来源 | 渠道日终账单文件 | 支付事件流 |
| 时效 | 小时到天 | 秒到分钟 |
| 覆盖 | 全量,权威 | 只覆盖收到事件的记录 |
| 主要风险 | 发现晚,损失已扩大 | 中间状态误判、乱序 |
| 适合处置 | 资金差错、结算调整 | 止付、重试、客服预警 |
工程上常见的组合是:实时流写一张“疑似差异”表,T+1 全量结果覆盖并关闭它。实时阶段允许误报,T+1 阶段必须给出确定结论。两个阶段共用同一套差异分类,否则运维要在两套口径之间翻译。
匹配键:订单号加方向
匹配键决定哪些记录应该被放在一起比较。用渠道流水号做键是错的:渠道重试会生成新的流水号,同一笔支付会出现两条记录,被误判为重复。用平台支付单号做键也不稳:渠道回调可能先于平台落库,平台还没有单号。
稳定的键是业务订单号加资金方向。订单号两边都有,且不随重试变化;方向把支付和退款分开,避免把一笔支付和一笔退款净额成零。金额不参与匹配,因为金额不符本身就是要发现的差异;状态也不参与匹配,否则状态差异永远配不上对。
有些业务存在一对多:一笔订单分多次支付,或者一次支付覆盖多个订单。这时键要扩展成“订单号 + 支付序号”或“结算批次号 + 明细号”,并且必须在两边的规范化阶段就拆平。对账器只处理一对一的规范记录,拆平逻辑放在上游,避免匹配器里出现复杂的多对多规则。
另一个细节是时间。渠道账单通常按渠道的清算日切分,平台按自己的创建时间切分,跨日边界会有几个小时的错位。T+1 全量对账要按“账期”而不是“自然日”取数,否则每天都会在边界上产生一批假差异。
差异分类:五类,处置不同
把差异分成可执行的类别,比统计“差异总数”有用得多。下面五类覆盖了绝大多数支付对账场景。
| 类型 | 含义 | 典型原因 | 首选动作 |
|---|---|---|---|
missing_platform | 渠道有成功流水,平台无订单 | 回调丢失、订单库写入失败 | 按渠道流水反查用户,人工确认后补单或退款 |
missing_channel | 平台有支付,渠道无成功流水 | 渠道异步失败、平台误记成功 | 立即止付核查,禁止发货 |
amount | 两边都有,金额不同 | 币种、手续费、部分退款 | 拉取渠道明细,人工核对 |
status | 金额一致,最终状态不同 | 渠道退款、平台状态机卡住 | 按状态机修复,必要时补偿 |
duplicate | 同一键出现多条记录 | 渠道重试、平台重复消费 | 保留一条,其余冲正或标记忽略 |
顺序很重要:先判重复,再判单边,最后判金额和状态。如果先比金额,一条重复记录会让总额翻倍,报成金额差异,掩盖真正的问题。单边记录没有可比金额,也不应该进入金额比较。
missing_platform 是最危险的一类,因为钱已经到了。自动补单必须非常谨慎:渠道流水可能对应一个已取消的订单,或者是一次欺诈支付。合理做法是自动进入“待确认”队列并通知客服,而不是直接创建订单发货。missing_channel 相反,平台已经认为收到钱,必须先冻结履约,再查渠道。
宽限期与 as-of:pending 不是差异
实时对账最容易犯的错,是把“渠道还在处理中”当成“渠道没有这笔支付”。支付事件从发起到最终状态可能间隔几秒到几分钟,这段时间里平台已经是成功,渠道还是 pending。如果立刻报差异,每天会产生大量噪声,运维很快就不再相信告警。
正确做法是给状态差异一个宽限期:只有当 pending 记录的存在时间超过宽限期,才升级为真正的状态差异;宽限期内的记录计入 deferred,不进入处置队列。宽限期应该按渠道的 P99 结算时延设定,而不是拍一个固定值。
对账必须是可重放的。给定同一批输入和同一个 AsOf 时间,输出必须完全一致,差异顺序也要稳定。否则同一份账单跑两次得到两个结果,运维无法判断是数据变了还是程序不稳定。实现上要显式传入对账时点,不读系统当前时间;差异按订单号和方向排序。
平台支付成功 ──> 渠道 pending ──(宽限期内)──> deferred,不告警
└─(超过宽限期)─> status 差异,进入处置
可运行实现
下面的对账器只做三件事:按(订单号,方向)聚合、按固定顺序分类、输出可排序的差异列表。金额统一用整数分,状态用枚举,输入不合法时整批拒绝而不是跳过。
// file: internal/platform/reconcile/reconcile.go
// tier: L1
// verify: go test ./internal/platform/reconcile/ -count=1
package reconcile
import (
"fmt"
"sort"
"time"
)
// Direction separates a payment from its refund so both may share an order
// number without being netted into one row.
type Direction string
const (
Pay Direction = "pay"
Refund Direction = "refund"
)
func (d Direction) valid() bool { return d == Pay || d == Refund }
// Status is the settlement state reported by one side.
type Status string
const (
Success Status = "success"
Failed Status = "failed"
Pending Status = "pending"
)
func (s Status) valid() bool { return s == Success || s == Failed || s == Pending }
// Txn is one normalized record. AmountCents is always positive; Direction
// carries the sign so refunds never rely on negative-amount parsing.
type Txn struct {
ID string
OrderNo string
Direction Direction
AmountCents int64
Status Status
OccurredAt time.Time
}
func (t Txn) validate() error {
if t.ID == "" || t.OrderNo == "" {
return fmt.Errorf("reconcile: record needs id and order_no, got id=%q order_no=%q", t.ID, t.OrderNo)
}
if !t.Direction.valid() || !t.Status.valid() {
return fmt.Errorf("reconcile: record %s has direction=%q status=%q", t.ID, t.Direction, t.Status)
}
if t.AmountCents <= 0 {
return fmt.Errorf("reconcile: record %s has non-positive amount %d", t.ID, t.AmountCents)
}
return nil
}
// DiffKind is the classification an operator acts on.
type DiffKind string
const (
MissingPlatform DiffKind = "missing_platform" // channel has money, platform has no order
MissingChannel DiffKind = "missing_channel" // platform has an order, channel has no money
AmountMismatch DiffKind = "amount"
StatusMismatch DiffKind = "status"
Duplicate DiffKind = "duplicate"
)
// Options controls the settlement window.
type Options struct {
Grace time.Duration
AsOf time.Time
}
func (o Options) validate() error {
if o.Grace < 0 {
return fmt.Errorf("reconcile: grace must not be negative, got %v", o.Grace)
}
if o.AsOf.IsZero() {
return fmt.Errorf("reconcile: AsOf must be set")
}
return nil
}
// Diff is one actionable mismatch.
type Diff struct {
OrderNo string
Direction Direction
Kind DiffKind
PlatformCount int
ChannelCount int
PlatformCents int64
ChannelCents int64
Detail string
}
// Result is the outcome of one reconciliation run.
type Result struct {
Matched int
Deferred int
Diffs []Diff
}
type groupKey struct {
orderNo string
direction Direction
}
func aggregate(txns []Txn) map[groupKey][]Txn {
out := make(map[groupKey][]Txn)
for _, t := range txns {
k := groupKey{orderNo: t.OrderNo, direction: t.Direction}
out[k] = append(out[k], t)
}
return out
}
func sumCents(txns []Txn) int64 {
var sum int64
for _, t := range txns {
sum += t.AmountCents
}
return sum
}
// dominantStatus collapses one side to a verdict. Success wins because a
// channel retry may leave a failed attempt beside the successful one.
func dominantStatus(txns []Txn) Status {
status := Pending
for _, t := range txns {
if t.Status == Success {
return Success
}
if t.Status == Failed {
status = Failed
}
}
return status
}
func newest(txns []Txn) time.Time {
var latest time.Time
for _, t := range txns {
if t.OccurredAt.After(latest) {
latest = t.OccurredAt
}
}
return latest
}
// Run compares the platform ledger with the channel bill. Matching is keyed by
// (order_no, direction); amount and status only classify a matched pair.
func Run(platform, channel []Txn, opt Options) (Result, error) {
if err := opt.validate(); err != nil {
return Result{}, err
}
for _, t := range platform {
if err := t.validate(); err != nil {
return Result{}, err
}
}
for _, t := range channel {
if err := t.validate(); err != nil {
return Result{}, err
}
}
pg, cg := aggregate(platform), aggregate(channel)
seen := make(map[groupKey]bool, len(pg)+len(cg))
keys := make([]groupKey, 0, len(pg)+len(cg))
for _, m := range []map[groupKey][]Txn{pg, cg} {
for k := range m {
if !seen[k] {
seen[k] = true
keys = append(keys, k)
}
}
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].orderNo != keys[j].orderNo {
return keys[i].orderNo < keys[j].orderNo
}
return keys[i].direction < keys[j].direction
})
res := Result{}
for _, k := range keys {
p, c := pg[k], cg[k]
d := Diff{OrderNo: k.orderNo, Direction: k.direction,
PlatformCount: len(p), ChannelCount: len(c),
PlatformCents: sumCents(p), ChannelCents: sumCents(c)}
switch {
case len(p) > 1 || len(c) > 1:
d.Kind = Duplicate
d.Detail = "more than one record for the same order and direction"
case len(c) == 0:
d.Kind, d.Detail = MissingChannel, "platform record has no channel settlement"
case len(p) == 0:
d.Kind, d.Detail = MissingPlatform, "channel settlement has no platform order"
case d.PlatformCents != d.ChannelCents:
d.Kind, d.Detail = AmountMismatch, "amount differs"
case dominantStatus(p) != dominantStatus(c):
if (dominantStatus(p) == Pending || dominantStatus(c) == Pending) &&
opt.AsOf.Sub(newest(append(append([]Txn{}, p...), c...))) < opt.Grace {
res.Deferred++
continue
}
d.Kind = StatusMismatch
d.Detail = fmt.Sprintf("status %s vs %s", dominantStatus(p), dominantStatus(c))
default:
res.Matched++
continue
}
res.Diffs = append(res.Diffs, d)
}
return res, nil
}
实现里有三个值得注意的取舍。第一,重复判断放在最前面,并且只要任一侧多于一条就报重复,不再继续比金额,避免重复记录把金额差异放大。第二,dominantStatus 让成功状态优先:渠道重试可能留下一条失败记录和一条成功记录,按成功算才不会误报。第三,宽限期用 AsOf 减去两边最新记录时间计算,而不是用记录自身的状态时间,保证重放时结果稳定。
本机实测:九组用例
测试用固定的 AsOf 和表驱动用例覆盖正常匹配、单边、金额、重复、宽限期内外、失败对成功、支付与退款分离。命令与原始输出(节选,完整报告见 .local/reports/be-arch/):
$ go test ./internal/platform/reconcile/ -count=1 -v
=== RUN TestRunTable/exact_match
reconcile_test.go:116: case="exact match" matched=2 deferred=0 diffs=[]
=== RUN TestRunTable/platform_only_means_no_money_arrived
reconcile_test.go:116: case="platform only means no money arrived" matched=0 deferred=0 diffs=[missing_channel:o-3]
=== RUN TestRunTable/channel_only_means_money_without_an_order
reconcile_test.go:116: case="channel only means money without an order" matched=0 deferred=0 diffs=[missing_platform:o-4]
=== RUN TestRunTable/amount_mismatch
reconcile_test.go:116: case="amount mismatch" matched=0 deferred=0 diffs=[amount:o-5]
=== RUN TestRunTable/duplicate_channel_record
reconcile_test.go:116: case="duplicate channel record" matched=0 deferred=0 diffs=[duplicate:o-6]
=== RUN TestRunTable/pending_within_grace_is_deferred
reconcile_test.go:116: case="pending within grace is deferred" matched=0 deferred=1 diffs=[]
=== RUN TestRunTable/failed_versus_success_is_a_status_diff
reconcile_test.go:116: case="failed versus success is a status diff" matched=0 deferred=0 diffs=[status:o-9]
PASS
ok archlab/internal/platform/reconcile 0.354s
读数可以直接对应到分类规则。完全匹配的两笔订单 matched=2,没有差异;平台单边报 missing_channel,渠道单边报 missing_platform;金额不同报 amount;渠道同一订单两条记录报 duplicate,而不是金额翻倍;宽限期内的 pending 只增加 deferred,不产生差异;失败对成功即使金额一致也报 status,因为失败不是“还没结算”,而是确定的相反结论,不适用 pending 的宽限逻辑。
这些耗时是单机内存比较的结果,不代表生产对账吞吐。真实系统的瓶颈通常在两侧取数和规范化,而不是比较本身;把账单切成按账期、按渠道的分片并行处理,比优化比较循环更有效。
处置队列:自动与人工的边界
差异分类的终点是处置。不是所有差异都值得自动修复,自动修复做错一次,损失可能比差异本身大。
| 差异 | 可自动 | 必须人工 | 关闭条件 |
|---|---|---|---|
duplicate | 标记多余记录、保留最早一条 | 涉及已发货或已退款 | 账本只保留一条且总额正确 |
amount | 只生成对账工单 | 全部 | 渠道明细与平台金额一致 |
status | 按状态机补事件 | 跨状态跳变 | 两边最终状态一致 |
missing_channel | 冻结订单、发告警 | 确认是否补发支付 | 渠道确认失败并完成取消 |
missing_platform | 进入待确认队列 | 全部 | 补单或原路退款完成 |
自动处置的退出条件必须写死,不能是“看起来好了”。比如重复记录的处理,退出条件应是“该键只剩一条有效记录,且账户总额回到预期值”,而不是“工单已标记完成”。否则重复记录被标记忽略,但资金仍然多记了一笔。
常见错误与边界
第一,把支付和退款净额计算。用订单号做键、金额直接相加,一笔支付加一笔退款会变成零,对账结果显示匹配,实际两笔资金流都没有核对。方向必须进入匹配键。
第二,用浮点数比较金额。19.99 在二进制浮点里不能精确表示,两边从不同格式解析后可能差一个极小值,被判定为金额不符。金额从入口开始就用整数分,数据库也存整数。
第三,把 pending 当成缺失。实时对账在没有宽限期的情况下会把大量正常在途支付报成差异,噪声会淹没真正的漏单。宽限期按渠道时延分布设定,并且显式区分 deferred 和差异。
第四,对账结果不稳定。读系统当前时间、用 map 遍历顺序输出、并发写入同一个结果切片,都会让同一批数据跑出不同结果。固定 AsOf、排序输出、单写者汇总,是可重放的最低要求。
第五,只对账不回写。差异表生成后没有任何状态流转,工单永远停在“新建”。每一类差异都要有负责人、SLA 和关闭条件,否则对账系统只是一个更贵的报表。
小结
对账的价值不在“比出多少条不一致”,而在“把不一致变成可执行的分类”。T+1 全量提供权威结论,实时流式提供早期止损,两者共用按订单号与方向建立的匹配键。重复、单边、金额、状态四类差异必须按固定顺序判断,pending 要用宽限期和 deferred 隔离。本节的对账器用九组用例验证了分类边界。下一节处理差异之后的动作:什么时候用冲正抹掉错误记账,什么时候用补偿发起新的正向业务,以及资金类操作最终一致要靠哪些不变量来证明。
阅读导航:上一节:幂等键设计:作用域、生命周期与存储 · 下一节:冲正、补偿与资金类操作的最终一致 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。