《后端架构设计·演进与治理》8.3 从演练到修复:游戏日、回归与证据留存

把一次游戏日从“跑完四个实验”变成可复核的治理闭环:用严重级别、责任人与到期日约束 Finding,要求 P1/P2 绑定修复前失败、修复后通过的回归用例,并用 SHA-256 封存证据链,给出结构化审计与内容校验的 Go 实现及变异测试。

演练结束,故事才刚刚开始

游戏日当天跑了四个实验:进程、网络、依赖、区域各一次。现场很热闹,群里发了截图,结论是“发现了两个问题,整体可控”。

两周后,审计要一份材料:当时到底坏了什么、谁负责修、修完没有、怎么证明修好了。团队翻出聊天记录、几张过期的大盘截图和一份手工填写的表格。截图没有采集时间,表格里的“已修复”没有对应提交,两个问题也没有回归用例。这次演练无法证明任何事。

问题不在演练本身,而在演练结束后的那半天没有被设计。一次游戏日真正的产出不是“跑过”,而是三样可复核的东西:

  1. Finding:发现了什么,严重级别、责任人、到期日、当前状态;
  2. Regression:修复是否有测试证明“修前失败、修后通过”;
  3. Evidence:当时的假设、时间线、指标和异常栈是否被完整留存,且之后没有被改写。

本节把这三样东西做成一个可审计的 GameDay 结构。它不替代 事件复盘与改进项 的流程,而是把复盘产出的承诺变成机器能检查的字段;证据采集的口径则沿用 可观测性契约 ,避免每个团队各拍各的截图。

Finding 不是待办列表

Finding 和普通工单的区别在于它必须能回答“为什么这次演练不算白做”。本文要求五个字段:严重级别、症状、根因、责任人、到期日,再加一个状态。

级别含义允许状态是否必须绑定回归
P1关键状态转换丢失或数据不可恢复open / fixed是
P2主要降级路径失效,需人工兜底open / fixed是
P3观测、文档或体验缺口open / fixed / accepted否

两条规则值得单独强调。

第一,P1/P2 不允许 accepted。可以接受一个观测缺口,不能接受“支付转换会丢,但我们决定接受”。如果业务真的决定接受,那应该修改系统假设或降级方案,而不是把 Finding 状态改成 accepted 了事。

第二,P1/P2 必须绑定回归用例。没有回归的修复,下一次演练还会以同样的方式失败;审计时也无法区分“真的修了”和“当时看起来好了”。

P3 可以接受,但仍要有责任人和到期日。允许接受不等于允许悬空,否则“低优先级”会变成“永远不做”的同义词。

回归用例必须证明两件事

一个测试被称为回归用例,至少要满足:

  • 修复前运行会失败;
  • 修复后运行会通过;
  • 能指回具体的 Finding;
  • 有证据说明它确实运行过。

第一条最容易被跳过。很多团队在修复后补一个测试,测试当然是绿的,但它从未在旧代码上跑过,因此不能证明它捕获了原来的缺陷。本文用两个布尔字段 FailsBeforeFix 与 PassesAfterFix 显式记录,缺一不可。

演练发现的往往是“组合条件”下的缺陷:特定超时、特定重试次数、特定实例分布。修复后补的测试如果只覆盖单点调用,可能永远抓不到原问题。要求“修前失败”迫使团队先复现,再修复。

证据要封存,而不是截图

证据留存最常见的做法是往共享盘扔截图。截图的问题是:没有采集时间、没有字节边界、可以被覆盖,也无法证明评审后没有被修改。

本文用 Evidence 记录四个字段:名称、类型、SHA-256、字节数、采集时间。内容本身可以放在对象存储或制品库,清单里只保留摘要。这样审计分两步:

  • Audit 检查清单结构:记录是否完整、证据类型是否齐全、Finding 是否有主、回归是否绑定、覆盖矩阵是否缺层;
  • Verify 重新计算内容摘要:字节是否仍与清单一致,是否缺失。

Go 实现:把闭环写成可审计结构

下面的实现覆盖三类检查:实验记录与证据、Finding 与回归、层与状态转换的覆盖。Audit 返回所有错误而不是第一个,便于一次修完;Verify 只做摘要比对,不重复结构校验。

// file: internal/platform/chaos/gameday.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"
)

type Severity string

const (
	SeverityP1 Severity = "P1"
	SeverityP2 Severity = "P2"
	SeverityP3 Severity = "P3"
)

func (s Severity) mustFix() bool { return s == SeverityP1 || s == SeverityP2 }

// Evidence is an immutable artifact. Content lives elsewhere; this record
// carries the digest that proves it did not change after the game day.
type Evidence struct {
	Name       string
	Kind       string
	SHA256     string
	Bytes      int64
	CapturedAt time.Time
}

func NewEvidence(name, kind string, at time.Time, body []byte) Evidence {
	sum := sha256.Sum256(body)
	return Evidence{
		Name: name, Kind: kind, SHA256: hex.EncodeToString(sum[:]),
		Bytes: int64(len(body)), CapturedAt: at,
	}
}

type ExperimentRecord struct {
	ID          string
	Layers      []Layer
	Transitions []string
	StartedAt   time.Time
	EndedAt     time.Time
	Aborted     bool
	Verdict     string // passed, failed, aborted
	Evidence    []Evidence
}

type Finding struct {
	ID           string
	ExperimentID string
	Severity     Severity
	Symptom      string
	RootCause    string
	Owner        string
	Due          time.Time
	Status       string // open, fixed, accepted
	RegressionID string
}

type Regression struct {
	ID             string
	FindingID      string
	FailsBeforeFix bool
	PassesAfterFix bool
	EvidenceName   string
}

type GameDay struct {
	Date                time.Time
	Records             []ExperimentRecord
	Findings            []Finding
	Regressions         []Regression
	RequiredLayers      []Layer
	RequiredTransitions []string
}

func validDigest(s string) bool {
	body, err := hex.DecodeString(s)
	return err == nil && len(body) == sha256.Size && hex.EncodeToString(body) == s
}

// Audit returns every reason the game day is not reviewable.
func (g GameDay) Audit(now time.Time) []error {
	var errs []error
	add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
	if g.Date.IsZero() {
		add("chaos: game day has no date")
	}
	records, evidenceNames := map[string]bool{}, map[string]bool{}
	layers, transitions := map[Layer]bool{}, map[string]bool{}
	for _, r := range g.Records {
		if r.ID == "" || records[r.ID] {
			add("chaos: empty or duplicate record %q", r.ID)
			continue
		}
		records[r.ID] = true
		if !r.EndedAt.After(r.StartedAt) {
			add("chaos: record %s ends before it starts", r.ID)
		}
		kinds := map[string]bool{}
		for _, ev := range r.Evidence {
			if ev.Name == "" || evidenceNames[ev.Name] {
				add("chaos: record %s has an empty or duplicate evidence name", r.ID)
			}
			evidenceNames[ev.Name] = true
			if !validDigest(ev.SHA256) || ev.Bytes <= 0 {
				add("chaos: evidence %q has an invalid digest or size", ev.Name)
			}
			if ev.CapturedAt.Before(r.StartedAt) || ev.CapturedAt.After(r.EndedAt) {
				add("chaos: evidence %q was captured outside %s", ev.Name, r.ID)
			}
			kinds[ev.Kind] = true
		}
		for _, kind := range []string{"hypothesis", "timeline", "metrics"} {
			if !kinds[kind] {
				add("chaos: record %s has no %s evidence", r.ID, kind)
			}
		}
		if r.Aborted && !kinds["abort"] {
			add("chaos: aborted record %s has no abort evidence", r.ID)
		}
		if r.Verdict == "failed" && !kinds["trace"] {
			add("chaos: failed record %s has no trace evidence", r.ID)
		}
		for _, l := range r.Layers {
			layers[l] = true
		}
		for _, tr := range r.Transitions {
			transitions[tr] = true
		}
	}
	findings := map[string]Finding{}
	for _, f := range g.Findings {
		if f.ID == "" || findings[f.ID].ID != "" {
			add("chaos: empty or duplicate finding %q", f.ID)
			continue
		}
		findings[f.ID] = f
		switch f.Status {
		case "open", "fixed", "accepted":
		default:
			add("chaos: finding %s has status %q", f.ID, f.Status)
		}
		if f.Owner == "" || f.Due.IsZero() {
			add("chaos: finding %s is missing an owner or due date", f.ID)
		}
		if f.Status == "open" && f.Due.Before(now) {
			add("chaos: finding %s is open past its due date", f.ID)
		}
		if f.Status == "accepted" && f.Severity.mustFix() {
			add("chaos: finding %s cannot accept %s", f.ID, f.Severity)
		}
	}
	regressions := map[string]Regression{}
	for _, r := range g.Regressions {
		regressions[r.ID] = r
		if !r.FailsBeforeFix || !r.PassesAfterFix {
			add("chaos: regression %s must fail before and pass after the fix", r.ID)
		}
		if !evidenceNames[r.EvidenceName] {
			add("chaos: regression %s references unknown evidence", r.ID)
		}
	}
	for _, f := range g.Findings {
		if !f.Severity.mustFix() {
			continue
		}
		r, ok := regressions[f.RegressionID]
		if !ok || r.FindingID != f.ID {
			add("chaos: finding %s (%s) has no bound regression", f.ID, f.Severity)
		}
	}
	if len(g.RequiredLayers) == 0 && len(g.RequiredTransitions) == 0 {
		add("chaos: game day declares no required coverage")
	}
	for _, l := range g.RequiredLayers {
		if !layers[l] {
			add("chaos: required layer %s has no record", l)
		}
	}
	for _, tr := range g.RequiredTransitions {
		if !transitions[tr] {
			add("chaos: required transition %s has no record", tr)
		}
	}
	return errs
}

// Verify recomputes every digest against the stored bodies.
func (g GameDay) Verify(contents map[string][]byte) []error {
	var errs []error
	for _, r := range g.Records {
		for _, ev := range r.Evidence {
			body, ok := contents[ev.Name]
			if !ok {
				errs = append(errs, fmt.Errorf("chaos: missing content for %q", ev.Name))
				continue
			}
			sum := sha256.Sum256(body)
			if hex.EncodeToString(sum[:]) != ev.SHA256 {
				errs = append(errs, fmt.Errorf("chaos: digest mismatch for %q", ev.Name))
			}
			if int64(len(body)) != ev.Bytes {
				errs = append(errs, fmt.Errorf("chaos: size mismatch for %q", ev.Name))
			}
		}
	}
	return errs
}

Audit 的错误信息刻意带上记录或 Finding 的 ID。审计失败时,人看到的不应该只是“有 3 个错误”,而是“gd-dependency 缺少 abort 证据”“F-1 是 P2 但没有绑定回归”。错误文本也是治理接口的一部分。

Verify 只遍历清单里已登记的证据,不扫描目录。这是有意的:目录里多出来的文件不能自动变成证据,只有经过评审、写进清单的内容才算数。

测试:用变异用例证明门禁真的会拦

审计代码最容易写成“看起来什么都能过”。测试的办法不是只跑一个合法样本,而是逐条破坏规则,确认每一种破坏都被拒绝。

// file: internal/platform/chaos/gameday_test.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos

import (
	"strings"
	"testing"
)

func joined(errs []error) string {
	parts := make([]string, len(errs))
	for i, err := range errs {
		parts[i] = err.Error()
	}
	return strings.Join(parts, " | ")
}

func TestGameDayVerifyDetectsTampering(t *testing.T) {
	g, contents := validGameDay()
	if errs := g.Verify(contents); len(errs) != 0 {
		t.Fatalf("valid evidence rejected: %s", joined(errs))
	}
	contents["gd-zone-metrics"] = []byte("success=0.999 rewritten after review")
	errs := g.Verify(contents)
	if len(errs) == 0 || !strings.Contains(joined(errs), "digest mismatch") {
		t.Fatalf("tamper not detected: %s", joined(errs))
	}
	t.Logf("tamper: %s", joined(errs))
	delete(contents, "gd-zone-metrics")
	errs = g.Verify(contents)
	if len(errs) == 0 || !strings.Contains(joined(errs), "missing content") {
		t.Fatalf("missing content not detected: %s", joined(errs))
	}
	t.Logf("missing: %s", joined(errs))
}

validGameDay() 构造四条记录:进程与区域实验通过,网络实验失败并留下 trace,依赖实验中止并留下 abort;两个 Finding 中一个 P2 已修复并绑定 R-1,一个 P3 仍开放;必选覆盖包含四层与三条状态转换。完整测试还逐条变异这些条件:

变异期望错误
删掉 metrics 证据no metrics evidence
中止记录删掉 abort 证据no abort evidence
失败记录删掉 trace 证据no trace evidence
P2 解绑回归no bound regression
回归只记录“修后通过”must fail before
删除区域实验required layer zone
P3 过期仍为 openopen past its due date

本机运行 go test ./internal/platform/chaos/ -count=1 -v,相关原始输出如下(固定清单与固定时钟,数值仅用于演示门禁行为):

=== RUN   TestGameDayAudit
    gameday_test.go:90: valid: records=4 findings=2 regressions=1
    gameday_test.go:115: missing_metrics: chaos: record gd-process has no metrics evidence
    gameday_test.go:115: aborted_without_abort: chaos: aborted record gd-dependency has no abort evidence
    gameday_test.go:115: failed_without_trace: chaos: failed record gd-network has no trace evidence
    gameday_test.go:115: p2_without_regression: chaos: finding F-1 (P2) has no bound regression
    gameday_test.go:115: regression_passes_only_after: chaos: regression R-1 must fail before and pass after the fix
    gameday_test.go:115: missing_layer: chaos: required layer zone has no record
    gameday_test.go:115: open_past_due: chaos: finding F-3 is open past its due date
--- PASS: TestGameDayAudit (0.00s)
=== RUN   TestGameDayVerifyDetectsTampering
    gameday_test.go:129: tamper: chaos: digest mismatch for "gd-zone-metrics" | chaos: size mismatch for "gd-zone-metrics"
    gameday_test.go:135: missing: chaos: missing content for "gd-zone-metrics"
--- PASS: TestGameDayVerifyDetectsTampering (0.00s)

覆盖矩阵:证明“四层都练过”不能靠嘴

游戏日最容易注水的地方是覆盖面。团队说“四层都覆盖了”,但记录里只有四个通过的实验,没有一条落在关键状态转换上。

本文要求 GameDay 显式声明 RequiredLayers 与 RequiredTransitions,审计时逐项检查是否至少有一条记录命中。这样做有两个后果:

  • 计划阶段就要写清楚“这次必须覆盖哪些层、哪些转换”,不能跑完再总结;
  • 缺项会直接导致审计失败,而不是在汇报 PPT 里被解释掉。

矩阵的行是层,列是关键状态转换,格子里填实验 ID。对于 arch-lab,最低要求是四层各一次,且 CREATED → INVENTORY_RESERVED、INVENTORY_RESERVED → PAID、INVENTORY_RESERVED → CANCELLED 三条转换至少各被一次实验触及。

常见错误与边界

  • 先修后补测试。 补出来的测试没有在旧代码上失败过,无法证明它捕获了原缺陷;FailsBeforeFix 必须来自真实运行。
  • 证据只存截图。 截图没有字节边界和摘要,无法验证是否被覆盖;至少应保存原始文本或制品,并记录 SHA-256。
  • 把 accepted 当万能出口。 P1/P2 不接受“已接受”;如果确实不修,应修改系统假设或降级方案,并留下决策记录。

边界也要说明:这套结构只校验“材料是否自洽”,不判断根因分析是否深刻、修复方案是否最优。它是一道门禁,不是评审的替代品。真正的技术判断仍需要人来看,尤其是 P1 的根因与回归用例是否覆盖了组合条件。

小结

游戏日的产出不是“跑过几次”,而是 Finding、Regression、Evidence 三件套,加上一张覆盖矩阵。Finding 用严重级别和到期日约束责任,回归用“修前失败、修后通过”约束有效性,证据用摘要约束不可篡改性,覆盖矩阵约束演练范围。

本节实现的 Audit 与 Verify 把其中可机械检查的部分固定下来,并用变异测试证明门禁真的会拦。剩下的部分——根因是否成立、修复是否过度、回归是否覆盖组合条件——仍然需要评审。工具的价值不是替人做判断,而是让判断建立在完整、未被改写的材料上。

阅读导航:上一节:8.2 演练设计:稳态假设、爆炸半径与止损 · 下一节:9.1 制品、环境与不可变部署 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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