Dubbo 服务版本管理不善导致灰度失败

场景:灰度发布流量全量走到旧版本,新版本 Provider 零流量,registry 机器齐全却路由不到 路径DynamicDirectory.doList()RouterChain.route()ConditionRouter.route()MatchPair.isMatch()UrlUtils.isMatchGlobPattern()

【遗迹】Provider 注册了,灰度流量就是过不去

上篇讲了 Dubbo Filter 顺序导致日志丢失的源码追踪,这篇来看另一个同样反直觉的问题。

Dubbo(2.7.23)灰度流量全部打到旧版本——但新版本的 Provider 明明在注册中心在线。翻到 ConditionRouter.MatchPair.isMatch() 的源码才发现——版本号一个字符的偏差,让路由把新版本 Provider 全过滤了。

灰度发布当天,新版本 UserService 上线了 2 个实例,配上了一条条件路由规则:

force: true
conditions:
  - host = 10.0.1.% => version = 2.0.0

预期:QA 组的机器(10.0.1.x 网段)发起的 RPC 请求,全量路由到 version=2.0.0 的 Provider。

结果 QA 验证直接报错:No provider available from registry

第一反应:网络不通? telnet 注册中心端口——通。各节点间网络——正常。

第二反应:注册中心挂了? zkServer.sh status——Mode: leader,正常。

第三反应:Consumer 缓存没刷新? 重启 Consumer——没用。重启 Provider——没用。清 Dubbo 本地缓存——没用。

第四反应:Admin 路由规则没下发? 翻 Admin 页面——规则在,enabled=true,force=true。

折腾了将近 2 个小时,直到有人用 zkCli 看了注册中心的 Provider 列表:

$ ls /dubbo/com.example.UserService/providers
provider://10.0.2.10:20880/... version=1.0.0
provider://10.0.2.11:20880/... version=2.0.0
provider://10.0.2.12:20880/... version=2.0
provider://10.0.2.13:20880/... version=v2.0.0

发现问题了:4 个 Provider,只有 1 个的 version 精确等于 2.0.0。另外 3 个要么少了 .0,要么多了 v 前缀,要么是旧版本的 1.0.0。路由规则要求匹配 version=2.0.0——其他的全对不上。

异常堆栈 + zkCli 查到的 Provider 列表

继续验证——用 Admin API 直接查路由规则和 Provider 版本分布:

$ curl -s http://dubbo-admin:8080/governance/rules | jq '.data[] | select(.service=="com.example.UserService")'
{
  "force": true,
  "conditions": ["host = 10.0.1.% => version = 2.0.0"],
  "enabled": true
}

$ curl -s http://dubbo-admin:8080/governance/providers/com.example.UserService | \
  jq '[.[].url | capture(".*version=(?<ver>[^&]+)") | .ver]'
["1.0.0", "2.0.0", "2.0", "v2.0.0"]

$ echo "精确匹配 version=2.0.0 的 Provider 数量: $(curl -s ... | jq '[.[].url | test("version=2.0.0[& ]")] | length')"
精确匹配 version=2.0.0 的 Provider 数量: 1

4 个 Provider 只有 1 个满足条件。如果这个 Provider 负载一高就超时——灰度入口只有 1 个节点可用。如果它恰好挂了——doList() 返回空列表 → 灰度组的请求全挂。

Admin 路由规则 + Provider 版本分布查询

【发掘】源码追踪:从路由规则到 isMatch 的全链路

Admin 上那行 host = 10.0.1.% => version = 2.0.0 是怎么变成对 Provider 的版本过滤逻辑的?

第一步:ConditionRouter.init() 收到 Admin 下发的规则字符串,用 => 拆分为 when 条件和 then 条件:

ConditionRouter.java#L89-L106, Dubbo 2.7.23)

对于这条规则,when 条件为空(匹配所有请求),then 条件为 host = 10.0.1.% version = 2.0.0

第二步:parseRule() 用正则 ([&!=,]*)\\s*([^&!=,\\s]+) 顺序解析 then 条件的每个 token:

"host"      → 创建 MatchPair,作为 key 存入 condition map
"10.0.1.%"  → 分隔符 "=" → 加入 pair.matches
"version"   → 分隔符 "&" → 创建新的 MatchPair
"2.0.0"     → 分隔符 "=" → 加入 pair.matches

解析完成后,thenCondition 结构为:

{
  "host"    → MatchPair { matches=["10.0.1.%"], mismatches=[] },
  "version" → MatchPair { matches=["2.0.0"], mismatches=[] }
}

ConditionRouter.init() + parseRule() 解析过程

第三步:ConditionRouter.route() 遍历所有 Provider Invoker,对每个调用 matchThen()

matchThen(providerUrl, consumerUrl)
  → matchCondition(thenCondition, providerUrl, consumerUrl, null)
    → 取 providerUrl 的 "version" 参数值 → "2.0" 或 "2.0.0" 或 "v2.0.0"
    → MatchPair.isMatch("2.0", null)
      → matches=["2.0.0"], mismatches=[]
      → UrlUtils.isMatchGlobPattern("2.0.0", "2.0", null)
        → pattern 无 "*" → "2.0.0".equals("2.0") → false

关键认知:字符串精确匹配不会因为你只差了一个 .0 就放过你。 "2.0.0".equals("2.0") 永远等于 false。

ConditionRouter.route() + MatchPair.isMatch() 源码

【路径】zkCli 三步定位 + version 格式影响矩阵

三命令定位法

# 命令 1:查所有 Provider 的 version 分布
$ zkCli.sh ls /dubbo/com.example.UserService/providers | \
  grep -oP 'version=\K\S+(?=&|$)' | sort | uniq -c | sort -rn
      1 v2.0.0
      1 2.0.0
      1 2.0
      1 1.0.0

# 命令 2:查 Provider 总数和匹配数
$ zkCli.sh ls /dubbo/com.example.UserService/providers | wc -l
4
$ zkCli.sh ls /dubbo/com.example.UserService/providers | grep "version=2.0.0" | wc -l
1

# 命令 3:查灰度路由规则条件
$ curl -s http://dubbo-admin:8080/governance/rules | jq '.data[] | select(.service=="com.example.UserService") | .conditions[]'
"host = 10.0.1.% => version = 2.0.0"

三条命令,30 秒。结果明确:路由规则要求 version=2.0.0,但实际 Provider 版本五花八门。

版本格式影响矩阵

同一路由规则 host = 10.0.1.% => version = 2.0.0,不同 version 格式的匹配结果:

Provider version isMatch 原因 灰度影响
1.0.0 false 旧版本,值不同 正常——旧版本就不该进灰度
2.0.0 true 精确匹配 正常——唯一被选中的节点
2.0 false .0,字符串不等 容量减少 50%——灰度只有 1 个节点
v2.0.0 false v 前缀,不等 新版本被过滤,灰度等于没部署
2.0.0-SNAPSHOT false SNAPSHOT 后缀 开发版本意外进入灰度,被过滤
V2.0.0 false 大写 V 大小写不敏感?不——Java 字符串敏感
02.0.0 false 前导零 人类看着一样,equals 说不一样

版本格式影响矩阵

通配符匹配的工作方式

如果把路由条件改成 version = 2.0*(用星号结尾),匹配规则变成 startsWith

Provider version isMatch(2.0*) 原来 isMatch(2.0.0)
2.0.0 true true
2.0 true false ← 修复了
2.0.1 true false ← 可能误伤
2.0.0-SNAPSHOT true false ← 可能误伤
v2.0.0 false false — v 前缀依然不行

version = 2.0* 能救回 2.0 格式偏差,但救不了 v2.0.0。根源在于——v 前缀说明版本管理系统本身就存在不一致,不是路由条件能兜底的。

UrlUtils.isMatchGlobPattern() 匹配逻辑

【解读】Dubbo 为什么不用语义化版本比较——以及这个设计决策的代价

一个合理的追问:Dubbo 做路由匹配时,为什么不用语义化版本比较(2.0.0 > 2.0 > 2),而是用字符串精确匹配?

UrlUtils.isMatchGlobPattern() 的实现——它是路由匹配的统一内核,处理所有 URL 参数的比对:applicationhostinterfaceversionmethods……全走同一套逻辑。

如果 version 要特殊处理——解析三段的 semantic version、做数值比较——那这条流水线就要为 version 开一个特例分支。这个特例意味着:

  • 每来一个 key,都要判断 "是不是 version"
  • 如果是,还要判断 "语义化版本格式是否正确"(万一有人用 gray-20260705 做版本号?)
  • Version 类的解析要写正则、要处理 SNAPSHOT、要兼容 1.0.0.RELEASE

Dubbo 的选择很明确:保持路由匹配逻辑的统一性,不为特定参数开特例。 version 和其他参数一样,走 equalsstartsWith——框架层面不做语义化理解。

这不是一个 Bug,是一个设计取舍。但问题是:这个取舍的代价通常不会在开发阶段暴露,而是在灰度发布当天才炸。

开发环境所有人用同一个 SNAPSHOT 版本,"version 写对了没有"从来不是 PR review 的内容。直到灰度路由规则需要精确匹配版本号时,才发现各团队对 version 的写法从来没有统一过——有人写 2.0.0,有人写 2.0,有人写 v2.0.0,有人压根没写。

一句话总结这个设计决策的代价:Dubbo 把版本管理的责任完全交给了团队规范。框架不帮你兜底。

对比来看,Spring Cloud 的版本路由走的是另一条路——通过 Eurekametadata-map 做 tag 路由,版本只是一个 metadata 键值对,匹配逻辑也是字符串比对,同样不做语义化版本比较。不是框架不愿意,是通用框架不可能预知你用什么版本号格式。统一性优先于特例优化,是这类框架的共性选择。

版本管理不善的根本原因

回到这个案例——问题根源不是 ConditionRouter 代码写错了,而是三个层面的管理缺失:

  1. 没有版本号规范:谁写 v2.0.0、谁写 2.0、谁不写——没有规则
  2. 没有预检机制:灰度规则上线前,没有核查 "Provider version 是否与规则条件一致"
  3. 没有兜底force=true 放大了问题——如果 force=false,至少还能回退到全部 Provider

这三个缺失叠加在一起:不规范的版本号 + 不检直接上 + force 不放水 → 灰度失败。

版本路由完整调用链路

【收获】排查锚点 + 版本管理规范

下次遇到灰度流量没到新版本,先搜 Provider URL 中的 version 参数。版本号差一个字符,路由判不匹配。

源码路径速查

角色 类名 方法 版本
目录服务 DynamicDirectory doList() Dubbo 2.7.23
路由入口 RouterChain route() Dubbo 2.7.23
条件路由 ConditionRouter route()matchThen() Dubbo 2.7.23
规则解析 ConditionRouter init()parseRule() Dubbo 2.7.23
匹配逻辑 ConditionRouter.MatchPair isMatch() Dubbo 2.7.23
匹配内核 UrlUtils isMatchGlobPattern() Dubbo 2.7.23

版本管理三阶段成熟度

级别 做法 灰度发布时
L1 混乱 version 随意写、有人不写 路由匹配全看运气,大概率失败
L2 规范 统一 X.Y.Z 格式,CI/CD 自动注入 精确匹配通过,灰度稳定
L3 预检 自动脚本比对 Provider version 与路由规则条件 上线前发现问题,而不是上线后

灰度上线前预检脚本

#!/bin/bash
# check-version-match.sh — 灰度前必检
SERVICE="com.example.UserService"
TARGET_VER="2.0.0"

# 查 Provider version 分布
VERSIONS=$(zkCli.sh ls /dubbo/$SERVICE/providers | grep -oP 'version=\K\S+(?=&|$)')

echo "=== Provider version 分布 ==="
echo "$VERSIONS" | sort | uniq -c | sort -rn

echo "=== 精确匹配 $TARGET_VER 的数量 ==="
MATCH=$(echo "$VERSIONS" | grep -c "^$TARGET_VER$")
echo "$MATCH"

if [ "$MATCH" -eq 0 ]; then
  echo "❌ 没有 Provider 匹配 version=$TARGET_VER,灰度无法工作!"
  exit 1
elif [ "$MATCH" -lt 3 ]; then
  echo "⚠️  只有 $MATCH 个 Provider 匹配,容量不足"
  exit 0
else
  echo "✅ $MATCH 个 Provider 匹配,灰度就绪"
fi

最终建议

一条简单规则:Consumer 和 Provider 的 version 保持完全一致。 "2.0""2.0.0""v2.0.0""2.0.0"。Dubbo 不做语义化版本比较——它也做不到,因为 version 可以是任何字符串,比如 "gray-20260705"

灰度前的每一次发布,花 30 秒跑一遍 grep version,把版本号分布列出来看一眼。版本号差一个字符,路由判不匹配——但这个"一个字符"的代价,可能是灰度失败后的 2 小时排查。

一个异常堆栈 = 一个类的某个方法出了问题。下次遇到灰度路由为空,先搜 Provider URL 中的 version 参数。

下篇我们聊聊 Dubbo 集群负载均衡策略选型不当导致的服务响应不均。