跳到正文

流量切换平台:多入口拉入拉出与灰度编排 ​

一句话:把 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 或 ISOLATEDDRAINING 排空、ISOLATED 立即断开,语义不同

这张表的结论是:不能用一个「下线成功」的布尔值代表所有入口。平台必须按类型分别查询,并把「已下发」和「已生效」分开记录。

顺序:拉出逆着调用方向,拉入顺着调用方向 ​

拉出时先断最外层入口,再逐层往里收,保证没有新流量进入;拉入时反过来,先让进程、消费能力、注册状态就绪,最后才把入口流量放进来。图里的虚线不是回滚,而是同一套动作在拉入时的执行方向。

状态机:把长流程变成可校验的迁移 ​

灰度或切换不是一次 API 调用,而是「锁资源 → 存规则 → 提交 → 下发 → 验证 → 完成」。用状态机约束非法跳步,比在每个接口里散落 if 更可靠:

状态码按阶段留区间(如 1–49 创建、50–99 资源、100–199 规则、200–299 发布),新增状态不用重排。每次迁移写一条变更日志:谁、从什么状态到什么状态、结果、耗时。资源锁按「应用 + 环境」加,防止两个方案同时改同一批实例。

两阶段发布:先保存,再让配置生效 ​

对 Nginx 这类「改数据库」和「reload 生效」分离的系统,直接改再发布会出现「库里有、机器上没有」:

  1. 保存:校验语法、写入配置库、返回版本号;此时线上仍是旧配置。
  2. 发布:按版本号下发到所有节点、执行 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 数量为 0
text
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 还是 ISOLATEDDRAINING 排空、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 移除会触发重平衡和重复消费。所以平台只能保证「动作按顺序下发并留下记录」,不能保证「所有入口同一秒生效」。真正的验收标准是操作后重新查询实际状态,而不是看接口返回成功。

把一门技术讲透到既懂也会用