<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>柠萌</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://www.cjjhc.cn/</id>
  <link href="https://www.cjjhc.cn/" rel="alternate"/>
  <link href="https://www.cjjhc.cn/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, 柠萌</rights>
  <subtitle>技术探索与山河漫游</subtitle>
  <title>柠萌的生活日记</title>
  <updated>2026-09-14T18:29:39.000Z</updated>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="Go语言" scheme="https://www.cjjhc.cn/categories/Go%E8%AF%AD%E8%A8%80/"/>
    <category term="后端开发" scheme="https://www.cjjhc.cn/categories/Go%E8%AF%AD%E8%A8%80/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="Goroutine" scheme="https://www.cjjhc.cn/tags/Goroutine/"/>
    <category term="Channel" scheme="https://www.cjjhc.cn/tags/Channel/"/>
    <category term="并发编程" scheme="https://www.cjjhc.cn/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/"/>
    <category term="Go Runtime" scheme="https://www.cjjhc.cn/tags/Go-Runtime/"/>
    <category term="性能优化" scheme="https://www.cjjhc.cn/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    <category term="CSP模型" scheme="https://www.cjjhc.cn/tags/CSP%E6%A8%A1%E5%9E%8B/"/>
    <category term="协程调度" scheme="https://www.cjjhc.cn/tags/%E5%8D%8F%E7%A8%8B%E8%B0%83%E5%BA%A6/"/>
    <content>
      <![CDATA[<p>在现代服务端开发中，高并发处理能力是衡量系统性能的关键指标。Go语言凭借其简洁的并发模型和强大的运行时支持，成为构建高并发服务的首选语言之一。本文将深入探讨Go语言并发编程的核心机制，帮助开发者从原理到实践全面掌握高并发开发技术。</p><h2 id="Goroutine与协程调度"><a href="#Goroutine与协程调度" class="headerlink" title="Goroutine与协程调度"></a>Goroutine与协程调度</h2><p>Goroutine是Go语言实现并发编程的核心概念，它是由Go运行时（Go Runtime）管理的轻量级线程。相比传统操作系统线程，Goroutine的创建成本极低，初始栈空间仅2KB，且能够动态增长，这使得我们可以轻松创建数万个并发任务。</p><p>Go Runtime的调度器采用了经典的MPG模型（Machine-Processor-Goroutine）：M代表实际的操作系统线程，P代表处理器（负责管理可运行队列），G代表Goroutine。当一个Goroutine发起系统调用阻塞时，对应的P会寻找其他可运行的Goroutine继续执行，从而充分利用CPU资源。这种设计使得Go程序能够在少量操作系统线程上高效调度大量Goroutine。</p><figure class="flatpaper-illustration"><svg viewBox="0 0 1200 675" width="100%" height="auto" role="img" aria-label="Go协程调度MPG模型示意"><rect x="50" y="50" width="300" height="200" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="200" y="140" font-family="sans-serif" font-size="18" fill="#2F4F4F" text-anchor="middle">Machine (M)</text><text x="200" y="170" font-family="sans-serif" font-size="14" fill="#2F4F4F" text-anchor="middle">操作系统线程</text><rect x="450" y="50" width="300" height="200" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="600" y="140" font-family="sans-serif" font-size="18" fill="#2F4F4F" text-anchor="middle">Processor (P)</text><text x="600" y="170" font-family="sans-serif" font-size="14" fill="#2F4F4F" text-anchor="middle">调度器上下文</text><rect x="850" y="50" width="300" height="200" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="1000" y="140" font-family="sans-serif" font-size="18" fill="#2F4F4F" text-anchor="middle">Goroutine (G)</text><text x="1000" y="170" font-family="sans-serif" font-size="14" fill="#2F4F4F" text-anchor="middle">协程任务</text><path d="M350 150 L450 150" stroke="#87A69D" stroke-width="3" fill="none" marker-end="url(#arrowhead)"/><path d="M750 150 L850 150" stroke="#87A69D" stroke-width="3" fill="none" marker-end="url(#arrowhead)"/><rect x="450" y="350" width="300" height="280" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="600" y="390" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">本地运行队列</text><circle cx="530" cy="450" r="25" fill="#D2B48C" stroke="#2F4F4F" stroke-width="1.5"/><circle cx="600" cy="450" r="25" fill="#D2B48C" stroke="#2F4F4F" stroke-width="1.5"/><circle cx="670" cy="450" r="25" fill="#D2B48C" stroke="#2F4F4F" stroke-width="1.5"/><circle cx="530" cy="520" r="25" fill="#D2B48C" stroke="#2F4F4F" stroke-width="1.5"/><circle cx="600" cy="520" r="25" fill="#D2B48C" stroke="#2F4F4F" stroke-width="1.5"/><circle cx="670" cy="520" r="25" fill="#D2B48C" stroke="#2F4F4F" stroke-width="1.5"/><path d="M600 250 L600 340" stroke="#87A69D" stroke-width="3" fill="none" marker-end="url(#arrowhead)"/><text x="200" y="620" font-family="sans-serif" font-size="12" fill="#8B3A2E" text-anchor="middle">G0</text><text x="600" y="620" font-family="sans-serif" font-size="12" fill="#8B3A2E" text-anchor="middle">G1 G2 G3 ...</text><defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#87A69D"/></marker></defs></svg><figcaption>图1：Go Runtime的MPG调度模型，展示了操作系统线程、调度器上下文与Goroutine之间的关系</figcaption></figure><p>从Go 1.14开始，调度器引入了基于信号的抢占式调度机制。当一个Goroutine运行时间过长（超过10毫秒）且没有主动让出CPU时，调度器会向其发送抢占信号，确保其他Goroutine有机会获得执行时间。这种设计有效避免了单个Goroutine长期占用CPU导致的延迟问题。</p><p>创建Goroutine非常简单，只需使用<code>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><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">processTask</span><span class="params">(id <span class="type">int</span>)</span></span> &#123;</span><br><span class="line">    fmt.Printf(<span class="string">&quot;Task %d started\n&quot;</span>, id)</span><br><span class="line">    time.Sleep(time.Second)</span><br><span class="line">    fmt.Printf(<span class="string">&quot;Task %d completed\n&quot;</span>, id)</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">main</span><span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">for</span> i := <span class="number">1</span>; i &lt;= <span class="number">10</span>; i++ &#123;</span><br><span class="line">        <span class="keyword">go</span> processTask(i)</span><br><span class="line">    &#125;</span><br><span class="line">    time.Sleep(<span class="number">2</span> * time.Second)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>虽然Goroutine创建成本低，但我们仍需要在设计阶段考虑合理的并发数量。过度的并发可能导致频繁的上下文切换和内存压力，反而降低整体性能。</p><h2 id="Channel与CSP通信模式"><a href="#Channel与CSP通信模式" class="headerlink" title="Channel与CSP通信模式"></a>Channel与CSP通信模式</h2><p>Go语言采用CSP（Communicating Sequential Processes）通信顺序进程模型，通过Channel实现Goroutine之间的安全通信。Channel本质上是一条带类型的管道，我们可以向其中发送数据或从中接收数据，发送和接收操作会阻塞直到另一端准备好。</p><p>Channel的创建使用<code>make</code>函数，可以创建有缓冲和无缓冲两种类型：无缓冲Channel要求发送和接收同时完成，适合用于同步场景；有缓冲Channel则允许发送操作在缓冲区满之前不阻塞，适合用于异步解耦。</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="comment">// 无缓冲Channel，用于同步通信</span></span><br><span class="line">unbuffered := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">int</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 有缓冲Channel，缓冲区容量为10</span></span><br><span class="line">buffered := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">int</span>, <span class="number">10</span>)</span><br></pre></td></tr></table></figure><p>在实际项目中，Channel常用于生产者和消费者模式、任务分发、结果收集等场景。以下是一个典型的worker pool实现：</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">workerPool</span><span class="params">(numWorkers <span class="type">int</span>, jobs &lt;-<span class="keyword">chan</span> <span class="type">int</span>, results <span class="keyword">chan</span>&lt;- <span class="type">int</span>)</span></span> &#123;</span><br><span class="line">    <span class="keyword">var</span> wg sync.WaitGroup</span><br><span class="line">    <span class="keyword">for</span> i := <span class="number">0</span>; i &lt; numWorkers; i++ &#123;</span><br><span class="line">        wg.Add(<span class="number">1</span>)</span><br><span class="line">        <span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">(workerID <span class="type">int</span>)</span></span> &#123;</span><br><span class="line">            <span class="keyword">defer</span> wg.Done()</span><br><span class="line">            <span class="keyword">for</span> job := <span class="keyword">range</span> jobs &#123;</span><br><span class="line">                result := process(job)</span><br><span class="line">                results &lt;- result</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;(i)</span><br><span class="line">    &#125;</span><br><span class="line">    wg.Wait()</span><br><span class="line">    <span class="built_in">close</span>(results)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>关闭Channel时需要遵循一个重要原则：不要向已关闭的Channel发送数据，否则会引发panic。通常由发送方负责关闭Channel，或者使用<code>defer</code>确保在函数退出时关闭。接收方可以通过多返回值检测Channel是否已关闭：<code>value, ok := &lt;-ch</code>，当<code>ok</code>为false时表示Channel已关闭且无更多数据。</p><p>Select语句是Channel编程中的重要工具，它类似于switch，但用于处理多个Channel的IO操作。Select会随机选择一个已准备好的case执行，如果所有case都阻塞且存在default分支，则执行default。这使得我们能够实现超时控制、多路复用和优雅取消等复杂逻辑：</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="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> msg := &lt;-ch1:</span><br><span class="line">    fmt.Println(<span class="string">&quot;Received from ch1:&quot;</span>, msg)</span><br><span class="line"><span class="keyword">case</span> msg := &lt;-ch2:</span><br><span class="line">    fmt.Println(<span class="string">&quot;Received from ch2:&quot;</span>, msg)</span><br><span class="line"><span class="keyword">case</span> &lt;-time.After(<span class="number">500</span> * time.Millisecond):</span><br><span class="line">    fmt.Println(<span class="string">&quot;Timeout waiting for message&quot;</span>)</span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line">    fmt.Println(<span class="string">&quot;No message available, continuing&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="并发安全与同步原语"><a href="#并发安全与同步原语" class="headerlink" title="并发安全与同步原语"></a>并发安全与同步原语</h2><p>当多个Goroutine访问共享资源时，必须使用同步机制保护数据，否则可能导致数据竞争（Data Race）和不可预期的结果。Go标准库提供了多种同步原语，应根据具体场景选择合适的工具。</p><p><code>sync.Mutex</code>是最常用的互斥锁，提供<code>Lock()</code>和<code>Unlock()</code>方法保护临界区。为了确保锁一定被释放，通常使用<code>defer</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="keyword">type</span> Counter <span class="keyword">struct</span> &#123;</span><br><span class="line">    mu    sync.Mutex</span><br><span class="line">    value <span class="type">int</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 *Counter)</span></span> Increment() &#123;</span><br><span class="line">    c.mu.Lock()</span><br><span class="line">    <span class="keyword">defer</span> c.mu.Unlock()</span><br><span class="line">    c.value++</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>对于读多写少的场景，<code>sync.RWMutex</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> SafeMap <span class="keyword">struct</span> &#123;</span><br><span class="line">    mu   sync.RWMutex</span><br><span class="line">    data <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"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(m *SafeMap)</span></span> Get(key <span class="type">string</span>) <span class="keyword">interface</span>&#123;&#125; &#123;</span><br><span class="line">    m.mu.RLock()</span><br><span class="line">    <span class="keyword">defer</span> m.mu.RUnlock()</span><br><span class="line">    <span class="keyword">return</span> m.data[key]</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 *SafeMap)</span></span> Set(key <span class="type">string</span>, value <span class="keyword">interface</span>&#123;&#125;) &#123;</span><br><span class="line">    m.mu.Lock()</span><br><span class="line">    <span class="keyword">defer</span> m.mu.Unlock()</span><br><span class="line">    m.data[key] = value</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>sync.WaitGroup</code>用于等待一组Goroutine完成，它维护一个计数器，调用<code>Add()</code>增加计数，<code>Done()</code>减少计数，<code>Wait()</code>阻塞直到计数器归零。原子操作<code>sync/atomic</code>包提供了对基本类型的原子读写，适用于计数器等简单场景，比锁的开销更小。</p><p>对于更复杂的并发模式，Go 1.21引入的<code>sync.OnceVal</code>和<code>sync.OnceFunc</code>可以确保函数只执行一次，而<code>sync.Map</code>则提供了并发安全的Map实现，适合读多写少的场景。</p><h2 id="性能调优与实战建议"><a href="#性能调优与实战建议" class="headerlink" title="性能调优与实战建议"></a>性能调优与实战建议</h2><p>生产环境中的并发程序需要持续关注性能表现。Go提供了强大的诊断工具：<code>pprof</code>用于CPU和内存分析，<code>trace</code>用于追踪Goroutine的执行状态，<code>race</code>检测器可以发现数据竞争问题。</p><p>在性能调优时，首要原则是避免不必要的并发创建。Goroutine虽然轻量，但并非零成本。每个Goroutine至少需要占用约2KB栈空间，在高并发场景下（如百万级并发连接），内存消耗仍然可观。Worker Pool模式通过复用固定数量的Goroutine处理任务，既能控制资源消耗，又能实现负载均衡。</p><p>Channel的选择也影响性能。无缓冲Channel涉及Goroutine切换开销，适合需要强同步的场景；有缓冲Channel可以缓解突发压力，但缓冲区满时仍会阻塞。根据实际业务特点选择合适的缓冲区大小，一般建议设置为合理的估算值，避免过大占用内存。</p><p>避免使用全局Mutex保护大对象，这会导致严重的锁竞争。可以将大锁拆分为多个小锁，或者使用分段锁技术。对于读操作远多于写操作的场景，优先考虑<code>sync.RWMutex</code>或无锁数据结构。</p><figure class="flatpaper-illustration"><svg viewBox="0 0 1200 675" width="100%" height="auto" role="img" aria-label="并发性能调优关键指标"><rect x="80" y="100" width="320" height="180" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="240" y="150" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">goroutine数量</text><text x="240" y="190" font-family="sans-serif" font-size="24" fill="#8B3A2E" text-anchor="middle">8,432</text><rect x="440" y="100" width="320" height="180" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="600" y="150" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">goroutine平均栈大小</text><text x="600" y="190" font-family="sans-serif" font-size="24" fill="#8B3A2E" text-anchor="middle">4.2 KB</text><rect x="800" y="100" width="320" height="180" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="960" y="150" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">GC暂停时间</text><text x="960" y="190" font-family="sans-serif" font-size="24" fill="#8B3A2E" text-anchor="middle">1.2 ms</text><rect x="80" y="320" width="320" height="180" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="240" y="370" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">每秒请求数(QPS)</text><text x="240" y="410" font-family="sans-serif" font-size="24" fill="#8B3A2E" text-anchor="middle">52,847</text><rect x="440" y="320" width="320" height="180" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="600" y="370" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">平均延迟(P99)</text><text x="600" y="410" font-family="sans-serif" font-size="24" fill="#8B3A2E" text-anchor="middle">23 ms</text><rect x="800" y="320" width="320" height="180" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="960" y="370" font-family="sans-serif" font-size="16" fill="#2F4F4F" text-anchor="middle">CPU利用率</text><text x="960" y="410" font-family="sans-serif" font-size="24" fill="#8B3A2E" text-anchor="middle">67%</text><rect x="200" y="540" width="800" height="80" rx="12" fill="#DCE5D9" stroke="#2F4F4F" stroke-width="2"/><text x="600" y="585" font-family="sans-serif" font-size="14" fill="#2F4F4F" text-anchor="middle">性能分析关键指标：监控Goroutine数量变化、GC频率与暂停、延迟分布可有效定位并发瓶颈</text></svg><figcaption>图2：并发服务性能监控关键指标面板，通过pprof和trace持续跟踪这些数据</figcaption></figure><p>Context传递是构建可取消并发任务的关键技术。将<code>context.Context</code>作为参数传递，在需要取消操作时（如客户端断开连接或请求超时）调用<code>cancel()</code>函数，所有监听Context的Goroutine都会收到取消信号并及时退出，避免资源泄漏：</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">fetchData</span><span class="params">(ctx context.Context, url <span class="type">string</span>)</span></span> ([]<span class="type">byte</span>, <span class="type">error</span>) &#123;</span><br><span class="line">    req, _ := http.NewRequestWithContext(ctx, <span class="string">&quot;GET&quot;</span>, url, <span class="literal">nil</span>)</span><br><span class="line">    client := &amp;http.Client&#123;Timeout: <span class="number">10</span> * time.Second&#125;</span><br><span class="line">    resp, err := client.Do(req)</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> <span class="literal">nil</span>, err</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">defer</span> resp.Body.Close()</span><br><span class="line">    <span class="keyword">return</span> io.ReadAll(resp.Body)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Go语言的并发模型以简洁优雅的设计著称，Goroutine和Channel构成了其核心。理解MPG调度器的运作机制，才能编写出高效利用系统资源的高并发程序。Channel的同步与解耦能力、Select的多路复用、Context的取消传播，构成了完整的并发控制体系。</p><p>在实际开发中，应该根据业务特点选择合适的并发模式，合理控制并发规模，重视并发安全，使用诊断工具持续优化性能。Go语言让并发编程变得触手可及，而深入理解其底层机制，则是成为高并发系统专家的必经之路。</p>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/24/go-concurrency-programming-guide/</id>
    <link href="https://www.cjjhc.cn/2026/08/24/go-concurrency-programming-guide/"/>
    <published>2026-08-24T13:19:57.000Z</published>
    <summary>系统讲解Go语言高并发编程的核心技术，深入剖析Goroutine调度机制、Channel通信模式、并发安全策略与性能调优实践。通过生产环境案例展示如何设计高效并发架构，帮助开发者充分利用Go Runtime优势，构建高吞吐、低延迟的现代服务。</summary>
    <title>Go语言高并发编程实战指南：从协程调度到性能调优</title>
    <updated>2026-09-14T18:29:39.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="后端开发" scheme="https://www.cjjhc.cn/categories/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="Go" scheme="https://www.cjjhc.cn/tags/Go/"/>
    <category term="性能优化" scheme="https://www.cjjhc.cn/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    <category term="pprof" scheme="https://www.cjjhc.cn/tags/pprof/"/>
    <category term="内存泄漏" scheme="https://www.cjjhc.cn/tags/%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F/"/>
    <category term="线上排障" scheme="https://www.cjjhc.cn/tags/%E7%BA%BF%E4%B8%8A%E6%8E%92%E9%9A%9C/"/>
    <content>
      <![CDATA[<p>Go 有垃圾回收，并不代表不会发生内存泄漏。只要仍有可达引用，GC 就不能释放对象；无界缓存、未清理的订阅、长期存活的 goroutine、不断增长的 map，以及被小切片引用的大底层数组，都可能让存活堆持续上升。</p><p>本文以一个订单服务为例：发布两小时后容器内存从 420 MiB 增长到 1.6 GiB，QPS 已回落但内存不降，最终被 OOMKilled。我们不靠重启掩盖问题，而是用运行时指标和两个时点的 heap profile，把“内存上涨”收敛为可验证的代码假设。</p><span id="more"></span><p><a href="/images/tech/diagrams/go-pprof-memory-leak.svg"><img src="/images/tech/diagrams/go-pprof-memory-leak.svg" alt="Go 服务从指标分层、同实例双快照、pprof 差分定位到修复复测的线上内存泄漏排查闭环图"></a></p><p><em>图：先判断增长来自 Go 存活堆还是进程其他区域，再对同一实例采集双时点快照；点击查看原图。</em></p><h2 id="一、先证明它真的是“泄漏”"><a href="#一、先证明它真的是“泄漏”" class="headerlink" title="一、先证明它真的是“泄漏”"></a>一、先证明它真的是“泄漏”</h2><p>线上看到的通常是容器 working set 或进程 RSS，而 pprof 的 heap profile 只描述 Go 堆中的抽样分配。两者不相等：线程栈、runtime 元数据、尚未归还操作系统的空闲页、cgo 分配、<code>mmap</code> 文件和内核缓冲都可能进入进程内存，却不会完整出现在 Go heap 中。</p><p>先把现象分成三类：</p><table><thead><tr><th>现象</th><th>更可能的原因</th><th>下一步</th></tr></thead><tbody><tr><td>每次 GC 后 <code>HeapAlloc</code> 基线仍持续上升</td><td>存活对象被长期引用</td><td>查看 <code>heap</code> 的 <code>inuse_space</code></td></tr><tr><td><code>HeapAlloc</code> 能回落，但累计分配和 GC 频率很高</td><td>分配抖动，不一定是泄漏</td><td>查看 <code>alloc_space</code>，优化热路径分配</td></tr><tr><td>RSS 上升，但 Go 存活堆稳定</td><td>空闲页未归还、cgo、<code>mmap</code> 或其他原生内存</td><td>对照 <code>HeapIdle</code>、<code>HeapReleased</code>，再检查原生分配</td></tr></tbody></table><p>如果服务暴露了 Go runtime 的 Prometheus 指标，至少把以下曲线放到同一个时间窗口：</p><ul><li>进程 RSS 或容器 working set；</li><li><code>HeapAlloc</code>、<code>HeapInuse</code>、<code>HeapIdle</code>、<code>HeapReleased</code>；</li><li>GC 次数、暂停时间和内存限制；</li><li>goroutine 数量、QPS、并发数、队列长度和错误率；</li><li>发布版本、实例重启和流量切换事件。</li></ul><p>关注“完成 GC 后的低水位”比盯着锯齿峰值更可靠。业务高峰产生临时对象时，堆先增长再被 GC 回收是正常现象；只有低水位跨多个周期持续抬高，才值得按对象保留问题调查。</p><h2 id="二、以受控方式开放-pprof"><a href="#二、以受控方式开放-pprof" class="headerlink" title="二、以受控方式开放 pprof"></a>二、以受控方式开放 pprof</h2><p>最省事的做法是副作用导入 <code>net/http/pprof</code>，但它会把处理器注册到 <code>http.DefaultServeMux</code>。生产服务更适合使用独立端口和独立 <code>ServeMux</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><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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> debugserver</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line"><span class="string">&quot;context&quot;</span></span><br><span class="line"><span class="string">&quot;errors&quot;</span></span><br><span class="line"><span class="string">&quot;log/slog&quot;</span></span><br><span class="line"><span class="string">&quot;net/http&quot;</span></span><br><span class="line"><span class="string">&quot;net/http/pprof&quot;</span></span><br><span class="line"><span class="string">&quot;time&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">Handler</span><span class="params">()</span></span> http.Handler &#123;</span><br><span class="line">mux := http.NewServeMux()</span><br><span class="line">mux.HandleFunc(<span class="string">&quot;/debug/pprof/&quot;</span>, pprof.Index)</span><br><span class="line">mux.HandleFunc(<span class="string">&quot;/debug/pprof/cmdline&quot;</span>, pprof.Cmdline)</span><br><span class="line">mux.HandleFunc(<span class="string">&quot;/debug/pprof/profile&quot;</span>, pprof.Profile)</span><br><span class="line">mux.HandleFunc(<span class="string">&quot;/debug/pprof/symbol&quot;</span>, pprof.Symbol)</span><br><span class="line">mux.HandleFunc(<span class="string">&quot;/debug/pprof/trace&quot;</span>, pprof.Trace)</span><br><span class="line"><span class="keyword">return</span> mux</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">Serve</span><span class="params">(ctx context.Context, addr <span class="type">string</span>, logger *slog.Logger)</span></span> <span class="type">error</span> &#123;</span><br><span class="line">srv := &amp;http.Server&#123;</span><br><span class="line">Addr:              addr,</span><br><span class="line">Handler:           Handler(),</span><br><span class="line">ReadHeaderTimeout: <span class="number">3</span> * time.Second,</span><br><span class="line">IdleTimeout:       <span class="number">30</span> * time.Second,</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">errCh := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">error</span>, <span class="number">1</span>)</span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123; errCh &lt;- srv.ListenAndServe() &#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line">shutdownCtx, cancel := context.WithTimeout(context.Background(), <span class="number">5</span>*time.Second)</span><br><span class="line"><span class="keyword">defer</span> cancel()</span><br><span class="line"><span class="keyword">return</span> srv.Shutdown(shutdownCtx)</span><br><span class="line"><span class="keyword">case</span> err := &lt;-errCh:</span><br><span class="line"><span class="keyword">if</span> errors.Is(err, http.ErrServerClosed) &#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">logger.Error(<span class="string">&quot;pprof server stopped&quot;</span>, <span class="string">&quot;error&quot;</span>, err)</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>这里的 <code>addr</code> 应来自受控配置。在虚拟机上可以监听 <code>127.0.0.1:6060</code>，通过 SSH 隧道访问；在 Kubernetes 中可以不创建 Service 和 Ingress，只对目标 Pod 执行 <code>kubectl port-forward</code>，并用 NetworkPolicy 阻断其他入口。如果平台必须通过管理网络访问，还应在代理层增加双向 TLS、短期凭证和审计日志。</p><p><code>cmdline</code>、goroutine 栈和 profile 都可能暴露包名、路径、参数甚至业务结构，因此绝不能把 <code>/debug/pprof/</code> 直接暴露到公网。Go 1.22 起这些端点只接受 GET，请求脚本也应显式使用 GET。</p><h2 id="三、选择实例并保全现场"><a href="#三、选择实例并保全现场" class="headerlink" title="三、选择实例并保全现场"></a>三、选择实例并保全现场</h2><p>不要一看到告警就轮流抓所有 Pod。先选出真正增长的单个实例，并记录：</p><ol><li>Pod 名称、启动时间、节点和当前流量；</li><li>Git commit、Go 版本、构建参数和镜像 digest；</li><li>与该实例完全匹配、未被 strip 掉必要符号的可执行文件；</li><li>告警前后的 RSS、Go 堆、GC 和 goroutine 曲线；</li><li>当时是否发生全量同步、大查询、缓存预热或流量倾斜。</li></ol><p>保留匹配版本的二进制很重要。profile 可以携带部分符号信息，但要可靠使用 <code>list</code>、<code>weblist</code> 和源码行视图，最好让分析工具拿到生成该 profile 的同一构建产物。</p><p>在本机建立端口转发：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">kubectl -n order port-forward pod/order-api-7d9f6c8d8f-k2m7p 6060:6060</span><br></pre></td></tr></table></figure><p>另开终端确认端点可用，并把采集时间、实例和版本写入文件名或旁边的事件记录：</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><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">curl -fsS http://127.0.0.1:6060/debug/pprof/</span><br><span class="line"></span><br><span class="line">curl -fsS -o heap-20260823-1030.pb.gz \</span><br><span class="line">  <span class="string">&#x27;http://127.0.0.1:6060/debug/pprof/heap?gc=1&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 在相近流量下等待内存再次增长，例如 10～20 分钟。</span></span><br><span class="line"></span><br><span class="line">curl -fsS -o heap-20260823-1050.pb.gz \</span><br><span class="line">  <span class="string">&#x27;http://127.0.0.1:6060/debug/pprof/heap?gc=1&#x27;</span></span><br></pre></td></tr></table></figure><p><code>gc=1</code> 会在生成 heap profile 前主动执行一次 GC，让两个快照都基于新完成的 GC，适合确认存活对象；它也会带来额外工作，不应被高频监控轮询。对延迟敏感的服务，可以先采集不带 <code>gc=1</code> 的快照，或只在一个代表性副本上低频执行。关键不是强制 GC 本身，而是两次采集条件一致、时间窗口内确实出现了持续增长。</p><h2 id="四、正确理解四种内存视角"><a href="#四、正确理解四种内存视角" class="headerlink" title="四、正确理解四种内存视角"></a>四、正确理解四种内存视角</h2><p>同一份 Go heap profile 通常包含四个 sample index：</p><table><thead><tr><th>sample index</th><th>含义</th><th>适合回答的问题</th></tr></thead><tbody><tr><td><code>inuse_space</code></td><td>当前仍存活对象的抽样字节数</td><td>哪些分配点正在占住最多堆内存？</td></tr><tr><td><code>inuse_objects</code></td><td>当前仍存活对象的抽样数量</td><td>是否有海量小对象未释放？</td></tr><tr><td><code>alloc_space</code></td><td>进程启动以来累计分配的抽样字节数</td><td>哪些路径制造了最大的分配吞吐？</td></tr><tr><td><code>alloc_objects</code></td><td>进程启动以来累计分配的抽样对象数</td><td>哪些路径频繁创建对象？</td></tr></tbody></table><p>排查“内存为什么不下来”时先看 <code>inuse_space</code>，再用 <code>inuse_objects</code> 判断是少量大对象还是大量小对象。<code>alloc_space</code> 很高只说明历史上分配得多：对象可能早已回收，它更适合排查 GC 压力和分配抖动，不能单独证明泄漏。</p><p>Go heap profile 是抽样数据，不是逐对象清单。小对象、短时间窗口和低样本数量都会带来噪声，因此应该看数量级、趋势和调用路径，不要对几十 KiB 的差异做过度解释。</p><h2 id="五、先读单个快照"><a href="#五、先读单个快照" class="headerlink" title="五、先读单个快照"></a>五、先读单个快照</h2><p>先用增长后的快照确认主要存活对象：</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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">go tool pprof \</span><br><span class="line">  -top \</span><br><span class="line">  -sample_index=inuse_space \</span><br><span class="line">  ./order-api \</span><br><span class="line">  heap-20260823-1050.pb.gz</span><br></pre></td></tr></table></figure><p><code>flat</code> 表示直接归因到该函数分配点的存活量，<code>cum</code> 表示该函数及其下游调用累计贡献。默认按 flat 排序；如果真正分配发生在通用解码器或标准库中，按 cum 排序更容易找到业务入口：</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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">go tool pprof \</span><br><span class="line">  -top -cum \</span><br><span class="line">  -sample_index=inuse_space \</span><br><span class="line">  ./order-api \</span><br><span class="line">  heap-20260823-1050.pb.gz</span><br></pre></td></tr></table></figure><p>也可以启动只监听本机的 Web 界面，在 Graph、Flame Graph、Peek 和 Source 之间切换：</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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">go tool pprof \</span><br><span class="line">  -http=127.0.0.1:0 \</span><br><span class="line">  -sample_index=inuse_space \</span><br><span class="line">  ./order-api \</span><br><span class="line">  heap-20260823-1050.pb.gz</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><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">(pprof) top 20</span><br><span class="line">(pprof) top -cum</span><br><span class="line">(pprof) list &#x27;example.com/order/internal/report.*Add&#x27;</span><br><span class="line">(pprof) peek &#x27;example.com/order/internal/report&#x27;</span><br></pre></td></tr></table></figure><p><code>list</code> 能把样本标到源码行上，但 pprof 展示的是<strong>存活对象的分配栈</strong>，不是 GC 意义上的完整引用链。看到 <code>json.Unmarshal</code> 占用高，不代表标准库泄漏；还要沿 cum 调用栈回到业务入口，检查谁把解码结果放进了长生命周期容器。</p><h2 id="六、用双快照差分消除存量噪声"><a href="#六、用双快照差分消除存量噪声" class="headerlink" title="六、用双快照差分消除存量噪声"></a>六、用双快照差分消除存量噪声</h2><p>服务启动后会长期保留路由表、连接池、模板、字典和正常缓存。单个快照常被这些合理存量占据，真正泄漏的增量反而不显眼。</p><p>对两个 live heap 快照使用 <code>-diff_base</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><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">go tool pprof \</span><br><span class="line">  -top \</span><br><span class="line">  -diff_base heap-20260823-1030.pb.gz \</span><br><span class="line">  -sample_index=inuse_space \</span><br><span class="line">  ./order-api \</span><br><span class="line">  heap-20260823-1050.pb.gz</span><br></pre></td></tr></table></figure><p>差分报告中的正值表示增长，负值表示减少。关注同时满足以下条件的路径：</p><ul><li>增长量明显高于抽样噪声；</li><li>与告警时间窗口和业务操作吻合；</li><li>第二、第三个窗口中仍以相近斜率增长；</li><li>能映射到一个长生命周期的 map、slice、队列、订阅或 goroutine。</li></ul><p>如果比较的是同一进程中随时间累计的 <code>alloc_space</code>，则可以使用 <code>-base</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><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">go tool pprof \</span><br><span class="line">  -top \</span><br><span class="line">  -base heap-20260823-1030.pb.gz \</span><br><span class="line">  -sample_index=alloc_space \</span><br><span class="line">  ./order-api \</span><br><span class="line">  heap-20260823-1050.pb.gz</span><br></pre></td></tr></table></figure><p>不要比较不同版本、不同 Pod 或明显不同流量下的快照；函数地址、代码路径和对象规模都可能不同，差分结论会失真。</p><h2 id="七、案例：无界的租户报表缓存"><a href="#七、案例：无界的租户报表缓存" class="headerlink" title="七、案例：无界的租户报表缓存"></a>七、案例：无界的租户报表缓存</h2><p>差分结果显示，20 分钟内约 620 MiB 的新增存活堆都来自 <code>report.(*Buffer).Add</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></pre></td><td class="code"><pre><span class="line">Showing nodes accounting for 619.8MB, 96.4% of 642.7MB total</span><br><span class="line">      flat  flat%   sum%        cum   cum%</span><br><span class="line">   487.2MB 75.8% 75.8%    487.2MB 75.8%  report.clonePayload</span><br><span class="line">   101.4MB 15.8% 91.6%    588.6MB 91.6%  report.(*Buffer).Add</span><br><span class="line">    31.2MB  4.9% 96.4%    619.8MB 96.4%  api.(*Handler).finishExport</span><br></pre></td></tr></table></figure><p>对应代码把每次导出的完整报表都追加到按租户分组的 slice，却没有容量和过期边界：</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> Buffer <span class="keyword">struct</span> &#123;</span><br><span class="line">mu       sync.Mutex</span><br><span class="line">byTenant <span class="keyword">map</span>[<span class="type">string</span>][]Report</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">(b *Buffer)</span></span> Add(tenantID <span class="type">string</span>, report Report) &#123;</span><br><span class="line">b.mu.Lock()</span><br><span class="line"><span class="keyword">defer</span> b.mu.Unlock()</span><br><span class="line"></span><br><span class="line">b.byTenant[tenantID] = <span class="built_in">append</span>(b.byTenant[tenantID], report)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>map 本身只占很小一部分，真正占内存的是 slice 中每个 <code>Report</code> 引用的 payload。由于 <code>Buffer</code> 与进程同生命周期，所有历史报表都保持可达，GC 的行为完全正确。</p><p>修复方案不是在告警时调用 <code>runtime.GC()</code>，而是为数据生命周期建立业务边界。假设产品只需要每个租户最近 100 条，可先做有界保留：</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="keyword">type</span> Buffer <span class="keyword">struct</span> &#123;</span><br><span class="line">mu           sync.Mutex</span><br><span class="line">byTenant     <span class="keyword">map</span>[<span class="type">string</span>][]Report</span><br><span class="line">maxPerTenant <span class="type">int</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">(b *Buffer)</span></span> Add(tenantID <span class="type">string</span>, report Report) &#123;</span><br><span class="line">b.mu.Lock()</span><br><span class="line"><span class="keyword">defer</span> b.mu.Unlock()</span><br><span class="line"></span><br><span class="line">items := <span class="built_in">append</span>(b.byTenant[tenantID], report)</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(items) &gt; b.maxPerTenant &#123;</span><br><span class="line"><span class="comment">// 复制到新底层数组，避免小切片继续引用曾经膨胀的大数组。</span></span><br><span class="line">kept := <span class="built_in">make</span>([]Report, b.maxPerTenant)</span><br><span class="line"><span class="built_in">copy</span>(kept, items[<span class="built_in">len</span>(items)-b.maxPerTenant:])</span><br><span class="line">items = kept</span><br><span class="line">&#125;</span><br><span class="line">b.byTenant[tenantID] = items</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>实际系统还应考虑 TTL、租户数量上限、单条 payload 大小限制和淘汰指标。如果数据必须完整保留，应把它写入有生命周期策略的外部存储，而不是把进程堆当数据库。</p><h2 id="八、修复后怎样证明问题消失"><a href="#八、修复后怎样证明问题消失" class="headerlink" title="八、修复后怎样证明问题消失"></a>八、修复后怎样证明问题消失</h2><p>“部署后没再 OOM”不够，因为流量可能恰好下降。用与故障时相近的数据量和操作序列做灰度验证，并预先写出成功条件：</p><ol><li>预热结束后，连续多个 GC 周期的 <code>HeapAlloc</code> 低水位进入平台期；</li><li>相同时间间隔的 <code>inuse_space</code> 差分不再持续指向同一业务路径；</li><li>缓存条目数、租户数和 payload 字节数都受显式上限约束；</li><li><code>alloc_space</code> 速率、GC CPU、P95&#x2F;P99 延迟和错误率没有明显恶化；</li><li>灰度实例与旧版本承接相近流量，观察时间覆盖原故障窗口。</li></ol><p>Go 堆下降后，RSS 不一定立即等比例回落。runtime 可能暂时保留空闲页以便复用，应结合 <code>HeapIdle</code> 与 <code>HeapReleased</code> 判断。不要把 <code>debug.FreeOSMemory()</code> 当作常规定时任务；它可能制造额外 GC 和延迟抖动，而且不会修复仍然可达的对象。</p><p>可以把这次故障转成回归测试：持续写入超过上限的数据，强制完成一个测试内 GC 周期后，验证缓存条目数和业务暴露的 payload 字节指标保持稳定。测试重点是业务不变量，不要依赖精确堆字节数，因为 GC 和抽样会随 Go 版本变化。</p><h2 id="九、pprof-看不到时往哪里查"><a href="#九、pprof-看不到时往哪里查" class="headerlink" title="九、pprof 看不到时往哪里查"></a>九、pprof 看不到时往哪里查</h2><p>如果 RSS 快速上涨而 <code>inuse_space</code> 稳定，继续重复抓 heap profile 通常不会得到新结论。按证据分流：</p><ul><li>goroutine 数同步增长：采集 goroutine profile，检查未退出的 worker、阻塞 channel、未关闭响应体和定时器；</li><li><code>HeapIdle - HeapReleased</code> 很高：说明 Go runtime 持有较多尚未归还的空闲堆页，结合分配峰值和后续复用观察；</li><li>使用 cgo 或原生 SDK：Go heap 无法完整解释其分配，需使用库自身统计或原生内存分析工具；</li><li>大量 <code>mmap</code>、文件缓存或共享内存：检查进程内存映射和操作系统指标；</li><li>只在某类请求后增长：给业务操作增加计数与字节指标，建立可复现负载，再采集对应窗口。</li></ul><p>goroutine profile 可以这样保存并分析：</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">curl -fsS -o goroutine.pb.gz \</span><br><span class="line">  <span class="string">&#x27;http://127.0.0.1:6060/debug/pprof/goroutine&#x27;</span></span><br><span class="line"></span><br><span class="line">go tool pprof -top ./order-api goroutine.pb.gz</span><br></pre></td></tr></table></figure><p>如需人工快速阅读完整栈，也可以临时请求 <code>?debug=2</code> 的文本格式，但要把结果当作敏感诊断数据妥善保存。</p><h2 id="十、线上排障清单"><a href="#十、线上排障清单" class="headerlink" title="十、线上排障清单"></a>十、线上排障清单</h2><h3 id="采集前"><a href="#采集前" class="headerlink" title="采集前"></a>采集前</h3><ul><li><input disabled="" type="checkbox"> 确认增长的是目标实例，并保存版本、流量和时间窗口；</li><li><input disabled="" type="checkbox"> 区分 RSS、Go 存活堆、空闲堆页和 goroutine 趋势；</li><li><input disabled="" type="checkbox"> 只通过本机或受控管理通道访问 pprof；</li><li><input disabled="" type="checkbox"> 准备与实例完全匹配的二进制；</li><li><input disabled="" type="checkbox"> 一次只采集一种 profile，避免诊断工具互相干扰。</li></ul><h3 id="分析时"><a href="#分析时" class="headerlink" title="分析时"></a>分析时</h3><ul><li><input disabled="" type="checkbox"> 先看 <code>inuse_space</code>，再按需要切到对象数和累计分配；</li><li><input disabled="" type="checkbox"> 同一实例采集两个以上时点，用差分识别持续增量；</li><li><input disabled="" type="checkbox"> 同时检查 flat 分配点和 cum 业务入口；</li><li><input disabled="" type="checkbox"> 回到代码确认对象由谁持有、何时删除、是否有容量边界；</li><li><input disabled="" type="checkbox"> 对 cgo、<code>mmap</code> 和 goroutine 保留保持独立假设。</li></ul><h3 id="修复后"><a href="#修复后" class="headerlink" title="修复后"></a>修复后</h3><ul><li><input disabled="" type="checkbox"> 在相近负载和足够长窗口中复测；</li><li><input disabled="" type="checkbox"> 同时验证堆、RSS、GC、延迟和错误率；</li><li><input disabled="" type="checkbox"> 为缓存大小、队列深度和 payload 字节数增加可观测上限；</li><li><input disabled="" type="checkbox"> 把故障场景转成容量测试、回归测试和运行手册；</li><li><input disabled="" type="checkbox"> profile 按敏感数据管理，到期删除。</li></ul><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>pprof 最有价值的地方不是生成一张漂亮的火焰图，而是把模糊的“进程内存很高”转换成一条证据链：先证明 Go 存活堆确实增长，再在同一实例、同一版本、相近负载下采集双快照，用正确的 sample index 做差分，最后回到对象生命周期修复并复测。</p><p>线上内存问题经常跨越 Go 堆、runtime、goroutine 和原生内存。守住指标分层、采集边界和对照实验，就能避免把正常缓存当泄漏，也不会在真正的无界引用面前只会重启和强制 GC。</p><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://go.dev/doc/diagnostics">Go Diagnostics</a></li><li><a href="https://pkg.go.dev/net/http/pprof"><code>net/http/pprof</code> 官方文档</a></li><li><a href="https://pkg.go.dev/runtime/pprof"><code>runtime/pprof</code> 官方文档</a></li><li><a href="https://github.com/google/pprof/blob/main/doc/README.md">Google pprof：比较 profiles 与 sample index</a></li></ul>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/24/go-pprof-memory-leak/</id>
    <link href="https://www.cjjhc.cn/2026/08/24/go-pprof-memory-leak/"/>
    <published>2026-08-24T09:00:00.000Z</published>
    <summary>Go 虽有垃圾回收，但仍会因无界缓存、未关闭订阅、长期存活 goroutine 或被小切片引用的大数组等无界引用导致内存泄漏。本文以订单服务 OOM 案例为线索，介绍先用运行时指标区分 RSS 与 Go 堆，再通过受控方式开放 pprof 并在同一实例上采集双时点 heap profile，随后用差分消除存量噪声、定位增长来源，最终回到对象生命周期通过有界保留和业务边界修复问题，并建立回归验证闭环。</summary>
    <title>Go 使用 pprof 排查线上内存泄漏</title>
    <updated>2026-08-24T17:56:21.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="项目管理" scheme="https://www.cjjhc.cn/categories/%E9%A1%B9%E7%9B%AE%E7%AE%A1%E7%90%86/"/>
    <category term="Git" scheme="https://www.cjjhc.cn/tags/Git/"/>
    <category term="Commit规范" scheme="https://www.cjjhc.cn/tags/Commit%E8%A7%84%E8%8C%83/"/>
    <category term="研发流程" scheme="https://www.cjjhc.cn/tags/%E7%A0%94%E5%8F%91%E6%B5%81%E7%A8%8B/"/>
    <category term="Submodule" scheme="https://www.cjjhc.cn/tags/Submodule/"/>
    <category term="项目管理" scheme="https://www.cjjhc.cn/tags/%E9%A1%B9%E7%9B%AE%E7%AE%A1%E7%90%86/"/>
    <content>
      <![CDATA[<p>研发流程真正需要解决的，不只是“代码如何提交”，而是任何一次线上变更都能回答五个问题：为什么改、谁改的、改了什么、如何验证、出了问题怎样回退。</p><p>一种简单而有效的方法，是把项目管理系统中的需求编号或 Bug 编号作为贯穿整个研发流程的关联键。</p><span id="more"></span><h2 id="一条贯穿研发流程的主线"><a href="#一条贯穿研发流程的主线" class="headerlink" title="一条贯穿研发流程的主线"></a>一条贯穿研发流程的主线</h2><p>假设项目管理系统中存在以下事项：</p><ul><li>需求：<code>REQ-1428</code>，增加订单导出功能；</li><li>缺陷：<code>BUG-2381</code>，修复导出文件中金额精度错误。</li></ul><p>编号会依次出现在分支、Commit、Pull Request、测试记录、构建产物和发布记录中：</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></pre></td><td class="code"><pre><span class="line">需求或 Bug</span><br><span class="line">  → 开发分支</span><br><span class="line">  → Commit</span><br><span class="line">  → Pull Request / Merge Request</span><br><span class="line">  → CI 与测试报告</span><br><span class="line">  → 发布版本</span><br><span class="line">  → 线上监控与复盘</span><br></pre></td></tr></table></figure><p>这样无论从项目管理系统、代码仓库还是线上版本出发，都能反向找到完整上下文。</p><h2 id="Commit-信息规范"><a href="#Commit-信息规范" class="headerlink" title="Commit 信息规范"></a>Commit 信息规范</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></pre></td><td class="code"><pre><span class="line">【需求编号】- 增加xxx</span><br><span class="line">【Bug编号】- 修复xxx</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><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">【REQ-1428】- 增加订单导出功能</span><br><span class="line">【BUG-2381】- 修复导出金额精度错误</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><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">【REQ-1428】feat: 增加订单导出功能</span><br><span class="line">【BUG-2381】fix: 修复导出金额精度错误</span><br><span class="line">【TECH-0362】refactor: 拆分订单查询与导出逻辑</span><br></pre></td></tr></table></figure><p>编号负责连接项目管理系统，<code>feat</code>、<code>fix</code>、<code>refactor</code> 等类型负责生成变更日志和版本统计。两者承担不同职责，可以同时保留。</p><h3 id="一条好-Commit-应该表达什么"><a href="#一条好-Commit-应该表达什么" class="headerlink" title="一条好 Commit 应该表达什么"></a>一条好 Commit 应该表达什么</h3><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></pre></td><td class="code"><pre><span class="line">【BUG-2381】fix: 修复导出金额精度错误</span><br><span class="line"></span><br><span class="line">原因：导出层将 Decimal 转换为 float，导致部分金额出现精度损失。</span><br><span class="line">处理：导出数据全程使用 Decimal，并统一保留两位小数。</span><br><span class="line">验证：补充边界金额单元测试，抽查导出文件与数据库记录一致。</span><br><span class="line">影响：仅涉及订单导出，不修改订单存储数据。</span><br></pre></td></tr></table></figure><p>避免使用“修改一下”“优化代码”“修复问题”这类无法独立理解的描述。</p><h2 id="分支命名规范"><a href="#分支命名规范" class="headerlink" title="分支命名规范"></a>分支命名规范</h2><p>分支名建议同时表达事项类型、编号和简短主题：</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">git switch -c feature/REQ-1428-order-export</span><br><span class="line">git switch -c bugfix/BUG-2381-export-amount</span><br><span class="line">git switch -c refactor/TECH-0362-order-query</span><br><span class="line">git switch -c hotfix/BUG-2401-payment-timeout</span><br></pre></td></tr></table></figure><p>推荐类型：</p><table><thead><tr><th>类型</th><th>使用场景</th></tr></thead><tbody><tr><td><code>feature/</code></td><td>新需求或功能</td></tr><tr><td><code>bugfix/</code></td><td>普通缺陷修复</td></tr><tr><td><code>hotfix/</code></td><td>需要紧急发布的线上故障</td></tr><tr><td><code>refactor/</code></td><td>不改变外部行为的重构</td></tr><tr><td><code>chore/</code></td><td>依赖、构建或工程配置</td></tr></tbody></table><p>分支应保持短生命周期。一个分支只处理一个事项，避免把多个需求混进同一个 Pull Request。</p><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>唯一编号、目标和业务价值；</li><li>明确范围与不做事项；</li><li>可验证的验收标准；</li><li>接口、数据、权限和兼容性影响；</li><li>发布方式与回滚条件。</li></ul><p>缺陷还应包含复现步骤、期望结果、实际结果、环境和必要日志。</p><h3 id="2-创建分支"><a href="#2-创建分支" class="headerlink" title="2. 创建分支"></a>2. 创建分支</h3><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></pre></td><td class="code"><pre><span class="line">git switch main</span><br><span class="line">git pull --ff-only</span><br><span class="line">git switch -c feature/REQ-1428-order-export</span><br></pre></td></tr></table></figure><p><code>--ff-only</code> 可以避免拉取时意外生成合并提交。如果团队使用 rebase 工作流，也应在团队规范中统一说明。</p><h3 id="3-小步提交"><a href="#3-小步提交" class="headerlink" title="3. 小步提交"></a>3. 小步提交</h3><p>先检查变化，再选择性暂存：</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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git status</span><br><span class="line">git diff</span><br><span class="line">git add -p</span><br><span class="line">git diff --cached</span><br><span class="line">git commit -m <span class="string">&quot;【REQ-1428】feat: 增加订单导出接口&quot;</span></span><br></pre></td></tr></table></figure><p><code>git add -p</code> 可以逐块选择变更，防止调试代码、格式化噪声或无关文件混入 Commit。</p><p>一个 Commit 应保持逻辑完整：代码、必要测试和相关文档一起提交，同时确保它可以独立通过构建。</p><h3 id="4-提交-Pull-Request"><a href="#4-提交-Pull-Request" class="headerlink" title="4. 提交 Pull Request"></a>4. 提交 Pull Request</h3><p>PR 标题继续携带编号：</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">【REQ-1428】增加订单导出功能</span><br></pre></td></tr></table></figure><p>PR 描述建议包含：</p><ul><li>关联事项与背景；</li><li>方案概述和关键取舍；</li><li>数据库、接口或配置变化；</li><li>测试范围和验证证据；</li><li>风险、监控指标与回滚方式；</li><li>页面或交互变化截图。</li></ul><h3 id="5-CI-与评审门禁"><a href="#5-CI-与评审门禁" class="headerlink" title="5. CI 与评审门禁"></a>5. CI 与评审门禁</h3><p>合并前至少执行：</p><ul><li>格式检查与静态分析；</li><li>单元测试和必要的集成测试；</li><li>依赖和安全扫描；</li><li>构建可部署产物；</li><li>Commit 或 PR 编号格式校验；</li><li>至少一名非作者评审通过。</li></ul><p>高风险变更可以要求架构、数据库或安全负责人追加评审。</p><h3 id="6-发布与回溯"><a href="#6-发布与回溯" class="headerlink" title="6. 发布与回溯"></a>6. 发布与回溯</h3><p>构建产物应记录 Git Commit SHA，并在发布记录中写入关联编号：</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></pre></td><td class="code"><pre><span class="line">版本：v2.8.0</span><br><span class="line">Commit：4f37c2a</span><br><span class="line">事项：REQ-1428、BUG-2381</span><br><span class="line">环境：production</span><br><span class="line">验证：订单导出成功率、接口延迟、错误率</span><br><span class="line">回滚：恢复 v2.7.3 镜像，不执行数据回滚</span><br></pre></td></tr></table></figure><p>出现线上问题时，可以从版本找到 Commit，再从 Commit 找到编号、PR、测试证据和负责人。</p><h2 id="自动校验-Commit-格式"><a href="#自动校验-Commit-格式" class="headerlink" title="自动校验 Commit 格式"></a>自动校验 Commit 格式</h2><p>团队可以在 CI 中校验标题。以下正则接受需求、Bug 和技术任务编号：</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">^【(REQ|BUG|TECH)-[0-9]+】(feat|fix|refactor|docs|test|chore|perf|ci): .+</span><br></pre></td></tr></table></figure><p>一个简单的 Shell 检查示例：</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><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">message=<span class="string">&quot;<span class="subst">$(git log -1 --pretty=%s)</span>&quot;</span></span><br><span class="line">pattern=<span class="string">&#x27;^【(REQ|BUG|TECH)-[0-9]+】(feat|fix|refactor|docs|test|chore|perf|ci): .+&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> ! <span class="built_in">printf</span> <span class="string">&#x27;%s&#x27;</span> <span class="string">&quot;<span class="variable">$message</span>&quot;</span> | grep -Eq <span class="string">&quot;<span class="variable">$pattern</span>&quot;</span>; <span class="keyword">then</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;Commit 格式错误：<span class="variable">$message</span>&quot;</span></span><br><span class="line">  <span class="built_in">exit</span> 1</span><br><span class="line"><span class="keyword">fi</span></span><br></pre></td></tr></table></figure><p>本地 Hook 可以尽早反馈，但真正的强制校验应放在服务端或 CI，因为客户端 Hook 可以被跳过。</p><h2 id="Git-常用命令速查"><a href="#Git-常用命令速查" class="headerlink" title="Git 常用命令速查"></a>Git 常用命令速查</h2><h3 id="查看状态和历史"><a href="#查看状态和历史" class="headerlink" title="查看状态和历史"></a>查看状态和历史</h3><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><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">git status</span><br><span class="line">git <span class="built_in">log</span> --oneline --graph --decorate --all</span><br><span class="line">git show &lt;commit&gt;</span><br><span class="line">git diff</span><br><span class="line">git diff --cached</span><br><span class="line">git blame &lt;file&gt;</span><br></pre></td></tr></table></figure><h3 id="分支操作"><a href="#分支操作" class="headerlink" title="分支操作"></a>分支操作</h3><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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git branch --all</span><br><span class="line">git switch &lt;branch&gt;</span><br><span class="line">git switch -c &lt;new-branch&gt;</span><br><span class="line">git branch -d &lt;merged-branch&gt;</span><br><span class="line">git fetch --prune</span><br></pre></td></tr></table></figure><h3 id="暂存与提交"><a href="#暂存与提交" class="headerlink" title="暂存与提交"></a>暂存与提交</h3><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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git add &lt;file&gt;</span><br><span class="line">git add -p</span><br><span class="line">git restore --staged &lt;file&gt;</span><br><span class="line">git commit</span><br><span class="line">git commit --amend</span><br></pre></td></tr></table></figure><p><code>--amend</code> 会重写最近一次 Commit。已经推送且多人使用的分支，不应随意改写历史。</p><h3 id="临时保存工作"><a href="#临时保存工作" class="headerlink" title="临时保存工作"></a>临时保存工作</h3><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">git stash push -m <span class="string">&quot;REQ-1428 未完成的导出页面&quot;</span></span><br><span class="line">git stash list</span><br><span class="line">git stash show -p stash@&#123;0&#125;</span><br><span class="line">git stash pop</span><br></pre></td></tr></table></figure><h3 id="撤销和恢复"><a href="#撤销和恢复" class="headerlink" title="撤销和恢复"></a>撤销和恢复</h3><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><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="comment"># 丢弃工作区中某个文件的未提交修改</span></span><br><span class="line">git restore &lt;file&gt;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 创建一个反向 Commit，适合已经共享的历史</span></span><br><span class="line">git revert &lt;commit&gt;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查找误删分支或重写前的 Commit</span></span><br><span class="line">git reflog</span><br></pre></td></tr></table></figure><p>不要在不理解影响时使用 <code>git reset --hard</code> 或强制推送。共享分支优先使用 <code>git revert</code> 保留可审计历史。</p><h3 id="同步主分支"><a href="#同步主分支" class="headerlink" title="同步主分支"></a>同步主分支</h3><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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git fetch origin</span><br><span class="line">git rebase origin/main</span><br><span class="line"></span><br><span class="line"><span class="comment"># 或采用合并策略</span></span><br><span class="line">git merge origin/main</span><br></pre></td></tr></table></figure><p>团队应统一选择 rebase 或 merge 策略。不要为了“历史好看”反复重写已经进入共享分支的 Commit。</p><h3 id="查找问题来源"><a href="#查找问题来源" class="headerlink" title="查找问题来源"></a>查找问题来源</h3><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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">log</span> -S <span class="string">&quot;关键代码&quot;</span> --oneline</span><br><span class="line">git <span class="built_in">log</span> --grep=<span class="string">&quot;BUG-2381&quot;</span> --oneline</span><br><span class="line">git bisect start</span><br><span class="line">git bisect bad</span><br><span class="line">git bisect good &lt;known-good-commit&gt;</span><br></pre></td></tr></table></figure><p><code>git bisect</code> 可以通过二分法定位引入问题的 Commit，配合自动化测试效果更好。</p><h2 id="Git-Submodule-的适用场景"><a href="#Git-Submodule-的适用场景" class="headerlink" title="Git Submodule 的适用场景"></a>Git Submodule 的适用场景</h2><p>Submodule 是嵌入在主仓库中的独立 Git 仓库。主仓库不直接保存它的全部内容，而是记录子仓库的一个确定 Commit。</p><p>适合场景：</p><ul><li>多个项目共享但需要独立发布的 SDK；</li><li>主项目需要锁定第三方源码的确定版本；</li><li>权限、发布节奏或历史必须独立的模块。</li></ul><p>如果模块必须与主项目频繁原子修改，Monorepo、包管理器或制品仓库通常更简单。</p><h2 id="添加-Submodule"><a href="#添加-Submodule" class="headerlink" title="添加 Submodule"></a>添加 Submodule</h2><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">git submodule add https://git.example.com/team/common-sdk.git libs/common-sdk</span><br><span class="line">git status</span><br><span class="line">git add .gitmodules libs/common-sdk</span><br><span class="line">git commit -m <span class="string">&quot;【TECH-0412】chore: 引入 common-sdk 子模块&quot;</span></span><br></pre></td></tr></table></figure><p>该操作会创建或更新 <code>.gitmodules</code>，同时让主仓库记录子模块当前 Commit。</p><h2 id="克隆包含-Submodule-的项目"><a href="#克隆包含-Submodule-的项目" class="headerlink" title="克隆包含 Submodule 的项目"></a>克隆包含 Submodule 的项目</h2><p>推荐一次性递归克隆：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git <span class="built_in">clone</span> --recurse-submodules https://git.example.com/team/app.git</span><br></pre></td></tr></table></figure><p>如果已经普通克隆：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git submodule update --init --recursive</span><br></pre></td></tr></table></figure><p>其中 <code>init</code> 根据 <code>.gitmodules</code> 初始化本地配置，<code>update</code> 检出主仓库记录的 Commit，<code>recursive</code> 继续处理嵌套子模块。</p><h2 id="更新-Submodule"><a href="#更新-Submodule" class="headerlink" title="更新 Submodule"></a>更新 Submodule</h2><h3 id="按主仓库锁定版本更新"><a href="#按主仓库锁定版本更新" class="headerlink" title="按主仓库锁定版本更新"></a>按主仓库锁定版本更新</h3><p>切换分支或拉取主仓库后执行：</p><figure class="highlight bash"><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">git pull --ff-only</span><br><span class="line">git submodule update --init --recursive</span><br></pre></td></tr></table></figure><p>这不会自动追踪子模块远端最新版本，而是恢复到主仓库记录的确定 Commit，因此构建具备可复现性。</p><h3 id="主动升级到子模块新版本"><a href="#主动升级到子模块新版本" class="headerlink" title="主动升级到子模块新版本"></a>主动升级到子模块新版本</h3><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><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="built_in">cd</span> libs/common-sdk</span><br><span class="line">git switch main</span><br><span class="line">git pull --ff-only</span><br><span class="line"><span class="built_in">cd</span> ../..</span><br><span class="line"></span><br><span class="line">git status</span><br><span class="line">git add libs/common-sdk</span><br><span class="line">git commit -m <span class="string">&quot;【TECH-0415】chore: 升级 common-sdk 版本&quot;</span></span><br></pre></td></tr></table></figure><p>主仓库中的这次 Commit 记录的是新的子模块 Commit 指针。</p><p>也可以根据 <code>.gitmodules</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></pre></td><td class="code"><pre><span class="line">git submodule update --remote --merge libs/common-sdk</span><br><span class="line">git add libs/common-sdk</span><br><span class="line">git commit -m <span class="string">&quot;【TECH-0415】chore: 升级 common-sdk 版本&quot;</span></span><br></pre></td></tr></table></figure><p>升级后必须提交主仓库中的指针变化，否则其他开发者和 CI 仍会使用旧版本。</p><h2 id="修改-Submodule-自身代码"><a href="#修改-Submodule-自身代码" class="headerlink" title="修改 Submodule 自身代码"></a>修改 Submodule 自身代码</h2><p>进入子模块后，它就是一个独立仓库：</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><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="built_in">cd</span> libs/common-sdk</span><br><span class="line">git switch -c feature/REQ-1502-export-helper</span><br><span class="line"></span><br><span class="line"><span class="comment"># 修改、测试并提交</span></span><br><span class="line">git add -p</span><br><span class="line">git commit -m <span class="string">&quot;【REQ-1502】feat: 增加导出格式化工具&quot;</span></span><br><span class="line">git push -u origin feature/REQ-1502-export-helper</span><br></pre></td></tr></table></figure><p>子模块 PR 合并后，回到主仓库更新并提交指针：</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><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="built_in">cd</span> libs/common-sdk</span><br><span class="line">git switch main</span><br><span class="line">git pull --ff-only</span><br><span class="line"><span class="built_in">cd</span> ../..</span><br><span class="line"></span><br><span class="line">git add libs/common-sdk</span><br><span class="line">git commit -m <span class="string">&quot;【REQ-1502】chore: 更新 common-sdk 引用&quot;</span></span><br></pre></td></tr></table></figure><p>因此一次跨仓库需求通常有两组 Commit 和 PR，它们应引用同一个需求编号。</p><h2 id="Submodule-常见问题"><a href="#Submodule-常见问题" class="headerlink" title="Submodule 常见问题"></a>Submodule 常见问题</h2><h3 id="子模块目录为空"><a href="#子模块目录为空" class="headerlink" title="子模块目录为空"></a>子模块目录为空</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git submodule update --init --recursive</span><br></pre></td></tr></table></figure><h3 id="显示-modified-content"><a href="#显示-modified-content" class="headerlink" title="显示 modified content"></a>显示 modified content</h3><p>先查看子模块内部状态：</p><figure class="highlight bash"><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">git -C libs/common-sdk status</span><br><span class="line">git diff --submodule</span><br></pre></td></tr></table></figure><p>确认是未提交修改、未跟踪文件，还是当前 Commit 与主仓库记录不一致。</p><h3 id="修改了仓库地址"><a href="#修改了仓库地址" class="headerlink" title="修改了仓库地址"></a>修改了仓库地址</h3><p>更新 <code>.gitmodules</code> 后同步本地配置：</p><figure class="highlight bash"><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">git submodule <span class="built_in">sync</span> --recursive</span><br><span class="line">git submodule update --init --recursive</span><br></pre></td></tr></table></figure><h3 id="子模块处于-detached-HEAD"><a href="#子模块处于-detached-HEAD" class="headerlink" title="子模块处于 detached HEAD"></a>子模块处于 detached HEAD</h3><p><code>git submodule update</code> 检出固定 Commit 时出现 detached HEAD 是正常现象。若要开发子模块，先显式切换或创建分支：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git -C libs/common-sdk switch main</span><br></pre></td></tr></table></figure><h3 id="查看所有子模块版本"><a href="#查看所有子模块版本" class="headerlink" title="查看所有子模块版本"></a>查看所有子模块版本</h3><figure class="highlight bash"><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">git submodule status --recursive</span><br><span class="line">git submodule foreach --recursive <span class="string">&#x27;git status --short --branch&#x27;</span></span><br></pre></td></tr></table></figure><h2 id="团队使用-Submodule-的约定"><a href="#团队使用-Submodule-的约定" class="headerlink" title="团队使用 Submodule 的约定"></a>团队使用 Submodule 的约定</h2><p>为了避免“我本地能运行，CI 却缺代码”，建议统一以下规则：</p><ol><li>CI 克隆必须启用递归 Submodule；</li><li>子模块升级和主仓库指针更新分成清晰 Commit；</li><li>子模块 Commit 必须已推送，主仓库才能引用；</li><li>不直接在 detached HEAD 上开发；</li><li>发布记录同时列出主仓库与关键子模块 SHA；</li><li>修改 <code>.gitmodules</code> 后执行并记录同步步骤；</li><li>定期检查失效地址、权限和长期未升级依赖。</li></ol><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></pre></td><td class="code"><pre><span class="line">事项：REQ-1428</span><br><span class="line">分支：feature/REQ-1428-order-export</span><br><span class="line">Commit：【REQ-1428】feat: 增加订单导出功能</span><br><span class="line">PR：【REQ-1428】增加订单导出功能</span><br><span class="line">测试：单元测试、接口测试、导出文件人工抽查</span><br><span class="line">发布：v2.8.0 / commit 4f37c2a</span><br><span class="line">监控：导出成功率、P95 延迟、任务失败数</span><br><span class="line">回滚：恢复上一镜像；新接口无数据迁移</span><br></pre></td></tr></table></figure><p>规范的价值不在于让 Commit 更整齐，而在于把需求、代码、测试、发布和线上结果连接成一条可以搜索、审计和复盘的证据链。</p><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://git-scm.com/docs/git-commit">Git Commit 官方文档</a></li><li><a href="https://git-scm.com/docs/git-submodule">Git Submodule 官方文档</a></li><li><a href="https://git-scm.com/book/en/v2/Git-Tools-Submodules">Pro Git：Submodules</a></li></ul><blockquote><p>资料与流程复核日期：2026-08-22。团队应再以自身代码托管平台、分支保护和工作项规则为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/22/project-management-git-workflow/</id>
    <link href="https://www.cjjhc.cn/2026/08/22/project-management-git-workflow/"/>
    <published>2026-08-22T14:00:00.000Z</published>
    <summary>从需求和缺陷编号出发，用分支、Commit、评审、测试、发布与 Git Submodule 建立可追踪的研发闭环。</summary>
    <title>用需求编号、Commit 与 Git 串联完整研发流程</title>
    <updated>2026-08-22T15:20:57.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="随笔" scheme="https://www.cjjhc.cn/categories/%E9%9A%8F%E7%AC%94/"/>
    <category term="柠萌" scheme="https://www.cjjhc.cn/tags/%E6%9F%A0%E8%90%8C/"/>
    <category term="技术生活" scheme="https://www.cjjhc.cn/tags/%E6%8A%80%E6%9C%AF%E7%94%9F%E6%B4%BB/"/>
    <category term="旅行记录" scheme="https://www.cjjhc.cn/tags/%E6%97%85%E8%A1%8C%E8%AE%B0%E5%BD%95/"/>
    <content>
      <![CDATA[<p>你好，欢迎来到 <strong>柠萌</strong>。</p><p>这里是一间写给技术爱好者与旅行者的线上书房。名字里有一点清新的酸甜，也代表一种期待：面对复杂系统时保持清醒，走进陌生山河时保持好奇。</p><span id="more"></span><h2 id="我们会写些什么"><a href="#我们会写些什么" class="headerlink" title="我们会写些什么"></a>我们会写些什么</h2><p>技术栏目关注真正能够落地的工程实践，包括 Kubernetes、云原生、Linux、数据库、前后端开发、系统设计和可观测性。文章不仅介绍工具怎么使用，也会解释为什么这样设计、可能遇到什么问题，以及如何验证结果。</p><p>旅行栏目主要记录四川与云南。从成都的茶馆街巷，到川西的雪山草原；从大理洱海的风，到滇西北的峡谷与高原，我们会尽量把路线、交通、住宿、季节、预算和注意事项整理清楚。</p><h2 id="关于内容的态度"><a href="#关于内容的态度" class="headerlink" title="关于内容的态度"></a>关于内容的态度</h2><p>我们希望这里的文章具备三个特点：</p><ul><li><strong>清晰</strong>：先说结论，再展开背景和步骤。</li><li><strong>实用</strong>：给出可以执行的建议，而不是简单罗列信息。</li><li><strong>诚实</strong>：说明限制与取舍，不把个人经验包装成唯一答案。</li></ul><p>技术会持续变化，旅行也会受到天气、交通和开放政策影响。重要的信息值得在行动前再次核实，而体验本身则值得慢慢发生。</p><h2 id="从这里开始"><a href="#从这里开始" class="headerlink" title="从这里开始"></a>从这里开始</h2><p>你可以通过顶部导航浏览<a href="/archives/">文章归档</a>、<a href="/categories/">内容分类</a>和<a href="/tags/">热门标签</a>，也可以使用站内搜索直接寻找感兴趣的主题。</p><p>愿你在这里找到解决一个问题的线索，也找到下一次出发的灵感。</p>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/22/hello-world/</id>
    <link href="https://www.cjjhc.cn/2026/08/22/hello-world/"/>
    <published>2026-08-22T12:00:00.000Z</published>
    <summary>在代码与山河之间，记录值得反复阅读的知识与风景。</summary>
    <title>你好，这里是柠萌</title>
    <updated>2026-08-23T04:29:09.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="工程效率" scheme="https://www.cjjhc.cn/categories/%E5%B7%A5%E7%A8%8B%E6%95%88%E7%8E%87/"/>
    <category term="文档" scheme="https://www.cjjhc.cn/tags/%E6%96%87%E6%A1%A3/"/>
    <category term="技术写作" scheme="https://www.cjjhc.cn/tags/%E6%8A%80%E6%9C%AF%E5%86%99%E4%BD%9C/"/>
    <category term="知识管理" scheme="https://www.cjjhc.cn/tags/%E7%9F%A5%E8%AF%86%E7%AE%A1%E7%90%86/"/>
    <content>
      <![CDATA[<p>从读者任务出发组织背景、步骤、验证与排错。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>开头说明读者能完成什么和前置条件。操作步骤提供可观察结果，关键决策解释原因与权衡。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>示例保持最小可运行，并标注版本。文档随代码评审，过期内容要删除或明确归档。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>开头说明目标读者、完成结果和前置条件；步骤给出命令、预期输出和回退方式。设计文档记录选项、约束和为何没有选择其他方案。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>示例放在 CI 中执行或至少定期验证，标注适用版本和最后确认日期。文档随代码评审，过期内容删除或明确归档。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“编写一份新人能独立完成部署的操作文档”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>开头写完成结果、适用版本和前置权限；每一步包含动作、预期现象与失败分支，架构决策记录约束和替代方案。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>让不了解项目的人按文档在干净环境执行，记录卡点；故意使用旧版本验证过期提示是否明显。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察首次完成率、卡点和过期链接；文档与代码同审，失效内容归档而不是继续误导。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 任务型文档、验证输出和过期审计记录。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>让未参与实现的人在干净环境按文档执行，记录所有需要口头补充的步骤</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</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></pre></td><td class="code"><pre><span class="line">目标：把 api-service 2.4.1 部署到预发环境</span><br><span class="line">读者：具有预发部署权限的值班工程师</span><br><span class="line">前置：已取得制品摘要，迁移兼容旧版本，维护窗口已登记</span><br><span class="line">成功：新版本健康实例达到 100%，错误率不高于基线，旧实例安全退出</span><br><span class="line">停止：5xx 连续 5 分钟高于 1%，或迁移出现不可逆错误</span><br></pre></td></tr></table></figure><p>命令附近要明确工作目录、所需权限、占位符和副作用。危险命令先提供只读预览，示例不能包含真实域名、账号、令牌或生产资源 ID。</p><h3 id="用“动作—预期—分支”组织步骤"><a href="#用“动作—预期—分支”组织步骤" class="headerlink" title="用“动作—预期—分支”组织步骤"></a>用“动作—预期—分支”组织步骤</h3><figure class="highlight markdown"><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="bullet">1.</span> 查看待部署制品摘要：<span class="code">`deployctl inspect &lt;release-id&gt;`</span></span><br><span class="line"><span class="bullet">   -</span> 预期：commit、镜像摘要和审批记录均存在。</span><br><span class="line"><span class="bullet">   -</span> 若摘要与变更单不一致：停止，不要重新打标签掩盖差异。</span><br><span class="line"><span class="bullet">2.</span> 对预发执行 dry-run：<span class="code">`deployctl plan --env staging &lt;release-id&gt;`</span></span><br><span class="line"><span class="bullet">   -</span> 预期：只更新 api-service，不删除共享资源。</span><br><span class="line"><span class="bullet">3.</span> 灰度 10%，观察 15 分钟后再继续。</span><br></pre></td></tr></table></figure><p>这比一串没有预期输出的命令更容易在压力下执行，也更便于审阅者发现权限扩大和不可逆步骤。</p><table><thead><tr><th>文档类型</th><th>必须持续校验的内容</th><th>合适的所有者</th></tr></thead><tbody><tr><td>教程</td><td>新手能否从空环境得到结果</td><td>开发者体验&#x2F;功能团队</td></tr><tr><td>操作手册</td><td>前提、阈值、回滚和升级路径</td><td>值班团队</td></tr><tr><td>API 参考</td><td>参数、返回、错误和版本</td><td>接口提供方</td></tr><tr><td>架构决策</td><td>上下文、选项、取舍和后果</td><td>决策负责人</td></tr><tr><td>故障复盘</td><td>时间线、影响、证据和行动项</td><td>事故负责人</td></tr></tbody></table><h3 id="把文档当作产品测试"><a href="#把文档当作产品测试" class="headerlink" title="把文档当作产品测试"></a>把文档当作产品测试</h3><p>代码块应在干净环境中执行或至少完成语法校验；链接和锚点由 CI 检查；截图要记录版本并避免承载唯一信息。邀请目标读者在不接受口头提示的情况下完成任务，记录卡住的位置、错误理解和额外搜索。</p><p>发布时在页首或页尾标注适用版本、最后复核日期和负责人。功能删除后同时删除入口或添加迁移说明，不要让过期文档继续被搜索引擎命中。紧急更新也要走最小审阅，并在事后补齐证据和原因。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://developers.google.com/tech-writing">Google 技术写作课程</a></li><li><a href="https://www.writethedocs.org/guide/">Write the Docs Guide</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/21/technical-writing/</id>
    <link href="https://www.cjjhc.cn/2026/08/21/technical-writing/"/>
    <published>2026-08-21T01:00:00.000Z</published>
    <summary>从读者任务出发组织背景、步骤、验证与排错。</summary>
    <title>写出清晰技术文档的方法</title>
    <updated>2026-08-21T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="工程效率" scheme="https://www.cjjhc.cn/categories/%E5%B7%A5%E7%A8%8B%E6%95%88%E7%8E%87/"/>
    <category term="测试" scheme="https://www.cjjhc.cn/tags/%E6%B5%8B%E8%AF%95/"/>
    <category term="质量保障" scheme="https://www.cjjhc.cn/tags/%E8%B4%A8%E9%87%8F%E4%BF%9D%E9%9A%9C/"/>
    <category term="自动化" scheme="https://www.cjjhc.cn/tags/%E8%87%AA%E5%8A%A8%E5%8C%96/"/>
    <content>
      <![CDATA[<p>把多数反馈放在快速稳定的单元与组件测试，把端到端测试留给关键路径。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>测试业务行为而非内部实现，边界依赖使用少量契约测试。端到端覆盖登录、支付等核心流程，不追求枚举所有组合。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>持续统计耗时、失败率和缺陷逃逸。测试代码同样需要重构，避免夹具过大和断言含糊。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>多数规则放快速单元测试，组件和契约测试验证边界，端到端仅覆盖高价值流程。测试行为而非内部调用次数，夹具保持小而明确。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>持续监控测试耗时、失败率和缺陷逃逸。flaky 测试必须记录并修复，不能通过无限重跑把不确定性隐藏起来。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为一个服务建立分层测试组合”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>大量单元测试覆盖领域分支，少量集成测试验证数据库和协议，关键用户旅程用端到端测试；契约测试守住服务边界。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>对同一缺陷分别选择最低有效测试层，注入网络失败与时间边界，避免全部依赖昂贵 E2E。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察反馈时间、不稳定率和线上逃逸；持续删除重复且无区分度的高层测试。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 测试分层表、失败夹具和不稳定用例清单。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>先运行最小单元测试，再运行依赖隔离的集成测试，最后只跑关键 E2E</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：为退款流程分配测试层级"><a href="#完整实战：为退款流程分配测试层级" class="headerlink" title="完整实战：为退款流程分配测试层级"></a>完整实战：为退款流程分配测试层级</h2><p>退款同时涉及金额规则、订单状态、支付网关和用户页面。不要先决定“写多少单元测试”，而是列出风险，再选择成本最低且能发现该风险的测试层。</p><table><thead><tr><th>风险</th><th>最合适的首选层级</th><th>原因</th></tr></thead><tbody><tr><td>金额舍入和退款上限</td><td>纯单元&#x2F;性质测试</td><td>组合多、执行快、容易穷举边界</td></tr><tr><td>API 错误码与幂等语义</td><td>契约测试</td><td>保护服务与调用方约定</td></tr><tr><td>数据事务与唯一约束</td><td>集成测试</td><td>需要真实数据库语义</td></tr><tr><td>支付网关请求映射</td><td>适配器测试 + 模拟服务器</td><td>不依赖真实扣款也能验证协议</td></tr><tr><td>用户发起退款主路径</td><td>少量端到端测试</td><td>验证浏览器到服务的关键接缝</td></tr></tbody></table><p>核心规则可以写成无框架依赖的函数，并覆盖边界：零金额、部分退款、重复请求、币种精度、订单状态不允许。与数据库相关的幂等约束则必须连接真实数据库验证，内存假实现无法证明并发下的唯一索引与事务行为。</p><figure class="highlight ts"><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="title function_">it</span>(<span class="string">&#x27;同一幂等键并发提交只创建一条退款&#x27;</span>, <span class="title function_">async</span> () =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> requests = <span class="title class_">Array</span>.<span class="title function_">from</span>(&#123; <span class="attr">length</span>: <span class="number">5</span> &#125;, <span class="function">() =&gt;</span></span><br><span class="line">    refundApi.<span class="title function_">create</span>(&#123; orderId, <span class="attr">amountCent</span>: <span class="number">500</span>, <span class="attr">idempotencyKey</span>: <span class="string">&#x27;refund-42&#x27;</span> &#125;)</span><br><span class="line">  )</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> results = <span class="keyword">await</span> <span class="title class_">Promise</span>.<span class="title function_">all</span>(requests)</span><br><span class="line">  <span class="title function_">expect</span>(<span class="keyword">new</span> <span class="title class_">Set</span>(results.<span class="title function_">map</span>(<span class="function"><span class="params">item</span> =&gt;</span> item.<span class="property">refundId</span>)).<span class="property">size</span>).<span class="title function_">toBe</span>(<span class="number">1</span>)</span><br><span class="line">  <span class="title function_">expect</span>(<span class="keyword">await</span> refundRepository.<span class="title function_">countByOrder</span>(orderId)).<span class="title function_">toBe</span>(<span class="number">1</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><h3 id="控制不稳定测试"><a href="#控制不稳定测试" class="headerlink" title="控制不稳定测试"></a>控制不稳定测试</h3><p>测试偶发失败通常来自未控制的时间、随机数、并发、端口、共享数据或外部网络。先保存失败种子、时区、运行时版本和日志，再修复根因。隔离区只能是有负责人和截止日期的临时措施，不能通过无限重跑掩盖红灯。</p><p>端到端测试只保留跨组件最有价值的旅程，例如登录、下单、退款；定位细节交给更低层测试。选择器使用可访问角色或稳定测试标识，不依赖脆弱的 DOM 层级和随机等待。</p><h3 id="衡量测试组合"><a href="#衡量测试组合" class="headerlink" title="衡量测试组合"></a>衡量测试组合</h3><p>覆盖率只说明哪些行执行过，不说明断言有效。更有用的信号包括缺陷逃逸率、关键路径覆盖、测试 P95 时长、不稳定率、失败定位时间和变更影响选择的准确性。一次测试体系调整后，应比较“发现同类缺陷所需时间”，而不只是用例数量。</p><p>发布前故意让支付适配器返回超时、重复回调和畸形响应，确认系统不会重复退款且能人工对账。回滚应用时保留退款记录和幂等键；测试清理脚本只能作用于明确的隔离数据库。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://martinfowler.com/articles/practical-test-pyramid.html">Practical Test Pyramid</a></li><li><a href="https://docs.pytest.org/en/stable/how-to/fixtures.html">pytest Fixtures</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/20/testing-pyramid/</id>
    <link href="https://www.cjjhc.cn/2026/08/20/testing-pyramid/"/>
    <published>2026-08-20T01:00:00.000Z</published>
    <summary>把多数反馈放在快速稳定的单元与组件测试，把端到端测试留给关键路径。</summary>
    <title>测试金字塔的现实落地</title>
    <updated>2026-08-20T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="工程效率" scheme="https://www.cjjhc.cn/categories/%E5%B7%A5%E7%A8%8B%E6%95%88%E7%8E%87/"/>
    <category term="Git" scheme="https://www.cjjhc.cn/tags/Git/"/>
    <category term="团队协作" scheme="https://www.cjjhc.cn/tags/%E5%9B%A2%E9%98%9F%E5%8D%8F%E4%BD%9C/"/>
    <category term="代码评审" scheme="https://www.cjjhc.cn/tags/%E4%BB%A3%E7%A0%81%E8%AF%84%E5%AE%A1/"/>
    <content>
      <![CDATA[<p>以短分支、小提交和清晰评审降低合并成本。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>从最新主干创建短生命周期分支，提交信息解释意图。PR 控制规模，描述风险、测试方式与截图。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>合并前同步主干并解决冲突，合并后及时删除分支。紧急修复也要经过最小必要评审并补齐测试。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>从最新主干创建短分支，使用小而完整的提交，PR 写清事项编号、风险和验证。合并策略在团队统一，主干开启保护和必需检查。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>共享分支避免改写历史；紧急修复同样补测试和复盘。用 <code>git range-diff</code> 或 PR diff 审查实际变化，合并后删除分支。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“用短分支和 PR 管理团队变更”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>从已确认的远端基线创建任务分支，小提交保持单一语义；合并前同步目标并运行验证，受保护分支通过 PR。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>测试冲突、目标分支前进、检查失败和 squash 合入，确认最终提交能从目标分支获取。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察分支存活时间、冲突率和回滚难度；错误合入优先 revert，不重写共享历史。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 分支基线、PR 验证与可恢复合入记录。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>git status --short --branch; git fetch --prune; git merge-base --is-ancestor &lt;source&gt; &lt;target&gt;</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</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></pre></td><td class="code"><pre><span class="line">main</span><br><span class="line"> ├─ feat/ORD-241-export-api</span><br><span class="line"> ├─ feat/ORD-241-export-ui</span><br><span class="line"> └─ fix/ORD-255-export-timezone</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><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">【ORD-241】- 增加异步导出任务接口</span><br><span class="line"></span><br><span class="line">- 导出请求使用业务幂等键</span><br><span class="line">- 大于 10 万行时拒绝同步处理</span><br><span class="line">- 验证：契约测试、重复提交测试、取消测试</span><br></pre></td></tr></table></figure><p>不要按“前端代码、后端代码、测试代码”切成三个无法单独工作的提交。更理想的是先提交兼容的后端能力与测试，再提交默认关闭的前端入口，最后单独开启功能开关。这样回退 UI 不会要求回退数据库。</p><h3 id="同步主线与处理冲突"><a href="#同步主线与处理冲突" class="headerlink" title="同步主线与处理冲突"></a>同步主线与处理冲突</h3><p>功能分支每天同步主线，降低最终集成成本。是否 rebase 取决于团队约定：未共享的个人分支可以整理历史；多人共同使用的分支不应随意改写历史。发生冲突时先理解两边意图，再运行相关测试，不能以“文件没有冲突标记”作为完成标准。</p><table><thead><tr><th>门禁</th><th>必须回答的问题</th><th>证据</th></tr></thead><tbody><tr><td>变更范围</td><td>是否只包含当前工作项</td><td>文件列表、差异摘要</td></tr><tr><td>兼容性</td><td>新旧版本能否交错运行</td><td>契约&#x2F;迁移测试</td></tr><tr><td>质量</td><td>核心行为和失败路径是否通过</td><td>CI 测试报告</td></tr><tr><td>发布</td><td>如何灰度、观察和回退</td><td>功能开关、指标、回滚步骤</td></tr><tr><td>安全</td><td>是否引入凭据或扩大权限</td><td>密钥扫描、权限复核</td></tr></tbody></table><h3 id="合并后的恢复演练"><a href="#合并后的恢复演练" class="headerlink" title="合并后的恢复演练"></a>合并后的恢复演练</h3><p>合并不是终点。部署前确认提交 SHA 与制品摘要的对应关系；部署后观察导出成功率、队列等待与错误码。若指标越线，先关闭导出入口，允许在途任务安全结束，再回退应用。数据库迁移使用 expand&#x2F;contract：先新增兼容结构，等所有旧实例退出且数据完成回填后，才在后续发布删除旧结构。</p><p>每季度选一个低风险变更演练 <code>revert</code>，确认回退提交能通过当前主线测试。不要在共享主线上强推或重写已经发布的历史；恢复生产应使用新的、可审计的修复或回退提交。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://git-scm.com/docs/gitworkflows">Git Workflows</a></li><li><a href="https://git-scm.com/docs/git-revert">Git Revert</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/19/git-branch-workflow/</id>
    <link href="https://www.cjjhc.cn/2026/08/19/git-branch-workflow/"/>
    <published>2026-08-19T01:00:00.000Z</published>
    <summary>以短分支、小提交和清晰评审降低合并成本。</summary>
    <title>小团队 Git 分支协作指南</title>
    <updated>2026-08-19T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="工程效率" scheme="https://www.cjjhc.cn/categories/%E5%B7%A5%E7%A8%8B%E6%95%88%E7%8E%87/"/>
    <category term="CI" scheme="https://www.cjjhc.cn/tags/CI/"/>
    <category term="测试" scheme="https://www.cjjhc.cn/tags/%E6%B5%8B%E8%AF%95/"/>
    <category term="DevOps" scheme="https://www.cjjhc.cn/tags/DevOps/"/>
    <content>
      <![CDATA[<p>让反馈更快、结果可复现，并把高风险问题挡在合并之前。</p><span id="more"></span><p><a href="/images/tech/diagrams/ci-artifact-promotion.svg"><img src="/images/tech/diagrams/ci-artifact-promotion.svg" alt="代码提交通过检查、测试和一次构建后将同一制品晋级到生产的 CI 流程图"></a></p><p><em>图：缓存用于加速，制品用摘要证明测试与生产使用的是同一组字节；点击查看原图。</em></p><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>先运行格式、静态检查和单元测试，再并行执行较慢任务。依赖与工具版本锁定，缓存以锁文件为键，避免污染。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>构建产物只生成一次并逐环境晋级。失败日志要直接指向原因，偶发测试应修复而不是无限重跑。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>先运行格式、静态检查和单测，慢任务并行；缓存以锁文件和工具版本为键。构建产物只生成一次，后续环境晋级同一 digest。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>统计流水线 P50&#x2F;P95 耗时、失败原因和 flaky 测试。对密钥使用短期身份，PR 流水线不向不可信代码暴露生产凭据。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“把代码变更变成可重复交付物”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>快速格式与单测先跑，构建一次并固定依赖；后续安全、集成和部署都复用同一产物摘要，敏感环境需要审批。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>模拟测试失败、缓存污染、制品上传中断和重复执行，确认流水线不会发布半成品。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察耗时、失败阶段和不稳定测试；新流水线先并行影子运行，异常切回旧定义。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 流水线定义、不可变制品和阶段证据清单。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>在临时分支分别制造单测失败、构建失败和发布审批拒绝，确认不会生成可发布状态</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：一条可定位失败的流水线"><a href="#完整实战：一条可定位失败的流水线" class="headerlink" title="完整实战：一条可定位失败的流水线"></a>完整实战：一条可定位失败的流水线</h2><p>以 Node.js 服务为例，流水线按“反馈速度”和“信任边界”分层。普通合并请求只需要读仓库和写临时制品；发布凭据只在受保护分支、人工或策略批准后的部署任务中短时获取。</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><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="attr">stages:</span> [<span class="string">validate</span>, <span class="string">test</span>, <span class="string">build</span>, <span class="string">verify</span>, <span class="string">deploy</span>]</span><br><span class="line"></span><br><span class="line"><span class="attr">validate:</span></span><br><span class="line">  <span class="attr">script:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">npm</span> <span class="string">ci</span> <span class="string">--ignore-scripts</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">lint</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">typecheck</span></span><br><span class="line"></span><br><span class="line"><span class="attr">test:</span></span><br><span class="line">  <span class="attr">script:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">npm</span> <span class="string">test</span> <span class="string">--</span> <span class="string">--ci</span></span><br><span class="line">  <span class="attr">artifacts:</span></span><br><span class="line">    <span class="attr">when:</span> <span class="string">always</span></span><br><span class="line">    <span class="attr">paths:</span> [<span class="string">reports/</span>]</span><br><span class="line"></span><br><span class="line"><span class="attr">build:</span></span><br><span class="line">  <span class="attr">script:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">npm</span> <span class="string">ci</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">npm</span> <span class="string">run</span> <span class="string">build</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">./scripts/write-provenance.sh</span></span><br><span class="line">  <span class="attr">artifacts:</span></span><br><span class="line">    <span class="attr">paths:</span> [<span class="string">dist/</span>, <span class="string">provenance.json</span>]</span><br></pre></td></tr></table></figure><p>示例语法需按具体 CI 平台调整。关键约束是构建一次：后续环境晋级同一个不可变制品，以摘要而不是可变标签识别。若在测试、预发和生产各重建一次，就无法证明测试过的字节等于最终发布的字节。</p><h3 id="缓存与制品不要混为一谈"><a href="#缓存与制品不要混为一谈" class="headerlink" title="缓存与制品不要混为一谈"></a>缓存与制品不要混为一谈</h3><ul><li>缓存用于加速，可丢失、可重建，不应成为正确性的前提。</li><li>制品是本次运行的交付结果，需要保留期、摘要、来源提交和构建证据。</li><li>依赖缓存键至少包含操作系统、运行时、锁文件摘要和影响依赖的构建参数。</li><li>从不可信分支恢复缓存时，不要让其中的可执行文件进入高权限发布任务。</li></ul><table><thead><tr><th>阶段</th><th align="right">目标时间</th><th>失败时应看到什么</th></tr></thead><tbody><tr><td>格式&#x2F;静态检查</td><td align="right">1～3 分钟</td><td>文件、规则和修复建议</td></tr><tr><td>单元测试</td><td align="right">3～8 分钟</td><td>失败用例、日志、覆盖变化</td></tr><tr><td>集成&#x2F;契约测试</td><td align="right">5～15 分钟</td><td>依赖版本、测试数据、请求证据</td></tr><tr><td>构建与扫描</td><td align="right">5～15 分钟</td><td>制品摘要、组件清单、风险例外</td></tr><tr><td>部署验证</td><td align="right">按环境预算</td><td>发布版本、健康指标、回滚状态</td></tr></tbody></table><h3 id="取消、重试与防止重复发布"><a href="#取消、重试与防止重复发布" class="headerlink" title="取消、重试与防止重复发布"></a>取消、重试与防止重复发布</h3><p>新提交到达时可取消同一分支的旧验证，但正在执行生产变更的任务不能被粗暴终止。网络抖动类步骤可以有限重试，确定性的编译或测试失败不应自动重跑来“刷绿”。部署使用环境锁或稳定发布 ID，重复触发不得产生第二次迁移或不可控副作用。</p><p>验收流水线本身时，故意制造 lint 失败、测试超时、缓存未命中、制品摘要不一致和部署健康检查失败。确认每种失败都停在正确阶段，低权限任务拿不到生产凭据，失败报告在任务结束后仍可读取，回滚使用的是上一个已验证制品。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://docs.github.com/actions/writing-workflows">GitHub Actions Workflow</a></li><li><a href="https://slsa.dev/spec/v1.1/">SLSA 规范</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/18/ci-pipeline-design/</id>
    <link href="https://www.cjjhc.cn/2026/08/18/ci-pipeline-design/"/>
    <published>2026-08-18T01:00:00.000Z</published>
    <summary>让反馈更快、结果可复现，并把高风险问题挡在合并之前。</summary>
    <title>高质量 CI 流水线怎么设计</title>
    <updated>2026-08-18T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="架构设计" scheme="https://www.cjjhc.cn/categories/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    <category term="分布式系统" scheme="https://www.cjjhc.cn/tags/%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F/"/>
    <category term="Redis" scheme="https://www.cjjhc.cn/tags/Redis/"/>
    <category term="限流" scheme="https://www.cjjhc.cn/tags/%E9%99%90%E6%B5%81/"/>
    <content>
      <![CDATA[<p>理解常见算法的突发能力、精度和分布式实现成本。</p><span id="more"></span><p><a href="/images/tech/diagrams/rate-limit-layers.svg"><img src="/images/tech/diagrams/rate-limit-layers.svg" alt="登录请求依次经过边缘、租户接口、账号和全局容量限制的分层限流图"></a></p><p><em>图：组合多个限流维度保护系统容量，同时降低共享 IP 与账号锁定误伤；点击查看原图。</em></p><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>令牌桶允许有限突发，适合 API 网关；滑动窗口更直观但存储成本更高。限流维度可以是用户、租户、接口或组合键。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>返回明确的剩余额度与重试时间。系统过载时限流要与排队、熔断和降级协同。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>先确定维度、窗口、配额和突发策略。令牌桶适合允许突发，滑动窗口更精确但成本更高；分布式实现用原子脚本或网关能力。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>返回 429、Retry-After 和剩余额度，演练存储不可用时采用 fail-open 还是 fail-closed。限流与排队、熔断、降级一起压测。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为公开 API 设置分层限流”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>在入口按身份和接口执行令牌桶，关键下游再设并发保护；返回明确重试提示，对不同租户设置公平配额。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>测试突发流量、持续超额、分布式节点时钟差和存储不可用，明确限流组件故障时开放还是关闭。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察拒绝率、下游饱和度与误伤；阈值先灰度，误伤升高立即恢复旧策略。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 限流决策表、租户配额和降级开关。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>回放突发与持续流量，核对允许量、拒绝量和下游并发上限</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：登录接口的分层限流"><a href="#完整实战：登录接口的分层限流" class="headerlink" title="完整实战：登录接口的分层限流"></a>完整实战：登录接口的分层限流</h2><p>登录限流既要保护服务，也要避免攻击者通过锁定某个账号阻止真实用户登录。单一 IP 限制会误伤公司出口和移动网络，单一账号限制又容易被用来拒绝服务，因此组合多个低基数字段：</p><table><thead><tr><th>层级</th><th>示例键</th><th>作用</th><th>超限后的动作</th></tr></thead><tbody><tr><td>边缘</td><td>IP &#x2F; 网段</td><td>吸收明显洪峰</td><td>短时拒绝或挑战</td></tr><tr><td>接口</td><td>租户 + 路由</td><td>保护服务总容量</td><td><code>429</code> + 合理退避</td></tr><tr><td>账号</td><td>账号摘要</td><td>限制撞库</td><td>渐进延迟、二次验证</td></tr><tr><td>全局</td><td>集群容量</td><td>防止依赖雪崩</td><td>负载削减、保留关键流量</td></tr></tbody></table><p>不要把原始邮箱、手机号作为监控标签或共享存储键；先规范化后使用带密钥的摘要，并限制保留期。</p><h3 id="令牌桶的原子状态转换"><a href="#令牌桶的原子状态转换" class="headerlink" title="令牌桶的原子状态转换"></a>令牌桶的原子状态转换</h3><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></pre></td><td class="code"><pre><span class="line">elapsed = max(0, now - last_refill)</span><br><span class="line">tokens = min(capacity, tokens + elapsed * refill_rate)</span><br><span class="line">if tokens &lt; cost:</span><br><span class="line">    reject(retry_after = (cost - tokens) / refill_rate)</span><br><span class="line">else:</span><br><span class="line">    accept(tokens = tokens - cost, last_refill = now)</span><br></pre></td></tr></table></figure><p>分布式实现需要明确时钟来源、状态分片、热点键和存储故障策略。安全敏感入口通常偏向 fail-closed，但健康检查、内部恢复流量可能需要独立配额，不能在限流存储故障时一起被堵死。</p><h3 id="容量与公平性验证"><a href="#容量与公平性验证" class="headerlink" title="容量与公平性验证"></a>容量与公平性验证</h3><p>用真实请求成本做权重：一次导出可能等于数十次查询。压测包含稳定流量、突发流量、单热点账号、多 NAT 用户和存储延迟。验收不仅看总 QPS，还看允许率、误伤率、P95 延迟、限流存储错误和不同租户的公平性。</p><p>客户端收到 <code>429</code> 后应遵循 <code>Retry-After</code> 并加入抖动；服务端不能让所有客户端在整秒边界同时重试。上线先以只观测模式计算“本应拒绝”的请求，再小比例执行。误伤越线时回退规则或提高容量，不要直接关闭所有保护；保留边缘与全局兜底层。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.rfc-editor.org/rfc/rfc9333">RateLimit HTTP Fields</a></li><li><a href="https://redis.io/docs/latest/develop/use-cases/rate-limiter/">Redis 限流示例</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/17/system-design-rate-limit/</id>
    <link href="https://www.cjjhc.cn/2026/08/17/system-design-rate-limit/"/>
    <published>2026-08-17T17:00:00.000Z</published>
    <summary>令牌桶允许有限突发适合API网关，滑动窗口更直观但存储成本更高。限流维度可以是用户、租户、接口或组合键，通过分层保护系统容量并降低误伤。实施时返回明确剩余额度与重试时间，限流需与排队、熔断和降级协同。验证应检查错误率、延迟、资源占用和异常路径，记录回滚方式。以登录接口为例，需组合边缘IP、租户接口、账号和全局容量多层限制，避免单一维度误伤真实用户。好的工程实践是用清晰边界、可观察结果和可逆变更降低长期成本，先解决最重要的问题再逐步自动化。</summary>
    <title>限流系统设计：令牌桶与滑动窗口</title>
    <updated>2026-08-22T18:38:40.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="架构设计" scheme="https://www.cjjhc.cn/categories/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    <category term="分布式系统" scheme="https://www.cjjhc.cn/tags/%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F/"/>
    <category term="可靠性" scheme="https://www.cjjhc.cn/tags/%E5%8F%AF%E9%9D%A0%E6%80%A7/"/>
    <category term="消息队列" scheme="https://www.cjjhc.cn/tags/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/"/>
    <content>
      <![CDATA[<p>围绕至少一次投递设计幂等消费、重试和死信处理。</p><span id="more"></span><p><a href="/images/tech/diagrams/message-queue-outbox.svg"><img src="/images/tech/diagrams/message-queue-outbox.svg" alt="订单事务写入 Outbox、消息发布并由消费者幂等处理的时序图"></a></p><p><em>图：事务 Outbox 解决发送空隙，消费端幂等保证重复传输只产生一个业务结果；点击查看原图。</em></p><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>生产者使用业务唯一键，消费者先判断是否处理过。重试采用退避并设置上限，永久失败进入死信队列等待人工或补偿。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>监控积压深度、最老消息年龄和失败率。消息顺序、吞吐与可用性之间存在权衡，应按业务选择分区键。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>生产者以业务键或 Outbox 保证消息与事务关系，消费者实现幂等并在成功提交业务状态后确认。重试采用退避，上限后进入死信。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>验证重复、乱序、延迟和毒消息。监控积压量、最老消息年龄、处理延迟和死信增长；分区键必须符合真正需要的顺序边界。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“保证消息至少一次投递下的业务唯一性”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>生产者使用事务消息或 outbox，消费者以业务键去重；成功提交后再确认消息，失败分类重试并设置死信处理。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>重复投递、乱序、毒消息和处理后确认前宕机都要演练，验证数据库只产生一次业务结果。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察积压、重试、死信和端到端延迟；异常时暂停消费而不是删除队列或跳过失败消息。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 消息契约、幂等表、重试策略和死信处置记录。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>重复投递同一业务键并在提交后确认前终止消费者，最终业务结果仍唯一</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：订单已支付事件"><a href="#完整实战：订单已支付事件" class="headerlink" title="完整实战：订单已支付事件"></a>完整实战：订单已支付事件</h2><p>订单服务更新状态后发布 <code>OrderPaid</code>，积分服务和通知服务各自消费。最危险的空隙是“数据库提交成功，但消息发送失败”。使用事务 Outbox 把业务更新和待发布事件写入同一数据库事务：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">BEGIN</span>;</span><br><span class="line"><span class="keyword">UPDATE</span> orders</span><br><span class="line"><span class="keyword">SET</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span>, version <span class="operator">=</span> version <span class="operator">+</span> <span class="number">1</span></span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">=</span> :order_id <span class="keyword">AND</span> status <span class="operator">=</span> <span class="string">&#x27;pending&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">INSERT INTO</span> outbox(event_id, aggregate_id, event_type, payload, created_at)</span><br><span class="line"><span class="keyword">VALUES</span> (:event_id, :order_id, <span class="string">&#x27;OrderPaid.v1&#x27;</span>, :payload, <span class="built_in">CURRENT_TIMESTAMP</span>);</span><br><span class="line"><span class="keyword">COMMIT</span>;</span><br></pre></td></tr></table></figure><p>独立发布器读取 outbox、发送到消息系统并标记已发布。发布器在确认前崩溃会造成重复发送，所以消费者仍必须幂等。不要宣称端到端“恰好一次”；通常可实现的是至少一次传输，加业务键去重后的单一业务结果。</p><h3 id="消费者的幂等边界"><a href="#消费者的幂等边界" class="headerlink" title="消费者的幂等边界"></a>消费者的幂等边界</h3><p>积分服务在同一事务中登记 <code>event_id</code> 并增加积分，唯一约束阻止重复：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">BEGIN</span>;</span><br><span class="line"><span class="keyword">INSERT INTO</span> consumed_events(consumer, event_id)</span><br><span class="line"><span class="keyword">VALUES</span> (<span class="string">&#x27;loyalty&#x27;</span>, :event_id)</span><br><span class="line"><span class="keyword">ON</span> CONFLICT DO NOTHING;</span><br><span class="line"><span class="comment">-- 只有上一步确实插入时才执行积分变更</span></span><br><span class="line"><span class="keyword">COMMIT</span>;</span><br></pre></td></tr></table></figure><p>如果副作用是调用第三方 API，还需传递稳定幂等键，或建立本地动作状态机进行对账；仅在本地记“消费过”不能证明第三方动作只执行一次。</p><table><thead><tr><th>故障</th><th>预期行为</th><th>必留证据</th></tr></thead><tbody><tr><td>提交后发布器崩溃</td><td>恢复后重发</td><td>outbox 延迟、event_id</td></tr><tr><td>消费完成前进程退出</td><td>消息重投，业务不重复</td><td>消费尝试和幂等命中</td></tr><tr><td>下游持续失败</td><td>有限重试后进入隔离队列</td><td>原因、次数、首次&#x2F;末次时间</td></tr><tr><td>消息乱序</td><td>旧版本不覆盖新状态</td><td>aggregate version</td></tr><tr><td>消息格式升级</td><td>新旧消费者均可解析</td><td>schema 版本和契约测试</td></tr></tbody></table><h3 id="积压、重放与隔离队列"><a href="#积压、重放与隔离队列" class="headerlink" title="积压、重放与隔离队列"></a>积压、重放与隔离队列</h3><p>监控队列深度不够，还要看最老消息年龄、生产&#x2F;消费速率差、处理 P95、重试率和隔离队列增长。重放前确认消费者版本、数据时间范围和副作用策略，先对少量事件 dry-run；隔离队列不是墓地，必须有负责人、告警和处理时限。</p><p>灰度期间按事件版本和消费者组观察。回滚消费者时必须仍能读取灰度期间产生的新事件，或在生产者端保持旧 schema；不要同时删除字段、回滚代码和清空积压，这会失去恢复依据。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.rabbitmq.com/docs/reliability">RabbitMQ 可靠性指南</a></li><li><a href="https://kafka.apache.org/documentation/#semantics">Kafka 投递语义</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/16/message-queue-reliability/</id>
    <link href="https://www.cjjhc.cn/2026/08/16/message-queue-reliability/"/>
    <published>2026-08-16T01:00:00.000Z</published>
    <summary>围绕至少一次投递设计幂等消费、重试和死信处理。</summary>
    <title>消息队列可靠性设计</title>
    <updated>2026-08-16T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="后端开发" scheme="https://www.cjjhc.cn/categories/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="并发" scheme="https://www.cjjhc.cn/tags/%E5%B9%B6%E5%8F%91/"/>
    <category term="Python" scheme="https://www.cjjhc.cn/tags/Python/"/>
    <category term="asyncio" scheme="https://www.cjjhc.cn/tags/asyncio/"/>
    <content>
      <![CDATA[<p>理解事件循环、协程与任务，正确处理超时和取消。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>异步适合大量等待型 I&#x2F;O，不会自动加速 CPU 密集任务。并发调用用 TaskGroup 组织，所有外部请求设置超时。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>取消是正常控制流，清理代码放在 finally。限制并发量，避免瞬间压垮数据库或第三方接口。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>I&#x2F;O 并发使用 <code>TaskGroup</code> 或受控 gather，每个外部调用设置 timeout，并用 Semaphore 限制数据库或第三方 API 并发。CPU 密集任务交给进程池或任务队列。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>正确传播 CancelledError，清理写在 finally。测试超时、取消和部分任务失败，避免创建 task 后不保存引用导致异常无人处理。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“并发调用多个 I&#x2F;O 服务并可及时取消”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>协程传递取消语义，TaskGroup 管理子任务；每个外部调用设置超时和并发信号量，阻塞函数移到专用线程。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>模拟一个子任务失败、整体超时和调用方断开，确认其余任务被回收且连接归还池。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察事件循环延迟、活跃任务和超时率；并发退化时降低上限或回退串行实现。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 可取消协程、并发上限和故障注入测试。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>python -m pytest -q，并检查测试退出后 asyncio.all_tasks() 无残留业务任务</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：有界并发抓取器"><a href="#完整实战：有界并发抓取器" class="headerlink" title="完整实战：有界并发抓取器"></a>完整实战：有界并发抓取器</h2><p>场景是批量访问一组内部只读接口。真正的约束不是“把 1,000 个协程一起启动”，而是总超时、单依赖并发、失败隔离和取消传播。下面示例只使用标准库，适用于支持 <code>TaskGroup</code> 与 <code>timeout</code> 的 Python 版本：</p><figure class="highlight python"><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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> asyncio</span><br><span class="line"><span class="keyword">from</span> collections.abc <span class="keyword">import</span> Awaitable, <span class="type">Callable</span>, Iterable</span><br><span class="line"></span><br><span class="line">Fetch = <span class="type">Callable</span>[[<span class="built_in">str</span>], Awaitable[<span class="built_in">bytes</span>]]</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">fetch_all</span>(<span class="params"></span></span><br><span class="line"><span class="params">    urls: Iterable[<span class="built_in">str</span>],</span></span><br><span class="line"><span class="params">    fetch: Fetch,</span></span><br><span class="line"><span class="params">    *,</span></span><br><span class="line"><span class="params">    concurrency: <span class="built_in">int</span> = <span class="number">8</span>,</span></span><br><span class="line"><span class="params">    timeout_s: <span class="built_in">float</span> = <span class="number">5.0</span>,</span></span><br><span class="line"><span class="params"></span>) -&gt; <span class="built_in">dict</span>[<span class="built_in">str</span>, <span class="built_in">bytes</span> | Exception]:</span><br><span class="line">    semaphore = asyncio.Semaphore(concurrency)</span><br><span class="line">    results: <span class="built_in">dict</span>[<span class="built_in">str</span>, <span class="built_in">bytes</span> | Exception] = &#123;&#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">one</span>(<span class="params">url: <span class="built_in">str</span></span>) -&gt; <span class="literal">None</span>:</span><br><span class="line">        <span class="keyword">try</span>:</span><br><span class="line">            <span class="keyword">async</span> <span class="keyword">with</span> semaphore:</span><br><span class="line">                <span class="keyword">async</span> <span class="keyword">with</span> asyncio.timeout(timeout_s):</span><br><span class="line">                    results[url] = <span class="keyword">await</span> fetch(url)</span><br><span class="line">        <span class="keyword">except</span> asyncio.CancelledError:</span><br><span class="line">            <span class="keyword">raise</span>  <span class="comment"># 取消必须继续向上传播</span></span><br><span class="line">        <span class="keyword">except</span> Exception <span class="keyword">as</span> exc:</span><br><span class="line">            results[url] = exc</span><br><span class="line"></span><br><span class="line">    <span class="keyword">async</span> <span class="keyword">with</span> asyncio.TaskGroup() <span class="keyword">as</span> group:</span><br><span class="line">        <span class="keyword">for</span> url <span class="keyword">in</span> urls:</span><br><span class="line">            group.create_task(one(url), name=<span class="string">f&quot;fetch:<span class="subst">&#123;url&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> results</span><br></pre></td></tr></table></figure><p>这里选择“单个请求失败不取消整批”，所以异常在 <code>one</code> 内被保存；若业务要求全有或全无，应让异常逃出任务，<code>TaskGroup</code> 会取消同组其他任务。两种语义都合理，关键是明确选择并写测试。</p><h3 id="队列比一次性创建全部任务更稳"><a href="#队列比一次性创建全部任务更稳" class="headerlink" title="队列比一次性创建全部任务更稳"></a>队列比一次性创建全部任务更稳</h3><p>待处理项可能达到几十万时，即使信号量限制了执行并发，一次性创建全部 Task 仍会占用内存。此时使用固定数量 worker 消费 <code>asyncio.Queue</code>，并为队列设置 <code>maxsize</code>，让生产者在下游变慢时自然背压。</p><table><thead><tr><th>风险</th><th>错误做法</th><th>可观察的修正</th></tr></thead><tbody><tr><td>依赖过载</td><td>无界 <code>gather</code></td><td>信号量&#x2F;固定 worker，记录在途数</td></tr><tr><td>永久挂起</td><td>只依赖客户端默认超时</td><td>设置单次与整批 deadline</td></tr><tr><td>取消失效</td><td>捕获所有异常后吞掉取消</td><td>单独重新抛出 <code>CancelledError</code></td></tr><tr><td>阻塞事件循环</td><td>协程里执行 CPU 或同步 I&#x2F;O</td><td>移出主循环或使用受控线程池</td></tr><tr><td>重试风暴</td><td>每层都自动重试</td><td>只在一层按错误类型、预算和抖动重试</td></tr></tbody></table><h3 id="必测失败路径"><a href="#必测失败路径" class="headerlink" title="必测失败路径"></a>必测失败路径</h3><p>用假 <code>fetch</code> 注入快速成功、超时、连接错误和取消。断言最大在途数不超过预算；取消外层任务后没有遗留任务；超时项被记录且整批能在 deadline 内退出。压测同时观察吞吐、P95 延迟、事件循环延迟、内存峰值和依赖端错误率，不能只比较总耗时。</p><p>若部署后依赖错误率或事件循环延迟升高，先把并发配置降回旧值，再停止接收新批次并等待在途任务收敛。并发参数应是可灰度配置，而不是写死在代码中的“最佳值”。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://docs.python.org/3/library/asyncio-task.html">Python asyncio Tasks</a></li><li><a href="https://docs.python.org/3/library/asyncio-sync.html">Python asyncio 同步原语</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/15/python-asyncio-guide/</id>
    <link href="https://www.cjjhc.cn/2026/08/15/python-asyncio-guide/"/>
    <published>2026-08-15T01:00:00.000Z</published>
    <summary>理解事件循环、协程与任务，正确处理超时和取消。</summary>
    <title>Python asyncio 并发编程指南</title>
    <updated>2026-08-15T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="后端开发" scheme="https://www.cjjhc.cn/categories/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="工程化" scheme="https://www.cjjhc.cn/tags/%E5%B7%A5%E7%A8%8B%E5%8C%96/"/>
    <category term="Go" scheme="https://www.cjjhc.cn/tags/Go/"/>
    <category term="微服务" scheme="https://www.cjjhc.cn/tags/%E5%BE%AE%E6%9C%8D%E5%8A%A1/"/>
    <content>
      <![CDATA[<p>Go 服务并不存在一套适用于所有团队的“标准目录”。目录的价值不是看起来整齐，而是让依赖方向、修改范围和运行边界一眼可见。</p><p>本文以一个连接 PostgreSQL 的订单 HTTP API 为例，从可运行的最小结构开始，逐步加入配置、协议适配、数据访问、测试和优雅退出。示例适合一个独立部署、由 2～10 人维护的服务；如果项目只是几百行的小工具，保持一个 <code>main</code> 包通常更好。</p><span id="more"></span><p><a href="/images/tech/diagrams/go-service-dependencies.svg"><img src="/images/tech/diagrams/go-service-dependencies.svg" alt="Go 服务中入站适配器、应用层、领域层、端口和基础设施的依赖方向图"></a></p><p><em>图：依赖箭头指向稳定的业务抽象，领域核心不依赖 HTTP、数据库或消息中间件；点击查看原图。</em></p><h2 id="一、先确定结构要解决什么问题"><a href="#一、先确定结构要解决什么问题" class="headerlink" title="一、先确定结构要解决什么问题"></a>一、先确定结构要解决什么问题</h2><p>在创建目录前，先回答四个问题：</p><ol><li><strong>部署单元是什么？</strong> 本文只有 <code>order-api</code> 一个进程，因此先使用一个 <code>go.mod</code>。</li><li><strong>核心规则是什么？</strong> 创建订单、查询订单等业务规则不应依赖 HTTP 框架或数据库驱动。</li><li><strong>外部变化从哪里进入？</strong> HTTP、PostgreSQL、配置和日志都属于外层适配。</li><li><strong>怎样验证边界没有被破坏？</strong> 单元测试验证业务规则，协议测试验证 HTTP，集成测试验证真实数据库，CI 负责重复执行。</li></ol><p>由此得到几条约束：</p><ul><li><code>main</code> 只读取启动参数、建立进程级上下文并调用装配函数；</li><li>业务包只依赖标准库和稳定的领域类型；</li><li>接口定义在消费它的一方，而不是统一放进 <code>interfaces</code> 目录；</li><li>基础设施实现依赖业务接口，业务代码不导入数据库驱动；</li><li>配置在启动阶段一次性读取和校验，运行时按值传递；</li><li>先做单模块，只有出现真实的发布或所有权边界时才拆模块。</li></ul><p>这些约束比“必须有几层目录”更重要。</p><h2 id="二、一个可演进的目录结构"><a href="#二、一个可演进的目录结构" class="headerlink" title="二、一个可演进的目录结构"></a>二、一个可演进的目录结构</h2><p>下面的结构足以承载一个中等规模的 HTTP 服务，同时没有提前引入过多抽象：</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><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line">order-api/</span><br><span class="line">├── cmd/</span><br><span class="line">│   └── order-api/</span><br><span class="line">│       └── main.go                 # 进程入口：信号、配置、退出码</span><br><span class="line">├── internal/</span><br><span class="line">│   ├── app/</span><br><span class="line">│   │   └── app.go                  # 依赖装配与服务生命周期</span><br><span class="line">│   ├── config/</span><br><span class="line">│   │   ├── config.go               # 环境变量读取与启动校验</span><br><span class="line">│   │   └── config_test.go</span><br><span class="line">│   ├── order/</span><br><span class="line">│   │   ├── model.go                # 订单模型与领域错误</span><br><span class="line">│   │   ├── service.go              # 用例及其需要的端口</span><br><span class="line">│   │   └── service_test.go         # 无外部依赖的业务测试</span><br><span class="line">│   ├── httpapi/</span><br><span class="line">│   │   ├── order_handler.go        # HTTP 请求与领域输入的转换</span><br><span class="line">│   │   ├── router.go</span><br><span class="line">│   │   └── order_handler_test.go   # httptest 协议测试</span><br><span class="line">│   └── postgres/</span><br><span class="line">│       ├── order_repository.go     # Repository 的 PostgreSQL 实现</span><br><span class="line">│       └── order_repository_test.go</span><br><span class="line">├── api/</span><br><span class="line">│   └── openapi.yaml                # 对外协议契约</span><br><span class="line">├── migrations/                     # 可审计、按版本执行的数据库变更</span><br><span class="line">├── test/</span><br><span class="line">│   └── integration/                # 跨包和真实依赖测试</span><br><span class="line">├── go.mod</span><br><span class="line">├── go.sum</span><br><span class="line">├── Makefile</span><br><span class="line">└── README.md                       # 运行、验证、配置和故障排查入口</span><br></pre></td></tr></table></figure><p>这里的 <code>internal</code> 是 Go 工具链可以执行的边界，并非团队口头约定。把服务实现放在仓库根部的 <code>internal</code> 下，可以避免其他模块直接依赖这些包，让内部重构不必承担公开 API 的兼容成本。</p><p>不要因为在其他仓库看到了某个目录就照搬：</p><ul><li>没有多个可执行程序时，<code>cmd</code> 仍可保留，但不必再制造多层空目录；</li><li>没有对外复用的 Go 库时，不需要 <code>pkg</code>；</li><li>没有数据库时，不需要 <code>repository</code> 或 <code>migrations</code>；</li><li>没有实际的平台级能力时，不要先建立巨大的 <code>platform</code> 包；</li><li><code>utils</code>、<code>common</code>、<code>types</code> 往往会掩盖职责，应按具体能力命名。</li></ul><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></pre></td><td class="code"><pre><span class="line">cmd/order-api ──► internal/app</span><br><span class="line">                      ├──► internal/config</span><br><span class="line">                      ├──► internal/httpapi ──► internal/order</span><br><span class="line">                      ├──► internal/postgres ─► internal/order</span><br><span class="line">                      └──► internal/order</span><br></pre></td></tr></table></figure><p>更准确地说：</p><ul><li><code>app</code> 知道所有具体实现，因为它负责装配；</li><li><code>httpapi</code> 使用 <code>order</code> 的输入、输出和错误，但不认识 PostgreSQL；</li><li><code>postgres</code> 实现 <code>order</code> 所需的数据访问接口；</li><li><code>order</code> 不导入 <code>httpapi</code>、<code>postgres</code>、SQL 驱动或 Web 框架；</li><li><code>cmd/order-api</code> 不包含业务分支。</li></ul><p>允许最外层知道更多，换取内层稳定。若某次修改让 <code>internal/order</code> 开始导入 <code>internal/httpapi</code>，应先停下来确认：这是业务规则，还是协议细节泄漏到了核心包？</p><h2 id="四、业务包：先写用例，再接基础设施"><a href="#四、业务包：先写用例，再接基础设施" class="headerlink" title="四、业务包：先写用例，再接基础设施"></a>四、业务包：先写用例，再接基础设施</h2><h3 id="4-1-领域模型和可识别错误"><a href="#4-1-领域模型和可识别错误" class="headerlink" title="4.1 领域模型和可识别错误"></a>4.1 领域模型和可识别错误</h3><p>领域错误应能被上层稳定识别，但不携带 HTTP 状态码或数据库错误码：</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><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> order</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;errors&quot;</span></span><br><span class="line">    <span class="string">&quot;time&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> (</span><br><span class="line">    ErrInvalidInput = errors.New(<span class="string">&quot;invalid order input&quot;</span>)</span><br><span class="line">    ErrNotFound     = errors.New(<span class="string">&quot;order not found&quot;</span>)</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Status <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> StatusPending Status = <span class="string">&quot;pending&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Order <span class="keyword">struct</span> &#123;</span><br><span class="line">    ID         <span class="type">string</span></span><br><span class="line">    CustomerID <span class="type">string</span></span><br><span class="line">    AmountCent <span class="type">int64</span></span><br><span class="line">    Status     Status</span><br><span class="line">    CreatedAt  time.Time</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> CreateInput <span class="keyword">struct</span> &#123;</span><br><span class="line">    CustomerID <span class="type">string</span></span><br><span class="line">    AmountCent <span class="type">int64</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>HTTP 层可以把 <code>ErrInvalidInput</code> 映射为 400，把 <code>ErrNotFound</code> 映射为 404；数据库层可以把 <code>sql.ErrNoRows</code> 转换为 <code>ErrNotFound</code>。这样协议和存储的变化都不会污染业务模型。</p><h3 id="4-2-接口放在消费方"><a href="#4-2-接口放在消费方" class="headerlink" title="4.2 接口放在消费方"></a>4.2 接口放在消费方</h3><p>订单用例需要存取订单，因此由 <code>order</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><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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> order</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;fmt&quot;</span></span><br><span class="line">    <span class="string">&quot;strings&quot;</span></span><br><span class="line">    <span class="string">&quot;time&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Repository <span class="keyword">interface</span> &#123;</span><br><span class="line">    Save(ctx context.Context, value Order) <span class="type">error</span></span><br><span class="line">    ByID(ctx context.Context, id <span class="type">string</span>) (Order, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Service <span class="keyword">struct</span> &#123;</span><br><span class="line">    repo  Repository</span><br><span class="line">    newID <span class="function"><span class="keyword">func</span><span class="params">()</span></span> <span class="type">string</span></span><br><span class="line">    now   <span class="function"><span class="keyword">func</span><span class="params">()</span></span> time.Time</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">NewService</span><span class="params">(repo Repository, newID <span class="keyword">func</span>()</span></span> <span class="type">string</span>, now <span class="function"><span class="keyword">func</span><span class="params">()</span></span> time.Time) *Service &#123;</span><br><span class="line">    <span class="keyword">return</span> &amp;Service&#123;repo: repo, newID: newID, now: now&#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="params">(s *Service)</span></span> Create(ctx context.Context, in CreateInput) (Order, <span class="type">error</span>) &#123;</span><br><span class="line">    customerID := strings.TrimSpace(in.CustomerID)</span><br><span class="line">    <span class="keyword">if</span> customerID == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> Order&#123;&#125;, fmt.Errorf(<span class="string">&quot;%w: customer_id is required&quot;</span>, ErrInvalidInput)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> in.AmountCent &lt;= <span class="number">0</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> Order&#123;&#125;, fmt.Errorf(<span class="string">&quot;%w: amount_cent must be positive&quot;</span>, ErrInvalidInput)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    value := Order&#123;</span><br><span class="line">        ID:         s.newID(),</span><br><span class="line">        CustomerID: customerID,</span><br><span class="line">        AmountCent: in.AmountCent,</span><br><span class="line">        Status:     StatusPending,</span><br><span class="line">        CreatedAt:  s.now().UTC(),</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> err := s.repo.Save(ctx, value); err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> Order&#123;&#125;, fmt.Errorf(<span class="string">&quot;save order: %w&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> value, <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="params">(s *Service)</span></span> ByID(ctx context.Context, id <span class="type">string</span>) (Order, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">if</span> strings.TrimSpace(id) == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> Order&#123;&#125;, fmt.Errorf(<span class="string">&quot;%w: id is required&quot;</span>, ErrInvalidInput)</span><br><span class="line">    &#125;</span><br><span class="line">    value, err := s.repo.ByID(ctx, id)</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> Order&#123;&#125;, fmt.Errorf(<span class="string">&quot;find order %q: %w&quot;</span>, id, err)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> value, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>newID</code> 和 <code>now</code> 也是依赖，但没必要为每个依赖都创建一个只有单方法的命名接口。函数参数足以让测试固定 ID 和时间，代码也更直观。</p><h2 id="五、适配器：把变化挡在业务包外"><a href="#五、适配器：把变化挡在业务包外" class="headerlink" title="五、适配器：把变化挡在业务包外"></a>五、适配器：把变化挡在业务包外</h2><h3 id="5-1-PostgreSQL-实现业务端口"><a href="#5-1-PostgreSQL-实现业务端口" class="headerlink" title="5.1 PostgreSQL 实现业务端口"></a>5.1 PostgreSQL 实现业务端口</h3><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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> postgres</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;database/sql&quot;</span></span><br><span class="line">    <span class="string">&quot;errors&quot;</span></span><br><span class="line">    <span class="string">&quot;fmt&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/order&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> OrderRepository <span class="keyword">struct</span> &#123;</span><br><span class="line">    db *sql.DB</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">NewOrderRepository</span><span class="params">(db *sql.DB)</span></span> *OrderRepository &#123;</span><br><span class="line">    <span class="keyword">return</span> &amp;OrderRepository&#123;db: db&#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="params">(r *OrderRepository)</span></span> Save(ctx context.Context, value order.Order) <span class="type">error</span> &#123;</span><br><span class="line">    <span class="keyword">const</span> query = <span class="string">`</span></span><br><span class="line"><span class="string">        INSERT INTO orders (id, customer_id, amount_cent, status, created_at)</span></span><br><span class="line"><span class="string">        VALUES ($1, $2, $3, $4, $5)`</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> _, err := r.db.ExecContext(ctx, query,</span><br><span class="line">        value.ID,</span><br><span class="line">        value.CustomerID,</span><br><span class="line">        value.AmountCent,</span><br><span class="line">        value.Status,</span><br><span class="line">        value.CreatedAt,</span><br><span class="line">    ); err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;insert order: %w&quot;</span>, err)</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><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *OrderRepository)</span></span> ByID(ctx context.Context, id <span class="type">string</span>) (order.Order, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">const</span> query = <span class="string">`</span></span><br><span class="line"><span class="string">        SELECT id, customer_id, amount_cent, status, created_at</span></span><br><span class="line"><span class="string">        FROM orders</span></span><br><span class="line"><span class="string">        WHERE id = $1`</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">var</span> value order.Order</span><br><span class="line">    err := r.db.QueryRowContext(ctx, query, id).Scan(</span><br><span class="line">        &amp;value.ID,</span><br><span class="line">        &amp;value.CustomerID,</span><br><span class="line">        &amp;value.AmountCent,</span><br><span class="line">        &amp;value.Status,</span><br><span class="line">        &amp;value.CreatedAt,</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">if</span> errors.Is(err, sql.ErrNoRows) &#123;</span><br><span class="line">        <span class="keyword">return</span> order.Order&#123;&#125;, order.ErrNotFound</span><br><span class="line">    &#125;</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> order.Order&#123;&#125;, fmt.Errorf(<span class="string">&quot;select order: %w&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> value, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> _ order.Repository = (*OrderRepository)(<span class="literal">nil</span>)</span><br></pre></td></tr></table></figure><p>最后一行是编译期断言。接口变化后，适配器会在构建阶段失败，而不是到运行时才暴露。</p><p>生产代码还应为查询设置超时、为连接池设置上限，并根据所用驱动识别唯一键冲突等错误。不要把原始 SQL 错误直接返回给客户端，也不要在日志中记录数据库连接串。</p><h3 id="5-2-HTTP-层定义自己真正需要的能力"><a href="#5-2-HTTP-层定义自己真正需要的能力" class="headerlink" title="5.2 HTTP 层定义自己真正需要的能力"></a>5.2 HTTP 层定义自己真正需要的能力</h3><p>处理器并不需要知道完整的 <code>*order.Service</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><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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> httpapi</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;encoding/json&quot;</span></span><br><span class="line">    <span class="string">&quot;errors&quot;</span></span><br><span class="line">    <span class="string">&quot;io&quot;</span></span><br><span class="line">    <span class="string">&quot;net/http&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/order&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> OrderCreator <span class="keyword">interface</span> &#123;</span><br><span class="line">    Create(context.Context, order.CreateInput) (order.Order, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Handler <span class="keyword">struct</span> &#123;</span><br><span class="line">    creator OrderCreator</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">NewHandler</span><span class="params">(creator OrderCreator)</span></span> *Handler &#123;</span><br><span class="line">    <span class="keyword">return</span> &amp;Handler&#123;creator: creator&#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="params">(h *Handler)</span></span> CreateOrder(w http.ResponseWriter, r *http.Request) &#123;</span><br><span class="line">    <span class="keyword">var</span> body <span class="keyword">struct</span> &#123;</span><br><span class="line">        CustomerID <span class="type">string</span> <span class="string">`json:&quot;customer_id&quot;`</span></span><br><span class="line">        AmountCent <span class="type">int64</span>  <span class="string">`json:&quot;amount_cent&quot;`</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, <span class="number">1</span>&lt;&lt;<span class="number">20</span>))</span><br><span class="line">    dec.DisallowUnknownFields()</span><br><span class="line">    <span class="keyword">if</span> err := dec.Decode(&amp;body); err != <span class="literal">nil</span> &#123;</span><br><span class="line">        status := http.StatusBadRequest</span><br><span class="line">        <span class="keyword">var</span> tooLarge *http.MaxBytesError</span><br><span class="line">        <span class="keyword">if</span> errors.As(err, &amp;tooLarge) &#123;</span><br><span class="line">            status = http.StatusRequestEntityTooLarge</span><br><span class="line">        &#125;</span><br><span class="line">        writeError(w, status, <span class="string">&quot;invalid request body&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> err := dec.Decode(&amp;<span class="keyword">struct</span>&#123;&#125;&#123;&#125;); err != io.EOF &#123;</span><br><span class="line">        writeError(w, http.StatusBadRequest, <span class="string">&quot;request body must contain one JSON object&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    value, err := h.creator.Create(r.Context(), order.CreateInput&#123;</span><br><span class="line">        CustomerID: body.CustomerID,</span><br><span class="line">        AmountCent: body.AmountCent,</span><br><span class="line">    &#125;)</span><br><span class="line">    <span class="keyword">if</span> errors.Is(err, order.ErrInvalidInput) &#123;</span><br><span class="line">        writeError(w, http.StatusBadRequest, err.Error())</span><br><span class="line">        <span class="keyword">return</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        writeError(w, http.StatusInternalServerError, <span class="string">&quot;internal error&quot;</span>)</span><br><span class="line">        <span class="keyword">return</span></span><br><span class="line">    &#125;</span><br><span class="line">    writeJSON(w, http.StatusCreated, value)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里还需要统一的 <code>writeJSON</code>、<code>writeError</code> 和日志中间件。需要特别约定：</p><ul><li>对外错误响应使用稳定错误码，不依赖可变的错误文本；</li><li>日志写入请求 ID、耗时和内部错误链，但不回传堆栈和 SQL；</li><li>请求体设置大小上限，解码时拒绝未知字段；</li><li>由入口或中间件设置请求超时，超时与取消继续通过 <code>context</code> 传递；</li><li>只有在确实需要时才引入 Web 框架，项目边界不应由框架目录决定。</li></ul><h2 id="六、装配层：唯一知道所有实现的地方"><a href="#六、装配层：唯一知道所有实现的地方" class="headerlink" title="六、装配层：唯一知道所有实现的地方"></a>六、装配层：唯一知道所有实现的地方</h2><p><code>internal/app</code> 负责创建数据库连接、业务服务、路由和 HTTP Server。下面展示关键生命周期代码：</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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> app</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;database/sql&quot;</span></span><br><span class="line">    <span class="string">&quot;errors&quot;</span></span><br><span class="line">    <span class="string">&quot;fmt&quot;</span></span><br><span class="line">    <span class="string">&quot;net/http&quot;</span></span><br><span class="line">    <span class="string">&quot;time&quot;</span></span><br><span class="line"></span><br><span class="line">    _ <span class="string">&quot;github.com/jackc/pgx/v5/stdlib&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/config&quot;</span></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/httpapi&quot;</span></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/order&quot;</span></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/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">Run</span><span class="params">(ctx context.Context, cfg config.Config)</span></span> <span class="type">error</span> &#123;</span><br><span class="line">    db, err := sql.Open(<span class="string">&quot;pgx&quot;</span>, cfg.DatabaseURL)</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> fmt.Errorf(<span class="string">&quot;open database: %w&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">defer</span> db.Close()</span><br><span class="line"></span><br><span class="line">    db.SetMaxOpenConns(cfg.DBMaxOpenConns)</span><br><span class="line">    db.SetMaxIdleConns(cfg.DBMaxIdleConns)</span><br><span class="line">    db.SetConnMaxLifetime(<span class="number">30</span> * time.Minute)</span><br><span class="line"></span><br><span class="line">    pingCtx, cancelPing := context.WithTimeout(ctx, <span class="number">3</span>*time.Second)</span><br><span class="line">    err = db.PingContext(pingCtx)</span><br><span class="line">    cancelPing()</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> fmt.Errorf(<span class="string">&quot;ping database: %w&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    repository := postgres.NewOrderRepository(db)</span><br><span class="line">    service := order.NewService(repository, newOrderID, time.Now)</span><br><span class="line">    handler := httpapi.NewHandler(service)</span><br><span class="line"></span><br><span class="line">    server := &amp;http.Server&#123;</span><br><span class="line">        Addr:              cfg.HTTPAddr,</span><br><span class="line">        Handler:           httpapi.NewRouter(handler, db),</span><br><span class="line">        ReadHeaderTimeout: <span class="number">5</span> * time.Second,</span><br><span class="line">        ReadTimeout:       <span class="number">10</span> * time.Second,</span><br><span class="line">        WriteTimeout:      <span class="number">15</span> * time.Second,</span><br><span class="line">        IdleTimeout:       <span class="number">60</span> * time.Second,</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    serveErr := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">error</span>, <span class="number">1</span>)</span><br><span class="line">    <span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">        err := server.ListenAndServe()</span><br><span class="line">        <span class="keyword">if</span> errors.Is(err, http.ErrServerClosed) &#123;</span><br><span class="line">            err = <span class="literal">nil</span></span><br><span class="line">        &#125;</span><br><span class="line">        serveErr &lt;- err</span><br><span class="line">    &#125;()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">select</span> &#123;</span><br><span class="line">    <span class="keyword">case</span> err := &lt;-serveErr:</span><br><span class="line">        <span class="keyword">return</span> err</span><br><span class="line">    <span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line">        shutdownCtx, cancel := context.WithTimeout(context.Background(), <span class="number">10</span>*time.Second)</span><br><span class="line">        <span class="keyword">defer</span> cancel()</span><br><span class="line">        <span class="keyword">if</span> err := server.Shutdown(shutdownCtx); err != <span class="literal">nil</span> &#123;</span><br><span class="line">            <span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;shutdown http server: %w&quot;</span>, err)</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> &lt;-serveErr</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>newOrderID</code> 的具体实现可以基于团队选定的 UUID 或其他 ID 库。装配层显式传递依赖，通常比全局 Service Locator 更易读、更易测试。只有对象图大到人工装配明显失控时，才考虑代码生成式依赖注入；不要用运行时容器隐藏构造失败。</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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> main</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;fmt&quot;</span></span><br><span class="line">    <span class="string">&quot;log/slog&quot;</span></span><br><span class="line">    <span class="string">&quot;os&quot;</span></span><br><span class="line">    <span class="string">&quot;os/signal&quot;</span></span><br><span class="line">    <span class="string">&quot;syscall&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/app&quot;</span></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/config&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">main</span><span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">if</span> err := run(); err != <span class="literal">nil</span> &#123;</span><br><span class="line">        slog.Error(<span class="string">&quot;order-api stopped&quot;</span>, <span class="string">&quot;error&quot;</span>, err)</span><br><span class="line">        os.Exit(<span class="number">1</span>)</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">run</span><span class="params">()</span></span> <span class="type">error</span> &#123;</span><br><span class="line">    cfg, err := config.Load()</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> fmt.Errorf(<span class="string">&quot;load config: %w&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    ctx, stop := signal.NotifyContext(</span><br><span class="line">        context.Background(),</span><br><span class="line">        syscall.SIGINT,</span><br><span class="line">        syscall.SIGTERM,</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">defer</span> stop()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> app.Run(ctx, cfg)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样 <code>main</code> 不负责创建路由、拼 SQL 或处理订单，它只管理进程边界。</p><h2 id="七、配置：启动时失败，运行时只读"><a href="#七、配置：启动时失败，运行时只读" class="headerlink" title="七、配置：启动时失败，运行时只读"></a>七、配置：启动时失败，运行时只读</h2><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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> config</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;errors&quot;</span></span><br><span class="line">    <span class="string">&quot;fmt&quot;</span></span><br><span class="line">    <span class="string">&quot;os&quot;</span></span><br><span class="line">    <span class="string">&quot;strconv&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Config <span class="keyword">struct</span> &#123;</span><br><span class="line">    HTTPAddr       <span class="type">string</span></span><br><span class="line">    DatabaseURL    <span class="type">string</span></span><br><span class="line">    DBMaxOpenConns <span class="type">int</span></span><br><span class="line">    DBMaxIdleConns <span class="type">int</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">Load</span><span class="params">()</span></span> (Config, <span class="type">error</span>) &#123;</span><br><span class="line">    maxOpen, err := intOr(<span class="string">&quot;DB_MAX_OPEN_CONNS&quot;</span>, <span class="number">20</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> Config&#123;&#125;, err</span><br><span class="line">    &#125;</span><br><span class="line">    maxIdle, err := intOr(<span class="string">&quot;DB_MAX_IDLE_CONNS&quot;</span>, <span class="number">5</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> Config&#123;&#125;, err</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    cfg := Config&#123;</span><br><span class="line">        HTTPAddr:       envOr(<span class="string">&quot;HTTP_ADDR&quot;</span>, <span class="string">&quot;:8080&quot;</span>),</span><br><span class="line">        DatabaseURL:    os.Getenv(<span class="string">&quot;DATABASE_URL&quot;</span>),</span><br><span class="line">        DBMaxOpenConns: maxOpen,</span><br><span class="line">        DBMaxIdleConns: maxIdle,</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> cfg.DatabaseURL == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> Config&#123;&#125;, errors.New(<span class="string">&quot;DATABASE_URL is required&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> cfg.DBMaxOpenConns &lt;= <span class="number">0</span> || cfg.DBMaxIdleConns &lt; <span class="number">0</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> Config&#123;&#125;, errors.New(<span class="string">&quot;database connection limits are out of range&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> cfg.DBMaxIdleConns &gt; cfg.DBMaxOpenConns &#123;</span><br><span class="line">        <span class="keyword">return</span> Config&#123;&#125;, errors.New(<span class="string">&quot;DB_MAX_IDLE_CONNS must not exceed DB_MAX_OPEN_CONNS&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> cfg, <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">envOr</span><span class="params">(key, fallback <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> value := os.Getenv(key); value != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> value</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> fallback</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">intOr</span><span class="params">(key <span class="type">string</span>, fallback <span class="type">int</span>)</span></span> (<span class="type">int</span>, <span class="type">error</span>) &#123;</span><br><span class="line">    value := os.Getenv(key)</span><br><span class="line">    <span class="keyword">if</span> value == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> fallback, <span class="literal">nil</span></span><br><span class="line">    &#125;</span><br><span class="line">    parsed, err := strconv.Atoi(value)</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> <span class="number">0</span>, fmt.Errorf(<span class="string">&quot;%s must be an integer: %w&quot;</span>, key, err)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> parsed, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>如果 <code>DB_MAX_OPEN_CONNS=abc</code>，服务会明确报错并拒绝启动，而不是悄悄回退到默认值。配置测试至少覆盖缺失必填项、非法数字、边界值和默认值。</p><p>配置实践还应遵循以下规则：</p><ul><li>密钥来自 Secret 或密钥管理系统，不提交到仓库；</li><li>启动日志可打印非敏感配置摘要，但数据库 URL、令牌和密码必须脱敏；</li><li>不在业务代码中随处调用 <code>os.Getenv</code>；</li><li>需要动态配置时，单独设计版本、缓存、失败回退和审计机制，不要把启动配置对象偷偷改成全局变量。</li></ul><h2 id="八、测试分层：不同风险交给不同测试"><a href="#八、测试分层：不同风险交给不同测试" class="headerlink" title="八、测试分层：不同风险交给不同测试"></a>八、测试分层：不同风险交给不同测试</h2><h3 id="8-1-业务单元测试"><a href="#8-1-业务单元测试" class="headerlink" title="8.1 业务单元测试"></a>8.1 业务单元测试</h3><p>业务测试不启动 HTTP，也不连接数据库。内存替身只实现当前测试需要的行为：</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><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> order_test</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;errors&quot;</span></span><br><span class="line">    <span class="string">&quot;testing&quot;</span></span><br><span class="line">    <span class="string">&quot;time&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="string">&quot;example.com/order-api/internal/order&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> repositoryStub <span class="keyword">struct</span> &#123;</span><br><span class="line">    saved order.Order</span><br><span class="line">    err   <span class="type">error</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">(r *repositoryStub)</span></span> Save(_ context.Context, value order.Order) <span class="type">error</span> &#123;</span><br><span class="line">    r.saved = value</span><br><span class="line">    <span class="keyword">return</span> r.err</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">(r *repositoryStub)</span></span> ByID(context.Context, <span class="type">string</span>) (order.Order, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> order.Order&#123;&#125;, order.ErrNotFound</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">TestServiceCreate</span><span class="params">(t *testing.T)</span></span> &#123;</span><br><span class="line">    fixedTime := time.Date(<span class="number">2026</span>, <span class="number">8</span>, <span class="number">14</span>, <span class="number">9</span>, <span class="number">0</span>, <span class="number">0</span>, <span class="number">0</span>, time.FixedZone(<span class="string">&quot;CST&quot;</span>, <span class="number">8</span>*<span class="number">60</span>*<span class="number">60</span>))</span><br><span class="line">    repo := &amp;repositoryStub&#123;&#125;</span><br><span class="line">    service := order.NewService(repo, <span class="function"><span class="keyword">func</span><span class="params">()</span></span> <span class="type">string</span> &#123; <span class="keyword">return</span> <span class="string">&quot;ord-001&quot;</span> &#125;, <span class="function"><span class="keyword">func</span><span class="params">()</span></span> time.Time &#123;</span><br><span class="line">        <span class="keyword">return</span> fixedTime</span><br><span class="line">    &#125;)</span><br><span class="line"></span><br><span class="line">    got, err := service.Create(context.Background(), order.CreateInput&#123;</span><br><span class="line">        CustomerID: <span class="string">&quot; customer-01 &quot;</span>,</span><br><span class="line">        AmountCent: <span class="number">9900</span>,</span><br><span class="line">    &#125;)</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;Create() error = %v&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> got.ID != <span class="string">&quot;ord-001&quot;</span> || got.CustomerID != <span class="string">&quot;customer-01&quot;</span> &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;Create() = %#v&quot;</span>, got)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> !got.CreatedAt.Equal(fixedTime.UTC()) &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;CreatedAt = %v, want %v&quot;</span>, got.CreatedAt, fixedTime.UTC())</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> repo.saved != got &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;saved = %#v, want %#v&quot;</span>, repo.saved, got)</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">TestServiceCreateRejectsInvalidAmount</span><span class="params">(t *testing.T)</span></span> &#123;</span><br><span class="line">    service := order.NewService(&amp;repositoryStub&#123;&#125;, <span class="function"><span class="keyword">func</span><span class="params">()</span></span> <span class="type">string</span> &#123; <span class="keyword">return</span> <span class="string">&quot;unused&quot;</span> &#125;, time.Now)</span><br><span class="line"></span><br><span class="line">    _, err := service.Create(context.Background(), order.CreateInput&#123;</span><br><span class="line">        CustomerID: <span class="string">&quot;customer-01&quot;</span>,</span><br><span class="line">        AmountCent: <span class="number">0</span>,</span><br><span class="line">    &#125;)</span><br><span class="line">    <span class="keyword">if</span> !errors.Is(err, order.ErrInvalidInput) &#123;</span><br><span class="line">        t.Fatalf(<span class="string">&quot;Create() error = %v, want ErrInvalidInput&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>还应补充保存失败、上下文取消、空客户编号等分支。重点不是追求一个漂亮的覆盖率数字，而是让关键规则和失败路径都能稳定复现。</p><h3 id="8-2-HTTP-协议测试"><a href="#8-2-HTTP-协议测试" class="headerlink" title="8.2 HTTP 协议测试"></a>8.2 HTTP 协议测试</h3><p>使用标准库 <code>httptest</code> 验证：</p><ul><li>合法请求返回 201 和约定 JSON；</li><li>缺字段、未知字段、超大请求体返回 400 或 413；</li><li>领域错误与 HTTP 状态码的映射稳定；</li><li><code>Content-Type</code>、请求 ID 等响应头符合契约；</li><li>内部错误不会泄漏 SQL、路径或凭据。</li></ul><p>处理器测试可以注入一个只实现 <code>OrderCreator</code> 的 stub，无需启动整个服务。路由级测试再通过 <code>httptest.NewServer</code> 验证中间件和路径参数。</p><h3 id="8-3-数据库集成测试"><a href="#8-3-数据库集成测试" class="headerlink" title="8.3 数据库集成测试"></a>8.3 数据库集成测试</h3><p>Repository 的正确性无法靠 Mock SQL 充分证明。应在隔离环境连接与生产同大版本的 PostgreSQL，执行真实迁移并验证：</p><ul><li>写入后可按 ID 读取；</li><li>唯一约束、非空约束和类型转换符合预期；</li><li><code>sql.ErrNoRows</code> 正确映射为领域错误；</li><li>事务失败后确实回滚；</li><li>测试数据按用例隔离并可清理。</li></ul><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="comment">//go:build integration</span></span><br></pre></td></tr></table></figure><p>本地或 CI 中显式执行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">go <span class="built_in">test</span> -tags=integration ./test/integration/... -count=1</span><br></pre></td></tr></table></figure><p>构建标签不能成为“长期不运行”的借口。至少在合并请求或主分支流水线中执行，并为依赖服务设置明确超时。</p><h2 id="九、健康检查与可观测性也属于结构设计"><a href="#九、健康检查与可观测性也属于结构设计" class="headerlink" title="九、健康检查与可观测性也属于结构设计"></a>九、健康检查与可观测性也属于结构设计</h2><p>健康端点应表达不同语义：</p><ul><li><code>/livez</code>：进程事件循环仍可响应，不因数据库短暂故障而重启；</li><li><code>/readyz</code>：实例是否可以接收新流量，可检查必要依赖，但要有很短的超时；</li><li><code>/metrics</code>：请求量、错误量、延迟和资源指标；</li><li>日志：结构化记录请求 ID、业务动作、耗时和错误链，避免记录敏感请求体。</li></ul><p>不要在每次存活检查里执行复杂 SQL。否则一次数据库抖动可能触发所有实例重启，进一步放大故障。就绪失败负责摘流量，存活失败只处理进程本身无法恢复的状态。</p><p>OpenTelemetry、Prometheus 或具体日志库应在适配层初始化，通过小接口或上下文传入业务用例。业务规则可以记录“订单创建失败”这类业务事件，但不应直接拼装某家可观测平台的字段格式。</p><h2 id="十、用-CI-把结构约束变成证据"><a href="#十、用-CI-把结构约束变成证据" class="headerlink" title="十、用 CI 把结构约束变成证据"></a>十、用 CI 把结构约束变成证据</h2><p>每次提交至少执行下面的检查：</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><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">test</span> -z <span class="string">&quot;<span class="subst">$(gofmt -l .)</span>&quot;</span></span><br><span class="line">go mod tidy</span><br><span class="line">git diff --exit-code -- go.mod go.sum</span><br><span class="line">go vet ./...</span><br><span class="line">go <span class="built_in">test</span> -race -shuffle=on ./...</span><br><span class="line">go build ./cmd/order-api</span><br></pre></td></tr></table></figure><p>这些命令分别守住格式、依赖声明、静态问题、并发安全和最终可构建性。若项目还包含集成测试，应在准备好隔离数据库后追加对应命令。</p><p>边界检查也可以从简单命令开始：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">go list -f <span class="string">&#x27;&#123;&#123;join .Imports &quot;\n&quot;&#125;&#125;&#x27;</span> ./internal/order</span><br></pre></td></tr></table></figure><p>审查输出中是否出现 HTTP 框架、SQL 驱动或项目外层包。团队规模扩大后，可把允许的依赖列表写成测试或静态检查规则，避免只依赖评审者记忆。</p><p>流水线通过不等于可以上线。发布阶段仍要保存镜像摘要、迁移版本、配置版本、健康检查结果和回滚目标；涉及数据库变更时使用扩展—迁移—收缩策略，确保新旧版本在滚动发布期间可以共存。</p><h2 id="十一、从混杂结构渐进迁移"><a href="#十一、从混杂结构渐进迁移" class="headerlink" title="十一、从混杂结构渐进迁移"></a>十一、从混杂结构渐进迁移</h2><p>假设现有服务把路由、SQL 和业务判断全写在 <code>main.go</code>，不要一次性搬完所有文件。更安全的顺序是：</p><ol><li><strong>补特征测试</strong>：先锁定当前对外行为和关键错误响应。</li><li><strong>瘦身入口</strong>：把服务启动与退出移到 <code>internal/app</code>，业务行为暂时不变。</li><li><strong>提取一个业务用例</strong>：例如只迁移“创建订单”，建立 <code>internal/order</code>。</li><li><strong>反转数据库依赖</strong>：由 <code>order</code> 定义最小 Repository，原 SQL 代码实现它。</li><li><strong>隔离 HTTP 转换</strong>：Handler 只做解析、调用和响应映射。</li><li><strong>补三层测试</strong>：业务单测、HTTP 测试、Repository 集成测试。</li><li><strong>删除旧路径</strong>：确认指标和日志无回归后，再清理重复实现。</li></ol><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></pre></td><td class="code"><pre><span class="line">【REQ-1842】- 提取订单创建用例并增加业务规则测试</span><br><span class="line">【REQ-1842】- 将 PostgreSQL 访问接入订单 Repository</span><br><span class="line">【BUG-1967】- 修复服务退出时未等待在途请求的问题</span><br></pre></td></tr></table></figure><p>评审时可以直接按提交验证依赖方向和行为变化，故障发生后也更容易定位与回退。</p><h2 id="十二、什么时候需要继续拆分"><a href="#十二、什么时候需要继续拆分" class="headerlink" title="十二、什么时候需要继续拆分"></a>十二、什么时候需要继续拆分</h2><h3 id="12-1-拆包的信号"><a href="#12-1-拆包的信号" class="headerlink" title="12.1 拆包的信号"></a>12.1 拆包的信号</h3><p>出现以下情况时，可以把一个业务包继续拆成子能力：</p><ul><li>文件长期由不同小组修改，合并冲突频繁；</li><li>某组规则有清楚的输入、输出和独立测试；</li><li>包名已经无法准确概括其中内容；</li><li>为完成一个小需求，必须理解大量不相关代码。</li></ul><p>不要只因为文件超过某个行数就拆包。内聚的 800 行代码可能比互相跳转的 8 个抽象层更容易维护。</p><h3 id="12-2-拆模块的信号"><a href="#12-2-拆模块的信号" class="headerlink" title="12.2 拆模块的信号"></a>12.2 拆模块的信号</h3><p>一个仓库可以长期只使用一个 <code>go.mod</code>。只有出现独立发布版本、外部消费者、不同兼容承诺或明确所有权边界时，才考虑多模块。</p><p>多模块本地联调可以使用 Go Workspace：</p><figure class="highlight bash"><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">go work init ./order-api ./shared-contracts</span><br><span class="line">go work <span class="built_in">sync</span></span><br></pre></td></tr></table></figure><p>Workspace 解决的是本地同时开发多个模块的问题，不会自动解决版本治理。CI 和发布仍应验证各模块在不依赖本地 <code>replace</code> 的情况下可以独立构建。是否提交 <code>go.work</code> 应由团队根据仓库形态统一决定，不能让个人工作区文件意外改变流水线解析结果。</p><h3 id="12-3-拆服务的信号"><a href="#12-3-拆服务的信号" class="headerlink" title="12.3 拆服务的信号"></a>12.3 拆服务的信号</h3><p>服务拆分应由独立扩缩容、故障隔离、数据所有权、发布节奏或团队所有权驱动。仅仅因为目录很多就拆微服务，通常会把本地函数调用变成需要超时、重试、幂等、鉴权和观测的网络调用，整体复杂度反而更高。</p><h2 id="十三、常见反模式与修正方式"><a href="#十三、常见反模式与修正方式" class="headerlink" title="十三、常见反模式与修正方式"></a>十三、常见反模式与修正方式</h2><table><thead><tr><th>反模式</th><th>直接后果</th><th>更可落地的修正</th></tr></thead><tbody><tr><td>顶层只有 <code>controller/service/repository</code></td><td>一个需求横跨全部目录，业务边界不可见</td><td>先按订单、库存等业务能力聚合，再在包内按需要拆文件</td></tr><tr><td>所有接口集中在 <code>interfaces</code></td><td>接口变大且脱离消费者</td><td>在使用方定义最小接口，由实现方做编译期断言</td></tr><tr><td><code>utils</code>、<code>common</code> 持续膨胀</td><td>职责模糊、依赖扩散</td><td>按真实能力命名，如 <code>money</code>、<code>clock</code>、<code>requestid</code></td></tr><tr><td><code>main.go</code> 包含业务逻辑</td><td>难以测试，多个入口无法复用</td><td>入口只处理进程，装配移到 <code>internal/app</code></td></tr><tr><td>全局数据库和配置单例</td><td>测试互相污染，初始化顺序隐蔽</td><td>构造函数显式注入，配置对象启动后只读</td></tr><tr><td>业务包返回 HTTP 状态码</td><td>核心逻辑绑定协议</td><td>返回领域错误，由 HTTP 或 gRPC 适配层映射</td></tr><tr><td>一开始创建多个 <code>go.mod</code></td><td>版本和联调成本过早出现</td><td>单模块起步，等独立发布边界真实存在再拆</td></tr><tr><td>为“以后复用”建立 <code>pkg</code></td><td>内部实现被误当成稳定公共 API</td><td>默认放 <code>internal</code>，确认外部消费者后再提取独立模块</td></tr></tbody></table><h2 id="十四、上线前检查清单"><a href="#十四、上线前检查清单" class="headerlink" title="十四、上线前检查清单"></a>十四、上线前检查清单</h2><h3 id="代码边界"><a href="#代码边界" class="headerlink" title="代码边界"></a>代码边界</h3><ul><li><input disabled="" type="checkbox"> <code>main</code> 只处理进程级职责；</li><li><input disabled="" type="checkbox"> 业务包不导入 HTTP 框架、SQL 驱动和具体消息 SDK；</li><li><input disabled="" type="checkbox"> 接口由消费方定义，并保持最小；</li><li><input disabled="" type="checkbox"> 包名短、明确，不使用含糊的 <code>common</code>、<code>util</code>、<code>types</code>；</li><li><input disabled="" type="checkbox"> 没有可变全局 Service、数据库连接或配置。</li></ul><h3 id="正确性"><a href="#正确性" class="headerlink" title="正确性"></a>正确性</h3><ul><li><input disabled="" type="checkbox"> 业务规则和失败路径有单元测试；</li><li><input disabled="" type="checkbox"> HTTP 错误映射和响应契约有 <code>httptest</code> 测试；</li><li><input disabled="" type="checkbox"> Repository 在真实同版本数据库上执行集成测试；</li><li><input disabled="" type="checkbox"> <code>gofmt</code>、<code>go vet</code>、竞态测试和最终构建均通过；</li><li><input disabled="" type="checkbox"> 超时、取消和事务回滚路径已验证。</li></ul><h3 id="运行与发布"><a href="#运行与发布" class="headerlink" title="运行与发布"></a>运行与发布</h3><ul><li><input disabled="" type="checkbox"> 启动时校验必填配置，日志不包含凭据；</li><li><input disabled="" type="checkbox"> HTTP Server 配置读取、写入、空闲和优雅退出超时；</li><li><input disabled="" type="checkbox"> <code>/livez</code> 与 <code>/readyz</code> 语义分离；</li><li><input disabled="" type="checkbox"> 数据库连接池上限与实例副本数一起核算；</li><li><input disabled="" type="checkbox"> 发布记录包含镜像摘要、迁移版本、观察窗口和回滚目标。</li></ul><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>一个好的 Go 服务结构，不是目录越多越专业，而是能够回答三个问题：业务规则在哪里、外部依赖从哪里接入、修改后怎样证明没有破坏行为。</p><p>从单模块、薄入口和 <code>internal</code> 开始；按业务能力聚合代码；让接口靠近消费方；在装配层连接具体实现；最后用单元测试、协议测试、集成测试和 CI 固化边界。等独立发布、所有权或故障隔离需求真正出现后，再拆模块或服务。这种结构既能服务今天的代码，也为明天的演进保留了空间。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://go.dev/doc/modules/layout">Go 官方：Organizing a Go module</a></li><li><a href="https://go.dev/blog/package-names">Go 官方：Package names</a></li><li><a href="https://go.dev/doc/tutorial/workspaces">Go 官方：Multi-module workspaces</a></li><li><a href="https://pkg.go.dev/net/http">Go 标准库：net&#x2F;http</a></li><li><a href="https://pkg.go.dev/net/http/httptest">Go 标准库：net&#x2F;http&#x2F;httptest</a></li><li><a href="https://pkg.go.dev/cmd/go#hdr-Test_packages">Go 命令：Test packages</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。示例中的模块路径、数据库参数和 ID 生成器需要替换为项目实际实现；工具行为可能演进，实施前以锁定 Go 版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/14/go-service-layout/</id>
    <link href="https://www.cjjhc.cn/2026/08/14/go-service-layout/"/>
    <published>2026-08-14T01:00:00.000Z</published>
    <summary>从一个可部署的 HTTP 服务出发，用 cmd、internal、业务能力包和消费方接口建立清晰依赖方向，并用测试与 CI 守住边界。</summary>
    <title>Go 服务项目结构实践</title>
    <updated>2026-08-14T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="后端开发" scheme="https://www.cjjhc.cn/categories/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="API" scheme="https://www.cjjhc.cn/tags/API/"/>
    <category term="REST" scheme="https://www.cjjhc.cn/tags/REST/"/>
    <category term="架构" scheme="https://www.cjjhc.cn/tags/%E6%9E%B6%E6%9E%84/"/>
    <content>
      <![CDATA[<p>用一致的资源模型、错误格式和演进策略降低协作成本。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>路径表达资源，HTTP 方法表达动作；分页、过滤和排序保持统一。错误响应包含稳定代码与可读信息，但不泄露内部堆栈。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>兼容性优先于追求完美。通过契约测试和弃用周期管理变更，幂等接口要明确幂等键与有效期。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>资源路径使用名词，方法表达动作；分页、过滤、排序和错误 envelope 在全站统一。写接口设计幂等键，错误返回稳定业务码和可追踪 request_id。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>先写 OpenAPI 契约并做消费者测试。版本演进优先兼容新增字段，删除或改变语义要经过弃用期和调用方数据验证。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“设计一个可演进的订单查询 API”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>资源命名稳定，分页与过滤有明确上限；错误返回机器码和 trace 标识；新增字段保持向后兼容，幂等写操作接收业务键。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>用旧客户端调用新服务，测试重复提交、非法分页、部分依赖失败和超时取消。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察错误码分布、兼容失败和重试次数；破坏性变更使用新版本并保留迁移窗口。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> OpenAPI 契约、兼容性样本和幂等测试。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>用旧客户端契约测试新服务，并重复提交相同幂等键</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：订单创建与查询-API"><a href="#完整实战：订单创建与查询-API" class="headerlink" title="完整实战：订单创建与查询 API"></a>完整实战：订单创建与查询 API</h2><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><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="attr">paths:</span></span><br><span class="line">  <span class="string">/orders:</span></span><br><span class="line">    <span class="attr">post:</span></span><br><span class="line">      <span class="attr">parameters:</span></span><br><span class="line">        <span class="bullet">-</span> <span class="attr">in:</span> <span class="string">header</span></span><br><span class="line">          <span class="attr">name:</span> <span class="string">Idempotency-Key</span></span><br><span class="line">          <span class="attr">required:</span> <span class="literal">true</span></span><br><span class="line">          <span class="attr">schema:</span> &#123; <span class="attr">type:</span> <span class="string">string</span>, <span class="attr">minLength:</span> <span class="number">16</span>, <span class="attr">maxLength:</span> <span class="number">64</span> &#125;</span><br><span class="line">      <span class="attr">responses:</span></span><br><span class="line">        <span class="attr">&quot;201&quot;:</span> &#123; <span class="attr">description:</span> <span class="string">created</span> &#125;</span><br><span class="line">        <span class="attr">&quot;409&quot;:</span> &#123; <span class="attr">description:</span> <span class="string">idempotency</span> <span class="string">key</span> <span class="string">reused</span> <span class="string">with</span> <span class="string">different</span> <span class="string">payload</span> &#125;</span><br><span class="line">  <span class="string">/orders/&#123;order_id&#125;:</span></span><br><span class="line">    <span class="attr">get:</span></span><br><span class="line">      <span class="attr">responses:</span></span><br><span class="line">        <span class="attr">&quot;200&quot;:</span> &#123; <span class="attr">description:</span> <span class="string">found</span> &#125;</span><br><span class="line">        <span class="attr">&quot;404&quot;:</span> &#123; <span class="attr">description:</span> <span class="string">not</span> <span class="string">found</span> &#125;</span><br></pre></td></tr></table></figure><p>幂等记录不能只保存“这个键见过”，还应绑定租户、调用方、请求摘要、响应和过期时间。同一键与同一摘要再次到达时返回原结果；同一键对应不同摘要时返回冲突，不能悄悄创建第二张订单。</p><p>统一错误 envelope，HTTP 状态表达协议语义，稳定业务码供程序分支，<code>request_id</code> 用于串联日志：</p><figure class="highlight json"><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="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;error&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;INVENTORY_NOT_ENOUGH&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="string">&quot;部分商品库存不足&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;request_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;req_01J...&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;details&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">&#123;</span> <span class="attr">&quot;sku&quot;</span><span class="punctuation">:</span> <span class="string">&quot;SKU-42&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;available&quot;</span><span class="punctuation">:</span> <span class="number">1</span> <span class="punctuation">&#125;</span><span class="punctuation">]</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="分页、并发修改与兼容"><a href="#分页、并发修改与兼容" class="headerlink" title="分页、并发修改与兼容"></a>分页、并发修改与兼容</h3><ul><li>列表数据持续变化时优先游标分页；游标应不可伪造，并固定排序字段与并列项规则。</li><li>更新接口可接受 <code>If-Match</code>&#x2F;版本号，旧版本提交返回冲突，防止后写覆盖先写。</li><li>新增可选响应字段通常可兼容，重命名字段、收紧枚举和改变 <code>null</code> 语义都可能破坏旧客户端。</li><li>写操作不要用模糊的 <code>200</code> 表示所有结果；创建、异步接受、无响应体应分别使用与语义相符的状态。</li></ul><h3 id="契约回归矩阵"><a href="#契约回归矩阵" class="headerlink" title="契约回归矩阵"></a>契约回归矩阵</h3><table><thead><tr><th>用例</th><th>请求</th><th>预期证据</th></tr></thead><tbody><tr><td>首次创建</td><td>新幂等键 + 合法载荷</td><td>一条订单、<code>201</code>、审计事件一条</td></tr><tr><td>网络重试</td><td>相同键 + 相同载荷</td><td>同一订单号，业务副作用不增加</td></tr><tr><td>错误复用</td><td>相同键 + 不同载荷</td><td><code>409</code>，原订单不变化</td></tr><tr><td>旧客户端</td><td>删除新增可选字段后调用</td><td>仍能解析成功响应</td></tr><tr><td>并发更新</td><td>两个相同旧版本号</td><td>只有一个成功，另一个冲突</td></tr></tbody></table><p>发布时先让提供方通过 OpenAPI 校验与消费者契约，再观察业务码分布、P95 延迟和幂等冲突率。回滚应用版本时不要同步删除新字段或幂等记录；数据结构的向后兼容窗口必须长于应用灰度窗口。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://spec.openapis.org/oas/latest.html">OpenAPI Specification</a></li><li><a href="https://www.rfc-editor.org/rfc/rfc9110">HTTP Semantics</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/13/api-design-guide/</id>
    <link href="https://www.cjjhc.cn/2026/08/13/api-design-guide/"/>
    <published>2026-08-13T01:00:00.000Z</published>
    <summary>用一致的资源模型、错误格式和演进策略降低协作成本。</summary>
    <title>REST API 设计的十条实用原则</title>
    <updated>2026-08-13T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="安全" scheme="https://www.cjjhc.cn/categories/%E5%AE%89%E5%85%A8/"/>
    <category term="Web安全" scheme="https://www.cjjhc.cn/tags/Web%E5%AE%89%E5%85%A8/"/>
    <category term="OWASP" scheme="https://www.cjjhc.cn/tags/OWASP/"/>
    <category term="后端" scheme="https://www.cjjhc.cn/tags/%E5%90%8E%E7%AB%AF/"/>
    <content>
      <![CDATA[<p>建立输入验证、输出编码、会话保护与最小权限的基础防线。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>服务端验证所有外部输入，按输出上下文编码，数据库使用参数化查询。Cookie 启用 HttpOnly、Secure 和合理的 SameSite。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>敏感操作需要重新认证或二次确认。依赖扫描、密钥轮换和安全日志应进入日常工程流程。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>服务端对所有输入做允许式验证，SQL 使用参数化查询，输出按 HTML、URL、JavaScript 等上下文编码。会话 Cookie 启用 HttpOnly、Secure 和合理 SameSite。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>用权限矩阵测试水平和垂直越权，敏感动作重新认证并记录审计日志。依赖扫描不能替代威胁建模和业务授权测试。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“保护一个带登录与写接口的 Web 应用”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>身份认证后仍逐资源鉴权；Cookie 设置 Secure、HttpOnly 与合适 SameSite，状态变更防 CSRF，输出按上下文编码。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>尝试水平越权、存储型 XSS、跨站请求、文件路径穿越和暴力登录，确认服务端拒绝且日志不泄密。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察鉴权拒绝、异常登录和安全头；修复发布失败时回退应用但保留临时访问限制。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 威胁模型、滥用用例和安全响应头快照。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>使用测试账号验证水平越权、CSRF、XSS 与限流，不对生产做未授权扫描</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：评论发布链路的威胁建模"><a href="#完整实战：评论发布链路的威胁建模" class="headerlink" title="完整实战：评论发布链路的威胁建模"></a>完整实战：评论发布链路的威胁建模</h2><p>假设用户可以编辑 Markdown 评论，服务端渲染后展示给其他人。先画清数据流：浏览器提交原文，API 完成鉴权和限流，数据库保存，渲染器转换 HTML，页面再输出。每跨过一个信任边界都重新验证，不能因为“前端已经过滤”就相信服务端收到的数据。</p><h3 id="输入、存储与输出编码是三件事"><a href="#输入、存储与输出编码是三件事" class="headerlink" title="输入、存储与输出编码是三件事"></a>输入、存储与输出编码是三件事</h3><ul><li>输入校验负责长度、类型、允许字符和业务约束，异常输入应尽早拒绝。</li><li>存储层使用参数化查询，数据库账号只拥有当前服务所需权限。</li><li>输出按上下文编码：HTML 文本、HTML 属性、URL、JavaScript 字符串不是同一种上下文。</li><li>允许富文本时使用成熟 sanitizer 的白名单策略；不要用正则表达式清理 HTML。</li></ul><figure class="highlight http"><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="attribute">Content-Security-Policy</span><span class="punctuation">: </span>default-src &#x27;self&#x27;; script-src &#x27;self&#x27; &#x27;nonce-&lt;random&gt;&#x27;; object-src &#x27;none&#x27;; base-uri &#x27;none&#x27;; frame-ancestors &#x27;none&#x27;</span><br><span class="line"><span class="attribute">X-Content-Type-Options</span><span class="punctuation">: </span>nosniff</span><br><span class="line"><span class="attribute">Referrer-Policy</span><span class="punctuation">: </span>strict-origin-when-cross-origin</span><br></pre></td></tr></table></figure><p>CSP 是输出编码与依赖治理后的纵深防御。nonce 必须按响应随机生成，不能写成固定字符串；上线前先用 Report-Only 收集违规，确认内联脚本和第三方来源，再逐步收紧。</p><h3 id="会话与写操作"><a href="#会话与写操作" class="headerlink" title="会话与写操作"></a>会话与写操作</h3><p>Cookie 会话至少根据部署条件设置 <code>Secure</code>、<code>HttpOnly</code> 和合适的 <code>SameSite</code>；登录、修改邮箱等敏感操作重新验证身份。基于 Cookie 的写接口仍需 CSRF 防护，不能把 CORS 当作 CSRF 方案。对象接口每次按当前用户检查对象级权限，避免只判断“已经登录”。</p><table><thead><tr><th>攻击用例</th><th>测试输入&#x2F;动作</th><th>通过条件</th></tr></thead><tbody><tr><td>存储型 XSS</td><td>评论中加入事件属性、危险 URL</td><td>页面显示安全文本或清理后的允许标签</td></tr><tr><td>SQL 注入</td><td>搜索条件加入引号与运算片段</td><td>查询按普通数据处理，无错误细节泄漏</td></tr><tr><td>越权读取</td><td>用户 A 请求用户 B 的评论草稿</td><td>返回拒绝且产生审计事件</td></tr><tr><td>CSRF</td><td>从第三方站点提交删除请求</td><td>请求被 token&#x2F;origin 校验拒绝</td></tr><tr><td>暴力尝试</td><td>短时重复登录</td><td>分层限速，不泄漏账号是否存在</td></tr></tbody></table><h3 id="上线前证据与应急"><a href="#上线前证据与应急" class="headerlink" title="上线前证据与应急"></a>上线前证据与应急</h3><p>依赖扫描、静态规则和动态扫描只能覆盖一部分风险。关键业务还需人工检查信任边界、权限矩阵、密钥管理和日志脱敏。日志不能保存密码、完整令牌、Cookie 或评论中的敏感原文。</p><p>灰度观察认证失败率、权限拒绝、CSP 报告、异常输入分布和 5xx。发现利用迹象时先隔离入口、吊销可能泄露的凭据并保全日志，再修复和轮换；不要为了恢复可用性临时关闭鉴权或放宽全站 CSP。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://owasp.org/www-project-application-security-verification-standard/">OWASP ASVS</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">MDN CSP</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/12/web-security-basics/</id>
    <link href="https://www.cjjhc.cn/2026/08/12/web-security-basics/"/>
    <published>2026-08-12T01:00:00.000Z</published>
    <summary>建立输入验证、输出编码、会话保护与最小权限的基础防线。</summary>
    <title>Web 应用安全基础：从输入到会话</title>
    <updated>2026-08-12T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="前端开发" scheme="https://www.cjjhc.cn/categories/%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="无障碍" scheme="https://www.cjjhc.cn/tags/%E6%97%A0%E9%9A%9C%E7%A2%8D/"/>
    <category term="HTML" scheme="https://www.cjjhc.cn/tags/HTML/"/>
    <category term="UX" scheme="https://www.cjjhc.cn/tags/UX/"/>
    <content>
      <![CDATA[<p>从语义结构、键盘操作、对比度和动态提示提升可访问性。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>优先使用原生按钮、链接与表单元素，确保焦点顺序合理且清晰可见。图片提供有意义的替代文本，装饰图片使用空文本。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>弹窗要管理焦点并支持 Escape 关闭。用键盘和屏幕阅读器完成一次核心流程，比只跑自动检查更可靠。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>使用语义 HTML，保证键盘可完成核心流程、焦点顺序合理且可见。表单 label 与错误提示关联，图片提供有意义 alt，动态状态使用适当 live region。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>自动扫描后仍要手工完成键盘和屏幕阅读器测试。弹窗打开时移动焦点、关闭时归还焦点，并测试放大、深色模式和减少动画偏好。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“让一个表单能被键盘和读屏稳定使用”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>使用原生 label、button 和 heading 语义；焦点顺序与视觉顺序一致，错误信息通过描述关系关联字段。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>只用键盘完成提交，使用读屏检查名称与状态，并测试 200% 缩放、减少动画和高对比度。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>跟踪自动检查与人工任务通过率；组件改造出现阻塞时回退单个组件而非关闭全站语义。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 键盘任务清单、读屏记录和自动扫描报告。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>不使用鼠标完成主要任务，并检查焦点可见、标签关联与错误提示</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：可访问的异步表单"><a href="#完整实战：可访问的异步表单" class="headerlink" title="完整实战：可访问的异步表单"></a>完整实战：可访问的异步表单</h2><p>以“创建项目”表单为例，验收目标不是扫描工具零告警，而是用户只用键盘、放大页面或使用读屏时仍能理解错误并完成提交。</p><figure class="highlight html"><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></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">form</span> <span class="attr">aria-labelledby</span>=<span class="string">&quot;create-title&quot;</span> <span class="attr">novalidate</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">h2</span> <span class="attr">id</span>=<span class="string">&quot;create-title&quot;</span>&gt;</span>创建项目<span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span><br><span class="line"></span><br><span class="line">  <span class="tag">&lt;<span class="name">label</span> <span class="attr">for</span>=<span class="string">&quot;project-name&quot;</span>&gt;</span>项目名称<span class="tag">&lt;/<span class="name">label</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">input</span></span></span><br><span class="line"><span class="tag">    <span class="attr">id</span>=<span class="string">&quot;project-name&quot;</span></span></span><br><span class="line"><span class="tag">    <span class="attr">name</span>=<span class="string">&quot;name&quot;</span></span></span><br><span class="line"><span class="tag">    <span class="attr">required</span></span></span><br><span class="line"><span class="tag">    <span class="attr">aria-describedby</span>=<span class="string">&quot;name-hint name-error&quot;</span></span></span><br><span class="line"><span class="tag">    <span class="attr">aria-invalid</span>=<span class="string">&quot;true&quot;</span></span></span><br><span class="line"><span class="tag">  &gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">p</span> <span class="attr">id</span>=<span class="string">&quot;name-hint&quot;</span>&gt;</span>2～40 个字符<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">p</span> <span class="attr">id</span>=<span class="string">&quot;name-error&quot;</span> <span class="attr">role</span>=<span class="string">&quot;alert&quot;</span>&gt;</span>请输入项目名称<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span><br><span class="line"></span><br><span class="line">  <span class="tag">&lt;<span class="name">button</span> <span class="attr">type</span>=<span class="string">&quot;submit&quot;</span>&gt;</span>创建<span class="tag">&lt;/<span class="name">button</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">p</span> <span class="attr">role</span>=<span class="string">&quot;status&quot;</span> <span class="attr">aria-live</span>=<span class="string">&quot;polite&quot;</span>&gt;</span><span class="tag">&lt;/<span class="name">p</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">form</span>&gt;</span></span><br></pre></td></tr></table></figure><p>标签、错误和帮助文字需要通过 ID 建立程序关系；placeholder 不能代替 label。提交失败后把焦点移到错误摘要，摘要中的链接再把焦点带到具体字段。提交成功或后台状态变化使用 <code>role=&quot;status&quot;</code> 的礼貌播报，避免每次输入都打断读屏。</p><h3 id="弹窗与焦点管理"><a href="#弹窗与焦点管理" class="headerlink" title="弹窗与焦点管理"></a>弹窗与焦点管理</h3><p>弹窗打开时将焦点放到标题、首个输入或最安全的操作；Tab 不应跑到遮罩后的页面；关闭后回到触发按钮。Escape 是否关闭取决于操作风险，不能让“确认付款”之类的流程被误触取消。视觉隐藏文本要使用可访问的 CSS 技术，不能用 <code>display:none</code> 后仍期待读屏朗读。</p><table><thead><tr><th>测试维度</th><th>操作</th><th>通过条件</th></tr></thead><tbody><tr><td>键盘</td><td>Tab、Shift+Tab、Enter、Space、Escape</td><td>顺序符合视觉与任务逻辑，无焦点陷阱</td></tr><tr><td>200%&#x2F;400% 缩放</td><td>缩放并完成表单</td><td>内容不被裁切，不依赖双向滚动</td></tr><tr><td>读屏</td><td>浏览标题、字段、错误和状态</td><td>名称、角色、状态与错误均可理解</td></tr><tr><td>高对比&#x2F;强制颜色</td><td>切换系统模式</td><td>焦点、边界和选中态不只靠颜色</td></tr><tr><td>动效偏好</td><td>开启减少动态效果</td><td>非必要动画停用，操作信息不丢失</td></tr></tbody></table><p>自动化可以检查缺少 label、无效 ARIA 和部分对比度问题，但无法证明焦点顺序合理、替代文本传达了正确含义，或读屏用户能完成任务。CI 使用静态规则和浏览器测试，发布前仍要走一遍关键任务手工脚本。</p><p>回归发现阻断性问题时，优先关闭新组件或回退交互，不要用删除 ARIA 属性“消除告警”。为每个问题保存页面、浏览器、辅助技术版本、复现按键与实际播报，才能让修复可重复验证。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.w3.org/WAI/WCAG22/quickref/">WCAG 2.2 Quick Reference</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Understanding_WCAG/Keyboard">MDN 键盘可访问性</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/11/frontend-accessibility/</id>
    <link href="https://www.cjjhc.cn/2026/08/11/frontend-accessibility/"/>
    <published>2026-08-11T01:00:00.000Z</published>
    <summary>从语义结构、键盘操作、对比度和动态提示提升可访问性。</summary>
    <title>前端无障碍设计检查清单</title>
    <updated>2026-08-11T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="前端开发" scheme="https://www.cjjhc.cn/categories/%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="工程化" scheme="https://www.cjjhc.cn/tags/%E5%B7%A5%E7%A8%8B%E5%8C%96/"/>
    <category term="TypeScript" scheme="https://www.cjjhc.cn/tags/TypeScript/"/>
    <category term="类型系统" scheme="https://www.cjjhc.cn/tags/%E7%B1%BB%E5%9E%8B%E7%B3%BB%E7%BB%9F/"/>
    <content>
      <![CDATA[<p>用联合类型、泛型和类型收窄表达业务约束，减少无效状态。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>优先让类型从数据和函数返回值推导，公共边界再显式声明。使用判别联合表达状态机，避免多个布尔值产生不可能组合。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>外部输入永远需要运行时校验，类型断言不能替代验证。复杂类型若难以解释，应回到更简单的数据模型。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>用判别联合表达加载、成功和失败状态，用泛型约束容器而不是返回 <code>any</code>。API 边界通过运行时 schema 验证后再转换成领域类型。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>开启 strict 并逐步减少断言。为非法状态写 <code>never</code> 穷尽检查，类型复杂到无法解释时优先简化数据模型而不是继续堆条件类型。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为外部 API 响应建立可靠类型边界”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>unknown 数据先由运行时 schema 校验，再转换为领域类型；用判别联合表达状态，避免 any 和非空断言扩散。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>构造缺字段、额外字段、旧版本和未知枚举值，确保错误在入口被分类而非在深层崩溃。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察校验失败和未知状态；兼容变更先扩读后改写，必要时恢复旧解码器。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 运行时 schema、领域类型和边界失败样本。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>npx tsc --noEmit &amp;&amp; npm test</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：让订单状态在编译期闭合"><a href="#完整实战：让订单状态在编译期闭合" class="headerlink" title="完整实战：让订单状态在编译期闭合"></a>完整实战：让订单状态在编译期闭合</h2><p>类型的价值不是把 JavaScript 改写成更多接口，而是让非法状态更难出现。订单查询可能返回加载中、成功、失败和空结果；如果用四个可选字段表达，调用方会遇到 <code>loading=true</code> 同时带 <code>error</code> 的矛盾组合。</p><figure class="highlight ts"><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"><span class="keyword">type</span> <span class="title class_">Order</span> = <span class="title class_">Readonly</span>&lt;&#123;</span><br><span class="line">  <span class="attr">id</span>: <span class="built_in">string</span></span><br><span class="line">  <span class="attr">totalCent</span>: <span class="built_in">number</span></span><br><span class="line">  <span class="attr">status</span>: <span class="string">&#x27;pending&#x27;</span> | <span class="string">&#x27;paid&#x27;</span> | <span class="string">&#x27;cancelled&#x27;</span></span><br><span class="line">&#125;&gt;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">LoadState</span>&lt;T&gt; =</span><br><span class="line">  | &#123; <span class="attr">kind</span>: <span class="string">&#x27;idle&#x27;</span> &#125;</span><br><span class="line">  | &#123; <span class="attr">kind</span>: <span class="string">&#x27;loading&#x27;</span>; <span class="attr">requestId</span>: <span class="built_in">string</span> &#125;</span><br><span class="line">  | &#123; <span class="attr">kind</span>: <span class="string">&#x27;success&#x27;</span>; <span class="attr">data</span>: <span class="keyword">readonly</span> T[] &#125;</span><br><span class="line">  | &#123; <span class="attr">kind</span>: <span class="string">&#x27;failure&#x27;</span>; <span class="attr">code</span>: <span class="built_in">string</span>; <span class="attr">retryable</span>: <span class="built_in">boolean</span> &#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">renderState</span>(<span class="params"><span class="attr">state</span>: <span class="title class_">LoadState</span>&lt;<span class="title class_">Order</span>&gt;</span>): <span class="built_in">string</span> &#123;</span><br><span class="line">  <span class="keyword">switch</span> (state.<span class="property">kind</span>) &#123;</span><br><span class="line">    <span class="keyword">case</span> <span class="string">&#x27;idle&#x27;</span>: <span class="keyword">return</span> <span class="string">&#x27;尚未查询&#x27;</span></span><br><span class="line">    <span class="keyword">case</span> <span class="string">&#x27;loading&#x27;</span>: <span class="keyword">return</span> <span class="string">`加载中：<span class="subst">$&#123;state.requestId&#125;</span>`</span></span><br><span class="line">    <span class="keyword">case</span> <span class="string">&#x27;success&#x27;</span>: <span class="keyword">return</span> <span class="string">`共 <span class="subst">$&#123;state.data.length&#125;</span> 条`</span></span><br><span class="line">    <span class="keyword">case</span> <span class="string">&#x27;failure&#x27;</span>: <span class="keyword">return</span> state.<span class="property">retryable</span> ? <span class="string">&#x27;失败，可重试&#x27;</span> : <span class="string">&#x27;失败，请检查输入&#x27;</span></span><br><span class="line">    <span class="attr">default</span>: <span class="keyword">return</span> <span class="title function_">assertNever</span>(state)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">assertNever</span>(<span class="params"><span class="attr">value</span>: <span class="built_in">never</span></span>): <span class="built_in">never</span> &#123;</span><br><span class="line">  <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`unhandled state: <span class="subst">$&#123;<span class="built_in">JSON</span>.stringify(value)&#125;</span>`</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>当以后新增 <code>cancelled</code> 分支，<code>assertNever</code> 会让漏改的消费方在编译时暴露。不要用 <code>as LoadState&lt;Order&gt;</code> 绕过构造错误；为每个分支提供显式工厂，或者让解析器返回判别联合。</p><h3 id="外部输入仍需运行时校验"><a href="#外部输入仍需运行时校验" class="headerlink" title="外部输入仍需运行时校验"></a>外部输入仍需运行时校验</h3><p>类型声明在编译后不会保护网络响应、环境变量和本地存储。下面的 <code>unknown</code> 迫使边界代码先缩窄类型：</p><figure class="highlight ts"><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="keyword">function</span> <span class="title function_">isOrder</span>(<span class="params"><span class="attr">value</span>: <span class="built_in">unknown</span></span>): value is <span class="title class_">Order</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="keyword">typeof</span> value !== <span class="string">&#x27;object&#x27;</span> || value === <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">false</span></span><br><span class="line">  <span class="keyword">const</span> item = value <span class="keyword">as</span> <span class="title class_">Record</span>&lt;<span class="built_in">string</span>, <span class="built_in">unknown</span>&gt;</span><br><span class="line">  <span class="keyword">return</span> <span class="keyword">typeof</span> item.<span class="property">id</span> === <span class="string">&#x27;string&#x27;</span></span><br><span class="line">    &amp;&amp; <span class="title class_">Number</span>.<span class="title function_">isInteger</span>(item.<span class="property">totalCent</span>)</span><br><span class="line">    &amp;&amp; [<span class="string">&#x27;pending&#x27;</span>, <span class="string">&#x27;paid&#x27;</span>, <span class="string">&#x27;cancelled&#x27;</span>].<span class="title function_">includes</span>(<span class="title class_">String</span>(item.<span class="property">status</span>))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>大型项目通常用 schema 库生成解析器或从 OpenAPI 生成类型，但仍要测试“服务端新增字段、缺字段、错误枚举、超大数字和 <code>null</code>”这些边界。生成代码要固定工具版本，并在 CI 检查契约漂移。</p><table><thead><tr><th>味道</th><th>隐患</th><th>改进方向</th></tr></thead><tbody><tr><td>大量 <code>any</code></td><td>错误跨层扩散</td><td>边界用 <code>unknown</code>，验证后缩窄</td></tr><tr><td>到处 <code>as</code></td><td>编译器被迫相信调用方</td><td>工厂函数、类型守卫或 schema</td></tr><tr><td>巨型全局类型</td><td>任一变化影响所有模块</td><td>按领域拥有类型，在边界映射</td></tr><tr><td>可选字段表达状态</td><td>合法组合不清晰</td><td>判别联合</td></tr><tr><td><code>enum</code> 与后端漂移</td><td>新值导致错误分支</td><td>运行时验证和未知值策略</td></tr></tbody></table><p>验收时运行严格类型检查、单元测试和契约样本；再搜索本次边界中新增的 <code>any</code>、非空断言和双重断言。回滚类型定义不能修复已经写入的数据，因此涉及序列化格式时要先保持新旧读写兼容，再分阶段删除旧分支。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html">TypeScript Narrowing</a></li><li><a href="https://www.typescriptlang.org/tsconfig/strict.html">TypeScript strict</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/10/typescript-practical-types/</id>
    <link href="https://www.cjjhc.cn/2026/08/10/typescript-practical-types/"/>
    <published>2026-08-10T01:00:00.000Z</published>
    <summary>用联合类型、泛型和类型收窄表达业务约束，减少无效状态。</summary>
    <title>TypeScript 实用类型设计</title>
    <updated>2026-08-10T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="前端开发" scheme="https://www.cjjhc.cn/categories/%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91/"/>
    <category term="TypeScript" scheme="https://www.cjjhc.cn/tags/TypeScript/"/>
    <category term="Vue" scheme="https://www.cjjhc.cn/tags/Vue/"/>
    <category term="前端性能" scheme="https://www.cjjhc.cn/tags/%E5%89%8D%E7%AB%AF%E6%80%A7%E8%83%BD/"/>
    <category term="JavaScript" scheme="https://www.cjjhc.cn/tags/JavaScript/"/>
    <category term="Web-Vitals" scheme="https://www.cjjhc.cn/tags/Web-Vitals/"/>
    <content>
      <![CDATA[<p>Vue 3 页面性能优化最容易走进两个误区：一上来就改代码，或者只盯着打包体积。真正影响用户体验的，可能是首屏图片、串行接口、主线程长任务、列表重复更新，也可能是发布后旧页面加载不到新 chunk。</p><p>本文以一个“商品检索与报表”页面为例，建立从测量、定位、修改到回归的完整流程。示例基于 Vue 3、Vue Router 和 Vite；代码需要按项目版本调整。</p><span id="more"></span><p><a href="/images/tech/diagrams/vue-performance-loop.svg"><img src="/images/tech/diagrams/vue-performance-loop.svg" alt="Vue 页面从固定基线、证据定位、删除无用工作到真实用户监控的性能优化闭环图"></a></p><p><em>图：先测量与定位，再删除不必要工作，最后用真实用户数据和 CI 预算守住收益；点击查看原图。</em></p><h2 id="一、先定义问题，而不是先写优化方案"><a href="#一、先定义问题，而不是先写优化方案" class="headerlink" title="一、先定义问题，而不是先写优化方案"></a>一、先定义问题，而不是先写优化方案</h2><p>假设页面包含：</p><ul><li>顶部筛选区和用户权限；</li><li>约 5,000 条可筛选记录；</li><li>一个低频打开的图表面板；</li><li>首屏商品图和多个后端接口；</li><li>SPA 路由，由 Vite 构建并部署到 CDN。</li></ul><p>用户反馈是“第一次打开慢，输入筛选条件也会卡”。这实际上是两个问题：</p><ol><li><strong>加载性能</strong>：多久看见主要内容，页面何时可以交互；</li><li><strong>更新性能</strong>：点击、输入和切换筛选条件后，下一帧多久出现。</li></ol><p>先写验收表。下面数字是案例目标，不是所有项目的行业标准：</p><table><thead><tr><th>指标</th><th align="right">示例基线</th><th align="right">本轮目标</th><th>数据来源</th></tr></thead><tbody><tr><td>LCP</td><td align="right">4.1 秒</td><td align="right">小于 2.5 秒</td><td>真实用户 P75 + 实验室复测</td></tr><tr><td>INP</td><td align="right">310 毫秒</td><td align="right">小于 200 毫秒</td><td>真实用户 P75</td></tr><tr><td>CLS</td><td align="right">0.18</td><td align="right">小于 0.1</td><td>真实用户 P75</td></tr><tr><td>首路由 JS（gzip）</td><td align="right">420 KB</td><td align="right">小于 260 KB</td><td>生产构建产物</td></tr><tr><td>筛选一次的主线程长任务</td><td align="right">180 毫秒</td><td align="right">不出现超过 50 毫秒的单任务</td><td>Performance 录制</td></tr><tr><td>5,000 行页面 DOM 节点</td><td align="right">25,000+</td><td align="right">只渲染可视窗口附近节点</td><td>Elements + Performance</td></tr></tbody></table><p>Web Vitals 的“良好”边界用于用户体验分层，项目预算还应结合设备、网络、业务价值和当前基线制定。对生产数据通常看第 75 百分位，并区分移动端、桌面端、地区和页面类型。</p><h2 id="二、建立可重复的测量环境"><a href="#二、建立可重复的测量环境" class="headerlink" title="二、建立可重复的测量环境"></a>二、建立可重复的测量环境</h2><h3 id="2-1-不要用开发模式评估首屏"><a href="#2-1-不要用开发模式评估首屏" class="headerlink" title="2.1 不要用开发模式评估首屏"></a>2.1 不要用开发模式评估首屏</h3><p>开发模式包含 HMR、调试信息和不同的模块加载方式。首屏与产物体积应使用生产构建：</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></pre></td><td class="code"><pre><span class="line">npm ci</span><br><span class="line">npm run build</span><br><span class="line">npm run preview -- --host 127.0.0.1</span><br></pre></td></tr></table></figure><p>记录 Node.js、包管理器、锁文件、浏览器版本和提交 SHA。优化前后使用相同设备、网络限速、CPU 限速、账号权限和数据集。</p><p>建议至少测四组：</p><table><thead><tr><th>场景</th><th>缓存</th><th>网络与 CPU</th><th>目的</th></tr></thead><tbody><tr><td>首次访问</td><td>清空</td><td>中端移动设备模拟、弱网</td><td>暴露下载、解析和首屏问题</td></tr><tr><td>二次访问</td><td>保留</td><td>同上</td><td>验证缓存策略</td></tr><tr><td>大数据筛选</td><td>保留</td><td>CPU 限速</td><td>暴露长任务与重复渲染</td></tr><tr><td>真实设备</td><td>自然</td><td>真实 Wi-Fi&#x2F;蜂窝网络</td><td>防止只优化模拟环境</td></tr></tbody></table><p>每个实验室场景至少执行 3～5 次，保存中位数和波动范围。只比较一次 Lighthouse 分数，很容易把网络噪声当成优化收益。</p><h3 id="2-2-用三类证据定位"><a href="#2-2-用三类证据定位" class="headerlink" title="2.2 用三类证据定位"></a>2.2 用三类证据定位</h3><ol><li><strong>Network</strong>：HTML、CSS、JS、图片和接口是否形成串行瀑布；</li><li><strong>Performance</strong>：LCP 元素是什么，长任务花在脚本执行、样式、布局还是绘制；</li><li><strong>Vue DevTools</strong>：哪些组件重复更新，单次渲染成本多大。</li></ol><p>临时启用 Vue 性能标记，便于在浏览器 Performance 时间线中定位组件：</p><figure class="highlight ts"><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">const</span> app = <span class="title function_">createApp</span>(<span class="title class_">App</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (<span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">env</span>.<span class="property">DEV</span> || <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">env</span>.<span class="property">VITE_PROFILE</span> === <span class="string">&#x27;true&#x27;</span>) &#123;</span><br><span class="line">  app.<span class="property">config</span>.<span class="property">performance</span> = <span class="literal">true</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>性能录制要包含一个完整动作，例如“加载页面—输入关键词—切换筛选—打开图表”。只录空闲页面，无法解释交互卡顿。</p><h2 id="三、第一步：先消除入口中的非必要代码"><a href="#三、第一步：先消除入口中的非必要代码" class="headerlink" title="三、第一步：先消除入口中的非必要代码"></a>三、第一步：先消除入口中的非必要代码</h2><h3 id="3-1-路由组件使用动态导入"><a href="#3-1-路由组件使用动态导入" class="headerlink" title="3.1 路由组件使用动态导入"></a>3.1 路由组件使用动态导入</h3><p>不要在路由表顶部静态导入所有页面：</p><figure class="highlight ts"><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">import</span> <span class="keyword">type</span> &#123; <span class="title class_">RouteRecordRaw</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;vue-router&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">const</span> <span class="attr">routes</span>: <span class="title class_">RouteRecordRaw</span>[] = [</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/products&#x27;</span>,</span><br><span class="line">    <span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;../views/ProductListView.vue&#x27;</span>),</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">path</span>: <span class="string">&#x27;/reports&#x27;</span>,</span><br><span class="line">    <span class="attr">component</span>: <span class="function">() =&gt;</span> <span class="keyword">import</span>(<span class="string">&#x27;../views/ReportView.vue&#x27;</span>),</span><br><span class="line">  &#125;,</span><br><span class="line">]</span><br></pre></td></tr></table></figure><p>动态导入会成为构建拆分点。验证时不要只看“chunk 数量增加”，还要看首路由 JS 是否下降、额外请求是否并行、用户跳转时是否出现明显等待。</p><p>拆得过细会增加请求调度和运行时开销。通常先按路由拆，再根据录制结果处理路由内的大型低频功能。</p><h3 id="3-2-低频重组件异步加载"><a href="#3-2-低频重组件异步加载" class="headerlink" title="3.2 低频重组件异步加载"></a>3.2 低频重组件异步加载</h3><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><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">&lt;script setup lang=&quot;ts&quot;&gt;</span><br><span class="line">import &#123; defineAsyncComponent, ref &#125; from &#x27;vue&#x27;</span><br><span class="line">import ChartLoading from &#x27;./ChartLoading.vue&#x27;</span><br><span class="line">import ChartLoadError from &#x27;./ChartLoadError.vue&#x27;</span><br><span class="line"></span><br><span class="line">const opened = ref(false)</span><br><span class="line"></span><br><span class="line">const SalesChart = defineAsyncComponent(&#123;</span><br><span class="line">  loader: () =&gt; import(&#x27;./SalesChart.vue&#x27;),</span><br><span class="line">  loadingComponent: ChartLoading,</span><br><span class="line">  errorComponent: ChartLoadError,</span><br><span class="line">  delay: 150,</span><br><span class="line">  timeout: 10_000,</span><br><span class="line">&#125;)</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;button type=&quot;button&quot; @click=&quot;opened = true&quot;&gt;查看趋势&lt;/button&gt;</span><br><span class="line">  &lt;SalesChart v-if=&quot;opened&quot; /&gt;</span><br><span class="line">&lt;/template&gt;</span><br></pre></td></tr></table></figure><p>必须同时设计加载、失败、重试和超时状态。弱网下只有一个永远旋转的 loading，并不算完成异步化。</p><h3 id="3-3-避免让-tree-shaking-失效"><a href="#3-3-避免让-tree-shaking-失效" class="headerlink" title="3.3 避免让 tree-shaking 失效"></a>3.3 避免让 tree-shaking 失效</h3><p>检查入口中这些常见问题：</p><ul><li>从聚合文件导入一个函数，却把带副作用的整个目录带入；</li><li>全量注册实际只使用少数组件的 UI 库或图标库；</li><li>把大型 JSON、语言包和编辑器插件静态导进入口；</li><li>同一依赖因版本不一致被打入多份；</li><li>为一个简单能力引入体积远大于业务代码的库。</li></ul><p>优先从明确的子路径导入，并确认第三方包提供 ESM 构建：</p><figure class="highlight ts"><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="comment">// 含糊的聚合入口，可能加载更多模块</span></span><br><span class="line"><span class="comment">// import &#123; formatMoney &#125; from &#x27;@/utils&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 职责明确，构建工具也更容易分析</span></span><br><span class="line"><span class="keyword">import</span> &#123; formatMoney &#125; <span class="keyword">from</span> <span class="string">&#x27;@/utils/money&#x27;</span></span><br></pre></td></tr></table></figure><p>不要为了少几 KB 复制第三方库实现。评估体积、维护成本、浏览器兼容和安全更新后再决定。</p><h2 id="四、第二步：压平网络瀑布"><a href="#四、第二步：压平网络瀑布" class="headerlink" title="四、第二步：压平网络瀑布"></a>四、第二步：压平网络瀑布</h2><h3 id="4-1-没有依赖关系的请求并行"><a href="#4-1-没有依赖关系的请求并行" class="headerlink" title="4.1 没有依赖关系的请求并行"></a>4.1 没有依赖关系的请求并行</h3><p>下面写法会让权限请求等待用户请求完成：</p><figure class="highlight ts"><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"><span class="keyword">const</span> profile = <span class="keyword">await</span> <span class="title function_">fetchProfile</span>()</span><br><span class="line"><span class="keyword">const</span> permissions = <span class="keyword">await</span> <span class="title function_">fetchPermissions</span>()</span><br></pre></td></tr></table></figure><p>如果两者没有依赖，应并行启动：</p><figure class="highlight ts"><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">const</span> [profile, permissions] = <span class="keyword">await</span> <span class="title class_">Promise</span>.<span class="title function_">all</span>([</span><br><span class="line">  <span class="title function_">fetchProfile</span>(),</span><br><span class="line">  <span class="title function_">fetchPermissions</span>(),</span><br><span class="line">])</span><br></pre></td></tr></table></figure><p>并行不是越多越好。请求共享限流、数据库连接或依赖前一个响应时，仍需按真实关系编排。目标是删除无意义等待，而不是瞬间压垮后端。</p><h3 id="4-2-为搜索请求取消过期工作"><a href="#4-2-为搜索请求取消过期工作" class="headerlink" title="4.2 为搜索请求取消过期工作"></a>4.2 为搜索请求取消过期工作</h3><p>快速输入可能产生多个并发请求，旧响应还可能覆盖新结果：</p><figure class="highlight ts"><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; ref, watch &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> query = <span class="title function_">ref</span>(<span class="string">&#x27;&#x27;</span>)</span><br><span class="line"><span class="keyword">const</span> rows = ref&lt;<span class="title class_">Product</span>[]&gt;([])</span><br><span class="line"></span><br><span class="line"><span class="title function_">watch</span>(query, <span class="title function_">async</span> (value, _oldValue, onCleanup) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> controller = <span class="keyword">new</span> <span class="title class_">AbortController</span>()</span><br><span class="line">  <span class="title function_">onCleanup</span>(<span class="function">() =&gt;</span> controller.<span class="title function_">abort</span>())</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> response = <span class="keyword">await</span> <span class="title function_">fetch</span>(<span class="string">`/api/products?q=<span class="subst">$&#123;<span class="built_in">encodeURIComponent</span>(value)&#125;</span>`</span>, &#123;</span><br><span class="line">    <span class="attr">signal</span>: controller.<span class="property">signal</span>,</span><br><span class="line">  &#125;)</span><br><span class="line">  <span class="keyword">if</span> (!response.<span class="property">ok</span>) &#123;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">`search failed: <span class="subst">$&#123;response.status&#125;</span>`</span>)</span><br><span class="line">  &#125;</span><br><span class="line">  rows.<span class="property">value</span> = <span class="keyword">await</span> response.<span class="title function_">json</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>生产代码还要区分主动取消和真实错误，增加输入防抖、空值处理和可见错误状态。后端仍需支持幂等、限流和超时，前端取消不能替代服务端保护。</p><h3 id="4-3-首屏图片和非首屏图片采用不同策略"><a href="#4-3-首屏图片和非首屏图片采用不同策略" class="headerlink" title="4.3 首屏图片和非首屏图片采用不同策略"></a>4.3 首屏图片和非首屏图片采用不同策略</h3><p>LCP 图片不要 <code>loading=&quot;lazy&quot;</code>；应给出明确尺寸，防止加载后推开内容：</p><figure class="highlight html"><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="tag">&lt;<span class="name">img</span></span></span><br><span class="line"><span class="tag">  <span class="attr">src</span>=<span class="string">&quot;https://cdn.example.com/product-hero-1280.webp&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">srcset</span>=<span class="string">&quot;</span></span></span><br><span class="line"><span class="string"><span class="tag">    https://cdn.example.com/product-hero-640.webp 640w,</span></span></span><br><span class="line"><span class="string"><span class="tag">    https://cdn.example.com/product-hero-960.webp 960w,</span></span></span><br><span class="line"><span class="string"><span class="tag">    https://cdn.example.com/product-hero-1280.webp 1280w</span></span></span><br><span class="line"><span class="string"><span class="tag">  &quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">sizes</span>=<span class="string">&quot;(max-width: 720px) 100vw, 960px&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">width</span>=<span class="string">&quot;1280&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">height</span>=<span class="string">&quot;720&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">fetchpriority</span>=<span class="string">&quot;high&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">decoding</span>=<span class="string">&quot;async&quot;</span></span></span><br><span class="line"><span class="tag">  <span class="attr">alt</span>=<span class="string">&quot;本周推荐商品&quot;</span></span></span><br><span class="line"><span class="tag">&gt;</span></span><br></pre></td></tr></table></figure><p>非首屏图片再使用 <code>loading=&quot;lazy&quot;</code>。还要检查 CDN 缓存、图片编码、响应尺寸和 alt 文本，不能只把原始 4K 图片换个 WebP 后缀。</p><h3 id="4-4-缓存策略要与发布方式配套"><a href="#4-4-缓存策略要与发布方式配套" class="headerlink" title="4.4 缓存策略要与发布方式配套"></a>4.4 缓存策略要与发布方式配套</h3><p>Vite 产物通常带内容哈希，可以长期缓存：</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></pre></td><td class="code"><pre><span class="line">/assets/*.js  Cache-Control: public, max-age=31536000, immutable</span><br><span class="line">/assets/*.css Cache-Control: public, max-age=31536000, immutable</span><br><span class="line">/index.html   Cache-Control: no-cache</span><br></pre></td></tr></table></figure><p>HTML 需要及时重新验证，哈希资源则应保留足够长时间。如果发布时立即删除旧 chunk，仍停留在旧 HTML 的用户可能遇到动态导入失败。更稳妥的做法是保留旧资源一段时间，并监控 <code>vite:preloadError</code>。</p><p>需要自动刷新时，必须限制同一版本只刷新一次，避免缓存或网络故障导致无限循环：</p><figure class="highlight ts"><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="variable language_">window</span>.<span class="title function_">addEventListener</span>(<span class="string">&#x27;vite:preloadError&#x27;</span>, <span class="function">(<span class="params">event</span>) =&gt;</span> &#123;</span><br><span class="line">  event.<span class="title function_">preventDefault</span>()</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> key = <span class="string">`preload-reloaded:<span class="subst">$&#123;__APP_VERSION__&#125;</span>`</span></span><br><span class="line">  <span class="keyword">if</span> (<span class="variable language_">sessionStorage</span>.<span class="title function_">getItem</span>(key) === <span class="string">&#x27;1&#x27;</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span></span><br><span class="line">  &#125;</span><br><span class="line">  <span class="variable language_">sessionStorage</span>.<span class="title function_">setItem</span>(key, <span class="string">&#x27;1&#x27;</span>)</span><br><span class="line">  <span class="variable language_">window</span>.<span class="property">location</span>.<span class="title function_">reload</span>()</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><code>__APP_VERSION__</code> 可在构建时注入。刷新前应上报错误，发布平台应继续保留旧静态资源；刷新只是兜底，不是资产生命周期策略。</p><h2 id="五、第三步：减少不必要的组件更新"><a href="#五、第三步：减少不必要的组件更新" class="headerlink" title="五、第三步：减少不必要的组件更新"></a>五、第三步：减少不必要的组件更新</h2><h3 id="5-1-让子组件-props-保持稳定"><a href="#5-1-让子组件-props-保持稳定" class="headerlink" title="5.1 让子组件 props 保持稳定"></a>5.1 让子组件 props 保持稳定</h3><p>下面的每一行都接收 <code>activeId</code>，选中项改变时所有行都会收到变化：</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></pre></td><td class="code"><pre><span class="line">&lt;ProductRow</span><br><span class="line">  v-for=&quot;item in products&quot;</span><br><span class="line">  :key=&quot;item.id&quot;</span><br><span class="line">  :item=&quot;item&quot;</span><br><span class="line">  :active-id=&quot;activeId&quot;</span><br><span class="line">/&gt;</span><br></pre></td></tr></table></figure><p>在父层先计算布尔值，大多数行的 <code>active</code> 不会变化：</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></pre></td><td class="code"><pre><span class="line">&lt;ProductRow</span><br><span class="line">  v-for=&quot;item in products&quot;</span><br><span class="line">  :key=&quot;item.id&quot;</span><br><span class="line">  :item=&quot;item&quot;</span><br><span class="line">  :active=&quot;item.id === activeId&quot;</span><br><span class="line">/&gt;</span><br></pre></td></tr></table></figure><p>同时避免每次渲染都在模板中创建新对象或新数组作为 prop。稳定引用能减少下游计算和更新，但不要为了引用稳定引入可变全局状态。</p><h3 id="5-2-computed-不要无条件返回新对象"><a href="#5-2-computed-不要无条件返回新对象" class="headerlink" title="5.2 computed 不要无条件返回新对象"></a>5.2 computed 不要无条件返回新对象</h3><p>Vue 3.4 及以上能在 computed 值不变时减少 effect 触发，但每次返回新对象会让引用始终不同：</p><figure class="highlight ts"><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">// 每次计算都创建新对象</span></span><br><span class="line"><span class="keyword">const</span> summary = <span class="title function_">computed</span>(<span class="function">() =&gt;</span> (&#123;</span><br><span class="line">  <span class="attr">hasResult</span>: rows.<span class="property">value</span>.<span class="property">length</span> &gt; <span class="number">0</span>,</span><br><span class="line">&#125;))</span><br></pre></td></tr></table></figure><p>如果调用方只需要一个布尔值，就直接返回稳定原始值：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> hasResult = <span class="title function_">computed</span>(<span class="function">() =&gt;</span> rows.<span class="property">value</span>.<span class="property">length</span> &gt; <span class="number">0</span>)</span><br></pre></td></tr></table></figure><p>不要一看到 computed 就手工缓存旧对象。先用性能录制证明它位于热点路径，并保证依赖在每次计算中完整收集。</p><h3 id="5-3-大型只读数据使用-shallowRef"><a href="#5-3-大型只读数据使用-shallowRef" class="headerlink" title="5.3 大型只读数据使用 shallowRef"></a>5.3 大型只读数据使用 shallowRef</h3><p>Vue 响应式默认是深层的。对于从服务端获取、后续按不可变方式替换的大数组，可以只跟踪根引用：</p><figure class="highlight ts"><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123; shallowRef &#125; <span class="keyword">from</span> <span class="string">&#x27;vue&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Product</span> = <span class="title class_">Readonly</span>&lt;&#123;</span><br><span class="line">  <span class="attr">id</span>: <span class="built_in">string</span></span><br><span class="line">  <span class="attr">name</span>: <span class="built_in">string</span></span><br><span class="line">  <span class="attr">priceCent</span>: <span class="built_in">number</span></span><br><span class="line">&#125;&gt;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> products = shallowRef&lt;<span class="keyword">readonly</span> <span class="title class_">Product</span>[]&gt;([])</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">reload</span>(<span class="params"></span>): <span class="title class_">Promise</span>&lt;<span class="built_in">void</span>&gt; &#123;</span><br><span class="line">  products.<span class="property">value</span> = <span class="keyword">await</span> <span class="title function_">fetchProducts</span>()</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">append</span>(<span class="params"><span class="attr">product</span>: <span class="title class_">Product</span></span>): <span class="built_in">void</span> &#123;</span><br><span class="line">  products.<span class="property">value</span> = [...products.<span class="property">value</span>, product]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>下面的深层修改不会触发更新，也违背了约定：</p><figure class="highlight ts"><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"><span class="comment">// 不要这样修改 shallowRef 内部数据</span></span><br><span class="line"><span class="comment">// products.value[0].name = &#x27;new name&#x27;</span></span><br></pre></td></tr></table></figure><p><code>shallowRef</code> 只适合大型、可按不可变方式处理的数据。普通表单和需要细粒度编辑的对象继续使用常规响应式 API 更清晰。</p><h3 id="5-4-谨慎使用-v-once-和-v-memo"><a href="#5-4-谨慎使用-v-once-和-v-memo" class="headerlink" title="5.4 谨慎使用 v-once 和 v-memo"></a>5.4 谨慎使用 v-once 和 v-memo</h3><p>完全不会变化的子树可使用 <code>v-once</code>。大列表中，确认依赖非常明确时可以使用 <code>v-memo</code>：</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></pre></td><td class="code"><pre><span class="line">&lt;ProductRow</span><br><span class="line">  v-for=&quot;item in products&quot;</span><br><span class="line">  :key=&quot;item.id&quot;</span><br><span class="line">  v-memo=&quot;[item.version, item.id === activeId]&quot;</span><br><span class="line">  :item=&quot;item&quot;</span><br><span class="line">  :active=&quot;item.id === activeId&quot;</span><br><span class="line">/&gt;</span><br></pre></td></tr></table></figure><p>如果忘记把影响 UI 的值加入依赖数组，页面会显示过期内容。先稳定 props、减少数据量，再考虑 <code>v-memo</code>；它不是修复错误状态设计的快捷方式。</p><h2 id="六、第四步：大列表只渲染用户看得见的部分"><a href="#六、第四步：大列表只渲染用户看得见的部分" class="headerlink" title="六、第四步：大列表只渲染用户看得见的部分"></a>六、第四步：大列表只渲染用户看得见的部分</h2><p>5,000 行即使数据获取很快，把每行十几个节点全部放进 DOM，也会增加创建、样式计算、布局和更新成本。</p><p>虚拟列表只渲染可视窗口和少量缓冲项。选型时验证：</p><ul><li>行高固定还是动态；</li><li>键盘导航、焦点和读屏是否正常；</li><li>滚动到指定项、展开行和选中状态是否正确；</li><li>容器缩放、窗口变化和移动端是否稳定；</li><li>服务端渲染或 hydration 是否支持；</li><li>数据追加后是否保持当前位置。</li></ul><p>虚拟列表不能替代后端分页。用户不需要一次下载几十万条记录时，应先分页、筛选和裁剪字段，再对单页的长列表做虚拟化。</p><p>优化后使用 Performance 和 Elements 面板确认 DOM 数量随可视区域变化，而不是仍然渲染全部节点后仅用 CSS 隐藏。</p><h2 id="七、第五步：把重计算移出关键交互"><a href="#七、第五步：把重计算移出关键交互" class="headerlink" title="七、第五步：把重计算移出关键交互"></a>七、第五步：把重计算移出关键交互</h2><h3 id="7-1-找到长任务内部真正昂贵的函数"><a href="#7-1-找到长任务内部真正昂贵的函数" class="headerlink" title="7.1 找到长任务内部真正昂贵的函数"></a>7.1 找到长任务内部真正昂贵的函数</h3><p>在 Performance 火焰图中区分：</p><ul><li>JavaScript 计算；</li><li>Vue patch 和组件更新；</li><li>Recalculate Style &#x2F; Layout；</li><li>图片解码与 Paint；</li><li>第三方脚本。</li></ul><p>如果筛选函数本身占用 150 毫秒，拆组件不会解决问题。先减少算法复杂度、预建索引、避免重复格式化，再考虑 Web Worker。</p><h3 id="7-2-CPU-密集型纯计算使用-Worker"><a href="#7-2-CPU-密集型纯计算使用-Worker" class="headerlink" title="7.2 CPU 密集型纯计算使用 Worker"></a>7.2 CPU 密集型纯计算使用 Worker</h3><figure class="highlight ts"><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="keyword">const</span> worker = <span class="keyword">new</span> <span class="title class_">Worker</span>(</span><br><span class="line">  <span class="keyword">new</span> <span class="title function_">URL</span>(<span class="string">&#x27;./product-search.worker.ts&#x27;</span>, <span class="keyword">import</span>.<span class="property">meta</span>.<span class="property">url</span>),</span><br><span class="line">  &#123; <span class="attr">type</span>: <span class="string">&#x27;module&#x27;</span> &#125;,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line">worker.<span class="title function_">postMessage</span>(&#123;</span><br><span class="line">  <span class="attr">requestId</span>: crypto.<span class="title function_">randomUUID</span>(),</span><br><span class="line">  <span class="attr">query</span>: query.<span class="property">value</span>,</span><br><span class="line">  <span class="attr">rows</span>: products.<span class="property">value</span>,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Worker 适合可序列化的 CPU 计算，不能直接操作 DOM。传输巨大对象也有复制成本；可以只传必要字段、使用 Transferable，或把索引长期保存在 Worker 内。</p><p>请求要携带 <code>requestId</code>，只接受最后一次查询的结果。组件卸载时终止 Worker 或移除监听，避免内存泄漏。</p><h3 id="7-3-第三方脚本延后并隔离"><a href="#7-3-第三方脚本延后并隔离" class="headerlink" title="7.3 第三方脚本延后并隔离"></a>7.3 第三方脚本延后并隔离</h3><p>埋点、客服、A&#x2F;B 测试和广告脚本经常占用首屏主线程。逐个记录其下载和执行成本：</p><ul><li>是否必须在首屏加载；</li><li>是否能在用户同意后或空闲阶段加载；</li><li>失败是否会阻塞业务；</li><li>是否有超时、版本和数据合规要求。</li></ul><p>不能修改的第三方脚本也要纳入性能预算。否则业务代码优化出的 100 毫秒，很快会被新增脚本吃掉。</p><h2 id="八、架构层优化：SPA-不一定适合所有页面"><a href="#八、架构层优化：SPA-不一定适合所有页面" class="headerlink" title="八、架构层优化：SPA 不一定适合所有页面"></a>八、架构层优化：SPA 不一定适合所有页面</h2><p>如果页面以营销内容、文档或商品详情为主，首屏内容和 SEO 很重要，可以评估 SSR 或 SSG；高度交互、登录后的后台页面则可能更适合 SPA。</p><p>选择架构前回答：</p><ul><li>首屏是否必须等待 JS 才出现主要内容；</li><li>页面是否需要搜索引擎抓取；</li><li>个性化是否导致服务器缓存命中率很低；</li><li>团队能否承担 SSR 缓存、hydration、监控和部署复杂度；</li><li>是否可以只对部分路由预渲染。</li></ul><p>SSR 能改善内容到达时间，但不会自动减少 hydration 的 JS 和主线程成本。架构变更要基于性能档案和业务目标，不应作为局部卡顿的第一反应。</p><h2 id="九、建立真实用户监控"><a href="#九、建立真实用户监控" class="headerlink" title="九、建立真实用户监控"></a>九、建立真实用户监控</h2><p>实验室能复现问题，RUM 才能说明真实用户是否受益。可使用 <code>web-vitals</code> 采集核心指标：</p><figure class="highlight ts"><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">import</span> &#123; onCLS, onINP, onLCP, <span class="keyword">type</span> <span class="title class_">Metric</span> &#125; <span class="keyword">from</span> <span class="string">&#x27;web-vitals&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">report</span>(<span class="params"><span class="attr">metric</span>: <span class="title class_">Metric</span></span>): <span class="built_in">void</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> body = <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123;</span><br><span class="line">    <span class="attr">name</span>: metric.<span class="property">name</span>,</span><br><span class="line">    <span class="attr">value</span>: metric.<span class="property">value</span>,</span><br><span class="line">    <span class="attr">rating</span>: metric.<span class="property">rating</span>,</span><br><span class="line">    <span class="attr">navigationType</span>: metric.<span class="property">navigationType</span>,</span><br><span class="line">    <span class="attr">route</span>: <span class="variable language_">window</span>.<span class="property">location</span>.<span class="property">pathname</span>,</span><br><span class="line">    <span class="attr">release</span>: __APP_VERSION__,</span><br><span class="line">  &#125;)</span><br><span class="line"></span><br><span class="line">  navigator.<span class="title function_">sendBeacon</span>(<span class="string">&#x27;/rum&#x27;</span>, body)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="title function_">onCLS</span>(report)</span><br><span class="line"><span class="title function_">onINP</span>(report)</span><br><span class="line"><span class="title function_">onLCP</span>(report)</span><br></pre></td></tr></table></figure><p>采集时避免 URL 查询参数、输入内容、用户标识等敏感信息。服务端做采样、限流和字段校验，并按版本、路由、设备和网络类型查看 P75。</p><p>一次优化发布后，至少比较：</p><ul><li>新旧版本样本量是否足够；</li><li>P50、P75、P95 是否一致改善；</li><li>移动端是否受益，桌面端是否退化；</li><li>错误率、白屏率和动态导入失败是否增加；</li><li>业务转化或任务完成率是否受影响。</li></ul><h2 id="十、把性能预算放进-CI"><a href="#十、把性能预算放进-CI" class="headerlink" title="十、把性能预算放进 CI"></a>十、把性能预算放进 CI</h2><p>首先保存构建产物清单：</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></pre></td><td class="code"><pre><span class="line">npm ci</span><br><span class="line">npm run build</span><br><span class="line">find dist/assets -<span class="built_in">type</span> f -<span class="built_in">printf</span> <span class="string">&#x27;%s %p\n&#x27;</span> | <span class="built_in">sort</span> -nr | <span class="built_in">head</span> -20</span><br></pre></td></tr></table></figure><p><code>find -printf</code> 适用于常见 Linux CI 环境。更完整的门禁应读取构建 manifest 或分析报告，对首路由初始 JS、CSS、最大异步 chunk、图片和字体分别设置预算。</p><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></pre></td><td class="code"><pre><span class="line">首路由初始 JS（gzip） &lt;= 260 KB</span><br><span class="line">单个异步 JS（gzip）   &lt;= 180 KB</span><br><span class="line">首屏 CSS（gzip）       &lt;= 60 KB</span><br><span class="line">单张首屏图片           &lt;= 180 KB</span><br></pre></td></tr></table></figure><p>这些数字只是案例。CI 需要同时输出“当前值、预算、相对主分支增量和最大贡献模块”，否则开发者只知道失败，不知道从哪里修。</p><p>不要让 gzip 体积成为唯一门禁。相同体积的 JavaScript，解析与执行成本可能不同；Web Vitals 和关键交互性能仍需在预览环境或真实用户数据中验证。</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></pre></td><td class="code"><pre><span class="line">【PERF-231】- 记录商品列表页性能基线与预算</span><br><span class="line">【PERF-231】- 按路由延迟加载报表与图表组件</span><br><span class="line">【PERF-231】- 并行用户信息与权限请求</span><br><span class="line">【PERF-231】- 使用虚拟列表降低商品行 DOM 数量</span><br><span class="line">【PERF-231】- 接入 LCP、INP 与 CLS 真实用户监控</span><br></pre></td></tr></table></figure><p>每个提交都记录同一测试矩阵下的前后对比。不要在一个提交中同时升级 Vue、替换 UI 库、重写状态管理和做性能优化，否则无法判断收益来自哪里，也很难安全回滚。</p><h3 id="推荐实施顺序"><a href="#推荐实施顺序" class="headerlink" title="推荐实施顺序"></a>推荐实施顺序</h3><ol><li>固定场景、指标、设备和数据集；</li><li>用 Network 与 Performance 找最大瓶颈；</li><li>先删除无用工作，再做路由级拆包；</li><li>压平独立接口和资源瀑布；</li><li>稳定 props、computed 和大型数据引用；</li><li>对真正的大列表做分页与虚拟化；</li><li>将确认过的 CPU 热点移入 Worker；</li><li>配置 HTML 与哈希资源缓存；</li><li>接入 RUM，灰度观察后再扩大流量；</li><li>把产物预算和回归场景加入 CI。</li></ol><h2 id="十二、常见“负优化”"><a href="#十二、常见“负优化”" class="headerlink" title="十二、常见“负优化”"></a>十二、常见“负优化”</h2><table><thead><tr><th>做法</th><th>为什么可能变慢</th><th>如何验证</th></tr></thead><tbody><tr><td>把每个组件都异步化</td><td>产生碎片请求和加载闪烁</td><td>对比首路由瀑布和交互等待</td></tr><tr><td>所有数据都改成 shallowRef</td><td>深层修改不再触发更新，状态约束变复杂</td><td>检查数据是否真正不可变</td></tr><tr><td>到处添加 v-memo</td><td>依赖遗漏导致过期 UI，维护成本上升</td><td>用组件测试覆盖所有依赖变化</td></tr><tr><td>只看 bundle 总大小</td><td>无法反映路由、解析、执行和缓存</td><td>同时看首路由、长任务和 RUM</td></tr><tr><td>LCP 图片懒加载</td><td>浏览器更晚才开始下载最大内容</td><td>查看 LCP 资源请求起点</td></tr><tr><td>发布后立刻删除旧 chunk</td><td>旧页面动态导入失败</td><td>监控 preload error 与白屏</td></tr><tr><td>为了分数删除可访问性能力</td><td>用户无法完成操作</td><td>键盘、读屏和真实任务回归</td></tr><tr><td>用 SSR 解决所有问题</td><td>hydration 和运维成本仍在</td><td>分开测服务响应、内容绘制和交互</td></tr></tbody></table><h2 id="十三、上线验收清单"><a href="#十三、上线验收清单" class="headerlink" title="十三、上线验收清单"></a>十三、上线验收清单</h2><h3 id="加载性能"><a href="#加载性能" class="headerlink" title="加载性能"></a>加载性能</h3><ul><li><input disabled="" type="checkbox"> 生产构建、固定网络和 CPU 条件下完成至少三次复测；</li><li><input disabled="" type="checkbox"> 首路由只加载当前页面必需代码；</li><li><input disabled="" type="checkbox"> 独立接口没有无意义串行等待；</li><li><input disabled="" type="checkbox"> LCP 图片尺寸正确、优先加载且未使用 lazy；</li><li><input disabled="" type="checkbox"> HTML 与带哈希静态资源采用不同缓存策略；</li><li><input disabled="" type="checkbox"> 旧 chunk 在滚动发布和观察窗口内仍可访问。</li></ul><h3 id="更新性能"><a href="#更新性能" class="headerlink" title="更新性能"></a>更新性能</h3><ul><li><input disabled="" type="checkbox"> props 在列表热点路径中保持稳定；</li><li><input disabled="" type="checkbox"> computed 不无条件制造新对象；</li><li><input disabled="" type="checkbox"> 大型只读数据才使用 shallowRef；</li><li><input disabled="" type="checkbox"> 长列表使用后端分页或虚拟化；</li><li><input disabled="" type="checkbox"> 搜索和切换操作可以取消过期请求；</li><li><input disabled="" type="checkbox"> 长任务已定位到具体函数并有前后录制。</li></ul><h3 id="发布质量"><a href="#发布质量" class="headerlink" title="发布质量"></a>发布质量</h3><ul><li><input disabled="" type="checkbox"> 首路由 JS、CSS、图片和字体符合项目预算；</li><li><input disabled="" type="checkbox"> 弱网、真实移动设备、键盘和读屏完成回归；</li><li><input disabled="" type="checkbox"> 动态导入存在加载失败和有限重试策略；</li><li><input disabled="" type="checkbox"> RUM 能按路由与发布版本观察 LCP、INP、CLS；</li><li><input disabled="" type="checkbox"> 错误率、白屏率和业务成功率没有退化；</li><li><input disabled="" type="checkbox"> 每个优化提交都能独立回退。</li></ul><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>Vue 3 性能优化不是“套用懒加载、缓存和虚拟列表”，而是一个证据闭环：先用相同条件复现，用网络瀑布和主线程录制定位，再删除不必要的下载、计算和 DOM，最后用真实用户指标确认收益。</p><p>最值得优先做的通常不是复杂技巧，而是路由边界清晰、请求依赖正确、首屏资源优先级合理、状态引用稳定、长列表不全量渲染，以及发布时不让旧页面失去 chunk。把这些约束写进性能预算和 CI，优化才能在后续需求中保持住。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://vuejs.org/guide/best-practices/performance">Vue 官方：Performance Best Practices</a></li><li><a href="https://vuejs.org/guide/components/async">Vue 官方：Async Components</a></li><li><a href="https://vuejs.org/api/reactivity-advanced.html">Vue 官方：Reactivity API - Advanced</a></li><li><a href="https://vite.dev/guide/build.html">Vite 官方：Building for Production</a></li><li><a href="https://vite.dev/guide/performance.html">Vite 官方：Performance</a></li><li><a href="https://web.dev/articles/vitals">web.dev：Web Vitals</a></li><li><a href="https://github.com/GoogleChrome/web-vitals">web-vitals 开源库</a></li></ul><blockquote><p>资料复核日期：2026-08-22。示例预算和性能数据用于说明方法，不代表你的项目现状；实施前应锁定 Vue、Vite、浏览器和构建环境版本，并以真实用户数据与同条件复测为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/09/vue-performance-guide/</id>
    <link href="https://www.cjjhc.cn/2026/08/09/vue-performance-guide/"/>
    <published>2026-08-09T01:00:00.000Z</published>
    <summary>用一个 Vue 3 列表页案例贯穿测量、拆包、网络瀑布、响应式开销、大列表、缓存和真实用户监控，形成可复用的优化闭环。</summary>
    <title>Vue 3 页面性能优化实战</title>
    <updated>2026-08-09T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="数据库" scheme="https://www.cjjhc.cn/categories/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    <category term="备份" scheme="https://www.cjjhc.cn/tags/%E5%A4%87%E4%BB%BD/"/>
    <category term="灾难恢复" scheme="https://www.cjjhc.cn/tags/%E7%81%BE%E9%9A%BE%E6%81%A2%E5%A4%8D/"/>
    <category term="数据库" scheme="https://www.cjjhc.cn/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    <content>
      <![CDATA[<p>只有真正恢复过的备份才值得信任。</p><span id="more"></span><p><a href="/images/tech/diagrams/database-backup-restore.svg"><img src="/images/tech/diagrams/database-backup-restore.svg" alt="主数据库通过基础备份和事务日志恢复到目标时间点并完成验证的流程图"></a></p><p><em>图：备份文件存在不等于满足 RPO&#x2F;RTO，隔离恢复和业务验证才形成闭环；点击查看原图。</em></p><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>定义 RPO 与 RTO 后选择全量、增量和日志备份组合。备份文件加密、异地保存，并限制读取权限。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>定期在隔离环境恢复，校验表数量、关键数据与应用可用性。记录耗时与缺口，把演练结果转化为自动化步骤。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>先定义 RPO&#x2F;RTO，再组合全量、增量和日志备份。备份加密、异地保存并记录校验和；恢复演练使用隔离环境和明确时间点。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>恢复后不仅检查任务成功，还要验证表数量、关键业务记录、约束、账号权限和应用读写。记录实际耗时，任何超过 RTO 的步骤都要自动化或优化。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“证明数据库备份真的能恢复”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>明确 RPO&#x2F;RTO，备份数据、结构和恢复所需配置；备份加密并存放在故障域之外，定期验证校验值。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>在隔离环境恢复全量备份并追加日志到目标时间，抽查行数、约束和关键业务查询。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>记录恢复耗时、可恢复时间点与失败步骤；演练不连接生产写端，测试数据按计划销毁。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 备份清单、校验摘要、恢复时间线和抽样查询。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>在隔离实例完整恢复，并把恢复结束时间与目标 RTO/RPO 对比</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：从目标倒推备份方案"><a href="#完整实战：从目标倒推备份方案" class="headerlink" title="完整实战：从目标倒推备份方案"></a>完整实战：从目标倒推备份方案</h2><p>先定义业务目标：例如订单库允许最多丢失 5 分钟数据（RPO），故障确认后 60 分钟内恢复关键查询（RTO）。每日全量备份不能满足 5 分钟 RPO，因此还需连续归档日志；只有备份文件，没有恢复时间预算，也不能证明满足 RTO。</p><table><thead><tr><th>资产</th><th>方式</th><th>保留</th><th>恢复用途</th></tr></thead><tbody><tr><td>基础备份</td><td>每日全量&#x2F;物理快照</td><td>近期多份</td><td>建立恢复基线</td></tr><tr><td>事务日志</td><td>连续归档</td><td>覆盖目标窗口</td><td>时间点恢复</td></tr><tr><td>配置与 schema</td><td>版本控制 + 导出</td><td>随发布</td><td>重建运行条件</td></tr><tr><td>密钥元数据</td><td>独立受控备份</td><td>与数据匹配</td><td>解密与审计</td></tr></tbody></table><p>备份至少跨故障域保存一份，使用独立身份与不可变&#x2F;受保护保留策略。加密密钥不能只存在于被备份的同一系统，也不能把真实密钥写进演练文档。</p><h3 id="隔离恢复演练"><a href="#隔离恢复演练" class="headerlink" title="隔离恢复演练"></a>隔离恢复演练</h3><ol><li>从备份目录随机选择一个恢复点，不让执行者只挑“已知可用”的最新文件。</li><li>在隔离网络创建新实例，恢复基础备份并重放到指定时间。</li><li>用只读校验核对 schema、关键表行数、抽样业务对象和校验和。</li><li>启动最小应用流量，验证登录、查询订单和对账任务。</li><li>记录实际 RPO&#x2F;RTO、人工步骤、缺失依赖和失败点，最后销毁演练数据。</li></ol><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></pre></td><td class="code"><pre><span class="line">restore_id: drill-2026-08</span><br><span class="line">requested_point: 2026-08-21T02:35:00Z</span><br><span class="line">last_replayed_point: 2026-08-21T02:34:42Z</span><br><span class="line">measured_rpo: 18s</span><br><span class="line">measured_rto: 43m</span><br><span class="line">validation: schema=pass, sampled_orders=pass, app_smoke=pass</span><br></pre></td></tr></table></figure><h3 id="不只验证“数据库能启动”"><a href="#不只验证“数据库能启动”" class="headerlink" title="不只验证“数据库能启动”"></a>不只验证“数据库能启动”</h3><p>检查外键与索引、字符集&#x2F;排序规则、账号权限、扩展、定时任务、对象存储引用和应用兼容性。故意注入损坏备份、缺少日志段、错误密钥和容量不足，确认流程会停止并给出可行动原因。</p><p>生产恢复涉及流量切换和数据权威源选择，必须有明确指挥者。原实例和恢复实例都发生写入后不能简单双向合并；先冻结写入、决定权威时间线并保全证据。每次演练形成带负责人和截止日期的改进项，连续多次达到 RPO&#x2F;RTO 才能把方案视为可靠。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.postgresql.org/docs/current/backup.html">PostgreSQL 备份与恢复</a></li><li><a href="https://www.postgresql.org/docs/current/continuous-archiving.html">PostgreSQL PITR</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/08/database-backup-drill/</id>
    <link href="https://www.cjjhc.cn/2026/08/08/database-backup-drill/"/>
    <published>2026-08-08T01:00:00.000Z</published>
    <summary>只有真正恢复过的备份才值得信任。</summary>
    <title>数据库备份恢复演练</title>
    <updated>2026-08-08T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="数据库" scheme="https://www.cjjhc.cn/categories/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    <category term="架构" scheme="https://www.cjjhc.cn/tags/%E6%9E%B6%E6%9E%84/"/>
    <category term="Redis" scheme="https://www.cjjhc.cn/tags/Redis/"/>
    <category term="缓存" scheme="https://www.cjjhc.cn/tags/%E7%BC%93%E5%AD%98/"/>
    <content>
      <![CDATA[<p>选择旁路缓存、写穿或延迟双删时，明确一致性边界。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>缓存键包含业务版本，设置带随机抖动的过期时间防止雪崩。热点空值可短暂缓存，极热键考虑本地缓存和请求合并。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>不要把 Redis 当成无容量上限的数据库。监控内存、淘汰、命中率和大键，设计降级路径以应对缓存不可用。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>缓存键包含业务与 schema 版本，TTL 加随机抖动；空结果短暂缓存防穿透，热点回源使用请求合并。写路径明确允许的短暂不一致窗口。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>监控命中率、内存、evicted_keys、过期和大键。演练 Redis 不可用时应用能限流或回源，禁止缓存故障直接拖垮数据库。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为热点商品详情增加缓存”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>定义 key 版本、TTL 抖动、空值缓存和失效责任；读路径允许回源，写路径选择旁路或事件失效并记录延迟。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>模拟击穿、穿透、雪崩、Redis 超时和旧事件乱序，确认数据库不会被瞬时打满。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察命中率、回源 QPS 和陈旧窗口；异常时关闭缓存读但保留数据库保护与限流。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> Key 规范、TTL 策略、回源保护和失效事件。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>redis-cli --scan --pattern &quot;cache:*&quot; | head; redis-cli TTL cache:sample</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：商品详情-Cache-Aside"><a href="#完整实战：商品详情-Cache-Aside" class="headerlink" title="完整实战：商品详情 Cache-Aside"></a>完整实战：商品详情 Cache-Aside</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></pre></td><td class="code"><pre><span class="line">key = product:&#123;tenant&#125;:&#123;product_id&#125;:v3</span><br><span class="line">value = &#123;schema_version, product, source_updated_at&#125;</span><br><span class="line">ttl = base_ttl + random_jitter</span><br></pre></td></tr></table></figure><p>键包含租户和 schema 版本，TTL 加抖动避免同批键同时过期。值中保留来源版本，便于诊断陈旧数据。不能把权限判断结果按错误维度共享，也不能让缓存命中绕过当前用户鉴权。</p><h3 id="防击穿、穿透与雪崩"><a href="#防击穿、穿透与雪崩" class="headerlink" title="防击穿、穿透与雪崩"></a>防击穿、穿透与雪崩</h3><table><thead><tr><th>风险</th><th>表现</th><th>处理</th></tr></thead><tbody><tr><td>热点击穿</td><td>一个热键过期，大量请求回源</td><td>singleflight&#x2F;短锁 + 过期数据兜底</td></tr><tr><td>缓存穿透</td><td>不存在 ID 持续回源</td><td>输入校验、短 TTL 空值、限流</td></tr><tr><td>集中过期</td><td>数据库瞬间升载</td><td>TTL 抖动、分批预热、容量保护</td></tr><tr><td>Redis 故障</td><td>所有请求直接冲击数据库</td><td>回源并发上限、熔断、负载削减</td></tr></tbody></table><p>分布式锁必须有过期和持有者 token，只允许持有者释放；即使有锁，数据库仍需承受锁服务失效。对强一致余额、权限撤销等数据，不要用普通 cache-aside 假装强一致。</p><h3 id="验证缓存是否真的有收益"><a href="#验证缓存是否真的有收益" class="headerlink" title="验证缓存是否真的有收益"></a>验证缓存是否真的有收益</h3><p>同时看命中率、按 keyspace 的 P95、回源 QPS、过期&#x2F;淘汰、内存碎片、热键分布和数据库负载。高命中率也可能掩盖序列化很慢或数据长期陈旧。测试冷启动、热点键、Redis 超时、批量过期和数据库降速，确认回源有界且不会形成重试风暴。</p><p>上线先给少量租户启用，保留绕过缓存的开关。回滚时关闭读缓存并停止填充即可，不要为了“清干净”对共享 Redis 执行宽泛删除；旧版本键依靠命名空间和 TTL 自然退出。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://redis.io/docs/latest/develop/use-cases/cache-aside/">Redis Cache-Aside</a></li><li><a href="https://redis.io/docs/latest/develop/clients/client-side-caching/">Redis 客户端缓存</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/07/redis-cache-patterns/</id>
    <link href="https://www.cjjhc.cn/2026/08/07/redis-cache-patterns/"/>
    <published>2026-08-07T01:00:00.000Z</published>
    <summary>选择旁路缓存、写穿或延迟双删时，明确一致性边界。</summary>
    <title>Redis 缓存模式与常见陷阱</title>
    <updated>2026-08-07T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="数据库" scheme="https://www.cjjhc.cn/categories/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    <category term="性能优化" scheme="https://www.cjjhc.cn/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    <category term="MySQL" scheme="https://www.cjjhc.cn/tags/MySQL/"/>
    <category term="SQL" scheme="https://www.cjjhc.cn/tags/SQL/"/>
    <content>
      <![CDATA[<p>把慢日志、执行计划和数据分布结合起来定位瓶颈。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>先确认慢是偶发还是稳定，再查看扫描行数、返回行数和锁等待。避免在索引列上做隐式类型转换或函数运算。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>优化目标不只是单次查询更快，也要降低并发下的资源消耗。调整 SQL 或索引后应回归写入性能与边界结果。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>启用受控慢日志并按总耗时、平均耗时和扫描行数排序。使用 <code>EXPLAIN ANALYZE</code> 检查访问类型、实际行数、回表与排序，确认字段类型避免隐式转换。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>在副本或压测环境验证新索引对写入和磁盘的影响。线上变更观察锁等待、Buffer Pool 和复制延迟，保留旧索引到稳定期结束。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“处理高峰期出现的 MySQL 慢查询”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>从慢日志确认指纹与频率，用 EXPLAIN ANALYZE 检查访问路径；先减少扫描与返回列，再考虑索引和分页方式。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>用接近生产的数据量测试选择性差、参数极端和锁等待，避免小表上优化得出错误结论。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察 P95、rows examined、锁等待和写放大；回滚索引或 SQL 前确认兼容旧应用版本。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 慢查询指纹、真实执行计划和数据分布说明。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>EXPLAIN ANALYZE SELECT ...;</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：订单列表从-1-8-秒降到预算内"><a href="#完整实战：订单列表从-1-8-秒降到预算内" class="headerlink" title="完整实战：订单列表从 1.8 秒降到预算内"></a>完整实战：订单列表从 1.8 秒降到预算内</h2><p>查询按用户、状态和时间筛选并倒序分页：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> id, status, total_cent, created_at</span><br><span class="line"><span class="keyword">FROM</span> orders</span><br><span class="line"><span class="keyword">WHERE</span> tenant_id <span class="operator">=</span> ?</span><br><span class="line">  <span class="keyword">AND</span> status <span class="operator">=</span> ?</span><br><span class="line">  <span class="keyword">AND</span> created_at <span class="operator">&lt;</span> ?</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at <span class="keyword">DESC</span>, id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">50</span>;</span><br></pre></td></tr></table></figure><p>候选复合索引是 <code>(tenant_id, status, created_at, id)</code>，等值条件在前、范围和排序字段在后。但索引设计不能只看这一条查询：需要统计字段选择性、其他常用路径、写入放大和索引体积，再在同数据分布的环境验证执行计划。</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></pre></td><td class="code"><pre><span class="line">EXPLAIN ANALYZE</span><br><span class="line"><span class="keyword">SELECT</span> id, status, total_cent, created_at</span><br><span class="line"><span class="keyword">FROM</span> orders</span><br><span class="line"><span class="keyword">WHERE</span> tenant_id <span class="operator">=</span> <span class="number">42</span></span><br><span class="line">  <span class="keyword">AND</span> status <span class="operator">=</span> <span class="string">&#x27;paid&#x27;</span></span><br><span class="line">  <span class="keyword">AND</span> created_at <span class="operator">&lt;</span> <span class="string">&#x27;2026-08-22 00:00:00&#x27;</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at <span class="keyword">DESC</span>, id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">50</span>;</span><br></pre></td></tr></table></figure><p>关注实际扫描行数、循环次数、排序&#x2F;临时表、估算与实际偏差，而不是只看到“使用了某个索引”就结束。若返回 50 行却扫描几十万行，索引或谓词仍有问题。</p><h3 id="安全变更顺序"><a href="#安全变更顺序" class="headerlink" title="安全变更顺序"></a>安全变更顺序</h3><table><thead><tr><th>阶段</th><th>动作</th><th>退出条件</th></tr></thead><tbody><tr><td>基线</td><td>保存查询指纹、P95、调用量、计划</td><td>能稳定复现</td></tr><tr><td>候选</td><td>在影子数据创建索引</td><td>计划改善且写入成本可接受</td></tr><tr><td>灰度</td><td>低峰在线变更，限制资源</td><td>复制延迟、锁等待未越线</td></tr><tr><td>观察</td><td>对比读写延迟和慢查询</td><td>完整业务周期无回退</td></tr><tr><td>清理</td><td>评估重复&#x2F;无用索引</td><td>有使用证据和回滚窗口</td></tr></tbody></table><p>深分页不要无限使用 <code>OFFSET</code>，它仍需扫描并丢弃前面的行。使用稳定排序的 seek&#x2F;cursor 分页，并包含并列字段 <code>id</code>。应用层同时限制最大页大小和时间范围，避免一个合法请求变成全表工作。</p><p>故障演练包括冷缓存、热点租户、统计信息过期、并发写入和复制库延迟。上线后观察查询 P95&#x2F;P99、扫描&#x2F;返回比、buffer pool 命中、锁等待、复制延迟与写入耗时。出现回归时先切回旧查询路径；删除索引是单独的后续动作，不要和应用回滚绑在一起。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://dev.mysql.com/doc/refman/8.4/en/explain.html">MySQL EXPLAIN</a></li><li><a href="https://dev.mysql.com/doc/refman/8.4/en/slow-query-log.html">MySQL 慢查询日志</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/06/mysql-slow-query/</id>
    <link href="https://www.cjjhc.cn/2026/08/06/mysql-slow-query/"/>
    <published>2026-08-06T01:00:00.000Z</published>
    <summary>把慢日志、执行计划和数据分布结合起来定位瓶颈。</summary>
    <title>MySQL 慢查询分析方法</title>
    <updated>2026-08-06T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="数据库" scheme="https://www.cjjhc.cn/categories/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    <category term="SQL" scheme="https://www.cjjhc.cn/tags/SQL/"/>
    <category term="PostgreSQL" scheme="https://www.cjjhc.cn/tags/PostgreSQL/"/>
    <category term="索引" scheme="https://www.cjjhc.cn/tags/%E7%B4%A2%E5%BC%95/"/>
    <content>
      <![CDATA[<p>围绕真实查询设计索引，而不是为每一列都建立索引。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>使用执行计划观察扫描方式、估算行数和排序成本。联合索引的列顺序应匹配过滤与排序条件，部分索引适合高频、稳定的子集查询。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>索引会增加写入与维护成本。上线后跟踪命中率、体积和未使用索引，并用接近生产规模的数据验证。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>从 <code>pg_stat_statements</code> 找高总耗时 SQL，在接近生产的数据上执行 <code>EXPLAIN (ANALYZE, BUFFERS)</code>。联合索引顺序匹配过滤、排序和选择性，必要时采用部分或覆盖索引。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>创建大索引评估锁和磁盘空间，生产可考虑 <code>CONCURRENTLY</code>。上线后比较执行时间、缓冲读取和写入成本，删除索引前确认完整业务周期内未使用。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“优化一个按租户和时间分页的慢查询”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>用真实参数执行 EXPLAIN ANALYZE，建立与过滤和排序顺序匹配的联合索引；控制包含列，避免索引覆盖所有字段。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>比较冷热缓存、不同租户数据倾斜和深分页，确认写入开销与磁盘增长可接受。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察执行时间、扫描行数和索引命中；收益不足时并发删除新索引并恢复原计划。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 查询指纹、计划前后对比和索引回退语句。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ...;</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：为多租户任务列表选索引"><a href="#完整实战：为多租户任务列表选索引" class="headerlink" title="完整实战：为多租户任务列表选索引"></a>完整实战：为多租户任务列表选索引</h2><p>查询最近未完成任务：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> id, priority, created_at</span><br><span class="line"><span class="keyword">FROM</span> jobs</span><br><span class="line"><span class="keyword">WHERE</span> tenant_id <span class="operator">=</span> $<span class="number">1</span></span><br><span class="line">  <span class="keyword">AND</span> finished_at <span class="keyword">IS</span> <span class="keyword">NULL</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> priority <span class="keyword">DESC</span>, created_at <span class="keyword">ASC</span>, id <span class="keyword">ASC</span></span><br><span class="line">LIMIT <span class="number">100</span>;</span><br></pre></td></tr></table></figure><p>如果已完成任务占绝大多数，部分索引能显著减少体积：</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> INDEX CONCURRENTLY idx_jobs_open_queue</span><br><span class="line"><span class="keyword">ON</span> jobs (tenant_id, priority <span class="keyword">DESC</span>, created_at, id)</span><br><span class="line"><span class="keyword">WHERE</span> finished_at <span class="keyword">IS</span> <span class="keyword">NULL</span>;</span><br></pre></td></tr></table></figure><p>谓词必须与查询条件匹配。<code>CONCURRENTLY</code> 降低建索引对写入的阻塞，但耗时更长、资源消耗更高，失败还可能留下无效索引；仍需在维护计划中监控，并按锁定版本文档确认事务限制。</p><h3 id="用真实执行计划验证"><a href="#用真实执行计划验证" class="headerlink" title="用真实执行计划验证"></a>用真实执行计划验证</h3><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></pre></td><td class="code"><pre><span class="line">EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT TEXT)</span><br><span class="line"><span class="keyword">SELECT</span> id, priority, created_at</span><br><span class="line"><span class="keyword">FROM</span> jobs</span><br><span class="line"><span class="keyword">WHERE</span> tenant_id <span class="operator">=</span> <span class="number">42</span> <span class="keyword">AND</span> finished_at <span class="keyword">IS</span> <span class="keyword">NULL</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> priority <span class="keyword">DESC</span>, created_at, id</span><br><span class="line">LIMIT <span class="number">100</span>;</span><br></pre></td></tr></table></figure><p>检查实际&#x2F;估算行数、shared hit&#x2F;read、排序、heap fetch 和循环次数。索引扫描不总是优于顺序扫描：小表、低选择性查询或需要返回大量行时，顺序扫描可能更便宜。不要通过永久关闭某类扫描强迫计划器。</p><table><thead><tr><th>场景</th><th>额外关注</th></tr></thead><tbody><tr><td>更新频繁列</td><td>索引维护、HOT update、膨胀</td></tr><tr><td>仅少数活跃行</td><td>部分索引与谓词稳定性</td></tr><tr><td>大范围时间数据</td><td>BRIN、分区与物理相关性</td></tr><tr><td>文本&#x2F;数组搜索</td><td>GIN&#x2F;GiST 的写入与 vacuum 成本</td></tr><tr><td>多条件组合</td><td>复合顺序、扩展统计、真实选择性</td></tr></tbody></table><p>索引上线后跨一个完整业务周期观察查询延迟、idx scan、索引大小、WAL、写入延迟和 autovacuum。删除“未使用索引”前排除备用库、月度任务和统计重启影响；先标记候选并观察，再在可恢复窗口单独删除。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.postgresql.org/docs/current/indexes.html">PostgreSQL 索引</a></li><li><a href="https://www.postgresql.org/docs/current/using-explain.html">PostgreSQL EXPLAIN</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/05/postgres-index-guide/</id>
    <link href="https://www.cjjhc.cn/2026/08/05/postgres-index-guide/"/>
    <published>2026-08-05T01:00:00.000Z</published>
    <summary>围绕真实查询设计索引，而不是为每一列都建立索引。</summary>
    <title>PostgreSQL 索引设计指南</title>
    <updated>2026-08-05T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="系统工程" scheme="https://www.cjjhc.cn/categories/%E7%B3%BB%E7%BB%9F%E5%B7%A5%E7%A8%8B/"/>
    <category term="安全" scheme="https://www.cjjhc.cn/tags/%E5%AE%89%E5%85%A8/"/>
    <category term="HTTPS" scheme="https://www.cjjhc.cn/tags/HTTPS/"/>
    <category term="TLS" scheme="https://www.cjjhc.cn/tags/TLS/"/>
    <content>
      <![CDATA[<p>理解证书链、域名验证和自动续期，避免证书到期事故。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>证书证明公钥与域名的绑定，服务器需要返回完整证书链。公网常用 ACME 自动签发，内网可以使用受信任的内部 CA。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>续期任务必须配套到期监控与实际握手检查。更换证书后确认负载均衡器、Ingress 和源站都加载了新版本。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>用 <code>openssl s_client -connect host:443 -servername host -showcerts</code> 检查 SNI、完整证书链、SAN 和有效期。自动续期后必须 reload 实际终止 TLS 的代理。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>同时监控文件到期时间和真实握手返回的证书，防止续期成功但服务未加载。私钥限制权限并建立轮换流程，不能写进镜像或 Git。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为内网站点建立可维护的 HTTPS”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>证书包含实际访问域名和完整链，私钥权限最小；自动续期在独立环境验证，并给过期时间设置提前告警。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>测试错误主机名、缺少中间证书、过期证书和旧协议，确认客户端错误与服务端日志可定位。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察握手失败和剩余有效期；新证书异常时恢复旧证书与配置，但不得继续使用已泄露私钥。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 证书链、续期演练与过期告警。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>openssl s_client -connect example.com:443 -servername example.com -showcerts &lt;/dev/null</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：为-Nginx-自动续期证书"><a href="#完整实战：为-Nginx-自动续期证书" class="headerlink" title="完整实战：为 Nginx 自动续期证书"></a>完整实战：为 Nginx 自动续期证书</h2><p>证书部署包含私钥生成、域名控制验证、签发、链配置、热加载和持续监控。先列清入口：根域、<code>www</code>、API 子域是否都需要，是否存在 IPv6、多个负载均衡器或 CDN；验证流量必须能到达完成 ACME challenge 的节点。</p><figure class="highlight nginx"><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="section">server</span> &#123;</span><br><span class="line">    <span class="attribute">listen</span> <span class="number">443</span> ssl http2;</span><br><span class="line">    <span class="attribute">server_name</span> api.example.com;</span><br><span class="line"></span><br><span class="line">    <span class="attribute">ssl_certificate</span>     /etc/nginx/tls/api.example.com/fullchain.pem;</span><br><span class="line">    <span class="attribute">ssl_certificate_key</span> /etc/nginx/tls/api.example.com/privkey.pem;</span><br><span class="line"></span><br><span class="line">    <span class="section">location</span> / &#123;</span><br><span class="line">        <span class="attribute">proxy_pass</span> http://api_upstream;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>路径只是示例。私钥目录只向必要进程开放，不进入镜像、仓库或构建日志；备份和迁移私钥也要使用独立加密与审计。</p><h3 id="续期任务必须验证新证书再切换"><a href="#续期任务必须验证新证书再切换" class="headerlink" title="续期任务必须验证新证书再切换"></a>续期任务必须验证新证书再切换</h3><ol><li>在临时路径完成签发，校验证书域名、有效期、颁发链和私钥匹配。</li><li>运行 <code>nginx -t</code> 验证完整配置。</li><li>原子替换证书链接并 reload；reload 失败时旧 worker 继续服务。</li><li>从外部入口重新握手，确认实际返回的是新序列号，而非只看磁盘文件。</li></ol><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts &lt;/dev/null</span><br></pre></td></tr></table></figure><table><thead><tr><th>故障注入</th><th>预期</th></tr></thead><tbody><tr><td>ACME 端点不可达</td><td>保留旧证书，任务失败并告警</td></tr><tr><td>返回错误域名证书</td><td>切换前拒绝</td></tr><tr><td>Nginx 配置错误</td><td><code>nginx -t</code> 阶段停止</td></tr><tr><td>单节点未 reload</td><td>外部探测发现序列号不一致</td></tr><tr><td>系统时间偏差</td><td>监控与 Doctor 明确报告</td></tr></tbody></table><p>到期监控应从客户端视角读取线上证书，按 30&#x2F;14&#x2F;7 天等分层告警，并记录续期任务最后成功时间。更换 CA 或链时需要覆盖老设备兼容性。回滚只恢复上一组完整证书链和私钥引用，不要复制半套文件；疑似私钥泄露时则应吊销、换钥并调查访问记录，不能简单回滚到已泄露密钥。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://datatracker.ietf.org/doc/html/rfc8446">TLS 1.3 规范</a></li><li><a href="https://letsencrypt.org/docs/">Let’s Encrypt 文档</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/04/https-certificate-basics/</id>
    <link href="https://www.cjjhc.cn/2026/08/04/https-certificate-basics/"/>
    <published>2026-08-04T01:00:00.000Z</published>
    <summary>理解证书链、域名验证和自动续期，避免证书到期事故。</summary>
    <title>HTTPS 证书与自动续期基础</title>
    <updated>2026-08-04T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="系统工程" scheme="https://www.cjjhc.cn/categories/%E7%B3%BB%E7%BB%9F%E5%B7%A5%E7%A8%8B/"/>
    <category term="DNS" scheme="https://www.cjjhc.cn/tags/DNS/"/>
    <category term="网络" scheme="https://www.cjjhc.cn/tags/%E7%BD%91%E7%BB%9C/"/>
    <category term="排障" scheme="https://www.cjjhc.cn/tags/%E6%8E%92%E9%9A%9C/"/>
    <content>
      <![CDATA[<p>从客户端缓存到权威服务器，系统定位解析失败、超时与结果不一致。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>先明确查询名称、记录类型和使用的 DNS 服务器，再分别检查本机、递归服务器和权威结果。注意搜索域、负缓存与 TTL。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>容器环境还要检查 CoreDNS、Service 名称和网络策略。临时写 hosts 只能用于验证，不应成为长期配置。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>用 <code>dig name A</code> 明确记录类型和响应服务器，再分别查询本机配置的递归 DNS 与权威服务器。检查状态码、Answer、TTL、CNAME 链和是否命中搜索域。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>容器环境继续检查 <code>/etc/resolv.conf</code>、CoreDNS 日志、Service 名称和 NetworkPolicy。清缓存只用于验证，修复应落在正确的权威记录和 TTL。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“排查“域名偶尔解析失败””为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>分别检查本机缓存、递归解析器、权威记录和网络路径；同时查询 A&#x2F;AAAA&#x2F;CNAME，记录 TTL、返回码和使用的服务器。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>对不存在域名、过期缓存、错误 DNSSEC 和 IPv6 不通分别验证，避免把 TCP 故障误判为 DNS。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>比较 SERVFAIL、NXDOMAIN、解析延迟与缓存命中；变更记录后等待 TTL，不连续修改多个层级。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 分层解析记录和 TTL 观察表。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>dig +trace example.com; dig A example.com; dig AAAA example.com</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：api-example-com-间歇解析失败"><a href="#完整实战：api-example-com-间歇解析失败" class="headerlink" title="完整实战：api.example.com 间歇解析失败"></a>完整实战：<code>api.example.com</code> 间歇解析失败</h2><p>先把“DNS 有问题”改写成可验证现象：哪些客户端、哪个网络、查询哪个名称和记录类型、从何时开始、失败比例、返回码与耗时。浏览器打不开不等于 DNS 失败，也可能是代理、TLS、路由或应用问题。</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">dig api.example.com A</span><br><span class="line">dig api.example.com AAAA</span><br><span class="line">dig @1.1.1.1 api.example.com A</span><br><span class="line">dig +trace api.example.com A</span><br></pre></td></tr></table></figure><p><code>+trace</code> 从根开始迭代，适合检查委派链，但它不复现企业递归解析器的缓存、策略和分流。应分别向客户端实际使用的递归服务器、权威服务器和一个对照公共解析器查询，并保存完整响应的状态、flags、answer、authority 与 TTL。</p><h3 id="按层缩小范围"><a href="#按层缩小范围" class="headerlink" title="按层缩小范围"></a>按层缩小范围</h3><table><thead><tr><th>层次</th><th>证据</th><th>常见问题</th></tr></thead><tbody><tr><td>客户端</td><td>resolv 配置、缓存、hosts、VPN</td><td>错误搜索域、负缓存、分流 DNS</td></tr><tr><td>递归解析器</td><td>查询日志、缓存命中、上游延迟</td><td>过期缓存、DNSSEC 校验、限流</td></tr><tr><td>委派</td><td>NS、glue、父区响应</td><td>域名服务器不一致、glue 错误</td></tr><tr><td>权威区</td><td>SOA serial、各 NS 答案</td><td>发布未同步、记录缺失、TTL 错配</td></tr><tr><td>传输</td><td>UDP&#x2F;TCP 53、EDNS、包大小</td><td>防火墙丢 TCP、分片或 MTU 问题</td></tr></tbody></table><p>若只有部分解析器返回旧地址，比较 TTL、SOA serial 和各权威 NS 的答案。若 UDP 查询截断而 TCP 失败，检查防火墙是否只放行 UDP 53。若只在公司网络失败，检查 split-horizon 区域和 VPN 路由，不要直接修改公网记录。</p><h3 id="变更与恢复"><a href="#变更与恢复" class="headerlink" title="变更与恢复"></a>变更与恢复</h3><p>DNS 迁移前至少一个旧 TTL 周期降低 TTL，确认权威区已同步，再切换记录；迁移完成并稳定后恢复正常 TTL。回滚时旧后端必须至少保留到缓存窗口结束。删除记录或 NS 属于高风险动作，先导出当前区文件和 registrar 配置。</p><p>验收同时从多地域递归解析器和实际客户端查询 A&#x2F;AAAA&#x2F;CNAME，确认最终地址、TTL、DNSSEC 状态和应用 TLS 主机名都正确。监控 SERVFAIL&#x2F;NXDOMAIN、解析 P95、权威错误率和新旧地址实际流量，不能只在本机 <code>ping</code> 一次。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://www.rfc-editor.org/rfc/rfc1034">RFC 1034 域名概念</a></li><li><a href="https://www.rfc-editor.org/rfc/rfc1035">RFC 1035 域名实现</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/03/dns-troubleshooting/</id>
    <link href="https://www.cjjhc.cn/2026/08/03/dns-troubleshooting/"/>
    <published>2026-08-03T01:00:00.000Z</published>
    <summary>从客户端缓存到权威服务器，系统定位解析失败、超时与结果不一致。</summary>
    <title>DNS 故障排查手册</title>
    <updated>2026-08-03T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="系统工程" scheme="https://www.cjjhc.cn/categories/%E7%B3%BB%E7%BB%9F%E5%B7%A5%E7%A8%8B/"/>
    <category term="网络" scheme="https://www.cjjhc.cn/tags/%E7%BD%91%E7%BB%9C/"/>
    <category term="Nginx" scheme="https://www.cjjhc.cn/tags/Nginx/"/>
    <category term="Web" scheme="https://www.cjjhc.cn/tags/Web/"/>
    <content>
      <![CDATA[<p>理解代理头、超时、缓冲和静态缓存，构建稳定的 Web 入口。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>正确传递 Host、客户端地址与协议头；连接、读取和发送超时应按业务特征设置。上传接口与流式响应不要沿用普通页面的缓冲策略。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>静态资源使用版本化文件名和长期缓存，HTML 保持较短缓存。修改配置前先校验语法，再平滑重载并观察错误率。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>为上游传递 Host、X-Forwarded-For 和 X-Forwarded-Proto，按接口特性设置 connect&#x2F;read&#x2F;send timeout。HTML 使用短缓存，带内容哈希的静态资源使用长期缓存。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>每次修改先执行 <code>nginx -t</code>，再平滑 reload。通过访问日志、上游响应时间和 499&#x2F;502&#x2F;504 比例验证，流式接口要关闭不适用的缓冲。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为两个后端实例提供反向代理”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>显式设置上游超时、请求体限制和转发头；健康检查与应用就绪语义一致，TLS 终止和真实客户端地址有可信代理边界。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>关闭一个上游、制造慢响应和超大请求，确认重试不会重复非幂等动作且错误码清晰。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察 upstream 响应时间、502&#x2F;504 和连接数；配置检查通过后再 reload，异常恢复旧配置。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 可校验配置、上游故障矩阵和回退文件。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>nginx -t &amp;&amp; nginx -T &gt; nginx-effective.conf</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：带超时边界的-API-代理"><a href="#完整实战：带超时边界的-API-代理" class="headerlink" title="完整实战：带超时边界的 API 代理"></a>完整实战：带超时边界的 API 代理</h2><p>反向代理要明确客户端、Nginx 和上游三段连接的超时与信任边界。下面是一个起点，不是通用最优配置：</p><figure class="highlight nginx"><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"><span class="section">upstream</span> api_backend &#123;</span><br><span class="line">    <span class="attribute">zone</span> api_backend <span class="number">64k</span>;</span><br><span class="line">    <span class="attribute">keepalive</span> <span class="number">64</span>;</span><br><span class="line">    <span class="attribute">server</span> <span class="number">10.0.1.10:8080</span> max_fails=<span class="number">3</span> fail_timeout=<span class="number">10s</span>;</span><br><span class="line">    <span class="attribute">server</span> <span class="number">10.0.1.11:8080</span> max_fails=<span class="number">3</span> fail_timeout=<span class="number">10s</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="section">server</span> &#123;</span><br><span class="line">    <span class="attribute">listen</span> <span class="number">443</span> ssl http2;</span><br><span class="line">    <span class="attribute">server_name</span> api.example.com;</span><br><span class="line"></span><br><span class="line">    <span class="section">location</span> /api/ &#123;</span><br><span class="line">        <span class="attribute">proxy_pass</span> http://api_backend;</span><br><span class="line">        <span class="attribute">proxy_http_version</span> <span class="number">1</span>.<span class="number">1</span>;</span><br><span class="line">        <span class="attribute">proxy_set_header</span> Host <span class="variable">$host</span>;</span><br><span class="line">        <span class="attribute">proxy_set_header</span> X-Request-ID <span class="variable">$request_id</span>;</span><br><span class="line">        <span class="attribute">proxy_set_header</span> X-Forwarded-Proto <span class="variable">$scheme</span>;</span><br><span class="line">        <span class="attribute">proxy_set_header</span> Connection <span class="string">&quot;&quot;</span>;</span><br><span class="line"></span><br><span class="line">        <span class="attribute">proxy_connect_timeout</span> <span class="number">2s</span>;</span><br><span class="line">        <span class="attribute">proxy_send_timeout</span> <span class="number">10s</span>;</span><br><span class="line">        <span class="attribute">proxy_read_timeout</span> <span class="number">30s</span>;</span><br><span class="line">        <span class="attribute">client_max_body_size</span> <span class="number">10m</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>只有代理处于受信网络边界时才转发或重写 <code>X-Forwarded-*</code>；外部客户端自带的同名头不能直接信任。<code>proxy_read_timeout</code> 是相邻读取间隔，不是完整业务 deadline，应用仍需自己的截止时间和取消传播。</p><h3 id="重试不是默认安全"><a href="#重试不是默认安全" class="headerlink" title="重试不是默认安全"></a>重试不是默认安全</h3><p>GET 等幂等请求可以在连接失败时有限重试，POST 写操作除非具有稳定幂等键且业务明确支持，否则不能自动转发到第二个上游。代理重试、客户端重试和服务内部重试叠加会形成放大，必须只在一层主导并设置总预算。</p><table><thead><tr><th>验收场景</th><th>预期证据</th></tr></thead><tbody><tr><td>单上游退出</td><td>新请求转到健康节点，在途请求行为可解释</td></tr><tr><td>上游延迟</td><td>到达超时后返回明确状态，无无限连接</td></tr><tr><td>大请求体</td><td>在代理层受控拒绝，不耗尽临时磁盘</td></tr><tr><td>客户伪造转发头</td><td>应用看到代理生成的可信链</td></tr><tr><td>WebSocket&#x2F;SSE</td><td>单独 location 配置并验证长连接</td></tr></tbody></table><p>变更前执行配置语法检查，再 reload 而非粗暴重启；从外部入口验证状态、证书、响应头和 request ID。观察各 upstream 的连接、响应时间、状态码、重试次数和活动连接。回滚使用上一版完整配置文件并再次语法检查，不能在生产机上临时手改后丢失差异。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html">NGINX 代理模块</a></li><li><a href="https://nginx.org/en/docs/control.html">NGINX 配置重载</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/02/nginx-reverse-proxy/</id>
    <link href="https://www.cjjhc.cn/2026/08/02/nginx-reverse-proxy/"/>
    <published>2026-08-02T01:00:00.000Z</published>
    <summary>理解代理头、超时、缓冲和静态缓存，构建稳定的 Web 入口。</summary>
    <title>Nginx 反向代理常用配置解析</title>
    <updated>2026-08-02T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="系统工程" scheme="https://www.cjjhc.cn/categories/%E7%B3%BB%E7%BB%9F%E5%B7%A5%E7%A8%8B/"/>
    <category term="排障" scheme="https://www.cjjhc.cn/tags/%E6%8E%92%E9%9A%9C/"/>
    <category term="性能优化" scheme="https://www.cjjhc.cn/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    <category term="Linux" scheme="https://www.cjjhc.cn/tags/Linux/"/>
    <content>
      <![CDATA[<p>从现象、资源到进程逐层排查，避免看到高 CPU 就盲目扩容。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>先确认影响范围和时间窗口，再观察负载、CPU、内存、磁盘与网络。区分 CPU 使用率与运行队列，区分缓存占用与真实内存压力。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>找到异常进程后结合日志、调用栈和版本变更验证假设。一次只调整一个变量，并记录前后指标，才能判断优化是否有效。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>先记录故障时间和影响，再用 <code>uptime</code>、<code>vmstat 1</code>、<code>iostat -xz 1</code>、<code>pidstat 1</code> 区分 CPU、内存、磁盘和进程问题。结论必须结合同一时间窗口的应用指标。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>修改参数前保存基线，一次只改一个变量。CPU 高要区分 user、system、iowait 与运行队列；内存高要区分 page cache 和实际回收压力。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“定位一台服务器响应变慢的首个瓶颈”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>先确认时间窗口和负载变化，再依次看 CPU、运行队列、内存回收、磁盘等待和网络；用进程维度验证系统级信号。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>制造 CPU 密集、内存压力和慢磁盘三种负载，比较指标特征，避免见到高 load 就只归因 CPU。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>保存基线与故障快照；调参前记录原值，收益不明确立即恢复并停止叠加修改。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 基线快照、故障窗口和瓶颈假设表。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>uptime; vmstat 1 5; iostat -xz 1 5; cat /proc/pressure/{cpu,memory,io}</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：API-延迟突然翻倍"><a href="#完整实战：API-延迟突然翻倍" class="headerlink" title="完整实战：API 延迟突然翻倍"></a>完整实战：API 延迟突然翻倍</h2><p>先问四件事：用户影响何时开始、哪些请求受影响、同期发布&#x2F;流量有什么变化、资源是否接近容量。不要看到 CPU 高就先扩容；高 CPU 可能是有效吞吐，也可能是自旋、GC 或压缩工作。</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><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">uptime</span></span><br><span class="line">vmstat 1 10</span><br><span class="line">pidstat -dur 1 10</span><br><span class="line">iostat -xz 1 10</span><br><span class="line">ss -s</span><br></pre></td></tr></table></figure><p>命令需按发行版安装对应工具。第一次采样保存原始输出和时间范围，后续所有证据围绕同一窗口对齐。</p><h3 id="USE-方法逐层排查"><a href="#USE-方法逐层排查" class="headerlink" title="USE 方法逐层排查"></a>USE 方法逐层排查</h3><table><thead><tr><th>资源</th><th>Utilization</th><th>Saturation</th><th>Errors</th></tr></thead><tbody><tr><td>CPU</td><td>user&#x2F;system&#x2F;iowait</td><td>run queue、steal</td><td>硬件&#x2F;调度异常</td></tr><tr><td>内存</td><td>working set、cache</td><td>reclaim、swap</td><td>OOM、分配失败</td></tr><tr><td>磁盘</td><td>throughput、busy</td><td>await、queue depth</td><td>I&#x2F;O 错误、超时</td></tr><tr><td>网络</td><td>bandwidth、pps</td><td>drop、retransmit</td><td>接口错误、连接失败</td></tr><tr><td>连接池</td><td>active&#x2F;idle</td><td>waiters、等待时间</td><td>timeout、拒绝</td></tr></tbody></table><p><code>load average</code> 不是 CPU 百分比，它还可能包含不可中断等待。<code>free</code> 中 cache 不等同于不可用内存；是否存在压力要结合 reclaim、swap、PSI 和应用行为。磁盘 <code>%util</code> 在并行设备上也不能单独解释饱和度，应一起看延迟和队列。</p><h3 id="从主机到进程再到代码"><a href="#从主机到进程再到代码" class="headerlink" title="从主机到进程再到代码"></a>从主机到进程再到代码</h3><p>确认瓶颈资源后定位进程、线程和调用栈。CPU 问题用 profile&#x2F;flame graph，I&#x2F;O 问题对齐块设备延迟与应用慢请求，内存问题比较 RSS、堆、page fault 与 OOM 记录。采样工具本身有成本，生产上先低频、短窗口并记录权限和影响。</p><p>用受控压测复现相同流量和数据分布，分别测试 CPU 限额、磁盘延迟、连接池耗尽与依赖超时。验收看业务吞吐、P95&#x2F;P99、错误率以及资源饱和度是否一起改善。止损可以是限流、降级或扩容；内核参数与持久配置变更单独灰度，回滚时恢复原值并再次采样，而不是用重启清空证据。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://docs.kernel.org/accounting/psi.html">Linux PSI 文档</a></li><li><a href="https://docs.kernel.org/admin-guide/cgroup-v2.html">Linux cgroup v2</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/08/01/linux-performance-basics/</id>
    <link href="https://www.cjjhc.cn/2026/08/01/linux-performance-basics/"/>
    <published>2026-08-01T01:00:00.000Z</published>
    <summary>从现象、资源到进程逐层排查，避免看到高 CPU 就盲目扩容。</summary>
    <title>Linux 性能排查的正确顺序</title>
    <updated>2026-08-01T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="可观测性" scheme="https://www.cjjhc.cn/categories/%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7/"/>
    <category term="微服务" scheme="https://www.cjjhc.cn/tags/%E5%BE%AE%E6%9C%8D%E5%8A%A1/"/>
    <category term="OpenTelemetry" scheme="https://www.cjjhc.cn/tags/OpenTelemetry/"/>
    <category term="Tracing" scheme="https://www.cjjhc.cn/tags/Tracing/"/>
    <content>
      <![CDATA[<p>用统一标准串联前端、网关、服务和数据库调用。</p><span id="more"></span><p><a href="/images/tech/diagrams/opentelemetry-trace.svg"><img src="/images/tech/diagrams/opentelemetry-trace.svg" alt="浏览器、网关、订单、库存、支付和 Collector 组成的一次请求 Trace 图"></a></p><p><em>图：自动插桩覆盖协议调用，手工 Span 只补关键业务阶段；点击查看原图。</em></p><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>先确定服务命名与资源属性，再接入自动埋点。为关键业务补充 span 属性与事件，但不要记录密码、令牌和完整个人信息。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>采样策略要兼顾成本和问题捕获：正常流量低比例采样，错误与慢请求提高保留概率。通过 trace ID 关联日志能显著缩短定位时间。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>统一 service.name、environment 和 version；自动埋点覆盖入口与常见客户端，关键业务再补手工 Span、事件和错误状态。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>验证上下文能跨 HTTP、消息队列和异步任务传播。采样后仍应保留错误与慢请求，属性禁止记录令牌、密码和完整个人信息。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为一次跨服务请求建立完整 Trace”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>入口创建根 Span 并传播标准上下文；数据库、消息和外部调用使用子 Span，记录状态码与版本但不记录敏感正文。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>模拟异步消息、超时重试和上下文丢失，确认父子关系、错误状态与采样结果正确。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察 Trace 完整率、孤儿 Span 和导出丢弃数；采集成本异常时降低采样并回退新埋点。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 语义约定、采样方案和 Trace 完整率报告。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>向测试 Collector 发送一条跨服务请求，按 trace_id 核对根 Span、子 Span 与状态</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：追踪一次下单请求"><a href="#完整实战：追踪一次下单请求" class="headerlink" title="完整实战：追踪一次下单请求"></a>完整实战：追踪一次下单请求</h2><p>从网关到订单、库存、支付服务，先统一资源属性（服务名、版本、部署环境），再定义少量稳定的业务 span。自动插桩负责 HTTP、数据库和消息中间件，手工 span 只补业务阶段，避免给每个函数都建 span。</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></pre></td><td class="code"><pre><span class="line">POST /orders                         820 ms</span><br><span class="line">├─ order.validate                     8 ms</span><br><span class="line">├─ inventory.reserve                115 ms</span><br><span class="line">├─ payment.authorize                640 ms  ERROR gateway_timeout</span><br><span class="line">└─ order.release_reservation         31 ms</span><br></pre></td></tr></table></figure><p>错误 span 应记录规范化状态和可诊断属性，但不能把卡号、令牌、完整 SQL 或请求正文塞进属性。<code>user.id</code>、<code>order.id</code> 等高基数字段是否记录，要经过数据治理；即使进入 Trace，也不要拿它们做无界指标标签。</p><h3 id="上下文传播与采样"><a href="#上下文传播与采样" class="headerlink" title="上下文传播与采样"></a>上下文传播与采样</h3><ul><li>入口校验传播头，建立或继续 trace；不要盲目信任外部传入的任意 baggage。</li><li>异步消息把 trace context 放入消息属性，消费端建立 consumer span，而不是伪装成同步调用。</li><li>头部采样成本可控但看不到最终结果；尾部采样能保留慢请求和错误，但需要集中缓冲与容量预算。</li><li>无论采样策略如何，基于指标的错误率和延迟监控不能依赖 Trace 样本完整性。</li></ul><table><thead><tr><th>验收项</th><th>注入方式</th><th>通过证据</th></tr></thead><tbody><tr><td>跨服务传播</td><td>发起一笔测试订单</td><td>所有服务共享 trace_id，父子关系正确</td></tr><tr><td>超时</td><td>支付模拟延迟</td><td>deadline 传播，取消后无幽灵工作</td></tr><tr><td>异步消费</td><td>发布订单事件</td><td>producer&#x2F;consumer 语义与链接清楚</td></tr><tr><td>脱敏</td><td>输入敏感字段</td><td>导出数据中不出现原文</td></tr><tr><td>出口故障</td><td>停止 collector</td><td>业务继续，遥测有界丢弃且告警</td></tr></tbody></table><p>Collector 使用队列、批处理、内存限制和重试，出口故障不能反压到业务请求。上线先估算每秒 span 数、平均大小和保留成本，再灰度调采样。观察完整率、孤儿 span、导出失败、队列丢弃和 collector 内存；回滚时先降低采样与关闭新增属性，而不是删除全部可观测性。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://opentelemetry.io/docs/concepts/signals/traces/">OpenTelemetry Traces</a></li><li><a href="https://opentelemetry.io/docs/specs/semconv/">OpenTelemetry 语义约定</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/07/31/opentelemetry-tracing/</id>
    <link href="https://www.cjjhc.cn/2026/07/31/opentelemetry-tracing/"/>
    <published>2026-07-31T01:00:00.000Z</published>
    <summary>用统一标准串联前端、网关、服务和数据库调用。</summary>
    <title>OpenTelemetry 链路追踪入门</title>
    <updated>2026-07-31T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="可观测性" scheme="https://www.cjjhc.cn/categories/%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7/"/>
    <category term="Grafana" scheme="https://www.cjjhc.cn/tags/Grafana/"/>
    <category term="Loki" scheme="https://www.cjjhc.cn/tags/Loki/"/>
    <category term="日志" scheme="https://www.cjjhc.cn/tags/%E6%97%A5%E5%BF%97/"/>
    <content>
      <![CDATA[<p>用标签克制、结构化日志和合理保留期控制日志系统成本。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>标签只放低基数字段，如应用、环境和级别；请求 ID、用户 ID 放在日志正文中查询。应用输出 JSON，统一时间、错误码和追踪字段。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>按业务价值设置保留期，调试日志动态开启。日志不能替代指标，排障时应从指标定位范围，再用日志寻找细节。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>标签仅使用应用、环境、集群和级别等低基数字段；请求 ID、用户 ID 和 URL 放 JSON 正文。Promtail 或 Alloy 解析失败时保留原始日志并记录解析错误。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>压测查询和摄入，监控丢弃行、限流和存储增长。为调试日志设置短保留期，敏感字段在进入 Loki 前脱敏。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为容器服务建立可控成本的集中日志”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>标签只放环境、服务和级别等低基数字段，请求号放正文；结构化输出时间、事件名和错误类型，并在采集端脱敏。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>压入大量唯一请求号确认不会成为标签；模拟多行异常和敏感字段，验证解析与遮蔽。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察摄取量、活跃流和查询延迟；标签爆炸时停止错误采集配置并回退。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 标签字典、脱敏规则和高基数基线。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>在测试租户比较变更前后的 active streams、摄取速率和查询延迟</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：控制日志标签基数"><a href="#完整实战：控制日志标签基数" class="headerlink" title="完整实战：控制日志标签基数"></a>完整实战：控制日志标签基数</h2><p>Loki 的标签用于定位有限的日志流，不是把所有字段都做索引。一个稳妥的标签集合可能只有环境、服务、区域和级别；request_id、user_id、订单号、URL 原文放在结构化日志正文中按需解析。</p><figure class="highlight json"><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="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;ts&quot;</span><span class="punctuation">:</span> <span class="string">&quot;2026-08-22T09:30:00Z&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;level&quot;</span><span class="punctuation">:</span> <span class="string">&quot;error&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;service&quot;</span><span class="punctuation">:</span> <span class="string">&quot;order-api&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;event&quot;</span><span class="punctuation">:</span> <span class="string">&quot;payment_authorize_failed&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;request_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;req_01J...&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;duration_ms&quot;</span><span class="punctuation">:</span> <span class="number">842</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;error_code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;UPSTREAM_TIMEOUT&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>示例查询先用低基数标签缩小流，再解析 JSON：</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></pre></td><td class="code"><pre><span class="line">&#123;env=&quot;prod&quot;, service=&quot;order-api&quot;&#125;</span><br><span class="line">| json</span><br><span class="line">| event=&quot;payment_authorize_failed&quot;</span><br><span class="line">| duration_ms &gt; 500</span><br></pre></td></tr></table></figure><p>不要使用 <code>request_id</code>、pod UID、完整路径或用户作为标签，否则流数量和索引成本会持续增长。动态路由应先归一成模板，如 <code>/orders/:id</code>。</p><h3 id="采集、保留与脱敏"><a href="#采集、保留与脱敏" class="headerlink" title="采集、保留与脱敏"></a>采集、保留与脱敏</h3><table><thead><tr><th>环节</th><th>控制点</th><th>失败信号</th></tr></thead><tbody><tr><td>应用</td><td>JSON schema、级别、采样</td><td>解析失败、日志量突增</td></tr><tr><td>Agent</td><td>多行合并、队列、丢弃策略</td><td>backlog、dropped entries</td></tr><tr><td>Loki</td><td>租户限额、保留、压缩</td><td>active streams、写入拒绝</td></tr><tr><td>查询</td><td>时间范围、行数、并发</td><td>查询 P95、超时率</td></tr></tbody></table><p>密码、令牌、Cookie、完整身份证件和支付信息在应用产生日志前就应移除；采集端正则脱敏只能兜底。异常堆栈需要稳定多行规则，避免一次异常变成数百条无上下文记录。</p><p>压测同时覆盖稳定写入、突发错误风暴和常用查询。验收标签基数、每 GB 日志成本、查询 P95、丢弃数和脱敏样本。上线先为新日志设采样或限额；异常增长时先降低噪声事件与调试级别，不能直接停止错误与审计日志。删除或缩短保留期属于数据治理动作，应单独审批并记录影响。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://grafana.com/docs/loki/latest/get-started/labels/">Loki 标签设计</a></li><li><a href="https://grafana.com/docs/loki/latest/get-started/labels/structured-metadata/">Loki 结构化元数据</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/07/30/loki-log-practice/</id>
    <link href="https://www.cjjhc.cn/2026/07/30/loki-log-practice/"/>
    <published>2026-07-30T01:00:00.000Z</published>
    <summary>用标签克制、结构化日志和合理保留期控制日志系统成本。</summary>
    <title>Loki 日志平台的低成本实践</title>
    <updated>2026-07-30T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="可观测性" scheme="https://www.cjjhc.cn/categories/%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7/"/>
    <category term="Grafana" scheme="https://www.cjjhc.cn/tags/Grafana/"/>
    <category term="数据可视化" scheme="https://www.cjjhc.cn/tags/%E6%95%B0%E6%8D%AE%E5%8F%AF%E8%A7%86%E5%8C%96/"/>
    <category term="监控" scheme="https://www.cjjhc.cn/tags/%E7%9B%91%E6%8E%A7/"/>
    <content>
      <![CDATA[<p>从概览到下钻组织信息，让值班人员一分钟内判断故障范围。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>第一屏展示请求量、错误率、延迟和饱和度；下方按依赖、实例和版本拆分。统一单位、时间范围与颜色含义，避免装饰性图表。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>变量用于环境和服务切换，但不要让查询组合爆炸。给发布事件加标注，可快速判断异常是否与变更相关。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>第一行展示流量、错误、延迟和饱和度，后续按版本、依赖和实例下钻。所有图表统一单位、时区、颜色语义和变量默认值。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>用一次真实故障验证值班人员能在一分钟内确认影响范围。发布事件写入 Annotation，查询设置合理最小间隔，避免大时间范围拖垮数据源。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“构建一张用于值班排障的服务总览”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>第一行展示流量、错误、延迟与饱和度，第二行按版本和实例下钻，所有面板统一时区、单位与变量。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>选择已知事故窗口复盘，确认从总览能定位到具体版本；测试无数据、部分实例丢失与高基数标签。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察查询耗时和面板失败率；高成本查询先禁用并回退到聚合指标。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 版本化 Dashboard JSON、变量契约和事故回放记录。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>先在测试 Grafana 导入 Dashboard，选择一个已知事故窗口核对每个面板</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：订单-API-值班大盘"><a href="#完整实战：订单-API-值班大盘" class="headerlink" title="完整实战：订单 API 值班大盘"></a>完整实战：订单 API 值班大盘</h2><p>仪表盘先服务一个决策：“订单 API 是否满足 SLO，问题发生在哪一层？”首页控制在一屏内，按流量、错误、延迟、饱和度排列，不把所有可采指标堆成墙。</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><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">sum(rate(http_server_requests_total&#123;service=&quot;order-api&quot;&#125;[5m]))</span><br><span class="line"></span><br><span class="line">sum(rate(http_server_requests_total&#123;service=&quot;order-api&quot;,status=~&quot;5..&quot;&#125;[5m]))</span><br><span class="line">/</span><br><span class="line">sum(rate(http_server_requests_total&#123;service=&quot;order-api&quot;&#125;[5m]))</span><br><span class="line"></span><br><span class="line">histogram_quantile(</span><br><span class="line">  0.95,</span><br><span class="line">  sum by (le) (rate(http_server_request_duration_seconds_bucket&#123;service=&quot;order-api&quot;&#125;[5m]))</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>查询需按实际指标名和直方图类型调整。延迟分位数应从 bucket 聚合，不能对各实例的 P95 再求平均。错误率分母、分子必须使用同一筛选范围，并明确 4xx 是否属于服务错误。</p><h3 id="面板层次"><a href="#面板层次" class="headerlink" title="面板层次"></a>面板层次</h3><table><thead><tr><th>层次</th><th>内容</th><th>值班动作</th></tr></thead><tbody><tr><td>概览</td><td>SLO、流量、错误、P95&#x2F;P99、发布标记</td><td>判断是否影响用户</td></tr><tr><td>服务</td><td>路由、实例、区域、依赖对比</td><td>缩小故障范围</td></tr><tr><td>资源</td><td>CPU、内存、连接池、队列、限流</td><td>判断饱和点</td></tr><tr><td>诊断</td><td>日志与 Trace 跳转</td><td>获取单请求证据</td></tr></tbody></table><p>变量要有默认值和数量上限。用户 ID、订单 ID、完整 URL 等高基数值不能直接作为大盘变量。面板标题写清单位、统计窗口和聚合维度；用颜色表达阈值时还需文字或数值，避免只有红绿差异。</p><p>发布标记必须带版本、提交和环境，才能解释指标拐点。验收时选取已知故障时间，确认值班者能从概览定位到具体依赖，并能从指标跳到同时间窗口的日志&#x2F;Trace。定期检查无数据、慢查询、变量展开过大和时区错位；大盘 JSON 进入版本控制，回滚与应用发布保持独立。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/best-practices/">Grafana Dashboard 最佳实践</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/07/29/grafana-dashboard-guide/</id>
    <link href="https://www.cjjhc.cn/2026/07/29/grafana-dashboard-guide/"/>
    <published>2026-07-29T01:00:00.000Z</published>
    <summary>从概览到下钻组织信息，让值班人员一分钟内判断故障范围。</summary>
    <title>一张好用的 Grafana 仪表盘如何设计</title>
    <updated>2026-07-29T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="可观测性" scheme="https://www.cjjhc.cn/categories/%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7/"/>
    <category term="监控" scheme="https://www.cjjhc.cn/tags/%E7%9B%91%E6%8E%A7/"/>
    <category term="SRE" scheme="https://www.cjjhc.cn/tags/SRE/"/>
    <category term="Prometheus" scheme="https://www.cjjhc.cn/tags/Prometheus/"/>
    <content>
      <![CDATA[<p>设计能说明影响、持续时间和处理动作的告警，减少噪声。</p><span id="more"></span><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>优先监控用户可感知的延迟、错误率与可用性，再补充资源指标。短暂尖峰使用 for 抑制，告警标签标明服务、环境和严重等级。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>每条告警都应有负责人、仪表盘和处理手册。定期回顾无人处理或频繁自愈的告警，删除无行动价值的规则。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>从用户可感知的可用性、延迟和错误率定义告警，再补充容量与饱和度。用 <code>for</code> 过滤短尖峰，Annotations 写清影响、仪表盘和处理手册。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>用历史数据回放规则，确认阈值在正常高峰不误报、真实故障能及时触发。每月统计告警数量、确认时间和无行动告警，持续删减噪声。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“为订单接口设计能行动的告警”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>先从用户可见的错误率与延迟设告警，再补容量和依赖信号；表达式设置持续窗口并附上 runbook、owner 和看板链接。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>回放短尖峰、持续失败和无流量场景，确认不会因瞬时噪声报警，也不会把无数据当健康。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>记录告警精确率、确认时长和静默次数；阈值变更可独立回退并保留历史对比。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 告警规则、单元测试样本和 Runbook。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>promtool check rules alerts.yml &amp;&amp; promtool test rules alerts.test.yml</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：用错误预算告警订单-API"><a href="#完整实战：用错误预算告警订单-API" class="headerlink" title="完整实战：用错误预算告警订单 API"></a>完整实战：用错误预算告警订单 API</h2><p>假设订单 API 的可用性 SLO 为 30 天 99.9%，SLI 定义为“非预期 5xx 的请求比例”。告警不直接写“5 分钟错误率大于某个固定数”，而是比较当前消耗错误预算的速度，并组合长短窗口：短窗口发现剧烈故障，长窗口过滤瞬时噪声。</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><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">(</span><br><span class="line">  sum(rate(http_requests_total&#123;service=&quot;order-api&quot;,status=~&quot;5..&quot;&#125;[5m]))</span><br><span class="line">  /</span><br><span class="line">  sum(rate(http_requests_total&#123;service=&quot;order-api&quot;&#125;[5m]))</span><br><span class="line">) &gt; (14.4 * 0.001)</span><br><span class="line">and</span><br><span class="line">(</span><br><span class="line">  sum(rate(http_requests_total&#123;service=&quot;order-api&quot;,status=~&quot;5..&quot;&#125;[1h]))</span><br><span class="line">  /</span><br><span class="line">  sum(rate(http_requests_total&#123;service=&quot;order-api&quot;&#125;[1h]))</span><br><span class="line">) &gt; (14.4 * 0.001)</span><br></pre></td></tr></table></figure><p>倍数和窗口只是演示，需按本项目 SLO、流量和通知响应时间推导。低流量路由还应设置最小请求量，避免一次失败触发毫无行动价值的百分比告警。</p><h3 id="一条可执行告警包含什么"><a href="#一条可执行告警包含什么" class="headerlink" title="一条可执行告警包含什么"></a>一条可执行告警包含什么</h3><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><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">annotations:</span></span><br><span class="line">  <span class="attr">summary:</span> <span class="string">&quot;订单 API 正在快速消耗错误预算&quot;</span></span><br><span class="line">  <span class="attr">impact:</span> <span class="string">&quot;部分用户可能无法创建或查询订单&quot;</span></span><br><span class="line">  <span class="attr">dashboard:</span> <span class="string">&quot;https://monitor.example.invalid/d/order-api&quot;</span></span><br><span class="line">  <span class="attr">runbook:</span> <span class="string">&quot;https://docs.example.invalid/runbooks/order-api-errors&quot;</span></span><br><span class="line"><span class="attr">labels:</span></span><br><span class="line">  <span class="attr">severity:</span> <span class="string">page</span></span><br><span class="line">  <span class="attr">service:</span> <span class="string">order-api</span></span><br><span class="line">  <span class="attr">owner:</span> <span class="string">checkout</span></span><br></pre></td></tr></table></figure><p>标签必须低基数且稳定，不要把 pod、异常文本或用户 ID 放进告警标签。runbook 应写首个只读检查、常见原因、止损动作、升级路径和验证恢复的查询。</p><table><thead><tr><th>演练</th><th>预期</th></tr></thead><tbody><tr><td>单实例 5xx</td><td>聚合告警一次，能下钻到实例</td></tr><tr><td>短暂尖峰</td><td>短窗口变化但不持续通知</td></tr><tr><td>全区域故障</td><td>快速 page，路由到实际值班人</td></tr><tr><td>指标停止上报</td><td>独立“遥测缺失”信号，不误报服务健康</td></tr><tr><td>故障恢复</td><td>满足恢复窗口后自动解除并保留事件</td></tr></tbody></table><p>用历史数据回放规则，统计本应触发和不应触发的时间段；上线先静默观察，再接低打扰渠道，最后才 page。每月检查告警量、有效率、确认时间、修复时间和无人负责的规则。临时静默必须有范围、原因和到期时间，不能通过永久加大阈值消除噪声。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/">Prometheus 告警规则</a></li><li><a href="https://prometheus.io/docs/prometheus/latest/configuration/unit_testing_rules/">Prometheus 规则单元测试</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/07/28/prometheus-alert-design/</id>
    <link href="https://www.cjjhc.cn/2026/07/28/prometheus-alert-design/"/>
    <published>2026-07-28T01:00:00.000Z</published>
    <summary>设计能说明影响、持续时间和处理动作的告警，减少噪声。</summary>
    <title>Prometheus 告警设计：从有数据到可行动</title>
    <updated>2026-07-28T01:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>柠萌</name>
    </author>
    <category term="云原生" scheme="https://www.cjjhc.cn/categories/%E4%BA%91%E5%8E%9F%E7%94%9F/"/>
    <category term="Argo CD" scheme="https://www.cjjhc.cn/tags/Argo-CD/"/>
    <category term="GitOps" scheme="https://www.cjjhc.cn/tags/GitOps/"/>
    <category term="持续交付" scheme="https://www.cjjhc.cn/tags/%E6%8C%81%E7%BB%AD%E4%BA%A4%E4%BB%98/"/>
    <content>
      <![CDATA[<p>让 Git 成为集群期望状态的唯一来源，并让变更过程可审计、可回滚。</p><span id="more"></span><p><a href="/images/tech/diagrams/gitops-reconcile.svg"><img src="/images/tech/diagrams/gitops-reconcile.svg" alt="应用仓库、制品仓库、环境仓库、调和控制器和集群组成的 GitOps 闭环图"></a></p><p><em>图：环境仓固定制品摘要，控制器持续比较 Git 期望状态与集群实际状态；点击查看原图。</em></p><h2 id="核心思路"><a href="#核心思路" class="headerlink" title="核心思路"></a>核心思路</h2><p>将应用清单与环境差异分离，生产分支启用评审保护。Argo CD 负责比较和同步，镜像构建流水线只更新版本，不直接持有集群管理员权限。</p><h2 id="落地步骤"><a href="#落地步骤" class="headerlink" title="落地步骤"></a>落地步骤</h2><p>自动同步适合成熟服务；数据库迁移和高风险变更可保留人工窗口。告警应覆盖同步失败、健康异常和持续漂移。</p><h2 id="验证清单"><a href="#验证清单" class="headerlink" title="验证清单"></a>验证清单</h2><p>实施后不要只确认“能运行”，还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录，后续维护会轻松很多。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>好的工程实践不是堆叠工具，而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题，再逐步自动化。</p><h2 id="可执行实践"><a href="#可执行实践" class="headerlink" title="可执行实践"></a>可执行实践</h2><p>仓库保存期望状态，CI 只构建镜像并更新不可变版本，Argo CD 负责差异与同步。为生产项目限制可用仓库、目标 namespace 和集群资源类型。</p><h2 id="验证与退出条件"><a href="#验证与退出条件" class="headerlink" title="验证与退出条件"></a>验证与退出条件</h2><p>先在手动同步模式观察 diff，再逐步开启自动同步与 self-heal。对于数据库迁移和删除资源，保留审批窗口并监控持续漂移。</p><h2 id="实践场景"><a href="#实践场景" class="headerlink" title="实践场景"></a>实践场景</h2><p>以“让集群状态由受审查的仓库版本驱动”为例。实施前先保存当前版本、配置和关键指标基线，写明成功条件与停止条件；涉及生产数据或外部写入时，先在隔离环境验证。</p><h2 id="分步实施"><a href="#分步实施" class="headerlink" title="分步实施"></a>分步实施</h2><p>分离应用源码与部署声明，固定镜像摘要；为同步顺序、健康检查和敏感配置建立清晰边界。每完成一步都记录可观察结果，不把命令返回零等同于业务正确。</p><h2 id="故障演练"><a href="#故障演练" class="headerlink" title="故障演练"></a>故障演练</h2><p>手工修改集群资源制造漂移，再提交一个错误声明，确认系统能发现差异且不会绕过保护直接删除关键资源。演练应使用可清理的测试数据，并验证告警、日志和恢复路径，而不只是确认系统最终又能访问。</p><h2 id="观测与回滚"><a href="#观测与回滚" class="headerlink" title="观测与回滚"></a>观测与回滚</h2><p>观察 OutOfSync、Health 和同步耗时；故障时回退 Git 提交并让控制器重新收敛。变更单中应写清负责人、观察窗口、阈值和回滚触发器，避免故障发生后再临时争论。</p><p>完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。</p><h2 id="第二轮实践复核"><a href="#第二轮实践复核" class="headerlink" title="第二轮实践复核"></a>第二轮实践复核</h2><p><strong>可复用交付物：</strong> 不可变部署声明、同步策略和回滚提交。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境；文章中的命令包含占位符，必须替换成自己的非生产目标后再执行。</p><p><strong>最低验证：</strong> <code>argocd app diff demo &amp;&amp; argocd app get demo</code></p><p>执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入，先使用隔离环境或 dry-run；任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。</p><h2 id="完整实战：让-Git、集群和发布证据对齐"><a href="#完整实战：让-Git、集群和发布证据对齐" class="headerlink" title="完整实战：让 Git、集群和发布证据对齐"></a>完整实战：让 Git、集群和发布证据对齐</h2><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></pre></td><td class="code"><pre><span class="line"><span class="attr">image:</span></span><br><span class="line">  <span class="attr">repository:</span> <span class="string">registry.example.com/order-api</span></span><br><span class="line">  <span class="attr">digest:</span> <span class="string">sha256:0123456789abcdef...</span></span><br></pre></td></tr></table></figure><p>使用摘要而非 <code>latest</code>，让 Git 中的期望状态精确指向已扫描制品。自动化提交包含源提交、镜像摘要和验证结果；部署控制器只拥有目标 namespace 所需权限，不能让普通应用仓库间接修改集群级 RBAC。</p><h3 id="同步策略按资源风险分层"><a href="#同步策略按资源风险分层" class="headerlink" title="同步策略按资源风险分层"></a>同步策略按资源风险分层</h3><table><thead><tr><th>资源</th><th>建议策略</th><th>原因</th></tr></thead><tbody><tr><td>Deployment&#x2F;Service</td><td>自动同步 + 健康检查</td><td>易回退、状态清晰</td></tr><tr><td>数据库迁移 Job</td><td>独立阶段、幂等、人工门禁</td><td>有数据副作用</td></tr><tr><td>CRD&#x2F;RBAC</td><td>单独应用与审阅</td><td>影响范围大</td></tr><tr><td>删除共享资源</td><td>默认禁止自动 prune</td><td>恢复成本高</td></tr></tbody></table><p>同步波次只能表达顺序，不能保证业务就绪。每个阶段要有真实健康条件；PreSync hook 重试时必须幂等，hook 完成记录不能是唯一审计证据。</p><h3 id="漂移、回滚与紧急修改"><a href="#漂移、回滚与紧急修改" class="headerlink" title="漂移、回滚与紧急修改"></a>漂移、回滚与紧急修改</h3><p>故意手改一个低风险副本数，确认控制器能报告 drift，并按策略纠正。生产紧急修改若无法等待 Git 流程，要记录操作者、原因和时限，随后立即回写 Git；否则下一次 reconcile 会覆盖现场修改。</p><p>回滚通过环境仓库提交把摘要恢复到上一已验证版本。若新版本执行了不兼容数据迁移，单纯回滚清单不会恢复数据，因此迁移应采用 expand&#x2F;contract 并独立验证。观察 sync 状态、health、reconcile 时长、漂移数、hook 失败和应用 SLO；控制器异常时先暂停自动化副作用，保留 Git 与集群证据，不要循环点击强制同步。</p><h3 id="一手资料"><a href="#一手资料" class="headerlink" title="一手资料"></a>一手资料</h3><ul><li><a href="https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/">Argo CD 同步阶段与波次</a></li><li><a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/health/">Argo CD 健康检查</a></li></ul><blockquote><p>资料与版本边界复核日期：2026-08-22。工具行为可能演进，实施前以锁定版本对应的官方文档为准。</p></blockquote>]]>
    </content>
    <id>https://www.cjjhc.cn/2026/07/27/gitops-with-argocd/</id>
    <link href="https://www.cjjhc.cn/2026/07/27/gitops-with-argocd/"/>
    <published>2026-07-27T01:00:00.000Z</published>
    <summary>让 Git 成为集群期望状态的唯一来源，并让变更过程可审计、可回滚。</summary>
    <title>用 Argo CD 实践 GitOps</title>
    <updated>2026-07-27T01:00:00.000Z</updated>
  </entry>
</feed>
