<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Go-Advanced on Tarragon</title><link>https://tarrragon.github.io/blog/tags/go-advanced/</link><description>Recent content in Go-Advanced on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 22 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/go-advanced/index.xml" rel="self" type="application/rss+xml"/><item><title>1.1 channel ownership 與關閉責任</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/</guid><description>&lt;p>Channel ownership 的核心規則是：能保證不再送出資料的一方，才有資格關閉 channel。建立 channel 的程式碼不一定是 owner；真正的 owner 是掌握 send lifecycle 的元件。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 send lifecycle 判斷誰能 close channel&lt;/li>
&lt;li>分辨 sender、receiver、coordinator 的責任&lt;/li>
&lt;li>用 channel direction 表達能力限制&lt;/li>
&lt;li>設計多 sender 的安全關閉流程&lt;/li>
&lt;li>用 context 表達接收端提早停止，而不是關閉不屬於自己的 channel&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察close-channel-的核心風險是責任不清">【觀察】close channel 的核心風險是責任不清&lt;/h2>
&lt;p>Channel 關閉錯誤的核心問題是 ownership 沒定義清楚。接收端想停止讀取時關閉輸入 channel，多個 sender 中任一個 sender 自行 close，共用 channel 被外部任意 close，這些都可能造成 panic 或資料遺失。&lt;/p>
&lt;p>責任不清的示例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Consume&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nf">handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的問題是 &lt;code>Consume&lt;/code> 只是 receiver，卻關閉了 sender 還可能使用的 channel。只要上游晚一點送資料，就會出現 &lt;code>send on closed channel&lt;/code>。&lt;/p>
&lt;h2 id="判讀close-的語意是不再有新值">【判讀】close 的語意是不再有新值&lt;/h2>
&lt;p>&lt;code>close(ch)&lt;/code> 的核心語意是「這個 channel 不會再收到新值」。它不是取消 goroutine 的通用手段，也不是釋放記憶體的必要動作。&lt;/p>
&lt;p>單一 sender 可以安全 close：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Produce&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">items&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">item&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 goroutine 是唯一 sender，因此它能保證迴圈結束後不再送出。receiver 可以用 &lt;code>range&lt;/code> 讀到 channel 關閉為止：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nf">Produce&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;b&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>receiver 不需要 close &lt;code>out&lt;/code>。接收完資料是 receiver 的狀態，不代表 sender 的生命週期已經結束。&lt;/p>
&lt;h2 id="策略先畫出-sender-和-receiver">【策略】先畫出 sender 和 receiver&lt;/h2>
&lt;p>Channel 設計的核心動作是先列出誰會 send、誰會 receive、誰知道所有 sender 已經結束。這比先決定 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 大小更重要。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>角色&lt;/th>
 &lt;th>能力&lt;/th>
 &lt;th>close 責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>single sender&lt;/td>
 &lt;td>送出資料，知道自己何時結束&lt;/td>
 &lt;td>擁有 close 責任&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>receiver&lt;/td>
 &lt;td>接收資料，可能提早停止&lt;/td>
 &lt;td>透過 context 通知停止&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>coordinator&lt;/td>
 &lt;td>等待所有 sender 結束&lt;/td>
 &lt;td>擁有 close 責任&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>external caller&lt;/td>
 &lt;td>持有 channel reference 但不了解生命週期&lt;/td>
 &lt;td>不參與 close 決策&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果無法回答「誰知道所有 sender 都結束」，就不應該 close 這個 channel。沒有 close 不一定是 bug；錯誤 close 才是更嚴重的問題。&lt;/p>
&lt;h2 id="執行多-sender-需要-coordinator-關閉">【執行】多 sender 需要 coordinator 關閉&lt;/h2>
&lt;p>多個 goroutine 送往同一個 channel 時，關閉責任必須交給 coordinator。任一 sender 都不能單方面 close，因為其他 sender 可能還在送。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputs&lt;/span> &lt;span class="o">...&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputs&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">inputs&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">input&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>轉送 goroutine 只負責送資料。另一個 goroutine 等所有 sender 結束後才 close &lt;code>out&lt;/code>。這把「送資料」和「宣告所有資料送完」分成兩個責任。&lt;/p></description><content:encoded><![CDATA[<p>Channel ownership 的核心規則是：能保證不再送出資料的一方，才有資格關閉 channel。建立 channel 的程式碼不一定是 owner；真正的 owner 是掌握 send lifecycle 的元件。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 send lifecycle 判斷誰能 close channel</li>
<li>分辨 sender、receiver、coordinator 的責任</li>
<li>用 channel direction 表達能力限制</li>
<li>設計多 sender 的安全關閉流程</li>
<li>用 context 表達接收端提早停止，而不是關閉不屬於自己的 channel</li>
</ol>
<hr>
<h2 id="觀察close-channel-的核心風險是責任不清">【觀察】close channel 的核心風險是責任不清</h2>
<p>Channel 關閉錯誤的核心問題是 ownership 沒定義清楚。接收端想停止讀取時關閉輸入 channel，多個 sender 中任一個 sender 自行 close，共用 channel 被外部任意 close，這些都可能造成 panic 或資料遺失。</p>
<p>責任不清的示例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">Consume</span><span class="p">(</span><span class="nx">input</span> <span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">for</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">input</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nf">handle</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式的問題是 <code>Consume</code> 只是 receiver，卻關閉了 sender 還可能使用的 channel。只要上游晚一點送資料，就會出現 <code>send on closed channel</code>。</p>
<h2 id="判讀close-的語意是不再有新值">【判讀】close 的語意是不再有新值</h2>
<p><code>close(ch)</code> 的核心語意是「這個 channel 不會再收到新值」。它不是取消 goroutine 的通用手段，也不是釋放記憶體的必要動作。</p>
<p>單一 sender 可以安全 close：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">Produce</span><span class="p">(</span><span class="nx">items</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">items</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">item</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 goroutine 是唯一 sender，因此它能保證迴圈結束後不再送出。receiver 可以用 <code>range</code> 讀到 channel 關閉為止：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nf">Produce</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>receiver 不需要 close <code>out</code>。接收完資料是 receiver 的狀態，不代表 sender 的生命週期已經結束。</p>
<h2 id="策略先畫出-sender-和-receiver">【策略】先畫出 sender 和 receiver</h2>
<p>Channel 設計的核心動作是先列出誰會 send、誰會 receive、誰知道所有 sender 已經結束。這比先決定 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 大小更重要。</p>
<table>
  <thead>
      <tr>
          <th>角色</th>
          <th>能力</th>
          <th>close 責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>single sender</td>
          <td>送出資料，知道自己何時結束</td>
          <td>擁有 close 責任</td>
      </tr>
      <tr>
          <td>receiver</td>
          <td>接收資料，可能提早停止</td>
          <td>透過 context 通知停止</td>
      </tr>
      <tr>
          <td>coordinator</td>
          <td>等待所有 sender 結束</td>
          <td>擁有 close 責任</td>
      </tr>
      <tr>
          <td>external caller</td>
          <td>持有 channel reference 但不了解生命週期</td>
          <td>不參與 close 決策</td>
      </tr>
  </tbody>
</table>
<p>如果無法回答「誰知道所有 sender 都結束」，就不應該 close 這個 channel。沒有 close 不一定是 bug；錯誤 close 才是更嚴重的問題。</p>
<h2 id="執行多-sender-需要-coordinator-關閉">【執行】多 sender 需要 coordinator 關閉</h2>
<p>多個 goroutine 送往同一個 channel 時，關閉責任必須交給 coordinator。任一 sender 都不能單方面 close，因為其他 sender 可能還在送。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">Merge</span><span class="p">(</span><span class="nx">inputs</span> <span class="o">...&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="nx">inputs</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">input</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">inputs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">input</span> <span class="o">:=</span> <span class="nx">input</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">for</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">input</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">event</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nb">close</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>轉送 goroutine 只負責送資料。另一個 goroutine 等所有 sender 結束後才 close <code>out</code>。這把「送資料」和「宣告所有資料送完」分成兩個責任。</p>
<h2 id="執行接收端提早停止要用-context">【執行】接收端提早停止要用 context</h2>
<p>Receiver 提早停止的核心做法是通知上游停止，而不是關閉輸入 channel。<code>context.Context</code> 是 Go 服務中最常見的停止訊號。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">Consume</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">input</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">input</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nf">handle</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Consume</code> 可以因為 context 取消而退出，也可以因為 input 關閉而退出。它沒有 close <code>input</code>，因為 input 的 send lifecycle 不屬於它。</p>
<p>這個邊界在服務中很重要。HTTP handler、background worker、connection writer 都可能提早退出，但不能任意 close 上游仍可能使用的 channel。</p>
<h2 id="策略channel-direction-把能力寫進型別">【策略】channel direction 把能力寫進型別</h2>
<p>Channel direction 的核心價值是限制函式能做的事。<code>chan&lt;- T</code> 只能 send，<code>&lt;-chan T</code> 只能 receive；這讓 ownership 更容易被讀者看見。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">StartWorker</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">,</span> <span class="nx">results</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Result</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">results</span> <span class="o">&lt;-</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>StartWorker</code> 只能從 <code>jobs</code> 收資料，只能往 <code>results</code> 送資料。它不能 close <code>jobs</code>，因為型別上就不是 sender；它是否能 close <code>results</code> 則要看它是不是唯一 sender。</p>
<p>方向限制不會自動解決所有權，但它能減少誤用，也讓 API 比註解更可靠。</p>
<h2 id="判讀done-channel-和-data-channel-分開表達不同語意">【判讀】done channel 和 data channel 分開表達不同語意</h2>
<p>停止訊號的核心語意應該和資料流分開。資料 channel 傳遞值；done channel 或 context 表示停止。把兩者混在一起會讓 close 的語意變模糊。</p>
<p>較清楚的設計：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Worker</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">w</span><span class="p">.</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>jobs</code> 關閉代表沒有更多 job。<code>ctx.Done()</code> 代表上層要求停止。這兩種退出原因不同，分開處理才能在 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、metric 或測試中看清楚。</p>
<h2 id="測試測試-close-行為要避免靠-sleep">【測試】測試 close 行為要避免靠 sleep</h2>
<p>Channel ownership 的測試目標是確認 sender 結束後會 close、receiver 取消時不會 panic、多 sender 等全部完成才 close。這類測試應使用 channel 同步，不應依賴任意 sleep。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestProduceClosesOutput</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">output</span> <span class="o">:=</span> <span class="nf">Produce</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="nx">got</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">output</span><span class="p">;</span> <span class="nx">got</span> <span class="o">!=</span> <span class="s">&#34;a&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;first value = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="s">&#34;a&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">output</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;output should be closed after producer finishes&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>多 sender 測試可以讀到輸出 channel 關閉為止，確認所有值都收到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestMergeClosesAfterAllInputsClose</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">a</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">b</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">a</span> <span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;a&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">b</span> <span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;b&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nb">close</span><span class="p">(</span><span class="nx">a</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nb">close</span><span class="p">(</span><span class="nx">b</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">output</span> <span class="o">:=</span> <span class="nf">Merge</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">bool</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">output</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">got</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">got</span><span class="p">[</span><span class="s">&#34;a&#34;</span><span class="p">]</span> <span class="o">||</span> <span class="p">!</span><span class="nx">got</span><span class="p">[</span><span class="s">&#34;b&#34;</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;merge should forward all events before closing&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試沒有固定等待時間。它把 channel close 本身當成同步訊號，結果更穩定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先聚焦單一 Go process 內的 channel close 與 goroutine lifecycle；跨 process 的 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、[<a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> group](/go-advanced/backend/knowledge-cards/consumer-group) 與分散式訊號，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel、goroutine 與 select 的協作；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Channel ownership 的核心是 send lifecycle。唯一 sender 可以在送完後 close；多 sender 需要 coordinator 統一 close；receiver 想停止時應使用 context，而不是關閉輸入 channel。把 sender、receiver、coordinator 分清楚，才能避免 <code>send on closed channel</code>、goroutine leak 與資料流混亂。</p>
]]></content:encoded></item><item><title>2.1 read pump / write pump 模式</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/</guid><description>&lt;p>Read pump / write pump 的核心規則是單一 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 連線的讀取與寫入必須分成兩個協調的 goroutine。Read pump 擁有讀取權，write pump 擁有寫入權；其他元件不直接操作底層 connection，而是透過 channel 或 method 協作。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 read pump、write pump、hub 的責任&lt;/li>
&lt;li>避免多 goroutine 同時寫同一條 WebSocket connection&lt;/li>
&lt;li>用 send channel 作為 server-to-client 推送邊界&lt;/li>
&lt;li>設計 client unregister 與 close path&lt;/li>
&lt;li>用 fake router 測試 read pump 的行為邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察websocket-是一條長生命週期雙向連線">【觀察】WebSocket 是一條長生命週期雙向連線&lt;/h2>
&lt;p>WebSocket 連線的核心特徵是 client 和 server 都可能主動送訊息。Client 可能送 subscribe、unsubscribe、ping 或 command；server 可能推送 notification、status update 或 error。&lt;/p>
&lt;p>若讀寫責任不分開，程式很容易出現這種結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleConnection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">msg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">serverMessages&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">msg&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">msg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;subscribe&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;subscribed&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的問題是多個路徑可能同時寫 connection：背景 goroutine 寫推送，read loop 裡也直接寫回應。多個 goroutine 同時寫 WebSocket 會讓錯誤、資料交錯與 close path 變得難以推理。&lt;/p>
&lt;h2 id="判讀read-pump-和-write-pump-是-ownership-邊界">【判讀】read pump 和 write pump 是 ownership 邊界&lt;/h2>
&lt;p>Read pump / write pump 的核心價值是 ownership。Read pump 是唯一讀取者，write pump 是唯一寫入者，其他元件只能透過它們的公開邊界互動。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Client&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>conn&lt;/code> 是底層連線，&lt;code>send&lt;/code> 是 server 要推給 client 的訊息佇列。其他元件不直接呼叫 &lt;code>conn.WriteJSON&lt;/code>，而是把 &lt;code>ServerMessage&lt;/code> 放進 &lt;code>send&lt;/code>。&lt;/p>
&lt;p>責任表：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>元件&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>不應做的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>read pump&lt;/td>
 &lt;td>讀 client message、交給 router&lt;/td>
 &lt;td>直接寫 WebSocket&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>write pump&lt;/td>
 &lt;td>寫 server message、送 heartbeat、送 close&lt;/td>
 &lt;td>處理 client action&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hub&lt;/td>
 &lt;td>註冊、取消註冊、廣播&lt;/td>
 &lt;td>直接讀寫 connection&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>router&lt;/td>
 &lt;td>解析 action、呼叫 usecase 或更新訂閱&lt;/td>
 &lt;td>關閉底層 connection&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個分工讓連線生命週期可以被測試與替換，而不是散在多個 goroutine 裡。&lt;/p>
&lt;h2 id="策略client-型別要表達連線邊界">【策略】Client 型別要表達連線邊界&lt;/h2>
&lt;p>Client 型別的核心責任是封裝單一連線的狀態與輸出佇列。它不應包含整個系統的業務狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Client&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kd">struct&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendBuffer&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">conn&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendBuffer&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kd">struct&lt;/span>&lt;span class="p">{}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>send&lt;/code> 有固定容量，避免慢 client 無限制累積訊息。&lt;code>subscriptions&lt;/code> 屬於這條連線的狀態，若會被多個 goroutine 讀寫，就需要 mutex 或集中到 hub event loop。&lt;/p></description><content:encoded><![CDATA[<p>Read pump / write pump 的核心規則是單一 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 連線的讀取與寫入必須分成兩個協調的 goroutine。Read pump 擁有讀取權，write pump 擁有寫入權；其他元件不直接操作底層 connection，而是透過 channel 或 method 協作。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 read pump、write pump、hub 的責任</li>
<li>避免多 goroutine 同時寫同一條 WebSocket connection</li>
<li>用 send channel 作為 server-to-client 推送邊界</li>
<li>設計 client unregister 與 close path</li>
<li>用 fake router 測試 read pump 的行為邊界</li>
</ol>
<hr>
<h2 id="觀察websocket-是一條長生命週期雙向連線">【觀察】WebSocket 是一條長生命週期雙向連線</h2>
<p>WebSocket 連線的核心特徵是 client 和 server 都可能主動送訊息。Client 可能送 subscribe、unsubscribe、ping 或 command；server 可能推送 notification、status update 或 error。</p>
<p>若讀寫責任不分開，程式很容易出現這種結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">handleConnection</span><span class="p">(</span><span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">for</span> <span class="nx">msg</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">serverMessages</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">msg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="kd">var</span> <span class="nx">msg</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">msg</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">if</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">Action</span> <span class="o">==</span> <span class="s">&#34;subscribe&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;subscribed&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式的問題是多個路徑可能同時寫 connection：背景 goroutine 寫推送，read loop 裡也直接寫回應。多個 goroutine 同時寫 WebSocket 會讓錯誤、資料交錯與 close path 變得難以推理。</p>
<h2 id="判讀read-pump-和-write-pump-是-ownership-邊界">【判讀】read pump 和 write pump 是 ownership 邊界</h2>
<p>Read pump / write pump 的核心價值是 ownership。Read pump 是唯一讀取者，write pump 是唯一寫入者，其他元件只能透過它們的公開邊界互動。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">id</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>conn</code> 是底層連線，<code>send</code> 是 server 要推給 client 的訊息佇列。其他元件不直接呼叫 <code>conn.WriteJSON</code>，而是把 <code>ServerMessage</code> 放進 <code>send</code>。</p>
<p>責任表：</p>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>責任</th>
          <th>不應做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>read pump</td>
          <td>讀 client message、交給 router</td>
          <td>直接寫 WebSocket</td>
      </tr>
      <tr>
          <td>write pump</td>
          <td>寫 server message、送 heartbeat、送 close</td>
          <td>處理 client action</td>
      </tr>
      <tr>
          <td>hub</td>
          <td>註冊、取消註冊、廣播</td>
          <td>直接讀寫 connection</td>
      </tr>
      <tr>
          <td>router</td>
          <td>解析 action、呼叫 usecase 或更新訂閱</td>
          <td>關閉底層 connection</td>
      </tr>
  </tbody>
</table>
<p>這個分工讓連線生命週期可以被測試與替換，而不是散在多個 goroutine 裡。</p>
<h2 id="策略client-型別要表達連線邊界">【策略】Client 型別要表達連線邊界</h2>
<p>Client 型別的核心責任是封裝單一連線的狀態與輸出佇列。它不應包含整個系統的業務狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">id</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">subscriptions</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">NewClient</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">,</span> <span class="nx">sendBuffer</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="nx">Client</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">id</span><span class="p">:</span>            <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">conn</span><span class="p">:</span>          <span class="nx">conn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">send</span><span class="p">:</span>          <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">ServerMessage</span><span class="p">,</span> <span class="nx">sendBuffer</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">subscriptions</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>send</code> 有固定容量，避免慢 client 無限制累積訊息。<code>subscriptions</code> 屬於這條連線的狀態，若會被多個 goroutine 讀寫，就需要 mutex 或集中到 hub event loop。</p>
<h2 id="執行read-pump-只處理-client-輸入">【執行】read pump 只處理 client 輸入</h2>
<p>Read pump 的核心責任是從 connection 讀訊息、轉成 <code>ClientMessage</code>、交給 router。它不應直接操作所有業務規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">MessageRouter</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">readPump</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">hub</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">,</span> <span class="nx">router</span> <span class="nx">MessageRouter</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">c</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="kd">var</span> <span class="nx">message</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">c</span><span class="p">,</span> <span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">c</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                <span class="nx">Error</span><span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nf">Error</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="p">})</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Read pump 收到 read error 時退出，並通知 hub unregister。這裡不直接 close <code>send</code>，因為 <code>send</code> 的關閉責任交給 hub 統一處理。</p>
<h2 id="執行write-pump-是唯一寫入者">【執行】write pump 是唯一寫入者</h2>
<p>Write pump 的核心責任是把 <code>send</code> channel 裡的 server message 寫回 WebSocket。所有寫入都集中在這一個 goroutine，能避免 concurrent write。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">writePump</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">message</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">send</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteMessage</span><span class="p">(</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">CloseMessage</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>當 <code>send</code> 被關閉時，write pump 送出 close message 並退出。這表示 hub 或 connection manager 是 <code>send</code> 的 owner，write pump 是 receiver。</p>
<p>下一章會把 heartbeat ticker 加進 write pump。原則不變：ping 也是寫入，所以也要由 write pump 統一執行。</p>
<h2 id="策略send-channel-是推送邊界">【策略】send channel 是推送邊界</h2>
<p><code>send</code> channel 的核心意義是把內部事件轉成 client 輸出佇列。其他元件可以嘗試送訊息，但不能直接寫 connection。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>TrySend</code> 使用 non-blocking send，表示 client <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿時不阻塞呼叫端。Hub 可以根據 <code>false</code> 決定丟棄訊息、取消註冊 client 或記錄 metric。</p>
<p>這個方法把 WebSocket 寫入問題轉成前一模組的 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 問題：滿載時要有明確策略。</p>
<h2 id="執行hub-統一管理-unregister">【執行】hub 統一管理 unregister</h2>
<p>Unregister 的核心目標是讓清理流程只有一個責任中心。Read pump、write pump、heartbeat 都可能發現連線失效，但不要讓每個地方各自 close channel 和 connection。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Hub</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">clients</span>    <span class="kd">map</span><span class="p">[</span><span class="o">*</span><span class="nx">Client</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">register</span>   <span class="kd">chan</span> <span class="o">*</span><span class="nx">Client</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">unregister</span> <span class="kd">chan</span> <span class="o">*</span><span class="nx">Client</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">broadcast</span>  <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">run</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">case</span> <span class="nx">client</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">h</span><span class="p">.</span><span class="nx">register</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">case</span> <span class="nx">client</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                <span class="nb">delete</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="nb">close</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="nx">_</span> <span class="p">=</span> <span class="nx">client</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個設計讓 <code>client.send</code> 只會在 hub 中被 close。其他 goroutine 只送 unregister 訊號，不直接關閉資源。</p>
<p>實務上要避免重複 unregister 造成 channel 重複 close。上例透過 <code>clients</code> map 判斷 client 是否仍註冊，讓 unregister 具備 idempotent 行為。</p>
<h2 id="判讀read-pump-結束不代表-write-pump-立刻結束">【判讀】read pump 結束不代表 write pump 立刻結束</h2>
<p>Read pump 與 write pump 的核心關係是協作，不是互相任意關閉。Read pump 發現錯誤後通知 hub；hub 關閉 <code>send</code>；write pump 收到 <code>send</code> 關閉後送 close message 並退出。</p>
<p>流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">read error
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">hub.unregister &lt;- client
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   ▼
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">hub closes client.send and conn
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">   ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">write pump exits</span></span></code></pre></div><p>這條路徑讓 close ownership 清楚。若 read pump 同時 close <code>send</code>，hub 也 close <code>send</code>，就會有 double close panic。</p>
<h2 id="測試router-可以用-fake-驗證-read-pump-邊界">【測試】router 可以用 fake 驗證 read pump 邊界</h2>
<p>Read pump 測試的核心目標是確認 client message 會交給 router，而不是在 read pump 裡塞入業務邏輯。完整 WebSocket integration test 可以留到測試模組；這裡先用 router 的小介面讓行為可替換。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeRouter</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">messages</span> <span class="p">[]</span><span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">fakeRouter</span><span class="p">)</span> <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">messages</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">messages</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若測試需要真實 connection，可用 <code>httptest.Server</code> 建立 WebSocket。若只測 router 規則，應直接測 router，不必繞過 network。</p>
<p>Write pump 的測試通常放在 integration test，因為它依賴真實 connection 寫入行為。單元測試則可以集中在 <code>TrySend</code>、router、hub unregister 這些純邊界。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一連線的 read/write ownership；跨節點 hub 與 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 互動，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine ownership、channel 與 backpressure；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">Go：channel ownership 與關閉責任</a></li>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Read pump / write pump 模式把一條 WebSocket 連線拆成清楚的 ownership：read pump 讀 client message，write pump 寫 server message，hub 統一註冊與清理。<code>send</code> channel 是推送邊界，所有 close path 應收斂到同一個 unregister 流程。這樣長連線才不會因為 concurrent write、double close 或慢 client 而失控。</p>
]]></content:encoded></item><item><title>3.1 GC 與 memory limit</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/</guid><description>&lt;p>GC 與 memory limit 的核心關係是：Go runtime 會根據 heap 成長決定何時執行 GC，而 memory limit 讓 runtime 有一個軟性記憶體目標。Memory limit 不是硬性上限，也不是 leak 修復工具；它是讓 runtime 更早回應記憶體壓力的控制訊號。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 heap growth、GOGC 與 GC 頻率的關係&lt;/li>
&lt;li>判斷 &lt;code>debug.SetMemoryLimit&lt;/code> 能解決什麼、不能解決什麼&lt;/li>
&lt;li>從環境變數設定服務 memory limit&lt;/li>
&lt;li>用 runtime &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 觀察調整效果&lt;/li>
&lt;li>分辨 GC 壓力、長期保留與真正 leak&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察長時間服務的記憶體問題通常是趨勢問題">【觀察】長時間服務的記憶體問題通常是趨勢問題&lt;/h2>
&lt;p>記憶體診斷的核心觀察是趨勢。Heap 是否持續上升、GC 後是否下降、goroutine 是否增加、某個操作後是否留下無法回收的資料，這些都比「現在用了多少 MB」更重要。&lt;/p>
&lt;p>常見現象：&lt;/p>
&lt;ul>
&lt;li>啟動後 heap 穩定在某個區間：通常正常。&lt;/li>
&lt;li>每次高峰後 heap 都能下降：可能是短暫配置。&lt;/li>
&lt;li>GC 後 heap 仍持續上升：可能有長期保留或 leak。&lt;/li>
&lt;li>GC 次數快速增加且 CPU 升高：可能是 allocation 壓力。&lt;/li>
&lt;li>goroutine 與 heap 同時增加：可能是 goroutine leak 或 send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 堆積。&lt;/li>
&lt;/ul>
&lt;p>Memory limit 可以幫 runtime 更積極控制 heap，但不能替代趨勢判讀。&lt;/p>
&lt;h2 id="判讀gc-控制的是-heap-成長">【判讀】GC 控制的是 heap 成長&lt;/h2>
&lt;p>Go GC 的核心目標是回收不再被引用的 heap 物件。Runtime 會根據 &lt;code>GOGC&lt;/code> 控制下一次 GC 觸發點。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">GOGC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">100&lt;/span> go run ./cmd/server&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>GOGC=100&lt;/code> 大致表示 heap 在上次 GC 後成長約 100% 時觸發下一次 GC。數字越小，GC 越頻繁，記憶體通常較低但 CPU 成本較高；數字越大，GC 較少，記憶體通常較高但 CPU 成本較低。&lt;/p>
&lt;p>這是取捨，不是調大或調小就一定更好。CPU 緊繃的服務可能不能承受過低 &lt;code>GOGC&lt;/code>；記憶體緊繃的服務可能不能承受過高 &lt;code>GOGC&lt;/code>。&lt;/p>
&lt;h2 id="判讀memory-limit-是-runtime-軟目標">【判讀】memory limit 是 runtime 軟目標&lt;/h2>
&lt;p>&lt;code>debug.SetMemoryLimit&lt;/code> 的核心用途是告訴 Go runtime 希望整體記憶體使用量靠近某個目標。當 runtime 接近目標時，會更積極回收 heap。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">configureRuntime&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">const&lt;/span> &lt;span class="nx">limit&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">512&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&lt;/span> &lt;span class="mi">20&lt;/span> &lt;span class="c1">// 512 MiB&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">debug&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetMemoryLimit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">limit&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這不是作業系統層級的硬限制。程式仍可能短暫超過這個值，特別是有大量非 Go heap 記憶體、cgo、mmap、大型 byte slice 尖峰或外部 library 配置時。&lt;/p>
&lt;p>Memory limit 適合容器、桌面常駐服務、背景 worker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> server 這類需要避免吃掉過多資源的服務。若部署平台已有 memory limit，Go runtime 的 limit 通常應略低於平台限制，留給非 Go heap 與系統開銷。&lt;/p>
&lt;h2 id="執行設定值應來自部署環境">【執行】設定值應來自部署環境&lt;/h2>
&lt;p>Memory limit 的核心配置原則是由部署環境決定，而不是寫死在 library 裡。應用入口可以讀取環境變數，解析後設定 runtime。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">ConfigureMemoryLimitFromEnv&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">raw&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_MEMORY_LIMIT_MB&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">mb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">strconv&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Atoi&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;parse APP_MEMORY_LIMIT_MB: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">mb&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_MEMORY_LIMIT_MB must be positive&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">debug&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetMemoryLimit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mb&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>錯誤應在啟動時明確失敗。服務若用錯誤設定悄悄運行，後續記憶體行為會很難解釋。&lt;/p></description><content:encoded><![CDATA[<p>GC 與 memory limit 的核心關係是：Go runtime 會根據 heap 成長決定何時執行 GC，而 memory limit 讓 runtime 有一個軟性記憶體目標。Memory limit 不是硬性上限，也不是 leak 修復工具；它是讓 runtime 更早回應記憶體壓力的控制訊號。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 heap growth、GOGC 與 GC 頻率的關係</li>
<li>判斷 <code>debug.SetMemoryLimit</code> 能解決什麼、不能解決什麼</li>
<li>從環境變數設定服務 memory limit</li>
<li>用 runtime <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 觀察調整效果</li>
<li>分辨 GC 壓力、長期保留與真正 leak</li>
</ol>
<hr>
<h2 id="觀察長時間服務的記憶體問題通常是趨勢問題">【觀察】長時間服務的記憶體問題通常是趨勢問題</h2>
<p>記憶體診斷的核心觀察是趨勢。Heap 是否持續上升、GC 後是否下降、goroutine 是否增加、某個操作後是否留下無法回收的資料，這些都比「現在用了多少 MB」更重要。</p>
<p>常見現象：</p>
<ul>
<li>啟動後 heap 穩定在某個區間：通常正常。</li>
<li>每次高峰後 heap 都能下降：可能是短暫配置。</li>
<li>GC 後 heap 仍持續上升：可能有長期保留或 leak。</li>
<li>GC 次數快速增加且 CPU 升高：可能是 allocation 壓力。</li>
<li>goroutine 與 heap 同時增加：可能是 goroutine leak 或 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 堆積。</li>
</ul>
<p>Memory limit 可以幫 runtime 更積極控制 heap，但不能替代趨勢判讀。</p>
<h2 id="判讀gc-控制的是-heap-成長">【判讀】GC 控制的是 heap 成長</h2>
<p>Go GC 的核心目標是回收不再被引用的 heap 物件。Runtime 會根據 <code>GOGC</code> 控制下一次 GC 觸發點。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">GOGC</span><span class="o">=</span><span class="m">100</span> go run ./cmd/server</span></span></code></pre></div><p><code>GOGC=100</code> 大致表示 heap 在上次 GC 後成長約 100% 時觸發下一次 GC。數字越小，GC 越頻繁，記憶體通常較低但 CPU 成本較高；數字越大，GC 較少，記憶體通常較高但 CPU 成本較低。</p>
<p>這是取捨，不是調大或調小就一定更好。CPU 緊繃的服務可能不能承受過低 <code>GOGC</code>；記憶體緊繃的服務可能不能承受過高 <code>GOGC</code>。</p>
<h2 id="判讀memory-limit-是-runtime-軟目標">【判讀】memory limit 是 runtime 軟目標</h2>
<p><code>debug.SetMemoryLimit</code> 的核心用途是告訴 Go runtime 希望整體記憶體使用量靠近某個目標。當 runtime 接近目標時，會更積極回收 heap。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">configureRuntime</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">const</span> <span class="nx">limit</span> <span class="p">=</span> <span class="mi">512</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span> <span class="c1">// 512 MiB</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">debug</span><span class="p">.</span><span class="nf">SetMemoryLimit</span><span class="p">(</span><span class="nx">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這不是作業系統層級的硬限制。程式仍可能短暫超過這個值，特別是有大量非 Go heap 記憶體、cgo、mmap、大型 byte slice 尖峰或外部 library 配置時。</p>
<p>Memory limit 適合容器、桌面常駐服務、背景 worker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> server 這類需要避免吃掉過多資源的服務。若部署平台已有 memory limit，Go runtime 的 limit 通常應略低於平台限制，留給非 Go heap 與系統開銷。</p>
<h2 id="執行設定值應來自部署環境">【執行】設定值應來自部署環境</h2>
<p>Memory limit 的核心配置原則是由部署環境決定，而不是寫死在 library 裡。應用入口可以讀取環境變數，解析後設定 runtime。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">ConfigureMemoryLimitFromEnv</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">raw</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_MEMORY_LIMIT_MB&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">raw</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">mb</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">Atoi</span><span class="p">(</span><span class="nx">raw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse APP_MEMORY_LIMIT_MB: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">mb</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;APP_MEMORY_LIMIT_MB must be positive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">debug</span><span class="p">.</span><span class="nf">SetMemoryLimit</span><span class="p">(</span><span class="nb">int64</span><span class="p">(</span><span class="nx">mb</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>錯誤應在啟動時明確失敗。服務若用錯誤設定悄悄運行，後續記憶體行為會很難解釋。</p>
<h2 id="策略runtime-metrics-用來看調整是否有效">【策略】runtime metrics 用來看調整是否有效</h2>
<p>Runtime metrics 的核心用途是驗證調整效果。只改 <code>GOGC</code> 或 memory limit，不看 heap 與 GC 趨勢，容易變成憑感覺調參。</p>
<p>簡單方式可以用 <code>runtime.ReadMemStats</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">ReadHeapAlloc</span><span class="p">()</span> <span class="kt">uint64</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">var</span> <span class="nx">stats</span> <span class="nx">runtime</span><span class="p">.</span><span class="nx">MemStats</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">ReadMemStats</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">stats</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="nx">stats</span><span class="p">.</span><span class="nx">HeapAlloc</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>較完整的服務可以使用 <code>runtime/metrics</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">ReadRuntimeSamples</span><span class="p">()</span> <span class="p">[]</span><span class="nx">metrics</span><span class="p">.</span><span class="nx">Sample</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">samples</span> <span class="o">:=</span> <span class="p">[]</span><span class="nx">metrics</span><span class="p">.</span><span class="nx">Sample</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;/memory/classes/heap/objects:bytes&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;/gc/cycles/total:gc-cycles&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;/sched/goroutines:goroutines&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">metrics</span><span class="p">.</span><span class="nf">Read</span><span class="p">(</span><span class="nx">samples</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="nx">samples</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>觀察時要看趨勢：調整後 heap 峰值是否下降、GC 次數是否合理、CPU 是否上升、goroutine 是否仍持續增加。</p>
<h2 id="判讀memory-limit-不能修正仍被引用的資料">【判讀】memory limit 不能修正仍被引用的資料</h2>
<p>Memory limit 的核心邊界是它只能影響 GC 行為，不能讓仍被引用的物件消失。若程式把資料一直留在 map、slice、cache、goroutine 或 send buffer 裡，GC 不能回收。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">cache</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">byte</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">func</span> <span class="nf">SavePayload</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">payload</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">cache</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">payload</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 <code>cache</code> 沒有大小限制、<a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a> 或刪除策略，memory limit 只會讓 GC 更常跑，但資料仍被 <code>cache</code> 引用。真正修正是設計 cache 淘汰、分頁、快照大小限制或資料釋放路徑。</p>
<p>因此遇到 heap 持續上升時，下一步是用 pprof 確認誰保留了記憶體。</p>
<h2 id="策略判斷是-gc-壓力還是長期保留">【策略】判斷是 GC 壓力還是長期保留</h2>
<p>記憶體問題的核心分流是：物件被大量配置但很快回收，還是物件被長期保留。</p>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>可能問題</th>
          <th>下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>alloc_space</code> 高，<code>inuse_space</code> 不高</td>
          <td>短命配置多，GC 壓力大</td>
          <td>找熱路徑 allocation</td>
      </tr>
      <tr>
          <td><code>inuse_space</code> 持續上升</td>
          <td>長期保留或 leak</td>
          <td>看 heap profile retainers</td>
      </tr>
      <tr>
          <td>goroutine 數量同步上升</td>
          <td>goroutine leak 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 堆積</td>
          <td>看 goroutine profile</td>
      </tr>
      <tr>
          <td>GC 次數暴增但 heap 仍高</td>
          <td>memory limit 壓力或資料保留</td>
          <td>檢查 cache/map/buffer</td>
      </tr>
  </tbody>
</table>
<p>這個分流會決定後續工具。GC 參數能緩解壓力，但保留資料要回到資料結構與 lifecycle 修。</p>
<h2 id="測試runtime-設定函式可以獨立測解析">【測試】runtime 設定函式可以獨立測解析</h2>
<p>Runtime 本身不需要在單元測試中反覆調參。應把環境解析邏輯獨立出來，測試輸入與錯誤即可。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">ParseMemoryLimitMB</span><span class="p">(</span><span class="nx">raw</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">int64</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">raw</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">mb</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">Atoi</span><span class="p">(</span><span class="nx">raw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse memory limit: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">mb</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;memory limit must be positive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">mb</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestParseMemoryLimitMB</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParseMemoryLimitMB</span><span class="p">(</span><span class="s">&#34;512&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;parse memory limit: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="mi">512</span><span class="o">&lt;&lt;</span><span class="mi">20</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;limit = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nb">int64</span><span class="p">(</span><span class="mi">512</span><span class="o">&lt;&lt;</span><span class="mi">20</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這讓設定邏輯可測，而不需要在每個測試中真的改 runtime 狀態。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go process 如何判讀 heap、GC 與 memory limit；平台 OOM 與部署合約，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 runtime 壓力、allocation 與 pprof 診斷；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">Go：資料結構與 allocation 壓力</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">Go：goroutine leak 偵測</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>GC 控制 heap 回收節奏，memory limit 給 runtime 一個記憶體軟目標。合理設定能降低長時間服務的資源風險，但不能修正 cache、map、slice、goroutine 或 buffer 長期持有資料。診斷時先看趨勢，再用 pprof 區分 GC 壓力與長期保留。</p>
]]></content:encoded></item><item><title>4.1 事件來源、處理流程與狀態邊界</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/</guid><description>&lt;p>事件系統的核心邊界是把「收到訊號」、「轉成事件」、「套用規則」、「更新狀態」與「輸出結果」拆開。每個邊界都應該有自己的型別與測試，否則一個 handler 或 worker 很快就會同時負責協定、驗證、去重、狀態與推送。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event source、normalizer、processor、repository、publisher 的責任&lt;/li>
&lt;li>用 Go interface 表達元件能力，而不是表達資料夾模板&lt;/li>
&lt;li>把外部格式限制在 adapter 內&lt;/li>
&lt;li>讓狀態更新集中到 repository 或 state owner&lt;/li>
&lt;li>用測試驗證每個邊界是否可替換&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察事件流程容易被寫成一團">【觀察】事件流程容易被寫成一團&lt;/h2>
&lt;p>事件流程膨脹的常見原因是入口程式碼太方便。HTTP handler 可以 decode JSON、驗證欄位、查 map、送通知；worker 也可以讀 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、判斷重複、更新狀態、寫 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>。短期看起來直接，長期會讓每個入口都複製一套規則。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;missing id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">seen&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusNoContent&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">seen&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">states&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">AccountID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Broadcast&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">AccountID&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的問題是責任混在一起。HTTP 協定、輸入格式、去重策略、狀態更新與推送規則都被綁在同一個函式，任何一項改變都會影響整個入口。&lt;/p>
&lt;h2 id="判讀事件邊界應該按照責任切開">【判讀】事件邊界應該按照責任切開&lt;/h2>
&lt;p>事件邊界的核心規則是每一層只知道自己必須知道的資訊。adapter 知道外部協定，normalizer 知道格式轉換，processor 知道事件規則，repository 知道狀態保存，publisher 知道輸出方式。&lt;/p>
&lt;p>一個可維護的事件流程可以長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">HTTP / queue / timer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> adapter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │ raw input
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> normalizer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> │ DomainEvent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├── deduper
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> ├── repository
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> └── publisher&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是依賴方向的要求。外部來源依賴內部事件模型；內部處理流程不依賴外部 raw payload。&lt;/p>
&lt;h2 id="策略先定義內部事件模型">【策略】先定義內部事件模型&lt;/h2>
&lt;p>內部事件模型的核心責任是提供穩定語意。不同來源可以有不同欄位名稱與時間格式，但進入 processor 前都應轉成同一種事件。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventNotificationCreated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification.created&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventAccountActivated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account.activated&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventJobFinished&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job.finished&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceHTTPCallback&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;http_callback&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceQueue&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;queue&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceTimer&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;timer&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DomainEvent&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">Source&lt;/span> &lt;span class="nx">EventSource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">ReceivedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>OccurredAt&lt;/code> 是事件發生時間，&lt;code>ReceivedAt&lt;/code> 是系統收到時間。這兩個欄位要分開，因為外部事件可能延遲送達；去重與排序通常看事件語意時間，操作監控通常看收到時間。&lt;/p>
&lt;h2 id="執行adapter-只負責外部格式">【執行】adapter 只負責外部格式&lt;/h2>
&lt;p>adapter 的核心責任是把外部輸入轉成內部事件或 command。它可以知道 JSON tag、HTTP status、queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、header，但不應直接修改狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;event_id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;account_id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventName&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;event_name&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Timestamp&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;timestamp&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">CallbackHandler&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">CallbackHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusInternalServerError&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;process_event_failed&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>handler 的測試應該檢查 HTTP 行為與 normalize 錯誤對應。事件規則的測試不應透過 HTTP handler 才能執行，否則 processor 的變化會被協定細節干擾。&lt;/p></description><content:encoded><![CDATA[<p>事件系統的核心邊界是把「收到訊號」、「轉成事件」、「套用規則」、「更新狀態」與「輸出結果」拆開。每個邊界都應該有自己的型別與測試，否則一個 handler 或 worker 很快就會同時負責協定、驗證、去重、狀態與推送。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 event source、normalizer、processor、repository、publisher 的責任</li>
<li>用 Go interface 表達元件能力，而不是表達資料夾模板</li>
<li>把外部格式限制在 adapter 內</li>
<li>讓狀態更新集中到 repository 或 state owner</li>
<li>用測試驗證每個邊界是否可替換</li>
</ol>
<hr>
<h2 id="觀察事件流程容易被寫成一團">【觀察】事件流程容易被寫成一團</h2>
<p>事件流程膨脹的常見原因是入口程式碼太方便。HTTP handler 可以 decode JSON、驗證欄位、查 map、送通知；worker 也可以讀 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、判斷重複、更新狀態、寫 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。短期看起來直接，長期會讓每個入口都複製一套規則。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">handleCallback</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">CallbackPayload</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;missing id&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">seen</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusNoContent</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">seen</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">states</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">]</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">,</span> <span class="s">&#34;active&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式的問題是責任混在一起。HTTP 協定、輸入格式、去重策略、狀態更新與推送規則都被綁在同一個函式，任何一項改變都會影響整個入口。</p>
<h2 id="判讀事件邊界應該按照責任切開">【判讀】事件邊界應該按照責任切開</h2>
<p>事件邊界的核心規則是每一層只知道自己必須知道的資訊。adapter 知道外部協定，normalizer 知道格式轉換，processor 知道事件規則，repository 知道狀態保存，publisher 知道輸出方式。</p>
<p>一個可維護的事件流程可以長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">HTTP / queue / timer
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    adapter
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        │ raw input
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   normalizer
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        │ DomainEvent
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">   processor
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ├── deduper
</span></span><span class="line"><span class="ln">13</span><span class="cl">        ├── repository
</span></span><span class="line"><span class="ln">14</span><span class="cl">        └── publisher</span></span></code></pre></div><p>這是依賴方向的要求。外部來源依賴內部事件模型；內部處理流程不依賴外部 raw payload。</p>
<h2 id="策略先定義內部事件模型">【策略】先定義內部事件模型</h2>
<p>內部事件模型的核心責任是提供穩定語意。不同來源可以有不同欄位名稱與時間格式，但進入 processor 前都應轉成同一種事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventType</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventNotificationCreated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;notification.created&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">EventAccountActivated</span>    <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;account.activated&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">EventJobFinished</span>         <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;job.finished&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">EventSource</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">SourceHTTPCallback</span> <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;http_callback&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">SourceQueue</span>        <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;queue&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">SourceTimer</span>        <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;timer&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">type</span> <span class="nx">DomainEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">ID</span>         <span class="kt">string</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">Source</span>     <span class="nx">EventSource</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">Type</span>       <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">SubjectID</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">OccurredAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">ReceivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">Payload</span>    <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>OccurredAt</code> 是事件發生時間，<code>ReceivedAt</code> 是系統收到時間。這兩個欄位要分開，因為外部事件可能延遲送達；去重與排序通常看事件語意時間，操作監控通常看收到時間。</p>
<h2 id="執行adapter-只負責外部格式">【執行】adapter 只負責外部格式</h2>
<p>adapter 的核心責任是把外部輸入轉成內部事件或 command。它可以知道 JSON tag、HTTP status、queue <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、header，但不應直接修改狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">CallbackPayload</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">EventID</span>   <span class="kt">string</span> <span class="s">`json:&#34;event_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">AccountID</span> <span class="kt">string</span> <span class="s">`json:&#34;account_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventName</span> <span class="kt">string</span> <span class="s">`json:&#34;event_name&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Timestamp</span> <span class="kt">string</span> <span class="s">`json:&#34;timestamp&#34;`</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">type</span> <span class="nx">CallbackHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">CallbackHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="kd">var</span> <span class="nx">payload</span> <span class="nx">CallbackPayload</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">payload</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeCallback</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_event&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;process_event_failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 的測試應該檢查 HTTP 行為與 normalize 錯誤對應。事件規則的測試不應透過 HTTP handler 才能執行，否則 processor 的變化會被協定細節干擾。</p>
<h2 id="執行normalizer-負責轉換與基本合約">【執行】normalizer 負責轉換與基本合約</h2>
<p>normalizer 的核心責任是把 raw input 轉成 <code>DomainEvent</code>，並拒絕語意不完整的資料。它是外部世界與內部模型的邊界。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NormalizeCallback</span><span class="p">(</span><span class="nx">raw</span> <span class="nx">CallbackPayload</span><span class="p">,</span> <span class="nx">receivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">occurredAt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">RFC3339</span><span class="p">,</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse timestamp: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">EventID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>     <span class="nx">SourceHTTPCallback</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nf">mapCallbackEventName</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">EventName</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">occurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">receivedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">return</span> <span class="nx">event</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="nf">Validate</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Type</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event type is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;subject id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;occurred at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;received at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>validation 應該保護 envelope 的必要欄位。更細的 payload 規則可以放在特定事件的 normalizer 或 processor，避免 <code>Validate</code> 變成所有事件的巨大規則表。</p>
<h2 id="執行processor-負責事件規則">【執行】processor 負責事件規則</h2>
<p>processor 的核心責任是套用內部事件規則。它可以驗證、去重、更新狀態、寫入事件紀錄、呼叫 publisher，但不應知道 HTTP body 或 queue message 的原始格式。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">Deduper</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">Publisher</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">deduper</span>    <span class="nx">Deduper</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">Publisher</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">duplicated</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;dedup event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="nx">duplicated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;apply event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;publish event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 processor 依賴能力介面，不依賴具體實作。Go 的 implicit interface 讓 memory repository、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> repository 或測試 fake 都可以自然接上。</p>
<h2 id="判讀publisher-失敗策略必須明確">【判讀】publisher 失敗策略必須明確</h2>
<p>publisher 的核心問題是「輸出失敗是否影響狀態成功」。即時通知、審計紀錄、外部 <a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> 的可靠性要求不同，不能一律用同一個錯誤策略。</p>
<p>常見策略：</p>
<table>
  <thead>
      <tr>
          <th>輸出類型</th>
          <th>失敗策略</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時 UI 推送</td>
          <td>記錄錯誤，可允許狀態已更新</td>
          <td>客戶端可重新查詢最新狀態</td>
      </tr>
      <tr>
          <td>事件紀錄</td>
          <td>失敗時中止流程</td>
          <td>紀錄是不可遺失的資料</td>
      </tr>
      <tr>
          <td>外部 webhook</td>
          <td>寫入 outbox，稍後重試</td>
          <td>下游需要可靠接收</td>
      </tr>
  </tbody>
</table>
<p>若 <code>repository.Apply</code> 成功但 <code>publisher.Publish</code> 失敗，系統必須知道這是可接受的降級，還是需要重試與補償。這個決策應該寫在 processor 或 usecase 的設計裡，不應藏在 publisher implementation。</p>
<h2 id="測試每個邊界分開測">【測試】每個邊界分開測</h2>
<p>事件邊界的測試目標是讓錯誤定位清楚。normalizer 測 raw input 轉換，processor 測規則順序，repository 測狀態一致性，publisher 測輸出協定。</p>
<p>processor fake test 範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestProcessorSkipsDuplicateEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nx">EventProcessor</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">deduper</span><span class="p">:</span>    <span class="nx">fakeDeduper</span><span class="p">{</span><span class="nx">duplicated</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="o">&amp;</span><span class="nx">fakeRepository</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">publisher</span><span class="p">:</span>  <span class="o">&amp;</span><span class="nx">fakePublisher</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nx">EventAccountActivated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">processor</span><span class="p">.</span><span class="nx">repository</span><span class="p">.(</span><span class="o">*</span><span class="nx">fakeRepository</span><span class="p">).</span><span class="nx">applied</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;duplicate event should not update repository&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試不需要 HTTP server。它直接驗證 processor 的規則：重複事件不應更新狀態，也不應送出推送。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go 服務內的事件來源與處理邊界；分散式一致性與 event sourcing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 action、event、repository 與 publisher 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go：逐步遷移到 ports/adapters 架構</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件系統的可維護性來自清楚邊界：adapter 處理外部格式，normalizer 建立內部事件，processor 套用規則，repository 擁有狀態，publisher 輸出結果。當每個元件只承擔一種責任時，新增來源、新增事件或替換儲存實作都會變成局部修改。</p>
]]></content:encoded></item><item><title>5.1 時間注入與狀態轉移測試</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/</guid><description>&lt;p>時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、ticker 或過期判斷，測試就不應依賴真實等待。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>now time.Time&lt;/code> 測試純狀態轉移&lt;/li>
&lt;li>用 &lt;code>func() time.Time&lt;/code> 注入長生命週期元件的時間來源&lt;/li>
&lt;li>用 table-driven test 覆蓋時間邊界&lt;/li>
&lt;li>把 ticker 排程與單次工作拆開測&lt;/li>
&lt;li>避免 &lt;code>time.Sleep&lt;/code> 造成慢且不穩定的測試&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察直接呼叫-timenow-會讓測試失去控制">【觀察】直接呼叫 time.Now 會讓測試失去控制&lt;/h2>
&lt;p>時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 &lt;code>time.Now()&lt;/code>，測試就無法完整控制輸入。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Since&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式看起來簡單，但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間，結果慢且不穩定。&lt;/p>
&lt;h2 id="判讀時間是狀態轉移的輸入">【判讀】時間是狀態轉移的輸入&lt;/h2>
&lt;p>時間測試的核心判讀是：如果時間會影響結果，時間就是輸入。把 &lt;code>now&lt;/code> 放進函式簽名，會讓狀態轉移規則變得可測。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Job&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">StartedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">FinishedAt&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sub&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>now&lt;/code> 是明確輸入，因此測試可以建立任何時間點。這也讓讀者一眼看出 &lt;code>Status&lt;/code> 看的是 &lt;code>Job&lt;/code> 與目前時間的關係。&lt;/p>
&lt;h2 id="執行用-table-driven-test-描述時間邊界">【執行】用 table-driven test 描述時間邊界&lt;/h2>
&lt;p>時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變，table-driven test 能讓這些情境集中呈現。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestStatus&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">startedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;active before idle threshold&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">4&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;idle after threshold&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">6&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;completed ignores idle threshold&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">30&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nx">FinishedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">ptrTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Status() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試不需要 &lt;code>time.Sleep&lt;/code>。案例名稱直接描述時間邊界，失敗時能快速定位是哪個規則壞了。&lt;/p>
&lt;h2 id="策略長生命週期元件用-time-provider">【策略】長生命週期元件用 time provider&lt;/h2>
&lt;p>Time provider 的核心用途是讓元件在多個方法中取得時間，但測試仍能控制時間來源。最輕量的形式是 &lt;code>func() time.Time&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Monitor&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewMonitor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Monitor&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">now&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Now&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Monitor&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span> &lt;span class="nx">Monitor&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Snapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>測試提供固定時間：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestMonitorSnapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">fixedNow&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">monitor&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewMonitor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fixedNow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Snapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">fixedNow&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">10&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;snapshot = %q, want idle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這比導入大型 clock framework 更輕量，也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求，再考慮統一 clock interface。&lt;/p></description><content:encoded><![CDATA[<p>時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a>、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、ticker 或過期判斷，測試就不應依賴真實等待。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>now time.Time</code> 測試純狀態轉移</li>
<li>用 <code>func() time.Time</code> 注入長生命週期元件的時間來源</li>
<li>用 table-driven test 覆蓋時間邊界</li>
<li>把 ticker 排程與單次工作拆開測</li>
<li>避免 <code>time.Sleep</code> 造成慢且不穩定的測試</li>
</ol>
<hr>
<h2 id="觀察直接呼叫-timenow-會讓測試失去控制">【觀察】直接呼叫 time.Now 會讓測試失去控制</h2>
<p>時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 <code>time.Now()</code>，測試就無法完整控制輸入。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;completed&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Since</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StartedAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;idle&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式看起來簡單，但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間，結果慢且不穩定。</p>
<h2 id="判讀時間是狀態轉移的輸入">【判讀】時間是狀態轉移的輸入</h2>
<p>時間測試的核心判讀是：如果時間會影響結果，時間就是輸入。把 <code>now</code> 放進函式簽名，會讓狀態轉移規則變得可測。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Job</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">StartedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;completed&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StartedAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;idle&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>now</code> 是明確輸入，因此測試可以建立任何時間點。這也讓讀者一眼看出 <code>Status</code> 看的是 <code>Job</code> 與目前時間的關係。</p>
<h2 id="執行用-table-driven-test-描述時間邊界">【執行】用 table-driven test 描述時間邊界</h2>
<p>時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變，table-driven test 能讓這些情境集中呈現。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestStatus</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">startedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">now</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">job</span>  <span class="nx">Job</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">want</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;active before idle threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">4</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">job</span><span class="p">:</span>  <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;idle after threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">6</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">job</span><span class="p">:</span>  <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;idle&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;completed ignores idle threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">job</span><span class="p">:</span> <span class="nx">Job</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">                <span class="nx">StartedAt</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">                <span class="nx">FinishedAt</span><span class="p">:</span> <span class="nf">ptrTime</span><span class="p">(</span><span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;completed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">now</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;Status() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要 <code>time.Sleep</code>。案例名稱直接描述時間邊界，失敗時能快速定位是哪個規則壞了。</p>
<h2 id="策略長生命週期元件用-time-provider">【策略】長生命週期元件用 time provider</h2>
<p>Time provider 的核心用途是讓元件在多個方法中取得時間，但測試仍能控制時間來源。最輕量的形式是 <code>func() time.Time</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Monitor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="nf">NewMonitor</span><span class="p">(</span><span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">Monitor</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">now</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">now</span> <span class="p">=</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Now</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">Monitor</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">now</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">m</span> <span class="nx">Monitor</span><span class="p">)</span> <span class="nf">Snapshot</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">m</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span> <span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試提供固定時間：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestMonitorSnapshot</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">fixedNow</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">monitor</span> <span class="o">:=</span> <span class="nf">NewMonitor</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">fixedNow</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nx">monitor</span><span class="p">.</span><span class="nf">Snapshot</span><span class="p">(</span><span class="nx">Job</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">fixedNow</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="o">-</span><span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="s">&#34;idle&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;snapshot = %q, want idle&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這比導入大型 clock framework 更輕量，也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求，再考慮統一 clock interface。</p>
<h2 id="判讀ticker-測試要拆排程與工作">【判讀】Ticker 測試要拆排程與工作</h2>
<p>Ticker 的核心問題是它同時包含「何時觸發」與「觸發時做什麼」。測試時應把單次工作抽出來，避免為了測狀態規則而等待 ticker。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Worker</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">syncOnce</span> <span class="kd">func</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">interval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">interval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">w</span><span class="p">.</span><span class="nf">syncOnce</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>SyncOnce</code> 可以單獨測規則，<code>Run</code> 只需要少數測試確認 context 取消與 ticker 排程。不要讓每個狀態測試都真的啟動 ticker。</p>
<h2 id="測試run-測試應用-context-控制退出">【測試】Run 測試應用 context 控制退出</h2>
<p>長生命週期 worker 的測試核心是讓退出條件可控。若只想測 context 取消，先取消 context 再呼叫 <code>Run</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestRunStopsWhenContextCanceled</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">syncOnce</span><span class="p">:</span> <span class="kd">func</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;syncOnce should not be called&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;Run() error = %v, want context canceled&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要等待一小時。<code>time.Hour</code> 只是確保 ticker 不會在測試中自然觸發，真正的退出由 context 控制。</p>
<h2 id="判讀sleep-based-test-應該是例外">【判讀】sleep-based test 應該是例外</h2>
<p>Sleep-based test 的核心問題是慢、不穩定、難以定位。排程、CI 負載與機器速度都可能讓測試偶發失敗。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestStatusWithSleep</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">start</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="mi">6</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span> <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">start</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">got</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試不應存在。它拖慢測試套件，仍然不能保證結果穩定。正確做法是直接建構 <code>now</code> 與 <code>StartedAt</code>。</p>
<p>若真的要等待非同步事件，應使用 deadline 與條件重試，而不是固定 sleep；下一章的 integration test 會使用這個原則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理時間作為輸入的可測性；更完整的 fake clock 與平台 timeout 合約，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 defer、select loop 與 timeout 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">Go：defer 與資源清理</a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>時間控制測試的重點是把時間變成可指定輸入。純邏輯用 <code>now time.Time</code>，長生命週期元件用 <code>func() time.Time</code>，ticker 排程和單次工作分開測。避免 <code>time.Sleep</code>，測試才會快速、穩定且可重現。</p>
]]></content:encoded></item><item><title>6.1 graceful shutdown 與 signal handling</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Graceful shutdown&lt;/a> 的核心目標是服務收到停止訊號後，不再接受新工作，並給既有工作一段時間完成或清理。Go 服務通常用 signal、root context、&lt;code>http.Server.Shutdown&lt;/code>、worker context 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 串起停止流程。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>把 OS signal 轉成 root context 取消&lt;/li>
&lt;li>用 &lt;code>http.Server.Shutdown&lt;/code> 停止接受新 request&lt;/li>
&lt;li>讓 worker、hub、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> pump 觀察同一個停止訊號&lt;/li>
&lt;li>設計 shutdown timeout 與強制退出邊界&lt;/li>
&lt;li>測試 server 與 worker 的停止流程&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察直接結束-process-會留下不確定狀態">【觀察】直接結束 process 會留下不確定狀態&lt;/h2>
&lt;p>Shutdown 的核心風險是停止流程不明確。服務可能正在處理 request、WebSocket client 仍在線、worker 正在寫資料、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 尚未 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、diagnostics 還以為服務可接流量。&lt;/p>
&lt;p>不完整停止常見後果：&lt;/p>
&lt;ul>
&lt;li>新 request 在服務即將關閉時仍被接受。&lt;/li>
&lt;li>WebSocket client 沒收到 close，server 端 goroutine 殘留。&lt;/li>
&lt;li>背景 worker 寫到一半被中斷。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a> 還是 200，負載平衡器繼續送流量。&lt;/li>
&lt;li>測試結束後留下 goroutine 或開放 port。&lt;/li>
&lt;/ul>
&lt;p>Graceful shutdown 是讓停止策略可預期。&lt;/p>
&lt;h2 id="判讀shutdown-是多階段流程">【判讀】shutdown 是多階段流程&lt;/h2>
&lt;p>Graceful shutdown 的核心流程是先停止接新工作，再讓既有工作收尾，最後釋放資源。&lt;/p>
&lt;p>建議順序：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">receive SIGINT/SIGTERM
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">cancel root context
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ├── readiness becomes false
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├── HTTP server stops accepting new requests
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ├── workers stop consuming new jobs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── WebSocket hub unregisters clients
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> └── diagnostics/log records shutdown reason
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">wait within timeout
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">process exits&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不同服務會有不同細節，但核心不變：停止訊號要集中，元件各自完成自己的 cleanup，整體流程要有 timeout。&lt;/p>
&lt;h2 id="執行signal-轉成-root-context">【執行】signal 轉成 root context&lt;/h2>
&lt;p>Signal handling 的核心責任是把作業系統訊號轉成應用程式可理解的取消訊號。Go 1.16 之後可以使用 &lt;code>signal.NotifyContext&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">stop&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">signal&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NotifyContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Interrupt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">syscall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SIGTERM&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nf">stop&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ctx&lt;/code> 是 root context。HTTP server、worker、hub、diagnostics 都應從它派生出自己的 lifecycle，而不是每個元件各自監聽 signal。&lt;/p>
&lt;p>Signal handler 不應放大量清理邏輯。它只負責發出停止意圖；實際清理由各元件在自己的 ownership 邊界內完成。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Graceful shutdown</a> 的核心目標是服務收到停止訊號後，不再接受新工作，並給既有工作一段時間完成或清理。Go 服務通常用 signal、root context、<code>http.Server.Shutdown</code>、worker context 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 串起停止流程。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>把 OS signal 轉成 root context 取消</li>
<li>用 <code>http.Server.Shutdown</code> 停止接受新 request</li>
<li>讓 worker、hub、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> pump 觀察同一個停止訊號</li>
<li>設計 shutdown timeout 與強制退出邊界</li>
<li>測試 server 與 worker 的停止流程</li>
</ol>
<hr>
<h2 id="觀察直接結束-process-會留下不確定狀態">【觀察】直接結束 process 會留下不確定狀態</h2>
<p>Shutdown 的核心風險是停止流程不明確。服務可能正在處理 request、WebSocket client 仍在線、worker 正在寫資料、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 尚未 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、diagnostics 還以為服務可接流量。</p>
<p>不完整停止常見後果：</p>
<ul>
<li>新 request 在服務即將關閉時仍被接受。</li>
<li>WebSocket client 沒收到 close，server 端 goroutine 殘留。</li>
<li>背景 worker 寫到一半被中斷。</li>
<li><a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a> 還是 200，負載平衡器繼續送流量。</li>
<li>測試結束後留下 goroutine 或開放 port。</li>
</ul>
<p>Graceful shutdown 是讓停止策略可預期。</p>
<h2 id="判讀shutdown-是多階段流程">【判讀】shutdown 是多階段流程</h2>
<p>Graceful shutdown 的核心流程是先停止接新工作，再讓既有工作收尾，最後釋放資源。</p>
<p>建議順序：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">receive SIGINT/SIGTERM
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">cancel root context
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        ├── readiness becomes false
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        ├── HTTP server stops accepting new requests
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        ├── workers stop consuming new jobs
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ├── WebSocket hub unregisters clients
</span></span><span class="line"><span class="ln">10</span><span class="cl">        └── diagnostics/log records shutdown reason
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln">13</span><span class="cl">wait within timeout
</span></span><span class="line"><span class="ln">14</span><span class="cl">        │
</span></span><span class="line"><span class="ln">15</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln">16</span><span class="cl">process exits</span></span></code></pre></div><p>不同服務會有不同細節，但核心不變：停止訊號要集中，元件各自完成自己的 cleanup，整體流程要有 timeout。</p>
<h2 id="執行signal-轉成-root-context">【執行】signal 轉成 root context</h2>
<p>Signal handling 的核心責任是把作業系統訊號轉成應用程式可理解的取消訊號。Go 1.16 之後可以使用 <code>signal.NotifyContext</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">stop</span> <span class="o">:=</span> <span class="nx">signal</span><span class="p">.</span><span class="nf">NotifyContext</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">os</span><span class="p">.</span><span class="nx">Interrupt</span><span class="p">,</span> <span class="nx">syscall</span><span class="p">.</span><span class="nx">SIGTERM</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">defer</span> <span class="nf">stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ctx</code> 是 root context。HTTP server、worker、hub、diagnostics 都應從它派生出自己的 lifecycle，而不是每個元件各自監聽 signal。</p>
<p>Signal handler 不應放大量清理邏輯。它只負責發出停止意圖；實際清理由各元件在自己的 ownership 邊界內完成。</p>
<h2 id="執行http-server-用-shutdown-停止接新-request">【執行】HTTP server 用 Shutdown 停止接新 request</h2>
<p><code>http.Server.Shutdown</code> 的核心行為是停止接受新連線，並等待既有 request 在 timeout 內完成。它比直接 <code>Close</code> 更適合 graceful shutdown。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RunHTTPServer</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">handler</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Server</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">Addr</span><span class="p">:</span>    <span class="s">&#34;:8080&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Handler</span><span class="p">:</span> <span class="nx">handler</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">errCh</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">errCh</span> <span class="o">&lt;-</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">shutdownCtx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="mi">10</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">defer</span> <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span> <span class="nx">server</span><span class="p">.</span><span class="nf">Shutdown</span><span class="p">(</span><span class="nx">shutdownCtx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">case</span> <span class="nx">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">errCh</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ErrServerClosed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Shutdown timeout 是必要邊界。沒有 timeout 的 shutdown 可能永遠等待某個卡住 request；timeout 太短則可能讓合理 request 來不及收尾。</p>
<h2 id="策略readiness-應先變成-false">【策略】readiness 應先變成 false</h2>
<p>Readiness 的核心用途是控制服務是否應接新流量。Shutdown 開始後，readiness 應先變成 false，再停止 server 或等待既有工作。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Lifecycle</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">shuttingDown</span> <span class="nx">atomic</span><span class="p">.</span><span class="nx">Bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">l</span> <span class="o">*</span><span class="nx">Lifecycle</span><span class="p">)</span> <span class="nf">BeginShutdown</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">l</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">l</span> <span class="o">*</span><span class="nx">Lifecycle</span><span class="p">)</span> <span class="nf">Ready</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="p">!</span><span class="nx">l</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Load</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Signal 收到後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">lifecycle</span><span class="p">.</span><span class="nf">BeginShutdown</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nf">cancel</span><span class="p">()</span></span></span></code></pre></div><p>這讓負載平衡器或監控能知道服務不應再接新流量。Process 還活著，但 readiness 已經反映操作狀態。</p>
<h2 id="執行背景工作要觀察-context">【執行】背景工作要觀察 context</h2>
<p>背景 worker 的核心 shutdown 條件是每個 loop 都能觀察停止訊號。Ticker、queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、WebSocket hub 都應該有退出路徑。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">RunOnce</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 <code>RunOnce</code> 可能執行很久，也應接收 context。否則外層 loop 看到 cancel，內層 I/O 或計算仍可能卡住。</p>
<h2 id="策略websocket-cleanup-要回到-hub-owner">【策略】WebSocket cleanup 要回到 hub owner</h2>
<p>WebSocket shutdown 的核心原則是讓 hub 或 connection manager 統一清理 client。不要讓 signal handler 直接遍歷各種 connection 並隨意 close。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nf">closeAllClients</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="nx">client</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">h</span><span class="p">.</span><span class="nx">register</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nf">registerClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">case</span> <span class="nx">client</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>closeAllClients</code> 應透過 hub 的既有 owner 邏輯關閉 <code>send</code>、移除訂閱、關閉 connection。這延續前面模組的 ownership 原則。</p>
<h2 id="測試shutdown-測試要觀察明確條件">【測試】shutdown 測試要觀察明確條件</h2>
<p>Shutdown 測試的核心是確認停止訊號能讓元件退出，而不是等待固定時間。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestWorkerStopsOnContextCancel</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kd">struct</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;worker did not stop&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>HTTP server 測試可以啟動 server 後 cancel context，確認 <code>RunHTTPServer</code> 回傳。測試應使用隨機 port 或 <code>httptest.Server</code>，避免固定 port 造成衝突。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理服務內部的 shutdown 順序與 cleanup owner；平台 hook、timeout 與 load balancer 合約，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine lifecycle、ticker cleanup 與 platform handoff；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">Go：defer 與資源清理</a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">Go：goroutine leak 偵測</a></li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Graceful shutdown 是多階段流程：signal 轉成 root context，readiness 先關閉，HTTP server 停止接新 request，worker 和 WebSocket hub 觀察 context 收尾，整體流程受 timeout 保護。停止訊號越集中，元件 ownership 越清楚，服務在部署、測試與本機開發時越不容易留下殘存 goroutine 或未釋放連線。</p>
]]></content:encoded></item><item><title>7.1 資料庫 transaction 與 schema migration</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/</guid><description>&lt;p>資料庫整合的核心責任是讓持久化行為符合 application 的狀態規則。Repository port 決定 usecase 需要哪些資料能力；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level&lt;/a> 則決定這些能力在資料庫中如何保持一致。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷 [&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> boundary](/go-advanced/backend/knowledge-cards/transaction-boundary) 應該放在 repository 還是 usecase&lt;/li>
&lt;li>理解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a> 為什麼要維持向前相容&lt;/li>
&lt;li>分辨 application validation、constraint 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level&lt;/a> 的責任&lt;/li>
&lt;li>用 contract test 保護 memory repository 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> repository 的一致行為&lt;/li>
&lt;li>讓 SQL 細節留在 adapter，讓 domain 規則留在 application&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go 入門：如何新增 repository port&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go 入門：狀態管理的安全邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Backend：Source of Truth&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Backend：Connection Pool&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Repository method 如何表達交易語意，讓 SQL 細節留在 adapter。&lt;/li>
&lt;li>一個 usecase 需要多筆寫入同時成功或失敗時，transaction boundary 應放在哪裡。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration&lt;/a> 如何維持向前相容，避免新舊程式版本互相破壞資料。&lt;/li>
&lt;li>Isolation level、unique constraint 與 application-level validation 如何分工。&lt;/li>
&lt;li>Contract test 如何保護 memory repository 與 database repository 的一致行為。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察transaction-是一致性邊界">【觀察】transaction 是一致性邊界&lt;/h2>
&lt;p>transaction 的核心用途是把一組資料庫操作綁成單一一致性單位。判斷重點是：這個 usecase 哪些狀態要一起成功或一起失敗。效能與寫入便利性都應放在一致性需求之後評估。&lt;/p>
&lt;p>例如建立訂單時，可能同時需要：&lt;/p>
&lt;ul>
&lt;li>寫入 order 主表&lt;/li>
&lt;li>寫入 order items&lt;/li>
&lt;li>更新 inventory&lt;/li>
&lt;li>寫入 outbox event&lt;/li>
&lt;/ul>
&lt;p>如果其中一個步驟失敗，整組操作就應回滾，避免 application 狀態和資料庫狀態分裂。&lt;/p>
&lt;h2 id="判讀transaction-boundary-應該跟-usecase-對齊">【判讀】transaction boundary 應該跟 usecase 對齊&lt;/h2>
&lt;p>交易邊界最常見的錯誤，是把 transaction 放得太低或太高。&lt;/p>
&lt;ul>
&lt;li>放太低：repository 各自開 transaction，usecase 層看起來成功，實際上無法保證整體一致。&lt;/li>
&lt;li>放太高：把不需要一致性的讀取、外部 API、長迴圈也包進 transaction，讓連線被占住太久。&lt;/li>
&lt;/ul>
&lt;p>一般原則是：&lt;/p>
&lt;ul>
&lt;li>要維持同一個 domain 不變式的寫入，應放在同一個 transaction。&lt;/li>
&lt;li>可以重試或可補償的外部互動，通常應放在 transaction 之外。&lt;/li>
&lt;/ul>
&lt;h2 id="策略migration-要讓舊版與新版可以共存">【策略】&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration&lt;/a> 要讓舊版與新版可以共存&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration&lt;/a> 的核心是讓部署期間的新舊版本能同時活著。實務上常見的是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract&lt;/a> 流程：&lt;/p></description><content:encoded><![CDATA[<p>資料庫整合的核心責任是讓持久化行為符合 application 的狀態規則。Repository port 決定 usecase 需要哪些資料能力；<a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>、<a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a>、<a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 與 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a> 則決定這些能力在資料庫中如何保持一致。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷 [<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> boundary](/go-advanced/backend/knowledge-cards/transaction-boundary) 應該放在 repository 還是 usecase</li>
<li>理解 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 為什麼要維持向前相容</li>
<li>分辨 application validation、constraint 與 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a> 的責任</li>
<li>用 contract test 保護 memory repository 與 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> repository 的一致行為</li>
<li>讓 SQL 細節留在 adapter，讓 domain 規則留在 application</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go 入門：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go 入門：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Backend：Source of Truth</a></li>
<li><a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Backend：Connection Pool</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Repository method 如何表達交易語意，讓 SQL 細節留在 adapter。</li>
<li>一個 usecase 需要多筆寫入同時成功或失敗時，transaction boundary 應放在哪裡。</li>
<li><a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration</a> 如何維持向前相容，避免新舊程式版本互相破壞資料。</li>
<li>Isolation level、unique constraint 與 application-level validation 如何分工。</li>
<li>Contract test 如何保護 memory repository 與 database repository 的一致行為。</li>
</ol>
<h2 id="觀察transaction-是一致性邊界">【觀察】transaction 是一致性邊界</h2>
<p>transaction 的核心用途是把一組資料庫操作綁成單一一致性單位。判斷重點是：這個 usecase 哪些狀態要一起成功或一起失敗。效能與寫入便利性都應放在一致性需求之後評估。</p>
<p>例如建立訂單時，可能同時需要：</p>
<ul>
<li>寫入 order 主表</li>
<li>寫入 order items</li>
<li>更新 inventory</li>
<li>寫入 outbox event</li>
</ul>
<p>如果其中一個步驟失敗，整組操作就應回滾，避免 application 狀態和資料庫狀態分裂。</p>
<h2 id="判讀transaction-boundary-應該跟-usecase-對齊">【判讀】transaction boundary 應該跟 usecase 對齊</h2>
<p>交易邊界最常見的錯誤，是把 transaction 放得太低或太高。</p>
<ul>
<li>放太低：repository 各自開 transaction，usecase 層看起來成功，實際上無法保證整體一致。</li>
<li>放太高：把不需要一致性的讀取、外部 API、長迴圈也包進 transaction，讓連線被占住太久。</li>
</ul>
<p>一般原則是：</p>
<ul>
<li>要維持同一個 domain 不變式的寫入，應放在同一個 transaction。</li>
<li>可以重試或可補償的外部互動，通常應放在 transaction 之外。</li>
</ul>
<h2 id="策略migration-要讓舊版與新版可以共存">【策略】<a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration</a> 要讓舊版與新版可以共存</h2>
<p><a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a> 的核心是讓部署期間的新舊版本能同時活著。實務上常見的是 <a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 流程：</p>
<ol>
<li>先新增欄位、表或索引。</li>
<li>讓新舊程式都能讀寫。</li>
<li>確認流量已切到新版本。</li>
<li>再移除舊欄位或舊邏輯。</li>
</ol>
<p>這樣做的目的，是避免應用版本與資料庫版本在 rolling deploy 時互相踩到。</p>
<h2 id="判讀constraintvalidation-與-isolation-level-各管不同風險">【判讀】constraint、validation 與 isolation level 各管不同風險</h2>
<p>這三者的責任應清楚分工：</p>
<ul>
<li>application validation：在進資料庫前先檢查基本輸入是否合法。</li>
<li>unique / foreign key / check constraint：在資料庫層保底，防止不合法資料落地。</li>
<li>isolation level：處理多交易同時進行時的可見性與衝突問題。</li>
</ul>
<p>如果只靠 application validation，資料庫仍可能被其他路徑寫入不合法資料。如果只靠資料庫 constraint，錯誤回報可能太晚。兩者通常要一起用。</p>
<h2 id="執行contract-test-檢查-repository-語意一致">【執行】contract test 檢查 repository 語意一致</h2>
<p>當你同時有 memory repository 與 database repository 時，測試重點是它們對外暴露的語意是否一致。SQL 細節屬於 database adapter 的內部實作。</p>
<p>通常要測：</p>
<ul>
<li>找不到資料時怎麼回傳</li>
<li>重複寫入時怎麼回傳</li>
<li>transaction 失敗時是否維持一致狀態</li>
<li>欄位驗證與預設值是否相同</li>
</ul>
<p>這類測試可以讓 repository adapter 保持可替換，讓資料庫替換時 usecase 維持穩定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會選定特定資料庫或 ORM。真正的重點是 Go application 如何定義資料一致性責任，讓 SQLite、PostgreSQL 或其他儲存技術都能成為可替換 adapter。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的 repository port 與狀態邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth</a></li>
</ul>
]]></content:encoded></item><item><title>模組一：進階並發模式</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/</guid><description>&lt;p>Go 並發設計的核心是明確定義 ownership、生命週期、 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 與共享狀態邊界。goroutine 很便宜，但失控的 goroutine、關閉錯誤的 channel、無限制堆積的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>，以及外洩的可變資料都會讓服務難以維護。&lt;/p>
&lt;p>本模組承接入門篇的 goroutine、channel、select、mutex 基礎，進一步處理長時間運行服務會遇到的問題：誰能關閉 channel、worker 如何停止、channel 滿載時怎麼回應、共享 map/slice 如何避免 data race、工作量如何限制、入口速率如何控制。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">1.1&lt;/a>&lt;/td>
 &lt;td>channel ownership 與關閉責任&lt;/td>
 &lt;td>用 sender lifecycle 判斷誰能 close channel&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">1.2&lt;/a>&lt;/td>
 &lt;td>select loop 的生命週期設計&lt;/td>
 &lt;td>同時處理輸入、ticker、取消與資源釋放&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">1.3&lt;/a>&lt;/td>
 &lt;td>非阻塞送出與事件丟棄策略&lt;/td>
 &lt;td>把 channel 滿載轉成明確服務行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">1.4&lt;/a>&lt;/td>
 &lt;td>共享狀態與複製邊界&lt;/td>
 &lt;td>用 lock、copy 與 owner method 保護可變資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">1.5&lt;/a>&lt;/td>
 &lt;td>bounded &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a>&lt;/td>
 &lt;td>限制同時執行的工作量，避免 goroutine 無限制堆積&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">1.6&lt;/a>&lt;/td>
 &lt;td>rate limiting 與 backpressure&lt;/td>
 &lt;td>用本地速率限制保護服務入口與下游依賴&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的通知與工作處理服務作為範例。範例會包含背景 worker、事件佇列、即時推送、狀態 repository 與測試 fake。&lt;/p>
&lt;p>範例只用來展示 Go 並發設計方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 channel direction 表達 send-only 與 receive-only 能力。&lt;/li>
&lt;li>用 context 作為 goroutine 停止訊號。&lt;/li>
&lt;li>用 select 管理多種輸入與 ticker。&lt;/li>
&lt;li>用 buffered channel 吸收短暫尖峰，但不把 buffer 當成容量規劃替代品。&lt;/li>
&lt;li>用 mutex 保護共享 map/slice。&lt;/li>
&lt;li>用 copy boundary 防止呼叫端修改內部狀態。&lt;/li>
&lt;li>用 worker pool 控制同時執行數。&lt;/li>
&lt;li>用 rate limiter 把過量輸入轉成可預期回應。&lt;/li>
&lt;li>用 race detector 與 focused tests 驗證並發邊界。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪個 goroutine 擁有 channel 的關閉責任&lt;/li>
&lt;li>一個長期 worker 停止時需要釋放哪些資源&lt;/li>
&lt;li>channel 滿載時應該等待、回錯、丟棄還是降級&lt;/li>
&lt;li>map、slice、pointer 何時會洩漏內部狀態&lt;/li>
&lt;li>什麼情況下 mutex 比 channel 更適合表達狀態擁有權&lt;/li>
&lt;li>什麼情況下需要 bounded worker pool&lt;/li>
&lt;li>入口過量時應排隊、限速、拒絕還是降級&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不討論分散式鎖、actor framework 或高階 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 系統。這些主題建立在本模組的基礎之上；本模組先把單一 Go process 內的 goroutine、worker、速率與共享資料邊界講清楚。外部 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 與分散式流量治理會放在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Go 並發設計的核心是明確定義 ownership、生命週期、 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 與共享狀態邊界。goroutine 很便宜，但失控的 goroutine、關閉錯誤的 channel、無限制堆積的 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>，以及外洩的可變資料都會讓服務難以維護。</p>
<p>本模組承接入門篇的 goroutine、channel、select、mutex 基礎，進一步處理長時間運行服務會遇到的問題：誰能關閉 channel、worker 如何停止、channel 滿載時怎麼回應、共享 map/slice 如何避免 data race、工作量如何限制、入口速率如何控制。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">1.1</a></td>
          <td>channel ownership 與關閉責任</td>
          <td>用 sender lifecycle 判斷誰能 close channel</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">1.2</a></td>
          <td>select loop 的生命週期設計</td>
          <td>同時處理輸入、ticker、取消與資源釋放</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">1.3</a></td>
          <td>非阻塞送出與事件丟棄策略</td>
          <td>把 channel 滿載轉成明確服務行為</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">1.4</a></td>
          <td>共享狀態與複製邊界</td>
          <td>用 lock、copy 與 owner method 保護可變資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">1.5</a></td>
          <td>bounded <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a></td>
          <td>限制同時執行的工作量，避免 goroutine 無限制堆積</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">1.6</a></td>
          <td>rate limiting 與 backpressure</td>
          <td>用本地速率限制保護服務入口與下游依賴</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的通知與工作處理服務作為範例。範例會包含背景 worker、事件佇列、即時推送、狀態 repository 與測試 fake。</p>
<p>範例只用來展示 Go 並發設計方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 channel direction 表達 send-only 與 receive-only 能力。</li>
<li>用 context 作為 goroutine 停止訊號。</li>
<li>用 select 管理多種輸入與 ticker。</li>
<li>用 buffered channel 吸收短暫尖峰，但不把 buffer 當成容量規劃替代品。</li>
<li>用 mutex 保護共享 map/slice。</li>
<li>用 copy boundary 防止呼叫端修改內部狀態。</li>
<li>用 worker pool 控制同時執行數。</li>
<li>用 rate limiter 把過量輸入轉成可預期回應。</li>
<li>用 race detector 與 focused tests 驗證並發邊界。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪個 goroutine 擁有 channel 的關閉責任</li>
<li>一個長期 worker 停止時需要釋放哪些資源</li>
<li>channel 滿載時應該等待、回錯、丟棄還是降級</li>
<li>map、slice、pointer 何時會洩漏內部狀態</li>
<li>什麼情況下 mutex 比 channel 更適合表達狀態擁有權</li>
<li>什麼情況下需要 bounded worker pool</li>
<li>入口過量時應排隊、限速、拒絕還是降級</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論分散式鎖、actor framework 或高階 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 系統。這些主題建立在本模組的基礎之上；本模組先把單一 Go process 內的 goroutine、worker、速率與共享資料邊界講清楚。外部 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 與分散式流量治理會放在 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a> 與 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="先備知識">先備知識</h2>
<ul>
<li><a href="/blog/go/04-concurrency/" data-link-title="模組四：並發模型" data-link-desc="從 goroutine、channel、select 與 RWMutex 理解 Go 並發模型">Go 入門：並發模型</a></li>
<li>知道 goroutine、channel、select、mutex 的基本用法</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 4-5 小時</p>
]]></content:encoded></item><item><title>1.2 select loop 的生命週期設計</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/</guid><description>&lt;p>&lt;code>select&lt;/code> loop 的核心責任是管理長時間 goroutine 的生命週期。它不只是等待多個 channel 的語法，而是決定元件如何接收輸入、處理定時任務、回應取消、釋放資源與停止。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>拆解 &lt;code>select&lt;/code> loop 中每個 case 的責任&lt;/li>
&lt;li>用 &lt;code>ctx.Done()&lt;/code> 設計一致的退出路徑&lt;/li>
&lt;li>正確建立與停止 ticker&lt;/li>
&lt;li>處理 channel 關閉後的 nil channel pattern&lt;/li>
&lt;li>測試 worker 在事件、ticker、取消下的行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察長期-goroutine-通常同時等待多種訊號">【觀察】長期 goroutine 通常同時等待多種訊號&lt;/h2>
&lt;p>長期 goroutine 的核心特徵是它不只處理一種資料。背景 worker 可能同時等待外部事件、定時掃描、清理工作與停止訊號。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">Worker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">statusTicker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">statusInterval&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">statusTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">cleanupTicker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">cleanupInterval&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">cleanupTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">processEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">statusTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">scanStatus&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">cleanupTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 loop 的責任不是「跑一個無限迴圈」，而是定義 worker 活著時能接受哪些訊號，以及停止時要如何退出。&lt;/p>
&lt;h2 id="判讀select-loop-是元件的生命週期表">【判讀】select loop 是元件的生命週期表&lt;/h2>
&lt;p>&lt;code>select&lt;/code> loop 的核心價值是把元件生命週期寫成明確表格。每個 case 都應該能回答：收到什麼訊號、代表什麼意思、下一步做什麼。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>case&lt;/th>
 &lt;th>系統意義&lt;/th>
 &lt;th>下一步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>ctx.Done()&lt;/code>&lt;/td>
 &lt;td>上層要求停止&lt;/td>
 &lt;td>回傳 context error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>w.events&lt;/code>&lt;/td>
 &lt;td>收到外部事件&lt;/td>
 &lt;td>套用處理流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>statusTicker.C&lt;/code>&lt;/td>
 &lt;td>到時間掃描狀態&lt;/td>
 &lt;td>執行週期任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>cleanupTicker.C&lt;/code>&lt;/td>
 &lt;td>到時間清理暫存資料&lt;/td>
 &lt;td>回收資源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>若某個 case 的意義說不清楚，通常代表 worker 責任太多，或事件來源還沒有被整理成清楚的 channel。&lt;/p>
&lt;h2 id="策略每個長期-goroutine-先回答四個問題">【策略】每個長期 goroutine 先回答四個問題&lt;/h2>
&lt;p>Select loop 設計的核心檢查是生命週期，而不是語法。寫 loop 前先回答四個問題：&lt;/p>
&lt;ol>
&lt;li>誰能停止它？&lt;/li>
&lt;li>它消費哪些輸入？&lt;/li>
&lt;li>它擁有哪些資源？&lt;/li>
&lt;li>停止時要回報錯誤、靜默退出，還是交給上層判斷？&lt;/li>
&lt;/ol>
&lt;p>例如：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Worker&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">statusInterval&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">cleanupInterval&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">processor&lt;/span> &lt;span class="nx">Processor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Worker&lt;/code> 消費 &lt;code>events&lt;/code>，擁有兩個 ticker，停止訊號來自 &lt;code>context.Context&lt;/code>。這些資訊應該能從型別與 &lt;code>Run&lt;/code> 方法看出來，而不是藏在任意 goroutine 裡。&lt;/p>
&lt;h2 id="執行ticker-要由使用者停止">【執行】ticker 要由使用者停止&lt;/h2>
&lt;p>Ticker 的核心規則是建立者負責停止。&lt;code>time.NewTicker&lt;/code> 會建立 runtime 資源；不再使用時應呼叫 &lt;code>Stop&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">Worker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ticker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">interval&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SyncOnce&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ticker 放在 &lt;code>Run&lt;/code> 裡建立，表示它的生命週期和 &lt;code>Run&lt;/code> 一致。&lt;code>defer ticker.Stop()&lt;/code> 讓 worker 不論因為 context、錯誤或 channel 關閉退出，都能釋放 ticker。&lt;/p>
&lt;p>如果 ticker 由外部傳入，外部就應該負責停止。擁有權要一致，否則測試和 shutdown 都會變得模糊。&lt;/p>
&lt;h2 id="執行處理已關閉-channel-要避免忙等">【執行】處理已關閉 channel 要避免忙等&lt;/h2>
&lt;p>已關閉 channel 的核心行為是讀取會立即回傳零值與 &lt;code>ok=false&lt;/code>。在 &lt;code>select&lt;/code> loop 裡，如果不處理這件事，loop 可能一直選到同一個已關閉 channel。&lt;/p>
&lt;p>當一個輸入關閉後，還要繼續處理其他輸入，可以把它設成 nil：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">Worker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">events&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">alerts&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">alerts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">alerts&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">events&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">processEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">alert&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">alerts&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">alerts&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">processAlert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">alert&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nil channel 在 &lt;code>select&lt;/code> 中永遠不會 ready。這讓 worker 能在某個來源關閉後繼續處理其他來源，而不是忙等或提早退出。&lt;/p></description><content:encoded><![CDATA[<p><code>select</code> loop 的核心責任是管理長時間 goroutine 的生命週期。它不只是等待多個 channel 的語法，而是決定元件如何接收輸入、處理定時任務、回應取消、釋放資源與停止。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>拆解 <code>select</code> loop 中每個 case 的責任</li>
<li>用 <code>ctx.Done()</code> 設計一致的退出路徑</li>
<li>正確建立與停止 ticker</li>
<li>處理 channel 關閉後的 nil channel pattern</li>
<li>測試 worker 在事件、ticker、取消下的行為</li>
</ol>
<hr>
<h2 id="觀察長期-goroutine-通常同時等待多種訊號">【觀察】長期 goroutine 通常同時等待多種訊號</h2>
<p>長期 goroutine 的核心特徵是它不只處理一種資料。背景 worker 可能同時等待外部事件、定時掃描、清理工作與停止訊號。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">statusTicker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">w</span><span class="p">.</span><span class="nx">statusInterval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">statusTicker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">cleanupTicker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">w</span><span class="p">.</span><span class="nx">cleanupInterval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">defer</span> <span class="nx">cleanupTicker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">w</span><span class="p">.</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">processEvent</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">statusTicker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">scanStatus</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">cleanupTicker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">cleanup</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 loop 的責任不是「跑一個無限迴圈」，而是定義 worker 活著時能接受哪些訊號，以及停止時要如何退出。</p>
<h2 id="判讀select-loop-是元件的生命週期表">【判讀】select loop 是元件的生命週期表</h2>
<p><code>select</code> loop 的核心價值是把元件生命週期寫成明確表格。每個 case 都應該能回答：收到什麼訊號、代表什麼意思、下一步做什麼。</p>
<table>
  <thead>
      <tr>
          <th>case</th>
          <th>系統意義</th>
          <th>下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ctx.Done()</code></td>
          <td>上層要求停止</td>
          <td>回傳 context error</td>
      </tr>
      <tr>
          <td><code>w.events</code></td>
          <td>收到外部事件</td>
          <td>套用處理流程</td>
      </tr>
      <tr>
          <td><code>statusTicker.C</code></td>
          <td>到時間掃描狀態</td>
          <td>執行週期任務</td>
      </tr>
      <tr>
          <td><code>cleanupTicker.C</code></td>
          <td>到時間清理暫存資料</td>
          <td>回收資源</td>
      </tr>
  </tbody>
</table>
<p>若某個 case 的意義說不清楚，通常代表 worker 責任太多，或事件來源還沒有被整理成清楚的 channel。</p>
<h2 id="策略每個長期-goroutine-先回答四個問題">【策略】每個長期 goroutine 先回答四個問題</h2>
<p>Select loop 設計的核心檢查是生命週期，而不是語法。寫 loop 前先回答四個問題：</p>
<ol>
<li>誰能停止它？</li>
<li>它消費哪些輸入？</li>
<li>它擁有哪些資源？</li>
<li>停止時要回報錯誤、靜默退出，還是交給上層判斷？</li>
</ol>
<p>例如：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Worker</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">events</span>          <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">statusInterval</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">cleanupInterval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">processor</span>       <span class="nx">Processor</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Worker</code> 消費 <code>events</code>，擁有兩個 ticker，停止訊號來自 <code>context.Context</code>。這些資訊應該能從型別與 <code>Run</code> 方法看出來，而不是藏在任意 goroutine 裡。</p>
<h2 id="執行ticker-要由使用者停止">【執行】ticker 要由使用者停止</h2>
<p>Ticker 的核心規則是建立者負責停止。<code>time.NewTicker</code> 會建立 runtime 資源；不再使用時應呼叫 <code>Stop</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">w</span><span class="p">.</span><span class="nx">interval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Ticker 放在 <code>Run</code> 裡建立，表示它的生命週期和 <code>Run</code> 一致。<code>defer ticker.Stop()</code> 讓 worker 不論因為 context、錯誤或 channel 關閉退出，都能釋放 ticker。</p>
<p>如果 ticker 由外部傳入，外部就應該負責停止。擁有權要一致，否則測試和 shutdown 都會變得模糊。</p>
<h2 id="執行處理已關閉-channel-要避免忙等">【執行】處理已關閉 channel 要避免忙等</h2>
<p>已關閉 channel 的核心行為是讀取會立即回傳零值與 <code>ok=false</code>。在 <code>select</code> loop 裡，如果不處理這件事，loop 可能一直選到同一個已關閉 channel。</p>
<p>當一個輸入關閉後，還要繼續處理其他輸入，可以把它設成 nil：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nx">events</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">alerts</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nx">alerts</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">events</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">||</span> <span class="nx">alerts</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="nx">events</span> <span class="p">=</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">processEvent</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">case</span> <span class="nx">alert</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">alerts</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="nx">alerts</span> <span class="p">=</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">processAlert</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">alert</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Nil channel 在 <code>select</code> 中永遠不會 ready。這讓 worker 能在某個來源關閉後繼續處理其他來源，而不是忙等或提早退出。</p>
<h2 id="判讀default-case-會改變-loop-的語意">【判讀】default case 會改變 loop 的語意</h2>
<p><code>default</code> 的核心效果是讓 <code>select</code> 不等待。這在非阻塞送出很有用，但在長期 worker 的主 loop 中要小心，因為它可能造成 busy loop。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nf">cleanup</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>當沒有事件時，這個 loop 會不停執行 <code>cleanup()</code>，可能吃滿 CPU。週期任務應該用 ticker 表達，不應用 <code>default</code> 假裝閒置時執行。</p>
<p>較清楚的做法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">cleanupTicker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">defer</span> <span class="nx">cleanupTicker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">cleanupTicker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nf">cleanup</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Ticker 讓頻率明確，也讓測試可以透過可控時間或手動觸發 channel 驗證行為。</p>
<h2 id="策略長工作要移出主要-loop">【策略】長工作要移出主要 loop</h2>
<p>Select loop 的核心風險是某個 case 裡的工作太久，導致其他訊號無法被處理。若 <code>processEvent</code> 可能執行很久，worker 在這段期間就不會回應 context 或 ticker。</p>
<p>可選策略：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>case 內同步執行</td>
          <td>工作短、需要順序處理</td>
          <td>慢事件會阻塞整個 loop</td>
      </tr>
      <tr>
          <td>啟動 bounded <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a></td>
          <td>工作可並行、需要限制併發</td>
          <td>需要排隊與 shutdown 設計</td>
      </tr>
      <tr>
          <td>送入另一個 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a></td>
          <td>入口要快速接收</td>
          <td>需要 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略</td>
      </tr>
  </tbody>
</table>
<p>長工作需要 bounded worker pool、另一個 queue 或明確的同步策略。無限制地在 case 裡 <code>go process(event)</code> 只會把排隊問題從 channel 轉成 goroutine 堆積，並讓 shutdown 和錯誤回報更難處理。</p>
<h2 id="測試把單次工作抽成方法">【測試】把單次工作抽成方法</h2>
<p>Select loop 的測試核心是避免所有邏輯都只能透過無限迴圈測。把單次工作抽成 <code>SyncOnce</code>、<code>ProcessOne</code> 或 <code>CleanupOnce</code>，可以讓規則測試和 lifecycle 測試分開。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">w</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Sync</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Run</code> 只負責排程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span></span></span></code></pre></div><p>單次工作測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSyncOnceCallsProcessor</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span><span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">());</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;sync once: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">processor</span><span class="p">.</span><span class="nx">called</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;processor should be called&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Lifecycle 測試則只確認 context 取消能讓 <code>Run</code> 退出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestRunStopsOnContextCancel</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">events</span><span class="p">:</span>          <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">statusInterval</span><span class="p">:</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">cleanupInterval</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;run error = %v, want context canceled&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種拆法讓測試不需要等待真實 ticker，也不需要在無限 loop 裡猜時間。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先把長生命週期 goroutine 的停止、輸入與排空講清楚；更完整的 worker 協調與平台排程責任，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go 進階：bounded worker pool</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine、channel 與 shutdown loop；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go：graceful shutdown 與 signal handling</a></li>
</ul>
<h2 id="小結">小結</h2>
<p><code>select</code> loop 是長期 goroutine 的生命週期表。好的 loop 會明確處理 context 取消、輸入 channel、ticker、資源釋放與 channel 關閉。避免在主 loop 中濫用 <code>default</code> 或無限制開 goroutine，才能讓服務在高流量、錯誤與 shutdown 情境下保持可預測。</p>
]]></content:encoded></item><item><title>2.2 heartbeat、deadline 與連線清理</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/</guid><description>&lt;p>Heartbeat 的核心目標是讓失效的長連線可以被發現並清理。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">Deadline&lt;/a> 定義讀寫最多能停滯多久，ping/pong 在沒有業務訊息時確認連線仍然活著，unregister 流程負責釋放連線與訂閱狀態。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 read deadline、write deadline、ping period、pong wait 的角色&lt;/li>
&lt;li>在 read pump 設定 pong handler 與 read limit&lt;/li>
&lt;li>在 write pump 用 ticker 統一送 ping&lt;/li>
&lt;li>讓 heartbeat 失敗進入同一條 unregister 路徑&lt;/li>
&lt;li>測試 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 設定與清理流程的邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察長連線可能在沒有錯誤訊息時失效">【觀察】長連線可能在沒有錯誤訊息時失效&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 長連線的核心風險是失效不一定立刻表現成明確錯誤。Client 可能斷網、瀏覽器休眠、代理中斷、行動網路切換，server 的 read 或 write 可能長時間卡住。&lt;/p>
&lt;p>沒有 heartbeat 的服務可能出現：&lt;/p>
&lt;ul>
&lt;li>client 已離線，但 server 還保留 client。&lt;/li>
&lt;li>訂閱狀態沒有清理，broadcast 仍嘗試推送。&lt;/li>
&lt;li>write pump 卡在慢或失效的 connection。&lt;/li>
&lt;li>goroutine、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>、記憶體逐步累積。&lt;/li>
&lt;/ul>
&lt;p>Heartbeat 的目的是讓失敗可以在合理時間內被觀測並進入清理流程。&lt;/p>
&lt;h2 id="判讀四個時間參數負責不同邊界">【判讀】四個時間參數負責不同邊界&lt;/h2>
&lt;p>Heartbeat 設計的核心是四個時間參數的關係。這些參數是讀寫生命週期的合約。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">writeWait&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">10&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">pongWait&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">60&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">pingPeriod&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">50&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">maxMessage&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&lt;/span> &lt;span class="mi">20&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>參數&lt;/th>
 &lt;th>角色&lt;/th>
 &lt;th>常見關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>writeWait&lt;/code>&lt;/td>
 &lt;td>單次寫入最多等待多久&lt;/td>
 &lt;td>小於 &lt;code>pongWait&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pongWait&lt;/code>&lt;/td>
 &lt;td>多久沒讀到資料就視為失效&lt;/td>
 &lt;td>大於 &lt;code>pingPeriod&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pingPeriod&lt;/code>&lt;/td>
 &lt;td>多久主動送一次 ping&lt;/td>
 &lt;td>小於 &lt;code>pongWait&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>maxMessage&lt;/code>&lt;/td>
 &lt;td>單筆 client message 大小上限&lt;/td>
 &lt;td>依協定需求設定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>pingPeriod&lt;/code> 應小於 &lt;code>pongWait&lt;/code>，讓 server 有時間送 ping 並等待 client 回 pong。&lt;code>writeWait&lt;/code> 保護每次寫入，避免 write pump 無限卡住。&lt;/p>
&lt;h2 id="執行read-pump-設定-read-deadline-與-pong-handler">【執行】read pump 設定 read deadline 與 pong handler&lt;/h2>
&lt;p>Read deadline 的核心語意是超過指定時間沒有讀取進展，下一次 read 會失敗。Pong handler 的核心責任是每次收到 pong 時延長 read deadline。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">configureRead&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetReadLimit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">maxMessage&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetReadDeadline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">pongWait&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetPongHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetReadDeadline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">pongWait&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Read pump 啟動時先設定：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">readPump&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">hub&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Hub&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">router&lt;/span> &lt;span class="nx">MessageRouter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">unregister&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">c&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">configureRead&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">message&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">router&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Route&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrySend&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">errorMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ReadJSON&lt;/code> 回錯時，read pump 不需要判斷每一種錯誤都如何清理；它只要退出並通知 hub。錯誤分類可以用於 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>，但清理路徑應一致。&lt;/p>
&lt;h2 id="執行write-pump-用-ticker-送-ping">【執行】write pump 用 ticker 送 ping&lt;/h2>
&lt;p>Ping 的核心規則是由 write pump 送出，因為 ping 也是 WebSocket write。讓其他 goroutine 直接送 ping 會破壞「write pump 是唯一寫入者」的原則。&lt;/p></description><content:encoded><![CDATA[<p>Heartbeat 的核心目標是讓失效的長連線可以被發現並清理。<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">Deadline</a> 定義讀寫最多能停滯多久，ping/pong 在沒有業務訊息時確認連線仍然活著，unregister 流程負責釋放連線與訂閱狀態。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 read deadline、write deadline、ping period、pong wait 的角色</li>
<li>在 read pump 設定 pong handler 與 read limit</li>
<li>在 write pump 用 ticker 統一送 ping</li>
<li>讓 heartbeat 失敗進入同一條 unregister 路徑</li>
<li>測試 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 設定與清理流程的邊界</li>
</ol>
<hr>
<h2 id="觀察長連線可能在沒有錯誤訊息時失效">【觀察】長連線可能在沒有錯誤訊息時失效</h2>
<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 長連線的核心風險是失效不一定立刻表現成明確錯誤。Client 可能斷網、瀏覽器休眠、代理中斷、行動網路切換，server 的 read 或 write 可能長時間卡住。</p>
<p>沒有 heartbeat 的服務可能出現：</p>
<ul>
<li>client 已離線，但 server 還保留 client。</li>
<li>訂閱狀態沒有清理，broadcast 仍嘗試推送。</li>
<li>write pump 卡在慢或失效的 connection。</li>
<li>goroutine、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>、記憶體逐步累積。</li>
</ul>
<p>Heartbeat 的目的是讓失敗可以在合理時間內被觀測並進入清理流程。</p>
<h2 id="判讀四個時間參數負責不同邊界">【判讀】四個時間參數負責不同邊界</h2>
<p>Heartbeat 設計的核心是四個時間參數的關係。這些參數是讀寫生命週期的合約。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">writeWait</span>  <span class="p">=</span> <span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">pongWait</span>   <span class="p">=</span> <span class="mi">60</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">pingPeriod</span> <span class="p">=</span> <span class="mi">50</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">maxMessage</span> <span class="p">=</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>參數</th>
          <th>角色</th>
          <th>常見關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>writeWait</code></td>
          <td>單次寫入最多等待多久</td>
          <td>小於 <code>pongWait</code></td>
      </tr>
      <tr>
          <td><code>pongWait</code></td>
          <td>多久沒讀到資料就視為失效</td>
          <td>大於 <code>pingPeriod</code></td>
      </tr>
      <tr>
          <td><code>pingPeriod</code></td>
          <td>多久主動送一次 ping</td>
          <td>小於 <code>pongWait</code></td>
      </tr>
      <tr>
          <td><code>maxMessage</code></td>
          <td>單筆 client message 大小上限</td>
          <td>依協定需求設定</td>
      </tr>
  </tbody>
</table>
<p><code>pingPeriod</code> 應小於 <code>pongWait</code>，讓 server 有時間送 ping 並等待 client 回 pong。<code>writeWait</code> 保護每次寫入，避免 write pump 無限卡住。</p>
<h2 id="執行read-pump-設定-read-deadline-與-pong-handler">【執行】read pump 設定 read deadline 與 pong handler</h2>
<p>Read deadline 的核心語意是超過指定時間沒有讀取進展，下一次 read 會失敗。Pong handler 的核心責任是每次收到 pong 時延長 read deadline。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">configureRead</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadLimit</span><span class="p">(</span><span class="nx">maxMessage</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">pongWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetPongHandler</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="kt">string</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">pongWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Read pump 啟動時先設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">readPump</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">hub</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">,</span> <span class="nx">router</span> <span class="nx">MessageRouter</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">c</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nf">configureRead</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="kd">var</span> <span class="nx">message</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">c</span><span class="p">,</span> <span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">c</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nf">errorMessage</span><span class="p">(</span><span class="nx">err</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ReadJSON</code> 回錯時，read pump 不需要判斷每一種錯誤都如何清理；它只要退出並通知 hub。錯誤分類可以用於 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>，但清理路徑應一致。</p>
<h2 id="執行write-pump-用-ticker-送-ping">【執行】write pump 用 ticker 送 ping</h2>
<p>Ping 的核心規則是由 write pump 送出，因為 ping 也是 WebSocket write。讓其他 goroutine 直接送 ping 會破壞「write pump 是唯一寫入者」的原則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">writePump</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">pingPeriod</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="nx">message</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">send</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetWriteDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">writeWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteMessage</span><span class="p">(</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">CloseMessage</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetWriteDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">writeWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteMessage</span><span class="p">(</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">PingMessage</span><span class="p">,</span> <span class="kc">nil</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>每次寫入前設定 write deadline。這包含正常訊息、ping、close message；只保護部分寫入會留下卡住路徑。</p>
<h2 id="判讀heartbeat-失敗走共用清理流程">【判讀】heartbeat 失敗走共用清理流程</h2>
<p>Heartbeat 失敗的核心語意是連線不可用。它應該進入和 read error、write error、client disconnect 相同的 unregister 流程，而不是在 ping 錯誤處重寫一套清理。</p>
<p>推薦流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">read error / write error / ping error
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">          │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">          ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">read pump exits or write pump exits
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">          │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">          ▼
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">hub unregisters client
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">          │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">          ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">close send, close conn, remove subscriptions</span></span></code></pre></div><p>實作可以用 hub unregister channel、context cancellation 或 connection manager。重點是所有失效都收斂到同一個 owner。</p>
<h2 id="策略read-pump-和-write-pump-都可能先失敗">【策略】read pump 和 write pump 都可能先失敗</h2>
<p>連線失效的核心不確定性是 read pump 和 write pump 哪個先看到錯誤不可預測。讀不到 pong 可能讓 read pump 先退出；寫 ping 失敗可能讓 write pump 先退出。</p>
<p>因此 unregister 必須可重複呼叫而不出錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">];</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nb">delete</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nb">close</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">client</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>用 <code>clients</code> map 判斷 client 是否仍註冊，可以避免重複 close <code>send</code>。這是 WebSocket cleanup 最容易漏掉的細節之一。</p>
<h2 id="策略heartbeat-參數要符合部署環境">【策略】heartbeat 參數要符合部署環境</h2>
<p>Heartbeat 參數的核心取捨是偵測速度與誤判風險。偵測太快會讓短暫網路抖動造成大量斷線；偵測太慢會讓失效連線保留太久。</p>
<p>調整時要考慮：</p>
<ul>
<li>load balancer 或 proxy idle timeout</li>
<li>行動網路與瀏覽器背景分頁行為</li>
<li>server 可接受的失效連線保留時間</li>
<li>ping 對大量連線造成的週期性流量</li>
<li>client 是否會自動重連</li>
</ul>
<p>若基礎設施會在 60 秒 idle 後關閉連線，server 的 ping period 就不能長於這個時間。這是部署環境合約，不是單純 Go 程式碼問題。</p>
<h2 id="測試把時間參數和清理邊界拆開測">【測試】把時間參數和清理邊界拆開測</h2>
<p>Heartbeat 的測試核心是不要用真實分鐘級等待。時間參數可以測設定值關係，清理流程可以測 unregister 是否 idempotent。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHeartbeatDurations</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">pingPeriod</span> <span class="o">&gt;=</span> <span class="nx">pongWait</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;pingPeriod must be smaller than pongWait&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">writeWait</span> <span class="o">&gt;=</span> <span class="nx">pongWait</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;writeWait should be smaller than pongWait&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Unregister 測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestUnregisterClientIsIdempotent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewClient</span><span class="p">(</span><span class="s">&#34;c1&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;client should be removed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>真實 ping/pong 行為適合放在 integration test。單元測試先保證時間合約與 cleanup owner 不會被破壞。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 WebSocket 連線的存活偵測與 cleanup；client 重連與 load balancer 參數，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 read/write pump、time control 與 shutdown；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">Go：defer 與資源清理</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">Go 進階：time control</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Heartbeat/deadline 的目的是讓失效連線在可預期時間內被發現並清理。Read deadline 搭配 pong handler 保護讀取端，write deadline 保護每次寫入，ping ticker 由 write pump 統一執行，所有錯誤最後都應進入同一個 unregister 流程。</p>
]]></content:encoded></item><item><title>3.2 pprof 基礎診斷流程</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/</guid><description>&lt;p>pprof 的核心用途是用實際執行資料定位效能問題。它能協助觀察 heap、goroutine、CPU、block、mutex 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a>，讓工程師從「感覺哪裡慢」改成「依 profile 判斷哪裡有壓力」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>安全地條件啟用 pprof endpoint&lt;/li>
&lt;li>判斷 heap、goroutine、CPU、block、mutex、trace 各自回答什麼問題&lt;/li>
&lt;li>用 &lt;code>go tool pprof&lt;/code> 取得 profile 並閱讀 &lt;code>top&lt;/code>&lt;/li>
&lt;li>區分 &lt;code>inuse_space&lt;/code> 與 &lt;code>alloc_space&lt;/code>&lt;/li>
&lt;li>把 profile 結果連回程式設計邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察效能問題需要先問對問題">【觀察】效能問題需要先問對問題&lt;/h2>
&lt;p>pprof 診斷的核心起點是先確認你要回答哪個問題。不同 profile 回答不同問題，拿錯工具會讓分析變成猜測。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>優先 profile&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>記憶體持續上升&lt;/td>
 &lt;td>heap &lt;code>inuse_space&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GC 壓力高、配置很多&lt;/td>
 &lt;td>heap &lt;code>alloc_space&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>goroutine 數量持續增加&lt;/td>
 &lt;td>goroutine profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU 使用率高&lt;/td>
 &lt;td>CPU profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>goroutine 常卡在 channel 或 syscall&lt;/td>
 &lt;td>goroutine / trace&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mutex 等待嚴重&lt;/td>
 &lt;td>mutex profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>channel/send/receive 阻塞多&lt;/td>
 &lt;td>block profile&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Profile 不是一次全抓就會自動給答案。先問清楚問題，再抓對應資料，分析成本會低很多。&lt;/p>
&lt;h2 id="判讀pprof-endpoint-是受控診斷入口">【判讀】pprof endpoint 是受控診斷入口&lt;/h2>
&lt;p>pprof endpoint 的核心安全責任是受控地暴露診斷資訊。它可能包含 goroutine stack、函式名稱、路徑、記憶體配置模式與部分請求脈絡；正式服務應把 &lt;code>/debug/pprof/&lt;/code> 放在明確啟用、內部網路或驗證保護之後。&lt;/p>
&lt;p>條件啟用範例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="s">&amp;#34;net/http/pprof&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">RegisterDebugEndpoints&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mux&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ServeMux&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_PPROF&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="nx">mux&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/debug/pprof/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DefaultServeMux&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實務上還可以只綁定 localhost、掛在內部管理 port、加上身份驗證，或只在開發與診斷環境啟用。重點是 pprof 要受控，而不是跟公開 API 一起裸露。&lt;/p>
&lt;h2 id="執行heap-profile-看記憶體保留與配置壓力">【執行】heap profile 看記憶體保留與配置壓力&lt;/h2>
&lt;p>Heap profile 的核心問題是「哪些物件佔用或配置了記憶體」。當服務記憶體持續上升時，heap profile 是第一個常用工具。&lt;/p>
&lt;p>看目前仍被保留的記憶體：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go tool pprof http://localhost:8080/debug/pprof/heap&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>進入 pprof 後：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">(pprof) top&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>inuse_space&lt;/code> 代表目前仍被保留的記憶體，適合分析 leak、cache、map、slice、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>、長期持有資料。&lt;/p>
&lt;p>看累積配置量：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>alloc_space&lt;/code> 代表累積配置量，適合分析 JSON marshal、slice append、短命 object、熱路徑反覆配置造成的 GC 壓力。&lt;/p>
&lt;h2 id="判讀heap-profile-要連回資料結構">【判讀】heap profile 要連回資料結構&lt;/h2>
&lt;p>Heap profile 的核心解讀是問「誰持有資料」或「誰反覆配置」。看到某個函式在 top 裡，下一步要回到資料結構與生命週期。&lt;/p>
&lt;p>常見對應：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>profile 現象&lt;/th>
 &lt;th>可能設計問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>map 持續佔用&lt;/td>
 &lt;td>cache 沒有淘汰或 key 無限制成長&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>slice/history 佔用高&lt;/td>
 &lt;td>history 無上限或 list 回傳太大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JSON marshal alloc 高&lt;/td>
 &lt;td>高頻推送每個 client 重複 marshal&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>bytes.Buffer 配置高&lt;/td>
 &lt;td>熱路徑重複建立 buffer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">websocket&lt;/a> message 佔用高&lt;/td>
 &lt;td>send buffer 滿載或慢 client&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Profile 給的是線索，不是最終修正。修正要回到資料模型、copy boundary、buffer policy 或 cache policy。&lt;/p>
&lt;h2 id="執行goroutine-profile-看存活與卡住路徑">【執行】goroutine profile 看存活與卡住路徑&lt;/h2>
&lt;p>Goroutine profile 的核心問題是「哪些 goroutine 還活著，以及它們卡在哪裡」。它常用來診斷 goroutine leak、channel 等待、鎖等待與 network read 阻塞。&lt;/p></description><content:encoded><![CDATA[<p>pprof 的核心用途是用實際執行資料定位效能問題。它能協助觀察 heap、goroutine、CPU、block、mutex 與 <a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a>，讓工程師從「感覺哪裡慢」改成「依 profile 判斷哪裡有壓力」。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>安全地條件啟用 pprof endpoint</li>
<li>判斷 heap、goroutine、CPU、block、mutex、trace 各自回答什麼問題</li>
<li>用 <code>go tool pprof</code> 取得 profile 並閱讀 <code>top</code></li>
<li>區分 <code>inuse_space</code> 與 <code>alloc_space</code></li>
<li>把 profile 結果連回程式設計邊界</li>
</ol>
<hr>
<h2 id="觀察效能問題需要先問對問題">【觀察】效能問題需要先問對問題</h2>
<p>pprof 診斷的核心起點是先確認你要回答哪個問題。不同 profile 回答不同問題，拿錯工具會讓分析變成猜測。</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>優先 profile</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>記憶體持續上升</td>
          <td>heap <code>inuse_space</code></td>
      </tr>
      <tr>
          <td>GC 壓力高、配置很多</td>
          <td>heap <code>alloc_space</code></td>
      </tr>
      <tr>
          <td>goroutine 數量持續增加</td>
          <td>goroutine profile</td>
      </tr>
      <tr>
          <td>CPU 使用率高</td>
          <td>CPU profile</td>
      </tr>
      <tr>
          <td>goroutine 常卡在 channel 或 syscall</td>
          <td>goroutine / trace</td>
      </tr>
      <tr>
          <td>mutex 等待嚴重</td>
          <td>mutex profile</td>
      </tr>
      <tr>
          <td>channel/send/receive 阻塞多</td>
          <td>block profile</td>
      </tr>
  </tbody>
</table>
<p>Profile 不是一次全抓就會自動給答案。先問清楚問題，再抓對應資料，分析成本會低很多。</p>
<h2 id="判讀pprof-endpoint-是受控診斷入口">【判讀】pprof endpoint 是受控診斷入口</h2>
<p>pprof endpoint 的核心安全責任是受控地暴露診斷資訊。它可能包含 goroutine stack、函式名稱、路徑、記憶體配置模式與部分請求脈絡；正式服務應把 <code>/debug/pprof/</code> 放在明確啟用、內部網路或驗證保護之後。</p>
<p>條件啟用範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="nx">_</span> <span class="s">&#34;net/http/pprof&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">func</span> <span class="nf">RegisterDebugEndpoints</span><span class="p">(</span><span class="nx">mux</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">ServeMux</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_PPROF&#34;</span><span class="p">)</span> <span class="o">!=</span> <span class="s">&#34;1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">Handle</span><span class="p">(</span><span class="s">&#34;/debug/pprof/&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">DefaultServeMux</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>實務上還可以只綁定 localhost、掛在內部管理 port、加上身份驗證，或只在開發與診斷環境啟用。重點是 pprof 要受控，而不是跟公開 API 一起裸露。</p>
<h2 id="執行heap-profile-看記憶體保留與配置壓力">【執行】heap profile 看記憶體保留與配置壓力</h2>
<p>Heap profile 的核心問題是「哪些物件佔用或配置了記憶體」。當服務記憶體持續上升時，heap profile 是第一個常用工具。</p>
<p>看目前仍被保留的記憶體：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof http://localhost:8080/debug/pprof/heap</span></span></code></pre></div><p>進入 pprof 後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">(pprof) top</span></span></code></pre></div><p><code>inuse_space</code> 代表目前仍被保留的記憶體，適合分析 leak、cache、map、slice、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>、長期持有資料。</p>
<p>看累積配置量：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap</span></span></code></pre></div><p><code>alloc_space</code> 代表累積配置量，適合分析 JSON marshal、slice append、短命 object、熱路徑反覆配置造成的 GC 壓力。</p>
<h2 id="判讀heap-profile-要連回資料結構">【判讀】heap profile 要連回資料結構</h2>
<p>Heap profile 的核心解讀是問「誰持有資料」或「誰反覆配置」。看到某個函式在 top 裡，下一步要回到資料結構與生命週期。</p>
<p>常見對應：</p>
<table>
  <thead>
      <tr>
          <th>profile 現象</th>
          <th>可能設計問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>map 持續佔用</td>
          <td>cache 沒有淘汰或 key 無限制成長</td>
      </tr>
      <tr>
          <td>slice/history 佔用高</td>
          <td>history 無上限或 list 回傳太大</td>
      </tr>
      <tr>
          <td>JSON marshal alloc 高</td>
          <td>高頻推送每個 client 重複 marshal</td>
      </tr>
      <tr>
          <td>bytes.Buffer 配置高</td>
          <td>熱路徑重複建立 buffer</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">websocket</a> message 佔用高</td>
          <td>send buffer 滿載或慢 client</td>
      </tr>
  </tbody>
</table>
<p>Profile 給的是線索，不是最終修正。修正要回到資料模型、copy boundary、buffer policy 或 cache policy。</p>
<h2 id="執行goroutine-profile-看存活與卡住路徑">【執行】goroutine profile 看存活與卡住路徑</h2>
<p>Goroutine profile 的核心問題是「哪些 goroutine 還活著，以及它們卡在哪裡」。它常用來診斷 goroutine leak、channel 等待、鎖等待與 network read 阻塞。</p>
<p>互動模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof http://localhost:8080/debug/pprof/goroutine</span></span></code></pre></div><p>文字 stack：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl <span class="s2">&#34;http://localhost:8080/debug/pprof/goroutine?debug=2&#34;</span></span></span></code></pre></div><p>若大量 goroutine 卡在同一個 channel receive、send、network read、ticker loop，通常代表某個退出條件、close path、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 或 unregister 設計有問題。</p>
<p>Goroutine profile 不只看數量。少量但卡在錯誤位置的 goroutine，也可能代表資源沒有被釋放。</p>
<h2 id="執行cpu-profile-看熱路徑">【執行】CPU profile 看熱路徑</h2>
<p>CPU profile 的核心問題是「程式把 CPU 時間花在哪裡」。它需要採樣一段時間，適合分析 CPU 使用率高或 request latency 異常。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof <span class="s2">&#34;http://localhost:8080/debug/pprof/profile?seconds=30&#34;</span></span></span></code></pre></div><p>常用指令：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">(pprof) top
</span></span><span class="line"><span class="ln">2</span><span class="cl">(pprof) list Encode</span></span></code></pre></div><p>CPU profile 要搭配流量情境解讀。低流量時抓到的 profile 可能沒有代表性；高流量時則要注意診斷本身也會造成額外負擔。</p>
<p>若 top 顯示大量時間花在 JSON encode、sort、lock、regex 或 compression，下一步應回到對應熱路徑，判斷是否可以減少工作、快取結果、改資料結構或降低呼叫頻率。</p>
<h2 id="策略block-與-mutex-profile-需要先啟用取樣">【策略】block 與 mutex profile 需要先啟用取樣</h2>
<p>Block/mutex profile 的核心用途是分析等待，而不是分析 CPU 計算。它們通常需要在程式中設定取樣比例。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">ConfigureBlockingProfiles</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">SetBlockProfileRate</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">SetMutexProfileFraction</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Block profile 看 goroutine 在同步原語上阻塞的時間，例如 channel send/receive、select、mutex。Mutex profile 看鎖競爭。</p>
<p>啟用取樣有成本，不一定要常駐開最高強度。診斷時可以條件啟用，或在壓測環境中使用。</p>
<h2 id="執行trace-看排程與延遲">【執行】trace 看排程與延遲</h2>
<p>Trace 的核心用途是觀察 goroutine 排程、network block、syscall、GC pause 與延遲事件。它比單一 profile 更完整，但也更重。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -o trace.out <span class="s2">&#34;http://localhost:8080/debug/pprof/trace?seconds=5&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">go tool trace trace.out</span></span></code></pre></div><p>Trace 適合用在你已經知道有延遲問題，但 heap、CPU、goroutine profile 都不足以解釋時。它能顯示 goroutine 何時 runnable、何時 blocked、何時被排程。</p>
<p>Trace 檔案可能很大，不適合長時間收集。通常先抓短時間，確認問題窗口後再精準分析。</p>
<h2 id="策略診斷流程要先保留現場">【策略】診斷流程要先保留現場</h2>
<p>pprof 診斷的核心流程是先保留現場，再改程式。若你先重啟服務或調參，可能會清掉最重要的證據。</p>
<p>建議流程：</p>
<ol>
<li>記錄當下流量、版本、操作、時間區間。</li>
<li>讀 runtime <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a>：heap、GC、goroutine、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 長度。</li>
<li>依問題抓 profile：heap、goroutine、CPU 或 trace。</li>
<li>用 profile 找出函式與 stack pattern。</li>
<li>回到程式碼確認資料結構、goroutine lifecycle 或 hot path。</li>
<li>修改後用相同情境再抓一次 profile 驗證。</li>
</ol>
<p>這個流程能避免「看到 top 第一名就改」的衝動。Profile 需要和情境一起讀，才不會誤判。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的 profile 讀法；商用 APM 與分散式 tracing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine、allocation 與 runtime metrics；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>pprof 是診斷工具，不是公開 API。Heap profile 看保留與配置，goroutine profile 看存活與卡住路徑，CPU profile 看熱點，block/mutex profile 看等待，trace 看排程與延遲。好的診斷流程會先問對問題、抓對 profile，再把結果連回資料結構、goroutine lifecycle 與服務行為。</p>
]]></content:encoded></item><item><title>4.2 事件去重與語義鍵設計</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</guid><description>&lt;p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event ID 去重與 domain key 去重的差異&lt;/li>
&lt;li>用 subject、event type、source group 與時間窗口設計 &lt;code>DedupKey&lt;/code>&lt;/li>
&lt;li>避免把不穩定欄位放進去重鍵&lt;/li>
&lt;li>設計去重表的過期與清理策略&lt;/li>
&lt;li>用 table-driven test 驗證去重邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣&lt;/h2>
&lt;p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。&lt;/p>
&lt;p>兩筆外部輸入可能長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req_1001&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;event_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;provider_7788&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;account_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acct_1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;event_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;activated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:03Z&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req_1002&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;event_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;provider_7788_retry&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;account_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acct_1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;event_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;activated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:05Z&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果直接比對整包 JSON，這兩筆不同；如果從 domain 看，它們可能都是「同一個 account 在同一小段時間內變成 active」。&lt;/p>
&lt;h2 id="判讀去重鍵是語意決策">【判讀】去重鍵是語意決策&lt;/h2>
&lt;p>去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash；hash 只能回答 bytes 是否相同，不能回答領域事件是否相同。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DedupKey&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceSet&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Window&lt;/span> &lt;span class="kt">int64&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 key 表示：同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件，視為同一件事。&lt;/p>
&lt;p>&lt;code>SourceSet&lt;/code> 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道，可以映射到同一個 source set；若兩個來源代表不同權威資料，則應分開。&lt;/p>
&lt;h2 id="策略先選擇去重層級">【策略】先選擇去重層級&lt;/h2>
&lt;p>去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>去重方式&lt;/th>
 &lt;th>判斷依據&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>event ID&lt;/td>
 &lt;td>外部或內部 event ID 相同&lt;/td>
 &lt;td>上游提供穩定唯一 ID&lt;/td>
 &lt;td>上游 retry 可能換 ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>domain key&lt;/td>
 &lt;td>subject、type、時間窗口相同&lt;/td>
 &lt;td>多來源可能描述同一件事&lt;/td>
 &lt;td>key 設太粗會誤殺事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩者並用&lt;/td>
 &lt;td>event ID 先判斷，再用 domain key 補強&lt;/td>
 &lt;td>上游 ID 大多可信但不完全穩定&lt;/td>
 &lt;td>實作與測試較複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>小型服務可以先使用 domain key。若上游提供可靠 event ID，則 event ID 可以成為第一層快速去重，domain key 作為跨來源重複的保護。&lt;/p>
&lt;h2 id="執行用內部事件建立-dedupkey">【執行】用內部事件建立 DedupKey&lt;/h2>
&lt;p>&lt;code>DedupKey&lt;/code> 應該建立在 &lt;code>DomainEvent&lt;/code> 上，而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewDedupKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">window&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">DedupKey&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">DedupKey&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceSet&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">sourceSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Source&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">Window&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnixNano&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">window&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">sourceSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&lt;/span> &lt;span class="nx">EventSource&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">source&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">SourceHTTPCallback&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">SourceQueue&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;external_delivery&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">SourceTimer&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;internal_scan&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>OccurredAt&lt;/code> 通常比 &lt;code>ReceivedAt&lt;/code> 更適合事件語意去重。兩筆 retry 可能收到時間不同，但實際描述的發生時間相近；若使用收到時間，系統忙碌或網路延遲就會改變去重結果。&lt;/p>
&lt;h2 id="判讀哪些欄位不該放進-key">【判讀】哪些欄位不該放進 key&lt;/h2>
&lt;p>去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測，不適合用於判斷是否同一事件。&lt;/p>
&lt;p>不適合放進 key 的欄位：&lt;/p>
&lt;ul>
&lt;li>&lt;code>request_id&lt;/code>：每次 request 都可能不同。&lt;/li>
&lt;li>&lt;code>received_at&lt;/code>：取決於系統接收時間，不一定是事件語意。&lt;/li>
&lt;li>&lt;code>delivery_attempt&lt;/code>：重試次數本身就是重複事件的證據。&lt;/li>
&lt;li>raw payload hash：欄位順序、metadata 或非語意欄位可能改變。&lt;/li>
&lt;li>client IP、瀏覽器識別字串：代表傳輸脈絡，不代表事件本身。&lt;/li>
&lt;/ul>
&lt;p>適合放進 key 的欄位：&lt;/p></description><content:encoded><![CDATA[<p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、<a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 event ID 去重與 domain key 去重的差異</li>
<li>用 subject、event type、source group 與時間窗口設計 <code>DedupKey</code></li>
<li>避免把不穩定欄位放進去重鍵</li>
<li>設計去重表的過期與清理策略</li>
<li>用 table-driven test 驗證去重邊界</li>
</ol>
<hr>
<h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣</h2>
<p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。</p>
<p>兩筆外部輸入可能長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;request_id&#34;</span><span class="p">:</span> <span class="s2">&#34;req_1001&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;event_id&#34;</span><span class="p">:</span> <span class="s2">&#34;provider_7788&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;account_id&#34;</span><span class="p">:</span> <span class="s2">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:03Z&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;request_id&#34;</span><span class="p">:</span> <span class="s2">&#34;req_1002&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;event_id&#34;</span><span class="p">:</span> <span class="s2">&#34;provider_7788_retry&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;account_id&#34;</span><span class="p">:</span> <span class="s2">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:05Z&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果直接比對整包 JSON，這兩筆不同；如果從 domain 看，它們可能都是「同一個 account 在同一小段時間內變成 active」。</p>
<h2 id="判讀去重鍵是語意決策">【判讀】去重鍵是語意決策</h2>
<p>去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash；hash 只能回答 bytes 是否相同，不能回答領域事件是否相同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">DedupKey</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">SubjectID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>      <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">SourceSet</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Window</span>    <span class="kt">int64</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 key 表示：同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件，視為同一件事。</p>
<p><code>SourceSet</code> 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道，可以映射到同一個 source set；若兩個來源代表不同權威資料，則應分開。</p>
<h2 id="策略先選擇去重層級">【策略】先選擇去重層級</h2>
<p>去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。</p>
<table>
  <thead>
      <tr>
          <th>去重方式</th>
          <th>判斷依據</th>
          <th>適用情境</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event ID</td>
          <td>外部或內部 event ID 相同</td>
          <td>上游提供穩定唯一 ID</td>
          <td>上游 retry 可能換 ID</td>
      </tr>
      <tr>
          <td>domain key</td>
          <td>subject、type、時間窗口相同</td>
          <td>多來源可能描述同一件事</td>
          <td>key 設太粗會誤殺事件</td>
      </tr>
      <tr>
          <td>兩者並用</td>
          <td>event ID 先判斷，再用 domain key 補強</td>
          <td>上游 ID 大多可信但不完全穩定</td>
          <td>實作與測試較複雜</td>
      </tr>
  </tbody>
</table>
<p>小型服務可以先使用 domain key。若上游提供可靠 event ID，則 event ID 可以成為第一層快速去重，domain key 作為跨來源重複的保護。</p>
<h2 id="執行用內部事件建立-dedupkey">【執行】用內部事件建立 DedupKey</h2>
<p><code>DedupKey</code> 應該建立在 <code>DomainEvent</code> 上，而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">SourceSet</span><span class="p">:</span> <span class="nf">sourceSet</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Source</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>    <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">sourceSet</span><span class="p">(</span><span class="nx">source</span> <span class="nx">EventSource</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">switch</span> <span class="nx">source</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">SourceQueue</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;external_delivery&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceTimer</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;internal_scan&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="nb">string</span><span class="p">(</span><span class="nx">source</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>OccurredAt</code> 通常比 <code>ReceivedAt</code> 更適合事件語意去重。兩筆 retry 可能收到時間不同，但實際描述的發生時間相近；若使用收到時間，系統忙碌或網路延遲就會改變去重結果。</p>
<h2 id="判讀哪些欄位不該放進-key">【判讀】哪些欄位不該放進 key</h2>
<p>去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測，不適合用於判斷是否同一事件。</p>
<p>不適合放進 key 的欄位：</p>
<ul>
<li><code>request_id</code>：每次 request 都可能不同。</li>
<li><code>received_at</code>：取決於系統接收時間，不一定是事件語意。</li>
<li><code>delivery_attempt</code>：重試次數本身就是重複事件的證據。</li>
<li>raw payload hash：欄位順序、metadata 或非語意欄位可能改變。</li>
<li>client IP、瀏覽器識別字串：代表傳輸脈絡，不代表事件本身。</li>
</ul>
<p>適合放進 key 的欄位：</p>
<ul>
<li>subject ID：事件作用的對象。</li>
<li>event type：發生了什麼事。</li>
<li>source set：資料權威或來源語意。</li>
<li>occurred time window：同一事件可接受的時間範圍。</li>
</ul>
<h2 id="策略時間窗口是取捨">【策略】時間窗口是取捨</h2>
<p>時間窗口的核心作用是容忍短時間內的重送。窗口越短，越不容易誤殺不同事件；窗口越長，越能吸收延遲與 retry。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">const</span> <span class="nx">defaultDedupWindow</span> <span class="p">=</span> <span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span></span></span></code></pre></div><p>窗口大小應該依事件語意決定：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>可用窗口</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>account activated</td>
          <td>1-5 分鐘</td>
          <td>同一 account 短時間重複啟用通常是 retry</td>
      </tr>
      <tr>
          <td>notification created</td>
          <td>不一定適合時間窗口</td>
          <td>使用者可能短時間建立多筆通知</td>
      </tr>
      <tr>
          <td>job finished</td>
          <td>30 秒-2 分鐘</td>
          <td>job 完成事件通常只應發生一次</td>
      </tr>
      <tr>
          <td>heartbeat received</td>
          <td>不應去重成單一事件</td>
          <td>heartbeat 本身就是週期訊號</td>
      </tr>
  </tbody>
</table>
<p>時間窗口不是萬用答案。若事件本身允許短時間內多次發生，就需要更細的 subject 或 event ID，而不是把窗口調小到碰運氣。</p>
<h2 id="執行deduper-要保護共享-map">【執行】Deduper 要保護共享 map</h2>
<p>in-memory deduper 的核心責任是記住近期看過的 key，並在多 goroutine 下保持安全。只要 processor 可能同時處理事件，就需要 mutex 或單一 goroutine 擁有去重表。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Deduper</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">seen</span>    <span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">window</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">window</span><span class="p">,</span> <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="o">*</span><span class="nx">Deduper</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Deduper</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">seen</span><span class="p">:</span>    <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">window</span><span class="p">:</span>  <span class="nx">window</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">expires</span><span class="p">:</span> <span class="nx">expires</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">d</span><span class="p">.</span><span class="nx">window</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ctx</code> 在 memory 實作中可能用不到，但保留在 port 上能讓未來改成 Redis、資料庫或遠端服務時支援取消與逾時。</p>
<h2 id="執行去重表必須清理">【執行】去重表必須清理</h2>
<p>去重表的核心風險是無限制成長。只要把 key 放進 map，就必須定義 key 何時過期。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Cleanup</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">seenAt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">seenAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">d</span><span class="p">.</span><span class="nx">expires</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>expires</code> 通常應該大於 <code>window</code>。窗口決定兩筆事件是否可能被視為相同，過期時間決定 key 在記憶體中保留多久；兩者不是同一個概念。</p>
<h2 id="測試用-table-driven-test-固定語意">【測試】用 table-driven test 固定語意</h2>
<p>去重測試的核心目標是把「什麼算相同」寫成案例。這比只測 map 是否有資料更重要。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDedupKey</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">a</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">b</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">same</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;same subject type and window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceQueue</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;different subject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_2&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;outside window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">a</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span> <span class="o">==</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">b</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;same key = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試把來源融合、subject 差異與時間窗口都明確化。未來調整 key 時，測試會提醒你正在改變事件語意，而不只是改一個 struct。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的事件去重語意；跨節點一致性與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event normalization、processor 與 source priority；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">Go：事件去重邏輯的重構策略</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件去重是領域語意設計，不是 payload 比對。好的 <code>DedupKey</code> 會使用 subject、event type、source set 與合適的 occurred time window，並避免 request ID、收到時間與 raw payload hash 這類不穩定欄位。去重表還必須有清理策略，否則事件系統會用記憶體 leak 換取短期正確性。</p>
]]></content:encoded></item><item><title>5.2 WebSocket integration test</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> integration test 的核心目標是驗證 client 與 server 透過真實連線互動後，協定行為是否正確。它比單元測試慢，但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>httptest.Server&lt;/code> 建立真實 WebSocket 測試入口&lt;/li>
&lt;li>將 &lt;code>http://&lt;/code> 測試 URL 轉成 &lt;code>ws://&lt;/code>&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 避免 read/write 永久卡住&lt;/li>
&lt;li>驗證 subscribe、push、error response 與 cleanup&lt;/li>
&lt;li>分辨 integration test 與 unit test 的責任邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察websocket-的錯誤常出現在元件交界">【觀察】WebSocket 的錯誤常出現在元件交界&lt;/h2>
&lt;p>WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過，但真實連線仍可能因為 upgrade path、read pump、write pump、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 或 unregister 流程出錯。&lt;/p>
&lt;p>Integration test 適合驗證這些交界：&lt;/p>
&lt;ul>
&lt;li>client 能否成功 dial 到 &lt;code>/ws&lt;/code>&lt;/li>
&lt;li>server 是否接受 client action&lt;/li>
&lt;li>subscribe 後是否收到 acknowledgement&lt;/li>
&lt;li>server broadcast 是否能推到 client&lt;/li>
&lt;li>client 關閉後 hub 是否清理連線&lt;/li>
&lt;li>錯誤 action 是否回 error message 而不是斷線&lt;/li>
&lt;/ul>
&lt;p>這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。&lt;/p>
&lt;h2 id="判讀integration-test-補的是協作信心">【判讀】integration test 補的是協作信心&lt;/h2>
&lt;p>Integration test 的核心責任是覆蓋協定流程，不是取代所有規則測試。Router validation、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization、dedup key、state transition 應主要用單元測試；WebSocket integration test 只挑關鍵端到端流程。&lt;/p>
&lt;p>建議分工：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>測試類型&lt;/th>
 &lt;th>負責內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>unit test&lt;/td>
 &lt;td>router、payload validation、subscription state、TrySend&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>integration test&lt;/td>
 &lt;td>dial、upgrade、read/write pump、server response、cleanup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>race test&lt;/td>
 &lt;td>hub、client state、repository 的並發存取&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果每個 validation case 都啟動 WebSocket server，測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。&lt;/p>
&lt;h2 id="執行用-httptestserver-建立真實入口">【執行】用 httptest.Server 建立真實入口&lt;/h2>
&lt;p>WebSocket integration test 的核心起點是 &lt;code>httptest.Server&lt;/code>。它提供真實 HTTP server，不需要手動管理 port。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestWebSocketSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">newRouter&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Close&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wsURL&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;ws&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimPrefix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">URL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;/ws&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DefaultDialer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Dial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">wsURL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;dial websocket: %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>httptest.NewServer&lt;/code> 產生的是 &lt;code>http://127.0.0.1:port&lt;/code>，WebSocket client 需要 &lt;code>ws://127.0.0.1:port/ws&lt;/code>，所以常用字串轉換。&lt;/p>
&lt;p>若 handler 需要 hub、router、fake repository，應在測試中明確組裝。這讓 integration test 的依賴可控。&lt;/p>
&lt;h2 id="策略測試-helper-應封裝連線樣板">【策略】測試 helper 應封裝連線樣板&lt;/h2>
&lt;p>Integration test 的核心樣板很多：建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複，但不要把協定斷言藏起來。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> integration test 的核心目標是驗證 client 與 server 透過真實連線互動後，協定行為是否正確。它比單元測試慢，但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>httptest.Server</code> 建立真實 WebSocket 測試入口</li>
<li>將 <code>http://</code> 測試 URL 轉成 <code>ws://</code></li>
<li>用 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 避免 read/write 永久卡住</li>
<li>驗證 subscribe、push、error response 與 cleanup</li>
<li>分辨 integration test 與 unit test 的責任邊界</li>
</ol>
<hr>
<h2 id="觀察websocket-的錯誤常出現在元件交界">【觀察】WebSocket 的錯誤常出現在元件交界</h2>
<p>WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過，但真實連線仍可能因為 upgrade path、read pump、write pump、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 或 unregister 流程出錯。</p>
<p>Integration test 適合驗證這些交界：</p>
<ul>
<li>client 能否成功 dial 到 <code>/ws</code></li>
<li>server 是否接受 client action</li>
<li>subscribe 後是否收到 acknowledgement</li>
<li>server broadcast 是否能推到 client</li>
<li>client 關閉後 hub 是否清理連線</li>
<li>錯誤 action 是否回 error message 而不是斷線</li>
</ul>
<p>這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。</p>
<h2 id="判讀integration-test-補的是協作信心">【判讀】integration test 補的是協作信心</h2>
<p>Integration test 的核心責任是覆蓋協定流程，不是取代所有規則測試。Router validation、<a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization、dedup key、state transition 應主要用單元測試；WebSocket integration test 只挑關鍵端到端流程。</p>
<p>建議分工：</p>
<table>
  <thead>
      <tr>
          <th>測試類型</th>
          <th>負責內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>unit test</td>
          <td>router、payload validation、subscription state、TrySend</td>
      </tr>
      <tr>
          <td>integration test</td>
          <td>dial、upgrade、read/write pump、server response、cleanup</td>
      </tr>
      <tr>
          <td>race test</td>
          <td>hub、client state、repository 的並發存取</td>
      </tr>
  </tbody>
</table>
<p>如果每個 validation case 都啟動 WebSocket server，測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。</p>
<h2 id="執行用-httptestserver-建立真實入口">【執行】用 httptest.Server 建立真實入口</h2>
<p>WebSocket integration test 的核心起點是 <code>httptest.Server</code>。它提供真實 HTTP server，不需要手動管理 port。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestWebSocketSubscribe</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewServer</span><span class="p">(</span><span class="nf">newRouter</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">Close</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">wsURL</span> <span class="o">:=</span> <span class="s">&#34;ws&#34;</span> <span class="o">+</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimPrefix</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">URL</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s">&#34;/ws&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">websocket</span><span class="p">.</span><span class="nx">DefaultDialer</span><span class="p">.</span><span class="nf">Dial</span><span class="p">(</span><span class="nx">wsURL</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;dial websocket: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>httptest.NewServer</code> 產生的是 <code>http://127.0.0.1:port</code>，WebSocket client 需要 <code>ws://127.0.0.1:port/ws</code>，所以常用字串轉換。</p>
<p>若 handler 需要 hub、router、fake repository，應在測試中明確組裝。這讓 integration test 的依賴可控。</p>
<h2 id="策略測試-helper-應封裝連線樣板">【策略】測試 helper 應封裝連線樣板</h2>
<p>Integration test 的核心樣板很多：建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複，但不要把協定斷言藏起來。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">handler</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">,</span> <span class="o">*</span><span class="nx">httptest</span><span class="p">.</span><span class="nx">Server</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewServer</span><span class="p">(</span><span class="nx">handler</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">Close</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">wsURL</span> <span class="o">:=</span> <span class="s">&#34;ws&#34;</span> <span class="o">+</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimPrefix</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">URL</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s">&#34;/ws&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">websocket</span><span class="p">.</span><span class="nx">DefaultDialer</span><span class="p">.</span><span class="nf">Dial</span><span class="p">(</span><span class="nx">wsURL</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;dial websocket: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">server</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Helper 負責重複 setup。測試本文仍應清楚寫出「送什麼 message、期待什麼 response」。</p>
<h2 id="執行action-測試要檢查協定語意">【執行】action 測試要檢查協定語意</h2>
<p>Action 測試的核心流程是送 client message、讀 server message、檢查協定欄位。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionReturnsAcknowledgement</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouter</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">request</span> <span class="o">:=</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span> <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">request</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;write subscribe: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;topic_subscribed&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;response type = %q, want topic_subscribed&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;response topic = %q, want alerts&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試檢查的是協定語意，不只是連線沒有斷。Subscribe 的成功條件是 server 明確回覆訂閱成功。</p>
<h2 id="執行每次讀取前設定-deadline">【執行】每次讀取前設定 deadline</h2>
<p>WebSocket integration test 的核心風險是永久卡住。每次等待 server message 前，都應設定 read deadline。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">));</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;set read deadline: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">var</span> <span class="nx">response</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">response</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;read server message: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nx">response</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Deadline 是測試保護。若 server 沒有送出預期訊息，測試會在合理時間內失敗，而不是卡住整個測試套件。</p>
<p><a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">Timeout</a> 不應過短。CI 可能比本機慢，測試應給足合理緩衝，但仍要能快速暴露失敗。</p>
<h2 id="執行推送測試要先建立可觀察觸發點">【執行】推送測試要先建立可觀察觸發點</h2>
<p>Server push 的核心測試流程是先讓 client 訂閱 topic，再從 server 端觸發 broadcast，最後讀取 client 收到的 message。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribedClientReceivesBroadcast</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">go</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">Run</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouterWithHub</span><span class="p">(</span><span class="nx">hub</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">writeClientMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">}),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">,</span> <span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;notification&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">pushed</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">pushed</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;notification&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;pushed type = %q, want notification&#34;</span><span class="p">,</span> <span class="nx">pushed</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試證明 subscribe state、hub broadcast、write pump 能透過真實 connection 協作。若只想測 <code>Broadcast</code> 是否檢查 topic，應寫 hub unit test，不必走 WebSocket。</p>
<h2 id="策略非同步清理用-eventually不用固定-sleep">【策略】非同步清理用 eventually，不用固定 sleep</h2>
<p>連線清理測試的核心問題是 cleanup 通常非同步發生。測試應等待可觀察條件，而不是固定 sleep。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">timeout</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">condition</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">deadline</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">timeout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Before</span><span class="p">(</span><span class="nx">deadline</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nf">condition</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;condition was not met within %s&#34;</span><span class="p">,</span> <span class="nx">timeout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>使用方式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestClientIsRemovedAfterClose</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouterWithHub</span><span class="p">(</span><span class="nx">hub</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">ClientCount</span><span class="p">()</span> <span class="o">==</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">ClientCount</span><span class="p">()</span> <span class="o">==</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>eventually</code> 不是任意等待；它等待具體條件。失敗時，測試會指出 cleanup 沒發生，而不是把時間耗掉後仍然不清楚原因。</p>
<h2 id="判讀error-action-應測協定不只測-log">【判讀】error action 應測協定，不只測 log</h2>
<p>WebSocket action 失敗的核心語意是單次 action 失敗，不一定代表連線失敗。Integration test 應確認 server 回 error message，並且連線仍可繼續使用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestUnknownActionReturnsErrorMessage</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouter</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">writeClientMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="s">&#34;unknown_action&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;error&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;response type = %q, want error&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若設計上 unknown action 應直接關閉連線，也應明確測出 close 行為。重點是協定行為要可驗證，不要只依賴 server <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go server 內的 WebSocket 協定協作；跨節點 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 與壓力測試，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket handler、pump 與 heartbeat；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go：read/write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go：heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">Go 進階：CI、fuzz、load test 與 chaos testing</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>WebSocket integration test 應少量覆蓋關鍵端到端協定：dial、送 action、收 response、server push、錯誤回應與 cleanup。測試要使用真實 <code>httptest.Server</code>，每次 read 前設定 deadline，等待非同步清理時使用 <code>eventually</code>。單元測試負責大量規則，integration test 負責證明真實連線能把規則串起來。</p>
]]></content:encoded></item><item><title>6.2 健康檢查與診斷 endpoint</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/</guid><description>&lt;p>健康檢查與診斷 endpoint 的核心差異是使用者與風險不同。&lt;code>/health&lt;/code> 給監控或負載平衡器判斷 process 是否活著，&lt;code>/ready&lt;/code> 判斷是否應接流量，&lt;code>/debug/...&lt;/code> 則給工程師排查問題且必須限制存取。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 health、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、diagnostics 的語意&lt;/li>
&lt;li>設計快速穩定的 &lt;code>/health&lt;/code>&lt;/li>
&lt;li>用 &lt;code>/ready&lt;/code> 控制是否接新流量&lt;/li>
&lt;li>條件啟用 pprof、runtime stats 等診斷入口&lt;/li>
&lt;li>測試 status code 與 JSON response 合約&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察所有狀態都塞進-health-會讓監控失真">【觀察】所有狀態都塞進 health 會讓監控失真&lt;/h2>
&lt;p>Health endpoint 的核心風險是語意混亂。若 &lt;code>/health&lt;/code> 同時檢查 process、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、外部 API、cache、背景同步，任何依賴短暫波動都可能讓服務被判定死亡。&lt;/p>
&lt;p>問題範例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">/health
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├── process alive?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ├── database reachable?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ├── queue lag small?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ├── external API reachable?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> └── background sync fresh?&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這些問題不應全部塞進同一個 endpoint。Process 活著、可接流量、依賴降級、工程診斷，是不同操作訊號。&lt;/p>
&lt;h2 id="判讀healthreadydiagnostics-回答不同問題">【判讀】health、ready、diagnostics 回答不同問題&lt;/h2>
&lt;p>操作 endpoint 的核心設計是每個 endpoint 只回答一個問題。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Endpoint&lt;/th>
 &lt;th>使用者&lt;/th>
 &lt;th>回答的問題&lt;/th>
 &lt;th>失敗影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>/health&lt;/code>&lt;/td>
 &lt;td>process monitor&lt;/td>
 &lt;td>process 是否基本活著&lt;/td>
 &lt;td>可能重啟 process&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>/ready&lt;/code>&lt;/td>
 &lt;td>load balancer&lt;/td>
 &lt;td>是否應接新流量&lt;/td>
 &lt;td>暫停導流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>/debug/...&lt;/code>&lt;/td>
 &lt;td>工程師&lt;/td>
 &lt;td>服務內部狀態如何&lt;/td>
 &lt;td>不應公開&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>/metrics&lt;/code>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> collector&lt;/td>
 &lt;td>可聚合監控資料&lt;/td>
 &lt;td>監控缺資料&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這樣切分後，某個外部依賴故障不一定要讓 process 被重啟；服務可能只是不 ready，或處於 degraded 狀態。&lt;/p>
&lt;h2 id="執行health-endpoint-應簡單快速">【執行】health endpoint 應簡單快速&lt;/h2>
&lt;p>Health endpoint 的核心責任是快速回答 process 是否能處理基本 HTTP request。它應該簡單、快速、穩定。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">HandleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ok&amp;#34;}`&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>/health&lt;/code> 不應執行昂貴查詢，也不應依賴大量下游服務。若健康檢查本身很慢，監控會把診斷工具變成新問題。&lt;/p>
&lt;h2 id="執行readiness-控制是否接流量">【執行】readiness 控制是否接流量&lt;/h2>
&lt;p>Readiness 的核心責任是回答「服務現在是否應該接新流量」。它可以檢查啟動狀態、必要依賴、shutdown 狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Readiness&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ready&lt;/span> &lt;span class="nx">atomic&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">shuttingDown&lt;/span> &lt;span class="nx">atomic&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Readiness&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Ready&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ready&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Load&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">shuttingDown&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Load&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">HandleReady&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">readiness&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Readiness&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerFunc&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">readiness&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Ready&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;not_ready&amp;#34;}`&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ready&amp;#34;}`&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>服務啟動尚未完成、必要背景同步尚未就緒、或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 已開始時，readiness 應回 &lt;code>503&lt;/code>。Process 仍然活著，但不應接新流量。&lt;/p>
&lt;h2 id="策略dependency-check-依照監控語意分層">【策略】dependency check 依照監控語意分層&lt;/h2>
&lt;p>依賴檢查的核心判斷是故障是否代表 process 應重啟。Database 暫時不可用不一定代表 process 壞掉；重啟可能無法修復，反而造成更多負載。&lt;/p></description><content:encoded><![CDATA[<p>健康檢查與診斷 endpoint 的核心差異是使用者與風險不同。<code>/health</code> 給監控或負載平衡器判斷 process 是否活著，<code>/ready</code> 判斷是否應接流量，<code>/debug/...</code> 則給工程師排查問題且必須限制存取。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 health、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、diagnostics 的語意</li>
<li>設計快速穩定的 <code>/health</code></li>
<li>用 <code>/ready</code> 控制是否接新流量</li>
<li>條件啟用 pprof、runtime stats 等診斷入口</li>
<li>測試 status code 與 JSON response 合約</li>
</ol>
<hr>
<h2 id="觀察所有狀態都塞進-health-會讓監控失真">【觀察】所有狀態都塞進 health 會讓監控失真</h2>
<p>Health endpoint 的核心風險是語意混亂。若 <code>/health</code> 同時檢查 process、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、外部 API、cache、背景同步，任何依賴短暫波動都可能讓服務被判定死亡。</p>
<p>問題範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">/health
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├── process alive?
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├── database reachable?
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ├── queue lag small?
</span></span><span class="line"><span class="ln">5</span><span class="cl">  ├── external API reachable?
</span></span><span class="line"><span class="ln">6</span><span class="cl">  └── background sync fresh?</span></span></code></pre></div><p>這些問題不應全部塞進同一個 endpoint。Process 活著、可接流量、依賴降級、工程診斷，是不同操作訊號。</p>
<h2 id="判讀healthreadydiagnostics-回答不同問題">【判讀】health、ready、diagnostics 回答不同問題</h2>
<p>操作 endpoint 的核心設計是每個 endpoint 只回答一個問題。</p>
<table>
  <thead>
      <tr>
          <th>Endpoint</th>
          <th>使用者</th>
          <th>回答的問題</th>
          <th>失敗影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/health</code></td>
          <td>process monitor</td>
          <td>process 是否基本活著</td>
          <td>可能重啟 process</td>
      </tr>
      <tr>
          <td><code>/ready</code></td>
          <td>load balancer</td>
          <td>是否應接新流量</td>
          <td>暫停導流</td>
      </tr>
      <tr>
          <td><code>/debug/...</code></td>
          <td>工程師</td>
          <td>服務內部狀態如何</td>
          <td>不應公開</td>
      </tr>
      <tr>
          <td><code>/metrics</code></td>
          <td><a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> collector</td>
          <td>可聚合監控資料</td>
          <td>監控缺資料</td>
      </tr>
  </tbody>
</table>
<p>這樣切分後，某個外部依賴故障不一定要讓 process 被重啟；服務可能只是不 ready，或處於 degraded 狀態。</p>
<h2 id="執行health-endpoint-應簡單快速">【執行】health endpoint 應簡單快速</h2>
<p>Health endpoint 的核心責任是快速回答 process 是否能處理基本 HTTP request。它應該簡單、快速、穩定。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">HandleHealth</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{&#34;status&#34;:&#34;ok&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>/health</code> 不應執行昂貴查詢，也不應依賴大量下游服務。若健康檢查本身很慢，監控會把診斷工具變成新問題。</p>
<h2 id="執行readiness-控制是否接流量">【執行】readiness 控制是否接流量</h2>
<p>Readiness 的核心責任是回答「服務現在是否應該接新流量」。它可以檢查啟動狀態、必要依賴、shutdown 狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Readiness</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ready</span>        <span class="nx">atomic</span><span class="p">.</span><span class="nx">Bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">shuttingDown</span> <span class="nx">atomic</span><span class="p">.</span><span class="nx">Bool</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">Readiness</span><span class="p">)</span> <span class="nf">Ready</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">ready</span><span class="p">.</span><span class="nf">Load</span><span class="p">()</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">r</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Load</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">HandleReady</span><span class="p">(</span><span class="nx">readiness</span> <span class="o">*</span><span class="nx">Readiness</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">HandlerFunc</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">readiness</span><span class="p">.</span><span class="nf">Ready</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{&#34;status&#34;:&#34;not_ready&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{&#34;status&#34;:&#34;ready&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>服務啟動尚未完成、必要背景同步尚未就緒、或 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 已開始時，readiness 應回 <code>503</code>。Process 仍然活著，但不應接新流量。</p>
<h2 id="策略dependency-check-依照監控語意分層">【策略】dependency check 依照監控語意分層</h2>
<p>依賴檢查的核心判斷是故障是否代表 process 應重啟。Database 暫時不可用不一定代表 process 壞掉；重啟可能無法修復，反而造成更多負載。</p>
<p>建議分層：</p>
<ul>
<li><code>/health</code>：只確認 process alive。</li>
<li><code>/ready</code>：確認必要依賴是否足以接新流量。</li>
<li><code>/diagnostics/dependencies</code>：提供工程師查看細節。</li>
</ul>
<p>診斷 response 可以包含穩定欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;degraded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;dependencies&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;database&#34;</span><span class="p">:</span> <span class="s2">&#34;ok&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;queue&#34;</span><span class="p">:</span> <span class="s2">&#34;lagging&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>監控應依賴 status code 與穩定欄位，工程師再用 body 細節診斷問題。自由文字可以輔助閱讀，但不應成為監控規則的依據。</p>
<h2 id="執行diagnostics-endpoint-要條件啟用">【執行】diagnostics endpoint 要條件啟用</h2>
<p>Diagnostics endpoint 的核心用途是提供工程師排查問題的資料。pprof、runtime metrics、internal queue length、goroutine count 都屬於這類。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RegisterDiagnostics</span><span class="p">(</span><span class="nx">mux</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">ServeMux</span><span class="p">,</span> <span class="nx">enabled</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">enabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/debug/runtime&#34;</span><span class="p">,</span> <span class="nx">HandleRuntimeStats</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="nf">HandleRuntimeStats</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kd">var</span> <span class="nx">stats</span> <span class="nx">runtime</span><span class="p">.</span><span class="nx">MemStats</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">ReadMemStats</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">stats</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">any</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="s">&#34;heap_alloc&#34;</span><span class="p">:</span>  <span class="nx">stats</span><span class="p">.</span><span class="nx">HeapAlloc</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="s">&#34;num_gc&#34;</span><span class="p">:</span>      <span class="nx">stats</span><span class="p">.</span><span class="nx">NumGC</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="s">&#34;goroutines&#34;</span><span class="p">:</span>  <span class="nx">runtime</span><span class="p">.</span><span class="nf">NumGoroutine</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">response</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Diagnostics 可能揭露內部狀態、記憶體資訊、goroutine 數量、路徑與部署細節，不應公開給一般使用者。若需要長期保留，至少應限制在內網、管理 port、認證或防火牆後。</p>
<h2 id="判讀status-code-是監控合約">【判讀】status code 是監控合約</h2>
<p>健康檢查的核心合約是 status code。監控系統通常先看 HTTP code 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>，不會理解複雜 body。</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>200 OK</code></td>
          <td>符合該 endpoint 的健康條件</td>
      </tr>
      <tr>
          <td><code>503 Service Unavailable</code></td>
          <td>暫時不可用或不應接流量</td>
      </tr>
      <tr>
          <td><code>405 Method Not Allowed</code></td>
          <td>呼叫方式錯誤</td>
      </tr>
      <tr>
          <td>timeout</td>
          <td>endpoint 無法在預期時間內回應</td>
      </tr>
  </tbody>
</table>
<p>Body 可以提供人類可讀資訊，但不應讓監控依賴自由文字。若要機器讀取，使用穩定 JSON 欄位，例如 <code>status</code>、<code>reason</code>、<code>dependencies</code>。</p>
<h2 id="測試endpoint-測試要鎖定-status-code">【測試】endpoint 測試要鎖定 status code</h2>
<p>Endpoint 測試的核心是驗證 status code 與穩定 JSON 欄位，而不是完整自由文字。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestReadyReturnsUnavailableWhenShuttingDown</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">readiness</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">Readiness</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">readiness</span><span class="p">.</span><span class="nx">ready</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">readiness</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;/ready&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nf">HandleReady</span><span class="p">(</span><span class="nx">readiness</span><span class="p">).</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Diagnostics endpoint 也應測 gate 關閉時不註冊或回 404，避免診斷入口不小心暴露。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 health、readiness 與 diagnostics 的語意切分；Prometheus、OpenTelemetry 與平台設定，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 pprof、runtime metrics 與 deploy readiness；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go：pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go：GC 與 memory limit</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go：結構化日誌</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a></li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a></li>
</ul>
<h2 id="小結">小結</h2>
<p><code>/health</code>、<code>/ready</code>、diagnostics endpoint 解決不同問題。Health 檢查 process 基本可用性，readiness 控制是否接新流量，diagnostics 支援工程排查且應限制存取。Status code 是監控合約，JSON body 是補充細節；把這些訊號混在一起會讓操作判斷與安全邊界都變模糊。</p>
]]></content:encoded></item><item><title>7.2 Durable queue、outbox 與 idempotency</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/</guid><description>&lt;p>跨 process 事件傳遞的核心責任是讓事件在失敗、重試與重複投遞下仍維持可預期語意。Channel 只能處理單一 process 內的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ；[durable &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go-advanced/backend/knowledge-cards/durable-queue)、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> store 才能處理服務重啟、網路失敗與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 重試。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 outbox 為什麼能避免半成功&lt;/li>
&lt;li>分辨 domain dedup key 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key 的用途&lt;/li>
&lt;li>設計可重入的 consumer / processor&lt;/li>
&lt;li>用 retry、DLQ 與回補流程處理失敗事件&lt;/li>
&lt;li>把事件可靠性寫進資料結構，讓規則可以被程式與測試驗證&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Backend：Ack / Nack&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">Backend：Retry Policy&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Backend：Dead-Letter Queue&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Backend：Consumer Lag&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Outbox 如何避免「狀態已寫入，但事件沒送出」的半成功。&lt;/li>
&lt;li>Idempotency key 如何和 domain dedup key 分工。&lt;/li>
&lt;li>Consumer retry、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message&lt;/a> 如何設計處理流程。&lt;/li>
&lt;li>At-least-once delivery 下，processor 如何保持可重入。&lt;/li>
&lt;li>Queue lag、retry count、dead-letter count 應如何進入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 與 metric。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察outbox-是把資料與事件綁在同一個-transaction">【觀察】outbox 是把資料與事件綁在同一個 transaction&lt;/h2>
&lt;p>outbox 的核心概念是：先把業務狀態與待發事件一起寫進資料庫，再由獨立 publisher 把 outbox 內容送到 queue 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>。這樣即使 process 在寫完資料後當機，也不會丟掉事件。&lt;/p>
&lt;p>典型流程是：&lt;/p>
&lt;ol>
&lt;li>usecase 開 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a>。&lt;/li>
&lt;li>寫入 domain data。&lt;/li>
&lt;li>寫入 outbox record。&lt;/li>
&lt;li>commit。&lt;/li>
&lt;li>background publisher 讀出未送出的 outbox。&lt;/li>
&lt;li>成功後把 outbox 標成已送出。&lt;/li>
&lt;/ol>
&lt;p>這個模型的重點是讓「至少會被發現並補送」成為可能。它承認跨 process 傳遞很難保證絕對只送一次，所以後續還要搭配 idempotency。&lt;/p>
&lt;h2 id="判讀idempotency-是跨-process-的必要邊界">【判讀】idempotency 是跨 process 的必要邊界&lt;/h2>
&lt;p>只要事件可能重送，consumer 就要能承受重複訊息。idempotent processor 的核心是讓同一筆事件重複進來時，結果仍然穩定。&lt;/p></description><content:encoded><![CDATA[<p>跨 process 事件傳遞的核心責任是讓事件在失敗、重試與重複投遞下仍維持可預期語意。Channel 只能處理單一 process 內的 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ；[durable <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go-advanced/backend/knowledge-cards/durable-queue)、<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox</a> 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store 才能處理服務重啟、網路失敗與 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 重試。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 outbox 為什麼能避免半成功</li>
<li>分辨 domain dedup key 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 的用途</li>
<li>設計可重入的 consumer / processor</li>
<li>用 retry、DLQ 與回補流程處理失敗事件</li>
<li>把事件可靠性寫進資料結構，讓規則可以被程式與測試驗證</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合</a></li>
<li><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Backend：Ack / Nack</a></li>
<li><a href="/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">Backend：Retry Policy</a></li>
<li><a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Backend：Dead-Letter Queue</a></li>
<li><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Backend：Consumer Lag</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Outbox 如何避免「狀態已寫入，但事件沒送出」的半成功。</li>
<li>Idempotency key 如何和 domain dedup key 分工。</li>
<li>Consumer retry、<a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a> 與 <a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message</a> 如何設計處理流程。</li>
<li>At-least-once delivery 下，processor 如何保持可重入。</li>
<li>Queue lag、retry count、dead-letter count 應如何進入 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 metric。</li>
</ol>
<h2 id="觀察outbox-是把資料與事件綁在同一個-transaction">【觀察】outbox 是把資料與事件綁在同一個 transaction</h2>
<p>outbox 的核心概念是：先把業務狀態與待發事件一起寫進資料庫，再由獨立 publisher 把 outbox 內容送到 queue 或 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>。這樣即使 process 在寫完資料後當機，也不會丟掉事件。</p>
<p>典型流程是：</p>
<ol>
<li>usecase 開 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>。</li>
<li>寫入 domain data。</li>
<li>寫入 outbox record。</li>
<li>commit。</li>
<li>background publisher 讀出未送出的 outbox。</li>
<li>成功後把 outbox 標成已送出。</li>
</ol>
<p>這個模型的重點是讓「至少會被發現並補送」成為可能。它承認跨 process 傳遞很難保證絕對只送一次，所以後續還要搭配 idempotency。</p>
<h2 id="判讀idempotency-是跨-process-的必要邊界">【判讀】idempotency 是跨 process 的必要邊界</h2>
<p>只要事件可能重送，consumer 就要能承受重複訊息。idempotent processor 的核心是讓同一筆事件重複進來時，結果仍然穩定。</p>
<p>常見做法包括：</p>
<ul>
<li>用 event ID 記錄已處理過的訊息</li>
<li>用 domain key 去重，讓同一個業務操作不會重複套用</li>
<li>用狀態機檢查 transition 是否已發生</li>
</ul>
<h2 id="策略dlq-是流程的一部分">【策略】DLQ 是流程的一部分</h2>
<p>當事件重試失敗，dead-letter queue 要變成可處理的操作流程。你要知道：</p>
<ul>
<li>為什麼失敗</li>
<li>要重試幾次</li>
<li>什麼錯誤可以直接放棄</li>
<li>什麼錯誤需要人工回補</li>
</ul>
<p>如果沒有這些規則，DLQ 只會變成看不完的黑洞。</p>
<h2 id="執行可重入-processor-的基本形式">【執行】可重入 processor 的基本形式</h2>
<p>可重入的核心要求是同一事件重跑時，不會把資料弄壞。簡化的處理流程通常長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">Processor</span><span class="p">)</span> <span class="nf">Handle</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">evt</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">evt</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">evt</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">MarkSeen</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">evt</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>實際實作時，<code>Seen</code> 與 <code>MarkSeen</code> 通常要跟業務狀態放在同一個一致性邊界裡，避免競態。</p>
<h2 id="延伸queue-lag-與-retry-需要被觀測">【延伸】queue lag 與 retry 需要被觀測</h2>
<p>只要有 durable queue，就一定會有 backlog、retry 與 failure pattern。這些訊號應進入 log 與 metric，讓工程師知道是 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 變慢、consumer 壞掉，還是下游依賴正在抖動。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不追求 exactly-once 的口號。教材重點會放在 Go 服務如何承認 at-least-once 的現實，並用 idempotent processor、outbox 與可觀測欄位降低風險。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接 Go 的事件邊界與非阻塞送出；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合</a></li>
</ul>
]]></content:encoded></item><item><title>模組二：WebSocket 服務架構</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 服務的核心難點是連線建立後的長生命週期管理。HTTP upgrade 只是入口；每個 client 都有讀取、寫入、心跳、訂閱、推送佇列與清理流程。任何一個邊界不清楚，都可能造成 goroutine leak、concurrent write、慢 client 拖垮服務或訂閱狀態不一致。&lt;/p>
&lt;p>本模組承接模組一的並發主題：read pump / write pump 對應 goroutine ownership，heartbeat 對應 select loop 與 ticker，send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>，訂閱集合對應共享狀態與 copy boundary。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">2.1&lt;/a>&lt;/td>
 &lt;td>read pump / write pump 模式&lt;/td>
 &lt;td>讓單一連線的讀取、寫入與清理責任可推理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">2.2&lt;/a>&lt;/td>
 &lt;td>heartbeat、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與連線清理&lt;/td>
 &lt;td>用 ping/pong、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與統一 unregister 偵測失效連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">2.3&lt;/a>&lt;/td>
 &lt;td>訂閱模型與訊息路由&lt;/td>
 &lt;td>把 client action 轉成可測的 command 與訂閱狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">2.4&lt;/a>&lt;/td>
 &lt;td>慢客戶端與 send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 管理&lt;/td>
 &lt;td>用 bounded buffer、drop policy 與 byte budget 控制容量風險&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。Client 可以訂閱 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>，server 會依 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 推送 notification、status update 或 error message。&lt;/p>
&lt;p>範例只用來展示 Go &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 服務設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用一個 read pump 負責 client 輸入。&lt;/li>
&lt;li>用一個 write pump 負責所有 WebSocket 寫入。&lt;/li>
&lt;li>用 channel 作為 client send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>。&lt;/li>
&lt;li>用 context、done channel 或 hub unregister 管理連線生命週期。&lt;/li>
&lt;li>用 ticker 實作 heartbeat，但由 write pump 統一寫 ping。&lt;/li>
&lt;li>用 mutex 或 hub event loop 保護訂閱狀態。&lt;/li>
&lt;li>用 non-blocking send 保護 hub 不被慢 client 卡住。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪個 goroutine 可以讀 WebSocket，哪個 goroutine 可以寫 WebSocket&lt;/li>
&lt;li>read pump、write pump、hub unregister 之間如何協作&lt;/li>
&lt;li>heartbeat 失敗後應該走哪一條清理路徑&lt;/li>
&lt;li>client action 應該在 router、usecase 還是 hub 裡處理&lt;/li>
&lt;li>send buffer 滿載時應該丟棄、斷線、回錯或改用可靠儲存&lt;/li>
&lt;/ol>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>WebSocket 章節依照連線生命週期拆分。Read/write pump、heartbeat、subscription routing、slow client 是四個不同責任；它們常在同一個 hub 或 client type 中互相呼叫，但教學上應分開建立模型。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 服務的核心難點是連線建立後的長生命週期管理。HTTP upgrade 只是入口；每個 client 都有讀取、寫入、心跳、訂閱、推送佇列與清理流程。任何一個邊界不清楚，都可能造成 goroutine leak、concurrent write、慢 client 拖垮服務或訂閱狀態不一致。</p>
<p>本模組承接模組一的並發主題：read pump / write pump 對應 goroutine ownership，heartbeat 對應 select loop 與 ticker，send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 對應 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>，訂閱集合對應共享狀態與 copy boundary。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">2.1</a></td>
          <td>read pump / write pump 模式</td>
          <td>讓單一連線的讀取、寫入與清理責任可推理</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">2.2</a></td>
          <td>heartbeat、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與連線清理</td>
          <td>用 ping/pong、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與統一 unregister 偵測失效連線</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">2.3</a></td>
          <td>訂閱模型與訊息路由</td>
          <td>把 client action 轉成可測的 command 與訂閱狀態</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">2.4</a></td>
          <td>慢客戶端與 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 管理</td>
          <td>用 bounded buffer、drop policy 與 byte budget 控制容量風險</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。Client 可以訂閱 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a>，server 會依 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 推送 notification、status update 或 error message。</p>
<p>範例只用來展示 Go <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 服務設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用一個 read pump 負責 client 輸入。</li>
<li>用一個 write pump 負責所有 WebSocket 寫入。</li>
<li>用 channel 作為 client send <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>。</li>
<li>用 context、done channel 或 hub unregister 管理連線生命週期。</li>
<li>用 ticker 實作 heartbeat，但由 write pump 統一寫 ping。</li>
<li>用 mutex 或 hub event loop 保護訂閱狀態。</li>
<li>用 non-blocking send 保護 hub 不被慢 client 卡住。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪個 goroutine 可以讀 WebSocket，哪個 goroutine 可以寫 WebSocket</li>
<li>read pump、write pump、hub unregister 之間如何協作</li>
<li>heartbeat 失敗後應該走哪一條清理路徑</li>
<li>client action 應該在 router、usecase 還是 hub 裡處理</li>
<li>send buffer 滿載時應該丟棄、斷線、回錯或改用可靠儲存</li>
</ol>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>WebSocket 章節依照連線生命週期拆分。Read/write pump、heartbeat、subscription routing、slow client 是四個不同責任；它們常在同一個 hub 或 client type 中互相呼叫，但教學上應分開建立模型。</p>
<p>如果只想處理單一問題，可以這樣查：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>優先閱讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>concurrent write 或讀寫責任混亂</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式</a></td>
      </tr>
      <tr>
          <td>連線失效沒有被清理</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat、deadline 與連線清理</a></td>
      </tr>
      <tr>
          <td>action、payload、訂閱狀態混在一起</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由</a></td>
      </tr>
      <tr>
          <td>慢 client 拖垮 hub</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論 WebSocket 壓縮、水平擴展、跨節點廣播或完整身份驗證。這些都是實務重要主題，但必須先建立單一 Go process 內的連線生命週期與容量邊界；後續可接 <a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">跨節點 WebSocket、presence 與重連協定</a>。</p>
<h2 id="先備知識">先備知識</h2>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/" data-link-title="模組一：進階並發模式" data-link-desc="channel ownership、select loop、非阻塞送出、共享狀態、worker pool 與 rate limiting">模組一：進階並發模式</a></li>
<li><a href="/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">Go 入門：標準庫 HTTP</a></li>
<li>知道 goroutine、channel、select、context 的基本用法</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>1.3 非阻塞送出與事件丟棄策略</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/</guid><description>&lt;p>非阻塞送出的核心取捨是用明確降級換取呼叫端可用性。當 channel 滿載時，程式可以等待、回錯、丟棄、覆蓋或轉交可靠儲存；選擇哪一個是服務語意，不是 &lt;code>select&lt;/code> 語法偏好。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 blocking send 與 non-blocking send 的服務語意&lt;/li>
&lt;li>為 HTTP、worker、即時推送設計不同滿載策略&lt;/li>
&lt;li>判斷哪些事件可以丟、哪些不能丟&lt;/li>
&lt;li>為 drop 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full 建立 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>/metric&lt;/li>
&lt;li>測試 channel 滿載時的行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察channel-滿載是容量訊號">【觀察】channel 滿載是容量訊號&lt;/h2>
&lt;p>Channel 滿載的核心意義是下游處理速度跟不上上游輸入速度。這可能是短暫尖峰，也可能是系統長期容量不足。&lt;/p>
&lt;p>最直接的 send 會接受 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 &lt;code>events&lt;/code> 沒有 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>，或 buffer 已滿，sender 會等待 receiver。這能保留資料，但也可能讓 HTTP handler、connection writer 或其他 goroutine 卡住。&lt;/p>
&lt;p>對批次 worker 來說，等待可能合理；對使用者 request 來說，無限等待通常會變成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 或 goroutine 堆積。&lt;/p>
&lt;h2 id="判讀blocking-send-表示願意等待">【判讀】blocking send 表示願意等待&lt;/h2>
&lt;p>Blocking send 的核心語意是 sender 接受下游 backpressure 。資料不會被丟掉，但 sender 的生命週期會被 receiver 影響。&lt;/p>
&lt;p>有 context 的 blocking send：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Enqueue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種寫法仍然願意等待，但不會無限等待。若 request 被取消或 timeout，send 也會停止。&lt;/p>
&lt;p>Blocking send 適合資料不能丟、上游能等待、且等待時間受 context 控制的情境。若沒有 context，blocking send 在服務入口通常風險較高。&lt;/p>
&lt;h2 id="判讀non-blocking-send-表示立即選擇替代路徑">【判讀】non-blocking send 表示立即選擇替代路徑&lt;/h2>
&lt;p>Non-blocking send 的核心語意是「能送就送，不能送就立刻走其他策略」。Go 常用 &lt;code>select&lt;/code> 加 &lt;code>default&lt;/code> 表達。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">ErrQueueFull&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">errors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event queue is full&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TryEnqueue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ErrQueueFull&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式不會等待 receiver。當 buffer 滿載時，呼叫端會立刻拿到 &lt;code>ErrQueueFull&lt;/code>，並可以決定回 HTTP 錯誤、記錄 drop、或改走其他儲存。&lt;/p>
&lt;p>Non-blocking send 不是比較進階的寫法。它只是把 backpressure 從「等待」改成「立即決策」。&lt;/p>
&lt;h2 id="策略先定義事件的保留等級">【策略】先定義事件的保留等級&lt;/h2>
&lt;p>滿載策略的核心判斷是資料語意。每種事件都應先定義保留等級：必須保存、可降級、可覆蓋、可取樣，或可延後處理。這個等級決定 channel 滿載時要等待、回錯、丟棄、覆蓋或轉交可靠儲存。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件類型&lt;/th>
 &lt;th>建議策略&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log&lt;/a>&lt;/td>
 &lt;td>不應直接丟，應寫可靠儲存或回錯&lt;/td>
 &lt;td>資料遺失會破壞稽核&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UI 即時提示&lt;/td>
 &lt;td>可丟棄或覆蓋&lt;/td>
 &lt;td>使用者可重新查詢狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>狀態轉移事件&lt;/td>
 &lt;td>通常不應丟&lt;/td>
 &lt;td>會造成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 不一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> sample&lt;/td>
 &lt;td>可取樣或丟棄&lt;/td>
 &lt;td>趨勢比單筆資料重要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>background refresh&lt;/td>
 &lt;td>可跳過本輪&lt;/td>
 &lt;td>下次仍可重新計算&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個表格的重點是要求每種事件都要有明確策略。若團隊只說「channel 滿了就 default」，通常代表資料語意還沒有想清楚。&lt;/p>
&lt;h2 id="執行http-入口要把滿載轉成狀態碼">【執行】HTTP 入口要把滿載轉成狀態碼&lt;/h2>
&lt;p>HTTP 入口的核心責任是把內部滿載轉成呼叫端能理解的結果。不要讓 request 一直等到 timeout，也不要把未接受的事件回成成功。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">EventHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerFunc&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-Request-ID&amp;#34;&lt;/span>&lt;span class="p">)}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">TryEnqueue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">errors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Is&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ErrQueueFull&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Retry-After&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event queue is full&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event enqueue failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusInternalServerError&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>202 Accepted&lt;/code> 表示事件已被服務接受進入後續處理。&lt;code>503 Service Unavailable&lt;/code> 表示服務目前無法接受更多事件，呼叫端可以稍後重試。&lt;/p></description><content:encoded><![CDATA[<p>非阻塞送出的核心取捨是用明確降級換取呼叫端可用性。當 channel 滿載時，程式可以等待、回錯、丟棄、覆蓋或轉交可靠儲存；選擇哪一個是服務語意，不是 <code>select</code> 語法偏好。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 blocking send 與 non-blocking send 的服務語意</li>
<li>為 HTTP、worker、即時推送設計不同滿載策略</li>
<li>判斷哪些事件可以丟、哪些不能丟</li>
<li>為 drop 與 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 建立 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>/metric</li>
<li>測試 channel 滿載時的行為</li>
</ol>
<hr>
<h2 id="觀察channel-滿載是容量訊號">【觀察】channel 滿載是容量訊號</h2>
<p>Channel 滿載的核心意義是下游處理速度跟不上上游輸入速度。這可能是短暫尖峰，也可能是系統長期容量不足。</p>
<p>最直接的 send 會接受 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span></span></span></code></pre></div><p>如果 <code>events</code> 沒有 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>，或 buffer 已滿，sender 會等待 receiver。這能保留資料，但也可能讓 HTTP handler、connection writer 或其他 goroutine 卡住。</p>
<p>對批次 worker 來說，等待可能合理；對使用者 request 來說，無限等待通常會變成 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 或 goroutine 堆積。</p>
<h2 id="判讀blocking-send-表示願意等待">【判讀】blocking send 表示願意等待</h2>
<p>Blocking send 的核心語意是 sender 接受下游 backpressure 。資料不會被丟掉，但 sender 的生命週期會被 receiver 影響。</p>
<p>有 context 的 blocking send：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">Enqueue</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">case</span> <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種寫法仍然願意等待，但不會無限等待。若 request 被取消或 timeout，send 也會停止。</p>
<p>Blocking send 適合資料不能丟、上游能等待、且等待時間受 context 控制的情境。若沒有 context，blocking send 在服務入口通常風險較高。</p>
<h2 id="判讀non-blocking-send-表示立即選擇替代路徑">【判讀】non-blocking send 表示立即選擇替代路徑</h2>
<p>Non-blocking send 的核心語意是「能送就送，不能送就立刻走其他策略」。Go 常用 <code>select</code> 加 <code>default</code> 表達。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">ErrQueueFull</span> <span class="p">=</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;event queue is full&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">func</span> <span class="nf">TryEnqueue</span><span class="p">(</span><span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrQueueFull</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式不會等待 receiver。當 buffer 滿載時，呼叫端會立刻拿到 <code>ErrQueueFull</code>，並可以決定回 HTTP 錯誤、記錄 drop、或改走其他儲存。</p>
<p>Non-blocking send 不是比較進階的寫法。它只是把 backpressure 從「等待」改成「立即決策」。</p>
<h2 id="策略先定義事件的保留等級">【策略】先定義事件的保留等級</h2>
<p>滿載策略的核心判斷是資料語意。每種事件都應先定義保留等級：必須保存、可降級、可覆蓋、可取樣，或可延後處理。這個等級決定 channel 滿載時要等待、回錯、丟棄、覆蓋或轉交可靠儲存。</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>建議策略</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log</a></td>
          <td>不應直接丟，應寫可靠儲存或回錯</td>
          <td>資料遺失會破壞稽核</td>
      </tr>
      <tr>
          <td>UI 即時提示</td>
          <td>可丟棄或覆蓋</td>
          <td>使用者可重新查詢狀態</td>
      </tr>
      <tr>
          <td>狀態轉移事件</td>
          <td>通常不應丟</td>
          <td>會造成 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 不一致</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> sample</td>
          <td>可取樣或丟棄</td>
          <td>趨勢比單筆資料重要</td>
      </tr>
      <tr>
          <td>background refresh</td>
          <td>可跳過本輪</td>
          <td>下次仍可重新計算</td>
      </tr>
  </tbody>
</table>
<p>這個表格的重點是要求每種事件都要有明確策略。若團隊只說「channel 滿了就 default」，通常代表資料語意還沒有想清楚。</p>
<h2 id="執行http-入口要把滿載轉成狀態碼">【執行】HTTP 入口要把滿載轉成狀態碼</h2>
<p>HTTP 入口的核心責任是把內部滿載轉成呼叫端能理解的結果。不要讓 request 一直等到 timeout，也不要把未接受的事件回成成功。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">EventHandler</span><span class="p">(</span><span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">HandlerFunc</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">event</span> <span class="o">:=</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Request-ID&#34;</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">TryEnqueue</span><span class="p">(</span><span class="nx">events</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">ErrQueueFull</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Retry-After&#34;</span><span class="p">,</span> <span class="s">&#34;5&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;event queue is full&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;event enqueue failed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>202 Accepted</code> 表示事件已被服務接受進入後續處理。<code>503 Service Unavailable</code> 表示服務目前無法接受更多事件，呼叫端可以稍後重試。</p>
<p>若事件不能丟，HTTP handler 應該回錯或寫入可靠儲存，不應假裝成功。</p>
<h2 id="執行即時推送可以選擇-drop-或-disconnect">【執行】即時推送可以選擇 drop 或 disconnect</h2>
<p>即時推送的核心問題是慢 client 不能拖住整個服務。若某個連線的 send buffer 滿了，服務要決定丟掉該訊息、丟掉舊訊息，還是關閉連線。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">Message</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">Message</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫端可以根據 <code>false</code> 決定策略：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">metrics</span><span class="p">.</span><span class="nf">Inc</span><span class="p">(</span><span class="s">&#34;client_send_dropped&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;drop client message&#34;</span><span class="p">,</span> <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;send_buffer_full&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>對狀態型 UI 來說，丟掉中間更新可能可以接受，因為下一次 snapshot 會補上最新狀態。對逐筆不可遺失訊息來說，應改用可靠佇列或明確斷線重連協定。</p>
<h2 id="策略buffer-只能吸收短暫尖峰">【策略】buffer 只能吸收短暫尖峰</h2>
<p>Buffer 的核心作用是平滑短時間流量差，不是解決長期處理能力不足。把 channel buffer 調大，只是延後滿載時間，也可能增加記憶體與延遲。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1024</span><span class="p">)</span></span></span></code></pre></div><p>設計 buffer 時至少要考慮：</p>
<ul>
<li>單筆事件大小</li>
<li><a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 峰值速度</li>
<li><a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 穩定處理速度</li>
<li>允許排隊延遲</li>
<li>滿載時的回應策略</li>
</ul>
<p>若 producer 每秒 1000 筆、consumer 每秒 100 筆，任何有限 buffer 都會滿。這時要改善 consumer 能力、增加 worker、做取樣、回錯或使用可靠 queue，而不是只調大數字。</p>
<h2 id="策略丟棄一定要可觀測">【策略】丟棄一定要可觀測</h2>
<p>Drop strategy 的核心要求是可觀測。只要系統選擇丟棄或降級，就應該留下 metric 或 structured log，否則資料遺失會變成隱性 bug。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TryEnqueueWithMetrics</span><span class="p">(</span><span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">logger</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">metrics</span><span class="p">.</span><span class="nf">Inc</span><span class="p">(</span><span class="s">&#34;event_enqueue_success&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">metrics</span><span class="p">.</span><span class="nf">Inc</span><span class="p">(</span><span class="s">&#34;event_enqueue_dropped&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;drop event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;queue_full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrQueueFull</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Log 適合保留單次事件脈絡，metric 適合觀察趨勢。若 drop rate 升高，代表服務正在降級；這應該能被監控看見。</p>
<h2 id="測試滿載行為要直接測">【測試】滿載行為要直接測</h2>
<p>Non-blocking send 的測試核心是先讓 channel 滿載，再確認函式立刻回錯。不要用 sleep 等待「可能會滿」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestTryEnqueueReturnsQueueFull</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;already_full&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nf">TryEnqueue</span><span class="p">(</span><span class="nx">events</span><span class="p">,</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;next&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">ErrQueueFull</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;error = %v, want ErrQueueFull&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Blocking send with context 也可以測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestEnqueueStopsWhenContextCanceled</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nf">Enqueue</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">events</span><span class="p">,</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;evt_1&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;error = %v, want context canceled&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些測試把滿載和取消變成可重現條件，不需要依賴時間推測。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 process 內的滿載處理策略；當訊息需要持久化、重試或跨 process 傳遞時，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel backpressure 、worker capacity 與事件丟棄策略；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Go：rate limiting 與 backpressure </a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go：多來源 event 融合</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>非阻塞送出是服務策略，不是語法技巧。Channel 滿載時，系統必須明確選擇等待、回錯、丟棄、覆蓋或轉交可靠儲存。選擇之前先定義事件的保留等級，選擇之後補上 log、metric 與測試，才能讓 backpressure 成為可管理的服務行為。</p>
]]></content:encoded></item><item><title>2.3 訂閱模型與訊息路由</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/</guid><description>&lt;p>訂閱模型的核心目標是把 client action 轉成明確的連線狀態與回應訊息。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 是長連線，單次 action 失敗通常不應直接關閉連線；router 應把錯誤轉成可理解的 server message。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>設計穩定的 client action envelope&lt;/li>
&lt;li>把 router、handler、usecase 與 client state 分開&lt;/li>
&lt;li>用訂閱集合表達 client 想收到的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>&lt;/li>
&lt;li>在 broadcast 前檢查訂閱狀態&lt;/li>
&lt;li>測試 action routing、payload validation 與 error response&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察client-message-很容易變成臨時協定">【觀察】client message 很容易變成臨時協定&lt;/h2>
&lt;p>WebSocket action 的核心風險是前後端快速加功能時，訊息格式變成一堆臨時欄位。若 action 名稱依賴按鈕、畫面或短期 UI 狀態，server 很快會累積難以維護的分支。&lt;/p>
&lt;p>不穩定的訊息格式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;button&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;watch&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;tab&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;jobs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;topic_1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種訊息描述 UI 發生什麼，不是描述 client 想對服務做什麼。服務端應該接收穩定 action，例如 &lt;code>subscribe_topic&lt;/code>、&lt;code>unsubscribe_topic&lt;/code>、&lt;code>list_subscriptions&lt;/code>。&lt;/p>
&lt;h2 id="判讀action-是-client-intent">【判讀】action 是 client intent&lt;/h2>
&lt;p>Client action 的核心語意是「client 想做什麼」。它不是 domain event，因為它還沒被驗證、授權或套用規則。Domain event 表示已經發生的事，action 表示請求。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionSubscribeTopic&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;subscribe_topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionUnsubscribeTopic&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;unsubscribe_topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionListTopics&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;list_topics&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ClientMessage&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">Action&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="s">`json:&amp;#34;action&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">Data&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span> &lt;span class="s">`json:&amp;#34;data,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>外層 envelope 穩定，內層 &lt;code>Data&lt;/code> 依 action 解析。這讓 read pump 可以先解析 envelope，router 再依 action 決定 payload 型別。&lt;/p>
&lt;h2 id="策略router-負責分派不擁有全部規則">【策略】router 負責分派，不擁有全部規則&lt;/h2>
&lt;p>Router 的核心責任是把 action 分派到對應 handler。它應該知道有哪些 action，但不應把訂閱規則、權限檢查、資料查詢全部塞在一個巨大 switch 裡。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Router&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">SubscriptionService&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">Router&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Route&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionSubscribeTopic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionUnsubscribeTopic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleUnsubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionListTopics&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleListTopics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unknown action: %s&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>switch&lt;/code> 讓支援的 action 集中可見。真正的訂閱狀態修改可以交給 &lt;code>SubscriptionService&lt;/code> 或 client method，避免 router 變成所有規則的聚集地。&lt;/p>
&lt;h2 id="執行payload-validation-在-action-邊界完成">【執行】payload validation 在 action 邊界完成&lt;/h2>
&lt;p>Payload validation 的核心責任是讓內部服務只收到有效 command。訂閱 topic 至少要檢查 JSON 格式、topic 是否空白、topic 名稱是否符合規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubscribeTopicCommand&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">ClientID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">Router&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unmarshal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;decode subscribe request: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">topic&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">topic&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;topic is required&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">cmd&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">SubscribeTopicCommand&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">ClientID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ID&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">subscriptions&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Request struct 是 wire format，command 是內部意圖。兩者分開後，JSON 命名、驗證錯誤與內部服務規則不會混在同一個型別。&lt;/p></description><content:encoded><![CDATA[<p>訂閱模型的核心目標是把 client action 轉成明確的連線狀態與回應訊息。<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 是長連線，單次 action 失敗通常不應直接關閉連線；router 應把錯誤轉成可理解的 server message。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>設計穩定的 client action envelope</li>
<li>把 router、handler、usecase 與 client state 分開</li>
<li>用訂閱集合表達 client 想收到的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a></li>
<li>在 broadcast 前檢查訂閱狀態</li>
<li>測試 action routing、payload validation 與 error response</li>
</ol>
<hr>
<h2 id="觀察client-message-很容易變成臨時協定">【觀察】client message 很容易變成臨時協定</h2>
<p>WebSocket action 的核心風險是前後端快速加功能時，訊息格式變成一堆臨時欄位。若 action 名稱依賴按鈕、畫面或短期 UI 狀態，server 很快會累積難以維護的分支。</p>
<p>不穩定的訊息格式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;button&#34;</span><span class="p">:</span> <span class="s2">&#34;watch&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;tab&#34;</span><span class="p">:</span> <span class="s2">&#34;jobs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;topic_1&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種訊息描述 UI 發生什麼，不是描述 client 想對服務做什麼。服務端應該接收穩定 action，例如 <code>subscribe_topic</code>、<code>unsubscribe_topic</code>、<code>list_subscriptions</code>。</p>
<h2 id="判讀action-是-client-intent">【判讀】action 是 client intent</h2>
<p>Client action 的核心語意是「client 想做什麼」。它不是 domain event，因為它還沒被驗證、授權或套用規則。Domain event 表示已經發生的事，action 表示請求。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">ClientAction</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">ActionSubscribeTopic</span>   <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;subscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">ActionUnsubscribeTopic</span> <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;unsubscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ActionListTopics</span>       <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;list_topics&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">ClientMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">Action</span> <span class="nx">ClientAction</span>    <span class="s">`json:&#34;action&#34;`</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Data</span>   <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="s">`json:&#34;data,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>外層 envelope 穩定，內層 <code>Data</code> 依 action 解析。這讓 read pump 可以先解析 envelope，router 再依 action 決定 payload 型別。</p>
<h2 id="策略router-負責分派不擁有全部規則">【策略】router 負責分派，不擁有全部規則</h2>
<p>Router 的核心責任是把 action 分派到對應 handler。它應該知道有哪些 action，但不應把訂閱規則、權限檢查、資料查詢全部塞在一個巨大 switch 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Router</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">subscriptions</span> <span class="o">*</span><span class="nx">SubscriptionService</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">Router</span><span class="p">)</span> <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">switch</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Action</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionSubscribeTopic</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleSubscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionUnsubscribeTopic</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleUnsubscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionListTopics</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleListTopics</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unknown action: %s&#34;</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Action</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>switch</code> 讓支援的 action 集中可見。真正的訂閱狀態修改可以交給 <code>SubscriptionService</code> 或 client method，避免 router 變成所有規則的聚集地。</p>
<h2 id="執行payload-validation-在-action-邊界完成">【執行】payload validation 在 action 邊界完成</h2>
<p>Payload validation 的核心責任是讓內部服務只收到有效 command。訂閱 topic 至少要檢查 JSON 格式、topic 是否空白、topic 名稱是否符合規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">SubscribeTopicRequest</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">SubscribeTopicCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ClientID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Topic</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">Router</span><span class="p">)</span> <span class="nf">handleSubscribe</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">raw</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">SubscribeTopicRequest</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">req</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;decode subscribe request: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">topic</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">topic</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;topic is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">SubscribeTopicCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">ClientID</span><span class="p">:</span> <span class="nx">client</span><span class="p">.</span><span class="nf">ID</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>    <span class="nx">topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Request struct 是 wire format，command 是內部意圖。兩者分開後，JSON 命名、驗證錯誤與內部服務規則不會混在同一個型別。</p>
<h2 id="執行訂閱集合是連線狀態">【執行】訂閱集合是連線狀態</h2>
<p>訂閱集合的核心語意是「這個 client 目前想收到哪些 topic」。它可以放在 client 上，也可以由 hub 集中保存；重點是 owner 要明確。</p>
<p>Client owner 版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">id</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">subscriptions</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Subscribe</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">[</span><span class="nx">topic</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Unsubscribe</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">delete</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">,</span> <span class="nx">topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">[</span><span class="nx">topic</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="nx">ok</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>map[string]struct{}</code> 是 Go 常見 set 表示法。若 read pump 修改訂閱，hub broadcast 讀取訂閱，就需要 lock 或把所有訂閱操作集中到 hub event loop。</p>
<h2 id="策略訂閱狀態也需要-copy-boundary">【策略】訂閱狀態也需要 copy boundary</h2>
<p>訂閱列表的核心風險是直接回傳 map 會暴露內部狀態。若需要列出目前訂閱，應回傳 slice 或 map copy。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Subscriptions</span><span class="p">()</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">topics</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">string</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">topic</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">topics</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">topics</span><span class="p">,</span> <span class="nx">topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">sort</span><span class="p">.</span><span class="nf">Strings</span><span class="p">(</span><span class="nx">topics</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">topics</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>回傳 sorted slice 讓測試更穩定，也避免呼叫端修改內部 map。排序不是業務必要條件，但對 API response 與測試可讀性有幫助。</p>
<h2 id="執行成功與失敗都應回-server-message">【執行】成功與失敗都應回 server message</h2>
<p>WebSocket action 的核心互動模式是 request-like，但連線不會因單次 action 結束。成功或失敗都應回一筆 server message，讓 client 能更新 UI 或顯示錯誤。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">ServerMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Type</span>  <span class="kt">string</span> <span class="s">`json:&#34;type&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Error</span> <span class="kt">string</span> <span class="s">`json:&#34;error,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">SubscriptionService</span><span class="p">)</span> <span class="nf">Subscribe</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">SubscribeTopicCommand</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;topic_subscribed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrClientQueueFull</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 action 失敗，read pump 或 router wrapper 可以把錯誤轉成 <code>ServerMessage{Type: &quot;error&quot;}</code>。不要只寫 server <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>，因為 client 需要知道該 action 沒有成功。</p>
<h2 id="執行broadcast-前檢查訂閱">【執行】broadcast 前檢查訂閱</h2>
<p>Broadcast 的核心規則是 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 只產生 topic 與 message，hub 決定哪些 client 應該收到。訂閱邏輯不應散落在每個 producer 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式先檢查訂閱，再嘗試送出。若 client 的 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿了，hub 可以 unregister 或採用其他慢 client 策略；下一章會專門處理。</p>
<h2 id="測試router-test-不需要真實-websocket">【測試】router test 不需要真實 WebSocket</h2>
<p>Router 的測試核心是 action 到行為的對應。它不需要真實 WebSocket connection，只需要 fake client 或檢查 client state。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionAddsTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">router</span> <span class="o">:=</span> <span class="nx">Router</span><span class="p">{</span><span class="nx">subscriptions</span><span class="p">:</span> <span class="nf">NewSubscriptionService</span><span class="p">()}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">data</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">RawMessage</span><span class="p">(</span><span class="s">`{&#34;topic&#34;:&#34;alerts&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;route subscribe: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;client should subscribe to alerts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Payload validation 也應獨立測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionRequiresTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">router</span> <span class="o">:=</span> <span class="nx">Router</span><span class="p">{</span><span class="nx">subscriptions</span><span class="p">:</span> <span class="nf">NewSubscriptionService</span><span class="p">()}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nx">json</span><span class="p">.</span><span class="nf">RawMessage</span><span class="p">(</span><span class="s">`{&#34;topic&#34;:&#34; &#34;}`</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;empty topic should return error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>WebSocket integration test 留給「真實 client/server 互動」；router 單元測試先確保協定語意正確。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 action envelope 到 subscription 的路由與 ownership；授權、presence 與跨節點同步，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket action、event fusion 與 handler boundary；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go：事件融合</a></li>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>訂閱模型把 client action 轉成連線狀態與 server response。Action 是 client intent，不是 domain event；router 負責分派，payload validation 在邊界完成，訂閱集合要有明確 owner，broadcast 由 hub 統一檢查訂閱。這樣新增 action 或 topic 時，修改範圍會清楚且可測。</p>
]]></content:encoded></item><item><title>3.3 goroutine leak 偵測</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/</guid><description>&lt;p>Goroutine leak 偵測的核心目標是確認已經沒有存在價值的 goroutine 能被停止。它通常是生命週期問題：誰取消、誰 close、誰解除 I/O 阻塞、誰停止 ticker。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨合理長期 goroutine 與 goroutine leak&lt;/li>
&lt;li>用 context、done channel、connection close 設計退出路徑&lt;/li>
&lt;li>用 pprof goroutine profile 判讀卡住 stack&lt;/li>
&lt;li>測試 worker、ticker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> pump 是否能退出&lt;/li>
&lt;li>從 leak pattern 回到 ownership 修正&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察goroutine-leak-是生命週期沒有結束">【觀察】goroutine leak 是生命週期沒有結束&lt;/h2>
&lt;p>Goroutine leak 的核心定義是某個 goroutine 已經沒有存在價值，卻仍然活著。它可能卡在 channel receive、channel send、network read、ticker、mutex 或永遠不會觸發的 select case。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">StartWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">jobs&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 worker 只有在 &lt;code>jobs&lt;/code> 被關閉時才會退出。若呼叫端永遠不關閉 &lt;code>jobs&lt;/code>，而 worker 也沒有 context，這個 goroutine 可能永久存在。&lt;/p>
&lt;p>長期存在不一定是 leak。HTTP server accept loop、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> exporter、background scheduler 都可能合理存在；問題是它們是否有明確停止條件，且 shutdown 時是否真的會走到。&lt;/p>
&lt;h2 id="判讀每個-goroutine-都要有退出原因">【判讀】每個 goroutine 都要有退出原因&lt;/h2>
&lt;p>Goroutine lifecycle 的核心檢查是每個 goroutine 都能回答三個問題：&lt;/p>
&lt;ol>
&lt;li>誰要求它停止？&lt;/li>
&lt;li>它如果卡在 channel 或 I/O，如何被喚醒？&lt;/li>
&lt;li>它停止後如何讓測試或上層知道？&lt;/li>
&lt;/ol>
&lt;p>若三題任一題答不出來，就有 leak 風險。&lt;/p>
&lt;p>例如 worker 應該有 context：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">RunWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 worker 有兩條退出路徑：上層取消 context，或 jobs channel 被關閉。這比只依賴 channel close 更容易整合到服務 shutdown。&lt;/p>
&lt;h2 id="策略io-阻塞需要-deadline-或-close">【策略】I/O 阻塞需要 deadline 或 close&lt;/h2>
&lt;p>I/O goroutine 的核心風險是 context 本身不一定能打斷底層阻塞呼叫。WebSocket read、TCP read、file watcher、外部 API call 都要確認是否支援 context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 或 close。&lt;/p>
&lt;p>WebSocket read pump 常見退出方式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">readPump&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">hub&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Hub&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">router&lt;/span> &lt;span class="nx">MessageRouter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">unregister&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">c&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">message&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">router&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Route&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>若 &lt;code>ReadJSON&lt;/code> 卡住，context 取消不一定直接讓它返回。實務上需要 read deadline、connection close 或 heartbeat 讓 read pump 有機會退出。&lt;/p>
&lt;h2 id="執行done-channel-讓測試能觀察退出">【執行】done channel 讓測試能觀察退出&lt;/h2>
&lt;p>測試 goroutine 是否退出的核心問題是需要可觀察訊號。&lt;code>done&lt;/code> channel 可以在 goroutine 結束時 close。&lt;/p></description><content:encoded><![CDATA[<p>Goroutine leak 偵測的核心目標是確認已經沒有存在價值的 goroutine 能被停止。它通常是生命週期問題：誰取消、誰 close、誰解除 I/O 阻塞、誰停止 ticker。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨合理長期 goroutine 與 goroutine leak</li>
<li>用 context、done channel、connection close 設計退出路徑</li>
<li>用 pprof goroutine profile 判讀卡住 stack</li>
<li>測試 worker、ticker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> pump 是否能退出</li>
<li>從 leak pattern 回到 ownership 修正</li>
</ol>
<hr>
<h2 id="觀察goroutine-leak-是生命週期沒有結束">【觀察】goroutine leak 是生命週期沒有結束</h2>
<p>Goroutine leak 的核心定義是某個 goroutine 已經沒有存在價值，卻仍然活著。它可能卡在 channel receive、channel send、network read、ticker、mutex 或永遠不會觸發的 select case。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">StartWorker</span><span class="p">(</span><span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">for</span> <span class="nx">job</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">jobs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 worker 只有在 <code>jobs</code> 被關閉時才會退出。若呼叫端永遠不關閉 <code>jobs</code>，而 worker 也沒有 context，這個 goroutine 可能永久存在。</p>
<p>長期存在不一定是 leak。HTTP server accept loop、<a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> exporter、background scheduler 都可能合理存在；問題是它們是否有明確停止條件，且 shutdown 時是否真的會走到。</p>
<h2 id="判讀每個-goroutine-都要有退出原因">【判讀】每個 goroutine 都要有退出原因</h2>
<p>Goroutine lifecycle 的核心檢查是每個 goroutine 都能回答三個問題：</p>
<ol>
<li>誰要求它停止？</li>
<li>它如果卡在 channel 或 I/O，如何被喚醒？</li>
<li>它停止後如何讓測試或上層知道？</li>
</ol>
<p>若三題任一題答不出來，就有 leak 風險。</p>
<p>例如 worker 應該有 context：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 worker 有兩條退出路徑：上層取消 context，或 jobs channel 被關閉。這比只依賴 channel close 更容易整合到服務 shutdown。</p>
<h2 id="策略io-阻塞需要-deadline-或-close">【策略】I/O 阻塞需要 deadline 或 close</h2>
<p>I/O goroutine 的核心風險是 context 本身不一定能打斷底層阻塞呼叫。WebSocket read、TCP read、file watcher、外部 API call 都要確認是否支援 context、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 或 close。</p>
<p>WebSocket read pump 常見退出方式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">readPump</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">hub</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">,</span> <span class="nx">router</span> <span class="nx">MessageRouter</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">c</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="kd">var</span> <span class="nx">message</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">c</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 <code>ReadJSON</code> 卡住，context 取消不一定直接讓它返回。實務上需要 read deadline、connection close 或 heartbeat 讓 read pump 有機會退出。</p>
<h2 id="執行done-channel-讓測試能觀察退出">【執行】done channel 讓測試能觀察退出</h2>
<p>測試 goroutine 是否退出的核心問題是需要可觀察訊號。<code>done</code> channel 可以在 goroutine 結束時 close。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">,</span> <span class="nx">done</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="kd">struct</span><span class="p">{})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestRunWorkerStops</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kd">struct</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">go</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">jobs</span><span class="p">,</span> <span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;worker did not stop&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">Timeout</a> 是測試保護，不是功能本身。真正的退出訊號是 <code>done</code> 被關閉。</p>
<h2 id="執行ticker-必須停止">【執行】ticker 必須停止</h2>
<p>Ticker leak 的核心原因是建立 ticker 後沒有呼叫 <code>Stop</code>。Ticker 會持有 runtime 資源；長時間服務若反覆建立不停止，會累積不必要成本。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RunCleanup</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nf">cleanup</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>defer ticker.Stop()</code> 應緊跟在成功建立 ticker 後。這樣不管函式因 context、錯誤或 channel 關閉退出，ticker 都會被停止。</p>
<p><code>time.After</code> 在一次性 timeout 很方便，但在高頻迴圈裡反覆建立 timer 可能造成額外配置。需要重複觸發時，優先使用 <code>Ticker</code> 或可重設的 <code>Timer</code> 並明確停止。</p>
<h2 id="判讀pprof-goroutine-profile-看-stack-pattern">【判讀】pprof goroutine profile 看 stack pattern</h2>
<p>Goroutine profile 的核心價值是顯示 goroutine stack。當 goroutine 數量持續上升時，先看它們卡在哪裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl <span class="s2">&#34;http://localhost:8080/debug/pprof/goroutine?debug=2&#34;</span></span></span></code></pre></div><p>常見 pattern：</p>
<table>
  <thead>
      <tr>
          <th>stack 類型</th>
          <th>可能原因</th>
          <th>回到哪個邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>channel receive</td>
          <td>上游不會再送，也沒 close/context</td>
          <td>channel ownership</td>
      </tr>
      <tr>
          <td>channel send</td>
          <td>下游不再接收或 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿</td>
          <td><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> / unregister</td>
      </tr>
      <tr>
          <td>network read</td>
          <td>沒有 deadline 或 connection 未 close</td>
          <td>heartbeat / I/O lifecycle</td>
      </tr>
      <tr>
          <td>ticker loop</td>
          <td>context 沒接上或 ticker 未 stop</td>
          <td>select loop lifecycle</td>
      </tr>
      <tr>
          <td>mutex lock</td>
          <td>鎖競爭或死鎖</td>
          <td>shared state owner</td>
      </tr>
  </tbody>
</table>
<p>看到 stack 後，下一步是回到對應 lifecycle 設計：誰負責停止，誰負責釋放阻塞點。</p>
<h2 id="策略websocket-pump-leak-要看-readwriteunregister-三方">【策略】WebSocket pump leak 要看 read/write/unregister 三方</h2>
<p>WebSocket goroutine leak 的核心常見原因是 read pump、write pump、hub unregister 沒有形成閉環。</p>
<p>目標流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">read pump error 或 connection close
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">hub unregister
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        ├── close client.send
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        └── close conn
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">write pump exits</span></span></code></pre></div><p>若 hub 沒有 close <code>send</code>，write pump 可能一直等。若 connection 沒有 close，read pump 可能卡在 read。若 unregister 不是 idempotent，重複 close 可能 panic。</p>
<p>Goroutine profile 若顯示大量 goroutine 卡在 <code>writePump</code> 的 send receive，通常要檢查 <code>client.send</code> 是否會被 close。若卡在 <code>ReadJSON</code>，要檢查 read deadline、heartbeat 與 connection close。</p>
<h2 id="測試用-goroutine-數量做粗略回歸檢查">【測試】用 goroutine 數量做粗略回歸檢查</h2>
<p>Goroutine 數量測試的核心用途是粗略檢查是否有明顯 leak。它不是精準證明，因為 Go runtime 與測試環境本身也會有 goroutine。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNoObviousGoroutineLeak</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">before</span> <span class="o">:=</span> <span class="nx">runtime</span><span class="p">.</span><span class="nf">NumGoroutine</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kd">struct</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;worker did not stop&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="nx">runtime</span><span class="p">.</span><span class="nf">NumGoroutine</span><span class="p">()</span> <span class="o">&lt;=</span> <span class="nx">before</span><span class="o">+</span><span class="mi">2</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這類測試要留緩衝，避免因 runtime 或其他測試 goroutine 造成假失敗。更可靠的測試仍是等待明確 <code>done</code> 訊號。</p>
<h2 id="判讀goroutine-leak-修正要改停止路徑">【判讀】goroutine leak 修正要改停止路徑</h2>
<p>Goroutine leak 的核心修正是補上停止路徑。</p>
<p>常見修正：</p>
<ul>
<li>加入 <code>ctx.Done()</code> case。</li>
<li>關閉由自己擁有的 output channel。</li>
<li>由 coordinator 等 sender 完成再 close。</li>
<li>對 network read/write 設定 deadline。</li>
<li>shutdown 時 close connection。</li>
<li>ticker 建立後 <code>defer Stop()</code>。</li>
<li>hub unregister 時 close send channel。</li>
</ul>
<p>修正後要用測試證明退出路徑真的會發生，再用 pprof 或 goroutine count 驗證趨勢。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 goroutine 的啟動、停止與阻塞邊界；更完整的 worker 全域治理，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">Go 進階：channel ownership 與關閉責任</a></li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go 進階：bounded worker pool</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine lifecycle、channel 與 shutdown；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Goroutine leak 是生命週期問題。每個長期 goroutine 都應知道誰能停止它、如何解除阻塞、如何讓測試觀察退出。Context、done channel、deadline、connection close、ticker stop 與 hub unregister 是主要工具。pprof goroutine profile 則用來確認還活著的 goroutine 卡在哪個邊界。</p>
]]></content:encoded></item><item><title>4.3 Source of Truth：狀態邊界</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of truth&lt;/a> 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化，但不能各自保存一份會被當成真相的資料。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷狀態真相應該由哪個元件擁有&lt;/li>
&lt;li>把狀態轉移集中在 repository 或 state owner&lt;/li>
&lt;li>同步更新 current state 與 history&lt;/li>
&lt;li>用 copy boundary 保護 slice、map、pointer&lt;/li>
&lt;li>分辨 internal state、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 與 response view&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察狀態分散會讓系統失去真相">【觀察】狀態分散會讓系統失去真相&lt;/h2>
&lt;p>狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map，worker 可能有 cache，publisher 可能有最後推送狀態；當三者不一致時，系統很難回答「現在到底是什麼狀態」。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">handlerStates&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">workerStates&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">publisherLastSent&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這三份資料可能都叫做 state，但只有一份應該是 source of truth。其他資料如果存在，應該明確標示為 cache、projection 或 delivery record，不能被當成狀態判斷依據。&lt;/p>
&lt;h2 id="判讀source-of-truth-是寫入權責">【判讀】source of truth 是寫入權責&lt;/h2>
&lt;p>source of truth 的核心不是「資料存在哪裡」，而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存；真正的邊界是所有寫入都經過同一組規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountPending&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountActive&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountBlocked&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;blocked&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AccountState&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">AccountStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>AccountState&lt;/code> 是 domain 狀態，不是 HTTP response。它應該表達系統內部真正需要維護的資料，而不是直接迎合某個 API 的輸出格式。&lt;/p>
&lt;h2 id="策略用明確方法集中狀態轉移">【策略】用明確方法集中狀態轉移&lt;/h2>
&lt;p>狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command，由 state owner 決定是否合法、如何更新、是否記錄 history。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">StateRepository&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">records&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">AccountRecord&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AccountRecord&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">Current&lt;/span> &lt;span class="nx">AccountState&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">History&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">AccountState&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewStateRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">StateRepository&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">StateRepository&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">records&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">AccountRecord&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>repository 擁有 &lt;code>records&lt;/code> map。其他元件不應取得這個 map 的 reference，也不應繞過 repository 修改狀態。&lt;/p>
&lt;h2 id="執行apply-把事件轉成狀態變化">【執行】Apply 把事件轉成狀態變化&lt;/h2>
&lt;p>&lt;code>Apply&lt;/code> 的核心責任是把 domain event 套用到 state。它是事件系統與狀態系統的交界。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">StateRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">transition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">next&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">next&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">record&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件，不會出現 current 已更新但 history 漏記的情境。&lt;/p>
&lt;p>&lt;code>transition&lt;/code> 可以是純函式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">transition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">current&lt;/span> &lt;span class="nx">AccountState&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">AccountState&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">EventAccountActivated&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">AccountState&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">AccountActive&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">AccountState&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unsupported event type: %s&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存，transition 負責 domain 規則。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of truth</a> 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化，但不能各自保存一份會被當成真相的資料。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷狀態真相應該由哪個元件擁有</li>
<li>把狀態轉移集中在 repository 或 state owner</li>
<li>同步更新 current state 與 history</li>
<li>用 copy boundary 保護 slice、map、pointer</li>
<li>分辨 internal state、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 與 response view</li>
</ol>
<hr>
<h2 id="觀察狀態分散會讓系統失去真相">【觀察】狀態分散會讓系統失去真相</h2>
<p>狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map，worker 可能有 cache，publisher 可能有最後推送狀態；當三者不一致時，系統很難回答「現在到底是什麼狀態」。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">handlerStates</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">workerStates</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">publisherLastSent</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{}</span></span></span></code></pre></div><p>這三份資料可能都叫做 state，但只有一份應該是 source of truth。其他資料如果存在，應該明確標示為 cache、projection 或 delivery record，不能被當成狀態判斷依據。</p>
<h2 id="判讀source-of-truth-是寫入權責">【判讀】source of truth 是寫入權責</h2>
<p>source of truth 的核心不是「資料存在哪裡」，而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存；真正的邊界是所有寫入都經過同一組規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountStatus</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">AccountPending</span> <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">AccountActive</span>  <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">AccountBlocked</span> <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;blocked&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountState</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">AccountStatus</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>AccountState</code> 是 domain 狀態，不是 HTTP response。它應該表達系統內部真正需要維護的資料，而不是直接迎合某個 API 的輸出格式。</p>
<h2 id="策略用明確方法集中狀態轉移">【策略】用明確方法集中狀態轉移</h2>
<p>狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command，由 state owner 決定是否合法、如何更新、是否記錄 history。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">StateRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">records</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">AccountRecord</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountRecord</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Current</span> <span class="nx">AccountState</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">History</span> <span class="p">[]</span><span class="nx">AccountState</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">func</span> <span class="nf">NewStateRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">StateRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">StateRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">records</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">AccountRecord</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>repository 擁有 <code>records</code> map。其他元件不應取得這個 map 的 reference，也不應繞過 repository 修改狀態。</p>
<h2 id="執行apply-把事件轉成狀態變化">【執行】Apply 把事件轉成狀態變化</h2>
<p><code>Apply</code> 的核心責任是把 domain event 套用到 state。它是事件系統與狀態系統的交界。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">record</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">next</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">transition</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">History</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">record</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件，不會出現 current 已更新但 history 漏記的情境。</p>
<p><code>transition</code> 可以是純函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">transition</span><span class="p">(</span><span class="nx">current</span> <span class="nx">AccountState</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">EventAccountActivated</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">Status</span><span class="p">:</span>    <span class="nx">AccountActive</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unsupported event type: %s&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存，transition 負責 domain 規則。</p>
<h2 id="判讀currenthistoryprojection-是不同資料">【判讀】current、history、projection 是不同資料</h2>
<p>狀態資料的核心分類是 internal state、history 與 projection。三者用途不同，不應混成同一個 struct 到處傳。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>角色</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>internal state</td>
          <td>系統判斷真相的資料</td>
          <td><code>AccountState</code></td>
      </tr>
      <tr>
          <td>history</td>
          <td>狀態變化紀錄</td>
          <td><code>[]AccountState</code></td>
      </tr>
      <tr>
          <td>projection</td>
          <td>查詢或 UI 需要的讀取模型</td>
          <td><code>AccountSummary</code></td>
      </tr>
      <tr>
          <td>response view</td>
          <td>特定 API 的輸出格式</td>
          <td><code>accountResponse</code></td>
      </tr>
  </tbody>
</table>
<p>projection 可以從 state 與 history 組出來，但 projection 不應反過來成為狀態真相。API 需要新增欄位時，優先新增 response view 或 projection，不要直接污染 internal state。</p>
<h2 id="執行查詢要回傳-copy">【執行】查詢要回傳 copy</h2>
<p>copy boundary 的核心目標是防止呼叫端修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Current</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">,</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>AccountState</code> 目前只有值型別欄位，直接回傳值即可。history 是 slice，必須複製：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">History</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">history</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">].</span><span class="nx">History</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">AccountState</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">history</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 state 內含 map、slice 或 pointer，還需要 deep copy。copy 有成本，但它是狀態邊界的保護；資料量大時應用分頁或 projection，不應直接暴露內部 slice。</p>
<h2 id="策略projection-讓查詢需求不污染狀態">【策略】projection 讓查詢需求不污染狀態</h2>
<p>projection 的核心用途是服務讀取需求。列表頁、儀表板、即時推送可能需要不同欄位，這些需求不應全部塞進 domain state。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountSummary</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>              <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Status</span>          <span class="nx">AccountStatus</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">LastChangedAt</span>   <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">HistoryCount</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Summary</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountSummary</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountSummary</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="nx">AccountSummary</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>        <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">LastChangedAt</span><span class="p">:</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">UpdatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">HistoryCount</span><span class="p">:</span>  <span class="nb">len</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">},</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>projection 可以由 repository 即時計算，也可以由背景 worker 預先維護。選擇哪一種取決於讀取量、資料量與一致性要求；小型服務先即時計算通常更容易理解。</p>
<h2 id="判讀mutex-與單一-goroutine-都能成為-state-owner">【判讀】mutex 與單一 goroutine 都能成為 state owner</h2>
<p>狀態擁有權的核心要求是同一時間只有受控路徑能修改資料。mutex 是常見選擇，單一 goroutine 擁有 state 也是 Go 常見模式。</p>
<p>mutex 版本適合直接方法呼叫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span></span></span></code></pre></div><p>單一 goroutine 版本適合事件流：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">stateCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">event</span> <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">reply</span> <span class="kd">chan</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩者都可以正確。選擇 mutex 時要小心 copy boundary；選擇 goroutine owner 時要設計 shutdown、reply channel 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>。不要為了使用 channel 而使用 channel，狀態模型簡單時 mutex 通常更直接。</p>
<h2 id="測試狀態測試要覆蓋轉移與外洩">【測試】狀態測試要覆蓋轉移與外洩</h2>
<p>狀態邊界的測試目標是確認轉移一致、history 同步、呼叫端不能修改內部資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHistoryReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewStateRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nx">EventAccountActivated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;apply event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">history</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="s">&#34;acct_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;history: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">history</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">AccountBlocked</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">again</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="s">&#34;acct_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;history again: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">if</span> <span class="nx">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">AccountActive</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;repository state was modified through returned history&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試檢查的是邊界，不只是結果值。對 Go 服務來說，防止 slice/map 外洩是狀態設計的重要一環。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內誰有寫入權責；資料庫 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 與 CQRS，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、state owner 與 projection 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Source of truth 是寫入權責，不是某個特定資料庫。狀態轉移應集中在 repository 或 state owner，current 與 history 要在同一邊界更新，查詢要回傳 copy 或 projection。當狀態真相清楚時，handler、worker、publisher 都能保持簡單，系統也能更容易加入資料庫或新的讀取模型。</p>
]]></content:encoded></item><item><title>5.3 race condition 檢查</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/</guid><description>&lt;p>Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置，但不能取代 ownership、mutex、channel 與狀態邊界設計。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 data race 與一般邏輯競爭&lt;/li>
&lt;li>用 &lt;code>go test -race ./...&lt;/code> 檢查並發路徑&lt;/li>
&lt;li>寫出能觸發共享狀態讀寫的測試&lt;/li>
&lt;li>依 race report 找到讀寫來源&lt;/li>
&lt;li>選擇 mutex、channel owner 或 atomic 修正同步邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察並發-bug-常常不會穩定重現">【觀察】並發 bug 常常不會穩定重現&lt;/h2>
&lt;p>Data race 的核心問題是測試可能偶爾通過、偶爾失敗，也可能完全不失敗但資料已經不安全。單次執行結果正確，不代表沒有未同步讀寫。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">increment&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">count&lt;/span>&lt;span class="o">++&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>count++&lt;/code> 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時，可能互相覆蓋結果，也可能被 race detector 偵測到未同步讀寫。&lt;/p>
&lt;h2 id="判讀data-race-是未同步的並發讀寫">【判讀】data race 是未同步的並發讀寫&lt;/h2>
&lt;p>Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體，其中至少一個是寫入，而且沒有同步保護。&lt;/p>
&lt;p>觸發測試：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestIncrementRace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nf">increment&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一般 &lt;code>go test&lt;/code> 不一定會失敗。&lt;code>go test -race&lt;/code> 會在 runtime 偵測這類未同步讀寫，並輸出讀取與寫入發生的位置。&lt;/p>
&lt;h2 id="執行用-go-test--race-跑到相關路徑">【執行】用 go test -race 跑到相關路徑&lt;/h2>
&lt;p>Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path，不會被它發現。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> -race ./...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢，但對含有 goroutine、共享 map、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub、background worker 的服務非常重要。&lt;/p>
&lt;p>若專案很大，可以先針對相關 package：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> -race ./internal/websocket ./internal/storage ./internal/worker&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範圍縮小能讓日常執行更快，但合併前仍應跑完整路徑。&lt;/p>
&lt;h2 id="策略併發測試要讓共享狀態真的被同時讀寫">【策略】併發測試要讓共享狀態真的被同時讀寫&lt;/h2>
&lt;p>Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫，race detector 沒有機會回報。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestRepositoryConcurrentAccess&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserRepository&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">i&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user_%d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Find&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock，&lt;code>-race&lt;/code> 會指出問題。&lt;/p>
&lt;h2 id="執行websocket-hub-也需要-race-path">【執行】WebSocket hub 也需要 race path&lt;/h2>
&lt;p>WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestHubConcurrentBroadcastAndUnregister&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHub&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">clients&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTestClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;client_%d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="mi">8&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;alerts&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">clients&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">client&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">struct&lt;/span>&lt;span class="p">{}{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">clients&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">clients&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Broadcast&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;alerts&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;notification&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">clients&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">unregisterClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">client&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試是否需要 lock，取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行，測試就應該透過 channel 操作，而不是直接呼叫未同步方法。測試要符合 ownership 設計，不應製造不被 API 允許的並發。&lt;/p></description><content:encoded><![CDATA[<p>Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置，但不能取代 ownership、mutex、channel 與狀態邊界設計。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 data race 與一般邏輯競爭</li>
<li>用 <code>go test -race ./...</code> 檢查並發路徑</li>
<li>寫出能觸發共享狀態讀寫的測試</li>
<li>依 race report 找到讀寫來源</li>
<li>選擇 mutex、channel owner 或 atomic 修正同步邊界</li>
</ol>
<hr>
<h2 id="觀察並發-bug-常常不會穩定重現">【觀察】並發 bug 常常不會穩定重現</h2>
<p>Data race 的核心問題是測試可能偶爾通過、偶爾失敗，也可能完全不失敗但資料已經不安全。單次執行結果正確，不代表沒有未同步讀寫。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">count</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">func</span> <span class="nf">increment</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">count</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>count++</code> 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時，可能互相覆蓋結果，也可能被 race detector 偵測到未同步讀寫。</p>
<h2 id="判讀data-race-是未同步的並發讀寫">【判讀】data race 是未同步的並發讀寫</h2>
<p>Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體，其中至少一個是寫入，而且沒有同步保護。</p>
<p>觸發測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestIncrementRace</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nf">increment</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>一般 <code>go test</code> 不一定會失敗。<code>go test -race</code> 會在 runtime 偵測這類未同步讀寫，並輸出讀取與寫入發生的位置。</p>
<h2 id="執行用-go-test--race-跑到相關路徑">【執行】用 go test -race 跑到相關路徑</h2>
<p>Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path，不會被它發現。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢，但對含有 goroutine、共享 map、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub、background worker 的服務非常重要。</p>
<p>若專案很大，可以先針對相關 package：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./internal/websocket ./internal/storage ./internal/worker</span></span></code></pre></div><p>範圍縮小能讓日常執行更快，但合併前仍應跑完整路徑。</p>
<h2 id="策略併發測試要讓共享狀態真的被同時讀寫">【策略】併發測試要讓共享狀態真的被同時讀寫</h2>
<p>Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫，race detector 沒有機會回報。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestRepositoryConcurrentAccess</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">i</span> <span class="o">:=</span> <span class="nx">i</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;user_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">id</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock，<code>-race</code> 會指出問題。</p>
<h2 id="執行websocket-hub-也需要-race-path">【執行】WebSocket hub 也需要 race path</h2>
<p>WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHubConcurrentBroadcastAndUnregister</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">clients</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;client_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">),</span> <span class="mi">8</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">clients</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">clients</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">,</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;notification&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">hub</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試是否需要 lock，取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行，測試就應該透過 channel 操作，而不是直接呼叫未同步方法。測試要符合 ownership 設計，不應製造不被 API 允許的並發。</p>
<h2 id="判讀race-report-要看讀寫兩端">【判讀】race report 要看讀寫兩端</h2>
<p>Race report 的核心資訊是兩個位置：一端讀或寫，另一端寫。修正時不要只看最後一行，要找出是哪個共享資料缺少同步。</p>
<p>典型報告會包含：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">WARNING: DATA RACE
</span></span><span class="line"><span class="ln">2</span><span class="cl">Read at 0x...
</span></span><span class="line"><span class="ln">3</span><span class="cl">  example.com/app.(*UserRepository).Find()
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">Previous write at 0x...
</span></span><span class="line"><span class="ln">6</span><span class="cl">  example.com/app.(*UserRepository).Save()</span></span></code></pre></div><p>這表示 <code>Find</code> 和 <code>Save</code> 同時碰到同一份資料，且缺少同步。修正方向是在 repository owner 補上 mutex、channel ownership 或其他同步邊界。</p>
<h2 id="策略修正方式要對應狀態形狀">【策略】修正方式要對應狀態形狀</h2>
<p>修正 data race 的核心選擇是建立正確同步邊界。常見方法有 mutex、channel owner、atomic。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適用情境</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>mutex</td>
          <td>多方法讀寫同一份 map/slice/state</td>
          <td>lock 要保護完整不變式</td>
      </tr>
      <tr>
          <td>channel owner</td>
          <td>狀態修改可集中成事件 loop</td>
          <td>要設計 reply、shutdown、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></td>
      </tr>
      <tr>
          <td>atomic</td>
          <td>單一數值 counter 或 flag</td>
          <td>不適合複雜狀態</td>
      </tr>
  </tbody>
</table>
<p>Mutex 範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Counter</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>    <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">value</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Counter</span><span class="p">)</span> <span class="nf">Inc</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">value</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Counter</span><span class="p">)</span> <span class="nf">Value</span><span class="p">()</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nx">value</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>鎖應該屬於擁有狀態的型別，並保護一個清楚的不變條件。只為了讓 race detector 安靜而到處加鎖，會讓 ownership 分散，後續仍然難以判斷資料一致性。</p>
<h2 id="判讀race-free-不代表行為正確">【判讀】race-free 不代表行為正確</h2>
<p>Race detector 的核心邊界是它只找 data race，不保證並發邏輯正確。沒有 data race 的程式仍可能 deadlock、漏訊息、順序錯誤、重複 close 或違反資料語意。</p>
<p>例如：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="c1">// drop</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式可能沒有 data race，但「<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 時丟訊息」是否正確是服務語意問題。Race detector 不會告訴你該丟、該斷線、還是該寫可靠 queue。</p>
<p>因此並發測試要分成兩層：</p>
<ul>
<li>用 <code>go test -race</code> 找未同步記憶體存取。</li>
<li>用行為測試檢查 channel close、queue full、context cancel、cleanup、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>。</li>
</ul>
<h2 id="測試把-race-check-納入固定流程">【測試】把 race check 納入固定流程</h2>
<p>Race check 的核心價值來自重複執行。只在出事後手動跑，效果有限。</p>
<p>建議流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> ./...
</span></span><span class="line"><span class="ln">2</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>日常開發可以先跑相關 package，提交前或 CI 跑完整 race suite。若 race suite 太慢，至少讓含有 hub、repository、worker、client state 的 package 固定跑 <code>-race</code>。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理共享 state、channel ownership 與 goroutine lifecycle 的 race 風險；lock-free 與完整 memory model，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">Go 進階：共享狀態與複製邊界</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">Go 進階：channel ownership 與關閉責任</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是共享狀態、channel ownership 與 lifecycle；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p><code>go test -race</code> 是 Go 並發服務的基本安全網，但它只檢查測試執行到的 data race。你仍然需要設計清楚的 state owner、lock boundary、channel ownership 與行為測試。Race-free 不是正確性的全部；它只是可靠性的第一層檢查。</p>
]]></content:encoded></item><item><title>6.3 結構化日誌欄位設計</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/</guid><description>&lt;p>結構化日誌欄位的核心目標是讓 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 可查詢、可聚合、可追蹤。Message 給人讀，欄位給系統查；重要資訊應放在穩定欄位，不應只藏在自由文字裡。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>設計穩定 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">log schema&lt;/a>&lt;/li>
&lt;li>用 &lt;code>layer&lt;/code>、&lt;code>request_id&lt;/code>、&lt;code>event_type&lt;/code>、&lt;code>reason&lt;/code> 支援查詢&lt;/li>
&lt;li>區分 message 與 structured fields 的責任&lt;/li>
&lt;li>避免重複記錄同一個錯誤&lt;/li>
&lt;li>避免把敏感資料寫進 log&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察自由文字-log-很難查詢">【觀察】自由文字 log 很難查詢&lt;/h2>
&lt;p>Log 設計的核心問題是事故發生時需要快速查詢。若所有資訊都在 message 裡，查詢只能依賴模糊字串。&lt;/p>
&lt;p>不穩定 log：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event accepted for user 123 request abc&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這行給人看可以，但系統很難穩定查 &lt;code>request_id=abc&lt;/code> 或 &lt;code>user_id=123&lt;/code>。不同工程師改字句後，查詢就可能失效。&lt;/p>
&lt;p>結構化 log：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event accepted&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">requestID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;user_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">userID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Message 描述發生什麼事，欄位提供可查詢資料。這是 log schema 的基本分工。&lt;/p>
&lt;h2 id="判讀log-schema-是查詢合約">【判讀】log schema 是查詢合約&lt;/h2>
&lt;p>Log schema 的核心規則是欄位名稱與值集合要穩定。&lt;code>request_id&lt;/code>、&lt;code>requestID&lt;/code>、&lt;code>rid&lt;/code> 混用會讓查詢與儀表板變得困難。&lt;/p>
&lt;p>常用欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>layer&lt;/code>&lt;/td>
 &lt;td>問題發生在哪個系統層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>request_id&lt;/code>&lt;/td>
 &lt;td>串起單次 HTTP request&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>event_id&lt;/code>&lt;/td>
 &lt;td>串起事件處理流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>event_type&lt;/code>&lt;/td>
 &lt;td>聚合某類 domain event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>client_id&lt;/code>&lt;/td>
 &lt;td>查 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client 行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>topic&lt;/code>&lt;/td>
 &lt;td>查訂閱或推送範圍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>reason&lt;/code>&lt;/td>
 &lt;td>聚合失敗原因&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>error&lt;/code>&lt;/td>
 &lt;td>保存錯誤文字&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>欄位不需要很多，但要一致。穩定欄位能讓除錯從「讀一堆文字」變成「查一組條件」。&lt;/p>
&lt;h2 id="執行layer-表示發生位置">【執行】layer 表示發生位置&lt;/h2>
&lt;p>&lt;code>layer&lt;/code> 的核心用途是標示 log 來自哪個系統層，協助工程師快速縮小問題範圍。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;queue full&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;worker&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;queue&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;events&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;buffer_full&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>常見 layer：&lt;/p>
&lt;ul>
&lt;li>&lt;code>http&lt;/code>&lt;/li>
&lt;li>&lt;code>websocket&lt;/code>&lt;/li>
&lt;li>&lt;code>worker&lt;/code>&lt;/li>
&lt;li>&lt;code>repository&lt;/code>&lt;/li>
&lt;li>&lt;code>runtime&lt;/code>&lt;/li>
&lt;li>&lt;code>diagnostics&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>名稱不需要多，但應穩定。若 &lt;code>worker&lt;/code>、&lt;code>background&lt;/code>、&lt;code>job_runner&lt;/code> 混用，查詢就會變麻煩。&lt;/p>
&lt;h2 id="策略correlation-id-串起一次流程">【策略】correlation ID 串起一次流程&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">Correlation ID&lt;/a> 的核心目標是把同一次請求或同一個事件流串起來。HTTP request 常用 &lt;code>request_id&lt;/code>，背景事件可以用 &lt;code>event_id&lt;/code> 或 &lt;code>trace_id&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">WithRequestLog&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">logger&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Logger&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Logger&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">requestID&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-Request-ID&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">requestID&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">requestID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">uuid&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewString&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">With&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">requestID&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>後續 handler、service、repository 都使用帶有 &lt;code>request_id&lt;/code> 的 logger。查詢單次流程時，不需要靠時間範圍猜哪些 log 相關。&lt;/p>
&lt;p>Correlation ID 不應包含敏感資料。它是追蹤用識別碼，不是使用者資料容器。&lt;/p>
&lt;h2 id="執行reason-欄位讓失敗可統計">【執行】reason 欄位讓失敗可統計&lt;/h2>
&lt;p>&lt;code>reason&lt;/code> 的核心用途是把錯誤原因變成可聚合分類。Message 可以給人讀，reason 給查詢與統計使用。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;reject event&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_payload&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>穩定 reason 可以回答「最近一小時最多的拒絕原因是什麼」。如果原因只寫在 message 中，查詢會依賴模糊字串比對。&lt;/p>
&lt;p>Reason 值應像 enum 一樣維持小集合，例如：&lt;/p>
&lt;ul>
&lt;li>&lt;code>invalid_payload&lt;/code>&lt;/li>
&lt;li>&lt;code>queue_full&lt;/code>&lt;/li>
&lt;li>&lt;code>permission_denied&lt;/code>&lt;/li>
&lt;li>&lt;code>timeout&lt;/code>&lt;/li>
&lt;li>&lt;code>client_disconnected&lt;/code>&lt;/li>
&lt;li>&lt;code>dependency_unavailable&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>&lt;code>reason&lt;/code> 應維持小集合分類，完整錯誤應放在 &lt;code>error&lt;/code> 欄位。這樣監控可以穩定聚合原因，工程師仍能從錯誤欄位取得診斷細節。&lt;/p>
&lt;h2 id="判讀錯誤只在負責處理的邊界記一次">【判讀】錯誤只在負責處理的邊界記一次&lt;/h2>
&lt;p>錯誤日誌的核心風險是同一個錯誤被每一層都記一次。這會放大噪音，讓事故時很難看出真正的失敗點。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;repository failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;save notification: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>上層又記一次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;request failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>較清楚的做法是底層 wrap error，上層在決定 response 或重試策略的邊界記錄一次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">service&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;create notification failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nf">reasonOf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>底層若有必要補充脈絡，優先透過 error wrapping 或 structured error，而不是每層都 &lt;code>Error&lt;/code> log。&lt;/p></description><content:encoded><![CDATA[<p>結構化日誌欄位的核心目標是讓 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 可查詢、可聚合、可追蹤。Message 給人讀，欄位給系統查；重要資訊應放在穩定欄位，不應只藏在自由文字裡。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>設計穩定 <a href="/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">log schema</a></li>
<li>用 <code>layer</code>、<code>request_id</code>、<code>event_type</code>、<code>reason</code> 支援查詢</li>
<li>區分 message 與 structured fields 的責任</li>
<li>避免重複記錄同一個錯誤</li>
<li>避免把敏感資料寫進 log</li>
</ol>
<hr>
<h2 id="觀察自由文字-log-很難查詢">【觀察】自由文字 log 很難查詢</h2>
<p>Log 設計的核心問題是事故發生時需要快速查詢。若所有資訊都在 message 裡，查詢只能依賴模糊字串。</p>
<p>不穩定 log：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;event accepted for user 123 request abc&#34;</span><span class="p">)</span></span></span></code></pre></div><p>這行給人看可以，但系統很難穩定查 <code>request_id=abc</code> 或 <code>user_id=123</code>。不同工程師改字句後，查詢就可能失效。</p>
<p>結構化 log：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;event accepted&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;request_id&#34;</span><span class="p">,</span> <span class="nx">requestID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;user_id&#34;</span><span class="p">,</span> <span class="nx">userID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>Message 描述發生什麼事，欄位提供可查詢資料。這是 log schema 的基本分工。</p>
<h2 id="判讀log-schema-是查詢合約">【判讀】log schema 是查詢合約</h2>
<p>Log schema 的核心規則是欄位名稱與值集合要穩定。<code>request_id</code>、<code>requestID</code>、<code>rid</code> 混用會讓查詢與儀表板變得困難。</p>
<p>常用欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>layer</code></td>
          <td>問題發生在哪個系統層</td>
      </tr>
      <tr>
          <td><code>request_id</code></td>
          <td>串起單次 HTTP request</td>
      </tr>
      <tr>
          <td><code>event_id</code></td>
          <td>串起事件處理流程</td>
      </tr>
      <tr>
          <td><code>event_type</code></td>
          <td>聚合某類 domain event</td>
      </tr>
      <tr>
          <td><code>client_id</code></td>
          <td>查 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client 行為</td>
      </tr>
      <tr>
          <td><code>topic</code></td>
          <td>查訂閱或推送範圍</td>
      </tr>
      <tr>
          <td><code>reason</code></td>
          <td>聚合失敗原因</td>
      </tr>
      <tr>
          <td><code>error</code></td>
          <td>保存錯誤文字</td>
      </tr>
  </tbody>
</table>
<p>欄位不需要很多，但要一致。穩定欄位能讓除錯從「讀一堆文字」變成「查一組條件」。</p>
<h2 id="執行layer-表示發生位置">【執行】layer 表示發生位置</h2>
<p><code>layer</code> 的核心用途是標示 log 來自哪個系統層，協助工程師快速縮小問題範圍。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;queue full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;worker&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;queue&#34;</span><span class="p">,</span> <span class="s">&#34;events&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;buffer_full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>常見 layer：</p>
<ul>
<li><code>http</code></li>
<li><code>websocket</code></li>
<li><code>worker</code></li>
<li><code>repository</code></li>
<li><code>runtime</code></li>
<li><code>diagnostics</code></li>
</ul>
<p>名稱不需要多，但應穩定。若 <code>worker</code>、<code>background</code>、<code>job_runner</code> 混用，查詢就會變麻煩。</p>
<h2 id="策略correlation-id-串起一次流程">【策略】correlation ID 串起一次流程</h2>
<p><a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">Correlation ID</a> 的核心目標是把同一次請求或同一個事件流串起來。HTTP request 常用 <code>request_id</code>，背景事件可以用 <code>event_id</code> 或 <code>trace_id</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">WithRequestLog</span><span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">,</span> <span class="nx">logger</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span><span class="p">)</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">requestID</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Request-ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">requestID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">requestID</span> <span class="p">=</span> <span class="nx">uuid</span><span class="p">.</span><span class="nf">NewString</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">logger</span><span class="p">.</span><span class="nf">With</span><span class="p">(</span><span class="s">&#34;request_id&#34;</span><span class="p">,</span> <span class="nx">requestID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>後續 handler、service、repository 都使用帶有 <code>request_id</code> 的 logger。查詢單次流程時，不需要靠時間範圍猜哪些 log 相關。</p>
<p>Correlation ID 不應包含敏感資料。它是追蹤用識別碼，不是使用者資料容器。</p>
<h2 id="執行reason-欄位讓失敗可統計">【執行】reason 欄位讓失敗可統計</h2>
<p><code>reason</code> 的核心用途是把錯誤原因變成可聚合分類。Message 可以給人讀，reason 給查詢與統計使用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;reject event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;invalid_payload&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>穩定 reason 可以回答「最近一小時最多的拒絕原因是什麼」。如果原因只寫在 message 中，查詢會依賴模糊字串比對。</p>
<p>Reason 值應像 enum 一樣維持小集合，例如：</p>
<ul>
<li><code>invalid_payload</code></li>
<li><code>queue_full</code></li>
<li><code>permission_denied</code></li>
<li><code>timeout</code></li>
<li><code>client_disconnected</code></li>
<li><code>dependency_unavailable</code></li>
</ul>
<p><code>reason</code> 應維持小集合分類，完整錯誤應放在 <code>error</code> 欄位。這樣監控可以穩定聚合原因，工程師仍能從錯誤欄位取得診斷細節。</p>
<h2 id="判讀錯誤只在負責處理的邊界記一次">【判讀】錯誤只在負責處理的邊界記一次</h2>
<p>錯誤日誌的核心風險是同一個錯誤被每一層都記一次。這會放大噪音，讓事故時很難看出真正的失敗點。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;repository failed&#34;</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span></span></span></code></pre></div><p>上層又記一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;request failed&#34;</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span></span></span></code></pre></div><p>較清楚的做法是底層 wrap error，上層在決定 response 或重試策略的邊界記錄一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;create notification failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="nf">reasonOf</span><span class="p">(</span><span class="nx">err</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>底層若有必要補充脈絡，優先透過 error wrapping 或 structured error，而不是每層都 <code>Error</code> log。</p>
<h2 id="策略敏感資料不進-log">【策略】敏感資料不進 log</h2>
<p>Log 欄位設計的核心安全邊界是只記錄診斷必要資料。token、密碼、完整 cookie、完整個資與機密 payload 都屬於應排除資料；結構化 log 很容易被集中保存與搜尋，敏感資料一旦進入 log，清理成本很高。</p>
<p>可以記錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;user login&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;user_id&#34;</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>應排除：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;user login&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;password&#34;</span><span class="p">,</span> <span class="nx">password</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;token&#34;</span><span class="p">,</span> <span class="nx">token</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>若需要診斷 payload，可記錄長度、hash、欄位是否存在，而不是完整內容。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Debug</span><span class="p">(</span><span class="s">&#34;payload received&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;payload_bytes&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;payload_sha256&#34;</span><span class="p">,</span> <span class="nf">checksum</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>所有會被收集或保存的 log 都應遵守同一套資料保護規則。Debug log 也會進入檔案、集中式 log 或診斷封包，因此不能把它當成敏感資料的例外通道。</p>
<h2 id="測試log-欄位可以用-handler-驗證">【測試】log 欄位可以用 handler 驗證</h2>
<p>Log schema 的測試核心是確認重要欄位存在，避免未來重構時消失。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestLogAttrsForEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="s">&#34;notification.created&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;notification_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">attrs</span> <span class="o">:=</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nf">hasAttr</span><span class="p">(</span><span class="nx">attrs</span><span class="p">,</span> <span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="s">&#34;evt_1&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;event_id attr missing&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nf">hasAttr</span><span class="p">(</span><span class="nx">attrs</span><span class="p">,</span> <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="s">&#34;notification.created&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;event_type attr missing&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不需要測整行 log 字串。測穩定欄位即可，message 文字可以保留一定調整空間。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 Go 服務內部的 structured log schema；集中式平台、欄位標準與隱私治理，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 structured recording、<a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a> 與 observability pipeline；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">Go：如何新增結構化記錄欄位</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go：結構化日誌</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go 入門：log/slog</a></li>
<li><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">Go 入門：如何新增結構化記錄欄位</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>結構化日誌的價值在於穩定欄位：<code>layer</code> 定位層級，<code>request_id</code> 串起請求，<code>event_id</code> 串起事件，<code>event_type</code> 支援聚合，<code>reason</code> 支援失敗分類。Message 給人讀，欄位給系統查。好的 log schema 能讓除錯從猜測變成查詢，同時避免敏感資料外洩與錯誤重複記錄。</p>
]]></content:encoded></item><item><title>7.3 跨節點 WebSocket、presence 與重連協定</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/cross-node-websocket/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/cross-node-websocket/</guid><description>&lt;p>跨節點 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 的核心責任是把連線狀態、訂閱狀態與推送路徑從單一記憶體 hub 延伸到多台 server。單一 process 內的 read pump、write pump、heartbeat 與 slow client 策略仍然有效，但跨節點後還需要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>、presence store、重連協定與授權邊界。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解單節點 hub 為什麼不夠&lt;/li>
&lt;li>看懂 presence store 與 broker 在系統中的角色&lt;/li>
&lt;li>設計 reconnect 後的補資料流程&lt;/li>
&lt;li>分辨訂閱路由、連線管理與授權邊界&lt;/li>
&lt;li>讓多台 server 在語意上看起來像同一個訊息系統&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go 進階：read pump / write pump 模式&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">Go 進階：訂閱模型與訊息路由&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">Go 進階：慢客戶端與 send buffer 管理&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>多台 server 如何知道某個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 的訂閱者在哪些節點。&lt;/li>
&lt;li>Presence store 如何記錄 client online、offline 與最後活動時間。&lt;/li>
&lt;li>Broker &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a> 如何和每個節點本地 send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 策略銜接。&lt;/li>
&lt;li>Client reconnect 如何使用 cursor、last event ID 或 snapshot 補資料。&lt;/li>
&lt;li>Topic ACL 與 subscription &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization&lt;/a> 應放在 router、usecase 還是 gateway。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察跨節點-websocket-的核心問題是狀態協調">【觀察】跨節點 WebSocket 的核心問題是狀態協調&lt;/h2>
&lt;p>WebSocket 協定解決的是單一連線的雙向通訊，但跨節點之後，真正麻煩的是狀態分散在多台 server。某個 client 可能連到 A 節點，但它關注的 topic 事件卻從 B 節點產生，這時就需要能夠路由、轉送與補資料。&lt;/p>
&lt;p>所以跨節點 WebSocket 的問題不只是「能不能推送」，而是：&lt;/p>
&lt;ul>
&lt;li>這個 client 現在在哪台 server&lt;/li>
&lt;li>它訂閱了哪些 topic&lt;/li>
&lt;li>推送失敗後要不要重送&lt;/li>
&lt;li>重新連線後要從哪裡補回遺漏事件&lt;/li>
&lt;/ul>
&lt;h2 id="判讀presence-store-是操作查詢">【判讀】presence store 是操作查詢&lt;/h2>
&lt;p>presence store 的用途是讓系統知道某個 client 或節點目前大概在線上還是離線。它通常是操作性資料，不一定是業務真相。&lt;/p>
&lt;p>常見欄位包括：&lt;/p>
&lt;ul>
&lt;li>client ID&lt;/li>
&lt;li>node ID&lt;/li>
&lt;li>connected at&lt;/li>
&lt;li>last seen&lt;/li>
&lt;li>subscription keys&lt;/li>
&lt;/ul>
&lt;p>這類資料要允許過期與清理，因為斷線、網路抖動與 crash 都可能讓狀態暫時不準。&lt;/p>
&lt;h2 id="策略reconnect-一定要有補資料設計">【策略】reconnect 一定要有補資料設計&lt;/h2>
&lt;p>只靠重新連上 WebSocket 並不能保證使用者不漏訊息。當連線中斷時，常見的補資料方式有：&lt;/p>
&lt;ul>
&lt;li>last event ID&lt;/li>
&lt;li>cursor / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a>&lt;/li>
&lt;li>snapshot + delta&lt;/li>
&lt;/ul>
&lt;p>選哪一種，取決於你的事件是否可排序、是否可回放，以及業務能容忍多大的缺口。&lt;/p>
&lt;h2 id="執行推送路徑通常要分三層">【執行】推送路徑通常要分三層&lt;/h2>
&lt;p>跨節點場景下，推送路徑常見會分成：&lt;/p>
&lt;ol>
&lt;li>事件產生端把訊息交給 broker 或 routing layer。&lt;/li>
&lt;li>節點收到後，交給本機 hub / connection manager。&lt;/li>
&lt;li>write pump 再把訊息送到單一 client。&lt;/li>
&lt;/ol>
&lt;p>這樣可以維持單一寫入者原則，避免多個 goroutine 同時寫 WebSocket。&lt;/p></description><content:encoded><![CDATA[<p>跨節點 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 的核心責任是把連線狀態、訂閱狀態與推送路徑從單一記憶體 hub 延伸到多台 server。單一 process 內的 read pump、write pump、heartbeat 與 slow client 策略仍然有效，但跨節點後還需要 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>、presence store、重連協定與授權邊界。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解單節點 hub 為什麼不夠</li>
<li>看懂 presence store 與 broker 在系統中的角色</li>
<li>設計 reconnect 後的補資料流程</li>
<li>分辨訂閱路由、連線管理與授權邊界</li>
<li>讓多台 server 在語意上看起來像同一個訊息系統</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go 進階：read pump / write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">Go 進階：訂閱模型與訊息路由</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">Go 進階：慢客戶端與 send buffer 管理</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>多台 server 如何知道某個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 的訂閱者在哪些節點。</li>
<li>Presence store 如何記錄 client online、offline 與最後活動時間。</li>
<li>Broker <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 如何和每個節點本地 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 策略銜接。</li>
<li>Client reconnect 如何使用 cursor、last event ID 或 snapshot 補資料。</li>
<li>Topic ACL 與 subscription <a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization</a> 應放在 router、usecase 還是 gateway。</li>
</ol>
<h2 id="觀察跨節點-websocket-的核心問題是狀態協調">【觀察】跨節點 WebSocket 的核心問題是狀態協調</h2>
<p>WebSocket 協定解決的是單一連線的雙向通訊，但跨節點之後，真正麻煩的是狀態分散在多台 server。某個 client 可能連到 A 節點，但它關注的 topic 事件卻從 B 節點產生，這時就需要能夠路由、轉送與補資料。</p>
<p>所以跨節點 WebSocket 的問題不只是「能不能推送」，而是：</p>
<ul>
<li>這個 client 現在在哪台 server</li>
<li>它訂閱了哪些 topic</li>
<li>推送失敗後要不要重送</li>
<li>重新連線後要從哪裡補回遺漏事件</li>
</ul>
<h2 id="判讀presence-store-是操作查詢">【判讀】presence store 是操作查詢</h2>
<p>presence store 的用途是讓系統知道某個 client 或節點目前大概在線上還是離線。它通常是操作性資料，不一定是業務真相。</p>
<p>常見欄位包括：</p>
<ul>
<li>client ID</li>
<li>node ID</li>
<li>connected at</li>
<li>last seen</li>
<li>subscription keys</li>
</ul>
<p>這類資料要允許過期與清理，因為斷線、網路抖動與 crash 都可能讓狀態暫時不準。</p>
<h2 id="策略reconnect-一定要有補資料設計">【策略】reconnect 一定要有補資料設計</h2>
<p>只靠重新連上 WebSocket 並不能保證使用者不漏訊息。當連線中斷時，常見的補資料方式有：</p>
<ul>
<li>last event ID</li>
<li>cursor / <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a></li>
<li>snapshot + delta</li>
</ul>
<p>選哪一種，取決於你的事件是否可排序、是否可回放，以及業務能容忍多大的缺口。</p>
<h2 id="執行推送路徑通常要分三層">【執行】推送路徑通常要分三層</h2>
<p>跨節點場景下，推送路徑常見會分成：</p>
<ol>
<li>事件產生端把訊息交給 broker 或 routing layer。</li>
<li>節點收到後，交給本機 hub / connection manager。</li>
<li>write pump 再把訊息送到單一 client。</li>
</ol>
<p>這樣可以維持單一寫入者原則，避免多個 goroutine 同時寫 WebSocket。</p>
<h2 id="延伸授權應該在進入路由前就處理">【延伸】授權應該在進入路由前就處理</h2>
<p>Topic ACL 要在訂閱建立時就確認這個 client 是否有資格加入。這能減少不必要的 fan-out 與敏感資料外流。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會選定特定 broker 或 presence <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>。重點是先讓跨節點責任可見，再依服務需求選擇 Redis、NATS、Kafka、PostgreSQL 或其他基礎設施。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket 連線架構與事件路由；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go 進階：read/write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">Go 進階：訂閱模型與訊息路由</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">Go 進階：慢客戶端與 send buffer 管理</a></li>
</ul>
]]></content:encoded></item><item><title>模組三：Runtime 與效能診斷</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/</guid><description>&lt;p>Runtime 診斷的核心目標是用資料判斷服務壓力來源。Go 服務長時間運行後，問題常出現在 heap 成長、GC 壓力、goroutine 數量、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 堆積、JSON 配置與共享狀態保留；診斷流程應先看趨勢，再用 profile 定位來源。&lt;/p>
&lt;p>本模組承接前面的並發、WebSocket 與測試可靠性：如果 goroutine lifecycle、send buffer、repository copy boundary 沒設計好，runtime 訊號會在 heap profile、goroutine profile、CPU profile 或 allocation profile 中反映出來。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">3.1&lt;/a>&lt;/td>
 &lt;td>GC 與 memory limit&lt;/td>
 &lt;td>理解 heap、GOGC、memory limit 與 runtime &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 的關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">3.2&lt;/a>&lt;/td>
 &lt;td>pprof 基礎診斷流程&lt;/td>
 &lt;td>用 heap、goroutine、CPU、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a> profile 定位壓力來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">3.3&lt;/a>&lt;/td>
 &lt;td>goroutine leak 偵測&lt;/td>
 &lt;td>從 stack pattern 回到 context、close、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與 ticker lifecycle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">3.4&lt;/a>&lt;/td>
 &lt;td>資料結構與 allocation 壓力&lt;/td>
 &lt;td>區分必要 copy、安全邊界與可優化熱路徑配置&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 WebSocket client lifecycle、background worker、repository list、JSON push payload 與 cache。&lt;/p>
&lt;p>範例只用來展示 Go runtime 診斷方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>runtime.ReadMemStats&lt;/code> 或 &lt;code>runtime/metrics&lt;/code> 觀察 heap、GC 與 goroutine 趨勢。&lt;/li>
&lt;li>用 &lt;code>debug.SetMemoryLimit&lt;/code> 給 runtime 軟性記憶體目標。&lt;/li>
&lt;li>用 pprof 分析 heap、goroutine、CPU、block、mutex 與 trace。&lt;/li>
&lt;li>用 goroutine profile 找出卡在 channel、network read、ticker、mutex 的路徑。&lt;/li>
&lt;li>用 &lt;code>alloc_space&lt;/code> 與 &lt;code>inuse_space&lt;/code> 區分配置壓力與保留記憶體。&lt;/li>
&lt;li>用資料結構設計降低不必要 allocation，但保留必要 copy boundary。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>記憶體問題是 GC 壓力、長期保留，還是短暫尖峰&lt;/li>
&lt;li>什麼情境適合調整 memory limit，什麼情境應該找 leak&lt;/li>
&lt;li>heap、goroutine、CPU、trace 各自回答什麼問題&lt;/li>
&lt;li>goroutine leak 應回到哪個 lifecycle 邊界修&lt;/li>
&lt;li>allocation 優化何時值得做，何時會破壞安全邊界&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不討論分散式 tracing 平台、完整監控系統或雲端特定 profiler。這些工具可以接在本模組之後；本模組先建立 Go runtime 原生訊號與 pprof 的診斷思路。後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Runtime 診斷的核心目標是用資料判斷服務壓力來源。Go 服務長時間運行後，問題常出現在 heap 成長、GC 壓力、goroutine 數量、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 堆積、JSON 配置與共享狀態保留；診斷流程應先看趨勢，再用 profile 定位來源。</p>
<p>本模組承接前面的並發、WebSocket 與測試可靠性：如果 goroutine lifecycle、send buffer、repository copy boundary 沒設計好，runtime 訊號會在 heap profile、goroutine profile、CPU profile 或 allocation profile 中反映出來。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">3.1</a></td>
          <td>GC 與 memory limit</td>
          <td>理解 heap、GOGC、memory limit 與 runtime <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 的關係</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">3.2</a></td>
          <td>pprof 基礎診斷流程</td>
          <td>用 heap、goroutine、CPU、<a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a> profile 定位壓力來源</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">3.3</a></td>
          <td>goroutine leak 偵測</td>
          <td>從 stack pattern 回到 context、close、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與 ticker lifecycle</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">3.4</a></td>
          <td>資料結構與 allocation 壓力</td>
          <td>區分必要 copy、安全邊界與可優化熱路徑配置</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 WebSocket client lifecycle、background worker、repository list、JSON push payload 與 cache。</p>
<p>範例只用來展示 Go runtime 診斷方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>runtime.ReadMemStats</code> 或 <code>runtime/metrics</code> 觀察 heap、GC 與 goroutine 趨勢。</li>
<li>用 <code>debug.SetMemoryLimit</code> 給 runtime 軟性記憶體目標。</li>
<li>用 pprof 分析 heap、goroutine、CPU、block、mutex 與 trace。</li>
<li>用 goroutine profile 找出卡在 channel、network read、ticker、mutex 的路徑。</li>
<li>用 <code>alloc_space</code> 與 <code>inuse_space</code> 區分配置壓力與保留記憶體。</li>
<li>用資料結構設計降低不必要 allocation，但保留必要 copy boundary。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>記憶體問題是 GC 壓力、長期保留，還是短暫尖峰</li>
<li>什麼情境適合調整 memory limit，什麼情境應該找 leak</li>
<li>heap、goroutine、CPU、trace 各自回答什麼問題</li>
<li>goroutine leak 應回到哪個 lifecycle 邊界修</li>
<li>allocation 優化何時值得做，何時會破壞安全邊界</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論分散式 tracing 平台、完整監控系統或雲端特定 profiler。這些工具可以接在本模組之後；本模組先建立 Go runtime 原生訊號與 pprof 的診斷思路。後續可接 <a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>1.4 共享狀態與複製邊界</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/</guid><description>&lt;p>共享狀態的核心規則是同一份可變資料若會被多個 goroutine 存取，就必須有明確 owner 與保護邊界。Map 需要同步，slice 回傳前通常要 copy，可變指標不能隨意暴露，修改行為應集中在擁有狀態的型別內。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷哪個型別擁有共享狀態&lt;/li>
&lt;li>用 &lt;code>sync.RWMutex&lt;/code> 保護 map 與 slice&lt;/li>
&lt;li>避免回傳內部 map、slice、pointer&lt;/li>
&lt;li>分辨 shallow copy 與 deep copy 的邊界&lt;/li>
&lt;li>用測試與 race detector 驗證共享狀態安全性&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察共享狀態風險通常延遲出現">【觀察】共享狀態風險通常延遲出現&lt;/h2>
&lt;p>共享狀態的核心風險是錯誤可能只在特定併發時序下出現。單元測試可能通過，本地手動操作也正常，但高流量下會出現 data race、map panic、狀態被外部修改或歷史資料被覆蓋。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Store&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Store&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">user&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Store&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個型別有兩個問題：map 沒有同步保護，且 &lt;code>Users&lt;/code> 直接暴露內部 map。呼叫端拿到 map 後可以繞過 &lt;code>Store&lt;/code> 修改資料。&lt;/p>
&lt;h2 id="判讀mutex-保護的是狀態不變式">【判讀】mutex 保護的是狀態不變式&lt;/h2>
&lt;p>Mutex 的核心責任不是讓程式「不會同時跑」，而是保護某一組資料的不變式。只要讀寫同一份可變資料，就應該由同一個 owner 控制 lock。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">UserRepository&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewUserRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>UserRepository&lt;/code> 是 &lt;code>users&lt;/code> map 的 owner。外部程式不應持有 &lt;code>users&lt;/code> 的 reference，也不應知道它用 map、資料庫或其他結構保存。&lt;/p>
&lt;h2 id="執行所有讀寫都經過-owner-method">【執行】所有讀寫都經過 owner method&lt;/h2>
&lt;p>共享 map 的核心規則是所有讀寫都經過同一組方法。寫入使用 &lt;code>Lock&lt;/code>，讀取使用 &lt;code>RLock&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Find&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>context.Context&lt;/code> 在 memory repository 裡可能用不到，但保留在 method signature 可以讓未來改成資料庫或遠端儲存時支援取消。這是 repository port 常見的演進邊界。&lt;/p>
&lt;h2 id="判讀回傳內部-map-會破壞-lock">【判讀】回傳內部 map 會破壞 lock&lt;/h2>
&lt;p>回傳 map 的核心風險是鎖只保護到方法結束。方法回傳後，呼叫端拿到的仍然是同一份 map，任何修改都會繞過 owner。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">UnsafeUsers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式看起來有加鎖，但鎖釋放後外部仍能修改內部 map：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">users&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnsafeUsers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">delete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;user_1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>安全做法是回傳 copy：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">result&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>呼叫端可以自由修改 &lt;code>result&lt;/code>，不會影響 repository 內部狀態。&lt;/p>
&lt;h2 id="判讀slice-copy-保護的是底層-array">【判讀】slice copy 保護的是底層 array&lt;/h2>
&lt;p>Slice 的核心風險是 slice header 會被複製，但底層 array 可能共享。直接回傳 slice 會讓呼叫端修改 owner 的內部資料。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">RecentEvents&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">events&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Event&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">RecentEvents&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>安全的 list method：&lt;/p></description><content:encoded><![CDATA[<p>共享狀態的核心規則是同一份可變資料若會被多個 goroutine 存取，就必須有明確 owner 與保護邊界。Map 需要同步，slice 回傳前通常要 copy，可變指標不能隨意暴露，修改行為應集中在擁有狀態的型別內。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷哪個型別擁有共享狀態</li>
<li>用 <code>sync.RWMutex</code> 保護 map 與 slice</li>
<li>避免回傳內部 map、slice、pointer</li>
<li>分辨 shallow copy 與 deep copy 的邊界</li>
<li>用測試與 race detector 驗證共享狀態安全性</li>
</ol>
<hr>
<h2 id="觀察共享狀態風險通常延遲出現">【觀察】共享狀態風險通常延遲出現</h2>
<p>共享狀態的核心風險是錯誤可能只在特定併發時序下出現。單元測試可能通過，本地手動操作也正常，但高流量下會出現 data race、map panic、狀態被外部修改或歷史資料被覆蓋。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Store</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">Store</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">user</span> <span class="nx">User</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">s</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">user</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">Store</span><span class="p">)</span> <span class="nf">Users</span><span class="p">()</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">s</span><span class="p">.</span><span class="nx">users</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個型別有兩個問題：map 沒有同步保護，且 <code>Users</code> 直接暴露內部 map。呼叫端拿到 map 後可以繞過 <code>Store</code> 修改資料。</p>
<h2 id="判讀mutex-保護的是狀態不變式">【判讀】mutex 保護的是狀態不變式</h2>
<p>Mutex 的核心責任不是讓程式「不會同時跑」，而是保護某一組資料的不變式。只要讀寫同一份可變資料，就應該由同一個 owner 控制 lock。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">UserRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>    <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">NewUserRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">UserRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">UserRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">users</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>UserRepository</code> 是 <code>users</code> map 的 owner。外部程式不應持有 <code>users</code> 的 reference，也不應知道它用 map、資料庫或其他結構保存。</p>
<h2 id="執行所有讀寫都經過-owner-method">【執行】所有讀寫都經過 owner method</h2>
<p>共享 map 的核心規則是所有讀寫都經過同一組方法。寫入使用 <code>Lock</code>，讀取使用 <code>RLock</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">user</span> <span class="nx">User</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">user</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>context.Context</code> 在 memory repository 裡可能用不到，但保留在 method signature 可以讓未來改成資料庫或遠端儲存時支援取消。這是 repository port 常見的演進邊界。</p>
<h2 id="判讀回傳內部-map-會破壞-lock">【判讀】回傳內部 map 會破壞 lock</h2>
<p>回傳 map 的核心風險是鎖只保護到方法結束。方法回傳後，呼叫端拿到的仍然是同一份 map，任何修改都會繞過 owner。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">UnsafeUsers</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式看起來有加鎖，但鎖釋放後外部仍能修改內部 map：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">users</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">UnsafeUsers</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">delete</span><span class="p">(</span><span class="nx">users</span><span class="p">,</span> <span class="s">&#34;user_1&#34;</span><span class="p">)</span></span></span></code></pre></div><p>安全做法是回傳 copy：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Users</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">id</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">result</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫端可以自由修改 <code>result</code>，不會影響 repository 內部狀態。</p>
<h2 id="判讀slice-copy-保護的是底層-array">【判讀】slice copy 保護的是底層 array</h2>
<p>Slice 的核心風險是 slice header 會被複製，但底層 array 可能共享。直接回傳 slice 會讓呼叫端修改 owner 的內部資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">RecentEvents</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>     <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">events</span> <span class="p">[]</span><span class="nx">Event</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">RecentEvents</span><span class="p">)</span> <span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">events</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">events</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>安全的 list method：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">RecentEvents</span><span class="p">)</span> <span class="nf">List</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">[]</span><span class="nx">Event</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">events</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">events</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>copy</code> 建立新的底層 array。呼叫端對 <code>result</code> 排序、截斷、append 或修改元素，不會改到 <code>r.events</code>。</p>
<h2 id="策略值型別可以-shallow-copy可變欄位需要-deep-copy">【策略】值型別可以 shallow copy，可變欄位需要 deep copy</h2>
<p>Copy boundary 的核心判斷是資料裡是否還包含可變 reference。若 struct 只有 string、int、time.Time 這類值型別，shallow copy 通常足夠；若 struct 包含 map、slice 或 pointer，就要考慮 deep copy。</p>
<p>值型別範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Event</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>      <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種 <code>Event</code> 放在 slice 裡，用 <code>copy</code> 複製 slice 通常足夠。</p>
<p>含可變欄位的範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Event</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>       <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Metadata</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這時只 copy slice 不夠，因為每個 <code>Event.Metadata</code> 仍然指向同一份 map。需要 clone：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">CloneEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="nx">Event</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">event</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Metadata</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">Metadata</span> <span class="p">=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Metadata</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">for</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">value</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Metadata</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">cloned</span><span class="p">.</span><span class="nx">Metadata</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">value</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>是否需要 deep copy 取決於 API 承諾。如果呼叫端不應修改 repository 內部資料，就要複製所有可變 reference。</p>
<h2 id="判讀回傳-pointer-要代表明確修改權">【判讀】回傳 pointer 要代表明確修改權</h2>
<p>Pointer 回傳的核心語意是呼叫端取得同一份資料的參照。若資料屬於共享狀態，回傳 pointer 通常會破壞 owner 邊界。</p>
<p>容易誤解的 API：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">FindPointer</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">User</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">user</span><span class="p">,</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式回傳的是區域變數 <code>user</code> 的指標，不是 map 內部資料的可修改入口。呼叫端修改這個 pointer，不會保存回 repository。API 看起來像能修改，實際不能，語意不清楚。</p>
<p>更清楚的做法是回傳 value，並提供明確 update method：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">UpdateEmail</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">email</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">user</span><span class="p">.</span><span class="nx">Email</span> <span class="p">=</span> <span class="nx">email</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>修改行為集中在 repository 內，lock、驗證與狀態一致性也留在同一個地方。</p>
<h2 id="策略mutex-和-channel-owner-要按資料形狀選擇">【策略】mutex 和 channel owner 要按資料形狀選擇</h2>
<p>狀態保護的核心選擇是 mutex owner 或 goroutine owner。兩者都符合 Go 的精神，差異在資料存取模式。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>mutex owner</td>
          <td>多個方法需要同步讀寫狀態</td>
          <td>要維護 lock 與 copy boundary</td>
      </tr>
      <tr>
          <td>goroutine owner</td>
          <td>所有修改都能表示成訊息</td>
          <td>要設計 command、reply、shutdown</td>
      </tr>
  </tbody>
</table>
<p>Mutex 版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</span><span class="p">)</span></span></span></code></pre></div><p>Goroutine owner 版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">command</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">kind</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">user</span>  <span class="nx">User</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">reply</span> <span class="kd">chan</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不要為了避免 mutex 而把簡單狀態硬改成複雜訊息系統。也不要在需要嚴格順序與單一事件流時到處加 lock。選擇應該來自資料形狀與讀寫模式。</p>
<h2 id="測試copy-boundary-要用外部修改驗證">【測試】copy boundary 要用外部修改驗證</h2>
<p>Copy boundary 的測試核心是呼叫 getter 後修改回傳值，再確認 owner 內部資料沒有被改動。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestUsersReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;user_1&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;a@example.com&#34;</span><span class="p">});</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;save user: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">users</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Users</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;users: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nb">delete</span><span class="p">(</span><span class="nx">users</span><span class="p">,</span> <span class="s">&#34;user_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;user_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;find user: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;repository should not be modified through returned map&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Slice copy 也用同樣方式測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestEventListReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">RecentEvents</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">events</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;evt_1&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nx">events</span><span class="p">.</span><span class="nf">List</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">got</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;changed&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">again</span> <span class="o">:=</span> <span class="nx">events</span><span class="p">.</span><span class="nf">List</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;evt_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;internal event was modified through returned slice&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這類測試能直接防止未來有人為了「省 copy」而破壞狀態邊界。</p>
<h2 id="測試race-detector-驗證同步邊界">【測試】race detector 驗證同步邊界</h2>
<p>Race detector 的核心用途是找出未同步的共享記憶體存取。對含有 goroutine、map、slice、repository 的測試，應定期執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>可以用併發測試增加觸發機率：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestRepositoryConcurrentAccess</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">i</span> <span class="o">:=</span> <span class="nx">i</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;user_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">id</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試本身不一定能證明沒有所有問題，但搭配 <code>-race</code> 可以檢查 repository 方法是否真的包住共享 map。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先聚焦單一 Go process 內的共享狀態保護；更外層的資料庫交易、快取一致性與資料複製邊界，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">Go 入門：指標與資料複製邊界</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、copy boundary 與 state owner；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>共享狀態的安全邊界由 owner、lock、copy 與明確修改方法組成。Map/slice 讀寫要經過同一個 owner；getter 不應暴露內部可變資料；含 map、slice、pointer 的 struct 要考慮 deep copy；修改行為應集中在方法內。這些規則能讓長時間運行的 Go 服務避開 data race、外部突變與難以重現的狀態錯誤。</p>
]]></content:encoded></item><item><title>2.4 慢客戶端與 send buffer 管理</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/</guid><description>&lt;p>慢客戶端管理的核心問題是單一 client 的讀取速度可能低於 server 推送速度。若 send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 沒有上限，慢 client 會把訊息堆在記憶體裡；若 hub 使用 blocking send，慢 client 會拖住所有 client。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨慢 client 對 hub、write pump、記憶體的影響&lt;/li>
&lt;li>用 bounded send channel 限制單一 client 的排隊量&lt;/li>
&lt;li>設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full 時的 drop、disconnect、coalesce 策略&lt;/li>
&lt;li>在必要時用 byte budget 管理大型 payload&lt;/li>
&lt;li>測試 send buffer 滿載與 client unregister 行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察慢-client-會把局部問題變成全域問題">【觀察】慢 client 會把局部問題變成全域問題&lt;/h2>
&lt;p>慢 client 的核心風險是它不只影響自己。若 hub broadcast 時對每個 client 使用 blocking send，其中一個 client 的 &lt;code>send&lt;/code> channel 滿了，hub 就可能卡住，其他 client 也收不到訊息。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Hub&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Broadcast&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">clients&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">send&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">message&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式看起來保證送達，但實際上把整個 hub 的可用性綁在最慢的 client 上。只要一個 client 不讀，所有 broadcast 都可能停住。&lt;/p>
&lt;h2 id="判讀send-channel-是每個-client-的容量邊界">【判讀】send channel 是每個 client 的容量邊界&lt;/h2>
&lt;p>Send channel 的核心責任是作為單一 client 的輸出佇列。它必須有容量上限，否則 server 會替慢 client 無限制保存訊息。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="nx">sendBufferSize&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">64&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Client&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendBufferSize&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Buffer 的目的只是吸收短暫尖峰，不是讓 client 長期落後。若 client 長期消費速度低於推送速度，任何有限 buffer 都會滿。&lt;/p>
&lt;h2 id="策略滿載策略取決於訊息語意">【策略】滿載策略取決於訊息語意&lt;/h2>
&lt;p>慢 client 滿載的核心決策是訊息能不能遺失。不同資料類型需要不同策略。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊息類型&lt;/th>
 &lt;th>常見策略&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即時狀態 snapshot&lt;/td>
 &lt;td>可丟棄舊訊息或 coalesce&lt;/td>
 &lt;td>最新狀態比每個中間狀態重要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>action result&lt;/td>
 &lt;td>優先送達，滿載時可斷線&lt;/td>
 &lt;td>client 需要知道操作結果&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>診斷 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> stream&lt;/td>
 &lt;td>可取樣或丟棄&lt;/td>
 &lt;td>資料量大，通常不是唯一真相&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>金流、訂單、稽核事件&lt;/td>
 &lt;td>不應只靠 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>&lt;/td>
 &lt;td>需要可靠儲存或可重播來源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>WebSocket send buffer 不應承擔資料可靠性。若訊息不能遺失，可靠性應放在資料庫、queue 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log&lt;/a>，WebSocket 只負責即時通知。&lt;/p>
&lt;h2 id="執行non-blocking-send-保護-hub">【執行】non-blocking send 保護 hub&lt;/h2>
&lt;p>Hub 的核心保護是 broadcast 時不被單一 client 阻塞。&lt;code>TrySend&lt;/code> 可以讓 hub 立即知道該 client 是否已滿載。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">TrySend&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">send&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hub 可以把滿載 client 送進 unregister：&lt;/p></description><content:encoded><![CDATA[<p>慢客戶端管理的核心問題是單一 client 的讀取速度可能低於 server 推送速度。若 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 沒有上限，慢 client 會把訊息堆在記憶體裡；若 hub 使用 blocking send，慢 client 會拖住所有 client。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨慢 client 對 hub、write pump、記憶體的影響</li>
<li>用 bounded send channel 限制單一 client 的排隊量</li>
<li>設計 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 時的 drop、disconnect、coalesce 策略</li>
<li>在必要時用 byte budget 管理大型 payload</li>
<li>測試 send buffer 滿載與 client unregister 行為</li>
</ol>
<hr>
<h2 id="觀察慢-client-會把局部問題變成全域問題">【觀察】慢 client 會把局部問題變成全域問題</h2>
<p>慢 client 的核心風險是它不只影響自己。若 hub broadcast 時對每個 client 使用 blocking send，其中一個 client 的 <code>send</code> channel 滿了，hub 就可能卡住，其他 client 也收不到訊息。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式看起來保證送達，但實際上把整個 hub 的可用性綁在最慢的 client 上。只要一個 client 不讀，所有 broadcast 都可能停住。</p>
<h2 id="判讀send-channel-是每個-client-的容量邊界">【判讀】send channel 是每個 client 的容量邊界</h2>
<p>Send channel 的核心責任是作為單一 client 的輸出佇列。它必須有容量上限，否則 server 會替慢 client 無限制保存訊息。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">const</span> <span class="nx">sendBufferSize</span> <span class="p">=</span> <span class="mi">64</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">id</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewClient</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="o">*</span><span class="nx">Client</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">id</span><span class="p">:</span>   <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">send</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">ServerMessage</span><span class="p">,</span> <span class="nx">sendBufferSize</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Buffer 的目的只是吸收短暫尖峰，不是讓 client 長期落後。若 client 長期消費速度低於推送速度，任何有限 buffer 都會滿。</p>
<h2 id="策略滿載策略取決於訊息語意">【策略】滿載策略取決於訊息語意</h2>
<p>慢 client 滿載的核心決策是訊息能不能遺失。不同資料類型需要不同策略。</p>
<table>
  <thead>
      <tr>
          <th>訊息類型</th>
          <th>常見策略</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時狀態 snapshot</td>
          <td>可丟棄舊訊息或 coalesce</td>
          <td>最新狀態比每個中間狀態重要</td>
      </tr>
      <tr>
          <td>action result</td>
          <td>優先送達，滿載時可斷線</td>
          <td>client 需要知道操作結果</td>
      </tr>
      <tr>
          <td>診斷 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> stream</td>
          <td>可取樣或丟棄</td>
          <td>資料量大，通常不是唯一真相</td>
      </tr>
      <tr>
          <td>金流、訂單、稽核事件</td>
          <td>不應只靠 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a></td>
          <td>需要可靠儲存或可重播來源</td>
      </tr>
  </tbody>
</table>
<p>WebSocket send buffer 不應承擔資料可靠性。若訊息不能遺失，可靠性應放在資料庫、queue 或 <a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a>，WebSocket 只負責即時通知。</p>
<h2 id="執行non-blocking-send-保護-hub">【執行】non-blocking send 保護 hub</h2>
<p>Hub 的核心保護是 broadcast 時不被單一 client 阻塞。<code>TrySend</code> 可以讓 hub 立即知道該 client 是否已滿載。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Hub 可以把滿載 client 送進 unregister：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種策略犧牲慢 client，保護整體服務。對即時通知服務來說，讓慢 client 重連並重新取得 snapshot，通常比讓所有 client 等它更合理。</p>
<h2 id="策略drop-newestdrop-oldestdisconnect-是不同語意">【策略】drop newest、drop oldest、disconnect 是不同語意</h2>
<p>Queue full 策略的核心差異是保留哪一筆資料，以及是否繼續維持連線。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>行為</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>drop newest</td>
          <td>新訊息不進 queue</td>
          <td>舊訊息仍有價值</td>
      </tr>
      <tr>
          <td>drop oldest</td>
          <td>移除舊訊息，保留最新</td>
          <td>狀態型更新</td>
      </tr>
      <tr>
          <td>disconnect</td>
          <td>關閉 client，要求重連</td>
          <td>client 已明顯跟不上</td>
      </tr>
      <tr>
          <td>coalesce</td>
          <td>合併多筆更新成一筆</td>
          <td><a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 最新狀態可覆蓋</td>
      </tr>
  </tbody>
</table>
<p>Drop oldest 範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySendLatest</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">send</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式表示「新狀態比舊狀態重要」。它不適合 action result 或不可遺失事件，因為它會主動丟掉尚未送出的舊訊息。</p>
<h2 id="策略byte-budget-比-message-count-更接近記憶體風險">【策略】byte budget 比 message count 更接近記憶體風險</h2>
<p>Message count 的核心限制是每筆訊息大小不同。64 筆小訊息和 64 筆大型 JSON payload 的記憶體成本差很多；當 payload 大小差異明顯時，可以加上 byte budget。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">send</span>      <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">sendBytes</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">maxBytes</span>  <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">size</span> <span class="o">:=</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">message</span><span class="p">.</span><span class="nf">Size</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="nx">size</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">c</span><span class="p">.</span><span class="nx">maxBytes</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="o">-</span><span class="nx">size</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="o">-</span><span class="nx">size</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Write pump 成功取出並寫出訊息後，必須扣回 byte budget：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">markSent</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="o">-</span><span class="nb">int64</span><span class="p">(</span><span class="nx">message</span><span class="p">.</span><span class="nf">Size</span><span class="p">()))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Byte budget 更接近記憶體風險，但也更複雜。只有在訊息大小差異大、或服務連線數高時才值得加入；小型服務先用固定 buffer 通常足夠。</p>
<h2 id="判讀write-pump-慢不一定是-client-的錯">【判讀】write pump 慢不一定是 client 的錯</h2>
<p>慢寫入的核心原因可能在 client，也可能在 server。Client 網路慢、瀏覽器停住、行動裝置休眠會造成慢寫；server payload 太大、序列化太慢、單次寫入沒有 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 也會造成問題。</p>
<p>排查方向：</p>
<ul>
<li>send buffer 長期接近滿載</li>
<li>write deadline 錯誤增加</li>
<li>單筆 message size 過大</li>
<li>broadcast 頻率超過 client 消費能力</li>
<li>某些 topic 推送量異常高</li>
</ul>
<p>queue full 的歸因應同時檢查 client 與 server 端訊號。若所有 client 都慢，通常是 server 推送量、payload 大小或下游網路策略出問題。</p>
<h2 id="策略滿載要有觀測欄位">【策略】滿載要有觀測欄位</h2>
<p>慢 client 策略的核心要求是可觀測。若系統選擇 drop 或 disconnect，應記錄足夠欄位讓工程師知道原因。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">handleFullClient</span><span class="p">(</span><span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">metrics</span><span class="p">.</span><span class="nf">Inc</span><span class="p">(</span><span class="s">&#34;websocket_client_send_full&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">h</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;websocket client send buffer full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="s">&#34;client_id&#34;</span><span class="p">,</span> <span class="nx">client</span><span class="p">.</span><span class="nf">ID</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="s">&#34;send_queue_len&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s">&#34;send_queue_cap&#34;</span><span class="p">,</span> <span class="nb">cap</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Log 用來追單次事件，metric 用來看趨勢。若滿載數量突然增加，可能是某個 topic 推送量上升，也可能是 client 版本或網路環境改變。</p>
<h2 id="測試滿載測試要先填滿-buffer">【測試】滿載測試要先填滿 buffer</h2>
<p>慢 client 測試的核心是直接建立滿載條件。容量為 1 的 channel 加上預先填滿的資料，可以穩定製造 queue full；sleep 只是在等待排程運氣。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestTrySendReturnsFalseWhenBufferFull</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">id</span><span class="p">:</span>   <span class="s">&#34;client_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">send</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">ServerMessage</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;first&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;second&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;TrySend should return false when buffer is full&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Hub unregister 行為也可以測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestBroadcastUnregistersFullClient</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;existing&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">,</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;new&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="nx">got</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">client</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;unregister client mismatch&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;full client should be unregistered&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這類測試直接驗證服務策略：client 滿載時，hub 不阻塞，而是走指定降級路徑。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 server 內的慢 client 與 send buffer 邊界；跨節點 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 與持久化同步，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 、non-blocking send 與 rate limiting；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Go：rate limiting 與 backpressure </a></li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>慢客戶端是 WebSocket 服務的容量控制問題。每個 client 的 send buffer 必須有上限，hub broadcast 不應被單一 client 阻塞，queue full 策略要符合訊息語意。必要時可加入 byte budget，但更重要的是明確決定 drop、disconnect、coalesce 或可靠儲存，並用 log、metric、測試讓降級行為可見。</p>
]]></content:encoded></item><item><title>3.4 資料結構與 allocation 壓力</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/</guid><description>&lt;p>Allocation 分析的核心目標是區分必要的安全複製與可優化的重複配置。Go 服務中很多配置來自 slice 成長、map/list 複製、JSON marshal、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 建立與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> payload；優化前要先確認配置是否位於熱路徑，且不能破壞狀態邊界。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 allocation 如何增加 GC 壓力&lt;/li>
&lt;li>分辨必要 copy boundary 與不必要重複配置&lt;/li>
&lt;li>用預配置降低 slice 成長成本&lt;/li>
&lt;li>判斷 JSON marshal 與 WebSocket payload 的重用邊界&lt;/li>
&lt;li>用 pprof 的 &lt;code>alloc_space&lt;/code> 與 &lt;code>inuse_space&lt;/code> 決定優化方向&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察allocation-壓力會放大-gc-成本">【觀察】allocation 壓力會放大 GC 成本&lt;/h2>
&lt;p>Allocation 的核心影響是增加 heap 成長速度，進而增加 GC 工作量。即使物件很快被回收，大量短命配置仍可能造成 CPU 與 latency 壓力。&lt;/p>
&lt;p>常見熱路徑：&lt;/p>
&lt;ul>
&lt;li>每次 WebSocket broadcast 都對每個 client 重新 marshal。&lt;/li>
&lt;li>每次 API list 都建立大型 slice。&lt;/li>
&lt;li>每次 repository 查詢都 copy 大型 map。&lt;/li>
&lt;li>每次 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 都組大量臨時欄位。&lt;/li>
&lt;li>每次 encode 都建立新的 &lt;code>bytes.Buffer&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>不是所有 allocation 都要消除。診斷重點是找出高頻、可避免、且不破壞邊界的配置。&lt;/p>
&lt;h2 id="判讀預配置解決的是成長成本">【判讀】預配置解決的是成長成本&lt;/h2>
&lt;p>Slice 預配置的核心用途是讓底層 array 成長符合預期。若結果長度可預估，應用 &lt;code>make&lt;/code> 設定容量。&lt;/p>
&lt;p>未預配置：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">BuildNames&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">names&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">users&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">names&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預配置：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">BuildNames&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">names&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">users&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">names&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這不是微優化。若這段程式在高頻 list API、background &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 或 broadcast path 中執行，預配置可以減少反覆擴容與 copy。&lt;/p>
&lt;h2 id="判讀copy-boundary-是必要成本">【判讀】copy boundary 是必要成本&lt;/h2>
&lt;p>安全複製的核心目的是保護內部可變狀態。Repository 回傳資料時 copy slice 或 map，會增加 allocation，但能避免外部突變與 data race。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ListUsers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">users&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 allocation 是狀態邊界的成本。優化前要先確認它是否真的是瓶頸，不能只因為 profile 看到配置就移除 copy。&lt;/p>
&lt;p>若列表很大且讀取頻繁，應考慮分頁、projection、snapshot cache 或只回傳必要欄位。不要為了省配置而直接暴露內部 map。&lt;/p>
&lt;h2 id="策略大型-list-優先改資料形狀">【策略】大型 list 優先改資料形狀&lt;/h2>
&lt;p>大型 list allocation 的核心問題常常是 API 一次回太多資料。若每次請求都複製整個 repository，配置與延遲都會隨資料量線性成長。&lt;/p>
&lt;p>可選策略：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;th>代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>分頁&lt;/td>
 &lt;td>使用者只需要部分資料&lt;/td>
 &lt;td>API 需要 cursor 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>projection&lt;/td>
 &lt;td>只需要摘要欄位&lt;/td>
 &lt;td>要維護讀取模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>snapshot cache&lt;/td>
 &lt;td>讀多寫少&lt;/td>
 &lt;td>要處理快取失效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>incremental update&lt;/td>
 &lt;td>WebSocket 推送最新變化&lt;/td>
 &lt;td>client 要能合併狀態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優化資料形狀通常比取消 copy 更安全。Copy boundary 保護正確性，資料形狀決定每次 copy 的成本。&lt;/p></description><content:encoded><![CDATA[<p>Allocation 分析的核心目標是區分必要的安全複製與可優化的重複配置。Go 服務中很多配置來自 slice 成長、map/list 複製、JSON marshal、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 建立與 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> payload；優化前要先確認配置是否位於熱路徑，且不能破壞狀態邊界。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 allocation 如何增加 GC 壓力</li>
<li>分辨必要 copy boundary 與不必要重複配置</li>
<li>用預配置降低 slice 成長成本</li>
<li>判斷 JSON marshal 與 WebSocket payload 的重用邊界</li>
<li>用 pprof 的 <code>alloc_space</code> 與 <code>inuse_space</code> 決定優化方向</li>
</ol>
<hr>
<h2 id="觀察allocation-壓力會放大-gc-成本">【觀察】allocation 壓力會放大 GC 成本</h2>
<p>Allocation 的核心影響是增加 heap 成長速度，進而增加 GC 工作量。即使物件很快被回收，大量短命配置仍可能造成 CPU 與 latency 壓力。</p>
<p>常見熱路徑：</p>
<ul>
<li>每次 WebSocket broadcast 都對每個 client 重新 marshal。</li>
<li>每次 API list 都建立大型 slice。</li>
<li>每次 repository 查詢都 copy 大型 map。</li>
<li>每次 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 都組大量臨時欄位。</li>
<li>每次 encode 都建立新的 <code>bytes.Buffer</code>。</li>
</ul>
<p>不是所有 allocation 都要消除。診斷重點是找出高頻、可避免、且不破壞邊界的配置。</p>
<h2 id="判讀預配置解決的是成長成本">【判讀】預配置解決的是成長成本</h2>
<p>Slice 預配置的核心用途是讓底層 array 成長符合預期。若結果長度可預估，應用 <code>make</code> 設定容量。</p>
<p>未預配置：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">BuildNames</span><span class="p">(</span><span class="nx">users</span> <span class="p">[]</span><span class="nx">User</span><span class="p">)</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">var</span> <span class="nx">names</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="nx">names</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>預配置：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">BuildNames</span><span class="p">(</span><span class="nx">users</span> <span class="p">[]</span><span class="nx">User</span><span class="p">)</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">names</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">string</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">users</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="nx">names</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這不是微優化。若這段程式在高頻 list API、background <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 或 broadcast path 中執行，預配置可以減少反覆擴容與 copy。</p>
<h2 id="判讀copy-boundary-是必要成本">【判讀】copy boundary 是必要成本</h2>
<p>安全複製的核心目的是保護內部可變狀態。Repository 回傳資料時 copy slice 或 map，會增加 allocation，但能避免外部突變與 data race。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">ListUsers</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">([]</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">users</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">User</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">users</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">users</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">users</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 allocation 是狀態邊界的成本。優化前要先確認它是否真的是瓶頸，不能只因為 profile 看到配置就移除 copy。</p>
<p>若列表很大且讀取頻繁，應考慮分頁、projection、snapshot cache 或只回傳必要欄位。不要為了省配置而直接暴露內部 map。</p>
<h2 id="策略大型-list-優先改資料形狀">【策略】大型 list 優先改資料形狀</h2>
<p>大型 list allocation 的核心問題常常是 API 一次回太多資料。若每次請求都複製整個 repository，配置與延遲都會隨資料量線性成長。</p>
<p>可選策略：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>分頁</td>
          <td>使用者只需要部分資料</td>
          <td>API 需要 cursor 或 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a></td>
      </tr>
      <tr>
          <td>projection</td>
          <td>只需要摘要欄位</td>
          <td>要維護讀取模型</td>
      </tr>
      <tr>
          <td>snapshot cache</td>
          <td>讀多寫少</td>
          <td>要處理快取失效</td>
      </tr>
      <tr>
          <td>incremental update</td>
          <td>WebSocket 推送最新變化</td>
          <td>client 要能合併狀態</td>
      </tr>
  </tbody>
</table>
<p>優化資料形狀通常比取消 copy 更安全。Copy boundary 保護正確性，資料形狀決定每次 copy 的成本。</p>
<h2 id="執行json-marshal-是-websocket-常見配置來源">【執行】JSON marshal 是 WebSocket 常見配置來源</h2>
<p>JSON 序列化的核心成本是把 Go 資料結構轉成 bytes。高頻 WebSocket 推送若對每個 client 反覆 marshal 同一份 message，會造成大量短命配置。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">payload</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">SendBytes</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>同一份 message 可以先 marshal 一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">payload</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">SendBytes</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個優化的前提是 <code>payload</code> 被視為只讀。Send path 不應修改傳入的 bytes；若某個 client 需要修改，就應在該 client 邊界 copy，而不是讓共享 payload 被改動。</p>
<h2 id="判讀bytes-重用要先定義所有權">【判讀】bytes 重用要先定義所有權</h2>
<p>Bytes 重用的核心風險是共享 slice 被修改。<code>[]byte</code> 是可變資料，傳給多個 client 時要明確規定它只讀。</p>
<p>可以用型別或註解表達語意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">EncodedMessage</span> <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">SendEncoded</span><span class="p">(</span><span class="nx">message</span> <span class="nx">EncodedMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Encoded</span><span class="p">:</span> <span class="nx">message</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這不能從型別上完全禁止修改，但能讓 API 語意更清楚。真正保護仍靠 ownership 規則、測試與 code review。</p>
<p>若無法保證下游不修改，就必須 copy：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">CloneBytes</span><span class="p">(</span><span class="nx">input</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="kt">byte</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">output</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">input</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">output</span><span class="p">,</span> <span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="nx">output</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>效能優化不能建立在模糊的可變資料共享上。</p>
<h2 id="策略syncpool-只適合已證明的熱路徑">【策略】sync.Pool 只適合已證明的熱路徑</h2>
<p><code>sync.Pool</code> 的核心用途是複用高頻、短命、可重建的暫存物件。它可以降低配置，但會增加所有權複雜度。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">bufferPool</span> <span class="p">=</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">Pool</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">New</span><span class="p">:</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">any</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nb">new</span><span class="p">(</span><span class="nx">bytes</span><span class="p">.</span><span class="nx">Buffer</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="nf">Encode</span><span class="p">(</span><span class="nx">value</span> <span class="kt">any</span><span class="p">)</span> <span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">buf</span> <span class="o">:=</span> <span class="nx">bufferPool</span><span class="p">.</span><span class="nf">Get</span><span class="p">().(</span><span class="o">*</span><span class="nx">bytes</span><span class="p">.</span><span class="nx">Buffer</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">defer</span> <span class="nx">bufferPool</span><span class="p">.</span><span class="nf">Put</span><span class="p">(</span><span class="nx">buf</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">buf</span><span class="p">.</span><span class="nf">Reset</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">buf</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">value</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">output</span> <span class="o">:=</span> <span class="nb">append</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="kc">nil</span><span class="p">),</span> <span class="nx">buf</span><span class="p">.</span><span class="nf">Bytes</span><span class="p">()</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="nx">output</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡仍然 copy 出 <code>output</code>，因為 <code>buf</code> 會被放回 pool。若直接回傳 <code>buf.Bytes()</code>，呼叫端拿到的 slice 可能在 pool 重用後被覆寫。</p>
<p>不要一開始就使用 <code>sync.Pool</code>。先用 pprof 證明配置是瓶頸，再評估 pool 是否值得承擔額外複雜度。</p>
<h2 id="判讀inuse-與-alloc-回答不同問題">【判讀】inuse 與 alloc 回答不同問題</h2>
<p>Heap profile 的核心判讀是分清 <code>inuse_space</code> 與 <code>alloc_space</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof http://localhost:8080/debug/pprof/heap
</span></span><span class="line"><span class="ln">2</span><span class="cl">go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>指標</th>
          <th>問題</th>
          <th>常見修正</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>inuse_space</code> 高</td>
          <td>現在誰保留記憶體</td>
          <td>cache 淘汰、釋放引用、限制 buffer</td>
      </tr>
      <tr>
          <td><code>alloc_space</code> 高</td>
          <td>誰累積配置最多</td>
          <td>預配置、重用、減少 marshal、改資料形狀</td>
      </tr>
  </tbody>
</table>
<p>若 <code>alloc_space</code> 高但 <code>inuse_space</code> 不高，代表配置很多但大多被回收，問題可能是 GC 壓力。若 <code>inuse_space</code> 持續上升，代表資料被長期保留，應檢查 cache、map、slice、goroutine reference 或 send buffer。</p>
<h2 id="策略allocation-優化要保留正確性邊界">【策略】allocation 優化要保留正確性邊界</h2>
<p>Allocation 優化的核心底線是不能破壞狀態安全。以下做法通常不可接受：</p>
<ul>
<li>為了省 copy，直接回傳 repository 內部 map。</li>
<li>為了省 bytes，讓多個 client 共享可修改 payload。</li>
<li>為了省 allocation，把 buffer 放回 pool 後仍回傳其底層 slice。</li>
<li>為了少建立 struct，把 request DTO 和 domain state 混用。</li>
</ul>
<p>較安全的優化順序：</p>
<ol>
<li>用 pprof 確認熱點。</li>
<li>預配置已知大小的 slice/map。</li>
<li>減少重複 marshal。</li>
<li>改 API 資料形狀，例如分頁或 projection。</li>
<li>最後才考慮 <code>sync.Pool</code>。</li>
</ol>
<p>這個順序先處理低風險、高可讀性的改動，再處理高複雜度工具。</p>
<h2 id="測試優化後要補邊界測試">【測試】優化後要補邊界測試</h2>
<p>Allocation 優化的測試核心是確保共享資料沒有被外部修改。若你重用 bytes、snapshot 或 pooled buffer，要補測試保護 ownership。</p>
<p>例如 repository list 仍要回傳 copy：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestListUsersReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;user_1&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">users</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">ListUsers</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;list users: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">users</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;changed&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">again</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">ListUsers</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;list users again: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;user_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;repository data was modified through returned slice&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試能防止未來為了省 allocation 而移除必要 copy。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理熱路徑上的配置與資料形狀；更大範圍的序列化與 payload 策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/02-types-data/struct-json/" data-link-title="2.1 struct 與 JSON tag" data-link-desc="理解 Go struct 如何表達資料形狀，並透過 JSON tag 對應外部格式">Go 入門：struct 與 JSON tag</a></li>
<li><a href="/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">Go 入門：slice 與 map</a></li>
<li><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">Go 入門：指標與資料複製邊界</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 copy boundary、JSON 與 runtime profile；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Allocation 優化要先判斷配置是否必要。保護狀態的 copy 是合理成本，高頻熱路徑的重複配置才是優先目標。JSON marshal、slice 成長、map/list 複製與 buffer 建立都是常見來源；用 pprof 區分 <code>inuse_space</code> 與 <code>alloc_space</code> 後，再決定預配置、分頁、projection、payload 重用或 <code>sync.Pool</code>。</p>
]]></content:encoded></item><item><title>4.4 多來源 event 融合</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</guid><description>&lt;p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 &lt;code>DomainEvent&lt;/code>。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨來源差異與 domain 規則差異&lt;/li>
&lt;li>為不同來源設計 adapter 與 normalize&lt;/li>
&lt;li>用 channel 或直接呼叫收斂事件入口&lt;/li>
&lt;li>為突發流量設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 策略&lt;/li>
&lt;li>決定錯誤應回給上游、重試、丟棄或記錄&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂&lt;/h2>
&lt;p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HTTP callback ──&amp;gt; validate A ──&amp;gt; update state A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ──&amp;gt; validate B ──&amp;gt; update state B
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ──&amp;gt; validate C ──&amp;gt; update state C&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。&lt;/p>
&lt;h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter&lt;/h2>
&lt;p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、HTTP status、payload 格式與重試語意；但轉成 &lt;code>DomainEvent&lt;/code> 後，processor 應該面對一致模型。&lt;/p>
&lt;p>目標結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HTTP callback ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ─┼─&amp;gt; normalize ─&amp;gt; DomainEvent ─&amp;gt; processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。&lt;/p>
&lt;h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任&lt;/h2>
&lt;p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>來源&lt;/th>
 &lt;th>adapter 責任&lt;/th>
 &lt;th>失敗回應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP callback&lt;/td>
 &lt;td>decode JSON、驗證簽章、normalize&lt;/td>
 &lt;td>回 4xx/5xx&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>queue consumer&lt;/td>
 &lt;td>decode message、控制 ack/nack、normalize&lt;/td>
 &lt;td>ack、nack 或 retry&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>timer scan&lt;/td>
 &lt;td>讀取本地狀態、產生內部事件&lt;/td>
 &lt;td>記錄錯誤或下次再掃&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>file reader&lt;/td>
 &lt;td>讀取增量資料、normalize&lt;/td>
 &lt;td>記錄 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 或停下&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。&lt;/p>
&lt;h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent&lt;/h2>
&lt;p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">RawHTTPEvent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeHTTPEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event_not_accepted&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>StatusAccepted&lt;/code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。&lt;/p></description><content:encoded><![CDATA[<p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 <code>DomainEvent</code>。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨來源差異與 domain 規則差異</li>
<li>為不同來源設計 adapter 與 normalize</li>
<li>用 channel 或直接呼叫收斂事件入口</li>
<li>為突發流量設計 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略</li>
<li>決定錯誤應回給上游、重試、丟棄或記錄</li>
</ol>
<hr>
<h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂</h2>
<p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HTTP callback ──&gt; validate A ──&gt; update state A
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ──&gt; validate B ──&gt; update state B
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ──&gt; validate C ──&gt; update state C</span></span></code></pre></div><p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。</p>
<h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter</h2>
<p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication</a>、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、HTTP status、payload 格式與重試語意；但轉成 <code>DomainEvent</code> 後，processor 應該面對一致模型。</p>
<p>目標結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HTTP callback ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ─┼─&gt; normalize ─&gt; DomainEvent ─&gt; processor
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ─┘</span></span></code></pre></div><p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。</p>
<h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任</h2>
<p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>adapter 責任</th>
          <th>失敗回應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP callback</td>
          <td>decode JSON、驗證簽章、normalize</td>
          <td>回 4xx/5xx</td>
      </tr>
      <tr>
          <td>queue consumer</td>
          <td>decode message、控制 ack/nack、normalize</td>
          <td>ack、nack 或 retry</td>
      </tr>
      <tr>
          <td>timer scan</td>
          <td>讀取本地狀態、產生內部事件</td>
          <td>記錄錯誤或下次再掃</td>
      </tr>
      <tr>
          <td>file reader</td>
          <td>讀取增量資料、normalize</td>
          <td>記錄 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 或停下</td>
      </tr>
  </tbody>
</table>
<p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。</p>
<h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent</h2>
<p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">HTTPEventHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">HTTPEventHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawHTTPEvent</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_event&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">,</span> <span class="s">&#34;event_not_accepted&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>StatusAccepted</code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。</p>
<h2 id="執行queue-adapter-控制-acknack">【執行】queue adapter 控制 ack/nack</h2>
<p>queue adapter 的核心責任是把 message lifecycle 對應到 processor 結果。processor 不應知道 ack、nack 或 delivery tag。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">QueueMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Body</span>        <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Ack</span>         <span class="kd">func</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Nack</span>        <span class="kd">func</span><span class="p">(</span><span class="nx">requeue</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">type</span> <span class="nx">QueueConsumer</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="nx">QueueConsumer</span><span class="p">)</span> <span class="nf">Handle</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">msg</span> <span class="nx">QueueMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">Body</span><span class="p">,</span> <span class="nx">c</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Nack</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Nack</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Ack</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式把 queue 的重試決策留在 adapter。對 processor 來說，事件只是一筆 <code>DomainEvent</code>；對 queue 來說，錯誤需要轉成 ack/nack 策略。</p>
<h2 id="策略共用-channel-需要-backpressure">【策略】共用 channel 需要 backpressure</h2>
<p>共用 channel 的核心用途是把多個來源收斂到同一個處理 loop。它不是必要架構，但在多來源、突發流量或單一 worker 順序處理時很有用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="mi">1024</span><span class="p">)</span></span></span></code></pre></div><p>channel 一旦有容量限制，就必須設計滿載策略。沒有滿載策略的 channel 只會把問題延後到 goroutine 堆積或 request 卡住。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">EnqueueEvent</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrEventQueueFull</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>HTTP handler 遇到 <code>ErrEventQueueFull</code> 可以回 <code>503</code>。queue consumer 可以 nack 並 <a href="/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue</a>。timer scan 可以跳過本輪。不同來源的上游回應不同，但進入 channel 的事件模型相同。</p>
<h2 id="執行processor-loop-擁有消費節奏">【執行】processor loop 擁有消費節奏</h2>
<p>processor loop 的核心責任是決定事件如何被消費與停止。它應該接受 context，並在 shutdown 時停止讀取新事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventLoop</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">events</span>    <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">logger</span>    <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">l</span> <span class="nx">EventLoop</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">l</span><span class="p">.</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">l</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="nx">l</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;process event failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                    <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                    <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>正式實作還要處理 channel close。若事件來源會關閉 channel，讀取時應使用 <code>event, ok := &lt;-l.events</code>；若 channel 由長生命週期服務持有，通常由 context 控制 shutdown。</p>
<h2 id="判讀錯誤策略要依來源與資料語意決定">【判讀】錯誤策略要依來源與資料語意決定</h2>
<p>錯誤策略的核心問題是「失敗後誰能重送，重送是否安全」。HTTP、queue、timer 的答案不同。</p>
<table>
  <thead>
      <tr>
          <th>錯誤位置</th>
          <th>HTTP callback</th>
          <th>queue message</th>
          <th>timer scan</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>decode 失敗</td>
          <td>400，不重試</td>
          <td>nack(false) 或 dead-letter</td>
          <td>記錄錯誤</td>
      </tr>
      <tr>
          <td>normalize 失敗</td>
          <td>400，不重試</td>
          <td>nack(false) 或 dead-letter</td>
          <td>記錄錯誤</td>
      </tr>
      <tr>
          <td>processor 暫時失敗</td>
          <td>503，可重試</td>
          <td>nack(true)</td>
          <td>下次再掃</td>
      </tr>
      <tr>
          <td>duplicate event</td>
          <td>202 或 204</td>
          <td>ack</td>
          <td>忽略</td>
      </tr>
      <tr>
          <td>publisher 失敗</td>
          <td>視語意而定</td>
          <td>視語意而定</td>
          <td>視語意而定</td>
      </tr>
  </tbody>
</table>
<p>錯誤策略不能只看技術來源，也要看資料語意。若事件已經成功更新狀態但即時推送失敗，HTTP 是否要回錯取決於 API 是否承諾推送已完成。</p>
<h2 id="策略觀測欄位要跨來源一致">【策略】觀測欄位要跨來源一致</h2>
<p>事件融合後的 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 metric 也應使用共同欄位。這讓你能跨 HTTP、queue、timer 比較同一類事件的行為。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">[]</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Attr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="p">[]</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Attr</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_source&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Source</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">Time</span><span class="p">(</span><span class="s">&#34;occurred_at&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">Time</span><span class="p">(</span><span class="s">&#34;received_at&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>adapter 可以額外記錄 HTTP path、queue name 或 timer name，但共同欄位應該來自 <code>DomainEvent</code>。這樣排查問題時，讀者不用先知道事件從哪個來源進來。</p>
<h2 id="測試融合測試要驗證同類事件走同一規則">【測試】融合測試要驗證同類事件走同一規則</h2>
<p>多來源測試的核心目標是確認不同 adapter 產生同一種 <code>DomainEvent</code>，並且 processor 對它們套用同一組規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHTTPAndQueueNormalizeToSameDomainEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">receivedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">httpEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">RawHTTPEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">EventID</span><span class="p">:</span>   <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">AccountID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">EventName</span><span class="p">:</span> <span class="s">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Timestamp</span><span class="p">:</span> <span class="s">&#34;2026-04-22T10:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">},</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize http event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">queueEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">        &#34;id&#34;:&#34;evt_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;subject&#34;:&#34;acct_1&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;type&#34;:&#34;account.activated&#34;,
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">        &#34;occurred_at&#34;:&#34;2026-04-22T10:00:00Z&#34;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">    }`</span><span class="p">),</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize queue event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">||</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;sources should normalize to same domain semantics&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不是要求兩個 event 完全相同。<code>Source</code> 可以不同；重點是 domain semantics 一致，processor 才能共用規則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理多來源事件如何在單一服務內融合；queue driver、outbox 與 tracing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是多來源 adapter、normalize 與 processor 的路線；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件融合的核心是把來源差異限制在 adapter 與 normalizer，讓 processor 只面對一致的 <code>DomainEvent</code>。HTTP、queue、timer 可以有不同的 backpressure 與錯誤回應，但不應複製 domain 規則。當來源增加時，系統應該增加 adapter，而不是增加另一套狀態更新流程。</p>
]]></content:encoded></item><item><title>5.4 table-driven test 的設計邊界</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/</guid><description>&lt;p>Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例，但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷什麼行為適合 table-driven test&lt;/li>
&lt;li>設計欄位少、意圖清楚的測試表&lt;/li>
&lt;li>發現 table test 膨脹成迷你框架的訊號&lt;/li>
&lt;li>拆分 validation、repository error、integration flow&lt;/li>
&lt;li>寫出能定位失敗情境的子測試名稱&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察table-driven-test-很容易被濫用">【觀察】table-driven test 很容易被濫用&lt;/h2>
&lt;p>Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client、expected &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、expected event，測試就會變成難懂的迷你框架。&lt;/p>
&lt;p>失控表格示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">method&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">body&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupRepo&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupClient&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">queueFull&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantStatus&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantMessage&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantEvent&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantLog&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="c1">// many unrelated cases&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種表格看似統一，實際上混合了 HTTP validation、repository error、client &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、event emission、log assertion。讀者必須同時理解多個系統層，才能看懂單一案例。&lt;/p>
&lt;h2 id="判讀好表格描述同一個行為維度">【判讀】好表格描述同一個行為維度&lt;/h2>
&lt;p>好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料，不改變測試流程。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestNormalizeTopic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">}{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; alerts &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;lowercase&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;ALERTS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeTopic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;NormalizeTopic(%q) = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這張表只測 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization。每個案例都只有 input 和 want，失敗時也能立刻看出是哪個 normalization 規則壞了。&lt;/p>
&lt;h2 id="策略表格欄位越多越要懷疑測試邊界">【策略】表格欄位越多，越要懷疑測試邊界&lt;/h2>
&lt;p>Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。&lt;/p>
&lt;p>拆分判斷：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>現象&lt;/th>
 &lt;th>問題&lt;/th>
 &lt;th>建議&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>很多 &lt;code>setupX bool&lt;/code>&lt;/td>
 &lt;td>setup 不一致&lt;/td>
 &lt;td>拆成不同測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>很多 &lt;code>wantX bool&lt;/code>&lt;/td>
 &lt;td>斷言目標不一致&lt;/td>
 &lt;td>拆成不同測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>loop 內大量 &lt;code>if tt...&lt;/code>&lt;/td>
 &lt;td>測試流程不一致&lt;/td>
 &lt;td>拆表或改成具名測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>案例名稱很長仍說不清&lt;/td>
 &lt;td>行為維度太多&lt;/td>
 &lt;td>回到單一行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>helper 隱藏主要斷言&lt;/td>
 &lt;td>可讀性下降&lt;/td>
 &lt;td>讓斷言留在測試本文&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格不是越通用越好。測試的第一責任是讓失敗可定位，不是消除所有重複。&lt;/p>
&lt;h2 id="執行validation-適合-table-test">【執行】validation 適合 table test&lt;/h2>
&lt;p>Validation 的核心特徵是輸入和輸出形狀一致，因此很適合 table-driven test。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestValidateSubscribeRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">request&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">}{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;valid topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;blank topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; &amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ValidateSubscribeRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">request&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;error = %v, wantErr %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這張表只問一件事：request 是否有效。它不測 WebSocket connection、不測 hub、不測 repository，因此案例可以保持簡潔。&lt;/p></description><content:encoded><![CDATA[<p>Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例，但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷什麼行為適合 table-driven test</li>
<li>設計欄位少、意圖清楚的測試表</li>
<li>發現 table test 膨脹成迷你框架的訊號</li>
<li>拆分 validation、repository error、integration flow</li>
<li>寫出能定位失敗情境的子測試名稱</li>
</ol>
<hr>
<h2 id="觀察table-driven-test-很容易被濫用">【觀察】table-driven test 很容易被濫用</h2>
<p>Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client、expected <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、expected event，測試就會變成難懂的迷你框架。</p>
<p>失控表格示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">name</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">method</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">body</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">setupRepo</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">setupClient</span>   <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">queueFull</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">wantStatus</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">wantMessage</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">wantEvent</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">wantLog</span>       <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1">// many unrelated cases</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種表格看似統一，實際上混合了 HTTP validation、repository error、client <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、event emission、log assertion。讀者必須同時理解多個系統層，才能看懂單一案例。</p>
<h2 id="判讀好表格描述同一個行為維度">【判讀】好表格描述同一個行為維度</h2>
<p>好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料，不改變測試流程。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNormalizeTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34; alerts &#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;lowercase&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;ALERTS&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NormalizeTopic</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;NormalizeTopic(%q) = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表只測 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization。每個案例都只有 input 和 want，失敗時也能立刻看出是哪個 normalization 規則壞了。</p>
<h2 id="策略表格欄位越多越要懷疑測試邊界">【策略】表格欄位越多，越要懷疑測試邊界</h2>
<p>Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。</p>
<p>拆分判斷：</p>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>問題</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>很多 <code>setupX bool</code></td>
          <td>setup 不一致</td>
          <td>拆成不同測試</td>
      </tr>
      <tr>
          <td>很多 <code>wantX bool</code></td>
          <td>斷言目標不一致</td>
          <td>拆成不同測試</td>
      </tr>
      <tr>
          <td>loop 內大量 <code>if tt...</code></td>
          <td>測試流程不一致</td>
          <td>拆表或改成具名測試</td>
      </tr>
      <tr>
          <td>案例名稱很長仍說不清</td>
          <td>行為維度太多</td>
          <td>回到單一行為</td>
      </tr>
      <tr>
          <td>helper 隱藏主要斷言</td>
          <td>可讀性下降</td>
          <td>讓斷言留在測試本文</td>
      </tr>
  </tbody>
</table>
<p>表格不是越通用越好。測試的第一責任是讓失敗可定位，不是消除所有重複。</p>
<h2 id="執行validation-適合-table-test">【執行】validation 適合 table test</h2>
<p>Validation 的核心特徵是輸入和輸出形狀一致，因此很適合 table-driven test。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestValidateSubscribeRequest</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">request</span> <span class="nx">SubscribeTopicRequest</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wantErr</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;valid topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">request</span><span class="p">:</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;empty topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">request</span><span class="p">:</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;blank topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">request</span><span class="p">:</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;   &#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ValidateSubscribeRequest</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">request</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;error = %v, wantErr %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表只問一件事：request 是否有效。它不測 WebSocket connection、不測 hub、不測 repository，因此案例可以保持簡潔。</p>
<h2 id="執行狀態轉移也適合-table-test">【執行】狀態轉移也適合 table test</h2>
<p>狀態轉移的核心特徵是輸入狀態、事件、期待輸出狀態。只要流程一致，就適合 table-driven test。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestJobTransition</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">current</span> <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">event</span>   <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">want</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">wantErr</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;pending starts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobPending</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">want</span><span class="p">:</span>    <span class="nx">JobRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;running finishes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobFinished</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">want</span><span class="p">:</span>    <span class="nx">JobSucceeded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;finished cannot start again&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobSucceeded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">Transition</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">current</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;error = %v, wantErr %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %s, want %s&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表的欄位都服務同一個行為：job status transition。若未來要測 repository 寫入失敗，應另開測試，不要塞進這張表。</p>
<h2 id="判讀不同-setup-應拆成不同測試">【判讀】不同 setup 應拆成不同測試</h2>
<p>測試拆分的核心原則是 setup 不同，通常就不是同一張表。HTTP validation、repository error、client queue full 都需要不同環境。</p>
<p>較清楚的拆法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeValidation</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="c1">// 只測 request validation</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeAddsTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// 只測成功訂閱後 client state</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeReturnsErrorWhenClientQueueFull</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1">// 只測 send buffer 滿載時的錯誤語意</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些測試可能有少量重複，但每個測試的失敗原因更清楚。測試重複一點可以接受；測試意圖混在一起會讓維護成本更高。</p>
<h2 id="策略helper-只包樣板不包判斷">【策略】helper 只包樣板，不包判斷</h2>
<p>Test helper 的核心責任是降低重複 setup，不應隱藏主要斷言。讀者應能在測試本文看到這個測試到底在驗證什麼。</p>
<p>可以包的樣板：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">value</span> <span class="kt">any</span><span class="p">)</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;marshal json: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不建議包成這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">assertSubscribeScenario</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">tt</span> <span class="nx">subscribeScenario</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1">// setup HTTP, setup WebSocket, setup repository,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// execute action, check response, check logs, check events</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>後者把測試主要邏輯藏進 helper。表格看起來短，但讀者必須跳到 helper 才知道每個欄位如何影響流程。</p>
<h2 id="執行子測試名稱要描述情境">【執行】子測試名稱要描述情境</h2>
<p>子測試名稱的核心作用是讓失敗輸出可定位。名稱應描述情境，不應只寫編號或重複函式名稱。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// ...</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;missing topic&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;unknown action&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;queue full returns unavailable&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>go test</code> 輸出會包含 <code>TestValidateSubscribeRequest/missing_topic</code> 這類資訊。當 CI 失敗時，讀者能先知道哪個情境壞了，再看 got/want 差異。</p>
<p>命名應該描述輸入情境或規則，不需要寫成完整句子，也不要只寫 <code>case 1</code>。</p>
<h2 id="測試table-test-本身也要保持可讀">【測試】table test 本身也要保持可讀</h2>
<p>Table-driven test 的核心完成標準是讀者能掃過表格就理解規則。若必須讀整個 loop 才懂欄位意義，表格設計就不夠清楚。</p>
<p>自檢問題：</p>
<ul>
<li>這張表是否只測一個行為？</li>
<li>每個欄位是否幾乎每個案例都用得到？</li>
<li>測試 loop 裡是否有大量條件分支？</li>
<li>子測試名稱是否能定位失敗情境？</li>
<li>got/want 斷言是否直接留在測試本文？</li>
</ul>
<p>任一題答否，先考慮拆測試，而不是加更多欄位。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一行為的多組資料案例；property-based、fuzz 與 snapshot 測試，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">Go 進階：CI、fuzz、load test 與 chaos testing</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 test case 設計、handler boundary 與 command 驗證；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Table-driven test 適合同一個行為的多組資料，不適合混合多種 setup 與斷言。欄位膨脹、loop 裡大量 <code>if tt...</code>、helper 隱藏主要判斷，都是拆表訊號。好的測試表讓案例更清楚，而不是把測試變成迷你框架。</p>
]]></content:encoded></item><item><title>6.4 版本偵測與 feature gate</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/feature-gate/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/feature-gate/</guid><description>&lt;p>Feature gate 的核心目標是在外部能力、部署環境或版本不同時，讓服務保留可預期行為。它明確管理功能何時啟用、關閉時如何降級、錯誤時如何回報。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 config struct 集中載入 feature gate&lt;/li>
&lt;li>把外部版本偵測轉成 capability&lt;/li>
&lt;li>為 gate 關閉時定義降級、回錯或延後處理策略&lt;/li>
&lt;li>避免在程式各處直接讀環境變數&lt;/li>
&lt;li>同時測試 feature 開與關兩條路徑&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察新功能上線需要可控行為">【觀察】新功能上線需要可控行為&lt;/h2>
&lt;p>Feature gate 的核心需求來自生產環境差異。新功能可能只在部分部署環境可用，外部依賴可能版本不同，某些診斷入口只應在內網啟用，某些即時能力需要先灰度。&lt;/p>
&lt;p>沒有 gate 時常見問題：&lt;/p>
&lt;ul>
&lt;li>新功能只能一次性全開或全關。&lt;/li>
&lt;li>部署環境不支援時服務直接失敗。&lt;/li>
&lt;li>測試只能覆蓋預設路徑。&lt;/li>
&lt;li>問題發生時無法快速降級。&lt;/li>
&lt;li>程式各處用環境變數判斷，行為難以推理。&lt;/li>
&lt;/ul>
&lt;p>Feature gate 的目的是讓行為決策集中、可測、可回滾。&lt;/p>
&lt;h2 id="判讀feature-gate-是行為合約">【判讀】feature gate 是行為合約&lt;/h2>
&lt;p>Feature gate 的核心語意是控制某段行為是否啟用，以及未啟用時系統要做什麼。它不只是 &lt;code>if&lt;/code>，而是一個操作合約。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Features&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">RealtimePush&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Diagnostics&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Pprof&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>開關名稱應描述功能，而不是描述臨時任務。&lt;code>RealtimePush&lt;/code> 比 &lt;code>NewCode&lt;/code> 更能長期維護；&lt;code>Diagnostics&lt;/code> 比 &lt;code>DebugStuff&lt;/code> 更清楚。&lt;/p>
&lt;p>Gate 應在應用啟動時集中載入，再傳給需要的元件。不要在程式各處反覆直接讀環境變數，否則測試與推理都會變困難。&lt;/p>
&lt;h2 id="執行集中載入-feature-config">【執行】集中載入 feature config&lt;/h2>
&lt;p>Feature config 的核心責任是把環境變數、設定檔或啟動參數轉成明確資料。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">LoadFeaturesFromEnv&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">Features&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Features&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">RealtimePush&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;FEATURE_REALTIME_PUSH&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Diagnostics&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_DIAGNOSTICS&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Pprof&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_PPROF&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>組裝時傳入元件：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">features&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">LoadFeaturesFromEnv&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">mux&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServeMux&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">RegisterDiagnostics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mux&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Diagnostics&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">publisher&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewPublisher&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">PublisherConfig&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">RealtimeEnabled&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RealtimePush&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">publisher&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這樣功能測試可以直接建構 &lt;code>Features&lt;/code>，不必依賴全域環境變數。環境變數解析只需要在 &lt;code>LoadFeaturesFromEnv&lt;/code> 的測試中覆蓋。&lt;/p>
&lt;h2 id="判讀版本偵測要轉成能力">【判讀】版本偵測要轉成能力&lt;/h2>
&lt;p>版本偵測的核心原則是不要讓整個程式到處比較版本字串。應把外部版本轉成 capability，內部只判斷能力。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Capabilities&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">SupportsStreaming&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">SupportsMetadata&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">DetectCapabilities&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">version&lt;/span> &lt;span class="nx">semver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Version&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Capabilities&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Capabilities&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">SupportsStreaming&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">version&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">GTE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">semver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">MustParse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2.0.0&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">SupportsMetadata&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">version&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">GTE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">semver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">MustParse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2.1.0&amp;#34;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>內部程式應寫成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nx">caps&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SupportsStreaming&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nf">useStreaming&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="k">return&lt;/span> &lt;span class="nf">usePolling&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這比到處寫 &lt;code>if version &amp;gt;= ...&lt;/code> 更清楚，也更容易測試。版本字串是外部事實，capability 是內部行為判斷。&lt;/p>
&lt;h2 id="策略gate-關閉時要有降級策略">【策略】gate 關閉時要有降級策略&lt;/h2>
&lt;p>Feature gate 的核心問題是關閉時要做什麼。常見策略包括降級、回錯、隱藏入口、排程稍後處理。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>行為&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback&lt;/a>&lt;/td>
 &lt;td>使用舊流程&lt;/td>
 &lt;td>新能力只是效率改善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>reject&lt;/td>
 &lt;td>回明確錯誤&lt;/td>
 &lt;td>功能沒有安全替代方案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hide&lt;/td>
 &lt;td>不註冊 endpoint 或不顯示入口&lt;/td>
 &lt;td>使用者不應看到該功能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>store for later&lt;/td>
 &lt;td>先保存，稍後處理&lt;/td>
 &lt;td>即時能力暫不可用但資料不能丟&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如即時推送關閉時，可以改成保存待處理資料：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="nx">Publisher&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Publish&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">realtimeEnabled&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">realtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Publish&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SaveForLater&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>降級策略要符合資料語意。不能即時送出不代表可以直接丟掉重要事件。&lt;/p>
&lt;h2 id="執行http-endpoint-可用-gate-控制註冊或行為">【執行】HTTP endpoint 可用 gate 控制註冊或行為&lt;/h2>
&lt;p>HTTP feature gate 的核心選擇是「不註冊 endpoint」或「註冊但回明確錯誤」。兩者語意不同。&lt;/p>
&lt;p>不註冊 endpoint：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Diagnostics&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nf">RegisterDiagnostics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mux&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合診斷入口、內部工具或不希望使用者看見的功能。&lt;/p>
&lt;p>註冊但回錯：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">HandleRealtimeExport&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">features&lt;/span> &lt;span class="nx">Features&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerFunc&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RealtimePush&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;realtime export is disabled&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusNotImplemented&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nf">startRealtimeExport&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合公開 API，讓呼叫端知道功能存在但目前不可用。&lt;/p>
&lt;h2 id="策略gate-不應散落成巢狀-if">【策略】gate 不應散落成巢狀 if&lt;/h2>
&lt;p>Feature gate 的核心維護風險是判斷散落在多層呼叫中，最後沒人知道功能到底何時啟用。&lt;/p></description><content:encoded><![CDATA[<p>Feature gate 的核心目標是在外部能力、部署環境或版本不同時，讓服務保留可預期行為。它明確管理功能何時啟用、關閉時如何降級、錯誤時如何回報。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 config struct 集中載入 feature gate</li>
<li>把外部版本偵測轉成 capability</li>
<li>為 gate 關閉時定義降級、回錯或延後處理策略</li>
<li>避免在程式各處直接讀環境變數</li>
<li>同時測試 feature 開與關兩條路徑</li>
</ol>
<hr>
<h2 id="觀察新功能上線需要可控行為">【觀察】新功能上線需要可控行為</h2>
<p>Feature gate 的核心需求來自生產環境差異。新功能可能只在部分部署環境可用，外部依賴可能版本不同，某些診斷入口只應在內網啟用，某些即時能力需要先灰度。</p>
<p>沒有 gate 時常見問題：</p>
<ul>
<li>新功能只能一次性全開或全關。</li>
<li>部署環境不支援時服務直接失敗。</li>
<li>測試只能覆蓋預設路徑。</li>
<li>問題發生時無法快速降級。</li>
<li>程式各處用環境變數判斷，行為難以推理。</li>
</ul>
<p>Feature gate 的目的是讓行為決策集中、可測、可回滾。</p>
<h2 id="判讀feature-gate-是行為合約">【判讀】feature gate 是行為合約</h2>
<p>Feature gate 的核心語意是控制某段行為是否啟用，以及未啟用時系統要做什麼。它不只是 <code>if</code>，而是一個操作合約。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Features</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">RealtimePush</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Diagnostics</span>  <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Pprof</span>        <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>開關名稱應描述功能，而不是描述臨時任務。<code>RealtimePush</code> 比 <code>NewCode</code> 更能長期維護；<code>Diagnostics</code> 比 <code>DebugStuff</code> 更清楚。</p>
<p>Gate 應在應用啟動時集中載入，再傳給需要的元件。不要在程式各處反覆直接讀環境變數，否則測試與推理都會變困難。</p>
<h2 id="執行集中載入-feature-config">【執行】集中載入 feature config</h2>
<p>Feature config 的核心責任是把環境變數、設定檔或啟動參數轉成明確資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">LoadFeaturesFromEnv</span><span class="p">()</span> <span class="nx">Features</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">Features</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">RealtimePush</span><span class="p">:</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;FEATURE_REALTIME_PUSH&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">Diagnostics</span><span class="p">:</span>  <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_DIAGNOSTICS&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Pprof</span><span class="p">:</span>        <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_PPROF&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>組裝時傳入元件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">features</span> <span class="o">:=</span> <span class="nf">LoadFeaturesFromEnv</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">mux</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewServeMux</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">RegisterDiagnostics</span><span class="p">(</span><span class="nx">mux</span><span class="p">,</span> <span class="nx">features</span><span class="p">.</span><span class="nx">Diagnostics</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">publisher</span> <span class="o">:=</span> <span class="nf">NewPublisher</span><span class="p">(</span><span class="nx">PublisherConfig</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">RealtimeEnabled</span><span class="p">:</span> <span class="nx">features</span><span class="p">.</span><span class="nx">RealtimePush</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">publisher</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣功能測試可以直接建構 <code>Features</code>，不必依賴全域環境變數。環境變數解析只需要在 <code>LoadFeaturesFromEnv</code> 的測試中覆蓋。</p>
<h2 id="判讀版本偵測要轉成能力">【判讀】版本偵測要轉成能力</h2>
<p>版本偵測的核心原則是不要讓整個程式到處比較版本字串。應把外部版本轉成 capability，內部只判斷能力。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Capabilities</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">SupportsStreaming</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">SupportsMetadata</span>  <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">DetectCapabilities</span><span class="p">(</span><span class="nx">version</span> <span class="nx">semver</span><span class="p">.</span><span class="nx">Version</span><span class="p">)</span> <span class="nx">Capabilities</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="nx">Capabilities</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SupportsStreaming</span><span class="p">:</span> <span class="nx">version</span><span class="p">.</span><span class="nf">GTE</span><span class="p">(</span><span class="nx">semver</span><span class="p">.</span><span class="nf">MustParse</span><span class="p">(</span><span class="s">&#34;2.0.0&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SupportsMetadata</span><span class="p">:</span>  <span class="nx">version</span><span class="p">.</span><span class="nf">GTE</span><span class="p">(</span><span class="nx">semver</span><span class="p">.</span><span class="nf">MustParse</span><span class="p">(</span><span class="s">&#34;2.1.0&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>內部程式應寫成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nx">caps</span><span class="p">.</span><span class="nx">SupportsStreaming</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nf">useStreaming</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">return</span> <span class="nf">usePolling</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span></span></span></code></pre></div><p>這比到處寫 <code>if version &gt;= ...</code> 更清楚，也更容易測試。版本字串是外部事實，capability 是內部行為判斷。</p>
<h2 id="策略gate-關閉時要有降級策略">【策略】gate 關閉時要有降級策略</h2>
<p>Feature gate 的核心問題是關閉時要做什麼。常見策略包括降級、回錯、隱藏入口、排程稍後處理。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>行為</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a></td>
          <td>使用舊流程</td>
          <td>新能力只是效率改善</td>
      </tr>
      <tr>
          <td>reject</td>
          <td>回明確錯誤</td>
          <td>功能沒有安全替代方案</td>
      </tr>
      <tr>
          <td>hide</td>
          <td>不註冊 endpoint 或不顯示入口</td>
          <td>使用者不應看到該功能</td>
      </tr>
      <tr>
          <td>store for later</td>
          <td>先保存，稍後處理</td>
          <td>即時能力暫不可用但資料不能丟</td>
      </tr>
  </tbody>
</table>
<p>例如即時推送關閉時，可以改成保存待處理資料：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="nx">Publisher</span><span class="p">)</span> <span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">realtimeEnabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">realtime</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">SaveForLater</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>降級策略要符合資料語意。不能即時送出不代表可以直接丟掉重要事件。</p>
<h2 id="執行http-endpoint-可用-gate-控制註冊或行為">【執行】HTTP endpoint 可用 gate 控制註冊或行為</h2>
<p>HTTP feature gate 的核心選擇是「不註冊 endpoint」或「註冊但回明確錯誤」。兩者語意不同。</p>
<p>不註冊 endpoint：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nx">features</span><span class="p">.</span><span class="nx">Diagnostics</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">RegisterDiagnostics</span><span class="p">(</span><span class="nx">mux</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>適合診斷入口、內部工具或不希望使用者看見的功能。</p>
<p>註冊但回錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">HandleRealtimeExport</span><span class="p">(</span><span class="nx">features</span> <span class="nx">Features</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">HandlerFunc</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">features</span><span class="p">.</span><span class="nx">RealtimePush</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;realtime export is disabled&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusNotImplemented</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nf">startRealtimeExport</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>適合公開 API，讓呼叫端知道功能存在但目前不可用。</p>
<h2 id="策略gate-不應散落成巢狀-if">【策略】gate 不應散落成巢狀 if</h2>
<p>Feature gate 的核心維護風險是判斷散落在多層呼叫中，最後沒人知道功能到底何時啟用。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;FEATURE_REALTIME_PUSH&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">version</span> <span class="o">&gt;=</span> <span class="s">&#34;2.0.0&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">if</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Enabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="c1">// ...</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>較清楚的做法是先組出 decision：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">RealtimeDecision</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Enabled</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Reason</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">DecideRealtime</span><span class="p">(</span><span class="nx">features</span> <span class="nx">Features</span><span class="p">,</span> <span class="nx">caps</span> <span class="nx">Capabilities</span><span class="p">)</span> <span class="nx">RealtimeDecision</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">features</span><span class="p">.</span><span class="nx">RealtimePush</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">RealtimeDecision</span><span class="p">{</span><span class="nx">Enabled</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;feature_disabled&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">caps</span><span class="p">.</span><span class="nx">SupportsStreaming</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">RealtimeDecision</span><span class="p">{</span><span class="nx">Enabled</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;streaming_not_supported&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nx">RealtimeDecision</span><span class="p">{</span><span class="nx">Enabled</span><span class="p">:</span> <span class="kc">true</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Decision 物件讓 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、測試與錯誤回應都能使用相同 reason。</p>
<h2 id="執行log-要記錄-gate-decision">【執行】log 要記錄 gate decision</h2>
<p>Feature gate 的核心操作需求是知道功能為何啟用或關閉。當 gate 影響行為時，應記錄穩定 reason。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">decision</span> <span class="o">:=</span> <span class="nf">DecideRealtime</span><span class="p">(</span><span class="nx">features</span><span class="p">,</span> <span class="nx">caps</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;realtime decision&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;feature&#34;</span><span class="p">,</span> <span class="s">&#34;realtime_push&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;enabled&#34;</span><span class="p">,</span> <span class="nx">decision</span><span class="p">.</span><span class="nx">Enabled</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="nx">decision</span><span class="p">.</span><span class="nx">Reason</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這能回答「功能為什麼沒有走即時推送」這類問題。Reason 應是小集合，不要塞完整錯誤字串。</p>
<h2 id="測試開與關兩條路徑都要測">【測試】開與關兩條路徑都要測</h2>
<p>Feature gate 測試的核心規則是同時測啟用與停用路徑。只測預設值很容易讓另一條路徑壞掉。</p>
<p>停用路徑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHandleRealtimeExportFeatureDisabled</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/export&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">handler</span> <span class="o">:=</span> <span class="nf">HandleRealtimeExport</span><span class="p">(</span><span class="nx">Features</span><span class="p">{</span><span class="nx">RealtimePush</span><span class="p">:</span> <span class="kc">false</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">handler</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusNotImplemented</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusNotImplemented</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>啟用路徑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDecideRealtimeEnabled</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">decision</span> <span class="o">:=</span> <span class="nf">DecideRealtime</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">Features</span><span class="p">{</span><span class="nx">RealtimePush</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Capabilities</span><span class="p">{</span><span class="nx">SupportsStreaming</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">decision</span><span class="p">.</span><span class="nx">Enabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;realtime should be enabled, reason %q&#34;</span><span class="p">,</span> <span class="nx">decision</span><span class="p">.</span><span class="nx">Reason</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>環境變數解析應單獨測 <code>LoadFeaturesFromEnv</code>。功能測試應直接傳入 <code>Features</code>，不要依賴全域環境狀態。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理服務內部的 gate 行為邊界；遠端 <a href="/blog/backend/knowledge-cards/feature-flag/" data-link-title="Feature Flag" data-link-desc="說明如何用可動態開關控制功能曝光與風險">feature flag</a> 平台與灰度流程，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 composition root、handler boundary 與 runtime gate；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Feature gate 是生產操作工具，也是程式設計邊界。好的 gate 會集中載入、轉成 capability、定義降級策略、輸出穩定 reason，並同時測試開與關兩條路徑。它控制的是行為合約，不只是把新程式碼藏在 <code>if</code> 後面。</p>
]]></content:encoded></item><item><title>7.4 Observability pipeline、metrics 與 tracing</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/</guid><description>&lt;p>Observability pipeline 的核心責任是把服務訊號整理成可查詢、可聚合、可關聯的診斷資料。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">Log schema&lt;/a> 描述單次事件，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 描述趨勢，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">trace context&lt;/a> 描述跨元件路徑，profile 描述 runtime 成本；它們的責任不同，但應使用一致的識別欄位串起來。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、metric、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a> 與 profile 各自回答什麼問題&lt;/li>
&lt;li>設計穩定的 correlation 欄位&lt;/li>
&lt;li>讓 Go 服務輸出適合聚合的診斷訊號&lt;/li>
&lt;li>在產生端控制敏感資料進入觀測管線&lt;/li>
&lt;li>了解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/alert/" data-link-title="Alert" data-link-desc="說明 alert 如何把需要處理的服務症狀轉成可行動通知">alert&lt;/a> 為什麼需要依賴穩定欄位&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go 入門：log/slog：結構化日誌&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sli-slo/" data-link-title="SLI / SLO" data-link-desc="說明服務品質指標與服務品質目標如何連接產品承諾">Backend：SLI / SLO&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metric-cardinality/" data-link-title="Metric Cardinality" data-link-desc="說明 metric label 組合數量如何影響觀測成本與查詢穩定性">Backend：Metric Cardinality&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/alert-runbook/" data-link-title="Alert Runbook" data-link-desc="說明告警如何連到可執行的排障與恢復流程">Backend：Alert Runbook&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Log、metric、trace、profile 分別回答哪些問題。&lt;/li>
&lt;li>&lt;code>request_id&lt;/code>、&lt;code>event_id&lt;/code>、&lt;code>trace_id&lt;/code>、&lt;code>span_id&lt;/code> 與 &lt;code>correlation_id&lt;/code> 如何分工。&lt;/li>
&lt;li>OpenTelemetry 導入時，Go 程式碼應保留哪些清楚邊界。&lt;/li>
&lt;li>Sensitive data policy 如何套用到 log、trace attribute 與 error event。&lt;/li>
&lt;li>Dashboard 與 alert 應依賴穩定欄位，讓查詢與告警規則可以被重複執行。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察診斷資料要先可關聯再談漂亮">【觀察】診斷資料要先可關聯，再談漂亮&lt;/h2>
&lt;p>Observability pipeline 的第一個要求是關聯能力。Log、metric、trace 的格式可以各自精緻，但欄位需要對齊，才能把同一筆請求、同一個事件、同一條 goroutine 路徑串起來。&lt;/p>
&lt;p>通常會先建立幾個穩定欄位：&lt;/p>
&lt;ul>
&lt;li>request_id&lt;/li>
&lt;li>event_id&lt;/li>
&lt;li>trace_id&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/span/" data-link-title="Span" data-link-desc="說明 trace 中一段工作如何記錄耗時、狀態與關聯">span&lt;/a>_id&lt;/li>
&lt;li>user_id 或 tenant_id&lt;/li>
&lt;/ul>
&lt;h2 id="判讀不同訊號回答不同問題">【判讀】不同訊號回答不同問題&lt;/h2>
&lt;ul>
&lt;li>log：這次發生了什麼。&lt;/li>
&lt;li>metric：這類事件發生得多不多、快不快、慢不慢。&lt;/li>
&lt;li>trace：它在多個元件之間怎麼走。&lt;/li>
&lt;li>profile：CPU、記憶體、goroutine 與等待成本落在哪裡。&lt;/li>
&lt;/ul>
&lt;p>如果某個問題要靠自由文字 log 去猜，通常代表欄位設計還不夠穩。&lt;/p>
&lt;h2 id="策略敏感資料要在產生端就攔住">【策略】敏感資料要在產生端就攔住&lt;/h2>
&lt;p>敏感資料政策應在產生端執行。Go 服務應該在輸出 log 或 trace attribute 前就決定哪些資訊可以外送。&lt;/p>
&lt;p>常見要注意的資料有：&lt;/p>
&lt;ul>
&lt;li>token&lt;/li>
&lt;li>email&lt;/li>
&lt;li>身分證號&lt;/li>
&lt;li>raw payload&lt;/li>
&lt;li>內部路徑與配置&lt;/li>
&lt;/ul>
&lt;h2 id="執行結構化-log-是-pipeline-的起點">【執行】結構化 log 是 pipeline 的起點&lt;/h2>
&lt;p>當 Go 服務使用結構化 log 時，最重要的是欄位穩定與語意清楚。這些 log 後面可能會被：&lt;/p>
&lt;ul>
&lt;li>集中式 log system 搜尋&lt;/li>
&lt;li>metric extraction 轉成趨勢指標&lt;/li>
&lt;li>alert rule 用來偵測異常&lt;/li>
&lt;/ul>
&lt;p>所以 log 欄位要維持穩定命名，分類資訊要放在結構化欄位裡。&lt;/p></description><content:encoded><![CDATA[<p>Observability pipeline 的核心責任是把服務訊號整理成可查詢、可聚合、可關聯的診斷資料。<a href="/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">Log schema</a> 描述單次事件，<a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 描述趨勢，<a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">trace context</a> 描述跨元件路徑，profile 描述 runtime 成本；它們的責任不同，但應使用一致的識別欄位串起來。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、metric、<a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a> 與 profile 各自回答什麼問題</li>
<li>設計穩定的 correlation 欄位</li>
<li>讓 Go 服務輸出適合聚合的診斷訊號</li>
<li>在產生端控制敏感資料進入觀測管線</li>
<li>了解 <a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a> 與 <a href="/blog/backend/knowledge-cards/alert/" data-link-title="Alert" data-link-desc="說明 alert 如何把需要處理的服務症狀轉成可行動通知">alert</a> 為什麼需要依賴穩定欄位</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go 入門：log/slog：結構化日誌</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
<li><a href="/blog/backend/knowledge-cards/sli-slo/" data-link-title="SLI / SLO" data-link-desc="說明服務品質指標與服務品質目標如何連接產品承諾">Backend：SLI / SLO</a></li>
<li><a href="/blog/backend/knowledge-cards/metric-cardinality/" data-link-title="Metric Cardinality" data-link-desc="說明 metric label 組合數量如何影響觀測成本與查詢穩定性">Backend：Metric Cardinality</a></li>
<li><a href="/blog/backend/knowledge-cards/alert-runbook/" data-link-title="Alert Runbook" data-link-desc="說明告警如何連到可執行的排障與恢復流程">Backend：Alert Runbook</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Log、metric、trace、profile 分別回答哪些問題。</li>
<li><code>request_id</code>、<code>event_id</code>、<code>trace_id</code>、<code>span_id</code> 與 <code>correlation_id</code> 如何分工。</li>
<li>OpenTelemetry 導入時，Go 程式碼應保留哪些清楚邊界。</li>
<li>Sensitive data policy 如何套用到 log、trace attribute 與 error event。</li>
<li>Dashboard 與 alert 應依賴穩定欄位，讓查詢與告警規則可以被重複執行。</li>
</ol>
<h2 id="觀察診斷資料要先可關聯再談漂亮">【觀察】診斷資料要先可關聯，再談漂亮</h2>
<p>Observability pipeline 的第一個要求是關聯能力。Log、metric、trace 的格式可以各自精緻，但欄位需要對齊，才能把同一筆請求、同一個事件、同一條 goroutine 路徑串起來。</p>
<p>通常會先建立幾個穩定欄位：</p>
<ul>
<li>request_id</li>
<li>event_id</li>
<li>trace_id</li>
<li><a href="/blog/backend/knowledge-cards/span/" data-link-title="Span" data-link-desc="說明 trace 中一段工作如何記錄耗時、狀態與關聯">span</a>_id</li>
<li>user_id 或 tenant_id</li>
</ul>
<h2 id="判讀不同訊號回答不同問題">【判讀】不同訊號回答不同問題</h2>
<ul>
<li>log：這次發生了什麼。</li>
<li>metric：這類事件發生得多不多、快不快、慢不慢。</li>
<li>trace：它在多個元件之間怎麼走。</li>
<li>profile：CPU、記憶體、goroutine 與等待成本落在哪裡。</li>
</ul>
<p>如果某個問題要靠自由文字 log 去猜，通常代表欄位設計還不夠穩。</p>
<h2 id="策略敏感資料要在產生端就攔住">【策略】敏感資料要在產生端就攔住</h2>
<p>敏感資料政策應在產生端執行。Go 服務應該在輸出 log 或 trace attribute 前就決定哪些資訊可以外送。</p>
<p>常見要注意的資料有：</p>
<ul>
<li>token</li>
<li>email</li>
<li>身分證號</li>
<li>raw payload</li>
<li>內部路徑與配置</li>
</ul>
<h2 id="執行結構化-log-是-pipeline-的起點">【執行】結構化 log 是 pipeline 的起點</h2>
<p>當 Go 服務使用結構化 log 時，最重要的是欄位穩定與語意清楚。這些 log 後面可能會被：</p>
<ul>
<li>集中式 log system 搜尋</li>
<li>metric extraction 轉成趨勢指標</li>
<li>alert rule 用來偵測異常</li>
</ul>
<p>所以 log 欄位要維持穩定命名，分類資訊要放在結構化欄位裡。</p>
<h2 id="延伸診斷和容量規劃要串在一起">【延伸】診斷和容量規劃要串在一起</h2>
<p>觀測資料不只是事後排障，也會反過來影響容量規劃與 release 判斷。當你看到 goroutine 數、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> lag、DB latency 或 retry rate 持續變高，就代表系統邊界已經開始吃緊。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會綁定特定 observability SaaS。教材重點會放在 Go 服務如何輸出穩定訊號，讓不同收集平台都能使用。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的結構化日誌與 runtime 診斷；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go：結構化日誌</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：架構邊界與事件系統</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</guid><description>&lt;p>架構邊界的核心目標是讓每個元件只承擔一種責任。事件來源負責接收外部訊號，normalize 階段負責轉成內部事件，processor 負責套用規則，repository 負責保存狀態真相，publisher 負責把結果送出去。&lt;/p>
&lt;p>事件驅動不是把所有東西都丟進 channel。Go 的事件系統需要明確的型別、清楚的擁有者、可測的狀態轉移，以及能在多來源輸入下維持一致的處理流程。&lt;/p>
&lt;p>本模組承接入門篇的 practical 與 refactoring：前面學會新增事件、建立 repository port、拆 handler、整理 domain package；這裡進一步處理「系統開始變大後，事件與狀態如何不失控」。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">4.1&lt;/a>&lt;/td>
 &lt;td>事件來源、處理流程與狀態邊界&lt;/td>
 &lt;td>用邊界拆開 reader、normalizer、processor、repository、publisher&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">4.2&lt;/a>&lt;/td>
 &lt;td>事件去重與語義鍵設計&lt;/td>
 &lt;td>用 domain key、時間窗口與清理策略管理重複事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">4.3&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth&lt;/a>：狀態邊界&lt;/td>
 &lt;td>集中狀態轉移、保護可變資料、設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">4.4&lt;/a>&lt;/td>
 &lt;td>多來源 event 融合&lt;/td>
 &lt;td>把 HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、timer 等來源收斂到同一套 domain event 流程&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的通知與工作處理服務作為範例。服務可能從 HTTP callback、queue message、timer 或檔案 reader 收到事件，最後更新內部狀態並推送通知。&lt;/p>
&lt;p>範例只用來展示 Go 的設計方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 struct 定義穩定的內部事件模型。&lt;/li>
&lt;li>用 interface 表達 reader、repository、publisher 這類能力。&lt;/li>
&lt;li>用 context 傳遞 request lifecycle、取消與逾時。&lt;/li>
&lt;li>用 mutex 或單一 goroutine 保護共享狀態。&lt;/li>
&lt;li>用 package 邊界限制 adapter、application、domain 的依賴方向。&lt;/li>
&lt;li>用 table-driven test 驗證 normalize、dedup 與狀態轉移。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>外部訊號是否應該轉成 domain event&lt;/li>
&lt;li>去重應該使用哪些欄位，哪些欄位不應進入 key&lt;/li>
&lt;li>狀態真相應該由哪個元件擁有&lt;/li>
&lt;li>新事件來源應該新增 adapter，還是修改 processor&lt;/li>
&lt;li>ports/adapters 與 event-driven service 如何在 Go 中自然結合&lt;/li>
&lt;/ol>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>本模組的四章分別處理事件系統的四個核心面向，不建議硬拆成更小的孤立段落。事件來源、去重、狀態真相與多來源融合會互相影響；拆得太碎會讓讀者看不到一筆事件如何從外部輸入走到狀態更新與推送。&lt;/p>
&lt;p>閱讀時可以把四章視為一條路線：&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界&lt;/a>：先建立元件分工。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計&lt;/a>：再定義「同一事件」的語意。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界&lt;/a>：接著決定誰能改狀態。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合&lt;/a>：最後處理 HTTP、queue、timer 等多入口協作。&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不實作完整 message queue、分散式 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 或 event sourcing 平台。這些主題需要更多基礎設施與操作細節；本模組先聚焦 Go 程式內部如何建立清楚的事件與狀態邊界。後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration&lt;/a> 以及 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>架構邊界的核心目標是讓每個元件只承擔一種責任。事件來源負責接收外部訊號，normalize 階段負責轉成內部事件，processor 負責套用規則，repository 負責保存狀態真相，publisher 負責把結果送出去。</p>
<p>事件驅動不是把所有東西都丟進 channel。Go 的事件系統需要明確的型別、清楚的擁有者、可測的狀態轉移，以及能在多來源輸入下維持一致的處理流程。</p>
<p>本模組承接入門篇的 practical 與 refactoring：前面學會新增事件、建立 repository port、拆 handler、整理 domain package；這裡進一步處理「系統開始變大後，事件與狀態如何不失控」。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">4.1</a></td>
          <td>事件來源、處理流程與狀態邊界</td>
          <td>用邊界拆開 reader、normalizer、processor、repository、publisher</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">4.2</a></td>
          <td>事件去重與語義鍵設計</td>
          <td>用 domain key、時間窗口與清理策略管理重複事件</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">4.3</a></td>
          <td><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth</a>：狀態邊界</td>
          <td>集中狀態轉移、保護可變資料、設計 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">4.4</a></td>
          <td>多來源 event 融合</td>
          <td>把 HTTP、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、timer 等來源收斂到同一套 domain event 流程</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的通知與工作處理服務作為範例。服務可能從 HTTP callback、queue message、timer 或檔案 reader 收到事件，最後更新內部狀態並推送通知。</p>
<p>範例只用來展示 Go 的設計方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 struct 定義穩定的內部事件模型。</li>
<li>用 interface 表達 reader、repository、publisher 這類能力。</li>
<li>用 context 傳遞 request lifecycle、取消與逾時。</li>
<li>用 mutex 或單一 goroutine 保護共享狀態。</li>
<li>用 package 邊界限制 adapter、application、domain 的依賴方向。</li>
<li>用 table-driven test 驗證 normalize、dedup 與狀態轉移。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>外部訊號是否應該轉成 domain event</li>
<li>去重應該使用哪些欄位，哪些欄位不應進入 key</li>
<li>狀態真相應該由哪個元件擁有</li>
<li>新事件來源應該新增 adapter，還是修改 processor</li>
<li>ports/adapters 與 event-driven service 如何在 Go 中自然結合</li>
</ol>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>本模組的四章分別處理事件系統的四個核心面向，不建議硬拆成更小的孤立段落。事件來源、去重、狀態真相與多來源融合會互相影響；拆得太碎會讓讀者看不到一筆事件如何從外部輸入走到狀態更新與推送。</p>
<p>閱讀時可以把四章視為一條路線：</p>
<ol>
<li><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>：先建立元件分工。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a>：再定義「同一事件」的語意。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</a>：接著決定誰能改狀態。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合</a>：最後處理 HTTP、queue、timer 等多入口協作。</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不實作完整 message queue、分散式 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 或 event sourcing 平台。這些主題需要更多基礎設施與操作細節；本模組先聚焦 Go 程式內部如何建立清楚的事件與狀態邊界。後續可接 <a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a> 以及 <a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>1.5 bounded worker pool</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/</guid><description>&lt;p>bounded &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 的核心責任是限制同時執行的工作量。goroutine 很便宜，但工作本身可能佔用 CPU、memory、file descriptor、外部 API quota 或資料庫連線；worker pool 讓容量限制成為程式設計的一部分。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些工作量邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go 入門：goroutine：背景工作與服務生命週期&lt;/a>：先理解 goroutine 的啟動和結束方式，才知道 worker pool 為什麼要限制並發數。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go 入門：channel：事件流與 backpressure &lt;/a>：job channel 的容量和阻塞行為，會直接影響 pool 的整體策略。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計&lt;/a>：worker 的停止、排空與關閉，通常都要回到 select loop 來說明。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>：真正跨 process 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、retry 與 dead-letter 行為屬於這裡。&lt;/li>
&lt;/ul>
&lt;h2 id="本章不處理">本章不處理&lt;/h2>
&lt;p>本章先把單一 process 內的工作量上限、停止與排空講清楚；跨 process 的 consumer 與 retry 機制，會放在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理單一 Go process 內的工作量限制。跨 process 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a>、[dead-letter &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go-advanced/backend/knowledge-cards/dead-letter-queue) 與重試政策會放在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是 goroutine、channel 與 select loop；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure &lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go：select：同時等待多種事件&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>bounded <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 的核心責任是限制同時執行的工作量。goroutine 很便宜，但工作本身可能佔用 CPU、memory、file descriptor、外部 API quota 或資料庫連線；worker pool 讓容量限制成為程式設計的一部分。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些工作量邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go 入門：goroutine：背景工作與服務生命週期</a>：先理解 goroutine 的啟動和結束方式，才知道 worker pool 為什麼要限制並發數。</li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go 入門：channel：事件流與 backpressure </a>：job channel 的容量和阻塞行為，會直接影響 pool 的整體策略。</li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a>：worker 的停止、排空與關閉，通常都要回到 select loop 來說明。</li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>：真正跨 process 的 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、retry 與 dead-letter 行為屬於這裡。</li>
</ul>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先把單一 process 內的工作量上限、停止與排空講清楚；跨 process 的 consumer 與 retry 機制，會放在 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理單一 Go process 內的工作量限制。跨 process 的 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a>、[dead-letter <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go-advanced/backend/knowledge-cards/dead-letter-queue) 與重試政策會放在 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine、channel 與 select loop；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
]]></content:encoded></item><item><title>7.5 Kubernetes、systemd 與 load balancer 合約</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/</guid><description>&lt;p>部署平台合約的核心責任是讓 Go 服務的生命週期和外部調度系統對齊。程式內部需要清楚的 context、shutdown &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health / liveness&lt;/a> 與 memory limit；Kubernetes、systemd、load balancer 或雲端平台則決定這些訊號何時被觸發與如何被解讀。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 shutdown、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a> 與 connection draining 的順序&lt;/li>
&lt;li>看懂平台 timeout 對 Go server 的影響&lt;/li>
&lt;li>分辨 health 與 readiness 的不同責任&lt;/li>
&lt;li>把 memory limit 與 Go runtime 的資源管理接在一起&lt;/li>
&lt;li>讓部署平台和程式彼此遵守同一份合約&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go 進階：GC 與 memory limit&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Backend：Graceful Shutdown&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">Backend：Failover&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>SIGTERM、shutdown timeout、readiness false 與 connection draining 的順序。&lt;/li>
&lt;li>Kubernetes &lt;code>terminationGracePeriodSeconds&lt;/code> 與 Go &lt;code>http.Server.Shutdown&lt;/code> 如何配合。&lt;/li>
&lt;li>Load balancer idle timeout 如何影響 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> heartbeat 參數。&lt;/li>
&lt;li>Container memory limit、Go memory limit 與 OOM killer 之間的關係。&lt;/li>
&lt;li>systemd restart policy 與 health endpoint 的責任分工。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察平台會主動改變服務生命週期">【觀察】平台會主動改變服務生命週期&lt;/h2>
&lt;p>Go 程式不會在真空裡執行。Kubernetes、systemd、load balancer、container runtime 都會影響服務何時接新請求、何時開始收尾、何時被強制終止。這表示程式不只要「能跑」，還要能跟平台協調。&lt;/p>
&lt;p>常見的生命週期訊號有：&lt;/p>
&lt;ul>
&lt;li>SIGTERM&lt;/li>
&lt;li>readiness false&lt;/li>
&lt;li>HTTP shutdown&lt;/li>
&lt;li>connection draining&lt;/li>
&lt;li>memory pressure&lt;/li>
&lt;/ul>
&lt;h2 id="判讀health-與-readiness-有不同合約">【判讀】health 與 readiness 有不同合約&lt;/h2>
&lt;p>health 通常表示服務自己還活著，readiness 則表示它是否適合接新流量。&lt;/p>
&lt;ul>
&lt;li>health 可以用來讓平台知道 process 還活著。&lt;/li>
&lt;li>readiness 可以用來讓 load balancer 停止送新請求。&lt;/li>
&lt;/ul>
&lt;p>如果兩者混在一起，部署時就容易出現「服務還沒收尾就被塞新流量」或「其實還能接流量卻被誤判下線」的問題。&lt;/p>
&lt;h2 id="策略shutdown-應該是可預期流程">【策略】shutdown 應該是可預期流程&lt;/h2>
&lt;p>典型的 shutdown 順序是：&lt;/p>
&lt;ol>
&lt;li>接收到停止訊號。&lt;/li>
&lt;li>先把 readiness 關掉。&lt;/li>
&lt;li>停止接新流量。&lt;/li>
&lt;li>讓現有 request / worker / websocket 收尾。&lt;/li>
&lt;li>超時後強制結束。&lt;/li>
&lt;/ol>
&lt;p>這個順序能讓平台有時間把流量移走，也讓應用有時間清理資源。&lt;/p>
&lt;h2 id="執行資源限制要和-runtime-觀念一起看">【執行】資源限制要和 runtime 觀念一起看&lt;/h2>
&lt;p>container memory limit 不只是部署平台的事，也會影響 Go runtime 的行為。當可用記憶體變少時，應用更需要控制：&lt;/p>
&lt;ul>
&lt;li>goroutine 數量&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 大小&lt;/li>
&lt;li>cache 體積&lt;/li>
&lt;li>in-memory &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 長度&lt;/li>
&lt;/ul>
&lt;p>如果這些沒有限制，平台的 OOM killer 可能會比你的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 先來。&lt;/p></description><content:encoded><![CDATA[<p>部署平台合約的核心責任是讓 Go 服務的生命週期和外部調度系統對齊。程式內部需要清楚的 context、shutdown <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、<a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health / liveness</a> 與 memory limit；Kubernetes、systemd、load balancer 或雲端平台則決定這些訊號何時被觸發與如何被解讀。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 shutdown、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a> 與 connection draining 的順序</li>
<li>看懂平台 timeout 對 Go server 的影響</li>
<li>分辨 health 與 readiness 的不同責任</li>
<li>把 memory limit 與 Go runtime 的資源管理接在一起</li>
<li>讓部署平台和程式彼此遵守同一份合約</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go 進階：GC 與 memory limit</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Backend：Graceful Shutdown</a></li>
<li><a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">Backend：Failover</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>SIGTERM、shutdown timeout、readiness false 與 connection draining 的順序。</li>
<li>Kubernetes <code>terminationGracePeriodSeconds</code> 與 Go <code>http.Server.Shutdown</code> 如何配合。</li>
<li>Load balancer idle timeout 如何影響 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> heartbeat 參數。</li>
<li>Container memory limit、Go memory limit 與 OOM killer 之間的關係。</li>
<li>systemd restart policy 與 health endpoint 的責任分工。</li>
</ol>
<h2 id="觀察平台會主動改變服務生命週期">【觀察】平台會主動改變服務生命週期</h2>
<p>Go 程式不會在真空裡執行。Kubernetes、systemd、load balancer、container runtime 都會影響服務何時接新請求、何時開始收尾、何時被強制終止。這表示程式不只要「能跑」，還要能跟平台協調。</p>
<p>常見的生命週期訊號有：</p>
<ul>
<li>SIGTERM</li>
<li>readiness false</li>
<li>HTTP shutdown</li>
<li>connection draining</li>
<li>memory pressure</li>
</ul>
<h2 id="判讀health-與-readiness-有不同合約">【判讀】health 與 readiness 有不同合約</h2>
<p>health 通常表示服務自己還活著，readiness 則表示它是否適合接新流量。</p>
<ul>
<li>health 可以用來讓平台知道 process 還活著。</li>
<li>readiness 可以用來讓 load balancer 停止送新請求。</li>
</ul>
<p>如果兩者混在一起，部署時就容易出現「服務還沒收尾就被塞新流量」或「其實還能接流量卻被誤判下線」的問題。</p>
<h2 id="策略shutdown-應該是可預期流程">【策略】shutdown 應該是可預期流程</h2>
<p>典型的 shutdown 順序是：</p>
<ol>
<li>接收到停止訊號。</li>
<li>先把 readiness 關掉。</li>
<li>停止接新流量。</li>
<li>讓現有 request / worker / websocket 收尾。</li>
<li>超時後強制結束。</li>
</ol>
<p>這個順序能讓平台有時間把流量移走，也讓應用有時間清理資源。</p>
<h2 id="執行資源限制要和-runtime-觀念一起看">【執行】資源限制要和 runtime 觀念一起看</h2>
<p>container memory limit 不只是部署平台的事，也會影響 Go runtime 的行為。當可用記憶體變少時，應用更需要控制：</p>
<ul>
<li>goroutine 數量</li>
<li><a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 大小</li>
<li>cache 體積</li>
<li>in-memory <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 長度</li>
</ul>
<p>如果這些沒有限制，平台的 OOM killer 可能會比你的 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 先來。</p>
<h2 id="延伸平台合約要被測試">【延伸】平台合約要被測試</h2>
<p>部署平台合約需要在測試或預備環境驗證。至少要確認：</p>
<ul>
<li>shutdown 時 request 是否停止接入</li>
<li>worker 是否有機會收尾</li>
<li>WebSocket 是否有 close path</li>
<li>health 與 readiness 是否分工清楚</li>
</ul>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會完整教 Kubernetes 或 systemd 操作。重點是讓 Go 程式設計能清楚暴露平台需要的生命週期訊號。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的 shutdown 與 runtime 限制；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go 進階：GC 與 memory limit</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
</ul>
]]></content:encoded></item><item><title>模組五：測試與可靠性</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/</guid><description>&lt;p>並發服務測試的核心目標是讓時間、連線、goroutine、共享狀態與錯誤路徑變得可重現。只測 happy path 不足以保護長時間運行的 Go 服務；真正需要測的是取消、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、cleanup、data race 與協定邊界。&lt;/p>
&lt;p>本模組承接前面的並發、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 與架構邊界：時間注入讓狀態轉移可測，WebSocket integration test 驗證真實連線互動，race detector 檢查共享狀態，table-driven test 幫助案例保持清楚。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">5.1&lt;/a>&lt;/td>
 &lt;td>時間注入與狀態轉移測試&lt;/td>
 &lt;td>不用 sleep 也能測 timeout、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與狀態轉移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">5.2&lt;/a>&lt;/td>
 &lt;td>WebSocket integration test&lt;/td>
 &lt;td>用真實 test server 驗證 client/server 協定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">5.3&lt;/a>&lt;/td>
 &lt;td>race condition 檢查&lt;/td>
 &lt;td>用 &lt;code>go test -race&lt;/code> 搭配併發測試找資料競爭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">5.4&lt;/a>&lt;/td>
 &lt;td>table-driven test 的設計邊界&lt;/td>
 &lt;td>讓測試表只描述單一行為維度&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 job 狀態轉移、WebSocket subscribe flow、client cleanup、repository concurrent access 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization。&lt;/p>
&lt;p>範例只用來展示 Go 測試設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>now time.Time&lt;/code> 或 &lt;code>func() time.Time&lt;/code> 控制時間。&lt;/li>
&lt;li>用 &lt;code>httptest.Server&lt;/code> 建立真實 WebSocket integration test。&lt;/li>
&lt;li>用 read/write deadline 避免測試永久卡住。&lt;/li>
&lt;li>用 &lt;code>eventually&lt;/code> helper 等待非同步清理，而不是固定 sleep。&lt;/li>
&lt;li>用 &lt;code>go test -race ./...&lt;/code> 檢查執行到的 data race。&lt;/li>
&lt;li>用小而清楚的 table-driven test 表達同一個行為的多組案例。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪些邏輯應該用純函式測，哪些需要 integration test&lt;/li>
&lt;li>測試裡的時間應該如何注入，而不是等待真實時間&lt;/li>
&lt;li>WebSocket 測試如何避免永久卡住&lt;/li>
&lt;li>race detector 能找什麼，不能證明什麼&lt;/li>
&lt;li>table-driven test 何時該拆成多個測試&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不建立完整測試框架，也不討論大型 CI 平台、壓力測試或 chaos testing。這些主題很重要，但本模組先聚焦單一 Go 服務內最常見、最容易失控的可靠性測試；後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing&lt;/a>。&lt;/p>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 3-4 小時&lt;/p></description><content:encoded><![CDATA[<p>並發服務測試的核心目標是讓時間、連線、goroutine、共享狀態與錯誤路徑變得可重現。只測 happy path 不足以保護長時間運行的 Go 服務；真正需要測的是取消、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、cleanup、data race 與協定邊界。</p>
<p>本模組承接前面的並發、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 與架構邊界：時間注入讓狀態轉移可測，WebSocket integration test 驗證真實連線互動，race detector 檢查共享狀態，table-driven test 幫助案例保持清楚。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">5.1</a></td>
          <td>時間注入與狀態轉移測試</td>
          <td>不用 sleep 也能測 timeout、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與狀態轉移</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">5.2</a></td>
          <td>WebSocket integration test</td>
          <td>用真實 test server 驗證 client/server 協定</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">5.3</a></td>
          <td>race condition 檢查</td>
          <td>用 <code>go test -race</code> 搭配併發測試找資料競爭</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">5.4</a></td>
          <td>table-driven test 的設計邊界</td>
          <td>讓測試表只描述單一行為維度</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 job 狀態轉移、WebSocket subscribe flow、client cleanup、repository concurrent access 與 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization。</p>
<p>範例只用來展示 Go 測試設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>now time.Time</code> 或 <code>func() time.Time</code> 控制時間。</li>
<li>用 <code>httptest.Server</code> 建立真實 WebSocket integration test。</li>
<li>用 read/write deadline 避免測試永久卡住。</li>
<li>用 <code>eventually</code> helper 等待非同步清理，而不是固定 sleep。</li>
<li>用 <code>go test -race ./...</code> 檢查執行到的 data race。</li>
<li>用小而清楚的 table-driven test 表達同一個行為的多組案例。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪些邏輯應該用純函式測，哪些需要 integration test</li>
<li>測試裡的時間應該如何注入，而不是等待真實時間</li>
<li>WebSocket 測試如何避免永久卡住</li>
<li>race detector 能找什麼，不能證明什麼</li>
<li>table-driven test 何時該拆成多個測試</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不建立完整測試框架，也不討論大型 CI 平台、壓力測試或 chaos testing。這些主題很重要，但本模組先聚焦單一 Go 服務內最常見、最容易失控的可靠性測試；後續可接 <a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>1.6 rate limiting 與 backpressure</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/</guid><description>&lt;p>rate limiting 的核心責任是把過量輸入轉成可預期的服務行為。服務可以等待、排隊、拒絕、降級或取樣，但這些策略應由程式明確決定，而不是讓 goroutine、channel 或 memory 自行失控。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go 入門：channel：事件流與 backpressure &lt;/a>：先理解 channel &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 和等待機制，才知道限流不是只有一種做法。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略&lt;/a>：當系統必須在滿載時做出明確選擇，這裡會處理 drop、覆蓋與回錯的語意。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>：跨節點流量治理、gateway 與 quota，屬於平台層責任。&lt;/li>
&lt;/ul>
&lt;h2 id="本章不處理">本章不處理&lt;/h2>
&lt;p>本章先處理單一 process 內的輸入控制與 backpressure ；跨節點流量治理、gateway 與 quota 的平台責任，會放在 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go process 內的速率控制。API gateway、load balancer、service mesh、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> quota 與跨節點流量治理會放在 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是 channel backpressure 、non-blocking send 與 worker capacity；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure &lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go：非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>rate limiting 的核心責任是把過量輸入轉成可預期的服務行為。服務可以等待、排隊、拒絕、降級或取樣，但這些策略應由程式明確決定，而不是讓 goroutine、channel 或 memory 自行失控。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go 入門：channel：事件流與 backpressure </a>：先理解 channel <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 和等待機制，才知道限流不是只有一種做法。</li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略</a>：當系統必須在滿載時做出明確選擇，這裡會處理 drop、覆蓋與回錯的語意。</li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>：跨節點流量治理、gateway 與 quota，屬於平台層責任。</li>
</ul>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 process 內的輸入控制與 backpressure ；跨節點流量治理、gateway 與 quota 的平台責任，會放在 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go process 內的速率控制。API gateway、load balancer、service mesh、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> quota 與跨節點流量治理會放在 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel backpressure 、non-blocking send 與 worker capacity；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool</a></li>
</ul>
]]></content:encoded></item><item><title>7.6 CI、fuzz、load test 與 chaos testing</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/</guid><description>&lt;p>可靠性驗證流程的核心責任是讓不同層級的測試回答不同風險。Unit test 驗證規則，integration test 驗證協定協作，race test 檢查資料競爭，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fuzz-test/" data-link-title="Fuzz Test" data-link-desc="說明用隨機與變異輸入驗證解析器與邊界處理健壯性">fuzz test&lt;/a> 尋找輸入邊界，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test&lt;/a> 驗證容量，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/chaos-test/" data-link-title="Chaos Test" data-link-desc="說明透過受控故障注入驗證系統在異常條件下的恢復能力">chaos test&lt;/a> 驗證失敗復原。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨不同測試層級各自要防的風險&lt;/li>
&lt;li>把 race、fuzz、load 與 chaos 放到合適的流程裡&lt;/li>
&lt;li>設計能回饋容量規劃的驗證流程&lt;/li>
&lt;li>不把端到端測試當成萬能答案&lt;/li>
&lt;li>讓測試結果回到 deployment 與 runtime 邊界&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>CI 中哪些測試應每次執行，哪些可以排程或合併前執行。&lt;/li>
&lt;li>Fuzzing 適合驗證 parser、normalizer 與 protocol decoder 的哪些邊界。&lt;/li>
&lt;li>Load test 如何設定 client 數、message rate、payload size 與觀測指標。&lt;/li>
&lt;li>Chaos testing 如何模擬 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 斷線、資料庫延遲、server shutdown 與網路抖動。&lt;/li>
&lt;li>測試結果如何回饋到 capacity planning 與 feature gate。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察不同測試層級回答不同問題">【觀察】不同測試層級回答不同問題&lt;/h2>
&lt;p>可靠性驗證最怕的錯誤，是把所有測試都塞成一種樣子。不同層級應該分工：&lt;/p>
&lt;ul>
&lt;li>unit test：規則有沒有寫對&lt;/li>
&lt;li>integration test：協定與元件有沒有接對&lt;/li>
&lt;li>race test：並發邊界有沒有資料競爭&lt;/li>
&lt;li>fuzz test：輸入邊界有沒有漏掉&lt;/li>
&lt;li>load test：容量與延遲是否能接受&lt;/li>
&lt;li>chaos test：失敗發生時系統能不能復原&lt;/li>
&lt;/ul>
&lt;h2 id="判讀race-test-是輔助檢查">【判讀】race test 是輔助檢查&lt;/h2>
&lt;p>&lt;code>go test -race&lt;/code> 能抓出實際跑到的資料競爭，但它不是正確性保證。真正的重點仍然是：&lt;/p>
&lt;ul>
&lt;li>state owner 是誰&lt;/li>
&lt;li>哪些資料需要 lock&lt;/li>
&lt;li>哪些資料應該只讓單一 goroutine 擁有&lt;/li>
&lt;li>哪些資料應該複製而不是共享&lt;/li>
&lt;/ul>
&lt;h2 id="策略load-test-的輸出要能回到容量判斷">【策略】load test 的輸出要能回到容量判斷&lt;/h2>
&lt;p>load test 不應只是跑出一個數字，還要能回答：&lt;/p>
&lt;ul>
&lt;li>哪個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 開始變長&lt;/li>
&lt;li>哪個 DB &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a> 開始飽和&lt;/li>
&lt;li>哪種 message rate 會讓 latency 明顯上升&lt;/li>
&lt;li>哪個 memory curve 表示需要調整 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 或 GC 參數&lt;/li>
&lt;/ul>
&lt;p>如果沒有這些觀察點，壓測結果就很難轉成實際修正。&lt;/p>
&lt;h2 id="執行chaos-test-應該模擬真實失敗">【執行】chaos test 應該模擬真實失敗&lt;/h2>
&lt;p>chaos test 的重點是模擬真實世界常見的失敗：&lt;/p>
&lt;ul>
&lt;li>broker 暫時不可用&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 延遲上升&lt;/li>
&lt;li>shutdown 中斷流量&lt;/li>
&lt;li>網路抖動或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這些情境應該回到 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a>、retry、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 設計。&lt;/p></description><content:encoded><![CDATA[<p>可靠性驗證流程的核心責任是讓不同層級的測試回答不同風險。Unit test 驗證規則，integration test 驗證協定協作，race test 檢查資料競爭，<a href="/blog/backend/knowledge-cards/fuzz-test/" data-link-title="Fuzz Test" data-link-desc="說明用隨機與變異輸入驗證解析器與邊界處理健壯性">fuzz test</a> 尋找輸入邊界，<a href="/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test</a> 驗證容量，<a href="/blog/backend/knowledge-cards/chaos-test/" data-link-title="Chaos Test" data-link-desc="說明透過受控故障注入驗證系統在異常條件下的恢復能力">chaos test</a> 驗證失敗復原。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨不同測試層級各自要防的風險</li>
<li>把 race、fuzz、load 與 chaos 放到合適的流程裡</li>
<li>設計能回饋容量規劃的驗證流程</li>
<li>不把端到端測試當成萬能答案</li>
<li>讓測試結果回到 deployment 與 runtime 邊界</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>CI 中哪些測試應每次執行，哪些可以排程或合併前執行。</li>
<li>Fuzzing 適合驗證 parser、normalizer 與 protocol decoder 的哪些邊界。</li>
<li>Load test 如何設定 client 數、message rate、payload size 與觀測指標。</li>
<li>Chaos testing 如何模擬 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 斷線、資料庫延遲、server shutdown 與網路抖動。</li>
<li>測試結果如何回饋到 capacity planning 與 feature gate。</li>
</ol>
<h2 id="觀察不同測試層級回答不同問題">【觀察】不同測試層級回答不同問題</h2>
<p>可靠性驗證最怕的錯誤，是把所有測試都塞成一種樣子。不同層級應該分工：</p>
<ul>
<li>unit test：規則有沒有寫對</li>
<li>integration test：協定與元件有沒有接對</li>
<li>race test：並發邊界有沒有資料競爭</li>
<li>fuzz test：輸入邊界有沒有漏掉</li>
<li>load test：容量與延遲是否能接受</li>
<li>chaos test：失敗發生時系統能不能復原</li>
</ul>
<h2 id="判讀race-test-是輔助檢查">【判讀】race test 是輔助檢查</h2>
<p><code>go test -race</code> 能抓出實際跑到的資料競爭，但它不是正確性保證。真正的重點仍然是：</p>
<ul>
<li>state owner 是誰</li>
<li>哪些資料需要 lock</li>
<li>哪些資料應該只讓單一 goroutine 擁有</li>
<li>哪些資料應該複製而不是共享</li>
</ul>
<h2 id="策略load-test-的輸出要能回到容量判斷">【策略】load test 的輸出要能回到容量判斷</h2>
<p>load test 不應只是跑出一個數字，還要能回答：</p>
<ul>
<li>哪個 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 開始變長</li>
<li>哪個 DB <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a> 開始飽和</li>
<li>哪種 message rate 會讓 latency 明顯上升</li>
<li>哪個 memory curve 表示需要調整 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 或 GC 參數</li>
</ul>
<p>如果沒有這些觀察點，壓測結果就很難轉成實際修正。</p>
<h2 id="執行chaos-test-應該模擬真實失敗">【執行】chaos test 應該模擬真實失敗</h2>
<p>chaos test 的重點是模擬真實世界常見的失敗：</p>
<ul>
<li>broker 暫時不可用</li>
<li><a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 延遲上升</li>
<li>shutdown 中斷流量</li>
<li>網路抖動或 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></li>
</ul>
<p>這些情境應該回到 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a>、retry、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 設計。</p>
<h2 id="延伸測試結果應回饋到-feature-gate">【延伸】測試結果應回饋到 feature gate</h2>
<p>如果某個功能在 load test 或 chaos test 下風險太高，最直接的做法不一定是先修完整系統，也可能是先用 feature gate 逐步推出、觀察與回收。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會綁定特定 CI 或壓測平台。教材重點會放在測試層級分工，避免把所有風險都塞進端到端測試。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的並發測試與可靠性驗證；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：測試基礎</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界</a></li>
</ul>
]]></content:encoded></item><item><title>模組六：生產操作</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/</guid><description>&lt;p>生產操作的核心目標是讓 Go 服務可停止、可觀測、可診斷、可漸進啟用功能。服務能在本機跑起來只是第一步；長時間運行後，真正重要的是 shutdown 是否可預期、監控訊號是否清楚、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 是否可查詢、功能開關是否有降級策略。&lt;/p>
&lt;p>本模組承接前面的並發、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、runtime 與測試：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 需要 context 和 goroutine lifecycle，health endpoint 需要區分可用性與診斷，structured &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 需要能追 event flow，feature gate 需要能安全控制新能力。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">6.1&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 與 signal handling&lt;/td>
 &lt;td>用 signal、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 與 owner cleanup 停止服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">6.2&lt;/a>&lt;/td>
 &lt;td>健康檢查與診斷 endpoint&lt;/td>
 &lt;td>區分 health、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、diagnostics 與 status code 合約&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">6.3&lt;/a>&lt;/td>
 &lt;td>結構化日誌欄位設計&lt;/td>
 &lt;td>用穩定欄位讓 log 可 grep、可聚合、可追蹤&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/feature-gate/" data-link-title="6.4 版本偵測與 feature gate" data-link-desc="依版本與環境能力啟用功能">6.4&lt;/a>&lt;/td>
 &lt;td>版本偵測與 feature gate&lt;/td>
 &lt;td>用功能開關、能力偵測與降級策略控制行為&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 HTTP server、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub、background worker、runtime diagnostics、structured log 與 feature gate。&lt;/p>
&lt;p>範例只用來展示 Go 生產操作設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>signal.NotifyContext&lt;/code> 或 signal channel 建立 root context。&lt;/li>
&lt;li>用 &lt;code>http.Server.Shutdown&lt;/code> 停止接受新 request。&lt;/li>
&lt;li>用 context 傳遞停止訊號給 worker、hub、WebSocket pump。&lt;/li>
&lt;li>用 &lt;code>/health&lt;/code>、&lt;code>/ready&lt;/code>、&lt;code>/debug/...&lt;/code> 分開不同操作訊號。&lt;/li>
&lt;li>用 &lt;code>log/slog&lt;/code> 建立穩定 structured fields。&lt;/li>
&lt;li>用 config struct 載入 feature gate，而不是到處讀環境變數。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>服務收到停止訊號後，哪些元件要先停止接流量&lt;/li>
&lt;li>health、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、diagnostics 各自回答什麼問題&lt;/li>
&lt;li>structured log 欄位如何支援查詢與聚合&lt;/li>
&lt;li>哪些資料不應進入 log&lt;/li>
&lt;li>feature gate 關閉時應降級、回錯、隱藏還是排程稍後處理&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不討論 Kubernetes、systemd、雲端平台或完整 SRE 流程的所有細節。這些環境會影響操作策略，但本模組先建立 Go 服務本身應具備的操作邊界；後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Kubernetes、systemd 與 load balancer 合約&lt;/a> 以及 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>生產操作的核心目標是讓 Go 服務可停止、可觀測、可診斷、可漸進啟用功能。服務能在本機跑起來只是第一步；長時間運行後，真正重要的是 shutdown 是否可預期、監控訊號是否清楚、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 是否可查詢、功能開關是否有降級策略。</p>
<p>本模組承接前面的並發、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、runtime 與測試：<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 需要 context 和 goroutine lifecycle，health endpoint 需要區分可用性與診斷，structured <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 需要能追 event flow，feature gate 需要能安全控制新能力。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">6.1</a></td>
          <td><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與 signal handling</td>
          <td>用 signal、context、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與 owner cleanup 停止服務</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">6.2</a></td>
          <td>健康檢查與診斷 endpoint</td>
          <td>區分 health、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、diagnostics 與 status code 合約</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">6.3</a></td>
          <td>結構化日誌欄位設計</td>
          <td>用穩定欄位讓 log 可 grep、可聚合、可追蹤</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/feature-gate/" data-link-title="6.4 版本偵測與 feature gate" data-link-desc="依版本與環境能力啟用功能">6.4</a></td>
          <td>版本偵測與 feature gate</td>
          <td>用功能開關、能力偵測與降級策略控制行為</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 HTTP server、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub、background worker、runtime diagnostics、structured log 與 feature gate。</p>
<p>範例只用來展示 Go 生產操作設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>signal.NotifyContext</code> 或 signal channel 建立 root context。</li>
<li>用 <code>http.Server.Shutdown</code> 停止接受新 request。</li>
<li>用 context 傳遞停止訊號給 worker、hub、WebSocket pump。</li>
<li>用 <code>/health</code>、<code>/ready</code>、<code>/debug/...</code> 分開不同操作訊號。</li>
<li>用 <code>log/slog</code> 建立穩定 structured fields。</li>
<li>用 config struct 載入 feature gate，而不是到處讀環境變數。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>服務收到停止訊號後，哪些元件要先停止接流量</li>
<li>health、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、diagnostics 各自回答什麼問題</li>
<li>structured log 欄位如何支援查詢與聚合</li>
<li>哪些資料不應進入 log</li>
<li>feature gate 關閉時應降級、回錯、隱藏還是排程稍後處理</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論 Kubernetes、systemd、雲端平台或完整 SRE 流程的所有細節。這些環境會影響操作策略，但本模組先建立 Go 服務本身應具備的操作邊界；後續可接 <a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Kubernetes、systemd 與 load balancer 合約</a> 以及 <a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>模組七：跨節點與平台整合</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/</guid><description>&lt;p>跨節點與平台整合的核心目標是把「單一 Go process 內的正確邊界」延伸到外部基礎設施。前六個模組先建立 goroutine lifecycle、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 連線、runtime 診斷、事件邊界、測試與操作語意；本模組處理服務進入多節點、多資料來源、多觀測工具與部署平台後會出現的新責任。&lt;/p>
&lt;p>本模組已開始補成正文。章節先定義問題邊界與前置脈絡，再逐步補上 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a>、outbox、跨節點 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、observability、部署與可靠性驗證的實作語意；後續仍可依實戰需求繼續擴寫。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本模組保留在 Go 進階篇，因為它要回答的是「Go 服務跨出單一 process 前，程式內部需要準備哪些 port、訊號、錯誤語意與測試合約」。具體資料庫、Redis、RabbitMQ、observability、Kubernetes 或 CI 平台操作，會放在跨語言的 &lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南&lt;/a>。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>承接問題&lt;/th>
 &lt;th>Backend 實作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">7.1&lt;/a>&lt;/td>
 &lt;td>資料庫 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 與 schema &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a>&lt;/td>
 &lt;td>狀態邊界進入持久化層後如何維持一致&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">資料庫與持久化&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">7.2&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">Durable queue&lt;/a>、outbox 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a>&lt;/td>
 &lt;td>事件跨 process 後如何避免遺失、重複與半成功&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列與事件傳遞&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">7.3&lt;/a>&lt;/td>
 &lt;td>跨節點 WebSocket、presence 與重連協定&lt;/td>
 &lt;td>多台 server 如何管理訂閱、推送與連線狀態&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">快取與 Redis&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">7.4&lt;/a>&lt;/td>
 &lt;td>Observability pipeline、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 與 tracing&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、metric、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a> 如何組成可操作的診斷系統&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">可觀測性平台&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">7.5&lt;/a>&lt;/td>
 &lt;td>Kubernetes、systemd 與 load balancer 合約&lt;/td>
 &lt;td>部署平台如何影響 shutdown、health 與資源限制&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台與網路入口&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">7.6&lt;/a>&lt;/td>
 &lt;td>CI、fuzz、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test&lt;/a> 與 chaos testing&lt;/td>
 &lt;td>測試如何從單一行為擴展到系統可靠性&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">可靠性驗證流程&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組和前面章節的關係">本模組和前面章節的關係&lt;/h2>
&lt;p>本模組適合在你已經理解單一 Go 服務的內部邊界後閱讀，用來補足生產環境常見的外部系統責任。&lt;/p></description><content:encoded><![CDATA[<p>跨節點與平台整合的核心目標是把「單一 Go process 內的正確邊界」延伸到外部基礎設施。前六個模組先建立 goroutine lifecycle、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 連線、runtime 診斷、事件邊界、測試與操作語意；本模組處理服務進入多節點、多資料來源、多觀測工具與部署平台後會出現的新責任。</p>
<p>本模組已開始補成正文。章節先定義問題邊界與前置脈絡，再逐步補上 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>、outbox、跨節點 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、observability、部署與可靠性驗證的實作語意；後續仍可依實戰需求繼續擴寫。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本模組保留在 Go 進階篇，因為它要回答的是「Go 服務跨出單一 process 前，程式內部需要準備哪些 port、訊號、錯誤語意與測試合約」。具體資料庫、Redis、RabbitMQ、observability、Kubernetes 或 CI 平台操作，會放在跨語言的 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南</a>。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>承接問題</th>
          <th>Backend 實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">7.1</a></td>
          <td>資料庫 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 與 schema <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a></td>
          <td>狀態邊界進入持久化層後如何維持一致</td>
          <td><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">資料庫與持久化</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">7.2</a></td>
          <td><a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">Durable queue</a>、outbox 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a></td>
          <td>事件跨 process 後如何避免遺失、重複與半成功</td>
          <td><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列與事件傳遞</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">7.3</a></td>
          <td>跨節點 WebSocket、presence 與重連協定</td>
          <td>多台 server 如何管理訂閱、推送與連線狀態</td>
          <td><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">快取與 Redis</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">7.4</a></td>
          <td>Observability pipeline、<a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 與 tracing</td>
          <td><a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、metric、<a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a> 如何組成可操作的診斷系統</td>
          <td><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">可觀測性平台</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">7.5</a></td>
          <td>Kubernetes、systemd 與 load balancer 合約</td>
          <td>部署平台如何影響 shutdown、health 與資源限制</td>
          <td><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台與網路入口</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">7.6</a></td>
          <td>CI、fuzz、<a href="/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test</a> 與 chaos testing</td>
          <td>測試如何從單一行為擴展到系統可靠性</td>
          <td><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">可靠性驗證流程</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組和前面章節的關係">本模組和前面章節的關係</h2>
<p>本模組適合在你已經理解單一 Go 服務的內部邊界後閱讀，用來補足生產環境常見的外部系統責任。</p>
<ul>
<li>事件與狀態邊界先讀 <a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">模組四：架構邊界與事件系統</a>。</li>
<li>WebSocket lifecycle 先讀 <a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">模組二：WebSocket 服務架構</a>。</li>
<li>測試可靠性先讀 <a href="/blog/go-advanced/05-testing-reliability/" data-link-title="模組五：測試與可靠性" data-link-desc="時間控制、WebSocket integration test、race check 與 table-driven test">模組五：測試與可靠性</a>。</li>
<li>操作語意先讀 <a href="/blog/go-advanced/06-production-operations/" data-link-title="模組六：生產操作" data-link-desc="graceful shutdown、健康檢查、結構化日誌與 feature gate">模組六：生產操作</a>。</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>目前已可作為第一輪正文閱讀，完整學習時間可隨後續擴寫再調整。</p>
]]></content:encoded></item><item><title>Go 進階指南</title><link>https://tarrragon.github.io/blog/go-advanced/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/</guid><description>&lt;p>本系列是接在入門教學之後的延伸路線，目標是把 Go 的並發模式、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 服務架構、runtime 診斷、狀態邊界與生產環境可觀測性講到能真正用在服務上。語法細節留在入門篇；進階篇聚焦長時間服務會遇到的設計壓力。&lt;/p>
&lt;h2 id="目標讀者">目標讀者&lt;/h2>
&lt;ul>
&lt;li>已完成 &lt;a href="https://tarrragon.github.io/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go 入門實戰指南&lt;/a> 的工程師&lt;/li>
&lt;li>想深入理解 Go 並發模型與 runtime 行為的開發者&lt;/li>
&lt;li>需要維護長時間運行服務的人&lt;/li>
&lt;li>想把 Go 服務從「能跑」提升到「可觀測、可測、可演進」的人&lt;/li>
&lt;/ul>
&lt;h2 id="學習目標">學習目標&lt;/h2>
&lt;ol>
&lt;li>掌握 goroutine、channel、mutex 的進階使用邊界&lt;/li>
&lt;li>理解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client lifecycle、heartbeat、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 與慢客戶端問題&lt;/li>
&lt;li>使用 pprof、runtime 記憶體限制與結構化日誌診斷服務&lt;/li>
&lt;li>設計 event-driven service 的資料邊界與去重策略&lt;/li>
&lt;li>建立並發測試、整合測試與可重現的時間控制&lt;/li>
&lt;li>能評估 Go 服務在生產環境的風險與操作策略&lt;/li>
&lt;li>知道單一 Go 服務延伸到跨節點與平台整合時，哪些責任會轉移到資料庫、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>、observability pipeline 與部署平台&lt;/li>
&lt;/ol>
&lt;h2 id="共用術語">共用術語&lt;/h2>
&lt;p>進階篇延續入門篇的 action、command、domain event、repository、port、adapter、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 等詞彙。若你需要確認這些詞在這套教材中的責任邊界，可以先回到 &lt;a href="https://tarrragon.github.io/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語&lt;/a>。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>Go 進階篇處理單一 Go 服務內部的高階能力：goroutine lifecycle、WebSocket pump、runtime 診斷、event boundary、race test、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 與 diagnostics endpoint。當內容開始碰到資料庫、Redis、RabbitMQ、Kafka、OpenTelemetry、Kubernetes 或 CI 平台操作時，就應該轉到跨語言的 &lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南&lt;/a>。&lt;/p>
&lt;p>模組七保留在進階篇裡，因為它要回答「Go 服務在跨出去以前，還需要先把哪些 port、訊號與測試合約準備好」。外部系統本身的選型與部署細節，則放在 Backend，讓不同語言都能共用同一套實作知識。&lt;/p>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;h3 id="模組一進階並發模式">&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/" data-link-title="模組一：進階並發模式" data-link-desc="channel ownership、select loop、非阻塞送出、共享狀態、worker pool 與 rate limiting">模組一：進階並發模式&lt;/a>&lt;/h3>
&lt;p>從服務實例理解 fan-in、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>、取消傳播與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ，先把並發語意說清楚。&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership 與關閉責任&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">select loop 的生命週期設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">bounded worker pool&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">rate limiting 與 backpressure &lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="模組二websocket-服務架構">&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">模組二：WebSocket 服務架構&lt;/a>&lt;/h3>
&lt;p>把 WebSocket server 的連線、訂閱、推送與錯誤處理拆成可維護的邊界。&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat、deadline 與連線清理&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="模組三runtime-與效能診斷">&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">模組三：Runtime 與效能診斷&lt;/a>&lt;/h3>
&lt;p>理解 Go runtime 如何在長時間運行服務中影響記憶體與排程行為。&lt;/p></description><content:encoded><![CDATA[<p>本系列是接在入門教學之後的延伸路線，目標是把 Go 的並發模式、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 服務架構、runtime 診斷、狀態邊界與生產環境可觀測性講到能真正用在服務上。語法細節留在入門篇；進階篇聚焦長時間服務會遇到的設計壓力。</p>
<h2 id="目標讀者">目標讀者</h2>
<ul>
<li>已完成 <a href="/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go 入門實戰指南</a> 的工程師</li>
<li>想深入理解 Go 並發模型與 runtime 行為的開發者</li>
<li>需要維護長時間運行服務的人</li>
<li>想把 Go 服務從「能跑」提升到「可觀測、可測、可演進」的人</li>
</ul>
<h2 id="學習目標">學習目標</h2>
<ol>
<li>掌握 goroutine、channel、mutex 的進階使用邊界</li>
<li>理解 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client lifecycle、heartbeat、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 與慢客戶端問題</li>
<li>使用 pprof、runtime 記憶體限制與結構化日誌診斷服務</li>
<li>設計 event-driven service 的資料邊界與去重策略</li>
<li>建立並發測試、整合測試與可重現的時間控制</li>
<li>能評估 Go 服務在生產環境的風險與操作策略</li>
<li>知道單一 Go 服務延伸到跨節點與平台整合時，哪些責任會轉移到資料庫、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>、observability pipeline 與部署平台</li>
</ol>
<h2 id="共用術語">共用術語</h2>
<p>進階篇延續入門篇的 action、command、domain event、repository、port、adapter、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 等詞彙。若你需要確認這些詞在這套教材中的責任邊界，可以先回到 <a href="/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語</a>。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>Go 進階篇處理單一 Go 服務內部的高階能力：goroutine lifecycle、WebSocket pump、runtime 診斷、event boundary、race test、<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與 diagnostics endpoint。當內容開始碰到資料庫、Redis、RabbitMQ、Kafka、OpenTelemetry、Kubernetes 或 CI 平台操作時，就應該轉到跨語言的 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南</a>。</p>
<p>模組七保留在進階篇裡，因為它要回答「Go 服務在跨出去以前，還需要先把哪些 port、訊號與測試合約準備好」。外部系統本身的選型與部署細節，則放在 Backend，讓不同語言都能共用同一套實作知識。</p>
<h2 id="教學模組">教學模組</h2>
<h3 id="模組一進階並發模式"><a href="/blog/go-advanced/01-concurrency-patterns/" data-link-title="模組一：進階並發模式" data-link-desc="channel ownership、select loop、非阻塞送出、共享狀態、worker pool 與 rate limiting">模組一：進階並發模式</a></h3>
<p>從服務實例理解 fan-in、<a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>、取消傳播與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ，先把並發語意說清楚。</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership 與關閉責任</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">select loop 的生命週期設計</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">bounded worker pool</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">rate limiting 與 backpressure </a></li>
</ul>
<h3 id="模組二websocket-服務架構"><a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">模組二：WebSocket 服務架構</a></h3>
<p>把 WebSocket server 的連線、訂閱、推送與錯誤處理拆成可維護的邊界。</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理</a></li>
</ul>
<h3 id="模組三runtime-與效能診斷"><a href="/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">模組三：Runtime 與效能診斷</a></h3>
<p>理解 Go runtime 如何在長時間運行服務中影響記憶體與排程行為。</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">GC 與 memory limit</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">goroutine leak 偵測</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">資料結構與 allocation 壓力</a></li>
</ul>
<h3 id="模組四架構邊界與事件系統"><a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">模組四：架構邊界與事件系統</a></h3>
<p>用事件驅動架構拆解服務責任，讓來源、處理與狀態不再混在一起。</p>
<ul>
<li><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合</a></li>
</ul>
<h3 id="模組五測試與可靠性"><a href="/blog/go-advanced/05-testing-reliability/" data-link-title="模組五：測試與可靠性" data-link-desc="時間控制、WebSocket integration test、race check 與 table-driven test">模組五：測試與可靠性</a></h3>
<p>針對並發服務建立能真正揭露風險的測試，而不是只追求覆蓋率。</p>
<ul>
<li><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">時間注入與狀態轉移測試</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">table-driven test 的設計邊界</a></li>
</ul>
<h3 id="模組六生產操作"><a href="/blog/go-advanced/06-production-operations/" data-link-title="模組六：生產操作" data-link-desc="graceful shutdown、健康檢查、結構化日誌與 feature gate">模組六：生產操作</a></h3>
<p>把本地服務推向可維護、可診斷、可部署的操作狀態。</p>
<ul>
<li><a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">健康檢查與診斷 endpoint</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計</a></li>
<li><a href="/blog/go-advanced/06-production-operations/feature-gate/" data-link-title="6.4 版本偵測與 feature gate" data-link-desc="依版本與環境能力啟用功能">版本偵測與 feature gate</a></li>
</ul>
<h3 id="模組七跨節點與平台整合"><a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">模組七：跨節點與平台整合</a></h3>
<p>承接各章「本章不處理」的延伸邊界，把單一服務往外擴張時必須補上的責任整理成一條清楚路線。</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">跨節點 WebSocket、presence 與重連協定</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Kubernetes、systemd 與 load balancer 合約</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing</a></li>
</ul>
<h2 id="學習路徑">學習路徑</h2>
<h3 id="路徑-a並發服務維護者">路徑 A：並發服務維護者</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">模組一 → 模組四 → 模組五</span></span></code></pre></div><p>重點：事件流、共享狀態、並發測試。</p>
<h3 id="路徑-bwebsocketapi-開發者">路徑 B：WebSocket/API 開發者</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">模組二 → 模組六 → 模組五</span></span></code></pre></div><p>重點：連線生命週期、訊息路由、操作診斷。</p>
<h3 id="路徑-c效能與可靠性工程師">路徑 C：效能與可靠性工程師</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">模組三 → 模組五 → 模組六</span></span></code></pre></div><p>重點：pprof、goroutine leak、race check、服務操作。</p>
<h3 id="路徑-d完整學習">路徑 D：完整學習</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">模組一 → 模組二 → 模組三 → 模組四 → 模組五 → 模組六 → 模組七</span></span></code></pre></div><p>按順序學習，建立完整的 Go 長時間運行服務模型。</p>
<h2 id="主題延伸地圖">主題延伸地圖</h2>
<p>進階篇的章節會反覆碰到 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、time、state、event、WebSocket 與 testing。這些主題會在不同服務壓力下承擔不同責任；主題延伸地圖用來幫讀者辨識每一層的分工。</p>
<table>
  <thead>
      <tr>
          <th>主題</th>
          <th>單一 process 內的設計</th>
          <th>生產操作</th>
          <th>跨節點邊界</th>
          <th>Backend 實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>並發與容量</td>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership</a>、<a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">select loop</a>、<a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">非阻塞送出</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">race condition 檢查</a>、<a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">graceful shutdown</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">可靠性驗證流程</a></td>
          <td><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證</a></td>
      </tr>
      <tr>
          <td>WebSocket</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read/write pump</a>、<a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat</a>、<a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">WebSocket integration test</a>、<a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">health diagnostics</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">跨節點 WebSocket</a></td>
          <td><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td>Runtime 診斷</td>
          <td><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">GC 與 memory limit</a>、<a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">pprof</a>、<a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">goroutine leak</a></td>
          <td><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">diagnostics endpoint</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline</a>、<a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">部署平台合約</a></td>
          <td><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a>、<a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台</a></td>
      </tr>
      <tr>
          <td>事件與狀態</td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">component boundaries</a>、<a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">source of truth</a>、<a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">event fusion</a></td>
          <td><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">outbox 與 idempotency</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction</a></td>
          <td><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td>測試分層</td>
          <td><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">時間控制</a>、<a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">table-driven test</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">race check</a>、<a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">integration test</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">可靠性驗證流程</a></td>
          <td><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證</a></td>
      </tr>
  </tbody>
</table>
<h2 id="先備知識">先備知識</h2>
<p>本系列假設你已經完成 <a href="/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go 入門實戰指南</a> 的基礎部分，因為下面這些章節會直接沿用那些概念：</p>
<ul>
<li><a href="/blog/go/03-stdlib/" data-link-title="模組三：標準庫實戰" data-link-desc="使用 fmt、time、encoding/json、net/http、log/slog、context、defer、flag 與 os/env 解決實務問題">模組三：標準庫實戰</a></li>
<li><a href="/blog/go/04-concurrency/" data-link-title="模組四：並發模型" data-link-desc="從 goroutine、channel、select 與 RWMutex 理解 Go 並發模型">模組四：並發模型</a></li>
<li><a href="/blog/go/05-error-testing/" data-link-title="模組五：錯誤處理與測試" data-link-desc="用明確錯誤路徑、testing、table-driven test 與時間注入驗證 Go 程式">模組五：錯誤處理與測試</a></li>
</ul>
<h2 id="每章結構">每章結構</h2>
<p>每章都採用「由淺到深」的結構，先說明問題，再切到設計與實作：</p>
<ol>
<li><strong>原理層</strong>：這個機制解決什麼問題</li>
<li><strong>設計層</strong>：在服務架構中如何切責任</li>
<li><strong>實作層</strong>：用簡化範例程式碼看具體做法</li>
<li><strong>實戰檢查</strong>：維護時要確認哪些風險</li>
</ol>
<hr>
<p><em>文件版本：v0.1.0</em>
<em>最後更新：2026-04-22</em>
<em>系列狀態：核心初稿完成，延伸模組規劃中</em></p>
]]></content:encoded></item></channel></rss>