chundev
Created: 2026-08-24
日期:2026-08-24
環境:macOS、APFS、Node.js、SQLite WAL、加密 SQLite CLI
正在被桌面應用程式使用的 SQLite WAL 資料庫,不能只複製主 .db 檔;已提交但尚未 checkpoint 的資料可能只存在 -wal。若因隔離要求不能用 SQLite 開啟來源,可在每次查詢前偵測 DB/WAL 變動,以新目錄複製 DB+WAL,核對複製前後 stat signature、來源/副本 SHA-256,再用副本實際開庫驗證;任何一步失敗就 fail closed。
這套方法在一次實測中,把約 70 MiB 主 DB 與約 2 MiB WAL 同步成第二份版本化 snapshot,查詢立即讀到新增紀錄,來源主 DB hash 維持不變。它是「來源不可被 SQLite 開啟」時的工程折衷,不是 SQLite Online Backup API 的形式化替代品。
許多桌面應用程式把本機狀態存進 SQLite。
典型需求不是「備份一次」而已,而是:
這類情境常出現在:
表面上看,只要定期 cp database.db snapshot.db。
但 WAL mode 改變了「資料庫狀態」的定義。
主 DB 不再等於完整的最新狀態。
WAL mode 的 writer 先把變更追加到 database.db-wal。
在 checkpoint 以前:
database.db 可以完全沒變。database.db-wal 會增加 frame。這不是寫入延遲,也不是 cache bug。
它是 WAL 的正常行為。
可能原因包括:
-shm。database disk image is malformed若 DB 與 journal/WAL 配對錯誤,副本可能包含:
SQLite 官方明確警告:transaction 進行中直接複製檔案,可能混合新舊內容;存在 journal 或 WAL 時,主檔與 journal 必須一起處理。
-shmWAL reader 不只讀主 DB 與 WAL。
它通常還需要 wal-index。
wal-index 通常映射到 database.db-shm。
新版 SQLite 可以在特定條件下讀取 read-only WAL database,但這不代表「開 read-only connection 絕不影響任何來源輔助檔」。
若隔離契約要求來源目錄完全不能被 SQLite runtime 接觸,最乾淨的界線是:
只在檔案層讀取來源,所有 SQLite open、WAL recovery 與 wal-index 建立都發生在副本目錄。
database.db 保存已 checkpoint 的 page image。
它是基底。
它不保證包含最新 commit。
database.db-wal 包含:
SQLite reader 開始 transaction 時會決定一個 end mark。
同一個 transaction 只讀到 end mark 以前的最後有效 frame。
因此 writer 可以繼續 append,而既有 reader 仍看到一致視圖。
database.db-shm 是 wal-index 的常見實作。
它的目的不是保存不可重建的業務資料。
它加速「某個 page 的最新 frame 在 WAL 哪裡」的查找。
SQLite 原始碼說明了幾個重要特性:
因此,建立隔離副本時通常應:
這個選擇降低了把來源 process-specific wal-index 狀態帶進副本的風險。
| 誤解 | 實際情況 | 為什麼會搞混 |
|---|---|---|
| 主 DB mtime 沒變代表沒有新資料 | 新 commit 可能只在 WAL | rollback journal 心智模型仍停留在主檔 |
-wal 只是暫存,可以不備份 |
WAL 是 persistent database state 的一部分 | 檔名看起來像 cache |
-shm 和 WAL 一樣不可缺 |
SHM 是可重建 wal-index | 三個檔案總是一起出現 |
readonly 表示零副作用 |
WAL reader 仍可能需要 SHM 或目錄權限 | 把 SQL read-only 與 filesystem zero-write 混為一談 |
| copy-on-write clone 是一致性 snapshot | clone 只降低複製成本,不凍結跨檔案時間點 | filesystem clone 與 database transaction snapshot 名稱相似 |
| 複製完成就代表可用 | 仍需 hash、open、schema 與業務查詢驗證 | 把 I/O success 當成 database correctness |
| 同步失敗時回舊資料比較友善 | 對 freshness-sensitive agent,silent stale 是假成功 | availability 優先的直覺不適用所有產品 |
mtime 相同足以證明來源沒變 |
timestamp 解析度、檔案替換與 checkpoint 都可能誤導 | stat 很快,容易被當成完整驗證 |
| 方案 | 一致性 | 是否開啟來源 DB | 對來源影響 | 適用情境 |
|---|---|---|---|---|
| SQLite Online Backup API | 高 | 是 | 使用正常 SQLite locking | 能合法建立來源 connection |
VACUUM INTO |
高 | 是 | 需要執行 SQL、建立一致副本 | 可控制來源 SQLite connection |
sqlite3_rsync |
高 | 是/依工具 | 官方 live-copy protocol | 遠端或標準 SQLite |
| 停止應用後複製 DB+WAL | 高 | 否 | 有停機 | 可接受短暫停止 |
| Filesystem snapshot | 取決於 FS 跨檔一致性 | 否 | 很低 | 有 volume snapshot 能力 |
| DB+WAL stable-copy protocol | 工程驗證型 | 否 | 只有檔案 read | 禁止 SQLite 接觸來源 |
| 只複製主 DB | 低 | 否 | 低 | 僅限確認沒有 WAL/journal |
如果能開啟來源 DB:
VACUUM INTO。sqlite3_rsync。如果不能開啟來源 DB:
本文方法不是一次原子的跨檔案 copy。
它採用「複製後證明來源在觀察窗內沒有可見變化」的策略。
對主 DB 與 WAL 取得:
概念表示:
db(dev, inode, size, mtimeNs) | wal(dev, inode, size, mtimeNs)
signature 用來做快速 dirty check。
它不是資料完整性的最終證據。
一次候選同步包含:
S0 = stat(DB, WAL)
copy(DB)
copy(WAL)
S1 = stat(DB, WAL)
hash(source DB, copied DB)
hash(source WAL, copied WAL)
S2 = stat(DB, WAL)
validate(copied DB + copied WAL)
接受條件:
S0 == S1 == S2
sourceDbHash == copiedDbHash
sourceWalHash == copiedWalHash
validation == success
S0 建立複製前基準。
S1 攔截 copy 過程中的變動。
S2 攔截 hash 過程中的變動。
如果只做 S0 == S1:
stat 相同不表示 bytes 必然相同。
SHA-256 用來檢查:
SHA-256 在這裡不是用來抵抗攻擊者碰撞。
它的角色是強內容等同性檢查。
NIST FIPS 180-4 定義 SHA-256 的 digest 行為。
這個 protocol 仍有理論限制:
因此對一般本機單 writer 桌面應用屬於務實方案。
對金融帳本、醫療寫入或法規備份,不應以此取代 SQLite 原生 backup。
背景 polling 的成本包括:
Query-time refresh 把 freshness 成本放到真正需要資料的時刻。
流程:
Agent request
|
v
ensureFresh()
|
+-- signature unchanged --> active snapshot
|
+-- signature changed ----> stable copy
|
+-- valid --> new active snapshot
|
+-- invalid --> fail closed
|
v
read-only query
設定值應分成三類。
HOST=127.0.0.1
LINE_DB_PATH=.data/fallback.db
LINE_SOURCE_DB_PATH=/absolute/path/to/source.db
LINE_SNAPSHOT_DIR=.data/snapshots
SQLITE_BIN=.local/bin/sqlite-tool
.env.example 只能放 placeholder。
真實絕對路徑不應公開。
.secrets/database-key
.secrets/api-token
要求:
0600。.data/fallback.db
.data/snapshots/
.local/bin/sqlite-tool
全部加入 .gitignore。
以下是簡化 TypeScript:
import { stat } from "node:fs/promises";
async function signature(databasePath: string): Promise<string> {
const db = await stat(databasePath, { bigint: true });
const wal = await stat(`${databasePath}-wal`, { bigint: true });
return [
db.dev,
db.ino,
db.size,
db.mtimeNs,
wal.dev,
wal.ino,
wal.size,
wal.mtimeNs,
].join(":");
}
實務上還要處理:
WAL 不存在不一定是錯誤。
可能原因:
因此 signature 應明確包含 wal:none,而不是把 ENOENT 一律視為致命錯誤。
每次同步都建立新目錄:
.data/snapshots/.incoming/candidate-ABC123/
候選目錄的規則:
.incoming。成功後才 rename:
.data/snapshots/snapshot-2026-08-24T14-27-38Z-ABC123/
這個 rename 的目的不是創造 DB atomicity。
DB atomicity 已由前面的 validation 判斷。
rename 的目的,是讓「可被 loader 看見」成為單一步驟。
Node.js 提供:
import { constants } from "node:fs";
import { copyFile } from "node:fs/promises";
await copyFile(source, destination, constants.COPYFILE_FICLONE);
COPYFILE_FICLONE 的語意是:
如果要求沒有 clone 就失敗:
await copyFile(source, destination, constants.COPYFILE_FICLONE_FORCE);
假設主 DB 是 70 MiB。
每次一般 copy 都要:
clone 可共享未改變 extent。
只有任一版本後續寫入時,相關 block 才分裂。
clone 不保證:
所以 clone 是成本最佳化,不是一致性證明。
Apple 的 APFS 文件列出 clonefile 與 COPYFILE_CLONE API。
Node 文件也明確說 copyFile() 本身不保證 atomicity。
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
async function sha256(filePath: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk as Buffer);
}
return hash.digest("hex");
}
驗證主 DB:
const [sourceHash, copiedHash] = await Promise.all([
sha256(sourceDb),
sha256(copiedDb),
]);
if (sourceHash !== copiedHash) {
throw new Error("Copied database hash does not match the source");
}
WAL 使用相同做法。
只 hash 副本只能得到 fingerprint。
它不能證明:
Hash 通常不是 secret。
但來源檔 hash 可能:
公開文章可用範例 hash。
內部 verification report 再保留精確值。
檔案 hash 相同仍不代表資料庫可查。
最後一定要實際開庫。
最低驗證:
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table';
更好的驗證:
SELECT
(SELECT COUNT(*) FROM sqlite_master WHERE type = 'table') AS tables,
(SELECT COUNT(*) FROM message_table) AS messages;
驗證應確認:
PRAGMA key 回成功不代表 key 正確某些加密 SQLite wrapper 會接受 key 設定動作。
錯誤 key 可能直到第一次 page read 才失敗。
因此必須執行真正讀取 sqlite_master 的 query。
錯誤做法:
sqlite-tool database.db "PRAGMA key='secret'; SELECT ..."
問題:
較好的做法是經 stdin 傳入,並避免 echo 到 log。
wal.c source — 官方 Git mirror 的 WAL frame、wal-index、SHM transient 性質與 reader end mark 實作註解。COPYFILE_FICLONE、fallback 行為,以及 copyFile() 不保證 atomicity。COPYFILE_CLONE 與 safe-save API 概覽。