<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Ember Moth</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://www.embermoth.blog/</id>
  <link href="https://www.embermoth.blog/" rel="alternate"/>
  <link href="https://www.embermoth.blog/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Ember Moth</rights>
  <subtitle>为世界上所有的美好而战!</subtitle>
  <title>Ember Moth's Blog</title>
  <updated>2026-09-27T07:00:00.000Z</updated>
  <entry>
    <author>
      <name>Ember Moth</name>
    </author>
    <category term="开发笔记" scheme="https://www.embermoth.blog/categories/%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0/"/>
    <category term="golang" scheme="https://www.embermoth.blog/tags/golang/"/>
    <category term="performance" scheme="https://www.embermoth.blog/tags/performance/"/>
    <category term="zero-allocation" scheme="https://www.embermoth.blog/tags/zero-allocation/"/>
    <category term="gc" scheme="https://www.embermoth.blog/tags/gc/"/>
    <category term="sync-pool" scheme="https://www.embermoth.blog/tags/sync-pool/"/>
    <category term="backpressure" scheme="https://www.embermoth.blog/tags/backpressure/"/>
    <category term="proxy" scheme="https://www.embermoth.blog/tags/proxy/"/>
    <category term="benchmark" scheme="https://www.embermoth.blog/tags/benchmark/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong> — 代理节点端的热路径是”每秒几十万次的小操作”：封一帧、加密一块、排队一个上传分片、发一个 UDP 报文。这类路径上每次操作多一次堆分配，单看 ns&#x2F;op 几乎没有变化，但会以 GC assist、堆扫描和尾延迟抖动的形式在高负载下集体兑现。这篇记录一轮把 SMux、VLESS Encryption、XHTTP、AnyTLS、Hysteria2 等热路径改到 0 allocs&#x2F;op 的工作：先定六条原则，再用五个可复用的模式落地，每个模式都配 wire 格式不变的 round-trip 测试与前后基准。最后一节讲零分配买不到的东西：当 profile 里排第一的是 <code>write</code> syscall 时，该做的是写合并，而我们否决了两种看起来更聪明的源侧方案。</p></blockquote><hr> <span id="more"></span><h2 id="一、为什么盯着-allocs-op-而不是-ns-op"><a href="#一、为什么盯着-allocs-op-而不是-ns-op" class="headerlink" title="一、为什么盯着 allocs&#x2F;op 而不是 ns&#x2F;op"></a>一、为什么盯着 allocs&#x2F;op 而不是 ns&#x2F;op</h2><p>先看这轮改造的结果表，注意时间列和分配列的反差：</p><table><thead><tr><th>路径</th><th align="right">优化前</th><th align="right">优化后</th><th>说明</th></tr></thead><tbody><tr><td>SMux 写帧等待与缓冲</td><td align="right">4.16 µs，160 B，3 allocs</td><td align="right">4.04 µs，0 B，0 allocs</td><td>时间约降 3%，收益在 GC</td></tr><tr><td>VLESS Encryption 8 KiB 写</td><td align="right">5.94 µs，24 B，1 alloc</td><td align="right">5.95 µs，0 B，0 allocs</td><td>加密 CPU 不变，去掉包装分配</td></tr><tr><td>XHTTP 1 MiB 上传队列</td><td align="right">16.1 µs，1 alloc</td><td align="right">13.6 µs，0 allocs</td><td>时间约降 15%</td></tr><tr><td>AnyTLS 256 KiB 写</td><td align="right">3.64 µs，320 B，5 allocs</td><td align="right">3.20 µs，0 B，0 allocs</td><td>5 次底层 write，时间约降 12%</td></tr><tr><td>Hysteria2 1200 B 封包</td><td align="right">无可比基线</td><td align="right">22.9 ns，0 B，0 allocs</td><td>只测封包</td></tr></tbody></table><p>时间几乎不动，分配全部归零。如果只看微基准的 ns&#x2F;op，这轮工作看起来不值得做。它的价值在别处：</p><ul><li><strong>GC assist</strong>。分配速率越高，用户 goroutine 被拉去帮 GC 干活的比例越高，这部分时间不在任何单个函数的 profile 里，散落在所有路径上。</li><li><strong>堆扫描</strong>。每个存活的小对象都是标记阶段要访问的一个节点。热路径上的临时对象生命周期短，但在高并发下”同时存活”的数量不小。</li><li><strong>尾延迟</strong>。GC 触发时机与负载相关，P99 抖动的相当一部分来自这里。</li></ul><p>所以这轮改造的度量口径是：先把 allocs&#x2F;op 打到 0，再看 ns&#x2F;op 有没有顺带改善。表里的数字来自同一台机器、内存或 discard writer、7 次取中位数，不包含真实网络、TLS、QUIC 拥塞控制和客户端开销。它们不能相加成”整体提升百分比”，最终必须用端到端压测看 CPU、RSS、吞吐和 P99。</p><h2 id="二、六条原则"><a href="#二、六条原则" class="headerlink" title="二、六条原则"></a>二、六条原则</h2><p>动手前先把规矩写下来，后面每个改动都要能对上其中一条：</p><ol><li><strong>wire 格式不因性能优化改变</strong>。手写的封包必须有”编码后再解码”的 round-trip 测试；涉及外部实现的协议还要有互操作测试。</li><li><strong>复用 backing array 和堆上常驻的 slice holder</strong>，不把 <code>[]byte</code> 直接装进 <code>any</code>，那会产生一次 slice header 的 boxing 分配。</li><li><strong>池化对象必须有明确的唯一所有者</strong>，或者像写入等待器那样显式记录双方完成状态。取消、超时和关闭路径必须能归还已授予的容量，且不能重复回收。</li><li><strong>背压直接授予真正能继续执行的等待者</strong>，不用关闭共享 channel 唤醒全部协程。</li><li><strong>分帧、分片和等待队列用迭代实现</strong>，不按数据量递归。缓冲区放对象池或堆上，goroutine 栈只保存 slice header、指针和固定大小状态。</li><li><strong><code>sync.Pool</code> 是降低稳态分配的手段，不是容量上限</strong>。运行时可以在 GC 时清空池；真正的内存边界由队列、单包大小、共享预算和协议限制提供。</li></ol><p>下面五个模式分别是原则 2、3、4、5 和”所有权接口”的落地。</p><h2 id="三、模式一：不要把-byte-放进-any"><a href="#三、模式一：不要把-byte-放进-any" class="headerlink" title="三、模式一：不要把 []byte 放进 any"></a>三、模式一：不要把 <code>[]byte</code> 放进 <code>any</code></h2><p>VLESS Encryption 的写路径每次要一块 <code>5 + 8192 + 16</code> 字节的输出缓冲。最直接的写法是 <code>sync.Pool</code> 存 <code>[]byte</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> outBytesPool = sync.Pool&#123;New: <span class="function"><span class="keyword">func</span><span class="params">()</span></span> any &#123; <span class="keyword">return</span> <span class="built_in">make</span>([]<span class="type">byte</span>, <span class="number">5</span>+<span class="number">8192</span>+<span class="number">16</span>) &#125;&#125;</span><br><span class="line"></span><br><span class="line">out := outBytesPool.Get().([]<span class="type">byte</span>)</span><br><span class="line"><span class="keyword">defer</span> outBytesPool.Put(out)   <span class="comment">// 这一行每次分配 24 字节</span></span><br></pre></td></tr></table></figure><p><code>sync.Pool</code> 的接口是 <code>any</code>。<code>Put(out)</code> 要把一个 24 字节的 slice header 装箱成接口值，这个 header 必须放在堆上，于是每次加密写多一次分配。基准里那个 “24 B，1 alloc&#x2F;op” 就是它。</p><p>修法是池里放<strong>指向 slice 的指针</strong>，让 header 常驻堆上被复用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> outBytesHolderPool = sync.Pool&#123;New: <span class="function"><span class="keyword">func</span><span class="params">()</span></span> any &#123;</span><br><span class="line">    buffer := <span class="built_in">make</span>([]<span class="type">byte</span>, <span class="number">5</span>+<span class="number">8192</span>+<span class="number">16</span>)</span><br><span class="line">    <span class="keyword">return</span> &amp;buffer</span><br><span class="line">&#125;&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(c *CommonConn)</span></span> Write(b []<span class="type">byte</span>) (<span class="type">int</span>, <span class="type">error</span>) &#123;</span><br><span class="line">    outBytesPtr := outBytesHolderPool.Get().(*[]<span class="type">byte</span>)</span><br><span class="line">    outBytes := *outBytesPtr</span><br><span class="line">    <span class="keyword">defer</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">        <span class="comment">// 复用 Get 返回的堆上 header；直接 Put(outBytes) 会装箱一个新 header</span></span><br><span class="line">        *outBytesPtr = outBytes</span><br><span class="line">        outBytesHolderPool.Put(outBytesPtr)</span><br><span class="line">    &#125;()</span><br><span class="line">    <span class="comment">// ... 按 8 KiB 分块加密写出</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>对外暴露的池类型保持原来的动态类型不变，record 格式不变，只是内部换了持有方式。结果：24 B &#x2F; 1 alloc 到 0 &#x2F; 0，时间持平。</p><p>这个模式的适用面很广：任何”池里存切片”的地方都值得检查一遍 Put 的参数类型。</p><h2 id="四、模式二：池化对象的双方完成协议"><a href="#四、模式二：池化对象的双方完成协议" class="headerlink" title="四、模式二：池化对象的双方完成协议"></a>四、模式二：池化对象的双方完成协议</h2><p>SMux 的写请求会进入一个整形器（shaper）排队，由发送循环异步写出。调用方等结果时可能超时、连接可能关闭。问题来了：等待器对象是池化的，<strong>谁来回收它</strong>？</p><ul><li>调用方超时返回后回收：发送循环稍后还要往里写结果，写到已被别人复用的对象上。</li><li>发送循环写完结果后回收：调用方可能还没读，读到已被复用的对象。</li></ul><p>两边都不能单独决定。解法是一个原子状态字，双方各置一位，<strong>最后完成的一方回收</strong>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> (</span><br><span class="line">    writeWaiterCallerDone <span class="type">uint32</span> = <span class="number">1</span> &lt;&lt; <span class="literal">iota</span></span><br><span class="line">    writeWaiterSenderDone</span><br><span class="line">    writeWaiterDone = writeWaiterCallerDone | writeWaiterSenderDone</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> writeWaiter <span class="keyword">struct</span> &#123;</span><br><span class="line">    result <span class="keyword">chan</span> writeResult   <span class="comment">// 容量 1，随对象一起复用</span></span><br><span class="line">    state  atomic.Uint32</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(waiter *writeWaiter)</span></span> release(flag <span class="type">uint32</span>) &#123;</span><br><span class="line">    <span class="keyword">if</span> waiter.state.Or(flag)|flag != writeWaiterDone &#123;</span><br><span class="line">        <span class="keyword">return</span>                <span class="comment">// 对方还没完成，由对方回收</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">select</span> &#123;                  <span class="comment">// 调用方超时离场时，结果可能还留在 channel 里</span></span><br><span class="line">    <span class="keyword">case</span> &lt;-waiter.result:</span><br><span class="line">    <span class="keyword">default</span>:</span><br><span class="line">    &#125;</span><br><span class="line">    waiter.state.Store(<span class="number">0</span>)</span><br><span class="line">    writeWaiterPool.Put(waiter)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>发送循环写完结果后 <code>release(SenderDone)</code>；调用方无论是正常读到结果、超时、还是连接关闭，都 <code>release(CallerDone)</code>。channel 是容量 1 的，随对象一起进池，下一帧不再分配。还有一条快路径：请求根本没进入整形器就失败时，调用方是唯一持有者，直接重置回收。</p><p>接收侧配套改了一处：接收环直接持有 <code>[]byte</code>，不再为每帧包一层 <code>*[]byte</code>。整形器的稠密槽位保持复用。三处合起来，写帧路径从 160 B &#x2F; 3 allocs 到 0。</p><p>这个模式解决的是一类通用问题：<strong>两个异步方共享一个池化对象，任何一方都可能先退出</strong>。原则 3 说的”显式记录双方完成状态”就是它。</p><h2 id="五、模式三：背压直接授予，不广播"><a href="#五、模式三：背压直接授予，不广播" class="headerlink" title="五、模式三：背压直接授予，不广播"></a>五、模式三：背压直接授予，不广播</h2><p>XHTTP 的上传侧要限制排队字节数。多个 HTTP handler 同时等容量时，旧做法是一个 generation channel：释放容量就关闭它，所有阻塞的 handler 一起醒来，重新抢锁、重新检查、大部分再次睡回去。上传并发高的时候，这是一场惊群。</p><p>新做法是<strong>一个侵入式双向链表</strong>记录等待者，释放容量时在锁内遍历链表，只把容量授予真正装得下的等待者，并且只唤醒它们：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> reservationWaiter <span class="keyword">struct</span> &#123;</span><br><span class="line">    queue    *uploadQueue</span><br><span class="line">    sequence <span class="type">uint64</span></span><br><span class="line">    size     <span class="type">int64</span></span><br><span class="line">    ready    <span class="keyword">chan</span> <span class="keyword">struct</span>&#123;&#125;     <span class="comment">// 容量 1，随对象进池</span></span><br><span class="line">    granted  <span class="type">bool</span></span><br><span class="line">    prev, next *reservationWaiter</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(group *reservationGroup)</span></span> wakeLocked() &#123;</span><br><span class="line">    <span class="keyword">for</span> waiter := group.head; waiter != <span class="literal">nil</span>; &#123;</span><br><span class="line">        next := waiter.next</span><br><span class="line">        <span class="keyword">if</span> waiter.queue.reserveLocked(waiter.size, waiter.queue.isHead(waiter.sequence)) &#123;</span><br><span class="line">            group.removeLocked(waiter)</span><br><span class="line">            waiter.granted = <span class="literal">true</span></span><br><span class="line">            waiter.ready &lt;- <span class="keyword">struct</span>&#123;&#125;&#123;&#125;</span><br><span class="line">        &#125;</span><br><span class="line">        waiter = next</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>真正难的是取消。等待者在 <code>ctx</code> 取消、队列关闭或停顿超时后要离场，而此刻可能刚好有一次释放把容量授予了它。两条路径在同一把锁下会合：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(queue *uploadQueue)</span></span> cancelReservationWaiter(waiter *reservationWaiter) &#123;</span><br><span class="line">    group.mu.Lock()</span><br><span class="line">    <span class="keyword">if</span> waiter.granted &#123;</span><br><span class="line">        queue.releaseReservationLocked(waiter.size)   <span class="comment">// 已授予的容量还回去</span></span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        group.removeLocked(waiter)                    <span class="comment">// 还在排队，摘链</span></span><br><span class="line">    &#125;</span><br><span class="line">    group.wakeLocked()                                <span class="comment">// 让出的容量继续调度别人</span></span><br><span class="line">    group.mu.Unlock()</span><br><span class="line">    recycleReservationWaiter(waiter)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>granted</code> 标志在锁内读写，所以”取消”与”授予”不可能同时成立而互不知情。等待者对象池化，<code>ready</code> channel 随对象复用，回收时先排空。</p><p>代价要写清楚：释放容量时最坏要扫完整个等待链表，O(W)。在异常大的等待集合下会增加锁持有时间和尾延迟，但它是循环不是递归，不会增加调用栈深度。顺序上传的快路径还有一个细节：不对局部的上传分片结构体取会逃逸的地址，否则编译器会把它挪到堆上，零分配就破了。</p><p>结果：1 MiB 上传队列从 16.1 µs &#x2F; 1 alloc 到 13.6 µs &#x2F; 0 allocs。时间下降 15% 的来源不是少了那一次分配，是少了惊群。</p><h2 id="六、模式四：分帧与分片迭代化"><a href="#六、模式四：分帧与分片迭代化" class="headerlink" title="六、模式四：分帧与分片迭代化"></a>六、模式四：分帧与分片迭代化</h2><p>两个协议路径原来都有”先构建完整中间结构、再逐个发送”的形状。</p><p><strong>AnyTLS</strong> 的数据帧载荷上限是 65535 字节。大写入要拆帧，上游某个版本的做法是把完整载荷先拷进一个可能很大的临时缓冲。改成在一把序列化锁下逐帧编码、写出、释放：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">err := s.withSerializedWrite(deadline, <span class="number">0</span>, <span class="function"><span class="keyword">func</span><span class="params">()</span></span> <span class="type">error</span> &#123;</span><br><span class="line">    <span class="keyword">for</span> <span class="built_in">len</span>(data) &gt; <span class="number">0</span> &#123;</span><br><span class="line">        chunkSize := min(<span class="built_in">len</span>(data), maxFramePayload)</span><br><span class="line">        buffer := encodeWireFrame(cmdPSH, sid, data[:chunkSize])   <span class="comment">// 池化字节切片</span></span><br><span class="line">        _, writeErr := s.writeConnLocked(buffer)</span><br><span class="line">        releaseFrameBuffer(buffer)</span><br><span class="line">        <span class="keyword">if</span> writeErr != <span class="literal">nil</span> &#123;</span><br><span class="line">            <span class="keyword">return</span> writeErr</span><br><span class="line">        &#125;</span><br><span class="line">        data = data[chunkSize:]</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>数据帧、控制帧和 padding 合并都直接用池化字节切片编码，不再为每帧创建一个 buffer 对象的 header。锁保证一次 <code>Stream.Write</code> 产生的所有帧保持调用方顺序。wire header 和 padding 语义不变。256 KiB 写从 320 B &#x2F; 5 allocs 到 0，时间降 12%。</p><p><strong>Hysteria2</strong> 的 UDP 超过 MTU 要分片。原来先构建一个 fragment 切片，每片再包一个 buffer view；现在逐片创建、发送、释放：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">fragmentCount := (<span class="built_in">len</span>(payload) + udpMTU - <span class="number">1</span>) / udpMTU</span><br><span class="line"><span class="keyword">if</span> fragmentCount &lt;= <span class="number">1</span> || fragmentCount &gt; math.MaxUint8 &#123;</span><br><span class="line">    <span class="keyword">return</span> c.writePacket(message)      <span class="comment">// 8 位 wire 字段装不下就走原有的过大错误路径，不递归重试</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">for</span> index, offset := <span class="number">0</span>, <span class="number">0</span>; offset &lt; <span class="built_in">len</span>(payload); index, offset = index+<span class="number">1</span>, offset+udpMTU &#123;</span><br><span class="line">    fragment := allocMessage()          <span class="comment">// 池化</span></span><br><span class="line">    *fragment = *message</span><br><span class="line">    fragment.fragmentData = payload[offset:min(offset+udpMTU, <span class="built_in">len</span>(payload))]</span><br><span class="line">    fragment.fragmentID, fragment.fragmentTotal = <span class="type">uint8</span>(index), <span class="type">uint8</span>(fragmentCount)</span><br><span class="line">    err := c.writePacket(fragment)</span><br><span class="line">    fragment.release()</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> err</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>同一模式套到 Hysteria 与 TUIC 上。三个协议都有 pack 到 decode 的 round-trip 测试，TUIC 覆盖 IPv4、IPv6、域名和空目标。</p><p>原则 5 在这里的含义很具体：分片数由数据量决定，如果用递归表达”剩余部分继续分”，栈深就随数据量增长；用循环，栈上只有几个整数。</p><h2 id="七、模式五：所有权接口代替拷贝"><a href="#七、模式五：所有权接口代替拷贝" class="headerlink" title="七、模式五：所有权接口代替拷贝"></a>七、模式五：所有权接口代替拷贝</h2><p>MTProxy 的记录层与加密层原来每条记录分配一次 <code>pending</code> 切片再做一次中间复制。改造后接入带所有权语义的读写接口：</p><ul><li><code>ReadBuffer</code> 把 TLS record 的载荷直接读进调用方的 buffer，未读完的长度和部分 header 留在连接状态里，不分配中间切片。</li><li><code>WriteBuffer</code> 接管 buffer 所有权，原地加密；有 5 字节 headroom 时直接写记录头，否则用最多 32 KiB 的池化批次。成功或失败都由本层消费或释放，交给下一层后不重复释放。</li><li>向量化写保持连续的 CTR 状态与批次顺序，错误时未发送的 buffer 也释放。</li><li>底层同时返回数据和错误时，先交付数据、下次读再返回错误，否则拷贝引擎会丢掉最后一段。</li></ul><p>结果：512 B 与 32 KiB 的写从 1 alloc 到 0，512 B 与 16 KiB 的读从 2 allocs 到 0。</p><p>入站侧用同样思路复用现成基础设施：SOCKS5 与 HTTP CONNECT 的握手预读数据转成受所有权管理的缓存，成功回复后允许公共拷贝引擎解开包装直接用底层连接；UDP 从队列取出池化 buffer 直接交给数据面，只在出站需要更多 headroom 时才复制；HTTP 上游 <code>Transport</code> 从每请求新建改为每入站复用。这一组改动没有带来吞吐的显著变化，只是每往返少一到两次分配，如实记录。</p><h2 id="八、sync-Pool-不是容量上限"><a href="#八、sync-Pool-不是容量上限" class="headerlink" title="八、sync.Pool 不是容量上限"></a>八、<code>sync.Pool</code> 不是容量上限</h2><p>零分配改造最容易滋生的错觉是”有了池就有了边界”。没有。运行时可以在任意一次 GC 时清空池，池只决定稳态下分配多少，不决定最坏情况下内存有多少。表里 XHTTP 那行”少量 B&#x2F;op 来自池被清空后的均摊补充”就是在提醒这件事。</p><p>真正的边界另外给：</p><ul><li>每条 UDP 会话占一个 goroutine、一个上游 socket、一条 NAT 表项，而 NAT 的全局 LRU 是所有用户共享的。于是按用户限制并发 UDP 会话，默认 1024，超出以明确错误拒绝，TCP 不计入。一个用户喷射目标地址只能耗尽自己的配额，挤不掉别人。</li><li>UDP 重组器有独立的最大包数、最大缓冲字节数和最大分片数。</li><li>上传队列有字节预算，请求超过单包上限直接拒绝，而不是排队。</li></ul><p>原则 6 的另一半是：这些边界要和池分开设计。池管快，预算管稳，混在一起两边都做不好。</p><h2 id="九、零分配买不到的东西"><a href="#九、零分配买不到的东西" class="headerlink" title="九、零分配买不到的东西"></a>九、零分配买不到的东西</h2><p>分配归零之后再看 profile，排第一的变成了 <code>net.(*netFD).Write</code>：Hysteria2 上传累计 21.7%，VLESS 加 TLS 上传累计 33.9%。这是 syscall 与拷贝进内核的成本，与分配无关。</p><h3 id="先量化天花板"><a href="#先量化天花板" class="headerlink" title="先量化天花板"></a>先量化天花板</h3><p>向 loopback 写同样总量，只改每次写的大小：</p><table><thead><tr><th align="right">每次写</th><th align="right">CPU&#x2F;字节</th><th align="right">相对 16 KiB</th></tr></thead><tbody><tr><td align="right">1350 B</td><td align="right">0.525 ns</td><td align="right">+62%</td></tr><tr><td align="right">4 KiB</td><td align="right">0.427 ns</td><td align="right">+32%</td></tr><tr><td align="right">16 KiB</td><td align="right">0.324 ns</td><td align="right">基准</td></tr><tr><td align="right">32 KiB</td><td align="right">0.291 ns</td><td align="right">−10%</td></tr><tr><td align="right">256 KiB</td><td align="right">0.282 ns</td><td align="right">−13%</td></tr></tbody></table><p>从 1.3 KiB 到 16 KiB 能省约 38% 的写 CPU，之后曲线走平。换算出每次写的固定开销约 330 ns，其余是把数据拷进内核。所以合并写的上限是明确的：只能回收固定开销那部分。</p><h3 id="两种被否决的源侧方案"><a href="#两种被否决的源侧方案" class="headerlink" title="两种被否决的源侧方案"></a>两种被否决的源侧方案</h3><p><strong>合并读装饰器</strong>：首次读之后把读超时设成”已过期”，非阻塞地把已排队的数据继续读进同一个缓冲，再恢复超时。加计数器实测 180 万次读、探测 0 次命中 0 次：转发 goroutine 永远快于 QUIC 连接的生产者，任何时刻队列里只有一个数据报，<strong>源端根本没有可合并的积压</strong>。更致命的是正确性：裸 TCP、TLS、QUIC 流在读超时后可恢复，带帧的读取器不行，WebSocket 场景直接建连失败、吞吐为零。带帧的源恰恰是合并唯一可能获益的一类，也正是不能用这个技术的一类。</p><p><strong>放开 MTU 钳制</strong>：让流式拷贝用更大的读缓冲。实测每核吞吐反而下降 8.3%。</p><h3 id="采用的写侧方案"><a href="#采用的写侧方案" class="headerlink" title="采用的写侧方案"></a>采用的写侧方案</h3><p>写侧合并不碰源、不设读超时，用显式等待换 CPU：累积到目标尺寸、窗口到期或流结束三者先到即刷出。三条约束：</p><ul><li>默认关闭时不包装目标，数据路径与改动前完全一致，有测试钉住。</li><li>只有已传输超过 512 KB 的流才算批量流，请求响应式流量永远走直路，不会被窗口延迟。</li><li>限速用户不合并，令牌桶已经在为那条流定速。</li></ul><p>Hysteria2 上传开 2 ms 窗口：吞吐从 0.458 到 0.623 Gbit&#x2F;s，每核效率从 0.318 到 0.395，分别 +35.9% 和 +24.3%。增幅大于成本曲线的预测，因为合并同时消掉了连接 goroutine 与转发 goroutine 之间的唤醒乒乓。1000 连接定速场景下积压 0.01 MiB，远低于判定阈值。</p><p>这一节想说的是：<strong>零分配是热路径优化的第一层，不是最后一层</strong>。分配归零后 profile 会诚实地告诉你下一个瓶颈在哪，而下一个瓶颈往往需要完全不同的手段，并且每个手段都要先量化上限、再测、再决定。</p><h2 id="十、怎么保证协议没被改坏"><a href="#十、怎么保证协议没被改坏" class="headerlink" title="十、怎么保证协议没被改坏"></a>十、怎么保证协议没被改坏</h2><p>这轮改动的每一项都动了封包路径，验证分四层：</p><ol><li><strong>round-trip 测试</strong>：每个手写编码器都有”编码后解码回来逐字段相等”的用例，覆盖边界尺寸、空载荷、最大分片数。</li><li><strong>互操作测试</strong>：涉及外部实现的协议，用真实客户端或参考实现对打。</li><li><strong>分配回归</strong>：基准以 <code>-benchmem</code> 运行，<code>allocs/op</code> 进入对比表；池被清空导致的均摊分配单独注明，不混进”回归”。</li><li><strong>端到端矩阵</strong>：微基准之外跑协议压力矩阵，看 CPU、RSS、吞吐与 P99 有没有系统性变化。写合并的每核吞吐数字来自这里，不来自微基准。</li></ol><p>以及一条纪律：基准表里的每个数字都写明机器、版本、样本数和”不包含什么”。上面那张表不包含真实网络、TLS、QUIC 拥塞控制和客户端开销，所以它只能用于同机回归，不能用于宣传。</p><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>回头看，五个模式其实是同一个问题的五个切面：</p><blockquote><p><strong>热路径上的每一次分配，都是一次没想清楚所有权的结果。</strong></p></blockquote><p>slice 被装箱，是因为没想清楚 header 归谁持有；等待器不能回收，是因为没想清楚两个异步方谁最后离场；惊群，是因为把”谁能继续”的判断推给了被唤醒者自己；递归分片，是因为让数据量决定了栈的形状；中间拷贝，是因为接口没有表达”这块内存现在归你”。把所有权说清楚，分配自然就没了。</p><p>然后 profile 会告诉你，下一个瓶颈是 syscall。那是另一篇文章的事，但决策方法是一样的：先量上限，再测方案，把否决的也写下来。</p>]]>
    </content>
    <id>https://www.embermoth.blog/2026/09/27/proxy-node-hot-path-zero-allocation/</id>
    <link href="https://www.embermoth.blog/2026/09/27/proxy-node-hot-path-zero-allocation/"/>
    <published>2026-09-27T07:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong> — 代理节点端的热路径是”每秒几十万次的小操作”：封一帧、加密一块、排队一个上传分片、发一个 UDP 报文。这类路径上每次操作多一次堆分配，单看 ns&#x2F;op 几乎没有变化，但会以 GC assist、堆扫描和尾延迟抖动的形式在高负载下集体兑现。这篇记录一轮把 SMux、VLESS Encryption、XHTTP、AnyTLS、Hysteria2 等热路径改到 0 allocs&#x2F;op 的工作：先定六条原则，再用五个可复用的模式落地，每个模式都配 wire 格式不变的 round-trip 测试与前后基准。最后一节讲零分配买不到的东西：当 profile 里排第一的是 <code>write</code> syscall 时，该做的是写合并，而我们否决了两种看起来更聪明的源侧方案。</p>
</blockquote>
<hr>]]>
    </summary>
    <title>代理节点端热路径的零分配改造：原则、五个模式与实测</title>
    <updated>2026-09-27T07:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ember Moth</name>
    </author>
    <category term="开发笔记" scheme="https://www.embermoth.blog/categories/%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0/"/>
    <category term="performance" scheme="https://www.embermoth.blog/tags/performance/"/>
    <category term="proxy" scheme="https://www.embermoth.blog/tags/proxy/"/>
    <category term="routing" scheme="https://www.embermoth.blog/tags/routing/"/>
    <category term="index" scheme="https://www.embermoth.blog/tags/index/"/>
    <category term="algorithm" scheme="https://www.embermoth.blog/tags/algorithm/"/>
    <category term="data-structure" scheme="https://www.embermoth.blog/tags/data-structure/"/>
    <category term="hash-table" scheme="https://www.embermoth.blog/tags/hash-table/"/>
    <category term="aho-corasick" scheme="https://www.embermoth.blog/tags/aho-corasick/"/>
    <category term="trie" scheme="https://www.embermoth.blog/tags/trie/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong> — 代理软件的路由规则是一张有序表：逐条检查，第一条命中的规则决定出口。这个语义天然导致”规则越多、未命中越慢”，因为一个不命中任何规则的请求要把整张表扫完，而带地理类别的规则每条都是几万个域名或网段。这篇文章记录一次不改变任何语义的重新设计：利用”最早规则赢”对匹配维度可分配这一性质，把所有规则的同类选择器合并进三个静态索引（冻结哈希表、一个 Aho-Corasick 自动机、一棵前缀树），每个索引只回答”本维度最早命中的规则是哪一条”，跨维度取最小即得全局答案。结果是未命中延迟从微秒级降到几十纳秒，并且不再随规则数增长；等价性由与朴素线性实现的百万次差分保证。</p></blockquote><hr> <span id="more"></span><h2 id="一、问题：规则越多，未命中越慢"><a href="#一、问题：规则越多，未命中越慢" class="headerlink" title="一、问题：规则越多，未命中越慢"></a>一、问题：规则越多，未命中越慢</h2><p>先把规则模型说清楚。一份路由配置是一个有序列表，每条规则有一个出口目标和若干选择器：</p><table><thead><tr><th>选择器</th><th>含义</th></tr></thead><tbody><tr><td>full</td><td>域名完全相等</td></tr><tr><td>suffix</td><td>域名以某后缀结尾，按标签边界对齐</td></tr><tr><td>keyword</td><td>域名包含某子串</td></tr><tr><td>regex</td><td>域名匹配正则</td></tr><tr><td>cidr</td><td>目标地址落在某网段</td></tr><tr><td>域名类别</td><td>一组预置的 full &#x2F; suffix &#x2F; keyword 条目，通常几千到几十万条</td></tr><tr><td>网段类别</td><td>一组预置的 cidr，按国家或运营商划分</td></tr></tbody></table><p>语义只有一条：<strong>按规则顺序，第一条有任意选择器命中的规则决定出口；都不命中则走默认。</strong></p><p>最直接的实现就是照着语义写：外层遍历规则，内层遍历选择器。类别选择器会被展开成一张哈希表或一棵树，所以单条规则的匹配是常数时间；但规则之间没有共享结构，N 条带类别的规则就是 N 次独立查表。命中靠前的规则时很快，问题出在<strong>未命中</strong>：一个不该走任何特殊出口的请求，必须把所有规则试完。</p><p>我们在一个 32 条规则的配置上测过这条路径的代价：</p><table><thead><tr><th>路径</th><th align="right">8 条规则</th><th align="right">32 条规则</th></tr></thead><tbody><tr><td>域名类别，命中 &#x2F; 未命中</td><td align="right">110 ns &#x2F; 901 ns</td><td align="right">109 ns &#x2F; 3500 ns</td></tr><tr><td>网段类别，命中 &#x2F; 未命中</td><td align="right">60 ns &#x2F; 95 ns</td><td align="right">62 ns &#x2F; 310 ns</td></tr><tr><td>关键字，命中 &#x2F; 未命中</td><td align="right">94 ns &#x2F; 417 ns</td><td align="right">94 ns &#x2F; 1565 ns</td></tr></tbody></table><p>命中列几乎不变，未命中列随规则数线性增长。而在真实流量里，大部分请求恰恰是”未命中任何特殊规则、走默认出口”的那一类。</p><p>内存侧也有同样形状的问题：每条规则各自持有自己的类别表，同一个域名在多条规则里被多次索引；表结构含指针，堆越大垃圾回收扫描越久。</p><h2 id="二、核心观察：最早规则赢是可分配的"><a href="#二、核心观察：最早规则赢是可分配的" class="headerlink" title="二、核心观察：最早规则赢是可分配的"></a>二、核心观察：最早规则赢是可分配的</h2><p>把语义写成式子。记 q 为一次请求，<code>match_d(rule, q)</code> 表示规则 rule 在维度 d（full&#x2F;suffix、keyword、regex、cidr）上命中 q。全局答案是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">answer(q) = min &#123; rule : ∃ d, match_d(rule, q) &#125;</span><br></pre></td></tr></table></figure><p>对一个”存在”取最小，可以按维度拆开：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">answer(q) = min over d ( min &#123; rule : match_d(rule, q) &#125; )</span><br></pre></td></tr></table></figure><p>也就是说，<strong>每个维度只需要回答”本维度最早命中的规则是哪一条”，跨维度取最小就是全局答案</strong>，与逐规则扫描严格等价。这条式子是整个设计的合法性基础：它允许我们把所有规则的同类选择器合并进一个索引，让索引在每个键上只记住最早引用它的规则。</p><h3 id="平局：同一条规则被多个维度命中"><a href="#平局：同一条规则被多个维度命中" class="headerlink" title="平局：同一条规则被多个维度命中"></a>平局：同一条规则被多个维度命中</h3><p>规则下标相同时结果一样，但诊断信息里要报告”是哪种匹配命中的”，这需要确定性。我们给每个维度的结果附一个 rank：</p><table><thead><tr><th>命中方式</th><th>rank</th></tr></thead><tbody><tr><td>普通 full &#x2F; suffix</td><td>0</td></tr><tr><td>域名类别展开的 full &#x2F; suffix</td><td>1</td></tr><tr><td>普通 keyword &#x2F; regex</td><td>2</td></tr><tr><td>普通 cidr</td><td>0</td></tr><tr><td>网段类别</td><td>1</td></tr></tbody></table><p>比较键是 <code>(rule, rank)</code>。把它打包进一个 32 位整数：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">packed = (rule + 1) &lt;&lt; 2 | rank       // 0 保留为&quot;空&quot;</span><br></pre></td></tr></table></figure><p>打包后整数的大小顺序就是 <code>(rule, rank)</code> 的字典序，索引内部所有的”取最小”都直接在整数上做，不需要解包。</p><h3 id="早退"><a href="#早退" class="headerlink" title="早退"></a>早退</h3><p>规则 0 是最优可能。任何维度一旦返回规则 0 且 rank 为最小可能值，后续维度都不必再查。更一般地，把当前最优 <code>best</code> 传进下一个维度，索引内部只在 <code>packed &lt; best</code> 时才更新。</p><h2 id="三、域名维度：一张冻结哈希表，一趟反向扫描"><a href="#三、域名维度：一张冻结哈希表，一趟反向扫描" class="headerlink" title="三、域名维度：一张冻结哈希表，一趟反向扫描"></a>三、域名维度：一张冻结哈希表，一趟反向扫描</h2><p>full 和 suffix 是同一类问题：suffix 的候选键是查询域名在每个标签边界处的后缀，full 是”从位置 0 开始的那个后缀”。所以两者合并进同一张表，用一个标志位区分。</p><h3 id="一趟算出全部候选键的哈希"><a href="#一趟算出全部候选键的哈希" class="headerlink" title="一趟算出全部候选键的哈希"></a>一趟算出全部候选键的哈希</h3><p>后缀匹配的朴素做法是对每个标签边界物化一个子串再哈希，域名有几个标签就要几趟。改成<strong>从字符串末尾向前</strong>做哈希：处理完位置 i 的字节时，哈希状态恰好就是后缀 <code>domain[i..]</code> 的哈希。于是一趟反向扫描，在每个标签边界处顺手拿到一个候选键的哈希，不物化任何子串，不分配。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">函数 匹配域名(domain, limit):</span><br><span class="line">    best = 0</span><br><span class="line">    hash = 初始偏移</span><br><span class="line">    for i 从 len(domain)-1 递减到 0:</span><br><span class="line">        hash = (hash xor domain[i]) * 质数            // 反向 FNV-1a</span><br><span class="line">        if i == 0 或 domain[i-1] == &#x27;.&#x27;:              // 标签边界</span><br><span class="line">            候选 = domain[i..]</span><br><span class="line">            r = 表.查找(候选, 后缀=true, hash)</span><br><span class="line">            if r != 0 且 r &lt; limit: best = min(best, r)</span><br><span class="line">            if i == 0:</span><br><span class="line">                r = 表.查找(候选, 后缀=false, hash)</span><br><span class="line">                if r != 0 且 r &lt; limit: best = min(best, r)</span><br><span class="line">    return best</span><br></pre></td></tr></table></figure><p>后缀键和完全键用同一个哈希值加一个固定扰动区分，这样一个哈希状态能同时服务两次探测。</p><h3 id="表的形状"><a href="#表的形状" class="headerlink" title="表的形状"></a>表的形状</h3><ul><li>开放寻址，容量为 2 的幂，装载因子不超过 0.75，线性探测。</li><li>每个槽 32 字节：字符串头、完整 64 位哈希、标志位、<code>rule + 1</code>。<code>rule + 1</code> 为 0 表示空槽。</li><li>槽里的字符串头<strong>直接引用</strong>共享的类别数据区或规则里的选择器字符串，不拷贝字节。</li><li>同一个键被多条规则引用时只保留最早的 <code>(rule, rank)</code>：建表时直接插入并做 min 更新，没有中间 map。</li><li>建表前按引用数预留容量，建完后按实际条目数收缩一次，用槽里已存的哈希重排，不再重新哈希字符串。</li></ul><p>一个设计变更记录：初版方案写的是”反向排序数组加二分查找”。实现时发现排序数组的前缀连续性根本没被利用，因为候选键本来就可以按标签逐个枚举；换成冻结哈希表后，每次查找从 O(log n) 次比较变成约一次探测，内存同阶。</p><h2 id="四、关键字维度：一个自动机，failure-链向下传播最小规则"><a href="#四、关键字维度：一个自动机，failure-链向下传播最小规则" class="headerlink" title="四、关键字维度：一个自动机，failure 链向下传播最小规则"></a>四、关键字维度：一个自动机，failure 链向下传播最小规则</h2><p>keyword 是子串匹配，所有规则的 keyword（包括类别展开出来的）进<strong>同一个</strong> Aho-Corasick 自动机。</p><ul><li>模式终端记录该 keyword 的最早 <code>packed</code> 值。</li><li>构建 failure 链时顺便做一件事：failure 目标一定比当前节点浅，它的输出已经是最终值，所以把它的输出 min 到当前节点上。传播完成后，每个节点的输出等于”终止于该节点或其任一 failure 祖先的所有模式”中的最早规则。</li><li>查询对域名扫一趟，取沿途节点输出的最小值，O(len) 得到 keyword 维度的答案。</li></ul><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">构建:</span><br><span class="line">    对每个 keyword 按最早 packed 去重           // 被多条规则引用的类别只建一次</span><br><span class="line">    建 trie，终端 output = min(output, packed)</span><br><span class="line">    BFS 计算 failure:</span><br><span class="line">        对每个节点 next:</span><br><span class="line">            next.failure = 沿父节点 failure 链找到的转移</span><br><span class="line">            next.output = min(next.output, failure.output)   // 向下传播</span><br><span class="line">    记录 minOutput = 全机最小 output</span><br><span class="line"></span><br><span class="line">查询(domain, best):</span><br><span class="line">    if best == minOutput: return best          // 已经不可能更好</span><br><span class="line">    state = root</span><br><span class="line">    for 每个字节 c:</span><br><span class="line">        state = 转移(state, c)                  // 失配沿 failure 回退</span><br><span class="line">        if state.output != 0 且 state.output &lt; best:</span><br><span class="line">            best = state.output</span><br><span class="line">            if best == minOutput: return best   // 早退</span><br><span class="line">    return best</span><br></pre></td></tr></table></figure><p>节点是扁平数组：每个节点保存 failure、边的起始下标和数量、输出；所有边按字节值排序放在两个平行数组里，转移用二分。整个自动机里没有一个指针。</p><p><code>minOutput</code> 早退很重要：合并之后，命中路径原本可能要扫完整个域名才能确认没有更早的规则，有了全机最小值，一旦碰到它就能立刻停下，命中路径不会因为合并而变慢。</p><h2 id="五、IP-维度：网段、地理库、私网合并进一棵前缀树"><a href="#五、IP-维度：网段、地理库、私网合并进一棵前缀树" class="headerlink" title="五、IP 维度：网段、地理库、私网合并进一棵前缀树"></a>五、IP 维度：网段、地理库、私网合并进一棵前缀树</h2><p>cidr 选择器、所有网段类别、内置的私网集合，全部进<strong>同一棵</strong>前缀树。</p><ul><li>节点 12 字节：两个子节点下标加一个 <code>packed</code>。子节点下标存”下标加一”，0 表示没有子节点。整棵树是一个数组。</li><li>IPv4 与 IPv6 各一个根。插入前做规范化：清掉主机位，IPv4 映射地址的 <code>/96</code> 及更长前缀折算成 IPv4 前缀。</li><li>终端存该网段上最早的 <code>packed</code>；同一网段被多条规则引用只留最小。</li><li>查询按位下行，经过的每个节点如果 <code>packed</code> 非零且规则下标不超过 limit，就参与取最小。一次下行同时回答了普通 cidr 和所有网段类别。</li><li>编译期顺便记下全树最小的规则下标 <code>earliestIPRule</code>，惰性解析要用它。</li></ul><p>之前网段类别的匹配是”按规则、按类别逐棵树查”，32 条规则的未命中要走 32 趟；合并后是一趟。</p><h3 id="一个真实的坑：别名地址族"><a href="#一个真实的坑：别名地址族" class="headerlink" title="一个真实的坑：别名地址族"></a>一个真实的坑：别名地址族</h3><p>在把这个设计移植到另一套实现时，审计发现一个结果差异：某个 6to4 形式的 IPv6 地址在原始地理数据库里能查到国家，但按默认方式迭代数据库条目时别名网段被省略了，合并进前缀树后这个地址就漏匹配。IPv4 兼容地址、6to4、Teredo、IPv4 翻译地址和 NAT64 这几个地址族都有同样的问题，而且不同版本的数据库对别名的覆盖并不一致。</p><p>修法不是把所有别名展开成重复网段塞进树里，而是对这几个地址族保留一次对原始数据库的回退查询，同时保证普通 IPv4 网段规则不会被误用到真实 IPv6 流量上。教训是：合并索引的输入是”数据库告诉你的网段”，而数据库自己有没有把别名算进去，要用真实数据对照，不能假设。</p><h2 id="六、查询流水线与惰性解析"><a href="#六、查询流水线与惰性解析" class="headerlink" title="六、查询流水线与惰性解析"></a>六、查询流水线与惰性解析</h2><p>把三个索引串起来：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">函数 路由(request):</span><br><span class="line">    if UDP 且端口 53 且存在 DNS 交换器: return DNS 目标      // 先于一切规则</span><br><span class="line">    domain = 归一化(request.domain)                            // 小写、去尾点</span><br><span class="line">    best = 域名表.匹配(domain, limit = 规则总数)</span><br><span class="line">    if best 不优于 &quot;规则 0 经类别命中&quot;:</span><br><span class="line">        best = 关键字自动机.匹配(domain, best)</span><br><span class="line">    for regex 规则按规则序:</span><br><span class="line">        if 规则下标 &gt;= best: break                              // 慢扫只剩 regex</span><br><span class="line">        if 正则命中: best = min(best, packed)</span><br><span class="line">    if 目标是 IP:</span><br><span class="line">        best = min(best, 前缀树.查询(ip, limit = best))</span><br><span class="line">    else if earliestIPRule &lt; best 且允许解析:</span><br><span class="line">        解析域名，对每个地址查前缀树，命中 earliestIPRule 即停   // 惰性 DNS</span><br><span class="line">    return 解码(best)</span><br></pre></td></tr></table></figure><p>几个细节：</p><ul><li><strong>regex 留在慢路径</strong>。它是唯一没有合并的维度，但 regex 规则按规则序排列，一旦当前规则下标已经不小于 <code>best</code> 就停止，所以它的代价被前面的索引压得很低。</li><li><strong>惰性解析的判定不变</strong>。只有当”最早的 IP 规则”确实可能改善当前答案时才发起 DNS 解析。合并树的 <code>earliestIPRule</code> 就是这个判定需要的那个数。</li><li><strong>快路径零分配</strong>。域名归一化不产生新字符串（小写化和去尾点在查询前由调用方保证或原地处理），候选后缀不物化，自动机和前缀树都是数组下标运算。</li></ul><h2 id="七、内存布局：让垃圾回收看不见它"><a href="#七、内存布局：让垃圾回收看不见它" class="headerlink" title="七、内存布局：让垃圾回收看不见它"></a>七、内存布局：让垃圾回收看不见它</h2><ul><li>快照内全部匹配状态是字节数组和无指针的结构体数组。对带垃圾回收的运行时来说，这些段不需要扫描；堆增长不再放大回收暂停。</li><li>域名、关键字、类别来源在编译期做 intern，重叠的类别只存一份字节。</li><li>每个快照新增的查询索引不超过每条目 64 字节；类别数据本体由跨快照的注册表共享，不随快照份数翻倍。</li><li>编译复杂度从 O(规则数) 变成 O(条目数)，因为代价从查询时搬到了编译时。所以给编译设绝对预算：3.2 万条目稳态编译低于 5 ms，10 万条目低于 50 ms。实测 3.2 万条目约 2.0 ms、478 次分配、索引约 2.9 MB。</li></ul><p>一条自我修正：设计初版写的目标是”内存下降三倍以上”，后来发现基线假设错了，旧实现里大类别集合早就是紧凑存储而不是散列表。这次重构真正的收益在<strong>查询延迟与规则数无关化</strong>，不在内存。文档保留了这条修正记录，而不是把目标悄悄改掉。</p><h2 id="八、等价性：三路差分"><a href="#八、等价性：三路差分" class="headerlink" title="八、等价性：三路差分"></a>八、等价性：三路差分</h2><p>“不改变任何语义”不能靠信心，要靠比对。我们用了三路差分而不是双实现对照：</p><ol><li><strong>冻结的旧实现副本</strong>作为过渡期的 oracle，锚定真实的旧行为。</li><li><strong>按语义契约直接写的朴素线性参考实现</strong>：外层遍历规则，内层遍历选择器，怎么慢都行，但每一行都能对着契约审计。它永久保留。</li><li><strong>新实现</strong>。</li></ol><p>三方逐字段比对：目标、规则下标、匹配种类、是否解析过 DNS、解析结果与错误。朴素参考的意义在于防止新旧实现共享同一个语义误读：如果旧实现本来就有 bug，冻结副本会把 bug 也冻结进 oracle，只有从契约出发独立写的参考能揭示它。</p><p>差分的输入由生成器按维度笛卡尔积产生，而不是手写：</p><ul><li>规则拓扑：单规则、多规则不重叠、同一键出现在规则 i &lt; j、同一请求被多条规则的不同维度同时命中、类别规则固定在规则 0、空选择器规则、十万级合成大类别。</li><li>平局：同一规则被 full&#x2F;suffix、类别、keyword&#x2F;regex 两两及三者同时命中，穷尽 rank 表的全部组合。</li><li>请求形态：大写、尾点、单标签、253 字节边界、空串、punycode、连字符；纯 IPv4、纯 IPv6、IPv4 映射地址。</li><li>DNS 条件：无解析器、解析成功、失败、空结果、<code>best</code> 恰好等于 <code>earliestIPRule</code> 的边界（此时不得解析）。</li><li>类别数据：资产缺失、类别不存在、空类别、属性过滤、资产原子替换后重新编译。</li></ul><p>规模：600 份随机配置乘 11 万次决策三路零分歧作为阶段门禁，收尾前 100 万次决策零分歧。语义契约共 9 条，每条有定向用例，重点是 rank 平局表。分阶段推进，每个阶段的合入条件是差分零分歧加基准不劣化，任一阶段出问题回滚该阶段即可。</p><h2 id="九、结果"><a href="#九、结果" class="headerlink" title="九、结果"></a>九、结果</h2><p>32 条规则的配置，同一台桌面机，重构前后：</p><table><thead><tr><th>路径</th><th align="right">重构前</th><th align="right">重构后</th><th align="right">倍数</th></tr></thead><tbody><tr><td>域名类别，未命中</td><td align="right">3500 ns</td><td align="right">36 ns</td><td align="right">97×</td></tr><tr><td>域名类别，命中</td><td align="right">109 ns</td><td align="right">45 ns</td><td align="right">2.4×</td></tr><tr><td>域名类别混关键字，未命中</td><td align="right">3500 ns</td><td align="right">70 ns</td><td align="right">50×</td></tr><tr><td>关键字，未命中</td><td align="right">1565 ns</td><td align="right">63 ns</td><td align="right">25×</td></tr><tr><td>关键字，命中</td><td align="right">94 ns</td><td align="right">78 ns</td><td align="right">1.2×</td></tr><tr><td>网段类别，未命中</td><td align="right">310 ns</td><td align="right">22 ns</td><td align="right">14×</td></tr><tr><td>网段类别，命中</td><td align="right">62 ns</td><td align="right">56 ns</td><td align="right">1.1×</td></tr><tr><td>普通 suffix</td><td align="right">46 ns</td><td align="right">42 ns</td><td align="right">1.1×</td></tr></tbody></table><p>未命中列不再随规则数变化：8 条规则和 32 条规则的未命中同值。命中列基本持平，说明合并没有让原本就快的路径变慢。</p><p>同一设计后来被移植到另一套实现里，规则表放大到 32768 条独立规则时：域名后缀未命中从 1.3 ms 降到 19 ns，关键字未命中从 294 µs 降到 27 ns，网段未命中从 93 µs 降到 4 ns。这些倍数没有实际意义，只说明一件事：旧实现是 O(规则数)，新实现不是。</p><p>也有变慢的地方，要写出来：8 条普通网段规则、命中最后一条的场景，从 23 ns 变成 26 ns。初版对小表用了专门的掩码扫描，统一进前缀树后反而多了几次节点跳转。并不是每种小配置都会受益，我们接受这个代价换取实现只有一份。</p><h2 id="十、没做的两件事"><a href="#十、没做的两件事" class="headerlink" title="十、没做的两件事"></a>十、没做的两件事</h2><p>设计里还有两项优化，评估之后都跳过了，而且写明了重新评估的触发条件。</p><ul><li><strong>regex 字面量门控</strong>：从每条正则提取必需字面量，并入关键字自动机做预筛，未命中门控的正则直接跳过。实测 32 条 regex 规则的慢扫未命中是 551 ns，比其他维度的问题低一个数量级，且实际配置里 regex 极少。触发条件：生产配置 regex 规则超过 64 条且路由在 profile 里可见。</li><li><strong>IPv4 每步 8 位的前缀树</strong>：把逐位下行的 32 次跳转变成 4 次。合并树落地后 IP 查询已在 22 到 56 ns，逐位下行不再是路由路径的瓶颈。触发条件：IP 基准显示前缀树是瓶颈。</li></ul><p>不做的理由和做的理由一样重要：每一项优化都对应一个能被测出来的问题，测不出来的问题不值得为它多养一份代码。</p><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>这次重新设计只用了三个古老的数据结构：哈希表、Aho-Corasick、前缀树。真正的工作不在结构本身，而在前面那一步：</p><blockquote><p><strong>证明”最早规则赢”可以按维度拆开取最小，于是”谁先匹配”这个问题从查询时的循环，变成了编译时的一次 min。</strong></p></blockquote><p>有了这条式子，所有规则的同类选择器就可以合并，未命中不再需要把表扫完；有了 rank 打包，平局在整数比较里自动解决；有了朴素参考实现的差分，”不改变语义”从承诺变成了可以跑一百万次的事实。</p><p>如果你也维护着一个按顺序匹配的规则引擎，我的建议只有两条：先把语义写成契约再动手，然后去测未命中，不要只测命中。命中很少是问题，把整张表扫完才是。</p>]]>
    </content>
    <id>https://www.embermoth.blog/2026/09/27/routing-index-rule-count-independent-lookup/</id>
    <link href="https://www.embermoth.blog/2026/09/27/routing-index-rule-count-independent-lookup/"/>
    <published>2026-09-27T02:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong> — 代理软件的路由规则是一张有序表：逐条检查，第一条命中的规则决定出口。这个语义天然导致”规则越多、未命中越慢”，因为一个不命中任何规则的请求要把整张表扫完，而带地理类别的规则每条都是几万个域名或网段。这篇文章记录一次不改变任何语义的重新设计：利用”最早规则赢”对匹配维度可分配这一性质，把所有规则的同类选择器合并进三个静态索引（冻结哈希表、一个 Aho-Corasick 自动机、一棵前缀树），每个索引只回答”本维度最早命中的规则是哪一条”，跨维度取最小即得全局答案。结果是未命中延迟从微秒级降到几十纳秒，并且不再随规则数增长；等价性由与朴素线性实现的百万次差分保证。</p>
</blockquote>
<hr>]]>
    </summary>
    <title>让路由查询与规则数无关：代理路由索引的一次重新设计</title>
    <updated>2026-09-27T02:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ember Moth</name>
    </author>
    <category term="开发笔记" scheme="https://www.embermoth.blog/categories/%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0/"/>
    <category term="performance" scheme="https://www.embermoth.blog/tags/performance/"/>
    <category term="rust" scheme="https://www.embermoth.blog/tags/rust/"/>
    <category term="smoltcp" scheme="https://www.embermoth.blog/tags/smoltcp/"/>
    <category term="tun" scheme="https://www.embermoth.blog/tags/tun/"/>
    <category term="tcp" scheme="https://www.embermoth.blog/tags/tcp/"/>
    <category term="netstack" scheme="https://www.embermoth.blog/tags/netstack/"/>
    <category term="tokio" scheme="https://www.embermoth.blog/tags/tokio/"/>
    <category term="concurrency" scheme="https://www.embermoth.blog/tags/concurrency/"/>
    <category term="clash-rs" scheme="https://www.embermoth.blog/tags/clash-rs/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong> — 上一篇讲了移动端为什么把 TCP 交还给内核。桌面端我们仍然用 smoltcp 跑用户态 TCP 栈，因为桌面没有 Extension 那样的内存墙，也不值得为省内存让每个载荷多穿一次 TUN。这条路径的骨架来自 clash-rs 的 netstack，社区已经替我们修过一批坑；这篇记录的是在此之上我们自己踩到的六个：一个 <code>Listen</code> 状态的 socket 在 SYN 送达前就被回收、一个形式上是 UB 的 SPSC 环、一个在 tx 队列满时自旋的 poll 循环、一个能终止整条隧道的 UDP 分片包、一个藏在 TxToken 里的 panic，以及每次读都清零 64 KiB 的接收路径。每个坑都给出症状、根因、修法和守住它的测试。</p></blockquote><hr> <span id="more"></span><h2 id="一、这套栈长什么样"><a href="#一、这套栈长什么样" class="headerlink" title="一、这套栈长什么样"></a>一、这套栈长什么样</h2><p>先把结构摆出来，后面每个坑都要对着它讲。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">TUN 读出 IP 包</span><br><span class="line">    |</span><br><span class="line">    v</span><br><span class="line">NetstackSink        按协议分流：TCP/ICMP 走 smoltcp，UDP 走独立的报文编解码器</span><br><span class="line">    |  (bounded mpsc)</span><br><span class="line">    v</span><br><span class="line">poll_packets 任务    解析 TCP 头；SYN 去重；为新连接创建 smoltcp socket 与 TcpStreamHandle；</span><br><span class="line">    |                把原始帧注入 NetstackDevice 的 rx 队列</span><br><span class="line">    |  (bounded mpsc)</span><br><span class="line">    v</span><br><span class="line">poll_sockets 任务    事件驱动地调 iface.poll()；在 smoltcp socket 与 SPSC 环之间搬字节；</span><br><span class="line">    |                处理半关闭、drop 后的 FIN、socket 回收</span><br><span class="line">    |  (SPSC byte ring x2 / 连接)</span><br><span class="line">    v</span><br><span class="line">TcpStream           面向分发器的 AsyncRead + AsyncWrite，用 AtomicWaker 与 poll_sockets 握手</span><br><span class="line">    |</span><br><span class="line">    v</span><br><span class="line">dispatcher -&gt; 出站</span><br></pre></td></tr></table></figure><p>smoltcp 自己看到的”网卡”是 <code>NetstackDevice</code>：一个实现了 <code>phy::Device</code> 的对象，rx 是一条有界 channel，tx 是另一条通向 TUN 写任务的有界 channel。每条连接持有四块固定内存：两块 smoltcp 的 <code>SocketBuffer</code>，两块我们自己的 relay 环。所有这些内存都在一个进程级的预算下，连接建立前先拿 permit，拿不到就拒绝这个 SYN。</p><h2 id="二、社区已经替我们踩过的坑"><a href="#二、社区已经替我们踩过的坑" class="headerlink" title="二、社区已经替我们踩过的坑"></a>二、社区已经替我们踩过的坑</h2><p>在讲我们自己的坑之前，先列出 clash-rs 的 netstack 里已经修好、我们读代码时才明白为什么要这样写的几处。它们不是我们的功劳，但理解它们是理解后面六个坑的前提。</p><table><thead><tr><th>坑</th><th>修法</th></tr></thead><tbody><tr><td><code>Device::receive</code> 先从 rx 队列取包、再预留 tx 槽，tx 满时取出的 ACK 被静默丢掉，smoltcp 发送窗口永不前进，下载停在 0</td><td>先 <code>try_reserve</code> tx 槽，拿不到就不碰 rx 队列</td></tr><tr><td>握手期 <code>may_recv()</code> &#x2F; <code>may_send()</code> 返回 false，被当成对端关闭，读写标志是单向的，一旦置位连接永久坏掉</td><td>只在状态离开 <code>Listen</code> &#x2F; <code>SynSent</code> &#x2F; <code>SynReceived</code> 之后才据此置位 EOF 与 BrokenPipe</td></tr><tr><td>应用侧 <code>TcpStream</code> 一 drop 就移除 socket，smoltcp 发送环里排队的数据被丢，大传输尾部截断</td><td>先把 relay 发送环排空进 smoltcp，再 <code>close()</code> 发 FIN，等 <code>is_active()</code> 变 false 才移除</td></tr><tr><td>同一 SYN 的重传各自创建一个 socket</td><td>五元组 SYN tracker，60 秒 TTL，上限一万条</td></tr><tr><td><code>poll_read</code> 先查空再注册 waker，中间被填充则 wake 丢失</td><td>先注册 waker 再复查，两处都是</td></tr></tbody></table><p>这些修复对应上游的几个 PR（<code>#1069</code>、<code>#1070</code>、<code>#1075</code>、<code>#1322</code>），感谢 clash-rs 的贡献者们。</p><h2 id="三、坑一：Listen-状态的-socket-在-SYN-送达前就被回收了"><a href="#三、坑一：Listen-状态的-socket-在-SYN-送达前就被回收了" class="headerlink" title="三、坑一：Listen 状态的 socket 在 SYN 送达前就被回收了"></a>三、坑一：<code>Listen</code> 状态的 socket 在 SYN 送达前就被回收了</h2><h3 id="症状"><a href="#症状" class="headerlink" title="症状"></a>症状</h3><p>桌面端饱和下载期间，新建连接全部 <code>ECONNREFUSED</code>，持续大约一分钟；偶尔还会出现新连接被归到错误来源的现象。下载本身不受影响，所以很容易被当成远端问题。</p><h3 id="根因"><a href="#根因" class="headerlink" title="根因"></a>根因</h3><p>这是一条四步的链：</p><ol><li><code>poll_packets</code> 为新 SYN 创建 smoltcp socket 并调用 <code>listen(dst)</code>，<strong>先</strong>通过控制总线把 socket 交给 <code>poll_sockets</code> 注册，<strong>再</strong>把原始 SYN 帧注入设备 rx 队列。顺序是对的，否则 SYN 到达时没有 listener 会直接被 RST。</li><li>但 <code>NetstackDevice::receive</code> 在 tx channel 满时会返回 <code>None</code>，把 rx 队列里的包留在原地。饱和下载时 TUN 写端落后，tx 就是满的。于是这个 SYN 会在 rx 队列里停留好几轮 poll。</li><li><code>poll_sockets</code> 每轮扫描所有 socket，用 <code>!socket.is_active()</code> 判断能否回收。smoltcp 0.14 对 <code>State::Listen</code> 的 <code>is_active()</code> 返回 <strong>false</strong>。一个还没消费到 SYN 的新 socket，在它眼里和一条已经关闭的连接没有区别。</li><li>socket 被移除。SYN 终于被处理时找不到 listener，smoltcp 回 RST。更糟的是 SYN tracker 仍然把这个五元组的重传归到”已存在的连接”，直接注入设备而不再创建 socket，于是接下来 60 秒内这条连接的每一次重试都吃 RST。</li></ol><h3 id="修法"><a href="#修法" class="headerlink" title="修法"></a>修法</h3><p>回收判定要区分状态。<code>Listen</code> 状态的 socket 只有在 SYN tracker 也会忘掉它之后才允许过期，其他状态沿用 <code>is_active()</code>：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// smoltcp 把 Listen socket 报告为不活跃。socket 在 SYN 注入之前就已注册，</span></span><br><span class="line"><span class="comment">/// 而设备会在 tx 满时把 SYN 留在队列里，所以 Listen 可以合法地跨越多轮 poll。</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">retire_tcp_socket</span>(state: tcp::State, is_active: <span class="type">bool</span>, age: Duration) <span class="punctuation">-&gt;</span> <span class="type">bool</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> state == tcp::State::Listen &#123;</span><br><span class="line">        age &gt;= SYN_TRACK_TTL</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        !is_active</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>TcpStreamHandle</code> 因此多记一个 <code>created_at</code>。测试直接对这个纯函数写：一秒钟大的 <code>Listen</code> 不回收，超过 TTL 的 <code>Listen</code> 回收，<code>Closed</code> 立刻回收，<code>Established</code> 且活跃不回收。</p><h3 id="教训"><a href="#教训" class="headerlink" title="教训"></a>教训</h3><p>第三方库对”不活跃”的定义未必是你的定义。<code>is_active()</code> 的文档没有错，错的是我们把”没有活动”读成了”可以扔掉”。</p><h2 id="四、坑二：一个形式上是-UB-的-SPSC-环"><a href="#四、坑二：一个形式上是-UB-的-SPSC-环" class="headerlink" title="四、坑二：一个形式上是 UB 的 SPSC 环"></a>四、坑二：一个形式上是 UB 的 SPSC 环</h2><h3 id="症状-1"><a href="#症状-1" class="headerlink" title="症状"></a>症状</h3><p>没有症状。测试全绿，双线程一百万字节来回搬运也一致。它是在一次静态审计里被指出来的。</p><h3 id="根因-1"><a href="#根因-1" class="headerlink" title="根因"></a>根因</h3><p>relay 环原来的形态是这样的：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">pub</span> <span class="keyword">struct</span> <span class="title class_">LockFreeRingBuffer</span> &#123;</span><br><span class="line">    buffer: UnsafeCell&lt;<span class="type">Box</span>&lt;[<span class="type">u8</span>]&gt;&gt;,</span><br><span class="line">    <span class="comment">// write_pos / read_pos 原子</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>生产者写入时 <code>let buffer = &amp;mut *self.buffer.get();</code>，消费者读取时 <code>let buffer = &amp;*self.buffer.get();</code>。两个线程同时持有对<strong>整个</strong> <code>Box&lt;[u8]&gt;</code> 的引用，其中一个还是 <code>&amp;mut</code>。虽然两侧实际触碰的字节区间永不重叠，但在 Rust 的别名模型下，<code>&amp;mut T</code> 与 <code>&amp;T</code> 对同一块内存同时存在就是未定义行为，编译器有权基于”独占”做优化。今天它恰好没做，不代表下个版本不做。</p><h3 id="修法-1"><a href="#修法-1" class="headerlink" title="修法"></a>修法</h3><p>把存储改成 <code>Box&lt;[UnsafeCell&lt;u8&gt;]&gt;</code>，两侧只持有共享引用，拷贝通过裸指针进入各自的不相交区间：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">pub</span> <span class="keyword">struct</span> <span class="title class_">SpscByteRing</span> &#123;</span><br><span class="line">    buffer: <span class="type">Box</span>&lt;[UnsafeCell&lt;<span class="type">u8</span>&gt;]&gt;,</span><br><span class="line">    capacity: <span class="type">usize</span>,</span><br><span class="line">    write_pos: AtomicUsize, <span class="comment">// 只有生产者写</span></span><br><span class="line">    read_pos: AtomicUsize,  <span class="comment">// 只有消费者写</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/// # Safety</span></span><br><span class="line"><span class="comment">/// 只有生产者可调用，且 data.len() 不超过从 pos 起的空闲区。</span></span><br><span class="line"><span class="keyword">unsafe</span> <span class="keyword">fn</span> <span class="title function_">write_at</span>(&amp;<span class="keyword">self</span>, pos: <span class="type">usize</span>, data: &amp;[<span class="type">u8</span>]) &#123;</span><br><span class="line">    <span class="keyword">let</span> <span class="variable">first</span> = data.<span class="title function_ invoke__">len</span>().<span class="title function_ invoke__">min</span>(<span class="keyword">self</span>.capacity - pos);</span><br><span class="line">    <span class="keyword">unsafe</span> &#123;</span><br><span class="line">        ptr::<span class="title function_ invoke__">copy_nonoverlapping</span>(data.<span class="title function_ invoke__">as_ptr</span>(), <span class="keyword">self</span>.<span class="title function_ invoke__">base</span>().<span class="title function_ invoke__">add</span>(pos), first);</span><br><span class="line">        ptr::<span class="title function_ invoke__">copy_nonoverlapping</span>(data.<span class="title function_ invoke__">as_ptr</span>().<span class="title function_ invoke__">add</span>(first), <span class="keyword">self</span>.<span class="title function_ invoke__">base</span>(), data.<span class="title function_ invoke__">len</span>() - first);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>UnsafeCell&lt;u8&gt;</code> 是 <code>repr(transparent)</code>，所以第 <code>i</code> 个字节就在 <code>base.add(i)</code>。位置发布仍然是 <code>write_pos</code> 用 <code>Release</code> 存、消费者用 <code>Acquire</code> 读，反向同理。<code>unsafe impl Send/Sync</code> 保留，注释写明前提：每个环恰好一个生产者一个消费者，且类型不对外暴露。</p><h3 id="教训-1"><a href="#教训-1" class="headerlink" title="教训"></a>教训</h3><p>“跑得对”和”定义良好”是两回事。本机没有 Miri 不是不改的理由，恰恰是要改的理由，因为你没有工具替你发现它什么时候开始跑不对。</p><h2 id="五、坑三：tx-队列满时-poll-循环在自旋"><a href="#五、坑三：tx-队列满时-poll-循环在自旋" class="headerlink" title="五、坑三：tx 队列满时 poll 循环在自旋"></a>五、坑三：tx 队列满时 poll 循环在自旋</h2><h3 id="症状-2"><a href="#症状-2" class="headerlink" title="症状"></a>症状</h3><p>TUN 写端落后时，运行 <code>poll_sockets</code> 的 worker CPU 打满，但吞吐没有任何提升。</p><h3 id="根因-2"><a href="#根因-2" class="headerlink" title="根因"></a>根因</h3><p><code>poll_sockets</code> 的节拍由 <code>iface.poll_delay()</code> 决定：返回零表示”立刻再 poll”。上游已经在每轮之后加了 <code>yield_now()</code>，防止这个循环饿死同一 worker 上的 <code>NetstackStream</code> 消费者。但当 tx channel 满时，情况变成：</p><ul><li>设备的 <code>receive()</code> 和 <code>transmit()</code> 都拿不到 tx 槽，全部返回 <code>None</code>；</li><li>smoltcp 有段要发，所以 <code>poll_delay()</code> 继续返回零；</li><li>每一轮都是完整的 <code>iface.poll()</code> 加 O(sockets) 扫描，什么都做不了，然后 <code>yield_now()</code>，然后再来一轮。</li></ul><p><code>yield_now</code> 解决的是公平性，解决不了”没有任何事件能让下一轮有进展”。</p><h3 id="修法-2"><a href="#修法-2" class="headerlink" title="修法"></a>修法</h3><p>让设备记住自己是因为 tx 满而放弃的，poll 循环据此改变等待方式：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// NetstackDevice</span></span><br><span class="line"><span class="keyword">let</span> <span class="variable">Ok</span>(permit) = <span class="keyword">self</span>.tx_sender.<span class="title function_ invoke__">try_reserve</span>() <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="keyword">self</span>.tx_stalled = <span class="literal">true</span>;</span><br><span class="line">    <span class="keyword">return</span> <span class="literal">None</span>;</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="comment">// poll_sockets 每轮结束</span></span><br><span class="line"><span class="keyword">if</span> device.<span class="title function_ invoke__">take_tx_stalled</span>() &#123;</span><br><span class="line">    <span class="comment">// 等 TUN 写任务腾出至少一个槽位，上界 20 ms 防止漏唤醒卡死</span></span><br><span class="line">    <span class="keyword">let</span> <span class="variable">_</span> = time::<span class="title function_ invoke__">timeout</span>(TX_STALL_WAIT, device.<span class="title function_ invoke__">wait_for_tx_capacity</span>()).<span class="keyword">await</span>;</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    task::<span class="title function_ invoke__">yield_now</span>().<span class="keyword">await</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>wait_for_tx_capacity</code> 就是 <code>tx_sender.reserve().await</code> 然后立刻丢掉 permit：我们只想知道”有位置了”，不想占它。</p><h3 id="教训-2"><a href="#教训-2" class="headerlink" title="教训"></a>教训</h3><p>自旋常常伪装成”事件驱动”。判断标准很简单：这一轮 poll 有没有任何可能产生进展。没有，就该等一个具体的条件，而不是让出时间片后立刻回来。</p><h2 id="六、坑四：一个-UDP-分片包终止整条隧道"><a href="#六、坑四：一个-UDP-分片包终止整条隧道" class="headerlink" title="六、坑四：一个 UDP 分片包终止整条隧道"></a>六、坑四：一个 UDP 分片包终止整条隧道</h2><h3 id="症状-3"><a href="#症状-3" class="headerlink" title="症状"></a>症状</h3><p>隧道运行中突然整体断开，日志里最后一条是 “smoltcp UDP dispatcher stopped unexpectedly”。复现条件：任何一个大于 MTU 的 UDP 报文，比如某些 DNS 大应答或 QUIC 探测。</p><h3 id="根因-3"><a href="#根因-3" class="headerlink" title="根因"></a>根因</h3><p>UDP 读端的 <code>SplitRead::recv</code> 原来是这样的语义：从 channel 取一个 IP 包，解析 IP 头和 UDP 头，任一失败返回 <code>None</code>。而调用方把 <code>None</code> 理解为”channel 已关闭”，于是退出循环，UDP 分发 future 以错误结束，外层 <code>select!</code> 把整个 TUN 后端一起拆掉。</p><p>一个大于 MTU 的 UDP 报文会被分片。第一个分片带着完整的 UDP 头，其中长度字段声明的是<strong>整个报文</strong>的长度，远大于这个分片的载荷，<code>UdpPacket::new_checked</code> 因此报错。一个包，一次解析失败，一条隧道。</p><h3 id="修法-3"><a href="#修法-3" class="headerlink" title="修法"></a>修法</h3><p>两层。读端只在 channel 真正关闭时返回 <code>None</code>，解析失败就跳过继续等下一个：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">pub</span> <span class="keyword">async</span> <span class="keyword">fn</span> <span class="title function_">recv</span>(&amp;<span class="keyword">mut</span> <span class="keyword">self</span>) <span class="punctuation">-&gt;</span> <span class="type">Option</span>&lt;UdpPacket&gt; &#123;</span><br><span class="line">    <span class="keyword">loop</span> &#123;</span><br><span class="line">        <span class="keyword">let</span> <span class="variable">data</span> = <span class="keyword">self</span>.recv.<span class="title function_ invoke__">recv</span>().<span class="keyword">await</span>?;</span><br><span class="line">        <span class="keyword">if</span> <span class="keyword">let</span> <span class="variable">Some</span>(packet) = <span class="title function_ invoke__">parse_udp_packet</span>(data) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="title function_ invoke__">Some</span>(packet);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>入口的 <code>NetstackSink</code> 也不再把分片和畸形帧往里送。smoltcp 不做重组，传输层头只在第一个分片里，转发分片只会在下游变成畸形 UDP 帧：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> <span class="keyword">let</span> <span class="variable">IpPacket</span>::<span class="title function_ invoke__">Ipv4</span>(ipv4) = &amp;packet</span><br><span class="line">    &amp;&amp; (ipv4.<span class="title function_ invoke__">more_frags</span>() || ipv4.<span class="title function_ invoke__">frag_offset</span>() != <span class="number">0</span>)</span><br><span class="line">&#123;</span><br><span class="line">    debug!(<span class="string">&quot;tun IPv4 fragment dropped&quot;</span>);</span><br><span class="line">    <span class="keyword">return</span> <span class="title function_ invoke__">Ok</span>(());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>同一类问题在写端也有一处：地址族不匹配或载荷超过 65507 字节的 UDP 报文无法封包，原来返回错误同样会结束中继。现在按”UDP 本来就是有损的”处理，丢弃、记日志、继续。</p><p>三个测试守着它：畸形帧和分片被丢弃且不报错；连续坏帧之后一个好帧仍能被读出；无法封包的出向报文不让中继失败。</p><h3 id="教训-3"><a href="#教训-3" class="headerlink" title="教训"></a>教训</h3><p>在数据面里，”解析失败”和”通道关闭”必须是两个类型。用 <code>Option</code> 同时表达两件事，迟早有一个被误读。</p><h2 id="七、坑五：藏在-TxToken-里的-panic"><a href="#七、坑五：藏在-TxToken-里的-panic" class="headerlink" title="七、坑五：藏在 TxToken 里的 panic"></a>七、坑五：藏在 TxToken 里的 panic</h2><h3 id="症状-4"><a href="#症状-4" class="headerlink" title="症状"></a>症状</h3><p>今天没有症状，smoltcp 不会请求超过它被告知的 MTU 的帧。但它是一个 panic 面：一旦触发，panic 发生在 <code>poll_sockets</code> 任务里，这个任务一死，栈里<strong>所有</strong> TCP 连接一起死。</p><h3 id="根因-4"><a href="#根因-4" class="headerlink" title="根因"></a>根因</h3><p><code>TxToken::consume(len, f)</code> 的契约是：给 smoltcp 一块 <code>len</code> 字节的缓冲让它序列化帧。我们的实现从 pool 拿一块 MTU 大小的 buffer，<code>set_len(len)</code> 后交给 <code>f</code>。原来的写法是 <code>set_len(len).expect(&quot;...&quot;)</code>。</p><h3 id="修法-4"><a href="#修法-4" class="headerlink" title="修法"></a>修法</h3><p>token 仍然必须运行序列化闭包，这是 smoltcp 的契约；但帧可以不发：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> <span class="variable">Some</span>(packet_buffer) = buffer.<span class="title function_ invoke__">set_len</span>(len) <span class="keyword">else</span> &#123;</span><br><span class="line">    warn!(len, mtu = buffer.<span class="title function_ invoke__">free_len</span>(), <span class="string">&quot;dropping smoltcp frame larger than the device MTU&quot;</span>);</span><br><span class="line">    <span class="keyword">let</span> <span class="keyword">mut </span><span class="variable">scratch</span> = <span class="built_in">vec!</span>[<span class="number">0u8</span>; len];</span><br><span class="line">    <span class="keyword">return</span> <span class="title function_ invoke__">f</span>(&amp;<span class="keyword">mut</span> scratch);</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>顺着这条，我们给整个 packet engine 开了 <code>clippy::unwrap_used</code> 与 <code>clippy::expect_used</code> 门禁，CI 以 <code>-D warnings</code> 执行。确属不变量的位置用 <code>#[expect(clippy::expect_used, reason = &quot;...&quot;)]</code> 写明依赖的是什么，其他一律改成 <code>let ... else</code> 或 <code>?</code>。第一次在 CI 上跑这个门禁，就在本机编不到的 Windows 分支里抓出六处。</p><h3 id="教训-4"><a href="#教训-4" class="headerlink" title="教训"></a>教训</h3><p>数据面里的每个 <code>expect</code> 都要回答一个问题：它 panic 的时候谁陪葬。答案如果是”所有连接”，那它就不该存在。</p><h2 id="八、坑六：每次读都清零-64-KiB"><a href="#八、坑六：每次读都清零-64-KiB" class="headerlink" title="八、坑六：每次读都清零 64 KiB"></a>八、坑六：每次读都清零 64 KiB</h2><h3 id="症状-5"><a href="#症状-5" class="headerlink" title="症状"></a>症状</h3><p>桌面端下行吞吐上不去，profile 里 <code>memset</code> 排在前面。</p><h3 id="根因-5"><a href="#根因-5" class="headerlink" title="根因"></a>根因</h3><p><code>TcpStream::poll_read</code> 用 tokio 的 <code>ReadBuf</code>。原来的写法是 <code>buf.initialize_unfilled()</code>，它会把<strong>整个</strong>未填充区清零后再返回切片。分发器给的读缓冲是 64 KiB，而 relay 环里此刻可能只有一个 MSS 的数据。于是每读 1460 字节，先清零 65536 字节。</p><h3 id="修法-5"><a href="#修法-5" class="headerlink" title="修法"></a>修法</h3><p>只初始化本次真正能取出的部分：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> <span class="variable">available</span> = read_buf.<span class="title function_ invoke__">len</span>().<span class="title function_ invoke__">min</span>(buf.<span class="title function_ invoke__">remaining</span>());</span><br><span class="line"><span class="keyword">let</span> <span class="variable">recv_buf</span> = buf.<span class="title function_ invoke__">initialize_unfilled_to</span>(available);</span><br><span class="line"><span class="keyword">let</span> <span class="variable">n</span> = read_buf.<span class="title function_ invoke__">dequeue_slice</span>(recv_buf);</span><br><span class="line">buf.<span class="title function_ invoke__">advance</span>(n);</span><br></pre></td></tr></table></figure><p>同一轮我们还调整了两组”把 TUN 当成不可信物理链路”的默认值。smoltcp 看到的对端是本机内核，链路是内存里的 TUN，这两件事允许我们：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 入向校验和不再校验，出向仍然生成</span></span><br><span class="line">capabilities.checksum.ipv4 = Checksum::Tx;</span><br><span class="line">capabilities.checksum.tcp  = Checksum::Tx;</span><br><span class="line">capabilities.checksum.udp  = Checksum::Tx;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 这一段 TCP 终止在本机内核，物理链路的拥塞窗口只会带来慢启动和虚假 RTO</span></span><br><span class="line">socket.<span class="title function_ invoke__">set_congestion_control</span>(tcp::CongestionControl::<span class="literal">None</span>);</span><br><span class="line">socket.<span class="title function_ invoke__">set_ack_delay</span>(<span class="literal">None</span>);      <span class="comment">// Interface::poll 在批次边界天然合并 ACK</span></span><br><span class="line">socket.<span class="title function_ invoke__">set_nagle_enabled</span>(<span class="literal">false</span>);</span><br><span class="line">socket.<span class="title function_ invoke__">set_keep_alive</span>(<span class="title function_ invoke__">Some</span>(Duration::<span class="title function_ invoke__">from_secs</span>(<span class="number">28</span>)));</span><br></pre></td></tr></table></figure><p>有一条测试专门断言五种协议的校验和都是”不校验、只生成”，防止将来有人把默认值改回去。</p><h3 id="教训-5"><a href="#教训-5" class="headerlink" title="教训"></a>教训</h3><p>用户态栈默认按”网卡对面是互联网”配置，而在 TUN 代理里，对面是同一台机器的内核。每一个默认值都值得重新问一遍：这个开销买的是什么。</p><h2 id="九、把它们放回同一张图"><a href="#九、把它们放回同一张图" class="headerlink" title="九、把它们放回同一张图"></a>九、把它们放回同一张图</h2><table><thead><tr><th>坑</th><th>层</th><th>症状类型</th><th>守住它的测试</th></tr></thead><tbody><tr><td>Listen socket 误回收</td><td>poll_sockets 回收判定</td><td>新连接被拒一分钟</td><td><code>a_listening_socket_waits_for_its_syn_instead_of_being_retired_as_inactive</code></td></tr><tr><td>SPSC 环别名</td><td>relay 环</td><td>无症状的 UB</td><td>环绕正确性、双线程一百万字节一致性</td></tr><tr><td>tx 满时自旋</td><td>poll 循环节拍</td><td>CPU 打满无进展</td><td><code>test_receive_drops_inbound_packet_when_tx_channel_full</code> 与 tx 停滞路径</td></tr><tr><td>分片包杀隧道</td><td>UDP 读端与入口 Sink</td><td>整条隧道断开</td><td>三条坏帧、分片、封包失败测试</td></tr><tr><td>TxToken panic</td><td>设备 tx 路径</td><td>全部连接陪葬</td><td>unwrap&#x2F;expect 门禁</td></tr><tr><td>每次读清零 64 KiB</td><td>TcpStream 读路径</td><td>吞吐上限</td><td>校验和策略断言</td></tr></tbody></table><p>六个坑里，两个是逻辑错误，两个是”正确但昂贵”，一个是形式上的 UB，一个是 panic 面。它们分布在这套栈的每一层，从设备抽象到面向应用的流，没有一个集中在”TCP 算法”本身。smoltcp 的 TCP 是对的，出问题的全是我们把它接进异步运行时、有界队列和多任务调度时的胶水。</p><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>这六个坑有一个共同点：</p><blockquote><p><strong>它们都不是 TCP 的问题，而是把一个同步的、假设自己独占网卡的协议栈，接进一个异步的、处处有界的运行时时，边界上的约定没有说清楚。</strong></p></blockquote><p><code>is_active()</code> 的语义、<code>Option</code> 同时表达两种结束、”立刻再 poll”在没有进展时意味着什么、<code>&amp;mut</code> 对整块内存意味着什么、<code>expect</code> panic 时谁陪葬、<code>ReadBuf</code> 的初始化范围。每一条都是边界处的约定，每一条都在代码里有了对应的测试。</p><p>如果你也在用 smoltcp 做 TUN 代理，我的建议是：不要只测 TCP 流量对不对，去测那些边界。tx 满的时候会发生什么，一个畸形包进来会发生什么，应用侧 drop 的时候环里还有数据会发生什么。这些地方才是用户态栈真正会坏的地方。</p><hr><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ul><li><span class="exturl" data-url="aHR0cHM6Ly9naXRodWIuY29tL3Ntb2x0Y3AtcnMvc21vbHRjcA==">smoltcp<i class="fa fa-external-link-alt"></i></span> 0.14</li><li>clash-rs 的 netstack 实现与相关修复：<code>#1069</code>、<code>#1070</code>、<code>#1075</code>、<code>#1322</code></li><li>上一篇：<a href="/2026/09/26/system-netstack-kernel-tcp-for-tun/">把 TCP 交还给内核：TUN 代理里的 system netstack 设计与实测</a></li><li>tokio <code>ReadBuf::initialize_unfilled_to</code></li><li>Rust 参考手册中关于别名与 <code>UnsafeCell</code> 的章节</li></ul>]]>
    </content>
    <id>https://www.embermoth.blog/2026/09/26/smoltcp-userspace-stack-six-pitfalls/</id>
    <link href="https://www.embermoth.blog/2026/09/26/smoltcp-userspace-stack-six-pitfalls/"/>
    <published>2026-09-26T13:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong> — 上一篇讲了移动端为什么把 TCP 交还给内核。桌面端我们仍然用 smoltcp 跑用户态 TCP 栈，因为桌面没有 Extension 那样的内存墙，也不值得为省内存让每个载荷多穿一次 TUN。这条路径的骨架来自 clash-rs 的 netstack，社区已经替我们修过一批坑；这篇记录的是在此之上我们自己踩到的六个：一个 <code>Listen</code> 状态的 socket 在 SYN 送达前就被回收、一个形式上是 UB 的 SPSC 环、一个在 tx 队列满时自旋的 poll 循环、一个能终止整条隧道的 UDP 分片包、一个藏在 TxToken 里的 panic，以及每次读都清零 64 KiB 的接收路径。每个坑都给出症状、根因、修法和守住它的测试。</p>
</blockquote>
<hr>]]>
    </summary>
    <title>用 smoltcp 做 TUN 代理的用户态栈：我们踩过的六个坑</title>
    <updated>2026-09-26T13:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ember Moth</name>
    </author>
    <category term="开发笔记" scheme="https://www.embermoth.blog/categories/%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0/"/>
    <category term="performance" scheme="https://www.embermoth.blog/tags/performance/"/>
    <category term="rust" scheme="https://www.embermoth.blog/tags/rust/"/>
    <category term="smoltcp" scheme="https://www.embermoth.blog/tags/smoltcp/"/>
    <category term="tun" scheme="https://www.embermoth.blog/tags/tun/"/>
    <category term="tcp" scheme="https://www.embermoth.blog/tags/tcp/"/>
    <category term="clash-rs" scheme="https://www.embermoth.blog/tags/clash-rs/"/>
    <category term="nat" scheme="https://www.embermoth.blog/tags/nat/"/>
    <category term="network-extension" scheme="https://www.embermoth.blog/tags/network-extension/"/>
    <category term="ios" scheme="https://www.embermoth.blog/tags/ios/"/>
    <category term="android" scheme="https://www.embermoth.blog/tags/android/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong> — 移动端隧道进程的内存墙让用户态 TCP 栈（smoltcp）成了负担：每条连接要背几百 KiB 的固定缓冲，还要自己处理重传、拥塞和窗口。system netstack 换了一条路：不实现 TCP，而是把 TUN 里读到的 TCP 包<strong>原地做源 NAT</strong>，改写后写回 TUN，让内核把它当成发给本机 listener 的连接；accept 出来的普通 socket 再按 NAT 表里恢复的原始五元组交给分发器。代价是每个 TCP 载荷要穿越 TUN 两次，第一版因此比 smoltcp 慢 40%，靠 GRO&#x2F;GSO 批量 I&#x2F;O 追回来；收益是每条连接的用户态内存从 1 MiB 降到一个约 100 字节的 NAT 表项。这篇文章记录设计、那些只有真机才会教你的坑，以及实测数字。</p></blockquote><hr> <span id="more"></span><h2 id="一、问题：移动端隧道进程的内存墙"><a href="#一、问题：移动端隧道进程的内存墙" class="headerlink" title="一、问题：移动端隧道进程的内存墙"></a>一、问题：移动端隧道进程的内存墙</h2><p>TUN 模式的代理客户端要在用户态解析 IP 包并把 TCP 流转发出去。常见做法是跑一个用户态 TCP&#x2F;IP 栈，比如 smoltcp、gVisor 的 netstack 或 lwIP。clash-rs 用的是 smoltcp。</p><p>桌面上这没什么问题。到了 iOS，情况完全不同：Packet Tunnel 运行在 Network Extension 进程里，系统给这个进程的内存上限只有几十 MB，超了直接被杀，用户看到的就是”VPN 掉线”。Android 的 VpnService 宽松一些，但仍然是后台进程的预算。</p><p>在这个预算里跑 smoltcp，有两个结构性问题：</p><ol><li><strong>每条 TCP 连接的固定内存</strong>。clash-rs 原版为每条 TUN 连接分配 2 × 256 KiB 的 smoltcp <code>SocketBuffer</code> 加 2 × 256 KiB 的 relay ring buffer，也就是 1 MiB，而且全部零初始化。64 条并发连接就是 64 MiB，一个网页打开就能把 Extension 打爆。我们后来在移动端把它收到每块 64 到 128 KiB，但线性增长的形状不变。</li><li><strong>用户态要自己扛 TCP 的全部行为</strong>。重传、拥塞控制、窗口缩放、SACK、弱网恢复，这些内核已经做了三十年的事，smoltcp 需要在移动端的休眠、切网、弱网条件下重新被验证一遍。</li></ol><p>而内核的 TCP 栈就在旁边，免费、成熟、内存不算在我们进程头上。问题只是：TUN 里读出来的是 IP 包，不是 socket。</p><h2 id="二、思路：不实现-TCP，而是把-TCP-包”骗”给内核"><a href="#二、思路：不实现-TCP，而是把-TCP-包”骗”给内核" class="headerlink" title="二、思路：不实现 TCP，而是把 TCP 包”骗”给内核"></a>二、思路：不实现 TCP，而是把 TCP 包”骗”给内核</h2><p>这个思路不是我们发明的，灵感来自 sing-box 的 <code>stack: system</code>。核心只有一句话：</p><blockquote><p><strong>把每个 TCP 包的源地址改成一个本机可路由的地址，目的地址改成本机一个正在 listen 的端口，再写回 TUN。内核会认为这是一条发给本机的连接，替我们完成整个 TCP。</strong></p></blockquote><p>具体涉及四个地址角色：</p><table><thead><tr><th>角色</th><th>举例</th><th>说明</th></tr></thead><tbody><tr><td>客户端地址</td><td><code>10.0.0.9:12345</code></td><td>App 发包时的源地址</td></tr><tr><td>网关地址</td><td><code>198.18.0.1</code></td><td>TUN 接口自己的地址，也是内核 listener 绑定的地址</td></tr><tr><td>NAT 地址</td><td><code>198.18.0.2</code></td><td>网关 + 1，改写后的源地址，必须在 TUN 前缀内，且不能是网络地址或广播地址</td></tr><tr><td>listener 端口</td><td><code>40000</code></td><td>内核 listener 的端口，启动时随机绑定</td></tr></tbody></table><p>数据流是这样的：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line">上行（App -&gt; 远端）</span><br><span class="line"></span><br><span class="line">  App 发出  10.0.0.9:12345 -&gt; 1.1.1.1:443</span><br><span class="line">      |</span><br><span class="line">      v  TUN 读出</span><br><span class="line">  改写为    198.18.0.2:P    -&gt; 198.18.0.1:40000      P = NAT 表分配的端口</span><br><span class="line">      |</span><br><span class="line">      v  写回 TUN</span><br><span class="line">  内核路由：目的是本机地址，投递给 listener</span><br><span class="line">      |</span><br><span class="line">      v</span><br><span class="line">  accept() 得到一条普通 TcpStream，peer = 198.18.0.2:P</span><br><span class="line">      |</span><br><span class="line">      v  用 P 反查 NAT 表</span><br><span class="line">  恢复原始五元组 10.0.0.9:12345 -&gt; 1.1.1.1:443，交给分发器 / 出站</span><br><span class="line"></span><br><span class="line">下行（内核 listener 的回包）</span><br><span class="line"></span><br><span class="line">  内核发出  198.18.0.1:40000 -&gt; 198.18.0.2:P</span><br><span class="line">      |</span><br><span class="line">      v  路由回 TUN，被我们读出</span><br><span class="line">  改写为    1.1.1.1:443      -&gt; 10.0.0.9:12345</span><br><span class="line">      |</span><br><span class="line">      v  写回 TUN</span><br><span class="line">  App 看到的是&quot;服务器直接回了我&quot;</span><br></pre></td></tr></table></figure><p>为什么 NAT 地址必须是网关 + 1 且在同一前缀内？因为 listener 的回包目的地址是 NAT 地址，内核要把它路由回 TUN 接口，我们才有机会读出来改写。如果 NAT 地址不在 TUN 前缀里，回包会走默认路由，整条链就断了。</p><p>UDP 不走这条路。UDP 包原样交给已有的 IP 报文编解码器和 datagram 分发路径，DNS 劫持等逻辑一个字节都不用改。ICMP 只处理一种：发给网关地址的 echo request，原地改成 reply 写回，其余丢弃。</p><h2 id="三、每个包上发生了什么"><a href="#三、每个包上发生了什么" class="headerlink" title="三、每个包上发生了什么"></a>三、每个包上发生了什么</h2><h3 id="1）解析：只做必要的校验，拒绝处理不了的形态"><a href="#1）解析：只做必要的校验，拒绝处理不了的形态" class="headerlink" title="1）解析：只做必要的校验，拒绝处理不了的形态"></a>1）解析：只做必要的校验，拒绝处理不了的形态</h3><p>IPv4 只看头长、总长和分片标志。分片包直接拒绝：端口只在第一个分片里，要正确处理必须重组，我们不做。</p><p>IPv6 要走一遍扩展头链：Hop-by-Hop、Routing、Destination Options 按 8 字节单位跳过，Authentication Header 按 4 字节单位跳过，遇到 Fragment（44）或 ESP（50）拒绝，前者需要重组，后者根本看不到端口。</p><p>所有下标访问前都有长度检查。这一层是纯字节操作，没有 I&#x2F;O，没有共享状态，所以能用最朴素的单元测试覆盖。</p><h3 id="2）分类：这个包是上行还是下行"><a href="#2）分类：这个包是上行还是下行" class="headerlink" title="2）分类：这个包是上行还是下行"></a>2）分类：这个包是上行还是下行</h3><p>判断下行只需要三个条件同时成立：源地址等于网关地址，目的地址等于 NAT 地址，源端口等于 listener 端口。其余全部按上行处理。</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> <span class="variable">rewritten</span> = <span class="keyword">if</span> view.source == tun_address</span><br><span class="line">    &amp;&amp; view.destination == nat_address</span><br><span class="line">    &amp;&amp; source_port == listener_port</span><br><span class="line">&#123;</span><br><span class="line">    <span class="comment">// 下行：用目的端口（NAT 端口）反查原始五元组</span></span><br><span class="line">    <span class="keyword">let</span> <span class="variable">entry</span> = nat.<span class="title function_ invoke__">lookup_back</span>(destination_port)?;</span><br><span class="line">    <span class="title function_ invoke__">rewrite_ipv4_tcp</span>(packet, header_len, entry.key.destination, entry.key.source)</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="comment">// 上行：按五元组查或分配 NAT 端口</span></span><br><span class="line">    <span class="keyword">let</span> <span class="variable">key</span> = TcpKey &#123; source, destination &#125;;</span><br><span class="line">    <span class="keyword">let</span> <span class="variable">port</span> = nat.<span class="title function_ invoke__">lookup_or_insert_packet</span>(key, <span class="title function_ invoke__">is_initial_tcp_syn</span>(packet, tcp))?;</span><br><span class="line">    <span class="title function_ invoke__">rewrite_ipv4_tcp</span>(</span><br><span class="line">        packet,</span><br><span class="line">        header_len,</span><br><span class="line">        SocketAddrV4::<span class="title function_ invoke__">new</span>(nat_address, port),</span><br><span class="line">        SocketAddrV4::<span class="title function_ invoke__">new</span>(tun_address, listener_port),</span><br><span class="line">    )</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>下行查不到映射的包直接丢弃，而不是带着一个错误的五元组转发出去。</p><h3 id="3）改写：RFC-1624-增量校验和"><a href="#3）改写：RFC-1624-增量校验和" class="headerlink" title="3）改写：RFC 1624 增量校验和"></a>3）改写：RFC 1624 增量校验和</h3><p>改写四元组要同时更新 IP 头校验和（IPv4）和 TCP 校验和。全量重算 TCP 校验和要扫一遍载荷，1500 字节的包就是 750 次加法。RFC 1624 的增量更新只需要对被替换的字做减旧加新：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// 把一次 16 位字替换累加到未折叠的校验和上。</span></span><br><span class="line"><span class="comment">/// 先把所有替换加完再折叠，避免 IPv4 折叠 6 次、IPv6 折叠 18 次。</span></span><br><span class="line"><span class="meta">#[inline]</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">checksum_add_replacement</span>(sum: &amp;<span class="keyword">mut</span> <span class="type">u32</span>, old: <span class="type">u16</span>, new: <span class="type">u16</span>) &#123;</span><br><span class="line">    *sum += (!old <span class="keyword">as</span> <span class="type">u32</span>) + new <span class="keyword">as</span> <span class="type">u32</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">#[inline]</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">checksum_finish_replacements</span>(sum: <span class="type">u32</span>) <span class="punctuation">-&gt;</span> <span class="type">u16</span> &#123;</span><br><span class="line">    !<span class="title function_ invoke__">checksum_fold</span>(sum)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>IPv4 要替换 4 个地址字加 2 个端口字，IPv6 要替换 16 个地址字加 2 个端口字（地址参与 TCP 伪首部）。成本与载荷大小无关。</p><p>一个容易漏的细节：TCP 校验和算出来是 0 时要写成 <code>0xFFFF</code>，因为 0 在 UDP 里表示”无校验和”，部分实现对 TCP 也会拒绝。</p><p>改写在接收 buffer 上<strong>原地</strong>完成。同一块 buffer 改完直接写回 TUN，TCP 路径上没有一次载荷拷贝，也没有一次 channel 传递。</p><h3 id="4）处置：三种结果"><a href="#4）处置：三种结果" class="headerlink" title="4）处置：三种结果"></a>4）处置：三种结果</h3><p>处理函数对每个包返回一个处置：<code>WriteBack(len)</code> 原地改写完成，把这么多字节写回设备；<code>ForwardUdp</code> 把整个 IP 包移交给 UDP 编解码器；<code>Done</code> 丢弃。I&#x2F;O 驱动只需要根据处置动作，不需要知道协议细节，这让 Linux 批量、macOS 批量、移动端逐包三套驱动共享同一段处理逻辑。</p><h2 id="四、NAT-表：把状态机写清楚比写快更重要"><a href="#四、NAT-表：把状态机写清楚比写快更重要" class="headerlink" title="四、NAT 表：把状态机写清楚比写快更重要"></a>四、NAT 表：把状态机写清楚比写快更重要</h2><p>NAT 表是这个设计里唯一的共享可变状态，所有难写的 bug 都在这里。</p><h3 id="1）结构"><a href="#1）结构" class="headerlink" title="1）结构"></a>1）结构</h3><p>双向映射：正向表按五元组查 NAT 端口，反向表按 NAT 端口查五元组。两张表都分 64 片，按哈希取片，让并发流不挤同一把锁。锁序固定为<strong>正向先于反向</strong>，<code>lookup_or_insert</code> 持有正向锁时取反向锁，任何需要两把锁的地方要么按这个顺序，要么先释放一把。</p><h3 id="2）三个状态"><a href="#2）三个状态" class="headerlink" title="2）三个状态"></a>2）三个状态</h3><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">enum</span> <span class="title class_">TcpEntryState</span> &#123;</span><br><span class="line">    HalfOpen,                   <span class="comment">// 已分配端口，内核 listener 尚未 accept</span></span><br><span class="line">    Active,                     <span class="comment">// 已 accept，流正在分发器里</span></span><br><span class="line">    Closing &#123; since: Instant &#125;, <span class="comment">// 流已结束，等内核的 FIN/ACK 翻译完</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>为什么需要 <code>Closing</code>？当分发器里的 TcpStream 被 drop，内核才开始关闭这条 accept 出来的 socket，它发出的 FIN 和最终 ACK 仍然要经过下行改写才能到达 App。如果流一结束就删映射，这些包查不到映射被丢弃，App 只能等自己的超时。所以映射要在 <code>Closing</code> 状态多活一个 linger 窗口（10 秒），由周期清理回收。</p><h3 id="3）generation：防止旧定时器拆掉新连接"><a href="#3）generation：防止旧定时器拆掉新连接" class="headerlink" title="3）generation：防止旧定时器拆掉新连接"></a>3）generation：防止旧定时器拆掉新连接</h3><p>同一个五元组的连接可以关掉再重连。如果重连复用了同一个 NAT 端口，旧连接的 linger 清理就可能把新连接的映射删掉；更糟的是，复用刚关闭的端口会撞上内核 socket 的 TIME_WAIT。</p><p>解法是两条：每次 <code>accept</code> 给表项打一个递增的 generation，清理和标记操作都带 generation，不匹配就不动；一个不带 ACK 的 SYN 到来时，如果现有映射处于 <code>Closing</code>，直接删掉它并分配一个<strong>不同的</strong>端口。</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// SYN 且无 ACK 才算新连接。SYN 重传仍复用 HalfOpen 映射，</span></span><br><span class="line"><span class="comment">/// 这个信号只用来替换分发器已标记为 Closing 的映射。</span></span><br><span class="line"><span class="meta">#[inline]</span></span><br><span class="line"><span class="keyword">fn</span> <span class="title function_">is_initial_tcp_syn</span>(packet: &amp;[<span class="type">u8</span>], tcp_offset: <span class="type">usize</span>) <span class="punctuation">-&gt;</span> <span class="type">bool</span> &#123;</span><br><span class="line">    packet.<span class="title function_ invoke__">get</span>(tcp_offset + <span class="number">13</span>).<span class="title function_ invoke__">is_some_and</span>(|flags| flags &amp; <span class="number">0x12</span> == <span class="number">0x02</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="4）清理不能黑洞活连接"><a href="#4）清理不能黑洞活连接" class="headerlink" title="4）清理不能黑洞活连接"></a>4）清理不能黑洞活连接</h3><p>周期清理先在各分片里收集过期端口，释放锁，再逐个删除。收集与删除之间，同一条流的新包可能刷新了 <code>last_seen</code>，或者 <code>accept</code> 已经把它变成 <code>Active</code>。删除前必须再检查一次”仍然过期”，否则下行路径会找不到映射，把内核发来的每个包都丢掉，这条活连接就被黑洞了。</p><h3 id="5）有界"><a href="#5）有界" class="headerlink" title="5）有界"></a>5）有界</h3><p>表项有预算，耗尽时返回错误而不是继续分配。分配新端口时探测上限 4096 次：表接近满时如果没有这个上限，一个包就会在持锁状态下扫完 55000 个端口。NAT 表项的预算与”活跃 TCP 会话”的准入预算是分开的：一次浏览器突发会同时产生大量 SYN、半开和 linger 中的映射，早期把两者绑在一起时，iOS 上 256 个五元组就把整张表耗尽了，而每个表项其实只有 100 字节。</p><h3 id="6）任务结束时的兜底"><a href="#6）任务结束时的兜底" class="headerlink" title="6）任务结束时的兜底"></a>6）任务结束时的兜底</h3><p><code>Active</code> 表项只有在会话任务正常返回时才被标记 <code>Closing</code>。分发器里一个被 tokio 吞掉的 panic，或者一次 abort，就会留下一个永远不会被回收的 <code>Active</code> 表项。修法是一个持有 NAT 引用、端口和 generation 的 guard，<code>Drop</code> 里调 <code>mark_closing</code>，任务无论怎么结束都会执行。</p><h2 id="五、accept-侧：内核给我们一条流，我们还它一个五元组"><a href="#五、accept-侧：内核给我们一条流，我们还它一个五元组" class="headerlink" title="五、accept 侧：内核给我们一条流，我们还它一个五元组"></a>五、accept 侧：内核给我们一条流，我们还它一个五元组</h2><p>listener 绑在网关地址的 0 端口，backlog 取自平台队列预算。accept 循环做四件事：</p><ol><li><strong>只接受来自 NAT 地址的对端</strong>。listener 对本机其他流量也可达，不是我们改写出来的连接直接丢。</li><li><strong>一个临界区里完成”查五元组 + 标 Active + 打 generation”</strong>。分开做的话，清理可能在两步之间把表项回收。</li><li><strong>准入预算</strong>。拿不到许可时，不是删映射，而是 drop socket 并把映射标为 <code>Closing</code>：内核已经完成了握手，drop 会让它发 FIN 或 RST，这个包经过下行改写能到达 App，App 立刻看到连接失败。删映射的效果是相反的，App 会对着黑洞重传到超时。</li><li><strong>错误分类</strong>。<code>EMFILE</code>、<code>ENFILE</code>、<code>ENOBUFS</code>、<code>ENOMEM</code> 是资源耗尽，退避 50 ms 再试并限频告警；<code>EINTR</code>、<code>EPROTO</code>、连接被对端中止是瞬时错误，1 ms 后重试；其他视为致命，整个栈退出并把错误向上报。</li></ol><p>accept 出来的 <code>TcpStream</code> 用恢复的原始五元组构造会话，走和 smoltcp 栈完全相同的 <code>dispatch_stream</code>。分发器、路由、出站对两个栈没有任何区别。</p><h2 id="六、真机教我们的一课：Darwin-上-listener-必须绑到-utun"><a href="#六、真机教我们的一课：Darwin-上-listener-必须绑到-utun" class="headerlink" title="六、真机教我们的一课：Darwin 上 listener 必须绑到 utun"></a>六、真机教我们的一课：Darwin 上 listener 必须绑到 utun</h2><p>这个坑值得单独一节，因为它在 Linux 上完全不存在，而且症状极具误导性。</p><p>iOS 真机上的现象：packet rewrite 的计数一直在涨，说明上行改写和写回都在工作；但 TCP accept 的计数始终为零。抓包能看到改写后的 SYN 进了 utun，却没有任何回应。</p><p>原因是 Darwin 的路由与 socket 绑定语义。listener 只绑了地址没绑接口，内核不把从 utun 重新注入的 SYN 与这个 listener 关联起来。sing-box 在 Darwin 上有一个 <code>ForwarderBindInterface</code> 的设计，做的正是这件事。修法是 bind 之前用 <code>IP_BOUND_IF</code> &#x2F; <code>IPV6_BOUND_IF</code> 把 socket 绑到实际的 utun 接口：</p><figure class="highlight rust"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> <span class="variable">socket</span> = Socket::<span class="title function_ invoke__">new</span>(Domain::IPV4, SocketType::STREAM, <span class="title function_ invoke__">Some</span>(Protocol::TCP))?;</span><br><span class="line"><span class="meta">#[cfg(any(target_os = <span class="string">&quot;ios&quot;</span>, target_os = <span class="string">&quot;macos&quot;</span>))]</span></span><br><span class="line"><span class="keyword">if</span> <span class="keyword">let</span> <span class="variable">Some</span>(interface_index) = interface_index &#123;</span><br><span class="line">    <span class="comment">// 没有 IP_BOUND_IF，iOS Network Extension 可以成功把 SYN 写回 utun，</span></span><br><span class="line">    <span class="comment">// 内核却永远不会把它交给这个 listener，引擎显示 Running 而 TCP 会话为零。</span></span><br><span class="line">    socket.<span class="title function_ invoke__">bind_device_by_index_v4</span>(<span class="title function_ invoke__">Some</span>(interface_index))?;</span><br><span class="line">&#125;</span><br><span class="line">socket.<span class="title function_ invoke__">bind</span>(&amp;SockAddr::<span class="title function_ invoke__">from</span>(SocketAddrV4::<span class="title function_ invoke__">new</span>(address, <span class="number">0</span>)))?;</span><br><span class="line">socket.<span class="title function_ invoke__">listen</span>(backlog)?;</span><br></pre></td></tr></table></figure><p>接口索引怎么拿？iOS 上 <code>tun-rs</code> 故意不暴露设备名，要对复制出来的 raw fd 调 <code>getsockopt(SYSPROTO_CONTROL, UTUN_OPT_IFNAME)</code> 读出 <code>utunN</code>，再 <code>if_nametoindex</code>。</p><p>顺着这个坑我们还补了两条启动约束：</p><ul><li><strong>就绪信号必须晚于 listener 绑定成功</strong>。之前 TUN 一起来就报 ready，上层看到的是”运行中”，实际上一条 TCP 都进不来。</li><li><strong>启动前对照真实接口校验配置的网关地址和掩码</strong>。listener 绑网关地址，NAT 地址从前缀推导，设备实际地址不一致就会绑到一个它没有的地址。Apple 上接口地址有时晚于 fd 出现，所以校验带 10 次、间隔 50 ms 的重试。</li></ul><p>IPv6 还有一个小坑：地址可能处于 DAD 的 tentative 状态，<code>bind</code> 返回 <code>EADDRNOTAVAIL</code>。Linux 用 <code>IPV6_FREEBIND</code> 绕过；其他平台回退到绑 <code>::</code>，靠 accept 循环的对端过滤兜底。</p><h2 id="七、性能：第一版比-smoltcp-慢，原因是设备操作翻倍"><a href="#七、性能：第一版比-smoltcp-慢，原因是设备操作翻倍" class="headerlink" title="七、性能：第一版比 smoltcp 慢，原因是设备操作翻倍"></a>七、性能：第一版比 smoltcp 慢，原因是设备操作翻倍</h2><h3 id="1）问题"><a href="#1）问题" class="headerlink" title="1）问题"></a>1）问题</h3><p>system 栈有一个结构性成本：<strong>每个 TCP 载荷要穿越 TUN 两次</strong>。上行被改写后重新注入，listener 的回包又经 TUN 读出、改写、再注入。smoltcp 栈从不重注入上行，它的用户态 TCP 直接消费。同样的载荷，system 栈的设备操作大约是两倍。</p><p>在 Linux 上用 iperf3 测第一版，一个 syscall 一个包，结果是 1、16、64 并发流分别比 smoltcp 慢 6.6%、40.5%、43.6%。原因不是 CPU，是设备队列在并发下被打满，丢包换来 TCP 重传。</p><h3 id="2）批量-I-O"><a href="#2）批量-I-O" class="headerlink" title="2）批量 I&#x2F;O"></a>2）批量 I&#x2F;O</h3><p>修法是 <code>tun-rs</code> 的 offload API：<code>recv_multiple</code> 一次 GRO 读（最多 64 KiB）拆成多个包，<code>send_multiple</code> 把多个包合并成更少更大的写。两个细节：</p><ul><li><strong><code>writev</code> 在这里没用</strong>。TUN 是字符设备，一次向量写仍然只组装一个包，只有 GRO&#x2F;GSO 能摊薄 syscall。</li><li><strong>接收 buffer 的数量要按 MSS 下限算，不是按 MTU</strong>。<code>recv_multiple</code> 按流的 MSS 切分一次 GRO 读，MSS 可能远小于 MTU（低 MTU 路径、<code>advmss</code> 路由、<code>TCP_MAXSEG</code>）。按 MTU 算的数量在 MSS 低于 512 时会溢出报 <code>ErrTooManySegments</code>；按 48 字节的 <code>TCP_MIN_SND_MSS</code> 算，64 KiB 需要 1367 个 buffer。同时把这个错误当成可丢弃的单次读错误而不是致命错误，对抗性的小分段最多导致丢包，不会拆掉整个后端。</li></ul><p>我们还实现并测了一个 ping-pong 方案：读写分在两个任务，来回交换 buffer 组，读永远不被写阻塞。结果更差：三档并发分别 +7.6%、−19.0%、−32.0%。每批一次 channel 往返的调度开销，比它省下的写阻塞更贵。代码里留了注释，免得下一个人再推导一遍。</p><h3 id="3）三套驱动，一份处理逻辑"><a href="#3）三套驱动，一份处理逻辑" class="headerlink" title="3）三套驱动，一份处理逻辑"></a>3）三套驱动，一份处理逻辑</h3><table><thead><tr><th>平台</th><th>驱动</th><th>说明</th></tr></thead><tbody><tr><td>Linux</td><td>GRO&#x2F;GSO 批量</td><td><code>recv_multiple</code> &#x2F; <code>send_multiple</code>，TCP 复用接收 buffer 写回，UDP 因批次复用接收区做一次受预算约束的拷贝</td></tr><tr><td>macOS</td><td><code>recvmsg_x</code> &#x2F; <code>sendmsg_x</code></td><td>每条消息仍是一个 IP 包，pool buffer 原地改写零拷贝，批量摊薄 syscall 与唤醒</td></tr><tr><td>iOS &#x2F; Android &#x2F; Windows</td><td>逐包</td><td>设备每次只给一个包；一次就绪边沿内同步排空最多 32 个包，写用 <code>try_send</code> 快路径，满了再走异步等待</td></tr></tbody></table><p>三套驱动都只有一个任务同时持有设备的读写：读出来的 buffer 改写后直接写回，没有 channel、没有拷贝。UDP 在移动端直接移交 buffer 所有权给编解码器，只有 Linux GRO 路径因为要复用接收区才拷一次。</p><h3 id="4）数字"><a href="#4）数字" class="headerlink" title="4）数字"></a>4）数字</h3><p>Linux 6.18（WSL2），i7-12650H，release 构建，两个 netns 用 veth 相连，iperf3，双方 TUN <code>txqueuelen</code> 都设为 10000，取稳态每秒区间的中位数。不经 TUN 的 veth 直连基线约 66 Gbps。</p><table><thead><tr><th>并发流</th><th>smoltcp</th><th>system</th><th>差值</th><th>smoltcp RSS</th><th>system RSS</th></tr></thead><tbody><tr><td>1</td><td>3.39 Gbps</td><td>4.82 Gbps</td><td>+42.2%</td><td>18 MB</td><td>18 MB</td></tr><tr><td>16</td><td>4.36 Gbps</td><td>4.68 Gbps</td><td>+7.3%</td><td>47 MB</td><td>18 MB</td></tr><tr><td>64</td><td>4.13 Gbps</td><td>4.09 Gbps</td><td>−0.8%</td><td>137 MB</td><td>19 MB</td></tr></tbody></table><p>吞吐上两者在高并发时打平，单流 system 更快。真正的差别在内存，而且是结构性的：system 栈不为连接分配任何用户态缓冲，一条连接的成本就是一个约 100 字节的 NAT 表项；smoltcp 的 137 MB 会随连接数继续线性增长。</p><p>一个还没完全解决的毛边：64 流时 system 栈仍记录约 7.4 万次设备 TX 丢包，尽管吞吐已经与 smoltcp 持平。把 <code>txqueuelen</code> 继续调大丢包归零、system 略微领先，所以这是突发吸收能力的问题，不是吞吐上限。顺带一提，默认的 <code>txqueuelen</code> 500 对多 Gbps 来说两个栈都太小，smoltcp 在 16&#x2F;64 流时也丢 3.9 万 &#x2F; 6.7 万包。</p><h2 id="八、有界：移动端每个队列都要有上限"><a href="#八、有界：移动端每个队列都要有上限" class="headerlink" title="八、有界：移动端每个队列都要有上限"></a>八、有界：移动端每个队列都要有上限</h2><p>在 Extension 进程里，任何无界的东西迟早变成一次崩溃。system 栈里每一个可能增长的量都有预算，而且预算来自同一个进程级的 buffer 系统，与 smoltcp 栈共享：</p><ul><li>NAT 表项数、TCP 准入许可数、UDP 上行与下行队列深度、listener backlog，全部取自平台级的队列上限。</li><li>iOS 的 TCP 准入在正常、升高、危急三档内存压力下分别是 64、32、16。压力升高<strong>只阻止新会话</strong>，已有会话保留许可自然结束，不为回收内存强拆连接。</li><li>耗尽时丢弃或拒绝并计数，告警限频到 5 秒一次，绝不阻塞 TCP 路径。</li><li>入向统计按 64 个包或 100 ms 批量提交一次原子操作，空闲隧道不再每 100 ms 醒一次刷零计数。</li></ul><h2 id="九、代价与边界"><a href="#九、代价与边界" class="headerlink" title="九、代价与边界"></a>九、代价与边界</h2><p>这个方案不是零成本，选它之前要知道它的账单：</p><ul><li><strong>设备操作翻倍</strong>。CPU 与功耗在移动端还需要真机测量，Linux 上的吞吐数字不能直接推到 iOS。</li><li><strong>内核 socket 内存看不见</strong>。它不在 Rust 堆里，是否计入 Extension 进程的 <code>phys_footprint</code> 不能靠堆统计推断，发布前必须测实际峰值。</li><li><strong>不重组分片</strong>，IPv4 和 IPv6 都是。带扩展头的 IPv6 UDP 也会被丢弃，因为共享的 UDP 编解码器假定固定 40 字节头。</li><li><strong>ICMP 只回应网关</strong>。ping 隧道内任意远端不会有回应。</li><li><strong>不在运行中切换栈</strong>。启动失败返回稳定错误，不在半启动状态静默换数据面。</li><li><strong>桌面继续用 smoltcp</strong>。桌面没有 Extension 那样的内存墙，没必要为省内存多付一倍设备操作。两个栈共享 buffer 系统、UDP 路径、分发器和出站，切换只是一个配置字段。</li></ul><h2 id="十、测试怎么写"><a href="#十、测试怎么写" class="headerlink" title="十、测试怎么写"></a>十、测试怎么写</h2><p>这个模块最值钱的测试都不需要特权 TUN：</p><ul><li><strong>增量校验和对照全量重算</strong>，IPv4 和 IPv6 各一份。IPv6 的测试要从一个正确的校验和出发，因为对零校验和做增量更新结果看起来合理但其实是错的。</li><li><strong>下行恢复</strong>：一个从 listener 回到 NAT 地址的包，改写后源目的必须是原始服务器和客户端；<strong>没有映射的下行包必须被丢弃</strong>。</li><li><strong>NAT 竞态</strong>：清理已判定过期、删除前同一流的新包到达，映射必须存活；新 SYN 必须拿到与 Closing 映射不同的端口，旧 linger 定时器不能删掉重连。</li><li><strong>容量释放</strong>：预算为 1 时第二条流被拒并计数，清理后能再分配。</li><li><strong>Darwin 绑定</strong>：在 macOS 上把 listener 绑到 <code>lo0</code>，用 <code>getsockopt(IP_BOUND_IF)</code> 读回接口索引断言相等。</li><li><strong>准入与内存压力</strong>：进入压力不撤销已有许可，只有活跃数降到新上限以下才放行新会话。</li><li><strong>Criterion 基准</strong>：纯改写、稳定流 NAT 改写、1024 条新流批量三个场景，直接调用生产的解析、分片查表和改写函数，避免基准演化成第二套算法。</li></ul><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>system netstack 的全部想法可以压成一句话：</p><blockquote><p><strong>不要在内核旁边再写一个 TCP。把包改写成内核愿意接的样子，让它替你干活，你只负责记住每条连接原来是谁。</strong></p></blockquote><p>这条路上真正难的不是改写本身，那只是几十行字节操作。难的是三件事：把 NAT 表的状态机和它的竞态写清楚；接受”设备操作翻倍”这个结构性成本并用批量 I&#x2F;O 把它摊掉；以及承认有些坑只有真机才会教你，比如 Darwin 上那个 SYN 进得去、accept 永远为零的下午。</p><p>如果你也在移动端做 TUN 代理，被 Extension 的内存上限追着跑，这个方案值得试。但请先测，再下结论：Linux 上的数字是我们的，你的平台上的数字得是你的。</p><hr><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ul><li>clash-rs 上的实现与讨论：<span class="exturl" data-url="aHR0cHM6Ly9naXRodWIuY29tL2liaWdidWcvY2xhc2gtcnMvcHVsbC8xNDkx">feat(tun): add system (kernel) TCP stack option #1491<i class="fa fa-external-link-alt"></i></span></li><li>sing-box 的 <code>stack: system</code>，本设计的思路来源，以及 Darwin 上的 <code>ForwarderBindInterface</code></li><li>RFC 1624：Computation of the Internet Checksum via Incremental Update</li><li><span class="exturl" data-url="aHR0cHM6Ly9naXRodWIuY29tL3htaDA1MTEvdHVuLXJz">tun-rs<i class="fa fa-external-link-alt"></i></span>：Linux GRO&#x2F;GSO 批量收发 API</li></ul>]]>
    </content>
    <id>https://www.embermoth.blog/2026/09/26/system-netstack-kernel-tcp-for-tun/</id>
    <link href="https://www.embermoth.blog/2026/09/26/system-netstack-kernel-tcp-for-tun/"/>
    <published>2026-09-26T07:30:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong> — 移动端隧道进程的内存墙让用户态 TCP 栈（smoltcp）成了负担：每条连接要背几百 KiB 的固定缓冲，还要自己处理重传、拥塞和窗口。system netstack 换了一条路：不实现 TCP，而是把 TUN 里读到的 TCP 包<strong>原地做源 NAT</strong>，改写后写回 TUN，让内核把它当成发给本机 listener 的连接；accept 出来的普通 socket 再按 NAT 表里恢复的原始五元组交给分发器。代价是每个 TCP 载荷要穿越 TUN 两次，第一版因此比 smoltcp 慢 40%，靠 GRO&#x2F;GSO 批量 I&#x2F;O 追回来；收益是每条连接的用户态内存从 1 MiB 降到一个约 100 字节的 NAT 表项。这篇文章记录设计、那些只有真机才会教你的坑，以及实测数字。</p>
</blockquote>
<hr>]]>
    </summary>
    <title>把 TCP 交还给内核：TUN 代理里的 system netstack 设计与实测</title>
    <updated>2026-09-26T07:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ember Moth</name>
    </author>
    <category term="开发笔记" scheme="https://www.embermoth.blog/categories/%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0/"/>
    <category term="ppanel" scheme="https://www.embermoth.blog/tags/ppanel/"/>
    <category term="golang" scheme="https://www.embermoth.blog/tags/golang/"/>
    <category term="gin" scheme="https://www.embermoth.blog/tags/gin/"/>
    <category term="hertz" scheme="https://www.embermoth.blog/tags/hertz/"/>
    <category term="backend" scheme="https://www.embermoth.blog/tags/backend/"/>
    <category term="performance" scheme="https://www.embermoth.blog/tags/performance/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong> — 这次迁移不是“把 import 从 gin 改成 hertz”这么简单，而是一次分层完成的渐进式重构：先抽离 transport 层，再做 Gin 兼容层，最后把热点路由和关键中间件原生化到 Hertz。中间我们甚至短暂用 Fiber 验证过 fallback 方案。最重要的收获不是“换了框架”，而是沉淀出一套对存量 Go 服务可复制的迁移方法。</p></blockquote><hr> <span id="more"></span><h2 id="一、为什么我们决定从-Gin-迁到-Hertz"><a href="#一、为什么我们决定从-Gin-迁到-Hertz" class="headerlink" title="一、为什么我们决定从 Gin 迁到 Hertz"></a>一、为什么我们决定从 Gin 迁到 Hertz</h2><p><code>gin</code> 是一个非常成熟的框架，ppanel-server 早期能快速迭代，离不开它带来的开发效率。</p><p>迁移的原因并不是因为 Gin “不够好”，而是随着项目变大，我们开始更在意几个问题：</p><ol><li><strong>HTTP 传输层还有没有进一步压榨性能和分配开销的空间？</strong></li><li><strong>热点路由能不能更直接地贴近底层请求模型，而不是永远挂在一层通用适配之上？</strong></li><li><strong>如果以后要继续演进网络层，中间件和 handler 能不能不要再和具体框架强耦合？</strong></li><li><strong>能不能在不阻塞正常开发的情况下完成迁移？</strong></li></ol><p>在旧实现里，HTTP 启动逻辑是标准的 Gin 形态：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">New</span><span class="params">(svc *svc.ServiceContext)</span></span> *gin.Engine &#123;</span><br><span class="line">    initialize.StartInitSystemConfig(svc)</span><br><span class="line"></span><br><span class="line">    r := gin.Default()</span><br><span class="line">    r.RemoteIPHeaders = []<span class="type">string</span>&#123;<span class="string">&quot;X-Original-Forwarded-For&quot;</span>, <span class="string">&quot;X-Forwarded-For&quot;</span>, <span class="string">&quot;X-Real-IP&quot;</span>&#125;</span><br><span class="line"></span><br><span class="line">    sessionStore, err := redis.NewStore(...)</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="built_in">panic</span>(err)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    r.Use(sessions.Sessions(<span class="string">&quot;ppanel&quot;</span>, sessionStore))</span><br><span class="line">    r.Use(middleware.TraceMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware, gin.Recovery())</span><br><span class="line"></span><br><span class="line">    handler.RegisterHandlers(r, svc)</span><br><span class="line">    handler.RegisterSubscribeHandlers(r, svc)</span><br><span class="line">    handler.RegisterTelegramHandlers(r, svc)</span><br><span class="line">    handler.RegisterNotifyHandlers(r, svc)</span><br><span class="line">    <span class="keyword">return</span> r</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码本身没有问题，但它暴露了一个事实：</p><p><strong>路由注册、中间件、Request&#x2F;Response 语义、框架默认行为，全部绑在了 Gin 上。</strong></p><p>这意味着一旦要迁移，就不只是“换个 router”那么简单，而是要同时面对：</p><ul><li><code>Context</code> 语义迁移</li><li><code>ShouldBind</code> &#x2F; <code>ShouldBindQuery</code> &#x2F; <code>ShouldBindUri</code> 行为迁移</li><li>中间件签名迁移</li><li><code>http.Request</code> &#x2F; <code>http.ResponseWriter</code> 兼容问题</li><li><code>ClientIP</code>、<code>Recovery</code>、Header 写回等细节差异</li></ul><p>所以我们一开始就没有把这次工作定义成“框架替换”，而是定义成：</p><blockquote><p><strong>一次围绕 transport 层解耦的渐进式重构。</strong></p></blockquote><hr><h2 id="二、迁移前先定原则：不是重写，而是渐进替换"><a href="#二、迁移前先定原则：不是重写，而是渐进替换" class="headerlink" title="二、迁移前先定原则：不是重写，而是渐进替换"></a>二、迁移前先定原则：不是重写，而是渐进替换</h2><p>在真正动手前，我们先定了几条硬约束。</p><h3 id="1）业务逻辑层尽量不动"><a href="#1）业务逻辑层尽量不动" class="headerlink" title="1）业务逻辑层尽量不动"></a>1）业务逻辑层尽量不动</h3><p><code>logic</code>、<code>svc</code>、<code>repository</code> 这些层不应该感知 Gin 还是 Hertz。<br>变化应该尽量收敛在 transport 层和最外面的 handler 层。</p><h3 id="2）允许新旧路由共存"><a href="#2）允许新旧路由共存" class="headerlink" title="2）允许新旧路由共存"></a>2）允许新旧路由共存</h3><p>我们不接受“一天之内把几百个 handler 全改成 Hertz 原生”的方案。<br>迁移必须允许：</p><ul><li>一部分路由继续走旧语义</li><li>一部分路由已经切到原生 Hertz</li><li>两者可以长期共存一段时间</li></ul><h3 id="3）中间件迁移要和-handler-迁移解耦"><a href="#3）中间件迁移要和-handler-迁移解耦" class="headerlink" title="3）中间件迁移要和 handler 迁移解耦"></a>3）中间件迁移要和 handler 迁移解耦</h3><p>在存量项目里，中间件通常比 handler 更麻烦。</p><p>像这些逻辑都深度依赖框架上下文：</p><ul><li>鉴权</li><li>设备加解密</li><li>Trace</li><li>Logger</li><li>CORS</li><li>Notify 校验</li></ul><p>如果把中间件和 handler 绑成一个迁移批次，风险会非常高。<br>所以我们选择：</p><ul><li>先让老中间件继续跑</li><li>再逐步把高价值中间件原生化到 Hertz</li></ul><h3 id="4）迁移过程必须可回退"><a href="#4）迁移过程必须可回退" class="headerlink" title="4）迁移过程必须可回退"></a>4）迁移过程必须可回退</h3><p>只要迁移路径不可回退，它就一定会拖慢团队节奏。<br>我们需要的不是“最优雅的理论方案”，而是<strong>对线上系统最安全的工程方案</strong>。</p><hr><h2 id="三、迁移不是一跳完成的：我们甚至短暂经过了-Fiber"><a href="#三、迁移不是一跳完成的：我们甚至短暂经过了-Fiber" class="headerlink" title="三、迁移不是一跳完成的：我们甚至短暂经过了 Fiber"></a>三、迁移不是一跳完成的：我们甚至短暂经过了 Fiber</h2><p>这次迁移的 commit 时间线，真实地反映了我们的思考过程：</p><table><thead><tr><th>阶段</th><th>提交</th><th>目标</th></tr></thead><tbody><tr><td>1</td><td><code>9e61fc8</code></td><td>重构 Gin server 初始化</td></tr><tr><td>2</td><td><code>666f561</code></td><td>增加 Fiber transport 骨架</td></tr><tr><td>3</td><td><code>6fc3274</code></td><td>给 Fiber 增加 Gin fallback</td></tr><tr><td>4</td><td><code>662d269</code></td><td>用 Hertz 替换 Fiber transport</td></tr><tr><td>5</td><td><code>adda20b</code></td><td>批量把 Gin handlers 迁到 Hertz 兼容层</td></tr><tr><td>6</td><td><code>1c2f889</code></td><td>把热点路由切到原生 Hertz</td></tr><tr><td>7</td><td><code>5e60172</code></td><td>把关键中间件改成原生 Hertz 中间件</td></tr></tbody></table><p>很多人看到这里会疑惑：</p><blockquote><p>不是写 Gin 到 Hertz 的迁移吗，为什么中间会出现 Fiber？</p></blockquote><p>答案很简单：</p><p><strong>我们先验证的是“迁移策略”，不是“框架喜好”。</strong></p><p>在早期阶段，我们更关心的是：</p><ul><li>能不能把 transport 层独立出来？</li><li>能不能让新 transport 承接一部分路由？</li><li>能不能让其余路由继续 fallback 到旧栈？</li></ul><p>Fiber 在这里扮演的角色，其实更像是一个“迁移策略验证器”。<br>等这条路径走通后，我们再把底层 transport 实现换成真正要落地的 Hertz。</p><p>这一步的价值非常大，因为它证明了一件事：</p><blockquote><p><strong>迁移的关键，不是先选新框架，而是先证明系统允许你渐进替换。</strong></p></blockquote><hr><h2 id="四、第一步：先把-transport-层从业务里抽出来"><a href="#四、第一步：先把-transport-层从业务里抽出来" class="headerlink" title="四、第一步：先把 transport 层从业务里抽出来"></a>四、第一步：先把 transport 层从业务里抽出来</h2><p>我们先做的不是改 handler，而是改启动方式。</p><p>在 <code>internal/server.go</code> 里，我们把 HTTP 启动抽象成了一个很薄的 <code>transportServer</code> 接口：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> transportServer <span class="keyword">interface</span> &#123;</span><br><span class="line">    Start()</span><br><span class="line">    Shutdown(ctx context.Context) <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>然后由 <code>newTransportServer</code> 决定底层到底实例化哪个实现：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">newTransportServer</span><span class="params">(svc *svc.ServiceContext, addr <span class="type">string</span>)</span></span> transportServer &#123;</span><br><span class="line">    <span class="keyword">var</span> tlsConfig *tls.Config</span><br><span class="line">    <span class="keyword">if</span> svc.Config.TLS.Enable &#123;</span><br><span class="line">        cert, err := tls.LoadX509KeyPair(svc.Config.TLS.CertFile, svc.Config.TLS.KeyFile)</span><br><span class="line">        <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">            logger.Errorf(<span class="string">&quot;load tls certificate error: %s&quot;</span>, err.Error())</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">        &#125;</span><br><span class="line">        tlsConfig = &amp;tls.Config&#123;</span><br><span class="line">            MinVersion:   tls.VersionTLS12,</span><br><span class="line">            Certificates: []tls.Certificate&#123;cert&#125;,</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> httpserver.New(svc, addr, tlsConfig)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个改动的意义非常大：</p><ul><li><code>Service.Start()</code> 不再关心 Gin &#x2F; Fiber &#x2F; Hertz</li><li>启停、重启、TLS、trace agent 生命周期仍然保持一致</li><li>transport 层从“嵌在业务启动里”变成了“可替换实现”</li></ul><p>很多迁移项目失败，是因为一上来就碰路由和业务。<br>我们这次先做这一步，本质上是在给后面所有迁移动作打地基。</p><hr><h2 id="五、第二步：不要急着重写-handler，先做一个兼容层"><a href="#五、第二步：不要急着重写-handler，先做一个兼容层" class="headerlink" title="五、第二步：不要急着重写 handler，先做一个兼容层"></a>五、第二步：不要急着重写 handler，先做一个兼容层</h2><p>真正让迁移速度提起来的，不是 Hertz 本身，而是我们写的 <code>pkg/hertzx</code>。</p><p>这个包的目标很明确：</p><blockquote><p><strong>让大部分原来依赖 Gin 语义的代码，在最小改动下先跑起来。</strong></p></blockquote><p>它没有试图完整“复刻一个 Gin”，而是只兼容项目里真正用到的那一小部分能力：</p><ul><li><code>Context</code></li><li><code>HandlerFunc</code></li><li><code>Engine</code> &#x2F; <code>RouterGroup</code></li><li><code>Wrap()</code></li><li><code>ShouldBind()</code> &#x2F; <code>ShouldBindJSON()</code> &#x2F; <code>ShouldBindQuery()</code> &#x2F; <code>ShouldBindUri()</code></li><li><code>JSON()</code> &#x2F; <code>String()</code> &#x2F; <code>Redirect()</code> &#x2F; <code>Header()</code></li><li><code>Abort()</code> &#x2F; <code>Next()</code></li><li><code>http.Request</code> &#x2F; <code>http.ResponseWriter</code> 兼容桥接</li></ul><p>核心入口大概长这样：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> HandlerFunc <span class="function"><span class="keyword">func</span><span class="params">(*Context)</span></span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">Wrap</span><span class="params">(handler HandlerFunc)</span></span> app.HandlerFunc &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="function"><span class="keyword">func</span><span class="params">(base context.Context, ctx *app.RequestContext)</span></span> &#123;</span><br><span class="line">        c := NewContext(base, ctx)</span><br><span class="line">        handler(c)</span><br><span class="line">        c.flush()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里最关键的是 <code>NewContext()</code>。它做了两件事：</p><ol><li>把 Hertz 的 <code>RequestContext</code> 包成一个我们熟悉的 <code>*Context</code></li><li>构造一个兼容逻辑层的 <code>*http.Request</code></li></ol><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewContext</span><span class="params">(base context.Context, ctx *app.RequestContext)</span></span> *Context &#123;</span><br><span class="line">    c := &amp;Context&#123;</span><br><span class="line">        base:    base,</span><br><span class="line">        ctx:     ctx,</span><br><span class="line">        Request: compatRequest(base, ctx),</span><br><span class="line">        Writer:  newResponseWriter(ctx),</span><br><span class="line">        keys:    <span class="built_in">make</span>(<span class="keyword">map</span>[<span class="type">string</span>]<span class="keyword">interface</span>&#123;&#125;),</span><br><span class="line">    &#125;</span><br><span class="line">    ctx.Set(contextKey, c)</span><br><span class="line">    <span class="keyword">return</span> c</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这层兼容的直接收益是：</p><ul><li>大量 handler 可以继续保留原来的调用形态</li><li><code>handler.RegisterHandlers(engine, svc)</code> 这样的 generated route 注册代码几乎不用推倒重来</li><li>老中间件还能继续以 <code>func(c *hertzx.Context)</code> 的方式运行</li></ul><p>这让我们避免了最危险的一件事：</p><blockquote><p><strong>在路由切换的同时，重写整个 handler 语义。</strong></p></blockquote><hr><h2 id="六、第三步：把批量迁移的-handler-先挂到兼容层上"><a href="#六、第三步：把批量迁移的-handler-先挂到兼容层上" class="headerlink" title="六、第三步：把批量迁移的 handler 先挂到兼容层上"></a>六、第三步：把批量迁移的 handler 先挂到兼容层上</h2><p>有了 <code>hertzx.Engine</code> 和 <code>hertzx.RouterGroup</code> 之后，大部分原来面向 Gin 的 handler 注册代码可以整体平移。</p><p>比如现在的注册方式仍然保持原来的组织结构：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">handler.RegisterHandlers(engine, svc)</span><br><span class="line">handler.RegisterTelegramHandlers(engine, svc)</span><br><span class="line">handler.RegisterNotifyHandlers(engine, svc)</span><br></pre></td></tr></table></figure><p>这里的 <code>engine</code> 不是 Gin 的 <code>*gin.Engine</code>，而是我们封装过的 <code>*hertzx.Engine</code>。<br>它对外暴露的 <code>Group</code>、<code>GET</code>、<code>POST</code>、<code>Use</code> 接口，都尽量保持了原有使用习惯。</p><p>这一步的价值不是“优雅”，而是“低风险”：</p><ul><li>route 文件不需要全改</li><li>handler 文件不需要当天全改</li><li>中间件的演进顺序可以独立安排</li></ul><p>也正因为这样，我们才能把迁移拆成多个 commit，持续推进，而不是开一个长寿命分支大爆炸合并。</p><hr><h2 id="七、第四步：热点路由优先原生化到-Hertz"><a href="#七、第四步：热点路由优先原生化到-Hertz" class="headerlink" title="七、第四步：热点路由优先原生化到 Hertz"></a>七、第四步：热点路由优先原生化到 Hertz</h2><p>兼容层只是桥，不是终点。</p><p>真正对性能和协议细节最敏感的路径，最终还是要回到 Hertz 原生接口上。<br>所以接下来我们专门引入了 <code>RegisterNativeHandlers()</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">RegisterNativeHandlers</span><span class="params">(router *server.Hertz, serverCtx *svc.ServiceContext)</span></span> &#123;</span><br><span class="line">    subscribePath := serverCtx.Config.Subscribe.SubscribePath</span><br><span class="line">    <span class="keyword">if</span> subscribePath == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        subscribePath = <span class="string">&quot;/v1/subscribe/config&quot;</span></span><br><span class="line">    &#125;</span><br><span class="line">    router.GET(subscribePath, SubscribeHandler(serverCtx))</span><br><span class="line">    <span class="keyword">if</span> serverCtx.Config.Subscribe.PanDomain &#123;</span><br><span class="line">        router.GET(<span class="string">&quot;/&quot;</span>, PanDomainSubscribeHandler(serverCtx))</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    serverGroup := router.Group(<span class="string">&quot;/v1/server&quot;</span>, serverHandler.ServerMiddleware(serverCtx))</span><br><span class="line">    serverGroup.GET(<span class="string">&quot;/config&quot;</span>, serverHandler.GetServerConfigHandler(serverCtx))</span><br><span class="line">    serverGroup.POST(<span class="string">&quot;/online&quot;</span>, serverHandler.PushOnlineUsersHandler(serverCtx))</span><br><span class="line">    serverGroup.POST(<span class="string">&quot;/push&quot;</span>, serverHandler.ServerPushUserTrafficHandler(serverCtx))</span><br><span class="line">    serverGroup.POST(<span class="string">&quot;/status&quot;</span>, serverHandler.ServerPushStatusHandler(serverCtx))</span><br><span class="line">    serverGroup.GET(<span class="string">&quot;/user&quot;</span>, serverHandler.GetServerUserListHandler(serverCtx))</span><br><span class="line"></span><br><span class="line">    router.GET(<span class="string">&quot;/v2/server/:server_id&quot;</span>, serverHandler.QueryServerProtocolConfigHandler(serverCtx))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>我们优先迁到原生 Hertz 的，是两类接口：</p><h3 id="1）订阅类接口"><a href="#1）订阅类接口" class="headerlink" title="1）订阅类接口"></a>1）订阅类接口</h3><p>订阅路径通常访问频率高、对 header &#x2F; query &#x2F; body &#x2F; user-agent 很敏感，而且很多时候返回的不是标准 JSON，而是配置文本。</p><p><code>SubscribeHandler</code> 迁到原生 Hertz 后，可以直接从 <code>RequestContext</code> 构造请求对象：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">SubscribeHandler</span><span class="params">(svcCtx *svc.ServiceContext)</span></span> app.HandlerFunc &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="function"><span class="keyword">func</span><span class="params">(c context.Context, ctx *app.RequestContext)</span></span> &#123;</span><br><span class="line">        req := types.SubscribeRequest&#123;</span><br><span class="line">            Token:  <span class="type">string</span>(ctx.GetHeader(<span class="string">&quot;token&quot;</span>)),</span><br><span class="line">            UA:     <span class="type">string</span>(ctx.UserAgent()),</span><br><span class="line">            Flag:   ctx.Query(<span class="string">&quot;flag&quot;</span>),</span><br><span class="line">            Type:   ctx.Query(<span class="string">&quot;type&quot;</span>),</span><br><span class="line">            Params: getQueryMap(ctx),</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span> req.Token == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">            req.Token = ctx.Query(<span class="string">&quot;token&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">        writeSubscribeResponse(c, ctx, svcCtx, req)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这类代码用原生 Hertz 写起来更直接，也更容易看清楚请求模型到底是什么。</p><h3 id="2）节点-Server-上报接口"><a href="#2）节点-Server-上报接口" class="headerlink" title="2）节点 &#x2F; Server 上报接口"></a>2）节点 &#x2F; Server 上报接口</h3><p>像这些路径：</p><ul><li><code>/v1/server/config</code></li><li><code>/v1/server/user</code></li><li><code>/v1/server/online</code></li><li><code>/v1/server/push</code></li><li><code>/v1/server/status</code></li><li><code>/v2/server/:server_id</code></li></ul><p>本身就更偏“协议接口”，请求模型清晰，错误码和 header 行为也很明确。</p><p>我们把公共解析逻辑也顺手抽了出来：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ServerMiddleware</span><span class="params">(svcCtx *svc.ServiceContext)</span></span> app.HandlerFunc &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="function"><span class="keyword">func</span><span class="params">(c context.Context, ctx *app.RequestContext)</span></span> &#123;</span><br><span class="line">        key, ok := ctx.GetQuery(<span class="string">&quot;secret_key&quot;</span>)</span><br><span class="line">        <span class="keyword">if</span> ok &amp;&amp; key == svcCtx.Config.Node.NodeSecret &#123;</span><br><span class="line">            ctx.Next(c)</span><br><span class="line">            <span class="keyword">return</span></span><br><span class="line">        &#125;</span><br><span class="line">        ctx.String(consts.StatusForbidden, <span class="string">&quot;Forbidden&quot;</span>)</span><br><span class="line">        ctx.Abort()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">serverCommonRequest</span><span class="params">(ctx *app.RequestContext)</span></span> (types.ServerCommon, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">var</span> serverID <span class="type">int64</span></span><br><span class="line">    <span class="keyword">if</span> rawServerID := ctx.Query(<span class="string">&quot;server_id&quot;</span>); rawServerID != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        id, err := strconv.ParseInt(rawServerID, <span class="number">10</span>, <span class="number">64</span>)</span><br><span class="line">        <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">            <span class="keyword">return</span> types.ServerCommon&#123;&#125;, err</span><br><span class="line">        &#125;</span><br><span class="line">        serverID = id</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> types.ServerCommon&#123;</span><br><span class="line">        Protocol:  ctx.Query(<span class="string">&quot;protocol&quot;</span>),</span><br><span class="line">        ServerId:  serverID,</span><br><span class="line">        SecretKey: ctx.Query(<span class="string">&quot;secret_key&quot;</span>),</span><br><span class="line">    &#125;, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这一步有两个非常直接的收益：</p><ul><li>减少 compat 层的额外跳转开销</li><li>让协议细节变得明确，不再埋在一层通用适配里</li></ul><hr><h2 id="八、第五步：中间件原生化，兼容层只留给真正需要的地方"><a href="#八、第五步：中间件原生化，兼容层只留给真正需要的地方" class="headerlink" title="八、第五步：中间件原生化，兼容层只留给真正需要的地方"></a>八、第五步：中间件原生化，兼容层只留给真正需要的地方</h2><p>最后一层优化，是中间件。</p><p>在最终形态里，HTTP server 的核心初始化已经直接挂载原生 Hertz 中间件：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">newServer</span><span class="params">(svc *svc.ServiceContext, opts []config.Option)</span></span> *Server &#123;</span><br><span class="line">    engine := hertzx.Default(opts...)</span><br><span class="line">    engine.Hertz().Use(middleware.TraceMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware)</span><br><span class="line"></span><br><span class="line">    handler.RegisterNativeHandlers(engine.Hertz(), svc)</span><br><span class="line">    handler.RegisterHandlers(engine, svc)</span><br><span class="line">    handler.RegisterTelegramHandlers(engine, svc)</span><br><span class="line">    handler.RegisterNotifyHandlers(engine, svc)</span><br><span class="line">    <span class="keyword">return</span> &amp;Server&#123;h: engine.Hertz()&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里有一个非常重要的策略选择：</p><blockquote><p><strong>不是所有中间件都要在同一天原生化。</strong></p></blockquote><p>我们优先原生化的是：</p><ul><li><code>TraceMiddleware</code></li><li><code>LoggerMiddleware</code></li><li><code>CorsMiddleware</code></li></ul><p>原因很简单：</p><ul><li>它们是全局中间件</li><li>会作用到每一个请求</li><li>原生化收益最大</li><li>行为最值得统一到 Hertz 模型里</li></ul><p>例如 <code>TraceMiddleware</code> 直接使用 <code>app.RequestContext</code> 采集 OpenTelemetry 所需字段：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">TraceMiddleware</span><span class="params">(_ *svc.ServiceContext)</span></span> app.HandlerFunc &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="function"><span class="keyword">func</span><span class="params">(c context.Context, ctx *app.RequestContext)</span></span> &#123;</span><br><span class="line">        tracer := trace.TracerFromContext(c)</span><br><span class="line">        spanName := ctx.FullPath()</span><br><span class="line">        method := <span class="type">string</span>(ctx.Method())</span><br><span class="line"></span><br><span class="line">        c, span := tracer.Start(</span><br><span class="line">            c,</span><br><span class="line">            fmt.Sprintf(<span class="string">&quot;%s %s&quot;</span>, method, spanName),</span><br><span class="line">            oteltrace.WithSpanKind(oteltrace.SpanKindServer),</span><br><span class="line">        )</span><br><span class="line">        <span class="keyword">defer</span> span.End()</span><br><span class="line"></span><br><span class="line">        requestId := trace.TraceIDFromContext(c)</span><br><span class="line">        ctx.Header(trace.RequestIdKey, requestId)</span><br><span class="line">        ctx.Next(c)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>LoggerMiddleware</code> 也同样直接从 Hertz 的 request&#x2F;response 结构里取信息，并对节点 telemetry 路径做了专门裁剪，避免把大 body 全量打进日志。</p><p>而像 <code>AuthMiddleware</code>、<code>DeviceMiddleware</code> 这类更依赖历史 <code>Context</code> 语义的中间件，则继续先挂在 compat 层上。这样迁移风险最低。</p><p>这也是我们这次迁移中反复验证的一条经验：</p><blockquote><p><strong>迁移顺序应该由“风险”和“收益”共同决定，而不是按文件顺序决定。</strong></p></blockquote><hr><h2 id="九、迁移中最值得记录的几个坑"><a href="#九、迁移中最值得记录的几个坑" class="headerlink" title="九、迁移中最值得记录的几个坑"></a>九、迁移中最值得记录的几个坑</h2><p>如果只写“迁移完成、效果很好”，这篇文章就没价值了。</p><p>真正有价值的部分，恰恰是那些在存量系统里一定会踩到的坑。</p><h3 id="坑-1：兼容的不是-API，而是语义"><a href="#坑-1：兼容的不是-API，而是语义" class="headerlink" title="坑 1：兼容的不是 API，而是语义"></a>坑 1：兼容的不是 API，而是语义</h3><p><code>ShouldBind()</code> 看起来只是一个函数名兼容，但真正麻烦的是：</p><ul><li>query 从哪里取</li><li>body 什么时候被读掉</li><li>form &#x2F; json &#x2F; uri 的优先级是什么</li><li>下游看到的是 Hertz request，还是兼容出来的 <code>*http.Request</code></li></ul><p>在 <code>hertzx.Context</code> 里，我们最终做的是一层“项目足够用”的绑定适配：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(c *Context)</span></span> ShouldBind(obj <span class="keyword">interface</span>&#123;&#125;) <span class="type">error</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> isJSONRequest(c.Request) &#123;</span><br><span class="line">        <span class="keyword">return</span> c.ShouldBindJSON(obj)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> err := bindValues(obj, c.Request.URL.Query()); err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> err</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> <span class="built_in">len</span>(c.ctx.Request.Body()) == <span class="number">0</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> c.ctx.Bind(obj)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>注意这里不是“1:1 复刻 Gin”，而是<strong>针对项目当前真实用法做兼容</strong>。<br>这点非常关键。</p><h3 id="坑-2：请求被改写后，要同步回-Hertz-本体"><a href="#坑-2：请求被改写后，要同步回-Hertz-本体" class="headerlink" title="坑 2：请求被改写后，要同步回 Hertz 本体"></a>坑 2：请求被改写后，要同步回 Hertz 本体</h3><p>设备加解密中间件是这次迁移里最典型的案例。</p><p>它会在请求进入业务前解密 query&#x2F;body，然后把解密后的内容重新塞回请求对象。<br>如果你只改了兼容出来的 <code>*http.Request</code>，但没有同步回 Hertz 原生 request，那么后面的 binder 和 native handler 看到的仍然是旧数据。</p><p>所以我们专门补了两个同步函数：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">hertzx.SyncRequestURI(c)</span><br><span class="line">hertzx.SyncRequestBody(c)</span><br></pre></td></tr></table></figure><p>这类问题在迁移前很难凭空想到，只有在“兼容层 + 原生层并存”的阶段才会暴露出来。</p><h3 id="坑-3：Client-IP-语义不是默认一致的"><a href="#坑-3：Client-IP-语义不是默认一致的" class="headerlink" title="坑 3：Client IP 语义不是默认一致的"></a>坑 3：Client IP 语义不是默认一致的</h3><p>旧 Gin 初始化里我们显式配置过：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">r.RemoteIPHeaders = []<span class="type">string</span>&#123;<span class="string">&quot;X-Original-Forwarded-For&quot;</span>, <span class="string">&quot;X-Forwarded-For&quot;</span>, <span class="string">&quot;X-Real-IP&quot;</span>&#125;</span><br></pre></td></tr></table></figure><p>而 Hertz 默认关注的是 <code>X-Forwarded-For</code> 和 <code>X-Real-IP</code>。<br>如果你的反向代理链路里用到了 <code>X-Original-Forwarded-For</code>，迁移后一定要重新检查 <code>ClientIP()</code> 的语义，否则日志、风控、审计链路都可能出现偏差。</p><p>这个问题很隐蔽，因为本地调试通常看不出来，只有挂到真实代理链路上才会暴露。</p><h3 id="坑-4：最容易出问题的是-Header-写回"><a href="#坑-4：最容易出问题的是-Header-写回" class="headerlink" title="坑 4：最容易出问题的是 Header 写回"></a>坑 4：最容易出问题的是 Header 写回</h3><p>兼容层里最危险的一类 bug，通常不是业务逻辑 bug，而是协议层 bug。</p><p>比如：</p><ul><li>Header 重复追加</li><li><code>Location</code> 重复</li><li><code>ETag</code> 行为异常</li><li>自定义 header 被覆盖或多写</li></ul><p>原因通常是：</p><ul><li>一部分 header 写到了兼容 <code>ResponseWriter</code></li><li>一部分 header 又直接写到了 Hertz 原生 response</li><li>最后 flush 时如果再统一回写一次，而且是 <code>Add</code> 而不是 <code>Set</code>，就可能把同一个 header 重复追加</li></ul><p>这类问题在普通“返回 200 就算通过”的测试里很难暴露，<br>但在 <code>redirect</code>、<code>subscription-userinfo</code>、<code>ETag</code> 这类协议头上会非常明显。</p><p>这也是我对兼容层最大的一个体会：</p><blockquote><p><strong>兼容层的复杂度不在于“能不能跑”，而在于“协议语义会不会悄悄漂移”。</strong></p></blockquote><hr><h2 id="十、迁移完成后，我们怎么验证它是安全的"><a href="#十、迁移完成后，我们怎么验证它是安全的" class="headerlink" title="十、迁移完成后，我们怎么验证它是安全的"></a>十、迁移完成后，我们怎么验证它是安全的</h2><p>很多迁移项目的问题是：</p><ul><li>编译过了</li><li>几个页面点通了</li><li>就默认迁移成功</li></ul><p>但 HTTP 框架迁移真正容易出问题的，是那些“肉眼不一定看得出来”的协议细节。</p><p>所以这次我们专门补了 transport 层测试。</p><p>例如 <code>internal/transport/httpserver/server_test.go</code> 里就覆盖了这些场景：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">TestServerSecretMiddlewareBlocksMigratedPost</span><span class="params">(t *testing.T)</span></span> &#123;</span><br><span class="line">    app := newTestServer(<span class="string">&quot;secret&quot;</span>)</span><br><span class="line">    status, body := performNativeRequest(app, http.MethodPost, <span class="string">&quot;/v1/server/online?secret_key=wrong&quot;</span>)</span><br><span class="line">    <span class="keyword">if</span> status != http.StatusForbidden &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;expected status %d, got %d&quot;</span>, http.StatusForbidden, status)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> body != <span class="string">&quot;Forbidden&quot;</span> &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;expected forbidden body, got %q&quot;</span>, body)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>以及：</p><ul><li><code>server_id</code> 非法时返回 <code>400</code></li><li>secret key 错误时返回 <code>401/403</code></li><li>CORS 预检请求要能绕过 server secret 校验</li></ul><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">TestCorsPreflightBypassesServerSecretMiddleware</span><span class="params">(t *testing.T)</span></span> &#123;</span><br><span class="line">    app := newTestServer(<span class="string">&quot;secret&quot;</span>)</span><br><span class="line"></span><br><span class="line">    ctx := app.Engine().NewContext()</span><br><span class="line">    ctx.Request.SetRequestURI(<span class="string">&quot;/v1/server/online&quot;</span>)</span><br><span class="line">    ctx.Request.Header.SetMethod(http.MethodOptions)</span><br><span class="line">    ctx.Request.Header.Set(<span class="string">&quot;Origin&quot;</span>, <span class="string">&quot;https://example.com&quot;</span>)</span><br><span class="line">    app.Engine().ServeHTTP(context.Background(), ctx)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> status := ctx.Response.StatusCode(); status != http.StatusNoContent &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;expected status %d, got %d&quot;</span>, http.StatusNoContent, status)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>除了测试，我们还加了 benchmark 和 CI workflow：</p><ul><li><code>scripts/perf/bench.sh</code></li><li><code>.github/workflows/performance.yml</code></li></ul><p>benchmark 直接跑 <code>./internal/transport/httpserver</code>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">go <span class="built_in">test</span> ./internal/transport/httpserver \</span><br><span class="line">  -run <span class="string">&#x27;^$&#x27;</span> \</span><br><span class="line">  -bench <span class="string">&#x27;^BenchmarkHTTPServer&#x27;</span> \</span><br><span class="line">  -benchmem</span><br></pre></td></tr></table></figure><p>这样迁移就不再只是“感觉上更快”，而是有持续可比较的数据基线。</p><hr><h2 id="十一、这次迁移里最有价值的，不是换成了-Hertz"><a href="#十一、这次迁移里最有价值的，不是换成了-Hertz" class="headerlink" title="十一、这次迁移里最有价值的，不是换成了 Hertz"></a>十一、这次迁移里最有价值的，不是换成了 Hertz</h2><p>如果只从结果看，这次迁移确实是“Gin -&gt; Hertz”。</p><p>但如果从工程角度看，真正有价值的东西其实是下面这几条：</p><h3 id="1）先抽-transport，再换引擎"><a href="#1）先抽-transport，再换引擎" class="headerlink" title="1）先抽 transport，再换引擎"></a>1）先抽 transport，再换引擎</h3><p>没有这一层抽象，后面的所有迁移都会互相缠绕。</p><h3 id="2）兼容层不是终点，是桥梁"><a href="#2）兼容层不是终点，是桥梁" class="headerlink" title="2）兼容层不是终点，是桥梁"></a>2）兼容层不是终点，是桥梁</h3><p><code>hertzx</code> 的意义不是永远存在，而是让我们能安全跨过去。</p><h3 id="3）热点路径优先原生化"><a href="#3）热点路径优先原生化" class="headerlink" title="3）热点路径优先原生化"></a>3）热点路径优先原生化</h3><p>真正值得原生化的，不是“最容易改的路由”，而是：</p><ul><li>请求量高</li><li>协议敏感</li><li>compat 成本高</li><li>性能收益明显</li></ul><h3 id="4）中间件可以独立演进"><a href="#4）中间件可以独立演进" class="headerlink" title="4）中间件可以独立演进"></a>4）中间件可以独立演进</h3><p>没必要把 handler 和 middleware 绑在一个迁移批次里。</p><h3 id="5）协议级测试比页面回归更重要"><a href="#5）协议级测试比页面回归更重要" class="headerlink" title="5）协议级测试比页面回归更重要"></a>5）协议级测试比页面回归更重要</h3><p>HTTP 框架迁移最可怕的往往不是业务错误，而是 header、status、body、redirect 语义漂移。</p><hr><h2 id="十二、如果让我再重来一次，我会更早做这三件事"><a href="#十二、如果让我再重来一次，我会更早做这三件事" class="headerlink" title="十二、如果让我再重来一次，我会更早做这三件事"></a>十二、如果让我再重来一次，我会更早做这三件事</h2><h3 id="1）更早补协议一致性测试"><a href="#1）更早补协议一致性测试" class="headerlink" title="1）更早补协议一致性测试"></a>1）更早补协议一致性测试</h3><p>尤其是这些：</p><ul><li>redirect</li><li>custom header</li><li>CORS preflight</li><li>ETag &#x2F; 304</li><li>Client IP</li></ul><p>这些测试写得越早，后面的兼容层越不容易悄悄长歪。</p><h3 id="2）更早明确-compat-层边界"><a href="#2）更早明确-compat-层边界" class="headerlink" title="2）更早明确 compat 层边界"></a>2）更早明确 compat 层边界</h3><p>哪些 API 兼容，哪些不兼容，最好一开始就说清楚。<br>不然 compat 层会不断“长功能”，最后变成另一个小框架。</p><h3 id="3）更早把高频路径切到原生-Hertz"><a href="#3）更早把高频路径切到原生-Hertz" class="headerlink" title="3）更早把高频路径切到原生 Hertz"></a>3）更早把高频路径切到原生 Hertz</h3><p>compat 层非常适合兜底，但它不适合永久承载热点流量。</p><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>这次迁移让我最大的一个感受是：</p><blockquote><p><strong>存量系统的框架迁移，核心问题从来不是“新框架香不香”，而是“我们有没有能力把变化控制在正确的边界里”。</strong></p></blockquote><p>从 Gin 迁到 Hertz，本质上不是一次“技术栈翻新”，而是一次面向长期演进能力的重构。</p><p>它让我们把 transport、handler、中间件、协议语义重新梳理了一遍；<br>也让我们以后再做网络层优化时，不需要再从一团耦合代码里硬拆。</p><p>如果你面对的也是一个已经在线上跑了很久的 Go 服务，我会非常建议你记住一句话：</p><blockquote><p><strong>不要试图一口气迁完所有东西。先让系统支持渐进迁移，然后再一点点替换。</strong></p></blockquote><p>这是这次 Gin -&gt; Hertz 迁移里，我们验证过最有效的路线。</p>]]>
    </content>
    <id>https://www.embermoth.blog/2026/05/21/ppanel-gin-to-hertz-migration/</id>
    <link href="https://www.embermoth.blog/2026/05/21/ppanel-gin-to-hertz-migration/"/>
    <published>2026-05-21T06:15:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong> — 这次迁移不是“把 import 从 gin 改成 hertz”这么简单，而是一次分层完成的渐进式重构：先抽离 transport 层，再做 Gin 兼容层，最后把热点路由和关键中间件原生化到 Hertz。中间我们甚至短暂用 Fiber 验证过 fallback 方案。最重要的收获不是“换了框架”，而是沉淀出一套对存量 Go 服务可复制的迁移方法。</p>
</blockquote>
<hr>]]>
    </summary>
    <title>从 Gin 迁移到 Hertz：一次渐进式重构实战</title>
    <updated>2026-05-21T06:15:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Ember Moth</name>
    </author>
    <category term="开发笔记" scheme="https://www.embermoth.blog/categories/%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0/"/>
    <category term="ppanel" scheme="https://www.embermoth.blog/tags/ppanel/"/>
    <category term="golang" scheme="https://www.embermoth.blog/tags/golang/"/>
    <category term="postgresql" scheme="https://www.embermoth.blog/tags/postgresql/"/>
    <category term="gorm" scheme="https://www.embermoth.blog/tags/gorm/"/>
    <category term="database" scheme="https://www.embermoth.blog/tags/database/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong> — 为已有项目新增第二种数据库支持，工作量远大于”加个驱动依赖”。<br>本文记录 ppanel-server 从 MySQL-only 到同时支持 MySQL + PostgreSQL 的完整改造：重构配置抽象层、拆分两套迁移 DDL、修复 model 层 20+ 个 MySQL 专属写法、顺带优化搜索索引。<br>涉及 2 个 PR、6 次提交、约 40 个文件改动。最有价值的发现是：引入异构数据库测试，意外暴露了一个 MySQL 下潜伏了半年的隐性 bug。</p></blockquote><hr> <span id="more"></span> <h2 id="一、故事的起点"><a href="#一、故事的起点" class="headerlink" title="一、故事的起点"></a>一、故事的起点</h2><p>事情始于社群里一条不算特别紧迫的请求——“能支持 PostgreSQL 吗？”</p><p>这不是第一次有人提了。ppanel-server 的社群中，PostgreSQL 支持的呼声断断续续出现了好几次。有人在用托管 PostgreSQL（Supabase、Neon、RDS for PostgreSQL），有人的服务器上已经跑着 PostgreSQL 实例，不想为 ppanel 再单独维护一套 MySQL。这些声音一直挂着，没人真的动手做。</p><p>我其实一直知道这件事早晚要面对。所以某天我下定决心后，先做了一件事——走进代码，看看如果要加 PostgreSQL，到底要动多少东西。</p><p>结果比我预想的要深得多。</p><p>配置结构体直接叫 <code>MySQL</code>，初始化向导的路由叫 <code>/init/mysql/test</code>，迁移 SQL 全是 MySQL 方言，model 层散落着 <code>FIND_IN_SET</code> 和各种 MySQL 专属写法。不是一两个地方的问题——是整个数据层被 MySQL 渗透透了。</p><p>最麻烦的地方在于：很多 MySQL 专属写法是隐形的。<code>FIND_IN_SET</code> 可以用 <code>grep</code> 搜出来，但隐式类型转换、保留字裸写、MySQL 独有的 SQL 方言散落在 GORM 的查询字符串里，静态分析根本抓不全。这意味着即使我把抽象层和迁移层改好了，PostgreSQL 能不能真正跑起来，只有跑起来才知道。</p><p>我决定认真做一次，不打补丁。不是”加个 driver import 再修几个报错”，而是从架构层面让 ppanel-server 真正支持两种数据库。</p><p>后来的故事证明，这个判断是对的——这项工作最终拆成了两个 PR：#130 负责基础设施（抽象层、配置层、迁移层），#133 专门修 model 层在 PostgreSQL 下的兼容性问题。</p><hr><h2 id="二、摸清现状：MySQL-假设渗透到了哪里"><a href="#二、摸清现状：MySQL-假设渗透到了哪里" class="headerlink" title="二、摸清现状：MySQL 假设渗透到了哪里"></a>二、摸清现状：MySQL 假设渗透到了哪里</h2><p>动手之前，我先做了一次全面的代码审计。MySQL 的假设一共渗透了四个层面。</p><h3 id="配置层"><a href="#配置层" class="headerlink" title="配置层"></a>配置层</h3><p><code>config.Config</code> 里一个 <code>MySQL orm.Config</code> 字段，整个程序读配置、存配置、校验配置都直接操作它：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> startConfigPath != <span class="string">&quot;etc/ppanel.yaml&quot;</span> &amp;&amp; c.MySQL.Addr == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="literal">true</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// ...</span></span><br><span class="line">c.MySQL = *cfg</span><br></pre></td></tr></table></figure><p>如果要支持第二种数据库，”加一个 PostgreSQL 字段”是最直接的做法，但也是最难维护的——调用方代码里会到处出现 <code>if db == MySQL { ... } else { ... }</code>。必须抽象出一个驱动无关的接口。</p><h3 id="初始化流程"><a href="#初始化流程" class="headerlink" title="初始化流程"></a>初始化流程</h3><p>首次部署的 Web 向导里，<code>handleInitConfig</code> 直接拼 MySQL 格式的 DSN（<code>user:pass@tcp(host:port)/db</code>），硬编码调用 <code>gorm.Open(mysql.Open(dsn))</code> 来测试连通性。路由 <code>/init/mysql/test</code> 则直接用了 <code>gorm.Open</code> 的 MySQL dialector。整个流程没有任何切换数据库的入口。</p><h3 id="ORM-连接层"><a href="#ORM-连接层" class="headerlink" title="ORM 连接层"></a>ORM 连接层</h3><p><code>orm</code> 包里只有一个 <code>ConnectMysql</code> 函数，import 的是 <code>gorm.io/driver/mysql</code>。</p><h3 id="迁移文件"><a href="#迁移文件" class="headerlink" title="迁移文件"></a>迁移文件</h3><p>所有 SQL 文件平铺在 <code>initialize/migrate/database/</code> 下。<code>CREATE TABLE</code> 用的是 <code>AUTO_INCREMENT</code>、<code>TINYINT(1)</code>、<code>ENGINE=InnoDB</code>。PostgreSQL 拿到这些文件一条都跑不通。</p><h3 id="model-层"><a href="#model-层" class="headerlink" title="model 层"></a>model 层</h3><p>这是最复杂的部分。MySQL 专属写法散落在十几个文件里，有的是显式的——<code>FIND_IN_SET</code>、<code>DATE_FORMAT</code>——有的是隐式的：保留字裸写、数值类型的文本匹配、隐式类型转换。静态搜索只能抓到前一类，后一类只有等跑 PostgreSQL 时才会炸出来。</p><hr><h2 id="三、设计驱动抽象层"><a href="#三、设计驱动抽象层" class="headerlink" title="三、设计驱动抽象层"></a>三、设计驱动抽象层</h2><p>啃下这块骨头之前，先定目标：<strong>调用方代码不应该知道底层是哪个数据库</strong>，所有驱动差异收敛到 <code>orm</code> 包内部。</p><h3 id="3-1-驱动标识与规范化"><a href="#3-1-驱动标识与规范化" class="headerlink" title="3.1 驱动标识与规范化"></a>3.1 驱动标识与规范化</h3><p>定义常量，和 <code>NormalizeDriver</code> 函数处理用户输入的各种别名：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> (</span><br><span class="line">    DriverMySQL    = <span class="string">&quot;mysql&quot;</span></span><br><span class="line">    DriverPostgres = <span class="string">&quot;postgres&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NormalizeDriver</span><span class="params">(driver <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">switch</span> strings.ToLower(strings.TrimSpace(driver)) &#123;</span><br><span class="line">    <span class="keyword">case</span> <span class="string">&quot;postgres&quot;</span>, <span class="string">&quot;postgresql&quot;</span>, <span class="string">&quot;pg&quot;</span>:</span><br><span class="line">        <span class="keyword">return</span> DriverPostgres</span><br><span class="line">    <span class="keyword">default</span>:</span><br><span class="line">        <span class="keyword">return</span> DriverMySQL</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>默认值是 MySQL，所有现有配置文件零改动。</p><h3 id="3-2-统一配置结构体"><a href="#3-2-统一配置结构体" class="headerlink" title="3.2 统一配置结构体"></a>3.2 统一配置结构体</h3><p><code>orm.Config</code> 增加 <code>Driver</code> 字段，其他字段在两个驱动间复用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Config <span class="keyword">struct</span> &#123;</span><br><span class="line">    Driver        <span class="type">string</span></span><br><span class="line">    Addr          <span class="type">string</span></span><br><span class="line">    Username      <span class="type">string</span></span><br><span class="line">    Password      <span class="type">string</span></span><br><span class="line">    Dbname        <span class="type">string</span></span><br><span class="line">    Config        <span class="type">string</span> <span class="comment">// DSN 附加参数</span></span><br><span class="line">    MaxIdleConns  <span class="type">int</span></span><br><span class="line">    MaxOpenConns  <span class="type">int</span></span><br><span class="line">    SlowThreshold <span class="type">int</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>然后在 <code>orm.Mysql</code> 上挂两个方法，把 DSN 格式差异藏起来：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(m Mysql)</span></span> Driver() <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> NormalizeDriver(m.Config.Driver)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(m Mysql)</span></span> MigrationDsn() <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> m.Driver() == DriverPostgres &#123;</span><br><span class="line">        <span class="keyword">return</span> fmt.Sprintf(<span class="string">&quot;postgres://%s:%s@%s/%s?%s&quot;</span>,</span><br><span class="line">            m.Config.Username, m.Config.Password,</span><br><span class="line">            m.Config.Addr, m.Config.Dbname, m.Config.Config)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> fmt.Sprintf(<span class="string">&quot;%s:%s@tcp(%s)/%s?%s&quot;</span>,</span><br><span class="line">        m.Config.Username, m.Config.Password,</span><br><span class="line">        m.Config.Addr, m.Config.Dbname, m.Config.Config)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="3-3-ConnectDatabase：统一连接入口"><a href="#3-3-ConnectDatabase：统一连接入口" class="headerlink" title="3.3 ConnectDatabase：统一连接入口"></a>3.3 ConnectDatabase：统一连接入口</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ConnectDatabase</span><span class="params">(m Mysql)</span></span> (*gorm.DB, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">switch</span> m.Driver() &#123;</span><br><span class="line">    <span class="keyword">case</span> DriverPostgres:</span><br><span class="line">        <span class="keyword">return</span> connectPostgres(m)</span><br><span class="line">    <span class="keyword">default</span>:</span><br><span class="line">        <span class="keyword">return</span> connectMySQL(m)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>新增的依赖：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">gorm.io/driver/postgres v1.6.0</span><br><span class="line">github.com/jackc/pgx/v5 v5.6.0</span><br></pre></td></tr></table></figure><p>注意 <code>gorm.io/driver/postgres</code> 底层用的是 <code>pgx/v5</code>，而不是老的 <code>lib/pq</code>。两者对 GORM 的上层接口无差异，但 <code>pgx</code> 性能更好、对连接池的控制更细。</p><hr><h2 id="四、改造配置层和初始化流程"><a href="#四、改造配置层和初始化流程" class="headerlink" title="四、改造配置层和初始化流程"></a>四、改造配置层和初始化流程</h2><h3 id="4-1-config-Config-的接口化"><a href="#4-1-config-Config-的接口化" class="headerlink" title="4.1 config.Config 的接口化"></a>4.1 config.Config 的接口化</h3><p><code>MySQL</code> 字段不能直接改名——现有 YAML 配置文件反序列化靠字段名匹配，改名就是破坏性变更。所以保留原字段，新增方法做兼容层：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(c *Config)</span></span> DatabaseConfig() orm.Config &#123;</span><br><span class="line">    <span class="keyword">if</span> c.Database.Driver != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> c.Database</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> c.MySQL  <span class="comment">// 回退到老字段</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(c *Config)</span></span> SetDatabaseConfig(cfg orm.Config) &#123;</span><br><span class="line">    c.Database = cfg</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>配置写回 YAML 时也统一走 <code>Database</code> 字段：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">newConfig := config.File&#123;</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">    Database: c.DatabaseConfig(), <span class="comment">// 原来是 MySQL: c.MySQL</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样三件事同时成立：</p><ul><li><strong>新部署</strong>：配置文件里出现 <code>Database.Driver: postgres</code></li><li><strong>老用户升级</strong>：<code>MySQL</code> 字段自动回退，行为不变</li><li><strong>老用户升级后重写配置</strong>：自动迁移到新格式</li></ul><h3 id="4-2-初始化向导的改造"><a href="#4-2-初始化向导的改造" class="headerlink" title="4.2 初始化向导的改造"></a>4.2 初始化向导的改造</h3><p><code>handleInitConfig</code> 的请求体里新增 <code>databaseDriver</code> 字段，对接 <code>buildDatabaseConfig</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">dbConfig, err := buildDatabaseConfig(</span><br><span class="line">    request.DatabaseDriver,</span><br><span class="line">    request.MysqlHost, request.MysqlPort,</span><br><span class="line">    request.MysqlDatabase, request.MysqlUser, request.MysqlPassword,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p><code>buildDatabaseConfig</code> 集中做三件事：规范化驱动名、验证合法性、注入各驱动的默认 DSN 参数（PostgreSQL 默认 <code>sslmode=disable&amp;TimeZone=Asia/Shanghai</code>）。</p><p>迁移的调用从：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">migrate.Migrate(dsn).Up()</span><br></pre></td></tr></table></figure><p>改成：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">migrate.Migrate(dbClient.Driver(), dbClient.MigrationDsn()).Up()</span><br></pre></td></tr></table></figure><p>同时新增 <code>/init/database/test</code> 路由，老的 <code>/init/mysql/test</code> 内部转发到新函数：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">HandleMySQLTest</span><span class="params">(c *gin.Context)</span></span> &#123;</span><br><span class="line">    HandleDatabaseTest(c) <span class="comment">// 向前兼容</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><hr><h2 id="五、迁移文件：从一套-DDL-到两套"><a href="#五、迁移文件：从一套-DDL-到两套" class="headerlink" title="五、迁移文件：从一套 DDL 到两套"></a>五、迁移文件：从一套 DDL 到两套</h2><p>这是最枯燥但不能省的工作。所有迁移文件平铺在 <code>database/</code> 下的局面不能再继续了——两种数据库的 DDL 语法差异，混在一个目录里迟早要出事。</p><p>新的目录结构：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">initialize/migrate/database/</span><br><span class="line">├── mysql/</span><br><span class="line">│   ├── 00001_init_schema.up.sql</span><br><span class="line">│   ├── 00002_init_basic_data.up.sql</span><br><span class="line">│   └── ...</span><br><span class="line">└── postgres/</span><br><span class="line">    ├── 00001_init_schema.up.sql</span><br><span class="line">    ├── 00002_init_basic_data.up.sql</span><br><span class="line">    └── ...</span><br></pre></td></tr></table></figure><p>以第一版建 <code>users</code> 表为例，差异其实比想象中多：</p><p><strong>MySQL 版本：</strong></p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> `users` (</span><br><span class="line">    `id`          <span class="type">BIGINT</span>       <span class="keyword">NOT NULL</span> AUTO_INCREMENT,</span><br><span class="line">    `email`       <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">NOT NULL</span>,</span><br><span class="line">    `password`    <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">NOT NULL</span>,</span><br><span class="line">    `balance`     <span class="type">DECIMAL</span>(<span class="number">10</span>,<span class="number">2</span>) <span class="keyword">DEFAULT</span> <span class="number">0.00</span>,</span><br><span class="line">    `status`      TINYINT(<span class="number">1</span>)   <span class="keyword">DEFAULT</span> <span class="number">1</span>,</span><br><span class="line">    `is_admin`    TINYINT(<span class="number">1</span>)   <span class="keyword">DEFAULT</span> <span class="number">0</span>,</span><br><span class="line">    `created_at`  DATETIME     <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span>,</span><br><span class="line">    `updated_at`  DATETIME     <span class="keyword">DEFAULT</span> <span class="built_in">CURRENT_TIMESTAMP</span> <span class="keyword">ON</span> <span class="keyword">UPDATE</span> <span class="built_in">CURRENT_TIMESTAMP</span>,</span><br><span class="line">    <span class="keyword">PRIMARY KEY</span> (`id`),</span><br><span class="line">    INDEX `idx_users_email` (`email`)</span><br><span class="line">) ENGINE<span class="operator">=</span>InnoDB <span class="keyword">DEFAULT</span> CHARSET<span class="operator">=</span>utf8mb4;</span><br></pre></td></tr></table></figure><p><strong>PostgreSQL 版本：</strong></p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> users (</span><br><span class="line">    id          BIGSERIAL    <span class="keyword">NOT NULL</span>,</span><br><span class="line">    email       <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">NOT NULL</span>,</span><br><span class="line">    password    <span class="type">VARCHAR</span>(<span class="number">255</span>) <span class="keyword">NOT NULL</span>,</span><br><span class="line">    balance     <span class="type">DECIMAL</span>(<span class="number">10</span>,<span class="number">2</span>) <span class="keyword">DEFAULT</span> <span class="number">0.00</span>,</span><br><span class="line">    status      <span class="type">SMALLINT</span>     <span class="keyword">DEFAULT</span> <span class="number">1</span>,</span><br><span class="line">    is_admin    <span class="type">BOOLEAN</span>      <span class="keyword">DEFAULT</span> <span class="literal">FALSE</span>,</span><br><span class="line">    created_at  <span class="type">TIMESTAMP</span>    <span class="keyword">DEFAULT</span> NOW(),</span><br><span class="line">    updated_at  <span class="type">TIMESTAMP</span>    <span class="keyword">DEFAULT</span> NOW(),</span><br><span class="line">    <span class="keyword">PRIMARY KEY</span> (id)</span><br><span class="line">);</span><br><span class="line"><span class="keyword">CREATE</span> INDEX idx_users_email <span class="keyword">ON</span> users(email);</span><br></pre></td></tr></table></figure><p>几个核心差异：</p><table><thead><tr><th>场景</th><th>MySQL</th><th>PostgreSQL</th></tr></thead><tbody><tr><td>自增主键</td><td><code>BIGINT AUTO_INCREMENT</code></td><td><code>BIGSERIAL</code></td></tr><tr><td>布尔值</td><td><code>TINYINT(1)</code> + 0&#x2F;1</td><td><code>BOOLEAN</code> + TRUE&#x2F;FALSE</td></tr><tr><td>时间戳默认值</td><td><code>CURRENT_TIMESTAMP</code></td><td><code>NOW()</code></td></tr><tr><td>更新时戳</td><td><code>ON UPDATE CURRENT_TIMESTAMP</code></td><td>需 trigger</td></tr><tr><td>引擎指定</td><td><code>ENGINE=InnoDB</code></td><td>无需</td></tr><tr><td>字符集</td><td><code>CHARSET utf8mb4</code></td><td>库级别</td></tr></tbody></table><p>我用 <code>golang-migrate</code> 这个框架，它支持按子目录区分数据库驱动，只需要在调用时传入正确的 <code>database</code> 参数：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// mysql</span></span><br><span class="line">migrate.Migrate(<span class="string">&quot;mysql&quot;</span>, <span class="string">&quot;user:pass@tcp(localhost:3306)/ppanel?...&quot;</span>).Up()</span><br><span class="line"><span class="comment">// postgres</span></span><br><span class="line">migrate.Migrate(<span class="string">&quot;postgres&quot;</span>, <span class="string">&quot;postgres://user:pass@localhost:5432/ppanel?...&quot;</span>).Up()</span><br></pre></td></tr></table></figure><p>框架自动定位到对应目录的 migration 文件。工作量集中在翻译 DDL 上——全量迁移文件大约 20 组，逐条翻译、逐条确认功能等价。</p><hr><h2 id="六、跑起来之后：model-层挖坑记"><a href="#六、跑起来之后：model-层挖坑记" class="headerlink" title="六、跑起来之后：model 层挖坑记"></a>六、跑起来之后：model 层挖坑记</h2><p>PR #130 合并之后，我长舒了一口气。抽象层、配置层、迁移层都过了，理论上 PostgreSQL 应该能跑起来了。</p><p>然后搭了一个 PostgreSQL 实例，启动服务，开始逐个调用接口。</p><p>日志里刷出来十几行错误。</p><p>有些是预料之中的——<code>FIND_IN_SET</code>、保留字——改了就行。有些则完全出乎意料，比如一个 MySQL 下潜伏了半年的 bug 因为 PostgreSQL 的严格类型检查而炸了出来。</p><p>以下按修复顺序，摘几个代表性的问题讲讲。</p><h3 id="6-1-FIND-IN-SET"><a href="#6-1-FIND-IN-SET" class="headerlink" title="6.1 FIND_IN_SET"></a>6.1 FIND_IN_SET</h3><p>项目里多处用逗号分隔的字段存关联 ID：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">node.group_ids = &quot;1,3,7&quot;</span><br></pre></td></tr></table></figure><p>原来的查询：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">db.Where(<span class="string">&quot;FIND_IN_SET(?, group_ids)&quot;</span>, groupID)</span><br></pre></td></tr></table></figure><p><code>FIND_IN_SET</code> 是 MySQL 专有函数，在 PostgreSQL 下报错 <code>function does not exist</code>。</p><p>修复：在 <code>orm</code> 包里增加 <code>CommaSeparatedContains</code>，对两种数据库生成不同 SQL：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">CommaSeparatedContains</span><span class="params">(column <span class="type">string</span>, value <span class="keyword">interface</span>&#123;&#125;)</span></span> clause.Expr &#123;</span><br><span class="line">    <span class="keyword">if</span> currentDriver == DriverPostgres &#123;</span><br><span class="line">        <span class="keyword">return</span> clause.Expr&#123;</span><br><span class="line">            SQL:  fmt.Sprintf(<span class="string">&quot;? = ANY(string_to_array(%s, &#x27;,&#x27;))&quot;</span>, column),</span><br><span class="line">            Vars: []<span class="keyword">interface</span>&#123;&#125;&#123;fmt.Sprintf(<span class="string">&quot;%v&quot;</span>, value)&#125;,</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> clause.Expr&#123;</span><br><span class="line">        SQL:  fmt.Sprintf(<span class="string">&quot;FIND_IN_SET(?, %s)&quot;</span>, column),</span><br><span class="line">        Vars: []<span class="keyword">interface</span>&#123;&#125;&#123;value&#125;,</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>调用方只需写：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">db.Where(orm.CommaSeparatedContains(<span class="string">&quot;group_ids&quot;</span>, groupID))</span><br></pre></td></tr></table></figure><p>这种模式后来被反复使用——不是所有问题都能靠一个 helper 解决，但这种”生成驱动相关的 SQL 片段，暴露统一接口”的模式，贯穿了整个改造。</p><h3 id="6-2-数值字段的文本-LIKE"><a href="#6-2-数值字段的文本-LIKE" class="headerlink" title="6.2 数值字段的文本 LIKE"></a>6.2 数值字段的文本 LIKE</h3><p><code>ticket.port</code> 是 <code>INT</code> 类型，但后台搜索框中用户可能输入”端口号片段”来筛选。原来的代码做字符串 LIKE 匹配。MySQL 会隐式做类型转换，所有行都能正常返回。PostgreSQL 直接报类型错误。</p><p>这种场景没有特别优雅的解法。我加了一层驱动判断：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> orm.CurrentDriver() == orm.DriverPostgres &#123;</span><br><span class="line">    db = db.Where(<span class="string">&quot;port::TEXT LIKE ?&quot;</span>, <span class="string">&quot;%&quot;</span>+keyword+<span class="string">&quot;%&quot;</span>)</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    db = db.Where(<span class="string">&quot;CAST(port AS CHAR) LIKE ?&quot;</span>, <span class="string">&quot;%&quot;</span>+keyword+<span class="string">&quot;%&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>不优雅，但稳妥。理想的方案是把这类场景收敛到 helper 里，但 port 的模糊搜索只出现在后台管理页面的全能搜索框里，调用点很少，就先以最小改动保证正确性。</p><h3 id="6-3-Node-BeforeUpdate-中的错误-Model-引用"><a href="#6-3-Node-BeforeUpdate-中的错误-Model-引用" class="headerlink" title="6.3 Node BeforeUpdate 中的错误 Model 引用"></a>6.3 Node BeforeUpdate 中的错误 Model 引用</h3><p><strong>这是整轮改造中价值最大的发现。</strong></p><p><code>Node</code> 的 <code>BeforeUpdate</code> hook 里有一段排序校验逻辑：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(n *Node)</span></span> BeforeUpdate(tx *gorm.DB) <span class="type">error</span> &#123;</span><br><span class="line">    <span class="keyword">var</span> existing Server <span class="comment">// 错误！应该是 Node</span></span><br><span class="line">    tx.Where(<span class="string">&quot;id = ?&quot;</span>, n.ID).First(&amp;existing)</span><br><span class="line">    <span class="comment">// ... 用 existing.Sort 做校验</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Server</code> 和 <code>Node</code> 恰好在结构上有点像（都有 <code>ID</code>、<code>Sort</code> 字段），MySQL 下 GORM 虽然表名走了 <code>servers</code> 表，但查询结果恰好包含这些字段，校验逻辑阴差阳错地通过了。这个 bug 一直静默存在了半年之久。</p><p>但在 PostgreSQL 下，这条 SQL 走了 <code>servers</code> 表并返回了错误的数据，排序校验逻辑在不正确的数据上运行，导致后续的状态更新调用产生 500。沿着错误栈一层层追下去，最终定位到 <code>BeforeUpdate</code> 的这个位置时，我盯着屏幕愣了几秒——“这也行？”</p><p>修复只是一行文本变化：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> existing Node <span class="comment">// 修正</span></span><br></pre></td></tr></table></figure><p>但这件事让我意识到：<strong>异构数据库的价值不仅是适配更多用户，它还扮演了”额外的编译器”的角色</strong>——在更好的类型系统、更严格的语法约束下，它发现了常规测试从未触达的 bug。</p><p>一个项目中同时跑两种数据库，它们的差异点本身就是一套隐性测试。</p><h3 id="6-4-保留字裸写"><a href="#6-4-保留字裸写" class="headerlink" title="6.4 保留字裸写"></a>6.4 保留字裸写</h3><p>PostgreSQL 对 SQL 保留字比 MySQL 严格得多。最典型的是 <code>system</code> 表的 <code>key</code> 列：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">db.Where(<span class="string">&quot;key = ?&quot;</span>, k)</span><br></pre></td></tr></table></figure><p>MySQL 下虽然 <code>key</code> 是保留字，但 DML 中 MySQL 会隐式加反引号。PostgreSQL 直接报语法错误。<code>show</code> 字段同理，分别出现在 <code>subscribe</code>、<code>announcement</code>、<code>document</code> 三个表里。</p><p>修复：不用手写字符串，改用 GORM 的 <code>clause.Eq</code>，它会自动对列名做安全引号处理：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/model/system/scope.go</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">WhereCategoryKey</span><span class="params">(category, key <span class="type">string</span>)</span></span> []clause.Interface &#123;</span><br><span class="line">    <span class="keyword">return</span> []clause.Interface&#123;</span><br><span class="line">        clause.Eq&#123;Column: <span class="string">&quot;category&quot;</span>, Value: category&#125;,</span><br><span class="line">        clause.Eq&#123;Column: <span class="string">&quot;key&quot;</span>, Value: key&#125;,</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>调用方从：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">db.Where(<span class="string">&quot;key = ?&quot;</span>, k).Where(<span class="string">&quot;category = ?&quot;</span>, cat)</span><br></pre></td></tr></table></figure><p>变成：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">db.Where(system.WhereCategoryKey(cat, k))</span><br></pre></td></tr></table></figure><p>安全、驱动无关。</p><p>改动波及了大约 10 个文件——凡是涉及 <code>system</code> 表 <code>key</code> 的 <code>updateXxxConfigLogic.go</code> 都要改。每个文件一两行，但必须全部过一遍，不能遗漏。</p><h3 id="6-5-查询函数的隐式副作用"><a href="#6-5-查询函数的隐式副作用" class="headerlink" title="6.5 查询函数的隐式副作用"></a>6.5 查询函数的隐式副作用</h3><p>这是一个设计问题，不是 SQL 兼容性问题。但在 PostgreSQL 的严格事务隔离下，它从”不优雅”变成了一个真实的 bug。</p><p><code>FindUsersSubscribeBySubscribeId</code> —— 函数名语义是”查找”——但函数体里实际还有写操作：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">FindUsersSubscribeBySubscribeId</span><span class="params">(id <span class="type">uint</span>)</span></span> ([]*UserSubscribe, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">var</span> list []*UserSubscribe</span><br><span class="line">    db.Where(<span class="string">&quot;subscribe_id = ?&quot;</span>, id).Find(&amp;list)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// ⚠️ 顺手激活待激活的订阅</span></span><br><span class="line">    <span class="keyword">for</span> _, item := <span class="keyword">range</span> list &#123;</span><br><span class="line">        <span class="keyword">if</span> item.Status == StatusPending &#123;</span><br><span class="line">            db.Model(item).Update(<span class="string">&quot;status&quot;</span>, StatusActive)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> list, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>调用方按函数名做信任判断，以为它只读不做写入。在 PostgreSQL 的事务隔离下，这种模式更容易造成并发写冲突。</p><p>拆分成两个职责清晰的函数：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">FindUsersSubscribeBySubscribeId</span><span class="params">(id <span class="type">uint</span>)</span></span> ([]*UserSubscribe, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">var</span> list []*UserSubscribe</span><br><span class="line">    db.Where(<span class="string">&quot;subscribe_id = ?&quot;</span>, id).Find(&amp;list)</span><br><span class="line">    <span class="keyword">return</span> list, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ActivatePendingSubscribesBySubscribeId</span><span class="params">(id <span class="type">uint</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> db.Model(&amp;UserSubscribe&#123;&#125;).</span><br><span class="line">        Where(<span class="string">&quot;subscribe_id = ? AND status = ?&quot;</span>, id, StatusPending).</span><br><span class="line">        Update(<span class="string">&quot;status&quot;</span>, StatusActive).Error</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>函数名就是语义协议，调用方按名称做信任判断。这段经历让我在 review 中养成了一个习惯：看到一个叫 <code>Find*</code>、<code>Get*</code>、<code>Query*</code> 的函数，多看一眼它的函数体里有没有 <code>Update</code>、<code>Insert</code>、<code>Delete</code>。</p><hr><h2 id="七、搜索性能：顺带修了一轮索引"><a href="#七、搜索性能：顺带修了一轮索引" class="headerlink" title="七、搜索性能：顺带修了一轮索引"></a>七、搜索性能：顺带修了一轮索引</h2><p>在改兼容性的过程中，我注意到搜索查询里大量使用 <code>%keyword%</code> 写法。这种写法两端都加通配符，B-Tree 索引完全用不上，一定是全表扫描。既然已经在改数据层了，顺手优化这块。</p><h3 id="7-1-搜索-Helper"><a href="#7-1-搜索-Helper" class="headerlink" title="7.1 搜索 Helper"></a>7.1 搜索 Helper</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">PrefixLike</span><span class="params">(keyword <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> EscapeLike(keyword) + <span class="string">&quot;%&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ContainsLike</span><span class="params">(keyword <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="string">&quot;%&quot;</span> + EscapeLike(keyword) + <span class="string">&quot;%&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">EscapeLike</span><span class="params">(keyword <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">    keyword = strings.ReplaceAll(keyword, <span class="string">&quot;\\&quot;</span>, <span class="string">&quot;\\\\&quot;</span>)</span><br><span class="line">    keyword = strings.ReplaceAll(keyword, <span class="string">&quot;%&quot;</span>, <span class="string">&quot;\\%&quot;</span>)</span><br><span class="line">    keyword = strings.ReplaceAll(keyword, <span class="string">&quot;_&quot;</span>, <span class="string">&quot;\\_&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> keyword</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>EscapeLike</code> 之前的搜索代码里完全没有，用户输入包含 <code>%</code> 或 <code>_</code> 时搜索语义会被静默篡改。</p><p>搜索策略调整：email、用户名等”用户通常从头输入”的字段改用 <code>PrefixLike</code>，可以命中 B-Tree 索引；节点备注、套餐名称等必须支持任意位置匹配的保留 <code>ContainsLike</code>。</p><h3 id="7-2-搜索索引迁移"><a href="#7-2-搜索索引迁移" class="headerlink" title="7.2 搜索索引迁移"></a>7.2 搜索索引迁移</h3><p>MySQL 版本加普通 B-Tree 索引，不做特殊处理。</p><p>PostgreSQL 版本引入 <code>pg_trgm</code> extension 和 GIN 索引来加速包含匹配：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE</span> EXTENSION IF <span class="keyword">NOT</span> <span class="keyword">EXISTS</span> pg_trgm;</span><br><span class="line"><span class="keyword">CREATE</span> INDEX CONCURRENTLY IF <span class="keyword">NOT</span> <span class="keyword">EXISTS</span> idx_nodes_remark_trgm</span><br><span class="line">    <span class="keyword">ON</span> nodes <span class="keyword">USING</span> gin(remark gin_trgm_ops);</span><br></pre></td></tr></table></figure><p><code>pg_trgm</code> 把字符串按三个字符一组切分，用 GIN 索引存储。几十万行以上的表，这个索引能把 <code>LIKE &#39;%keyword%&#39;</code> 从秒级降到毫秒级。</p><p>部署注意事项：<code>CREATE EXTENSION</code> 需要超级用户权限，托管 PostgreSQL 需要在控制台手动启用或请 DBA 协助。我在 PR 描述里特意标注了这一点。</p><hr><h2 id="八、向后兼容：让已有-MySQL-用户无感升级"><a href="#八、向后兼容：让已有-MySQL-用户无感升级" class="headerlink" title="八、向后兼容：让已有 MySQL 用户无感升级"></a>八、向后兼容：让已有 MySQL 用户无感升级</h2><p>整个改造过程中，我心里一直挂着一条红线：<strong>已有 MySQL 用户什么都不用改，二进制替换重启即可。</strong></p><p>保障机制：</p><ol><li><code>config.Config</code> 保留 <code>MySQL</code> 字段。<code>Database.Driver</code> 为空时自动回退读 <code>MySQL</code>。</li><li><code>NormalizeDriver(&quot;&quot;)</code> 默认返回 <code>&quot;mysql&quot;</code>。</li><li>路由 <code>/init/mysql/test</code> 保留，内部转发给新函数。</li><li>迁移文件只迁移目录，内容不改。</li></ol><p>新配置格式示例：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">Database:</span></span><br><span class="line">  <span class="attr">Driver:</span> <span class="string">postgres</span></span><br><span class="line">  <span class="attr">Addr:</span> <span class="string">localhost:5432</span></span><br><span class="line">  <span class="attr">Username:</span> <span class="string">postgres</span></span><br><span class="line">  <span class="attr">Password:</span> <span class="string">password</span></span><br><span class="line">  <span class="attr">Dbname:</span> <span class="string">ppanel</span></span><br><span class="line">  <span class="attr">Config:</span> <span class="string">sslmode=disable&amp;TimeZone=Asia/Shanghai</span></span><br></pre></td></tr></table></figure><p>也可以只写：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">Database:</span></span><br><span class="line">  <span class="attr">Addr:</span> <span class="string">localhost:3306</span></span><br><span class="line">  <span class="attr">Username:</span> <span class="string">root</span></span><br><span class="line">  <span class="attr">Password:</span> <span class="string">password</span></span><br><span class="line">  <span class="attr">Dbname:</span> <span class="string">ppanel</span></span><br></pre></td></tr></table></figure><p><code>Driver</code> 留空则默认为 MySQL。</p><hr><h2 id="九、技术取舍：一个没有重命名的命名"><a href="#九、技术取舍：一个没有重命名的命名" class="headerlink" title="九、技术取舍：一个没有重命名的命名"></a>九、技术取舍：一个没有重命名的命名</h2><p>细心的读者可能注意到：在整篇文章里，承载 PostgreSQL 逻辑的 Go 类型仍然叫 <code>orm.Mysql</code>。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">type Mysql struct &#123; Config &#125;  // 同时也处理 Postgres 连接</span><br></pre></td></tr></table></figure><p>为什么不重命名为 <code>orm.DBConfig</code> 或 <code>orm.Driver</code>？这是一个真实的遗留设计债务。</p><p>做这个改造时，<code>orm.Mysql</code> 被引用在几十个文件里——作为函数参数类型、结构体字面量初始化、方法接收者。改名意味着全量搜索替换，增加 reviewer 的心智负担，而且没有任何行为收益。在当时”最小改动原则”的约束下，我选择保留命名。</p><p>这是一个 tradeoff：新增 PostgreSQL 支持已经改变了 40 个文件，再叠一个全量重命名，PR diff 会膨胀到根本没法 review。改命名应该是一个独立的、纯重构的 PR——从效果上看，早晚会做。但适配本身不必须等它。</p><p>这和学习接口的一个道理：先让它 work，再让它 clean。</p><hr><h2 id="十、回顾"><a href="#十、回顾" class="headerlink" title="十、回顾"></a>十、回顾</h2><p>两个 PR，六次提交，约四十个文件改动：</p><table><thead><tr><th>PR</th><th>提交</th><th>内容</th></tr></thead><tbody><tr><td>#130</td><td>ee53e6e</td><td>PostgreSQL 驱动支持，配置抽象，迁移拆分</td></tr><tr><td>#130</td><td>dad79dd</td><td>CI 发布流程调整</td></tr><tr><td>#133</td><td>064cc96</td><td>model 层 PostgreSQL 兼容性修复</td></tr><tr><td>#133</td><td>1ec853e</td><td>拆分订阅查询和激活逻辑</td></tr><tr><td>#133</td><td>b4629b8</td><td>搜索查询优化和索引迁移</td></tr><tr><td>#133</td><td>7b074e2</td><td>MySQL 保留字修复</td></tr></tbody></table><p>几条值得记录的教训：</p><p><strong>1. 驱动抽象要尽早做。</strong> 如果一开始 orm 层就是 <code>ConnectDatabase(driver, config)</code> 的形态，后来加驱动只是加一个 case 的工作量。等到代码里散落了上百处 <code>mysql.Open</code> 的调用，就很难优雅了。</p><p><strong>2. GORM 不能屏蔽所有 SQL 差异。</strong> <code>FIND_IN_SET</code>、保留字、数值类型转换这些问题，GORM 的 standardized API 覆盖不到。写 model 层时要有跨数据库意识，预期之外的 SQL 字符不该出现在 <code>db.Where()</code> 的字符串里。</p><p><strong>3. 函数名就是语义协议。</strong> <code>Find*</code> 里写 <code>Update</code> 是程序员的信任违约。被 PostgreSQL 逼着修了这个问题后，我现在 code review 里多了一条默认检查。</p><p><strong>4. 异构数据库测试的价值超过适配本身。</strong> 这是整件事里最意外的收获。引入 PostgreSQL 不仅让项目支持了更多用户，它还像一个”额外的编译器”一样，用更严格的类型检查暴露了一个 MySQL 下潜伏半年的 bug。<strong>选择支持多种数据库，不仅仅是扩大用户面——它在替你运行一条额外的、价值极高的测试套件。</strong></p><p><strong>5. 迁移 SQL 早分早省事。</strong> 两套 DDL 语法差异放到一个目录里，不管理论上多小心，迟早混淆。目录结构是最简单的强制约束。</p><p><strong>6. 向后兼容不是可选项，是硬条件。</strong> 已经有用户在生产环境跑着你的软件，你的改进不能让他们升级后亮红灯。保留路由、保留字段、设定合理的默认值——这些不会出现在 feature 列表里，但比你写的新功能更重要。</p><p>最后：如果你在考虑为一个 MySQL-only 的项目加 PostgreSQL 支持，我的建议就是 <strong>做，并且做彻底</strong>。因为你会发现，真正难的不是加 PostgreSQL，而是揭开 MySQL 留给你的一切便利假设。</p>]]>
    </content>
    <id>https://www.embermoth.blog/2026/05/19/ppanel-postgresql-support/</id>
    <link href="https://www.embermoth.blog/2026/05/19/ppanel-postgresql-support/"/>
    <published>2026-05-19T09:23:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong> — 为已有项目新增第二种数据库支持，工作量远大于”加个驱动依赖”。<br>本文记录 ppanel-server 从 MySQL-only 到同时支持 MySQL + PostgreSQL 的完整改造：重构配置抽象层、拆分两套迁移 DDL、修复 model 层 20+ 个 MySQL 专属写法、顺带优化搜索索引。<br>涉及 2 个 PR、6 次提交、约 40 个文件改动。最有价值的发现是：引入异构数据库测试，意外暴露了一个 MySQL 下潜伏了半年的隐性 bug。</p>
</blockquote>
<hr>]]>
    </summary>
    <title>从MySQL Only到PostgreSQL+MySQL：一次从驱动抽象到 SQL 兼容的完整改造</title>
    <updated>2026-05-19T09:23:00.000Z</updated>
  </entry>
</feed>
