實體主機 SQLite 線上備份:WAL 一致性與還原驗證
備份工作顯示成功,還原後卻少了最近一批紀錄,這類故障在使用 SQLite WAL 模式的檔案服務中特別容易被誤判。檔案複製成功,只能證明位元組已搬移完畢,不能證明資料庫處於一致狀態。對於持續記錄檔案索引、工作狀態或備份清單的實體主機,應將「產生副本」與「證明副本可用」分成兩個步驟。
1. 先釐清 WAL 與備份的界線
在 WAL 模式下,已提交的變更可能先保存在 -wal 檔案中,之後才透過檢查點寫回資料庫主檔。服務仍在運作時執行 cp service.db backup.db,可能會遺漏這些變更;依序複製主檔、WAL 與共享記憶體檔案,也不代表取得了同一時間點的快照。
線上備份介面透過資料庫連線讀取一致的內容,而不是盲目複製持續變動的檔案。這種方式適合服務無法停止寫入的情境,但仍會產生讀取與寫入目標端的負載。若能關閉所有連線,離線備份會更簡單;不過,停止寫入的範圍必須涵蓋背景工作,不能只停止前端請求。
WAL 不是異地備份,鏡像也不是備份。可靠的副本必須位於來源資料庫的故障範圍之外,並經過獨立讀取驗證。
OnuCloud 實體主機標配兩顆 1TB NVMe,各自直接連接,沒有硬體 RAID。將副本寫入另一顆硬碟,可以分離部分讀寫負載,但兩顆硬碟仍在同一台機器內,不能取代異地保存。
2. 建立可重複驗證的持續寫入範例
以下實驗適用於 Debian 12 等具備 Python 3 與 sqlite3 標準函式庫模組的環境。先檢查模組,再建立獨立的暫存目錄;所有操作都只針對實驗檔案,不會存取正式環境的資料庫,也不需要修改系統服務。
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
umask 077
export LAB_DIR="$(mktemp -d)"
printf '%s\n' "$LAB_DIR"
範例使用兩張資料表:events 儲存明細,totals 儲存累計值。每次交易都會同時新增一筆明細並增加累計值,因此副本必須符合「明細金額總和等於累計值」。這項條件比檢查檔案是否存在更有意義,也能說明結構完整與業務正確是兩種不同的驗收。
下方指令碼內含寫入執行緒與備份流程。目錄由前一步傳入,每次重新驗證時都應建立新目錄,避免與舊範例混用。正式環境的資料庫通常更複雜,應另外定義跨資料表關係與應用程式版本要求,不能直接套用這個計數模型。
3. 使用線上備份介面產生並檢查副本
指令碼會先完成一次提交,再讓寫入執行緒持續執行,同時進行線上備份。目標端先使用暫存檔,只有完整性檢查、外鍵檢查與業務不變條件全部通過後,才重新命名為正式實驗副本。任何步驟發生例外,都會使指令碼失敗,不會發布新副本。
python3 - <<'PY'
import os
import sqlite3
import threading
import time
from pathlib import Path
base = Path(os.environ["LAB_DIR"])
source = base / "source.db"
pending = base / "backup.pending.db"
published = base / "backup.db"
db = sqlite3.connect(source)
mode = db.execute("PRAGMA journal_mode=WAL").fetchone()[0]
if mode.lower() != "wal":
raise RuntimeError("WAL mode unavailable")
db.executescript("""
CREATE TABLE events (
n INTEGER PRIMARY KEY,
amount INTEGER NOT NULL
);
CREATE TABLE totals (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance INTEGER NOT NULL
);
INSERT INTO totals VALUES (1, 0);
""")
db.close()
stop = threading.Event()
ready = threading.Event()
errors = []
def writer():
conn = None
try:
conn = sqlite3.connect(source, timeout=5)
while not stop.is_set():
with conn:
conn.execute("INSERT INTO events(amount) VALUES (1)")
conn.execute(
"UPDATE totals SET balance = balance + 1 WHERE id = 1"
)
ready.set()
time.sleep(0.01)
except Exception as exc:
errors.append(exc)
ready.set()
finally:
if conn is not None:
conn.close()
worker = threading.Thread(target=writer)
worker.start()
try:
if not ready.wait(5):
raise RuntimeError("Writer did not become ready")
if errors:
raise RuntimeError("Writer failed") from errors[0]
src = sqlite3.connect(source, timeout=5)
dst = sqlite3.connect(pending, timeout=5)
try:
src.backup(dst, pages=128, sleep=0.01)
finally:
dst.close()
src.close()
finally:
stop.set()
worker.join()
if errors:
raise RuntimeError("Writer failed") from errors[0]
check = sqlite3.connect(pending)
try:
result = check.execute("PRAGMA integrity_check").fetchall()
if result != [("ok",)]:
raise RuntimeError("Integrity check failed")
if check.execute("PRAGMA foreign_key_check").fetchall():
raise RuntimeError("Foreign key check failed")
count, amount = check.execute(
"SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM events"
).fetchone()
balance = check.execute(
"SELECT balance FROM totals WHERE id = 1"
).fetchone()[0]
if not (count > 0 and count == amount == balance):
raise RuntimeError("Business invariant failed")
finally:
check.close()
os.replace(pending, published)
print(f"Verified rows: {count}")
print(published)
PY
pages=128 指每一輪處理的資料庫頁面數,不是位元組數;sleep 主要用於資料庫忙碌時的重試,並不是固定的傳輸速率限制器。正式執行時,應記錄耗時與失敗原因,並為整個工作設定截止時間。持續高頻寫入可能拉長備份時間,逾時應判定為失敗,而不是將尚未完成的檔案標示為成功。
這裡的重新命名是為了區分待驗證與已驗收的副本,不代表具備斷電後的持久性保證。正式備份流程仍需配合檔案同步、目錄同步及目標儲存系統的語意,設計資料持久化的界線。
4. 隔離還原,避免用來源資料庫替副本作證
備份結束後,來源資料庫可能已繼續寫入,因此不能要求副本的資料列數等於稍後查詢到的來源資料庫列數。應先檢查副本本身是否一致,再依備份時間區間判斷資料的新鮮度。線上備份不是持續複寫,也不會自動提供還原至任意時間點的能力。
從正式副本另行建立還原測試副本
實驗副本的連線已關閉,可以離線複製到另一個目錄。以下以唯讀模式開啟還原測試副本,確認資料表結構與關鍵關係;不要將還原測試的連線指向 source.db,否則驗證的會是原始資料庫。
mkdir "$LAB_DIR/restore"
cp "$LAB_DIR/backup.db" "$LAB_DIR/restore/service.db"
python3 - <<'PY'
import os
import sqlite3
from pathlib import Path
path = Path(os.environ["LAB_DIR"]) / "restore" / "service.db"
conn = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
try:
print(conn.execute("PRAGMA integrity_check").fetchall())
print(conn.execute("PRAGMA foreign_key_check").fetchall())
print(conn.execute("""
SELECT
(SELECT COALESCE(SUM(amount), 0) FROM events),
(SELECT balance FROM totals WHERE id = 1)
""").fetchone())
finally:
conn.close()
PY
通過條件是完整性檢查結果為 ok、外鍵檢查未傳回任何資料列,且兩個累計值相等。範例沒有外鍵,因此外鍵檢查結果為空,只能證明檢查流程可以執行;實際業務應保留真實的約束條件進行驗證。
正式環境的驗收還必須使用相符版本的應用程式開啟測試副本,讀取檔案索引或工作清單,並停用郵件、回呼與自動執行工作。資料庫能開啟,不代表外部檔案也完整。
5. 將單次實驗轉為備份驗收條件
上線前至少應保留四項證據:來源資料庫路徑與應用程式版本、備份起訖時間、檢查結果、隔離讀取結果。時間用於判斷資料新鮮度,不應解讀為精確的交易截止點;有明確還原點需求的業務,應另外設計提交序號與還原目標。
備份目錄應限制存取權限,避免資料庫中的權杖、使用者資料或工作參數隨副本外洩。副本傳送至異地後,應再次核對檔案校驗值,並重做還原測試;校驗值一致只能證明傳輸完整,不能取代資料庫驗收。
失敗時應保留上一份已驗證的副本,待驗證檔案另外處理,不要用空檔案覆蓋成功紀錄。若資料庫參照附件、索引分片或其他資料庫,單一資料庫的線上備份不會提供跨資源的原子性,必須透過業務停止寫入或版本清單來協調。
最終驗收條件不是「複製命令成功執行」,而是「副本獨立存在、結構檢查通過、業務關係成立、應用程式能夠隔離讀取」。這四項條件同時成立,備份才具有可驗證的還原價值。
常見問題
SQLite 使用 WAL 時,可以只複製資料庫主檔作為線上備份嗎?
不能將此視為可靠的線上備份流程。已提交的交易可能仍在 WAL 中,分別複製主檔與 WAL 也不保證來自相同時間點。應使用 SQLite 線上備份介面,或關閉所有連線後依離線流程複製。
PRAGMA integrity_check 回傳 ok,就代表備份可以還原嗎?
不代表。它主要檢查資料庫內部結構,還需要另外執行 foreign_key_check,並於隔離副本驗證關鍵資料表、業務不變條件及應用程式讀取路徑。
AMD EPYC 7543P 獨立實體主機
$472/月起