《游戏服务端架构·品类、状态与拓扑》1.1 游戏服务端到底在为谁做架构

从北辰工作室三款产品在一次周五晚高峰同时出问题讲起,拆开游戏服务端真正服务的三类对象:玩家、运营与团队;说明有状态、强实时、长会话、玩法耦合四个特殊性如何把普通后端经验推到边界之外,再用房间状态机与状态所有权表落下第一组可验证约束,并给出第一份 ADR。

周五晚上九点的那次卡顿

北辰工作室有三款在线产品:《裂隙战场》是 5v5 竞技,《群岛纪元》是全球同服的 SLG,《泡泡工坊》是休闲小游戏。它们共用一套账号、支付和运营后台。

某个周五晚上九点,三件事同时发生:

  • 《裂隙战场》的排位赛延迟从 38ms 涨到 210ms,玩家在社区贴出“技能按了没反应”的录屏;
  • 《群岛纪元》的跨服王战结算卡住,两个服的奖励发放相差了 40 分钟;
  • 《泡泡工坊》的登录接口成功率从 99.9% 掉到 92%,客服收到大量“进不去”的工单。

运维当晚的处置很典型:先扩容网关,再重启结算进程,最后把登录服务回滚到上一个版本。三件事都“恢复”了,但没有人能说清楚它们为什么会同时发生。

事后复盘发现,三款产品在代码层面共享了一个“通用游戏服务”进程。这个进程既处理登录,又持有房间状态,还在同一批 goroutine 里跑结算。周五晚高峰时,《群岛纪元》的一次全服结算占满了调度器,把《裂隙战场》的房间 tick 和《泡泡工坊》的登录请求一起拖慢。

这不是某一个模块写错了,而是架构边界没有画:谁拥有状态、谁可以阻塞谁、哪一类失败可以接受,都没有明确答案。

三个“谁”:玩家、运营、团队

讨论游戏服务端架构时,最容易滑向“用什么框架、上什么中间件”。但在选型之前,先要回答这套架构到底在为谁服务。

玩家要的是可预期的体验

玩家不关心你用了几个微服务。玩家只感知三件事:

  1. 我的操作多久有反馈;
  2. 同一局里别人的延迟会不会让我吃亏;
  3. 掉线、重连、结算时我的资产会不会丢。

这三件事对应三类不同的时间尺度。把它们混成一句“低延迟”,架构就失去了取舍依据。

运营要的是可发布、可回滚、可算账

运营关心的是:

  • 能不能在工作日下午发一个活动,而不需要停服;
  • 出了问题能不能在十分钟内回滚,而不是等第二天;
  • 这个月的服务器账单能不能按产品、按玩法拆开。

一个“技术上很优雅”的架构,如果每次发版都要停服,对运营就是不可用的。

团队要的是能分工、能维护

北辰工作室的服务端团队只有 18 个人,却要维护三款产品。团队边界决定系统边界:

  • 平台组负责账号、支付、运营后台;
  • 竞技组负责《裂隙战场》的房间与战斗;
  • SLG 组负责《群岛纪元》的世界与结算;
  • 休闲组只有 3 个人,必须复用平台能力。

如果架构把三款产品的状态塞进同一个进程,团队就无法独立发布,故障也无法隔离。康威定律在这里不是一句口号,而是排班表。

游戏服务端的四个特殊性

普通 Web 后端也有状态、并发和故障。游戏服务端的难点在于四个特殊性同时出现。

有状态,而且状态在内存里

电商订单可以每次从数据库读。游戏房间不行:一局 5v5 的实时状态每 33ms 变一次,全部落库既慢又没必要。房间状态必须留在内存,内存就有生命周期、迁移和丢失问题。

强实时,延迟是玩法的一部分

在 CRUD 系统里,200ms 的延迟只是“慢一点”。在竞技游戏里,200ms 的延迟会改变命中判定、技能先后和比赛结果。延迟不是非功能指标,它是玩法规则的一部分。

长会话,连接本身就是状态

一局比赛 20 分钟,一次 SLG 在线可能 2 小时。连接上挂着会话、订阅、房间成员关系和未确认的消息。断线不是“重新请求一次”,而是“把一整套上下文接回来”。

玩法强耦合,边界经常被玩法打破

“背包”看起来是独立模块,但掉落要写背包,任务要读背包,交易要冻结背包,赛季结算要清背包。玩法一变,边界就要重画。架构必须允许边界演进,而不是假设边界一次画对。

一个把所有职责塞进同一个进程的实现

先看反面示例。下面的代码刻意省略了错误处理,只保留结构:

// 反面示例:仅用于说明职责混杂,未纳入 gsarch-lab 编译。
type GameService struct {
    mu    sync.Mutex
    rooms map[string]*Room
    db    *sql.DB
}

func (s *GameService) Handle(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    defer s.mu.Unlock()

    switch r.URL.Path {
    case "/login":
        s.login(w, r)
    case "/room/join":
        s.joinRoom(w, r)
    case "/battle/settle":
        s.settle(w, r) // 同步写库,持有全局锁
    }
}

这段代码有三个致命问题:

  1. 全局锁把无关请求串行化。结算写库时,登录和加入房间都被挡住。
  2. 状态所有权不清。rooms 由 GameService 持有,但 settle 又要写数据库,没人说得清房间状态的权威副本在哪。
  3. 故障无法隔离。结算慢、数据库抖动、登录洪峰,最终都表现为“整个服务卡住”。

代价拆解:六类问题,一个根因

把上面的实现逐条拆开,会看到六类代价:

代价表现根因
状态重启后房间丢失,重连接不回上下文没有定义状态的权威副本与快照
并发一个慢请求拖垮所有房间锁的粒度和所有权边界不清
故障结算故障扩散到登录没有故障域隔离
变更改登录要回归整个服务模块之间没有契约
观测出问题只能看单一日志没有按房间、按玩家、按玩法拆分指标
安全客户端能影响结算结果权威边界没有落在服务端

六类问题看起来分散,根因是同一个:规则没有落点。状态机、所有权、超时、幂等、指标,都没有被写成代码里可检查的东西。

第一版边界:房间状态机

架构的第一步不是画一张大图,而是把一条最重要的规则变成可执行约束。

对《裂隙战场》来说,最重要的规则是:一局房间只能按固定生命周期流转,任何非法流转都必须被拒绝。把这条规则写成一个表驱动的状态机:

// file: internal/room/state.go
// tier: L1
// verify: go test ./internal/room/
package room

import "fmt"

// State is the lifecycle state of one match room.
type State string

const (
	StateCreated  State = "CREATED"
	StateMatching State = "MATCHING"
	StateLoading  State = "LOADING"
	StateRunning  State = "RUNNING"
	StateSettling State = "SETTLING"
	StateClosed   State = "CLOSED"
)

// allowed is the only place that decides which room transitions are legal.
var allowed = map[State][]State{
	StateCreated:  {StateMatching, StateClosed},
	StateMatching: {StateLoading, StateClosed},
	StateLoading:  {StateRunning, StateClosed},
	StateRunning:  {StateSettling, StateClosed},
	StateSettling: {StateClosed},
	StateClosed:   {},
}

// Room is the minimal state machine shared by the three Northstar products.
type Room struct {
	ID      string
	State   State
	Version int64
}

func New(id string) *Room {
	return &Room{ID: id, State: StateCreated}
}

func (r *Room) CanTransition(to State) bool {
	for _, s := range allowed[r.State] {
		if s == to {
			return true
		}
	}
	return false
}

func (r *Room) Transition(to State) error {
	if !r.CanTransition(to) {
		return fmt.Errorf("room %s: illegal transition %s -> %s", r.ID, r.State, to)
	}
	r.State = to
	r.Version++
	return nil
}

这段代码只有 50 行,但它同时解决了三件事:

  • 规则集中:合法流转只在 allowed 里定义,不在业务代码里散落 if;
  • 可穷举测试:6 个状态、36 种组合可以被一张表全部覆盖;
  • 可审计:每次成功流转 Version 加一,失败不改版本,后续做乐观锁和事件溯源都有依据。

对应的测试必须穷举所有状态组合,而不是只测“正常路径”:

// file: internal/room/state_test.go
// tier: L1
// verify: go test ./internal/room/
package room

import "testing"

func TestTransitionTableIsExhaustive(t *testing.T) {
	states := []State{
		StateCreated, StateMatching, StateLoading,
		StateRunning, StateSettling, StateClosed,
	}
	for _, from := range states {
		for _, to := range states {
			r := New("r-1")
			r.State = from
			want := false
			for _, s := range allowed[from] {
				if s == to {
					want = true
					break
				}
			}
			if got := r.CanTransition(to); got != want {
				t.Fatalf("%s -> %s: got %v, want %v", from, to, got, want)
			}
		}
	}
}

func TestTransitionBumpsVersionOnlyOnSuccess(t *testing.T) {
	r := New("r-1")
	if err := r.Transition(StateRunning); err == nil {
		t.Fatal("CREATED -> RUNNING must fail")
	}
	if r.Version != 0 {
		t.Fatalf("failed transition changed version to %d", r.Version)
	}
	if err := r.Transition(StateMatching); err != nil {
		t.Fatalf("CREATED -> MATCHING: %v", err)
	}
	if r.State != StateMatching || r.Version != 1 {
		t.Fatalf("got state=%s version=%d", r.State, r.Version)
	}
}

func TestHappyPath(t *testing.T) {
	r := New("r-1")
	for _, s := range []State{
		StateMatching, StateLoading, StateRunning, StateSettling, StateClosed,
	} {
		if err := r.Transition(s); err != nil {
			t.Fatalf("transition to %s: %v", s, err)
		}
	}
	if r.Version != 5 {
		t.Fatalf("version=%d, want 5", r.Version)
	}
}

在本机验证:

$ cd .local/checks/gsarch-lab
$ gofmt -l .
$ go vet ./...
$ go test ./internal/room/
ok  	gsarchlab/internal/room	0.4s

gofmt -l 无输出,go vet 无告警,测试通过。这是本书的 L1 口径:不依赖外部进程,单包可复现。

状态所有权表

状态机只定义了生命周期,还没有回答“状态归谁”。在卷一第 1 章,先把三款产品的核心状态列成所有权表:

状态权威副本持久化快照频率丢失后的恢复方式
账号登录态账号服务有每次续期重新登录
房间生命周期房间服事件日志状态变更时从事件重建
战斗内单位状态战斗进程无每 30 秒或结算时重开或回放恢复
SLG 世界地图世界服分片有每次写操作分片主副本切换
玩家背包角色服务有每次变更数据库加事件对账
排行榜排行服务有定期快照从明细重算

这张表的价值不在于“写得多完整”,而在于它强迫团队回答三个问题:

  1. 这个状态的内存副本由谁持有?
  2. 数据库是权威还是缓存?
  3. 进程崩溃后,玩家会看到什么?

所有权表一旦明确,部署单元和故障域就有了依据。反过来,如果两个服务都声称自己拥有同一个状态,故障时一定互相覆盖。

ADR-001:房间生命周期由房间服独占

把上面的决定写成一份 ADR:

背景:房间状态每 tick 变化,不能每次落库;登录、匹配、结算又需要读取房间状态。

备选方案:

  • A. 房间状态放共享缓存,所有服务直接读写;
  • B. 房间状态由房间服独占,其他服务通过命令和事件交互;
  • C. 房间状态每次写数据库,服务无状态。

决定:选 B。

理由:

  • A 的问题是并发写没有单一权威,容易出现旧状态覆盖新状态;
  • C 的问题是 33ms 一次的全量写库成本不可接受;
  • B 把并发收敛到房间服内部,其他服务只发命令,读走事件或查询接口。

后果:

  • 房间服必须做持久化和崩溃恢复;
  • 其他服务不能绕过房间服改状态,接口会多一点;
  • 房间服成为有状态服务,发布时需要排空和迁移。

ADR 不是文档装饰。它把“为什么不是 A/C”固定下来,半年后有人提出“把房间状态放 Redis 吧”,团队可以直接回到这份记录,而不是重新吵一遍。

常见错误与边界

第一,把“低延迟”当成一个数。帧预算、操作预算、结算预算是三件事,混在一起就无法定位问题。

第二,用全局锁保护所有状态。锁的粒度应该和所有权边界一致,而不是和进程边界一致。

第三,认为微服务一定比单体好。拆分的前提是边界稳定、团队独立、故障需要隔离;三者都不成立时,拆了只会多出网络故障。

第四,把状态机和业务规则分开写。状态机如果不被业务代码调用,就只是一张图;必须让非法流转在代码里直接失败。

第五,忽略“失败时的玩家体验”。架构不只是正常路径的设计,更是失败路径的设计:掉线、重连、结算失败、跨服超时,玩家分别看到什么。

第六,把架构评审做成“谁声音大谁赢”。评审需要质量属性场景、备选方案和可验证的后果,而不是偏好。

小结

游戏服务端架构首先回答三个“谁”:玩家要可预期的体验,运营要可发布可回滚可算账,团队要能分工能维护。游戏的特殊性——有状态、强实时、长会话、玩法耦合——让普通后端经验在边界处失效。

这一节落下的第一组约束是:房间生命周期用表驱动状态机表达,核心状态用所有权表明确权威副本,并用 ADR 记录为什么不让多个服务同时写房间。下一节把“低延迟”拆成三类预算,给出可以写进代码和压测脚本的量化口径。

阅读导航:上一节:目录 · 下一节:1.2 三类延迟预算:帧、操作、结算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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