<?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>Backup on Tarragon</title><link>https://tarrragon.github.io/blog/tags/backup/</link><description>Recent content in Backup on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/backup/index.xml" rel="self" type="application/rss+xml"/><item><title>JSONL 匯出與備份格式</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/jsonl-storage/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/jsonl-storage/</guid><description>&lt;p>Collector 的 day-one 主要儲存是 SQLite（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>）。JSONL（JSON Lines）保留作為匯出和備份格式 — 人類可讀、grep 友好、SQLite 資料庫損壞時可以從 JSONL 重建。Collector 提供 &lt;code>monitor export --format=jsonl&lt;/code> 指令匯出事件，也可以設定同步寫入 JSONL 作為即時備份。&lt;/p>
&lt;p>JSONL 的格式是每行一個 JSON 物件。作為匯出格式，核心優勢是工具鏈成熟 — &lt;code>grep&lt;/code> 過濾、&lt;code>jq&lt;/code> 結構化查詢、&lt;code>tail -f&lt;/code> 即時監控，不需要 database client。&lt;/p>
&lt;h2 id="一天一檔">一天一檔&lt;/h2>
&lt;p>事件按日期分檔：&lt;code>events-2026-06-19.jsonl&lt;/code>、&lt;code>events-2026-06-20.jsonl&lt;/code>。每天零點（或 UTC 日期變更時）切換到新檔案。&lt;/p>
&lt;p>一天一檔的好處：&lt;/p>
&lt;p>&lt;strong>時間範圍查詢直接對應到檔案&lt;/strong>。查「昨天的 error」只需要讀一個檔案，不需要掃描整個資料集。&lt;/p>
&lt;p>&lt;strong>保留策略按檔案操作&lt;/strong>。保留 30 天的資料 = 刪除 30 天前的檔案。不需要 database 的 TTL 機制或 partition pruning。&lt;/p>
&lt;p>&lt;strong>備份和搬移按檔案操作&lt;/strong>。rsync 一個目錄就完成備份；搬移特定日期的資料 = 搬移對應檔案。&lt;/p>
&lt;p>一天一檔的風險是單日資料量過大時，單一檔案的 grep 查詢會變慢。自用工具場景下，單日事件量通常在數千到數萬筆，檔案大小在 MB 級，grep 查詢在秒級完成。當單日事件量超過百萬筆時，需要考慮演進到更適合的儲存方案（見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>）。&lt;/p>
&lt;h2 id="append-only-寫入">Append-only 寫入&lt;/h2>
&lt;p>JSONL 的寫入模式是 append-only — 新事件追加到檔案尾端，已寫入的事件不修改。&lt;/p>
&lt;p>Append-only 的操作特性：&lt;/p>
&lt;p>&lt;strong>寫入不需要鎖&lt;/strong>。&lt;code>os.OpenFile&lt;/code> 用 &lt;code>O_APPEND&lt;/code> flag 開啟，OS 保證每次 write 是 atomic 的（在 write size 不超過 &lt;code>PIPE_BUF&lt;/code> 的前提下，Linux 上是 4096 bytes）。單一事件的 JSON 通常在這個限制內。&lt;/p>
&lt;p>&lt;strong>不會損壞既有資料&lt;/strong>。寫入失敗（磁碟滿、程序崩潰）最多造成最後一行不完整，不影響前面的行。恢復時刪除最後一行的不完整片段即可。&lt;/p>
&lt;p>&lt;strong>支援 tail -f 即時監控&lt;/strong>。&lt;code>tail -f events-2026-06-19.jsonl | jq .&lt;/code> 即時顯示新寫入的事件，不需要額外的 streaming 機制。&lt;/p>
&lt;h2 id="gzip-壓縮">Gzip 壓縮&lt;/h2>
&lt;p>歷史檔案（非當天的）用 gzip 壓縮。JSON 文字的壓縮率通常在 80-90%（10MB 壓縮到 1-2MB）。&lt;/p>
&lt;p>壓縮策略：&lt;/p>
&lt;p>&lt;strong>當天的檔案不壓縮&lt;/strong>。保持 append-only 和 tail -f 的能力。&lt;/p>
&lt;p>&lt;strong>日期切換時壓縮前一天的檔案&lt;/strong>。用 cron job 或 collector 啟動時檢查，把 &lt;code>events-2026-06-18.jsonl&lt;/code> 壓縮為 &lt;code>events-2026-06-18.jsonl.gz&lt;/code>。&lt;/p>
&lt;p>&lt;strong>查詢壓縮檔用 zgrep / zcat&lt;/strong>。&lt;code>zgrep &amp;quot;error&amp;quot; events-2026-06-18.jsonl.gz&lt;/code> 不需要先解壓。&lt;/p>
&lt;h2 id="jsonl-備份的保留">JSONL 備份的保留&lt;/h2>
&lt;p>JSONL 備份檔的保留策略和 SQLite 主要儲存的分層保留獨立 — JSONL 是最後的重建來源，保留期限可以比 SQLite 中的原始事件更長。&lt;/p>
&lt;p>典型配置：JSONL 備份保留 30 天（即使 SQLite 中的原始事件只保留 7 天），提供 SQLite 損壞時的 30 天重建窗口。超過 30 天的 JSONL 壓縮檔用 cron job 清理：&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">find /var/lib/collector/events/ -name &lt;span class="s2">&amp;#34;events-*.jsonl.gz&amp;#34;&lt;/span> -mtime +30 -delete&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主要儲存的查詢驅動分層保留策略見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Collector 的 day-one 主要儲存是 SQLite（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。JSONL（JSON Lines）保留作為匯出和備份格式 — 人類可讀、grep 友好、SQLite 資料庫損壞時可以從 JSONL 重建。Collector 提供 <code>monitor export --format=jsonl</code> 指令匯出事件，也可以設定同步寫入 JSONL 作為即時備份。</p>
<p>JSONL 的格式是每行一個 JSON 物件。作為匯出格式，核心優勢是工具鏈成熟 — <code>grep</code> 過濾、<code>jq</code> 結構化查詢、<code>tail -f</code> 即時監控，不需要 database client。</p>
<h2 id="一天一檔">一天一檔</h2>
<p>事件按日期分檔：<code>events-2026-06-19.jsonl</code>、<code>events-2026-06-20.jsonl</code>。每天零點（或 UTC 日期變更時）切換到新檔案。</p>
<p>一天一檔的好處：</p>
<p><strong>時間範圍查詢直接對應到檔案</strong>。查「昨天的 error」只需要讀一個檔案，不需要掃描整個資料集。</p>
<p><strong>保留策略按檔案操作</strong>。保留 30 天的資料 = 刪除 30 天前的檔案。不需要 database 的 TTL 機制或 partition pruning。</p>
<p><strong>備份和搬移按檔案操作</strong>。rsync 一個目錄就完成備份；搬移特定日期的資料 = 搬移對應檔案。</p>
<p>一天一檔的風險是單日資料量過大時，單一檔案的 grep 查詢會變慢。自用工具場景下，單日事件量通常在數千到數萬筆，檔案大小在 MB 級，grep 查詢在秒級完成。當單日事件量超過百萬筆時，需要考慮演進到更適合的儲存方案（見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>）。</p>
<h2 id="append-only-寫入">Append-only 寫入</h2>
<p>JSONL 的寫入模式是 append-only — 新事件追加到檔案尾端，已寫入的事件不修改。</p>
<p>Append-only 的操作特性：</p>
<p><strong>寫入不需要鎖</strong>。<code>os.OpenFile</code> 用 <code>O_APPEND</code> flag 開啟，OS 保證每次 write 是 atomic 的（在 write size 不超過 <code>PIPE_BUF</code> 的前提下，Linux 上是 4096 bytes）。單一事件的 JSON 通常在這個限制內。</p>
<p><strong>不會損壞既有資料</strong>。寫入失敗（磁碟滿、程序崩潰）最多造成最後一行不完整，不影響前面的行。恢復時刪除最後一行的不完整片段即可。</p>
<p><strong>支援 tail -f 即時監控</strong>。<code>tail -f events-2026-06-19.jsonl | jq .</code> 即時顯示新寫入的事件，不需要額外的 streaming 機制。</p>
<h2 id="gzip-壓縮">Gzip 壓縮</h2>
<p>歷史檔案（非當天的）用 gzip 壓縮。JSON 文字的壓縮率通常在 80-90%（10MB 壓縮到 1-2MB）。</p>
<p>壓縮策略：</p>
<p><strong>當天的檔案不壓縮</strong>。保持 append-only 和 tail -f 的能力。</p>
<p><strong>日期切換時壓縮前一天的檔案</strong>。用 cron job 或 collector 啟動時檢查，把 <code>events-2026-06-18.jsonl</code> 壓縮為 <code>events-2026-06-18.jsonl.gz</code>。</p>
<p><strong>查詢壓縮檔用 zgrep / zcat</strong>。<code>zgrep &quot;error&quot; events-2026-06-18.jsonl.gz</code> 不需要先解壓。</p>
<h2 id="jsonl-備份的保留">JSONL 備份的保留</h2>
<p>JSONL 備份檔的保留策略和 SQLite 主要儲存的分層保留獨立 — JSONL 是最後的重建來源，保留期限可以比 SQLite 中的原始事件更長。</p>
<p>典型配置：JSONL 備份保留 30 天（即使 SQLite 中的原始事件只保留 7 天），提供 SQLite 損壞時的 30 天重建窗口。超過 30 天的 JSONL 壓縮檔用 cron job 清理：</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">find /var/lib/collector/events/ -name <span class="s2">&#34;events-*.jsonl.gz&#34;</span> -mtime +30 -delete</span></span></code></pre></div><p>主要儲存的查詢驅動分層保留策略見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>。</p>
<h2 id="匯出的實作注意">匯出的實作注意</h2>
<p>匯出使用 streaming — 從 storage 逐筆讀取、逐行寫出，記憶體使用和事件總量無關。300 萬筆事件（約 900MB JSONL）不需要整批載入記憶體。</p>
<p>匯出的 JSONL 檔案包含事件明文（已 redaction 的欄位除外）。匯出後的檔案不受 collector 的存取控制保護，注意存放位置和存取權限。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 的完整架構 → <a href="/blog/monitoring/04-collector/architecture/" data-link-title="Collector 架構" data-link-desc="HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine 的五段式處理鏈路">Collector 架構</a></li>
<li>查詢設計 → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>儲存撐不住時的演進 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
</ul>
]]></content:encoded></item><item><title>無 SSH 環境的資料庫備份與變更管理</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/</guid><description>&lt;p>程式碼可以從 Git repo 重新上傳，資料庫裡的資料一旦遺失或損壞就回不來。在無 SSH 的環境裡，資料庫的備份與變更管理比程式碼更需要紀律，因為可用的工具受限（通常只有 phpMyAdmin）、沒有 point-in-time recovery（PITR）、也沒有自動化快照。本篇從工具限制出發，建立一套在這些約束條件下仍能可靠運作的備份與變更流程。&lt;/p>
&lt;p>本篇是&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管&lt;/a>的延伸，聚焦在資料庫層面。程式碼與部署紀律見主文。&lt;/p>
&lt;h2 id="phpmyadmin-的限制與對策">phpMyAdmin 的限制與對策&lt;/h2>
&lt;p>phpMyAdmin 是多數無 SSH 環境預裝的資料庫管理介面，匯出功能涵蓋完整 SQL dump，但它跑在 PHP 執行環境裡，受限於 &lt;code>max_execution_time&lt;/code> 和記憶體上限。資料庫超過 50MB 時，匯出經常在執行到一半就因 timeout 中斷，產出不完整的 SQL 檔案——而不完整的 dump 在還原時只會匯入前半段的表、後面的表靜靜消失。&lt;/p>
&lt;h3 id="大資料庫的匯出對策">大資料庫的匯出對策&lt;/h3>
&lt;p>第一個選項是分表匯出。phpMyAdmin 的匯出頁面允許選擇要匯出的資料表，把一次完整匯出拆成 3-5 批，每批在 timeout 之前完成。缺點是匯出不是原子操作——不同批次之間如果有寫入，表之間的參照關係可能不一致（例如訂單表引用的商品 ID 在商品表的那一批裡還沒匯出）。對多數讀取為主的站台，這個不一致窗口可接受；對交易密集的站台，需要在低流量時段操作。&lt;/p>
&lt;p>第二個選項是調整 phpMyAdmin 的 timeout。部分主機允許在 phpMyAdmin 的設定目錄放自訂的 &lt;code>config.inc.php&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">$cfg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;ExecTimeLimit&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">600&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 從預設 300 秒增加到 600 秒
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>cPanel 主機通常在「軟體」區塊的 phpMyAdmin 設定裡有對應的 UI 選項。Plesk 的路徑是「資料庫」→「phpMyAdmin 設定」。能不能改取決於主機商的權限政策，改之前先確認。&lt;/p>
&lt;p>第三個選項是繞過 phpMyAdmin。如果主機允許遠端 MySQL 連線（在 cPanel 的「遠端 MySQL」頁面加白名單 IP），就能用桌面工具直連資料庫匯出：&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>DBeaver&lt;/td>
 &lt;td>跨平台&lt;/td>
 &lt;td>免費&lt;/td>
 &lt;td>右鍵資料庫 → 匯出 → SQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TablePlus&lt;/td>
 &lt;td>macOS / Windows&lt;/td>
 &lt;td>付費&lt;/td>
 &lt;td>Cmd+Shift+E 匯出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HeidiSQL&lt;/td>
 &lt;td>Windows&lt;/td>
 &lt;td>免費&lt;/td>
 &lt;td>工具 → 匯出資料庫為 SQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mysqldump&lt;/td>
 &lt;td>CLI（需本機安裝）&lt;/td>
 &lt;td>免費&lt;/td>
 &lt;td>見下方指令&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>桌面工具直連 MySQL 比 phpMyAdmin 穩定，因為匯出跑在本機、不受主機的 PHP timeout 限制。mysqldump 是最可靠的選項：&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">mysqldump -h db-host.example.com -u dbuser -p &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> --single-transaction --routines --triggers &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> dbname &amp;gt; backup_&lt;span class="k">$(&lt;/span>date +%Y%m%d_%H%M&lt;span class="k">)&lt;/span>.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--single-transaction&lt;/code> 對 InnoDB 表做一致性快照，不需要鎖表。&lt;code>--routines&lt;/code> 和 &lt;code>--triggers&lt;/code> 確保 stored procedure 和觸發器也被包含在 dump 裡——phpMyAdmin 匯出預設也包含，但容易在手動選項時漏勾。&lt;/p>
&lt;h3 id="匯出後的驗證">匯出後的驗證&lt;/h3>
&lt;p>匯出完成後檢查 SQL 檔案的結尾。完整的 mysqldump 結尾會有 &lt;code>-- Dump completed on YYYY-MM-DD HH:MM:SS&lt;/code>。phpMyAdmin 匯出的結尾會有 &lt;code>-- phpMyAdmin SQL Dump&lt;/code> 的對應結尾標記。如果檔案在某個 &lt;code>INSERT INTO&lt;/code> 語句中間斷掉，這份 dump 就是不完整的，還原時會靜靜丟失後面的資料。&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">tail -5 backup_20260626_1430.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># 預期看到 &amp;#34;Dump completed&amp;#34; 或完整的結尾註解&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="備份策略頻率與保留">備份策略：頻率與保留&lt;/h2>
&lt;p>備份頻率由資料的變更速率決定。一個每天只有幾筆訂單的小型電商，每週備份加上每次變更前備份就夠用。一個每天有數百筆交易的服務，需要每日備份。判斷依據是：如果最新的備份丟了、要用上一份還原，能接受丟失多少資料？這個時間差就是實際的 RPO（Recovery Point Objective）。&lt;/p>
&lt;h3 id="保留策略">保留策略&lt;/h3>
&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>每日&lt;/td>
 &lt;td>每天&lt;/td>
 &lt;td>7 份&lt;/td>
 &lt;td>近期資料遺失的還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>每週&lt;/td>
 &lt;td>每週一&lt;/td>
 &lt;td>4 份&lt;/td>
 &lt;td>一到四週前的回溯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>變更前&lt;/td>
 &lt;td>每次&lt;/td>
 &lt;td>長期保留&lt;/td>
 &lt;td>schema 變更的回退保險點&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>命名用時間戳避免覆蓋：&lt;code>dbname_20260626_1430.sql.gz&lt;/code>。壓縮用 gzip（&lt;code>gzip backup.sql&lt;/code>），50MB 的 SQL dump 通常壓到 5-10MB。&lt;/p></description><content:encoded><![CDATA[<p>程式碼可以從 Git repo 重新上傳，資料庫裡的資料一旦遺失或損壞就回不來。在無 SSH 的環境裡，資料庫的備份與變更管理比程式碼更需要紀律，因為可用的工具受限（通常只有 phpMyAdmin）、沒有 point-in-time recovery（PITR）、也沒有自動化快照。本篇從工具限制出發，建立一套在這些約束條件下仍能可靠運作的備份與變更流程。</p>
<p>本篇是<a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>的延伸，聚焦在資料庫層面。程式碼與部署紀律見主文。</p>
<h2 id="phpmyadmin-的限制與對策">phpMyAdmin 的限制與對策</h2>
<p>phpMyAdmin 是多數無 SSH 環境預裝的資料庫管理介面，匯出功能涵蓋完整 SQL dump，但它跑在 PHP 執行環境裡，受限於 <code>max_execution_time</code> 和記憶體上限。資料庫超過 50MB 時，匯出經常在執行到一半就因 timeout 中斷，產出不完整的 SQL 檔案——而不完整的 dump 在還原時只會匯入前半段的表、後面的表靜靜消失。</p>
<h3 id="大資料庫的匯出對策">大資料庫的匯出對策</h3>
<p>第一個選項是分表匯出。phpMyAdmin 的匯出頁面允許選擇要匯出的資料表，把一次完整匯出拆成 3-5 批，每批在 timeout 之前完成。缺點是匯出不是原子操作——不同批次之間如果有寫入，表之間的參照關係可能不一致（例如訂單表引用的商品 ID 在商品表的那一批裡還沒匯出）。對多數讀取為主的站台，這個不一致窗口可接受；對交易密集的站台，需要在低流量時段操作。</p>
<p>第二個選項是調整 phpMyAdmin 的 timeout。部分主機允許在 phpMyAdmin 的設定目錄放自訂的 <code>config.inc.php</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">$cfg</span><span class="p">[</span><span class="s1">&#39;ExecTimeLimit&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="mi">600</span><span class="p">;</span> <span class="c1">// 從預設 300 秒增加到 600 秒
</span></span></span></code></pre></div><p>cPanel 主機通常在「軟體」區塊的 phpMyAdmin 設定裡有對應的 UI 選項。Plesk 的路徑是「資料庫」→「phpMyAdmin 設定」。能不能改取決於主機商的權限政策，改之前先確認。</p>
<p>第三個選項是繞過 phpMyAdmin。如果主機允許遠端 MySQL 連線（在 cPanel 的「遠端 MySQL」頁面加白名單 IP），就能用桌面工具直連資料庫匯出：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>平台</th>
          <th>費用</th>
          <th>匯出方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DBeaver</td>
          <td>跨平台</td>
          <td>免費</td>
          <td>右鍵資料庫 → 匯出 → SQL</td>
      </tr>
      <tr>
          <td>TablePlus</td>
          <td>macOS / Windows</td>
          <td>付費</td>
          <td>Cmd+Shift+E 匯出</td>
      </tr>
      <tr>
          <td>HeidiSQL</td>
          <td>Windows</td>
          <td>免費</td>
          <td>工具 → 匯出資料庫為 SQL</td>
      </tr>
      <tr>
          <td>mysqldump</td>
          <td>CLI（需本機安裝）</td>
          <td>免費</td>
          <td>見下方指令</td>
      </tr>
  </tbody>
</table>
<p>桌面工具直連 MySQL 比 phpMyAdmin 穩定，因為匯出跑在本機、不受主機的 PHP timeout 限制。mysqldump 是最可靠的選項：</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">mysqldump -h db-host.example.com -u dbuser -p <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --single-transaction --routines --triggers <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  dbname &gt; backup_<span class="k">$(</span>date +%Y%m%d_%H%M<span class="k">)</span>.sql</span></span></code></pre></div><p><code>--single-transaction</code> 對 InnoDB 表做一致性快照，不需要鎖表。<code>--routines</code> 和 <code>--triggers</code> 確保 stored procedure 和觸發器也被包含在 dump 裡——phpMyAdmin 匯出預設也包含，但容易在手動選項時漏勾。</p>
<h3 id="匯出後的驗證">匯出後的驗證</h3>
<p>匯出完成後檢查 SQL 檔案的結尾。完整的 mysqldump 結尾會有 <code>-- Dump completed on YYYY-MM-DD HH:MM:SS</code>。phpMyAdmin 匯出的結尾會有 <code>-- phpMyAdmin SQL Dump</code> 的對應結尾標記。如果檔案在某個 <code>INSERT INTO</code> 語句中間斷掉，這份 dump 就是不完整的，還原時會靜靜丟失後面的資料。</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">tail -5 backup_20260626_1430.sql
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 預期看到 &#34;Dump completed&#34; 或完整的結尾註解</span></span></span></code></pre></div><h2 id="備份策略頻率與保留">備份策略：頻率與保留</h2>
<p>備份頻率由資料的變更速率決定。一個每天只有幾筆訂單的小型電商，每週備份加上每次變更前備份就夠用。一個每天有數百筆交易的服務，需要每日備份。判斷依據是：如果最新的備份丟了、要用上一份還原，能接受丟失多少資料？這個時間差就是實際的 RPO（Recovery Point Objective）。</p>
<h3 id="保留策略">保留策略</h3>
<table>
  <thead>
      <tr>
          <th>備份類型</th>
          <th>頻率</th>
          <th>保留數量</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每日</td>
          <td>每天</td>
          <td>7 份</td>
          <td>近期資料遺失的還原</td>
      </tr>
      <tr>
          <td>每週</td>
          <td>每週一</td>
          <td>4 份</td>
          <td>一到四週前的回溯</td>
      </tr>
      <tr>
          <td>變更前</td>
          <td>每次</td>
          <td>長期保留</td>
          <td>schema 變更的回退保險點</td>
      </tr>
  </tbody>
</table>
<p>命名用時間戳避免覆蓋：<code>dbname_20260626_1430.sql.gz</code>。壓縮用 gzip（<code>gzip backup.sql</code>），50MB 的 SQL dump 通常壓到 5-10MB。</p>
<h3 id="儲存位置">儲存位置</h3>
<p>本機是第一份副本，但本機磁碟故障時備份也跟著消失。至少再推一份到雲端儲存：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># rclone 同步到 Google Drive（事先用 rclone config 設定 remote）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rclone copy /local/backups/db/ gdrive:project-backups/db/ --max-age 7d
</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"># 或推到 S3</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws s3 sync /local/backups/db/ s3://my-project-backups/db/ --storage-class STANDARD_IA</span></span></code></pre></div><h3 id="備份驗證">備份驗證</h3>
<p>備份存在不等於備份可用。每月至少做一次驗證：把最新的 dump 匯入本地 MySQL，檢查關鍵表的 row count 跟 prod 一致、應用程式能正常啟動。如果匯入報錯或 row count 差異超過預期，備份流程有問題要立刻排查。</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">mysql -u root -p local_testdb &lt; backup_20260626_1430.sql
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;SELECT COUNT(*) FROM orders;&#34;</span> local_testdb</span></span></code></pre></div><h2 id="自動化備份無-ssh-環境的限制下">自動化備份（無 SSH 環境的限制下）</h2>
<p>無 SSH 環境的自動化受限程度取決於主機提供的能力。三個層級由好到差：</p>
<p><strong>主機有 cron + mysqldump 路徑</strong>：部分主機在 cPanel 的「cron 工作」裡允許設定排程指令。mysqldump 通常安裝在 <code>/usr/bin/mysqldump</code>，可以直接用：</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"># cPanel cron job（每天凌晨 3 點）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">0</span> <span class="m">3</span> * * * /usr/bin/mysqldump -u dbuser -p<span class="s1">&#39;password&#39;</span> dbname <span class="p">|</span> gzip &gt; /home/user/backups/db_<span class="k">$(</span>date +<span class="se">\%</span>Y<span class="se">\%</span>m<span class="se">\%</span>d<span class="k">)</span>.sql.gz</span></span></code></pre></div><p>密碼寫在 cron 指令裡不理想但在無 SSH 環境選擇有限。用 <code>.my.cnf</code> 檔案存密碼（<code>chmod 600</code>）較安全，但不是所有主機都支援。</p>
<p><strong>主機有遠端 MySQL 但沒 cron</strong>：用本機排程（macOS launchd / Windows Task Scheduler / Linux cron）跑 mysqldump 遠端連線：</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="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># local-backup.sh — 本機排程每天跑</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">BACKUP_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/backups/myproject/db&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">mysqldump -h db-host.example.com -u dbuser -p<span class="s1">&#39;password&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --single-transaction dbname <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  <span class="p">|</span> gzip &gt; <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">/db_</span><span class="k">$(</span>date +%Y%m%d_%H%M<span class="k">)</span><span class="s2">.sql.gz&#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"># 推到雲端</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">rclone copy <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span> gdrive:project-backups/db/ --max-age 7d
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 清理超過 30 天的本地備份</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">find <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span> -name <span class="s2">&#34;*.sql.gz&#34;</span> -mtime +30 -delete</span></span></code></pre></div><p><strong>沒有 cron 也沒有遠端 MySQL</strong>：只能靠手動的 phpMyAdmin 匯出，加上 cPanel 的「備份精靈」（如果主機方案包含）。cPanel 備份精靈可以設定每日或每週的完整備份（含資料庫 + 檔案），但免費方案通常不支援排程。這是最受限的情境——如果連手動匯出都嫌麻煩，最高優先的升級路徑是開通遠端 MySQL 存取。</p>
<h2 id="資料庫變更的-migration-紀律">資料庫變更的 migration 紀律</h2>
<p>Schema 變更（加欄位、改索引、拆表）在沒有 migration 工具的 legacy PHP 專案裡，全靠手動在 phpMyAdmin 執行 SQL。migration 紀律的目標是讓每一次 schema 變更有紀錄、可重播、可回退。</p>
<h3 id="migration-檔案格式">Migration 檔案格式</h3>
<p>每次 schema 變更寫成一個獨立的 SQL 檔案，存在 repo 的 <code>migrations/</code> 目錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- migrations/2026-06-26-001-add-users-email-verified.sql
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 目的：新增 email 驗證欄位，支援 email 驗證流程
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">-- 回退：ALTER TABLE users DROP COLUMN email_verified;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- UP
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">email_verified</span><span class="w"> </span><span class="n">TINYINT</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">AFTER</span><span class="w"> </span><span class="n">email</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_users_email_verified</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="p">(</span><span class="n">email_verified</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- DOWN（回退用，不自動執行）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- DROP INDEX idx_users_email_verified ON users;
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- ALTER TABLE users DROP COLUMN email_verified;</span></span></span></code></pre></div><p>檔名的結構是 <code>日期-序號-描述</code>，序號處理同一天多次變更的排序。UP 段是要執行的 SQL，DOWN 段是回退 SQL（註解掉，手動需要時才用）。</p>
<h3 id="追蹤哪些-migration-已執行">追蹤哪些 migration 已執行</h3>
<p>在資料庫建一張追蹤表：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="n">migrations_log</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">    </span><span class="n">id</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="n">AUTO_INCREMENT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="n">filename</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="n">applied_at</span><span class="w"> </span><span class="n">DATETIME</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">CURRENT_TIMESTAMP</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">    </span><span class="n">applied_by</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>每次在 prod 執行完一個 migration，手動插入一筆紀錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">migrations_log</span><span class="w"> </span><span class="p">(</span><span class="n">filename</span><span class="p">,</span><span class="w"> </span><span class="n">applied_by</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;2026-06-26-001-add-users-email-verified.sql&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;alice&#39;</span><span class="p">);</span></span></span></code></pre></div><p>查哪些 migration 還沒跑：比對 <code>migrations/</code> 目錄的檔案清單跟 <code>migrations_log</code> 表的 filename 欄。這不是自動化的 migration runner（像 Laravel 的 artisan migrate），但在沒有框架支援的 legacy 專案裡，一張表加一個目錄就能達到可追蹤的最低標準。</p>
<h3 id="執行流程">執行流程</h3>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>動作</th>
          <th>失敗時</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>在本地 DB 執行 migration、確認語法正確</td>
          <td>修正 SQL 再試</td>
      </tr>
      <tr>
          <td>2</td>
          <td>備份 prod DB（完整 dump 或受影響的表）</td>
          <td>如果備份失敗、不繼續</td>
      </tr>
      <tr>
          <td>3</td>
          <td>在 prod 的 phpMyAdmin 執行 UP 段</td>
          <td>用 DOWN 段回退、還原備份</td>
      </tr>
      <tr>
          <td>4</td>
          <td>驗證：檢查表結構、跑應用程式確認正常</td>
          <td>用 DOWN 段回退、還原備份</td>
      </tr>
      <tr>
          <td>5</td>
          <td>插入 migrations_log 紀錄</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>高風險的 migration（改大表結構、刪欄位、改資料類型）在步驟 2 要做完整的資料庫 dump 而非只備份受影響的表，因為外鍵和觸發器可能讓影響範圍超出目標表。</p>
<h2 id="還原演練">還原演練</h2>
<p>備份的價值在還原成功的那一刻才被驗證。沒有演練過的備份等同於不存在——匯出可能不完整、SQL 版本可能不相容、匯入順序可能因為外鍵而失敗。</p>
<h3 id="演練流程">演練流程</h3>
<p>在本地用最新的備份還原一次完整的資料庫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 建一個測試用的空資料庫</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;CREATE DATABASE restore_test;&#34;</span>
</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"># 匯入備份</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">mysql -u root -p restore_test &lt; backup_20260626_1430.sql
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 驗證</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;SHOW TABLES;&#34;</span> restore_test
</span></span><span class="line"><span class="ln">9</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;SELECT COUNT(*) FROM orders;&#34;</span> restore_test</span></span></code></pre></div><p>驗證三件事：表結構完整（<code>SHOW TABLES</code> 的表數量跟 prod 一致）、資料完整（關鍵表的 row count 一致）、應用程式能跑（把本地應用指向 restore_test 資料庫、打開首頁和幾個關鍵流程）。</p>
<h3 id="還原時間的量測">還原時間的量測</h3>
<p>記錄從開始匯入到驗證完成的時間。這個數字就是事故時的最快恢復時間。如果一個 500MB 的資料庫匯入需要 40 分鐘，加上排查原因和決策的時間，實際恢復可能超過一小時。知道這個數字，才能在事故時給管理層一個實際的時間預期。</p>
<h3 id="無-ssh-環境沒有-pitr">無 SSH 環境沒有 PITR</h3>
<p>無 SSH 的主機環境的 MySQL 通常不提供 binlog 層級的 point-in-time recovery。能還原到的最近時間點就是最新備份的時間點——備份是每天凌晨做的、下午三點出事，那就是丟失當天的所有寫入。這是備份頻率需要跟資料變更速率對齊的根本原因。交易密集的站台如果無法接受一天的資料丟失，升級到有 binlog / PITR 的環境（VPS 或 managed MySQL）是必要的投資。</p>
<h2 id="大資料庫的特殊處理">大資料庫的特殊處理</h2>
<p>資料庫超過 500MB 時，備份和還原的操作時間和失敗風險都會上升。需要針對大表做特殊處理。</p>
<p>超過 1GB 的單表通常是 log 表、歷史紀錄表、或含有二進位大物件（BLOB）的表。對這類表的備份策略跟業務表不同：</p>
<ul>
<li><strong>log / 歷史表</strong>：備份時可以加 <code>--where=&quot;created_at &gt; DATE_SUB(NOW(), INTERVAL 90 DAY)&quot;</code> 只匯出近期資料，歷史資料另做一次性歸檔</li>
<li><strong>BLOB 欄位</strong>（圖片、PDF）：用 <code>--no-data</code> 單獨匯出 schema，BLOB 內容如果已經搬到檔案系統或 CDN，資料庫裡只需要保留路徑參考</li>
<li><strong>InnoDB 大表</strong>：<code>--single-transaction</code> 避免鎖表，但匯出期間的記憶體消耗跟表大小成正比，本機如果記憶體不足可以加 <code>--quick</code>（逐行讀取、不緩衝整張表）</li>
</ul>





<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"># 大表匯出：逐行讀取 + 一致性快照 + 壓縮</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqldump -h db-host.example.com -u dbuser -p <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --single-transaction --quick <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  dbname large_table <span class="p">|</span> gzip &gt; large_table_<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.sql.gz</span></span></code></pre></div><p>資料庫規模成長到備份時間超過維護視窗（例如匯出要兩小時但只有一小時的低流量時段），代表這類環境的備份能力已經到頂，需要評估升級到有 automated snapshot 的 managed MySQL 或 VPS。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：主文，涵蓋程式碼備份、部署紀律與整體接管流程</li>
<li>→ <a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a>：DB migration 跟 code deploy 要同步——schema 改了但 code 沒跟上會讓服務壞掉</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：DB credential 的掃描與保護、SQL injection 風險評估</li>
<li>→ <a href="/blog/infra/05-core-services/stateful-protection-dependency/" data-link-title="Stateful 資源保護與跨服務依賴表達" data-link-desc="stateful 資源的保護策略（multi-AZ、備份、刪除保護）、stateful 與 stateless 的操作差異，以及用 output 與 data source 表達服務間依賴">Stateful 資源保護與跨服務依賴</a>：IaC 環境裡的備份、deletion protection 與 PITR 設計</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">治理好習慣</a>：tagging、secret 管理與成本可見性的長期治理</li>
</ul>
]]></content:encoded></item><item><title>MySQL PITR + Backup Strategy：備份不是「拷貝資料」、是 N 點任意 restore 的能力</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/pitr-backup/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/pitr-backup/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 &lt;em>backup + PITR&lt;/em> — 不是「拷貝資料」、是「N 點任意 restore 的能力」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;p>「我們每天 mysqldump 一次、放 S3、沒問題吧」是個常見錯誤。問「能不能 restore 到 5 分鐘前」、答案會是 &lt;em>不能&lt;/em>。Dump-based backup 只能 restore 到 &lt;em>dump 那個瞬間&lt;/em>、5 分鐘前的事故無法 recover、必須等下次 dump。&lt;/p>
&lt;p>&lt;strong>真正的 backup strategy 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/point-in-time-recovery/" data-link-title="Point-in-Time Recovery" data-link-desc="說明如何用完整備份加上後續變更日誌，把資料庫還原到任意時間點">PITR（point-in-time recovery）&lt;/a>&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;em>能 restore 到任意過去時間點&lt;/em>（RPO 取決於 binlog flush 頻率、可接近 0）&lt;/li>
&lt;li>由 &lt;em>full backup 基線&lt;/em> + &lt;em>binlog 連續流&lt;/em>（從 backup 點到目標時間點的 incremental delta）組成&lt;/li>
&lt;li>Restore 過程：先 restore full backup → 再 apply binlog 到目標 timestamp 或 GTID&lt;/li>
&lt;/ul>
&lt;p>這篇 deep article 把 backup &lt;em>拆解成能力&lt;/em>、然後展開達到此能力需要的工具鏈跟工程紀律。&lt;/p>
&lt;h2 id="backup-三層責任">Backup 三層責任&lt;/h2>
&lt;p>PITR 的 &lt;em>能力&lt;/em> 由三層工程責任達成、任一層失效則 PITR 不成立：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Layer 1: Full Backup（基線）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ (mysqldump / XtraBackup / MyDumper / LVM snapshot / EBS snapshot)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Layer 2: Binlog Stream（incremental）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ (sync_binlog=1 + binlog 持續流到 backup storage)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">Layer 3: Restore + Replay 流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> (能 restore full + 能 apply binlog 到目標時間點)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每層的 &lt;em>backup&lt;/em> 不夠 — 必須有 &lt;em>測試 restore 流程&lt;/em> 才算真的有 backup。「dump 在 S3」加「沒有 verified restore」= no backup。&lt;/p>
&lt;h2 id="tool-1mysqldump--邏輯備份最廣容最慢">Tool 1：mysqldump — 邏輯備份、最廣容、最慢&lt;/h2>





&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">mysqldump --single-transaction --master-data&lt;span class="o">=&lt;/span>&lt;span class="m">2&lt;/span> --gtid-purged&lt;span class="o">=&lt;/span>ON &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> --triggers --routines --events &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> --all-databases &amp;gt; full-backup.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>輸出&lt;/strong>：SQL statement、純文字、可 grep / 編輯。&lt;/p>
&lt;p>&lt;strong>Trade-off&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>優點：跨 MySQL 版本（5.7 → 8.0 也讀）、跨 cloud / 跨 OS、可選 dump 部分 table&lt;/li>
&lt;li>缺點：&lt;em>極慢&lt;/em>（rebuild 整 DB 從 SQL execute）、大 DB（&amp;gt; 100 GB）不適用、restore 時長 hours+&lt;/li>
&lt;li>&lt;code>--single-transaction&lt;/code>：InnoDB only、用 REPEATABLE READ 拿 consistent snapshot、不 lock 表&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>適合&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> overview 的 implementation-layer deep article。Overview 已說明 MySQL 在 OLTP 譜系的定位、本文聚焦 <em>backup + PITR</em> — 不是「拷貝資料」、是「N 點任意 restore 的能力」。</p></blockquote>
<hr>
<p>「我們每天 mysqldump 一次、放 S3、沒問題吧」是個常見錯誤。問「能不能 restore 到 5 分鐘前」、答案會是 <em>不能</em>。Dump-based backup 只能 restore 到 <em>dump 那個瞬間</em>、5 分鐘前的事故無法 recover、必須等下次 dump。</p>
<p><strong>真正的 backup strategy 是 <a href="/blog/backend/knowledge-cards/point-in-time-recovery/" data-link-title="Point-in-Time Recovery" data-link-desc="說明如何用完整備份加上後續變更日誌，把資料庫還原到任意時間點">PITR（point-in-time recovery）</a></strong>：</p>
<ul>
<li><em>能 restore 到任意過去時間點</em>（RPO 取決於 binlog flush 頻率、可接近 0）</li>
<li>由 <em>full backup 基線</em> + <em>binlog 連續流</em>（從 backup 點到目標時間點的 incremental delta）組成</li>
<li>Restore 過程：先 restore full backup → 再 apply binlog 到目標 timestamp 或 GTID</li>
</ul>
<p>這篇 deep article 把 backup <em>拆解成能力</em>、然後展開達到此能力需要的工具鏈跟工程紀律。</p>
<h2 id="backup-三層責任">Backup 三層責任</h2>
<p>PITR 的 <em>能力</em> 由三層工程責任達成、任一層失效則 PITR 不成立：</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">Layer 1: Full Backup（基線）
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓     (mysqldump / XtraBackup / MyDumper / LVM snapshot / EBS snapshot)
</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">Layer 2: Binlog Stream（incremental）
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ↓     (sync_binlog=1 + binlog 持續流到 backup storage)
</span></span><span class="line"><span class="ln">6</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">7</span><span class="cl">Layer 3: Restore + Replay 流程
</span></span><span class="line"><span class="ln">8</span><span class="cl">         (能 restore full + 能 apply binlog 到目標時間點)</span></span></code></pre></div><p>每層的 <em>backup</em> 不夠 — 必須有 <em>測試 restore 流程</em> 才算真的有 backup。「dump 在 S3」加「沒有 verified restore」= no backup。</p>
<h2 id="tool-1mysqldump--邏輯備份最廣容最慢">Tool 1：mysqldump — 邏輯備份、最廣容、最慢</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">mysqldump --single-transaction --master-data<span class="o">=</span><span class="m">2</span> --gtid-purged<span class="o">=</span>ON <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --triggers --routines --events <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --all-databases &gt; full-backup.sql</span></span></code></pre></div><p><strong>輸出</strong>：SQL statement、純文字、可 grep / 編輯。</p>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：跨 MySQL 版本（5.7 → 8.0 也讀）、跨 cloud / 跨 OS、可選 dump 部分 table</li>
<li>缺點：<em>極慢</em>（rebuild 整 DB 從 SQL execute）、大 DB（&gt; 100 GB）不適用、restore 時長 hours+</li>
<li><code>--single-transaction</code>：InnoDB only、用 REPEATABLE READ 拿 consistent snapshot、不 lock 表</li>
</ul>
<p><strong>適合</strong>：</p>
<ul>
<li>&lt; 100 GB DB</li>
<li>Schema dump（migration / 給 dev clone DB）</li>
<li>跨版本 migrate</li>
<li>配 binlog 做 PITR baseline</li>
</ul>
<p><strong>不適合</strong>：</p>
<ul>
<li>
<blockquote>
<p>500 GB DB（restore 跑 days）</p></blockquote>
</li>
<li>高吞吐 production（dump 跑時 hold MVCC read view、bloat）</li>
</ul>
<h2 id="tool-2percona-xtrabackup--物理備份快production-標準">Tool 2：Percona XtraBackup — 物理備份、快、production 標準</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">xtrabackup --backup --target-dir<span class="o">=</span>/backup/full-2026-05-19 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --user<span class="o">=</span>backup --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --slave-info --safe-slave-backup
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Prepare（apply 內部 redo log、變成可 restore 狀態）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">xtrabackup --prepare --target-dir<span class="o">=</span>/backup/full-2026-05-19</span></span></code></pre></div><p><strong>輸出</strong>：InnoDB 資料檔案的 binary copy。</p>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：<em>極快</em>（直接 copy file、無 SQL execute）、適合 TB-scale DB、restore 跑時間跟 copy file 同</li>
<li>缺點：MySQL 版本綁定（XtraBackup 8.0 不能 restore 5.7 backup）、有 storage engine 限制（只 InnoDB）</li>
<li><em>Incremental backup</em> 支援：基於 LSN（log sequence number）只 copy 變更 page</li>
</ul>
<p><strong>Incremental flow</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Day 1: Full backup</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">xtrabackup --backup --target-dir<span class="o">=</span>/backup/full-day1
</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"># Day 2: Incremental（only changes since day 1）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">xtrabackup --backup --target-dir<span class="o">=</span>/backup/inc-day2 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --incremental-basedir<span class="o">=</span>/backup/full-day1
</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"># Restore: Apply incremental on top of full</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">xtrabackup --prepare --apply-log-only --target-dir<span class="o">=</span>/backup/full-day1
</span></span><span class="line"><span class="ln">10</span><span class="cl">xtrabackup --prepare --apply-log-only --target-dir<span class="o">=</span>/backup/full-day1 <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --incremental-dir<span class="o">=</span>/backup/inc-day2
</span></span><span class="line"><span class="ln">12</span><span class="cl">xtrabackup --prepare --target-dir<span class="o">=</span>/backup/full-day1</span></span></code></pre></div><p><strong>適合</strong>：</p>
<ul>
<li>
<blockquote>
<p>100 GB production DB</p></blockquote>
</li>
<li>每日 incremental + 週一次 full（典型 enterprise schedule）</li>
<li>從自管 MySQL 遷 cloud（XtraBackup + rsync 到 cloud restore）</li>
</ul>
<p><strong>不適合</strong>：</p>
<ul>
<li>Schema-only dump（用 mysqldump 更簡單）</li>
<li>跨 major version restore</li>
</ul>
<h2 id="tool-3mydumper--並行邏輯備份">Tool 3：MyDumper — 並行邏輯備份</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">mydumper --user<span class="o">=</span>backup --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --threads<span class="o">=</span><span class="m">8</span> --rows<span class="o">=</span><span class="m">100000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --outputdir<span class="o">=</span>/backup/mydumper-2026-05-19 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --less-locking</span></span></code></pre></div><p><strong>輸出</strong>：每張 table 一個 <code>.sql</code> file（schema） + 多個 chunked <code>.dat</code> file（資料）。</p>
<p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：<em>並行 dump</em>（per-table thread）、比 mysqldump 快 5-10x、可恢復斷點（resume）</li>
<li>缺點：tooling 不如 mysqldump 普及、需要單獨裝</li>
<li>對應的 <code>myloader</code> restore：也並行、比 mysqldump restore 快 5-10x</li>
</ul>
<p><strong>適合</strong>：</p>
<ul>
<li>100 GB - 1 TB 範圍</li>
<li>中型 production、想要邏輯備份的可讀性 + 並行加速</li>
</ul>
<h2 id="tool-4lvm--ebs-snapshot--物理-file-system-層">Tool 4：LVM / EBS Snapshot — 物理 file system 層</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"># 1. Freeze MySQL（讓 write 暫停）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql&gt; FLUSH TABLES WITH READ LOCK<span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 2. Trigger snapshot（EBS / LVM）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">aws ec2 create-snapshot --volume-id vol-xxx --description <span class="s2">&#34;mysql-2026-05-19&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 3. Unfreeze</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">mysql&gt; UNLOCK TABLES<span class="p">;</span></span></span></code></pre></div><p><strong>Trade-off</strong>：</p>
<ul>
<li>優點：超快（file system 層）、適合 <em>VM-based MySQL</em>（EC2 / on-prem）</li>
<li>缺點：必須 <em>暫停 write</em>（短時間 lock）、不能跨 OS / cloud 移植</li>
<li>AWS RDS / Aurora 全部走這條路（自動 snapshot）</li>
</ul>
<p><strong>適合</strong>：</p>
<ul>
<li>AWS RDS / Aurora（自動）</li>
<li>自管 MySQL on EC2 with EBS（EBS snapshot 結合 mysql freeze）</li>
<li>大 DB 想要 fast backup + fast restore</li>
</ul>
<h2 id="binlog-based-pitr">Binlog-based PITR</h2>
<p>Full backup 加上 binlog 才能達到 PITR。Binlog 是 MySQL replication / CDC / PITR 共用的 source。</p>
<p><strong>配置</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[mysqld]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">log_bin</span> <span class="o">=</span> <span class="s">mysql-bin</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">binlog_format</span> <span class="o">=</span> <span class="s">ROW                  # ROW 必須</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">binlog_row_image</span> <span class="o">=</span> <span class="s">FULL              # 完整 row image</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">sync_binlog</span> <span class="o">=</span> <span class="s">1                      # 每次 commit fsync binlog（zero loss）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">binlog_expire_logs_seconds</span> <span class="o">=</span> <span class="s">1209600 # 14 天 retention（依需求調）</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">gtid_mode</span> <span class="o">=</span> <span class="s">ON                       # GTID 必須、PITR 用 GTID 識別 transaction</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">enforce_gtid_consistency</span> <span class="o">=</span> <span class="s">ON</span></span></span></code></pre></div><p><strong>Binlog backup</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 持續 stream binlog 到 backup storage</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqlbinlog --read-from-remote-server --raw --stop-never <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --user<span class="o">=</span>replication --password<span class="o">=</span>... <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --host<span class="o">=</span>primary.example.com <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --result-file<span class="o">=</span>/backup/binlog/ mysql-bin.000001 <span class="p">&amp;</span></span></span></code></pre></div><p><code>--read-from-remote-server</code> + <code>--stop-never</code> 持續從 primary tail binlog、不間斷 stream 到 backup directory。每個 binlog file 寫滿後 close + 開新 file。</p>
<h2 id="restore--pitr-流程">Restore + PITR 流程</h2>
<p>完整 PITR 流程（restore 到 2026-05-19 14:30:00）：</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"># Step 1: Restore full backup</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">xtrabackup --copy-back --target-dir<span class="o">=</span>/backup/full-2026-05-18  <span class="c1"># 前一天 full</span>
</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"># Step 2: 啟動 MySQL（會看到 backup 拿那刻的 GTID set）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">systemctl start mysqld
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># Step 3: 查 full backup 結束時的 GTID</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">mysql&gt; SHOW MASTER STATUS<span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">+------------------+----------+------------------------------------------+
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">|</span> File             <span class="p">|</span> Position <span class="p">|</span> Executed_Gtid_Set                        <span class="p">|</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">+------------------+----------+------------------------------------------+
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">|</span> mysql-bin.000150 <span class="p">|</span>     <span class="m">1234</span> <span class="p">|</span> server-uuid:1-12345                      <span class="p">|</span>
</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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># Step 4: Apply binlog 從 backup 之後到目標時間</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">mysqlbinlog --start-datetime<span class="o">=</span><span class="s2">&#34;2026-05-18 03:00:00&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>            --stop-datetime<span class="o">=</span><span class="s2">&#34;2026-05-19 14:30:00&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>            /backup/binlog/mysql-bin.000150 <span class="se">\
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="se"></span>            /backup/binlog/mysql-bin.000151 <span class="se">\
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="se"></span>            ...                                <span class="c1"># 列所有需要的 binlog</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="p">|</span> mysql -u root -p
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"># Step 5: 驗證 GTID set 到目標時間點對應的位置</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">mysql&gt; SHOW MASTER STATUS<span class="p">;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"># Executed_Gtid_Set 應包含到目標時間點的 transaction</span></span></span></code></pre></div><p>對 <em>精確 GTID-based PITR</em>（停在特定 transaction、不是 timestamp）：</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">mysqlbinlog --include-gtids<span class="o">=</span><span class="s1">&#39;server-uuid:1-50000&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>            /backup/binlog/mysql-bin.000150 ... <span class="p">|</span> mysql -u root -p</span></span></code></pre></div><h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="1-gtid-處理不一致--restore-後-replication-broken">1. GTID 處理不一致 — Restore 後 replication broken</h3>
<p>XtraBackup restore 時 <code>--slave-info</code> 紀錄 GTID purged set、mysqldump 用 <code>--gtid-purged=ON</code>。如果 restore 後沒正確 set <code>gtid_purged</code>、replica re-attach 時 GTID gap error。</p>
<p>修法：</p>
<ul>
<li>XtraBackup restore：用 <code>xtrabackup_binlog_info</code> 內的 GTID set 設 <code>SET GLOBAL gtid_purged='...';</code></li>
<li>mysqldump：dump file 內已有 <code>SET @@GLOBAL.GTID_PURGED='...';</code>、執行 dump 自動 set</li>
<li>Restore 後 <em>先驗證 <code>Executed_Gtid_Set</code></em> 跟 source 預期對齊、再 START SLAVE</li>
</ul>
<h3 id="2-binlog-gap--中間遺漏-file-直接-restore-fail">2. Binlog gap — 中間遺漏 file 直接 restore fail</h3>
<p>Binlog stream 失聯（network blip / disk full）+ binlog rotate、<code>mysql-bin.000156</code> 不在 backup storage 內。PITR 試圖跨過該 file restore、跳過已 commit transaction、結果 <em>資料不一致</em>（不是錯誤、是 <em>silently incorrect</em>）。</p>
<p>修法：</p>
<ul>
<li><em>Binlog stream 必須持續</em>、失聯 → alert</li>
<li>監控 backup storage 內 binlog 連續性（file name 連號、無 gap）</li>
<li>Restore 前 <em>先驗證 binlog 完整性</em>：<code>mysqlbinlog --verify-binlog-checksum *.bin &gt; /dev/null</code></li>
<li>對 missing binlog <em>中止 PITR</em>、不繼續 partial restore</li>
</ul>
<h3 id="3-backup-沒-verify--真事故時才發現-restore-broken">3. Backup 沒 verify — 真事故時才發現 restore broken</h3>
<p>每天備份成功、storage 用了 5 TB、實際 <em>從未 restore 過</em>。事故發生 restore 才知道 backup file corrupt / GTID 錯 / binlog gap、整套無用。</p>
<p>修法：</p>
<ul>
<li><em>自動化 restore test</em>：每週 / 每月在 staging server 跑完整 restore + PITR、跑完 SELECT 比對 production</li>
<li>驗證 restore 後 row count 跟 production 接近、<code>CHECKSUM TABLE</code> 比對主要 table</li>
<li>真的事故時 RTO 才不會 surprise</li>
</ul>
<h3 id="4-rpo-不到-1-分鐘的代價">4. RPO 不到 1 分鐘的代價</h3>
<p>「我要 RPO &lt; 1 分鐘」聽起來合理、但實現需要：</p>
<ul>
<li><code>sync_binlog=1</code>（每 commit fsync、寫吞吐降 10-30%）</li>
<li>Binlog stream 到 <em>獨立 storage</em>（不只是 primary local disk）、cross-region replication（額外 network cost）</li>
<li>Replica 也用 semi-sync 配合（zero binlog loss）</li>
<li>監控 + alert RPO 違反（&lt; 1 分鐘 stream lag）</li>
</ul>
<p><strong>TCO</strong>：~30% 寫吞吐 penalty + 額外 storage / network cost + 7x24 on-call。考慮 <em>real RPO requirement</em> — 多數 application 5 分鐘 RPO 已足夠、追求 1 分鐘 RPO 不划算。</p>
<p>修法：</p>
<ul>
<li>跟 product / business 確認 <em>真 RPO 要求</em></li>
<li><em>RPO budget = 寫吞吐 trade-off + ops cost</em>、不是 free</li>
<li>用 <a href="/blog/backend/01-database/vendors/aurora/" data-link-title="AWS Aurora" data-link-desc="AWS managed PostgreSQL / MySQL、storage / compute 分離、&#43;75% 效能改善的 production 證據">Aurora</a> / managed offering 把 RPO 議題 outsource（Aurora &lt; 1 秒 RPO + 自動 cross-AZ）</li>
</ul>
<h3 id="5-encryption-key-沒備份--restore-後解不開資料">5. Encryption key 沒備份 — Restore 後解不開資料</h3>
<p>啟用 <em>encryption at rest</em>（MySQL 8.0+ <code>default_table_encryption=ON</code> + keyring plugin / component；MariaDB 用 <code>innodb_encrypt_tables</code>）後、所有 InnoDB tablespace 都加密。Master key 在 <em>keyring file</em> 或 KMS-backed component。如果 backup 只 backup MySQL data file、沒備 keyring、restore 後資料 <em>encrypted 但無 key、無法讀</em>。</p>
<p>修法：</p>
<ul>
<li><em>Keyring file 跟 data file 分開儲存</em>、但兩者 <em>都要 backup</em></li>
<li>用 <em>KMS-based keyring</em>（AWS KMS / HashiCorp Vault）取代 file-based、key 不在 MySQL server 上</li>
<li>Disaster recovery runbook 紀錄 <em>key recovery 流程</em>、不要假設「重 install MySQL」就能解</li>
</ul>
<h2 id="容量規劃要點">容量規劃要點</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Full backup 頻率</td>
          <td>週一次（XtraBackup）或日一次（小 DB）</td>
      </tr>
      <tr>
          <td>Incremental 頻率</td>
          <td>每日（XtraBackup incremental）</td>
      </tr>
      <tr>
          <td>Binlog retention</td>
          <td>14 天（給 PITR window）</td>
      </tr>
      <tr>
          <td>Backup retention</td>
          <td>Full × 4 週 + 月度 archive × 12 個月</td>
      </tr>
      <tr>
          <td>Storage cost</td>
          <td>約 2-3x DB size（full + incremental + binlog）</td>
      </tr>
      <tr>
          <td>Cross-region copy</td>
          <td>必要（local backup 失效時還有 disaster recovery）</td>
      </tr>
      <tr>
          <td>Restore test 頻率</td>
          <td>每週 staging 上跑、每月 production-like 跑</td>
      </tr>
  </tbody>
</table>
<h2 id="跟其他模組整合">跟其他模組整合</h2>
<h3 id="跟-replication-topology">跟 Replication topology</h3>
<p>Replication replica 不能取代 backup — replica 上的 DROP TABLE 也會被 replicate、replica 上資料同樣消失。Backup 是 <em>獨立保險</em>。詳見 <a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">Replication Topology</a>。</p>
<h3 id="跟-innodb-tuning">跟 InnoDB Tuning</h3>
<p><code>innodb_flush_log_at_trx_commit=1</code> + <code>sync_binlog=1</code> 是 backup-friendly 的設定（zero loss）、但寫吞吐降。如果為了寫吞吐放寬 durability、必須接受 <em>PITR window</em> 也 widening。詳見 <a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">InnoDB Tuning</a>。</p>
<h3 id="跟-aurora-mysql">跟 Aurora MySQL</h3>
<p>Aurora 完全 outsource backup — automatic continuous backup + PITR &lt; 1 秒、不必管 mysqldump / XtraBackup / binlog stream。從 Aurora 遷出時、需要重新建 self-managed backup chain。詳見 <a href="/blog/backend/01-database/vendors/mysql/migrate-to-aurora/" data-link-title="MySQL → Aurora MySQL：storage layer 轉手到 AWS、replication / HA / backup 全部 outsource" data-link-desc="自管 MySQL → Aurora MySQL 是 Type C operational hybrid migration — wire protocol 一致、ops 責任轉到 AWS。本文走 6 維 audit（Operational High）、Aurora storage architecture 衝擊、4-phase migration、5 production 踩雷、何時維持原路線。">migrate-to-aurora</a>。</p>
<h3 id="跟-postgresql-pitr">跟 PostgreSQL PITR</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>MySQL PITR</th>
          <th>PostgreSQL PITR</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Logical backup</td>
          <td>mysqldump / MyDumper</td>
          <td>pg_dump / pg_dumpall</td>
      </tr>
      <tr>
          <td>Physical backup</td>
          <td>XtraBackup</td>
          <td>pg_basebackup / pgBackRest</td>
      </tr>
      <tr>
          <td>Incremental log</td>
          <td>Binary log（binlog）</td>
          <td>WAL (Write-Ahead Log)</td>
      </tr>
      <tr>
          <td>Stream tool</td>
          <td>mysqlbinlog &ndash;read-from-remote-server</td>
          <td>pg_receivewal</td>
      </tr>
      <tr>
          <td>PITR command</td>
          <td>mysqlbinlog &ndash;stop-datetime</td>
          <td>pg_ctl + recovery.conf / standby.signal</td>
      </tr>
      <tr>
          <td>Identifier</td>
          <td>GTID 或 file:position</td>
          <td>LSN（Log Sequence Number）</td>
      </tr>
      <tr>
          <td>Cross-version</td>
          <td>mysqldump（廣容）</td>
          <td>pg_dump（廣容）</td>
      </tr>
  </tbody>
</table>
<p>兩家 PITR 概念類似（full + log replay）、tool name 不同、概念對等。詳見 <a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PostgreSQL PITR + WAL Archiving</a>。</p>
<h2 id="何時-outsource-backup">何時 outsource backup</h2>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AWS 生態 + 不想管 backup ops</td>
          <td>Aurora MySQL（內建 PITR）</td>
      </tr>
      <tr>
          <td>GCP 生態</td>
          <td>Cloud SQL（內建 PITR）</td>
      </tr>
      <tr>
          <td>Azure 生態</td>
          <td>Azure DB for MySQL</td>
      </tr>
      <tr>
          <td>跨雲 + 想自管</td>
          <td>XtraBackup + binlog stream + S3</td>
      </tr>
      <tr>
          <td>規模小、可接受 mysqldump</td>
          <td>mysqldump cron + S3</td>
      </tr>
      <tr>
          <td>規模大、無 cloud</td>
          <td>Percona XtraBackup Enterprise + tape archive</td>
      </tr>
      <tr>
          <td>強合規（HIPAA / PCI-DSS）</td>
          <td>自管 + air-gap backup + audit trail</td>
      </tr>
  </tbody>
</table>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL vendor overview</a></li>
<li><a href="/blog/backend/01-database/vendors/mysql/replication-topology/" data-link-title="MySQL Replication Topology：async / semi-sync / GTID 不是三選一、是三個 trade-off 軸的疊加" data-link-desc="MySQL replication 不是「選 async 還是 semi-sync」、是 *durability / latency / consistency* 三個 trade-off 軸的疊加；GTID 是跨 mode 的 infrastructure layer、不是第三種 mode。本文走 3 軸取捨模型 → async / semi-sync 行為對比 → GTID 替代 binlog-position 的好處 → 配置 step-by-step → 5 production 踩雷（lag 暴衝 / semi-sync 退回 async / GTID gap / Loss-Less semi-sync 真的 loss-less / chained replication 雪崩）→ 跟 Aurora MySQL / Vitess / ProxySQL / Orchestrator 整合">MySQL Replication Topology</a>（binlog 跟 PITR 共用 source）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/innodb-tuning/" data-link-title="MySQL InnoDB Tuning：為什麼一個 100 GB DB 在 64 GB RAM server 上 query 慢 5 倍" data-link-desc="InnoDB 是 MySQL 預設 storage engine、預設值給 256 MB buffer pool（早期 default）。本文從一個常見痛點開場（DB &gt; RAM 但 server 仍 swap）、走 4 個 critical knob（buffer pool / redo log / flush method / IO capacity）、各自如何影響讀寫吞吐、配置 step-by-step、5 production 踩雷（buffer pool warm-up / log file 大小 / 設 sync_binlog=0 換速度 / IO scheduler / undo log 膨脹）、跟 SSD / NVMe / EBS 的 IO 假設">MySQL InnoDB Tuning</a>（durability + backup 互動）</li>
<li><a href="/blog/backend/01-database/vendors/mysql/migrate-to-aurora/" data-link-title="MySQL → Aurora MySQL：storage layer 轉手到 AWS、replication / HA / backup 全部 outsource" data-link-desc="自管 MySQL → Aurora MySQL 是 Type C operational hybrid migration — wire protocol 一致、ops 責任轉到 AWS。本文走 6 維 audit（Operational High）、Aurora storage architecture 衝擊、4-phase migration、5 production 踩雷、何時維持原路線。">migrate-to-aurora</a>（backup outsource）</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/" data-link-title="PostgreSQL PITR &#43; WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈" data-link-desc="Base backup &#43; WAL archive 構成 PITR 的雙軌資料、archive_command &#43; restore_command 配置、用 pgBackRest / WAL-G 替代手寫腳本、5 個 production 踩雷（archive 靜默失敗 / archive lag / 錯誤 target time / base backup 過期未清 / timeline 分歧 recovery 模糊）、跟 Patroni &#43; monitoring 整合">PostgreSQL PITR + WAL Archiving</a>（PG sibling）</li>
<li>官方：<a href="https://docs.percona.com/percona-xtrabackup/8.0/">Percona XtraBackup</a> / <a href="https://github.com/mydumper/mydumper">MyDumper</a> / <a href="https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html">mysqldump</a></li>
</ul>
]]></content:encoded></item><item><title>PostgreSQL PITR + WAL archiving：從 base backup 到 point-in-time recovery 的完整鏈</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pitr-wal-archiving/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 backup / recovery 是 OLTP 必備能力、本文聚焦 &lt;em>PITR（Point-In-Time Recovery）的雙軌資料設計 + production 5 個 failure mode&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>Logical bug 在 production 部署、執行 6 小時後才發現 — 某個 batch job 把 50 萬筆 user.email 改成 NULL。此時：&lt;/p>
&lt;ul>
&lt;li>還原最新 daily backup（昨晚）→ 丟掉今天所有正常寫入（訂單、註冊）&lt;/li>
&lt;li>從 standby promote → standby 已同步 bug、跟 primary 同狀態&lt;/li>
&lt;li>從 application log 重建 → 部分操作不可逆（已寄出 email）&lt;/li>
&lt;/ul>
&lt;p>PITR 是這類 &lt;em>logical disaster&lt;/em> 的標準解 — 不還原到 backup 時間點、而是 &lt;em>還原到 bug 發生前一刻&lt;/em>（例：1 分鐘前）。需要 &lt;em>base backup + WAL archive&lt;/em> 雙軌資料：base backup 是 snapshot、WAL archive 是 snapshot 之後的所有寫入；recovery 時 replay WAL 到指定 timestamp / LSN / transaction ID。&lt;/p>
&lt;h2 id="核心概念base-backup--wal-archive-的雙軌設計">核心概念：base backup + WAL archive 的雙軌設計&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">[Base backup t0] + [WAL archive t0 → now]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 全量 snapshot incremental log
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> └────── recover to t_target ──→ [restored cluster at t_target]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個軌道各自獨立但必須對齊：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Base backup&lt;/strong>：某時刻整個 data dir 的 snapshot。&lt;code>pg_basebackup&lt;/code> / &lt;code>pgBackRest&lt;/code> / &lt;code>WAL-G&lt;/code> 都產這個；通常 &lt;em>每天 / 每週&lt;/em> 跑一次&lt;/li>
&lt;li>&lt;strong>WAL archive&lt;/strong>：base backup 之後每段 WAL 都 push 到外部 storage（S3 / GCS / NFS）。&lt;code>archive_command&lt;/code> 觸發、PostgreSQL 等到 archive 成功才 &lt;em>回收&lt;/em> 那段 WAL&lt;/li>
&lt;/ol>
&lt;p>兩者組合決定 RPO（recovery point objective）：&lt;/p>
&lt;ul>
&lt;li>RPO ≈ WAL archive frequency（streaming 即時、&lt;code>archive_timeout&lt;/code> 預設 1 分鐘）&lt;/li>
&lt;li>RPO 不是 base backup frequency — daily base backup + 每分鐘 archive WAL → RPO 1 分鐘&lt;/li>
&lt;/ul>
&lt;p>RTO（recovery time objective）跟 &lt;em>base backup size + WAL replay 量&lt;/em> 相關：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a> overview 的 implementation-layer deep article。Overview 已說明 backup / recovery 是 OLTP 必備能力、本文聚焦 <em>PITR（Point-In-Time Recovery）的雙軌資料設計 + production 5 個 failure mode</em>。</p></blockquote>
<h2 id="問題情境">問題情境</h2>
<p>Logical bug 在 production 部署、執行 6 小時後才發現 — 某個 batch job 把 50 萬筆 user.email 改成 NULL。此時：</p>
<ul>
<li>還原最新 daily backup（昨晚）→ 丟掉今天所有正常寫入（訂單、註冊）</li>
<li>從 standby promote → standby 已同步 bug、跟 primary 同狀態</li>
<li>從 application log 重建 → 部分操作不可逆（已寄出 email）</li>
</ul>
<p>PITR 是這類 <em>logical disaster</em> 的標準解 — 不還原到 backup 時間點、而是 <em>還原到 bug 發生前一刻</em>（例：1 分鐘前）。需要 <em>base backup + WAL archive</em> 雙軌資料：base backup 是 snapshot、WAL archive 是 snapshot 之後的所有寫入；recovery 時 replay WAL 到指定 timestamp / LSN / transaction ID。</p>
<h2 id="核心概念base-backup--wal-archive-的雙軌設計">核心概念：base backup + WAL archive 的雙軌設計</h2>





<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">[Base backup t0]  +  [WAL archive t0 → now]
</span></span><span class="line"><span class="ln">2</span><span class="cl">     ↓                       ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">  全量 snapshot          incremental log
</span></span><span class="line"><span class="ln">4</span><span class="cl">     ↓                       ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">     └────── recover to t_target ──→ [restored cluster at t_target]</span></span></code></pre></div><p>兩個軌道各自獨立但必須對齊：</p>
<ol>
<li><strong>Base backup</strong>：某時刻整個 data dir 的 snapshot。<code>pg_basebackup</code> / <code>pgBackRest</code> / <code>WAL-G</code> 都產這個；通常 <em>每天 / 每週</em> 跑一次</li>
<li><strong>WAL archive</strong>：base backup 之後每段 WAL 都 push 到外部 storage（S3 / GCS / NFS）。<code>archive_command</code> 觸發、PostgreSQL 等到 archive 成功才 <em>回收</em> 那段 WAL</li>
</ol>
<p>兩者組合決定 RPO（recovery point objective）：</p>
<ul>
<li>RPO ≈ WAL archive frequency（streaming 即時、<code>archive_timeout</code> 預設 1 分鐘）</li>
<li>RPO 不是 base backup frequency — daily base backup + 每分鐘 archive WAL → RPO 1 分鐘</li>
</ul>
<p>RTO（recovery time objective）跟 <em>base backup size + WAL replay 量</em> 相關：</p>
<ul>
<li>Restore base backup ~ 1-4 小時（TB 級）</li>
<li>WAL replay 時間 ~ archive 累積量 / replay throughput</li>
</ul>
<h2 id="step-by-step-配置">Step-by-step 配置</h2>
<h3 id="primaryarchive_command-設好">Primary：archive_command 設好</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># postgresql.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">wal_level</span> <span class="o">=</span> <span class="s">replica                          # 預設 replica、PITR 需要</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">archive_mode</span> <span class="o">=</span> <span class="s">on                            # 啟用 archive</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">archive_command</span> <span class="o">=</span> <span class="s">&#39;wal-g wal-push %p&#39;        # 或 pgBackRest / 自寫 script</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">archive_timeout</span> <span class="o">=</span> <span class="s">60                         # 60s 無 WAL 時強制切 segment</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">max_wal_size</span> <span class="o">=</span> <span class="s">4GB</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">checkpoint_timeout</span> <span class="o">=</span> <span class="s">15min</span></span></span></code></pre></div><p><code>archive_command</code> 必須 <em>回 exit code 0 才算成功</em>；非 0 PostgreSQL retry、retry 失敗會在 <code>pg_wal</code> 堆積 WAL 直到 disk 滿。<strong>critical：archive_command 不能寫成 silent-fail</strong>。</p>
<h3 id="用-pgbackrest-取代手寫-script">用 pgBackRest 取代手寫 script</h3>
<p>production 強烈不建議自寫 archive script — pgBackRest / WAL-G / Barman 處理過所有 edge case：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># pgbackrest.conf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">[global]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">repo1-type</span><span class="o">=</span><span class="s">s3</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">repo1-s3-bucket</span><span class="o">=</span><span class="s">mybucket</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">repo1-s3-region</span><span class="o">=</span><span class="s">us-east-1</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">repo1-retention-full</span><span class="o">=</span><span class="s">4                       # 留 4 個 full backup</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">repo1-retention-diff</span><span class="o">=</span><span class="s">8                       # 留 8 個 differential</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">repo1-cipher-type</span><span class="o">=</span><span class="s">aes-256-cbc                # encrypt at rest</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">process-max</span><span class="o">=</span><span class="s">8                                # parallel restore</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">[main]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="na">pg1-path</span><span class="o">=</span><span class="s">/var/lib/postgresql/16/main</span></span></span></code></pre></div>




<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"># 跑 full backup</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pgbackrest --stanza<span class="o">=</span>main backup --type<span class="o">=</span>full
</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"># archive_command 用 pgbackrest 內建</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nv">archive_command</span> <span class="o">=</span> <span class="s1">&#39;pgbackrest --stanza=main archive-push %p&#39;</span></span></span></code></pre></div><p>pgBackRest 處理：parallel push、compression、encryption、checksum、archive replay timing、backup catalog、retention 自動清理。</p>
<h3 id="restorerecovery_target_time">Restore：recovery_target_time</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 從 S3 / repo 拉 base backup</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pgbackrest --stanza<span class="o">=</span>main --type<span class="o">=</span><span class="nb">time</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --target<span class="o">=</span><span class="s2">&#34;2026-05-18 14:30:00+00&#34;</span> restore
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 2. PostgreSQL 進 recovery mode、自動 replay WAL 到 target time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># (pgBackRest 寫好 recovery.signal + postgresql.auto.conf)</span>
</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"># 3. 確認到目標 timestamp 後、promote</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">pg_ctl promote</span></span></code></pre></div><p>Recovery target 三種：</p>
<ul>
<li><strong><code>recovery_target_time</code></strong>：到某 timestamp</li>
<li><strong><code>recovery_target_xid</code></strong>：到某 transaction ID（log 有 xid 才好定位）</li>
<li><strong><code>recovery_target_lsn</code></strong>：到某 WAL LSN（最精確、但需要事先記下 LSN）</li>
</ul>
<p>production 多用 timestamp、application log 有時間戳容易定位。</p>
<h2 id="故障演練--邊界-case">故障演練 / 邊界 case</h2>
<h3 id="case-1archive_command-靜默失敗">Case 1：archive_command 靜默失敗</h3>
<p><strong>徵兆</strong>：DBA 發現某 PITR test 時、最近 3 天的 WAL 在 S3 上沒有；但 PostgreSQL 沒 alert、<code>pg_wal</code> 也沒堆積（早就被回收？）。</p>
<p><strong>根因</strong>：archive_command 寫成 <code>aws s3 cp %p s3://bucket/... 2&gt;/dev/null</code> — 錯誤訊息被吞、exit code 卻是 0（cp 失敗但 redirect 後 shell wrapper 不傳 fail code）；PostgreSQL 以為成功、繼續 advance WAL pointer、舊 WAL 已回收、archive 上實際沒有。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>絕對不要靜默 exit code</strong>：archive_command 必須 <em>fail loud</em>、exit code 非 0</li>
<li><strong>用 pgBackRest / WAL-G</strong>、不自寫 shell 腳本</li>
<li><strong>monitoring</strong>：對 archive lag 寫 alert</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">pg_last_archived_xact_time</span><span class="p">(),</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">pg_last_archived_xact_time</span><span class="p">()</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">lag</span><span class="p">;</span></span></span></code></pre></div><p>alert if lag &gt; 5 minutes</p>
<ol start="4">
<li><strong>定期測試 restore</strong>：每月跑一次 PITR drill、實際從 archive restore + 驗證 timestamp</li>
</ol>
<h3 id="case-2wal-archive-lagprimary-disk-壓力">Case 2：WAL archive lag、primary disk 壓力</h3>
<p><strong>徵兆</strong>：<code>pg_wal</code> 目錄持續長大、<code>df -h</code> 90%+；<code>pg_stat_archiver</code> 顯示 <code>failed_count</code> 累積、<code>last_failed_time</code> 是 30 分鐘前；archive_command 寫不出去（S3 throttle / network 慢）。</p>
<p><strong>根因</strong>：archive_command 寫到 S3、但 S3 rate limit / connection timeout、PostgreSQL retry；WAL 一直在 <code>pg_wal</code> 不能回收、disk 持續長。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預防</strong>：<code>archive_command</code> 內部 retry + parallel push（pgBackRest 自帶 <code>process-max</code>）</li>
<li><strong>alert</strong>：<code>pg_stat_archiver.failed_count</code> 增長 + primary disk usage &gt; 80%</li>
<li><strong>緊急</strong>：暫時改 archive_command 寫 local NFS / 其他 storage、等 S3 恢復再同步；不要直接 disable archive（會丟資料）</li>
<li><strong>架構</strong>：archive storage 至少跨 region 兩份、單一 storage 故障不影響 archive</li>
</ol>
<h3 id="case-3recovery-跑到-wrong-target-time">Case 3：recovery 跑到 wrong target time</h3>
<p><strong>徵兆</strong>：PITR 還原後資料看起來 <em>缺一塊</em>；DBA 後悔 — target time 設早了 30 分鐘、recovery 已 promote、後續 WAL 在新 timeline 上、回不去。</p>
<p><strong>根因</strong>：recovery 過程不可逆 — 一旦 promote 開新 timeline、舊 WAL 在新 timeline 上不會被 replay；想還原到更晚 timestamp 必須 <em>重新 restore base backup + WAL</em>。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong><code>recovery_target_action = pause</code></strong>（PG 13+）：到 target time 後 <em>暫停</em>、不自動 promote；DBA 手動 query 確認資料對才 promote</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">recovery_target_time</span> <span class="o">=</span> <span class="s">&#39;2026-05-18 14:30:00+00&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">recovery_target_action</span> <span class="o">=</span> <span class="s">pause</span></span></span></code></pre></div><ol start="2">
<li><strong>多次 PITR 試錯</strong>：用 <em>獨立 staging cluster</em> restore、驗證 target time 對、再對 production 跑</li>
<li><strong>記錄 target time 來源</strong>：application log / event timestamp 多比對、避免時區錯亂（<code>+00</code> UTC 跟 local time 差）</li>
</ol>
<h3 id="case-4base-backup-過期未清storage-爆">Case 4：base backup 過期未清、storage 爆</h3>
<p><strong>徵兆</strong>：S3 backup bucket size 半年內從 200GB 漲到 5TB；DBA 才發現 retention 沒設、daily base backup 留 180 天。</p>
<p><strong>根因</strong>：archive_command 自寫腳本沒 retention 邏輯、或 pgBackRest 設了 <code>repo1-retention-full=180</code> 漏看；DB 容量本來就成長 + 每日 full backup 累積。</p>
<p><strong>修法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># pgBackRest retention：4 full + auto-expire archive</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">repo1-retention-full</span><span class="o">=</span><span class="s">4                         # 留 4 個 full backup</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">repo1-retention-diff</span><span class="o">=</span><span class="s">8                         # 留 8 個 differential</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">repo1-retention-archive</span><span class="o">=</span><span class="s">4                      # WAL archive 跟 full 對齊</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">repo1-retention-archive-type</span><span class="o">=</span><span class="s">full</span></span></span></code></pre></div><p>storage budgeting：</p>
<ul>
<li>daily full + diff + WAL archive ≈ 1-2x DB size / day</li>
<li>4-week retention → ~30-60x DB size storage</li>
<li>跨 region replication → 2-3x</li>
</ul>
<h3 id="case-5timeline-分歧後-recovery-模糊">Case 5：timeline 分歧後 recovery 模糊</h3>
<p><strong>徵兆</strong>：production 經歷一次 failover（Patroni promote）+ 之後又 PITR 一次；現在要再 PITR 到 failover 前一刻、archive 上有兩個 timeline、recovery target 搞不清要哪個。</p>
<p><strong>根因</strong>：每次 promote 開新 timeline ID（<code>.history</code> 檔）；archive storage 上同 LSN 可能對應不同 timeline；recovery target time 在分歧點附近、ambiguous。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong><code>recovery_target_timeline</code></strong> 明示要 follow 哪個 timeline</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="na">recovery_target_time</span> <span class="o">=</span> <span class="s">&#39;2026-05-15 10:00:00+00&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">recovery_target_timeline</span> <span class="o">=</span> <span class="s">&#39;3&#39;                 # 要 follow timeline 3</span></span></span></code></pre></div><ol start="2">
<li><strong>熟悉 <code>.history</code> 檔</strong>：<code>/wal_archive/000000XX.history</code> 記錄 timeline 切換點、PITR 前先看</li>
<li><strong>預防</strong>：每次 promote 後 <em>立刻</em> 跑新的 base backup、簡化未來 PITR 流程（不用跨 timeline）</li>
</ol>
<h2 id="容量--cost-規劃">容量 / cost 規劃</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>估算</th>
          <th>警戒</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Base backup size</td>
          <td>跟 DB data dir 大小成正比（PostgreSQL 內部 compression 後）</td>
          <td>每 backup ~ 0.5-1x DB size</td>
      </tr>
      <tr>
          <td>WAL archive size</td>
          <td>~5-50GB / day depending on write volume</td>
          <td>1TB DB / write-heavy 可能 100GB+ / day</td>
      </tr>
      <tr>
          <td>Storage retention</td>
          <td>4-12 weeks 典型</td>
          <td>30-60x DB size budget</td>
      </tr>
      <tr>
          <td>Base backup time</td>
          <td>TB 級 1-4 小時</td>
          <td>跑在 maintenance window</td>
      </tr>
      <tr>
          <td>Restore time</td>
          <td>base backup restore + WAL replay</td>
          <td>TB 級 PITR 通常 2-6 小時</td>
      </tr>
      <tr>
          <td>Network bandwidth</td>
          <td>full backup 期間 100-500 Mbps</td>
          <td>跨 region 注意 egress cost</td>
      </tr>
  </tbody>
</table>
<p>實務 default：</p>
<ul>
<li>Daily full backup + 4 weeks retention</li>
<li>WAL archive every 60s（<code>archive_timeout = 60</code>）</li>
<li>跨 region replication（S3 → S3 cross-region）</li>
<li>月度 restore drill 驗證可用</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-patroni-ha-整合">跟 <a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> 整合</h3>
<p>Patroni 不管 backup，但 promotion 後 timeline 切換影響 archive：</p>
<ol>
<li>archive_command 用 <code>%t</code>（timeline）+ <code>%f</code>（filename）路徑、避免不同 timeline WAL 覆蓋</li>
<li>Patroni <code>recovery_conf</code> 包含 <code>restore_command</code>、standby clone 從 archive 拉</li>
<li>每次 Patroni failover 後跑 <em>full backup</em>、簡化未來 PITR</li>
</ol>
<h3 id="跟-logical-replication-對位">跟 <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">logical replication</a> 對位</h3>
<p>PITR 跟 logical replication 服務不同 use case：</p>
<ul>
<li>PITR 是 <em>災難恢復</em>（logical bug / corruption）— 全量還原到某時刻</li>
<li>Logical replication 是 <em>連續 sync</em> — Kafka / 跨 DB 即時複製</li>
</ul>
<p>兩者 <em>都依賴 WAL</em>、但目標不同；同 PostgreSQL 可同時跑、互不衝突。</p>
<h3 id="跟-monitoring--alert">跟 monitoring + alert</h3>
<p>關鍵 metric：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- archive 健康度
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_archiver</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- archived_count, failed_count, last_archived_wal, last_archived_time
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- WAL 在 pg_wal 等待 archive 量
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_ls_waldir</span><span class="p">()</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">~</span><span class="w"> </span><span class="s1">&#39;^[0-9A-F]{24}$&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- base backup 上次跑時間
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- (pgBackRest API 或 backup catalog)</span></span></span></code></pre></div><p>Prometheus alert 三條：archive failed_count 增、archive lag &gt; 5min、base backup &gt; 25h 沒跑。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Incremental backup（PG 17+）</strong>：base backup 不全量、只 base + incremental</li>
<li><strong>Block-level differential</strong>：pgBackRest 已支援</li>
<li><strong>Cloud-native 替代</strong>：RDS / Aurora 用 storage-layer snapshot、不走 PITR 鏈</li>
<li><strong><code>pg_dump</code> vs PITR</strong>：pg_dump 是 logical backup（resume to different schema OK）、PITR 是 physical（必須同 version + same arch）</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a></li>
<li>上游 chapter：<a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同 DB 內 schema 演進與資料變更的可分段驗證流程、跟 1.12 cross-DB migration 分工">Database Migration Playbook</a> — PITR 是 migration 的失敗回退</li>
<li>平行 deep article：<a href="/blog/backend/01-database/vendors/postgresql/patroni-ha/" data-link-title="PostgreSQL Patroni HA：從 leader 失聯到 client 重連的 5 段 failover lifecycle" data-link-desc="Patroni 把 PostgreSQL HA 拆成 detection / election / promotion / reconfiguration / recovery 五段 lifecycle、每段都有獨立配置跟 failure mode；DCS quorum &#43; watchdog 防 split-brain、async/sync replication 取捨、5 個 production 踩雷、跟 PgBouncer / HAProxy / cert-manager 整合">Patroni HA</a> / <a href="/blog/backend/01-database/vendors/postgresql/logical-replication-debezium/" data-link-title="PostgreSQL Logical Replication &#43; Debezium CDC：replication slot × failure × recovery 對照" data-link-desc="PostgreSQL logical replication slot 跟 Debezium CDC 的失效模式對照表：slot lag 撐爆 primary disk / schema change 斷流 / 初始 COPY 鎖表 / zombie slot 不釋放 / replay storm 後 offset reset；publication / subscription / pgoutput 配置、跟 Kafka outbox pattern 整合">Logical Replication + Debezium</a> / <a href="/blog/backend/01-database/vendors/postgresql/autovacuum-tuning/" data-link-title="PostgreSQL autovacuum tuning：為什麼你的 autovacuum 永遠追不上 bloat" data-link-desc="MVCC 怎麼產生 dead tuple、autovacuum cost-based throttle 為什麼預設保守、per-table tuning 怎麼設、5 個 production 踩雷（cost_limit 太低 / 長 transaction blocks vacuum / anti-wraparound 在 peak / partition vacuum 滿 worker / index bloat 沒處理）、跟 partitioning &#43; monitoring 整合">autovacuum tuning</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>mysqldump</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/mysqldump/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/mysqldump/</guid><description>&lt;p>mysqldump 是 MySQL 和 MariaDB 內建的命令列備份工具，把整個資料庫（或指定的表）匯出成一份包含 CREATE TABLE 和 INSERT 語句的 SQL 純文字檔。還原時把這份檔案餵給 &lt;code>mysql&lt;/code> client 就能重建資料。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>mysqldump 是有 SSH 存取（或 remote MySQL 存取）時的主要備份手段。比 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a> 的匯出更可靠——不受 web server 的 timeout 和記憶體限制影響，可以處理數 GB 的資料庫。沒有 SSH 的環境只能退回 phpMyAdmin 匯出。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手時如果 server 上有 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron&lt;/a> job 在跑 mysqldump，代表前任有做自動備份——確認輸出的 dump 檔案存在哪、保留幾天、有沒有被驗證過能還原。如果沒有任何 mysqldump cron，代表備份可能只靠 phpMyAdmin 手動匯出或完全沒做。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>常用的 flag 組合：&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">mysqldump -u user -p &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> --single-transaction &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> --routines &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> --triggers &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> dbname &amp;gt; dump-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Flag&lt;/th>
 &lt;th>作用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>--single-transaction&lt;/code>&lt;/td>
 &lt;td>InnoDB 表不鎖表匯出（用一致性快照），生產備份必備&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--routines&lt;/code>&lt;/td>
 &lt;td>含 stored procedure 和 function&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--triggers&lt;/code>&lt;/td>
 &lt;td>含 trigger&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--quick&lt;/code>&lt;/td>
 &lt;td>逐行讀取、不把整個表載入記憶體，大表必備&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>還原指令：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mysql -u user -p dbname &amp;lt; dump-20260626.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>mysqldump 產出的是邏輯備份（SQL 語句），還原速度取決於資料量——幾百 MB 以內分鐘級，數 GB 可能要半小時以上。需要更快的備份/還原（物理備份），要用 Percona XtraBackup 或 MySQL Enterprise Backup。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a>：無 SSH 時的替代備份手段&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron&lt;/a>：搭配 cron 做定期自動備份&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>mysqldump 是 MySQL 和 MariaDB 內建的命令列備份工具，把整個資料庫（或指定的表）匯出成一份包含 CREATE TABLE 和 INSERT 語句的 SQL 純文字檔。還原時把這份檔案餵給 <code>mysql</code> client 就能重建資料。</p>
<h2 id="概念位置">概念位置</h2>
<p>mysqldump 是有 SSH 存取（或 remote MySQL 存取）時的主要備份手段。比 <a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a> 的匯出更可靠——不受 web server 的 timeout 和記憶體限制影響，可以處理數 GB 的資料庫。沒有 SSH 的環境只能退回 phpMyAdmin 匯出。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手時如果 server 上有 <a href="/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron</a> job 在跑 mysqldump，代表前任有做自動備份——確認輸出的 dump 檔案存在哪、保留幾天、有沒有被驗證過能還原。如果沒有任何 mysqldump cron，代表備份可能只靠 phpMyAdmin 手動匯出或完全沒做。</p>
<h2 id="設計責任">設計責任</h2>
<p>常用的 flag 組合：</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">mysqldump -u user -p <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --single-transaction <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --routines <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --triggers <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  dbname &gt; dump-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.sql</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>Flag</th>
          <th>作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--single-transaction</code></td>
          <td>InnoDB 表不鎖表匯出（用一致性快照），生產備份必備</td>
      </tr>
      <tr>
          <td><code>--routines</code></td>
          <td>含 stored procedure 和 function</td>
      </tr>
      <tr>
          <td><code>--triggers</code></td>
          <td>含 trigger</td>
      </tr>
      <tr>
          <td><code>--quick</code></td>
          <td>逐行讀取、不把整個表載入記憶體，大表必備</td>
      </tr>
  </tbody>
</table>
<p>還原指令：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mysql -u user -p dbname &lt; dump-20260626.sql</span></span></code></pre></div><p>mysqldump 產出的是邏輯備份（SQL 語句），還原速度取決於資料量——幾百 MB 以內分鐘級，數 GB 可能要半小時以上。需要更快的備份/還原（物理備份），要用 Percona XtraBackup 或 MySQL Enterprise Backup。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a>：無 SSH 時的替代備份手段</li>
<li><a href="/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron</a>：搭配 cron 做定期自動備份</li>
</ul>
]]></content:encoded></item><item><title>MySQL Backup Restore Drill</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/hands-on/backup-restore-drill/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/mysql/hands-on/backup-restore-drill/</guid><description>&lt;p>MySQL backup restore drill 的核心責任是證明資料可以從 backup 回到可用狀態。這篇承接 &lt;a href="../../pitr-backup/">PITR / Backup&lt;/a>，用 logical dump 建立最小演練框架，並保留 physical backup / binlog PITR 的 evidence 欄位。&lt;/p>
&lt;p>本文的驗收標準是：你能產出 dump、記錄 binlog position、還原到隔離 database、跑 validation query，並寫下 RPO / RTO note。&lt;/p>
&lt;h2 id="create-backup">Create Backup&lt;/h2>
&lt;p>Create backup 的核心責任是建立可還原 artifact。&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">mkdir -p /tmp/mysql-backup-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysqldump -h 127.0.0.1 -P &lt;span class="m">33069&lt;/span> -u app_user -papp_pw &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> --single-transaction --routines --triggers appdb &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> &amp;gt; /tmp/mysql-backup-lab/appdb.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>記錄 binlog 狀態：&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">mysql -h 127.0.0.1 -P &lt;span class="m">33069&lt;/span> -u root -proot_pw -e &lt;span class="s2">&amp;#34;SHOW BINARY LOG STATUS;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--single-transaction&lt;/code> 適合 InnoDB consistent dump。大型 production 要評估 physical backup、backup lock、replication lag 與 binlog retention。&lt;/p>
&lt;h2 id="mutate-source">Mutate Source&lt;/h2>
&lt;p>Mutate source 的核心責任是讓 restore 時間點具體化。&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">mysql -h 127.0.0.1 -P &lt;span class="m">33069&lt;/span> -u app_user -papp_pw appdb &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -e &lt;span class="s2">&amp;#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key) VALUES (1, 777, &amp;#39;after-backup-write&amp;#39;);&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Source 現在比 backup 多一筆。這能用來討論 RPO 與 binlog PITR。&lt;/p>
&lt;h2 id="restore-isolated-database">Restore Isolated Database&lt;/h2>
&lt;p>Restore isolated database 的核心責任是避免覆蓋 source。&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">mysql -h 127.0.0.1 -P &lt;span class="m">33069&lt;/span> -u root -proot_pw &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> -e &lt;span class="s2">&amp;#34;DROP DATABASE IF EXISTS appdb_restore; CREATE DATABASE appdb_restore;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">mysql -h 127.0.0.1 -P &lt;span class="m">33069&lt;/span> -u root -proot_pw appdb_restore &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> &amp;lt; /tmp/mysql-backup-lab/appdb.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Validation：&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">mysql -h 127.0.0.1 -P &lt;span class="m">33069&lt;/span> -u root -proot_pw appdb_restore &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;SQL&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="s">SELECT COUNT(*) FROM accounts;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="s">SELECT COUNT(*) FROM ledger_entries;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="s">SELECT a.owner_name, SUM(l.amount_cents) AS balance_cents
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="s">FROM accounts a JOIN ledger_entries l ON l.account_id = a.id
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="s">GROUP BY a.owner_name;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="s">SQL&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Validation query 要和 application smoke test 對齊。正式 drill 還要啟動 app 指向 restore database。&lt;/p>
&lt;h2 id="rpo--rto-note">RPO / RTO Note&lt;/h2>
&lt;p>RPO / RTO note 的核心責任是把演練結果轉成服務承諾。&lt;/p></description><content:encoded><![CDATA[<p>MySQL backup restore drill 的核心責任是證明資料可以從 backup 回到可用狀態。這篇承接 <a href="../../pitr-backup/">PITR / Backup</a>，用 logical dump 建立最小演練框架，並保留 physical backup / binlog PITR 的 evidence 欄位。</p>
<p>本文的驗收標準是：你能產出 dump、記錄 binlog position、還原到隔離 database、跑 validation query，並寫下 RPO / RTO note。</p>
<h2 id="create-backup">Create Backup</h2>
<p>Create backup 的核心責任是建立可還原 artifact。</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">mkdir -p /tmp/mysql-backup-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqldump -h 127.0.0.1 -P <span class="m">33069</span> -u app_user -papp_pw <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --single-transaction --routines --triggers appdb <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  &gt; /tmp/mysql-backup-lab/appdb.sql</span></span></code></pre></div><p>記錄 binlog 狀態：</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">mysql -h 127.0.0.1 -P <span class="m">33069</span> -u root -proot_pw -e <span class="s2">&#34;SHOW BINARY LOG STATUS;&#34;</span></span></span></code></pre></div><p><code>--single-transaction</code> 適合 InnoDB consistent dump。大型 production 要評估 physical backup、backup lock、replication lag 與 binlog retention。</p>
<h2 id="mutate-source">Mutate Source</h2>
<p>Mutate source 的核心責任是讓 restore 時間點具體化。</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">mysql -h 127.0.0.1 -P <span class="m">33069</span> -u app_user -papp_pw appdb <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -e <span class="s2">&#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key) VALUES (1, 777, &#39;after-backup-write&#39;);&#34;</span></span></span></code></pre></div><p>Source 現在比 backup 多一筆。這能用來討論 RPO 與 binlog PITR。</p>
<h2 id="restore-isolated-database">Restore Isolated Database</h2>
<p>Restore isolated database 的核心責任是避免覆蓋 source。</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">mysql -h 127.0.0.1 -P <span class="m">33069</span> -u root -proot_pw <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -e <span class="s2">&#34;DROP DATABASE IF EXISTS appdb_restore; CREATE DATABASE appdb_restore;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">mysql -h 127.0.0.1 -P <span class="m">33069</span> -u root -proot_pw appdb_restore <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  &lt; /tmp/mysql-backup-lab/appdb.sql</span></span></code></pre></div><p>Validation：</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">mysql -h 127.0.0.1 -P <span class="m">33069</span> -u root -proot_pw appdb_restore <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">SELECT COUNT(*) FROM accounts;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">SELECT COUNT(*) FROM ledger_entries;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">SELECT a.owner_name, SUM(l.amount_cents) AS balance_cents
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">FROM accounts a JOIN ledger_entries l ON l.account_id = a.id
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">GROUP BY a.owner_name;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>Validation query 要和 application smoke test 對齊。正式 drill 還要啟動 app 指向 restore database。</p>
<h2 id="rpo--rto-note">RPO / RTO Note</h2>
<p>RPO / RTO note 的核心責任是把演練結果轉成服務承諾。</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th>記錄內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Backup time</td>
          <td>dump start / finish</td>
      </tr>
      <tr>
          <td>Binlog position</td>
          <td>file、position 或 GTID set</td>
      </tr>
      <tr>
          <td>Restore time</td>
          <td>開始 restore 到 validation 成功</td>
      </tr>
      <tr>
          <td>Data gap</td>
          <td>backup 後需要 binlog 補回的寫入</td>
      </tr>
      <tr>
          <td>Smoke test</td>
          <td>application workflow</td>
      </tr>
  </tbody>
</table>
<p>完成本篇後，binlog CDC 讀 <a href="../../binlog-cdc/">Binlog CDC</a>；PITR 策略讀 <a href="../../pitr-backup/">PITR / Backup</a>。</p>
]]></content:encoded></item><item><title>SQLite Backup Restore Drill</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/backup-restore-drill/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/backup-restore-drill/</guid><description>&lt;p>SQLite backup restore drill 的核心責任是證明單檔 database 可以被一致備份並還原。這篇承接 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/" data-link-title="SQLite file lifecycle 與 backup boundary" data-link-desc="把 SQLite 單檔案正式狀態拆成 WAL、backup API、restore drill、corruption recovery 與操作責任邊界">File lifecycle / backup boundary&lt;/a>，把備份從概念轉成 artifact、validation query 與 RPO / RTO note。&lt;/p>
&lt;p>本文的驗收標準是：你能從 live &lt;code>app.db&lt;/code> 建立 backup，將它還原到隔離路徑，通過 &lt;code>integrity_check&lt;/code> 與核心查詢，並記錄 restore duration。&lt;/p>
&lt;h2 id="prepare-source">Prepare Source&lt;/h2>
&lt;p>Prepare source 的核心責任是建立一個有 WAL 與資料變化的 live database。若你已跑過 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/" data-link-title="SQLite Local File Quickstart" data-link-desc="SQLite local .db file、schema、seed data、PRAGMA baseline、query sample 與 cleanup 的操作說明">local file quickstart&lt;/a>，可以直接沿用 &lt;code>/tmp/sqlite-lab/app.db&lt;/code>。&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">mkdir -p /tmp/sqlite-lab/backup /tmp/sqlite-lab/restore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/sqlite-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;PRAGMA journal_mode = WAL;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (2, 100, &amp;#39;backup-drill-1&amp;#39;, &amp;#39;2026-05-21T01:00:00Z&amp;#39;);&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一步讓 source database 有新的資料。後續會用 backup snapshot 和 source 後續寫入做對照。&lt;/p>
&lt;h2 id="create-backup">Create Backup&lt;/h2>
&lt;p>Create backup 的核心責任是用 SQLite-aware 方法建立一致 snapshot。SQLite CLI &lt;code>.backup&lt;/code> 會透過 SQLite backup API 產出目標檔案。&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">sqlite3 app.db &lt;span class="s2">&amp;#34;.backup &amp;#39;backup/app-backup.db&amp;#39;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 backup/app-backup.db &lt;span class="s2">&amp;#34;PRAGMA integrity_check;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>預期 &lt;code>integrity_check&lt;/code> 輸出 &lt;code>ok&lt;/code>。這是最小 backup evidence。&lt;/p>
&lt;p>&lt;code>VACUUM INTO&lt;/code> 也可以產出 compact copy，適合想順便整理檔案大小的情境。&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">sqlite3 app.db &lt;span class="s2">&amp;#34;VACUUM INTO &amp;#39;backup/app-vacuum-copy.db&amp;#39;;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 backup/app-vacuum-copy.db &lt;span class="s2">&amp;#34;PRAGMA integrity_check;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>.backup&lt;/code> 與 &lt;code>VACUUM INTO&lt;/code> 都要在 runbook 中標明使用條件、耗時、目標路徑與失敗處理。正式環境還要記錄檔案大小、checksum 與 storage retention。&lt;/p>
&lt;h2 id="mutate-source-after-backup">Mutate Source After Backup&lt;/h2>
&lt;p>Mutate source 的核心責任是確認 backup 是時間點 snapshot。備份後對 source 寫入新資料，再用 restore 驗證 backup 保持原時間點。&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">sqlite3 app.db &lt;span class="s2">&amp;#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 777, &amp;#39;after-backup-write&amp;#39;, &amp;#39;2026-05-21T01:05:00Z&amp;#39;);&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sqlite3 app.db &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM ledger_entries;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sqlite3 backup/app-backup.db &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM ledger_entries;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Source count 應比 backup count 多一筆。這個差異讓 RPO 討論具體化：backup 只保護到它建立的時間點。&lt;/p>
&lt;h2 id="restore-isolated-copy">Restore Isolated Copy&lt;/h2>
&lt;p>Restore isolated copy 的核心責任是避免把演練和 source 混在一起。把 backup 複製到 restore path，所有 validation 都對 restore file 執行。&lt;/p></description><content:encoded><![CDATA[<p>SQLite backup restore drill 的核心責任是證明單檔 database 可以被一致備份並還原。這篇承接 <a href="/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/" data-link-title="SQLite file lifecycle 與 backup boundary" data-link-desc="把 SQLite 單檔案正式狀態拆成 WAL、backup API、restore drill、corruption recovery 與操作責任邊界">File lifecycle / backup boundary</a>，把備份從概念轉成 artifact、validation query 與 RPO / RTO note。</p>
<p>本文的驗收標準是：你能從 live <code>app.db</code> 建立 backup，將它還原到隔離路徑，通過 <code>integrity_check</code> 與核心查詢，並記錄 restore duration。</p>
<h2 id="prepare-source">Prepare Source</h2>
<p>Prepare source 的核心責任是建立一個有 WAL 與資料變化的 live database。若你已跑過 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/local-file-quickstart/" data-link-title="SQLite Local File Quickstart" data-link-desc="SQLite local .db file、schema、seed data、PRAGMA baseline、query sample 與 cleanup 的操作說明">local file quickstart</a>，可以直接沿用 <code>/tmp/sqlite-lab/app.db</code>。</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">mkdir -p /tmp/sqlite-lab/backup /tmp/sqlite-lab/restore
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /tmp/sqlite-lab
</span></span><span class="line"><span class="ln">3</span><span class="cl">sqlite3 app.db <span class="s2">&#34;PRAGMA journal_mode = WAL;&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">sqlite3 app.db <span class="s2">&#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (2, 100, &#39;backup-drill-1&#39;, &#39;2026-05-21T01:00:00Z&#39;);&#34;</span></span></span></code></pre></div><p>這一步讓 source database 有新的資料。後續會用 backup snapshot 和 source 後續寫入做對照。</p>
<h2 id="create-backup">Create Backup</h2>
<p>Create backup 的核心責任是用 SQLite-aware 方法建立一致 snapshot。SQLite CLI <code>.backup</code> 會透過 SQLite backup API 產出目標檔案。</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">sqlite3 app.db <span class="s2">&#34;.backup &#39;backup/app-backup.db&#39;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 backup/app-backup.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p>預期 <code>integrity_check</code> 輸出 <code>ok</code>。這是最小 backup evidence。</p>
<p><code>VACUUM INTO</code> 也可以產出 compact copy，適合想順便整理檔案大小的情境。</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">sqlite3 app.db <span class="s2">&#34;VACUUM INTO &#39;backup/app-vacuum-copy.db&#39;;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 backup/app-vacuum-copy.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p><code>.backup</code> 與 <code>VACUUM INTO</code> 都要在 runbook 中標明使用條件、耗時、目標路徑與失敗處理。正式環境還要記錄檔案大小、checksum 與 storage retention。</p>
<h2 id="mutate-source-after-backup">Mutate Source After Backup</h2>
<p>Mutate source 的核心責任是確認 backup 是時間點 snapshot。備份後對 source 寫入新資料，再用 restore 驗證 backup 保持原時間點。</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">sqlite3 app.db <span class="s2">&#34;INSERT INTO ledger_entries(account_id, amount_cents, idempotency_key, created_at) VALUES (1, 777, &#39;after-backup-write&#39;, &#39;2026-05-21T01:05:00Z&#39;);&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sqlite3 app.db <span class="s2">&#34;SELECT COUNT(*) FROM ledger_entries;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">sqlite3 backup/app-backup.db <span class="s2">&#34;SELECT COUNT(*) FROM ledger_entries;&#34;</span></span></span></code></pre></div><p>Source count 應比 backup count 多一筆。這個差異讓 RPO 討論具體化：backup 只保護到它建立的時間點。</p>
<h2 id="restore-isolated-copy">Restore Isolated Copy</h2>
<p>Restore isolated copy 的核心責任是避免把演練和 source 混在一起。把 backup 複製到 restore path，所有 validation 都對 restore file 執行。</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">cp backup/app-backup.db restore/app-restored.db
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">sqlite3 restore/app-restored.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">sqlite3 restore/app-restored.db <span class="s">&lt;&lt;&#39;SQL&#39;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">.headers on
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">.mode column
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">SELECT account_id, SUM(amount_cents) AS balance_cents
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">FROM ledger_entries
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">GROUP BY account_id
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">ORDER BY account_id;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">SQL</span></span></span></code></pre></div><p>正式 restore drill 還要啟動 application 指向 <code>restore/app-restored.db</code>，跑核心 read/write smoke test。若 application 需要 migration，也要確認 restore file 的 <code>PRAGMA user_version</code> 與 app version 相容。</p>
<h2 id="rpo--rto-note">RPO / RTO Note</h2>
<p>RPO / RTO note 的核心責任是把演練結果轉成服務承諾。RPO 是可接受資料遺失窗口，RTO 是可接受恢復時間。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>本 lab 記錄方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RPO</td>
          <td>backup 建立時間到事故時間的資料差距</td>
      </tr>
      <tr>
          <td>RTO</td>
          <td>從取得 backup 到 app smoke test 成功耗時</td>
      </tr>
  </tbody>
</table>
<p>可以用 shell 的 <code>time</code> 記錄 restore duration。</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="nb">time</span> sqlite3 restore/app-restored.db <span class="s2">&#34;PRAGMA integrity_check;&#34;</span></span></span></code></pre></div><p>正式服務要把 RPO / RTO 寫進 <a href="/blog/backend/01-database/vendors/sqlite/observability-runbook/" data-link-title="SQLite Observability and Runbook" data-link-desc="SQLite production runbook、backup evidence、WAL growth、busy errors、disk usage、restore drill 與 incident route">observability / runbook</a>。</p>
<h2 id="known-gap">Known Gap</h2>
<p>Known gap 的核心責任是讓 lab 結果誠實。這個 drill 驗證 SQLite-aware backup 與 restore path；它尚未覆蓋 object storage credential、remote retention、large database restore time、encrypted disk、user device support flow 與 legal retention。</p>
<p>完成本篇後，下一步可以進入 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/wal-busy-reproduction/" data-link-title="SQLite WAL Busy Reproduction" data-link-desc="SQLite long transaction、SQLITE_BUSY、busy_timeout、checkpoint growth 與 writer queue 的操作說明">WAL busy reproduction</a> 觀察 writer boundary，或進入 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/" data-link-title="SQLite Migration Fixture Lab" data-link-desc="SQLite user_version、table rebuild migration、fixture snapshot、rollback note 與 CI evidence 的操作說明">migration fixture lab</a> 建立 schema change evidence。</p>
]]></content:encoded></item><item><title>SQLite file lifecycle 與 backup boundary</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/file-lifecycle-backup-boundary/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 embedded、local-first、edge 與低操作成本場景；本文聚焦 &lt;em>SQLite 檔案生命週期 + backup / restore 邊界&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite 的 file lifecycle 是把「一個資料庫檔案」升級成正式狀態的操作契約。SQLite 省掉 server process、帳號管理與網路連線，但它把 durability、backup、restore、locking 與 corruption recovery 放回 application process、filesystem 與 runbook；讀者要判斷的是這些責任是否已經有人承擔。&lt;/p>
&lt;p>這篇文章適合三種情境。第一種是 CLI、desktop、mobile 或 edge service 已經用 SQLite 保存正式資料；第二種是 single-instance backend 想用 SQLite 降低操作成本；第三種是 test fixture 用 SQLite，但需要知道哪些差異會讓 production database 的 bug 漏掉。&lt;/p>
&lt;h2 id="核心模型資料庫檔案是一組受-sqlite-管理的狀態檔">核心模型：資料庫檔案是一組受 SQLite 管理的狀態檔&lt;/h2>
&lt;p>SQLite 的資料庫狀態由 main database file 與 journal / WAL sidecar 共同構成。Rollback journal mode 會在寫入期間產生 journal file；WAL mode 會讓寫入先進入 &lt;code>-wal&lt;/code> 檔，並用 &lt;code>-shm&lt;/code> 檔協調 reader / writer。操作上看似「一個 &lt;code>.db&lt;/code> 檔」，production runbook 要把 sidecar file、checkpoint、backup API 與 restore test 一起納入。&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>.db&lt;/code>&lt;/td>
 &lt;td>持久化資料、schema、index&lt;/td>
 &lt;td>file owner、permission、storage durability、snapshot 位置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-wal&lt;/code>&lt;/td>
 &lt;td>WAL mode 下尚未 checkpoint 的寫入&lt;/td>
 &lt;td>WAL growth、checkpoint cadence、backup 是否包含一致快照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-shm&lt;/code>&lt;/td>
 &lt;td>WAL index 與跨 connection 協調&lt;/td>
 &lt;td>local filesystem lock 是否可靠、部署是否跨 process 共用檔案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>checkpoint&lt;/td>
 &lt;td>把 WAL 內容合併回 main database&lt;/td>
 &lt;td>checkpoint latency、writer pause、檔案大小是否持續膨脹&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>backup API&lt;/td>
 &lt;td>線上複製一致 snapshot&lt;/td>
 &lt;td>backup 是否在 application 還活著時仍能取得一致狀態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的讀法是先找「誰有權改檔案」。SQLite 的核心風險多半來自繞過 SQLite library 的檔案操作，例如直接 copy 活躍 WAL database、把 database 放在 lock 語意不可靠的 filesystem、或讓多個不協調的 process 同時寫同一份檔案。&lt;/p>
&lt;h2 id="wal-mode讀取並發提升後writer-boundary-仍然存在">WAL mode：讀取並發提升後，writer boundary 仍然存在&lt;/h2>
&lt;p>WAL mode 的工程價值是讓 reader 與 writer 的衝突下降。讀取可以看 main database 加上 WAL 中的 snapshot，寫入則 append 到 WAL；這讓 read-heavy workload 比 rollback journal mode 更容易撐住互動式服務。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 embedded、local-first、edge 與低操作成本場景；本文聚焦 <em>SQLite 檔案生命週期 + backup / restore 邊界</em>。</p></blockquote>
<p>SQLite 的 file lifecycle 是把「一個資料庫檔案」升級成正式狀態的操作契約。SQLite 省掉 server process、帳號管理與網路連線，但它把 durability、backup、restore、locking 與 corruption recovery 放回 application process、filesystem 與 runbook；讀者要判斷的是這些責任是否已經有人承擔。</p>
<p>這篇文章適合三種情境。第一種是 CLI、desktop、mobile 或 edge service 已經用 SQLite 保存正式資料；第二種是 single-instance backend 想用 SQLite 降低操作成本；第三種是 test fixture 用 SQLite，但需要知道哪些差異會讓 production database 的 bug 漏掉。</p>
<h2 id="核心模型資料庫檔案是一組受-sqlite-管理的狀態檔">核心模型：資料庫檔案是一組受 SQLite 管理的狀態檔</h2>
<p>SQLite 的資料庫狀態由 main database file 與 journal / WAL sidecar 共同構成。Rollback journal mode 會在寫入期間產生 journal file；WAL mode 會讓寫入先進入 <code>-wal</code> 檔，並用 <code>-shm</code> 檔協調 reader / writer。操作上看似「一個 <code>.db</code> 檔」，production runbook 要把 sidecar file、checkpoint、backup API 與 restore test 一起納入。</p>
<table>
  <thead>
      <tr>
          <th>檔案 / 機制</th>
          <th>服務責任</th>
          <th>操作判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.db</code></td>
          <td>持久化資料、schema、index</td>
          <td>file owner、permission、storage durability、snapshot 位置</td>
      </tr>
      <tr>
          <td><code>-wal</code></td>
          <td>WAL mode 下尚未 checkpoint 的寫入</td>
          <td>WAL growth、checkpoint cadence、backup 是否包含一致快照</td>
      </tr>
      <tr>
          <td><code>-shm</code></td>
          <td>WAL index 與跨 connection 協調</td>
          <td>local filesystem lock 是否可靠、部署是否跨 process 共用檔案</td>
      </tr>
      <tr>
          <td>checkpoint</td>
          <td>把 WAL 內容合併回 main database</td>
          <td>checkpoint latency、writer pause、檔案大小是否持續膨脹</td>
      </tr>
      <tr>
          <td>backup API</td>
          <td>線上複製一致 snapshot</td>
          <td>backup 是否在 application 還活著時仍能取得一致狀態</td>
      </tr>
  </tbody>
</table>
<p>這張表的讀法是先找「誰有權改檔案」。SQLite 的核心風險多半來自繞過 SQLite library 的檔案操作，例如直接 copy 活躍 WAL database、把 database 放在 lock 語意不可靠的 filesystem、或讓多個不協調的 process 同時寫同一份檔案。</p>
<h2 id="wal-mode讀取並發提升後writer-boundary-仍然存在">WAL mode：讀取並發提升後，writer boundary 仍然存在</h2>
<p>WAL mode 的工程價值是讓 reader 與 writer 的衝突下降。讀取可以看 main database 加上 WAL 中的 snapshot，寫入則 append 到 WAL；這讓 read-heavy workload 比 rollback journal mode 更容易撐住互動式服務。</p>
<p>WAL mode 同時保留 single writer boundary。SQLite 仍以檔案鎖與 transaction serialisation 控制寫入；寫入交易越長，其他 writer 等待時間越長，application 看到的訊號通常是 <code>SQLITE_BUSY</code>、latency spike 或 background job 卡住。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>常見原因</th>
          <th>第一輪處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SQLITE_BUSY</code> 增加</td>
          <td>長交易、background migration、慢 disk</td>
          <td>縮短 write transaction、加 busy timeout、把批次寫入切小</td>
      </tr>
      <tr>
          <td><code>-wal</code> 檔持續變大</td>
          <td>checkpoint 追不上、long reader 卡住</td>
          <td>找出長讀取、調整 checkpoint cadence、把 analytics query 移出路徑</td>
      </tr>
      <tr>
          <td>restore 後資料落差</td>
          <td>backup 沒取得一致 snapshot</td>
          <td>改用 <code>.backup</code> / backup API / <code>VACUUM INTO</code>，並演練 restore</td>
      </tr>
      <tr>
          <td>latency 受 fsync 拉高</td>
          <td><code>synchronous=FULL</code> + 高寫入頻率</td>
          <td>重新定義 durability 需求，評估 server SQL 或 managed service</td>
      </tr>
  </tbody>
</table>
<p>WAL mode 的 capacity gate 是「寫入是否仍能用一個 writer 排隊」。如果服務壓力來自大量並行寫入、多 instance active write 或跨 region 寫入，SQLite 的簡單性開始變成排隊與恢復成本；這時候要回到 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a>、<a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a> 或 <a href="/blog/backend/01-database/global-distributed-oltp/" data-link-title="1.11 全球分散式 OLTP" data-link-desc="Spanner / Aurora DSQL / Cosmos DB multi-region write / CockroachDB / TiDB 的全球一致性取捨">global distributed OLTP</a>。</p>
<h2 id="backup-boundary複製檔案與取得一致-snapshot-是兩件事">Backup boundary：複製檔案與取得一致 snapshot 是兩件事</h2>
<p>SQLite backup 的核心責任是取得某一時間點的一致 snapshot。當 database live 且 WAL mode 開啟時，直接複製 <code>.db</code> 檔容易漏掉 <code>-wal</code> 中尚未 checkpoint 的寫入；即使同時複製 sidecar file，也要面對複製期間狀態變動的 race。正式服務應使用 SQLite 提供的 backup path 或可驗證的 filesystem snapshot。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適合情境</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.backup</code> / Backup API</td>
          <td>live database、application 仍在服務</td>
          <td>SQLite 管理 source lock，產出開始備份時的一致 snapshot</td>
      </tr>
      <tr>
          <td><code>VACUUM INTO</code></td>
          <td>想同時 compact + 輸出新檔</td>
          <td>需要 I/O 空間與時間，適合 maintenance 或低流量窗口</td>
      </tr>
      <tr>
          <td>filesystem snapshot</td>
          <td>VM / volume 層已有一致 snapshot 能力</td>
          <td>要確認 snapshot 包含 main file 與 WAL sidecar，且 lock 語意清楚</td>
      </tr>
      <tr>
          <td>Litestream</td>
          <td>single-primary SQLite 的持續備份</td>
          <td>適合 DR / restore，不把 SQLite 變成 multi-primary database</td>
      </tr>
      <tr>
          <td>手動 <code>cp</code></td>
          <td>database 已關閉或已完成 checkpoint</td>
          <td>live WAL database 的一致性風險高，production runbook 應改路由</td>
      </tr>
  </tbody>
</table>
<p>Backup method 的選擇要先回到 <a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO</a> 與 <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a>。如果產品可以接受每天一次快照，<code>VACUUM INTO</code> 或 scheduled backup 足夠；如果資料損失窗口要降到分鐘級或秒級，就要看 Litestream 類連續複製，或直接升級到 server database 的 PITR / replica 模型。</p>
<h2 id="restore-drillsqlite-production-readiness-看還原不只看備份成功">Restore drill：SQLite production readiness 看還原，不只看備份成功</h2>
<p>Restore drill 的責任是證明備份能在事故時接回服務。SQLite 的備份檔通常只有一個 target file，表面上比 PostgreSQL PITR 或 MySQL binlog recovery 簡單；真正的風險在 application binary、schema migration version、file permission、deployment path 與舊 WAL sidecar 是否一起對齊。</p>
<p>一個最小 restore drill 應保留五個檢查點：</p>
<ol>
<li>從備份產出新的 database file，不覆蓋 production path。</li>
<li>用 application binary 啟動 read-only smoke test，確認 schema version 與 migration table。</li>
<li>跑 row count、critical query、checksum 或 domain validation query。</li>
<li>驗證 file owner、permission、disk path、SELinux / container mount 或 volume 設定。</li>
<li>以 incident decision log 記錄 restore time、data freshness、known gap 與 owner。</li>
</ol>
<p>Restore drill 的交付物應接回 <a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a> 與 <a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a>。SQLite 的低操作成本來自日常元件少；事故時仍需要 evidence、owner 與 rollback condition。</p>
<h2 id="corruption-recovery先保全證據再決定修復或還原">Corruption recovery：先保全證據，再決定修復或還原</h2>
<p>SQLite <a href="/blog/backend/knowledge-cards/corruption-recovery/" data-link-title="Corruption Recovery" data-link-desc="說明資料損毀事故如何先辨識來源、保全證據，再決定修復或還原">corruption recovery</a> 的核心責任是區分「資料庫檔案本身受損」與「application 寫入了錯誤資料」。前者要走 file-level evidence、<code>.recover</code>、backup restore 與 filesystem / hardware investigation；後者要走資料修復、migration rollback 或 business reconciliation。</p>
<table>
  <thead>
      <tr>
          <th>觀察訊號</th>
          <th>優先判讀</th>
          <th>下一步路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SQLITE_CORRUPT</code></td>
          <td>database page / btree 受損</td>
          <td>複製原檔保存證據、用 <code>.recover</code> 嘗試導出、從最近 backup 建新檔</td>
      </tr>
      <tr>
          <td>power loss 後啟動異常</td>
          <td>journal / WAL recovery 問題</td>
          <td>確認 sidecar file 是否仍在、檢查 storage sync 與 <code>synchronous</code> 設定</td>
      </tr>
      <tr>
          <td>restore 後 business data 錯誤</td>
          <td>備份點或 migration 錯誤</td>
          <td>對照 validation query、migration log、事件補償與 <a href="/blog/backend/01-database/reconciliation-data-repair/" data-link-title="1.9 Reconciliation 與 Data Repair" data-link-desc="資料不一致的分類、偵測模式、修復策略、audit trail、跟 backup / PITR 整合">reconciliation</a></td>
      </tr>
      <tr>
          <td>network filesystem 上偶發錯誤</td>
          <td>lock 語意與 filesystem 問題</td>
          <td>把 SQLite 移回 local disk，或升級 server database</td>
      </tr>
  </tbody>
</table>
<p>Corruption 事件的第一個操作是保存原始檔案與 sidecar。直接在疑似受損檔案上跑修復、vacuum 或 application migration，會讓後續 root cause analysis 失去證據；比較穩定的流程是複製原檔、在副本上嘗試 <code>.recover</code>，同時從備份恢復服務路徑。</p>
<h2 id="anti-recommendation維持-sqlite-的條件要可被操作驗證">Anti-recommendation：維持 SQLite 的條件要可被操作驗證</h2>
<p>SQLite 的合理使用條件是「單一 writer、檔案生命週期清楚、restore drill 成立」。只要這三件事能被 runbook 驗證，SQLite 在 embedded、desktop、mobile、edge-local 或 small backend 場景可以是 production state。</p>
<p>升級條件則來自操作責任外溢。需要 database user / role、中心化 audit、多人同時寫、跨 instance failover、online schema migration、PITR、read replica 或跨 region transaction 時，server SQL 或 managed SQL 的操作模型會比繼續包裝 SQLite 清楚。</p>
<table>
  <thead>
      <tr>
          <th>目前壓力</th>
          <th>留在 SQLite 的條件</th>
          <th>升級路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>read-heavy local store</td>
          <td>WAL + restore drill 成立</td>
          <td>維持 SQLite，補 observability 與 backup evidence</td>
      </tr>
      <tr>
          <td>single-instance backend</td>
          <td>writer queue 可接受、RPO / RTO 明確</td>
          <td>SQLite + Litestream；或升級 PostgreSQL / MySQL</td>
      </tr>
      <tr>
          <td>edge / serverless</td>
          <td>平台已提供 SQLite-compatible 運作模型</td>
          <td>Cloudflare D1 / Turso；跨 region transaction 回到 global DB</td>
      </tr>
      <tr>
          <td>multi-tenant SaaS</td>
          <td>tenant 數少且 file ownership 清楚</td>
          <td>PostgreSQL / Aurora / CockroachDB</td>
      </tr>
      <tr>
          <td>regulated data</td>
          <td>backup encryption、audit、restore 可驗證</td>
          <td>PostgreSQL / managed SQL + audit / PITR</td>
      </tr>
  </tbody>
</table>
<p>這張表的核心是把操作責任具體化，而非替 SQLite 設流量天花板。小型服務可能用 SQLite 長期穩定運作；同樣流量下，一旦合規、稽核、多人操作或 HA 需求進來，server database 的長期成本會更容易被治理。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite production runbook 至少要能回答下列問題：</p>
<ol>
<li>Database file、WAL sidecar 與 backup target 在哪個 volume、由誰擁有。</li>
<li><code>journal_mode</code>、<code>synchronous</code>、busy timeout、checkpoint cadence 與 migration policy 如何設定。</li>
<li>Backup 用 <code>.backup</code> / backup API / <code>VACUUM INTO</code> / Litestream 的哪一條路徑。</li>
<li>Restore drill 最近一次何時執行，RPO / RTO 是否符合產品承諾。</li>
<li><code>SQLITE_BUSY</code>、WAL growth、disk full、backup failure 與 restore failure 如何告警。</li>
<li>Corruption recovery 時誰保存原檔、誰啟動 restore、誰決定修復或 fail-forward。</li>
</ol>
<p>這份清單要接到服務 ownership，而非留在工程師個人習慣。SQLite 的優勢是 deployment surface 小；production 化的代價是把檔案、備份與恢復流程寫進同一份可交接 runbook。</p>
<h2 id="引用路徑">引用路徑</h2>
<ul>
<li>上游 overview：<a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite vendor page</a></li>
<li>服務責任：<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth</a>、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">Database</a></li>
<li>恢復目標：<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO</a>、<a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a></li>
<li>證據交接：<a href="/blog/backend/04-observability/observability-evidence-package/" data-link-title="4.20 Observability Evidence Package" data-link-desc="把 log、metric、trace、audit 與資料品質限制包成可交接證據">Observability Evidence Package</a>、<a href="/blog/backend/08-incident-response/incident-decision-log/" data-link-title="8.19 Incident Decision Log" data-link-desc="把事中假設、決策、證據、回退條件與責任人留下可復盤紀錄">Incident Decision Log</a></li>
<li>官方文件：<a href="https://www.sqlite.org/wal.html">SQLite Write-Ahead Logging</a>、<a href="https://www.sqlite.org/backup.html">SQLite Backup API</a>、<a href="https://www.sqlite.org/howtocorrupt.html">How To Corrupt An SQLite Database File</a>、<a href="https://www.sqlite.org/recovery.html">Recovering Data From A Corrupt SQLite Database</a>、<a href="https://www.sqlite.org/whentouse.html">Appropriate Uses For SQLite</a>、<a href="https://www.sqlite.org/mostdeployed.html">Most Widely Deployed SQL Database Engine</a></li>
<li>延伸工具：<a href="https://litestream.io/reference/restore/">Litestream restore reference</a>、<a href="https://litestream.io/getting-started/">Litestream getting started</a></li>
</ul>
]]></content:encoded></item></channel></rss>