《游戏服务端架构·平台化与架构治理》8.3 架构规则的自动化检查

复盘结论写了“禁止跨层调用”,三个月后同类问题再次出现,原因是规则只停在文档里。本节把评审结论、事故复盘和平台契约转成带编号的规则,按编译期、测试期、运行期三层落地检查,并讨论豁免的时限与 owner、误报率、规则废弃,以及违规报告如何接入 CI 和值班流程。

复盘写了“禁止”,三个月后又出现

一次跨区结算事故之后,复盘文档里加了一条结论:“结算服务不得直接读取世界分片的表,必须通过结算服务自己的快照。”结论写得很清楚,负责人、截止时间、验收标准都有。

三个月后,另一个小组为了赶活动,又在结算链路里加了一处直读。问起来不是不知道规则,而是规则只在复盘文档里,没人会在提交代码前翻三个月的文档。

这就是规则和检查的区别。规则是“应该怎样”,检查是“现在是不是这样”。只有规则没有检查,规则就依赖每个人的记忆;而记忆在排期压力下最先失效。

这一节讨论怎么把7.2 复盘方法与改进闭环 产出的结论、评审结论和平台契约变成可执行的检查:规则长什么样、在哪一层运行、怎么豁免、什么时候废弃。

规则从哪里来

规则不能凭空发明。每条规则都应该能追到一个具体来源,否则它只是某个人的偏好。

来源典型规则为什么需要自动化
评审结论跨产品变更必须经过产品负责人批准人工检查会漏,且无法审计
事故复盘结算服务不得直读世界分片表同类问题复发时能立即发现
平台契约所有服务必须声明 owner 和 SLO新增服务容易漏填,运行期无法归因
合规要求玩家数据不得跨区域写入违规成本高,必须阻断而非提醒

来源决定规则的效力。来自事故复盘的规则通常应该阻断,因为它已经被真实事故验证过;来自评审讨论的规则可以先警告,观察一段时间再升级。

每条规则至少要有编号、来源、层级、严重程度、owner 和检查条件。没有编号的规则无法豁免,没有 owner 的规则没人维护,没有层级的规则不知道该在哪跑。

一条规则一个检查

“禁止跨层调用”不是一条可执行的规则,因为它没有说清楚:哪一层到哪一层、什么算调用、例外是什么。可执行的规则必须落到一个具体检查上。

模糊规则可执行检查
不要跨层调用internal/domain/** 不得导入 internal/adapter/**
服务要有负责人每个服务的元数据必须包含非空 owner
配置要和基线一致运行期 flag/cross-match 的实际值必须等于 on
接口要兼容新旧版本 minor 偏差不得超过 1

一条规则对应一个检查,不是为了简单,而是为了可归因。违规报告里出现“规则 R-17 在服务 X 上失败”,比出现“架构不规范”有用得多。

规则也不应该互相嵌套。A 规则包含 B 规则,会导致豁免 A 时 B 是否仍然生效变得模糊。宁可拆成两条独立规则,也不要制造隐式依赖。

三个检查层级

层级能查什么查不到什么反馈延迟适合的规则
编译期包依赖、导入前缀、生成代码的结构运行时配置、真实流量秒级依赖方向、分层约束
测试期契约兼容、序列化、接口行为生产配置漂移、环境差异分钟级协议兼容、必填字段
运行期配置实际值、跨服务调用、数据写入代码结构、未执行的路径分钟到小时配置漂移、数据所有权

三层不是替代关系,而是覆盖不同的事实。编译期看不到配置,运行期看不到代码结构,测试期两者都只能看一部分。

一个常见错误是把所有规则都塞进 CI。配置漂移在 CI 里永远查不出来,因为 CI 没有生产配置;反过来,包依赖也不该等到运行期才发现。规则放在错误的层级,要么误报,要么漏报。

运行期检查需要额外的设计:谁来执行、结果写到哪里、失败时是否阻断。运行期通常不能“阻断”,只能告警和自动回滚,所以它的严重程度要和处置能力匹配。

规则是数据,不是散落的脚本

如果每条规则都写成一段独立脚本,半年后会出现三种问题:不知道一共有多少条、不知道哪条还在生效、不知道怎么统一豁免。

更好的做法是把规则建模成数据:规则本身描述“检查什么”,事实描述“观察到了什么”,引擎负责匹配。下面的 Rule 和 Fact 就是这种拆分。

Rule{ID, Source, Level, Kind, Severity, Status, Pattern}
Fact{Kind, Target, Attributes}

规则是长期存在的配置,事实是每次检查的输入。同一套引擎既可以在 CI 里跑编译期事实,也可以在运行期跑配置事实,不需要为每个层级重写一套框架。

生命周期:新增、豁免、废弃

规则也有生命周期。没有废弃机制的规则库会越来越臃肿,最后没人敢动。

阶段触发条件必须记录退出条件
新增评审结论、事故复盘、契约变更编号、来源、owner、层级、严重程度连续两个周期无误报
观察新规则先以警告运行误报样本、真实违规数误报率可接受后转阻断
豁免有明确偿还计划规则 ID、目标、owner、原因、到期时间到期自动失效
废弃规则对应的问题已消失,或约束已变更废弃原因、替代规则从规则库移除,保留历史

新增规则时最容易犯的错,是一上来就阻断。团队第一次被一条误报挡住,就会学会加豁免而不是修问题。先警告、再阻断,看起来慢,实际更快。

废弃规则时最容易犯的错,是直接删除。删除后历史违规记录失去解释,旧豁免也找不到对应规则。正确做法是标记为 retired,引擎跳过它,但记录仍然保留。

豁免:时限、owner、原因

豁免不是“关掉规则”,而是“在限定范围内、限定时间内允许违规”。三个字段缺一不可。

  • owner:必须是人。团队名在到期时找不到具体负责人。
  • 原因:说明为什么现在不能修。没有原因的豁免无法判断是否仍然成立。
  • 到期时间:必须是未来时间。到期后自动失效,违规重新进入报告。

豁免的目标要具体到模块或服务,不能写“全站”。全站豁免等于关闭规则;如果确实要关闭,应该走废弃流程,而不是伪装成豁免。

到期处理必须由代码执行。人工清理豁免的结局是:三个月后没人记得这条豁免为什么存在,也没人敢删。

报告怎么接进流程

违规报告的价值不在于“有多少条”,而在于“谁必须做什么”。报告至少要有四个数字:阻断数、警告数、豁免数、新增数。只报总数会掩盖结构变化。

数字用途异常信号
阻断数决定流水线是否通过长期为零且规则很多,可能是规则没生效
警告数进入排期和债务台账持续上升说明存量在恶化
豁免数衡量例外规模豁免数接近违规数,说明规则与现状脱节
新增数判断是否在扩散新增集中在同一团队,可能是契约或工具问题

报告要按目标排序,而不是按规则排序。值班同学关心“哪个服务有问题”,不是“哪条规则触发得多”。同一目标的多条违规应该聚合在一起,否则一个服务出问题会淹没其他服务。

CI 里阻断的规则,报告要直接给出修复建议和规则来源。只写“R-17 失败”,提交人还得去查文档;写“R-17 失败:internal/domain 不得导入 internal/adapter,来源:2026-09 跨区结算复盘”,修复路径就清楚了。

代码:规则引擎、豁免与报告

下面的 internal/archrules/rules.go 把规则、事实、豁免和报告放在一个包里。它不依赖任何外部框架,同一套逻辑可以在 CI 命令、测试和服务启动检查里复用。

package archrules

import (
	"fmt"
	"sort"
	"strings"
	"time"
)

// Source records why a rule exists. A rule without a source is an opinion,
// and opinions do not survive the next reorganisation.
type Source string

const (
	Review     Source = "review"
	Postmortem Source = "postmortem"
	Contract   Source = "contract"
)

// Level is where the check runs. Earlier levels are cheaper and catch fewer
// problems; runtime checks catch configuration drift that no build can see.
type Level string

const (
	CompileTime Level = "compile"
	TestTime    Level = "test"
	Runtime     Level = "runtime"
)

type Status string

const (
	Active  Status = "active"
	Retired Status = "retired"
)

type Severity string

const (
	Warning  Severity = "warning"
	Blocking Severity = "blocking"
)

type Kind string

const (
	ForbiddenDependency Kind = "forbidden-dependency"
	RequiredAttribute   Kind = "required-attribute"
	ConfigDrift         Kind = "config-drift"
)

// Rule is one executable check. Pattern means different things per kind: an
// import prefix, an attribute name, or an expected configuration value.
type Rule struct {
	ID       string
	Source   Source
	Level    Level
	Kind     Kind
	Severity Severity
	Status   Status
	Pattern  string
}

// Fact is the observation a rule runs against. Keeping facts as data rather
// than callbacks lets the same rule engine run in CI and in production.
type Fact struct {
	Kind       Kind
	Target     string
	Attributes map[string]string
}

// Exemption is time-boxed. Expiry is data, not a calendar reminder: an
// expired exemption stops suppressing on the next evaluation.
type Exemption struct {
	RuleID    string
	Target    string
	Owner     string
	Reason    string
	ExpiresAt time.Time
}

type Violation struct {
	RuleID   string
	Target   string
	Level    Level
	Severity Severity
	Message  string
	Exempted bool
	Owner    string
}

// Evaluate runs every active rule against every matching fact and returns
// violations sorted by target and rule. Retired rules are skipped; they stay
// in the source as history, not as checks.
func Evaluate(rules []Rule, facts []Fact, exemptions []Exemption, at time.Time) []Violation {
	byKey := make(map[string]Exemption, len(exemptions))
	for _, e := range exemptions {
		byKey[e.RuleID+"|"+e.Target] = e
	}
	var out []Violation
	for _, rule := range rules {
		if rule.Status == Retired {
			continue
		}
		for _, fact := range facts {
			if fact.Kind != rule.Kind {
				continue
			}
			bad, msg := check(rule, fact)
			if !bad {
				continue
			}
			v := Violation{
				RuleID:   rule.ID,
				Target:   fact.Target,
				Level:    rule.Level,
				Severity: rule.Severity,
				Message:  msg,
			}
			if e, ok := byKey[rule.ID+"|"+fact.Target]; ok {
				switch {
				case e.Owner == "" || e.Reason == "":
					v.Message += " (exemption invalid: owner and reason are required)"
				case !at.Before(e.ExpiresAt):
					v.Message += " (exemption expired at " + e.ExpiresAt.Format(time.RFC3339) + ")"
				default:
					v.Exempted = true
					v.Owner = e.Owner
					v.Message += " (exempted by " + e.Owner + " until " + e.ExpiresAt.Format(time.RFC3339) + ")"
				}
			}
			out = append(out, v)
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Target != out[j].Target {
			return out[i].Target < out[j].Target
		}
		return out[i].RuleID < out[j].RuleID
	})
	return out
}

func check(rule Rule, fact Fact) (bool, string) {
	switch rule.Kind {
	case ForbiddenDependency:
		if rule.Pattern == "" {
			return false, ""
		}
		if strings.HasPrefix(fact.Target, rule.Pattern) {
			return true, fmt.Sprintf("dependency on forbidden prefix %q", rule.Pattern)
		}
	case RequiredAttribute:
		if rule.Pattern == "" {
			return false, ""
		}
		if strings.TrimSpace(fact.Attributes[rule.Pattern]) == "" {
			return true, fmt.Sprintf("missing required attribute %q", rule.Pattern)
		}
	case ConfigDrift:
		if actual := fact.Attributes["actual"]; actual != rule.Pattern {
			return true, fmt.Sprintf("config drift: want %q, got %q", rule.Pattern, actual)
		}
	}
	return false, ""
}

type Report struct {
	Violations []Violation
	Blocking   int
	Warnings   int
	Exempted   int
}

// BuildReport separates blocking failures from warnings and exemptions. A
// report that only counts violations hides how much of the debt is already
// known and time-boxed.
func BuildReport(vs []Violation) Report {
	r := Report{Violations: vs}
	for _, v := range vs {
		if v.Exempted {
			r.Exempted++
			continue
		}
		if v.Severity == Blocking {
			r.Blocking++
		} else {
			r.Warnings++
		}
	}
	return r
}

func (r Report) Passed() bool { return r.Blocking == 0 }

几个关键点:

  • Evaluate 按事实类型匹配规则。规则只看自己关心的事实,不需要为每个规则写一段遍历代码。
  • retired 规则直接跳过。废弃不是删除,历史记录和旧豁免仍然能对应到规则 ID。
  • 豁免键是“规则 ID + 目标”。同一条规则在不同服务上的豁免互相独立,不会因为一个服务豁免而放过另一个。
  • 过期豁免不改结论,只改说明。违规仍然是违规,Message 里写明“exemption expired at …”,报告能看出是豁免失效而不是新问题。
  • BuildReport 把豁免从阻断计数里剔除。豁免是已知且有时限的债务,不应该让流水线一直红着;但它仍然出现在报告里,不能被隐藏。

误报率与维护成本

规则库和代码一样有维护成本。一条误报率高的规则,会消耗团队对整套治理机制的信任。下表的阈值是示例值,实际取值要按团队历史分布调整。

指标口径处理动作
误报率被人工判定为误报的违规 / 总违规超过 10% 先降级为警告
豁免率豁免数 / 违规数超过 30% 说明规则与现状脱节
维护成本每月修规则花费的人日超过新规则收益就废弃或合并
覆盖率有检查的规则 / 应自动化的规则低于 50% 说明规则还停在文档里

误报率要按规则统计,不能看全局。一条规则误报 50%,可能被另外九条正常规则平均掉,但它对提交人的伤害是真实的。

规则合并要谨慎。两条规则检查同一个事实、严重程度相同,可以合并;检查不同事实的规则合并后,豁免和废弃都会变得模糊。

失败路径

失败路径表现兜底
规则没有来源不知道为什么要遵守,换人后失传每条规则记录来源文档和编号
规则没有 owner误报长期没人修新增规则必须指定 owner
一开始就阻断首周误报挡住发布,团队学会加豁免先警告一个迭代,再转阻断
豁免永久化到期没人处理到期自动失效,报告标注过期
规则层级放错CI 查运行期配置,永远查不到按事实可得性选层级
报告只报总数不知道谁该修按目标聚合,给修复建议

检查表

新增规则时:

  • 规则有编号、来源、owner、层级和严重程度。
  • 规则能落到一个具体检查,不写“不要跨层”这类模糊表述。
  • 检查放在能拿到事实的层级,不硬塞进 CI。
  • 新规则先警告运行,观察误报率后再阻断。
  • 阻断规则在报告里给出修复建议和来源。

维护规则库时:

  • 豁免有 owner、原因和未来到期时间。
  • 到期豁免由代码自动失效,不依赖人工清理。
  • 废弃规则标记为 retired,不直接删除。
  • 每月看误报率、豁免率、维护成本和覆盖率。
  • 报告按目标聚合,区分阻断、警告、豁免和新增。

小结

架构规则的自动化检查,核心不是写多少脚本,而是让每条规则都有来源、有层级、有 owner、有生命周期。规则是数据,事实是输入,引擎负责匹配;这样同一套机制才能同时服务 CI、测试和运行期。

规则要按事实可得性放在正确的层级:编译期查依赖,测试期查契约,运行期查配置。豁免要有时限和 owner,到期自动失效;规则要能废弃,而不是越积越多。下一节讨论组织边界如何影响规则的制定与执行:谁有权定义规则,谁承担违反规则的代价。

阅读导航:上一节:8.2 技术债与架构适应度函数 · 下一节:9.1 团队边界与服务所有权 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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