一次发布里塞了四件事
「回声」是一个内容社区,订单系统负责会员、打赏和广告位购买。周五下午,团队准备上线“订单页改版”。发布单里列了四项:前端按钮文案与结算逻辑、会员费率从 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只检查结构完整性,不检查阈值、容量和业务影响。 - 把数据回填当成普通任务。回填会覆盖新数据,必须和水位线、跳过条件、校验和一起设计。
- 发布单不记录配置版本。镜像回滚后配置仍是新值,事故会以另一种形式继续。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。