《互联网产品的服务端架构·规模化与架构演进》9.3 结果与回退条件

灰度到一半时「回声」发现推荐质量下滑,团队决定回退,却只切了读开关:双写还在继续、缓存没有失效,用户半小时后仍看到新结果。本节把回退条件拆成一致性、延迟、成本、质量与可逆性五类门禁,给出判定函数的实现与测试,并复盘一次门禁名重复、失败项被覆盖的切流事故。

回退不是承认失败,而是预案的一部分

「回声」的 Feed 重写走到灰度 50%。双写差异率稳定在万分之二,首页 p99 从 1180 ms 降到 520 ms,成本每千次下降 8 分。业务侧却出现了另一个信号:灰度用户的次日留存比对照组低 0.6 个百分点,负反馈率上升了 12%。这两个数字都不在切流评审的 PPT 里。

团队开会讨论了两个小时。一方认为延迟和成本改善足够大,质量波动在置信区间内;另一方认为负反馈率已经连续五天上升,不能再扩大灰度。会议最后决定“先回退到 10% 观察”。操作同学把读流量的灰度比例从 50% 调回 10%,然后在群里说了一句“已回退”。

第二天问题没有消失。灰度用户仍然看到新链路的结果,新存储也仍在接收双写。排查发现,回退只改了读流量的比例,没有清 Feed 缓存,缓存 TTL 是 30 分钟;双写开关也没有关,新存储继续累积数据。更麻烦的是,团队后来想把新链路彻底下线时,已经无法判断新存储里的数据是“回退前的有效写入”还是“回退后的脏数据”。

这一节讨论结果与回退条件。核心观点只有一句:回退不是一次点击,而是一组必须提前定义、提前演练、并且能被验证的条件。

门禁要能回答“哪一道没过”,而不是只有“能不能切”

切流决策最常见的实现是一个大 if:错误率低于 1%、延迟低于 800 ms、差异率低于万分之五,三个条件用 && 连起来,全部满足就切。这种写法在顺利时没有问题,出问题时只留下一句“条件不满足”,没人知道是哪一项、差多少、该找谁。

更可用的做法是把每个条件建模成一道有名字的门禁。每道门禁独立采集、独立判定,最后只做汇总。表里是「回声」实际使用的五类门禁(阈值为案例假设值):

门禁类别观测指标示例阈值数据来源解释责任
一致性双写差异率连续 3 个窗口低于万分之五比对任务数据平台
延迟首页 p99不高于旧链路的 110%接入层监控Feed 团队
成本每千次请求成本不高于旧链路的 105%账单与用量平台团队
质量负反馈率、次日留存不劣于对照组 5%实验平台产品团队
可逆性回退演练是否通过最近 7 天内完成一次演练记录值班负责人

五类门禁里,前四类回答“新链路好不好”,最后一类回答“如果不好,能不能退回去”。很多团队只做前四类,等到真要回退时才发现回退脚本半年没跑过、缓存清理接口已经改签名、双写开关藏在另一个服务里。

门禁的名字必须稳定且唯一。名字是报告的主键,也是值班同学沟通时的共同语言。“quality 没过”比“那个业务指标好像不太行”更容易定位。名字重复则会让汇总逻辑产生歧义:两个都叫 quality 的门禁,一个过、一个不过,最终结论取决于遍历顺序,而不是事实。

回退条件要写成可观测的句子

“推荐质量下降”不是回退条件,因为它无法在五分钟内被判定。“负反馈率连续三个小时高于对照组 10%,且样本量大于一万”才是。可观测的句子通常包含四个部分:指标、比较基准、持续时间、最小样本量。缺任何一个,值班同学都会在凌晨三点陷入争论。

回退条件还要区分“自动”和“人工”。延迟、错误率、差异率这类可以自动判定的指标,应该允许系统直接停止扩大灰度;留存、内容质量这类需要解释的指标,应该触发人工评审,而不是让一个阈值替产品做决定。

回退本身也要有门禁

回退动作至少要做四件事:停止扩大灰度、把读流量切回旧链路、关闭双写或标记待补数据、清理新链路的缓存。只做其中一件,就会出现“看起来回退了,用户还在看新结果”的情况。

这四件事应该被写成一张检查表,并且定期演练。演练不需要真实故障,可以在测试环境构造一条差异数据,然后走完整条回退路径,确认监控、缓存、双写开关和值班通知都按预期工作。没有演练过的回退,只能算一个想法。

回退窗口要留多久

回退窗口不是“回退按钮还能点多久”,而是“旧链路还能不能完整承接流量”。旧链路的机器、缓存和数据库连接必须保持在线,不能为了省钱在切流当天缩容。一个常见的错误是:新链路上线后立刻把旧链路缩到 10% 容量,等到需要回退时发现旧链路自己先扛不住了。

窗口长度取决于数据保留和流量周期。至少覆盖一个完整的业务高峰和一个版本发布周期;如果 Feed 有季节性活动,还要把活动期算进去。窗口结束前不要删除旧存储,也不要改旧链路的表结构,否则回退会变成一次新的迁移。

回退时数据怎么处理

双写期间新存储会持续收到写入,回退时必须给这些数据一个明确归宿。直接删除是最危险的做法,因为其中可能包含回退前已经验证有效的内容;完全不处理又会让下一次切流面对一份来源不明的数据。

可用的做法是给写入打标记:双写消息带上批次号和时间戳,回退时把新存储标记为“灰度期数据”,保留只读,等下一次切流前做一次对账再决定合并或丢弃。标记必须由写入方生成,不能靠回退时间推算,否则时钟偏差会让边界数据归错批次。

一次“门禁全过”的切流

事故的起点是一个实现细节。早期的汇总代码用 map 保存门禁结果:

results := map[string]bool{}
for _, g := range gates {
    results[g.Name] = g.Passed
}

这段代码看起来无害,但它默认名字唯一。产品团队为了区分“点击质量”和“负反馈质量”,注册了两道都叫 quality 的门禁。后注册的那道覆盖了前一道。切流当天,点击质量通过,负反馈质量没有通过;map 里只剩下后写入的值,恰好是通过,汇总结果变成了 go。

切流后第三天,负反馈率继续上升,团队决定回退。回退操作只改了读流量比例,没有清缓存,也没有停双写。用户在接下来半小时里仍然看到新链路的结果;新存储则继续接收写入,导致后续无论切回哪一边,都无法用“写入时间”区分数据来源。

这次事故有两个独立的教训。第一,门禁的名字冲突必须在注册时拒绝,而不是在汇总时靠覆盖顺序碰运气。第二,回退必须是一个包含多个步骤的流程,而不是一个开关;流程里的每一步都要有负责人和验证方式。

修复后的实现把“判定”和“校验”分开:Decision 只负责汇总,Validate 在切流前检查门禁清单本身是否合法。名字重复、名字为空、清单为空都会在切流前报错,而不是等到事后复盘才发现。

用 Gate 与 Decision 把“全过才走”写死

配套代码 internal/feedmig/rollback.go 刻意保持很小:一个门禁结构体、一个汇总函数、一个校验函数。它不读取监控,也不执行回退,只回答“这组门禁现在能不能走”。

// file: internal/feedmig/rollback.go
// tier: L1
// verify: go test ./internal/feedmig/

package feedmig

import "fmt"

// Gate 是切流前必须通过的一道检查。
// Name 用来在报告里定位是哪一道门禁,Passed 只表达本次观测结果。
type Gate struct {
	Name   string
	Passed bool
}

// Decision 汇总所有门禁:全部通过才返回 go,否则返回 hold。
// 空集合返回 hold,因为“没有任何证据”不等于“全部通过”。
func Decision(gates []Gate) string {
	if len(gates) == 0 {
		return "hold"
	}
	for _, g := range gates {
		if !g.Passed {
			return "hold"
		}
	}
	return "go"
}

// Validate 检查门禁清单本身是否可用。
// 空清单、空名字和重复名字都会让结论失去意义,必须在切流前拒绝。
func Validate(gates []Gate) error {
	if len(gates) == 0 {
		return fmt.Errorf("feedmig: gate list is empty")
	}
	seen := make(map[string]struct{}, len(gates))
	for i, g := range gates {
		if g.Name == "" {
			return fmt.Errorf("feedmig: gate at index %d has empty name", i)
		}
		if _, ok := seen[g.Name]; ok {
			return fmt.Errorf("feedmig: duplicate gate %q", g.Name)
		}
		seen[g.Name] = struct{}{}
	}
	return nil
}

Decision 对空清单返回 hold,而不是 go。从逻辑上说,“所有门禁都通过”在空集上是真命题,但从运维上说,没有任何门禁意味着没有任何证据。把空集当成通过,会让一个配置加载失败的系统自动切流,这是最危险的一类默认值。

Decision 遇到第一道失败就返回,不继续遍历。返回值只有 go 和 hold 两种,调用方不需要知道失败的是第几道;需要定位时,应该去看门禁明细,而不是让汇总函数承担报告职责。这个分工让函数保持纯粹,也方便测试。

Validate 检查空清单、空名字和重复名字。重复名字是最容易被忽略的一项,因为它不会让程序崩溃,只会让结果悄悄依赖写入顺序。把这项检查放在切流前,等于把“map 覆盖”这类实现细节挡在决策之外。

门禁的阈值不在这份代码里。阈值应该由配置和实验平台提供,并且每次调整都有记录。代码只负责“全过才 go”,不负责判断阈值是否合理;把两者混在一起,会让修改一个业务阈值变成一次代码发布。

测试:全过、一票否决与清单合法性

回退判定的测试要覆盖三种情况:全部通过、任意一道失败、空清单。校验函数的测试则要覆盖重复名、空名和空清单。

// file: internal/feedmig/rollback_test.go
// tier: L1
// verify: go test ./internal/feedmig/

package feedmig

import "testing"

func TestDecision(t *testing.T) {
	allPass := []Gate{
		{Name: "divergence", Passed: true},
		{Name: "latency", Passed: true},
		{Name: "quality", Passed: true},
	}
	if got := Decision(allPass); got != "go" {
		t.Fatalf("Decision(all pass) = %q, want go", got)
	}

	oneFail := []Gate{
		{Name: "divergence", Passed: true},
		{Name: "latency", Passed: false},
	}
	if got := Decision(oneFail); got != "hold" {
		t.Fatalf("Decision(one fail) = %q, want hold", got)
	}

	if got := Decision(nil); got != "hold" {
		t.Fatalf("Decision(nil) = %q, want hold", got)
	}
}

func TestValidate(t *testing.T) {
	ok := []Gate{
		{Name: "divergence", Passed: true},
		{Name: "latency", Passed: false},
	}
	if err := Validate(ok); err != nil {
		t.Fatalf("Validate(valid) = %v, want nil", err)
	}

	if err := Validate(nil); err == nil {
		t.Fatal("Validate(nil) = nil, want error")
	}
	if err := Validate([]Gate{}); err == nil {
		t.Fatal("Validate(empty) = nil, want error")
	}
	if err := Validate([]Gate{{Name: "divergence"}, {Name: "divergence"}}); err == nil {
		t.Fatal("Validate(duplicate) = nil, want error")
	}
	if err := Validate([]Gate{{Name: ""}}); err == nil {
		t.Fatal("Validate(empty name) = nil, want error")
	}
}

func TestDecisionAfterValidate(t *testing.T) {
	gates := []Gate{
		{Name: "divergence", Passed: true},
		{Name: "latency", Passed: true},
	}
	if err := Validate(gates); err != nil {
		t.Fatalf("Validate: %v", err)
	}
	if got := Decision(gates); got != "go" {
		t.Fatalf("Decision = %q, want go", got)
	}
}

TestDecision 里的空清单用例是有意为之。它锁定的是一条运维语义,而不是数学语义:没有证据不等于通过。如果未来有人觉得空清单应该返回 go,这个测试会迫使他先解释配置加载失败时会发生什么。

TestValidate 覆盖了重复名字。测试里两个门禁都叫 divergence,一个通过、一个不通过;校验函数必须在切流前拒绝这组输入,而不是让 Decision 去猜哪一个才算数。

TestDecisionAfterValidate 把两个函数串起来,模拟真实调用顺序:先校验清单,再汇总结果。它不能证明阈值合理,但能证明“合法清单 + 全过”会得到 go 这个最基本的契约。

测试没有覆盖“门禁数据过期”。例如一致性门禁的最近一次比对是三天前,Passed 仍然是 true。这类问题需要在采集层给门禁加时间戳和有效期,而不是在汇总函数里判断。汇总函数只消费已经判定好的布尔值,这是它保持简单的代价,也是它可靠的原因。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/feedmig/ -run 'TestDecision|TestValidate' -count=1 -v

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

=== RUN   TestDecision
--- PASS: TestDecision (0.00s)
=== RUN   TestValidate
--- PASS: TestValidate (0.00s)
=== RUN   TestDecisionAfterValidate
--- PASS: TestDecisionAfterValidate (0.00s)
PASS
ok  	webapp/internal/feedmig	0.470s

三个用例全部通过,退出码为 0。-run 只筛选了门禁相关的用例,基线比较与双写比对分别在前两节验证。

结果验收看哪几个数

切流后的验收不应该只看“没报错”。至少要看四组数:用户侧延迟与错误率、业务侧负反馈与留存、成本侧的每千次请求账单、数据侧的双写差异与覆盖完整性。四组数里任何一组显著劣化,都应该触发回退评审。

验收周期要和业务周期匹配。Feed 的留存变化通常需要几天才能看出来,切流后立刻宣布成功是不严谨的;但也不能无限期观察,否则旧链路的维护成本会一直挂着。一个实用的折中是:技术指标观察 24 小时,业务指标观察 7 天,期间保持旧链路可回退,7 天后再决定是否缩容。

小结

回退不是失败的证明,而是架构演进的一部分。一个没有回退预案的切流方案,本质上是在赌新链路不会出问题;而所有工程决策都应该假设它会出问题。

门禁的价值在于把“能不能切”拆成一组有名字、有数据来源、有解释责任的条件。全过才走,一票否决,空清单不走。名字必须唯一,清单必须校验,回退动作必须包含停灰度、切读、停双写、清缓存四步,并且定期演练。

下一章进入 IM 场景,讨论会话归属与路由:当长连接不再是无状态请求,服务端要回答的第一个问题就变成了“这个连接应该由谁负责”。

阅读导航:上一节:9.2 双写、灰度与切换 · 下一节:10.1 会话归属与路由 。

常见错误与边界

  • 把回退当成一个开关。只切读流量、不停双写、不清缓存,用户仍会看到新结果。
  • 门禁没有名字或名字重复。重复名会让结果依赖遍历顺序,必须在校验阶段拒绝。
  • 空门禁清单返回 go。没有证据不等于通过,配置加载失败时尤其危险。
  • 回退条件不可观测。“质量下降”无法在五分钟内判定,必须写成指标、基准、时长和样本量。
  • 只做自动门禁,不留人工评审。留存和内容质量需要解释,不能让一个阈值替产品做决定。
  • 回退脚本从不演练。半年没跑过的脚本,等于没有脚本。
  • 门禁数据过期仍算通过。布尔值需要配时间戳和有效期,过期应当视为不通过。
  • 把阈值写进代码。阈值调整应该走配置和记录,否则每次微调都变成一次发布。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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