本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 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
2
3
4
5
6
7
8
def shadow_gate(c, produced_dual, now, lateness):
due = [eid for eid, t in produced_dual.items() if now - t > lateness]
missing = [eid for eid in due if eid not in c.shadow_new]
lat = sorted(c.shadow_new[e] - produced_dual[e] for e in due if e in c.shadow_new)
p99 = lat[int(0.99 * (len(lat) - 1))] if lat else None
return {"checked": len(due), "missing": len(missing), "p99_lateness": p99,
"payload_mismatch": c.payload_mismatch,
"ok": bool(due) and not missing and c.payload_mismatch == 0 and p99 is not None and p99 <= lateness}

“已检查数为 0”不算通过,这条规则防止在双写刚开始时就切换。只比对超过允许迟到时间的事件,是因为新通道里尚未到达的事件无法区分“还在路上”与“丢了”。允许迟到时间本身是一个判断:Dataflow 模型论文把水位线描述为对已处理事件时间的一个下界,常常是启发式的,并指出它有时太快,会有迟到数据落在水位线之后;有时又太慢,一个慢数据就能拖住整条流水线。迁移中的“30 秒”是同类的启发式选择,所以第 4 节还要有补漏期和隔离区兜底。

双写时写新通道可能失败(生产端得到错误)。失败的事件进入出站表,每秒补发一次。实验中出过一个问题:最初的出站表只在切换时一次性补发,补发的副本比旧通道那份晚了几十到几百秒,超过 120 秒去重窗口的那部分被当作新事件再次应用。补发延迟必须小于去重窗口,这是两个参数之间的硬约束。

4. 切换之后:补漏与隔离

切换后,消费端处理旧通道事件的规则:

1
2
3
4
5
6
7
elif self.ctl.drain_until is not None and self.sim.now <= self.ctl.drain_until:
self._apply(src, e) # 切换后的限时补漏:只补新通道缺的(去重会拦下已应用的)
elif self.applied.get(e.eid, 0) == 0:
# 补漏期结束后才到达、且从未被应用:进入隔离区等待对账,不静默丢弃
self.quarantined.append((self.sim.now, e.eid, "old_after_drain"))
else:
self.late_old_ignored += 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 与 KafkaProducer Javadoc:幂等由生产者 ID 与序号实现,只在单会话内成立;应用层重发无法去重;3.0 起默认开启幂等。
  • RabbitMQ 4.3 文档 Consumer Acknowledgements:未确认的投递在通道关闭时重新入队;重投带 redeliver 标记且可能投给另一个消费者;消费者应按幂等设计。https://www.rabbitmq.com/docs/confirms

本批导航