一次“无感切换”演练,为什么用户还是感知到了
「回声」每年做两次跨机房切换演练,目标写得很清楚:用户无感。演练当晚,监控面板上可用性 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说明统计已坏,继续结束切换会把错误状态固化。 - 演练只在低峰做。低峰期的队列长度和高峰完全不同,至少要在有代表性流量下跑一次。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。