<?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>Distributed-Systems on Tarragon</title><link>https://tarrragon.github.io/blog/tags/distributed-systems/</link><description>Recent content in Distributed-Systems on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 23 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/distributed-systems/index.xml" rel="self" type="application/rss+xml"/><item><title>8.1 Google：大規模微服務與索引服務</title><link>https://tarrragon.github.io/blog/go/08-case-studies/google/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/google/</guid><description>&lt;p>Google 的官方案例最適合用來理解 Go 的原始定位：這門語言的目標是解決大型工程團隊在多核心、網路、模組化與依賴管理上的問題。Google Core Data Solutions 團隊把原本的單體 C++ 索引堆疊拆成多個微服務，並把多數索引服務改寫成 Go。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/google/">Using Go at Google&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://go.dev/solutions/google/coredata">How Google’s Core Data Solutions Team Uses Go&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://go.dev/talks/2012/splash.article">Go at Google: Language Design in the Service of Software Engineering&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 很適合大型服務拆分之後的邊界管理。&lt;/li>
&lt;li>built-in concurrency 對高併發索引與資料處理很重要。&lt;/li>
&lt;li>Go 的簡單語法與明確依賴，能讓大團隊維持可讀性。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/kubernetes/kubernetes">kubernetes/kubernetes&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Kubernetes 不是 Google 內部產品，但它很好地呈現了 Google 文化裡常見的 Go 工程模式：大型 codebase、明確 package 邊界、cmd 入口與大量服務協調。&lt;/p></description><content:encoded><![CDATA[<p>Google 的官方案例最適合用來理解 Go 的原始定位：這門語言的目標是解決大型工程團隊在多核心、網路、模組化與依賴管理上的問題。Google Core Data Solutions 團隊把原本的單體 C++ 索引堆疊拆成多個微服務，並把多數索引服務改寫成 Go。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/google/">Using Go at Google</a></li>
<li><a href="https://go.dev/solutions/google/coredata">How Google’s Core Data Solutions Team Uses Go</a></li>
<li><a href="https://go.dev/talks/2012/splash.article">Go at Google: Language Design in the Service of Software Engineering</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 很適合大型服務拆分之後的邊界管理。</li>
<li>built-in concurrency 對高併發索引與資料處理很重要。</li>
<li>Go 的簡單語法與明確依賴，能讓大團隊維持可讀性。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/kubernetes/kubernetes">kubernetes/kubernetes</a></li>
</ul>
<p>Kubernetes 不是 Google 內部產品，但它很好地呈現了 Google 文化裡常見的 Go 工程模式：大型 codebase、明確 package 邊界、cmd 入口與大量服務協調。</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>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>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>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>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>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>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>8.7 Cockroach Labs：分散式 SQL 資料庫</title><link>https://tarrragon.github.io/blog/go/08-case-studies/cockroach-labs/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/cockroach-labs/</guid><description>&lt;p>Cockroach Labs 的案例適合放在 Go 教材裡，因為它把 Go 的工程價值推到很高的門檻：分散式 SQL、交易一致性、可水平擴展、容錯與長期可維護。官方案例直接提到，Go 的 performance、garbage collection 與低入門門檻，是 CockroachDB 的重要選擇原因。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.cockroachlabs.com/blog/why-go-was-the-right-choice-for-cockroachdb/">Why Go was the right choice for CockroachDB&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.cockroachlabs.com/docs/stable/why-cockroachdb">Why CockroachDB?&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 不只適合 API，也適合超大型資料系統。&lt;/li>
&lt;li>大型系統裡，語言的可讀性與團隊進入門檻很重要。&lt;/li>
&lt;li>Go 在複雜系統中的優勢，常常是讓工程複雜度可控。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/cockroachdb/cockroach">cockroachdb/cockroach&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/cockroachdb/cockroach-go">cockroachdb/cockroach-go&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這是本模組最值得讀的 repo 之一。你可以對照第七模組的 package 邊界、接口設計與 composition root，理解大型 Go 系統如何組織。&lt;/p></description><content:encoded><![CDATA[<p>Cockroach Labs 的案例適合放在 Go 教材裡，因為它把 Go 的工程價值推到很高的門檻：分散式 SQL、交易一致性、可水平擴展、容錯與長期可維護。官方案例直接提到，Go 的 performance、garbage collection 與低入門門檻，是 CockroachDB 的重要選擇原因。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://www.cockroachlabs.com/blog/why-go-was-the-right-choice-for-cockroachdb/">Why Go was the right choice for CockroachDB</a></li>
<li><a href="https://www.cockroachlabs.com/docs/stable/why-cockroachdb">Why CockroachDB?</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 不只適合 API，也適合超大型資料系統。</li>
<li>大型系統裡，語言的可讀性與團隊進入門檻很重要。</li>
<li>Go 在複雜系統中的優勢，常常是讓工程複雜度可控。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/cockroachdb/cockroach">cockroachdb/cockroach</a></li>
<li><a href="https://github.com/cockroachdb/cockroach-go">cockroachdb/cockroach-go</a></li>
</ul>
<p>這是本模組最值得讀的 repo 之一。你可以對照第七模組的 package 邊界、接口設計與 composition root，理解大型 Go 系統如何組織。</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></channel></rss>