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 层去「方便复用」,测试会先拦住他。架构原则只有变成断言,才不会被下一个人改掉。
本节小结
这一节确立了三件事:
- 分层的起点是状态归属,不是目录结构。一个状态可以有多个读者,但只能有一个写者;分层就是按写入权限把状态切开。
- 四层各有明确的状态归属特征:gateway 持连接状态(可复制),logic 持玩家状态(按玩家分片),scene 持世界状态(单实例单写者,不可复制),data 只存快照且客户端不可直达。
- 归属是可执行的:「每条命令归到唯一的层」「每份状态归到唯一的写者」被编码成两张表和一组断言,不是注释里的约定。
下一节把「唯一写者」这条原则落到两个最常出事的场景上:奖励被重复发放,以及旧数据覆盖了新数据。
阅读导航:上一节:目录 · 下一节:1.2 状态归谁:防重复发奖与旧数据覆盖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。