《MMO 服务器开发》14.3 结算与赛季边界

赛季结算真正的难点不在发奖,而在边界:最后一秒还在涨分怎么办、崩溃重试会不会重复发奖、结算读的是活榜单还是快照。本节给 aegis 的 rank 包写一套可测的结算流程——冻结快照、按名次分档、发奖幂等,并给出赛季窗口的半开区间判据,把「哪一 tick 属于哪个赛季」钉死。

14.3 结算与赛季边界

14.2 把榜单做得又快又稳,但一个榜单跑得再快,赛季总要结束。结算要做的事听起来简单:读出最终排名,按名次发奖,把榜单清零进入下一个赛季。真正难的不是发奖本身,而是边界。

三个边界会轮流咬人。第一,最后时刻还在涨分:玩家在赛季结束的最后一秒还在打怪,这条分数算本赛季还是下赛季?如果结算读的是「活榜单」,读的过程中榜单还在变,名次就不确定了。第二,崩溃重试:发奖发到一半进程挂了,重启后重跑,会不会给前一半玩家发两次?第三,窗口归属:赛季结束是一个精确的时刻,而分数更新带着自己的时间戳,两者怎么对齐才不会漏发或错发。

这一节把结算拆成三步——冻结快照、按档发奖、幂等重试——每一步都写成能跑的代码。所有代码仍在 internal/rank 包,只用标准库。

冻结:结算读的是一份快照

第一原则:结算绝不能读活榜单。只要榜单还在被写入,读到的名次就是「某一瞬间的近似」,两个玩家可能读到不一致的名次。正确做法是先冻结——把榜单的前 N 名复制成一份不可变的快照,此后所有结算逻辑只认这份快照,活榜单照常进入下个赛季。

冻结还有一个容易忽略的细节:快照要多大?结算只需要发奖名次覆盖到的那一段——如果最低奖励是「前 100 名」,就只冻结前 100,不必把整张榜都复制下来。这既省内存,也让 Settle 的输入保持小而确定;冻结之外的名次只留一个总数用于展示即可。

Settle 接收的 snapshot 就是这份冻结结果,它由调用方在赛季锁内用 Board.TopK 一次性取出。下面这段是 L1,go test 可以直接跑。它把快照按名次分档成奖励,并把结果记成一份可重放的 Settlement:

// file: internal/rank/settlement.go
// tier: L1
// verify: go test ./internal/rank/ -run TestSettlement -v

package rank

// RewardTier assigns a reward to a contiguous, 1-based, inclusive rank range.
// Ranks are read from a frozen snapshot, so a late score update can never
// half-apply a tier.
type RewardTier struct {
	From   int
	To     int
	ItemID uint32
	Amount int64
}

// Grant is one item reward handed to a player.
type Grant struct {
	ItemID uint32
	Amount int64
}

// Settlement is the immutable result of closing one season. It is keyed by
// SeasonID so replaying the same season is a no-op: Deliver checks Delivered
// before it sends, which is how duplicate rewards are prevented.
type Settlement struct {
	SeasonID  int64
	FrozenAt  int64
	Ranks     map[uint64]int     // player -> final rank
	Rewards   map[uint64][]Grant // player -> grants
	Delivered map[uint64]bool
}

// Settle freezes a board's top standings into a Settlement. snapshot must be the
// TopK list captured under the season lock at frozenAt; anything submitted at or
// after frozenAt belongs to the next season, not this one.
func Settle(seasonID, frozenAt int64, snapshot []Entry, tiers []RewardTier) *Settlement {
	s := &Settlement{
		SeasonID:  seasonID,
		FrozenAt:  frozenAt,
		Ranks:     make(map[uint64]int, len(snapshot)),
		Rewards:   make(map[uint64][]Grant),
		Delivered: make(map[uint64]bool),
	}
	for i, e := range snapshot {
		rankNo := i + 1
		s.Ranks[e.PlayerID] = rankNo
		for _, t := range tiers {
			if rankNo >= t.From && rankNo <= t.To {
				s.Rewards[e.PlayerID] = append(s.Rewards[e.PlayerID], Grant{ItemID: t.ItemID, Amount: t.Amount})
			}
		}
	}
	return s
}

// Deliver hands a player their rewards exactly once. A second call for the same
// player is a no-op, which makes the whole settlement safe to retry after a
// crash in the middle of distribution.
func (s *Settlement) Deliver(id uint64, send func(itemID uint32, amount int64)) bool {
	if s.Delivered[id] {
		return false
	}
	for _, g := range s.Rewards[id] {
		send(g.ItemID, g.Amount)
	}
	s.Delivered[id] = true
	return true
}

// InSeason reports whether a score update timestamped at belongs to the season
// window [start, end). Updates at or after end are the next season's.
func InSeason(at, start, end int64) bool {
	return at >= start && at < end
}

RewardTier 用的是闭区间 [From, To],因为它描述的是「第几名到第几名」这种人类语言,1 起且两端都算。注意多个档可以重叠:一个玩家既可能是「冠军档」也是「全员参与档」,Settle 会把两档的奖励都追加进 Rewards[id],这是有意为之——分档奖励本来就可能叠加,不是互斥的。测试里 5 个玩家、3 个档,正好把「一人多档」和「只中一档」两种情形都覆盖:

// file: internal/rank/settlement_test.go
// tier: L1
// verify: go test ./internal/rank/ -run TestSettlement -v

package rank

import "testing"

func TestSettlementTiersAndIdempotency(t *testing.T) {
	snap := []Entry{
		{PlayerID: 10, Score: 500},
		{PlayerID: 20, Score: 400},
		{PlayerID: 30, Score: 300},
		{PlayerID: 40, Score: 200},
		{PlayerID: 50, Score: 100},
	}
	tiers := []RewardTier{
		{From: 1, To: 1, ItemID: 9001, Amount: 1},   // champion
		{From: 2, To: 3, ItemID: 9002, Amount: 1},   // runner-up band
		{From: 1, To: 5, ItemID: 9003, Amount: 100}, // participation
	}
	s := Settle(7, 1_000, snap, tiers)

	if s.Ranks[10] != 1 || s.Ranks[50] != 5 {
		t.Fatalf("ranks=%v", s.Ranks)
	}
	if len(s.Rewards[10]) != 2 { // champion + participation
		t.Fatalf("player10 rewards=%v", s.Rewards[10])
	}
	if len(s.Rewards[20]) != 2 { // runner-up + participation
		t.Fatalf("player20 rewards=%v", s.Rewards[20])
	}
	if len(s.Rewards[40]) != 1 || s.Rewards[40][0].ItemID != 9003 {
		t.Fatalf("player40 rewards=%v", s.Rewards[40])
	}

	sent := map[uint64]int{}
	mk := func(id uint64) func(uint32, int64) {
		return func(item uint32, amount int64) { sent[id]++ }
	}
	if !s.Deliver(10, mk(10)) {
		t.Fatal("first deliver should report true")
	}
	if s.Deliver(10, mk(10)) {
		t.Fatal("second deliver must be a no-op")
	}
	if sent[10] != 2 {
		t.Fatalf("player10 sends=%d want 2", sent[10])
	}
}

func TestSeasonBoundary(t *testing.T) {
	start, end := int64(1000), int64(2000)
	cases := []struct {
		at   int64
		want bool
	}{
		{999, false},  // before the season
		{1000, true},  // inclusive start
		{1999, true},  // last tick that belongs to the season
		{2000, false}, // exclusive end: the next season owns this tick
		{2001, false},
	}
	for _, c := range cases {
		if got := InSeason(c.at, start, end); got != c.want {
			t.Fatalf("InSeason(%d)=%v want %v", c.at, got, c.want)
		}
	}
}

赛季窗口与边界秒

赛季窗口用半开区间 [start, end),这是整章唯一不能含糊的约定。为什么不是闭区间 [start, end]?因为赛季是首尾相接的:这个赛季的 end 就是下个赛季的 start。如果用闭区间,边界那一 tick 会同时属于两个赛季,被结算两次。半开区间让每个时刻恰好属于一个赛季,不留缝隙也不重叠。

InSeason 把这条规则写成一行:at >= start && at < end。看似平淡,但赛季系统里绝大多数「少发一份奖」和「发两份奖」的 bug,根子都是这里用了 <= 或 >。边界判据必须只有一处定义,所有判断窗口的地方都调它,不要各写各的。

落到工程上,还有三个必须钉死的点:

  1. 冻结时刻 = 赛季 end。Settle 的 frozenAt 就取窗口的右端点,快照在该时刻之后不再接受任何写入。此后到达的分数更新,at >= end,按 InSeason 判为假,归下个赛季。
  2. 时间戳由服务端盖,不能用客户端上报的时间。否则玩家可以伪造一个「赛季结束前」的时间戳,把下赛季的分数塞进上赛季。
  3. 窗口用整型毫秒,不用浮点、不用本地时区。赛季边界跨时区是运维事故的常见来源,统一用 UTC 毫秒,展示时再转本地时间。

把这三条合起来看,「最后一秒还在涨分」这个问题就有了确定答案:以服务端盖章的时间戳为准,落在 [start, end) 内的算本赛季,否则算下赛季。不存在「差不多算本赛季」的模糊地带。把四种边界情形列在一起,判据就一目了然:

边界情形判据归属
分数落在 [start, end) 内InSeason 为真本赛季
分数 at >= endInSeason 为假下赛季
结算重跑已存在同 SeasonID 记录补发未发完的
发奖中途崩溃逐玩家 Delivered 标记断点续发

最后一点关于时钟:赛季边界用的是绝对时间,而服务器可能重启、可能跨时区部署、可能有多台机器。所以窗口的 start/end 必须在配置里写死为 UTC 毫秒,不能靠「当前时间 + 一个偏移」在运行时算——那会在夏令时切换或运维改机器时间时出错。把边界当作数据而不是当作算出来的东西,是避免这类事故最简单的一条纪律。展示层要显示「还剩 3 天」,那是渲染的事,不能反过来影响判定。

发奖幂等与重试

结算最怕的不是算错,是发重。发奖要经过「查名单 → 发邮件/加背包 → 记录已发」三步,任何一步之间进程崩溃,重启后都要能安全重跑。如果重跑时无脑再发一遍,前一半玩家就拿了双份奖励——这在经济系统里是灾难。

Deliver 的幂等靠一个 Delivered map[uint64]bool:发之前先查,发完立刻置位,第二次调用直接返回 false 不发。这里的关键是置位和发送的顺序与语义:

  • 先查 Delivered,已发则返回 false——这是重试安全的入口。
  • 发送本身如果可能失败(邮件服务超时),要么把「已发」标记放在发送成功之后(可能重发,宁可重发不可漏发,配合下游去重),要么把发送做成事务性的(邮件落库和标记同一次提交)。本卷的 send 是个同步回调,所以直接「发完置位」。
  • 返回值区分「这次真的发了」和「之前发过了」,让调用方能统计「本轮实际发出多少份」,方便对账。

真正的幂等键是 SeasonID。同一赛季的结算结果只应该存在一份 Settlement;重跑时应该复用已有的那份,而不是 Settle 出新的一份再发。工程上的写法是:结算入口先用 SeasonID 查有没有已存在的结算记录,有就直接进入「补发未发完的玩家」分支。这样即便发奖中途崩溃,重启后也是接着没发完的继续,而不是从头再来。

这里有一个顺序上的坑:Settle 和 Deliver 之间不能插入任何「不可重放」的操作。如果 Settle 之后先改了榜单(比如清零),再进入发奖,那么发奖崩溃重启时榜单已经没了,快照也没落盘,就再也算不出名次了。所以正确顺序是「冻结快照 → 持久化结算记录 → 发奖 → 全部发完后才重置榜单」。冻结在前、重置在后,中间任何一步崩溃都能靠重跑补齐。

步骤崩溃后果重启后怎么补
冻结快照快照未落盘重新 TopK 再冻结
落结算记录记录未落盘重跑 Settle
逐玩家发奖发了一半补发未发完的玩家
重置榜单不该在此时发生仅在全部发完后执行

一个容易忽略的细节:Delivered 是逐玩家的,不是整个赛季一把梭。如果做成「整个赛季一个 applied 标志」,那么发到第 500 个玩家时崩溃,重启后这个标志还是 false,会从头重发前 500 个。逐玩家标记的代价是记录更多,但换来的是精确的断点续发——这正是「发奖可重试」和「发奖幂等」的区别:前者要求能接着跑,后者要求不重复。两者都要满足,粒度就必须细到玩家。

结算还应该留下可审计的痕迹:每一份 Settlement 记下赛季号、冻结时刻、每个玩家的最终名次与所发奖励。这样一旦有玩家申诉「我明明是第 3 名却没收到奖」,可以直接查这份记录,而不是去翻当时的榜单——那个榜单早就被清空进入下赛季了。冻结快照在这里的价值再次显现:它不只是发奖的输入,也是事后对账的唯一依据。

本节小结

  • 结算绝不读活榜单:先用 Board.TopK 在赛季锁内冻结一份快照,此后只认快照。
  • 分档奖励用闭区间 [From, To](1 起、两端含),多个档可叠加,不是互斥。
  • 赛季窗口用半开区间 [start, end),让每个时刻恰好属于一个赛季,避免边界重发或漏发。
  • 时间戳必须由服务端盖章、用整型毫秒、统一 UTC;冻结时刻取窗口右端点。
  • 发奖幂等靠逐玩家的 Delivered 标记,粒度细到玩家才能「断点续发且不重复」。
  • 幂等键是 SeasonID:同一赛季复用同一份结算记录,重跑只补发未发完的部分。

阅读导航:上一节:14.2 写缓冲与分段榜单 · 下一节:15.1 邮件投递与去重 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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