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× 原则),并在切流后按机房分组看错误率。