《互联网产品的服务端架构·产品、流量与基础能力》10.1 功能上线的最小发布单元

一次发布混进三个功能、一个索引重建和一份配置,出故障时没人说得清该回滚哪一段。文章把发布拆成代码、配置、schema、数据修复四类可独立观测、独立回滚的变更单元,用 Go 校验组件冲突、不可逆变更的观测信号与纯配置误标,并给出表驱动测试与真实运行输出。

一次发布里塞了四件事

「回声」是一个内容社区,订单系统负责会员、打赏和广告位购买。周五下午,团队准备上线“订单页改版”。发布单里列了四项:前端按钮文案与结算逻辑、会员费率从 0.6% 调到 0.5%、给 orders 表加一个渠道索引、把历史订单的 channel 字段回填一遍。负责人认为它们都“跟订单有关”,打包进同一个发布窗口。

发布后十分钟,下单接口错误率从 0.2% 升到 7%(案例假设值)。值班同学先回滚了应用镜像,错误率下降到 1.5%,但索引重建任务还在数据库上跑,DDL 已经把写入锁住;回填任务也继续扫描旧数据,把已经被新代码改过的行又写回旧格式。费率配置在配置中心里已经生效,回滚镜像不会回滚配置,财务口径和新代码对不上。

两个小时里,团队做了三次“回滚”,每次只回滚了自己负责的那一块。事故报告最后写的是“发布流程不规范”,但真正的问题是:他们把一次 git merge 当成了发布单元,而没有定义哪些变更是可以一起观测、一起回滚的。

发布单元是按回滚边界切的

发布单元不是“一次合并”,也不是“一个服务”,而是“一组能独立观测、独立回滚的变更”。判断标准有两个:出问题时能不能只看这一组的指标;回滚时能不能只撤这一组而不影响其他组。

变更类别典型内容主要风险观测信号回滚方式
代码接口逻辑、页面渲染、客户端协议新逻辑错误、兼容性破坏错误率、延迟、业务转化回滚镜像或关闭开关
配置费率、限额、白名单、文案值错误、缓存未刷新配置版本、业务指标突变改回旧值并刷新
schema加列、加索引、改类型、删列锁表、旧代码写入失败锁等待、写入错误、复制延迟通常不可逆,只能向前修复
数据修复回填、重算、清洗覆盖新数据、重复写入影响行数、校验和用备份或反向任务补偿

四类变更的风险形状不同。代码和配置通常可以快速回滚,schema 往往不可逆,数据修复一旦覆盖就难以还原。把它们捆在一起,等于用最慢、最不可逆的那一项决定整个发布的回滚能力。

组件的概念用来检查“能不能独立回滚”。如果一个组件同时出现在两个变更里,两个变更就无法分别撤销:回滚 A 会把 B 的改动一起带走,或者 B 的写入依赖 A 留下的结构。发布前应该让组件集合互不相交,交集部分单独拆成一个变更,或者明确合并成一个不可分割的单元。

不可逆变更必须带观测信号。删列、改类型、覆盖数据这些操作没有“撤回到上一版”的按钮,只能靠指标尽早发现异常,再向前修复。没有信号的不可逆变更不是勇敢,是赌博。

失败场景:回滚了代码,索引还在跑

把那次事故拆开看,四个变更各自都“没错”,错在它们被当成一个整体。

第一个问题是没有独立观测。错误率上升时,团队无法判断是代码逻辑、费率配置还是索引导致的。镜像回滚后错误率下降,大家以为找到原因,实际上只是代码路径不再触发被锁住的写入。

第二个问题是 schema 变更不可逆。索引已经建了一半,回滚应用不会删除索引;如果直接取消,可能留下无效索引。团队只能等它跑完,而它正是写入变慢的原因之一。schema 变更应该单独发布,并且选择低峰期、可在线执行的方案,而不是和业务代码挤在同一分钟。

第三个问题是数据回填和新代码并发。回填任务按旧格式写 channel,新代码按新格式写 channel_v2,两边互相覆盖。回填没有水位线,也没有跳过最近被修改的行,导致一部分订单在事故后仍然带着错误字段。

第四个问题是配置没有版本化的回滚路径。费率从 0.6% 调到 0.5% 是在配置中心直接改的,没有关联发布单,也没有记录“这次发布包含哪个配置版本”。镜像回滚后,配置仍然是新值,财务对账出现一批按 0.5% 计算的订单。

如果发布单在提交前跑一次单元校验,至少能发现两个问题:orders 这个组件同时出现在 schema 变更和数据回填里,无法独立回滚;索引变更不可逆却没有配置观测信号。它们不会阻止发布,但会迫使团队把它们拆开,并在发布前想清楚每个单元失败时怎么办。

用 Validate 固定发布单元

Change 把一次变更描述成名称、类别、组件、是否可逆和观测信号。Validate 只做发布前能静态判断的检查:名称不能为空、组件不能为空、类别必须已知、不可逆变更必须有信号、组件不能跨变更重复、纯配置不能标成 schema。

// file: internal/release/unit.go
// tier: L1
// verify: go test ./internal/release/

package release

import (
	"fmt"
	"strings"
)

// ChangeKind 是变更的类别。类别决定它的风险、观测方式和回滚手段。
type ChangeKind string

const (
	KindCode   ChangeKind = "code"
	KindConfig ChangeKind = "config"
	KindSchema ChangeKind = "schema"
	KindData   ChangeKind = "data"
)

// Change 是一次发布里的一个变更单元。
// Components 是它触及的组件;Signals 是判断它是否健康的观测指标。
type Change struct {
	Name       string
	Kind       ChangeKind
	Components []string
	Reversible bool
	Signals    []string
}

// Validate 检查一组变更能否作为一次发布。
// 同一个组件不能出现在两个变更里,否则两个变更无法独立回滚。
func Validate(changes []Change) error {
	owner := make(map[string]int)
	for i, c := range changes {
		if strings.TrimSpace(c.Name) == "" {
			return fmt.Errorf("release: change %d has empty name", i)
		}
		if len(c.Components) == 0 {
			return fmt.Errorf("release: change %q has no components", c.Name)
		}
		if err := validKind(c.Kind); err != nil {
			return fmt.Errorf("release: change %q: %w", c.Name, err)
		}
		if !c.Reversible && len(c.Signals) == 0 {
			return fmt.Errorf("release: irreversible change %q has no signals", c.Name)
		}
		if c.Kind == KindSchema && onlyConfig(c.Components) {
			return fmt.Errorf("release: change %q is pure config but labeled schema", c.Name)
		}
		for _, comp := range c.Components {
			comp = strings.TrimSpace(comp)
			if comp == "" {
				return fmt.Errorf("release: change %q has empty component", c.Name)
			}
			if prev, ok := owner[comp]; ok && prev != i {
				return fmt.Errorf("release: component %q appears in %q and %q",
					comp, changes[prev].Name, c.Name)
			}
			owner[comp] = i
		}
	}
	return nil
}

func validKind(k ChangeKind) error {
	switch k {
	case KindCode, KindConfig, KindSchema, KindData:
		return nil
	default:
		return fmt.Errorf("unknown change kind %q", k)
	}
}

func onlyConfig(components []string) bool {
	for _, c := range components {
		if !strings.HasPrefix(strings.TrimSpace(c), "config:") {
			return false
		}
	}
	return len(components) > 0
}

Kind 用字符串常量而不是整数,是为了让发布单、日志和报告里直接可读。四类变更的边界要在团队内写清楚:改 config: 前缀的配置项就是配置变更,不要因为“它影响订单”就标成 schema;schema 变更必须真的改数据库结构。

Components 的命名约定是“类型:名称”,例如 api:checkout、config:fee_rate、schema:order_idx、job:backfill。这样 onlyConfig 可以判断一个变更是否全部由配置组件组成。纯配置变更如果标成 schema,会让发布系统按不可逆流程处理它,也会让回滚时不敢动配置。

组件重复检查按“不同变更”判断。同一个变更里重复写同一个组件是允许的,因为那只是描述冗余;但两个变更共享组件就必须报错。实现里用 owner 记录组件第一次出现的变更下标,只有下标不同才报错。

不可逆变更的信号不能只写一个名字,还要在发布单里写清楚阈值和观察窗口。Validate 只能检查“有没有”,不能检查“够不够”。这是静态校验的边界:它负责拦住明显不完整的发布单,不负责替团队做风险判断。

测试:坏单元必须在发布前被拦下

测试用一张表覆盖六种拒绝理由,再加两个正向用例:四类变更互不重叠可以通过;同一个变更内部重复组件可以通过。

// file: internal/release/unit_test.go
// tier: L1
// verify: go test ./internal/release/

package release

import (
	"strings"
	"testing"
)

func TestValidateAcceptsIndependentChanges(t *testing.T) {
	changes := []Change{
		{Name: "checkout-v2", Kind: KindCode, Components: []string{"api:checkout"}, Reversible: true},
		{Name: "fee-rate", Kind: KindConfig, Components: []string{"config:fee_rate"}, Reversible: true},
		{Name: "order-index", Kind: KindSchema, Components: []string{"schema:order_idx"},
			Reversible: false, Signals: []string{"query_p99"}},
		{Name: "backfill-orders", Kind: KindData, Components: []string{"job:backfill"}, Reversible: true},
	}
	if err := Validate(changes); err != nil {
		t.Fatalf("Validate() = %v, want nil", err)
	}
}

func TestValidateAllowsRepeatedComponentWithinOneChange(t *testing.T) {
	changes := []Change{
		{Name: "checkout-v2", Kind: KindCode, Components: []string{"api:checkout", "api:checkout"}, Reversible: true},
	}
	if err := Validate(changes); err != nil {
		t.Fatalf("Validate() = %v, want nil", err)
	}
}

func TestValidateRejectsBadChanges(t *testing.T) {
	cases := []struct {
		name    string
		changes []Change
		want    string
	}{
		{
			name:    "empty name",
			changes: []Change{{Name: " ", Kind: KindCode, Components: []string{"api:a"}, Reversible: true}},
			want:    "empty name",
		},
		{
			name:    "no components",
			changes: []Change{{Name: "a", Kind: KindCode, Reversible: true}},
			want:    "no components",
		},
		{
			name:    "unknown kind",
			changes: []Change{{Name: "a", Kind: "shadow", Components: []string{"api:a"}, Reversible: true}},
			want:    "unknown change kind",
		},
		{
			name: "irreversible without signals",
			changes: []Change{
				{Name: "drop-column", Kind: KindSchema, Components: []string{"schema:order_legacy"}, Reversible: false},
			},
			want: "no signals",
		},
		{
			name: "duplicate component across changes",
			changes: []Change{
				{Name: "checkout-v2", Kind: KindCode, Components: []string{"api:checkout"}, Reversible: true},
				{Name: "checkout-flag", Kind: KindConfig, Components: []string{"api:checkout"}, Reversible: true},
			},
			want: "appears in",
		},
		{
			name: "pure config labeled schema",
			changes: []Change{
				{Name: "fee-rate", Kind: KindSchema, Components: []string{"config:fee_rate"}, Reversible: true},
			},
			want: "pure config",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := Validate(tc.changes)
			if err == nil {
				t.Fatal("Validate() = nil, want error")
			}
			if !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("Validate() = %q, want substring %q", err, tc.want)
			}
		})
	}
}

TestValidateAcceptsIndependentChanges 里的四个变更分别属于代码、配置、schema 和数据,组件互不相交。它同时验证不可逆的 schema 变更带了 query_p99 信号,因此可以通过。

TestValidateAllowsRepeatedComponentWithinOneChange 固定“同变更内重复组件不算冲突”的语义。如果实现把同一变更里的重复也当成冲突,发布单会因为描述冗余被误拒。

TestValidateRejectsBadChanges 的每个子测试只改一个条件,断言错误信息包含可定位的关键词。这样失败时不需要读源码就能知道是哪条规则拦住的。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/release/ -count=1 -run 'TestValidateAccepts|TestValidateAllows|TestValidateRejects' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestValidateAcceptsIndependentChanges
--- PASS: TestValidateAcceptsIndependentChanges (0.00s)
=== RUN   TestValidateAllowsRepeatedComponentWithinOneChange
--- PASS: TestValidateAllowsRepeatedComponentWithinOneChange (0.00s)
=== RUN   TestValidateRejectsBadChanges
=== RUN   TestValidateRejectsBadChanges/empty_name
=== RUN   TestValidateRejectsBadChanges/no_components
=== RUN   TestValidateRejectsBadChanges/unknown_kind
=== RUN   TestValidateRejectsBadChanges/irreversible_without_signals
=== RUN   TestValidateRejectsBadChanges/duplicate_component_across_changes
=== RUN   TestValidateRejectsBadChanges/pure_config_labeled_schema
--- PASS: TestValidateRejectsBadChanges (0.00s)
    --- PASS: TestValidateRejectsBadChanges/empty_name (0.00s)
    --- PASS: TestValidateRejectsBadChanges/no_components (0.00s)
    --- PASS: TestValidateRejectsBadChanges/unknown_kind (0.00s)
    --- PASS: TestValidateRejectsBadChanges/irreversible_without_signals (0.00s)
    --- PASS: TestValidateRejectsBadChanges/duplicate_component_across_changes (0.00s)
    --- PASS: TestValidateRejectsBadChanges/pure_config_labeled_schema (0.00s)
PASS
ok  	webapp/internal/release	0.295s

输出里只匹配了发布单元的三个测试,没有包含同包的灰度与数据兼容测试。0.295s 是一次本机运行的耗时,会随机器和负载变化。

小结

发布单元是按回滚边界切的,不是按代码仓库的合并记录切的。代码、配置、schema、数据修复四类变更的风险和回滚手段不同,混在一起会让最不可逆的那一项决定整个发布的命运。

Validate 能静态拦住六类问题:空名称、空组件、未知类别、不可逆无信号、跨变更组件冲突、纯配置误标 schema。它不能判断信号阈值是否合理,也不能替你决定 schema 变更该不该在低峰期做,但能把“发布单根本没写清楚”这种低级问题挡在发布之前。

下一节会把发布单元放进灰度流程:按用户分桶、用开关控制、用观测阈值决定是否回滚。

阅读导航:上一节:目录与学习路径 · 下一节:10.2 灰度、开关与回滚 。

常见错误与边界

  • 把一次 git merge 当成发布单元。合并记录只说明代码一起进了主干,不说明它们能一起回滚。
  • 让两个变更共享同一个组件。回滚其中一个时,另一个的改动会被一起带走。
  • 不可逆变更不写观测信号。删列、改类型没有撤销按钮,只能靠指标尽早发现。
  • 纯配置变更标成 schema。发布系统会按不可逆流程处理,回滚时也不敢改配置。
  • 只检查组件名是否为空,不检查组件是否重复。重复组件是回滚边界被破坏的直接信号。
  • 认为校验通过就等于发布安全。Validate 只检查结构完整性,不检查阈值、容量和业务影响。
  • 把数据回填当成普通任务。回填会覆盖新数据,必须和水位线、跳过条件、校验和一起设计。
  • 发布单不记录配置版本。镜像回滚后配置仍是新值,事故会以另一种形式继续。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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