《MMO 服务器开发》1.1 gateway / logic / scene / data 分层

卷一讲完网络内核的四层,这一卷换一个视角重新切一遍:从玩法系统的状态归属出发,回答 gateway / logic / scene / data 各自该持有什么状态。本节先把「谁写状态,谁就是那一层」立成第一原则,再把每条命令归到唯一的处理层、把每份状态归到唯一的写入者,最后用一份能跑的 Go 代码把这两条归属规则钉成断言。

1.1 gateway / logic / scene / data 分层

卷一《网络与并发内核》已经画过一张四层图,为什么卷二的第一章还要再讲一次分层?因为两张图回答的是不同的问题。卷一的分层是按数据流切的——字节怎么进来、帧怎么切、消息怎么路由、定时器怎么组织;那是「包该走哪条路」的视角。卷二把镜头拉近到玩法系统,视角换成状态怎么归属:背包里的金币、地图上的坐标、会话里的重连票据,这些状态分别由谁持有、由谁写入、能不能被复制。

这两个视角不冲突,但也不能互相替代。卷一关心的是「一条消息从 socket 到业务函数经过了什么」,卷二关心的是「一次状态改动该由哪一层拍板」。玩法系统的绝大多数线上事故——重复发奖、旧数据覆盖新数据、跨服写错资产——都不是网络层的问题,而是状态归属没定清楚。所以这一节不从协议讲起,先把归属规则立住,后面 17 章引入的每一个玩法模块(背包、任务、公会、交易、匹配)都要挂到这套规则上。

本节做三件事:立一条第一原则,给出四层在玩法域上的边界,再把归属规则编码成一张可以跑测试的表。

分层的起点:状态归属,不是目录结构

先看一个几乎每个项目都踩过的反例。很多团队按业务模块分目录:bag/、quest/、mail/、guild/,看起来清清楚楚。可一旦上线,你会发现玩家的金币同时被背包、任务、邮件、公会四个模块各写一份,出事时谁都不知道最终值该是哪个——因为目录是按「功能」分的,不是按「状态的唯一写入者」分的。功能可以很多个模块都碰,但状态只能有一个写者。

所以分层的第一个问题不是「这个功能放哪个目录」,而是「这份状态谁说了算」。把归属定下来,目录结构自然就长出来了;反过来先分目录,归属会被目录切碎,碎到最后没人能说清「谁的写入是权威的」。

这条原则可以浓缩成一句话:一个状态可以有多个读者,但只能有一个写者。 读者越多越好——客户端要读、别的玩家要读、排行榜要读;写者则必须唯一,否则两个写者各写各的,状态就会分叉。分层本质上就是按「写入权限」把状态切成几块,而不是按代码目录切。

四层的边界与依赖方向

aegis 把服务端切成四层,每一层持有一类状态,依赖方向自上而下:

   客户端 C1 C2 C3 …… (长连接:TCP / KCP / WebSocket)
        │            │            │
        ▼            ▼            ▼
┌───────────────────────────────────────────────────────────┐
│  gateway  持有「连接状态」:心跳、重连票据、限速窗口         │
│           每个连接一份,天然可复制——多台网关各持自己的副本  │
├───────────────────────────────────────────────────────────┤
│  logic    持有「玩家状态」:背包、任务、社交、邮件           │
│           以 PlayerID 为键,同一玩家只有一个写者,玩家可分片 │
├───────────────────────────────────────────────────────────┤
│  scene    持有「世界状态」:坐标、AOI、战斗                  │
│           以地图实例为键,一个实例只有一个写者(不可复制)   │
├───────────────────────────────────────────────────────────┤
│  data     持有「持久化状态」:落盘快照、账本、审计日志       │
│           不持有活状态,只存快照;客户端永不直接对话         │
└───────────────────────────────────────────────────────────┘
        依赖方向:gateway → logic → scene → data,只准向下

逐层看它们的状态归属特征:

  • gateway:持有连接状态——心跳时间、重连票据、限速窗口。这些状态每个连接一份,所以它天然可复制:两台网关各持自己那份连接副本,永远不会分叉。gateway 不认识「玩家」,所以它可以水平扩展,扛住全部连接压力。
  • logic:持有玩家状态——背包、任务、社交、邮件。以 PlayerID 为键,同一个玩家只能有一个写者,但不同玩家可以分到不同的 logic 分片。logic 认识「玩家」,但不认识「地图坐标」。
  • scene:持有世界状态——坐标、AOI、战斗结果。以地图实例为键,一个实例只有一个写者,这是不可复制的硬约束。同一个人、同一块地图的状态,只能由一台场景进程说了算,否则两个进程会各自算出一份位置。
  • data:持有持久化状态——落盘快照、账本、审计日志。它不持有活状态,只持有「某一时刻的快照」;而且客户端永远不能直接和它对话,所有写入都得经过上面三层。

依赖方向只准向下:gateway → logic → scene → data。反过来走就是越权——比如 scene 越过 logic 直接给客户端发协议包,或者 gateway 直接去读写数据库。这两条越权在后面的章节里都会被反复提起。

可运行产物:把「命令归谁」「状态归谁」写成表

光画图不够。归属规则如果不落成代码,半年后一定会有人把「移动」命令丢进 logic 层去处理,因为它「看起来也是玩家发来的」。本节的可运行产物把两条归属规则——每条命令归到唯一的处理层、每份状态归到唯一的写入者——编码成两张表,并用测试锁住它们。

下面这段是 L1,go test 可以直接跑。先看 internal/layer/route.go:

// file: internal/layer/route.go
// tier: L1
// verify: go test ./internal/layer/
// Package layer encodes the four-tier split of a single aegis server, and the
// ownership rule that falls out of it: every command is handled by exactly one
// tier, and every piece of state has exactly one writer.
//
// The four tiers are gateway, logic, scene and data. The important asymmetry is
// that gateway and logic state can be replicated (one copy per connection, one
// copy per player session), while scene state cannot: a map instance has a
// single writer, and that writer is the only process allowed to mutate it.
package layer

// Tier names one layer of the aegis single-server topology.
type Tier string

const (
	TierGateway Tier = "gateway" // owns sockets: handshake, heartbeat, rate limit
	TierLogic   Tier = "logic"   // owns players: session, bag, quest, social
	TierScene   Tier = "scene"   // owns the world: position, AOI, combat
	TierData    Tier = "data"    // owns persistence: snapshots, ledgers
)

// Command numbers, copied verbatim from the shared wire table so this chapter
// speaks the same protocol as the rest of the book.
const (
	CmdHeartbeatReq  uint16 = 0x0001
	CmdErrorNotify   uint16 = 0x0003
	CmdLoginReq      uint16 = 0x0010
	CmdEnterWorldReq uint16 = 0x0101
	CmdAttrSync      uint16 = 0x0103
	CmdMoveReq       uint16 = 0x0201
	CmdAoiAdd        uint16 = 0x0203
	CmdChatSend      uint16 = 0x0301
	CmdSkillCastReq  uint16 = 0x0401
	CmdDamageNotify  uint16 = 0x0403
	CmdItemUseReq    uint16 = 0x0501
	CmdBagSync       uint16 = 0x0503
	CmdQuestAccept   uint16 = 0x0601
	CmdQuestFinish   uint16 = 0x0603
	CmdPartyInvite   uint16 = 0x0701
	CmdGuildJoin     uint16 = 0x0801
	CmdRankQuery     uint16 = 0x0901
	CmdMailClaim     uint16 = 0x0A02
	CmdAuctionBid    uint16 = 0x0B02
	CmdMatchJoin     uint16 = 0x0C01
)

// routeTable maps a command to the single tier that owns its handling. The
// mapping is not a guess: a command lands on the tier that owns the state it
// touches. Heartbeat touches the connection (gateway); moving touches the map
// (scene); opening the bag touches the player (logic).
var routeTable = map[uint16]Tier{
	CmdHeartbeatReq:  TierGateway,
	CmdErrorNotify:   TierGateway,
	CmdLoginReq:      TierLogic,
	CmdEnterWorldReq: TierLogic,
	CmdAttrSync:      TierLogic,
	CmdMoveReq:       TierScene,
	CmdAoiAdd:        TierScene,
	CmdChatSend:      TierLogic,
	CmdSkillCastReq:  TierScene,
	CmdDamageNotify:  TierScene,
	CmdItemUseReq:    TierLogic,
	CmdBagSync:       TierLogic,
	CmdQuestAccept:   TierLogic,
	CmdQuestFinish:   TierLogic,
	CmdPartyInvite:   TierLogic,
	CmdGuildJoin:     TierLogic,
	CmdRankQuery:     TierLogic,
	CmdMailClaim:     TierLogic,
	CmdAuctionBid:    TierLogic,
	CmdMatchJoin:     TierLogic,
}

// Route returns the tier that must handle cmd. The second result is false for
// an unknown command; callers should hand those back to the gateway, which is
// the only tier that owns the socket and can therefore answer with an error or
// close the connection. Note that Route never returns TierData: the data tier
// has no wire commands, because clients are never allowed to talk to storage
// directly.
func Route(cmd uint16) (Tier, bool) {
	t, ok := routeTable[cmd]
	return t, ok
}

// Scope says how a piece of state may be replicated across processes.
type Scope string

const (
	// ScopePerConnection state is copied once per open socket. Two gateways may
	// each hold their own copy without disagreeing, because each copy belongs to
	// a different connection.
	ScopePerConnection Scope = "per-connection"
	// ScopePerPlayer state is owned by the logic tier and keyed by PlayerID. It
	// is still single-writer per player, but different players may live on
	// different logic shards.
	ScopePerPlayer Scope = "per-player"
	// ScopePerInstance state is owned by the scene tier and keyed by map
	// instance. Exactly one process may mutate a given instance.
	ScopePerInstance Scope = "per-instance"
)

// StateKind enumerates the pieces of state this chapter cares about placing.
type StateKind string

const (
	StateConnHeartbeat StateKind = "conn.heartbeat" // last-seen time, RTT
	StateSessionTicket StateKind = "session.ticket" // reconnect ticket for a player
	StatePlayerBag     StateKind = "player.bag"     // items, gold, currency
	StateWorldPosition StateKind = "world.position" // authoritative X/Y on a map
	StateInstanceAOI   StateKind = "instance.aoi"   // who is near whom on a map
)

// Placement answers "who owns this state, and how may it be replicated".
type Placement struct {
	Tier  Tier
	Scope Scope
}

// placementTable is the ownership rule of section 1.1 in code form.
var placementTable = map[StateKind]Placement{
	StateConnHeartbeat: {TierGateway, ScopePerConnection},
	StateSessionTicket: {TierGateway, ScopePerConnection},
	StatePlayerBag:     {TierLogic, ScopePerPlayer},
	StateWorldPosition: {TierScene, ScopePerInstance},
	StateInstanceAOI:   {TierScene, ScopePerInstance},
}

// Place returns the placement of a state kind, if it is known.
func Place(k StateKind) (Placement, bool) {
	p, ok := placementTable[k]
	return p, ok
}

// SingleWriter reports whether the placement forbids replication, i.e. whether
// exactly one process is allowed to mutate the state. Only instance-scoped
// state is single-writer; connection- and player-scoped state may each have
// their own copy.
func (p Placement) SingleWriter() bool {
	return p.Scope == ScopePerInstance
}

// TierOfPlacement is the tier that owns a state kind, or ok=false if unknown.
func TierOfPlacement(k StateKind) (Tier, bool) {
	p, ok := Place(k)
	if !ok {
		return "", false
	}
	return p.Tier, true
}

再配一个测试 internal/layer/route_test.go,断言三条不变量:命令落到持有其状态的层、未知命令不被路由、以及没有任何命令归到 data 层:

// file: internal/layer/route_test.go
// tier: L1
// verify: go test ./internal/layer/
package layer

import "testing"

func TestRouteOwnsStateNotBytes(t *testing.T) {
	cases := map[uint16]Tier{
		CmdHeartbeatReq: TierGateway, // touches the connection
		CmdLoginReq:     TierLogic,   // touches the player session
		CmdMoveReq:      TierScene,   // touches the map
		CmdSkillCastReq: TierScene,   // touches the map
		CmdItemUseReq:   TierLogic,   // touches the player bag
		CmdMailClaim:    TierLogic,   // touches the player inbox
	}
	for cmd, want := range cases {
		got, ok := Route(cmd)
		if !ok {
			t.Errorf("Route(0x%04X): not found", cmd)
			continue
		}
		if got != want {
			t.Errorf("Route(0x%04X) = %s, want %s", cmd, got, want)
		}
	}
}

func TestRouteUnknownIsNotRouted(t *testing.T) {
	if _, ok := Route(0xFFFF); ok {
		t.Fatal("Route(0xFFFF) must not resolve")
	}
}

func TestRouteNeverReturnsDataTier(t *testing.T) {
	// Clients never talk to storage directly: no command may be owned by data.
	for cmd := range routeTable {
		if got, _ := Route(cmd); got == TierData {
			t.Errorf("Route(0x%04X) = data; the data tier has no wire commands", cmd)
		}
	}
}

func TestSceneStateIsSingleWriter(t *testing.T) {
	for _, k := range []StateKind{StateWorldPosition, StateInstanceAOI} {
		p, ok := Place(k)
		if !ok {
			t.Fatalf("Place(%s): not found", k)
		}
		if p.Tier != TierScene {
			t.Errorf("Place(%s).Tier = %s, want scene", k, p.Tier)
		}
		if !p.SingleWriter() {
			t.Errorf("Place(%s) must be single-writer", k)
		}
	}
}

func TestConnectionStateIsReplicable(t *testing.T) {
	p, _ := Place(StateConnHeartbeat)
	if p.Tier != TierGateway {
		t.Fatalf("Place(conn.heartbeat).Tier = %s, want gateway", p.Tier)
	}
	if p.SingleWriter() {
		t.Fatal("connection state must be replicable: each socket has its own copy")
	}
}

本机实跑结果(go test ./internal/layer/ -v):

=== RUN   TestRouteOwnsStateNotBytes
--- PASS: TestRouteOwnsStateNotBytes (0.00s)
=== RUN   TestRouteUnknownIsNotRouted
--- PASS: TestRouteUnknownIsNotRouted (0.00s)
=== RUN   TestRouteNeverReturnsDataTier
--- PASS: TestRouteNeverReturnsDataTier (0.00s)
=== RUN   TestSceneStateIsSingleWriter
--- PASS: TestSceneStateIsSingleWriter (0.00s)
=== RUN   TestConnectionStateIsReplicable
--- PASS: TestConnectionStateIsReplicable (0.00s)
PASS
ok  	aegis/internal/layer	0.247s

TestRouteNeverReturnsDataTier 这条断言值得单独说一句。它把「客户端永远不直接和存储对话」这条架构原则,变成了一条可以失败的测试:将来任何人往 routeTable 里加一条 CmdXxx: TierData,测试立刻变红。同样,TestSceneStateIsSingleWriter 守住的是「场景状态不可复制」——哪天有人想把 world.position 挪到 logic 层去「方便复用」,测试会先拦住他。架构原则只有变成断言,才不会被下一个人改掉。

本节小结

这一节确立了三件事:

  1. 分层的起点是状态归属,不是目录结构。一个状态可以有多个读者,但只能有一个写者;分层就是按写入权限把状态切开。
  2. 四层各有明确的状态归属特征:gateway 持连接状态(可复制),logic 持玩家状态(按玩家分片),scene 持世界状态(单实例单写者,不可复制),data 只存快照且客户端不可直达。
  3. 归属是可执行的:「每条命令归到唯一的层」「每份状态归到唯一的写者」被编码成两张表和一组断言,不是注释里的约定。

下一节把「唯一写者」这条原则落到两个最常出事的场景上:奖励被重复发放,以及旧数据覆盖了新数据。

阅读导航:上一节:目录 · 下一节:1.2 状态归谁:防重复发奖与旧数据覆盖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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