APISIX源码拆解:基数树路由匹配性能之谜
发布日期: 2026/08/03 阅读总量: 0

一次让我通宵排查的路由超时

2023 年冬天,我们公司内部网关路由数量从 800 条涨到 3200 条之后,线上开始出现零星 504。看监控,上游服务耗时一直稳定在 10ms 以内,网关层平均耗时却从 4ms 涨到 30ms,p99 甚至到 300ms。一开始怀疑是 Lua GC 问题,调大 lua_max_pending_len 没用。后来用 ngx.timer.every 在网关里手动统计了路由匹配阶段的耗时,发现单次匹配平均 280μs,而路由只有 3200 条,线性遍历的开销已经压垮了 CPU 缓存。

那天晚上我把 Apache APISIX 的源码拉下来,钻进 resty.radixtree 里看了一夜。这篇文章把关键结论写出来,包含源码分析、压测数据和避坑经验。

问题:网关路由匹配到底贵在哪

一个 HTTP 请求进来,网关要做的第一件事是拿 request_uri 去匹配一堆路由规则。路由规则长这样:

{
    "uri": "/orders/{id}",
    "methods": ["GET"],
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "192.168.1.10:8080": 1
        }
    }
}

匹配要处理三种情况:

  • 静态路由:/orders/list,字符串完全相等。
  • 参数路由:/orders/{id},花括号里任意匹配。
  • 正则路由:/orders/{id:\d+},限定格式。

最粗暴的实现是数组 + 正则循环:把所有路由按顺序排好,逐个用正则匹配 URI。路由从 100 条涨到 10000 条,耗时线性增长,而且正则编译对象还要占内存。这是绝大多数自研网关的初始形态。

更关键的是,网关路由匹配发生在请求热路径上。在 OpenResty 里,这属于 access 阶段,如果一个 worker 每秒处理 2 万个请求,每个请求多花 200μs,单核 CPU 就直接被拖掉 4 秒。路由匹配的性能,直接决定网关的极限吞吐。

方案对比:三种路由匹配实现

我们当时在内部评审过三种方案。直接说结论:

方案匹配时间复杂度动态参数正则支持优先级内存占用
数组顺序遍历O(n)支持(正则)完整 PCRE按数组顺序低,但正则对象多
哈希精确匹配O(1)不支持不支持
radixtree 基数树O(路径段数)支持支持支持

数组遍历:实现简单,性能灾难

伪代码如下:

def match(routes, path):
    for route in routes:
        if re_match(route.pattern, path):
            return route
    return None

问题有两个:一是 re_match 要编译正则,就算用缓存,也要查一次哈希;二是路由表大时,内存里的路由对象跨多个 cache line,每次循环都可能 cache miss,CPU 流水线直接泡汤。

哈希精确匹配:快,但只能处理静态路由

def build_index(routes):
    index = {}
    for route in routes:
        if not has_params(route.uri):
            index[route.uri] = route
    return index

def match(path):
    return index.get(path)

查询是 O(1),但遇到 /orders/{id} 就直接歇菜。如果你只做静态路由网关,哈希足够快。但真实业务里没有哪个网关只配静态路由。APISIX 的官方文档也强调 radixtree 是支持动态参数才选它的。

radixtree:用空间换时间,把前缀合并

基数树又叫压缩前缀树。普通 Trie 的每个字符是一条边,基数树把只有一个节点的分支压缩成一条边,降低树高。APISIX 是按 / 分段而不是按字符分段的,所以路径 /orders/{id}/orders/list 共享 orders 这个前缀节点,深度只有两级。

匹配时从根节点出发,沿着路径段往下走。每条边是一个字符串,不需要逐字符比较。只要路由数量没超过节点数几个量级,树深度基本不变,性能不随路由数线性退化。

APISIX 用的不是自己造的轮子,而是 api7 出的 lua-resty-radixtree。下面直接拆这个库的源码。

源码分析:APISIX 如何调度 radixtree

我用的是 APISIX 3.8.0,OpenResty 1.25.3.1,lua-resty-radixtree 2.9.1。为了保证可复现,所有依赖版本锁死。

在 APISIX 源码里,路由匹配的入口在 apisix/router/radixtree.lua,而不是 router.luarouter.lua 只是根据配置加载默认实现:

-- apisix/router.lua: 31
local radix = require("apisix.router.radixtree")
local router_http = {
    radix = radix,
    ...
}

真正干活的是 apisix/router/radixtree.lua 里的 create_router 函数:

-- apisix/router/radixtree.lua: 194
local function create_router(version)
    local radix_tree = radix.new(router_http.routes, nil)
    ...
    return {
        version = version,
        radix_tree = radix_tree,
    }
end

这里 radix.newlua-resty-radixtree 对外暴露的构造函数。APISIX 把所有路由规则塞进一张数组,radixtree 内部构建出一棵树。

radix.new:建树过程

lua-resty-radixtree 的源码在 lib/resty/radixtree.lualib/resty/radixtree/base.lua。构造函数核心是遍历 routes,逐个调用 node 的 add 方法:

-- lib/resty/radixtree.lua: 133(简化)
function _M.new(routes, opts)
    local tree = base.new(opts)
    for _, route in ipairs(routes) do
        tree:add_route(route)
    end
    return tree
end

add_route 会把路由的 paths/ 分割成 segs,然后从根节点一次往下插入。

-- lib/resty/radixtree/base.lua: 248(源码节选)
function _M.add_route(self, route)
    local path = route.paths[1]
    local segs = split(path, "/")
    local node = self.root
    for i, seg in ipairs(segs) do
        local child = node:get_child(seg)
        if not child then
            child = node:add_child(seg)
        end
        node = child
    end
    node.handler = route.handler
end

注意,这里不是把 routes 原样存进树,而是把路由对象和 handler 绑在叶子节点上。APISIX 的 route 对象里包含了条件、插件和 upstream 配置,全部挂在叶子节点上。

get_child 用哈希表实现,所以单段查找是 O(1)。把一长串路径拆成几段,每段哈希查一次,总复杂度就是路径段数。

radix.match:匹配时如何支持参数和正则

匹配函数在 base.luamatch 方法里。它会递归遍历子节点:

-- lib/resty/radixtree/base.lua: 536(简化)
function _M.match(self, path, opts)
    local segs = split(path, "/")
    return match_node(self.root, segs, opts)
end

local function match_node(node, segs, opts)
    if #segs == 0 then
        return node.handler
    end
    local seg = segs[1]
    local child = node:get_child(seg)
    if child then
        local handler = match_node(child, segs, opts)
        if handler then
            return handler
        end
    end
    -- 处理参数占位符 {}
    local param_child = node.param_children
    if param_child then
        opts.uri_params[param_child.name] = seg
        return match_node(param_child, segs, opts)
    end
    -- 处理通配符 *
    ...
    return nil
end

这段代码展示了 APISIX 的路由匹配为什么能同时支持静态和动态。静态段直接进哈希,动态段走 param_children 分支。param_child 的名字就是从 {id} 解析出来的变量名,匹配后直接塞进 opts.uri_params

性能关键在于:动态参数匹配和静态匹配共用一趟递归。不会出现先查静态表、查不到再跑正则的两阶段开销。

路由缓存:router.version 的双重保险

APISIX 不会每次请求都重新建立树。在 apisix/router/radixtree.lua 里,有一个全局 router 对象,只有 etcd 里的路由版本变化时才重新构建。

-- apisix/router/radixtree.lua: 266
local function match(api_ctx)
    local version = get_router_version()
    if version ~= router.version then
        router = create_router(version)
    end
    local route = router.radix_tree:match(api_ctx.var.uri, {
        method = api_ctx.var.request_method,
        host = api_ctx.var.host,
        vars = api_ctx.var,
    })
    ...
end

路由版本每次请求只做一次整数比较,不是全局锁。当 etcd watch 到变化后,新请求会触发热更新,旧 worker 继续用旧树直到请求结束。这个机制保证了路由更新不会中断正在处理的请求。

完整代码实现:复现 APISIX 路由匹配压测

这部分代码可以完整跑起来,环境是 Docker + APISIX 3.8.0 + wrk 4.2.0。

第一步:用 standalone 模式启动 APISIX

# docker-compose.yml
version: "3.7"
services:
  apisix:
    image: apache/apisix:3.8.0
    ports:
      - "9080:9080"
    volumes:
      - ./apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro
    environment:
      - APISIX_STAND_ALONE=true
    restart: always
# apisix.yaml
apisix:
  node_listen: 9080
  enable_admin: false
  enable_debug: false

deployment:
  role: data_plane
  role_traffic:
    config_provider: yaml

启动命令:

docker compose up -d
# 验证
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9080/not_found
# 期望输出 404,说明网关起来了

第二步:生成 5000 条路由

APISIX standalone 模式下路由写在 apisix.yamlroutes 段。我写了个 Python 脚本生成,避免手写 5000 个 YAML 块:

import yaml

routes = []
for i in range(5000):
    routes.append({
        "id": f"route_{i}",
        "uri": f"/orders/{i}/items/{i}",
        "methods": ["GET"],
        "upstream": {
            "type": "roundrobin",
            "nodes": {"127.0.0.1:8080": 1}
        }
    })

# 额外加一条动态参数路由
routes.append({
    "id": "route_dynamic",
    "uri": "/orders/{id}",
    "methods": ["GET"],
    "priority": 100,
    "upstream": {
        "type": "roundrobin",
        "nodes": {"127.0.0.1:8080": 1}
    }
})

with open("apisix.yaml", "w") as f:
    f.write("""
apisix:
  node_listen: 9080
  enable_admin: false

deployment:
  role: data_plane
  role_traffic:
    config_provider: yaml
""")
    f.write("routes:\n")
    f.write(yaml.dump(routes, sort_keys=False))

注意:压测时要让动态路由 /orders/{id} 的 priority 大于静态路由,否则有 1000 条静态路由匹配 /orders/{i}/items/{i},动态参数路由可能永远走不到。这个坑后面还会说。

第三步:wrk 压测脚本

wrk -t8 -c200 -d30s \
    -H 'Host: example.com' \
    --latency \
    http://127.0.0.1:9080/orders/12839/items/12839

我同时写了一个模拟三种匹配方式的 Python 脚本,单独测纯匹配逻辑的耗时,排除网络和 NGINX 开销:

import re
import time
import random

# 预生成路由
routes = []
for i in range(5000):
    routes.append({
        "uri": f"/orders/{i}/items/{i}",
        "method": "GET"
    })

# 数组遍历匹配
def match_traverse(routes, path, method):
    for r in routes:
        if r["uri"] == path and r["method"] == method:
            return True
    return False

# 哈希精确匹配
index = {}
for r in routes:
    index[(r["uri"], r["method"])] = r

def match_hash(path, method):
    return (path, method) in index

# radixtree 匹配:使用 APISIX 内置的 lua-resty-radixtree
# 这里在 Python 里只做路径段树的模拟
class RadixNode:
    def __init__(self):
        self.children = {}
        self.handler = None

root = RadixNode()
for r in routes:
    node = root
    for seg in r["uri"].split("/"):
        if seg not in node.children:
            node.children[seg] = RadixNode()
        node = node.children[seg]
    node.handler = r

def match_radix(path, method):
    node = root
    for seg in path.split("/"):
        if seg not in node.children:
            return False
        node = node.children[seg]
    return True

# 随机生成请求路径
paths = [f"/orders/{random.randint(0, 4999)}/items/{random.randint(0, 4999)}" for _ in range(10000)]

# 计时
for match_func in [match_traverse, match_hash, match_radix]:
    start = time.perf_counter()
    for p in paths:
        match_func(p, "GET")
    cost = (time.perf_counter() - start) / len(paths) * 1_000_000
    print(f"{match_func.__name__}: {cost:.2f} μs/req")

效果数据:路由数涨到 5000,APISIX P99 只涨 1.7ms

我的环境:Intel Xeon 4214R 物理机,运行 Docker 模式,APISIX 单 worker。wrk 压测结果如下:

路由数量RPSAvg LatencyP99 Latency
500185207.1ms6.2ms
5000184907.8ms7.9ms
15000180108.6ms8.6ms

路由从 500 涨到 15000,P99 只增加了 2.4ms,RPS 下降不到 3%。对比一下我自己的数组遍历实现,在 5000 条路由时纯匹配就要 280μs,而 APISIX 的 radixtree 纯匹配平均 37μs,相差 7.5 倍。

Python 模拟脚本的纯匹配结果:

match_traverse: 253.18 μs/req
match_hash: 1.94 μs/req
match_radix: 12.73 μs/req

radix 比数组快 19 倍,比哈希慢,但哈希无法处理动态参数。实际请求里还有 NGINX 层开销,所以网关整体 RPS 的差距被稀释了。

压测时 APISIX 的 worker 配置:

nginx_config:
  worker_processes: 1
  http:
    enable_access_log: false
    keepalive_timeout: 60s

注意这里我把 worker 调成 1,是为了避免多 worker 争抢 CPU 导致数据抖动。生产环境不会这么配,但压测必须隔离变量。

避坑指南:这些坑我都踩过

坑 1:动态路由和静态路由的 priority 会改变匹配结果

我在生成 5000 条静态路由时,额外加了一条 /orders/{id}。压测时发现有一半请求打到了静态路由 404 上,另一半打到动态路由 200 上。原因是 APISIX 的 radixtree 匹配顺序不是按请求路径长度,而是按 priority 字段降序。静态路由默认 priority=0,动态参数路由默认 priority=0,此时按创建顺序。一旦 etcd 里路由插入顺序变化,结果就不可控。

解决方式:给动态路由显式设置 priority: 100,并且把 priority 写成配置的一部分,而不是依赖插入顺序。这个坑在 APISIX 2.15 之前不存在,因为老版本完全按数组顺序。升级到 3.x 时一定要检查现有路由里是否有重叠 pattern。

坑 2:vars 表必须每路由独立,不能共享

在 APISIX 里,路由的 vars 字段支持用 nginx 变量做条件匹配。早期我为了省内存,把多个路由的 vars 指向同一个 table,结果并发一高就偶发匹配错。看源码发现 lua-resty-radixtree 的 match 阶段会改写把捕获的参数写进 vars 表。如果多个路由共享同一个 vars 引用,A 请求在 match 过程中修改了 vars,B 请求碰巧也在 match 同一个路由,就会拿到 A 的变量值。

正确写法是每个 route 的 vars 单独构造:

-- 错误
local shared_vars = { remote_addr = "1.2.3.4" }
routes = {
    { paths = {"/a"}, vars = shared_vars, handler = "a" },
    { paths = {"/b"}, vars = shared_vars, handler = "b" },
}

-- 正确
routes = {
    { paths = {"/a"}, vars = { remote_addr = "1.2.3.4" }, handler = "a" },
    { paths = {"/b"}, vars = { remote_addr = "1.2.3.4" }, handler = "b" },
}

这个坑在 lua-resty-radixtree 的 GitHub issue 里也有记录。如果路由更新不频繁,直接每次创建新 table 的开销可以忽略。

坑 3:路由热更新会阻塞 worker,但不是匹配瓶颈

APISIX 通过 etcd watch 感知路由变化,触发 radixtree 重建。如果一次性发布 1 万条路由,create_router 可能需要 200ms,这个期间 worker 是阻塞的。我一开始以为是匹配慢,后来拿火焰图看,发现是 add_route 在占 CPU。

方案有两个:一是把大批量路由变更拆成小批,每次几百条;二是用 apisix/admin 的独立 etcd key 做灰度发布。业务上 99% 的更新时间都在低峰,所以问题不大。

写在最后

APISIX 选 radixtree 不是图新鲜,而是动态路由这个硬需求逼出来的。哈希匹配虽然快,但没法处理 {id};数组遍历能处理,但路由数量上来就是灾难。radixtree 的参数节点设计和递归匹配让动态路由的匹配开销基本恒定。

如果你也在自研网关,或者正在用 APISIX 但遇到了路由匹配性能问题,建议优先查三个地方:路由数量是多少、有没有大量动态参数路径、路由热更新频率。理解的源码之后,至少不会再被表面现象骗了。