《MMO 服务器开发》1.1 分层与职责边界:一个 MMO 服务端长什么样

一个 MMO 服务端和 Web 后端最大的差别,是它要扛住成千上万条有状态的长连接,而每条连接背后的玩家状态必须有唯一的家。本节先画出网关、逻辑、场景、数据四层的职责边界与依赖方向,再用一份能跑的 Go 代码把四层的启动与优雅关闭顺序钉成断言,作为全书的立论起点。

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() 时,测试会替他记住这个约定。

本节小结

这一节确立了三件事:

  1. MMO 服务端的立论起点是「长连接有状态」,它和 Web 无状态的差异,决定了分层不能照抄。
  2. 四层各有明确的边界:接入层不认识玩家(所以可复制),场景层是世界状态的唯一权威(所以不可水平复制)。
  3. 分层是可执行的:启动与关闭的顺序被写成断言,不是靠注释里的约定。

下一节回答一个更现实的问题:站内已经有一大批游戏服务器文章,本书到底写什么、不写什么,边界在哪里。

阅读导航:上一节:目录 · 下一节:1.2 本书范围与站内分工 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标