图在墙上,系统在机器上
北辰工作室的会议室里挂着一张《群岛纪元》的架构图,画于 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 实跑;部署清单路径只作为锚点示例。
数据流视图:数据从哪来、谁拥有、留多久
| 数据 | 产生者 | 拥有者 | 写入 | 保留 |
|---|---|---|---|---|
| 会话票据 | gateway | session-store | 登录/重连 | 7 天 |
| 房间快照 | room | room 与对象存储 | 每局结束 | 30 天 |
| 战斗事件 | room | battle-log | tick 内追加 | 90 天 |
| 结算明细 | settler | 结算库 | 结算完成 | 永久(归档) |
数据流视图不是 DFD 的复刻。它只需要回答四个问题:谁产生、谁拥有、谁可以写、保留多久。这四列决定了合规边界和故障恢复顺序,也决定了数据出问题时先找谁。
一致性靠锚点,不靠自觉
每个元素都必须有一个锚点:代码目录、配置文件键或部署清单路径。锚点不是描述,而是可以在仓库里 stat 到的东西。
检查分三步:
- 单视图校验:ID 唯一,名称、类型、锚点非空,链接端点存在,组件必须有父容器。
- 锚点校验:每个锚点在仓库索引里存在。
- 跨视图校验:同名元素的名称和类型一致;组件父容器存在;每个容器都有部署宿主;数据流中的存储必须出现在容器视图。
第三步最关键。它抓的是“图 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 时序图、状态机与契约表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。