项目里的路由从几十条涨到几千条,请求匹配速度会变慢吗?这一篇直接读 gin 的路由树源码(tree.go,fork 自 julienschmidt/httprouter)搞清楚三件事:公共前缀是怎么被压缩进同一个节点的;节点自己维护的那个 indices 字符串是怎么做到"看一眼首字节就知道往哪个子节点走"的;还有一个容易被忽略的机制——priority 字段会让"挂载路由更多"的分支被排到更靠前的位置去优先尝试。另外用真实的 unsafe.Pointer + 反射技巧,把 gin.Engine 内部完全没有导出的路由树结构整棵挖出来打印了一遍。
延续 Go 标准库系列的方法论:本机真实拉取 gin-gonic/gin,pin 到本机 go.mod 解析出的真实版本,全部实验都是本机真实执行的 Go 程序,不是伪代码。
gin-gonic/[email protected](本机 go.mod 解析版本) · tree.go L99 type node struct { path string indices string wildChild bool nType nodeType priority uint32 children []*node handlers HandlersChain fullPath string }
path 存的是一段可能跨越多个字符的公共前缀,不是单个字符——这就是"压缩"前缀树(radix tree)和普通 trie 树的区别。indices 是一个字符串,第 i 个字符对应 children[i] 路径的第一个字节,查找时不需要遍历比较每个子节点的完整路径,只要拿请求路径当前位置的那个字节去 indices 里做一次线性(字符数很少,通常几个到十几个)扫描。priority 记录了"这个节点下面挂了多少条路由",tree.go 顶部注释明确写着这份代码 fork 自 julienschmidt/httprouter。
gin.Engine.trees 是未导出字段,正常代码访问不到。用 reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())) 在每一层都重新"解锁"只读标记——跟上一篇 sync 里读 Mutex 内部状态字是同一个思路的延伸。
r := gin.New()
r.GET("/api/v1/users", h)
r.GET("/api/v1/users/:id", h)
r.GET("/api/v1/users/:id/posts", h)
r.GET("/api/v1/posts", h)
r.GET("/api/v1/posts/:id", h)
r.GET("/static/*filepath", h)
path="/" type=root indices="as" priority=6 fullPath="/"
path="api/v1/" type=static indices="up" priority=5 fullPath="/api/v1/"
path="users" type=static indices="/" priority=3 handler=true fullPath="/api/v1/users"
path="/" wildChild=true fullPath="/api/v1/users/:id"
path=":id" type=param priority=2 handler=true fullPath="/api/v1/users/:id"
path="/posts" priority=1 handler=true fullPath="/api/v1/users/:id/posts"
path="posts" type=static indices="/" priority=2 handler=true fullPath="/api/v1/posts"
path="/" wildChild=true fullPath="/api/v1/posts/:id"
path=":id" type=param priority=1 handler=true fullPath="/api/v1/posts/:id"
path="static" type=static indices="/" priority=1 fullPath="/static/*filepath"
path="" type=catchAll wildChild=true fullPath="/static/*filepath"
path="/*filepath" type=catchAll priority=1 handler=true fullPath="/static/*filepath"
注册了 6 条路由,真实打印出来的树只有 12 个节点,而不是每个 URL 段一个节点。最直观的是 "api/v1/" 被压缩成了单独一个节点(而不是拆成 a→p→i→/→v→1→/ 七层)——因为这一整段前缀被 /api/v1/users、/api/v1/users/:id 等 5 条路由完全共享,直到 "users" 和 "posts" 才第一次出现分叉,这时 indices 才第一次变成两个字符 "up"。priority 也精确对得上:根节点是 6(全部路由都经过根),"api/v1/" 节点是 5(除了 /static/*filepath 之外的全部路由都经过它)。
r := gin.New()
r.GET("/user/:id", h)
r.GET("/user/:name", h) // 同一位置,参数名不一样
real panic: ':name' in new path '/user/:name' conflicts with existing wildcard ':id' in existing prefix '/user/:id'
r := gin.New()
r.GET("/dup", h)
r.GET("/dup", h) // 完全相同的路径注册两次
real panic: handlers are already registered for path '/dup'
r := gin.New()
r.GET("/user/:id", h)
r.GET("/user/:id/posts", h) // 同一个参数名,但在更深的位置 —— 这个不冲突
no panic: /user/:id and /user/:id/posts coexist fine
树的同一个位置只能有一种"分叉方式"——要么是一段确定的静态前缀,要么是唯一确定名字的参数节点,两者不能共存,同名参数在不同深度出现也完全没问题。这也是为什么"两个团队各自往同一个前缀底下加路由,一个用 :id 一个用 :userId"这种情况在真实项目里会直接把服务启动阶段炸掉,而不是运行时才发现路由匹配错了对象——panic 发生在 r.GET() 调用的那一刻,也就是启动阶段。
gin-gonic/[email protected](本机 go.mod 解析版本) · tree.go L110 // Increments priority of the given child and reorders if necessary func (n *node) incrementChildPrio(pos int) int { cs := n.children cs[pos].priority++ prio := cs[pos].priority // 找到新的位置(往前挪),边挪边交换 indices 和 children 的顺序 newPos := pos for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- { ... } return newPos }
r.GET("/a", h); r.GET("/b", h); r.GET("/c", h)
// root.indices = ?
for i := 1; i <= 5; i++ {
r.GET(fmt.Sprintf("/c/x%d", i), h) // 再往 /c/... 底下塞 5 条路由
}
// root.indices = ?
after /a /b /c (顺序注册): root.indices="abc" priorities=[1 1 1]
after 5 more routes under /c/...: root.indices="cab" priorities=[6 1 1]
一开始按 a、b、c 的顺序注册,indices 就是 "abc";往 /c 底下又塞了 5 条路由之后,c 这个分支的 priority 从 1 涨到 6,incrementChildPrio 把它往前挪到了第一位,indices 变成了 "cab"。效果是:请求量最大、挂载路由最多的那个分支,在匹配时会被优先尝试——这是一个静态的、基于"路由注册数量"的启发式优化,不是运行时基于真实请求频率动态调整的。
rSmall := gin.New(); rSmall.GET("/api/v1/users/:id", h) // 只有 1 条路由
rBig := gin.New(); rBig.GET("/api/v1/users/:id", h)
for i := 0; i < 3000; i++ { rBig.GET(fmt.Sprintf("/api/v1/%s/%d/detail", randPrefix(), i), h) } // 再塞 3000 条无关路由
// 各自查找同一个 "/api/v1/users/42" 20 万次
1 route registered: 675.7ns/lookup
3001 routes registered: 600.6ns/lookup
ratio: 0.89x
(第二次独立运行: 704.7ns vs 626.0ns, ratio: 0.89x —— 完全一致)
多注册 3000 条完全无关的路由之后,查找同一条已存在路由的耗时不但没有变慢,反而在测量误差范围内几乎一样(两次独立运行的比值都是 0.89x)。这正是压缩前缀树的核心价值:查找一条路径的成本只跟这条路径本身的深度/长度有关,跟路由表里总共注册了多少条路由无关——如果 Gin 用的是线性遍历所有路由做正则匹配这种朴素实现,3000 条无关路由必然会让每次查找显著变慢。
把上面几组真实实验按发生顺序串成一条演示。
全部数据来自本机真实 go run 的输出(tree_dump.go 的完整树结构、conflict.go 的真实 panic 消息、priority.go 的重排前后 indices、lookup_bench.go 的两次独立计时),Python 脚本重新解析了真实树结构文本,独立核对了每个非叶子节点的 priority 是否精确等于它下面挂载的真实路由数;并且用一份独立重写的 incrementChildPrio 前移模型,从 "abc" 推导出了 "cab",跟真实输出逐字符一致。