<?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>Retention on Tarragon</title><link>https://tarrragon.github.io/blog/tags/retention/</link><description>Recent content in Retention on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/retention/index.xml" rel="self" type="application/rss+xml"/><item><title>Cohort Analysis</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">Cohort analysis&lt;/a> 把使用者按共同特徵分群（cohort），比較不同群體在同一個指標上的表現差異。整體平均留存率 40% 可能隱藏了「1 月註冊的使用者留存 60%、3 月註冊的留存 20%」的差異。Cohort analysis 揭露平均值遮蔽的趨勢。&lt;/p>
&lt;h2 id="cohort-的定義方式">Cohort 的定義方式&lt;/h2>
&lt;h3 id="時間-cohort最常用">時間 cohort（最常用）&lt;/h3>
&lt;p>按使用者完成某個動作的時間分群。「1 月份註冊的使用者」「第 12 週 onboarding 完成的使用者」。&lt;/p>
&lt;p>時間 cohort 回答的問題：產品的留存率是否隨時間改善？新版本上線後註冊的使用者留存是否比舊版本高？&lt;/p>
&lt;h3 id="行為-cohort">行為 cohort&lt;/h3>
&lt;p>按使用者的行為特徵分群。「首次使用就完成購買的使用者」「使用過搜尋功能的使用者」「連續 3 天登入的使用者」。&lt;/p>
&lt;p>行為 cohort 回答的問題：哪些行為和留存相關？做了 X 的使用者留存率是否比沒做 X 的高？&lt;/p>
&lt;h3 id="屬性-cohort">屬性 cohort&lt;/h3>
&lt;p>按使用者的固有屬性分群。「iOS 使用者」「企業方案使用者」「來自特定廣告渠道的使用者」。&lt;/p>
&lt;p>屬性 cohort 回答的問題：不同平台/方案/來源的使用者行為是否不同？&lt;/p>
&lt;h2 id="留存率矩陣">留存率矩陣&lt;/h2>
&lt;p>留存率矩陣是 cohort analysis 最常見的呈現方式。每行代表一個 cohort（例如某月註冊的使用者），每列代表註冊後的第 N 天/週/月，格中的值是該 cohort 在第 N 期仍活躍的比例。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Cohort&lt;/th>
 &lt;th>第 0 週&lt;/th>
 &lt;th>第 1 週&lt;/th>
 &lt;th>第 2 週&lt;/th>
 &lt;th>第 4 週&lt;/th>
 &lt;th>第 8 週&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1 月&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>45%&lt;/td>
 &lt;td>32%&lt;/td>
 &lt;td>22%&lt;/td>
 &lt;td>18%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2 月&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>48%&lt;/td>
 &lt;td>35%&lt;/td>
 &lt;td>25%&lt;/td>
 &lt;td>20%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3 月&lt;/td>
 &lt;td>100%&lt;/td>
 &lt;td>52%&lt;/td>
 &lt;td>40%&lt;/td>
 &lt;td>30%&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>從這張矩陣可以看到：留存率逐月改善（1 月 → 3 月的第 1 週留存從 45% 升到 52%）。如果 2 月有產品改版，這個改善可能和改版相關。&lt;/p>
&lt;h2 id="cohort-analysis-的判讀">Cohort analysis 的判讀&lt;/h2>
&lt;h3 id="自然衰減-vs-產品問題">自然衰減 vs 產品問題&lt;/h3>
&lt;p>所有產品都有自然衰減 — 使用者隨時間減少是正常的。Cohort analysis 的價值在於區分「正常衰減」和「異常衰減」。&lt;/p>
&lt;p>如果所有 cohort 的衰減曲線形狀相似，衰減是產品層面的結構性問題（例如缺少持續使用的理由）。如果某個 cohort 的衰減明顯比其他 cohort 快，需要調查該 cohort 的特殊情況（當時的產品版本、市場環境、使用者來源）。&lt;/p>
&lt;h3 id="穩態留存">穩態留存&lt;/h3>
&lt;p>留存率通常在某個時間點後趨於穩定 — 留下來的使用者不再大量流失。穩態留存的百分比和到達穩態的時間是產品健康度的核心指標。&lt;/p>
&lt;p>穩態留存高但到達時間長 = 產品有價值但 onboarding 需要改善。穩態留存低 = 產品的持續使用價值不足。&lt;/p>
&lt;h2 id="和-funnel-的關係">和 funnel 的關係&lt;/h2>
&lt;p>Funnel analysis 回答「使用者在哪一步流失」（單次流程），cohort analysis 回答「使用者是否持續回來」（長期行為）。兩者互補：funnel 改善單次流程的轉換率，cohort 追蹤改善是否帶來長期留存的變化。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>使用者從哪來 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/attribution/" data-link-title="Attribution" data-link-desc="使用者從哪來、哪個渠道帶來轉換 — last-touch / first-touch / multi-touch 歸因模型的差異和選擇">Attribution&lt;/a>&lt;/li>
&lt;li>單次流程的流失分析 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis&lt;/a>&lt;/li>
&lt;li>使用者分群的工程實作 → &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群&lt;/a>&lt;/li>
&lt;li>客戶終身價值 → &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/ltv/" data-link-title="LTV" data-link-desc="說明客戶終身價值與其在估值中的作用">LTV&lt;/a>&lt;/li>
&lt;li>留存率 → &lt;a href="https://tarrragon.github.io/blog/business/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明客戶留存率與其對單位經濟的決定作用">Retention&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><a href="/blog/monitoring/knowledge-cards/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="說明把使用者按共同特徵分群、比較不同群組行為差異的分析方法">Cohort analysis</a> 把使用者按共同特徵分群（cohort），比較不同群體在同一個指標上的表現差異。整體平均留存率 40% 可能隱藏了「1 月註冊的使用者留存 60%、3 月註冊的留存 20%」的差異。Cohort analysis 揭露平均值遮蔽的趨勢。</p>
<h2 id="cohort-的定義方式">Cohort 的定義方式</h2>
<h3 id="時間-cohort最常用">時間 cohort（最常用）</h3>
<p>按使用者完成某個動作的時間分群。「1 月份註冊的使用者」「第 12 週 onboarding 完成的使用者」。</p>
<p>時間 cohort 回答的問題：產品的留存率是否隨時間改善？新版本上線後註冊的使用者留存是否比舊版本高？</p>
<h3 id="行為-cohort">行為 cohort</h3>
<p>按使用者的行為特徵分群。「首次使用就完成購買的使用者」「使用過搜尋功能的使用者」「連續 3 天登入的使用者」。</p>
<p>行為 cohort 回答的問題：哪些行為和留存相關？做了 X 的使用者留存率是否比沒做 X 的高？</p>
<h3 id="屬性-cohort">屬性 cohort</h3>
<p>按使用者的固有屬性分群。「iOS 使用者」「企業方案使用者」「來自特定廣告渠道的使用者」。</p>
<p>屬性 cohort 回答的問題：不同平台/方案/來源的使用者行為是否不同？</p>
<h2 id="留存率矩陣">留存率矩陣</h2>
<p>留存率矩陣是 cohort analysis 最常見的呈現方式。每行代表一個 cohort（例如某月註冊的使用者），每列代表註冊後的第 N 天/週/月，格中的值是該 cohort 在第 N 期仍活躍的比例。</p>
<table>
  <thead>
      <tr>
          <th>Cohort</th>
          <th>第 0 週</th>
          <th>第 1 週</th>
          <th>第 2 週</th>
          <th>第 4 週</th>
          <th>第 8 週</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 月</td>
          <td>100%</td>
          <td>45%</td>
          <td>32%</td>
          <td>22%</td>
          <td>18%</td>
      </tr>
      <tr>
          <td>2 月</td>
          <td>100%</td>
          <td>48%</td>
          <td>35%</td>
          <td>25%</td>
          <td>20%</td>
      </tr>
      <tr>
          <td>3 月</td>
          <td>100%</td>
          <td>52%</td>
          <td>40%</td>
          <td>30%</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>從這張矩陣可以看到：留存率逐月改善（1 月 → 3 月的第 1 週留存從 45% 升到 52%）。如果 2 月有產品改版，這個改善可能和改版相關。</p>
<h2 id="cohort-analysis-的判讀">Cohort analysis 的判讀</h2>
<h3 id="自然衰減-vs-產品問題">自然衰減 vs 產品問題</h3>
<p>所有產品都有自然衰減 — 使用者隨時間減少是正常的。Cohort analysis 的價值在於區分「正常衰減」和「異常衰減」。</p>
<p>如果所有 cohort 的衰減曲線形狀相似，衰減是產品層面的結構性問題（例如缺少持續使用的理由）。如果某個 cohort 的衰減明顯比其他 cohort 快，需要調查該 cohort 的特殊情況（當時的產品版本、市場環境、使用者來源）。</p>
<h3 id="穩態留存">穩態留存</h3>
<p>留存率通常在某個時間點後趨於穩定 — 留下來的使用者不再大量流失。穩態留存的百分比和到達穩態的時間是產品健康度的核心指標。</p>
<p>穩態留存高但到達時間長 = 產品有價值但 onboarding 需要改善。穩態留存低 = 產品的持續使用價值不足。</p>
<h2 id="和-funnel-的關係">和 funnel 的關係</h2>
<p>Funnel analysis 回答「使用者在哪一步流失」（單次流程），cohort analysis 回答「使用者是否持續回來」（長期行為）。兩者互補：funnel 改善單次流程的轉換率，cohort 追蹤改善是否帶來長期留存的變化。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>使用者從哪來 → <a href="/blog/monitoring/08-business-analytics/attribution/" data-link-title="Attribution" data-link-desc="使用者從哪來、哪個渠道帶來轉換 — last-touch / first-touch / multi-touch 歸因模型的差異和選擇">Attribution</a></li>
<li>單次流程的流失分析 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>使用者分群的工程實作 → <a href="/blog/monitoring/08-business-analytics/rfm-segmentation/" data-link-title="RFM 分群" data-link-desc="Recency / Frequency / Monetary 三維度的使用者分群 — 從行為事件計算 RFM 分數、定義使用者群體、驅動差異化策略">RFM 分群</a></li>
<li>客戶終身價值 → <a href="/blog/business/knowledge-cards/ltv/" data-link-title="LTV" data-link-desc="說明客戶終身價值與其在估值中的作用">LTV</a></li>
<li>留存率 → <a href="/blog/business/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明客戶留存率與其對單位經濟的決定作用">Retention</a></li>
</ul>
]]></content:encoded></item><item><title>RFM 分群</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/rfm-segmentation/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM&lt;/a> 分群用三個維度衡量使用者的價值：Recency（最近一次互動是多久前）、Frequency（互動的頻率）、Monetary（互動的金額或價值）。三個維度各自獨立評分，組合成使用者的 RFM profile，驅動差異化的營運策略。&lt;/p>
&lt;h2 id="三個維度">三個維度&lt;/h2>
&lt;h3 id="recency最近一次互動的時間距離">Recency：最近一次互動的時間距離&lt;/h3>
&lt;p>計算使用者最後一次有意義的互動到現在的天數。「有意義的互動」取決於業務定義 — 電商是最後一次購買，SaaS 是最後一次登入，媒體是最後一次內容消費。&lt;/p>
&lt;p>Recency 的價值在於「最近互動的使用者比很久沒來的使用者更可能再次互動」。Recency 高（最近才來）的使用者是活躍群體，Recency 低（很久沒來）的使用者是流失風險群體。&lt;/p>
&lt;h3 id="frequency互動的頻率">Frequency：互動的頻率&lt;/h3>
&lt;p>計算使用者在特定時間窗口內的互動次數。時間窗口取決於業務節奏 — 日用品電商看近 90 天的購買次數，SaaS 看近 30 天的登入次數。&lt;/p>
&lt;p>Frequency 區分「偶爾來的使用者」和「常客」。高頻使用者是產品的核心用戶群，他們的行為和需求代表產品的核心價值。&lt;/p>
&lt;h3 id="monetary互動的價值">Monetary：互動的價值&lt;/h3>
&lt;p>計算使用者在特定時間窗口內貢獻的總金額。適用於有直接收入的業務（電商、訂閱服務）。&lt;/p>
&lt;p>沒有直接收入的產品可以用替代指標：內容平台用消費的內容數量，社群平台用產生的內容數量，工具類產品用使用的功能數量。替代指標的選擇依據是「哪個行為最能代表使用者的投入程度」。&lt;/p>
&lt;h2 id="rfm-分數計算">RFM 分數計算&lt;/h2>
&lt;p>每個維度獨立評分，通常用 1-5 分。評分方式有兩種：&lt;/p>
&lt;h3 id="等距分割">等距分割&lt;/h3>
&lt;p>把每個維度的值域等分成 5 段。Recency 0-6 天 = 5 分、7-13 天 = 4 分、依此類推。&lt;/p>
&lt;p>優點是簡單直覺；缺點是不考慮使用者分佈 — 如果大部分使用者的 Recency 在 0-6 天，5 分的群體佔大多數，分群的鑑別度低。&lt;/p>
&lt;h3 id="等量分割分位數">等量分割（分位數）&lt;/h3>
&lt;p>用分位數確保每個分數段的使用者數量大致相等。前 20% 的 Recency = 5 分、次 20% = 4 分。&lt;/p>
&lt;p>優點是每個分數段有足夠的使用者數量做分析；缺點是分數的業務意義不固定 — 5 分代表的天數取決於使用者分佈，不是固定的閾值。&lt;/p>
&lt;h2 id="rfm-群體定義">RFM 群體定義&lt;/h2>
&lt;p>三個維度各 5 分，組合出 125 種 RFM profile（5 × 5 × 5）。實務上不需要 125 種策略，通常歸納成 5-8 個有業務意義的群體：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>群體&lt;/th>
 &lt;th>RFM 特徵&lt;/th>
 &lt;th>描述&lt;/th>
 &lt;th>策略方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>冠軍客戶&lt;/td>
 &lt;td>R5 F5 M5&lt;/td>
 &lt;td>最近才來、經常來、消費高&lt;/td>
 &lt;td>維持關係、VIP 待遇&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>忠實客戶&lt;/td>
 &lt;td>R4-5 F4-5 M3-5&lt;/td>
 &lt;td>經常來、消費中到高&lt;/td>
 &lt;td>交叉銷售、推薦&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>潛力客戶&lt;/td>
 &lt;td>R4-5 F1-2 M1-2&lt;/td>
 &lt;td>最近才來、但頻率和消費低&lt;/td>
 &lt;td>引導更多互動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>沉睡客戶&lt;/td>
 &lt;td>R1-2 F3-5 M3-5&lt;/td>
 &lt;td>曾經活躍但很久沒來&lt;/td>
 &lt;td>挽回活動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流失客戶&lt;/td>
 &lt;td>R1 F1 M1&lt;/td>
 &lt;td>很久沒來、頻率低、消費低&lt;/td>
 &lt;td>評估挽回成本效益&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="工程實作">工程實作&lt;/h2>
&lt;p>RFM 計算的輸入是使用者的行為事件。從 collector 的 JSONL 資料計算 RFM：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>擷取&lt;/strong>：篩選目標事件（購買、登入、使用功能），按 user_id 分群&lt;/li>
&lt;li>&lt;strong>計算 R&lt;/strong>：每個 user_id 的最新事件時間到現在的天數&lt;/li>
&lt;li>&lt;strong>計算 F&lt;/strong>：每個 user_id 在時間窗口內的事件數量&lt;/li>
&lt;li>&lt;strong>計算 M&lt;/strong>：每個 user_id 在時間窗口內的 monetary 屬性加總&lt;/li>
&lt;li>&lt;strong>評分&lt;/strong>：對 R/F/M 各自用分位數或等距分割評分&lt;/li>
&lt;li>&lt;strong>分群&lt;/strong>：根據 RFM 分數組合定義群體&lt;/li>
&lt;/ol>
&lt;p>這個計算可以用 SQL（如果資料在資料庫）或 Python pandas（如果資料在 JSONL 檔案）完成。定期重算（每天或每週），產出使用者群體標籤。&lt;/p>
&lt;p>RFM 分群需要的資料可以從自架 collector 提取 — &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析&lt;/a>展示了 grep + jq 在自架環境中的分析能力和邊界。RFM 分出的群體還可以用 &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis&lt;/a> 追蹤留存趨勢，兩種分析互補。分群和分析的前提是正確的&lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計&lt;/a> — 事件的屬性決定了 R/F/M 能否被計算。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/monitoring/knowledge-cards/rfm/" data-link-title="RFM" data-link-desc="說明用 Recency / Frequency / Monetary 三個維度把使用者分成可操作群組的分群方法">RFM</a> 分群用三個維度衡量使用者的價值：Recency（最近一次互動是多久前）、Frequency（互動的頻率）、Monetary（互動的金額或價值）。三個維度各自獨立評分，組合成使用者的 RFM profile，驅動差異化的營運策略。</p>
<h2 id="三個維度">三個維度</h2>
<h3 id="recency最近一次互動的時間距離">Recency：最近一次互動的時間距離</h3>
<p>計算使用者最後一次有意義的互動到現在的天數。「有意義的互動」取決於業務定義 — 電商是最後一次購買，SaaS 是最後一次登入，媒體是最後一次內容消費。</p>
<p>Recency 的價值在於「最近互動的使用者比很久沒來的使用者更可能再次互動」。Recency 高（最近才來）的使用者是活躍群體，Recency 低（很久沒來）的使用者是流失風險群體。</p>
<h3 id="frequency互動的頻率">Frequency：互動的頻率</h3>
<p>計算使用者在特定時間窗口內的互動次數。時間窗口取決於業務節奏 — 日用品電商看近 90 天的購買次數，SaaS 看近 30 天的登入次數。</p>
<p>Frequency 區分「偶爾來的使用者」和「常客」。高頻使用者是產品的核心用戶群，他們的行為和需求代表產品的核心價值。</p>
<h3 id="monetary互動的價值">Monetary：互動的價值</h3>
<p>計算使用者在特定時間窗口內貢獻的總金額。適用於有直接收入的業務（電商、訂閱服務）。</p>
<p>沒有直接收入的產品可以用替代指標：內容平台用消費的內容數量，社群平台用產生的內容數量，工具類產品用使用的功能數量。替代指標的選擇依據是「哪個行為最能代表使用者的投入程度」。</p>
<h2 id="rfm-分數計算">RFM 分數計算</h2>
<p>每個維度獨立評分，通常用 1-5 分。評分方式有兩種：</p>
<h3 id="等距分割">等距分割</h3>
<p>把每個維度的值域等分成 5 段。Recency 0-6 天 = 5 分、7-13 天 = 4 分、依此類推。</p>
<p>優點是簡單直覺；缺點是不考慮使用者分佈 — 如果大部分使用者的 Recency 在 0-6 天，5 分的群體佔大多數，分群的鑑別度低。</p>
<h3 id="等量分割分位數">等量分割（分位數）</h3>
<p>用分位數確保每個分數段的使用者數量大致相等。前 20% 的 Recency = 5 分、次 20% = 4 分。</p>
<p>優點是每個分數段有足夠的使用者數量做分析；缺點是分數的業務意義不固定 — 5 分代表的天數取決於使用者分佈，不是固定的閾值。</p>
<h2 id="rfm-群體定義">RFM 群體定義</h2>
<p>三個維度各 5 分，組合出 125 種 RFM profile（5 × 5 × 5）。實務上不需要 125 種策略，通常歸納成 5-8 個有業務意義的群體：</p>
<table>
  <thead>
      <tr>
          <th>群體</th>
          <th>RFM 特徵</th>
          <th>描述</th>
          <th>策略方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>冠軍客戶</td>
          <td>R5 F5 M5</td>
          <td>最近才來、經常來、消費高</td>
          <td>維持關係、VIP 待遇</td>
      </tr>
      <tr>
          <td>忠實客戶</td>
          <td>R4-5 F4-5 M3-5</td>
          <td>經常來、消費中到高</td>
          <td>交叉銷售、推薦</td>
      </tr>
      <tr>
          <td>潛力客戶</td>
          <td>R4-5 F1-2 M1-2</td>
          <td>最近才來、但頻率和消費低</td>
          <td>引導更多互動</td>
      </tr>
      <tr>
          <td>沉睡客戶</td>
          <td>R1-2 F3-5 M3-5</td>
          <td>曾經活躍但很久沒來</td>
          <td>挽回活動</td>
      </tr>
      <tr>
          <td>流失客戶</td>
          <td>R1 F1 M1</td>
          <td>很久沒來、頻率低、消費低</td>
          <td>評估挽回成本效益</td>
      </tr>
  </tbody>
</table>
<h2 id="工程實作">工程實作</h2>
<p>RFM 計算的輸入是使用者的行為事件。從 collector 的 JSONL 資料計算 RFM：</p>
<ol>
<li><strong>擷取</strong>：篩選目標事件（購買、登入、使用功能），按 user_id 分群</li>
<li><strong>計算 R</strong>：每個 user_id 的最新事件時間到現在的天數</li>
<li><strong>計算 F</strong>：每個 user_id 在時間窗口內的事件數量</li>
<li><strong>計算 M</strong>：每個 user_id 在時間窗口內的 monetary 屬性加總</li>
<li><strong>評分</strong>：對 R/F/M 各自用分位數或等距分割評分</li>
<li><strong>分群</strong>：根據 RFM 分數組合定義群體</li>
</ol>
<p>這個計算可以用 SQL（如果資料在資料庫）或 Python pandas（如果資料在 JSONL 檔案）完成。定期重算（每天或每週），產出使用者群體標籤。</p>
<p>RFM 分群需要的資料可以從自架 collector 提取 — <a href="/blog/monitoring/08-business-analytics/self-hosted-funnel/" data-link-title="從 collector 資料做基礎 funnel 分析" data-link-desc="SQLite 層能做什麼程度的 funnel、PostgreSQL 層提供什麼進階能力、JSONL 匯出後的臨時分析">從 collector 資料做基礎 funnel 分析</a>展示了 grep + jq 在自架環境中的分析能力和邊界。RFM 分出的群體還可以用 <a href="/blog/monitoring/08-business-analytics/cohort-analysis/" data-link-title="Cohort Analysis" data-link-desc="按共同特徵分群、比較不同群體的留存率和行為差異 — 從「平均值」到「誰在用、誰離開了」">Cohort analysis</a> 追蹤留存趨勢，兩種分析互補。分群和分析的前提是正確的<a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a> — 事件的屬性決定了 R/F/M 能否被計算。</p>
]]></content:encoded></item><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><item><title>7.R11.P14 備份刪除證據缺口</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/problem-cards/fp-backup-deletion-evidence-gap/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/problem-cards/fp-backup-deletion-evidence-gap/</guid><description>&lt;p>這個失效樣式的核心問題是刪除閉環只覆蓋主系統，沒有覆蓋備份路徑的可驗證證據。當備份刪除證據不足，資料暴露會長期停留在隱性狀態，並破壞 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/data-lifecycle/" data-link-title="Data Lifecycle" data-link-desc="說明資料從建立、使用、保留到刪除的責任邊界">data lifecycle&lt;/a> 一致性。&lt;/p>
&lt;h2 id="常見形成條件">常見形成條件&lt;/h2>
&lt;ul>
&lt;li>正式資料刪除流程未同步到備份刪除流程。&lt;/li>
&lt;li>備份保留政策與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention&lt;/a> 承諾缺少對齊條件。&lt;/li>
&lt;li>刪除回覆缺少主體、時間與資產的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log&lt;/a> 欄位。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;ul>
&lt;li>主系統刪除完成後，備份仍可長期還原相同資料。&lt;/li>
&lt;li>刪除事件在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/incident-timeline/" data-link-title="Incident Timeline" data-link-desc="說明事故時間線如何支援判斷、溝通與復盤">incident timeline&lt;/a> 與稽核鏈上缺少備份路徑證據。&lt;/li>
&lt;li>使用者刪除請求關閉後仍出現同資料外送跡象。&lt;/li>
&lt;/ul>
&lt;h2 id="案例觸發參考">案例觸發參考&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/lastpass-2022-backup-chain/" data-link-title="7.R7.4.1 LastPass 2022：備份路徑與鏈式入侵" data-link-desc="開發環境資訊外流如何沿著備份路徑擴大成資料風險">LastPass 2022&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/snowflake-2024-credential-abuse/" data-link-title="7.R7.4.2 Snowflake 2024：憑證濫用與資料竊取" data-link-desc="外洩憑證與 MFA 缺口如何在資料平台形成高風險外送事件">Snowflake 2024&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/progress-wsftp-2023-file-service-breach/" data-link-title="7.R7.4.6 Progress WS_FTP 2023：檔案服務入口與資料外送" data-link-desc="對外檔案服務漏洞在企業環境常直接轉為資料外送風險">WS_FTP 2023&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="來源流程卡">來源流程卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/red-team/problem-cards/export-flow-abuse/" data-link-title="7.R11.8 匯出流程濫用" data-link-desc="說明匯出流程為何常被放大為資料外送主路徑">匯出流程濫用&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>本失效樣式對應的實作 chain：&lt;/p>
&lt;p>&lt;strong>控制面（mitigation 在這裡定義）&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/data-residency-deletion-and-evidence-chain/" data-link-title="7.11 資料駐留、刪除與證據鏈" data-link-desc="定義跨區資料駐留、刪除請求與可驗證證據鏈問題">7.10 資料 residency / 刪除與證據鏈&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>演練 / 控制落地（轉成欄位）&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/recovery-readiness-pattern/" data-link-title="Recovery Readiness Pattern" data-link-desc="定義長時間 outage 復原、備援存取與外部依賴溝通的共同欄位">Recovery readiness pattern&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/evidence-chain-pattern/" data-link-title="Evidence Chain Pattern" data-link-desc="定義事故與演練需要保存的訊號、決策、artifact、timeline 與 retention 證據">Evidence chain pattern&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個失效樣式的核心問題是刪除閉環只覆蓋主系統，沒有覆蓋備份路徑的可驗證證據。當備份刪除證據不足，資料暴露會長期停留在隱性狀態，並破壞 <a href="/blog/backend/knowledge-cards/data-lifecycle/" data-link-title="Data Lifecycle" data-link-desc="說明資料從建立、使用、保留到刪除的責任邊界">data lifecycle</a> 一致性。</p>
<h2 id="常見形成條件">常見形成條件</h2>
<ul>
<li>正式資料刪除流程未同步到備份刪除流程。</li>
<li>備份保留政策與 <a href="/blog/backend/knowledge-cards/retention/" data-link-title="Retention" data-link-desc="說明資料或事件保留多久，以及保留期限如何影響重放與成本">retention</a> 承諾缺少對齊條件。</li>
<li>刪除回覆缺少主體、時間與資產的 <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log</a> 欄位。</li>
</ul>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>主系統刪除完成後，備份仍可長期還原相同資料。</li>
<li>刪除事件在 <a href="/blog/backend/knowledge-cards/incident-timeline/" data-link-title="Incident Timeline" data-link-desc="說明事故時間線如何支援判斷、溝通與復盤">incident timeline</a> 與稽核鏈上缺少備份路徑證據。</li>
<li>使用者刪除請求關閉後仍出現同資料外送跡象。</li>
</ul>
<h2 id="案例觸發參考">案例觸發參考</h2>
<ul>
<li><a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/lastpass-2022-backup-chain/" data-link-title="7.R7.4.1 LastPass 2022：備份路徑與鏈式入侵" data-link-desc="開發環境資訊外流如何沿著備份路徑擴大成資料風險">LastPass 2022</a></li>
<li><a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/snowflake-2024-credential-abuse/" data-link-title="7.R7.4.2 Snowflake 2024：憑證濫用與資料竊取" data-link-desc="外洩憑證與 MFA 缺口如何在資料平台形成高風險外送事件">Snowflake 2024</a></li>
<li><a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/progress-wsftp-2023-file-service-breach/" data-link-title="7.R7.4.6 Progress WS_FTP 2023：檔案服務入口與資料外送" data-link-desc="對外檔案服務漏洞在企業環境常直接轉為資料外送風險">WS_FTP 2023</a></li>
</ul>
<h2 id="來源流程卡">來源流程卡</h2>
<ul>
<li><a href="/blog/backend/07-security-data-protection/red-team/problem-cards/export-flow-abuse/" data-link-title="7.R11.8 匯出流程濫用" data-link-desc="說明匯出流程為何常被放大為資料外送主路徑">匯出流程濫用</a></li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<p>本失效樣式對應的實作 chain：</p>
<p><strong>控制面（mitigation 在這裡定義）</strong>：</p>
<ul>
<li><a href="/blog/backend/07-security-data-protection/data-residency-deletion-and-evidence-chain/" data-link-title="7.11 資料駐留、刪除與證據鏈" data-link-desc="定義跨區資料駐留、刪除請求與可驗證證據鏈問題">7.10 資料 residency / 刪除與證據鏈</a></li>
</ul>
<p><strong>演練 / 控制落地（轉成欄位）</strong>：</p>
<ul>
<li><a href="/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/recovery-readiness-pattern/" data-link-title="Recovery Readiness Pattern" data-link-desc="定義長時間 outage 復原、備援存取與外部依賴溝通的共同欄位">Recovery readiness pattern</a></li>
<li><a href="/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/evidence-chain-pattern/" data-link-title="Evidence Chain Pattern" data-link-desc="定義事故與演練需要保存的訊號、決策、artifact、timeline 與 retention 證據">Evidence chain pattern</a></li>
</ul>
]]></content:encoded></item></channel></rss>