《互联网产品的服务端架构·规模化与架构演进》10.3 切换演练与用户无感

「回声」做了一次跨机房切换演练,监控全绿,用户却收到重复的离线消息。本节把无感切换拆成排空、补洞、回执三步,给出切换计划校验与完成判定两个函数,复盘一次切换后两端同时补发历史消息造成的重复推送,并给出灰度顺序、回滚条件与切换报告的验收指标。

一次“无感切换”演练,为什么用户还是感知到了

「回声」每年做两次跨机房切换演练,目标写得很清楚:用户无感。演练当晚,监控面板上可用性 99.99%,连接成功率没有下降,P99 发送延迟只抖了一下。值班同学在群里发了“演练成功”。

第二天客服转来一批投诉:昨晚十点半左右,很多用户收到同一条消息的两到三次离线推送。有人点进去发现消息已经在对话里,只是时间显示得更晚;有人把应用切到后台再打开,未读数从 3 变成了 9。

复盘时把时间线对齐,问题出在切换完成后的十分钟。切换期间,旧机房的消息队列还在排空,新机房的网关已经开始接收重连。客户端重连后向新机房补拉增量,同时旧机房的推送任务也在把队列里剩下的消息推给同一批用户。两个来源都认为自己是“补发”,没有一方知道对方已经发过。

这就是“无感切换”最常见的误解:把切换当成一次流量搬家,而不是一次状态交接。流量可以在几秒内切完,状态不会。未读位、已读回执、待发队列、离线推送记录,任何一项没交接清楚,用户就会看到重复或空洞。

这一节把切换拆成排空、补洞、回执三步,并把“计划是否可执行”和“这次切换是否可以结束”写成两个可测试的函数。

无感是三步,不是一句目标

一次可回滚的节点切换,至少包含三个动作,顺序不能乱。

第一步是排空。把旧节点从新连接的候选集合里摘掉,但不断开已有连接;等待存量请求处理完、待发队列清空,或者 drain 窗口到期。排空期间旧节点仍然可以读写,只是不再接收新会话。

第二步是补洞。新节点接管后,客户端用本地最大序号发起增量同步,服务端返回缺失区间。补洞必须在排空之后,否则旧节点还在写入,新节点补到的“最新”很快又落后。

第三步是回执。客户端收到消息后回报已读位或接收位,服务端据此推进游标,并通知推送服务不要再发。回执是切换完成的信号,不是可选的优化。

三步的失败后果差别很大:

步骤关键动作观察信号失败后果
排空摘流但不断连,等待队列清空或超时待发队列长度、连接数、drain 剩余时间旧节点仍有写入,新节点补到的是旧快照
补洞按 Seq 拉取缺失区间,分批处理缺口数量、补洞耗时、失败重试次数消息空洞,用户看到对话断层
回执客户端上报接收位,服务端推进游标回执率、已读位滞后、重复推送量推送重复,未读数反弹

「回声」第一次演练只做了第一步,第二步靠客户端自然重连时的增量拉取,第三步完全没有纳入切换流程。监控之所以全绿,是因为它只看连接和延迟,没有看“同一消息被推送了几次”。指标选错了,成功就是假的。

排空窗口怎么定

排空窗口不是越长越好。窗口太短,队列没清完就断开,消息要等补洞;窗口太长,旧节点一直占着资源,切换时间被拉长,回滚窗口也跟着变长。

合理的做法是先测出待发队列的 P99 清空时间,再留一倍余量。比如 P99 是十二秒,窗口定三十秒;如果三十秒内没清空,不再等,直接进入补洞,并把这批未确认消息记入切换报告。窗口到期不是失败,而是“带着已知缺口继续”,缺口必须被记录和补偿。

排空期间还要冻结新的迁移。如果一边排空一边把别的会话迁进来,队列永远清不空。切换计划里应该有一个明确的“迁移冻结”状态,由切换协调者持有,而不是靠各节点自觉。

补洞和全量同步的边界

补洞适合缺口小、序号连续的场景。如果客户端离线超过消息保留期,或者本地序号已经严重落后,补洞会变成一次事实上的全量同步。这时应该直接走全量,而不是让补洞接口分页拉几十万条。

判断边界可以看两个数:缺口跨度和缺口数量。跨度超过保留期,或者缺口数量超过单次同步上限,就走全量。全量同步期间,客户端应该先展示本地缓存,再逐步替换,避免白屏。

灰度顺序与回滚条件

切换不应该一次把全部流量切过去。更稳的顺序是:先切百分之一的连接做观察,再切百分之十,最后全量。每一档观察三个信号:新建连接成功率、补洞缺口数量、重复推送量。三个信号都在阈值内,才进入下一档。

回滚条件必须提前写死,不能等出事再讨论。比如“新建连接成功率低于 99.5% 持续两分钟”或“重复推送量超过基线三倍持续五分钟”就回滚。回滚不是把流量切回去就结束,还要把已经迁移的会话记录标记为待清理,否则下一次切换会继承一份错误的归属目录。

灰度期间,新旧两套消费者可能同时存活。必须保证同一批消息只有一个消费者在发推送,否则灰度本身就制造重复。常见的做法是按会话分片,而不是按流量比例分消费者;这样同一条会话始终只由一个消费者负责。

切换和日常发布有什么区别

日常发布通常假设“新版本可以处理旧数据”,所以摘一台、发一台、再挂回去就够了。切换假设的是“两套环境同时存在一段时间”,数据和控制面都可能被两边同时修改。前者是版本问题,后者是归属问题。

这个区别决定了两件事。第一,切换必须有一个全局协调者,知道当前进行到哪一步、哪些节点已经冻结迁移;日常发布可以每台机器独立决策。第二,切换必须有明确的回滚点,回滚后要清理已经产生的副作用,而不只是把流量切回去。

把切换当成一次更大的发布,是很多重复推送事故的共同起点。

失败场景:切流后离线消息重复推送

问题最集中的一次发生在切换后的第七分钟。旧机房的待发队列里还有一批消息,负责推送的消费者没有随切换一起停止;新机房的网关在客户端重连后,又把同一批消息作为增量返回。两条路径都触发了离线推送。

用户侧看到的是:同一条消息先以离线通知到达,点开后对话里已经有一条;过了一会儿又收到一条通知,点进去还是同一条。未读数因为重复推送被加了两遍,已读位却只推进了一次,所以数字对不上。

根因有三个层面。第一,切换计划没有定义“推送消费者何时停”。排空只针对接入层,后台消费者被漏掉了。第二,补洞结果没有和推送记录对账。新机房不知道旧机房已经推过哪些序号。第三,客户端没有按消息 ID 去重,服务端的重复直接透传给了用户。

修复方案也分三层。切换计划里增加“消费者停机”步骤,顺序是:停止旧机房的新消息入队、等待队列清空、停止消费者、再切流量。补洞接口返回消息时带上“是否已推送”标记,新机房据此决定是否再发通知。客户端按消息 ID 去重,重复消息只更新状态,不重新计数。

还有一个组织层面的教训:演练的成功标准不能只由基础设施团队定义。连接成功率是基础设施指标,重复推送量是产品指标,两个都要在切换报告里。只考核前者,团队就会优化错的东西。

事后补的监控里,最有用的不是“重复推送总数”,而是“同一用户十分钟内收到相同消息 ID 的次数”。总数会被正常流量稀释,按用户聚合才能看出哪些人真的被打扰。再往下钻,可以按客户端版本分组,确认是服务端重复,还是旧版本客户端缺少去重。

这次事故还暴露了一个流程问题:切换演练没有“用户视角”的验收人。基础设施团队看连接,SRE 看容量,没有人负责在演练后抽样检查真实账号的收件箱。补上这个角色之后,第二次演练在灰度阶段就发现了重复推送,流量没有切到全量。

把前置条件与完成条件写成函数

配套代码 internal/immig/switch.go 只做两件事:切换开始前校验计划,切换过程中判断是否可以结束。它不执行排空、不发消息、不读监控,因此可以在没有集群的环境里把边界测清楚。

// file: internal/immig/switch.go
// tier: L1
// verify: go test ./internal/immig/

package immig

import "fmt"

// Plan 描述一次节点切换:把 From 上承载的会话迁到 To,
// DrainSeconds 是在摘除流量前留给存量请求排空的秒数。
type Plan struct {
	From         string
	To           string
	DrainSeconds int
}

// Validate 在切换开始前拦截明显错误的计划。同一节点之间切换没有意义,
// 负的 drain 窗口会让排空逻辑立刻放行,两者都应该在入口拒绝。
func Validate(p Plan) error {
	if p.From == "" || p.To == "" {
		return fmt.Errorf("immig: switch plan needs both from and to")
	}
	if p.From == p.To {
		return fmt.Errorf("immig: switch plan from and to are both %q", p.From)
	}
	if p.DrainSeconds < 0 {
		return fmt.Errorf("immig: switch plan has negative drain seconds %d", p.DrainSeconds)
	}
	return nil
}

// Result 汇总一次切换的发送与确认计数。Sent 是切换开始后发往新节点的
// 消息数,Acked 是其中收到客户端回执的数量。
type Result struct {
	Sent  int
	Acked int
}

// Complete 判断一次切换是否可以结束。全部回执即完成;否则只有等待窗口
// 耗尽(timeoutSeconds <= 0)才允许带着未回执的消息结束,交给补洞流程处理。
// 计数非法(负数或 Acked 大于 Sent)一律返回 false,避免把损坏的统计
// 当成完成信号。
func Complete(r Result, timeoutSeconds int) bool {
	if r.Sent < 0 || r.Acked < 0 || r.Acked > r.Sent {
		return false
	}
	if r.Acked == r.Sent {
		return true
	}
	return timeoutSeconds <= 0
}

Validate 拦截三类计划:起止节点相同、drain 窗口为负、起止节点为空。前两类是明显的配置错误,第三类通常来自配置中心返回了空值。把它们放在切换入口,比等到排空阶段才发现要便宜得多。

Complete 的规则是“全部回执,或窗口耗尽”。这里有两个容易写错的地方。

第一,Acked == Sent 就应该完成,不需要等窗口耗尽。很多实现会写成“等到窗口结束再检查”,结果是切换明明已经完成,还要空等三十秒。切换时间越长,回滚窗口越短。

第二,窗口耗尽时允许带着未回执结束,但必须把未回执的数量交给补洞流程。Complete 只回答“能不能结束”,不回答“结束后怎么补”。把补偿逻辑塞进来,函数就会依赖消息队列和重试策略,测试也会变得笨重。

计数非法时返回 false,是防御性设计。Acked > Sent 说明统计口径已经坏了,可能是重复回执,也可能是两个消费者写同一个计数器。这时候结束切换,等于把错误状态当成完成状态。

为什么超时也算完成

严格来说,超时结束不是“完成”,而是“放弃等待”。但在切换流程里,必须有一个确定的退出条件,否则协调者会一直等下去,回滚窗口被耗尽,故障时无法回到旧节点。把超时定义为“带着缺口完成”,并强制记录缺口,比无限等待更安全。

这也意味着切换报告必须包含三个数:发送数、回执数、未回执数。只看“成功”或“失败”两个状态,无法判断这次切换留下了多少债。

测试:同节点、负窗口与未回执

切换逻辑的测试分两组:计划校验和完成判定。它们都不依赖真实节点,跑起来是毫秒级。

// file: internal/immig/switch_test.go
// tier: L1
// verify: go test ./internal/immig/

package immig

import "testing"

func TestValidateAcceptsRunnablePlan(t *testing.T) {
	if err := Validate(Plan{From: "node-a", To: "node-b", DrainSeconds: 30}); err != nil {
		t.Fatalf("Validate: %v", err)
	}
	if err := Validate(Plan{From: "node-a", To: "node-b", DrainSeconds: 0}); err != nil {
		t.Fatalf("zero drain should be allowed: %v", err)
	}
}

func TestValidateRejectsBadPlan(t *testing.T) {
	cases := []struct {
		name string
		p    Plan
	}{
		{"same node", Plan{From: "node-a", To: "node-a", DrainSeconds: 30}},
		{"negative drain", Plan{From: "node-a", To: "node-b", DrainSeconds: -1}},
		{"missing from", Plan{To: "node-b", DrainSeconds: 30}},
		{"missing to", Plan{From: "node-a", DrainSeconds: 30}},
	}
	for _, tc := range cases {
		if err := Validate(tc.p); err == nil {
			t.Fatalf("%s: want error, got nil", tc.name)
		}
	}
}

func TestCompleteWhenAllAcked(t *testing.T) {
	if !Complete(Result{Sent: 12, Acked: 12}, 30) {
		t.Fatal("all acked should complete even with time left")
	}
	if !Complete(Result{Sent: 0, Acked: 0}, 30) {
		t.Fatal("nothing to send should complete")
	}
}

func TestCompleteWaitsForTimeout(t *testing.T) {
	partial := Result{Sent: 12, Acked: 9}
	if Complete(partial, 30) {
		t.Fatal("partial result should wait while time remains")
	}
	if !Complete(partial, 0) {
		t.Fatal("partial result should complete when window is exhausted")
	}
	if !Complete(partial, -5) {
		t.Fatal("negative remaining window means exhausted")
	}
}

func TestCompleteRejectsBrokenCounters(t *testing.T) {
	cases := []Result{
		{Sent: 3, Acked: 4},
		{Sent: -1, Acked: 0},
		{Sent: 3, Acked: -1},
	}
	for _, r := range cases {
		if Complete(r, 0) {
			t.Fatalf("Complete(%+v) = true, want false", r)
		}
	}
}

TestValidateRejectsBadPlan 用表驱动覆盖四种坏计划。起止相同是最常见的一种,通常来自“把节点从池子里摘掉又加回去”的自动化脚本;负 drain 来自配置解析把空值转成了 -1;空起止则来自配置中心返回了不完整对象。

TestCompleteWhenAllAcked 锁住“全部回执立刻完成”,同时覆盖发送数为零的空切换。空切换应该直接完成,而不是等窗口耗尽,否则一次没有流量的维护窗口也会被拖满三十秒。

TestCompleteWaitsForTimeout 覆盖部分回执的两种结局:还有时间就继续等,时间耗尽就带着缺口结束。负的剩余窗口被当成已耗尽,这是为了兼容调用方用“截止时间减当前时间”计算剩余秒数时可能出现的负数。

TestCompleteRejectsBrokenCounters 覆盖三种坏计数:回执多于发送、发送为负、回执为负。它们都应该返回 false。这个测试的价值不在于正常路径,而在于防止后来的人为了“容错”把非法计数静默修正成合法值。

测试没有覆盖“回执迟到”。一个在切换结束后才到达的回执,应该被补洞流程吸收,而不是让 Complete 重新变成未完成。Complete 是无状态判断,不负责跟踪迟到回执,这个边界要在调用方处理。

也没有覆盖“多个切换同时执行”。函数只校验单个计划,协调者层面的互斥不在这个包里。真实系统至少需要一把按机房持有的锁,防止两个人同时发起方向相反的切换。锁的粒度、超时和抢占策略属于部署编排,不属于切换判定,放在这里反而会让函数难以测试。

运行命令与真实输出

在仓库根目录执行:

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

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

=== RUN   TestValidateAcceptsRunnablePlan
--- PASS: TestValidateAcceptsRunnablePlan (0.00s)
=== RUN   TestValidateRejectsBadPlan
--- PASS: TestValidateRejectsBadPlan (0.00s)
=== RUN   TestCompleteWhenAllAcked
--- PASS: TestCompleteWhenAllAcked (0.00s)
=== RUN   TestCompleteWaitsForTimeout
--- PASS: TestCompleteWaitsForTimeout (0.00s)
=== RUN   TestCompleteRejectsBrokenCounters
--- PASS: TestCompleteRejectsBrokenCounters (0.00s)
PASS
ok  	webapp/internal/immig	0.489s

五个用例全部通过,退出码为 0。-run 只筛了切换相关用例,路由与同步测试没有执行;需要全量确认时去掉 -run 再跑一次。

全量执行时,本包十五个用例都会跑一遍,耗时仍然在秒级。-count=1 的作用是禁用测试结果缓存,避免看到上一次的 PASS 却误以为这次也通过。切换逻辑一旦改动,建议同时跑 go vet,它能抓到格式化字符串参数不匹配这类单元测试覆盖不到的问题。

小结

用户无感不是一句验收口号,而是排空、补洞、回执三步都做到位之后的结果。排空保证旧节点不再产生新状态,补洞保证新节点补齐旧状态,回执保证两端对“已经处理到哪”达成一致。少任何一步,监控都可能是绿的,用户却已经感知到了。

切换计划要在开始前校验,切换完成要有确定的退出条件,超时结束要带着缺口记录。把这两件事写成纯函数,不能让切换自动变安全,但能让每次演练的失败原因可复现、可归因。

下一章进入容量与稳定性:当规模继续增长,怎么用压测和容量规划证明系统还撑得住。

阅读导航:上一节:10.2 消息同步与顺序 · 下一节:11.1 容量规划与压测 。

常见错误与边界

  • 把切换当成流量搬家。只切接入层,不处理待发队列和后台消费者,重复推送几乎必然发生。
  • 排空窗口拍脑袋定。窗口太短会留下大量缺口,太长会耗尽回滚时间,应该按队列清空时间的 P99 定。
  • 排空期间还允许迁移。新会话不断迁入,旧节点的队列永远清不空。
  • 补洞放在排空之前。旧节点还在写,新节点补到的“最新”很快又落后。
  • 把超时当成失败。超时是带着缺口的完成,必须记录未回执数量并交给补洞,而不是无限等待。
  • 切换报告只看连接成功率。重复推送量、未读位偏差、补洞失败数同样是验收指标。
  • 回执计数不校验。Acked > Sent 说明统计已坏,继续结束切换会把错误状态固化。
  • 演练只在低峰做。低峰期的队列长度和高峰完全不同,至少要在有代表性流量下跑一次。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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