《游戏服务端架构·规模化、全球化与成本》6.3 对账、补偿与定时任务

《泡泡工坊》一次支付回调超时,渠道扣了款、游戏侧却没发货;《群岛纪元》的跨服结算单与角色账本差了三笔。本节划出必须对账的四类异步结果,给出差异分类、补偿预算与转人工边界,并用可运行的 Go 夹具演示差异分类、批次断点续跑、单实例选主与幂等重放。

渠道说扣款成功,游戏说没有订单

《泡泡工坊》接了一家新的支付渠道。上线第三天,客服收到三张工单:玩家提供了渠道扣款截图,游戏里没有到账记录。查日志发现,渠道回调在 5 秒超时后重试了两次,而游戏侧第一次处理时已经写入了订单,只是在提交事务后、发送发货消息前进程被重启。订单状态停在 paid,发货消息从未产生。

这不是消息队列能修的问题:消息根本没有发出去。修复办法是事务性发件箱——订单状态与待发消息在同一个本地事务里提交,再由投递器把发件箱扫进队列。但发件箱本身也会漏:投递器崩溃、队列不可用、消费端永久失败。所以还需要第二道网:对账。

同一个月,《群岛纪元》的跨服结算也出现了差异。结算服务按赛季积分生成结算单,邮件服务按结算单发附件;一次版本发布让两边的赛季边界差了六小时,三笔结算单没有对应邮件。差异不大,但如果没有对账,它会在赛季结束后变成“为什么别人有我没有”的舆情。

这一节讨论:哪些异步结果必须对账,差异怎么分类,补偿自动做到哪一步,以及定时任务如何选主、断点续跑、补上错过的窗口。

四类必须对账的异步结果

不是所有异步链路都值得对账。判据是:是否存在不可逆的对外副作用,且两侧状态由不同系统维护。

链路权威源对账窗口差异后果动作
支付发货渠道账单5 分钟 + T+1玩家已付款未到账补发或退款
活动发奖活动配置与发件箱每小时应得奖励缺失补发
跨服结算结算单与角色账本每日赛季奖励错发重算后补差
邮件附件发件箱与附件表每小时邮件在、附件丢补附件
战斗统计战斗日志每日报表偏差重算即可,不必补偿

最后一行说明边界:可重算的统计不需要补偿任务,重跑批处理就行。对账的成本在差异处理和人工介入,不要把它浪费在没有副作用的链路上。

对账的输入必须是两侧独立的快照。如果“实际侧”就是从游戏库导出的,而“期望侧”也是同一张表算出来的,那只能验证计算逻辑,验证不了消息是否送达。支付对账要拿渠道账单,发奖对账要拿活动配置和发件箱,跨服结算要拿结算单和角色账本。

差异先分类,再决定补不补

“发现差异就补发”是危险做法。重复发奖和漏发奖都是资损,只是方向相反。差异至少要分成五类:

分类含义自动动作人工介入
missing_remote期望有、实际无补发超过金额阈值转工单
missing_local实际有、期望无冻结并核查必须人工
amount_mismatch两侧都有、金额不同不自动补必须人工
status_mismatch状态机不一致按状态机规则推进或回滚跨状态转工单
duplicate同一订单多行撤销多余行涉及已消费道具时人工

只有 missing_remote 适合自动补偿,因为它的语义最确定:期望侧说应该有,实际侧确认没有。missing_local 可能是脏数据、测试单或人工补录,自动补发会放大错误。amount_mismatch 更不能猜——差 1 元和差 1000 元的原因可能完全不同。

分类之后,同一订单的多种差异要合并成一条补偿任务,避免同一订单被两个 worker 同时处理。任务的状态只有 pending 和 escalated 两种对外的稳定状态,中间的重试次数是内部计数。

定时任务的三条硬要求

对账和补偿通常挂在定时任务上。定时任务看起来简单,实际有三个必须解决的问题:

要求不做的后果做法
单实例选主两个实例同时补发,重复发奖带 TTL 的租约,续约失败即让位
批次幂等任务重跑,同一批补两次批次内每个业务键记账,应用前查重
断点续跑跑到一半崩溃,从头再来每个键成功后立即写断点,而不是整批写完

选主用租约,不用“启动时抢一次”。租约要有 TTL,持有者定期续约;进程卡死时租约过期,另一个实例才能接管。注意租约只保证“同一时刻大概率只有一个持有者”,网络分区时仍可能出现双持有,所以补偿动作本身必须幂等,不能把选主当成唯一防线。

断点的粒度决定重跑成本。按批次记录“第几批完成”不够:一批一千条,第九百条失败,重跑会把前九百条再做一遍。正确做法是每个业务键应用成功后立即记录,重跑时跳过已记录的键。记录本身要和业务写在同一事务里,否则会出现“记了没做”或“做了没记”。

错过窗口是定时任务最常见的线上问题。调度器宕机两小时,恢复后如果只跑“当前窗口”,中间两个窗口就永久丢了。任务启动时应该从上次断点开始扫描所有到期窗口,按窗口 ID 去重,而不是假设自己一直在运行。

补偿窗口与人工边界

自动补偿要有明确预算,不能无限重试:

  • 确定性差异(missing_remote)示例重试 3 次,间隔 1、5、15 分钟。
  • 三次失败后转工单,任务状态置为 escalated,不再自动执行。
  • 补偿窗口示例为 24 小时;超过窗口的差异只生成报表,不自动补发。

为什么要有窗口?因为越久远的差异,业务上下文越难还原。三天前的活动配置可能已经下线,自动补发会引用不存在的物品 ID;一个月前的支付订单可能已经过了渠道退款期。窗口不是技术限制,是业务可解释性的边界。

转工单时要带够信息:订单号、差异分类、两侧快照、已尝试次数、最后一次错误。只写“补偿失败”的工单,处理人还要从头查一遍,等于没有转。

可运行片段:分类、合并与升级

internal/reconjobs 把上面的规则落成三个函数:Reconcile 按订单号比较两侧快照并分类,Plan 把同一订单的多种差异合并成一条任务,Task.Fail 执行重试预算并在超限时升级。排序保证同样输入永远得到同样输出,便于重跑比对。

// file: internal/reconjobs/reconjobs.go
// tier: L1
// verify: go test ./internal/reconjobs/
package reconjobs

import (
	"sort"
)

// Entry 是对账两侧共用的最小账目字段。
type Entry struct {
	OrderID string
	Account string
	Amount  int64
	Status  string
}

// Kind 是差异分类;分类决定补偿动作,而不是由告警人手工判断。
type Kind string

const (
	MissingRemote Kind = "missing_remote"
	MissingLocal  Kind = "missing_local"
	AmountDiff    Kind = "amount_mismatch"
	StatusDiff    Kind = "status_mismatch"
	Duplicate     Kind = "duplicate"
)

// Diff 描述一条订单在两侧的差异。
type Diff struct {
	Kind     Kind
	OrderID  string
	Expected Entry
	Actual   Entry
}

// Reconcile 以订单号为主键比较两侧快照,输出按订单号排序,保证重跑结果稳定。
func Reconcile(expected, actual []Entry) []Diff {
	exp := make(map[string]Entry, len(expected))
	expDup := make(map[string]bool)
	for _, e := range expected {
		if _, ok := exp[e.OrderID]; ok {
			expDup[e.OrderID] = true
		}
		exp[e.OrderID] = e
	}
	act := make(map[string][]Entry, len(actual))
	for _, a := range actual {
		act[a.OrderID] = append(act[a.OrderID], a)
	}

	var diffs []Diff
	for id, e := range exp {
		rows := act[id]
		if len(rows) == 0 {
			diffs = append(diffs, Diff{Kind: MissingRemote, OrderID: id, Expected: e})
			continue
		}
		if len(rows) > 1 || expDup[id] {
			diffs = append(diffs, Diff{Kind: Duplicate, OrderID: id, Expected: e, Actual: rows[0]})
			continue
		}
		a := rows[0]
		if e.Amount != a.Amount {
			diffs = append(diffs, Diff{Kind: AmountDiff, OrderID: id, Expected: e, Actual: a})
			continue
		}
		if e.Status != a.Status {
			diffs = append(diffs, Diff{Kind: StatusDiff, OrderID: id, Expected: e, Actual: a})
		}
	}
	for id, rows := range act {
		if _, ok := exp[id]; ok {
			continue
		}
		diffs = append(diffs, Diff{Kind: MissingLocal, OrderID: id, Actual: rows[0]})
	}

	sort.Slice(diffs, func(i, j int) bool {
		if diffs[i].OrderID != diffs[j].OrderID {
			return diffs[i].OrderID < diffs[j].OrderID
		}
		return diffs[i].Kind < diffs[j].Kind
	})
	return diffs
}

// Task 是一条可执行的补偿任务;同一订单的多种差异合并为一条任务。
type Task struct {
	OrderID  string
	Kinds    []Kind
	Attempts int
	Max      int
	State    string
}

const (
	Pending   = "pending"
	Escalated = "escalated"
)

// Plan 把差异转成补偿任务。自动重试有上限,超过后转人工工单。
func Plan(diffs []Diff, maxAttempts int) []Task {
	if maxAttempts < 1 {
		maxAttempts = 1
	}
	byOrder := make(map[string][]Kind)
	for _, d := range diffs {
		byOrder[d.OrderID] = append(byOrder[d.OrderID], d.Kind)
	}
	tasks := make([]Task, 0, len(byOrder))
	for id, kinds := range byOrder {
		tasks = append(tasks, Task{
			OrderID: id,
			Kinds:   kinds,
			Max:     maxAttempts,
			State:   Pending,
		})
	}
	sort.Slice(tasks, func(i, j int) bool { return tasks[i].OrderID < tasks[j].OrderID })
	return tasks
}

// Fail 记录一次失败;返回 true 表示本次失败后应转人工。
func (t *Task) Fail() bool {
	t.Attempts++
	if t.Attempts >= t.Max {
		t.State = Escalated
	}
	return t.State == Escalated
}

同一包的 jobs.go 提供租约与断点:Lease.Acquire 用 TTL 控制单实例,Run 在每个键成功后立即记账,批次全部完成才推进 Next。两部分合起来才是完整的对账任务,文章只展开差异分类这一段。

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/reconjobs/
ok  gsarchlab/internal/reconjobs

该包共有 5 个表驱动测试,覆盖差异分类、任务合并、升级边界、租约竞争与断点续跑;退出码为 0。

对账记录该留什么

对账不是跑完就结束,记录本身决定了事后能不能解释清楚。建议每次对账批次至少留下面这些字段:

字段用途常见错误
批次号关联一次对账的全部明细用日期当批次号,补跑会冲突
业务类型发奖、支付、跨服结算、附件只记“对账”
时间窗口本次覆盖的起止时间用任务开始时间代替
两侧数据源与版本渠道导出、内部表快照不记快照版本,无法复现
差异条数分类前的总数只记已修复数
差异分类缺失、重复、金额、状态混合成一个“异常”
处理动作自动补、挂起、转工单只记“已处理”
处理人/系统谁做的决定自动与人工分不清
关联工单转人工后的上下文工单里没有批次号
保留期按财务与合规要求与业务日志同周期清理

保留期要和证据链对齐:支付类对账记录通常需要覆盖退款与申诉窗口,跨服结算记录至少要覆盖一个赛季。记录本身也可能含敏感信息,导出与访问要按附录 B 的方式控制。

一个可用的判据是:半年后如果出现一笔争议订单,仅凭对账记录能否还原“当时两侧各是什么状态、为什么做了这个处理”。还原不了,就说明字段不够。

小结

对账只覆盖“有不可逆副作用且两侧独立维护”的链路。差异必须先分类:只有确定性的缺失适合自动补,金额与状态冲突必须人工。补偿要有预算和窗口,超过边界转工单,并带够上下文。定时任务用租约选主、按键记断点、启动时补扫错过窗口;租约不是唯一防线,补偿动作本身必须幂等。

常见错误有五个:拿同一张表的两份导出做对账;发现差异就补发;断点按批次记录;调度器恢复后只跑当前窗口;转工单只写“失败”。前两个会制造新的资损,后三个会让一次本可自动收场的事故拖成一周的手工核对。

下一章进入跨服链路:匹配、报名与跨服结算。本节的对账夹具会在那里复用,但业务规则——谁有资格、何时锁定、如何回滚——由下一章展开。

阅读导航:上一节:6.2 积压、顺序与死信 · 下一节:7.1 跨服匹配与报名 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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