《游戏服务端架构·品类、状态与拓扑》12.1 C4、部署视图与数据流

北辰工作室的架构图半年后就和仓库对不上:匹配服务拆了、存储换了、分片扩了,图还在原处。本节用上下文、容器、组件、部署四类视图加数据流视图,说明每类视图的读者与更新频率,并给出锚点校验、悬空链接检查和跨视图一致性检查的 Go 实现,让图和代码不再各说各话。

图在墙上,系统在机器上

北辰工作室的会议室里挂着一张《群岛纪元》的架构图,画于 2025 年底。图上有一个叫 match-svc 的方框,连着 Redis 和八个世界分片。2026 年 9 月,匹配服务已经拆成两个部署单元,Redis 换成了自建 KV,世界分片从 8 个扩到 24 个,图没有改。

新来的 SRE 值班时按图排查,找到了一个已经不存在的服务名。这不是图画得不好,而是图没有和代码、配置建立锚点,也没有人负责更新。

本节只讨论“怎么把已经选定的拓扑讲清楚”,不重新讨论拓扑本身。单体、大厅/房间、分片的选择在第 3 章;这里关心的是四类视图各自服务谁、多久更新一次,以及如何用代码检查图和仓库是否还对得上。

四类视图,四类读者

C4 常被当成四张必须画全的图。更准确的说法是四个抽象层级,每层回答不同人的问题。

视图读者回答的问题更新频率
上下文制作人、新同事、外部合作方系统边界在哪,谁在使用产品边界变化时
容器服务端、平台工程师有哪些进程、存储、消息部署单元变化时
组件模块负责人容器内部怎么分工模块职责变化时
部署SRE、值班容器跑在哪、怎么扩缩容每次发布或容量变更

组件视图不是容器的放大版。它只画会被独立修改、独立测试、独立替换的部件;一个两百行的工具函数不值得一个方框。

上下文视图:先画边界

《群岛纪元》的上下文图只有六个元素:

玩家 ──登录/支付──> northstar-platform ──推送──> 推送网关
                          │
                          ├──匹配──> 匹配服务
                          ├──结算──> 支付渠道
                          └──登录──> 账号中心

上下文图里不出现 Redis、Kafka、Pod。出现它们,说明画图的人已经跳到了容器层,读者也会开始问部署细节。

容器视图:进程、存储和消息

容器视图回答“到底部署哪些东西”。以《裂隙战场》为例,最小集合是:

容器职责状态伸缩单位
gateway长连接、鉴权、转发无状态按连接数
lobby登录、组队、房间分配无状态按 QPS
room单局 tick 与结算有状态按房间数
session-store会话与重连票据有状态按容量
battle-log战斗事件与回放只追加按写入量

“状态”一列比“技术栈”更重要。它决定发布方式、故障恢复顺序和扩容边界。

组件视图:只画会被独立修改的部件

以 room 容器为例,组件视图只保留四个部件:matchmaker、tickloop、combat、settler。它们之间的箭头必须和代码里的包依赖一致;如果图上有箭头而代码里没有 import,或者反过来,图就失去意义。

组件视图的另一个作用是指出父容器。一个组件不能悬空存在,它必须属于某个容器;这条规则很适合写成校验。

部署视图:把容器放到机器上

部署视图必须能回答三个问题:哪个区域、哪台机器或节点、一个实例承载多少。图中每个容器都要有一条 hosts 关系;没有宿主的容器,通常意味着图画漏了,或者这个容器根本不该存在。

region: ap-east
┌─ node-a ──────────────┐   ┌─ node-b ──────────────┐
│ gateway x 4           │   │ room x 32             │
│ lobby x 2             │   │ battle-log x 2        │
└───────────────────────┘   └───────────────────────┘

图中的数字只是示例值。本机没有 K8s 环境,本节不声称做过 K8s 实跑;部署清单路径只作为锚点示例。

数据流视图:数据从哪来、谁拥有、留多久

数据产生者拥有者写入保留
会话票据gatewaysession-store登录/重连7 天
房间快照roomroom 与对象存储每局结束30 天
战斗事件roombattle-logtick 内追加90 天
结算明细settler结算库结算完成永久(归档)

数据流视图不是 DFD 的复刻。它只需要回答四个问题:谁产生、谁拥有、谁可以写、保留多久。这四列决定了合规边界和故障恢复顺序,也决定了数据出问题时先找谁。

一致性靠锚点,不靠自觉

每个元素都必须有一个锚点:代码目录、配置文件键或部署清单路径。锚点不是描述,而是可以在仓库里 stat 到的东西。

检查分三步:

  1. 单视图校验:ID 唯一,名称、类型、锚点非空,链接端点存在,组件必须有父容器。
  2. 锚点校验:每个锚点在仓库索引里存在。
  3. 跨视图校验:同名元素的名称和类型一致;组件父容器存在;每个容器都有部署宿主;数据流中的存储必须出现在容器视图。

第三步最关键。它抓的是“图 A 改了、图 B 没改”这类错误,而不是错别字。部署视图的锚点允许和容器视图不同,因为前者指向发布清单,后者指向代码目录。

代码:视图模型与三类校验

// file: internal/views/views.go
// tier: L1
// verify: go test ./internal/views/
package views

import (
	"fmt"
	"strings"
)

// Kind is one of the five views the team keeps current.
type Kind string

const (
	Context    Kind = "context"
	Container  Kind = "container"
	Component  Kind = "component"
	Deployment Kind = "deployment"
	DataFlow   Kind = "dataflow"
)

var kinds = map[Kind]bool{
	Context:    true,
	Container:  true,
	Component:  true,
	Deployment: true,
	DataFlow:   true,
}

// Profile records who reads a view and when it must be refreshed.
type Profile struct {
	Readers string
	Cadence string
}

// Profiles is code, not wiki text: an unowned view goes stale by default.
var Profiles = map[Kind]Profile{
	Context:    {"制作人、新同事", "产品边界变化时"},
	Container:  {"服务端与平台工程师", "部署单元变化时"},
	Component:  {"模块负责人", "模块职责变化时"},
	Deployment: {"SRE、值班", "每次发布或扩缩容变更"},
	DataFlow:   {"数据、合规、后端", "数据模型或保留策略变化时"},
}

// Element is a box in exactly one view.
type Element struct {
	ID     string
	Name   string
	Type   string // person, system, container, component, store, topic, node
	Anchor string // repository path or config key that proves it exists
	Parent string // component only: the container ID that owns it
}

// Link is a directed relationship between two elements in the same view.
type Link struct {
	From  string
	To    string
	Label string
}

// View is one diagram plus the metadata needed to check it.
type View struct {
	Kind     Kind
	Elements []Element
	Links    []Link
}

// AnchorIndex is the set of paths and keys the repository actually contains.
type AnchorIndex map[string]bool

// Validate checks one view in isolation: identity, anchors, and endpoints.
func Validate(v View) []error {
	var errs []error
	if !kinds[v.Kind] {
		errs = append(errs, fmt.Errorf("unknown view kind %q", v.Kind))
	}
	seen := make(map[string]bool, len(v.Elements))
	for i, e := range v.Elements {
		if strings.TrimSpace(e.ID) == "" {
			errs = append(errs, fmt.Errorf("%s element %d: empty id", v.Kind, i))
			continue
		}
		if seen[e.ID] {
			errs = append(errs, fmt.Errorf("%s: duplicate element id %q", v.Kind, e.ID))
		}
		seen[e.ID] = true
		for _, f := range []struct{ name, value string }{
			{"name", e.Name}, {"type", e.Type}, {"anchor", e.Anchor},
		} {
			if strings.TrimSpace(f.value) == "" {
				errs = append(errs, fmt.Errorf("%s element %q: empty %s", v.Kind, e.ID, f.name))
			}
		}
		if v.Kind == Component && strings.TrimSpace(e.Parent) == "" {
			errs = append(errs, fmt.Errorf("component %q: missing parent container", e.ID))
		}
		if v.Kind != Component && e.Parent != "" {
			errs = append(errs, fmt.Errorf("%s element %q: parent only valid in component view", v.Kind, e.ID))
		}
	}
	for _, l := range v.Links {
		if !seen[l.From] || !seen[l.To] {
			errs = append(errs, fmt.Errorf("%s link %q->%q: unknown endpoint", v.Kind, l.From, l.To))
		}
		if l.From == l.To {
			errs = append(errs, fmt.Errorf("%s link %q: self reference", v.Kind, l.From))
		}
		if strings.TrimSpace(l.Label) == "" {
			errs = append(errs, fmt.Errorf("%s link %q->%q: empty label", v.Kind, l.From, l.To))
		}
	}
	return errs
}

// CheckAnchors rejects elements whose anchor is absent from the repository.
func CheckAnchors(v View, idx AnchorIndex) []error {
	var errs []error
	for _, e := range v.Elements {
		if strings.TrimSpace(e.Anchor) == "" {
			continue // already reported by Validate
		}
		if !idx[e.Anchor] {
			errs = append(errs, fmt.Errorf("%s element %q: anchor %q not found", v.Kind, e.ID, e.Anchor))
		}
	}
	return errs
}

// CrossCheck enforces the rules that only make sense across views:
// consistent identity, hosted containers, known parents, and stores that
// exist in the container view.
func CrossCheck(all []View) []error {
	var errs []error
	byID := make(map[string]Element)
	viewOf := make(map[string]Kind)
	containers := make(map[string]bool)
	containerViewIDs := make(map[string]bool)
	hosted := make(map[string]bool)
	var components []Element
	var stores []Element

	for _, v := range all {
		for _, e := range v.Elements {
			if prev, ok := byID[e.ID]; ok {
				if prev.Name != e.Name || prev.Type != e.Type {
					errs = append(errs, fmt.Errorf("element %q differs between %s and %s", e.ID, viewOf[e.ID], v.Kind))
				}
				if prev.Anchor != e.Anchor && v.Kind != Deployment && viewOf[e.ID] != Deployment {
					errs = append(errs, fmt.Errorf("element %q: anchor %q vs %q", e.ID, prev.Anchor, e.Anchor))
				}
			} else {
				byID[e.ID] = e
				viewOf[e.ID] = v.Kind
			}
			switch v.Kind {
			case Container:
				containerViewIDs[e.ID] = true
				if e.Type == "container" {
					containers[e.ID] = true
				}
			case Component:
				components = append(components, e)
			case DataFlow:
				stores = append(stores, e)
			}
		}
		for _, l := range v.Links {
			if v.Kind == Deployment && l.Label == "hosts" {
				hosted[l.To] = true
			}
		}
	}

	for _, c := range components {
		if !containers[c.Parent] {
			errs = append(errs, fmt.Errorf("component %q: parent %q is not a container", c.ID, c.Parent))
		}
	}
	for _, s := range stores {
		if !containerViewIDs[s.ID] {
			errs = append(errs, fmt.Errorf("dataflow store %q: missing from container view", s.ID))
		}
	}
	for id := range containers {
		if !hosted[id] {
			errs = append(errs, fmt.Errorf("container %q: no deployment element hosts it", id))
		}
	}
	return errs
}

Validate 负责单张图,CheckAnchors 负责仓库锚点,CrossCheck 负责跨图一致性。三者分开,是因为输入不同:前两个只需要一张图和索引,第三个需要全部视图。Profiles 把每类视图的读者与更新时机写进代码,但真正的负责人仍然要落到具体的人。

测试覆盖的四类错误

lab 里有四组表驱动测试:单视图校验、锚点校验、跨视图校验和视图画像。失败用例包括未知视图类型、重复元素、悬空链接、未托管容器、父容器不存在和数据流存储未声明。测试只跑本包:

$ cd .local/checks/gsarch-lab
$ go test -count=1 ./internal/views/
ok  	gsarchlab/internal/views	0.275s

本机同时跑了 gofmt -l internal/views(无输出)和 go vet ./internal/views/(退出码 0)。

常见错误与边界

第一,把 C4 当四张必须画全的图。小系统可以只画上下文和部署,组件视图等模块稳定后再补。

第二,图里出现代码中不存在的服务。锚点校验会直接失败,这是好事,不要用“规划中”绕过。

第三,部署视图只画 Pod,不画区域和存储。故障域往往由区域和存储决定,不是由 Pod 决定。

第四,数据流只画箭头,不写拥有者和保留期。出了数据问题,没人知道该找谁,也无法回答合规问题。

第五,视图没有负责人。把读者和更新时机写进代码只是提醒,不能替代值班表上的名字。

第六,跨视图校验太晚接入。最好在提交时跑,否则图会先过期三个月,再被一次性大改。

小结

四类视图服务四类读者,数据流视图补上“数据归谁”。图能长期有效的前提不是画得漂亮,而是每个元素都有锚点、每次变更都能被校验。internal/views 把单图校验、锚点校验和跨图校验拆开,让“图过期”从审美问题变成可执行的失败。

下一节把视角从静态结构转到动态交互:时序图、状态机与契约表如何同源。

阅读导航:上一节:11.3 故障域与爆炸半径 · 下一节:12.2 时序图、状态机与契约表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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