一次“只改一个字段”的发布
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 校验与跨服务兼容测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。