《后端架构设计·演进与治理》2.2 契约版本演进:向后兼容、弃用与消费者驱动

从 order.created 事件把金额从整数分改成字符串小数的一次发布事故切入,区分 backward、forward 与 full 三种兼容方向,给出七类字段变更的判定表和单文件 Go 检查器,并用真实测试输出说明新增、删除、改类型与改可选性各自会让哪个方向断裂。

一次“只改一个字段”的发布

arch-lab 的 order.created 事件到 v1 时只有四个字段:id、amount(整数分)、customer(对象)和可选的 note。支付团队为了支持多币种,在一次发布里同时做了两件事:新增可选字段 currency,把 amount 从整数分改成字符串小数。发布说明写的是“新增币种字段”,CI 里 provider 自己的集成测试全绿,因为测试只用了支付团队自己的样例。两个小时后,对账消费者开始报错。它按 v1 的约定把 amount 当整数处理,遇到字符串 "128.00" 直接反序列化失败;另一个只读取 id 和 amount 的报表任务则因为新增了 currency 而正常,因为它忽略未知字段。事故的根因不是“改错了类型”,而是团队没有把“兼容性”拆成方向:对谁兼容、在哪个方向上兼容、谁有权判断这个方向是否重要。

兼容性是两个方向,不是一个形容词

backward compatible 的准确含义是:新版本的读者能读旧版本写出的数据。forward compatible 是:旧版本的读者能读新版本写出的数据。两者都成立才叫 full compatible。只说“向后兼容”而不说方向,等于没说;同一个变更完全可能对新读者安全、对旧读者致命。这里必须先声明读者模型,否则判定无法复现:本节采用两条规则,读者忽略不认识的字段,读者拒绝缺失的必填字段。现实中的 JSON 反序列化器、Protobuf 和 Avro 各有细节差异,但这两条覆盖了大多数服务端实现,也足以解释事故。新增字段时,新读者读旧数据会缺字段,旧读者读新数据会多字段;删除字段时反过来。把这两个方向分开,才能解释为什么“新增一个必填字段”不是“加个字段而已”。

七类变更的判定表

把字段变更拆到最小,只有七类:新增可选、新增必填、删除可选、删除必填、改类型、可选改必填、必填改可选。每一类在两个方向上的结果如下。

变更backward(新读旧)forward(旧读新)判定
新增可选字段安全:缺字段用默认值安全:忽略未知字段full
新增必填字段断裂:旧数据没有该字段安全:旧读者忽略它forward
删除可选字段安全:新读者忽略它安全:旧读者用默认值full
删除必填字段安全:新读者忽略它断裂:新数据缺必填字段backward
改字段类型断裂:旧值类型不符断裂:新值类型不符breaking
可选改必填断裂:旧数据可能缺字段安全:新数据总有该字段forward
必填改可选安全:旧数据总有该字段断裂:新数据可能缺字段backward

表里最反直觉的是“删除可选字段判定为 full”。在纯 schema 模型下它确实两个方向都安全,但这不等于可以删:某个消费者可能一直依赖这个可选字段,只是没有把它写进 schema。这正是单纯比较 schema 的盲区,也是 2.3 要引入消费者期望的原因。同样,“新增必填字段”在表里是 forward,不代表可以发布。它意味着新读者读不了旧数据,只有在所有旧数据都完成回填、且消费者先升级之后才安全。这类变更的正确做法不是加 required,而是先加可选字段、回填、观察一个发布周期,再决定是否收紧。

弃用不是删除

版本演进最常见的错误是把“弃用”和“删除”当成同一天的动作。弃用只表示“不再推荐使用”,删除才会真正改变数据形状。一个可执行的时间线至少分三段:先发布双写或双读,让新旧字段同时存在;再把消费者逐个迁到新字段,并用访问日志确认旧字段的读取量归零;最后才删除旧字段,而且删除前要跑一次全量消费者清单核对。版本号本身也要有规则。事件名带版本(order.created.v2)适合破坏性变更,因为新旧事件可以并行存在;字段级版本(schema_version: 2)适合同一事件内的兼容演进,但要求消费者真的按版本分支。两者混用会让“当前版本”失去唯一含义,排查时连该看哪份 schema 都不确定。站内错误模型与版本化 讨论过 API 层的同类问题,事件契约的判定可以沿用同一套方向定义。回滚窗口也会反过来约束版本策略。如果发布后 30 分钟内可能回滚,那么新版本必须能读旧数据,否则回滚后的旧代码读到新事件会直接失败。这一点在回滚与兼容窗口 里有更完整的发布侧讨论。

一个可被 CI 调用的检查器

下面的 CheckCompatibility 只做一件事:给定同一份 schema 的两个版本,逐字段判定两个方向,并给出 full、backward、forward 或 breaking。它不读取消费者代码,也不猜测意图;这些留给下一节的消费者驱动门禁。

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

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

// ValueType mirrors JSON, not Go, because the contract crosses languages.
type ValueType string

const (
	TypeString ValueType = "string"
	TypeInt    ValueType = "int"
	TypeFloat  ValueType = "float"
	TypeBool   ValueType = "bool"
	TypeObject ValueType = "object"
	TypeArray  ValueType = "array"
)

func (t ValueType) valid() bool {
	switch t {
	case TypeString, TypeInt, TypeFloat, TypeBool, TypeObject, TypeArray:
		return true
	}
	return false
}

// FieldSpec is one field in one schema version. Required means the producer
// always sends it and the consumer may fail when it is absent.
type FieldSpec struct {
	Name     string
	Type     ValueType
	Required bool
}

// Schema is one version of a message contract.
type Schema struct {
	Name    string
	Version string
	Fields  []FieldSpec
}

// Validate rejects schemas that cannot be compared meaningfully.
func (s Schema) Validate() error {
	if strings.TrimSpace(s.Name) == "" {
		return fmt.Errorf("contract: schema name is empty")
	}
	if strings.TrimSpace(s.Version) == "" {
		return fmt.Errorf("contract: schema %q has no version", s.Name)
	}
	seen := make(map[string]bool, len(s.Fields))
	for _, f := range s.Fields {
		if strings.TrimSpace(f.Name) == "" {
			return fmt.Errorf("contract: schema %q has an empty field name", s.Name)
		}
		if seen[f.Name] {
			return fmt.Errorf("contract: schema %q has duplicate field %q", s.Name, f.Name)
		}
		seen[f.Name] = true
		if !f.Type.valid() {
			return fmt.Errorf("contract: schema %q field %q has invalid type %q", s.Name, f.Name, f.Type)
		}
	}
	return nil
}

// CompatVerdict is the strongest statement the checker can make.
type CompatVerdict string

const (
	VerdictFull     CompatVerdict = "full"     // both directions safe
	VerdictBackward CompatVerdict = "backward" // new reader reads old data
	VerdictForward  CompatVerdict = "forward"  // old reader reads new data
	VerdictBreaking CompatVerdict = "breaking" // neither direction safe
)

// FieldChange records one difference and its effect on each direction.
// Readers ignore unknown fields and reject missing required fields.
type FieldChange struct {
	Field    string
	Kind     string
	Backward bool
	Forward  bool
}

// CompatReport is the machine-readable result a CI job can gate on.
type CompatReport struct {
	From     string
	To       string
	Backward bool
	Forward  bool
	Verdict  CompatVerdict
	Changes  []FieldChange
}

// CheckCompatibility compares two versions of the same schema. It does not
// guess intent: adding an optional field is full-compatible, adding a
// required field is forward-only, and a type change is breaking unless the
// field is introduced under a new name.
func CheckCompatibility(old, new Schema) (CompatReport, error) {
	if err := old.Validate(); err != nil {
		return CompatReport{}, fmt.Errorf("old: %w", err)
	}
	if err := new.Validate(); err != nil {
		return CompatReport{}, fmt.Errorf("new: %w", err)
	}
	if old.Name != new.Name {
		return CompatReport{}, fmt.Errorf("contract: cannot compare %q with %q", old.Name, new.Name)
	}

	index := func(fields []FieldSpec) map[string]FieldSpec {
		m := make(map[string]FieldSpec, len(fields))
		for _, f := range fields {
			m[f.Name] = f
		}
		return m
	}
	oldFields, newFields := index(old.Fields), index(new.Fields)
	names := make([]string, 0, len(oldFields)+len(newFields))
	for name := range oldFields {
		names = append(names, name)
	}
	for name := range newFields {
		if _, ok := oldFields[name]; !ok {
			names = append(names, name)
		}
	}
	sort.Strings(names)

	report := CompatReport{From: old.Version, To: new.Version, Backward: true, Forward: true}
	record := func(c FieldChange) {
		report.Changes = append(report.Changes, c)
		report.Backward = report.Backward && c.Backward
		report.Forward = report.Forward && c.Forward
	}

	for _, name := range names {
		before, inOld := oldFields[name]
		after, inNew := newFields[name]
		switch {
		case !inNew:
			kind := "removed_optional"
			if before.Required {
				kind = "removed_required"
			}
			record(FieldChange{name, kind, true, !before.Required})
		case !inOld:
			kind := "added_optional"
			if after.Required {
				kind = "added_required"
			}
			record(FieldChange{name, kind, !after.Required, true})
		case before.Type != after.Type:
			record(FieldChange{name, "type_changed", false, false})
		case before.Required != after.Required:
			kind := "made_required"
			if !after.Required {
				kind = "made_optional"
			}
			record(FieldChange{name, kind, !after.Required, after.Required})
		}
	}

	switch {
	case report.Backward && report.Forward:
		report.Verdict = VerdictFull
	case report.Backward:
		report.Verdict = VerdictBackward
	case report.Forward:
		report.Verdict = VerdictForward
	default:
		report.Verdict = VerdictBreaking
	}
	return report, nil
}

实现里有三个刻意选择。第一,字段按名字排序后遍历,报告顺序与声明顺序无关,CI diff 稳定。第二,任何未知类型、重名字段或空版本号都直接报错,避免检查器在坏输入上给出“兼容”的假结论。第三,类型变更一律判 breaking,不做 int → float 之类的隐式放宽;跨语言消费者对数值精度的处理并不一致,放宽需要单独的兼容证明。

测试覆盖七类变更中的全部情况,外加“无变更”和三类坏输入。每个用例只允许产生一条 change,防止实现把一次变更拆成多条而掩盖方向。

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

import "testing"

func baseOrderFields() []FieldSpec {
	return []FieldSpec{
		{Name: "id", Type: TypeString, Required: true},
		{Name: "amount", Type: TypeInt, Required: true},
		{Name: "customer", Type: TypeObject, Required: true},
		{Name: "note", Type: TypeString},
	}
}

func withField(fields []FieldSpec, f FieldSpec) []FieldSpec {
	out := append([]FieldSpec(nil), fields...)
	return append(out, f)
}

func withoutField(fields []FieldSpec, name string) []FieldSpec {
	out := make([]FieldSpec, 0, len(fields))
	for _, f := range fields {
		if f.Name != name {
			out = append(out, f)
		}
	}
	return out
}

func replaceField(fields []FieldSpec, f FieldSpec) []FieldSpec {
	out := make([]FieldSpec, 0, len(fields))
	for _, old := range fields {
		if old.Name == f.Name {
			out = append(out, f)
			continue
		}
		out = append(out, old)
	}
	return out
}

func orderSchema(version string, fields []FieldSpec) Schema {
	return Schema{Name: "order.created", Version: version, Fields: fields}
}

func TestCheckCompatibilityVerdicts(t *testing.T) {
	base := orderSchema("v1", baseOrderFields())
	cases := []struct {
		name    string
		next    Schema
		verdict CompatVerdict
		kind    string
	}{
		{"add optional", orderSchema("v2", withField(baseOrderFields(), FieldSpec{Name: "currency", Type: TypeString})), VerdictFull, "added_optional"},
		{"add required", orderSchema("v2", withField(baseOrderFields(), FieldSpec{Name: "currency", Type: TypeString, Required: true})), VerdictForward, "added_required"},
		{"remove required", orderSchema("v2", withoutField(baseOrderFields(), "amount")), VerdictBackward, "removed_required"},
		{"remove optional", orderSchema("v2", withoutField(baseOrderFields(), "note")), VerdictFull, "removed_optional"},
		{"type change", orderSchema("v2", replaceField(baseOrderFields(), FieldSpec{Name: "amount", Type: TypeString, Required: true})), VerdictBreaking, "type_changed"},
		{"optional to required", orderSchema("v2", replaceField(baseOrderFields(), FieldSpec{Name: "note", Type: TypeString, Required: true})), VerdictForward, "made_required"},
		{"required to optional", orderSchema("v2", replaceField(baseOrderFields(), FieldSpec{Name: "amount", Type: TypeInt})), VerdictBackward, "made_optional"},
	}
	for _, tc := range cases {
		report, err := CheckCompatibility(base, tc.next)
		if err != nil {
			t.Fatalf("%s: unexpected error: %v", tc.name, err)
		}
		if report.Verdict != tc.verdict {
			t.Errorf("%s: verdict=%s want=%s", tc.name, report.Verdict, tc.verdict)
		}
		if len(report.Changes) != 1 || report.Changes[0].Kind != tc.kind {
			t.Errorf("%s: changes=%+v want one %s", tc.name, report.Changes, tc.kind)
		}
		t.Logf("%s: %s -> %s verdict=%s backward=%t forward=%t",
			tc.name, report.From, report.To, report.Verdict, report.Backward, report.Forward)
	}
}

func TestCheckCompatibilityNoChange(t *testing.T) {
	base := orderSchema("v1", baseOrderFields())
	report, err := CheckCompatibility(base, orderSchema("v1", baseOrderFields()))
	if err != nil {
		t.Fatal(err)
	}
	if report.Verdict != VerdictFull || len(report.Changes) != 0 {
		t.Fatalf("identical schema must be full, got %s with %d changes", report.Verdict, len(report.Changes))
	}
	t.Logf("identical: verdict=%s changes=%d", report.Verdict, len(report.Changes))
}

func TestCheckCompatibilityRejectsBadInput(t *testing.T) {
	base := orderSchema("v1", baseOrderFields())
	cases := []struct {
		name     string
		old, new Schema
	}{
		{"duplicate field", base, Schema{Name: "order.created", Version: "v2", Fields: []FieldSpec{
			{Name: "id", Type: TypeString}, {Name: "id", Type: TypeString},
		}}},
		{"invalid type", base, orderSchema("v2", withField(baseOrderFields(), FieldSpec{Name: "x", Type: "decimal"}))},
		{"different name", base, Schema{Name: "payment.captured", Version: "v2", Fields: baseOrderFields()}},
	}
	for _, tc := range cases {
		if _, err := CheckCompatibility(tc.old, tc.new); err == nil {
			t.Errorf("%s: expected error, got nil", tc.name)
		} else {
			t.Logf("%s: rejected: %v", tc.name, err)
		}
	}
}

本机实测:七个判定方向与三类坏输入

在 .local/checks/be-arch-gov-ch02 的 module archlab(go 1.26.0)中执行 gofmt -l .、go vet ./...、go test ./... -count=1 -v,全部通过;环境为 macOS darwin/arm64、Go 1.26.0。完整输出在 .local/reports/be-arch-gov/ch02-be-arch-gov-02-2-contract-versioning-consumer-driven.txt。

=== RUN   TestCheckCompatibilityVerdicts
    schema_test.go:75: add optional: v1 -> v2 verdict=full backward=true forward=true
    schema_test.go:75: add required: v1 -> v2 verdict=forward backward=false forward=true
    schema_test.go:75: remove required: v1 -> v2 verdict=backward backward=true forward=false
    schema_test.go:75: remove optional: v1 -> v2 verdict=full backward=true forward=true
    schema_test.go:75: type change: v1 -> v2 verdict=breaking backward=false forward=false
    schema_test.go:75: optional to required: v1 -> v2 verdict=forward backward=false forward=true
    schema_test.go:75: required to optional: v1 -> v2 verdict=backward backward=true forward=false
--- PASS: TestCheckCompatibilityVerdicts (0.00s)
=== RUN   TestCheckCompatibilityNoChange
    schema_test.go:89: identical: verdict=full changes=0
--- PASS: TestCheckCompatibilityNoChange (0.00s)
=== RUN   TestCheckCompatibilityRejectsBadInput
    schema_test.go:108: duplicate field: rejected: new: contract: schema "order.created" has duplicate field "id"
    schema_test.go:108: invalid type: rejected: new: contract: schema "order.created" field "x" has invalid type "decimal"
    schema_test.go:108: different name: rejected: contract: cannot compare "order.created" with "payment.captured"
--- PASS: TestCheckCompatibilityRejectsBadInput (0.00s)
PASS
ok  	archlab/internal/platform/contract	0.413s

七行判定与上表逐条对应。事故里的两个变更分别是 add optional(full)和 type change(breaking);如果 CI 只跑了这两条,至少能拦住金额改类型,而不会把整次发布都放行。新增 currency 本身没有问题,问题在于它和类型变更被当成同一个“兼容变更”一起发布。

三类坏输入也被拒绝。重名字段会让“按名字判定”失去意义,未知类型会让比较退化成字符串比较,跨 schema 名比较则可能把不相关的两个契约判成兼容;这三种情况都应以错误退出,而不是给出一个看起来正常的报告。

常见错误与边界

第一,把“向后兼容”当成唯一目标。只保 backward 会让回滚变得不安全:回滚后的旧代码读到新数据,缺必填字段就直接失败。发布窗口内 backward 和 forward 至少要有一个被明确覆盖,两个都没有就是 breaking。第二,删除可选字段不算破坏性变更。schema 层面确实两个方向都安全,但消费者可能一直在读它。删除前要用访问日志或消费者清单确认读取量为零,不能只看 schema 判定。

第三,类型变更靠“我们的解析器能转”过关。跨语言消费者的数值精度、空值和时间格式处理不同,int → float 在 Go 里可能无感,在强类型语言里可能直接反序列化失败。类型变更应新建字段名,走双写和弃用流程。

第四,只比较 schema,不验证真实消息。schema 相等不代表 producer 真的按 schema 发;样例消息、可空字段和默认值都可能偏离。下一节把消费者期望和 provider 实际样例接进门禁,补上这一层。

小结

契约兼容性必须带方向:backward 是新读者读旧数据,forward 是旧读者读新数据,两者都成立才是 full。把字段变更拆成七类后,判定就变成一张可复核的表,而不是发布评审时的口头承诺。本机的检查器实现了这张表,并用七类变更、无变更和三类坏输入验证了行为。它的边界同样明确:只看 schema,不看消费者,也不看真实消息,所以“删除可选字段”会被判成 full。这个盲区不是实现缺陷,而是输入不足,需要下一节的消费者驱动契约来补。

版本演进的目标不是“永远不 breaking”,而是让每一次 breaking 都被看见、被排期、被消费者确认。先加可选字段、回填、迁移消费者、再删除,这条 expand-contract 路径比任何版本号命名规则都更可靠。

阅读导航:上一节:2.1 同步调用与异步事件:组合而不是二选一 · 下一节:2.3 契约变更门禁:schema 校验与跨服务兼容测试 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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