《MMO 服务器开发》10.2 对账与数据修复工单

跨服结算和本服流水对不上,差在哪、谁来修、修完谁签字?本节先把对账口径钉死——谁对谁、按什么周期、比哪些字段;再给差异分级:金额类必须逐笔一致、展示类可以容忍;然后用 aegis 的 recon 包写一个可跑的对账器,造出若干差异样本,断言分级正确、可镜像的被自动修复、金额冲突的生成带审批留痕的修复工单。

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 是整个对账器的「法律条文」,五条判据按顺序短路求值,值得逐条读:

  1. 两边都在且值相等 → ClassMatch,什么也不做。
  2. 否则若是展示类 → ClassTolerable,故意排在缺边判断之前,这样展示类缺边也容忍,不生成任何动作。
  3. 否则若有一边缺失 → ClassRepairable,因为金额类里「一边有一边无」几乎总是漏记,可以按有值那边镜像。
  4. 剩下的(两边都在、值不等、又是金额类)→ 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 事件溯源与审计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标