<?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>Log-Compaction on Tarragon</title><link>https://tarrragon.github.io/blog/tags/log-compaction/</link><description>Recent content in Log-Compaction 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/log-compaction/index.xml" rel="self" type="application/rss+xml"/><item><title>Kafka Retention 與 Tiered Storage：保留策略、log compaction 與冷熱分層</title><link>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/retention-tiered-storage/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/03-message-queue/vendors/kafka/retention-tiered-storage/</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 的 implementation-layer deep article、聚焦保留與分層儲存。選型層的「該不該選 Kafka」「跟其他 broker 差在哪」見 overview；本文回答「保留策略怎麼設、log compaction 怎麼運作、冷熱分層怎麼讓容量跟保留期解耦、踩哪些坑」。配置段在 Apache Kafka KRaft 單節點實機驗證；tiered storage 段標註未實機驗證的範圍。&lt;/p>&lt;/blockquote>
&lt;h2 id="retention-是-replay-window-的物理邊界">Retention 是 replay window 的物理邊界&lt;/h2>
&lt;p>Retention 的核心責任是決定「一筆訊息在 broker 上能存活多久」、而這條邊界直接界定 consumer 能往回重播多遠。Kafka 的 log 是 append-only 的事件序列、訊息寫入後不會被原地修改；retention 是唯一會把舊訊息從磁碟移除的機制。設多久、用什麼條件刪、刪掉之後 consumer 還能不能讀到，全由保留策略決定。&lt;/p>
&lt;p>這條邊界之所以重要、是因為 Kafka 的多 consumer 模型讓「重播」變成一級能力。同一個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 可以被多組 consumer 各自從任意 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 開始讀、每組維護自己的進度；只要訊息還在 retention 範圍內、新加入的 consumer 或出事後要補算的 consumer 都能從頭重讀。一旦訊息超過 retention 被刪、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window&lt;/a> 就到此為止、補償只能改走資料庫或上游來源。&lt;/p>
&lt;p>Kafka 提供兩條獨立的保留軸、可單獨用也可同時用：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>配置&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>典型場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>retention.ms&lt;/code>&lt;/td>
 &lt;td>訊息寫入時間超過設定值（時間軸）&lt;/td>
 &lt;td>「保留 7 天事件供事故 replay」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>retention.bytes&lt;/code>&lt;/td>
 &lt;td>該 partition log 總大小超過設定值（容量軸）&lt;/td>
 &lt;td>「每 partition 上限 50 GB、防止磁碟塞爆」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩者同時設&lt;/td>
 &lt;td>任一條件先達到就刪（取交集、誰先到誰生效）&lt;/td>
 &lt;td>「保留 7 天、但單 partition 不超過 50 GB」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>時間軸對齊的是 replay 需求：把 retention 設成「事故從發生到偵測到修復的最長時間」、確保發現要補算時事件還在。容量軸對齊的是成本與磁碟保護：避免某個突發高流量 topic 把 broker 磁碟寫滿、拖垮同 broker 上其他 partition。兩者同時設時是「誰先觸發誰生效」、所以容量軸常常會在高流量時段提前砍掉本來預期能保留 7 天的事件——這個交互是後面故障演練的重點之一。&lt;/p>
&lt;p>實機建立一個同時設兩軸的 topic、&lt;code>--describe&lt;/code> 會把保留配置直接列在 Configs：&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"># CLI 在容器內 /opt/kafka/bin/、bootstrap-server 指向 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 --create --topic ret-delete --partitions &lt;span class="m">1&lt;/span> &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> --config retention.ms&lt;span class="o">=&lt;/span>&lt;span class="m">60000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --config retention.bytes&lt;span class="o">=&lt;/span>&lt;span class="m">10485760&lt;/span> &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> --config segment.ms&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">kafka-topics.sh --describe --topic ret-delete --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1"># Configs: retention.ms=60000,retention.bytes=10485760,segment.ms=10000,...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>retention 不是寫死在建 topic 當下、線上可以用 &lt;code>kafka-configs.sh --alter&lt;/code> 動態調整、立即生效不需重啟 broker：&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">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --add-config retention.ms&lt;span class="o">=&lt;/span>&lt;span class="m">3600000&lt;/span> &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> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Completed updating config for topic ret-delete.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">kafka-configs.sh --describe --entity-type topics --entity-name ret-delete &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --bootstrap-server localhost:9092
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># retention.ms=3600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=3600000}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>動態調整的 retention 屬於 &lt;code>DYNAMIC_TOPIC_CONFIG&lt;/code>、優先於 broker 層的 &lt;code>log.retention.*&lt;/code> 預設值；synonyms 欄位會把覆蓋關係列出來、排查時可確認當前生效的是哪一層。&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 的 implementation-layer deep article、聚焦保留與分層儲存。選型層的「該不該選 Kafka」「跟其他 broker 差在哪」見 overview；本文回答「保留策略怎麼設、log compaction 怎麼運作、冷熱分層怎麼讓容量跟保留期解耦、踩哪些坑」。配置段在 Apache Kafka KRaft 單節點實機驗證；tiered storage 段標註未實機驗證的範圍。</p></blockquote>
<h2 id="retention-是-replay-window-的物理邊界">Retention 是 replay window 的物理邊界</h2>
<p>Retention 的核心責任是決定「一筆訊息在 broker 上能存活多久」、而這條邊界直接界定 consumer 能往回重播多遠。Kafka 的 log 是 append-only 的事件序列、訊息寫入後不會被原地修改；retention 是唯一會把舊訊息從磁碟移除的機制。設多久、用什麼條件刪、刪掉之後 consumer 還能不能讀到，全由保留策略決定。</p>
<p>這條邊界之所以重要、是因為 Kafka 的多 consumer 模型讓「重播」變成一級能力。同一個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 可以被多組 consumer 各自從任意 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 開始讀、每組維護自己的進度；只要訊息還在 retention 範圍內、新加入的 consumer 或出事後要補算的 consumer 都能從頭重讀。一旦訊息超過 retention 被刪、<a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 就到此為止、補償只能改走資料庫或上游來源。</p>
<p>Kafka 提供兩條獨立的保留軸、可單獨用也可同時用：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>觸發條件</th>
          <th>典型場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>retention.ms</code></td>
          <td>訊息寫入時間超過設定值（時間軸）</td>
          <td>「保留 7 天事件供事故 replay」</td>
      </tr>
      <tr>
          <td><code>retention.bytes</code></td>
          <td>該 partition log 總大小超過設定值（容量軸）</td>
          <td>「每 partition 上限 50 GB、防止磁碟塞爆」</td>
      </tr>
      <tr>
          <td>兩者同時設</td>
          <td>任一條件先達到就刪（取交集、誰先到誰生效）</td>
          <td>「保留 7 天、但單 partition 不超過 50 GB」</td>
      </tr>
  </tbody>
</table>
<p>時間軸對齊的是 replay 需求：把 retention 設成「事故從發生到偵測到修復的最長時間」、確保發現要補算時事件還在。容量軸對齊的是成本與磁碟保護：避免某個突發高流量 topic 把 broker 磁碟寫滿、拖垮同 broker 上其他 partition。兩者同時設時是「誰先觸發誰生效」、所以容量軸常常會在高流量時段提前砍掉本來預期能保留 7 天的事件——這個交互是後面故障演練的重點之一。</p>
<p>實機建立一個同時設兩軸的 topic、<code>--describe</code> 會把保留配置直接列在 Configs：</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"># CLI 在容器內 /opt/kafka/bin/、bootstrap-server 指向 broker</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">kafka-topics.sh --create --topic ret-delete --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --config retention.ms<span class="o">=</span><span class="m">60000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --config retention.bytes<span class="o">=</span><span class="m">10485760</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">10000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl">kafka-topics.sh --describe --topic ret-delete --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"># Configs: retention.ms=60000,retention.bytes=10485760,segment.ms=10000,...</span></span></span></code></pre></div><p>retention 不是寫死在建 topic 當下、線上可以用 <code>kafka-configs.sh --alter</code> 動態調整、立即生效不需重啟 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">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config retention.ms<span class="o">=</span><span class="m">3600000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for topic ret-delete.</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">kafka-configs.sh --describe --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># retention.ms=3600000 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:retention.ms=3600000}</span></span></span></code></pre></div><p>動態調整的 retention 屬於 <code>DYNAMIC_TOPIC_CONFIG</code>、優先於 broker 層的 <code>log.retention.*</code> 預設值；synonyms 欄位會把覆蓋關係列出來、排查時可確認當前生效的是哪一層。</p>
<h2 id="segment-是刪除的最小單位">Segment 是刪除的最小單位</h2>
<p>Retention 刪資料的最小單位是 log segment、不是單筆訊息。理解這一點才能解釋「為什麼設了 retention.ms 之後，過期的訊息有時還在」。每個 partition 的 log 在磁碟上被切成多個 segment 檔、只有 active segment（當前正在寫入的那一個）以外、已經 roll over 的 segment 才會被 retention 檢查並整段刪除。</p>
<p>Segment 何時 roll over 由兩個條件決定：<code>segment.bytes</code>（檔案大到上限、預設 1 GB、最小 1 MB）或 <code>segment.ms</code>（檔案存在時間超過設定）。實機寫入 ~6 MB 資料到一個 <code>segment.bytes=1048576</code>（1 MB）的 topic、磁碟上會看到 6 個 roll 過的 segment：</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">00000000000000000000.log   1045229   # 已 roll，可被 retention 刪
</span></span><span class="line"><span class="ln">2</span><span class="cl">00000000000000001024.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">3</span><span class="cl">00000000000000002048.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">4</span><span class="cl">00000000000000003072.log   1046336   # 已 roll
</span></span><span class="line"><span class="ln">5</span><span class="cl">00000000000000004096.log   1037748   # 已 roll
</span></span><span class="line"><span class="ln">6</span><span class="cl">00000000000000005112.log    904737   # active segment，不會被刪</span></span></code></pre></div><p>Retention 的實際刪除動作由背景執行緒週期性執行、頻率是 broker 層的 <code>log.retention.check.interval.ms</code>、預設 300000 毫秒（5 分鐘）。這代表「過期」跟「被刪」之間有最長一個檢查週期的延遲：訊息超過 retention.ms 的瞬間不會立刻消失、要等下一次檢查跑到、且該訊息所在的 segment 已經 roll over、整段才會被刪。實機把 retention.bytes 設成 2 MB、寫進 6 MB（6 個 segment）、在 5 分鐘檢查週期內查 earliest offset 仍是 0——超量的 segment 還沒被回收、因為檢查執行緒還沒跑到下一輪。</p>
<p>這個機制有兩個操作後果。其一、磁碟用量會在「超過 retention 上限」到「下一次檢查」之間短暫超標、容量規劃要把這段 overshoot 算進緩衝。其二、把 retention.ms 設得比 segment.ms 還短沒有意義：訊息要等所在 segment roll 才可能被刪、active segment 永遠刪不掉、所以實際最短保留時間是 <code>max(retention.ms, segment 尚未 roll 的時間)</code>。</p>
<h2 id="cleanuppolicydelete-與-compact-是兩種回收語意">cleanup.policy：delete 與 compact 是兩種回收語意</h2>
<p><code>cleanup.policy</code> 決定 retention 用哪種語意回收空間、是保留策略最關鍵的分岔。預設值 <code>delete</code> 是時間或容量到期就整段刪除、適合事件流（event stream）：訊息代表「發生過的事實」、過了 replay window 就沒有保留價值。另一個值 <code>compact</code> 是 log compaction、語意完全不同：它保留每個 key 的最新值、刪除同 key 的歷史版本、適合「狀態快照」型資料。</p>
<p>兩者的判準是這份 log 表達的是「事件序列」還是「最終狀態」。訂單建立、付款完成、商品瀏覽這類事件、每一筆都是獨立事實、用 <code>delete</code>；使用者個人設定、商品庫存當前值、CDC 同步出來的資料表鏡像這類「同一個 key 不斷被覆寫、只關心最新值」的資料、用 <code>compact</code>。Kafka 內部的 <code>__consumer_offsets</code> topic 就是 compact——它只需要每個 consumer group 的最新 offset、不需要歷史 commit 記錄。</p>
<p>兩者可以同時開（<code>cleanup.policy=compact,delete</code>）：先按 key 壓縮保留最新值、同時對壓縮後的結果再套時間 / 容量上限。用 <code>kafka-configs.sh</code> 切換時、逗號分隔的值要用中括號群組、否則會被解析成兩個獨立 config：</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">kafka-configs.sh --alter --entity-type topics --entity-name ret-delete <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --add-config <span class="s1">&#39;cleanup.policy=[compact,delete]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Completed updating config for topic ret-delete.</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># describe: cleanup.policy=compact,delete</span></span></span></code></pre></div><h2 id="log-compaction-用最新值取代歷史">Log compaction 用最新值取代歷史</h2>
<p>Log compaction 的核心責任是讓一個 topic 收斂成「每個 key 的最新狀態」、同時保有 Kafka 的 log 重播能力。它的運作方式是背景的 log cleaner 執行緒掃描已 roll 的 segment、對每個 key 只保留 offset 最大的那筆、把同 key 的舊版本標記移除、再把存活的記錄重寫成新 segment。Compaction 後、新加入的 consumer 從頭讀一次、拿到的就是整個 keyspace 的最新快照、而非完整變更歷史。</p>
<p>實機驗證最直接：建一個 compact topic、對 3 個 key 各寫 2 個版本（舊值在前、新值在後）、等 compaction 跑完、從頭消費：</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">kafka-topics.sh --create --topic ret-compact --partitions <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --config cleanup.policy<span class="o">=</span>compact <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --config min.cleanable.dirty.ratio<span class="o">=</span>0.01 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --config segment.ms<span class="o">=</span><span class="m">5000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --config delete.retention.ms<span class="o">=</span><span class="m">100</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 寫 k1/k2/k3 各舊值一筆、再各新值一筆（key:value 用冒號分隔）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;k1:v1-old\nk2:v1-old\nk3:v1-old\nk1:v2-new\nk2:v2-new\nk3:v2-new\n&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  kafka-console-producer.sh --topic ret-compact <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --property parse.key<span class="o">=</span><span class="nb">true</span> --property key.separator<span class="o">=</span>: <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 等 segment roll + compaction，再從頭消費</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">kafka-console-consumer.sh --topic ret-compact --from-beginning <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --property print.key<span class="o">=</span><span class="nb">true</span> --property print.offset<span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --timeout-ms <span class="m">6000</span> --bootstrap-server localhost:9092
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># Offset:3  k1  v2-new</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># Offset:4  k2  v2-new</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># Offset:5  k3  v2-new</span></span></span></code></pre></div><p>寫進 6 筆、從頭只讀到 3 筆——k1/k2/k3 的 <code>v1-old</code>（offset 0-2）被壓縮移除、只留每個 key 的 <code>v2-new</code>。關鍵細節：offset 沒有重新編號、留存記錄保留原始 offset（3、4、5）、log 的位置語意不變、其他 consumer 的 offset 進度不會錯位。</p>
<p>Compaction 的觸發不是即時的、由幾個參數共同決定。<code>min.cleanable.dirty.ratio</code> 是「髒比例」門檻、髒記錄（已被新版本取代但還沒清掉的舊版本）佔 log 比例超過這個值、cleaner 才會處理該 partition、預設 0.5（驗證時調成 0.01 加速觸發）。<code>segment.ms</code> 控制 active segment 多久 roll、只有 roll 過的 segment 能被 compact。<code>delete.retention.ms</code> 控制 tombstone（value 為 null 的刪除標記）保留多久——compaction topic 用 null value 表示「這個 key 已刪除」、tombstone 要保留夠久讓所有 consumer 都讀到刪除事件、之後才清掉。</p>
<p>Tombstone 是 compaction 表達「刪除」的方式：寫一筆 key 存在、value 為 null 的記錄、compaction 會把該 key 的所有歷史連同這筆 tombstone 在 <code>delete.retention.ms</code> 之後一起清除。這讓 compact topic 能表達「key 從存在到被刪」的完整生命週期、而不只是「永遠累積最新值」。</p>
<h2 id="tiered-storage-讓容量與保留期解耦">Tiered Storage 讓容量與保留期解耦</h2>
<blockquote>
<p>以下 tiered storage 段落依 Apache Kafka 官方文件（KIP-405）與 Pinterest / LinkedIn 公開案例敘述、未在本文的 KRaft 單節點環境實機驗證。Apache Kafka 的原生 tiered storage（<code>remote.storage.enable</code>）在當前版本屬 early-access、需要額外的 RemoteStorageManager plugin 與 broker 設定；正式採用前以官方文件版本標註為準。</p></blockquote>
<p>Tiered storage 的核心責任是把 broker 的「儲存容量」跟「保留期長度」解耦。傳統 Kafka 的保留期受限於 broker 本機磁碟：想保留 30 天、就得讓每個 broker 的 local disk 容納 30 天的全量資料、retention 拉長等於 broker 數量或單機磁碟線性增長、而 broker 的 CPU / 記憶體 / 網路其實沒用到那麼多。Tiered storage 把 log 分成兩層：熱資料（近期、頻繁讀）留在 broker local disk（local tier）、冷資料（過期門檻之外、偶爾 replay）卸載到遠端物件儲存如 S3（remote tier）。Broker 只需放得下熱資料、保留期可以拉到數月甚至更久、成本變成 S3 的物件儲存費而非 broker 機群。</p>
<p>分層的觸發由 <code>local.retention.ms</code> / <code>local.retention.bytes</code>（本機保留多久 / 多大、超過就卸到 remote）跟整體的 <code>retention.ms</code> / <code>retention.bytes</code>（含 remote 的總保留邊界、超過才真正刪除）共同界定。一筆訊息的生命週期變成：寫入 local tier、超過 local retention 卸到 remote tier、超過整體 retention 從 remote 刪除。Replay window 因此可以遠大於 broker local disk 容量。</p>
<p>讀取路徑分熱冷兩條、效能特性不同。Consumer 讀近期 offset、資料在 local tier、走的是 Kafka 一向的 page cache + 順序讀路徑、低延遲高吞吐。Consumer 讀很舊的 offset（例如出事後從幾週前重播）、資料在 remote tier、broker 要先從 S3 把對應 segment 拉回來才能 serve、第一次讀的延遲明顯高於熱路徑、吞吐受 S3 頻寬與 broker 拉取並行度限制。這個熱冷讀差異是 tiered storage 的核心取捨——也是故障演練要處理的場景。</p>
<p>業界對 tiered storage 有兩條不同的工程路線、對應不同的 broker 角色定位：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>broker 角色</th>
          <th>代表案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Broker-coupled（KIP-405 原生）</td>
          <td>broker 仍是 remote 讀的熱路徑、代理拉取</td>
          <td>Apache Kafka 原生 tiered storage</td>
      </tr>
      <tr>
          <td>Broker-decoupled</td>
          <td>consumer 直接從 S3 拉、broker 不在熱路徑</td>
          <td><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest Tiered Storage</a></td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 的 broker-decoupled 做法</a>把 ~200 TB/day 熱資料卸到 S3、讓 consumer 直接從 S3 拉冷資料、broker 不再是冷讀的熱路徑。它揭露的設計判讀是「broker 運算資源」跟「跨 AZ 網路成本」其實該分開治理、而不是綁在 broker 容量擴張上——保留期變長不該等於 broker 機群變大。</p>
<p><a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">LinkedIn 的分層叢集策略</a>是另一個層次的「分層」：把不同業務特性與可靠性需求的 workload 拆到不同叢集（依關鍵程度分群、例如關鍵 / 一般 / 實驗性，分層名稱為示意而非案例原文用詞）、避免混在同一叢集時故障與資源競爭互相放大。這裡的「分層」指叢集隔離、不是儲存的冷熱分層。兩種「分層」常被混談、但解的是不同問題：tiered storage 解單一 topic 的儲存成本、tiered clusters 解多 workload 的隔離治理。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="retention-太短replay-window-不夠補事故">Retention 太短、replay window 不夠補事故</h3>
<p><strong>徵兆</strong>：下游 consumer 出 bug、產出錯誤的衍生資料、幾天後才被對帳發現；要從原始事件重播修復時、發現最舊的事件已經被刪、replay 從某個時間點之後才有資料、之前的修不回來。</p>
<p><strong>根因</strong>：retention.ms 設得比「事故從發生到偵測到開始修復的最長時間」短。Replay window 由 broker retention 與 consumer checkpoint 共同界定、retention 是其物理上限；偵測延遲一旦超過 retention、要補算時原始事件已過期。常見的隱性誘因是把 retention 按「正常 consumer 跟得上的進度」來設（例如 consumer 通常落後幾分鐘、就設 1 天保險）、卻沒按「最壞情況下多久才會發現問題」來設。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把 retention.ms 對齊事故偵測到修復的最長時間、而非 consumer 正常落後量；對帳 / 審計類 pipeline 的偵測週期常以天計、retention 要跟著拉到對應天數。</li>
<li>對「偵測延遲可能很長」的關鍵 topic、在下游另留可重算的來源（資料庫快照、上游 source of truth）、不把 Kafka retention 當唯一補償依據。</li>
<li>用 <code>kafka-configs.sh --alter</code> 動態延長 retention 是即時生效的、但只對「還沒被刪」的訊息有用——已刪的救不回來；所以調整要趁事故升級前、發現偵測週期被低估的當下就改、不是等出事才改。</li>
<li>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>：replay 要能指定 time range、超出 retention 的 time range 直接無效。</li>
</ol>
<h3 id="compaction-開了磁碟卻沒回收">Compaction 開了、磁碟卻沒回收</h3>
<p><strong>徵兆</strong>：topic 設了 <code>cleanup.policy=compact</code>、預期同 key 舊版本會被清掉、磁碟用量卻持續上漲、<code>--describe</code> 看 partition log 一直變大；從頭消費仍讀到大量同 key 的歷史版本。</p>
<p><strong>根因</strong>：compaction 觸發條件沒滿足。log cleaner 只處理已 roll 的 segment、active segment 永遠不壓縮；<code>min.cleanable.dirty.ratio</code> 預設 0.5、髒比例沒到一半 cleaner 不動手；如果寫入集中在少數 key、active segment 遲遲不 roll（segment.bytes / segment.ms 都沒到）、髒記錄全積在 active segment 裡、compaction 看不到它們。另一個常見原因是 broker 的 log cleaner 執行緒數（<code>log.cleaner.threads</code>）不足以跟上高寫入量、cleaner backlog 累積。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 active segment 會適時 roll：對寫入量不大但需要及時壓縮的 topic、設 <code>segment.ms</code>（例如數小時）強制 roll、讓髒記錄離開 active segment 進入可壓縮範圍。</li>
<li>視壓縮急迫度調 <code>min.cleanable.dirty.ratio</code>：要更積極壓縮就調低（驗證時用 0.01）、但調太低會讓 cleaner 頻繁重寫 segment、增加 I/O——這是壓縮及時性跟 cleaner 開銷的取捨。</li>
<li>監控 cleaner backlog：看 broker 的 <code>log-cleaner</code> 相關 metric、backlog 持續成長代表 cleaner 執行緒不夠、加 <code>log.cleaner.threads</code>。</li>
<li>確認沒有把 compact 用在「其實該 delete」的事件流上——事件流每筆 key 多半唯一、compaction 沒有舊版本可壓、磁碟自然不會降；那種情況該用 <code>delete</code> 加 retention。</li>
</ol>
<h3 id="cold-tier-讀延遲拖垮-replay">Cold tier 讀延遲拖垮 replay</h3>
<p><strong>徵兆</strong>：開了 tiered storage、平時讀近期資料正常、一旦發起從幾週前的舊 offset 大規模 replay、consumer 的吞吐驟降、p99 拉取延遲飆高、broker S3 拉取頻寬打滿、同 broker 上其他正常 consumer 也跟著受影響。</p>
<p><strong>根因</strong>：舊 offset 的資料在 remote tier、每次讀要先從 S3 把 segment 拉回 broker、第一次冷讀延遲遠高於 local tier 的順序讀。大規模 replay 等於一次要從 S3 拉大量冷 segment、S3 頻寬與 broker 拉取並行成為瓶頸；broker-coupled 架構下這些拉取流量全經過 broker、會排擠到熱路徑的正常服務。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>把大規模冷 replay 排到低流量時段、避免跟線上熱路徑爭 broker 資源與 S3 頻寬。</li>
<li>控制 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 選型。">replay boundary</a> 指定 time range / tenant / partition、分批拉冷資料、不要一次全量回放整個保留期。</li>
<li>評估 broker-decoupled 架構（如 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">Pinterest 做法</a>）：consumer 直接從 S3 拉冷資料、把冷讀流量從 broker 熱路徑移開、保護線上服務。</li>
<li>容量規劃把「冷讀延遲」算進 RTO：replay window 拉很長能補很久以前的事故、但補的速度受 cold tier 吞吐限制、事故修復時間估算要把這段拉取時間算進去。</li>
</ol>
<h3 id="retentionbytes-在高流量時段提早刪">retention.bytes 在高流量時段提早刪</h3>
<p><strong>徵兆</strong>：retention.ms 明明設了 7 天、某次流量突增後、consumer 卻發現幾小時前的事件就已經被刪、replay 拿不到本該還在的資料；earliest offset 在沒人預期的時候大幅前移。</p>
<p><strong>根因</strong>：retention.ms 與 retention.bytes 同時設時是「誰先觸發誰生效」。流量突增讓 partition log 在遠不到 7 天時就撞到 retention.bytes 容量上限、容量軸先觸發、舊 segment 被提前刪除——時間軸的 7 天承諾在高流量下失效。常見於「按平均流量估容量上限、卻遇到尖峰流量」、或多個 topic 共享磁碟時為了保護磁碟把每 topic 容量上限壓得偏低。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>釐清這個 topic 的保留承諾是時間還是容量主導：以 replay window 為準的關鍵 topic、容量上限要按「尖峰流量 × 保留天數」估、而非平均流量、否則尖峰時容量軸會偷走時間承諾。</li>
<li>監控 earliest offset 與 log 大小的變化率：earliest offset 在非預期時間前移、就是 retention.bytes 提前觸發的訊號、加進告警。</li>
<li>要硬保證時間保留、就把 retention.bytes 設成 -1（不限容量、純時間軸）、改用獨立的磁碟告警與容量規劃來防磁碟塞爆、而不是用 retention.bytes 兼做兩件事。</li>
<li>評估 tiered storage：把保留壓力從 broker local disk 移到 remote tier、local 只留熱資料、就不必為了保護 broker 磁碟而把 retention.bytes 壓低、時間承諾不再被容量上限侵蝕。</li>
</ol>
<h2 id="容量與成本">容量與成本</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算與判讀</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local disk 用量</td>
          <td>partition 數 × 單 partition log 大小 × replication factor</td>
          <td>接近磁碟上限時 retention.bytes 會提前砍時間承諾</td>
      </tr>
      <tr>
          <td>保留期 vs 成本</td>
          <td>純 local 時 retention 線性推高 broker 磁碟成本</td>
          <td>數月保留 + 純 local = broker 機群為冷資料買單</td>
      </tr>
      <tr>
          <td>Tiered remote 成本</td>
          <td>S3 物件儲存費 + 冷讀時的拉取 / egress 流量費</td>
          <td>跨 AZ / 跨 region 冷讀 egress 成本易被低估</td>
      </tr>
      <tr>
          <td>Retention 檢查延遲</td>
          <td>過期到實際刪除最長一個 <code>log.retention.check.interval.ms</code>（預設 5 分）</td>
          <td>容量規劃要預留 overshoot 緩衝</td>
      </tr>
      <tr>
          <td>Compaction 開銷</td>
          <td>cleaner 重寫 segment 的 I/O、隨 dirty.ratio 調低而上升</td>
          <td>dirty.ratio 過低 = cleaner 頻繁重寫、I/O 壓力升</td>
      </tr>
      <tr>
          <td>Cold replay 吞吐</td>
          <td>受 remote tier（S3）頻寬與 broker 拉取並行度限制</td>
          <td>大規模 cold replay 排低流量時段、分批進行</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>事件流 topic 用 <code>delete</code>、retention.ms 對齊事故偵測到修復的最長時間、retention.bytes 設 -1 或按尖峰流量估、不讓容量軸偷走時間承諾。</li>
<li>狀態快照 / CDC 鏡像 topic 用 <code>compact</code>、確認 active segment 會適時 roll、監控 cleaner backlog。</li>
<li>需要長保留期（數月以上）且 broker 磁碟成本敏感時、評估 tiered storage、把冷資料移到 S3、broker 只放熱資料。</li>
<li>任何 retention 調整前先確認當前生效層級（<code>kafka-configs.sh --describe</code> 看 synonyms）、避免 broker 預設與 topic 動態配置混淆。</li>
</ul>
<h2 id="整合與下一步">整合與下一步</h2>
<h3 id="跟-replay-邊界對齊">跟 replay 邊界對齊</h3>
<p>Retention 是 <a href="/blog/backend/knowledge-cards/replay-window/" data-link-title="Replay Window" data-link-desc="說明事件可重播的時間或 offset 範圍邊界，由 retention 與 checkpoint 決定">replay window</a> 的物理上限、但 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 選型。">event contract</a> 是否齊備（event id / schema version / occurred time / dedup key）。保留策略設計要跟 <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> 一起看：retention 決定「能不能讀到」、event contract 決定「讀到了能不能正確重播」、兩者缺一 replay 都不成立。相關概念見 <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 與 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 知識卡。</p>
<h3 id="跟分層叢集治理對位">跟分層叢集治理對位</h3>
<p>本文的 tiered storage 解的是單一 topic 的儲存成本；<a href="/blog/backend/03-message-queue/cases/linkedin-kafka-tiered-clusters/" data-link-title="3.C4 LinkedIn：Kafka 分層叢集治理" data-link-desc="Kafka 從單叢集走向 tiered clusters 的轉換案例。">3.C4 LinkedIn 分層叢集</a>解的是多 workload 的隔離——把不同可靠性需求的 topic 拆到不同叢集、避免資源競爭互相放大。保留策略在分層叢集裡會按層差異化：critical 叢集拉長 retention 保 replay、experimental 叢集縮短 retention 控成本。</p>
<h3 id="跟-broker-decoupled-架構的取捨">跟 broker-decoupled 架構的取捨</h3>
<p><a href="/blog/backend/03-message-queue/cases/kafka-pinterest-tiered-storage/" data-link-title="3.C11 Pinterest：Kafka tiered storage broker-decoupled" data-link-desc="Pinterest 採 broker-decoupled tiered storage、把 ~200 TB/day 熱資料卸到 S3、broker 不再是熱路徑。">3.C11 Pinterest broker-decoupled tiered storage</a> 把冷讀流量從 broker 熱路徑移開、是「cold tier 讀延遲拖垮 replay」故障演練的架構級解法；它跟 <a href="/blog/backend/03-message-queue/cases/kafka-pinterest-shallow-mirror/" data-link-title="3.C12 Pinterest：Shallow Mirror 優化 MirrorMaker" data-link-desc="Pinterest 跨 3 region MirrorMaker、原版解壓&#43;重壓造成 CPU/memory 2-10x spike、改 RecordBatch 層淺迭代。">3.C12 Pinterest Shallow Mirror</a> 揭露的「跨區同步是 CPU + memory + 網路三維壓力」一起、構成 Pinterest 在儲存與複製兩條路徑上的成本治理。</p>
<h3 id="回上游">回上游</h3>
<ul>
<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 模型">Apache Kafka</a>（「Tiered storage」與「Cross-region 與分層叢集」段）</li>
<li>平行 deep article：<a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">consumer rebalance 與 lag 診斷</a> / <a href="/blog/backend/03-message-queue/vendors/kafka/" data-link-title="Apache Kafka" data-link-desc="Distributed event streaming platform、log-based 模型">replication、ISR 與 exactly-once</a>（同 vendor 其他實作層議題）</li>
<li>下游能力：<a href="/blog/backend/03-message-queue/consumer-design/" data-link-title="3.4 consumer 設計與去重" data-link-desc="整理 consumer、checkpoint 與 replay safety">3.4 consumer 設計</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>
</ul>
]]></content:encoded></item></channel></rss>