APISIX路由冲突解决:vars负向匹配实践与踩坑记录 做 API 网关的人迟早会碰到路由冲突这个坑尤其是服务规模上来之后同一个 uri 前缀被多个业务方抢着用后配的路由经常“失灵”。我这两年用 APISIX 比较多vars 条件匹配是我解决这类问题最常用的手段尤其是负向匹配几乎每个精准确分流场景都离不开它。这篇就当是把自己的实践笔记整理出来从路由冲突的成因开始把 vars 的匹配逻辑、负向匹配的写法以及我实际踩过的坑一次讲清楚。1. 路由冲突是怎么来的先看懂 APISIX 的路由匹配顺序1.1 默认只有 uri 匹配时冲突几乎是必然的APISIX 最基础的路由写法长这样{ uri: /api/*, upstream: { type: roundrobin, nodes: { 10.0.0.1:8080: 1 } } }这种写法意味着“所有以 /api/ 开头的请求都转发到 10.0.0.1:8080”。如果整个网关只有这一条路由那没有任何问题。但现实是网关要服务的往往是多业务线、多版本、多环境大家都在用/api/*这种大前缀。这时候冲突就来了两条路由都匹配同一个请求网关到底听谁的APISIX 在这个问题上的行为是“按优先级选一条而不是合并”。它会先收集所有匹配请求的路由按 priority 字段从高到低排序再按创建时间从早到晚排序然后选第一条。这听起来很简单但实际部署时经常出幺蛾子。最常见的情况是有一条老的/api/*兜底路由priority 是默认的 0新加了一条/api/order/*priority 也是默认 0你满心以为更具体的路径会优先命中但实际测试发现请求全走了老路由。原因就是 APISIX 在 uri 匹配阶段并不遵循“最长前缀优先”它只看“匹配了就进入候选集合”然后再按顺序挑一个。于是新路由永远排不到第一位。1.2 添加了 vars 之后匹配逻辑变成了“uri 条件”双因子要打破这种冲突最直接的办法是让每条路由具备“排他性”的匹配条件。APISIX 的 vars 字段就是干这个的。它允许你在 uri 之外再加一组条件只有 uri 和 vars 同时满足才算这条路由命中。举个例子{ uri: /api/*, vars: [ [arg_version, , v2] ], upstream_id: order-v2 }这条路由只有请求携带?versionv2才会命中。这样一来即使它和其它/api/*路由的优先级相同也不会被老路由抢走因为老路由根本没有这个条件两者在候选集合里不会同时出现。1.3 典型冲突场景什么时候必须上 vars我整理了几个自己实际遇到过的场景供参考冲突场景表面现象本质原因新旧版本路由共存新版本接口始终不生效老路由 /api/* 优先级更高且无版本条件内部接口被公网规则匹配内部管理接口走到了对外服务uri 前缀重叠缺少来源或路径排除条件多租户同路径不同服务租户 A 的请求跑到租户 B仅靠 uri 无法区分租户标识灰度发布时流量不准确灰度比例形同虚设未在路由层按参数/请求头切分流量这些场景的共同点在于单靠 uri 字符串表达的信息不够了必须依赖请求参数、请求头、客户端 IP 等更多维度来划分请求归属。而 vars 正好提供了这些维度的条件匹配能力。2. vars 条件匹配的基本用法与操作符2.1 vars 的核心结构Lua 表数组和 AND 语义第一次看 APISIX 配置的人容易被 vars 的嵌套结构绕晕。其实它就是一个二维数组每个内层数组是三个元素[变量名称, 操作符, 预期值]多个内层数组并列时它们之间是“并且”的关系也就是所有条件都必须成立。这一点非常关键也是最容易踩坑的地方vars 里没有“或”的写法想表达“参数 a 等于 x 或 y”得拆成两条路由或者换用正则匹配。实际配置示例{ uri: /api/*, vars: [ [arg_version, , v1], [http_x_from, ~~, ^internal] ] }这个意思是请求必须是/api/前缀同时带上versionv1参数并且请求头X-From以internal开头三条同时满足才命中。变量名字段里的arg_前缀对应 query 参数http_前缀对应请求头remote_addr对应客户端 IPuri对应当前路径。这些其实都是 Nginx 变量的那套命名风格APISIX 把它直接沿用了下来所以熟悉 Nginx 的人上手会非常快。2.2 支持的操作符对照表vars 里不是只能用等号APISIX 支持的操作符比很多人以为的要多我列一个常用对照表操作符含义使用示例说明等于[arg_version, , v1]精确匹配字符串按字面值比较~不等于[arg_version, ~, v1]排除某个精确值大于[arg_age, , 18]数值比较或字典序比较看类型小于[arg_age, , 60]同上大于等于[arg_age, , 18]比较行为同小于等于[arg_age, , 60]比较行为同~~正则匹配[uri, ~~, ^/api/v[0-9]]使用正则表达式注意转义!~~正则不匹配[uri, !~~, ^/api/internal]负向匹配的核心操作符2.3 类型比较和缺失参数最容易搞混的细节使用、这类比较符时很多人默认按数字比较但实际行为取决于变量的取值。比如arg_age从 query 里拿到的永远是字符串100 9在纯字符串比较下是成立的因为字符1小于字符9。这个坑在年龄、版本号这类数字字符串上特别容易爆发。我的建议是如果要做数值范围判断最好在业务代码里先统一格式比如固定补零v001、v010或者直接用正则匹配数字范围别依赖、的隐式类型转换。缺失参数也是一个隐蔽问题。请求来自 cURL“缺少 version 参数”的 curl 请求[arg_version, , ]很可能是成立的因为未定义变量在比较时会被当作空字符串处理。这导致有些人写“参数缺失时走 A 路由”的判断结果把“参数为空字符串”的请求也带进来了。相比之下负向匹配会更稳后面细说。3. 负向匹配实践把“排除”写进路由规则3.1 负向匹配的实现方式~ 与 !~~“负向匹配”这个词听起来高大上本质上就是取反不匹配某个值、不匹配某个正则。具体到 APISIX vars有两个操作符承担这个角色~简单的值不相等适合排除某个确定的参数取值!~~正则取反适合排除一类路径、一类来源 IP、一类请求头。为什么这种写法在分流场景非常重要因为很多路由规则不是“哪些请求要”而是“哪些请求不要”。比如所有/api/*都走到默认服务但/api/internal/*必须单独处理所有上传请求都走 A 服务但来自内网 IP 的上传请求需要走 B 服务所有带X-Debug: true的请求都走调试服务其余正常走正式服务。这些场景用正向匹配写起来非常啰嗦你要是把“所有路径”都一一列出来配置能写出一本书来。负向匹配可以在一行里表达“排除某一部分”其余全部命中。3.2 实例一排除内部接口的精准分流假设网关下有一个老路由{ uri: /api/*, upstream_id: default-service }现在需要新增一个内部接口/api/internal/status它不能走旧逻辑要转发到专门的运维服务。如果只加一条新路由{ uri: /api/internal/status, upstream_id: ops-service }两条路由的优先级如果相同按之前说的规则默认路由很可能会先命中。因为 APISIX 的 uri 匹配是“匹配即候选”并不是“更长更优先”。这里有两种解法第一种是给新路由设一个比默认路由更高的 priority但这会埋下隐患以后每加一条新路由都要记得跟旧的默认路由比优先级维护成本越来越高。第二种就是负向匹配直接在默认路由上把内部接口排除掉{ uri: /api/*, vars: [ [uri, !~~, ^/api/internal] ], upstream_id: default-service }这样默认路由在 uri 匹配阶段之外又加了一层“uri 不能以 /api/internal 开头”的限制内部接口的请求就不能命中这条路由了。新路由还是管/api/internal/status两者互不干扰。3.3 实例二按参数版本分流加上兜底路由另一个典型场景是版本分流。三个路由逻辑分别是参数versionv1→ 老服务参数versionv2→ 新服务其余情况 → 默认服务。前两条用正向匹配就够了{ uri: /api/*, vars: [[arg_version, , v1]], upstream_id: old-service }{ uri: /api/*, vars: [[arg_version, , v2]], upstream_id: new-service }问题在“其余情况”这条兜底路由上。用[arg_version, ~, v1]只能排除 v1v2 还是会命中用[arg_version, , ]又只匹配缺失参数碰到 v3、v4 就失效。这里我用的是负向正则统一排除{ uri: /api/*, vars: [ [arg_version, !~~, ^(v1|v2)$] ], upstream_id: default-service }这样无论参数是 v3、unknown 还是完全缺失都会被兜底路由接住。负向匹配的价值在这里体现得最明显它把“未知集合”全部兜住而不是死板地列举所有允许值。4. 从冲突到分流一个完整案例的落地过程4.1 场景描述与需求拆解我挑一个之前真实处理过的案例来完整走一遍。场景是这样的有一个老平台所有请求都走/api/*上游是legacy-upstream。现在新业务上线了要求满足下面几点请求携带X-Channel: mobile的所有接口走新的移动端服务mobile-upstream请求路径是/api/admin/*的无论什么渠道都走管理后台服务admin-upstream其它所有请求维持原样继续走legacy-upstream。如果只用 uri 配置这个需求几乎无解因为/api/*把一切都吞进去了。用 priority 硬压也会很痛苦。我最终的方案是三条路由靠 vars 完成精确切分。4.2 使用 Admin API 落地路由配置首先创建管理后台路由它优先级最高而且路径特征最明确curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/admin-route \ -H X-API-KEY: admin-key \ -d { uri: /api/admin/*, upstream_id: admin-upstream, priority: 100 }这里把 priority 显式设为 100是为了避免未来新增路由时被默认 0 的规则干扰。虽然我们后面也用了负向匹配但管理后台毕竟是敏感路径双保险更稳妥。接着创建移动端路由curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/mobile-route \ -H X-API-KEY: admin-key \ -d { uri: /api/*, vars: [ [http_x_channel, , mobile] ], upstream_id: mobile-upstream }这里有个细节X-Channel请求头在 vars 里对应的变量名是http_x_channel统一转成小写中间的连字符变成下划线。这是 Nginx 变量名的固定规则很多人第一次写会按原始头名来配结果匹配不上。最后处理老路由必须把管理后台和移动端请求都排除掉curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/legacy-route \ -H X-API-KEY: admin-key \ -d { uri: /api/*, vars: [ [uri, !~~, ^/api/admin], [http_x_channel, !~~, ^mobile$] ], upstream_id: legacy-upstream }注意两点。第一uri变量在 vars 里也可用不需要额外开关直接写就行。第二[http_x_channel, !~~, ^mobile$]这个条件表达的是“请求头不是 mobile”理论上也可以写成~ mobile。但写成正则的好处是如果未来渠道名变成mobile-android、mobile-ios我只要改一下正则的半边就行不用动结构。用~的话只能死等mobile一个字面值。4.3 验证阶段用一组请求验证分流结果配置完路由之后我习惯按以下顺序做验证每一步都能明确告诉我“哪条规则没生效”第一组验证管理后台curl -i http://127.0.0.1:9080/api/admin/users \ -H X-Channel: mobile期望结果打到admin-upstream。如果这条失败多半是 admin 路由的 uri 前缀写错了或者老路由的负向匹配表达式写成了^/api/admin/漏掉了恰好等于/api/admin的请求。第二组验证移动端curl -i http://127.0.0.1:9080/api/order/list \ -H X-Channel: mobile期望结果打到mobile-upstream。如果这条打到老服务检查 admin 路由是否也用uri: /api/admin/*抢先匹配了如果没抢那大概率是http_x_channel变量名写错。第三组验证老路由剩余流量curl -i http://127.0.0.1:9080/api/order/list不带X-Channel头期望结果打到legacy-upstream。这条正常的话说明负向匹配把移动端请求成功过滤掉了。我在这个案例里体会最深的是负向匹配最怕的是“正则没写全”。比如[uri, !~~, ^/api/admin]它只排除以 admin 开头的路径像/api/administrator这种也会被误伤。要更精确得加边界符[uri, !~~, ^/api/admin(/|$)]这样只有/api/admin本身和/api/admin/后面还跟路径的请求会被排除/api/administrator不会再受影响。5. 实操中踩过的坑与排查技巧5.1 常见问题速查表我在长期使用 vars 的过程中总结了一份问题清单新同学照着排能省很多时间现象可能原因解决办法带请求头的请求匹配不上请求头变量名写错确认X-Foo对应http_x_foo全小写下划线负向正则把正常请求排除了正则边界不严谨补上路径结束符或参数结束符多条路由同时命中且顺序不对priority 未设置或设置不合理给特殊路由设更高 priorityvars 里多个条件“或”关系不生效误以为数组内是 ORvars 内是 AND需拆路由或改正则数值比较结果不对字符串隐式类型转换统一格式或改用正则范围匹配修改路由后没生效Admin API 缓存或 etcd 同步延迟稍等后重试或检查 etcd 健康状态正则里点号没转义匹配范围扩大需要匹配字面量点时写\\.5.2 正则转义看到的字符不等于正则表达式的字符vars 里的~~和!~~走的是标准正则我见过太多人在小数点、斜杠上翻车。举个例子[http_x_version, ~~, ^v1.0$]这个正则在匹配v1.0时是能过的因为.在正则里代表“任意一个字符”所以v1a0、v1X0全都会被匹配上。如果你只想匹配字面量版本号v1.0必须写成[http_x_version, ~~, ^v1\\.0$]这个规则不仅适用于 vars只要是在正则表达式里使用我都会下意识检查一遍有没有需要转义的特殊字符。还有一个低头回踩的坑在 Admin API 的 JSON 配置里写反斜杠JSON 层面还要再转义一层。比如实际想要\\.传到正则引擎JSON 里得写\\\\.。所以很多看起来“莫名其妙的匹配失败”其实是配置里的反斜杠在 JSON 解析中就少了一层。5.3 调试思路先看匹配结果再查路由顺序遇到“路由不生效”的情况我的排查顺序是固定的。先在 APISIX 中用当时出问题的请求做一次“模拟匹配”curl -s http://127.0.0.1:9180/apisix/admin/routes \ -H X-API-KEY: admin-key把现有路由全量捞出来手动数一下候选路由有哪些。这一步能判断“是不是路由冲突导致被抢先命中”。确定存在多条候选路由后检查所有候选路由的 priority 字段。如果都一样再按创建时间推断谁排在前面。APISIX 可能不会直接告诉你是哪一条最终匹配但你可以用负向匹配临时加上排除条件把不该命中的路由排开让目标路由变成唯一候选。我经常这么干把目标路由临时加一个独一无二的请求头条件比如[http_x_tag, , debug]请求时带上X-Tag: debug如果流量目标正确说明路由本身没问题问题出在优先级或条件冲突。另外APISIX 的访问日志里会记录匹配到的 route_id。如果日志配置合理你直接在日志里看 route_id 是哪条再回头找 config会比瞎猜快得多。我强烈建议在测试环境把 access_log 的 route_id 字段打开。5.4 负向匹配的维护成本正则越简单越好负向匹配确实好用但我也得提醒一句它写起来是一行正则维护起来可能要命。尤其当规则不断迭代出现“排除所有 /api/v1但保留 /api/v1/internal同时 /api/v1/internal/health 又不排除”这种需求时一条负向正则内就会演变成长难句。我的建议优先拆路由而不是在一个 vars 里堆超长正则每个排除条件独立成一个条件数组元素比如[uri, !~~, ^/api/internal]和[uri, !~~, ^/api/private]分开写可读性远好于写进同一个正则定期用正则测试工具把配置里的正则拿出来测一遍别只在 APISIX 里试错。还有一点是关于args参数的。vars 里的arg_xxx只匹配 query 里的单个参数如果请求是 POST 并且参数在 body 里arg_系列变量是不生效的。需要匹配 body 内容时通常得配合插件或改造上游逻辑vars 本身解决不了。这一点在排查“为什么参数明明传了但匹配不上”时经常被发现提前知道能省不少时间。几个个人体会我在实际落地 vars 和负向匹配的过程中最大的感受是APISIX 把路由匹配从“路径字符串相等”升级成了“多维条件求值”这是 API 网关精细化运营的基础。以前遇到冲突只能靠调 priority 硬碰硬现在用 vars 把每条路由的边界划清楚规则之间不再互相打架。如果你刚开始改造我建议从最简单的负向匹配入手找到你手上那条最大的兜底路由给它的 uri 后面加上一两个!~~条件把你不想让它处理的请求排出去。这个动作风险最小、收益最直接做几次之后你就自然理解 vars 的表达能力了。另外一个值得养成的习惯是所有路由声明式配置都放进 Git 管理用 APISIX 的 Admin API 同步配置时把 vars 的变更也写进 commit message。路由规则这种东西当时写的人都觉得自己很懂三个月后再回看没有历史记录基本等于失忆。最后补一个平时容易忽略的细节vars 里的变量名大小写、下划线、连字符一不小心就会写错。写完之后直接 curl 一个带实际请求的测试比盯着 JSON 配置看半天都有效。这套东西不难但足够细节化慢工出细活排错时耐心一点路由规则最终会变得非常稳定。