房间没了,玩家还在线
《裂隙战场》的一台房间服进程因为内存泄漏被系统杀掉,上面有四十多场进行中的对局。没有跨机热备,房间状态只在本机内存和一份异步快照里。进程重启后,恢复流程从最近快照开始回放事件日志:有的房间回到了十秒前,有的房间因为快照太旧直接判定失效,客户端收到“本局已结束”。
玩家的反馈很具体:技能冷却倒退了、击杀数少了一个、正在施放的大招没了。这些都不是“数据错误”,而是恢复点选择的必然结果。房间状态不是账本,它允许回退,但必须让玩家感知一致:要么整局回退到同一个时刻,要么明确告知本局作废。
《群岛纪元》的问题更难。世界状态恢复后,离线收益要按“上次登录到恢复点”重新计算;如果恢复点比玩家实际在线时间早,玩家会看到收益变少;如果补偿逻辑又在恢复后重发了一次,玩家会拿到双份。时间在游戏里不是单调的,回档会让它“跳回去”,而所有依赖时间的结算都必须能识别这种跳跃。
这一节讨论:有状态服务恢复到哪个时刻、快照与事件日志怎么约定、重连如何补偿、重复结算如何拦截,以及恢复顺序为什么必须先账号资产、后玩法。
恢复到哪个时刻:快照加事件日志
有状态服务的恢复单位不是“一台机器”,而是“一个状态机在某个序号上的值”。最常用的组合是:
- 快照:某一序号
Seq上的完整状态,写入对象存储,带校验和。 - 事件日志:
Seq+1之后的所有增量,按序号连续追加。 - 恢复目标:一个明确的
target Seq,通常是故障前最后一个已确认的序号。
恢复过程是“加载快照,回放 Seq+1..target 的事件”。这条链路有三个硬约束:
- 序号连续。少一条事件,恢复出的状态就是错的,而且往往错得很隐蔽——金额差一点、冷却差几秒。发现跳号必须失败,不能跳过。
- 事件幂等可重放。同一条事件回放两次,结果必须和一次相同;否则“重试恢复”本身会制造新数据。
- 快照与日志同源。快照的
Seq必须精确对应日志位点。用“快照时间”去猜位点,在并发写入下一定会错位。
快照频率是成本与 RTO 的权衡:快照越密,恢复要回放的事件越少、RTO 越短,但快照本身的写入与存储成本越高。示例目标:房间状态每 30 秒一次增量快照、每 5 分钟一次全量;世界状态每小时一次全量、事件日志持续追加。这些是设计目标,不是实测值。
快照与事件日志的契约
| 字段 | 快照 | 事件 | 约束 |
|---|---|---|---|
| 房间/世界 ID | 必须有 | 必须有 | 恢复时不可跨对象拼接 |
序号 Seq | 快照覆盖到的最后序号 | 本条事件的序号 | 严格递增、无空洞 |
| 时间 | 快照生成时间 | 事件发生时间 | 用于 TTL 与时间跳跃判定 |
| 校验 | 内容校验和 | 事件体校验和 | 损坏必须拒绝,不可“尽力恢复” |
| 保留 | 按恢复目标保留 | 至少覆盖到上一个全量快照 | 日志短于快照间隔 = 无法恢复 |
最后一行是很多团队踩过的坑:事件日志只保留 24 小时,全量快照每周一次。中间六天没有任何可回放路径,备份看起来齐全,实际只能恢复到上周。
事件日志还要区分“已确认”和“已写入”。客户端收到“操作成功”之前的事件,恢复时可以丢弃;已经确认的事件,恢复时必须存在。这个边界要和网络层、存储层的确认语义对齐,否则会出现“玩家看到成功、恢复后消失”的投诉。
重连补偿:玩家离线这段时间发生了什么
重连不是“重新登录”,而是“把玩家断线期间的状态变化补给他”。补偿计划要回答三个问题:从哪个序号开始补、补多久、补不了怎么办。
| 情况 | 处理 | 玩家感知 |
|---|---|---|
| 快照新、事件全 | 回放 Seq+1..current | 无缝续上 |
| 快照旧但在 TTL 内 | 回放更长区间 | 短暂加载 |
| 超过 TTL | 不再回放,从新状态开始 | 本局作废或重新入场 |
| 事件有空洞 | 视为不可恢复 | 明确告知,不假装成功 |
TTL 是产品与技术共同定的:房间对局可能只允许 60 秒内重连,SLG 的世界状态可能允许几小时。超过 TTL 还强行回放,会让玩家看到一个“和队友描述不一致”的战场,比直接作废更糟。
“时间跳跃”要单独处理。恢复点早于玩家最后在线时间时,所有基于“当前时间减去上次时间”的计算都会变大:离线收益、建筑完成、体力恢复。正确做法是把恢复点作为新的时间基准,并把“跳跃区间”记进结算上下文;重复计算的部分用幂等键拦截,而不是靠人工核对。
恢复后的正确性:幂等与对账
恢复之后最常见的二次事故不是“没恢复”,而是“恢复了两次”。典型链路:
- 结算服务已经给玩家发了赛季奖励,但恢复点早于发奖时刻。
- 恢复后,定时任务发现“该玩家还没领奖”,于是再发一次。
- 玩家拿到双份,或者邮件里出现两封内容相同的附件。
防线有两层:幂等键保证同一个业务动作只生效一次;对账发现“账本和事实不一致”的情况。幂等键要由业务语义生成,例如 赛季ID/玩家ID/奖励档位,而不是由请求 ID 生成——请求重试会换 ID,业务动作不会。
幂等表本身也会丢。如果幂等表和业务数据在同一个库、同一次恢复里回退,那么“已经发过”的记录也没了。所以幂等键要尽量和业务结果同事务提交;跨服务时,用对账兜底,参见对账、补偿与定时任务 。
还有一类重复来自客户端:切换或重连期间,玩家重复点击“领取”。服务端不能假设客户端只发一次;幂等键必须从第一次请求就带上,并在恢复后仍然有效。
降级恢复:先让人进来,再让玩法转起来
灾难恢复不是“全部恢复才算成功”。更现实的目标是分级恢复:先恢复登录与资产,让玩家能进来;再恢复匹配与房间;最后恢复世界状态与排行榜。顺序由依赖关系决定,而不是由系统重要性决定。
账号与登录
|
+--> 玩家资产(背包、货币)
| |
| +--> 商城与支付确认
| |
| +--> 匹配与房间
| |
| +--> 对局结算
|
+--> 社交与聊天(可独立降级)
世界状态(SLG)
|
+--> 联盟与占领
+--> 赛季与排行榜
+--> 离线推进与定时结算
这张图的用法是排恢复批次:第一批只恢复账号、登录、资产读;第二批恢复资产写与支付确认;第三批恢复匹配、房间与结算;世界状态单独一批。每批都要有“可服务”的判定标准,而不是“进程起来了”。
降级恢复的代价是状态不一致窗口。玩家能登录,但看不到联盟;能匹配,但结算延迟。这些窗口要提前告诉客服和运营,否则会被当成新故障。降级开关本身也要演练:一个从未被打开过的开关,在事故时大概率打不开。
可运行片段:恢复、重连与幂等
internal/statedr 把恢复链路拆成可测试的函数。statedr.go 负责快照恢复与重连计划:Restore 要求事件从 Seq+1 开始连续,跳号返回 ErrGap、重复返回 ErrDuplicate、目标未达到返回 ErrIncomplete;PlanReconnect 在快照超过 TTL 时拒绝回放,避免用增量解释一个已经无法解释的状态。
// file: internal/statedr/statedr.go
// tier: L1
// verify: go test ./internal/statedr/
package statedr
import (
"errors"
"fmt"
)
var (
ErrGap = errors.New("event gap")
ErrDuplicate = errors.New("duplicate sequence")
ErrIncomplete = errors.New("target sequence not reached")
)
// Event 是房间状态的一条增量;Seq 必须连续,不能跳号。
type Event struct {
Seq uint64
PlayerID string
Delta int64
AtUnixMs int64
}
// Snapshot 是某一时刻的房间状态;Balances 在恢复时会被复制,调用方无需加锁。
type Snapshot struct {
RoomID string
Seq uint64
AtUnixMs int64
Balances map[string]int64
}
func (s Snapshot) clone() Snapshot {
out := Snapshot{
RoomID: s.RoomID,
Seq: s.Seq,
AtUnixMs: s.AtUnixMs,
Balances: make(map[string]int64, len(s.Balances)),
}
for k, v := range s.Balances {
out.Balances[k] = v
}
return out
}
// Restore 把快照推进到 target。事件必须从 Seq+1 开始连续;跳号或重复都返回错误,
// 因为“少一条事件”比“恢复失败”危险得多。
func Restore(s Snapshot, events []Event, target uint64) (Snapshot, int, error) {
out := s.clone()
if target <= s.Seq {
return out, 0, nil
}
expect := s.Seq + 1
applied := 0
for _, e := range events {
if e.Seq <= s.Seq {
continue
}
if e.Seq > target {
break
}
switch {
case e.Seq < expect:
return Snapshot{}, 0, fmt.Errorf("%w: seq=%d", ErrDuplicate, e.Seq)
case e.Seq > expect:
return Snapshot{}, 0, fmt.Errorf("%w: want=%d got=%d", ErrGap, expect, e.Seq)
}
out.Balances[e.PlayerID] += e.Delta
out.Seq = e.Seq
if e.AtUnixMs > out.AtUnixMs {
out.AtUnixMs = e.AtUnixMs
}
expect++
applied++
}
if out.Seq < target {
return out, applied, fmt.Errorf("%w: target=%d reached=%d", ErrIncomplete, target, out.Seq)
}
return out, applied, nil
}
// Reconnect 是重连补偿计划;Expired 为 true 时客户端必须接受“从新状态开始”。
type Reconnect struct {
RoomID string
FromSeq uint64
ToSeq uint64
Replay []Event
Expired bool
Reason string
}
// PlanReconnect 计算断线重连需要补发的事件。快照超过 TTL 时不再回放,
// 因为长时间离线后的状态已经无法用增量解释,继续回放只会制造幻觉。
func PlanReconnect(s Snapshot, events []Event, current uint64, nowMs, ttlMs int64) Reconnect {
rc := Reconnect{RoomID: s.RoomID, FromSeq: s.Seq, ToSeq: current}
if ttlMs > 0 && nowMs-s.AtUnixMs > ttlMs {
rc.Expired = true
rc.Reason = "snapshot_expired"
return rc
}
if current <= s.Seq {
rc.Reason = "already_current"
return rc
}
expect := s.Seq + 1
for _, e := range events {
if e.Seq <= s.Seq || e.Seq > current {
continue
}
if e.Seq != expect {
rc.Expired = true
rc.Reason = "event_gap"
rc.Replay = nil
return rc
}
rc.Replay = append(rc.Replay, e)
expect++
}
if expect != current+1 {
rc.Expired = true
rc.Reason = "event_gap"
rc.Replay = nil
}
return rc
}
同一包的 idempotent.go 负责重复拦截:RequestLog.Once 按键缓存成功结果,失败不缓存,保证客户端重试仍有机会成功;Ledger.Grant 以业务键去重后入账,重复请求返回 false 且余额不变。两者都不依赖数据库,测试可以完全在内存里复现。
// file: internal/statedr/idempotent.go
// tier: L1
// verify: go test ./internal/statedr/
package statedr
import "errors"
// ErrNegativeAmount 拒绝负额度,避免“幂等”掩盖金额错误。
var ErrNegativeAmount = errors.New("amount must be non-negative")
// RequestLog 是有限容量的幂等键表:按键缓存成功结果,重复请求直接返回旧值。
type RequestLog struct {
capacity int
results map[string]int64
order []string
}
func NewRequestLog(capacity int) *RequestLog {
if capacity <= 0 {
capacity = 1
}
return &RequestLog{
capacity: capacity,
results: make(map[string]int64, capacity),
}
}
// Once 执行 fn 并在成功后记住结果;replayed 为 true 表示没有再次执行 fn。
// 失败不缓存,这样客户端重试仍有机会成功。
func (l *RequestLog) Once(key string, fn func() (int64, error)) (result int64, replayed bool, err error) {
if v, ok := l.results[key]; ok {
return v, true, nil
}
v, err := fn()
if err != nil {
return 0, false, err
}
l.results[key] = v
l.order = append(l.order, key)
if len(l.order) > l.capacity {
oldest := l.order[0]
l.order = l.order[1:]
delete(l.results, oldest)
}
return v, false, nil
}
// Ledger 是奖励账本;同一个 grant key 只生效一次,重复请求返回 false。
type Ledger struct {
applied map[string]struct{}
Balance int64
}
func NewLedger() *Ledger {
return &Ledger{applied: make(map[string]struct{})}
}
// Grant 以业务键去重后入账。返回 false 表示这是重复请求,账本没有变化。
func (l *Ledger) Grant(key string, amount int64) (bool, error) {
if amount < 0 {
return false, ErrNegativeAmount
}
if _, ok := l.applied[key]; ok {
return false, nil
}
l.applied[key] = struct{}{}
l.Balance += amount
return true, nil
}
本机验证命令与结果:
cd .local/checks/gsarch-lab
gofmt -l internal/statedr
go vet ./internal/statedr/
go test -count=1 ./internal/statedr/
gofmt -l 无输出,go vet 退出码 0,go test 输出 ok gsarchlab/internal/statedr,退出码 0。该包有 6 个表驱动测试,覆盖连续回放、重复序号、事件空洞、目标未达、快照不可变、重连 TTL 与空洞、幂等缓存与淘汰、账本去重。
失败模式
失败模式一:跳过空洞继续恢复。 恢复脚本发现缺一条事件,选择“先恢复能恢复的”。这会把一个可检测的故障变成一个不可解释的状态差异。
失败模式二:用快照时间猜位点。 快照写入和事件追加是并发的,时间戳不能替代序号。位点必须由写入方在生成快照时显式记录。
失败模式三:重连无上限。 玩家离线一小时后仍尝试回放,回放出一堆已经过期的状态。TTL 不是技术限制,是产品可解释性的边界。
失败模式四:幂等键用请求 ID。 客户端重试会生成新 ID,幂等表形同虚设。键必须来自业务语义。
失败模式五:恢复后立刻放开所有写入。 定时任务、补偿任务、运营后台同时开始写,重复结算集中爆发。恢复后应先冻结写侧,完成对账再逐步放开。
小结
有状态服务的恢复单位是“某个序号上的状态”,不是一台机器。快照提供基线,事件日志提供增量,两者靠连续的序号绑定;跳号必须失败,重复必须拦截。重连补偿要有 TTL,超过边界就从新状态开始,不能假装无缝。恢复后的正确性靠业务语义的幂等键与对账兜底,而不是靠“应该不会重复”的假设。降级恢复按依赖顺序分批进行,先让人进来,再让玩法转起来。
到这里,第 8 章的三个问题——数据还在不在、服务什么时候回来、有状态的东西怎么救——已经分别展开。下一章转向成本:这些容灾能力各自值多少钱,又该怎么归因到具体产品。
阅读导航:上一节:8.2 主备、双活与故障切换 · 下一节:9.1 成本归因与单位经济 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。