基于压缩字典树RadixTree实现API路径匹配
很久没写更新了,非常惭愧,一点一点补吧。这次介绍一下年初写的一个API匹配功能
1.问题的引入
年初网关这块出于多方面的考虑,希望重构API网关的路由匹配实现,我们的API采用restful风格,在路径中存在参数,称之为API模板。在网关层,后端服务注册开放出去的API,要遵守API模板的约束。网关的一个主要职责,就是将请求path匹配到指定的API(模板)上,并将请求转发给注册了该API(模板)的后端服务。如果没有匹配到,则说明是一个无法识别的请求,并予以拒绝。
先来看下api模板长什么样子
/a/b/c
/a/b/{p1}/d
/a/b/{p1}/{p2}
RFC6570中约定了多种level的模板规范,这里我们的API规范采用了LEVEL1的模板规范,即只有大括号表达的模板参数,且大括号前后没有额外的常量串,前面只能是/,后面可以是/或结尾,所以上面三个示例都是合法的API模板。
以上面的模板为例,下面用一些请求示例,来说明API的匹配结果
/a/b - 不匹配
/a/b/c - 匹配第一条规则
/a/b/d - 不匹配
/a/b/d/d - 匹配第二条规则
/a/b/f/d - 匹配第二条规则
/a/b/f/e - 匹配第三条规则
/a/b/d/e/ - 不匹配
1.1.最多常量匹配
如果所有的API都是不带模板参数的常量API,那么基于字符串比较的匹配复杂度应该是O(nm),其中n为API数量,m为API平均字符串长度。但是麻烦在于这里有带有模板参数,比如下面的情况。
/a/b/{p}
/a/b/c
当请求/a/b/c到达时,从规则上来讲,两个API都是能匹配成功的,这种情况我们希望选中第二个API,即当请求匹配多个API时,选择常量部分匹配最多的API。这意味着,除非完整匹配到了全常量定义的API时,匹配可以立即结束,否则只要匹配到的API中带有参数,我们就需要继续进行匹配。
1.2.API歧义
这也引入了歧义的问题,我们再看下面的两个API
/a/b/{p}/d
/a/b/c/{p}
当请求/a/b/c/d到达时,这两个API都是能匹配成功的,这种情况下,我们期望匹配到第二个API,其语义是,尽可能多的在前缀方向匹配常量内容。
1.3.无法消除的歧义
有一种歧义是无法消除的,比如下面的示例
/user/{uid}
/user/{name}
我们期望在API向网关注册时解决此类问题,即从源头上避免将此类歧义问题,带入匹配运行时。
除了对单个API请求的匹配的需求外,还有一种场景,要求我们可以对所有的API进行遍历,我们期望在O(n)时间内完成所有API的遍历。
虽然需求略显啰嗦,但这基本是每一个API网关都可能面临的问题,总结一下我们的需求
- 实现带模板参数的API匹配
- 匹配多个API时选择常量吻合度最高的API
- 在前缀方向选择常量匹配尽可能多的API
- 避免无法消除的歧义进入运行时的匹配阶段
- 提供线性的访问方式
2.解决思路
为了解决上述的API匹配问题,我引入了字典树结构
2.1.压缩字典树数据结构
字典树是一种加速查找的方法,网上和算法书籍有很多介绍,不再赘述。
由于内部制定了一些API的命名规范,大部分API都会具有类似的前缀,所以传统字典树按字符推进查找的方式空间利用率较低,这里采用了路由实现中常用的压缩字典树。
举例来说
/a/b/c
/a/b/d
传统字典树的结构如下:
而压缩字典树结构如下:
可见压缩字典树可以降低树的高度,提高空间利用率和查找效率。
2.2.匹配逻辑设计
那么如何解决参数部分的匹配呢?
由于模板参数不是可直接比较的内容,它是一个占位符,对应实际请求path中的一个可变长部分。所以在字典树中,不能对模板参数进行分割,它需要作为一个独立的树节点加入到RadixTree,比如下面的例子

同一个位置可能会有多个带参的API,比如下面的情况

为了能优先匹配到尽可能多的常量部分,我们对树的子节点进行分组,一组是常量部分,一组是变量部分,比如节点/a/b/的两个子节点集合{c}、{{p1},{p2}},当匹配进行时,我们需要优先去常量集合进行匹配,当常量集合没有找到匹配时,再去变量集合匹配。这里任意一个变量子集都可能实现匹配,所以需要对变量集合所有子节点进行回溯,这也是带模板参数压缩字典树对普通常量字典树性能低的主要原因,子节点方向上计算量可能成倍的增长。
跨过了当前模板参数层的后续匹配规则跟之前是一样的,这里剩余的部分是/d和/{p3},还是尽量匹配常量/d,匹配不上再选择{p3}。
需要注意的一点是,API的结尾并不一定在叶子结点上,所以你需要在每个节点上标明当前节点是否是某个API的结尾,比如下面的例子
/a/b
/a/b/c
/a/b/d
这个树看起来是这样的

但/a/b也是一个API,所以这里需要在/a/b节点上标明
最后,为了满足线性访问的需要,我们还需要在每个API的结束顶点增加一个双向链表指针。
至此,这个数据结构已经满足我们的需求了,下面我们来设计实现它的方法
3.设计实现
3.1.基础数据结构
type node_type int
const (
NT_Root = iota + 1 // 根节点
NT_Static // 实例节点
NT_Variable // 参数节点
)
type tagdata struct {
fullpath string // API的完整路径
next *tagdata // 双向链表后向指针
prev *tagdata // 双向链表前向指针
custom interface{}// 只会出现在API末端节点的自定义数据
}
// trie树节点
type node struct {
tp node_type // 当前节点类型
instance []*node // 实例节点
variable []*node // 参数节点
path string // 当前节点路径
indices string // 子节点首字母表,只存储非参数子节点(instance)的首字母
tag *tagdata // 外挂数据
}
3.2.辅助方法
func min(a, b int) int {
if a <= b {
return a
}
return b
}
// 参数节点不计入公共前缀
func longestCommonPrefix(a, b string) int {
i := 0
max := min(len(a), len(b))
for i < max && a[i] == b[i] && a[i] != '{' && b[i] != '{' {
i++
}
return i
}
// 查找参数节点,获得参数节点值和之前、之后的节点路径
// 如果path中包含参数,比如/A/{param}/B
// variable - {param}
// i - 3
func findVariable(path string) (variable string, i int) {
// Find start
for start, c := range []byte(path) {
if c != '{' {
continue
}
for end, c := range []byte(path[start+1:]) {
if c == '}' {
return path[start : start+1+end+1], start
}
}
break
}
return "", -1
}
3.3.插入API
func (n *node) addRoute(path string, tag *tagdata) {
fullPath := path
// Empty tree
if n.path == "" && n.indices == "" {
n.insertChild(path, fullPath, tag)
n.tp = NT_Root
return
}
walk:
for {
// 找到待插入path与当前节点path的公共前缀,遇到参数节点会提前返回
i := longestCommonPrefix(path, n.path)
// 公共前缀长度小于当前节点n.path,裂变当前节点为父子节点
if i < len(n.path) {
child := node{
path: n.path[i:],
tp: NT_Static,
indices: n.indices,
instance: n.instance,
variable: n.variable,
tag: n.tag,
}
n.instance = []*node{&child}
n.variable = nil
n.indices = string([]byte{n.path[i]})
n.path = path[:i]
n.tag = nil
}
// 公共前缀长度小于剩余path,剩余部分
if i < len(path) {
path = path[i:]
idxc := path[0]
if idxc != '{' {
// 尝试查找同前缀n.instance子节点
for i, c := range []byte(n.indices) {
if c == idxc {
n = n.instance[i]
continue walk
}
}
// 没有同前缀instance子节点,创建新的子节点,并执行insertChild
n.indices += string([]byte{idxc})
child := &node{tp: NT_Static}
n.instance = append(n.instance, child)
n = child
} else {
// 尝试查找同名n.variable子节点
variable, _ := findVariable(path)
for _, vchild := range n.variable {
if variable == vchild.path {
path = path[len(variable):]
// 此时path要么是空串,要么是"/"开头的内容
if len(path) > 0 {
idxc = path[0]
for i, k := range []byte(vchild.indices) {
if k == idxc {
// 继续向下查找
n = vchild.instance[i]
continue walk
}
}
vchild.indices += string([]byte{idxc})
child := &node{tp: NT_Static}
vchild.instance = append(vchild.instance, child)
n = child
} else {
// 空串,到达末端
n = vchild
goto finish
}
continue walk
}
}
}
n.insertChild(path, fullPath, tag)
return
}
finish:
// n.path与path相等,当前n就是输入path的末端节点
td := &tagdata{
fullpath: fullPath,
next: tag.next,
}
if td.next != nil {
td.next.prev = td
}
tag.next, td.prev = td, tag
n.tag = td
return
}
}
// 在当前node下插入path
func (n *node) insertChild(path, fullPath string, tag *tagdata) {
for {
// 查找路径中的参数
wildcard, i := findVariable(path)
if i < 0 { // 路径中没有参数
break
}
// 解析参数
if i > 0 {
n.path = path[:i] // 保存前缀常量串到当前node
path = path[i:] // 取得path的其他部分
}
// 将参数部分构造一个新的子节点
child := &node{
tp: NT_Variable,
path: wildcard,
}
n.variable = append(n.variable, child)
n.tp = NT_Static
n = child
// 参数不是末尾,将当前节点修改为参数节点,并继续迭代
if len(wildcard) < len(path) {
path = path[len(wildcard):]
child := &node{
tp: NT_Static,
path: path,
}
// 之所以赋值给instance子节点slice,是因为规则中假定不会出现{PARAM1}{PARAM2}的情形
// 连续参数之间至少会有斜线分割,即{PARAM1}/{PARAM2},所以{PARAM1}下一个节点必然是instance
n.instance = append(n.instance, child)
n.indices += string([]byte{path[0]})
n = child
continue
}
// 到达path的末尾,结束
break
}
// 将路径与全路径赋值给末端n节点
n.path = path
td := &tagdata{
fullpath: fullPath,
next: tag.next,
}
if td.next != nil {
td.next.prev = td
}
tag.next, td.prev = td, tag
n.tag = td
}
3.4.查找API
3.4.1.自定义栈
为了避免递归函数调用,这里自定义了一个栈数据结构,用于实现非递归回溯
type stackNode struct {
n *node // 当前节点
i int // 子节点数组索引
p string // path,用于跟n.variable匹配
c bool // 是否instance匹配失败
}
// 获取stackNode.n.variable中的下一个node
func (sn *stackNode) next() *node {
if sn.i+1 < len(sn.n.variable) {
sn.i++
return sn.n.variable[sn.i]
}
return nil
}
type vstack []*stackNode
// 压栈
func (v *vstack) push(sn *stackNode) {
*v = append(*v, sn)
}
// 弹栈
func (v *vstack) pop() *stackNode {
sn := (*v)[len(*v)-1]
*v = (*v)[:len(*v)-1]
return sn
}
// 获取栈顶
func (v vstack) top() *stackNode {
if len(v) == 0 {
return nil
}
return v[len(v)-1]
}
3.4.2.匹配实现
func (n *node) match(path string, d debug) []string {
// 栈长度设置为20,大部分路径深度不会达到这个数量
var result []string
stack := vstack(make([]*stackNode, 0, 20))
fn := func(op int, path string) {
if d != nil {
d(op, path)
}
}
walk:
for {
prefix := n.path
var idxc byte
if n.tp == NT_Variable {
i := 0
// 当前节点是参数节点,跳过参数部分
for i < len(path) && path[i] != '/' {
i++
}
// 到达结尾且末端节点为合法路径,当前分支匹配结束
if i == len(path) && n.tag != nil {
result = append(result, n.tag.fullpath)
fn(OP_Backword, n.path)
goto loopback
}
// 截断path
path = path[i:]
if len(path) == 0 {
// path匹配在非末端参数节点提前结束,当前分支匹配结束
fn(OP_Backword, n.path)
goto loopback
}
idxc = path[0]
} else if len(path) > len(prefix) {
if path[:len(prefix)] == prefix {
// 前缀吻合当前节点
path = path[len(prefix):]
idxc = path[0]
} else {
// 当前路径匹配失败,需要回退
fn(OP_Backword, n.path)
goto loopback
}
} else if path == prefix {
// 剩余路径完全匹配,当前分支匹配结束
if n.tag != nil {
result = append(result, n.tag.fullpath)
fn(OP_Backword, n.path)
goto loopback
}
}
// 执行到这里,说明已经完成了当前节点的匹配,还需要在当前节点的子节点中继续匹配
// 尝试在instance列表中匹配
for i, c := range []byte(n.indices) {
if c == idxc {
// go deep
stack.push(&stackNode{
n: n,
i: 0,
p: path,
c: true,
})
fn(OP_Forward, n.path)
n = n.instance[i]
continue walk
}
}
// 尝试在variable列表中匹配
if len(n.variable) > 0 {
// 保存现场
stack.push(&stackNode{
n: n,
i: 0,
p: path,
})
fn(OP_Forward, n.path)
n = n.variable[0]
continue walk
}
// 当前路径匹配结束,回退
fn(OP_Backword, n.path)
loopback:
top := stack.top()
for top != nil {
// instance分支匹配失败,尝试variable分支
if top.c && len(top.n.variable) > 0 {
top.i = 0
top.c = false
n = top.n.variable[0]
continue walk
}
// variable下top.i分支匹配失败,尝试迭代top.i+1
if next := top.next(); next != nil {
n = next
path = top.p
continue walk
}
// top节点的variable已经完成遍历,出栈
stack.pop()
top = stack.top()
}
// stack为空,结束匹配
break
}
return result
}
3.5.删除API
由于我这里的实践中,采用的是异步定时全量更新策略,具体来说,完成新树构造后,对树根对象采用RCU策略实现运行时更新,所以对于运行时是无锁的。
如果要实现删除,需要注意以下几点:
- 当被删除API为非叶子节点时,不要真实删除节点,只更新其属性值即可
- 被删除的节点如果是叶子节点,需要在其父节点上移除该节点的索引
- 被删除的节点需要在双链表上脱链
- 并发访问的状态下,需要加锁
4.复杂度及性能
4.1.复杂度
压缩字典树的复杂度主要取决于其中API的路径长度和API的差异度,这决定了树的深度。当路径差异度较大时,它可能退化为传统的字典树。另外一个约束条件是模板参数,由于需要对带有模板参数的节点进行回溯,这个过程也会显著影响匹配的性能。所以有如下复杂度分析
- 对于传统字典树,每个节点在基于前缀进行查找,复杂度为常数1
- 我们假设在同一个树节点位置上平均有m个不同的模板参数,则每个节点上有m次回溯。
- 单个节点的匹配复杂度为1+m(单个子树匹配),匹配的节点数量在n次,其中n为API的长度或树的平均深度
实际复杂度为n+(m^n),即当不存在模板参数时,匹配的复杂度为n,即全常量压缩字典树。可见差异模板参数的数据起决定作用,约定良好的API规范可以有效提高匹配的性能
4.2.性能测试结果
测试思路是使用静态的API原始数据构造Radix树,然后使用线上环境的请求数据循环进行匹配查找,测试环境和数据如下
- API条目:4w+
- 线上请求数:30w+
- 测试运行环境:2.3GHz、L2 256KB、L3 4M、Mem 8G,单线程运行
测试结果:每个API匹配耗时0.5-5μm
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异