bigywater.tech
Gin · 6

节点优先级:为什么常用路由排前面

常有人说 Gin 的路由“把常用的路由排在前面”。这篇用逐条注册看 indices 怎么重排,再对 20 万次请求前后的整棵树做对比,最后量出排在第 26 位和第 1 位到底差多少纳秒,看这个优化究竟优化了什么。

abc → bac → cba → cab → acb
依次注册 11 条路由时根节点 indices 的变化
0 处变化
对 /z/hot 发 20 万次请求前后整棵树的差异
≈ 8 ns
第 26 位与第 1 位的查找耗时差(32.6 vs 24.3 ns)

冒泡的规则

实测 · 本机执行输出
  tree.go(Gin v1.12.0):
     111 | func (n *node) incrementChildPrio(pos int) int {
     112 |     cs := n.children
     113 |     cs[pos].priority++
     114 |     prio := cs[pos].priority
     115 | 
     116 |     // Adjust position (move to front)
     117 |     newPos := pos
     118 |     for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
     119 |         // Swap node positions
     120 |         cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
     121 |     }
     122 | 
     123 |     // Build new index char string
     124 |     if newPos != pos {
     125 |         n.indices = n.indices[:newPos] + // Unchanged prefix, might be empty
     126 |             n.indices[pos:pos+1] + // The index char we move
     127 |             n.indices[newPos:pos] + n.indices[pos+1:] // Rest without char at 'pos'
     128 |     }
     129 | 
     130 |     return newPos
     131 | }
     132 | 
     133 | // addRoute adds a node with the given handle to the path.
     134 | // Not concurrency-safe!
     135 | func (n *node) addRoute(path string, handlers HandlersChain) {

每注册一条经过某个子节点的路由,incrementChildPrio 先把它的 priority 加 1,再和前面的兄弟比:前一个兄弟的 priority 严格小于它就换位置,并同步改写 indices。这一步只在注册时发生。

逐条注册看 indices 的变化

实测 · 本机执行输出
  注册 /a/1   → "/a/1" [root prio=1 indices=""] handlers=1 full="/a/1"
  注册 /b/1   → "/" [root prio=2 indices="ab"]
  注册 /c/1   → "/" [root prio=3 indices="abc"]
  注册 /b/2   → "/" [root prio=4 indices="bac"]
  注册 /c/2   → "/" [root prio=5 indices="bca"]
  注册 /c/3   → "/" [root prio=6 indices="cba"]
  注册 /c/4   → "/" [root prio=7 indices="cba"]
  注册 /a/2   → "/" [root prio=8 indices="cba"]
  注册 /a/3   → "/" [root prio=9 indices="cab"]
  注册 /a/4   → "/" [root prio=10 indices="cab"]
  注册 /a/5   → "/" [root prio=11 indices="acb"]
  最终的树:
  "/" [root prio=11 indices="acb"]
    "a/" [static prio=5 indices="12345"]
      "1" [static prio=1 indices=""] handlers=1 full="/a/1"
      "2" [static prio=1 indices=""] handlers=1 full="/a/2"
      "3" [static prio=1 indices=""] handlers=1 full="/a/3"
      "4" [static prio=1 indices=""] handlers=1 full="/a/4"
      "5" [static prio=1 indices=""] handlers=1 full="/a/5"
    "c/" [static prio=4 indices="1234"]
      "1" [static prio=1 indices=""] handlers=1 full="/c/1"
      "2" [static prio=1 indices=""] handlers=1 full="/c/2"
      "3" [static prio=1 indices=""] handlers=1 full="/c/3"
      "4" [static prio=1 indices=""] handlers=1 full="/c/4"
    "b/" [static prio=2 indices="12"]
      "1" [static prio=1 indices=""] handlers=1 full="/b/1"
      "2" [static prio=1 indices=""] handlers=1 full="/b/2"

indices 记录的是兄弟节点按 priority 从大到小排列的首字节。注册 /b/2 让 b 的 2 条超过 a 的 1 条(abc → bac);注册 /c/3 让 c 的 3 条排到最前(cba);/a/3 让 a 的 3 条超过 b 的 2 条(cab);最后 /a/5 让 a 的 5 条超过 c 的 4 条(acb)。平局时(如 /c/4、/a/2 之后)顺序保持不变。

不是“访问次数”

实测 · 本机执行输出
  请求前,根节点和它的两个孩子:
    "/" [root prio=21 indices="az"]
      "a/" [static prio=20 indices="1023456789"]
      "z/hot" [static prio=1 indices=""] handlers=1 full="/z/hot"
  200000 次 GET /z/hot 之后整棵树( 23 个节点)与之前逐字节相同: true

文章标题里的“常用路由”容易误导:Gin 并不统计访问。priority 是子树里的路由条数,假设“路由多的分支更可能被访问”。实验里对 /z/hot 发了 20 万次请求,整棵树和请求前逐字节相同,z 仍然排在 a(20 条路由)后面。

排在前面能省多少

实测 · 本机执行输出
    树 A 的根: "/" [root prio=26 indices="abcdefghijklmnopqrstuvwxyz"]
    树 B 的根: "/" [root prio=35 indices="zabcdefghijklmnopqrstuvwxy"]
    第 1 轮
    A: GET /z/item0(z 在第 26 位)     中位 32.6 ns/op(最小 32.5 / 最大 32.7)
    B: GET /z/item0(z 在第 1 位)      中位 27.7 ns/op(最小 27.6 / 最大 27.9)
    A: GET /a/item0(a 在第 1 位)      中位 24.3 ns/op(最小 24.2 / 最大 24.5)
    B: GET /a/item0(a 在第 2 位)      中位 24.5 ns/op(最小 24.4 / 最大 24.6)
    第 2 轮
    A: GET /z/item0(z 在第 26 位)     中位 32.6 ns/op(最小 32.4 / 最大 33.2)
    B: GET /z/item0(z 在第 1 位)      中位 27.7 ns/op(最小 27.5 / 最大 27.8)
    A: GET /a/item0(a 在第 1 位)      中位 24.3 ns/op(最小 24.1 / 最大 24.3)
    B: GET /a/item0(a 在第 2 位)      中位 24.4 ns/op(最小 24.4 / 最大 24.5)

查找时用请求路径的下一个字节在 indices 里线性扫描。两棵形状相同的树,查同一条路由,它在 indices 里排第 26 位比排第 1 位多约 8 ns,折合每个兄弟 0.3 ns 左右。一次完整的 ServeHTTP 本身只有二三十纳秒,所以兄弟多的时候这个顺序才有一点意义;树 B 里 z 的数字(27.7 ns)比树 A 的 a(24.3 ns)高,是因为 z 子树里多了一层(item0…item9),不是顺序的问题。

分步演示

节点优先级
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。DumpTree 是源码副本里加的只读打印函数;基准用 testing.Benchmark,每个数字是 9 轮的中位数,整个实验跑了两轮,两轮的中位数相差不超过 0.1 ns;被测的是 Engine.ServeHTTP 加一个丢弃输出的 ResponseWriter,不含网络
    • 没有验证:真实业务路由表(几百条、带参数)里顺序对延迟的影响;更多兄弟节点(几百个)时线性扫描的耗时;不同 CPU 架构上的数字;为什么 Gin 选“路由条数”而不是真实访问量做优先级(只能从源码看出它这样做,设计动机我没有查证)
    京ICP备2026067502号-1京公网安备11010502063487号