30.8 服务路由(条件 / 标签 / 脚本)

一句话

负载均衡回答 “从候选里挑哪一个”,路由回答 “候选是谁”:它按规则先过滤提供者列表,再把剩下的交给 LB——一条 消费者条件 => 提供者过滤 的规则,就能实现多机房分组、灰度发布、流量切换、读写分离与黑白名单。

先分清四个东西

概念 作用点 决定什么 典型实现
服务路由 消费者发起调用之前 候选集合(哪些节点可调) Dubbo condition/script router、Istio VirtualService + subset
负载均衡 候选集合之内 选哪一个节点 轮询 / 加权 / 最少连接 / 一致性哈希(42.4 服务发现与客户端负载均衡)
网关路由 入口 L7 请求进哪个服务集群 Nginx location、Spring Cloud Gateway(47.2 API 网关)
灰度发布 用路由实现的一种“用法” 多大比例流量进新版本 路由规则 + 版本标签(30.6 服务版本与兼容);入口侧的灰度(切人/粘性/放量节奏)见 47.3 路由与灰度

🔑 顺序是路由在前、LB 在后。反过来(先 LB 选一个、再被路由过滤掉)会出现 “选中的节点不可用 → 重试 → 再选 → 再被过滤”,重试次数与延迟一起爆。这也是很多框架把 Router 链放在 LoadBalance 之前的原因。

四个应用场景

场景 要解决的问题 路由规则长什么样
分组调用 服务部署在多机房 / 多个公有云,消费者该调哪一组 按 host 网段或 idc、cloud 标签过滤
灰度发布(金丝雀) 新版本先给一小部分流量 按 version=canary 或 x-canary: 1 过滤
流量切换 机房光缆被挖断 / 着火,整个机房不可用 一条 “排除该机房” 的规则,秒级生效
读写分离 读多写少,读请求不得影响写 按方法名前缀把读方法指向读节点、写方法指向写节点

原文给的微博案例值得记住:按 “核心/非核心” 与 “私有云/公有云” 双重分组,让调用尽量留在同一机房——跨机房调用的延迟与抖动是路由要消灭的第一号目标。

两类规则

条件路由:消费者条件 => 提供者过滤

condition://0.0.0.0/dubbo.test.interfaces.TestService?category=routers&dynamic=true&rule=...
# rule 的内容就是一行表达式:
host = 10.20.153.10 => host = 10.20.153.11

语义只有三条,但每一条都能踩坑:

写法 含义
host = 10.20.153.10 => host = 10.20.153.11 IP 为 .10 的消费者只调 .11 这个提供者
=> host != 10.20.153.11 左侧为空 → 对所有消费者生效
host = 10.20.153.10 => 右侧为空 → 禁止该消费者调用(不是“不过滤”)

五条常用规则(都可直接抄):

用途 规则
排除故障节点 / 预发机 => host != 172.22.3.91
白名单(只允许某几台消费) host != 10.20.153.10,10.20.153.11 =>
黑名单(禁止某几台消费) host = 10.20.153.10,10.20.153.11 =>
机房隔离 / 就近访问 host = 172.22.3.* => host = 172.22.3.*
读写分离 method = find*,list*,get*,is* => role = read 与 method != find*,list*,get*,is* => role = write

⚠️ 空右侧是全禁,不是“不过滤”。这是最危险的一条语义:写规则时手滑删掉右边,全站调用立刻被拒绝。上线前先在 1% 流量上验证。

脚本路由:route(invokers) 返回子集

// script://...&rule=<urlencoded>
function route(invokers) {
  var result = new java.util.ArrayList(invokers.size());
  for (var i = 0; i < invokers.size(); i++) {
    if ("10.20.153.10".equals(invokers.get(i).getUrl().getHost())) {
      result.add(invokers.get(i));
    }
  }
  return result;
}(invokers);

表达式写不出来的逻辑(按用户 ID 取模、按 header 查表、按时间窗切换)用脚本更直接。代价是规则即代码:无法静态校验、无法可视化对比、出错只能靠回滚。

规则从哪来:三种来源与优先级

来源 适用 生效方式
本地配置 个别消费者有定制需求;配置中心不可用时的兜底 重启或热加载
配置中心(推荐) 绝大多数场景,统一管理 看实现:现代配置中心是推送/长轮询(秒级);早期实现只在启动时拉取,改规则要等重启或定时刷新
动态下发 机房故障切流、临时摘节点 治理平台 → 配置中心 → 消费者订阅

三者的优先级(原文口径):本地配置 > 动态下发 > 配置中心。也就是说本地的定制会盖掉平台统一下发的规则——这既是灵活性,也是事故源:排查时先看本地有没有旧规则。

把语义跑出来:一段可运行的过滤器

规则语义靠嘴讲容易错,写成代码跑一遍最清楚(本机实测,Python 3.14 / macOS arm64):

import fnmatch

PROVIDERS = [
    {"host": "172.22.3.91", "role": "read"},
    {"host": "172.22.3.92", "role": "read"},
    {"host": "172.22.3.94", "role": "read"},
    {"host": "172.22.3.97", "role": "write"},
    {"host": "10.20.153.10", "role": "read"},
]
C_BJ  = {"host": "172.22.3.10", "method": "getUser"}
C_OUT = {"host": "10.20.153.10", "method": "createOrder"}

def match(expr, node):
    expr = expr.strip()
    if not expr: return True
    neg = "!=" in expr
    key = expr.partition("=")[0].strip().rstrip("!").strip()
    val = expr.split("=", 1)[1]
    if key not in node: return False
    pats = [v.strip() for v in val.split(",") if v.strip()]
    hit = any(fnmatch.fnmatch(str(node[key]), p) for p in pats)
    return (not hit) if neg else hit

def route(rule, consumer, provs=PROVIDERS):
    cond, _, filt = rule.partition("=>")
    if cond.strip() and not match(cond, consumer):
        return provs, "消费端不匹配 → 不改集合"
    if not filt.strip():
        return [], "提供端为空 → 禁止调用"
    return [p for p in provs if match(filt, p)], "按规则过滤"
排除坏节点             172.22.3.10  => host != 172.22.3.91                    → [92, 94, 97, 10.20.153.10]
黑名单(禁该消费端)     172.22.3.10  host = 172.22.3.10 =>                    → 空(拒绝)        [提供端为空 → 禁止调用]
机房亲和(同网段)      172.22.3.10  host = 172.22.3.* => host = 172.22.3.*    → [91, 92, 94, 97]  [按规则过滤]
机房亲和(外网消费端)   10.20.153.10 host = 172.22.3.* => host = 172.22.3.*    → 全部 5 个         [消费端不匹配 → 不改集合]
读写分离·读            172.22.3.10  method = get*,find*,is* => role = read    → [91, 92, 94, 10.20.153.10]
读写分离·写            10.20.153.10 method != get*,find*,is* => role = write   → [97]

三个语义细节只有跑一遍才看得清:

现象 含义
第 2 行返回空 右侧为空是拒绝,不是放过
第 4 行返回全部 5 个 消费者不匹配左侧条件时,规则对它不生效(不是“也没得调”)
第 6 行只返回 97 写规则必须用 method !=,否则写请求会被读规则挡住

生产落地:指名 → 可抄 → 可验 → 可退

指名:Java 系用 Dubbo 的条件路由 / 脚本路由(或 Nacos 的权重与标签路由);K8s + Mesh 用 Istio VirtualService + DestinationRule 的 subset;入口层用 Spring Cloud Gateway / Nginx;治理平台用 Dubbo Admin / Nacos 控制台维护规则。

可抄(Istio 的 header 灰度,与条件路由等价):

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: user-svc
spec:
  hosts: ["user-svc"]
  http:
    - match:                       # 带 x-canary: 1 的请求走 v2
        - headers:
            x-canary:
              exact: "1"
      route:
        - destination: { host: user-svc, subset: v2 }
    - route:                        # 其余走 v1
        - destination: { host: user-svc, subset: v1 }

可验:三个检查点。

检查 怎么看 合格线
规则是否真的生效 按 subset/version 分组的 QPS 指标 灰度量 = 预期比例(如 1%)
有没有误禁 消费者侧 no provider available 错误率 0;一出现先查“空右侧”规则
生效延迟 从下发到指标变化的时间 秒级(配置中心推送);分钟级说明还在轮询

可退:路由出事的退路是清空规则——空规则集等于“不路由”,退化为纯 LB,业务照常跑。所以上线时要把 “一键清空” 做进治理平台,并保留上一版规则可回滚;本地配置留一份兜底规则,配置中心不可用时不至于完全失去分流能力(但兜底规则里绝不能写“排除整个机房”,否则配置中心故障 + 机房故障会叠加成全站不可用)。

反模式

反模式 后果
把 “空右侧” 当成 “不过滤” 一条误改的规则让全站调用被拒
按 IP 段做灰度比例 网段与用户分布无关,实际灰度量远偏离预期
路由规则写死在代码里 每次切流都要发版,故障时来不及
规则没有版本与回滚 改错只能逐台恢复,恢复时间以小时计
以为下发了就生效,其实配置中心只支持启动拉取 规则改了没反应,故障时以为“切流失败”
先 LB 再路由 选中即被过滤,重试放大、延迟飙升
规则过宽(host = *) 看起来生效,实际什么都没过滤,灰度为 0 也无人发现
兜底规则里写 “排除某机房” 配置中心故障与机房故障叠加,全站不可用
灰度只切流量、不隔离数据 新版本读到旧 schema,回滚也修不回来

三条铁律

🔑 路由决定 “候选是谁”,LB 决定 “选哪一个”;顺序不能反,职责不能混。

🔑 规则要能一键清空、能回滚、能按 1% 验证:路由是生产开关,不是配置文件。

🔑 空右侧 = 全禁:任何自动生成规则的脚本,都要对右侧做非空校验。

自检

Q1 服务路由与负载均衡的区别是什么?为什么顺序不能反?

▸ 答案:路由在调用前过滤候选集合,LB 在集合内选一个节点。如果先 LB 选了一个、再被路由过滤掉,就只能重试再选,可能连续选中被排除的节点——重试次数与延迟同时放大。正确顺序是 Router 链 → LoadBalance。

Q2 host = 10.20.153.10 => 这条规则的含义是什么?为什么危险?

▸ 答案:左侧匹配到 IP 为 10.20.153.10 的消费者,右侧为空表示没有任何可用提供者,即禁止该消费者调用。危险在于它和 “右侧不写就是不过滤” 的直觉相反——一条误删右侧的规则会立刻让全站调用失败,且报错是 “no provider available”,很容易被误判成注册中心故障。

Q3 消费者不匹配左侧条件时,规则会把它路由到哪里?

▸ 答案:不改变它的候选集合(规则对它不生效),它仍然看到全部提供者。这也是 “机房亲和” 类规则能安全存在的原因:同网段的消费者被限制在本机房,其他消费者不受影响。演示代码第 4 行就是这个语义。

Q4 为什么灰度发布不能按 “IP 段比例” 控制流量?

▸ 答案:因为 IP 段与真实用户/请求量没有对应关系:一个网段可能只有 3 台机器却贡献 30% 流量,也可能有 300 台机器只贡献 1%。按 IP 做灰度,实际灰度量会严重偏离预期。正确做法是按请求特征(header、用户 ID 取模、版本标签)路由,并在指标上按版本分组核对比例。

Q5 路由规则放在本地、配置中心、动态下发,三者的优先级与适用场景?

▸ 答案:优先级 本地 > 动态下发 > 配置中心。配置中心适合绝大多数统一管理场景(秒级推送);动态下发用于机房故障切流、临时摘节点这类运维动作;本地配置用于个别消费者的定制或配置中心不可用时的兜底。副作用是本地规则会盖掉平台规则,排查时必须先确认本地没有旧配置。

Q6 脚本路由比条件路由灵活,代价是什么?

▸ 答案:规则即代码——无法静态校验、无法可视化对比、无法用表达式工具做冲突检测;出错时只能靠回滚,且对写规则的人有编程能力要求。所以实践里用条件路由覆盖 90% 场景,脚本只用于 “按用户 ID 取模、按 header 查表” 这类表达式表达不了的逻辑。

Q7 机房 A 故障,用路由把流量切到机房 B,为什么可能引发二次故障?

▸ 答案:两个原因:① 容量不足——B 机房平时只承载 50%,突然接 100%;② 兜底规则叠加——“排除故障机房” 的规则如果写在本地兜底里,配置中心恢复后仍生效,或者与 “就近访问” 规则冲突导致 B 机房内部也被限流。正确做法是切流前核对 B 的容量水位(见 36.4 两地三中心与多活 的容量 2× 原则),并在切流后按机房分组看错误率。

关联