《游戏服务端架构·品类、状态与拓扑》11.2 有状态服务的边界

《裂隙战场》的房间服在滚动重启时丢掉进行中的对局,租约过期后新旧两个房间进程同时写结算,造成双份奖励。本节比较状态外置、分区归属、主备租约三条出路,给出房间服、地图服、会话服的边界,并用带 fencing epoch、续约、drain 与双主拒绝的 Go 租约注册表说明重启、迁移和缩容时如何不丢状态。

滚动重启丢掉的那一局

《裂隙战场》每周三凌晨做滚动重启。房间服的设计是“先摘流量,再等 90 秒,然后退出”。第七周,一位玩家在重启窗口里打排位:房间 A 收到退出信号,tick 停止,但结算任务还没写完;90 秒后进程退出,这局状态丢失。补偿脚本按“异常结束”发了金币,玩家却掉了段位。

同一个月还有一次更危险的事故。房间 B 的租约到期,分配器把同一间房分给了新进程;旧进程只是暂停了 40 秒(GC 停顿加上宿主机迁移),恢复后继续写结算。两个进程都认为自己是权威,同一局结算了两次。第一次丢的是数据,第二次丢的是信任。

这两次事故指向同一个问题:有状态服务的边界不是“进程在哪里”,而是“谁在什么时刻拥有哪份状态,以及旧拥有者如何被挡住”。这一节讨论三条出路、三类服务的边界,以及一套可执行的租约不变量。

无状态是手段,不是目标

“无状态服务”经常被当成架构目标,但它只是一种手段:把状态移到别处,让进程可以随时替换。问题在于,不是所有状态都能移走。

状态能否外置原因
钱包余额、订单能需要持久化、审计和幂等,本来就属于数据库
会话令牌、路由表能丢失后可以重连,重建成本低
匹配队列能队列可以重建,短暂重复可接受
房间 tick 的输入缓冲不能每帧都在变,外置一次就多一次 RTT 和一致性问题
战斗中的物理与技能状态不能高频、强时序,权威状态必须贴着 tick
客户端连接本身不能socket 绑定在进程上,迁移只能重建连接

所以正确的问法不是“这个服务能不能无状态”,而是“哪些状态必须留在进程内,哪些可以外置,留下的部分由谁保证唯一”。房间服、地图服、会话服的答案并不相同。

三条出路与各自代价

有状态服务只有三条基本出路,其余方案都是它们的组合:

出路做法适用主要代价典型失败
状态外置状态写 Redis、数据库或对象存储会话、钱包、匹配队列多一次网络往返,存在一致性窗口写成功但响应丢失,客户端重试造成重复
分区归属每个分区固定由一个进程拥有房间、地图区块迁移需要排空与快照分区表过期,两个进程都以为自己拥有
主备与租约一个主,备选在租约过期后接管分配器、会话路由、全局配置故障切换有延迟旧主暂停后恢复,继续写已易主的状态

三条出路可以叠加。比如地图服把区块状态留在内存(分区归属),每 30 秒做一次快照到对象存储(状态外置),再用租约决定哪个进程是区块主(主备)。关键不是选一条,而是对每一类状态明确:权威副本在哪、写入如何排序、旧副本如何被挡住。

房间服、地图服、会话服的边界

服务权威状态可否外置迁移单位重启后果
房间服tick、输入缓冲、战斗状态否单间房该局丢失,需补偿
地图服区块实体、行军、资源点部分(快照)单个区块回滚到最近快照,可能丢数秒
会话服连接、令牌、路由是单个会话客户端重连,重建路由
经济服余额、流水、订单是单个账号只要账本和幂等键正确,不丢数据

房间服的迁移单位必须小到“一局”,否则一次发布影响所有对局;地图服的迁移单位必须小到“一个区块”,否则全球同服会变成全球停机;会话服的迁移单位是“一个会话”,所以它可以做得最像无状态服务。大厅、房间与分配器的标准形态见3.2 大厅、房间与战斗:竞技品类的标准形态 ,断线重连的会话细节见5.3 会话、重连、踢人与迁移 。

四条租约不变量

无论用 etcd、Redis 还是数据库实现,租约至少要满足四条不变量:

  1. 同一分区最多一个活着的拥有者。 获取租约必须是条件写,不能用“先读后写”。
  2. epoch 单调递增。 每次接管 epoch 加一,写入路径携带 epoch,存储端拒绝旧 epoch。
  3. 续约必须发生在到期之前。 TTL 要覆盖最坏停顿,包括 GC、宿主机迁移和网络抖动。
  4. drain 阻止新拥有者,release 只接受匹配的 fence。 迁移期间不能让第二个进程提前接管。

这四条里最容易被忽略的是第二条。只检查“谁是 owner”不够:旧进程可能在失去租约后恢复,它手里仍然有 owner 名字。只有 epoch(或 fencing token)能让存储端识别“这是上一任的写”。

用代码表达租约

下面的 internal/stateful 把四条不变量写成一个可测试的租约注册表。它用内存 map 代替生产环境的条件写,但错误语义和状态迁移与 etcd/Redis 实现一致:

// file: internal/stateful/stateful.go
// tier: L1
// verify: go test ./internal/stateful/
package stateful

import (
	"errors"
	"sort"
	"strings"
	"sync"
	"time"
)

var (
	ErrEmptyPartition = errors.New("stateful: partition must not be blank")
	ErrEmptyOwner     = errors.New("stateful: owner must not be blank")
	ErrNoLease        = errors.New("stateful: partition has no lease")
	ErrLeaseExpired   = errors.New("stateful: lease expired; take over with a new epoch")
	ErrNotOwner       = errors.New("stateful: owner or epoch does not match the lease")
	ErrDualPrimary    = errors.New("stateful: another owner holds a live lease")
	ErrDraining       = errors.New("stateful: partition is draining; new owners are rejected")
)

// Lease is the right to serve one partition. Epoch increases on every
// takeover, so a paused old owner cannot write after the new owner starts.
type Lease struct {
	Partition string
	Owner     string
	Epoch     uint64
	ExpiresAt time.Time
}

// Registry is the lease store's contract. In production the same operations
// run as conditional writes (compare owner and epoch) in etcd, Redis or a
// database; the map here only makes the rules executable in tests.
type Registry struct {
	mu       sync.Mutex
	now      func() time.Time
	ttl      time.Duration
	leases   map[string]Lease
	draining map[string]bool
}

func NewRegistry(ttl time.Duration, now func() time.Time) *Registry {
	if now == nil {
		now = time.Now
	}
	return &Registry{
		now: now, ttl: ttl,
		leases:   make(map[string]Lease),
		draining: make(map[string]bool),
	}
}

// Acquire takes a partition for owner. A live lease held by the same owner is
// renewed; a live lease held by anyone else is a dual-primary attempt. An
// expired lease is taken over with a higher epoch, unless the partition is
// draining, in which case the migration must finish first.
func (r *Registry) Acquire(partition, owner string) (Lease, error) {
	if strings.TrimSpace(partition) == "" {
		return Lease{}, ErrEmptyPartition
	}
	if strings.TrimSpace(owner) == "" {
		return Lease{}, ErrEmptyOwner
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	now := r.now()
	if old, ok := r.leases[partition]; ok && now.Before(old.ExpiresAt) {
		if old.Owner != owner {
			if r.draining[partition] {
				return Lease{}, ErrDraining
			}
			return Lease{}, ErrDualPrimary
		}
		old.ExpiresAt = now.Add(r.ttl)
		r.leases[partition] = old
		return old, nil
	}
	if r.draining[partition] {
		return Lease{}, ErrDraining
	}
	lease := Lease{Partition: partition, Owner: owner, Epoch: 1, ExpiresAt: now.Add(r.ttl)}
	if old, ok := r.leases[partition]; ok {
		lease.Epoch = old.Epoch + 1
	}
	r.leases[partition] = lease
	return lease, nil
}

// Renew extends a live lease. The owner and epoch must both match: matching
// only the owner would let a paused process resume after a takeover.
func (r *Registry) Renew(partition, owner string, epoch uint64) (Lease, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	lease, ok := r.leases[partition]
	if !ok {
		return Lease{}, ErrNoLease
	}
	if !r.now().Before(lease.ExpiresAt) {
		return Lease{}, ErrLeaseExpired
	}
	if lease.Owner != owner {
		return Lease{}, ErrDualPrimary
	}
	if lease.Epoch != epoch {
		return Lease{}, ErrNotOwner
	}
	lease.ExpiresAt = r.now().Add(r.ttl)
	r.leases[partition] = lease
	return lease, nil
}

// Drain marks a partition for migration. Renew still works so the current
// owner can reach a checkpoint; Acquire for a new owner is rejected until the
// current owner releases.
func (r *Registry) Drain(partition string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.leases[partition]; !ok {
		return ErrNoLease
	}
	r.draining[partition] = true
	return nil
}

// Release drops the lease and clears the drain mark. The epoch check rejects a
// stale owner that was replaced while it was paused.
func (r *Registry) Release(partition, owner string, epoch uint64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	lease, ok := r.leases[partition]
	if !ok {
		return ErrNoLease
	}
	if lease.Owner != owner || lease.Epoch != epoch {
		return ErrNotOwner
	}
	delete(r.leases, partition)
	delete(r.draining, partition)
	return nil
}

// Current returns a copy for status pages and for the drain controller.
func (r *Registry) Current(partition string) (Lease, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	lease, ok := r.leases[partition]
	return lease, ok
}

// Partitions lists lease keys in sorted order so snapshots are reproducible.
func (r *Registry) Partitions() []string {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]string, 0, len(r.leases))
	for partition := range r.leases {
		out = append(out, partition)
	}
	sort.Strings(out)
	return out
}

四个操作的边界值得逐条说明:

  • Acquire 先判断活租约:同 owner 是续约,不同 owner 是双主;过期租约可以接管并递增 epoch;drain 中的分区拒绝新 owner。
  • Renew 同时校验过期时间、owner 和 epoch。只校验 owner 会让暂停的旧进程在接管后继续续约。
  • Drain 只打标记,不删除租约。当前 owner 仍然可以续约到检查点,新 owner 必须等 Release。
  • Release 要求 owner 与 epoch 都匹配。旧 owner 拿旧 epoch 释放,会得到 ErrNotOwner,不会删掉新主人的租约。

本机验证

internal/stateful 有 6 个测试函数、26 个测试运行,覆盖首次获取、同主续约、异主拒绝、过期接管、续约校验、drain 与释放、fencing 旧主、以及 16 个并发获取者只有一个赢家。实际运行结果:

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/stateful
$ go vet ./internal/stateful/
$ go test -count=1 ./internal/stateful/
ok  	gsarchlab/internal/stateful	0.245s

gofmt -l 与 go vet 均无输出,退出码为 0,耗时随机器变化。

重启、迁移与缩容检查表

操作前置条件漏掉后的表现
滚动重启drain、写检查点、release旧进程已退出,新进程还没接管,对局丢失
分区迁移源 release 后目标以更高 epoch 获取两个进程同时写,结算重复
缩容先 drain 要下线的分区,再摘节点直接杀进程,内存状态无快照
租约存储故障时钟源、法定人数、超时策略明确脑裂或全体租约同时过期
宿主机时钟漂移统一使用租约存储的时间同一租约在不同节点到期时间不同

这条表的核心是顺序:先停止接受新工作,再保存状态,最后释放所有权。任何一步颠倒,都会给旧进程留下继续写的窗口。地图区块的快照与回滚细节见8.1 全球同服与大地图分片 ,内存快照的组织方式见4.1 内存状态、持久化与快照 。

常见错误与边界

第一,TTL 按“平均对局时长”设置。正确口径是最坏情况:最长对局加结算宽限,再加一次 GC 或宿主机迁移的停顿。

第二,续约只在定时器里做,不做写入前检查。进程可能刚从长时间暂停中恢复,定时器还没触发,业务写已经发生;写入路径应校验租约仍在有效期内。

第三,drain 时立刻删除租约。删除会让新 owner 马上接管,而旧 owner 还在写检查点;正确做法是保留租约到 release。

第四,把租约存储当成强一致数据库却不读它的时钟。多节点本地时钟漂移会让“未过期”在不同进程里有不同答案。

第五,把无状态当成目标,强行把 tick 状态外置。每帧一次 Redis 往返会把 20ms 的 tick 预算吃光,最后得到的是一个更慢、更脆的有状态服务。

第六,只监控进程存活,不监控租约年龄。进程健康但租约长期不续期,说明续约链路已经坏了;反过来,租约数量持续增长说明 release 链路有问题。

小结

有状态服务的边界由三件事定义:权威状态放在哪、分区如何归属、旧拥有者如何被挡住。状态外置、分区归属和主备租约是三条基本出路,房间服、地图服、会话服的选择取决于迁移单位和重启后果。租约的四条不变量里,epoch fencing 是防止双主的关键;restart、迁移和缩容都要遵守“停止新工作、保存状态、释放所有权”的顺序。下一节把视角从单个服务拉到故障域:一台节点、一个机架或一个可用区坏掉时,影响面到底有多大。

阅读导航:上一节:11.1 部署单元怎么切 · 下一节:11.3 故障域与爆炸半径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查