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,根子都是这里用了 <= 或 >。边界判据必须只有一处定义,所有判断窗口的地方都调它,不要各写各的。
落到工程上,还有三个必须钉死的点:
- 冻结时刻 = 赛季
end。Settle的frozenAt就取窗口的右端点,快照在该时刻之后不再接受任何写入。此后到达的分数更新,at >= end,按InSeason判为假,归下个赛季。 - 时间戳由服务端盖,不能用客户端上报的时间。否则玩家可以伪造一个「赛季结束前」的时间戳,把下赛季的分数塞进上赛季。
- 窗口用整型毫秒,不用浮点、不用本地时区。赛季边界跨时区是运维事故的常见来源,统一用 UTC 毫秒,展示时再转本地时间。
把这三条合起来看,「最后一秒还在涨分」这个问题就有了确定答案:以服务端盖章的时间戳为准,落在 [start, end) 内的算本赛季,否则算下赛季。不存在「差不多算本赛季」的模糊地带。把四种边界情形列在一起,判据就一目了然:
| 边界情形 | 判据 | 归属 |
|---|---|---|
分数落在 [start, end) 内 | InSeason 为真 | 本赛季 |
分数 at >= end | InSeason 为假 | 下赛季 |
| 结算重跑 | 已存在同 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 邮件投递与去重 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。