實體主機 SQLite 線上備份:WAL 一致性與還原驗證

實體主機 SQLite 線上備份:WAL 一致性與還原驗證

實體主機 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/月起

設定 OnuCloud 實體主機 查看 OnuCloud 租用價格

依工作負載選擇實體主機配置

AMD EPYC 7543P 獨享整機;儲存備援與資料備份由管理員自行規劃。