后端系统工程 E04:消息通道替换与迁移——迟到事件与回退边界
本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的
batch-4/目录(examples/、evidence/等子目录);业务数据均为合成数据。
替换一条在用的消息通道,比如把自建队列换成托管服务,或者在两个集群之间搬迁,不能靠“周五晚上停写、切换、周一观察”。切换的那一刻,旧通道里还有在途事件,新通道刚开始接收的事件可能比旧通道里的同一事件先到;切换之后,旧通道还会因为长尾延迟吐出几条早该到达的事件。迁移方案要回答四个问题:怎样证明新通道可以接手,切换时同一事件来自两条通道怎么办,切换后旧通道迟到的事件怎么办,以及到什么时候为止还能无损地退回去。
本篇用离散事件模型实现一个四阶段迁移控制器,在同一条合成事件流上注入延迟、重复、丢失和写入失败,验证副作用恰好一次,并用负向对照说明去重窗口和补漏期为什么不能随意缩短。
1. 需求与不变量
事件流是合成的业务通知事件:200 个业务键,每秒 50 个事件,每个事件带全局唯一的 eid、所属 key、在 key 内单调递增的 version 和一个随机载荷。消费者对每个事件产生两类副作用:
- 计数类副作用:每应用一次计数加 1。它不是幂等的,重复应用会被直接观察到。
- 状态类副作用:
state[key] = version,只在新版本更大时覆盖。它能暴露乱序。
不变量:
- M1 每个产生的事件,计数类副作用恰好应用一次。
- M2 迁移结束时,每个
key的状态等于生产端的最新版本。 - M3 任何没有被应用的事件都要出现在隔离区中,不能静默消失。
- M4 迁移的每次阶段跳转都有记录;不允许的跳转被拒绝并留下记录。
通道特征(教学假设):旧通道平均延迟 0.5 秒,1% 的事件有 40 秒长尾,1% 重复投递;新通道平均延迟 0.2 秒,2% 重复投递。模型运行 700 秒,前 600 秒产生约 3 万个事件。
2. 阶段与契约
---
title: 图 E04-1 迁移阶段(实线为允许的跳转;OLD_RETIRED 之后没有回退)
---
stateDiagram-v2
[*] --> OLD_ONLY
OLD_ONLY --> DUAL_WRITE: 开始双写
DUAL_WRITE --> CUTOVER: 影子比对通过
DUAL_WRITE --> OLD_ONLY: 放弃迁移
CUTOVER --> DUAL_WRITE: 回退(旧通道仍在双写)
CUTOVER --> OLD_RETIRED: 停止写旧通道(不可逆点)
OLD_RETIRED --> [*]
| 阶段 | 生产端写入 | 权威通道(产生副作用) | 另一条通道的用途 |
|---|---|---|---|
OLD_ONLY |
旧 | 旧 | — |
DUAL_WRITE |
旧 + 新 | 旧 | 新通道只做影子比对 |
CUTOVER |
旧 + 新 | 新 | 旧通道在补漏期(60 秒)内补应用新通道缺的事件;补漏期之后,未应用过的事件进入隔离区 |
OLD_RETIRED |
新 | 新 | — |
两条通道同时投递同一事件时,靠消费端的去重记录区分:eid 首次应用的时间写入去重表,保留 dedupe_window(默认 120 秒);窗口内再次到达的同一 eid 被丢弃。去重窗口的下限由两条通道的最大延迟差决定,旧通道 40 秒的长尾意味着窗口必须明显大于 40 秒。
这里的去重是消费端的业务去重,不能用通道自带的机制替代。Kafka 的幂等生产者由 broker 为每个生产者分配 ID,并按随消息发送的序号去重,Javadoc 说明这一保证只在单个生产者会话内成立,应用层的重发无法被去重。RabbitMQ 的文档要求消费者为重投做好准备并按幂等设计,重投的消息带 redeliver 标记,并且可能投给另一个消费者。迁移期间的“同一事件”来自两条不同的通道,任何一条通道内部的机制都看不到另一条通道。
3. 影子比对:什么时候可以切换
DUAL_WRITE 阶段,生产端对新通道写入成功的事件记入 produced_dual。切换前的检查只看“创建已超过允许迟到时间(30 秒)”的事件:
1 | |
“已检查数为 0”不算通过,这条规则防止在双写刚开始时就切换。只比对超过允许迟到时间的事件,是因为新通道里尚未到达的事件无法区分“还在路上”与“丢了”。允许迟到时间本身是一个判断:Dataflow 模型论文把水位线描述为对已处理事件时间的一个下界,常常是启发式的,并指出它有时太快,会有迟到数据落在水位线之后;有时又太慢,一个慢数据就能拖住整条流水线。迁移中的“30 秒”是同类的启发式选择,所以第 4 节还要有补漏期和隔离区兜底。
双写时写新通道可能失败(生产端得到错误)。失败的事件进入出站表,每秒补发一次。实验中出过一个问题:最初的出站表只在切换时一次性补发,补发的副本比旧通道那份晚了几十到几百秒,超过 120 秒去重窗口的那部分被当作新事件再次应用。补发延迟必须小于去重窗口,这是两个参数之间的硬约束。
4. 切换之后:补漏与隔离
切换后,消费端处理旧通道事件的规则:
1 | |
隔离区里的条目是“候选”而不是“确认丢失”:旧通道的副本有时比新通道的副本先到,进入隔离区时尚未应用,几百毫秒后新通道的副本到达并被应用。所以统计时只看运行结束时仍未应用的隔离条目,对账也只回放这一部分。
5. 实验
运行入口 examples/e04_migration/:python3 -m unittest test_migration -v,10 个用例通过;python3 report.py 生成 11 个场景的结果(evidence/E04/cases.txt)。阶段时刻:100 秒开始双写,300 秒切换,500 秒停止写旧通道。
| 场景 | 丢失 | 重复 | 未应用的隔离条目 | 状态一致 | 阶段结果 |
|---|---|---|---|---|---|
| 基线 | 0 | 0 | 0 | 是 | 三次跳转全部完成 |
| 影子期新通道丢失 1% | 0 | 0 | 0 | 是 | 切换被比对拒绝;停止旧通道的跳转也被拒绝 |
| 双写时写新通道失败 2%(出站表每秒补发) | 0 | 0 | 0 | 是 | 完成 |
| 负向对照:去重窗口 5 秒 | 0 | 18 | 0 | 是 | 完成 |
| 切换期新通道静默丢失 1%(300–480 秒) | 58 | 0 | 58 | 是 | 完成 |
| 同上,结束后回放隔离区 | 0 | 0 | — | 是 | 完成 |
| 负向对照:切换后立即丢弃旧通道 | 185 | 0 | 0 | 否 | 完成 |
| 退役后新通道丢失 1%(520 秒起) | 32 | 0 | 0 | 否 | 完成 |
| 350 秒回退到双写 | 0 | 0 | 0 | 是 | 回退成功 |
| 550 秒(已退役)请求回退 | 0 | 0 | 0 | 是 | 回退被拒绝 |
| 负向对照:状态不检查版本 | 0 | 0 | 0 | 否 | 完成 |
5.1 正常路径
基线中约 3 万个事件全部恰好应用一次。去重表拦下了 3475 次重复到达,其中既有通道自身的重复投递,也有切换前后两条通道的同一事件;5381 次是补漏期之后到达、此前已应用的旧通道事件。状态类副作用中有 1338 次因版本较小被跳过,这是跨通道乱序的直接证据,但最终状态与生产端一致。影子比对检查了 8614 个事件,缺失 0,p99 延迟 0.90 秒。
5.2 边界:迟到事件与去重窗口
把去重窗口缩到 5 秒,18 个事件被应用了两次。它们都是旧通道长尾延迟约 40 秒的事件:新通道的副本先到并被应用,5 秒后去重记录过期,旧通道的副本到达时已无法识别。窗口由跨通道的最大延迟差决定,而这个差值在迁移期间只能从影子比对的延迟分布中估计。
5.3 失败:切换后的丢失
切换期间新通道静默丢失 1%,58 个事件没有被新通道投递。补漏期(300–360 秒)内丢失的那部分由旧通道补应用;补漏期之后丢失的 58 个全部进入隔离区,与丢失数完全相等(M3),回放后丢失为 0。
把旧通道在切换时直接丢弃,同样的故障造成 185 个事件丢失,隔离区为空,丢失没有留下任何记录,最终状态也与生产端不一致。补漏期与隔离区的意义在这里很清楚:它们不能阻止新通道丢消息,但能让每一条丢失被看见。
5.4 回退边界
350 秒从 CUTOVER 回退到 DUAL_WRITE,旧通道重新成为权威。切换期间已从新通道应用的事件仍在去重表中,旧通道的副本被拦下,结果无丢失、无重复。回退可行的前提是旧通道在 CUTOVER 期间一直在被写入;500 秒停止写旧通道之后,旧通道缺少这之后的全部事件,回退必须先从新通道回填,所以控制器拒绝 OLD_RETIRED → DUAL_WRITE,记录为 REJECT。
退役之后的丢失(520 秒起新通道丢失 1%)造成 32 个事件丢失,隔离区为空。旧通道已经不再是安全网,迁移机制对这一阶段的丢失无能为力。这类丢失只能靠生产端与消费端按 eid 或按计数对账来发现,属于新通道常态运行的问题,而不是迁移的问题。之所以单独列出,是因为“迁移完成”很容易被理解为“新通道已被证明可靠”,而迁移期间的检查只覆盖了迁移期间。
6. 运行手册:每一步的进入条件与观测
把控制器的跳转规则翻译成操作步骤,每一步都有可观测的进入条件。下表中的条件都在模型里有对应计数,部署时应当成为迁移看板上的指标:
| 步骤 | 进入条件 | 观测 | 失败时的动作 |
|---|---|---|---|
| 开始双写 | 新通道容量与权限已就绪;出站表补发任务运行中 | 写新通道失败率、出站表积压与最老条目年龄 | 积压最老条目接近去重窗口时停止推进 |
| 申请切换 | 影子比对:已检查数 > 0、缺失 0、载荷不一致 0、p99 延迟 ≤ 允许迟到时间 | 比对报告 | 比对不通过时控制器拒绝跳转,留在双写 |
| 切换 | 去重窗口 > 影子比对观测到的最大延迟差 | 去重命中数、版本较小被跳过的次数 | 回退到双写 |
| 补漏期结束 | 补漏期内旧通道补应用的事件数已归零或稳定 | 隔离区中未应用条目数 | 未应用条目非零:先对账回放,再考虑退役 |
| 停止写旧通道 | 隔离区为空;回退演练已做过一次 | — | 此后不能回退,只能前进 |
最后一行的“回退演练已做过一次”不是形式要求。回退路径平时从不执行,只在新通道出问题时才用到,如果第一次执行就是在事故中,它的正确性完全没有证据。本文的 test_rollback_before_retire 就是这条路径的演练:切换 50 秒后回退,检查无丢失、无重复。
迁移期间的告警也需要按阶段调整。双写阶段,新通道的消费延迟不影响业务,告警级别应当很低;切换之后,同样的延迟直接影响业务,必须升级。阶段变化本身应当触发告警规则的切换,而不是依赖值班人员记得手动修改,第 36 篇的规则版本机制可以承担这件事。
7. 方案比较
| 方案 | 切换时的重复 | 迟到事件 | 回退 | 代价 |
|---|---|---|---|---|
| 停写切换:停止生产 → 排空旧通道 → 切到新通道 | 无 | 排空时一并处理 | 切换后无法回退 | 停写时长取决于最慢的在途事件 |
| 消费端切换,不做去重 | 有 | 被重复应用或丢弃 | 可以 | 违反 M1 |
| 双写 + 影子比对 + 去重 + 补漏期(本文) | 被去重拦下 | 补漏期内补应用,之后进入隔离区 | 停止写旧通道之前可以 | 双写成本;去重表大小 ≈ 速率 × 窗口 |
| 只从新通道消费,旧通道数据离线回放补齐 | 依赖回放时的去重 | 回放覆盖 | 依赖回放 | 对账与回放工具成为关键路径 |
本文方案的成本主要是去重表:每秒 50 个事件、窗口 120 秒,约 6000 条记录,可以放在内存或与业务数据同库的表中。窗口必须覆盖最大延迟差,所以通道的长尾越长,表越大。生产速率增长 100 倍时,这张表需要与业务副作用放在同一个事务里持久化,否则消费端重启后会丢失去重记录;第 18 篇讨论过“消费成功但确认丢失”时副作用的去重,迁移期间面对的是同一个问题,只是重复的来源多了一条通道。
8. 限制
- 模型单消费者、单分区;没有覆盖消费组再均衡,也没有覆盖新旧通道分区数不同、按键路由变化的情况。
- 去重表在内存中,消费端重启时会丢失;第 6 节已说明持久化要求。
- 生产端双写是两次独立写入,不是原子的;“写旧成功、写新失败”由出站表补发,“写旧失败、写新成功”没有模拟。
- 通道的延迟、重复、丢失是独立随机的;真实故障往往是相关的(例如一段时间内全部变慢)。
- 没有使用任何真实消息组件;Kafka 与 RabbitMQ 的行为只引用文档,用来说明通道内去重机制的边界。
9. 来源
核对日期 2026-10-18。
- Akidau 等,The Dataflow Model,PVLDB 8(12), 2015,DOI 10.14778/2824032.2824076:水位线是常为启发式的下界,可能过快(出现迟到数据)或过慢。论文中没有“allowed lateness”一词,本文的“允许迟到时间”是自定义参数。
- Apache Kafka 4.3 文档 Design 与
KafkaProducerJavadoc:幂等由生产者 ID 与序号实现,只在单会话内成立;应用层重发无法去重;3.0 起默认开启幂等。 - RabbitMQ 4.3 文档 Consumer Acknowledgements:未确认的投递在通道关闭时重新入队;重投带
redeliver标记且可能投给另一个消费者;消费者应按幂等设计。https://www.rabbitmq.com/docs/confirms
本批导航
- 上一篇选修:E03 大群历史的冷热分层
- 下一篇选修:E05 幂等导出与断点续传
- 相关章节:17 从持久日志构建最小消息队列、18 消息队列接入与积压恢复、23 分片扩容时的数据交接



