《后端架构设计·分布式与数据一致性》9.2 对账系统:T+1、实时与差异分类

以订单支付流水与渠道账单对账为例,对比 T+1 全量与实时流式对账在覆盖、时效和成本上的取舍,给出按订单号与方向匹配、再按金额和状态分类的 Go 对账器,用九组表驱动用例展示漏单、单边、金额、重复和宽限期状态差异如何进入不同处置队列,并说明自动与人工处置的退出条件。

对账不是“把两张表比一下”

假设 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 隔离。本节的对账器用九组用例验证了分类边界。下一节处理差异之后的动作:什么时候用冲正抹掉错误记账,什么时候用补偿发起新的正向业务,以及资金类操作最终一致要靠哪些不变量来证明。

阅读导航:上一节:幂等键设计:作用域、生命周期与存储 · 下一节:冲正、补偿与资金类操作的最终一致 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查