1.1 分层与职责边界:一个 MMO 服务端长什么样
做 MMO 服务端和做 Web 后端,最容易踩的坑不是「不会写高并发」,而是拿 Web 后端的直觉去切分层。Web 那套「负载均衡 → 无状态应用 → 缓存 → 数据库」的肌肉记忆,一旦照搬到游戏服务端,几乎必然在第一次压力测试就崩掉——不是性能崩,是模型崩:你的应用层被设计成随时可替换、随时可扩容,而 MMO 的逻辑恰恰要求「同一个人、同一块地图的状态,只能由一台机器说了算」。
所以本书第一章不从协议、不从 I/O 模型讲起,而是先画一张图:一个 MMO 服务端长什么样、每层负责什么、层与层之间的边界在哪里。这张图是后面 17 章的地基,后面每一章引入的零件(编解码、路由、定时器、Actor、同步)都会被挂到这张图的某一层上。凡是「游戏服务端该分几层」的架构综述,站内已经有更完整的文章,本节不重复——本节只做一件事:把分层画清楚,然后用一份真跑的代码把「启动顺序」和「关闭顺序」钉成断言。
立论起点:长连接有状态,和 Web 无状态差在哪
本书全部的取舍,都从一句话推导出来:MMO 服务端要维护大量长连接,而每条连接背后是一个有状态、且状态必须被权威维护的玩家。
Web 后端的理想形态是「无状态」:一次请求进来,应用从数据库/缓存读出需要的东西,算完写回去,然后把状态忘掉。因为无状态,你可以在任意机器上再起一个实例,把流量均摊过去;因为无状态,一台机器挂了,负载均衡把请求打到别的机器就行,用户几乎无感。这套模型的全部威力,都建立在「应用本身不持有状态」这个前提上。
游戏服务端的前提正好相反:
- 连接是长期的。玩家一局在线几十分钟到几小时,连接一直挂着。连接本身就携带状态——心跳、序列号、已发送但未确认的推送,全在连接上。
- 状态是有归属的。玩家 A 站在哪块地图、血量多少、背包里有什么,必须由一个明确的「权威」持有。不能这次请求在机器 1 算、下次在机器 2 算,因为两台机器的状态会分叉。
- 交互是双向的。Web 是「客户端问、服务端答」,游戏是「服务端主动推」——别人走近你、公会有人说话、你被攻击,都是服务端在没有请求的情况下推给客户端的。
这三条差异,直接决定了分层不能照抄 Web。Web 那套把「无状态应用」横向复制一百份的玩法,在游戏里会把「权威」撕成一百份互不相识的副本。正确的做法不是消灭状态,而是给状态找到唯一的家,再把无状态的、可复制的部分(接入、转发)单独切出来。这就是下一节四层划分的由来。
四层:gateway / logic / scene / data
aegis(本书从零实现的服务器框架)把服务端切成四层,依赖方向自上而下:客户端连到接入层,接入层把解好的请求交给逻辑层,逻辑层向场景层要世界状态,场景层通过数据层落盘。
客户端 C1 C2 C3 …… (长连接:TCP / KCP / WebSocket)
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────┐
│ 接入层 Gateway 连接、握手、心跳、鉴权、限速、转发 │
│ 只认字节与命令号,不认识「玩家」 │
├───────────────────────────────────────────────────────┤
│ 逻辑层 Logic 登录、会话、消息路由、任务、聊天、组队 │
│ 认识「玩家」,但不认识「地图坐标」 │
├───────────────────────────────────────────────────────┤
│ 场景层 Scene 地图、AOI、移动、战斗、广播、NPC/AI │
│ 持有权威世界状态,是「唯一说了算」的一层 │
├───────────────────────────────────────────────────────┤
│ 数据层 Data 存档、背包、账本、日志、持久化 │
│ 只管读写,不掺和玩法逻辑 │
└───────────────────────────────────────────────────────┘
依赖方向:Gateway → Logic → Scene → Data,只准向下
- 接入层 Gateway:唯一直接和客户端 socket 打交道的一层。它负责握手、鉴权、心跳保活、限速、把字节流拆成一条条消息,然后把「命令号 + 消息体」转给逻辑层。它的关键特征是它不认识玩家——它只认连接和消息,不认识「这个玩家的背包」。正因为不认识业务,它可以被复制很多份,承担全部连接压力。
- 逻辑层 Logic:认识「玩家」这一级概念:登录、会话、消息路由、任务、聊天、组队。它知道「玩家 1001 要打开背包」,但不知道背包物品在世界里摆在哪个坐标。它是玩家的「会话大脑」,不是世界的「物理大脑」。
- 场景层 Scene:全书的重量级角色,也是 MMO 与 Web 最不可通约的一层。它持有权威世界状态——谁在哪、朝向哪、血量多少、技能是否命中。同一块地图只能由一台场景进程负责,这是「有状态」的硬约束在架构上的落点。客户端预测、服务端校正、兴趣过滤、广播,全部发生在这里。
- 数据层 Data:只管持久化。它把场景层和逻辑层的内存状态落盘,或者按需读回。它不该知道「这个道具是任务奖励还是商店买的」——那是逻辑层的事。
四层之间最重要的是两条边界:接入层不认识玩家,所以它可以水平扩展;场景层是世界状态的唯一权威,所以它不能水平复制。理解了这两条,就理解了为什么游戏服务端的分层和 Web 长得不一样。
可运行产物:把四层的启动与关闭顺序钉成断言
分层光画图不够。层与层之间的依赖顺序必须可执行,否则半年后一定会有人在场景层初始化之前就去读世界状态。本节的可运行产物是一份 Go 代码:它把四层抽象成带生命周期的组件,并强制「按 gateway → logic → scene → data 启动、按相反顺序关闭」。
下面这段是 L1,go test 可以直接跑。先看 internal/overview/layers.go:
// file: internal/overview/layers.go
// tier: L1
// verify: go test ./internal/overview/
// Package overview sketches the four-tier shape of an MMO server.
//
// The types here model lifecycle and composition, not business logic. The
// point is to make the boundary between tiers explicit, and to prove that
// assembling a server is a matter of ordering components correctly, not of
// gluing code together by hand.
package overview
import (
"errors"
"fmt"
)
// Layer names one tier of a typical MMO server.
type Layer string
const (
LayerGateway Layer = "gateway" // owns the long-lived client connections
LayerLogic Layer = "logic" // owns sessions, login and routing
LayerScene Layer = "scene" // owns the authoritative world state
LayerData Layer = "data" // owns persistence
)
// Component is a single tier with a lifecycle. Components are started once in
// tier order and shut down once in reverse tier order. Both directions matter:
// the gateway must be listening before the logic tier accepts forwarded
// requests, and on the way down data must flush before scene forgets what it
// was flushing.
type Component interface {
Name() Layer
Start() error
Shutdown() error
}
// Recorder captures lifecycle events so tests can assert on ordering.
type Recorder struct{ events []string }
// Record appends one event of the form "start:gateway".
func (r *Recorder) Record(kind string, layer Layer) {
r.events = append(r.events, kind+":"+string(layer))
}
// Events returns a copy of the recorded events in the order they happened.
func (r *Recorder) Events() []string {
out := make([]string, len(r.events))
copy(out, r.events)
return out
}
// tierComponent is the shared behaviour of the four concrete tiers, so the
// ordering rules live in exactly one place.
type tierComponent struct {
layer Layer
recorder *Recorder
started bool
}
func (c *tierComponent) Name() Layer { return c.layer }
func (c *tierComponent) Start() error {
if c.started {
return fmt.Errorf("overview: %s already started", c.layer)
}
c.started = true
c.recorder.Record("start", c.layer)
return nil
}
func (c *tierComponent) Shutdown() error {
if !c.started {
return fmt.Errorf("overview: %s not started", c.layer)
}
c.started = false
c.recorder.Record("shutdown", c.layer)
return nil
}
// Gateway / Logic / Scene / Data are the four concrete tiers.
type (
Gateway struct{ tierComponent }
Logic struct{ tierComponent }
Scene struct{ tierComponent }
Data struct{ tierComponent }
)
// NewGateway builds a gateway tier that records into r.
func NewGateway(r *Recorder) *Gateway { return &Gateway{tierComponent{LayerGateway, r, false}} }
// NewLogic builds a logic tier that records into r.
func NewLogic(r *Recorder) *Logic { return &Logic{tierComponent{LayerLogic, r, false}} }
// NewScene builds a scene tier that records into r.
func NewScene(r *Recorder) *Scene { return &Scene{tierComponent{LayerScene, r, false}} }
// NewData builds a data tier that records into r.
func NewData(r *Recorder) *Data { return &Data{tierComponent{LayerData, r, false}} }
// Server is the assembled server: an ordered list of components. The order is
// the boot order; Shutdown always walks the list backwards.
type Server struct {
components []Component
started int
}
// NewServer assembles a server from the given components, in boot order.
func NewServer(components ...Component) (*Server, error) {
if len(components) == 0 {
return nil, errors.New("overview: server needs at least one component")
}
seen := make(map[Layer]bool, len(components))
for _, c := range components {
if seen[c.Name()] {
return nil, fmt.Errorf("overview: duplicate layer %s", c.Name())
}
seen[c.Name()] = true
}
return &Server{components: components}, nil
}
// Start boots every component in order; on failure it unwinds the prefix in
// reverse so the process never leaks a half-open tier.
func (s *Server) Start() error {
for i, c := range s.components {
if err := c.Start(); err != nil {
for j := i - 1; j >= 0; j-- {
_ = s.components[j].Shutdown()
}
return fmt.Errorf("overview: start %s: %w", c.Name(), err)
}
s.started = i + 1
}
return nil
}
// Shutdown stops every started component in reverse boot order.
func (s *Server) Shutdown() error {
var firstErr error
for i := s.started - 1; i >= 0; i-- {
if err := s.components[i].Shutdown(); err != nil && firstErr == nil {
firstErr = err
}
}
s.started = 0
return firstErr
}
再配套一个测试 internal/overview/layers_test.go,断言三件事:启动顺序、关闭顺序是启动的严格逆序、以及某一层启动失败时前缀被正确回滚:
// file: internal/overview/layers_test.go
// tier: L1
// verify: go test ./internal/overview/
package overview
import (
"errors"
"reflect"
"testing"
)
func TestBootThenShutdownOrder(t *testing.T) {
rec := &Recorder{}
srv, err := NewServer(NewGateway(rec), NewLogic(rec), NewScene(rec), NewData(rec))
if err != nil {
t.Fatalf("NewServer: %v", err)
}
if err := srv.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if err := srv.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
want := []string{
"start:gateway", "start:logic", "start:scene", "start:data",
"shutdown:data", "shutdown:scene", "shutdown:logic", "shutdown:gateway",
}
if got := rec.Events(); !reflect.DeepEqual(got, want) {
t.Fatalf("events = %v, want %v", got, want)
}
}
func TestRejectsDuplicateAndEmpty(t *testing.T) {
rec := &Recorder{}
if _, err := NewServer(NewGateway(rec), NewGateway(rec)); err == nil {
t.Fatal("expected duplicate-layer error, got nil")
}
if _, err := NewServer(); err == nil {
t.Fatal("expected empty-server error, got nil")
}
}
func TestUnwindsOnFailedStart(t *testing.T) {
rec := &Recorder{}
boom := &failingComponent{tierComponent{LayerScene, rec, false}}
srv, _ := NewServer(NewGateway(rec), NewLogic(rec), boom, NewData(rec))
if err := srv.Start(); err == nil {
t.Fatal("expected start error, got nil")
}
// The scene tier failed, so only gateway and logic were started; they must
// be unwound in reverse. data must never have started at all.
want := []string{"start:gateway", "start:logic", "shutdown:logic", "shutdown:gateway"}
if got := rec.Events(); !reflect.DeepEqual(got, want) {
t.Fatalf("unwind events = %v, want %v", got, want)
}
}
// failingComponent models a tier whose Start fails, so the unwind path can be
// exercised without touching a real socket or database.
type failingComponent struct{ tierComponent }
func (c *failingComponent) Start() error { return errors.New("scene: simulated boot failure") }
本机实跑结果(go test ./internal/overview/ -v):
--- PASS: TestBootThenShutdownOrder (0.00s)
--- PASS: TestRejectsDuplicateAndEmpty (0.00s)
--- PASS: TestUnwindsOnFailedStart (0.00s)
PASS
ok aegis/internal/overview 0.324s
注意 TestUnwindsOnFailedStart 断言的是「回滚只关了已经开起来的两层」——data 从未启动,所以它的关闭事件绝不能出现。这正是分层代码里最容易写错的地方:忘记回滚,或者回滚时把没启动的层也关一遍。有了这条断言,半年后有人改 Start() 时,测试会替他记住这个约定。
本节小结
这一节确立了三件事:
- MMO 服务端的立论起点是「长连接有状态」,它和 Web 无状态的差异,决定了分层不能照抄。
- 四层各有明确的边界:接入层不认识玩家(所以可复制),场景层是世界状态的唯一权威(所以不可水平复制)。
- 分层是可执行的:启动与关闭的顺序被写成断言,不是靠注释里的约定。
下一节回答一个更现实的问题:站内已经有一大批游戏服务器文章,本书到底写什么、不写什么,边界在哪里。
阅读导航:上一节:目录 · 下一节:1.2 本书范围与站内分工 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。