<?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>Isr on Tarragon</title><link>https://tarrragon.github.io/blog/tags/isr/</link><description>Recent content in Isr 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/isr/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>