<?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-05-21T06:15: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="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>
