<?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>Persistence on Tarragon</title><link>https://tarrragon.github.io/blog/tags/persistence/</link><description>Recent content in Persistence 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/persistence/index.xml" rel="self" type="application/rss+xml"/><item><title>離線 buffer 與重試</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/offline-buffer/</guid><description>&lt;p>離線 buffer 處理的是「事件產生時網路不可用」的場景。記憶體 buffer 有容量上限，離線時間超過 buffer 容量時需要決策：丟棄舊事件、持久化到本地儲存、或兩者混合。每種策略有不同的複雜度和資料保留量的取捨。&lt;/p>
&lt;h2 id="三種策略">三種策略&lt;/h2>
&lt;h3 id="fifo-丟棄最簡單">FIFO 丟棄（最簡單）&lt;/h3>
&lt;p>Buffer 滿時丟棄最舊的事件，保留最新的。整個 buffer 在記憶體中，不做本地 persistence。&lt;/p>
&lt;p>優點：實作最簡單（array + 容量檢查），不需要檔案系統存取，不增加磁碟 I/O。&lt;/p>
&lt;p>代價：離線超過 buffer 容量時，較舊的事件永久遺失。如果離線 30 分鐘、buffer 容量 200 筆、事件產生速率每分鐘 10 筆，前 100 筆（前 10 分鐘）的事件被丟棄。&lt;/p>
&lt;p>適合場景：自用工具（離線場景少、遺失部分事件影響低）、SDK 初期版本（先用最簡單的策略上線）。&lt;/p>
&lt;h3 id="本地-persistence最完整">本地 persistence（最完整）&lt;/h3>
&lt;p>Buffer 滿時把事件寫入本地檔案（SQLite、JSONL 檔案、SharedPreferences / UserDefaults）。網路恢復後從本地檔案讀取並補發。&lt;/p>
&lt;p>優點：離線期間的事件不會遺失（在本地儲存容量內）。&lt;/p>
&lt;p>代價：實作複雜度高 — 需要處理檔案讀寫、並發存取（多執行緒安全）、本地儲存容量管理（磁碟空間上限）、補發時的去重（同一筆事件可能已在記憶體 buffer 中被 flush 過）。&lt;/p>
&lt;p>適合場景：商業產品（使用者在地鐵、電梯、飛航模式下使用）、離線時間長且事件不可遺失的需求。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>記憶體 buffer 處理正常情況和短暫離線。離線超過記憶體 buffer 容量時，溢出的事件寫入本地檔案。網路恢復後先 flush 記憶體 buffer（最新事件），再補發本地檔案中的事件（較舊事件）。&lt;/p>
&lt;p>混合策略的實作複雜度介於兩者之間。本地檔案只在溢出時使用，正常情況下不產生磁碟 I/O。&lt;/p>
&lt;h2 id="恢復後補發">恢復後補發&lt;/h2>
&lt;p>網路恢復後補發離線期間累積的事件，需要處理三個問題：&lt;/p>
&lt;h3 id="補發順序">補發順序&lt;/h3>
&lt;p>離線事件按 timestamp 順序補發，保持事件的時間順序。Collector 端收到的事件 timestamp 可能比當前時間早數小時 — 這是正常的離線補發，collector 應該根據事件的 timestamp 處理，不依賴收到時間。&lt;/p>
&lt;h3 id="補發速率">補發速率&lt;/h3>
&lt;p>一次送出大量離線事件可能讓 collector 過載。分批補發（每批 50-100 筆，間隔 1-2 秒），讓 collector 有時間處理。&lt;/p>
&lt;h3 id="去重">去重&lt;/h3>
&lt;p>同一筆事件可能同時存在於記憶體 buffer 和本地檔案中（寫入本地檔案時 buffer 中也有一份）。Collector 端用事件的唯一識別（timestamp + session_id + name 的組合，或 SDK 產生的 event_id UUID）做去重。&lt;/p>
&lt;h2 id="本地儲存容量管理">本地儲存容量管理&lt;/h2>
&lt;p>本地 persistence 需要設定磁碟使用上限。上限取決於事件大小和保留時間。&lt;/p>
&lt;p>以平均每筆事件 500 bytes 估算：&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>1 MB&lt;/td>
 &lt;td>~2,000&lt;/td>
 &lt;td>約 3 小時（每分鐘 10 筆）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10 MB&lt;/td>
 &lt;td>~20,000&lt;/td>
 &lt;td>約 33 小時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>50 MB&lt;/td>
 &lt;td>~100,000&lt;/td>
 &lt;td>約 7 天&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>自用工具 1 MB 足夠（離線場景少）。行動 app 10-50 MB 合理（使用者可能整天離線）。超過上限時用 FIFO 丟棄最舊的本地檔案。&lt;/p>
&lt;h2 id="各平台的本地儲存路徑">各平台的本地儲存路徑&lt;/h2>
&lt;p>本地 persistence 的檔案路徑和格式因平台而異。MVP 階段全用記憶體 FIFO（最簡單策略），本地 persistence 標為第二階段。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>平台&lt;/th>
 &lt;th>建議路徑&lt;/th>
 &lt;th>檔案格式&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Flutter&lt;/td>
 &lt;td>&lt;code>getApplicationSupportDirectory()&lt;/code>&lt;/td>
 &lt;td>JSONL&lt;/td>
 &lt;td>不會被 iCloud 備份（和 Documents 不同）、不會被系統自動清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python&lt;/td>
 &lt;td>&lt;code>~/.cache/monitor/&lt;/code> 或 &lt;code>platformdirs.user_cache_dir('monitor')&lt;/code>&lt;/td>
 &lt;td>JSONL&lt;/td>
 &lt;td>遵循 XDG 標準、&lt;code>platformdirs&lt;/code> 套件處理跨平台&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS/Web&lt;/td>
 &lt;td>&lt;code>localStorage&lt;/code> 或 &lt;code>IndexedDB&lt;/code>&lt;/td>
 &lt;td>JSON&lt;/td>
 &lt;td>localStorage 有 5MB 限制、IndexedDB 更大但 API 較複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>App 被強制終止時（iOS 的 &lt;code>kill&lt;/code>、Android 的 process death），記憶體 buffer 中未 flush 的事件會遺失。Flutter 的 &lt;code>AppLifecycleState.detached&lt;/code> 不保證有時間執行 flush。接受這個遺失 — 強制終止是極端情境，下次啟動時 SDK 重新開始收集。&lt;/p></description><content:encoded><![CDATA[<p>離線 buffer 處理的是「事件產生時網路不可用」的場景。記憶體 buffer 有容量上限，離線時間超過 buffer 容量時需要決策：丟棄舊事件、持久化到本地儲存、或兩者混合。每種策略有不同的複雜度和資料保留量的取捨。</p>
<h2 id="三種策略">三種策略</h2>
<h3 id="fifo-丟棄最簡單">FIFO 丟棄（最簡單）</h3>
<p>Buffer 滿時丟棄最舊的事件，保留最新的。整個 buffer 在記憶體中，不做本地 persistence。</p>
<p>優點：實作最簡單（array + 容量檢查），不需要檔案系統存取，不增加磁碟 I/O。</p>
<p>代價：離線超過 buffer 容量時，較舊的事件永久遺失。如果離線 30 分鐘、buffer 容量 200 筆、事件產生速率每分鐘 10 筆，前 100 筆（前 10 分鐘）的事件被丟棄。</p>
<p>適合場景：自用工具（離線場景少、遺失部分事件影響低）、SDK 初期版本（先用最簡單的策略上線）。</p>
<h3 id="本地-persistence最完整">本地 persistence（最完整）</h3>
<p>Buffer 滿時把事件寫入本地檔案（SQLite、JSONL 檔案、SharedPreferences / UserDefaults）。網路恢復後從本地檔案讀取並補發。</p>
<p>優點：離線期間的事件不會遺失（在本地儲存容量內）。</p>
<p>代價：實作複雜度高 — 需要處理檔案讀寫、並發存取（多執行緒安全）、本地儲存容量管理（磁碟空間上限）、補發時的去重（同一筆事件可能已在記憶體 buffer 中被 flush 過）。</p>
<p>適合場景：商業產品（使用者在地鐵、電梯、飛航模式下使用）、離線時間長且事件不可遺失的需求。</p>
<h3 id="混合策略">混合策略</h3>
<p>記憶體 buffer 處理正常情況和短暫離線。離線超過記憶體 buffer 容量時，溢出的事件寫入本地檔案。網路恢復後先 flush 記憶體 buffer（最新事件），再補發本地檔案中的事件（較舊事件）。</p>
<p>混合策略的實作複雜度介於兩者之間。本地檔案只在溢出時使用，正常情況下不產生磁碟 I/O。</p>
<h2 id="恢復後補發">恢復後補發</h2>
<p>網路恢復後補發離線期間累積的事件，需要處理三個問題：</p>
<h3 id="補發順序">補發順序</h3>
<p>離線事件按 timestamp 順序補發，保持事件的時間順序。Collector 端收到的事件 timestamp 可能比當前時間早數小時 — 這是正常的離線補發，collector 應該根據事件的 timestamp 處理，不依賴收到時間。</p>
<h3 id="補發速率">補發速率</h3>
<p>一次送出大量離線事件可能讓 collector 過載。分批補發（每批 50-100 筆，間隔 1-2 秒），讓 collector 有時間處理。</p>
<h3 id="去重">去重</h3>
<p>同一筆事件可能同時存在於記憶體 buffer 和本地檔案中（寫入本地檔案時 buffer 中也有一份）。Collector 端用事件的唯一識別（timestamp + session_id + name 的組合，或 SDK 產生的 event_id UUID）做去重。</p>
<h2 id="本地儲存容量管理">本地儲存容量管理</h2>
<p>本地 persistence 需要設定磁碟使用上限。上限取決於事件大小和保留時間。</p>
<p>以平均每筆事件 500 bytes 估算：</p>
<table>
  <thead>
      <tr>
          <th>上限</th>
          <th>可儲存事件數</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 MB</td>
          <td>~2,000</td>
          <td>約 3 小時（每分鐘 10 筆）</td>
      </tr>
      <tr>
          <td>10 MB</td>
          <td>~20,000</td>
          <td>約 33 小時</td>
      </tr>
      <tr>
          <td>50 MB</td>
          <td>~100,000</td>
          <td>約 7 天</td>
      </tr>
  </tbody>
</table>
<p>自用工具 1 MB 足夠（離線場景少）。行動 app 10-50 MB 合理（使用者可能整天離線）。超過上限時用 FIFO 丟棄最舊的本地檔案。</p>
<h2 id="各平台的本地儲存路徑">各平台的本地儲存路徑</h2>
<p>本地 persistence 的檔案路徑和格式因平台而異。MVP 階段全用記憶體 FIFO（最簡單策略），本地 persistence 標為第二階段。</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>建議路徑</th>
          <th>檔案格式</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Flutter</td>
          <td><code>getApplicationSupportDirectory()</code></td>
          <td>JSONL</td>
          <td>不會被 iCloud 備份（和 Documents 不同）、不會被系統自動清理</td>
      </tr>
      <tr>
          <td>Python</td>
          <td><code>~/.cache/monitor/</code> 或 <code>platformdirs.user_cache_dir('monitor')</code></td>
          <td>JSONL</td>
          <td>遵循 XDG 標準、<code>platformdirs</code> 套件處理跨平台</td>
      </tr>
      <tr>
          <td>JS/Web</td>
          <td><code>localStorage</code> 或 <code>IndexedDB</code></td>
          <td>JSON</td>
          <td>localStorage 有 5MB 限制、IndexedDB 更大但 API 較複雜</td>
      </tr>
  </tbody>
</table>
<p>App 被強制終止時（iOS 的 <code>kill</code>、Android 的 process death），記憶體 buffer 中未 flush 的事件會遺失。Flutter 的 <code>AppLifecycleState.detached</code> 不保證有時間執行 flush。接受這個遺失 — 強制終止是極端情境，下次啟動時 SDK 重新開始收集。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>攢批送出策略 → <a href="/blog/monitoring/03-sdk-design/batch-flush/" data-link-title="攢批送出策略" data-link-desc="flush interval / buffer size / flush on close 三個控制點決定事件何時離開 SDK — 平衡即時性和網路效率">攢批送出策略</a></li>
<li>SDK 端的資料脫敏 → <a href="/blog/monitoring/03-sdk-design/redaction-helper/" data-link-title="SDK redaction helper" data-link-desc="在事件離開 SDK 前移除敏感資訊 — 預設 redaction rule 處理常見 pattern，自訂 rule 處理業務特定的 secret">SDK redaction helper</a></li>
<li>Collector 端如何處理補發事件 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>從 SDK 到 storage 的端到端資料損失地圖 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item><item><title>Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis&lt;/a> overview 的 implementation-layer deep article。持久化跟&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校&lt;/a>互相耦合（fork 的 copy-on-write 是 maxmemory headroom 的主要消耗者），兩篇建議一起讀。機制以 &lt;a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">Redis persistence 官方文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="fork-那一瞬間">fork 那一瞬間&lt;/h2>
&lt;p>Redis 是單執行緒處理命令的，這是它延遲可預測的根基——直到它需要把記憶體裡的資料寫到磁碟。RDB snapshot 跟 AOF rewrite 都不能在主執行緒上慢慢做（會凍結所有命令），於是 Redis 的解法是 &lt;code>fork()&lt;/code>：複製出一個子進程，由子進程把當下的記憶體快照寫到磁碟，主進程繼續服務。&lt;/p>
&lt;p>問題在 &lt;code>fork()&lt;/code> 本身不是免費的。Linux 的 &lt;code>fork()&lt;/code> 要複製父進程的分頁表（page table），記憶體越大、分頁表越大，這個複製動作越久——而它發生在主執行緒上，是阻塞的。一個 20GB 的 Redis 實例，fork 可能凍結主執行緒數百毫秒到一秒。在這段時間裡，所有命令排隊，p99 延遲從 1ms 跳到 500ms+。&lt;/p>
&lt;p>更糟的是 fork 之後。&lt;code>fork()&lt;/code> 用 copy-on-write：子進程跟父進程共享實體分頁，直到某一方寫入才複製。子進程只讀（在寫 snapshot），但父進程持續服務寫入，每改一個分頁就觸發一次複製。寫入越密集、snapshot 跑越久，被複製的分頁越多，最壞情況記憶體接近翻倍。這就是為什麼 Redis 的 maxmemory 必須留 headroom——不是給資料，是給 fork 期間的分頁複製。&lt;/p>
&lt;p>理解持久化，本質是理解「fork 的延遲尖峰」與「資料持久性」之間的取捨。本文按這條線展開機制、配置與踩坑。&lt;/p>
&lt;h2 id="核心概念aof-與-rdb-是兩種不同的持久性語意">核心概念：AOF 與 RDB 是兩種不同的持久性語意&lt;/h2>
&lt;p>Redis 的兩種持久化不是「二選一的同類選項」，它們回答的是不同問題。&lt;/p>
&lt;p>&lt;strong>RDB 是某個時間點的記憶體快照&lt;/strong>。它把整個 dataset 序列化成一個緊湊的二進位檔（&lt;code>dump.rdb&lt;/code>）。優點是檔案小、還原快（直接載入記憶體）、fork 一次寫完。缺點是兩次 snapshot 之間的寫入會在崩潰時全部遺失——RDB 的持久性顆粒度是「上一次 save 到現在」，可能是幾分鐘的資料。&lt;/p>
&lt;p>&lt;strong>AOF 是命令的 append-only log&lt;/strong>。每個改變資料的命令（&lt;code>SET&lt;/code>、&lt;code>LPUSH&lt;/code>&amp;hellip;）被追加到 log 檔，還原時重放整個 log。優點是持久性顆粒度細（最多丟 &lt;code>fsync&lt;/code> 策略決定的一小段）。缺點是 log 會無限增長，需要定期 rewrite 壓縮——而 rewrite 也要 fork。&lt;/p>
&lt;p>兩者的 fork 觸發點不同但機制相同：RDB 是 &lt;code>BGSAVE&lt;/code>（手動或 save 規則觸發）fork，AOF 是 &lt;code>BGREWRITEAOF&lt;/code>（log 太大時觸發）fork。兩個若同時跑，記憶體壓力疊加。&lt;/p>
&lt;h3 id="aof-的-fsync-策略決定丟多少資料">AOF 的 fsync 策略決定丟多少資料&lt;/h3>
&lt;p>AOF 寫 log 分兩步：先 write 到 OS 的 page cache，再 fsync 刷到磁碟。&lt;code>appendfsync&lt;/code> 控制 fsync 頻率，這是持久性與延遲的核心旋鈕：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;code>appendfsync&lt;/code>&lt;/th>
 &lt;th>fsync 時機&lt;/th>
 &lt;th>崩潰最多丟&lt;/th>
 &lt;th>延遲影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>always&lt;/code>&lt;/td>
 &lt;td>每個寫命令&lt;/td>
 &lt;td>幾乎不丟&lt;/td>
 &lt;td>每次寫都等磁碟、延遲最高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>everysec&lt;/code>&lt;/td>
 &lt;td>每秒一次（背景）&lt;/td>
 &lt;td>最多 1 秒&lt;/td>
 &lt;td>多數場景的平衡點（預設）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>no&lt;/code>&lt;/td>
 &lt;td>交給 OS（~30 秒）&lt;/td>
 &lt;td>OS 決定、可能丟很多&lt;/td>
 &lt;td>延遲最低、持久性最弱&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>everysec&lt;/code> 是多數場景的預設選擇——背景執行緒每秒 fsync，主執行緒不等磁碟，崩潰最多丟 1 秒。但要注意：當磁碟 I/O 飽和，背景 fsync 跑超過 1 秒沒完成，主執行緒會被迫等待（避免 buffer 無限堆積），這時延遲尖峰跟 &lt;code>always&lt;/code> 一樣明顯。&lt;/p>
&lt;h3 id="混合持久化rdb-preamble--aof-tail">混合持久化：RDB preamble + AOF tail&lt;/h3>
&lt;p>Redis 4.0 後的 &lt;code>aof-use-rdb-preamble yes&lt;/code>（4.0+ 預設開）把兩者結合：AOF rewrite 時，先寫一段 RDB 格式的快照當前綴，後面接增量命令 log。還原時先快速載入 RDB preamble，再重放尾端的 log。這拿到了 RDB 的還原速度與 AOF 的細顆粒持久性，是目前的建議配置。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a> overview 的 implementation-layer deep article。持久化跟<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a>互相耦合（fork 的 copy-on-write 是 maxmemory headroom 的主要消耗者），兩篇建議一起讀。機制以 <a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">Redis persistence 官方文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="fork-那一瞬間">fork 那一瞬間</h2>
<p>Redis 是單執行緒處理命令的，這是它延遲可預測的根基——直到它需要把記憶體裡的資料寫到磁碟。RDB snapshot 跟 AOF rewrite 都不能在主執行緒上慢慢做（會凍結所有命令），於是 Redis 的解法是 <code>fork()</code>：複製出一個子進程，由子進程把當下的記憶體快照寫到磁碟，主進程繼續服務。</p>
<p>問題在 <code>fork()</code> 本身不是免費的。Linux 的 <code>fork()</code> 要複製父進程的分頁表（page table），記憶體越大、分頁表越大，這個複製動作越久——而它發生在主執行緒上，是阻塞的。一個 20GB 的 Redis 實例，fork 可能凍結主執行緒數百毫秒到一秒。在這段時間裡，所有命令排隊，p99 延遲從 1ms 跳到 500ms+。</p>
<p>更糟的是 fork 之後。<code>fork()</code> 用 copy-on-write：子進程跟父進程共享實體分頁，直到某一方寫入才複製。子進程只讀（在寫 snapshot），但父進程持續服務寫入，每改一個分頁就觸發一次複製。寫入越密集、snapshot 跑越久，被複製的分頁越多，最壞情況記憶體接近翻倍。這就是為什麼 Redis 的 maxmemory 必須留 headroom——不是給資料，是給 fork 期間的分頁複製。</p>
<p>理解持久化，本質是理解「fork 的延遲尖峰」與「資料持久性」之間的取捨。本文按這條線展開機制、配置與踩坑。</p>
<h2 id="核心概念aof-與-rdb-是兩種不同的持久性語意">核心概念：AOF 與 RDB 是兩種不同的持久性語意</h2>
<p>Redis 的兩種持久化不是「二選一的同類選項」，它們回答的是不同問題。</p>
<p><strong>RDB 是某個時間點的記憶體快照</strong>。它把整個 dataset 序列化成一個緊湊的二進位檔（<code>dump.rdb</code>）。優點是檔案小、還原快（直接載入記憶體）、fork 一次寫完。缺點是兩次 snapshot 之間的寫入會在崩潰時全部遺失——RDB 的持久性顆粒度是「上一次 save 到現在」，可能是幾分鐘的資料。</p>
<p><strong>AOF 是命令的 append-only log</strong>。每個改變資料的命令（<code>SET</code>、<code>LPUSH</code>&hellip;）被追加到 log 檔，還原時重放整個 log。優點是持久性顆粒度細（最多丟 <code>fsync</code> 策略決定的一小段）。缺點是 log 會無限增長，需要定期 rewrite 壓縮——而 rewrite 也要 fork。</p>
<p>兩者的 fork 觸發點不同但機制相同：RDB 是 <code>BGSAVE</code>（手動或 save 規則觸發）fork，AOF 是 <code>BGREWRITEAOF</code>（log 太大時觸發）fork。兩個若同時跑，記憶體壓力疊加。</p>
<h3 id="aof-的-fsync-策略決定丟多少資料">AOF 的 fsync 策略決定丟多少資料</h3>
<p>AOF 寫 log 分兩步：先 write 到 OS 的 page cache，再 fsync 刷到磁碟。<code>appendfsync</code> 控制 fsync 頻率，這是持久性與延遲的核心旋鈕：</p>
<table>
  <thead>
      <tr>
          <th><code>appendfsync</code></th>
          <th>fsync 時機</th>
          <th>崩潰最多丟</th>
          <th>延遲影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>always</code></td>
          <td>每個寫命令</td>
          <td>幾乎不丟</td>
          <td>每次寫都等磁碟、延遲最高</td>
      </tr>
      <tr>
          <td><code>everysec</code></td>
          <td>每秒一次（背景）</td>
          <td>最多 1 秒</td>
          <td>多數場景的平衡點（預設）</td>
      </tr>
      <tr>
          <td><code>no</code></td>
          <td>交給 OS（~30 秒）</td>
          <td>OS 決定、可能丟很多</td>
          <td>延遲最低、持久性最弱</td>
      </tr>
  </tbody>
</table>
<p><code>everysec</code> 是多數場景的預設選擇——背景執行緒每秒 fsync，主執行緒不等磁碟，崩潰最多丟 1 秒。但要注意：當磁碟 I/O 飽和，背景 fsync 跑超過 1 秒沒完成，主執行緒會被迫等待（避免 buffer 無限堆積），這時延遲尖峰跟 <code>always</code> 一樣明顯。</p>
<h3 id="混合持久化rdb-preamble--aof-tail">混合持久化：RDB preamble + AOF tail</h3>
<p>Redis 4.0 後的 <code>aof-use-rdb-preamble yes</code>（4.0+ 預設開）把兩者結合：AOF rewrite 時，先寫一段 RDB 格式的快照當前綴，後面接增量命令 log。還原時先快速載入 RDB preamble，再重放尾端的 log。這拿到了 RDB 的還原速度與 AOF 的細顆粒持久性，是目前的建議配置。</p>
<h2 id="配置持久化的設定路徑">配置：持久化的設定路徑</h2>





<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"># --- RDB snapshot 規則（多久 + 多少改動觸發 BGSAVE）---</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># redis.conf:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">#   save 900 1      # 900 秒內有 1 個 key 改動</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">#   save 300 100    # 300 秒內有 100 個改動</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">#   save 60 10000   # 60 秒內有 10000 個改動</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 純 cache 不需要 RDB 可關閉：</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">#   save &#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># --- AOF 設定 ---</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">redis-cli CONFIG SET appendonly yes
</span></span><span class="line"><span class="ln">11</span><span class="cl">redis-cli CONFIG SET appendfsync everysec
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># AOF rewrite 觸發條件：比上次 rewrite 大 100% 且至少 64MB</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">redis-cli CONFIG SET auto-aof-rewrite-percentage <span class="m">100</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">redis-cli CONFIG SET auto-aof-rewrite-min-size 64mb
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 混合持久化（4.0+ 預設）</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">redis-cli CONFIG SET aof-use-rdb-preamble yes</span></span></code></pre></div><p>降低 fork 衝擊的兩個系統層設定：</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"># 1. 關閉 Transparent Huge Pages（THP）——THP 會讓 copy-on-write 以 2MB 為單位複製、放大 fork 後的記憶體與延遲</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> never &gt; /sys/kernel/mm/transparent_hugepage/enabled
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 2. 允許 overcommit memory——fork 時 Linux 預設可能因 overcommit 檢查拒絕 fork、導致 BGSAVE 失敗</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># /etc/sysctl.conf:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">#   vm.overcommit_memory = 1</span></span></span></code></pre></div><p>這兩個是 Redis 官方明確建議的系統設定，沒設好會直接讓 fork 失敗或放大延遲尖峰。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1bgsave-那一刻-p99-延遲尖峰">Case 1：BGSAVE 那一刻 p99 延遲尖峰</h3>
<p><strong>徵兆</strong>：監控上每隔一段時間（對齊 save 規則）出現規律的延遲尖峰，p99 從 2ms 跳到 300-800ms，持續一兩秒後恢復。<code>INFO stats</code> 的 <code>latest_fork_usec</code> 顯示某次 fork 花了 700000 微秒（0.7 秒）。</p>
<p><strong>根因</strong>：大記憶體實例的 <code>fork()</code> 要複製分頁表，這個動作阻塞主執行緒。實例越大尖峰越明顯，THP 開著會更嚴重。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 THP 關閉（最常見的放大原因）</li>
<li>把 RDB save 規則放寬或關閉——純 cache 場景靠 AOF 或乾脆不持久化</li>
<li>大實例考慮分片，把單實例記憶體降下來，fork 成本隨之降低</li>
<li>在 replica 上做持久化（master 只服務、replica 負責 BGSAVE），把 fork 尖峰移出服務路徑</li>
</ol>
<h3 id="case-2fork-期間記憶體翻倍觸發-oom">Case 2：fork 期間記憶體翻倍觸發 OOM</h3>
<p><strong>徵兆</strong>：BGSAVE 開始後記憶體快速上升，<code>used_memory_rss</code> 在 snapshot 期間衝高，撞到機器 RAM 上限，Linux OOM killer 把 redis-server 進程 SIGKILL，無預警下線。</p>
<p><strong>根因</strong>：copy-on-write 在寫入密集期間複製大量分頁，maxmemory 沒留足夠 headroom。maxmemory 設成 RAM 的 90%+ 時，fork 期間的分頁複製把 RSS 推爆系統。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>maxmemory 設成 RAM 的 60-70%，留 30-40% 給 fork copy-on-write（寫入越密集留越多）</li>
<li>設 <code>vm.overcommit_memory = 1</code> 避免 fork 直接被拒</li>
<li>在低寫入時段（夜間）排程 BGSAVE，減少 fork 期間被複製的分頁</li>
<li>監控 <code>latest_fork_usec</code> 與 BGSAVE 期間的 RSS 峰值，跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a>的 headroom 計算合看</li>
</ol>
<h3 id="case-3aof-everysec-在磁碟飽和時退化成-always">Case 3：AOF everysec 在磁碟飽和時退化成 always</h3>
<p><strong>徵兆</strong>：平常延遲穩定，某段時間（通常伴隨大量寫入或磁碟被其他進程佔用）延遲全面上升，<code>INFO</code> 的 <code>aof_delayed_fsync</code> 計數持續增加。</p>
<p><strong>根因</strong>：<code>everysec</code> 的背景 fsync 應該每秒完成，但磁碟 I/O 飽和時 fsync 跑超過 1 秒。Redis 為了不讓 AOF buffer 無限堆積，會在主執行緒上阻塞等 fsync 完成——<code>everysec</code> 在這個情境下退化成接近 <code>always</code> 的延遲行為。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>用獨立的高 IOPS 磁碟給 AOF（不要跟 OS / log / 其他服務共用 I/O）</li>
<li>監控 <code>aof_delayed_fsync</code>，持續增加代表磁碟跟不上寫入</li>
<li>評估 <code>no-appendfsync-on-rewrite yes</code>——AOF rewrite 期間暫停 fsync，避免 rewrite 的 I/O 跟 fsync 互搶（代價是 rewrite 期間崩潰丟更多）</li>
<li>寫入吞吐超過單磁碟負荷是擴容訊號，不是調 fsync 能解</li>
</ol>
<h3 id="case-4aof-檔尾損壞讓-redis-起不來">Case 4：AOF 檔尾損壞讓 Redis 起不來</h3>
<p><strong>徵兆</strong>：Redis 崩潰後重啟失敗，log 顯示 <code>Bad file format reading the append only file</code>，服務無法載入 AOF。</p>
<p><strong>根因</strong>：崩潰發生在 AOF 寫到一半，最後一條命令只寫了部分 byte，AOF 檔尾不完整。Redis 預設 <code>aof-load-truncated yes</code> 應能容忍尾端截斷，但若損壞在中段（罕見的磁碟錯誤）或設了 <code>aof-load-truncated no</code>，載入直接失敗。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>確認 <code>aof-load-truncated yes</code>（預設），容忍尾端截斷自動修復</li>
<li>中段損壞用 <code>redis-check-aof --fix appendonly.aof</code> 修復（會截掉損壞點之後的內容、有資料遺失）</li>
<li>修復前先備份原 AOF 檔，不要直接覆蓋</li>
<li>混合持久化下還原優先用 RDB preamble，降低純 AOF replay 的損壞風險</li>
</ol>
<h3 id="case-5以為有持久化其實-bgsave-一直在失敗">Case 5：以為有持久化、其實 BGSAVE 一直在失敗</h3>
<p><strong>徵兆</strong>：某次需要從 RDB 還原時發現 <code>dump.rdb</code> 是好幾天前的，期間的資料全沒了。回查 log 發現 BGSAVE 一直報 <code>Can't save in background: fork: Cannot allocate memory</code>。</p>
<p><strong>根因</strong>：<code>vm.overcommit_memory</code> 是預設的 0，Linux 在 fork 時做嚴格的記憶體檢查——當 Redis 已用掉大半 RAM，fork 估算可能需要翻倍記憶體而被拒。BGSAVE 靜默失敗，RDB 停留在最後一次成功的版本，但沒人在看 log。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>設 <code>vm.overcommit_memory = 1</code>，讓 fork 在記憶體吃緊時仍能成功（靠 copy-on-write 實際不會真的翻倍）</li>
<li>監控 <code>rdb_last_bgsave_status</code> 與 <code>aof_last_bgrewrite_status</code>，<code>err</code> 要立刻告警</li>
<li>監控 <code>rdb_last_save_time</code>，距今太久代表持久化已停擺</li>
<li>持久化的存在不等於可用——定期演練從備份還原，驗證 RDB / AOF 真的能載入</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>持久化的容量判讀，圍繞 fork 成本與磁碟負荷：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>latest_fork_usec</code></td>
          <td>&lt; 100ms（小實例）</td>
          <td>&gt; 數百 ms → 實例太大、考慮分片或 replica 持久化</td>
      </tr>
      <tr>
          <td>fork 期間 RSS 峰值</td>
          <td>&lt; 機器 RAM</td>
          <td>接近 RAM → maxmemory headroom 不足</td>
      </tr>
      <tr>
          <td><code>aof_delayed_fsync</code></td>
          <td>接近 0</td>
          <td>持續增加 → 磁碟 I/O 跟不上、換高 IOPS 磁碟</td>
      </tr>
      <tr>
          <td><code>rdb_last_bgsave_status</code></td>
          <td><code>ok</code></td>
          <td><code>err</code> → fork 失敗、查 overcommit / 記憶體</td>
      </tr>
      <tr>
          <td>AOF 檔大小 / dataset</td>
          <td>rewrite 後接近 dataset 大小</td>
          <td>遠大於 dataset → rewrite 沒觸發、檢查閾值</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>fork 尖峰無法接受、實例又必須大</strong>：把持久化移到 replica（master 純服務），或走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster 分片</a>降低單實例記憶體。</li>
<li><strong>大記憶體下 fork 成本是結構性瓶頸</strong>：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> 用 fork-less snapshot 機制，大記憶體場景的快照不付 fork 的延遲與記憶體翻倍代價——若 fork 尖峰是主要痛點，這是值得評估的架構替代。</li>
<li><strong>需要真正的 source-of-truth 持久性（不是盡力而為）</strong>：Redis 持久化本質是 cache 的回填保險，不是交易級持久性。要強持久性走 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">MemoryDB</a>（multi-AZ transaction log）或 <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">database 模組</a>。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>持久化決策的起點其實是一個選型問題：這份資料是 cache 還是 source-of-truth。</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體調校</a></strong>：fork 的 copy-on-write 是 maxmemory headroom 的主要消耗者，兩者必須一起算。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/sentinel-ha-failover/" data-link-title="Redis Sentinel 與 failover 時序：從 master 死掉到 client 重連的每一段" data-link-desc="Redis Sentinel 的 failover 不是一個瞬間動作，是 down 偵測 → quorum 確認 → 選主 → 提升 → 配置廣播 → client 重連的一條時序鏈，每一段都有自己的延遲與失敗模式。本文展開 Sentinel 的判定模型與這條時序、5 個讓 failover 卡住或丟資料的 production 踩坑，以及 Sentinel 撐不住該往 Cluster 或 managed 走的邊界">replication / failover</a></strong>：replica 是承接持久化負擔的地方，也是 fork 尖峰的替代執行點。</li>
<li><strong>跟 <a href="/blog/backend/09-performance-capacity/cases/tubi-elasticache-ml-feature-store/" data-link-title="9.C25 Tubi：從 ScyllaDB 遷到 ElastiCache、ML feature store 達 sub-10ms p99" data-link-desc="Tubi 把 ML 推薦的 feature store 從 ScyllaDB 遷到 ElastiCache for Redis、99 百分位延遲降到 10ms 以下">Tubi 的 cache vs durable 選型</a></strong>：Tubi 把 ML feature store 從 ScyllaDB（durable）遷到 ElastiCache，判斷是「feature 可重新計算」——這正是「不需要持久化」的判斷，持久化配置應隨之簡化甚至關閉。反過來，若資料不可重建，問題在選錯儲存層，不在持久化調校。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">cache copy boundary</a></strong>：服務若把 Redis 當主要 serving layer，持久化決定了重啟後是冷啟動回源雪崩還是溫啟動，跟 stampede 防護直接相關。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/redis/" data-link-title="Redis" data-link-desc="OSS in-memory data structure store、cache 主流">Redis</a></li>
<li>同 vendor deep article：<a href="/blog/backend/02-cache-redis/vendors/redis/memory-eviction-tuning/" data-link-title="Redis 記憶體與淘汰調校：maxmemory-policy、LFU 與碎片化的實戰判讀" data-link-desc="Redis 的記憶體是一條會在半夜爆掉的曲線：maxmemory 設多少、policy 選 LRU 還 LFU、碎片化什麼時候開始吃掉 30% RAM、OOM 時 noeviction 怎麼讓寫入全部失敗。本文展開 Redis 記憶體會計模型、eviction policy 的選型判讀、5 個把記憶體配置寫成 production 事故的踩坑，以及單機記憶體撞牆後該往 cluster 還是 DragonflyDB 走的邊界">記憶體與淘汰調校</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Cluster re-sharding</a></li>
<li>上游概念：<a href="/blog/backend/02-cache-redis/cache-copy-freshness-boundary/" data-link-title="2.7 Cache Copy Boundary 與 Freshness" data-link-desc="說明快取何時只是可重建副本，何時會影響交易、權限或配額正確性。">2.7 cache copy boundary</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item></channel></rss>