主题
流量切换平台:多入口拉入拉出与灰度编排
一句话:把 Nginx 权重、注册中心实例、RocketMQ 消费组和双活机房状态收敛成一次可编排、可查询、可回滚的变更;它换来跨入口的一致操作,代价是每个入口的「摘流量」语义并不相同,平台记录也永远只是意图,不是事实。
适用版本:Nginx 1.24+ / OpenResty、Dubbo 3.x、Spring Cloud 2023.0.x、RocketMQ 4.9 与 5.x、Apollo 2.x。优雅停机的进程侧动作见 11-优雅上下线与启动检查。
为什么需要
一次节点下线,手工要做四件事:把 Nginx 权重改 0、从注册中心摘实例、把消费者移出消费组、等进程排空。任何一步顺序错了,都会留下尾巴:
- 先停进程再摘入口:入口仍转发,出现 502;上游重试把流量放大。
- 先摘注册中心就停进程:消费者本地缓存还没过期,仍会调用到已停实例。
- 直接删消费者:触发 Rebalance,未确认消息重新投递,业务看到重复消费。
- 双活机房切换只改 DNS:长连接和本地缓存不认新地址,切换看起来成功,实际一半流量还在旧机房。
入口越多,越需要把「谁在用这个应用、每类流量现在是什么状态、这次操作按什么顺序做」从个人经验变成平台能力。
核心机制
统一抽象:同一动作,不同实现
平台只定义三个动作:pullOut(摘流量)、pullIn(放流量)、queryStatus(查实际状态)。每类流量实现自己的适配器,编排层不关心底层是改权重还是改路由规则。
| 流量类型 | 拉出的实际动作 | 生效边界 |
|---|---|---|
| 七层负载(Nginx/SLB) | weight=0 或摘除 upstream | 新请求不再选中;已建立连接与在途请求继续 |
| 四层负载(F5/LVS) | 摘除后端或置零权重 | 取决于健康检查与会话保持;长连接可能仍落在旧节点 |
| RPC(Dubbo/Spring Cloud) | 从注册中心注销或打标禁用 | 消费者有本地实例缓存,要等缓存过期或主动通知 |
| MQ(RocketMQ) | 移出消费组或暂停消费 | 触发 Rebalance,未 ack 消息重投,可能重复消费 |
| 双活/单元化 | 置为 DRAINING 或 ISOLATED | DRAINING 排空、ISOLATED 立即断开,语义不同 |
这张表的结论是:不能用一个「下线成功」的布尔值代表所有入口。平台必须按类型分别查询,并把「已下发」和「已生效」分开记录。
顺序:拉出逆着调用方向,拉入顺着调用方向
拉出时先断最外层入口,再逐层往里收,保证没有新流量进入;拉入时反过来,先让进程、消费能力、注册状态就绪,最后才把入口流量放进来。图里的虚线不是回滚,而是同一套动作在拉入时的执行方向。
状态机:把长流程变成可校验的迁移
灰度或切换不是一次 API 调用,而是「锁资源 → 存规则 → 提交 → 下发 → 验证 → 完成」。用状态机约束非法跳步,比在每个接口里散落 if 更可靠:
状态码按阶段留区间(如 1–49 创建、50–99 资源、100–199 规则、200–299 发布),新增状态不用重排。每次迁移写一条变更日志:谁、从什么状态到什么状态、结果、耗时。资源锁按「应用 + 环境」加,防止两个方案同时改同一批实例。
两阶段发布:先保存,再让配置生效
对 Nginx 这类「改数据库」和「reload 生效」分离的系统,直接改再发布会出现「库里有、机器上没有」:
- 保存:校验语法、写入配置库、返回版本号;此时线上仍是旧配置。
- 发布:按版本号下发到所有节点、执行 reload、逐节点收集结果。
好处是保存失败不影响现网,发布前可二次确认,批量节点可以重试。代价是要处理「部分节点成功」:平台必须把每个节点的结果落库,失败节点要么重试,要么整体回滚,不能只返回一个笼统的失败。
并行执行:快,但不能乱序
多个应用、多个流量类型可以并行,但有两类必须串行:
- 同一应用、同一流量类型:并行会互相覆盖配置,后写的赢。
- 有依赖的方向:先发布后端再放前端流量;拉出时反过来。后端没就绪就放流量,用户直接看到错误。
实现上用固定大小线程池 + 每个任务独立超时 + 汇总所有异常,而不是「一个失败就取消全部」。对可重试的读操作和不可重试的写操作要分开标记,避免重试把一次变更做成两次。
双活切换:三态比开关更接近现实
双活节点通常需要三个状态:ACTIVE(正常接收)、DRAINING(不接新流量,等在途完成)、ISOLATED(立即隔离,用于故障)。切换前先确认复制延迟、数据归属和回滚路径;切换动作本身只是改路由,数据是否能切过去由同步链路决定。
实战场景
一、切换显示成功,502 却在三分钟后爆发
现象:一次机房内节点迁移,平台工单显示“Nginx 摘除成功、注册中心下线成功、进程已停止”。实际操作里运维先 kill -9 了进程,再改权重。之后入口 502 持续 4 分钟,且上游网关重试把下游 QPS 放大到平时的 2.3 倍(经验值,需校准);平台侧状态看起来全绿。
排查:布尔值“下线成功”掩盖了四类流量的不同生效边界。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 动作顺序 | 平台变更日志里每步时间戳:进程停止 vs 入口权重归零 | 进程先停 → 中间窗口内入口仍在转发 |
| 七层入口实际状态 | 直连 Nginx 查 upstream 当前权重;看 502 与 upstream 地址分布 | 仍有请求选中旧节点 → 权重未生效或 reload 未下发 |
| 已建立连接 | ss -tn dst <old-node> 在旧进程停止后的连接数 | 权重只挡新请求,存量连接照样失败 |
| 注册中心与客户端缓存 | 实例列表 vs 消费者侧日志里最后一次打到旧 IP 的时间 | 35s 缓存窗口内旧地址仍被使用 |
| 在途任务 | 进程日志里被中断的异步任务、未提交事务 | kill -9 直接丢弃,没有排空 |
| 重试放大 | 上游重试次数与下游 QPS 曲线 | 失败被重试放大成二次拥塞 |
平台记录只是意图,事实要 query 回来对账。
对症做法:
text
拉出顺序(逆着调用方向):
① 入口权重置 0 / 网关摘路由 → 只挡新请求
② 注册中心下线或打标禁用 → 等消费者缓存过期(示例 35s + 缓冲)
③ 停止 MQ 拉取,等在途消息 ack → 不触发不必要的 Rebalance
④ SIGTERM,进程排空在途 HTTP 请求 → 超时后记录强制中断数
拉入顺序(顺着调用方向):④→③→②→①,进程与消费能力先就绪,最后放入口流量java
// 每步执行后必须回查真实状态,而不是相信适配器返回值
ChangeResult r = nginxAdapter.pullOut(target);
WireStatus s = nginxAdapter.query(target); // 直连被操作系统
if (!s.weightZeroed()) throw new StepFailedException(r.stepId(), s); // 停在该步并保留已完成记录
audit.record(target, r, s, cost); // 目标、下发结果、实际状态、耗时yaml
# 长连接与在途请求要进程侧配合,权重归零不等于流量停止
server.shutdown: graceful
spring.lifecycle.timeout-per-shutdown-phase: 30s
terminationGracePeriodSeconds: 90 # 大于“等待缓存过期 + 排空”总时长复盘要点:验收口径是“操作后重新查询实际状态”,逐项对齐:入口权重、注册实例数、消费组 diff、活跃连接数、错误率与 P99 是否回基线。判据可量化为“切换窗口内 5xx 必须为 0,且平台状态与实际状态的对账差异为 0”;有差异就按版本回滚而不是重复下发同一版本。
二、移出消费组触发 Rebalance,消息整批重复
现象:一次扩缩容把 pay-consumer 组的 6 个实例缩到 4 个,之后对账发现 1200 余笔支付回调重复入账(经验值,需校准),其中大部分被幂等拦截、少量产生了重复流水。RocketMQ 控制台显示该组发生多次 Rebalance。
排查:MQ 的“拉出”语义与其他入口不同:移出成员会触发重平衡,未确认消息重新投递。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 重平衡次数 | 控制台/日志里该消费组的 Rebalance 记录与时间 | 每次成员变化都会重新分配队列 |
| 未 ack 消息 | 消费者的 ackIndex 与 broker 侧未确认偏移 | 实例退出时未 ack 的消息必然重投 |
| 消费模式 | 集群模式还是广播;顺序消息是否锁定队列 | 顺序消息重平衡期间可能阻塞 |
| 幂等是否存在 | 业务唯一键与去重表命中率 | 无幂等则重投直接变成重复数据 |
| 拉出方式 | 是“暂停消费”还是“直接删实例/删组” | 删除等于强制 Rebalance,暂停可控 |
| 消费积压 | diff 与堆积增长曲线 | 缩容后剩余实例承接能力不足 |
对症做法:
java
// 1. 拉出用“暂停拉取 + 等 ack”,不要直接删实例
consumer.suspend(); // 停止拉取新消息
boolean drained = consumer.awaitInFlight(30, SECONDS); // 等在途消息 ack
if (!drained) metrics.forceUnacked(consumer.unackedCount()); // 记录强制中断,不假装干净
consumer.shutdown(); // 再退出,此时未 ack 数量为 0text
2. 顺序执行而不是与进程停止并行:MQ 拉出 → 等 ack → 注册中心下线 → 入口权重 → 进程退出
3. 消费幂等是前置条件:bizKey(支付流水号)唯一索引 + ON CONFLICT 忽略,重复投递返回首次结果
4. 缩容要按队列数整除规划(例如 16 队列对应 4/8/16 实例),避免频繁重平衡java
// 3. 幂等实现要点:去重要覆盖“处理成功但 ack 前崩溃”的窗口
public void onMessage(PayCallback msg) {
if (dedup.seen(msg.serialNo())) return; // 已处理,直接确认
txTemplate.execute(s -> { apply(msg); dedup.mark(msg.serialNo(), msg.id()); return null; });
} // 去重记录与业务写入在同一事务,避免“标记了但没落库”复盘要点:判据是“切换窗口内的重复入账笔数必须为 0”,且这个结论要靠幂等拦截计数验证,而不是靠没投诉。把“删除消费者”这个动作从平台里去掉,只保留暂停与优雅关闭两条路径;对顺序消息和大批量消费,还要单独评估 Rebalance 期间的队列锁定与积压风险,见 优雅上下线与启动检查。
三、机房切换后一半流量还在旧机房
现象:双活切流演练中,平台把 A 机房置为不接流量并改完路由,工单显示成功。20 分钟后监控显示 A 机房仍在承接约 45% 请求(经验值,需校准),且 B 机房出现数据写入冲突告警。
排查:路由改了不等于流量走了,更不等于数据能切。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 入口实际分布 | 按机房维度统计 QPS、新建连接数 | 仍有新建连接 → 路由未覆盖该入口 |
| 长连接与客户端缓存 | 存量连接数按机房;SDK 本地实例缓存 TTL | 长连接不会因权重变化迁移,需主动关闭 |
| DNS 生效 | 各解析节点的 TTL 与实测解析结果 | 只改 DNS 时,客户端缓存与递归服务器 TTL 决定时间 |
| 状态语义 | 置为 DRAINING 还是 ISOLATED | DRAINING 排空、ISOLATED 立即断开,混用会丢在途请求 |
| 数据前置条件 | 复制延迟、数据归属、双写冲突窗口 | 流量能切不代表数据能切 |
| 回滚路径 | 是否保留上一版本路由、能否秒级切回 | 回滚方案缺失时演练风险极高 |
本例两处命中:SDK 侧本地实例缓存与长连接未处理,所以连接仍指向 A;切换前未确认 A→B 的复制延迟已追平,写请求切过去后出现归属冲突。
对症做法:
text
切换前置检查(任一不满足就不切):
① 复制延迟 < 阈值(例如 1s,经验值需按业务校准)且无积压 DDL
② 数据归属已按单元迁移或本单元无写请求
③ 回滚版本就绪,路由可按版本切回
④ 长连接处理方案明确:主动发送关闭帧或分批断开,让客户端重连到新入口java
// DRAINING:不接新流量,等在途完成;不要用 ISOLATED 做计划内切换
unit.setStatus(DRAINING);
awaitInFlight(unit, 60, SECONDS); // 排空在途请求
closeLongConnectionsInBatches(unit, 500); // 分批断开长连接,避免瞬时重连风暴
unit.setStatus(ISOLATED); // 确认无在途后才隔离java
// 客户端要能感知切换:注册中心下线 + 缓存刷新,而不是只改入口权重
// 依赖本地实例缓存的 RPC 客户端,必须等一个 TTL 周期后再判定“流量已走”复盘要点:演练的验收不是“平台返回成功”,而是三项对账:按机房的 QPS 分布达到目标、无数据归属冲突、回滚可在约定时间内完成。判据建议固化成:切换后 5 分钟内 A 机房新建连接为 0,且复制延迟保持低于阈值;两条中任一条不满足即自动回滚。控制面本身不进入业务链路——平台不可用时,现有流量必须继续按最后一份配置工作,这也是这次演练同时验证的一项。
最小上手
适配器接口只保留三个方法,新增流量类型时不改编排层:
java
public interface TrafficAdapter {
WireType type();
ChangeResult pullOut(Target target); // 摘流量
ChangeResult pullIn(Target target); // 放流量
WireStatus query(Target target); // 查实际状态
}编排层按「入口 → 注册中心 → MQ → 进程」排序执行拉出,任一步失败就停在该步并保留已完成步骤的记录;拉入按相反顺序执行。query 必须直连被操作系统,不能只读平台库。
生产动作
- 操作前:列出应用关联的全部流量类型与实例;确认当前权重、注册实例数、消费组 diff;确认没有其他方案持有同一应用的锁。
- 操作中:先
query再执行;每步记录目标、实际结果、耗时;批量操作限制并发度,给单步超时。 - 操作后:重新
query对账,而不是相信返回值;看入口错误率、注册实例数、消费积压、P99 延迟是否回到基线。 - 失败处理:区分「未下发」「已下发未生效」「部分节点生效」;后两种要按版本回滚,不要重复下发同一版本。
- 权限与审计:生产切换单独授权;记录操作人、原因、目标、版本号;紧急操作保留事后复核入口。
- 平台自身:控制面不进入业务请求链路;平台不可用时,现有流量必须继续按最后一份配置工作。
边界
- 实例少时不必上平台:几个应用、一种入口,用发布系统和注册中心自带能力就够;平台的价值在入口多、操作频繁、需要审计之后才出现。
- 不能替代滚动发布:它管的是「流量从哪来」,不管「新版本怎么起、怎么验」;两者要配合,不能拿切换平台当发布系统。
- 不能保证在途请求完成:权重归零只影响新选择,长连接、异步任务、事务提交仍可能在旧实例上继续;必须配合进程侧排空。
- 配置中心是最终生效:RPC 路由、MQ 消费组变更都依赖客户端刷新,平台显示成功不等于所有实例已切换。
- 不解决数据一致性:机房切换只改流量方向;复制延迟、双写冲突、数据归属要由数据层单独设计。
讲清楚
流量切换平台做的事,是把「改 Nginx 权重、摘注册中心、移消费组、切机房」这些分散动作收成一次可编排的变更。它用统一接口抹平入口差异,用状态机约束操作顺序,用两阶段发布避免配置库和机器不一致,用并行执行缩短批量操作时间。但每类流量的「摘除」语义不同:权重归零只挡新请求,注册中心下线有客户端缓存,MQ 移除会触发重平衡和重复消费。所以平台只能保证「动作按顺序下发并留下记录」,不能保证「所有入口同一秒生效」。真正的验收标准是操作后重新查询实际状态,而不是看接口返回成功。