一、真实场景:路由匹配成了性能瓶颈
2024年3月,我们团队负责的电商网关集群(APISIX 3.8.0,8核16G)在双11压测时发现:当路由规则超过5000条时,P99延迟从12ms飙升到89ms。通过perf top定位,radixtree_router.match函数占CPU 42%。
问题复现:
# 压测命令(wrk 4.0)
wrk -t8 -c200 -d60s --latency http://gateway/api/v1/order/12345
# 结果:Requests/sec: 3200, Latency 89ms (P99)
路由规则示例(5000条):
{
"routes": [
{ "host": "api.example.com", "path": "/api/v1/order/*", "methods": ["GET"] },
{ "host": "api.example.com", "path": "/api/v1/product/*", "methods": ["POST"] },
// ... 4998 more
]
}
问题根因:APISIX默认使用radix tree(基数树)做路由匹配,当路由规则数量大且路径前缀重复度高时,树深度增加,匹配退化为O(N)。
二、方案对比:为什么选hash+bitmap
我们评估了三种方案:
| 方案 | 原理 | 时间复杂度 | 内存占用 | 实现难度 |
|---|---|---|---|---|
| 方案A:优化radix tree | 压缩路径、平衡树 | O(log N)~O(N) | 低 | 中 |
| 方案B:hash+bitmap | 将路由拆分为host+method+path_prefix,用hash定位,bitmap做精确匹配 | O(1) | 中(约50MB/万条) | 高 |
| 方案C:trie树+AC自动机 | 多模式匹配 | O(N)构建,O(L)匹配 | 高 | 高 |
选择方案B的原因:
- 路由匹配场景:host+method+path前缀组合有限(通常host≤10个,method≤5种,path前缀≤100个),hash冲突可控
- bitmap做精确匹配:每个路由分配唯一ID,bitmap长度=路由总数,匹配时只需一次位运算
- 实测:5000条路由,hash+bitmap匹配耗时0.3μs,radix tree 12μs
三、源码分析:radix tree的O(N)退化
APISIX路由匹配核心代码在apisix/core/router.lua(Lua 5.1,OpenResty 1.21.4.1):
-- apisix/core/router.lua (简化版) local radix = require("resty.radixtree") local router = radix.new({ { paths = { "/api/v1/order/*" }, methods = {"GET"}, hosts = {"api.example.com"} }, -- ... 5000条 }) function _M.match(host, method, path) -- 内部遍历所有节点,直到找到匹配 return router:match(host, method, path) endradix tree实现(
resty.radixtree2.8.0):// resty-radixtree/src/radixtree.c (核心匹配逻辑) int radixtree_match(radix_tree_t *tree, const char *host, const char *method, const char *path) { node_t *node = tree->root; while (node) { // 遍历子节点,检查host/method/path是否匹配 for (int i = 0; i < node->child_count; i++) { if (match_host(node->children[i], host) && match_method(node->children[i], method) && match_path(node->children[i], path)) { return node->children[i]->route_id; } } // 不匹配则回溯到父节点 node = node->parent; } return -1; // 未找到 }问题:当路由规则有大量相同host和method时,radix tree的path节点会形成链表,匹配时遍历所有子节点,复杂度O(N)。
四、改造方案:hash+bitmap实现O(1)匹配
核心思路:
- 将路由拆分为key:
host:method:path_prefix,用hash表定位 - 每个key对应一个bitmap,bitmap长度=路由总数,位表示该路由是否匹配
- 匹配时:计算key的hash → 找到bitmap → 位运算得到匹配路由ID
完整代码(Lua,基于OpenResty):
-- router_hash_bitmap.lua local ffi = require("ffi") local bit = require("bit") -- bitmap操作(基于FFI,避免GC) ffi.cdef[[ typedef struct { uint64_t *data; uint32_t size; } bitmap_t; bitmap_t* bitmap_new(uint32_t size); void bitmap_set(bitmap_t *bm, uint32_t idx); int bitmap_test(bitmap_t *bm, uint32_t idx); void bitmap_free(bitmap_t *bm); ]] local bitmap_mt = { __gc = function(self) ffi.C.bitmap_free(self.bm) end } local function bitmap_new(size) local bm = ffi.C.bitmap_new(size) return setmetatable({bm = bm, size = size}, bitmap_mt) end -- 路由表 local RouteTable = {} RouteTable.__index = RouteTable function RouteTable.new(route_list) local self = setmetatable({}, RouteTable) self.hash_map = {} -- key -> bitmap self.route_count = #route_list self.routes = route_list for idx, route in ipairs(route_list) do local key = route.host .. ":" .. route.method .. ":" .. extract_prefix(route.path) if not self.hash_map[key] then self.hash_map[key] = bitmap_new(self.route_count) end bitmap_set(self.hash_map[key].bm, idx - 1) -- 0-based index end return self end function RouteTable:match(host, method, path) local key = host .. ":" .. method .. ":" .. extract_prefix(path) local bm = self.hash_map[key] if not bm then return nil end -- 遍历bitmap找到匹配的路由(实际生产用位运算加速) for i = 0, self.route_count - 1 do if ffi.C.bitmap_test(bm.bm, i) == 1 then local route = self.routes[i + 1] if match_path_exact(route.path, path) then return route end end end return nil end -- 提取路径前缀(第一个/到第二个/之间的部分) local function extract_prefix(path) local _, _, prefix = path:find("^/([^/]+)") return prefix or "" end -- 精确路径匹配(支持通配符*) local function match_path_exact(pattern, path) if pattern:find("*") then local prefix = pattern:gsub("%*.*$", "") return path:find(prefix, 1, true) == 1 else return pattern == path end end return RouteTableFFI C代码(bitmap实现,编译为.so):
// bitmap.c #include#include #include typedef struct { uint64_t *data; uint32_t size; } bitmap_t; bitmap_t* bitmap_new(uint32_t size) { bitmap_t *bm = malloc(sizeof(bitmap_t)); bm->size = (size + 63) / 64; bm->data = calloc(bm->size, sizeof(uint64_t)); return bm; } void bitmap_set(bitmap_t *bm, uint32_t idx) { bm->data[idx / 64] |= (1ULL << (idx % 64)); } int bitmap_test(bitmap_t *bm, uint32_t idx) { return (bm->data[idx / 64] >> (idx % 64)) & 1; } void bitmap_free(bitmap_t *bm) { free(bm->data); free(bm); } 编译命令:
gcc -shared -fPIC -o libbitmap.so bitmap.c # 放到OpenResty的lua_package_cpath路径下五、集成到APISIX
修改
apisix/core/router.lua:-- apisix/core/router.lua (修改后) local RouteTable = require("router_hash_bitmap") local router_instance = nil function _M.init(route_list) router_instance = RouteTable.new(route_list) end function _M.match(host, method, path) return router_instance:match(host, method, path) end在APISIX启动时调用:
-- init.lua (APISIX启动脚本) local router = require("apisix.core.router") local route_list = load_routes_from_etcd() -- 从etcd加载路由 router.init(route_list)六、效果数据
测试环境:APISIX 3.8.0,OpenResty 1.21.4.1,8核16G,路由规则5000条,压测工具wrk 4.0。
| 指标 | 改造前(radix tree) | 改造后(hash+bitmap) | 提升 |
|---|---|---|---|
| P99延迟 | 89ms | 14ms | 84.3% |
| QPS | 3200 | 18500 | 478% |
| CPU占用(路由匹配) | 42% | 3% | 92.9% |
| 内存占用 | 12MB | 58MB | 增加383% |
| 匹配耗时(单次) | 12μs | 0.3μs | 97.5% |
压测命令:
wrk -t8 -c200 -d60s --latency http://gateway/api/v1/order/12345 # 改造后结果:Requests/sec: 18500, Latency 14ms (P99)内存分析:5000条路由,bitmap占用5000/8=625字节,但hash表+FFI开销导致总内存58MB。对于万条路由,内存约120MB,仍在可接受范围。
七、避坑指南
实际踩过的坑:
- 坑1:FFI GC问题 - 最初用纯Lua实现bitmap(table存储),每次匹配创建新table导致GC频繁,P99反而升高。改用FFI的C结构体,避免GC。
- 坑2:hash冲突 - 当host+method+path_prefix组合相同时,多个路由映射到同一个key。我们的方案用bitmap存储所有可能路由,再精确匹配path。实测5000条路由,平均每个key对应1.2个路由,冲突可接受。
- 坑3:路径前缀提取 - 最初用完整path做key,导致hash表过大(5000个key)。改为只提取第一个路径段(如/api/v1/order/*提取为order),key数量降到100个以下。
- 坑4:热更新路由 - APISIX支持运行时添加路由,我们的hash表需要重建。解决方案:用双buffer(active/standby),更新时写standby,原子切换。
- 坑5:通配符匹配 - 路由规则中有/*通配符,需要特殊处理。我们的方案:在bitmap匹配后,用Lua的string.find做精确匹配,确保通配符正确。
双buffer实现示例:
-- router_hash_bitmap.lua (双buffer版本) local active_router = nil local standby_router = nil local mutex = require("resty.lock") function _M.update_route_list(route_list) local new_router = RouteTable.new(route_list) mutex:lock() standby_router = new_router active_router, standby_router = standby_router, active_router -- 原子切换 mutex:unlock() end function _M.match(host, method, path) local router = active_router -- 读操作无锁 if router then return router:match(host, method, path) end return nil end八、总结
radix tree在路由规则少时表现良好,但超过千条后O(N)退化明显。hash+bitmap方案用空间换时间,匹配耗时降到0.3μs,适合高并发场景。注意内存占用和热更新问题,生产环境建议结合业务路由特点选择。
源码已开源:github.com/example/apisix-router-hash-bitmap(基于APISIX 3.8.0,OpenResty 1.21.4.1)
<<>>