<?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>Replication on Tarragon</title><link>https://tarrragon.github.io/blog/tags/replication/</link><description>Recent content in Replication on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/replication/index.xml" rel="self" type="application/rss+xml"/><item><title>KeyDB active-active 多主複製：last-write-wins 會默默吃掉哪一筆寫入</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/keydb/active-active-replication/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/keydb/active-active-replication/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB&lt;/a> overview 的 implementation-layer deep article。選型層（KeyDB vs Redis / DragonflyDB / Valkey、為何選 fork）見 overview；本文只處理「決定用 KeyDB active-active 後，衝突與一致性怎麼判」。命令實機驗證於 eqalpha/keydb image、最後檢查日 2026-06-16；複製機制以 &lt;a href="https://docs.keydb.dev/docs/active-rep/">KeyDB active-replication 文件&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="兩邊都能寫聽起來太美好">兩邊都能寫，聽起來太美好&lt;/h2>
&lt;p>Redis 的複製是單向的：一個 master 寫、replica 唯讀。要跨區讓兩邊都能就近寫入，Redis 本身做不到（得靠應用層分區或外部工具）。KeyDB 的 active-active 把這個限制拿掉——兩個（含以上）KeyDB 節點都是 master、都能接受寫入、互相把寫入同步給對方。對「兩個 region 都要低延遲寫入同一份 cache」的場景，這聽起來解決了所有問題。&lt;/p>
&lt;p>問題藏在「兩邊同時寫同一個 key」的那一刻。active-active 沒有全域協調者來仲裁誰對誰錯，它用 last-write-wins（LWW）：比較兩筆寫入的時間戳，留下較晚的、默默丟掉較早的。多數時候沒事，但當兩個 region 在幾毫秒內各自更新同一個 key，其中一筆寫入會無聲消失——沒有錯誤、沒有日誌、application 以為自己寫成功了。&lt;/p>
&lt;p>理解 KeyDB active-active 就是理解這個取捨：它用 LWW 換到了「兩邊都能寫」的可用性，代價是放棄了強一致與「不丟寫入」的保證。本文展開複製機制、衝突語意，以及哪些資料放得進這個模型、哪些放進去就是 bug。&lt;/p>
&lt;h2 id="核心概念active-active-的複製與衝突語意">核心概念：active-active 的複製與衝突語意&lt;/h2>
&lt;p>active-active 不是「分散式交易」，它是「雙向非同步複製 + LWW 衝突解決」。理解它要抓三個點：&lt;/p>
&lt;p>&lt;strong>每個節點都是 active-replica&lt;/strong>。一般 Redis replica 是唯讀的；KeyDB 的 active-replica 既接受本地寫入、又接收對方的複製流。兩個節點互相設定對方為 master，形成雙向複製環。實機看到的 role 就是 &lt;code>active-replica&lt;/code>（不是 master / slave）。&lt;/p>
&lt;p>&lt;strong>複製是非同步的&lt;/strong>。本地寫入立即回 OK 給 client，之後才非同步傳給對方節點。這意味著兩個節點之間永遠有一個複製延遲窗口——在這個窗口內，兩邊看到的資料可能不同。這是 active-active 是 AP（可用性 + 分區容忍）而非 CP 的根本原因。&lt;/p>
&lt;p>&lt;strong>衝突用 last-write-wins 解決&lt;/strong>。同一個 key 在兩個節點被並發修改時，KeyDB 比較版本，保留較晚的寫入、丟棄較早的。沒有 merge、沒有 vector clock、沒有 application callback——就是比誰較晚。KeyDB 用 hybrid logical clock（HLC）排序、不是純 wall-clock，但 HLC 仍綁節點實體時鐘——時鐘不同步（clock skew）會直接影響哪一筆被判定為「較晚」。同步的是 key 的「值」不是「操作」，這也是為什麼並發 INCR 會互相覆蓋而非累加（見故障演練 Case 1）。&lt;/p>
&lt;p>&lt;strong>每筆寫入帶來源標記避免無限迴圈&lt;/strong>。A 的寫入同步給 B 後，B 不會再把它當成新寫入傳回 A（否則會無限循環）。KeyDB 用來源標記處理這個，但複製拓樸設計錯（例如環狀多節點）仍可能放大流量。&lt;/p>
&lt;h2 id="配置兩節點-active-active-的設定路徑">配置：兩節點 active-active 的設定路徑&lt;/h2>
&lt;p>實機驗證的最小雙主設定（兩個節點互相複製）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 節點 A 與 B 都開 active-replica + multi-master&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">docker run -d --name kdb-a --network kdbnet -p 6401:6379 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> eqalpha/keydb keydb-server --active-replica yes --multi-master yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">docker run -d --name kdb-b --network kdbnet -p 6402:6379 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> eqalpha/keydb keydb-server --active-replica yes --multi-master yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 互相指向對方（形成雙向複製）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> replicaof kdb-b &lt;span class="m">6379&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6402&lt;/span> replicaof kdb-a &lt;span class="m">6379&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機驗證雙向同步（最後檢查日 2026-06-16）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 寫 A、讀 B&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> SET fromA hello &lt;span class="c1"># → OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6402&lt;/span> GET fromA &lt;span class="c1"># → hello （A 的寫入同步到 B）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 寫 B、讀 A（雙向）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6402&lt;/span> SET fromB world &lt;span class="c1"># → OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> GET fromB &lt;span class="c1"># → world （B 的寫入同步到 A）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># 確認 role 與複製鏈路&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">keydb-cli -p &lt;span class="m">6401&lt;/span> INFO replication &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;role|master_link_status|connected_slaves&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># role:active-replica&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># master_link_status:up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1"># connected_slaves:1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個節點都回報 &lt;code>role:active-replica&lt;/code>（不是傳統的 master / slave），&lt;code>master_link_status:up&lt;/code> 確認複製鏈路健康。寫入任一節點、另一節點都讀得到，這就是 active-active 的核心行為。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a> overview 的 implementation-layer deep article。選型層（KeyDB vs Redis / DragonflyDB / Valkey、為何選 fork）見 overview；本文只處理「決定用 KeyDB active-active 後，衝突與一致性怎麼判」。命令實機驗證於 eqalpha/keydb image、最後檢查日 2026-06-16；複製機制以 <a href="https://docs.keydb.dev/docs/active-rep/">KeyDB active-replication 文件</a> 為準。</p></blockquote>
<h2 id="兩邊都能寫聽起來太美好">兩邊都能寫，聽起來太美好</h2>
<p>Redis 的複製是單向的：一個 master 寫、replica 唯讀。要跨區讓兩邊都能就近寫入，Redis 本身做不到（得靠應用層分區或外部工具）。KeyDB 的 active-active 把這個限制拿掉——兩個（含以上）KeyDB 節點都是 master、都能接受寫入、互相把寫入同步給對方。對「兩個 region 都要低延遲寫入同一份 cache」的場景，這聽起來解決了所有問題。</p>
<p>問題藏在「兩邊同時寫同一個 key」的那一刻。active-active 沒有全域協調者來仲裁誰對誰錯，它用 last-write-wins（LWW）：比較兩筆寫入的時間戳，留下較晚的、默默丟掉較早的。多數時候沒事，但當兩個 region 在幾毫秒內各自更新同一個 key，其中一筆寫入會無聲消失——沒有錯誤、沒有日誌、application 以為自己寫成功了。</p>
<p>理解 KeyDB active-active 就是理解這個取捨：它用 LWW 換到了「兩邊都能寫」的可用性，代價是放棄了強一致與「不丟寫入」的保證。本文展開複製機制、衝突語意，以及哪些資料放得進這個模型、哪些放進去就是 bug。</p>
<h2 id="核心概念active-active-的複製與衝突語意">核心概念：active-active 的複製與衝突語意</h2>
<p>active-active 不是「分散式交易」，它是「雙向非同步複製 + LWW 衝突解決」。理解它要抓三個點：</p>
<p><strong>每個節點都是 active-replica</strong>。一般 Redis replica 是唯讀的；KeyDB 的 active-replica 既接受本地寫入、又接收對方的複製流。兩個節點互相設定對方為 master，形成雙向複製環。實機看到的 role 就是 <code>active-replica</code>（不是 master / slave）。</p>
<p><strong>複製是非同步的</strong>。本地寫入立即回 OK 給 client，之後才非同步傳給對方節點。這意味著兩個節點之間永遠有一個複製延遲窗口——在這個窗口內，兩邊看到的資料可能不同。這是 active-active 是 AP（可用性 + 分區容忍）而非 CP 的根本原因。</p>
<p><strong>衝突用 last-write-wins 解決</strong>。同一個 key 在兩個節點被並發修改時，KeyDB 比較版本，保留較晚的寫入、丟棄較早的。沒有 merge、沒有 vector clock、沒有 application callback——就是比誰較晚。KeyDB 用 hybrid logical clock（HLC）排序、不是純 wall-clock，但 HLC 仍綁節點實體時鐘——時鐘不同步（clock skew）會直接影響哪一筆被判定為「較晚」。同步的是 key 的「值」不是「操作」，這也是為什麼並發 INCR 會互相覆蓋而非累加（見故障演練 Case 1）。</p>
<p><strong>每筆寫入帶來源標記避免無限迴圈</strong>。A 的寫入同步給 B 後，B 不會再把它當成新寫入傳回 A（否則會無限循環）。KeyDB 用來源標記處理這個，但複製拓樸設計錯（例如環狀多節點）仍可能放大流量。</p>
<h2 id="配置兩節點-active-active-的設定路徑">配置：兩節點 active-active 的設定路徑</h2>
<p>實機驗證的最小雙主設定（兩個節點互相複製）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 節點 A 與 B 都開 active-replica + multi-master</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --name kdb-a --network kdbnet -p 6401:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  eqalpha/keydb keydb-server --active-replica yes --multi-master yes
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker run -d --name kdb-b --network kdbnet -p 6402:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  eqalpha/keydb keydb-server --active-replica yes --multi-master yes
</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="c1"># 互相指向對方（形成雙向複製）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">keydb-cli -p <span class="m">6401</span> replicaof kdb-b <span class="m">6379</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">keydb-cli -p <span class="m">6402</span> replicaof kdb-a <span class="m">6379</span></span></span></code></pre></div><p>實機驗證雙向同步（最後檢查日 2026-06-16）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 寫 A、讀 B</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">keydb-cli -p <span class="m">6401</span> SET fromA hello   <span class="c1"># → OK</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">keydb-cli -p <span class="m">6402</span> GET fromA         <span class="c1"># → hello   （A 的寫入同步到 B）</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="c1"># 寫 B、讀 A（雙向）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">keydb-cli -p <span class="m">6402</span> SET fromB world   <span class="c1"># → OK</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">keydb-cli -p <span class="m">6401</span> GET fromB         <span class="c1"># → world   （B 的寫入同步到 A）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 確認 role 與複製鏈路</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">keydb-cli -p <span class="m">6401</span> INFO replication <span class="p">|</span> grep -E <span class="s2">&#34;role|master_link_status|connected_slaves&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># role:active-replica</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># master_link_status:up</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># connected_slaves:1</span></span></span></code></pre></div><p>兩個節點都回報 <code>role:active-replica</code>（不是傳統的 master / slave），<code>master_link_status:up</code> 確認複製鏈路健康。寫入任一節點、另一節點都讀得到，這就是 active-active 的核心行為。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1並發寫同一-key一筆寫入無聲消失">Case 1：並發寫同一 key、一筆寫入無聲消失</h3>
<p><strong>徵兆</strong>：兩個 region 的 application 各自更新同一個 user 的 cache（例如 profile），事後發現其中一個 region 的更新「沒生效」——但寫入時 application 收到的是 OK，沒有任何錯誤。</p>
<p><strong>根因</strong>：active-active 的 LWW。兩筆寫入在複製延遲窗口內並發發生，KeyDB 比較時間戳保留較晚的、默默丟棄較早的。application 兩邊都以為自己寫成功了（本地確實 OK），但同步後只有一筆存活。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>不要讓同一個 key 被多個 region 並發寫——按 key 分區（user X 的寫入永遠路由到 region A），把多主退化成「就近讀 + 單點寫」</li>
<li>真的需要多點寫的計數器類資料，用 CRDT 語意的結構（KeyDB 的 LWW 不適合 counter，並發 INCR 會互相覆蓋而非累加）</li>
<li>接受 LWW 是 cache 的取捨——可重建的 cache 副本丟一筆寫入可回源重算，不可重建的資料不該放 active-active</li>
<li>衝突無聲是最危險的——加應用層的寫入審計（不靠 KeyDB 告警）</li>
</ol>
<h3 id="case-2clock-skew-讓較晚的判定錯亂">Case 2：clock skew 讓「較晚」的判定錯亂</h3>
<p><strong>徵兆</strong>：明明 region B 後寫的值，最後存活的卻是 region A 先寫的值——LWW 的「後寫者勝」失效。</p>
<p><strong>根因</strong>：LWW 比較時間戳，但兩個節點的系統時鐘若沒同步（clock skew），「較晚」的判定就錯了。B 的時鐘慢了 200ms，B 後寫的值帶的時間戳反而比 A 早，被判定為「較舊」丟棄。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>所有 KeyDB 節點強制 NTP 時鐘同步，把 skew 壓到毫秒級</li>
<li>監控節點間的時鐘偏差，skew 超過複製延遲就有 LWW 判定錯亂風險</li>
<li>對時間敏感的衝突，LWW 本質不可靠——時鐘永遠無法完美同步，這是 LWW 模型的固有弱點</li>
<li>需要正確衝突解決的場景，不要用 LWW 的 active-active，改強一致儲存</li>
</ol>
<h3 id="case-3複製延遲下的-stale-read">Case 3：複製延遲下的 stale read</h3>
<p><strong>徵兆</strong>：region A 寫入後，立刻有請求打到 region B 讀同一 key，讀到舊值；幾百毫秒後再讀才是新值。</p>
<p><strong>根因</strong>：active-active 是非同步複製，A 的寫入要經過網路傳到 B 才可見。在這個複製延遲窗口內，B 讀到的是 stale 值。跨 region 的延遲窗口比同 AZ 大得多。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>寫後需要立即一致讀的路徑，讀同一個寫入的節點（read-your-writes 綁定到寫入 region）</li>
<li>監控節點間複製延遲，跨 region 的延遲是 stale window 的下界</li>
<li>接受最終一致——這是 active-active 的本質，cache 場景多數可容忍短暫 stale</li>
<li>不可容忍 stale 的資料不適合 active-active，走單寫入點 + 跨區唯讀 replica</li>
</ol>
<h3 id="case-4複製拓樸設計錯流量放大或迴圈">Case 4：複製拓樸設計錯、流量放大或迴圈</h3>
<p><strong>徵兆</strong>：加了第三個 active 節點組成環狀後，節點間流量異常放大、CPU 升高，甚至同一筆寫入被反覆傳遞。</p>
<p><strong>根因</strong>：active-active 多節點（&gt; 2）的拓樸需要小心設計。全互連（full mesh）下每筆寫入要傳給所有其他節點、流量隨節點數平方成長；環狀拓樸若來源標記處理不當可能放大傳遞。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>多節點 active-active 優先用 full mesh 但控制節點數（active-active 不適合大量節點）</li>
<li>監控節點間複製流量，異常放大代表拓樸或來源標記問題</li>
<li>大規模多區優先考慮「每區單寫入點 + 跨區唯讀」而非全 active-active</li>
<li>active-active 的甜蜜點是 2-3 個區的雙向就近寫，不是大規模 mesh</li>
</ol>
<h3 id="case-5節點重連後的全量重同步衝擊">Case 5：節點重連後的全量重同步衝擊</h3>
<p><strong>徵兆</strong>：一個節點短暫斷線後重連，重連瞬間 CPU / 網路尖峰，期間延遲升高。</p>
<p><strong>根因</strong>：節點斷線時間過長、超過複製 backlog 能覆蓋的範圍，重連時要做全量重同步（full resync）——對方節點要產生快照（fork、見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence 的 fork 成本</a>，KeyDB 繼承 Redis 的 fork 機制）並傳輸整個 dataset。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設足夠大的 <code>repl-backlog-size</code>，讓短暫斷線走部分同步（partial resync）而非全量</li>
<li>重同步的 fork 成本跟記憶體 headroom 相關，節點要留 fork 空間</li>
<li>監控 <code>master_link_status</code>，頻繁 down / up 代表網路不穩、要先修網路</li>
<li>跨 region 的 active-active 對網路穩定性敏感，不穩的鏈路會頻繁觸發重同步</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>active-active 的容量判讀，核心在衝突率與複製健康：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同 key 跨節點並發寫入率</td>
          <td>接近 0（key 按區分區）</td>
          <td>高 → LWW 丟寫入風險、改 key 分區</td>
      </tr>
      <tr>
          <td>節點間 clock skew</td>
          <td>&lt; 複製延遲（毫秒級）</td>
          <td>大 → LWW 判定錯亂、強制 NTP</td>
      </tr>
      <tr>
          <td>節點間複製延遲</td>
          <td>跨 region 可接受的 stale 窗</td>
          <td>過大 → stale read 嚴重、檢查網路</td>
      </tr>
      <tr>
          <td><code>master_link_status</code></td>
          <td><code>up</code></td>
          <td>頻繁 down → 網路不穩、會觸發重同步</td>
      </tr>
      <tr>
          <td>active 節點數</td>
          <td>2-3（雙向就近寫）</td>
          <td>過多 → mesh 流量平方成長、改單寫入點拓樸</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>需要正確的衝突解決 / 不能丟寫入</strong>：LWW 不保證，走強一致儲存（<a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">database 模組</a> 的 multi-region 一致性方案）或單寫入點架構。</li>
<li><strong>需要 counter / 累加語意的多點寫</strong>：LWW 會讓並發 INCR 互相覆蓋，KeyDB active-active 不適合，改 CRDT 或單點 counter。</li>
<li><strong>跨 region 但可接受單寫入點</strong>：用 Redis / Valkey 的單向複製（一區寫、其他區唯讀），比 active-active 簡單且無衝突。</li>
<li><strong>大規模多區</strong>：active-active 的甜蜜點是 2-3 區，更大規模走 managed 的跨區方案（<a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache Global Datastore</a> 的 active-passive）。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>active-active 是 KeyDB 區別於 Redis 的核心能力之一，但它的取捨跨多個子系統：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB overview</a></strong>：overview 點到 active-active 是 last-write-wins、本文展開它什麼時候默默丟資料。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence / fork latency</a></strong>：KeyDB 繼承 Redis 的 fork 機制，節點重連的全量重同步付 fork 成本。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a></strong>：active-active 的 stale window 與 LWW 丟寫入，本質是「cache 副本的新鮮度與一致性邊界」議題的多主版本。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap KeyDB cross-cloud case</a></strong>：Snap 用 KeyDB 的主因是 cross-cloud latency 治理（cache 與 application 共置），active-active 的雙向就近寫是這類 multi-cloud 場景的工具，但要按 key 分區避開 LWW 衝突。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a></li>
<li>對照 vendor：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/shared-nothing-multicore-architecture/" data-link-title="DragonflyDB shared-nothing 多核架構：用 scale-up 取代 Redis Cluster" data-link-desc="Redis 要靠 Cluster 分片才能用滿一台多核機器，DragonflyDB 賭的是相反方向——單一進程 thread-per-core、shared-nothing、把單機推到 Redis 要好幾個 shard 才達到的規模。本文展開 thread-per-core 與 dashtable 的架構、fork-less snapshot、5 個把架構假設寫成 production 事故的踩坑，以及 scale-up 撞牆該回 Cluster 的邊界">DragonflyDB 多核架構</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">Redis Sentinel failover</a>（單向複製的 HA）</li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Kafka Replication、ISR 與 exactly-once：從 acks 到端到端不重不漏</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/replication-isr-exactly-once/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka&lt;/a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 &lt;em>寫入承諾&lt;/em> 跟 &lt;em>處理語義&lt;/em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線&lt;/h2>
&lt;p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 &lt;em>寫入承諾&lt;/em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、&lt;code>acks&lt;/code> 與 &lt;code>min.insync.replicas&lt;/code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 &lt;em>處理語義&lt;/em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。&lt;/p>
&lt;p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。&lt;/p>
&lt;p>這個拆分對映 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。&lt;/p>
&lt;h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本&lt;/h2>
&lt;p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 &lt;em>靜態配置&lt;/em> 轉成 &lt;em>動態保證&lt;/em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。&lt;/p>
&lt;p>一個 follower 留在 ISR 內的條件是：它在 &lt;code>replica.lag.time.max.ms&lt;/code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。&lt;/p>
&lt;p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、&lt;code>acks=all&lt;/code> 的寫入承諾會無法滿足 &lt;code>min.insync.replicas&lt;/code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。&lt;/p>
&lt;p>實機看 ISR 的方式是 &lt;code>kafka-topics.sh --describe&lt;/code>、Isr 欄位列出當前同步的 broker id：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># RF=3、min.insync.replicas=2 的 topic、三 broker 都同步時&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">kafka-topics.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># Topic: repl-demo PartitionCount: 1 ReplicationFactor: 3 Configs: min.insync.replicas=2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Topic: repl-demo Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Replicas 欄位是 &lt;em>配置上&lt;/em> 的 3 份副本、Isr 欄位是 &lt;em>當前實際同步&lt;/em> 的集合。兩者一致代表健康；Isr 比 Replicas 短代表有副本落後。日常巡檢用 &lt;code>kafka-topics.sh --describe --under-replicated-partitions&lt;/code> 直接列出 Isr 短於 Replicas 的 partition。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka</a> overview「Replication 與 exactly-once 升級」段的 implementation-layer deep article。Overview 已給出 partition / replication 的選型定位、本文展開 <em>寫入承諾</em> 跟 <em>處理語義</em> 兩條獨立軸線怎麼設、邊界在哪、成本是什麼。對應反例 <a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a>。</p></blockquote>
<h2 id="寫入承諾與處理語義是兩條獨立軸線">寫入承諾與處理語義是兩條獨立軸線</h2>
<p>Kafka 的可靠性拆成兩個彼此正交的問題、混在一起談是多數誤配的起點。第一條軸線是 <em>寫入承諾</em>：一筆訊息寫進 broker 後、在多少 replica 落地才算「成功」、broker 掛掉時這筆訊息會不會消失。這條軸線由 replication factor、ISR、<code>acks</code> 與 <code>min.insync.replicas</code> 共同決定、屬於 broker 端的耐久性保證。第二條軸線是 <em>處理語義</em>：同一筆訊息在 producer 重送、consumer 重啟、partition rebalance 等情境下、會不會被寫進去兩次或被處理兩次。這條軸線由 producer idempotence、transaction 與 consumer 端的 commit 設計決定、屬於端到端的正確性保證。</p>
<p>兩條軸線可以獨立調整：可以有「寫入承諾很強但處理語義是 at-least-once」的配置（acks=all + 非冪等 consumer）、也可以有「寫入承諾較弱但已開冪等」的配置。把 exactly-once 當成單一開關去找、是因為沒看出這兩條軸線存在。本文先講第一條（replication / ISR / acks）、再講第二條（idempotence / transaction）、最後談兩者疊起來能達成什麼、達不成什麼。</p>
<p>這個拆分對映 <a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a> 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 兩張知識卡：前者描述 broker 承諾的送達次數、後者描述處理端怎麼讓「送達多次」不等於「生效多次」。</p>
<h2 id="isr誰算跟得上的副本">ISR：誰算「跟得上」的副本</h2>
<p>ISR（in-sync replica、同步副本集）是一個 partition 當前「跟得上 leader」的 replica 集合、是 Kafka 把 replication factor 這個 <em>靜態配置</em> 轉成 <em>動態保證</em> 的關鍵概念。Replication factor = 3 只說明這個 partition 有 3 份 replica；但任一時刻真正跟得上 leader 的可能只有 2 份或 1 份。ISR 就是這個「當前實際同步」的集合、寫入承諾的判斷都基於 ISR、不是基於 replication factor。</p>
<p>一個 follower 留在 ISR 內的條件是：它在 <code>replica.lag.time.max.ms</code>（預設 30 秒）內持續向 leader 拉取資料、且追上 leader 的 log end offset。當 follower 因為 broker 慢、網路抖動、GC 停頓或 disk 壓力而落後超過這個時間窗、leader 會把它移出 ISR — 這就是 ISR shrink（收縮）。當它恢復、重新追上、再被加回 ISR — 這是 ISR expand（擴張）。</p>
<p>ISR 收縮本身不是故障、是 Kafka 對「這個 follower 暫時不可信」的誠實表態。真正的風險在於：ISR 收縮到某個程度後、<code>acks=all</code> 的寫入承諾會無法滿足 <code>min.insync.replicas</code> 而開始拒絕寫入。下一段的 acks 取捨直接建立在 ISR 這個概念上。</p>
<p>實機看 ISR 的方式是 <code>kafka-topics.sh --describe</code>、Isr 欄位列出當前同步的 broker id：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># RF=3、min.insync.replicas=2 的 topic、三 broker 都同步時</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-topics.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Topic: repl-demo  PartitionCount: 1  ReplicationFactor: 3  Configs: min.insync.replicas=2</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">#   Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1</span></span></span></code></pre></div><p>Replicas 欄位是 <em>配置上</em> 的 3 份副本、Isr 欄位是 <em>當前實際同步</em> 的集合。兩者一致代表健康；Isr 比 Replicas 短代表有副本落後。日常巡檢用 <code>kafka-topics.sh --describe --under-replicated-partitions</code> 直接列出 Isr 短於 Replicas 的 partition。</p>
<h2 id="acks-與-mininsyncreplicas寫入承諾的兩個旋鈕">acks 與 min.insync.replicas：寫入承諾的兩個旋鈕</h2>
<p>寫入承諾由 producer 端的 <code>acks</code> 跟 broker / topic 端的 <code>min.insync.replicas</code> 共同決定、兩者必須一起設才有意義。<code>acks</code> 決定 producer 在收到「成功」回應前、要等多少 replica 確認；<code>min.insync.replicas</code> 決定 broker 在 ISR 不足時是否拒絕寫入。前者是 producer 的等待策略、後者是 broker 的拒絕底線。</p>
<p><code>acks</code> 三個值對應遞增的耐久性與遞增的延遲成本：</p>
<table>
  <thead>
      <tr>
          <th>acks 值</th>
          <th>承諾</th>
          <th>資料風險</th>
          <th>延遲</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>不等任何確認、送出即視為成功</td>
          <td>leader 沒收到也不知道、broker 掛掉直接丟</td>
          <td>最低</td>
      </tr>
      <tr>
          <td>1</td>
          <td>leader 寫入本地 log 即回成功</td>
          <td>leader 確認後、follower 同步前掛掉、這筆訊息遺失</td>
          <td>中</td>
      </tr>
      <tr>
          <td>all</td>
          <td>ISR 內所有 replica 都確認才回成功</td>
          <td>ISR 內任一存活即不丟；ISR 不足 min.insync 時拒絕寫入</td>
          <td>最高</td>
      </tr>
  </tbody>
</table>
<p><code>acks=0</code> 適用「丟一兩筆無所謂」的場景、例如高頻 metric 上報、log shipping 的非關鍵層。它把網路往返成本壓到最低、代價是 producer 完全不知道 broker 有沒有收到。任何牽涉金流、訂單、狀態變更的訊息都不該用 acks=0。</p>
<p><code>acks=1</code> 是一個容易被誤以為安全的中間值。它只等 leader 寫入本地、不等 follower 同步。多數時候運作正常、但存在一個明確的資料遺失窗口：leader 回了成功、follower 還沒拉到這筆訊息、此時 leader 所在 broker 崩潰、新 leader 從 follower 中選出 — 那筆「已回成功」的訊息在新 leader 上不存在、producer 卻以為寫成功了。這個窗口在正常運行時很窄、但在 broker 滾動重啟、硬體故障、AZ 中斷時會被放大。</p>
<p><code>acks=all</code> 是耐久性配置的正解、但只有搭配 <code>min.insync.replicas ≥ 2</code> 才完整。單獨設 acks=all、若 <code>min.insync.replicas=1</code>、那麼當 ISR 收縮到只剩 leader 一份時、acks=all 等同 acks=1 — 「所有 ISR 確認」這個條件在 ISR 只剩 1 份時形同虛設。<code>min.insync.replicas=2</code> 補上這個漏洞：它要求 ISR 至少有 2 份才接受 acks=all 寫入、否則直接拒絕、把「靜默遺失」轉成「明確拒絕」。</p>
<p><code>min.insync.replicas</code> 是 topic-level 可動態調整的配置、不需重啟 broker：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 動態調整單一 topic 的 min.insync.replicas</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-configs.sh --alter --topic repl-demo <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --add-config min.insync.replicas<span class="o">=</span><span class="m">2</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --bootstrap-server kafka1:9092
</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="c1"># 查當前值、synonyms 會顯示 topic override 蓋過 broker default</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">kafka-configs.sh --describe --topic repl-demo --bootstrap-server kafka1:9092
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># min.insync.replicas=2 synonyms={DYNAMIC_TOPIC_CONFIG:min.insync.replicas=2,</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">#   DYNAMIC_DEFAULT_BROKER_CONFIG:min.insync.replicas=1, DEFAULT_CONFIG:min.insync.replicas=1}</span></span></span></code></pre></div><p>RF=3 + acks=all + min.insync.replicas=2 是業界對「不能丟資料」topic 的標準三件組：3 份副本提供冗餘、acks=all 要求同步確認、min.insync=2 在容忍一台 broker 掛掉的同時仍保證每筆寫入落在至少兩份 replica。容忍度的算術是 <code>RF - min.insync.replicas</code>：3 - 2 = 1、代表可以掉一台 broker 仍正常寫入、掉兩台則寫入被拒（但已寫入的資料不丟）。</p>
<h2 id="producer-idempotence去掉重送造成的重複">Producer idempotence：去掉重送造成的重複</h2>
<p>Producer idempotence（冪等生產者、<code>enable.idempotence=true</code>）解決的是 <em>producer 重送</em> 造成的 broker 端重複。它讓「producer 因為沒收到 ack 而重送同一筆訊息」這件事、在 broker 端被去重、不會寫進兩筆。這是處理語義軸線的第一塊、獨立於前面的寫入承諾。</p>
<p>問題的根源是：producer 送出訊息後、若因網路超時沒收到 broker 的 ack、它無法分辨是「訊息沒送到」還是「訊息送到了但 ack 在回程丟了」。預設行為是重送。在沒有冪等保護時、若實際是後者、broker 就收到兩筆相同訊息、partition 裡出現重複。</p>
<p>冪等機制的做法是給每個 producer 分配一個 producer ID（PID）、並為每個 partition 維護一個遞增的 sequence number。Broker 記住每個 (PID, partition) 已接受的最大 sequence；重送的訊息帶相同 sequence、broker 認出是重複、直接丟棄並回成功。這個保證的範圍是 <em>單一 producer session 內、單一 partition</em> 的精確一次寫入。</p>
<p>開啟方式是 producer 端設 <code>enable.idempotence=true</code>。在較新版 Kafka 這已是預設值、且它會隱含要求 <code>acks=all</code>、<code>retries &gt; 0</code>、<code>max.in.flight.requests.per.connection ≤ 5</code> — 因為冪等去重依賴這些前提。冪等的成本極低（broker 多維護 PID/sequence 的少量 metadata）、幾乎沒有理由關閉。</p>
<p>需要明確的邊界是：冪等只覆蓋 <em>同一個 producer session</em>。Producer 重啟後拿到新的 PID、broker 無法把新舊 session 的訊息關聯起來。跨 session 的去重、以及「寫多個 partition 要嘛全成功要嘛全失敗」的需求、要靠下一段的 transaction。</p>
<h2 id="kafka-transaction-與-read_committed跨-partition-的原子寫入">Kafka transaction 與 read_committed：跨 partition 的原子寫入</h2>
<p>Kafka transaction（交易）解決的是 <em>跨多個 partition 的原子寫入</em> 與 <em>consume-process-produce 的原子提交</em>。它讓一組寫入（可能跨多個 topic / partition）以及對應的 consumer offset commit、要嘛全部對下游可見、要嘛全部不可見。這是處理語義軸線的第二塊、建立在冪等之上。</p>
<p>典型場景是 stream processing 的 consume-process-produce 迴圈：consumer 讀入一批訊息、處理後產出結果寫到另一個 topic、然後 commit 讀取進度。若這三步不是原子的、崩潰時可能出現「結果已產出但 offset 沒 commit」（重啟後重複處理、重複產出）或「offset 已 commit 但結果沒寫成功」（訊息遺失）。Transaction 把「產出結果」跟「commit offset」綁成一個原子操作、消除這個窗口。</p>
<p>啟用 transaction 需要 producer 設一個穩定的 <code>transactional.id</code>、並在程式碼中走完整的 transaction 生命週期：</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">producer.initTransactions()      // 向 transaction coordinator 註冊、fence 掉舊 session
</span></span><span class="line"><span class="ln">2</span><span class="cl">producer.beginTransaction()
</span></span><span class="line"><span class="ln">3</span><span class="cl">  producer.send(record1)          // 跨多個 topic/partition 的寫入
</span></span><span class="line"><span class="ln">4</span><span class="cl">  producer.send(record2)
</span></span><span class="line"><span class="ln">5</span><span class="cl">  producer.sendOffsetsToTransaction(offsets, groupMetadata)  // consumer 進度也納入交易
</span></span><span class="line"><span class="ln">6</span><span class="cl">producer.commitTransaction()      // 全部原子提交；失敗則 abortTransaction()</span></span></code></pre></div><p><code>transactional.id</code> 提供跨 session 的 fencing（隔離）：同一個 transactional.id 的新 producer 啟動時、coordinator 會 fence 掉舊的、避免「殭屍 producer」在崩潰後復活還繼續寫。這是冪等的 PID 機制做不到的跨 session 保證。</p>
<blockquote>
<p><strong>實機限制</strong>：<code>kafka-console-producer.sh</code> 帶 <code>--producer-property transactional.id=...</code> 不會自動呼叫 <code>initTransactions()</code>、會直接報 <code>IllegalStateException: Cannot add partition ... before completing a call to initTransactions</code>。完整 transaction 生命週期只能在 client code 中驗證、無法用 console 工具演示。本文的 transaction 行為描述依官方 producer API 語義、生命週期程式碼未經本地 client 實機跑通。</p></blockquote>
<p>Transaction 的另一半在 consumer 端：<code>isolation.level=read_committed</code>。預設的 <code>read_uncommitted</code> 會讀到尚未 commit、甚至最終被 abort 的 transactional 訊息。設成 <code>read_committed</code> 後、consumer 只會看到已 commit 的 transactional 訊息、abort 的訊息對它不可見、未 commit 的訊息會被擋在 last stable offset（LSO）之前等待。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># consumer 以 read_committed 隔離級別讀取、只看已 commit 的 transactional 訊息</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-console-consumer.sh --topic repl-demo --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --isolation-level read_committed <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --bootstrap-server kafka1:9092</span></span></code></pre></div><p>需要注意：對非 transactional 的普通訊息、read_committed 跟 read_uncommitted 行為相同 — 普通訊息一律可見。隔離級別只對 transactional 訊息產生差異。這也是為什麼若上游沒有任何 transactional producer、把 consumer 改成 read_committed 不會有任何可觀察的效果。</p>
<h2 id="端到端-exactly-once-的邊界與成本">端到端 exactly-once 的邊界與成本</h2>
<p>端到端 exactly-once 的意思是：訊息從 producer 到 consumer 處理結果、整條路徑上「不重不漏」。它由前面所有零件疊出來、但有明確的適用邊界、不是萬用保證。</p>
<p>Kafka 原生能提供 exactly-once 的範圍是 <em>Kafka-to-Kafka 的封閉迴圈</em>：consume from Kafka、process、produce to Kafka、commit offset、整個用 transaction 綁定。Kafka Streams 框架把這套封裝成 <code>processing.guarantee=exactly_once_v2</code> 一個配置、底層就是 transaction + 冪等 + read_committed 的組合。在這個封閉迴圈內、exactly-once 是真實成立的。</p>
<p>邊界出現在 <em>離開 Kafka 的那一刻</em>。當處理結果要寫進外部系統（資料庫、HTTP API、第三方服務、寄信、扣款）、Kafka 的 transaction 管不到外部系統的提交。一筆訊息「已扣款但 offset commit 前崩潰」這種跨系統不一致、Kafka transaction 無法消除 — 它只保證 Kafka 內部的原子性。跨系統的 exactly-once 要靠外部系統自己的冪等鍵（idempotency key）、或 outbox pattern、或兩階段提交、由應用層補上、不是 Kafka 送的。</p>
<p>成本方面、exactly-once 不是免費的耐久性升級：</p>
<table>
  <thead>
      <tr>
          <th>成本維度</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>吞吐</td>
          <td>transaction 的 begin/commit 與 coordinator 往返增加 per-batch overhead、吞吐下降</td>
      </tr>
      <tr>
          <td>延遲</td>
          <td>read_committed 要等 LSO 推進、consumer 端引入額外延遲</td>
      </tr>
      <tr>
          <td>複雜度</td>
          <td>producer 要管 transaction 生命週期、abort 路徑、fencing；錯誤處理比 fire-forget 重</td>
      </tr>
      <tr>
          <td>coordinator 壓力</td>
          <td>transaction coordinator 與 <code>__transaction_state</code> topic 成為新的關鍵路徑與容量點</td>
      </tr>
  </tbody>
</table>
<p>務實的判斷是：先確認需求真的是 exactly-once、還是「at-least-once + 下游冪等」就夠。多數業務（包括金流）用 at-least-once 送達 + 下游用業務冪等鍵去重、就達到了「效果上不重複」、且吞吐與複雜度成本遠低於完整 transaction exactly-once。完整的 Kafka transaction exactly-once 留給 Kafka-to-Kafka 的 stream processing pipeline、那是它的甜蜜點。這個取捨對映 <a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 對「在哪一層放冪等」的判讀。</p>
<h2 id="故障演練">故障演練</h2>
<p>可靠性配置的價值在故障時才顯現。以下演練在 3-broker KRaft 叢集（RF=3、min.insync.replicas=2）上跑、用停 broker 製造 ISR 收縮、觀察各配置的真實行為。</p>
<h3 id="isr-收縮到低於-mininsyncreplicas-時-acksall-被拒">ISR 收縮到低於 min.insync.replicas 時 acks=all 被拒</h3>
<p><strong>演練</strong>：起 3-broker 叢集、建 RF=3 / min.insync.replicas=2 的 topic、初始 ISR = 三台全在。依序停掉兩個 follower broker、觀察 ISR 收縮、再用 acks=all produce。</p>
<p><strong>初始狀態</strong>（ISR 三份全在、acks=all 正常）：</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">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1
</span></span><span class="line"><span class="ln">2</span><span class="cl"># acks=all produce → exit=0</span></span></code></pre></div><p><strong>停一個 follower（broker 3）</strong>、ISR 收縮到 2 份、仍滿足 min.insync=2：</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">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,1
</span></span><span class="line"><span class="ln">2</span><span class="cl"># acks=all produce → exit=0（ISR=2 仍 &gt;= min.insync=2、寫入接受）</span></span></code></pre></div><p><strong>再停一個 follower（broker 1）</strong>、ISR 收縮到只剩 leader 1 份、低於 min.insync=2：</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"># acks=all produce → broker 拒絕：
</span></span><span class="line"><span class="ln">2</span><span class="cl">[Producer] Got error produce response ... Error: NOT_ENOUGH_REPLICAS, retrying
</span></span><span class="line"><span class="ln">3</span><span class="cl">org.apache.kafka.common.errors.NotEnoughReplicasException:
</span></span><span class="line"><span class="ln">4</span><span class="cl">  Messages are rejected since there are fewer in-sync replicas than required.</span></span></code></pre></div><p><strong>判讀</strong>：這正是 min.insync.replicas 的設計意圖在運作。ISR 不足時、broker 選擇 <em>明確拒絕寫入</em>（NOT_ENOUGH_REPLICAS）、而不是降級成 acks=1 默默接受。對 producer 而言、寫入失敗會觸發 retry、retry 耗盡後拋例外、上游應用感知到「現在寫不進去」、可以 fail-fast 或 backpressure — 而不是寫了一筆只在單一 broker 上、隨時可能隨那台 broker 一起消失的「假成功」訊息。把資料遺失轉成可觀測的寫入拒絕、是這個配置的全部目的。</p>
<p><strong>恢復</strong>：重啟兩個 broker、ISR 自動 expand 回三份、acks=all 恢復接受寫入：</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">Topic: repl-demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 1,2,3</span></span></code></pre></div><blockquote>
<p>附帶觀察：在 KRaft 模式下、controller 也是 quorum（本演練三台都兼任 controller）。同時停掉兩台、controller quorum 失去多數、<code>kafka-topics.sh --describe</code> 對 metadata 的查詢會 timeout（DisconnectException）。production 叢集應把 controller 數量與 broker 故障域分開規劃、避免 broker 故障連帶打垮 metadata 平面。</p></blockquote>
<h3 id="unclean-leader-election-的取捨">Unclean leader election 的取捨</h3>
<p>當一個 partition 的所有 ISR replica 都不可用、只剩一個 <em>曾經落後、已被踢出 ISR</em> 的 replica 還活著、Kafka 面臨一個無法兩全的選擇。<code>unclean.leader.election.enable=false</code>（預設）會選擇 <em>不選 leader</em>：這個 partition 進入不可用狀態、拒絕讀寫、直到某個 ISR replica 恢復。<code>unclean.leader.election.enable=true</code> 會選擇 <em>把那個落後的 replica 提為 leader</em>：partition 立刻恢復可用、代價是那個 replica 上缺失的訊息（leader 掛掉前已 commit 但它還沒同步到的部分）永久遺失。</p>
<p><strong>判讀</strong>：這是一個 <em>可用性 vs 耐久性</em> 的直接取捨、沒有正確答案、只有對映業務的選擇。對金流、訂單、審計這類「丟一筆都不行」的 topic、保持 false、寧可 partition 短暫不可用也不接受靜默資料遺失。對 metric、log、可重算的衍生資料、開 true 換可用性、丟幾筆可接受。預設 false 是合理的安全預設、但要意識到它的代價是「所有 replica 都不在 ISR 時、partition 會卡住不可用」、這在多 broker 同時故障時會發生。</p>
<h3 id="idempotent-producer-對重送去重">Idempotent producer 對重送去重</h3>
<p><strong>演練</strong>：producer 開 <code>enable.idempotence=true</code>、acks=all、模擬 ack 丟失導致的重送。</p>
<p><strong>判讀</strong>：冪等開啟後、producer 因網路超時重送的訊息帶相同 (PID, partition, sequence)、broker 認出 sequence 重複、丟棄重送並回成功、partition 內不出現重複。實機上 <code>enable.idempotence=true</code> 的 produce 寫入正常（exit=0）、消費端讀回的訊息數等於實際送出的邏輯訊息數、重送不放大。要記住的邊界仍是：這只覆蓋單一 producer session；producer 重啟換 PID 後、跨 session 的重複要靠 transaction 或下游冪等鍵處理。</p>
<h3 id="transaction-中途失敗的-read_committed-隔離">Transaction 中途失敗的 read_committed 隔離</h3>
<p><strong>演練</strong>：transactional producer 在 beginTransaction 後寫入若干訊息、然後 abortTransaction（模擬處理中途失敗）；consumer 分別用 read_uncommitted 與 read_committed 讀取。</p>
<p><strong>判讀</strong>：read_committed 的 consumer 看不到被 abort 的訊息 — 中途失敗的 transaction 對它等於沒發生過、不會讀到「處理一半的髒資料」。read_uncommitted 的 consumer 則會讀到這些最終被 abort 的訊息、若據此處理就產生了不該發生的副作用。這是 transaction 隔離的核心價值：把「transaction 失敗」的可見性控制在 commit 邊界內。</p>
<blockquote>
<p>本段的 abort 行為依官方 transaction 語義描述。本地以 <code>kafka-console-consumer.sh --isolation-level read_committed</code> 驗證了隔離級別參數可用、且對已 commit 的普通訊息 read_committed 與 read_uncommitted 輸出一致（普通訊息一律可見、隔離級別只對 transactional 訊息產生差異）；完整的 begin/abort transaction 生命週期需 client code、未用 console 工具跑通。</p></blockquote>
<h2 id="capacity--cost">Capacity / cost</h2>
<p>各配置的容量與成本影響、決定它適用的規模與 topic 類別：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>吞吐 / 延遲影響</th>
          <th>適用</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>acks=0</td>
          <td>最低延遲、最高吞吐</td>
          <td>可丟的 metric / log shipping</td>
          <td>任何狀態變更類訊息不可用</td>
      </tr>
      <tr>
          <td>acks=1</td>
          <td>中等、單次往返</td>
          <td>容忍極少量遺失的衍生資料</td>
          <td>誤當安全選項、broker 故障窗口會遺失</td>
      </tr>
      <tr>
          <td>acks=all + min.insync=2 + RF=3</td>
          <td>延遲 +1 次跨 broker 往返、吞吐略降</td>
          <td>不能丟的業務訊息</td>
          <td>min.insync 沒設則 acks=all 在 ISR=1 時失效</td>
      </tr>
      <tr>
          <td>enable.idempotence=true</td>
          <td>幾乎無額外成本</td>
          <td>所有 producer 預設開</td>
          <td>只覆蓋單一 session</td>
      </tr>
      <tr>
          <td>transaction + read_committed</td>
          <td>begin/commit overhead、read 端 LSO 等待延遲</td>
          <td>Kafka-to-Kafka stream processing 封閉迴圈</td>
          <td>跨外部系統不成立、coordinator 成新關鍵路徑</td>
      </tr>
  </tbody>
</table>
<p>務實 default：</p>
<ul>
<li>業務 topic 一律 RF=3 + acks=all + min.insync.replicas=2、idempotence 預設開</li>
<li>容忍度算術 <code>RF - min.insync.replicas</code> 要 ≥ 1、否則單台 broker 維護就會中斷寫入</li>
<li>完整 transaction exactly-once 只給 Kafka-to-Kafka pipeline；跨系統用 at-least-once + 下游冪等鍵</li>
<li>unclean.leader.election 保持 false、除非該 topic 明確可丟資料換可用性</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-processing-recovery-semantics-對位">跟 processing-recovery-semantics 對位</h3>
<p>寫入承諾保證訊息留在 broker、但 <em>處理</em> 的不重不漏在 consumer 端。<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a> 展開 consumer 的 commit 時機、崩潰恢復的 replay 範圍、以及「冪等放在哪一層」的判讀 — 跟本文的 transaction exactly-once 邊界互補：本文界定 Kafka 能送什麼、那篇界定處理端怎麼接才不放大重複。</p>
<h3 id="跟-event-contract-replay-boundary-對位">跟 event-contract-replay-boundary 對位</h3>
<p>Exactly-once 的封閉迴圈假設訊息格式穩定、replay 可重現。<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a> 展開 schema 演進與 replay 邊界 — 當 transaction 提供的原子性遇上 schema 變更、replay 舊訊息的可重現性會受 contract 影響、是 exactly-once 在時間維度上的延伸限制。</p>
<h3 id="對應反例-3c9">對應反例 3.C9</h3>
<p><a href="/blog/backend/03-message-queue/cases/failure-queue-semantics-mismatch-cutover/" data-link-title="3.C9 反例：Queue 語義切換誤配" data-link-desc="at-least-once / exactly-once 語義誤配導致資料重複與遺漏。">3.C9 Queue 語義誤配</a> 是本文兩條軸線混淆的真實後果：broker 遷移後「名稱上相近的 delivery semantics」在失敗重播時產生不同結果、出現重複扣款與狀態漏更新。判讀路徑正是本文的拆分 — 先確認是寫入承諾（acks / ISR）還是處理語義（idempotence / commit 時機）出問題、不要用 queue depth 這種寫入承諾層的指標去判斷處理語義層的故障。</p>
<h3 id="對應案例-3c21-goldman-sachs-msk-遷移">對應案例 3.C21 Goldman Sachs MSK 遷移</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-goldman-sachs-msk-migration/" data-link-title="3.C21 Goldman Sachs：MSK 遷移 with MirrorMaker 2" data-link-desc="Goldman Sachs Global Investment Research 從 on-prem Kafka 遷到 MSK、用 MM2 同步 topic/ACL/offset、atomic cutover 7 小時完成。">3.C21 Goldman Sachs MSK 遷移</a> 揭露遷移時可靠性配置的細節風險集中在 client 端的 timeout / flush / LB 配置、而非 broker 本身。本文的 acks=all 在 ISR 不足時拒絕寫入、若 client 端的 retry 與 timeout 沒對齊（如 flush timeout 太短）、會把「broker 正常的 backpressure」誤判成「遷移失敗」。可靠性配置與 client 容錯參數要一起驗證。</p>
<h3 id="下一步路由">下一步路由</h3>
<ul>
<li>上游概念：<a href="/blog/backend/knowledge-cards/delivery-semantics/" data-link-title="Delivery Semantics" data-link-desc="說明事件投遞語意如何定義遺失、重複、順序與補償策略">delivery semantics</a>、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 知識卡</li>
<li>同 vendor：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">Kafka overview</a> 的 producer / consumer 設計段</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/processing-recovery-semantics/" data-link-title="3.6 Processing Semantics 與 Recovery Semantics" data-link-desc="說明投遞成功、處理成功與恢復成功為何是三個不同判斷。">3.6 processing-recovery-semantics</a>、<a href="/blog/backend/03-message-queue/event-contract-replay-boundary/" data-link-title="3.7 Event Contract 與 Replay Boundary" data-link-desc="說明 event schema、idempotency key、replay window 與補償如何先於 broker 選型。">3.7 event-contract-replay-boundary</a>、<a href="/blog/backend/06-reliability/idempotency-replay/" data-link-title="6.12 Idempotency 與 Replay 驗證" data-link-desc="把重試、重播與冪等性從口頭約定變成可驗證屬性">6.12 idempotency / replay</a></li>
<li>方法論：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/replication-topology/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/replication-topology/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>replication topology&lt;/em> — 從 single primary 到 multi-replica 部署的 3 個 trade-off 軸跟 5 段配置。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇&lt;/h2>
&lt;p>Replication mode 選擇看起來是「選 async 還是 semi-sync」、但決策實際是 3 個獨立 trade-off 軸的權衡、async / semi-sync 是這些軸的兩個常見組合 &lt;em>名稱&lt;/em>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>軸&lt;/th>
 &lt;th>端 A&lt;/th>
 &lt;th>端 B&lt;/th>
 &lt;th>MySQL 旋鈕&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Durability&lt;/strong>&lt;/td>
 &lt;td>primary 寫完就 commit&lt;/td>
 &lt;td>至少一個 standby 收到才 commit&lt;/td>
 &lt;td>&lt;code>rpl_semi_sync_master_enabled&lt;/code> / sync ack count&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Latency&lt;/strong>&lt;/td>
 &lt;td>client 等 primary 寫完 OK&lt;/td>
 &lt;td>client 等 standby ack（額外 RTT）&lt;/td>
 &lt;td>&lt;code>rpl_semi_sync_master_timeout&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
 &lt;td>replica 隨時可能 stale&lt;/td>
 &lt;td>replica 跟 primary 保證讀到一致&lt;/td>
 &lt;td>application read routing rule（不是 replication 旋鈕）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「async vs semi-sync」實際上是 &lt;em>durability + latency 兩軸&lt;/em> 的選擇、不影響 &lt;em>consistency 軸&lt;/em>（consistency 在 read routing 層決定）。Group Replication / MySQL Cluster（synchronous multi-primary）會同時改三軸、是另一個故事、不在本文 scope。&lt;/p>
&lt;p>跟這三軸獨立的、是 &lt;em>replication 機制本身的可維護性&lt;/em>。binlog position-based replication 用 &lt;code>(file, position)&lt;/code> 標 replica 進度、failover 時要對齊 position 容易出錯；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/gtid/" data-link-title="GTID" data-link-desc="說明全域交易識別碼如何讓複製進度與故障切換不依賴實體 log 位置">&lt;strong>GTID（Global Transaction Identifier）&lt;/strong>&lt;/a>用全域 transaction ID 標進度、failover / re-pointing 不必算 position。GTID 是 &lt;em>跨 mode 的 infrastructure&lt;/em>、不是第三種 mode。&lt;/p>
&lt;h2 id="async-replicationdefault--高-throughput-的代價">Async replication：default + 高 throughput 的代價&lt;/h2>
&lt;p>Async 是 MySQL 預設、行為：&lt;/p>
&lt;ol>
&lt;li>Primary 寫 binlog、立刻 commit、回應 client OK&lt;/li>
&lt;li>Replica 的 IO thread 從 primary pull binlog event 到 local relay log&lt;/li>
&lt;li>Replica 的 SQL thread apply relay log（單 thread 或 multi-thread parallel）&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Trade-off&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>replication topology</em> — 從 single primary 到 multi-replica 部署的 3 個 trade-off 軸跟 5 段配置。</p></blockquote>
<hr>
<h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇</h2>
<p>Replication mode 選擇看起來是「選 async 還是 semi-sync」、但決策實際是 3 個獨立 trade-off 軸的權衡、async / semi-sync 是這些軸的兩個常見組合 <em>名稱</em>：</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>端 A</th>
          <th>端 B</th>
          <th>MySQL 旋鈕</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Durability</strong></td>
          <td>primary 寫完就 commit</td>
          <td>至少一個 standby 收到才 commit</td>
          <td><code>rpl_semi_sync_master_enabled</code> / sync ack count</td>
      </tr>
      <tr>
          <td><strong>Latency</strong></td>
          <td>client 等 primary 寫完 OK</td>
          <td>client 等 standby ack（額外 RTT）</td>
          <td><code>rpl_semi_sync_master_timeout</code></td>
      </tr>
      <tr>
          <td><strong>Consistency</strong></td>
          <td>replica 隨時可能 stale</td>
          <td>replica 跟 primary 保證讀到一致</td>
          <td>application read routing rule（不是 replication 旋鈕）</td>
      </tr>
  </tbody>
</table>
<p>「async vs semi-sync」實際上是 <em>durability + latency 兩軸</em> 的選擇、不影響 <em>consistency 軸</em>（consistency 在 read routing 層決定）。Group Replication / MySQL Cluster（synchronous multi-primary）會同時改三軸、是另一個故事、不在本文 scope。</p>
<p>跟這三軸獨立的、是 <em>replication 機制本身的可維護性</em>。binlog position-based replication 用 <code>(file, position)</code> 標 replica 進度、failover 時要對齊 position 容易出錯；<a href="/blog/backend/knowledge-cards/gtid/" data-link-title="GTID" data-link-desc="說明全域交易識別碼如何讓複製進度與故障切換不依賴實體 log 位置"><strong>GTID（Global Transaction Identifier）</strong></a>用全域 transaction ID 標進度、failover / re-pointing 不必算 position。GTID 是 <em>跨 mode 的 infrastructure</em>、不是第三種 mode。</p>
<h2 id="async-replicationdefault--高-throughput-的代價">Async replication：default + 高 throughput 的代價</h2>
<p>Async 是 MySQL 預設、行為：</p>
<ol>
<li>Primary 寫 binlog、立刻 commit、回應 client OK</li>
<li>Replica 的 IO thread 從 primary pull binlog event 到 local relay log</li>
<li>Replica 的 SQL thread apply relay log（單 thread 或 multi-thread parallel）</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Durability：primary 寫完 commit、replica 還沒 pull = primary 在這瞬間 crash + 永久故障 → <em>data loss</em>（已 commit 的 transaction 在 replica 不存在）</li>
<li>Latency：client 不等 replica、寫入延遲 = primary 自身寫 binlog 的時間（通常 &lt; 1ms with <code>innodb_flush_log_at_trx_commit=1</code>）</li>
<li>Consistency：replica 可能 lag、application 讀 replica 會 stale；用 <code>SHOW SLAVE STATUS</code> 看 <code>Seconds_Behind_Master</code></li>
</ul>
<p><strong>適用</strong>：</p>
<ul>
<li>主流選擇（90% 場景）</li>
<li>Failover loss 在容忍範圍（多數 web 應用容忍 1-2 秒 data loss）</li>
<li>Read scaling 為主要 driver、絕對 durability 非首要</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>金融交易 / 訂單系統、不允許 any data loss</li>
<li>Compliance 要求 zero data loss（PCI-DSS / 部分監管場景）</li>
</ul>
<h2 id="semi-sync-replication至少一個-standby-ack-才-commit">Semi-sync replication：至少一個 standby ack 才 commit</h2>
<p>Semi-sync 在 async 基礎上加 <em>primary 等至少 N 個 replica ack 才 commit</em> 的步驟：</p>
<ol>
<li>Primary 寫 binlog</li>
<li>Primary 發送 binlog event 到所有 replica</li>
<li><em>Primary 等至少 N 個 replica 回 ack</em>（N 是 <code>rpl_semi_sync_master_wait_for_slave_count</code>、預設 1）</li>
<li>Primary commit、回應 client</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Durability：至少 N 個 replica 收到 binlog（不一定 apply）、primary crash 後 replica 還有 binlog 可 promote、保證 zero data loss（但是 <em>binlog-level</em>、不是 <em>applied-level</em>）</li>
<li>Latency：client 等 primary + 一輪 replica ack RTT；跨 AZ 通常 +1-3ms、跨 region 可能 +50-200ms</li>
<li>Consistency：跟 async 一樣、replica apply 仍 async、application 讀 replica 仍可能 stale</li>
</ul>
<p><strong>MySQL 5.7+ 區分 <em>standard</em> 跟 <em>Loss-Less</em> semi-sync</strong>：</p>
<ul>
<li>Standard semi-sync（5.5-5.6）：primary 先 commit 再等 ack、ack 超時 fallback 成 async — <em>仍可能 lose data</em></li>
<li>Loss-Less semi-sync（5.7+、<code>rpl_semi_sync_master_wait_point=AFTER_SYNC</code>）：primary 寫完 binlog 但 <em>先等 ack 再 commit</em>、ack 超時 fallback async 之前已寫 binlog 仍保證 durable</li>
</ul>
<p>Production 場景必須用 Loss-Less semi-sync、不是 standard。</p>
<p><strong>適用</strong>：</p>
<ul>
<li>金融交易 / 訂單 / payment ledger</li>
<li>不允許 data loss、可接受寫入延遲 +1-3ms</li>
<li>已有 multi-AZ / multi-region 部署、replica 物理上可靠</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>跨 region semi-sync（RTT 50-200ms）通常不划算 — 寫吞吐砍半、改用 <em>region-local sync replica + cross-region async chain</em></li>
<li>寫吞吐 &gt; 50K WPS 且容忍 sub-second loss — async 即可</li>
</ul>
<h2 id="gtid-based-replication機制升級跨-mode-都需要">GTID-based replication：機制升級、跨 mode 都需要</h2>
<p>GTID 把每個 transaction 標一個全域 ID：<code>&lt;server_uuid&gt;:&lt;transaction_id&gt;</code>。Replica 紀錄「已 apply 的 GTID set」、不再用 <code>(binlog_file, position)</code>。</p>
<p><strong>為什麼 GTID 比 binlog position 好</strong>：</p>
<ul>
<li><strong>Failover re-pointing 簡單</strong>：promote 新 primary 後、其他 replica 重新 attach 不必算 <code>MASTER_LOG_FILE</code> + <code>MASTER_LOG_POS</code>、用 <code>CHANGE MASTER TO MASTER_AUTO_POSITION=1</code> 即可</li>
<li><strong>Multi-source replication 可行</strong>：一個 replica 從多個 primary 拉、各 primary 的 GTID set 獨立 track</li>
<li><strong>Consistency check 容易</strong>：兩個 server 對 GTID set、就知道誰落後、有無 gap</li>
<li><strong>跟 group replication / MySQL Cluster 必需</strong>：5.7+ 多 primary 場景 GTID 是前提</li>
</ul>
<p><strong>設定流程</strong>（兩階段、不能直接開）：</p>
<ol>
<li>
<p><strong>Phase 1 (預備、所有 server 同 mode)</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">gtid_mode</span> <span class="o">=</span> <span class="s">ON_PERMISSIVE  -- 接受 GTID 跟 non-GTID transaction</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">enforce_gtid_consistency</span> <span class="o">=</span> <span class="s">ON  -- 拒絕無法用 GTID 表達的 statement（CREATE TABLE...SELECT 等）</span></span></span></code></pre></div></li>
<li>
<p><strong>Phase 2 (rolling、全部 server 都 Phase 1 後)</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">gtid_mode</span> <span class="o">=</span> <span class="s">ON  -- 只接受 GTID transaction</span></span></span></code></pre></div></li>
</ol>
<p>跳 phase 直接 <code>gtid_mode=ON</code> 會讓 replication break（既有 non-GTID transaction 無法處理）。Production 啟用 GTID 要排 maintenance window、跑完 phase 1 觀察 1-2 天再進 phase 2。</p>
<h2 id="配置-step-by-steploss-less-semi-sync--gtid-組合">配置 step-by-step（Loss-Less semi-sync + GTID 組合）</h2>
<p>實務最常見組合：Loss-Less semi-sync + GTID。配置順序：</p>
<h3 id="step-1primary--replica-都開-gtid兩-phase-跑完">Step 1：Primary + replica 都開 GTID（兩 phase 跑完）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># my.cnf on primary AND replica</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">gtid_mode</span> <span class="o">=</span> <span class="s">ON</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">enforce_gtid_consistency</span> <span class="o">=</span> <span class="s">ON</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">log_bin</span> <span class="o">=</span> <span class="s">mysql-bin</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">log_slave_updates</span> <span class="o">=</span> <span class="s">1  -- replica 也記 binlog (chained replication 需要)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">binlog_format</span> <span class="o">=</span> <span class="s">ROW    -- ROW 比 STATEMENT 安全</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">sync_binlog</span> <span class="o">=</span> <span class="s">1        -- 每次 commit fsync binlog</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">innodb_flush_log_at_trx_commit</span> <span class="o">=</span> <span class="s">1  -- 每次 commit fsync InnoDB log</span></span></span></code></pre></div><h3 id="step-2primary-安裝-semi-sync-plugin">Step 2：Primary 安裝 semi-sync plugin</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">INSTALL</span><span class="w"> </span><span class="n">PLUGIN</span><span class="w"> </span><span class="n">rpl_semi_sync_master</span><span class="w"> </span><span class="n">SONAME</span><span class="w"> </span><span class="s1">&#39;semisync_master.so&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_enabled</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_wait_for_slave_count</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 至少 1 個 ack
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_wait_point</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">AFTER_SYNC</span><span class="p">;</span><span class="w">   </span><span class="c1">-- Loss-Less
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_master_timeout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">10000</span><span class="p">;</span><span class="w">           </span><span class="c1">-- 10s timeout、超時 fallback async</span></span></span></code></pre></div><h3 id="step-3replica-安裝-semi-sync-plugin">Step 3：Replica 安裝 semi-sync plugin</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">INSTALL</span><span class="w"> </span><span class="n">PLUGIN</span><span class="w"> </span><span class="n">rpl_semi_sync_slave</span><span class="w"> </span><span class="n">SONAME</span><span class="w"> </span><span class="s1">&#39;semisync_slave.so&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SET</span><span class="w"> </span><span class="k">GLOBAL</span><span class="w"> </span><span class="n">rpl_semi_sync_slave_enabled</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="n">STOP</span><span class="w"> </span><span class="n">SLAVE</span><span class="w"> </span><span class="n">IO_THREAD</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">START</span><span class="w"> </span><span class="n">SLAVE</span><span class="w"> </span><span class="n">IO_THREAD</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 重啟 IO thread 啟用 semi-sync</span></span></span></code></pre></div><h3 id="step-4replica-attach-primary">Step 4：Replica attach primary</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">CHANGE</span><span class="w"> </span><span class="n">MASTER</span><span class="w"> </span><span class="k">TO</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="n">MASTER_HOST</span><span class="o">=</span><span class="s1">&#39;primary.example.com&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="n">MASTER_PORT</span><span class="o">=</span><span class="mi">3306</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="n">MASTER_USER</span><span class="o">=</span><span class="s1">&#39;repl&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="n">MASTER_PASSWORD</span><span class="o">=</span><span class="s1">&#39;...&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="n">MASTER_AUTO_POSITION</span><span class="o">=</span><span class="mi">1</span><span class="p">;</span><span class="w">  </span><span class="c1">-- 用 GTID auto-position
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">START</span><span class="w"> </span><span class="n">SLAVE</span><span class="p">;</span></span></span></code></pre></div><h3 id="step-5驗證">Step 5：驗證</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- Primary: 確認 semi-sync 啟用 + 有 active client
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_status&#39;</span><span class="p">;</span><span class="w">      </span><span class="c1">-- ON
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_clients&#39;</span><span class="p">;</span><span class="w">     </span><span class="c1">-- ≥ 1
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_yes_tx&#39;</span><span class="p">;</span><span class="w">      </span><span class="c1">-- &gt; 0 (有 transaction 走 semi-sync)
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;Rpl_semi_sync_master_no_tx&#39;</span><span class="p">;</span><span class="w">       </span><span class="c1">-- 應該 = 0 (沒有 fallback 成 async)
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- Replica: 確認 GTID + IO thread 正常
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">SLAVE</span><span class="w"> </span><span class="n">STATUS</span><span class="err">\</span><span class="k">G</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- Slave_IO_Running: Yes
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- Slave_SQL_Running: Yes
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- Retrieved_Gtid_Set: 跟 primary Executed_Gtid_Set 接近
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- Seconds_Behind_Master: 觀察 lag</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-replication-lag-暴衝--單-sql-thread-bottleneck">1. Replication lag 暴衝 — 單 SQL thread bottleneck</h3>
<p>預設 replica 的 SQL thread 是 <em>單 thread</em> apply、primary 多 thread 寫入時 replica 跟不上、lag 從 &lt; 100ms 飆到分鐘級。常見觸發：批次 UPDATE / DELETE、大 transaction、index rebuild。</p>
<p>修法：</p>
<ul>
<li>啟用 <em>multi-thread replication</em>：<code>slave_parallel_workers = 8</code>（per database 或 per logical clock parallel）</li>
<li>5.7+ 用 <code>slave_parallel_type = LOGICAL_CLOCK</code>：依 primary 上的 group commit 並行度自動 parallel</li>
<li>8.0+ 的 <em>writeset-based parallel</em>：<code>binlog_transaction_dependency_tracking = WRITESET</code>、更細粒度並行</li>
</ul>
<p>監控：<code>Seconds_Behind_Master</code> 是 <em>表面指標</em>、實際看 <code>Executed_Gtid_Set</code> 跟 primary 對比的 GTID gap 更準。</p>
<h3 id="2-semi-sync-timeout-fallback-成-async沒監控就看不見">2. Semi-sync timeout fallback 成 async（沒監控就看不見）</h3>
<p><code>rpl_semi_sync_master_timeout</code> 預設 10000ms（10 秒）、超時後 <em>自動 fallback async</em>、直到 replica 重連。Application 視角看不到任何 error、但 <em>durability guarantee 已失效</em>。</p>
<p>修法：</p>
<ul>
<li>監控 <code>Rpl_semi_sync_master_status</code> — fallback 後變 OFF</li>
<li>監控 <code>Rpl_semi_sync_master_no_tx</code> — fallback 期間每個 transaction 都計數</li>
<li>Alert 規則：5 分鐘內 <code>no_tx</code> 增加 &gt; 0 即告警</li>
<li>Timeout 設太短（&lt; 5s）容易 false positive、設太長（&gt; 30s）crash 時 data loss 風險增</li>
</ul>
<h3 id="3-gtid-gap--replica-無法-attach">3. GTID gap — replica 無法 attach</h3>
<p>Replica 重新 attach primary 時報 <code>ERROR 1236: ... transactions you need from master are purged</code>、原因是 primary 的 <code>binlog_expire_logs_seconds</code> 過短、需要的 binlog 已被清掉。GTID 模式下這個錯誤更明顯（直接看 GTID gap）、但 binlog position 模式下也一樣。</p>
<p>修法：</p>
<ul>
<li><code>binlog_expire_logs_seconds = 604800</code>（7 天）作為 baseline</li>
<li>大流量 server 確認 disk 容量能撐 7 天 binlog（一個高峰小時 binlog 可能 GB 級）</li>
<li>真的 gap 太大時用 <em>base backup + replay binlog</em> 重建 replica、不要硬 reset GTID</li>
</ul>
<h3 id="4-loss-less-semi-sync-不一定真的-loss-less">4. Loss-Less semi-sync 不一定真的 loss-less</h3>
<p><code>AFTER_SYNC</code> 模式 <em>primary 寫 binlog → 等 ack → commit</em>、看起來 zero loss。但 <em>primary 寫完 binlog 還沒等 ack 時 crash</em> + replica <em>剛好沒收到那個 binlog event</em> + replica promote — 這個 binlog event 在新 primary 不存在、但舊 primary 的 binlog 仍紀錄為 <em>已寫 binlog 未 commit</em>。client 收到 <em>connection lost</em>、不知道 transaction 是否成功。</p>
<p>修法：</p>
<ul>
<li>接受這個 <em>edge case unknown state</em>、application 用 idempotency key + retry 處理</li>
<li>Loss-Less semi-sync 保證的是 <em>已 commit transaction 不會丟</em>、不是 <em>所有寫入都 ack-and-tell</em></li>
<li>真的 zero unknown state 需要 group replication / Galera Cluster / MySQL Cluster（synchronous multi-primary）</li>
</ul>
<h3 id="5-chained-replication-雪崩">5. Chained replication 雪崩</h3>
<p>Topology 是 <code>primary → replica1 → replica2 → ...</code>（hub-and-spoke 之外的選擇、節省 primary 出口頻寬）。Replica1 SQL thread 卡住、replica2 跟 replica3 都被 block、整條 chain 雪崩。</p>
<p>修法：</p>
<ul>
<li>避免超過 2 層 chain（primary → tier1 replica → tier2 replica 是上限）</li>
<li>用 <em>parallel binary log relay</em>（5.7+ <code>slave_pending_jobs_size_max</code> + parallel workers）讓 chain 中段不阻塞</li>
<li>規模真的大、改用 <em>binlog server</em>（如 Maxwell / MaxScale）解耦 chain dependency</li>
<li>跨 region 用 <em>region-local hub + cross-region async</em>、不是長 chain</li>
</ul>
<h2 id="容量--cost-對照">容量 / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>寫吞吐影響</th>
          <th>Replica overhead</th>
          <th>適合 workload</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Async + binlog position</td>
          <td>baseline</td>
          <td>低（IO + SQL thread）</td>
          <td>高吞吐、容忍 sub-second loss</td>
      </tr>
      <tr>
          <td>Async + GTID</td>
          <td>baseline</td>
          <td>同上、failover 容易</td>
          <td>大多數 production 預設</td>
      </tr>
      <tr>
          <td>Loss-Less semi-sync + GTID（1 ack）</td>
          <td>-10% ~ -20%</td>
          <td>同上 + ack RTT</td>
          <td>金融、訂單、不容忍 data loss</td>
      </tr>
      <tr>
          <td>Loss-Less semi-sync + GTID（2 ack）</td>
          <td>-15% ~ -30%</td>
          <td>同上、跨 AZ</td>
          <td>強 durability + multi-AZ HA</td>
      </tr>
      <tr>
          <td>Group Replication（synchronous）</td>
          <td>-30% ~ -50%</td>
          <td>高（每 transaction quorum）</td>
          <td>不允許 single-primary、multi-primary 寫入</td>
      </tr>
  </tbody>
</table>
<p>跨 AZ semi-sync 通常加 1-3ms、跨 region 加 50-200ms — 寫密集 workload 跨 region semi-sync 通常不划算、改用 <em>region-local sync + cross-region async chain</em>。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="aurora-mysql">Aurora MySQL</h3>
<p>Aurora MySQL 用 <em>AWS-managed storage layer</em>、storage 自動 replicate 6 份跨 3 AZ、不需要應用層配 semi-sync。從自管 MySQL 遷 Aurora 時、上方所有 semi-sync 配置 <em>消失</em>、改成 Aurora storage quorum（4 of 6 write、3 of 6 read）。</p>
<p>trade-off 軸的 <em>durability</em> 完全交給 Aurora、application 只關心 <em>latency</em> + <em>consistency</em>。詳見 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>。</p>
<h3 id="vitesssharding-layer">Vitess（sharding layer）</h3>
<p>Vitess shard 內部仍用 MySQL replication（async or semi-sync）、Vitess 不取代 replication topology、是 <em>上層 routing</em>。Vitess <code>vttablet</code> 每個 shard 有自己的 primary + replica、跟本文 topology 設計一致。</p>
<p>Vitess 比較大議題在 <em>cross-shard transaction</em>（VReplication 跨 shard binlog stream）、不是 replication topology — 詳見 MySQL backlog 中 <em>Vitess sharding 設計</em> 篇（待寫）。</p>
<h3 id="proxysqlread-replica-routing">ProxySQL（read replica routing）</h3>
<p>ProxySQL 是 MySQL 生態的 <em>connection pool + query routing</em> 標準、按 query type（SELECT vs DML）跟 replica lag 自動 route。寫入路 primary、讀走 replica、replica lag &gt; N 秒時暫時退路 primary 維持 consistency。</p>
<p>ProxySQL 跟本文 replication topology 是 <em>互補不重疊</em> — replication 設定哪些 server 有什麼資料、ProxySQL 設定 query 怎麼分配。詳見 MySQL backlog 中 <em>ProxySQL 配置</em> 篇（待寫）。</p>
<h3 id="orchestratorha-failover">Orchestrator（HA failover）</h3>
<p>Orchestrator 是 MySQL HA topology 管理 + 自動 failover 工具、用 GTID 偵測 replica 進度、failover 時自動 promote 最新 replica。對比 PostgreSQL 的 Patroni（詳見 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a>）— 兩者角色相同、Orchestrator 需要 GTID + 對 MySQL 行為熟、Patroni 需要 DCS（etcd / Consul）+ 對 PG 行為熟。</p>
<p>詳見 MySQL backlog 中 <em>Orchestrator failover 設計</em> 篇（待寫）。</p>
<h3 id="cdcmaxwell--debezium">CDC（Maxwell / Debezium）</h3>
<p>Maxwell（Zendesk 出品、MySQL-only）跟 Debezium（Red Hat、MySQL / PG / MongoDB 都支援）都讀 MySQL binlog 轉成 event stream（Kafka / Kinesis / Pulsar）。Binlog 必須 <code>ROW</code> format、GTID 啟用後 <em>exactly-once</em> delivery 更好維護（不需算 binlog position）。</p>
<p>跟 PG logical replication + Debezium 對比、MySQL 用 binlog（physical / row-level）不是 logical decoding、所以 schema change 時 <em>CDC consumer 要 schema-aware</em> 處理。詳見 MySQL backlog 中 <em>Binary log + Maxwell / Debezium CDC</em> 篇（待寫）。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/replication-topology/" data-link-title="PostgreSQL Replication Topology：async / sync / quorum 三模式跟 LSN &#43; replication slot 的三軸組合" data-link-desc="PostgreSQL streaming replication 不是「sync 或 async」、是 *durability / latency / consistency* 三軸組合 &#43; LSN-based 進度追蹤 &#43; replication slot 治理。本文走 3 軸取捨模型、async / sync / quorum-based sync 行為對比、LSN &#43; replication slot 機制、配置 step-by-step、5 production 踩雷（standby lag 暴衝 / sync standby 退回 async / orphan replication slot / cascading replication 雪崩 / failover 後 timeline 分歧）、跟 Patroni HA &#43; logical replication 整合">PostgreSQL Replication Topology</a>（PG sibling、streaming + LSN + slot 機制 vs MySQL binlog 對位）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PostgreSQL Patroni HA</a>（PG sibling、不同 HA 機制）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PostgreSQL Logical Replication + Debezium</a>（PG CDC sibling、不同 replication 抽象層）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/replication-slot-management/" data-link-title="PostgreSQL Replication Slot Management：Physical / Logical / Failover Slot 治理" data-link-desc="PG replication slot 是 *primary 端的 standby 進度紀錄*、防 WAL premature deletion。但 orphan slot 會吃 disk、failover 後 logical slot 不會自動跟新 primary、是 PG 操作的 hidden complexity。本文走 physical / logical slot 差異、slot lifecycle、failover slot synchronization（PG 17&#43; 新特性）、orphan slot 治理、5 production 踩雷（orphan slot disk 爆 / logical slot lag / failover 後 slot 丟 / wal_keep_size 跟 slot 衝突 / connection 同時打 slot 數量限制）">PostgreSQL Replication Slot Management</a>（PG slot 治理、MySQL 無對應概念）</li>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor page</a>（managed MySQL、replication 交給 storage layer）</li>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a>（transaction 行為跟 replication 互動）</li>
<li><a href="/blog/backend/01-database/kv-document-capacity-planning/" data-link-title="1.10 KV / Document DB 容量規劃" data-link-desc="DynamoDB / Cosmos DB / Bigtable / MongoDB 等 KV / Document DB 的容量設計、partition key 取捨、capacity mode 選擇">1.10 KV / Document DB 容量規劃</a> / <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a>（替代路徑）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡片</a> / <a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">eventual consistency 卡片</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale read 卡片</a></li>
<li>官方：<a href="https://dev.mysql.com/doc/refman/8.0/en/replication.html">MySQL Replication</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/replication-semisync.html">Semi-Sync</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/replication-gtids.html">GTID</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL Replication Topology：async / sync / quorum 三模式跟 LSN + replication slot 的三軸組合</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-topology/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/replication-topology/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 &lt;em>streaming replication topology&lt;/em> — 從 single primary 到 multi-standby 部署的 3 個 trade-off 軸 + LSN + replication slot 機制。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇&lt;/h2>
&lt;p>PG streaming replication mode 選擇看起來是「async 還是 sync」、實際是 3 個獨立 trade-off 軸的組合、async / sync / quorum-based sync 是這些軸的常見組合 &lt;em>名稱&lt;/em>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>軸&lt;/th>
 &lt;th>端 A&lt;/th>
 &lt;th>端 B&lt;/th>
 &lt;th>PG 旋鈕&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Durability&lt;/strong>&lt;/td>
 &lt;td>primary 寫完就 commit&lt;/td>
 &lt;td>至少一個 standby 收到才 commit&lt;/td>
 &lt;td>&lt;code>synchronous_commit&lt;/code> / &lt;code>synchronous_standby_names&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Latency&lt;/strong>&lt;/td>
 &lt;td>client 等 primary 寫完 OK&lt;/td>
 &lt;td>client 等 standby ack（額外 RTT）&lt;/td>
 &lt;td>同上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
 &lt;td>standby 隨時可能 stale&lt;/td>
 &lt;td>standby 跟 primary 保證讀到一致&lt;/td>
 &lt;td>application read routing rule（不是 replication 旋鈕）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟這三軸獨立的、是 &lt;em>replication 機制本身的可維護性&lt;/em>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LSN（Log Sequence Number）&lt;/strong>：PG 用全域 byte offset 標 WAL 進度、所有 standby 同步用 LSN 對齊、不像 MySQL 早期 binlog position + file 雙欄&lt;/li>
&lt;li>&lt;strong>Replication slot&lt;/strong>：primary 紀錄每個 standby 已接收的 LSN、防 standby 失聯期間 WAL 被清掉、是 streaming replication 的 &lt;em>持久化進度追蹤&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology&lt;/a> 對比、PG 的 LSN + replication slot 直接內建 &lt;em>standby 進度追蹤&lt;/em>、不像 MySQL 5.7- 要靠 binlog position + GTID 雙機制；但 slot 是 &lt;em>primary 紀錄&lt;/em>、orphan slot 是 PG-specific 議題（slot 留 WAL 直到 standby 重連、standby 永久失聯 → primary disk 爆）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a> overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 <em>streaming replication topology</em> — 從 single primary 到 multi-standby 部署的 3 個 trade-off 軸 + LSN + replication slot 機制。</p></blockquote>
<hr>
<h2 id="replication-的-3-個-trade-off-軸--mode-選擇">Replication 的 3 個 trade-off 軸 + mode 選擇</h2>
<p>PG streaming replication mode 選擇看起來是「async 還是 sync」、實際是 3 個獨立 trade-off 軸的組合、async / sync / quorum-based sync 是這些軸的常見組合 <em>名稱</em>：</p>
<table>
  <thead>
      <tr>
          <th>軸</th>
          <th>端 A</th>
          <th>端 B</th>
          <th>PG 旋鈕</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Durability</strong></td>
          <td>primary 寫完就 commit</td>
          <td>至少一個 standby 收到才 commit</td>
          <td><code>synchronous_commit</code> / <code>synchronous_standby_names</code></td>
      </tr>
      <tr>
          <td><strong>Latency</strong></td>
          <td>client 等 primary 寫完 OK</td>
          <td>client 等 standby ack（額外 RTT）</td>
          <td>同上</td>
      </tr>
      <tr>
          <td><strong>Consistency</strong></td>
          <td>standby 隨時可能 stale</td>
          <td>standby 跟 primary 保證讀到一致</td>
          <td>application read routing rule（不是 replication 旋鈕）</td>
      </tr>
  </tbody>
</table>
<p>跟這三軸獨立的、是 <em>replication 機制本身的可維護性</em>：</p>
<ul>
<li><strong>LSN（Log Sequence Number）</strong>：PG 用全域 byte offset 標 WAL 進度、所有 standby 同步用 LSN 對齊、不像 MySQL 早期 binlog position + file 雙欄</li>
<li><strong>Replication slot</strong>：primary 紀錄每個 standby 已接收的 LSN、防 standby 失聯期間 WAL 被清掉、是 streaming replication 的 <em>持久化進度追蹤</em></li>
</ul>
<p>跟 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a> 對比、PG 的 LSN + replication slot 直接內建 <em>standby 進度追蹤</em>、不像 MySQL 5.7- 要靠 binlog position + GTID 雙機制；但 slot 是 <em>primary 紀錄</em>、orphan slot 是 PG-specific 議題（slot 留 WAL 直到 standby 重連、standby 永久失聯 → primary disk 爆）。</p>
<h2 id="async-streamingdefault--高-throughput-的代價">Async streaming：default + 高 throughput 的代價</h2>
<p>Async 是 PG 預設、行為：</p>
<ol>
<li>Primary 寫 WAL 進 <code>pg_wal/</code> 目錄、commit、回應 client OK</li>
<li>WAL sender process 把 WAL stream 給 standby</li>
<li>Standby WAL receiver 寫 standby 的 <code>pg_wal/</code>、startup 進程 redo 套用</li>
</ol>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>Durability：primary commit 後 standby 還沒收 → primary 永久故障 → <em>data loss</em>（已 commit 的 transaction 在 standby 不存在）</li>
<li>Latency：client 寫入延遲 = primary 自身 fsync WAL 的時間（<code>fsync=on</code> + <code>synchronous_commit=on</code> 預設、通常 &lt; 1ms 在 SSD / NVMe）</li>
<li>Consistency：standby 可能 lag、application 讀 standby 會 stale；用 <code>pg_stat_replication.write_lag / flush_lag / replay_lag</code> 看</li>
</ul>
<p><strong>配置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># postgresql.conf on primary</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">wal_level</span> <span class="o">=</span> <span class="s">replica          # 至少 replica（logical 是 superset）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">max_wal_senders</span> <span class="o">=</span> <span class="s">10         # 並行 WAL sender process 數（依 standby 數量）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">wal_keep_size</span> <span class="o">=</span> <span class="s">1024MB       # WAL 保留量（slot 為主、但 backup buffer）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">synchronous_commit</span> <span class="o">=</span> <span class="s">on      # 預設、primary 自己 fsync WAL</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># synchronous_standby_names 留空 = async</span></span></span></code></pre></div><p><strong>適用</strong>：</p>
<ul>
<li>主流選擇（90% 場景）</li>
<li>Failover loss 在容忍範圍（多數 web 應用容忍 1-2 秒 data loss）</li>
<li>Read scaling 為主要 driver、絕對 durability 非首要</li>
</ul>
<h2 id="sync-streaming至少一個-standby-flush-wal-才-commit">Sync streaming：至少一個 standby flush WAL 才 commit</h2>
<p>Sync mode 在 async 基礎上加 <em>primary 等指定 standby flush WAL 才回 client</em>：</p>
<ol>
<li>Primary 寫 WAL、send to standby</li>
<li>Standby 收到 WAL、寫進 <code>pg_wal/</code>、fsync、回 ack</li>
<li><em>Primary 等 ack</em> → commit → 回 client</li>
</ol>
<p><code>synchronous_commit</code> 有 5 個 level、不是 binary：</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>行為</th>
          <th>Latency 影響</th>
          <th>Crash data loss</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>off</code></td>
          <td>primary 不等自己 fsync、background flush</td>
          <td>+0</td>
          <td>primary crash 丟 0-1 秒</td>
      </tr>
      <tr>
          <td><code>local</code></td>
          <td>primary fsync own WAL（不等 standby）</td>
          <td>baseline</td>
          <td>primary crash 0、standby 丟</td>
      </tr>
      <tr>
          <td><code>remote_write</code></td>
          <td>primary fsync + standby 收到（不必 standby fsync）</td>
          <td>+1 RTT 大致</td>
          <td>OS crash on standby 丟</td>
      </tr>
      <tr>
          <td><code>on</code> (預設)</td>
          <td>primary fsync + standby fsync（standby 收進 disk）</td>
          <td>+1 RTT + fsync</td>
          <td>全 crash 都不丟</td>
      </tr>
      <tr>
          <td><code>remote_apply</code></td>
          <td>primary fsync + standby fsync + standby 已 <em>replay</em>（visible to read）</td>
          <td>+1 RTT + fsync + replay</td>
          <td>全 crash 都不丟 + replica 立刻可讀</td>
      </tr>
  </tbody>
</table>
<p><strong>配置（synchronous）</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">synchronous_commit</span> <span class="o">=</span> <span class="s">on</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">synchronous_standby_names</span> <span class="o">=</span> <span class="s">&#39;FIRST 1 (standby1, standby2)&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># &#39;FIRST 1&#39; = 第一個 active standby ack 即可</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># &#39;ANY 2 (s1, s2, s3)&#39; = 任 2 個 ack 即可（quorum-based）</span></span></span></code></pre></div><p><strong>Quorum-based sync</strong>：用 <code>ANY N</code> 語法、達到 N 個 ack 就 commit、提高 latency stability（不依賴特定 standby）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">synchronous_standby_names</span> <span class="o">=</span> <span class="s">&#39;ANY 2 (standby1, standby2, standby3)&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 3 個 standby 中任 2 個 ack 即 commit</span></span></span></code></pre></div><p><strong>適用</strong>：</p>
<ul>
<li>金融交易 / 訂單 / payment ledger（不允許 data loss）</li>
<li>已有 multi-AZ deploy、replica 物理上可靠</li>
<li>可接受寫入延遲 +1-3ms (跨 AZ)</li>
</ul>
<p><strong>不適用</strong>：</p>
<ul>
<li>跨 region sync（RTT 50-200ms）— 寫吞吐砍半、改用 <em>region-local sync + cross-region async</em></li>
<li>寫吞吐 &gt; 50K WPS + 容忍 sub-second loss — async 即可</li>
</ul>
<h2 id="lsn--replication-slotpg-的進度追蹤機制">LSN + Replication Slot：PG 的進度追蹤機制</h2>
<p>PG 每個 WAL 寫入都標 <em>LSN</em>（64-bit byte offset）。Standby 紀錄 <em>已收到 / 已 flush / 已 replay</em> 的 LSN、primary 透過 streaming protocol 知道每個 standby 進度。</p>
<p><strong>Replication slot</strong> 是 <em>primary 端的 standby 進度紀錄</em>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 建 physical replication slot（給 streaming replication 用）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby1_slot&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 查 slot 狀態
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">slot_name</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">,</span><span class="w"> </span><span class="n">confirmed_flush_lsn</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">       </span><span class="n">pg_size_pretty</span><span class="p">(</span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">lag</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="p">;</span></span></span></code></pre></div><p><strong>Slot 的核心責任</strong>：</p>
<ul>
<li><em>防 WAL premature deletion</em>：standby 失聯（restart / network blip）、primary 仍保留 slot 對應 LSN 之後的 WAL、standby 重連可繼續 stream</li>
<li><em>無需 base backup re-build</em>：跟沒 slot 的 standby 對比、有 slot 的 standby 失聯後重連、不用重建</li>
</ul>
<p><strong>Slot 跟 <code>wal_keep_size</code></strong>：</p>
<ul>
<li><code>wal_keep_size</code>（PG 13+）/ <code>wal_keep_segments</code>（&lt; 13）：minimum WAL 保留量、不依賴 slot</li>
<li>Slot 是 <em>動態保留</em>：直到 slot 的 standby 推進 LSN 才釋放對應 WAL</li>
<li>兩者組合：<code>wal_keep_size</code> 是底線、slot 是 standby-specific 動態保留</li>
</ul>
<p><strong>Standby 配置（用 slot）</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># standby1 postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">primary_conninfo</span> <span class="o">=</span> <span class="s">&#39;host=primary.example.com port=5432 user=replication password=...&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">primary_slot_name</span> <span class="o">=</span> <span class="s">&#39;standby1_slot&#39;   # 用 primary 上預先建的 slot</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">hot_standby</span> <span class="o">=</span> <span class="s">on                       # 讓 standby 接受 read query</span></span></span></code></pre></div><p><code>standby.signal</code> 空檔案在 PG_DATA 內、告訴 PG 這是 standby、進入 recovery mode。</p>
<h2 id="配置-step-by-stepsync-streaming--slot">配置 step-by-step（sync streaming + slot）</h2>
<p>實務最常見組合：sync streaming + replication slot + cross-AZ replica。</p>
<h3 id="step-1primary-配置">Step 1：Primary 配置</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">wal_level</span> <span class="o">=</span> <span class="s">replica</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">max_wal_senders</span> <span class="o">=</span> <span class="s">10</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">max_replication_slots</span> <span class="o">=</span> <span class="s">10</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">synchronous_commit</span> <span class="o">=</span> <span class="s">on</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">synchronous_standby_names</span> <span class="o">=</span> <span class="s">&#39;FIRST 1 (standby1, standby2)&#39;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">wal_keep_size</span> <span class="o">=</span> <span class="s">1024MB</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># pg_hba.conf — 允許 replication 連線</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">host replication replication 10.0.0.0/16 scram-sha-256</span></span></span></code></pre></div><p>Restart primary 套用。</p>
<h3 id="step-2建-replication-user--slot">Step 2：建 replication user + slot</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">USER</span><span class="w"> </span><span class="n">replication</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="n">REPLICATION</span><span class="w"> </span><span class="n">PASSWORD</span><span class="w"> </span><span class="s1">&#39;...&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby1_slot&#39;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_create_physical_replication_slot</span><span class="p">(</span><span class="s1">&#39;standby2_slot&#39;</span><span class="p">);</span></span></span></code></pre></div><h3 id="step-3standby-base-backup">Step 3：Standby base backup</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 standby 上跑</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pg_basebackup -h primary.example.com -D /var/lib/postgresql/data <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -U replication -P -X stream <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -S standby1_slot -R
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># -R: 自動生成 standby.signal + primary_conninfo</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># -X stream: 邊 backup 邊 stream 增量 WAL（避免 backup 期間 WAL gap）</span></span></span></code></pre></div><h3 id="step-4standby-啟動">Step 4：Standby 啟動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># standby /var/lib/postgresql/data/postgresql.auto.conf 已有：</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># primary_conninfo = &#39;host=primary.example.com user=replication password=... application_name=standby1&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># primary_slot_name = &#39;standby1_slot&#39;</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">pg_ctl -D /var/lib/postgresql/data start</span></span></code></pre></div><h3 id="step-5驗證">Step 5：驗證</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- Primary: 確認 standby 連上
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">application_name</span><span class="p">,</span><span class="w"> </span><span class="k">state</span><span class="p">,</span><span class="w"> </span><span class="n">sync_state</span><span class="p">,</span><span class="w"> </span><span class="n">write_lag</span><span class="p">,</span><span class="w"> </span><span class="n">flush_lag</span><span class="p">,</span><span class="w"> </span><span class="n">replay_lag</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_replication</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 應顯示 standby1 / streaming / sync / 各 lag
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- Standby: 確認在 recovery + 收到 WAL
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_is_in_recovery</span><span class="p">(),</span><span class="w"> </span><span class="n">pg_last_wal_receive_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">pg_last_wal_replay_lsn</span><span class="p">();</span></span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-standby-lag-暴衝--single-replay-process-bottleneck">1. Standby lag 暴衝 — Single replay process bottleneck</h3>
<p>PG standby 是 <em>single startup process</em> 套用 WAL（不像 MySQL multi-thread replication）、primary 高並發寫入時 standby 跟不上、lag 從 &lt; 100ms 飆到分鐘級。常見觸發：批次 UPDATE / DELETE、大 transaction、index 建立、autovacuum 大量 dead tuple cleanup。</p>
<p>修法：</p>
<ul>
<li><em>Parallel WAL apply</em>（PG 14+）：<code>max_parallel_workers_per_gather</code> 增加 background worker、但仍受 startup process 主導</li>
<li>對 <em>read scaling</em> 場景接受 standby lag、application 用 <em>primary read 對 latency-critical query</em></li>
<li><em>Cascading replication</em> 對 high-fan-out 解決 sender CPU bottleneck、但 standby replay 仍 single-thread</li>
</ul>
<p>監控：<code>pg_stat_replication.replay_lag</code> 是 <em>最後一個 commit 到 standby replay 的時間差</em>、超過 threshold 即告警。</p>
<h3 id="2-sync-standby-失聯時-primary-commit-卡住">2. Sync standby 失聯時 primary commit 卡住</h3>
<p><code>synchronous_standby_names = 'FIRST 1 (standby1)'</code> + standby1 down → primary commit <em>等永遠</em>。Application 全部 timeout。</p>
<p>修法：</p>
<ul>
<li>用 <code>ANY N</code> quorum：<code>synchronous_standby_names = 'ANY 1 (standby1, standby2)'</code> — 任一 standby ack 即可</li>
<li>設多 standby、防單一失聯</li>
<li>監控 sync standby 健康、自動 failover 切 sync mode 到其他 standby（Patroni 自動做）</li>
<li>緊急情況：在 primary 跑 <code>ALTER SYSTEM SET synchronous_standby_names = ''; SELECT pg_reload_conf();</code> 暫時退 async（接受 data loss risk）</li>
</ul>
<h3 id="3-orphan-replication-slot--primary-disk-爆">3. Orphan replication slot — Primary disk 爆</h3>
<p>Standby 失聯（永久故障 / 重 decommission 但忘了 drop slot）、primary slot 持續保留 WAL、<code>pg_wal/</code> 累積到 disk 滿、primary 也掛。</p>
<p>修法：</p>
<ul>
<li>
<p>監控 <code>pg_replication_slots.active</code> — <code>false</code> 持續 &gt; N 小時是警訊</p>
</li>
<li>
<p>監控 slot lag：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">slot_name</span><span class="p">,</span><span class="w"> </span><span class="n">active</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">       </span><span class="n">pg_size_pretty</span><span class="p">(</span><span class="n">pg_wal_lsn_diff</span><span class="p">(</span><span class="n">pg_current_wal_lsn</span><span class="p">(),</span><span class="w"> </span><span class="n">restart_lsn</span><span class="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">retained_wal</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_replication_slots</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">retained_wal</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">10</span><span class="n">GB</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p>設 <code>max_slot_wal_keep_size</code>（PG 13+）— slot 對應 WAL 超過 limit 自動 invalidate slot（standby 之後要 base backup 重來）</p>
</li>
<li>
<p>DR runbook 紀錄 <em>standby 退役流程</em> 必須包含 <code>pg_drop_replication_slot('xxx')</code></p>
</li>
</ul>
<h3 id="4-cascading-replication-雪崩">4. Cascading replication 雪崩</h3>
<p>Topology <code>primary → standby1 → standby2 → ...</code>（每層遞迴 stream）。Standby1 startup process 卡住、後續 standby 都被 block、整條 chain 雪崩。</p>
<p>修法：</p>
<ul>
<li>避免超過 2 層 cascade（primary → tier1 → tier2 是上限）</li>
<li>跨 region 用 <em>region-local tier1 + cross-region tier2</em>、不是長 chain</li>
<li>真的大規模、改用 <em>binlog server</em> style：<a href="https://github.com/postgresml/PgCat">Citus / PgCat</a> 等中介、或 logical replication 解耦</li>
</ul>
<h3 id="5-failover-後-timeline-分歧">5. Failover 後 timeline 分歧</h3>
<p>Primary 失敗、standby1 promote 為新 primary、其他 standby（standby2 / 3）原本連舊 primary、必須重新連 standby1。但 PG 用 <em>timeline</em>（每次 promotion 增 1）標 WAL 分支、原 standby 的 timeline 跟新 primary 不同。重連時看到 timeline mismatch、報錯。</p>
<p>修法：</p>
<ul>
<li><em>pg_rewind</em> 工具：對比新 primary 跟舊 standby 的 timeline 分歧點、把舊 standby 上 <em>新 primary 沒有的 WAL</em> 倒退、然後從分歧點重新跟新 primary 同步</li>
<li><em>Base backup re-build</em>：對舊 standby 重建 — 慢但保證乾淨</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni</a> 自動處理 pg_rewind / base backup 選擇</li>
</ul>
<h2 id="容量--cost-對照">容量 / cost 對照</h2>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>寫吞吐影響</th>
          <th>Standby overhead</th>
          <th>適合 workload</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Async streaming + slot</td>
          <td>baseline</td>
          <td>低（WAL receive + startup）</td>
          <td>高吞吐、容忍 sub-second loss</td>
      </tr>
      <tr>
          <td>Sync <code>remote_write</code> + 1 standby</td>
          <td>-5% ~ -10%</td>
          <td>同上 + RTT</td>
          <td>一般 production、可接受 OS crash 丟</td>
      </tr>
      <tr>
          <td>Sync <code>on</code> + 1 standby</td>
          <td>-10% ~ -20%</td>
          <td>同上 + fsync</td>
          <td>金融、訂單、不容忍 data loss</td>
      </tr>
      <tr>
          <td>Sync <code>on</code> + ANY 2 quorum</td>
          <td>-15% ~ -30%</td>
          <td>同上、跨 AZ</td>
          <td>強 durability + multi-AZ HA</td>
      </tr>
      <tr>
          <td>Sync <code>remote_apply</code> + 1 standby</td>
          <td>-20% ~ -40%</td>
          <td>同上 + replay</td>
          <td>強一致 read on standby（少用、成本高）</td>
      </tr>
  </tbody>
</table>
<p>跨 AZ sync 通常加 1-3ms、跨 region 加 50-200ms — 寫密集 workload 跨 region sync 通常不划算、改用 <em>region-local sync + cross-region async chain</em>。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="patroni-ha">Patroni HA</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni</a> 是 PG HA 自動 failover 標準、依賴 DCS（etcd / Consul）+ 本文 replication topology。Patroni 自動：</p>
<ul>
<li>偵測 primary 失聯、promote 適合 standby</li>
<li>處理 timeline 分歧（pg_rewind）</li>
<li>重配 sync standby（避免 sync standby 失聯卡 primary）</li>
</ul>
<h3 id="logical-replication--debezium">Logical Replication + Debezium</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical replication + Debezium</a> 是 <em>跟 streaming replication 共用 WAL</em> 但不同 abstraction — logical decoding output event、streaming replication output physical bytes。Logical replication slot 跟 physical slot 共存、各自獨立 retention。</p>
<h3 id="pitr--wal-archiving">PITR + WAL Archiving</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PITR + WAL Archiving</a> 用 <em>archive_command</em> 把 WAL ship 到 S3、跟 streaming replication 並行：</p>
<ul>
<li>Streaming：給 <em>活的 standby</em>（real-time read scaling / HA）</li>
<li>Archive：給 <em>PITR + 新 standby base backup source</em></li>
</ul>
<p>兩者使用同一 WAL stream、不衝突。</p>
<h3 id="connection-路由pgbouncer--readwrite-split">Connection 路由（PgBouncer + read/write split）</h3>
<p><a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">PgBouncer</a> 不做 read/write split（transaction pool 不看 SQL）。Read replica routing 通常用 <em>application-level</em> 或 <em>HAProxy 監控 standby health</em>。</p>
<h3 id="跟-mysql-replication-topology-對比">跟 MySQL Replication Topology 對比</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>PG streaming replication</th>
          <th>MySQL replication</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>進度追蹤</td>
          <td>LSN（單一 byte offset）</td>
          <td>GTID 或 binlog (file, position)</td>
      </tr>
      <tr>
          <td>標準工具</td>
          <td>streaming replication（physical）+ logical</td>
          <td>binlog ROW format</td>
      </tr>
      <tr>
          <td>Sync 機制</td>
          <td><code>synchronous_commit</code> + standby names</td>
          <td>semi-sync plugin</td>
      </tr>
      <tr>
          <td>Quorum</td>
          <td><code>ANY N</code> syntax</td>
          <td><code>rpl_semi_sync_master_wait_for_slave_count</code></td>
      </tr>
      <tr>
          <td>Replay parallelism</td>
          <td>Single startup process</td>
          <td>Multi-thread (logical clock / writeset)</td>
      </tr>
      <tr>
          <td>Replica routing</td>
          <td>PgBouncer 不看 SQL、需外接</td>
          <td>ProxySQL 內建 query routing</td>
      </tr>
  </tbody>
</table>
<p>兩者 high-level 對等、低層機制有顯著差異。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">PG Patroni HA</a>（HA failover、依賴本文 replication topology）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">PG Logical Replication + Debezium</a>（不同 abstraction、共用 WAL）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PG PITR + WAL Archiving</a>（streaming + archive 並行）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pgbouncer-config/" data-link-title="PostgreSQL pgBouncer 配置 &#43; 連線池治理" data-link-desc="pgBouncer transaction pooling 配置、跟 application connection pool 的分層、production 故障演練（pool exhaustion / stale connection / DNS failover）跟容量規劃">PG PgBouncer</a>（connection pool、不做 read/write split）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（sibling、不同機制）</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum 卡片</a> / <a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read 卡片</a> / <a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">eventual-consistency 卡片</a></li>
<li>官方：<a href="https://www.postgresql.org/docs/current/warm-standby.html">PG Streaming Replication</a> / <a href="https://www.postgresql.org/docs/current/app-pgbasebackup.html">pg_basebackup</a></li>
</ul>
]]></content:encoded></item><item><title>Aurora Storage Architecture：quorum-based 分散式 log 與韌性即性能設計</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/storage-architecture/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/storage-architecture/</guid><description>&lt;p>Aurora 把 storage 從「block device + WAL on local disk」重寫成跨 AZ 分散式 log service、compute node 只負責 process query 跟 generate redo log records。這個設計直接決定 read replica、failover、backup 跟跨 AZ replication 的物理上限 — 不理解 storage layer 設計、就無法解釋為什麼 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &amp;#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix consolidation&lt;/a> 拿到 +75% 效能、為什麼 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &amp;#43;50% 不影響延遲">9.C4 DraftKings&lt;/a> replication lag 從 30 秒降到 10-30ms、為什麼 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered&lt;/a> 能同時把韌性跟性能當成單一目標。&lt;/p>
&lt;p>本文不是 Aurora overview（請看 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&amp;#43;75% 效能改善的 production 證據">Aurora vendor 頁&lt;/a>）— 而是 storage-level 設計的實作層教學。覆蓋 quorum-based replication 的工程含義、「韌性即性能」frame 為什麼成立、OLTP workload 在 storage 設計下的讀寫雙峰錯位、跟容量規劃的判讀槓桿。&lt;/p>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>典型觸發場景：團隊從 RDS PostgreSQL / 自管 PostgreSQL 遷到 Aurora、看到「跨 AZ replication lag 從秒級降到毫秒級」、但讀文件「quorum」「4-of-6」「分散式 storage」訊息密集、不知道哪些設計決策要相信、哪些是 marketing 詞。&lt;/p>
&lt;p>讀者常見的具體疑問：&lt;/p>
&lt;ul>
&lt;li>「為什麼 Aurora 寫入比 RDS 還低、不是該因為跨 AZ network round-trip 而變慢？」&lt;/li>
&lt;li>「Storage layer 跟 compute layer 分離具體怎麼影響 backup、failover 跟 read replica？」&lt;/li>
&lt;li>「6 個 storage node 失去 2 個還能寫嗎？失去 3 個呢？」&lt;/li>
&lt;li>「Aurora 文件講『韌性』跟『性能』都用 storage 設計解釋、是同一件事還是兩件事？」&lt;/li>
&lt;/ul>
&lt;p>進一步問題：傳統工程文化把可靠性跟性能視為對立 — HA 投資（跨 AZ replication、failover 演練）通常被當成性能成本、不被視為性能來源。Aurora 設計反這個直覺、但讀者需要看到具體機制才能信。Standard Chartered case 揭露這個 frame 在受監管銀行業務（要求兩者同時達標）的價值；DraftKings 揭露具體數字（讀 &amp;lt; 1ms、寫 6ms）。&lt;/p>
&lt;h2 id="核心機制quorum-based-分散式-log">核心機制：quorum-based 分散式 log&lt;/h2>
&lt;p>Aurora storage 的 first-class concept 是 &lt;em>quorum 寫入 + 6-way 跨 AZ replication&lt;/em>。傳統 PostgreSQL primary 把 storage 跟 CPU / RAM 綁定、storage 擴容要換 instance、replication 在 compute 層做（streaming replication、logical replication）。Aurora 把 storage 拉到分散式 log service、6 個 storage node 各自獨立、application 看到的仍是 single primary SQL。&lt;/p></description><content:encoded><![CDATA[<p>Aurora 把 storage 從「block device + WAL on local disk」重寫成跨 AZ 分散式 log service、compute node 只負責 process query 跟 generate redo log records。這個設計直接決定 read replica、failover、backup 跟跨 AZ replication 的物理上限 — 不理解 storage layer 設計、就無法解釋為什麼 <a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix consolidation</a> 拿到 +75% 效能、為什麼 <a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> replication lag 從 30 秒降到 10-30ms、為什麼 <a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 能同時把韌性跟性能當成單一目標。</p>
<p>本文不是 Aurora overview（請看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor 頁</a>）— 而是 storage-level 設計的實作層教學。覆蓋 quorum-based replication 的工程含義、「韌性即性能」frame 為什麼成立、OLTP workload 在 storage 設計下的讀寫雙峰錯位、跟容量規劃的判讀槓桿。</p>
<h2 id="問題情境">問題情境</h2>
<p>典型觸發場景：團隊從 RDS PostgreSQL / 自管 PostgreSQL 遷到 Aurora、看到「跨 AZ replication lag 從秒級降到毫秒級」、但讀文件「quorum」「4-of-6」「分散式 storage」訊息密集、不知道哪些設計決策要相信、哪些是 marketing 詞。</p>
<p>讀者常見的具體疑問：</p>
<ul>
<li>「為什麼 Aurora 寫入比 RDS 還低、不是該因為跨 AZ network round-trip 而變慢？」</li>
<li>「Storage layer 跟 compute layer 分離具體怎麼影響 backup、failover 跟 read replica？」</li>
<li>「6 個 storage node 失去 2 個還能寫嗎？失去 3 個呢？」</li>
<li>「Aurora 文件講『韌性』跟『性能』都用 storage 設計解釋、是同一件事還是兩件事？」</li>
</ul>
<p>進一步問題：傳統工程文化把可靠性跟性能視為對立 — HA 投資（跨 AZ replication、failover 演練）通常被當成性能成本、不被視為性能來源。Aurora 設計反這個直覺、但讀者需要看到具體機制才能信。Standard Chartered case 揭露這個 frame 在受監管銀行業務（要求兩者同時達標）的價值；DraftKings 揭露具體數字（讀 &lt; 1ms、寫 6ms）。</p>
<h2 id="核心機制quorum-based-分散式-log">核心機制：quorum-based 分散式 log</h2>
<p>Aurora storage 的 first-class concept 是 <em>quorum 寫入 + 6-way 跨 AZ replication</em>。傳統 PostgreSQL primary 把 storage 跟 CPU / RAM 綁定、storage 擴容要換 instance、replication 在 compute 層做（streaming replication、logical replication）。Aurora 把 storage 拉到分散式 log service、6 個 storage node 各自獨立、application 看到的仍是 single primary SQL。</p>
<p><strong>Storage layout</strong>：每個 storage segment 跨 3 AZ × 2 node、共 6 個 storage node。一個 cluster 的 storage 被切成多個 10GB segment、每個 segment 6-way 複製。</p>
<p><strong>Quorum 設定</strong>：</p>
<ul>
<li>Write quorum：4-of-6（4 個 storage node 確認寫入才算 commit）— 容忍 1 AZ 失效 + 1 node 失效仍能寫</li>
<li>Read quorum：3-of-6（讀 3 個 node 取最新版本）— 比 write 小、降低 read latency</li>
<li>算術不對稱：寫嚴讀鬆是設計選擇、不是 marketing — durability 由寫端保證、讀端可以放寬</li>
</ul>
<p><strong>Write path 跟傳統 PostgreSQL 的差異</strong>：</p>
<ul>
<li>PostgreSQL primary：寫 WAL 到 local disk + dirty page flush + 透過 streaming replication 推到 replica</li>
<li>Aurora compute node：只送 <em>redo log records</em> 到 storage、不送整個 page；storage node 自己 apply redo log 重建 page、自己 checkpoint、自己 backup</li>
<li>工程含義：compute node 寫量小、CPU 不被 dirty page flush 佔用、寫入路徑變短</li>
</ul>
<p><strong>「韌性即性能」frame</strong>（<a href="/blog/backend/09-performance-capacity/cases/standard-chartered-aurora-banking/" data-link-title="9.C14 Standard Chartered：受監管銀行的 Aurora 4000 TPS 容量提升" data-link-desc="Standard Chartered 銀行遷移到 Aurora 後吞吐量提升 10 倍至 4000 TPS、跨 7 個受監管市場">9.C14 Standard Chartered</a> 揭露）：</p>
<p>Aurora 把 HA 從 application-level（Patroni promotion + WAL catch-up）下推到 storage-level。設計含義是：storage 投資（6-way 跨 AZ replication）自動成為 read replica 的容量基底 — read replica 不需要 catch-up WAL、直接從共享 storage 讀、HA 預算同步轉成讀分流預算。</p>
<p>對 Standard Chartered 受監管銀行業務這代表：合規要求的 RPO / RTO 不能放棄、但業務也要求每秒 4000 TPS、兩者必須同時達成。傳統路徑要分別投資 HA（複雜的 streaming replication topology）跟性能（read replica catch-up tuning）、且兩個投資互相干擾。Aurora 讓 <em>同一份 storage 投資</em> 同時提供兩件事 — case「判讀」段第 2 點原話：「Aurora 的多 AZ storage + replica 同時提供性能（讀分流）跟韌性（故障切換）、達成 <em>韌性即性能</em> 的目標」。</p>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">quorum</a>、<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">replication-lag</a>。</p>
<p><strong>跟通用 quorum 概念差在哪</strong>：Aurora quorum 是 <em>storage-level</em>（不是 application-level Cassandra 風格）、application 看到 single primary SQL、不用感知 quorum；vs Cassandra application 要選 consistency level（ONE / QUORUM / ALL）。</p>
<h2 id="oltp-workload-shape讀寫雙峰錯位">OLTP workload shape：讀寫雙峰錯位</h2>
<p>Aurora 設計的工程含義在 application 層落地時、要看 workload 形狀。<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 揭露一個 OLTP 容量規劃的典型 pattern。</p>
<p><strong>DraftKings 揭露的雙峰錯位</strong>（case「觀察」段最後一行原文）：「write workloads spike up significantly around payout events, but opening the app during the game also activates a lot of balance queries」— 比賽進行時是讀爆量（balance query）、payout event 時是寫爆量（ledger write）、兩個峰不在同一時刻。</p>
<p><strong>工程含義</strong>：</p>
<ul>
<li>讀寫資源規劃要分開、不能用「峰值總 TPS」單一數字規劃容量</li>
<li>讀峰拉 read replica 容量、寫峰靠 primary instance class 跟 commit batching、兩條路徑獨立預配</li>
<li>預估 headroom 也要分開：讀的 headroom 可以靠 auto-scale replica 接、寫的 headroom 要靠 primary 提前升 instance class（不能 auto-scale）</li>
</ul>
<p><strong>Application-level boundary</strong>：雙峰錯位是 <em>application 層</em> 拆讀寫 datasource 的決策訊號、storage layer 本身不解。Aurora 共享 storage 提供 lag 上限可預測（10-30ms）— 這是 read replica 變成「production-grade 可用」的前提、但讀寫分流要 application 端拆 read / write data source 才能落地。Storage 設計給的是「可預測的 lag 上限」、不是「自動讀寫分離」。</p>
<p><strong>跨 case 對照</strong>：</p>
<p><a href="/blog/backend/09-performance-capacity/cases/fanduel-dual-peak-betting-streaming/" data-link-title="9.C28 FanDuel：體育直播 &#43; 投注的雙重峰值" data-link-desc="FanDuel 3.5M MAU、Super Bowl 期間擴容 5-10 倍、用 AWS Local Zones &#43; Wavelength &#43; Outposts 處理 20&#43; 州的雙重峰值">9.C28 FanDuel</a> 揭露另一種雙峰 — 直播 + 投注 <em>兩種服務</em> 同時峰、不是同服務讀寫錯位。這兩種雙峰類型要分清楚：</p>
<ul>
<li>同服務讀寫錯位（DraftKings）：解法是 read / write data source 拆分、共享 Aurora cluster</li>
<li>跨服務雙峰（FanDuel）：解法是不同服務各自獨立擴容、betting 走 Aurora、streaming 走 CDN</li>
</ul>
<p>雙峰類型不同、容量規劃策略不同。</p>
<h2 id="step-by-step-配置--觀測">Step-by-step 配置 / 觀測</h2>
<p>Aurora storage 是 cluster-level、不暴露 segment-level config。讀者能影響的維度是 instance class、storage type、backup retention 跟 monitoring。</p>
<p><strong>Cluster 建立</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds create-db-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --engine aurora-postgresql <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --engine-version 15.5 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --master-username admin <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --master-user-password <span class="s2">&#34;</span><span class="k">$(</span>aws secretsmanager get-secret-value --secret-id db-password --query SecretString --output text<span class="k">)</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --storage-type aurora-iopt1 <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --backup-retention-period <span class="m">7</span></span></span></code></pre></div><p>關鍵欄位：</p>
<ul>
<li><code>--storage-type aurora-iopt1</code>：Aurora I/O-Optimized、月費高 30% 但無 I/O 收費；write-heavy + scan-heavy workload 才划算</li>
<li><code>--storage-type aurora</code>（預設）：Standard storage、按 I/O 計費；read-light workload 划算</li>
<li><code>--backup-retention-period 7</code>：1-35 天、影響 PITR 範圍</li>
</ul>
<p><strong>觀測 storage 狀態</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds describe-db-clusters <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-cluster-identifier my-cluster <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;DBClusters[0].{StorageType:StorageType,AllocatedStorage:AllocatedStorage,Status:Status}&#39;</span></span></span></code></pre></div><p><strong>CloudWatch metric</strong>（cluster-level）：</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">VolumeBytesUsed           # 當前 storage 用量、接近 128 TB 上限要警告
</span></span><span class="line"><span class="ln">2</span><span class="cl">VolumeReadIOPs            # storage 層讀 IOPS、判斷 I/O-Optimized ROI
</span></span><span class="line"><span class="ln">3</span><span class="cl">VolumeWriteIOPs           # storage 層寫 IOPS、跟 compute 層 WriteIOPS 對照
</span></span><span class="line"><span class="ln">4</span><span class="cl">AuroraVolumeBytesLeftTotal # 剩餘可用 storage</span></span></code></pre></div><p><strong>Performance Insights wait event</strong>：</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">db.IO.aurora_redo_log_flush   # quorum write 等待訊號、p99 &gt; 10ms 要看
</span></span><span class="line"><span class="ln">2</span><span class="cl">db.IO.aurora_storage_xx       # storage layer I/O 細節</span></span></code></pre></div><p><strong>驗證點</strong>：</p>
<ul>
<li>寫入 latency p99：PostgreSQL primary 1-3ms vs Aurora 3-6ms、跨 AZ network round-trip 是物理下界</li>
<li>Read latency p99：Aurora &lt; 1ms（從共享 storage 讀、不跨 AZ）</li>
<li>Storage autoscale event：128 TB 上限前自動 grow per 10GB</li>
</ul>
<p><strong>Rollback boundary</strong>：Aurora storage 是 cluster-level、無法回滾 storage 設計；唯一 rollback 是切回 RDS / 自管（走 migration playbook、不是配置層 rollback）。</p>
<h2 id="故障模式--邊界-case">故障模式 / 邊界 case</h2>
<h3 id="case-1誤以為-aurora-寫入一定比-postgresql-primary-快">Case 1：誤以為 Aurora 寫入一定比 PostgreSQL primary 快</h3>
<p>徵兆：團隊期待 Aurora 寫入比自管 PostgreSQL 快、實測 p99 寫入 latency 沒明顯改善、甚至小 row + 單筆 commit 場景 Aurora 反而慢。</p>
<p>原因：跨 AZ network round-trip 是 3-5ms 物理下界、4-of-6 quorum 至少要等 4 個 storage node ack、單筆小寫場景 local SSD primary 仍有 latency 優勢。Aurora 的寫入優勢在 <em>壓力下</em> 才顯現 — write throughput 高峰時 PostgreSQL primary 受限於 dirty page flush + WAL fsync + replica catch-up、Aurora 的 storage layer 各自獨立處理 redo log apply。</p>
<blockquote>
<p><strong>數字口徑</strong>：「跨 AZ round-trip 3-5ms」屬通用工程估算（光速下界 + AWS 區內 AZ 物理距離）、case 未直接量化、實際值依 region / AZ pair / instance 類型而異、要看 AWS 官方 latency table 或自家 benchmark 校正。下方 DraftKings 6ms 寫入是 case 揭露的 production reference、可作為對照基線。</p></blockquote>
<p>修：</p>
<ul>
<li>benchmark 要跑壓力測試、不能只測單筆 latency</li>
<li>寫入 latency 不是 Aurora 的核心賣點、是 <em>可預測的 read replica lag + 韌性</em> 才是</li>
<li>DraftKings 6ms 寫入是 production reference：跨 AZ quorum 的物理下界、不是 Aurora 慢</li>
</ul>
<h3 id="case-2az-level-outage-期間寫入-latency-spike">Case 2：AZ-level outage 期間寫入 latency spike</h3>
<p>徵兆：1 個 AZ 失效後、寫入 p99 從 6ms spike 到 30-50ms、application timeout 增加。</p>
<p>原因：失去 1 AZ 後 quorum 仍成立（4-of-6 → 用剩 4 個 node 寫）、但 storage node fault 期間需要等 timeout 才確認；單一 storage node 額外 fault 會把寫推到 timeout。Aurora 在 AZ outage 期間 <em>能寫</em>、但不是 <em>性能不變</em>。</p>
<p>修：</p>
<ul>
<li>監測 <code>AuroraVolumeBytesLeftTotal</code> 跟 storage IOPS 分布、AZ outage 期間自動切到剩餘 AZ</li>
<li>application 端做 retry + circuit breaker、不要假設寫入永遠 6ms</li>
<li>確認 cluster 至少跨 3 AZ deploy、單 AZ outage 才有 quorum 餘地</li>
</ul>
<h3 id="case-3io-optimized-費用誤判">Case 3：I/O-Optimized 費用誤判</h3>
<p>徵兆：team 看 Aurora I/O-Optimized「無 I/O 收費」直接切過去、月帳變高 25%、沒看到 ROI。</p>
<p>原因：Standard storage 按 I/O 收費、I/O-Optimized 月費比 Standard 高 30%。只有 <em>write-heavy + scan-heavy</em> workload（I/O 月費接近 instance 費用）才划算；read-light + write-light workload 反而吃虧。</p>
<p>修：</p>
<ul>
<li>先量測 baseline I/O：<code>VolumeReadIOPs + VolumeWriteIOPs × $0.20 per million I/O</code> vs Standard 月費</li>
<li>I/O 費用 &gt; instance 費用 30% 才切 I/O-Optimized</li>
<li>DraftKings 用 I/O-Optimized 是因為金融帳本 write-heavy + balance query scan-heavy、ROI 明顯</li>
</ul>
<h3 id="case-4storage-autoscale-假設">Case 4：Storage autoscale 假設</h3>
<p>徵兆：TRUNCATE / DROP 大表釋放 50% storage、但下月帳單沒回落。</p>
<p>原因：Aurora storage 自動 grow、但 <em>不自動 shrink</em>。已分配的 storage 持續計費、TRUNCATE / DROP 只釋放 logical space、physical storage 仍占用。要 shrink 必須走 logical migration（dump / restore 到新 cluster）。</p>
<p>修：</p>
<ul>
<li>大量 DROP 操作前先評估是否值得做 logical migration</li>
<li>用 partition + DETACH 而非 DROP TABLE、partition 可以單獨 archive</li>
<li>接受 storage 用量是 <em>peak watermark</em> 而非 <em>current usage</em></li>
</ul>
<h3 id="case-5replication-lag-誤解">Case 5：Replication lag 誤解</h3>
<p>徵兆：read replica lag 10-30ms 看起來夠快、application 假設 read-after-write consistency、用戶下注後立刻查 balance 偶發看到舊資料。</p>
<p>原因：10-30ms 是 <em>typical</em>、heavy write + slow query 期間可能秒級。Aurora 共享 storage 設計讓 lag <em>可預測</em>（不會像 PostgreSQL streaming replication unbounded）、但 <em>可預測</em> 不等於 <em>zero</em>。Read-after-write 場景仍需要 application 端處理。</p>
<p>修：</p>
<ul>
<li>用戶寫操作後 N 秒內走 primary（N 由 lag p99 決定、典型 100ms）</li>
<li>Aurora 提供 session pinning：寫完同 session 短期內走 primary</li>
<li>不能假設「Aurora replication lag 小到可以忽略」、要看 application 容忍度</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p><strong>核心 metric</strong>：</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">VolumeBytesUsed           # storage 用量、128 TB 上限預警
</span></span><span class="line"><span class="ln">2</span><span class="cl">AuroraReplicaLag          # replica lag、判斷讀寫分流可行性
</span></span><span class="line"><span class="ln">3</span><span class="cl">db.IO.aurora_redo_log_flush # quorum write 等待、storage 瓶頸訊號</span></span></code></pre></div><p><strong>Production reference number</strong>（<a href="/blog/backend/09-performance-capacity/cases/draftkings-aurora-financial-ledger/" data-link-title="9.C4 DraftKings：Aurora 撐 100 萬 ops/min 的體育博彩金融帳本" data-link-desc="DraftKings 用 Aurora MySQL 跑體育博彩金融帳本、Super Bowl 流量 &#43;50% 不影響延遲">9.C4 DraftKings</a> 揭露、case「觀察」段表格）：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>DraftKings 在 Aurora MySQL 的數字</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>讀延遲</td>
          <td>&lt; 1 ms</td>
      </tr>
      <tr>
          <td>寫延遲</td>
          <td>6 ms</td>
      </tr>
      <tr>
          <td>Replication lag</td>
          <td>從 30 秒降到 10-30 ms</td>
      </tr>
  </tbody>
</table>
<p>這個 production reference 取代用「typical 3-5ms」籠統說法。讀寫 6x 差距是 OLTP 容量規劃槓桿 baseline — 寫延遲是 quorum 4-of-6 + 跨 AZ network round-trip 的物理下界、不是 storage 設計能再壓低。引用時要明示是 DraftKings production reference、不是 Aurora marketing。</p>
<p><strong>容量上限</strong>：</p>
<ul>
<li>128 TB / cluster（超過要拆 cluster、見 <a href="../read-replica-scaling/">Aurora read replica scaling</a> fleet 治理 SSoT）</li>
<li>15 read replica / region（<a href="../read-replica-scaling/">Aurora read replica scaling</a> 展開）</li>
<li>Storage 自動 grow per 10GB</li>
</ul>
<p><strong>跨 region replication</strong>：<a href="../global-database-multi-region/">Aurora Global Database</a> 用 <code>AuroraGlobalDBReplicationLag</code> 監測、&lt; 1 秒 typical。</p>
<p><strong>回路徑</strong>：<a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 Observability Evidence Package</a> 抽 CloudWatch evidence、<a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a> 判斷 storage-bound vs compute-bound。</p>
<h2 id="netflix-75-效能改善的根因">Netflix +75% 效能改善的根因</h2>
<p><a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix consolidation</a> 案例揭露 storage 設計的具體效能含義。Netflix 把多套 RDBMS（PostgreSQL / MySQL / Oracle）統一到 Aurora、拿到 <em>up to 75%</em> 效能改善、-28% 成本。</p>
<p><strong>+75% 的根因</strong>：</p>
<ul>
<li>傳統 PostgreSQL primary 寫 WAL + dirty page flush + 透過 streaming replication 推到 replica</li>
<li>Compute 大量 CPU 用在 dirty page flush + replication encoding、不是用在 query processing</li>
<li>Aurora compute 只送 redo log records、storage 自己 apply page、自己 checkpoint</li>
<li>→ 同樣 instance class 下、Aurora compute 能處理更多 query</li>
</ul>
<p>這不是 marketing 的「分散式儲存讓效能提升」籠統說法、而是具體的 <em>compute 不再 flush dirty page</em>。</p>
<p><strong>scope warning（必明示、case 自帶警示原話）</strong>：</p>
<p>「effective 75% improvement 是跨多 workload 的最大改善幅度、不是『每個 workload 都 +75%』。實際每個 workload 改善幅度從 10% 到 75% 不等」（case「需要警惕」段第 1 點）。</p>
<p>引用 Netflix 時不能把 75% 套到單一 workload — 容量規劃要看自家 workload 形狀（write-heavy / read-heavy / scan-heavy）、預估改善幅度範圍而非單一數字。</p>
<h2 id="fleet-治理cross-link不展開">Fleet 治理（cross-link、不展開）</h2>
<p>Production scale 不是「單一巨型 Aurora cluster」而是 <em>fleet of clusters</em> — 5 case 揭露同一 frame：</p>
<ul>
<li>DraftKings 200 個獨立 cluster（按業務切分）</li>
<li>Netflix 多 cluster（微服務私有 store）</li>
<li>Standard Chartered 7 個 cluster（受監管市場 boundary）</li>
</ul>
<p>跨 case 合成的 fleet 拓樸 3 條 driver（business sharding / microservice ownership / 合規市場 boundary）跟「何時拆 cluster vs 加 replica」的判讀順序、SSoT 在 <a href="../read-replica-scaling/">Aurora read replica scaling</a> 邊界段。Storage 設計本身不解 fleet 邊界決策 — Aurora 解 single-cluster scaling（quorum / 共享 storage / 共享 backup）、但「拆幾個 cluster」是業務拓樸決策。</p>
<h2 id="邊界與整合--下一步">邊界與整合 / 下一步</h2>
<p><strong>Sibling deep articles</strong>：</p>
<ul>
<li><a href="../cross-az-failover-rto/">Aurora cross-AZ failover RTO</a> — storage 設計如何加速 failover（replica 不需要 catch-up）</li>
<li><a href="../read-replica-scaling/">Aurora read replica scaling</a> — 共享 storage 為什麼能養 15 replica + fleet 治理 SSoT</li>
<li><a href="../global-database-multi-region/">Aurora Global Database</a> — 跨 region storage replication 設計</li>
</ul>
<p><strong>Migration playbook</strong>：</p>
<ul>
<li><a href="../migrate-from-self-managed-pg-mysql/">PostgreSQL / MySQL → Aurora</a> — storage 設計差是 operational redesign 的核心 driver</li>
</ul>
<p><strong>1.x 章節互引</strong>：</p>
<ul>
<li><a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 Transaction Boundary</a> — quorum 寫入 vs single-primary transaction 邊界</li>
<li><a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> — Aurora storage 是 single-region scaling、不是 distributed SQL</li>
</ul>
<p><strong>何時不用本文</strong>：single-region OLTP 用 RDS 仍足夠、storage architecture 細節不影響容量規劃時可跳過、看 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> 即可。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora vendor overview</a> — 服務定位、適用 / 不適用場景</li>
<li><a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">Quorum 卡片</a> — 概念基底</li>
<li><a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">Replication Lag 卡片</a> — 對照通用 replication lag 模型</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a> — 本文遵循的 6 規格面寫作模板</li>
<li>官方：<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.StorageReliability.html">Aurora storage architecture</a></li>
</ul>
]]></content:encoded></item><item><title>MongoDB Replica Set Read Preference：DB 層 causal session vs cache 層 freshness token</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/replica-set-read-preference/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/replica-set-read-preference/</guid><description>&lt;p>MongoDB replica set 在小規模時 read preference 五擇一就夠用、&lt;code>primary&lt;/code> 走預設、想分擔 primary 改 &lt;code>secondary&lt;/code> — 直觀但會在 production 反噬。讀者真正撞到的議題分兩層：DB 層的 read-your-own-write（同 client 寫完馬上讀讀不到）跟跨層的 read-after-write（write 進 MongoDB、cache 還是舊資料）。前者用 causal consistency session 解、後者要走 freshness token 跨層協議。Coinbase 1.5M reads/sec 不是純 MongoDB 撐出來、是 DB + cache 跨層合成。本文把 read preference 機制 + 跨層協作講清楚。&lt;/p>
&lt;p>本文不重複 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview&lt;/a> 已寫過的 replica set 簡介 — 而是 production 部署 + 跨層協作 + 失敗修復的實作層教學。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>進本文前先確認 MongoDB 已通過適配判讀&lt;/strong>：workload 是否落在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 判讀軸見 &lt;a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀&lt;/a>。Read scaling 是 &lt;em>已選 MongoDB 後&lt;/em> 的容量決策、判讀通不過時 read preference 修補無法救回 vendor 選錯。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境read-scaling-撞牆的兩種長相">問題情境：read scaling 撞牆的兩種長相&lt;/h2>
&lt;p>典型觸發場景：primary 寫入飽和、TL 提議「讀都打 secondary」想橫向擴容。改完後幾個 production 徵兆連環出現：&lt;/p>
&lt;ul>
&lt;li>User 看到「我剛下的訂單怎麼還沒出現」— write 進 primary、立刻 read 打 secondary、secondary 還沒 apply 該寫入、user 看到 stale data&lt;/li>
&lt;li>跨 region replica set：app server 在 Tokyo、primary 在 Singapore、每筆讀走 70ms 跨海 RTT；改 &lt;code>nearest&lt;/code> 後 latency 降但 stale read 出現&lt;/li>
&lt;li>Replication lag 在 backup 期間飆到分鐘級、&lt;code>secondary&lt;/code> read 拿到幾分鐘前的資料、前端報表時間軸對不上&lt;/li>
&lt;li>Failover 期間 read preference 沒寫好、client 一直連舊 primary、&lt;code>SocketTimeout&lt;/code> 直到 driver retry 邏輯介入&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>第二類議題、規模更大&lt;/strong>：把所有 read 打 secondary、replica 數量加到 5-7 仍撐不住 sustained 高 read（&amp;gt;500K reads/sec）；replication lag 升 + secondary CPU 飽和。這時 read preference 已不夠、必須加 cache + 跨層 freshness 機制。&lt;/p>
&lt;p>讀者徵兆：&lt;code>rs.printSecondaryReplicationInfo()&lt;/code> 顯示 lag 分鐘級、application log 出現「我剛寫的資料讀不到」客訴、failover 演練後 connection error 持續 30s+、cache hit rate 跟 read latency 反向相關。&lt;/p></description><content:encoded><![CDATA[<p>MongoDB replica set 在小規模時 read preference 五擇一就夠用、<code>primary</code> 走預設、想分擔 primary 改 <code>secondary</code> — 直觀但會在 production 反噬。讀者真正撞到的議題分兩層：DB 層的 read-your-own-write（同 client 寫完馬上讀讀不到）跟跨層的 read-after-write（write 進 MongoDB、cache 還是舊資料）。前者用 causal consistency session 解、後者要走 freshness token 跨層協議。Coinbase 1.5M reads/sec 不是純 MongoDB 撐出來、是 DB + cache 跨層合成。本文把 read preference 機制 + 跨層協作講清楚。</p>
<p>本文不重複 <a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> 已寫過的 replica set 簡介 — 而是 production 部署 + 跨層協作 + 失敗修復的實作層教學。</p>
<blockquote>
<p><strong>進本文前先確認 MongoDB 已通過適配判讀</strong>：workload 是否落在 MongoDB 適用區（document shape 主導 / contract layer 該放哪 / 跨雲 hedging 是否需要）— 判讀軸見 <a href="../schema-design-pattern/#%e5%95%8f%e9%a1%8c%e6%83%85%e5%a2%83document-%e8%87%aa%e7%94%b1%e7%9a%84%e5%be%8c%e5%ba%a7%e5%8a%9b">schema-design-pattern 開頭 3 軸前置判讀</a>。Read scaling 是 <em>已選 MongoDB 後</em> 的容量決策、判讀通不過時 read preference 修補無法救回 vendor 選錯。</p></blockquote>
<h2 id="問題情境read-scaling-撞牆的兩種長相">問題情境：read scaling 撞牆的兩種長相</h2>
<p>典型觸發場景：primary 寫入飽和、TL 提議「讀都打 secondary」想橫向擴容。改完後幾個 production 徵兆連環出現：</p>
<ul>
<li>User 看到「我剛下的訂單怎麼還沒出現」— write 進 primary、立刻 read 打 secondary、secondary 還沒 apply 該寫入、user 看到 stale data</li>
<li>跨 region replica set：app server 在 Tokyo、primary 在 Singapore、每筆讀走 70ms 跨海 RTT；改 <code>nearest</code> 後 latency 降但 stale read 出現</li>
<li>Replication lag 在 backup 期間飆到分鐘級、<code>secondary</code> read 拿到幾分鐘前的資料、前端報表時間軸對不上</li>
<li>Failover 期間 read preference 沒寫好、client 一直連舊 primary、<code>SocketTimeout</code> 直到 driver retry 邏輯介入</li>
</ul>
<p><strong>第二類議題、規模更大</strong>：把所有 read 打 secondary、replica 數量加到 5-7 仍撐不住 sustained 高 read（&gt;500K reads/sec）；replication lag 升 + secondary CPU 飽和。這時 read preference 已不夠、必須加 cache + 跨層 freshness 機制。</p>
<p>讀者徵兆：<code>rs.printSecondaryReplicationInfo()</code> 顯示 lag 分鐘級、application log 出現「我剛寫的資料讀不到」客訴、failover 演練後 connection error 持續 30s+、cache hit rate 跟 read latency 反向相關。</p>
<p>Case anchor：<a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> 揭露「document model 撐 1.5M reads/sec 靠 cache + freshness token」、含警示「1.5M reads/sec 是 users 服務 <em>加上 cache</em> 的數字、不是 MongoDB cluster 純讀取數字」。跨 region read preference 改 <code>nearest</code> 後 stale read 的具體 incident 細節需未來 case 補完、本文以「常見 failure pattern」處理。</p>
<h2 id="核心機制">核心機制</h2>
<h3 id="mongodb-read-preference--read-concern-兩軸">MongoDB read preference + read concern 兩軸</h3>
<p>Read preference 五種：</p>
<ul>
<li><strong><code>primary</code></strong>（預設）：只打 primary、強一致、primary 飽和時無路可走</li>
<li><strong><code>primaryPreferred</code></strong>：先 primary、primary 不可用 fallback secondary</li>
<li><strong><code>secondary</code></strong>：只打 secondary、永遠拒 primary、failover 期間若所有 secondary 都不行就拋錯</li>
<li><strong><code>secondaryPreferred</code></strong>：先 secondary、secondary 不可用 fallback primary</li>
<li><strong><code>nearest</code></strong>：不是「最近的 secondary」、是「ping latency 最低的 member」（可能是 primary）；driver 用 latency window（預設 15ms）內隨機挑</li>
</ul>
<p>Read concern 是另一軸：</p>
<ul>
<li><strong><code>local</code></strong>：讀本地最新（含未確認）、效能最佳、可能讀到後來 rollback 的資料</li>
<li><strong><code>available</code></strong>：跟 <code>local</code> 類似但對 sharded cluster 有差異</li>
<li><strong><code>majority</code></strong>：讀到「已寫到多數 member」的資料、寫入 commit 後在多數 member 確認後才看得到</li>
<li><strong><code>linearizable</code></strong>：強制最新、必須打 primary、最高 latency</li>
</ul>
<p>Write concern <code>w: &quot;majority&quot;</code> 保證寫入確認後在多數 member 上、但不保證 secondary 馬上 visible — 兩個概念分開。</p>
<h3 id="causal-consistency-sessiondb-層機制">Causal consistency session（DB 層機制）</h3>
<p>Causal consistency session 解的是 <em>單 client</em> 在 <em>MongoDB cluster 內部</em> 的因果一致：</p>
<ul>
<li>Client session 帶 <code>clusterTime</code> + <code>operationTime</code></li>
<li>Driver 把 read 路由到「已 apply 該 operationTime」的 member</li>
<li>實現 read-your-own-write（自己剛寫的、自己讀得到）</li>
</ul>
<p>機制只在「同一 client session」內生效。跨 client 的因果一致（A 寫 → B 讀）不在範圍內。</p>
<p>其他輔助機制：</p>
<ul>
<li><strong>Tag set</strong>：member 標 <code>{region: &quot;ap-tokyo&quot;, role: &quot;analytics&quot;}</code>、read preference 帶 tag 把流量路由到特定 member</li>
<li><strong>Hidden / delayed secondary</strong>：不參與 election、不接 client read、做 backup / DR 用</li>
<li><strong>Election</strong>：primary 失聯後 majority 投票選新 primary、預設 10s 內完成；election 期間所有 primary read 失敗</li>
</ul>
<h3 id="freshness-tokencache-層機制">Freshness token（cache 層機制）</h3>
<p>9.C36 Coinbase 揭露的 <em>跨層</em> 機制 — 解的是 <em>MongoDB + cache 跨層</em> 的 read-after-write、不是 cluster 內部。對應 <a href="/blog/backend/knowledge-cards/freshness-token/" data-link-title="Freshness Token" data-link-desc="DB write 後返回的版本 token、後續 read 帶 token、保證 read 看到的資料 ≥ token 版本、解 DB &#43; cache 跨層 read-after-write">Freshness Token</a> 卡片的 application-level 版本協議定義：</p>
<p><strong>觸發條件</strong>：直接打 MongoDB 不可能撐 1.5M reads/sec（口徑：users 服務應用層觀察、含 cache、非 MongoDB cluster 純讀取）。Coinbase 在 users 服務前加 Memcached query cache、單 document query 先查 cache。</p>
<p><strong>跨層一致性問題</strong>：write 進 MongoDB primary、cache 還是舊資料、client 下次 read 從 cache 拿到舊版。</p>
<p><strong>freshness token 機制</strong>：</p>
<ol>
<li>Write 成功後、server 給 client 一個 token（包含 OCC version / clusterTime）</li>
<li>Client 之後 read 帶這個 token</li>
<li>Server 保證返回的資料版本 ≥ token</li>
<li>若 cache 的版本 &lt; token、bypass cache 直接打 DB</li>
</ol>
<p><strong>跟 causal consistency session 的關係</strong>：兩者解決同一類問題（read-after-write）但作用範圍不同。Causal session 是 DB 層、保證在同一 cluster 內 read-your-own-write；freshness token 是 <em>DB + cache 兩層共用的版本協議</em>、保證跨層 read-your-own-write。</p>
<h3 id="跨層協作三選一">跨層協作三選一</h3>
<p>讀者真實系統的 read 一致性需求要選哪層處理：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只用 DB 層（causal session）</td>
          <td>無 cache 層、讀寫都直接打 MongoDB cluster</td>
          <td>replica scaling 上限約幾十萬 reads/sec</td>
      </tr>
      <tr>
          <td>只用 cache 層（freshness token）</td>
          <td>有 cache、跨層一致性要求高、application 願改</td>
          <td>需設計 token 協議 + cache bypass 邏輯</td>
      </tr>
      <tr>
          <td>兩層並用</td>
          <td>大規模 OLTP、cluster 內也要 causal、跨 cache 也要 freshness</td>
          <td>複雜度最高、但 Coinbase 規模必走此路</td>
      </tr>
  </tbody>
</table>
<p>對應 knowledge card：<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">stale-read</a>、<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">replication-lag</a>、<a href="/blog/backend/knowledge-cards/session-consistency/" data-link-title="Session Consistency" data-link-desc="同一使用者工作階段內維持讀寫一致、跨工作階段允許短暫不一致">session-consistency</a>、<a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">eventual-consistency</a>。</p>
<h2 id="操作流程">操作流程</h2>
<p><strong>Step 1：read shape 分類</strong>。把所有 read 分成四類：</p>
<ul>
<li>(a) 強一致必須 read-your-own-write（訂單詳情、帳戶餘額）</li>
<li>(b) 容忍秒級 lag（個人資料、商品詳情）</li>
<li>(c) 容忍分鐘級 lag（報表、analytics）</li>
<li>(d) 大規模 read scaling 需 cache + freshness token（用戶資料 / 高頻 product query）</li>
</ul>
<p><strong>Step 2：依分類對映機制</strong>。</p>
<table>
  <thead>
      <tr>
          <th>分類</th>
          <th>Read preference</th>
          <th>Read concern</th>
          <th>跨層機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>(a)</td>
          <td>primary</td>
          <td>majority</td>
          <td>causal consistency session</td>
      </tr>
      <tr>
          <td>(b)</td>
          <td>secondaryPreferred</td>
          <td>local</td>
          <td>monitoring lag alarm</td>
      </tr>
      <tr>
          <td>(c)</td>
          <td>secondary（tag set）</td>
          <td>available</td>
          <td>無</td>
      </tr>
      <tr>
          <td>(d)</td>
          <td>secondaryPreferred</td>
          <td>majority</td>
          <td>cache + freshness token + bypass</td>
      </tr>
  </tbody>
</table>
<p><strong>Step 3：driver config</strong>（Node.js / Java / Python 都類似）：</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">mongodb://host1:27017,host2:27017,host3:27017/db?
</span></span><span class="line"><span class="ln">2</span><span class="cl">  replicaSet=rs0&amp;
</span></span><span class="line"><span class="ln">3</span><span class="cl">  readPreference=secondaryPreferred&amp;
</span></span><span class="line"><span class="ln">4</span><span class="cl">  readPreferenceTags=region:ap-tokyo&amp;
</span></span><span class="line"><span class="ln">5</span><span class="cl">  readPreferenceTags=&amp;
</span></span><span class="line"><span class="ln">6</span><span class="cl">  maxStalenessSeconds=90&amp;
</span></span><span class="line"><span class="ln">7</span><span class="cl">  readConcernLevel=majority</span></span></code></pre></div><p><code>readPreferenceTags</code> 寫多個 = fallback chain（先 tokyo 失敗 fallback 任意）。<code>maxStalenessSeconds=90</code> 拒絕 lag &gt; 90s 的 secondary。</p>
<p><strong>Step 4：causal consistency session</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">with</span> <span class="n">client</span><span class="o">.</span><span class="n">start_session</span><span class="p">(</span><span class="n">causal_consistency</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">coll</span><span class="o">.</span><span class="n">insert_one</span><span class="p">(</span><span class="n">doc</span><span class="p">,</span> <span class="n">session</span><span class="o">=</span><span class="n">s</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1"># 下面這個 find 自動路由到能讀到剛才寫的 member</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">coll</span><span class="o">.</span><span class="n">find_one</span><span class="p">({</span><span class="s2">&#34;_id&#34;</span><span class="p">:</span> <span class="n">doc</span><span class="p">[</span><span class="s2">&#34;_id&#34;</span><span class="p">]},</span> <span class="n">session</span><span class="o">=</span><span class="n">s</span><span class="p">)</span></span></span></code></pre></div><p>Session 結束後因果關係結束、下個 session 不繼承。</p>
<p><strong>Step 5：freshness token 設計</strong>（9.C36 Coinbase 模式）：</p>
<ul>
<li>Write API 返回 <code>{result, version_token}</code> — token 含 OCC version 或 MongoDB clusterTime</li>
<li>Read API 接受 optional <code>If-Version-≥</code> header / parameter</li>
<li>Cache lookup 比對 cache entry version 跟 token、低於 token 就 invalidate + bypass 到 MongoDB</li>
<li>DB 層 read 用 <code>readConcern: &quot;majority&quot;</code> 保證返回的 version ≥ token</li>
</ul>
<p><strong>Step 6：staging 驗證</strong>。灌入 replication lag（暫停 secondary apply）驗證 application 行為；灌入 stale cache 驗證 token bypass 邏輯；模擬 failover 驗證 driver retry。</p>
<p>驗證點：</p>
<ul>
<li><code>rs.printSecondaryReplicationInfo()</code> lag &lt; SLO</li>
<li>driver metric <code>readPreferenceUsageCount</code> 分布符合預期</li>
<li>failover drill 後 read recovery &lt; 15s</li>
<li>cache hit rate vs freshness bypass rate 比例監控</li>
</ul>
<p>Rollback boundary：read preference 是 driver-side config、可以 hot-swap；causal consistency session 需 application code 改、需灰度；freshness token 是 application + cache + DB 三方協議、回退需協調。</p>
<h2 id="失敗模式">失敗模式</h2>
<p><strong>Read-after-write 不一致（DB 層）</strong>：寫 primary → 立刻 secondary read、應用 race condition 顯示「資料消失」。修法是 causal consistency session、driver 自動路由到已 apply 該寫入的 member。</p>
<p><strong>Read-after-write 不一致（跨層）</strong>：寫 primary → cache 還是舊資料 → user 看到舊資料。causal session 解不了（cache 在 MongoDB 外）、必須走 freshness token 跨層協議。</p>
<p><strong>Stale read 在 lag 高峰</strong>：backup / DDL / 大量寫入導致 secondary lag 分鐘級、<code>secondary</code> read 拿到舊資料。修法設 <code>maxStalenessSeconds</code> 拒舊 member、driver 自動轉到較新的 member 或 primary。</p>
<p><strong><code>nearest</code> 在跨 region 不穩</strong>：latency 抖動讓 driver 在 primary / secondary 跳、寫一致性與 read latency 同時惡化。修法是不要用 <code>nearest</code> 解跨 region 議題、應該用 tag set 明確路由。</p>
<p><strong>Failover 期間 primary read 全失敗</strong>：election 10s 內所有 primary read 拋錯。修法改 <code>primaryPreferred</code> + driver retry 邏輯吃掉短暫失敗、application 端配 retry policy。</p>
<p><strong>Tag set 失準</strong>：把 <code>region: &quot;ap-tokyo&quot;</code> 的流量路由到 tag 為 tokyo 的 member、但該 member 故障時沒 fallback、流量直接停。修法是 tag 設多層 fallback chain、最後一層留空 tag 表示「任意 member」。</p>
<p><strong>Analytical query 跑 OLTP secondary</strong>：<code>secondaryPreferred</code> 把報表打 OLTP secondary、報表 query 拖垮 OLTP read latency。修法是 analytical workload 用 tag set 路由到專屬 analytics secondary、跟 OLTP read 隔離。</p>
<p><strong>Freshness token 漏寫</strong>：write 沒帶 token 給 client / client 沒帶 token、token 機制 silently 失效、read 走 cache 拿舊資料。修法 token 必須 e2e 強制（middleware 自動帶 / 自動驗證）、不能靠 application 自覺。</p>
<p><strong>Cache bypass 比例失控</strong>：所有 read 都 bypass cache、cache 等於沒裝。修法是 token 失敗率要監控、過高表示 cache invalidation 設計有問題（cache 沒在 write 後 update / invalidate）。</p>
<p>Anti-recommendation：</p>
<ul>
<li>read-heavy 但有強一致需求的場景不要為了 scale 改 secondary read；該換 SQL + read replica 加 application-level cache、或加 sharding 把 primary 寫散開</li>
<li>大規模 OLTP（&gt;500K reads/sec）想單靠 MongoDB read preference 撐 = 拿不到那個量級。Coinbase 案明示「直接打 MongoDB 不可能撐 1.5M reads/sec」、必須 cache + freshness token</li>
</ul>
<h2 id="容量與觀測">容量與觀測</h2>
<p>關鍵 metric：</p>
<ul>
<li><strong>Replica health</strong>：每個 member 的 <code>opcounters</code> 分布、<code>rs.status().members[].optimeDate</code> 推算 lag</li>
<li><strong>Read preference 命中</strong>：driver-side <code>readPreferenceTags</code> 命中率</li>
<li><strong>一致性 SLO</strong>：stale read 比例（causal consistency 拒絕重試次數）</li>
<li><strong>跨層 freshness</strong>：cache hit rate vs freshness bypass rate</li>
</ul>
<p>Mongo command：</p>
<ul>
<li><code>rs.status()</code>：replica set 整體</li>
<li><code>rs.printSecondaryReplicationInfo()</code>：lag 概況</li>
<li><code>db.serverStatus().repl</code>：詳細 replication metric</li>
<li><code>db.adminCommand({replSetGetStatus:1})</code>：完整 status</li>
</ul>
<p>Application observability：APM 看「同一 session 內 write + read 順序對 latency / error 的影響」、SLO 是 read-your-own-write 命中率；跨層還要看 freshness token 流動完整性（write 是否發 token、read 是否帶 token、cache 是否驗 token）。</p>
<p>Lag alarm：lag &gt; 30s 預警、&gt; 90s 觸發 driver <code>maxStalenessSeconds</code> 自動拒讀。</p>
<p>回到 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">4.20 observability evidence</a>：把 read preference 命中分布、replication lag time series、failover drill recovery time、freshness token bypass rate 列為 evidence。</p>
<p>回到 <a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 bottleneck localization</a>：read latency 異常時要區分 (a) primary 飽和 (b) secondary lag 高 (c) tag routing 把流量集中到單一 member (d) cache hit rate 下降 / bypass 率上升。</p>
<h2 id="邊界與整合">邊界與整合</h2>
<h3 id="frame-5合規邊界--mongodb-用-cluster-per-region-吸收">Frame 5：合規邊界 — MongoDB 用 cluster-per-region 吸收</h3>
<p>MongoDB / Atlas 沒有 <em>row-level locality</em> 機制（不像 CockroachDB 可把單 row pin 在合規 region）— 跨境合規必須以 <em>cluster-per-region</em> 拓樸吸收：每個合規市場開獨立 cluster、application 層做 routing、不靠 replica set / sharded cluster 機制跨 region。</p>
<p>跨 vendor 對照：</p>
<table>
  <thead>
      <tr>
          <th>Vendor</th>
          <th>合規吸收機制</th>
          <th>拓樸特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MongoDB / Cosmos DB</td>
          <td>cluster-per-region（無 row-level locality 等價物）</td>
          <td>各 region 獨立 cluster、application 層做市場 routing</td>
      </tr>
      <tr>
          <td>Aurora</td>
          <td>fleet 拓樸（每市場獨立 cluster、Global Database 在合規場景反指標）</td>
          <td>active-passive per market、跨市場不複製</td>
      </tr>
      <tr>
          <td>CockroachDB</td>
          <td>locality + placement（邏輯一個 cluster + region pinning + Outposts）</td>
          <td>單 logical cluster、physical row 鎖在合規 region</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td>region-pinned Global Tables（按 region 開關 replication、各市場可分離）</td>
          <td>仍 active-active、但 replication 範圍可控</td>
      </tr>
  </tbody>
</table>
<p><strong>MongoDB 在這 frame 的退化點</strong>：read preference 機制本身不解合規 — 即使 <code>readPreferenceTags={region:eu}</code> 把流量路由到歐洲 secondary、但 primary 在亞洲時跨境 replication 仍在跑、合規 audit 不會放行 <em>路由層</em> 控制當作 <em>資料邊界</em> 控制。合規市場必須整 cluster 分離、再用 application 層 routing 把 user 帶到對應 cluster。</p>
<p><strong>Atlas 在合規場景的 fit</strong>：Atlas global cluster（zone sharding 把 shard 鎖在 region）是「跨 region 但 <em>資料 pin 在 zone</em>」的中介選項、適合 GDPR 軟條款（資料在歐洲 EEA 內可流動）；strict 條款（資料不能離開單一國家）仍須走 cluster-per-region。</p>
<h3 id="sibling-與-cross-link">Sibling 與 cross-link</h3>
<p>Sibling deep articles：</p>
<ul>
<li><a href="../shard-key-selection/">shard key selection</a> — read preference 解決不了 write 飽和、要切 shard</li>
<li><a href="../change-streams-kafka/">change streams + Kafka</a> — change stream 預設打 primary、放 secondary 的 trade-off</li>
<li><a href="../aggregation-pipeline-optimization/">aggregation pipeline optimization</a> — 把 analytical aggregation 路由到專屬 secondary</li>
<li><a href="../connection-management-and-cache-layer/">connection management and cache layer</a> — freshness token 是該篇的核心議題之一、本文聚焦 DB 層 vs cache 層機制對照、不展開 cache 部署架構</li>
</ul>
<p>Migration playbook：</p>
<ul>
<li>跨 region 強 consistency 需求 → <a href="/blog/backend/01-database/vendors/cosmosdb/" data-link-title="Azure Cosmos DB" data-link-desc="全球分散式 multi-model DB、5 個 consistency levels、Microsoft 自家 dogfood 證據">→ Cosmos DB MongoDB API</a>（5 consistency level）</li>
<li>跨 region 想保留原生 MongoDB → <a href="/blog/backend/01-database/vendors/mongodb/migrate-to-atlas/" data-link-title="MongoDB → Atlas：Atlas 不是 MongoDB &#43; managed、是另一個 product" data-link-desc="Atlas 號稱「MongoDB managed」但 operational model 完全不同（auto-scaling / VPC peering / IAM-driven access / 內建 backup / billing 模型）；本文採用 Type C operational redesign hybrid 結構、4-phase operational migration &#43; drop-in cutover、5 個 production 踩雷（連線數限制 / IP whitelist / backup retention / IAM token 過期 / billing 暴漲）">→ Atlas global cluster</a></li>
</ul>
<p>跟 1.x 互引：<a href="/blog/backend/01-database/high-concurrency-access/" data-link-title="1.1 高併發下的 SQL 讀寫邊界" data-link-desc="說明高併發服務如何共用資料庫 client、控制 transaction、管理 connection pool、避免資料庫成為瓶頸">1.1 高併發資料存取</a> 處理 read scaling pattern；<a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">1.11 全球分散式 OLTP</a> 處理跨 region 一致性升級路徑。</p>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mongodb/" data-link-title="MongoDB" data-link-desc="Document database 代表、Atlas managed、跨雲可用、許多大規模平台從 MongoDB 起家">MongoDB vendor overview</a> — 本文是該頁尾「replica set + read preference」backlog 的深度展開</li>
<li><a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章方法論</a></li>
<li><a href="/blog/backend/09-performance-capacity/cases/coinbase-mongodb-document-platform/" data-link-title="9.C36 Coinbase：MongoDB 撐 Ruby 單體 &#43; 1.5M reads/sec identity 服務" data-link-desc="Coinbase 以 MongoDB 為主資料層、自建 mongobetween connection proxy、users 服務在加密貨幣 surge 時撐 1.5M reads/sec">9.C36 Coinbase</a> — freshness token + 1.5M reads/sec（含 cache）</li>
<li>官方：<a href="https://www.mongodb.com/docs/manual/core/read-preference/">MongoDB Read Preference</a>、<a href="https://www.mongodb.com/docs/manual/reference/read-concern/">Read Concern</a>、<a href="https://www.mongodb.com/docs/manual/core/causal-consistency-read-write-concerns/">Causal Consistency</a></li>
</ul>
]]></content:encoded></item><item><title>MySQL Multi-source Replication</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/multi-source-replication/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/multi-source-replication/</guid><description>&lt;p>MySQL multi-source replication 的核心責任是讓一個 replica 從多個 source 接收資料。這種拓撲常用於資料整併、分庫匯總、migration staging、報表集中或多個 bounded context 的 read consolidation。&lt;/p>
&lt;p>本文的判讀錨點是：multi-source replication 是 consolidation pattern，而非 multi-primary conflict resolution。每個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replication-channel/" data-link-title="Replication Channel" data-link-desc="說明多來源複製中，每個來源對應的獨立複製通道如何成為隔離單位">replication channel&lt;/a> 要有獨立 source、schema scope、lag、error handling 與 ownership。&lt;/p>
&lt;h2 id="use-cases">Use Cases&lt;/h2>
&lt;p>Use cases 的核心責任是確認 multi-source 解決的是整併需求。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>適合條件&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Reporting replica&lt;/td>
 &lt;td>多個 source 匯入同一 read-only target&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Migration staging&lt;/td>
 &lt;td>新平台先接多個 source binlog&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Regional fan-in&lt;/td>
 &lt;td>多區 local DB 匯總到中心&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Shard consolidation&lt;/td>
 &lt;td>多 shard 同 schema 匯入 reporting DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Audit / CDC sink&lt;/td>
 &lt;td>變更集中供後續 pipeline 使用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Multi-source target 通常應 read-only。若 target 同時接受 application write，就要設計 conflict 與 ownership，複雜度會大幅提高。&lt;/p>
&lt;h2 id="channel-design">Channel Design&lt;/h2>
&lt;p>Channel design 的核心責任是把每個 source 隔離成可觀測單位。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計項&lt;/th>
 &lt;th>審查問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Channel name&lt;/td>
 &lt;td>是否能看出 source / owner / purpose&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Schema scope&lt;/td>
 &lt;td>不同 source 是否寫入不同 schema / table&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GTID&lt;/td>
 &lt;td>GTID domain / collision policy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter&lt;/td>
 &lt;td>replicate-do / ignore 規則是否可審查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Credential&lt;/td>
 &lt;td>每個 channel 是否獨立 secret&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lag alert&lt;/td>
 &lt;td>channel-level lag 與 error&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Channel 命名要可讀。Incident 時看到 channel 名稱，就要知道哪個 source、哪個 team、哪個用途與是否可暫停。&lt;/p>
&lt;h2 id="conflict-boundary">Conflict Boundary&lt;/h2>
&lt;p>Conflict boundary 的核心責任是避免多個 source 寫同一份邏輯資料。Multi-source 沒有自動解決業務 conflict 的能力。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Conflict 類型&lt;/th>
 &lt;th>控制方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Primary key collision&lt;/td>
 &lt;td>shard key prefix、schema isolation&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Duplicate natural key&lt;/td>
 &lt;td>source namespace、dedupe layer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Out-of-order update&lt;/td>
 &lt;td>source ownership、event timestamp&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Delete collision&lt;/td>
 &lt;td>tombstone policy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DDL drift&lt;/td>
 &lt;td>migration coordination&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>最安全的 pattern 是每個 source 寫自己的 schema 或帶 source namespace 的 table。若多 source 寫同一 table，必須先設計 key space 與 conflict policy。&lt;/p>
&lt;h2 id="monitoring">Monitoring&lt;/h2>
&lt;p>Monitoring 的核心責任是讓每個 channel 的狀態可見。&lt;/p></description><content:encoded><![CDATA[<p>MySQL multi-source replication 的核心責任是讓一個 replica 從多個 source 接收資料。這種拓撲常用於資料整併、分庫匯總、migration staging、報表集中或多個 bounded context 的 read consolidation。</p>
<p>本文的判讀錨點是：multi-source replication 是 consolidation pattern，而非 multi-primary conflict resolution。每個 <a href="/blog/backend/knowledge-cards/replication-channel/" data-link-title="Replication Channel" data-link-desc="說明多來源複製中，每個來源對應的獨立複製通道如何成為隔離單位">replication channel</a> 要有獨立 source、schema scope、lag、error handling 與 ownership。</p>
<h2 id="use-cases">Use Cases</h2>
<p>Use cases 的核心責任是確認 multi-source 解決的是整併需求。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>適合條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Reporting replica</td>
          <td>多個 source 匯入同一 read-only target</td>
      </tr>
      <tr>
          <td>Migration staging</td>
          <td>新平台先接多個 source binlog</td>
      </tr>
      <tr>
          <td>Regional fan-in</td>
          <td>多區 local DB 匯總到中心</td>
      </tr>
      <tr>
          <td>Shard consolidation</td>
          <td>多 shard 同 schema 匯入 reporting DB</td>
      </tr>
      <tr>
          <td>Audit / CDC sink</td>
          <td>變更集中供後續 pipeline 使用</td>
      </tr>
  </tbody>
</table>
<p>Multi-source target 通常應 read-only。若 target 同時接受 application write，就要設計 conflict 與 ownership，複雜度會大幅提高。</p>
<h2 id="channel-design">Channel Design</h2>
<p>Channel design 的核心責任是把每個 source 隔離成可觀測單位。</p>
<table>
  <thead>
      <tr>
          <th>設計項</th>
          <th>審查問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Channel name</td>
          <td>是否能看出 source / owner / purpose</td>
      </tr>
      <tr>
          <td>Schema scope</td>
          <td>不同 source 是否寫入不同 schema / table</td>
      </tr>
      <tr>
          <td>GTID</td>
          <td>GTID domain / collision policy</td>
      </tr>
      <tr>
          <td>Filter</td>
          <td>replicate-do / ignore 規則是否可審查</td>
      </tr>
      <tr>
          <td>Credential</td>
          <td>每個 channel 是否獨立 secret</td>
      </tr>
      <tr>
          <td>Lag alert</td>
          <td>channel-level lag 與 error</td>
      </tr>
  </tbody>
</table>
<p>Channel 命名要可讀。Incident 時看到 channel 名稱，就要知道哪個 source、哪個 team、哪個用途與是否可暫停。</p>
<h2 id="conflict-boundary">Conflict Boundary</h2>
<p>Conflict boundary 的核心責任是避免多個 source 寫同一份邏輯資料。Multi-source 沒有自動解決業務 conflict 的能力。</p>
<table>
  <thead>
      <tr>
          <th>Conflict 類型</th>
          <th>控制方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Primary key collision</td>
          <td>shard key prefix、schema isolation</td>
      </tr>
      <tr>
          <td>Duplicate natural key</td>
          <td>source namespace、dedupe layer</td>
      </tr>
      <tr>
          <td>Out-of-order update</td>
          <td>source ownership、event timestamp</td>
      </tr>
      <tr>
          <td>Delete collision</td>
          <td>tombstone policy</td>
      </tr>
      <tr>
          <td>DDL drift</td>
          <td>migration coordination</td>
      </tr>
  </tbody>
</table>
<p>最安全的 pattern 是每個 source 寫自己的 schema 或帶 source namespace 的 table。若多 source 寫同一 table，必須先設計 key space 與 conflict policy。</p>
<h2 id="monitoring">Monitoring</h2>
<p>Monitoring 的核心責任是讓每個 channel 的狀態可見。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SHOW</span><span class="w"> </span><span class="n">REPLICA</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="n">CHANNEL</span><span class="w"> </span><span class="s1">&#39;source_a&#39;</span><span class="err">\</span><span class="k">G</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">REPLICA</span><span class="w"> </span><span class="n">STATUS</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="n">CHANNEL</span><span class="w"> </span><span class="s1">&#39;source_b&#39;</span><span class="err">\</span><span class="k">G</span></span></span></code></pre></div><p>要觀測：</p>
<ol>
<li>IO thread / SQL thread status。</li>
<li>Seconds behind source。</li>
<li>Last IO error / SQL error。</li>
<li>Relay log growth。</li>
<li>GTID executed / retrieved。</li>
<li>Channel credential expiry。</li>
</ol>
<p>Lag 要分 channel 告警。總體 replica 健康不足以定位哪個 source 卡住。</p>
<h2 id="migration-pattern">Migration Pattern</h2>
<p>Migration pattern 的核心責任是把 multi-source 用在可回退的搬遷。</p>
<table>
  <thead>
      <tr>
          <th>Phase</th>
          <th>Evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source audit</td>
          <td>schema、GTID、binlog format</td>
      </tr>
      <tr>
          <td>Target setup</td>
          <td>channel、filter、credential</td>
      </tr>
      <tr>
          <td>Backfill</td>
          <td>dump / load、checksum</td>
      </tr>
      <tr>
          <td>Catch-up</td>
          <td>channel lag、error</td>
      </tr>
      <tr>
          <td>Read test</td>
          <td>report query、row count</td>
      </tr>
      <tr>
          <td>Cutover</td>
          <td>read endpoint switch</td>
      </tr>
      <tr>
          <td>Cleanup</td>
          <td>stop channel、retention、secret</td>
      </tr>
  </tbody>
</table>
<p>Migration target 若只是 reporting，cutover 風險較低；若要成為 new primary，還要處理 write freeze、conflict、application route 與 rollback。</p>
<h2 id="failure-modes">Failure Modes</h2>
<p>Failure modes 的核心責任是把 multi-source 事故分 channel 處理。</p>
<table>
  <thead>
      <tr>
          <th>Failure mode</th>
          <th>判讀訊號</th>
          <th>修正方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Single channel lag</td>
          <td>某 source 延遲</td>
          <td>查 source load、network、SQL error</td>
      </tr>
      <tr>
          <td>DDL drift</td>
          <td>replication SQL error</td>
          <td>migration coordination</td>
      </tr>
      <tr>
          <td>Key collision</td>
          <td>duplicate key error</td>
          <td>namespace / key rewrite</td>
      </tr>
      <tr>
          <td>Relay log growth</td>
          <td>target apply 慢</td>
          <td>調整 parallel apply、拆 workload</td>
      </tr>
      <tr>
          <td>Credential expired</td>
          <td>IO thread stopped</td>
          <td>rotate secret、resume channel</td>
      </tr>
  </tbody>
</table>
<p>Channel failure 要避免全局操作。只停問題 channel，保留其他 channel，能降低 blast radius。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>Multi-source replication 完成後，基本拓撲讀 <a href="../replication-topology/">Replication Topology</a>；failover 讀 <a href="../orchestrator-failover/">Orchestrator Failover</a>；CDC 與 binlog 讀 <a href="../binlog-cdc/">Binlog CDC</a>。</p>
]]></content:encoded></item><item><title>MySQL Replication Failover Lab</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/hands-on/replication-failover-lab/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/hands-on/replication-failover-lab/</guid><description>&lt;p>MySQL replication failover lab 的核心責任是讓讀者觀察 source / replica 拓撲在 promotion 時的資料與 client route。這篇承接 &lt;a href="../../replication-topology/">Replication Topology&lt;/a> 與 &lt;a href="../../orchestrator-failover/">Orchestrator Failover&lt;/a>。&lt;/p>
&lt;p>本文的驗收標準是：你能記錄 replication status、lag、promotion timeline、client error sample、validation query 與 incident decision log。&lt;/p>
&lt;h2 id="baseline-replication">Baseline Replication&lt;/h2>
&lt;p>Baseline replication 的核心責任是先保存 source / replica 狀態。實際建立 replication 依 GTID、binlog file position、Docker topology 或 managed service 而異；本文聚焦演練 evidence。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SHOW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STATUS&lt;/span>&lt;span class="err">\&lt;/span>&lt;span class="k">G&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SHOW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BINARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LOG&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STATUS&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Baseline 要記錄：&lt;/p>
&lt;ol>
&lt;li>Source host / replica host。&lt;/li>
&lt;li>GTID executed / retrieved。&lt;/li>
&lt;li>IO thread / SQL thread。&lt;/li>
&lt;li>Seconds behind source。&lt;/li>
&lt;li>Read endpoint / write endpoint。&lt;/li>
&lt;/ol>
&lt;h2 id="client-workload">Client Workload&lt;/h2>
&lt;p>Client workload 的核心責任是讓 failover 對 application 可見。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">while&lt;/span> true&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> mysql -h &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$MYSQL_WRITE_HOST&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> -u app_user -papp_pw appdb &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -e &lt;span class="s2">&amp;#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key) VALUES (1, 1, UUID());&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> sleep &lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 synthetic workload 產生成功、timeout、duplicate、read-only 或 connection error。正式演練要避免碰 production side effect。&lt;/p>
&lt;h2 id="promotion-frame">Promotion Frame&lt;/h2>
&lt;p>Promotion frame 的核心責任是把 failover action 寫成可審查步驟。&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">failover_start:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">old_source:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">candidate_replica:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">lag_before:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">promotion_method:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">accepted_data_loss:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">operator:&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Managed service、Orchestrator 或手動 promotion 都要留下同樣欄位。工具不同，決策證據一致。&lt;/p>
&lt;h2 id="validation">Validation&lt;/h2>
&lt;p>Validation 的核心責任是確認 promoted instance 可讀寫且資料符合預期。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ledger_entries&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MAX&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ledger_entries&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SHOW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VARIABLES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIKE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;read_only&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SHOW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VARIABLES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIKE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;super_read_only&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>若使用 GTID，還要比較 source / replica 的 GTID set。若有 external side effect，要用 idempotency key 做 reconciliation。&lt;/p>
&lt;h2 id="client-route">Client Route&lt;/h2>
&lt;p>Client route 的核心責任是確認 application、ProxySQL、DNS 或 secret 已指向新 writer。&lt;/p>
&lt;p>檢查項目：&lt;/p>
&lt;ol>
&lt;li>Write endpoint 是否更新。&lt;/li>
&lt;li>ProxySQL writer hostgroup 是否切換。&lt;/li>
&lt;li>Application pool 是否清掉舊連線。&lt;/li>
&lt;li>Retry 是否有 backoff。&lt;/li>
&lt;li>Read replica 是否重新掛到新 source。&lt;/li>
&lt;/ol>
&lt;p>Failover 完成標準包含資料庫 promotion 與 client route 穩定。只 promote 成功，application 仍可能寫到舊 endpoint。&lt;/p></description><content:encoded><![CDATA[<p>MySQL replication failover lab 的核心責任是讓讀者觀察 source / replica 拓撲在 promotion 時的資料與 client route。這篇承接 <a href="../../replication-topology/">Replication Topology</a> 與 <a href="../../orchestrator-failover/">Orchestrator Failover</a>。</p>
<p>本文的驗收標準是：你能記錄 replication status、lag、promotion timeline、client error sample、validation query 與 incident decision log。</p>
<h2 id="baseline-replication">Baseline Replication</h2>
<p>Baseline replication 的核心責任是先保存 source / replica 狀態。實際建立 replication 依 GTID、binlog file position、Docker topology 或 managed service 而異；本文聚焦演練 evidence。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SHOW</span><span class="w"> </span><span class="n">REPLICA</span><span class="w"> </span><span class="n">STATUS</span><span class="err">\</span><span class="k">G</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SHOW</span><span class="w"> </span><span class="nb">BINARY</span><span class="w"> </span><span class="n">LOG</span><span class="w"> </span><span class="n">STATUS</span><span class="p">;</span></span></span></code></pre></div><p>Baseline 要記錄：</p>
<ol>
<li>Source host / replica host。</li>
<li>GTID executed / retrieved。</li>
<li>IO thread / SQL thread。</li>
<li>Seconds behind source。</li>
<li>Read endpoint / write endpoint。</li>
</ol>
<h2 id="client-workload">Client Workload</h2>
<p>Client workload 的核心責任是讓 failover 對 application 可見。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">while</span> true<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  mysql -h <span class="s2">&#34;</span><span class="nv">$MYSQL_WRITE_HOST</span><span class="s2">&#34;</span> -u app_user -papp_pw appdb <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>    -e <span class="s2">&#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key) VALUES (1, 1, UUID());&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  sleep <span class="m">1</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>這個 synthetic workload 產生成功、timeout、duplicate、read-only 或 connection error。正式演練要避免碰 production side effect。</p>
<h2 id="promotion-frame">Promotion Frame</h2>
<p>Promotion frame 的核心責任是把 failover action 寫成可審查步驟。</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">failover_start:
</span></span><span class="line"><span class="ln">2</span><span class="cl">old_source:
</span></span><span class="line"><span class="ln">3</span><span class="cl">candidate_replica:
</span></span><span class="line"><span class="ln">4</span><span class="cl">lag_before:
</span></span><span class="line"><span class="ln">5</span><span class="cl">promotion_method:
</span></span><span class="line"><span class="ln">6</span><span class="cl">accepted_data_loss:
</span></span><span class="line"><span class="ln">7</span><span class="cl">operator:</span></span></code></pre></div><p>Managed service、Orchestrator 或手動 promotion 都要留下同樣欄位。工具不同，決策證據一致。</p>
<h2 id="validation">Validation</h2>
<p>Validation 的核心責任是確認 promoted instance 可讀寫且資料符合預期。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">ledger_entries</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="n">created_at</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">ledger_entries</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">VARIABLES</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;read_only&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">SHOW</span><span class="w"> </span><span class="n">VARIABLES</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;super_read_only&#39;</span><span class="p">;</span></span></span></code></pre></div><p>若使用 GTID，還要比較 source / replica 的 GTID set。若有 external side effect，要用 idempotency key 做 reconciliation。</p>
<h2 id="client-route">Client Route</h2>
<p>Client route 的核心責任是確認 application、ProxySQL、DNS 或 secret 已指向新 writer。</p>
<p>檢查項目：</p>
<ol>
<li>Write endpoint 是否更新。</li>
<li>ProxySQL writer hostgroup 是否切換。</li>
<li>Application pool 是否清掉舊連線。</li>
<li>Retry 是否有 backoff。</li>
<li>Read replica 是否重新掛到新 source。</li>
</ol>
<p>Failover 完成標準包含資料庫 promotion 與 client route 穩定。只 promote 成功，application 仍可能寫到舊 endpoint。</p>
<h2 id="incident-log">Incident Log</h2>
<p>Incident log 的核心責任是把演練結果保存。</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">Drill id:
</span></span><span class="line"><span class="ln">2</span><span class="cl">RTO observed:
</span></span><span class="line"><span class="ln">3</span><span class="cl">RPO / accepted data loss:
</span></span><span class="line"><span class="ln">4</span><span class="cl">Client errors:
</span></span><span class="line"><span class="ln">5</span><span class="cl">Validation:
</span></span><span class="line"><span class="ln">6</span><span class="cl">Follow-up:</span></span></code></pre></div><p>完成本篇後，拓撲設計讀 <a href="../../replication-topology/">Replication Topology</a>；自動化工具讀 <a href="../../orchestrator-failover/">Orchestrator Failover</a>；routing 讀 <a href="../proxysql-routing-lab/">ProxySQL Routing Lab</a>。</p>
]]></content:encoded></item><item><title>PostgreSQL Cross-region DR</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/cross-region-dr/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/cross-region-dr/</guid><description>&lt;p>PostgreSQL cross-region DR 的核心責任是把區域性事故下的資料恢復、服務切換與資料一致性風險寫成可演練流程。跨區 DR 通常由法規、業務連續性、雲區故障、區域隔離或高可用承諾觸發。&lt;/p>
&lt;p>本文的判讀錨點是：cross-region DR 是恢復策略，而非自動等同 multi-region active-active。PostgreSQL 可以透過 backup / WAL archive、physical standby、logical replication、managed service replica 或 application-level replication 支援不同 RPO / RTO；每種路線都有資料延遲、切換與回切成本。&lt;/p>
&lt;h2 id="dr-strategy">DR Strategy&lt;/h2>
&lt;p>DR strategy 的核心責任是把恢復目標和技術路線對齊。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>RPO / RTO 型態&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Backup + WAL archive&lt;/td>
 &lt;td>RPO 依 WAL archive，RTO 依 restore&lt;/td>
 &lt;td>成本敏感、低頻災難復原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cross-region standby&lt;/td>
 &lt;td>RPO 接近 replication lag，RTO 較短&lt;/td>
 &lt;td>需要較快啟動 read / promote&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Logical replication&lt;/td>
 &lt;td>table-level / selective DR&lt;/td>
 &lt;td>跨版本、跨 schema、局部資料同步&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Managed global DB&lt;/td>
 &lt;td>雲平台提供跨區 replica&lt;/td>
 &lt;td>希望降低自管複製與 promote 維運&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Application replay&lt;/td>
 &lt;td>event / queue 重建狀態&lt;/td>
 &lt;td>domain event 已是 source of truth&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>RPO 要由業務定義。若付款、訂單、庫存只允許秒級遺失，backup-only 路線通常成本不足；若是內部報表或可重建資料，backup + WAL archive 可能足夠。&lt;/p>
&lt;h2 id="physical-vs-logical">Physical vs Logical&lt;/h2>
&lt;p>Physical vs logical 的核心責任是區分 byte-level recovery 與 row-level replication。Physical replica 保留 PostgreSQL cluster 層級狀態；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/logical-replication/" data-link-title="Logical Replication" data-link-desc="說明以表為粒度解碼 row-level 變更的複製方式，對照 byte-level 的實體複製">logical replication&lt;/a> 提供 table / publication 層級彈性。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>Physical standby&lt;/th>
 &lt;th>Logical replication&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>粒度&lt;/td>
 &lt;td>cluster / database&lt;/td>
 &lt;td>table / publication&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>版本彈性&lt;/td>
 &lt;td>通常要求版本與系統相容&lt;/td>
 &lt;td>可支援跨版本 / selective migration&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DDL&lt;/td>
 &lt;td>跟隨 WAL / 需相容&lt;/td>
 &lt;td>需要 schema coordination&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Failover&lt;/td>
 &lt;td>promote standby&lt;/td>
 &lt;td>application / target DB 切換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>風險&lt;/td>
 &lt;td>replication lag、timeline&lt;/td>
 &lt;td>slot lag、schema drift、missing key&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Physical standby 適合整體 DR。它的 runbook 要處理 WAL archive、replication lag、promotion、timeline、DNS / connection string 切換與回切。&lt;/p>
&lt;p>Logical replication 適合局部資料或跨版本轉換。它的 runbook 要處理 publication、subscription、replication slot、schema migration ordering 與資料 diff。&lt;/p>
&lt;h2 id="failover-runbook">Failover Runbook&lt;/h2>
&lt;p>Failover runbook 的核心責任是把災難切換變成可演練步驟。最小流程包含 incident declare、source freeze、replica health check、promote、traffic switch、data validation 與 rollback / rebuild。&lt;/p></description><content:encoded><![CDATA[<p>PostgreSQL cross-region DR 的核心責任是把區域性事故下的資料恢復、服務切換與資料一致性風險寫成可演練流程。跨區 DR 通常由法規、業務連續性、雲區故障、區域隔離或高可用承諾觸發。</p>
<p>本文的判讀錨點是：cross-region DR 是恢復策略，而非自動等同 multi-region active-active。PostgreSQL 可以透過 backup / WAL archive、physical standby、logical replication、managed service replica 或 application-level replication 支援不同 RPO / RTO；每種路線都有資料延遲、切換與回切成本。</p>
<h2 id="dr-strategy">DR Strategy</h2>
<p>DR strategy 的核心責任是把恢復目標和技術路線對齊。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>RPO / RTO 型態</th>
          <th>適合情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Backup + WAL archive</td>
          <td>RPO 依 WAL archive，RTO 依 restore</td>
          <td>成本敏感、低頻災難復原</td>
      </tr>
      <tr>
          <td>Cross-region standby</td>
          <td>RPO 接近 replication lag，RTO 較短</td>
          <td>需要較快啟動 read / promote</td>
      </tr>
      <tr>
          <td>Logical replication</td>
          <td>table-level / selective DR</td>
          <td>跨版本、跨 schema、局部資料同步</td>
      </tr>
      <tr>
          <td>Managed global DB</td>
          <td>雲平台提供跨區 replica</td>
          <td>希望降低自管複製與 promote 維運</td>
      </tr>
      <tr>
          <td>Application replay</td>
          <td>event / queue 重建狀態</td>
          <td>domain event 已是 source of truth</td>
      </tr>
  </tbody>
</table>
<p>RPO 要由業務定義。若付款、訂單、庫存只允許秒級遺失，backup-only 路線通常成本不足；若是內部報表或可重建資料，backup + WAL archive 可能足夠。</p>
<h2 id="physical-vs-logical">Physical vs Logical</h2>
<p>Physical vs logical 的核心責任是區分 byte-level recovery 與 row-level replication。Physical replica 保留 PostgreSQL cluster 層級狀態；<a href="/blog/backend/knowledge-cards/logical-replication/" data-link-title="Logical Replication" data-link-desc="說明以表為粒度解碼 row-level 變更的複製方式，對照 byte-level 的實體複製">logical replication</a> 提供 table / publication 層級彈性。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Physical standby</th>
          <th>Logical replication</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>粒度</td>
          <td>cluster / database</td>
          <td>table / publication</td>
      </tr>
      <tr>
          <td>版本彈性</td>
          <td>通常要求版本與系統相容</td>
          <td>可支援跨版本 / selective migration</td>
      </tr>
      <tr>
          <td>DDL</td>
          <td>跟隨 WAL / 需相容</td>
          <td>需要 schema coordination</td>
      </tr>
      <tr>
          <td>Failover</td>
          <td>promote standby</td>
          <td>application / target DB 切換</td>
      </tr>
      <tr>
          <td>風險</td>
          <td>replication lag、timeline</td>
          <td>slot lag、schema drift、missing key</td>
      </tr>
  </tbody>
</table>
<p>Physical standby 適合整體 DR。它的 runbook 要處理 WAL archive、replication lag、promotion、timeline、DNS / connection string 切換與回切。</p>
<p>Logical replication 適合局部資料或跨版本轉換。它的 runbook 要處理 publication、subscription、replication slot、schema migration ordering 與資料 diff。</p>
<h2 id="failover-runbook">Failover Runbook</h2>
<p>Failover runbook 的核心責任是把災難切換變成可演練步驟。最小流程包含 incident declare、source freeze、replica health check、promote、traffic switch、data validation 與 rollback / rebuild。</p>
<table>
  <thead>
      <tr>
          <th>Step</th>
          <th>操作</th>
          <th>Evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Declare incident</td>
          <td>確認 primary region 事故範圍</td>
          <td>incident decision log</td>
      </tr>
      <tr>
          <td>Freeze source</td>
          <td>停止寫入或確認 source 已不可用</td>
          <td>last known LSN / timestamp</td>
      </tr>
      <tr>
          <td>Check replica</td>
          <td>lag、WAL received、read health</td>
          <td>replica status snapshot</td>
      </tr>
      <tr>
          <td>Promote</td>
          <td>promote standby 或啟用 target</td>
          <td>new timeline / role</td>
      </tr>
      <tr>
          <td>Switch traffic</td>
          <td>DNS、secret、connection string</td>
          <td>app smoke test</td>
      </tr>
      <tr>
          <td>Validate</td>
          <td>row count、critical invariant</td>
          <td>validation report</td>
      </tr>
      <tr>
          <td>Rebuild</td>
          <td>重建舊 primary 或新 standby</td>
          <td>follow-up runbook</td>
      </tr>
  </tbody>
</table>
<p>Failover 決策要有 owner。自動化可以執行步驟，但是否接受資料遺失、是否凍結寫入、是否 promote，仍需要明確責任人與 tripwire。</p>
<h2 id="data-reconciliation">Data Reconciliation</h2>
<p>Data reconciliation 的核心責任是處理 cross-region 切換後的資料差異。只要 replication lag 存在，failover 後就可能有未套用交易。</p>
<table>
  <thead>
      <tr>
          <th>差異類型</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>已提交但未複製</td>
          <td>從 source WAL / app log / event 補償</td>
      </tr>
      <tr>
          <td>client retry 重複寫入</td>
          <td>idempotency key / natural key 去重</td>
      </tr>
      <tr>
          <td>sequence / identity</td>
          <td>target sequence reset / collision check</td>
      </tr>
      <tr>
          <td>external side effect</td>
          <td>payment、email、queue 需對帳</td>
      </tr>
  </tbody>
</table>
<p>Reconciliation 要先定義 critical table。所有表都做 full diff 成本高；付款、訂單、權限、ledger、mutation log 等高風險資料要有專用 validation query。</p>
<h2 id="drill-design">Drill Design</h2>
<p>Drill design 的核心責任是定期驗證 RPO / RTO。DR 文件只有在演練後才可信。</p>
<p>演練至少包含：</p>
<ol>
<li>從 backup + WAL 還原到指定時間。</li>
<li>Promote standby 到 isolated environment。</li>
<li>Application 使用 DR endpoint 跑 smoke test。</li>
<li>計算實際 RPO / RTO。</li>
<li>記錄失敗點、人工步驟與下一次修正。</li>
</ol>
<p>演練應避開 production destructive action。使用 isolated VPC、staging app、read-only validation 與 mock external side effect。</p>
<h2 id="no-go-conditions">No-Go Conditions</h2>
<p>No-go conditions 的核心責任是指出 PostgreSQL cross-region DR 的邊界。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>建議路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多區同時交易寫入是核心需求</td>
          <td>CockroachDB / Spanner / YugabyteDB 類 distributed SQL</td>
      </tr>
      <tr>
          <td>RPO 接近零且跨區距離大</td>
          <td>synchronous replication latency 成本評估</td>
      </tr>
      <tr>
          <td>Team 缺少 DR 演練能力</td>
          <td>managed service + vendor runbook</td>
      </tr>
      <tr>
          <td>數據 residency 限制跨區複製</td>
          <td>regional shard / policy-driven replication</td>
      </tr>
  </tbody>
</table>
<p>Cross-region DR 要誠實面對延遲。把每個 region 都變成 writer 需要 distributed transaction 模型；PostgreSQL DR 路線主要提供恢復與切換。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>Cross-region DR 完成後，恢復實作讀 <a href="../pitr-wal-archiving/">PITR / WAL Archiving</a>；replication 架構讀 <a href="../replication-topology/">Replication Topology</a>；跨區 rollout 的資料政策讀 <a href="../multi-region-gdpr-rollout/">Multi-region GDPR Rollout</a>。</p>
]]></content:encoded></item><item><title>SQLite Litestream / LiteFS Replication</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/litestream-litefs-replication/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/litestream-litefs-replication/</guid><description>&lt;p>Litestream / LiteFS replication 的核心責任是把 SQLite 的 single-file operation 補成可恢復、可部署、可讀擴展的服務形狀。這類工具延伸 SQLite，但它們解決的問題不同：Litestream 主要把 WAL 變化持續送到 replica storage，強化 backup 與 restore；LiteFS 主要在 Fly.io 生態中透過 primary lease 與 filesystem layer 支援 replicated SQLite deployment。&lt;/p>
&lt;p>本文的判讀錨點是：replicated SQLite 要先說明 replica 的服務責任。它可能是 continuous backup、warm restore source、read replica、primary failover helper 或 deployment topology；每一種責任都有不同的 RPO、RTO、freshness 與 incident runbook。&lt;/p>
&lt;h2 id="replication-taxonomy">Replication Taxonomy&lt;/h2>
&lt;p>Replication taxonomy 的核心責任是把「有複本」拆成可操作的幾種能力。SQLite 周邊工具常用 replication 這個字，但 operator 需要知道它到底保護哪個風險。&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>Continuous backup&lt;/td>
 &lt;td>降低資料遺失窗口&lt;/td>
 &lt;td>replica lag、restore 成功&lt;/td>
 &lt;td>把 replica 當 active-active database&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Read replica&lt;/td>
 &lt;td>降低 read latency / 壓力&lt;/td>
 &lt;td>freshness、read error rate&lt;/td>
 &lt;td>忽略 stale read&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Warm standby&lt;/td>
 &lt;td>縮短 restore / failover&lt;/td>
 &lt;td>promotion drill、DNS / routing&lt;/td>
 &lt;td>只備份檔案、未演練切換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Primary lease&lt;/td>
 &lt;td>控制單一 writer ownership&lt;/td>
 &lt;td>writer lease、fencing log&lt;/td>
 &lt;td>多個 node 同時寫同一份邏輯狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Consensus SQL&lt;/td>
 &lt;td>多節點一致性寫入&lt;/td>
 &lt;td>quorum、leader election&lt;/td>
 &lt;td>用 WAL shipping 取代 distributed OLTP&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Continuous backup 的語言是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO&lt;/a>。它關心最近一次成功送出的 WAL、snapshot freshness、object storage credential、restore 指令與演練結果。&lt;/p>
&lt;p>Read replica 的語言是 freshness。Replica 能降低 read latency 或保護 primary workload，但讀者要知道 stale window、read-after-write policy、fallback to primary 與 cache invalidation。&lt;/p>
&lt;p>Primary lease 的語言是 writer ownership。SQLite 的服務形狀仍適合 single writer；工具可以協助 deployment 切換，但 application 要配合 fencing、retry 與 promotion evidence。&lt;/p>
&lt;h2 id="litestream-boundary">Litestream Boundary&lt;/h2>
&lt;p>Litestream boundary 的核心責任是把 SQLite WAL 變成可持續複製的 backup stream。Litestream 官方說明把它定位為 SQLite streaming replication tool，並在 &lt;a href="https://litestream.io/how-it-works/">How it works&lt;/a> 與 &lt;a href="https://litestream.io/reference/restore/">restore command&lt;/a> 文件中強調 replica 與 restore workflow。&lt;/p>
&lt;p>Litestream 適合下列情境：&lt;/p>
&lt;ol>
&lt;li>單節點 SQLite app 要降低資料遺失窗口。&lt;/li>
&lt;li>系統可接受 restore 後重新啟動 service。&lt;/li>
&lt;li>Object storage credential、retention、restore drill 可以被管理。&lt;/li>
&lt;li>Write pattern 適中，WAL stream 與 snapshot 維護成本可控。&lt;/li>
&lt;/ol>
&lt;p>Litestream 的設計重點是 backup evidence。Runbook 要記錄 replica destination、last replicated generation、last restore test、expected RPO、expected RTO、restore target path、credential rotation 與 corruption triage。&lt;/p></description><content:encoded><![CDATA[<p>Litestream / LiteFS replication 的核心責任是把 SQLite 的 single-file operation 補成可恢復、可部署、可讀擴展的服務形狀。這類工具延伸 SQLite，但它們解決的問題不同：Litestream 主要把 WAL 變化持續送到 replica storage，強化 backup 與 restore；LiteFS 主要在 Fly.io 生態中透過 primary lease 與 filesystem layer 支援 replicated SQLite deployment。</p>
<p>本文的判讀錨點是：replicated SQLite 要先說明 replica 的服務責任。它可能是 continuous backup、warm restore source、read replica、primary failover helper 或 deployment topology；每一種責任都有不同的 RPO、RTO、freshness 與 incident runbook。</p>
<h2 id="replication-taxonomy">Replication Taxonomy</h2>
<p>Replication taxonomy 的核心責任是把「有複本」拆成可操作的幾種能力。SQLite 周邊工具常用 replication 這個字，但 operator 需要知道它到底保護哪個風險。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>主要責任</th>
          <th>成功訊號</th>
          <th>常見誤判</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Continuous backup</td>
          <td>降低資料遺失窗口</td>
          <td>replica lag、restore 成功</td>
          <td>把 replica 當 active-active database</td>
      </tr>
      <tr>
          <td>Read replica</td>
          <td>降低 read latency / 壓力</td>
          <td>freshness、read error rate</td>
          <td>忽略 stale read</td>
      </tr>
      <tr>
          <td>Warm standby</td>
          <td>縮短 restore / failover</td>
          <td>promotion drill、DNS / routing</td>
          <td>只備份檔案、未演練切換</td>
      </tr>
      <tr>
          <td>Primary lease</td>
          <td>控制單一 writer ownership</td>
          <td>writer lease、fencing log</td>
          <td>多個 node 同時寫同一份邏輯狀態</td>
      </tr>
      <tr>
          <td>Consensus SQL</td>
          <td>多節點一致性寫入</td>
          <td>quorum、leader election</td>
          <td>用 WAL shipping 取代 distributed OLTP</td>
      </tr>
  </tbody>
</table>
<p>Continuous backup 的語言是 <a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO</a> 與 <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a>。它關心最近一次成功送出的 WAL、snapshot freshness、object storage credential、restore 指令與演練結果。</p>
<p>Read replica 的語言是 freshness。Replica 能降低 read latency 或保護 primary workload，但讀者要知道 stale window、read-after-write policy、fallback to primary 與 cache invalidation。</p>
<p>Primary lease 的語言是 writer ownership。SQLite 的服務形狀仍適合 single writer；工具可以協助 deployment 切換，但 application 要配合 fencing、retry 與 promotion evidence。</p>
<h2 id="litestream-boundary">Litestream Boundary</h2>
<p>Litestream boundary 的核心責任是把 SQLite WAL 變成可持續複製的 backup stream。Litestream 官方說明把它定位為 SQLite streaming replication tool，並在 <a href="https://litestream.io/how-it-works/">How it works</a> 與 <a href="https://litestream.io/reference/restore/">restore command</a> 文件中強調 replica 與 restore workflow。</p>
<p>Litestream 適合下列情境：</p>
<ol>
<li>單節點 SQLite app 要降低資料遺失窗口。</li>
<li>系統可接受 restore 後重新啟動 service。</li>
<li>Object storage credential、retention、restore drill 可以被管理。</li>
<li>Write pattern 適中，WAL stream 與 snapshot 維護成本可控。</li>
</ol>
<p>Litestream 的設計重點是 backup evidence。Runbook 要記錄 replica destination、last replicated generation、last restore test、expected RPO、expected RTO、restore target path、credential rotation 與 corruption triage。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">litestream restore -o /var/lib/app/restored.db s3://example-bucket/app.db
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 /var/lib/app/restored.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p>這段命令是 restore drill 的最小骨架。正式 runbook 要補上 service stop、database path、sidecar file、permission、checksum、application smoke test 與 rollback decision。</p>
<p>Litestream 的風險集中在 restore path。備份存在和服務可恢復是兩件事；每次 release 或 schema migration 後，都應用 staging data 跑一次 restore、integrity check、row count 與 application smoke test。</p>
<h2 id="litefs-boundary">LiteFS Boundary</h2>
<p>LiteFS boundary 的核心責任是支援 replicated deployment topology，而非只做 backup。LiteFS 在 Fly.io 文件中被定位為 SQLite replication layer，透過 FUSE filesystem 與 primary lease 模型協助應用在多個 instance 間運作。</p>
<p>LiteFS 適合下列情境：</p>
<ol>
<li>App 仍希望使用 SQLite file 與 local SQL path。</li>
<li>Deployment 有多個 instance，但 write authority 可以集中到 primary。</li>
<li>Read replica freshness 可以被產品接受。</li>
<li>Team 願意把 filesystem layer、primary lease、promotion 與 platform operation 納入 runbook。</li>
</ol>
<p>LiteFS 的設計重點是 primary ownership。Application 要知道 write request 到哪裡執行、primary 切換時如何重試、read replica 讀到舊資料時如何回應，以及 promotion 完成前哪些 endpoint 要進入 degraded mode。</p>
<p>LiteFS 的 incident route 要從 writer ownership 開始查。若出現 write error、stale read 或 suspected split brain，先查看 primary lease、instance health、replication lag、pending writes 與 platform network，再處理 application retry。</p>
<h2 id="failure-modes">Failure Modes</h2>
<p>Failure modes 的核心責任是把 replicated SQLite 的事故從「資料庫壞了」拆成可排查訊號。SQLite file、WAL、object storage、filesystem layer、deployment platform 與 application retry 都可能是問題來源。</p>
<table>
  <thead>
      <tr>
          <th>Failure mode</th>
          <th>判讀訊號</th>
          <th>立即處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Replica lag</td>
          <td>last replicated time 落後</td>
          <td>降低 write rate、檢查 credential / network</td>
      </tr>
      <tr>
          <td>Restore lag</td>
          <td>WAL files 過多、restore time 變長</td>
          <td>觸發 snapshot、演練 restore</td>
      </tr>
      <tr>
          <td>Stale read</td>
          <td>使用者讀到舊資料</td>
          <td>fallback primary read、標記 freshness</td>
      </tr>
      <tr>
          <td>Writer lease confusion</td>
          <td>多 instance write error</td>
          <td>暫停寫入、確認 primary、fencing old writer</td>
      </tr>
      <tr>
          <td>Object storage failure</td>
          <td>backup upload error</td>
          <td>切換 credential / destination、補上重送</td>
      </tr>
      <tr>
          <td>Sidecar file mismatch</td>
          <td>restore / copy 後 integrity fail</td>
          <td>回到 backup API / official restore path</td>
      </tr>
  </tbody>
</table>
<p>Replica lag 要接到 alert。對 Litestream，它意味著 RPO 正在擴大；對 LiteFS，它可能同時影響 read freshness 與 failover confidence。</p>
<p>Restore lag 要接到 release gate。若 restore time 已超過目標 <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a>，就要調整 snapshot frequency、資料保留策略或搬到 server database。</p>
<p>Stale read 要接到產品語言。使用者看到舊資料時，系統可以顯示 sync state、重讀 primary、限制 critical action 或提供 refresh；這些策略要在設計階段決定。</p>
<h2 id="no-go-conditions">No-Go Conditions</h2>
<p>No-go condition 的核心責任是避免把 replicated SQLite 推到 distributed OLTP 的位置。SQLite 周邊 replication 工具可以強化單節點與 read replica，但高寫入、多 writer、強一致跨 region transaction 需要不同資料庫模型。</p>
<table>
  <thead>
      <tr>
          <th>No-go 訊號</th>
          <th>原因</th>
          <th>路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多 region 都要接受交易性寫入</td>
          <td>single writer / primary lease 壓力過高</td>
          <td><a href="/blog/backend/01-database/vendors/cockroachdb/" data-link-title="CockroachDB" data-link-desc="分散式 SQL、PostgreSQL 相容、跨區強一致、Spanner 的開源 / 跨雲替代">CockroachDB</a> 或 Spanner</td>
      </tr>
      <tr>
          <td>每秒大量 concurrent writer</td>
          <td>lock contention 與 replica lag 擴大</td>
          <td>PostgreSQL / MySQL / managed OLTP</td>
      </tr>
      <tr>
          <td>Central audit / DB role 是硬需求</td>
          <td>SQLite file model 缺少 server role</td>
          <td><a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a></td>
      </tr>
      <tr>
          <td>Restore drill 經常超過 RTO</td>
          <td>file size / WAL backlog 已超界</td>
          <td>server DB、sharding 或資料生命週期重整</td>
      </tr>
      <tr>
          <td>Incident team 缺少 filesystem layer 維護能力</td>
          <td>operation model 超過組織能力</td>
          <td>managed SQL 或 D1 / Turso managed path</td>
      </tr>
  </tbody>
</table>
<p>No-go 條件要在 design review 階段列出。SQLite replication 的好處是低成本與低元件數；當核心需求變成跨節點一致性寫入，繼續調工具會把風險藏在 incident 時刻。</p>
<h2 id="decision-route">Decision Route</h2>
<p>Decision route 的核心責任是把資料保護、讀擴展與高可用分開選型。Litestream / LiteFS 位置清楚時，SQLite 可以保持簡潔；位置混淆時，系統會同時缺 backup evidence 與 transaction guarantee。</p>
<table>
  <thead>
      <tr>
          <th>需求</th>
          <th>建議路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單節點 SQLite 需要 continuous backup</td>
          <td>Litestream + restore drill</td>
      </tr>
      <tr>
          <td>多 instance deployment 需要 primary lease</td>
          <td>LiteFS + write routing / promotion runbook</td>
      </tr>
      <tr>
          <td>Edge app 需要 managed SQL-like platform</td>
          <td><a href="/blog/backend/01-database/vendors/sqlite/d1-turso-libsql-comparison/" data-link-title="SQLite D1 / Turso / libSQL Comparison" data-link-desc="Cloudflare D1、Turso、libSQL 與 local SQLite 在 edge、replication、consistency、migration 與 vendor boundary 的比較">D1 / Turso / libSQL comparison</a></td>
      </tr>
      <tr>
          <td>多 tenant OLTP 需要 central operation</td>
          <td>PostgreSQL / MySQL / Aurora</td>
      </tr>
      <tr>
          <td>Global transaction 是核心需求</td>
          <td>Distributed OLTP</td>
      </tr>
  </tbody>
</table>
<p>選擇 Litestream 時，完成標準是能在 staging 從 replica restore 出可用 DB。選擇 LiteFS 時，完成標準是能演練 primary 切換、read freshness、write retry 與 degraded mode。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>Litestream / LiteFS replication 完成後，下一步要回到 SQLite operation evidence。File copy、backup API 與 WAL sidecar 請讀 <a href="/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/" data-link-title="SQLite file lifecycle 與 backup boundary" data-link-desc="把 SQLite 單檔案正式狀態拆成 WAL、backup API、restore drill、corruption recovery 與操作責任邊界">file lifecycle / backup boundary</a>；busy、lock 與 writer 壓力請讀 <a href="/blog/backend/01-database/vendors/sqlite/wal-concurrency-locking/" data-link-title="SQLite WAL Concurrency and Locking" data-link-desc="SQLite WAL mode 如何降低 reader / writer 衝突、保留 single writer boundary，並用 SQLITE_BUSY、WAL growth、checkpoint 訊號判斷 production 上限">WAL concurrency / locking</a>；完整 runbook 請讀 <a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">SQLite observability / runbook</a>。</p>
]]></content:encoded></item></channel></rss>