复盘写了“禁止”,三个月后又出现
一次跨区结算事故之后,复盘文档里加了一条结论:“结算服务不得直接读取世界分片的表,必须通过结算服务自己的快照。”结论写得很清楚,负责人、截止时间、验收标准都有。
三个月后,另一个小组为了赶活动,又在结算链路里加了一处直读。问起来不是不知道规则,而是规则只在复盘文档里,没人会在提交代码前翻三个月的文档。
这就是规则和检查的区别。规则是“应该怎样”,检查是“现在是不是这样”。只有规则没有检查,规则就依赖每个人的记忆;而记忆在排期压力下最先失效。
这一节讨论怎么把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 团队边界与服务所有权 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。