基线快照不是架构图
2.1 用 C4 画过容器与组件,4.1 定过模块边界,6.1 定过数据所有权,9.1 把模块映射到团队。这四份材料各自正确,合在一起却可能互相矛盾:某个模块没有部署单元,某张表被两个模块声明,某个 owner 已经不在值班名册。
12.2 把这些事实冻结成一份快照:模块、依赖方向、部署单元、数据 owner 一次性对齐,并计算内容指纹。快照不是图,而是可 diff、可校验、可回滚的数据。
模块清单与依赖方向
| 模块 | Owner | 部署单元 | 自有数据 | 允许依赖 |
|---|---|---|---|---|
| order | team-order | orderd | orders、order_items | 无 |
| catalog | team-order | orderd | skus、prices | 无 |
| inventory | team-stock | orderd | inventory、reservations | order、ports |
| payment | team-pay | orderd | payments | order、ports |
| ports | team-order | orderd | 无 | order、catalog |
| adapters | platform | orderd | 无 | order、catalog、ports、platform |
| platform | platform | orderd | 无 | 无 |
依赖方向只允许从上表右侧指向左侧的同层或更低层。成环检查不在这里重复:4.1 的 internal/modules 已经用 go/parser 扫描 import 并做过环检测,本节的校验器只回答“模块、部署与数据是否对得上”。
部署单元与数据 owner
| 部署单元 | 类型 | 副本 | 内容 | 说明 |
|---|---|---|---|---|
| orderd | runtime | 3 | 七个模块 | 唯一可部署二进制,无状态,前面挂负载均衡 |
| pg-primary | data | 1 | 七张表 | PostgreSQL 18.4,按模块划分 schema 前缀而不是独立库 |
卷一只有一个运行时单元,这是 1.3 ADR 的直接后果。好处是跨模块事务仍在本地事务里完成;代价是任何模块的 panic 或内存泄漏都会带走整个进程,爆炸半径等于全部业务。这个代价写进 12.3 的风险台账,不在这里用“以后拆”掩盖。
数据 owner 指模块,不是 DBA。orders 只允许 order 模块写,inventory 需要读取订单状态时调用 order 的公开接口,不能跨模块 JOIN。L3 验证时把 owner 写进表注释 owner=order,让数据库自己留下可核对的证据。
三者一致的三条规则
- 每个模块必须恰好属于一个
runtime部署单元,且该单元的modules列表包含它。 - 每张表只能有一个 owner 模块,owner 必须存在、必须声明这张表,表必须落在
data单元。 - 模块声明的每张表都必须有存储条目,且存储条目的 owner 与模块一致;没有孤儿声明,也没有无主表。
再加一条元规则:快照内容变化时指纹必须重算。指纹只覆盖规范化后的内容,不包含生成时间,否则每次导出都会“漂移”。
快照的 Go 结构
// file: internal/archsnapshot/snapshot.go
// tier: L2
// verify: go test ./internal/archsnapshot/ -count=1
package archsnapshot
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"slices"
"sort"
)
type Module struct {
Name string `json:"name"`
Owner string `json:"owner"`
DeployUnit string `json:"deploy_unit"`
OwnsData []string `json:"owns_data,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
type DeployUnit struct {
Name string `json:"name"`
Kind string `json:"kind"` // runtime | data
Modules []string `json:"modules,omitempty"`
Replicas int `json:"replicas"`
}
type DataStore struct {
Table string `json:"table"`
Engine string `json:"engine"`
Owner string `json:"owner"`
DeployUnit string `json:"deploy_unit"`
}
type Snapshot struct {
Version string `json:"version"`
Owners []string `json:"owners"`
Modules []Module `json:"modules"`
DeployUnits []DeployUnit `json:"deploy_units"`
DataStores []DataStore `json:"data_stores"`
Fingerprint string `json:"fingerprint,omitempty"`
}
type Finding struct {
Code string `json:"code"`
Ref string `json:"ref"`
Message string `json:"message"`
}
// Validate 检查模块、部署单元、数据 owner 三者是否闭合。
// 依赖方向与成环由 4.1 的 internal/modules 校验器负责,这里不重复。
func (s Snapshot) Validate() []Finding {
var out []Finding
add := func(code, ref, format string, args ...any) {
out = append(out, Finding{Code: code, Ref: ref, Message: fmt.Sprintf(format, args...)})
}
if s.Version == "" {
add("SNAPSHOT_VERSION", "snapshot", "version is required")
}
owners := map[string]bool{}
for _, o := range s.Owners {
if o == "" {
add("OWNER_EMPTY", "owners", "owner must not be empty")
}
owners[o] = true
}
units := map[string]DeployUnit{}
listed := map[string]map[string]bool{}
for _, u := range s.DeployUnits {
if u.Kind != "runtime" && u.Kind != "data" {
add("UNIT_KIND", u.Name, "kind %q must be runtime or data", u.Kind)
}
units[u.Name] = u
listed[u.Name] = map[string]bool{}
for _, name := range u.Modules {
listed[u.Name][name] = true
}
}
modules := map[string]Module{}
for _, m := range s.Modules {
modules[m.Name] = m
}
for _, m := range s.Modules {
if !owners[m.Owner] {
add("MODULE_OWNER", m.Name, "owner %q not in roster", m.Owner)
}
u, ok := units[m.DeployUnit]
if !ok {
add("MODULE_UNIT", m.Name, "deploy unit %q does not exist", m.DeployUnit)
} else if u.Kind != "runtime" {
add("MODULE_UNIT_KIND", m.Name, "modules can only run in runtime units")
} else if !listed[u.Name][m.Name] {
add("MODULE_NOT_LISTED", m.Name, "unit %q does not list this module", u.Name)
}
}
stores := map[string]DataStore{}
for _, d := range s.DataStores {
if _, ok := stores[d.Table]; ok {
add("DATA_TABLE_DUPLICATE", d.Table, "table has more than one owner")
continue
}
stores[d.Table] = d
m, ok := modules[d.Owner]
if !ok {
add("DATA_OWNER", d.Table, "owner module %q does not exist", d.Owner)
continue
}
if !slices.Contains(m.OwnsData, d.Table) {
add("DATA_CLAIM_MISMATCH", d.Table, "module %q does not claim this table", m.Name)
}
if u, ok := units[d.DeployUnit]; !ok || u.Kind != "data" {
add("DATA_UNIT", d.Table, "store must live in an existing data unit")
}
}
for _, m := range s.Modules {
for _, table := range m.OwnsData {
d, ok := stores[table]
if !ok {
add("DATA_STORE_MISSING", m.Name, "claimed table %q has no store", table)
} else if d.Owner != m.Name {
add("DATA_OWNER_CONFLICT", table, "store owner is %q, module is %q", d.Owner, m.Name)
}
}
}
if s.Fingerprint != "" && s.Fingerprint != s.ComputeFingerprint() {
add("FINGERPRINT_DRIFT", "snapshot", "fingerprint does not match content")
}
return out
}
// ComputeFingerprint 先排序再哈希,保证同一内容不受声明顺序影响。
func (s Snapshot) ComputeFingerprint() string {
c := s
c.Fingerprint = ""
sort.Slice(c.Modules, func(i, j int) bool { return c.Modules[i].Name < c.Modules[j].Name })
for i := range c.Modules {
sort.Strings(c.Modules[i].OwnsData)
sort.Strings(c.Modules[i].DependsOn)
}
sort.Slice(c.DeployUnits, func(i, j int) bool { return c.DeployUnits[i].Name < c.DeployUnits[j].Name })
for i := range c.DeployUnits {
sort.Strings(c.DeployUnits[i].Modules)
}
sort.Slice(c.DataStores, func(i, j int) bool { return c.DataStores[i].Table < c.DataStores[j].Table })
raw, _ := json.Marshal(c)
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
ComputeFingerprint 先按名字排序模块、单元与表,再排序每个列表内部的元素,最后对 JSON 求 SHA-256。这样 module 声明顺序变化不会造成假漂移,而真正的模块、表或 owner 变化一定会改变指纹。
测试:三处不一致都会被拒收
// file: internal/archsnapshot/snapshot_test.go
// tier: L2
// verify: go test ./internal/archsnapshot/ -run 'TestValidateClean|TestRejectsThreeWayMismatch|TestFingerprint' -v
package archsnapshot
import "testing"
func fixture() Snapshot {
return Snapshot{
Version: "arch-lab-v1.0.0",
Owners: []string{"team-order", "team-stock", "team-pay", "platform"},
Modules: []Module{
{Name: "order", Owner: "team-order", DeployUnit: "orderd", OwnsData: []string{"orders", "order_items"}},
{Name: "catalog", Owner: "team-order", DeployUnit: "orderd", OwnsData: []string{"skus", "prices"}},
{Name: "inventory", Owner: "team-stock", DeployUnit: "orderd", OwnsData: []string{"inventory", "reservations"}, DependsOn: []string{"order", "ports"}},
{Name: "payment", Owner: "team-pay", DeployUnit: "orderd", OwnsData: []string{"payments"}, DependsOn: []string{"order", "ports"}},
{Name: "ports", Owner: "team-order", DeployUnit: "orderd", DependsOn: []string{"order", "catalog"}},
{Name: "adapters", Owner: "platform", DeployUnit: "orderd", DependsOn: []string{"order", "catalog", "ports", "platform"}},
{Name: "platform", Owner: "platform", DeployUnit: "orderd"},
},
DeployUnits: []DeployUnit{
{Name: "orderd", Kind: "runtime", Replicas: 3, Modules: []string{"order", "catalog", "inventory", "payment", "ports", "adapters", "platform"}},
{Name: "pg-primary", Kind: "data", Replicas: 1},
},
DataStores: []DataStore{
{Table: "orders", Engine: "postgres", Owner: "order", DeployUnit: "pg-primary"},
{Table: "order_items", Engine: "postgres", Owner: "order", DeployUnit: "pg-primary"},
{Table: "skus", Engine: "postgres", Owner: "catalog", DeployUnit: "pg-primary"},
{Table: "prices", Engine: "postgres", Owner: "catalog", DeployUnit: "pg-primary"},
{Table: "inventory", Engine: "postgres", Owner: "inventory", DeployUnit: "pg-primary"},
{Table: "reservations", Engine: "postgres", Owner: "inventory", DeployUnit: "pg-primary"},
{Table: "payments", Engine: "postgres", Owner: "payment", DeployUnit: "pg-primary"},
},
}
}
func module(b *Snapshot, name string) *Module {
for i := range b.Modules {
if b.Modules[i].Name == name {
return &b.Modules[i]
}
}
panic("module not found: " + name)
}
func store(b *Snapshot, table string) *DataStore {
for i := range b.DataStores {
if b.DataStores[i].Table == table {
return &b.DataStores[i]
}
}
panic("store not found: " + table)
}
func has(findings []Finding, code string) bool {
for _, f := range findings {
if f.Code == code {
return true
}
}
return false
}
func TestValidateClean(t *testing.T) {
if got := fixture().Validate(); len(got) != 0 {
t.Fatalf("clean snapshot returned %v", got)
}
}
func TestRejectsThreeWayMismatch(t *testing.T) {
tests := []struct {
name string
mutate func(*Snapshot)
code string
}{
{"module missing from unit", func(s *Snapshot) {
s.DeployUnits[0].Modules = []string{"order", "catalog", "inventory", "ports", "adapters", "platform"}
}, "MODULE_NOT_LISTED"},
{"unknown data owner", func(s *Snapshot) { store(s, "payments").Owner = "team-ghost" }, "DATA_OWNER"},
{"two modules claim one table", func(s *Snapshot) {
module(s, "order").OwnsData = append(module(s, "order").OwnsData, "payments")
}, "DATA_OWNER_CONFLICT"},
{"unknown deploy unit", func(s *Snapshot) { module(s, "platform").DeployUnit = "ghost" }, "MODULE_UNIT"},
{"store in runtime unit", func(s *Snapshot) { store(s, "orders").DeployUnit = "orderd" }, "DATA_UNIT"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := fixture()
tc.mutate(&s)
if findings := s.Validate(); !has(findings, tc.code) {
t.Fatalf("want %s, got %v", tc.code, findings)
}
})
}
}
// 黄金指纹同时检查顺序无关性与基线漂移。
func TestFingerprint(t *testing.T) {
a, b := fixture(), fixture()
b.Modules[0], b.Modules[1] = b.Modules[1], b.Modules[0]
if a.ComputeFingerprint() != b.ComputeFingerprint() {
t.Fatal("fingerprint changed when module order changed")
}
a.Fingerprint = a.ComputeFingerprint()
const want = "b619ce119b160a8b02f5f637c4a7e2510ecf59ad79ce8aee098d0ad7aaf33078"
if a.Fingerprint != want {
t.Fatalf("fingerprint = %s, want %s", a.Fingerprint, want)
}
if got := a.Validate(); len(got) != 0 {
t.Fatalf("sealed snapshot returned %v", got)
}
}
真实运行结果(macOS arm64,Go 1.26.0):
$ go test ./internal/archsnapshot -count=1
ok archlab/internal/archsnapshot 0.545s
$ go test ./internal/archsnapshot -run 'TestValidateClean|TestRejectsThreeWayMismatch|TestFingerprint' -v
--- PASS: TestValidateClean (0.00s)
--- PASS: TestRejectsThreeWayMismatch (0.00s)
--- PASS: TestRejectsThreeWayMismatch/module_missing_from_unit (0.00s)
--- PASS: TestRejectsThreeWayMismatch/unknown_data_owner (0.00s)
--- PASS: TestRejectsThreeWayMismatch/two_modules_claim_one_table (0.00s)
--- PASS: TestRejectsThreeWayMismatch/unknown_deploy_unit (0.00s)
--- PASS: TestRejectsThreeWayMismatch/store_in_runtime_unit (0.00s)
--- PASS: TestFingerprint (0.00s)
PASS
黄金指纹是 b619ce119b160a8b02f5f637c4a7e2510ecf59ad79ce8aee098d0ad7aaf33078。任何模块、表或 owner 变化都会让这个测试失败,评审者必须先确认变更,再更新常量,不能顺手把测试改绿。
L3:用 PostgreSQL 18.4 核对数据 owner
以下步骤要求一个可用的 PostgreSQL 18.4 实例,本机没有该实例,未在本机执行:
createdb archlab
psql archlab -c "create table orders (id text primary key); comment on table orders is 'owner=order';"
psql archlab -c "create table payments (id text primary key); comment on table payments is 'owner=payment';"
psql "$ARCHLAB_PG_DSN" -At -F $'\t' -c "
select c.relname, obj_description(c.oid,'pg_class')
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' order by 1;"
示例只建 orders 与 payments 两张表,预期输出两行 <table>\towner=<module>,与快照中对应条目比对。完整七张表由 6.1 的迁移脚本创建,之后跑同一条查询逐条核对;这里只验证注释约定,不验证业务列。
常见错误与边界
把部署单元当成模块是最常见的错误:orderd 里有七个模块,不代表有七个服务。第二个错误是让 DBA 当数据 owner:DBA 能建表,但不能决定哪张表归哪个业务模块写。第三个错误是手工改快照后不重算指纹,CI 会在下一次运行时以 FINGERPRINT_DRIFT 拒绝。
快照只覆盖卷一的单库单进程形态。它不能证明拆分后仍然成立,也不校验 SQL 里是否真的没有跨模块 JOIN;后者需要 8.3 的适应度函数或数据库权限来兜底。L3 步骤只定了核对口径,没有在本机跑过,不能写成“已验证”。
小结
12.2 把架构图变成了可 diff 的数据:七个模块、一个运行时单元、一个数据单元、七张有明确 owner 的表,外加一个内容指纹。internal/archsnapshot 校验三者闭合,黄金指纹把“基线变了”变成一次需要评审的测试失败。下一节把这些结构映射到可测的质量属性场景,并给出风险台账与关闭条件。
阅读导航:上一节:12.1 需求、约束与非功能目标 · 下一节:12.3 质量属性场景与架构风险清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。