渠道说扣款成功,游戏说没有订单
《泡泡工坊》接了一家新的支付渠道。上线第三天,客服收到三张工单:玩家提供了渠道扣款截图,游戏里没有到账记录。查日志发现,渠道回调在 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 跨服匹配与报名 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。