10.2 对账与数据修复工单
上一节的 Saga 保证「尽力把系统拉回一致」,但它保证的是过程,不是结果。补偿会失败、进程会在补偿前崩、消息会丢、时钟会飘,只要有一样发生,两个服务的账本就会对不上。所以 Saga 之上必须再压一层:对账——定期把两边的账摆在一起比,把差找出来。
对账的难点不在写个 diff,而在三件事:口径(谁和谁比、比什么字段)、分级(哪些差异是事故、哪些可以容忍)、处置(能自动修的自己修,修不了的开工单交给人)。这一节把这三件事各写一段代码,全部在 internal/saga/recon/ 包,只用标准库。
对账口径:谁对谁、按什么周期、比什么
「对账」这个词最容易被做错的地方,是没人说得清对的是哪两份账。一个跨服系统里至少有三份账:本服游戏服的结算流水、跨服中心的结算流水、以及支付/渠道侧的充值流水。两两之间都要对,但周期和字段完全不同:
- 本服 ↔ 跨服中心:对的是「一场跨服战场打完,本服扣了什么、中心记了什么」。周期可以短(几分钟一轮),因为两边都是自己的服务。
- 游戏服 ↔ 支付渠道:对的是「玩家充值了多少钱、到账多少点券」。周期按渠道给的对账文件走,通常 T+1。
- 游戏服 ↔ 背包/邮件/拍卖:对的是「物品产出和消耗是否守恒」,属于内部自洽校验。
口径的核心是对账键。两边必须有一个共同的键——通常就是订单号(orderID)。没有共同键的两份账,是没法机械比的,只能靠人工。选定键之后,比什么字段也要写死:金额类的比数值,展示类的比存在性即可。
| 对账对 | 周期 | 对账键 | 比对字段 |
|---|---|---|---|
| 本服 ↔ 跨服中心 | 5 分钟 | orderID | 点券/物品数值 |
| 游戏服 ↔ 支付渠道 | T+1 | 渠道单号 | 充值金额、到账点券 |
| 内部产出 ↔ 消耗 | 每日 | 物品实例 | 数量守恒 |
一张对账表的每一行,都记着「这个键、左边是什么、右边是什么、结论是什么」。结论只有几种:两边一致、差一点点(可容忍)、差得能自动补、差得必须人来判。下一节把这几档落成代码。
差异分级与归因
差异不能一视同仁。把所有差异都报警,运维会被淹没;把所有差异都忽略,事故就漏出去了。正确做法是按业务后果分级:
- 金额类必须逐笔一致:点券、物品数量、结算金额。差 1 点点券都是钱,必须查清。这类差异要么能自动补(一边漏记,按另一边的值镜像过去),要么开单子人工判。
- 展示类可以容忍:昵称、外观、榜面文案。两边不一致顶多是显示不同步,下一轮同步就会对齐,不必惊动任何人。
分级的判据只取决于条目的种类(Kind),跟差多少无关——这是关键:不是「差 5 以内容忍、差 5 以上报警」,而是「展示类一律容忍、金额类一律追究」。这样规则才稳定,不会因为阈值调参而漏掉大额事故。
归因则是回答「这个差异是怎么来的」。常见的三种:漏记(一边写成功、另一边消息丢了)、重复(重试导致一边写了两笔)、口径错(两边对同一个业务用了不同字段)。前两种可以自动修复,第三种只能人工——因为不知道「正确值」该是多少。
可跑的对账器
下面这段是 L1,go test 可以直接跑。它把「分级」和「修复」两件事写进两个方法:Compare 给每个键定级,Apply 修复可修的、给修不了的开工单。
// file: internal/saga/recon/recon.go
// tier: L1
// verify: go test ./internal/saga/recon/
package recon
import (
"fmt"
"sort"
)
// Kind separates money-like entries (must match to the cent) from cosmetic
// ones (a display name may lag without anyone losing anything).
type Kind int
const (
KindAmount Kind = iota // 金额类:点券、物品数量、结算流水
KindDisplay // 展示类:昵称、外观、榜面文案
)
// Class is the verdict the reconciler assigns to one compared key.
type Class int
const (
ClassMatch Class = iota // both sides agree
ClassTolerable // display drift, no action
ClassRepairable // one side missing, mirror the other
ClassTicket // amount mismatch, needs a human
)
// Entry is one line of a ledger. Key is the order id both sides share.
type Entry struct {
Key string
PlayerID uint64
Kind Kind
Value int64
}
// Diff is the comparison of one key across the two sides.
type Diff struct {
Key string
Kind Kind
Left *Entry // nil when the key is absent on the left
Right *Entry // nil when the key is absent on the right
Class Class
}
// Ticket is the durable record of a difference a machine refused to fix.
type Ticket struct {
ID string
Key string
PlayerID uint64
Kind Kind
LeftValue int64
RightValue int64
Reason string
State string // "open" until a human closes it
}
// Reconciler compares two ledgers keyed by order id.
type Reconciler struct{ seq int }
// Compare walks the union of both key sets and grades every key.
func (r *Reconciler) Compare(left, right map[string]Entry) []Diff {
seen := map[string]bool{}
keys := make([]string, 0, len(left)+len(right))
for _, m := range []map[string]Entry{left, right} {
for k := range m {
if !seen[k] {
seen[k] = true
keys = append(keys, k)
}
}
}
sort.Strings(keys)
out := make([]Diff, 0, len(keys))
for _, k := range keys {
d := Diff{Key: k}
if l, ok := left[k]; ok {
d.Left, d.Kind = &l, l.Kind
}
if rr, ok := right[k]; ok {
d.Right, d.Kind = &rr, rr.Kind
}
d.Class = grade(d)
out = append(out, d)
}
return out
}
// grade encodes the whole policy in one place: money mismatches go to a human,
// a one-sided money entry can be mirrored, display drift is ignored.
func grade(d Diff) Class {
switch {
case d.Left != nil && d.Right != nil && d.Left.Value == d.Right.Value:
return ClassMatch
case d.Kind == KindDisplay:
return ClassTolerable
case d.Left == nil || d.Right == nil:
return ClassRepairable
default:
return ClassTicket
}
}
// Apply fixes every repairable diff by writing the missing side, and returns
// the diffs that could not be fixed automatically as tickets.
func (r *Reconciler) Apply(left, right map[string]Entry, diffs []Diff) []Ticket {
var tickets []Ticket
for _, d := range diffs {
switch d.Class {
case ClassRepairable:
if d.Left == nil {
left[d.Key] = *d.Right
} else {
right[d.Key] = *d.Left
}
case ClassTicket:
r.seq++
tickets = append(tickets, Ticket{
ID: fmt.Sprintf("RPT-%04d", r.seq),
Key: d.Key,
PlayerID: pickPlayer(d),
Kind: d.Kind,
LeftValue: val(d.Left),
RightValue: val(d.Right),
Reason: fmt.Sprintf("amount mismatch on %s: left=%d right=%d", d.Key, val(d.Left), val(d.Right)),
State: "open",
})
}
}
return tickets
}
func pickPlayer(d Diff) uint64 {
if d.Left != nil {
return d.Left.PlayerID
}
if d.Right != nil {
return d.Right.PlayerID
}
return 0
}
func val(e *Entry) int64 {
if e == nil {
return 0
}
return e.Value
}
grade 是整个对账器的「法律条文」,五条判据按顺序短路求值,值得逐条读:
- 两边都在且值相等 →
ClassMatch,什么也不做。 - 否则若是展示类 →
ClassTolerable,故意排在缺边判断之前,这样展示类缺边也容忍,不生成任何动作。 - 否则若有一边缺失 →
ClassRepairable,因为金额类里「一边有一边无」几乎总是漏记,可以按有值那边镜像。 - 剩下的(两边都在、值不等、又是金额类)→
ClassTicket,交给人工。
Apply 把可修的真的写回去:left 缺就补到 left,right 缺就补到 right。这一步是幂等的——镜像完之后两边值相同,再跑一次 Compare 就是 ClassMatch,不会重复生成工单。不可修的生成 Ticket,State 固定为 "open",等人工处理。
造样本验证:分级、修复、工单
下面这段是 L1,go test 可以直接跑。它造了两份有五种差异的账,验证分级正确、可镜像的被修复、冲突的生成工单:
// file: internal/saga/recon/recon_test.go
// tier: L1
// verify: go test ./internal/saga/recon/ -v
package recon
import "testing"
func ledger(entries ...Entry) map[string]Entry {
m := map[string]Entry{}
for _, e := range entries {
m[e.Key] = e
}
return m
}
func TestReconGradesRepairsAndTickets(t *testing.T) {
// The game shard recorded four lines; the cross-server center recorded a
// slightly different set. Every interesting case is present once.
game := ledger(
Entry{Key: "o1", PlayerID: 10, Kind: KindAmount, Value: 300}, // agree
Entry{Key: "o2", PlayerID: 20, Kind: KindAmount, Value: 500}, // mismatch
Entry{Key: "o3", PlayerID: 30, Kind: KindAmount, Value: 120}, // game-only
Entry{Key: "o4", PlayerID: 40, Kind: KindDisplay, Value: 1}, // cosmetic drift
)
center := ledger(
Entry{Key: "o1", PlayerID: 10, Kind: KindAmount, Value: 300},
Entry{Key: "o2", PlayerID: 20, Kind: KindAmount, Value: 550},
Entry{Key: "o4", PlayerID: 40, Kind: KindDisplay, Value: 2},
Entry{Key: "o5", PlayerID: 50, Kind: KindAmount, Value: 90}, // center-only
)
r := &Reconciler{}
diffs := r.Compare(game, center)
want := map[string]Class{
"o1": ClassMatch, "o2": ClassTicket, "o3": ClassRepairable,
"o4": ClassTolerable, "o5": ClassRepairable,
}
for _, d := range diffs {
if want[d.Key] != d.Class {
t.Fatalf("key %s class=%v want %v", d.Key, d.Class, want[d.Key])
}
}
tickets := r.Apply(game, center, diffs)
if center["o3"].Value != 120 || game["o5"].Value != 90 {
t.Fatalf("repairable rows not mirrored: o3=%+v o5=%+v", center["o3"], game["o5"])
}
if len(tickets) != 1 || tickets[0].Key != "o2" || tickets[0].State != "open" {
t.Fatalf("tickets=%+v want exactly o2 open", tickets)
}
if tickets[0].LeftValue != 500 || tickets[0].RightValue != 550 || tickets[0].PlayerID != 20 {
t.Fatalf("ticket fields=%+v", tickets[0])
}
}
五个键正好覆盖五种结局:o1 两边一致(无动作)、o2 金额冲突(开工单)、o3 只有本服有(镜像到中心)、o4 展示类不一致(容忍)、o5 只有中心有(镜像到本服)。断言里最要紧的一条是工单只有 o2 一条——如果 o3/o5 也被开了工单,说明分级写错了,把「可镜像」误判成了「需人工」。
工单的留痕是这一节真正的交付物。一张 Ticket 至少要能回答:差在哪个键、属于哪个玩家、两边的值各是多少、为什么修不了、当前是什么状态。没有这些字段,工单就是一句「好像哪里不对」,接到的人还得从头查。生产上 Ticket 会落到一张表里,附带创建时间、处理人、处理结论——谁在什么时候用什么依据改了账,全部可追溯。这正是审计要的东西,也是下一节 10.3 事件溯源要接上的地方。
本节小结 / 实测结论
- 对账先定口径:谁对谁、按什么周期、以什么为对账键、比哪些字段;没有共同键的两份账只能人工。
- 差异按业务后果分级,不按差值大小:金额类必须逐笔一致,展示类可以容忍。
- 可镜像的(一边有一边无)自动修,
Apply幂等,重复跑不会重复补。 - 修不了的(两边都在但值不等)生成
Ticket,State="open",带齐键、玩家、两边值、原因,交人工。 - 工单必须留痕:谁、何时、凭什么、改了哪笔账,全部可追溯。
阅读导航:上一节:10.1 跨服务事务与补偿 · 下一节:10.3 事件溯源与审计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。