雲端 Mac 上的 iOS SQLite 讀取快照驗收:交易一致性與檢查點邊界

雲端 Mac 上的 iOS SQLite 讀取快照驗收:交易一致性與檢查點邊界

背景同步已提交新紀錄,清單重新整理後卻仍顯示舊值;重建快取後問題暫時消失,下一次批次讀取又再次出現。這時應先檢查讀取是否仍停留在同一個 SQLite 交易中。在雲端 Mac 上用兩個獨立連線重現,比反覆操作遠端桌面更容易區分資料庫語意與介面快取問題。

先定義讀取必須遵守的契約

本文只討論本機檔案資料庫的 WAL 模式,不將結論套用到記憶體資料庫或共用快取組態。準備好可用的 Python 3 與 SQLite 模組,先執行 python3 -c 'import sqlite3; print(sqlite3.sqlite_version)',並將輸出連同測試紀錄一起保存。

驗收目標是:讀取連線執行首次查詢後,寫入連線仍可提交;原有的讀取交易仍會讀到舊值;讀取交易結束後,再次查詢才會看到新值。這裡的「舊」是交易一致性的表現,不是資料遺失。

不要為了讓清單「更即時」,就在需要一致檢視的多資料表讀取過程中任意提交交易。應先決定業務需要的是同一份快照,還是每次查詢都讀取最新提交的資料。

測試必須使用實際的暫存檔案與兩個連線。兩個一般的 :memory: 連線各自擁有獨立的資料庫,無法驗證這組互動。

用雙連線建立最小實驗

將以下內容儲存為 snapshot_check.py,執行 python3 snapshot_check.py。腳本不會連線到業務資料庫,暫存目錄會在離開其作用範圍後自動刪除。所有游標都會立即取完結果並關閉,避免將尚未結束的陳述式誤判為顯式交易問題。

import sqlite3
import tempfile
from pathlib import Path

def one(db, sql):
    cursor = db.execute(sql)
    try:
        return cursor.fetchone()
    finally:
        cursor.close()

with tempfile.TemporaryDirectory() as root:
    path = Path(root) / "snapshot.sqlite"
    writer = sqlite3.connect(path, isolation_level=None)
    reader = sqlite3.connect(path, isolation_level=None)
    try:
        assert one(writer, "PRAGMA journal_mode=WAL")[0] == "wal"
        writer.execute("PRAGMA wal_autocheckpoint=0")
        writer.execute("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)")
        writer.execute("INSERT INTO item VALUES(1, 'old')")
        one(writer, "PRAGMA wal_checkpoint(TRUNCATE)")

        reader.execute("BEGIN")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"

        writer.execute("BEGIN IMMEDIATE")
        writer.execute("UPDATE item SET value='new' WHERE id=1")
        writer.execute("COMMIT")

        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"
        held = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert held[1] > held[2], held

        reader.execute("COMMIT")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "new"

        released = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert released[1] == released[2], released
        print({"held": held, "released": released, "result": "passed"})
    finally:
        if reader.in_transaction:
            reader.rollback()
        if writer.in_transaction:
            writer.rollback()
        reader.close()
        writer.close()

程式碼明確停用自動檢查點,只是為了方便觀察實驗過程,並非正式環境的設定建議。初始化後的截斷操作也僅適用於這個由測試獨占的夾具;不要直接套用到正在處理任務的資料庫。

分清 BEGIN 與首次讀取

一般的 BEGIN 啟動的是延遲交易,不能直接將執行這一行的時間視為快照時間。上述實驗是在第一次查詢時,才建立讀取檢視。

接著另做一個獨立變體:將寫入與提交移到讀取連線的第一次查詢之前。此時,第一次查詢與同一交易中的後續查詢都應取得 new,斷言也必須同步調整。

這能釐清一種常見誤判:日誌記錄了「交易開始」,工程師便認為後續所有讀取都對應到那個時間點。診斷時至少應記錄連線編號、交易編號、首次查詢與交易結束的位置,不要只記錄方法的進入點。

多次查詢是否必須一致

例如先查詢訂單總數,再查詢明細;若兩次查詢應屬於同一份業務快照,就應放在一個短時間的讀取交易中。若每次捲動載入都需要最新資料,就不應將整個頁面生命週期包在同一個交易裡。驗收標準應由業務邊界決定,而不是看哪種方式似乎更新得更快。

觀察檢查點進度,不只看檔案大小

wal_checkpoint(PASSIVE) 會傳回三個整數,依序代表忙碌狀態、日誌影格數與已完成檢查點的影格數。夾具中原有的讀取交易仍需要較早的檢視,因此部分新影格無法完成檢查點;釋放讀取交易後,再次執行便能推進。

不要以固定數值斷言某次執行恰好產生多少影格。頁面大小、資料庫配置與執行階段程式庫版本都會影響數量,測試應檢查相對關係。

同樣地,已完成檢查點的影格數追上日誌影格數,不代表 WAL 檔案會立即縮小。檔案可以保留空間,供後續重複使用。觀察正式環境時,應同時記錄讀取交易持續時間、檢查點結果與寫入趨勢,不能只因為目錄裡有一個大檔案就刪除日誌。

若第二個斷言失敗,先找出仍開啟的讀取交易、游標或其他連線,再分析檢查點策略。不要先加入迴圈強制截斷,反而掩蓋真正持續過久的讀取交易。

將夾具移入 iOS 資料層測試

主機端實驗驗證的是目前 Python 所連結的 SQLite。移入 iOS 後,必須在目標模擬器或裝置上,透過實際的資料存取層重新執行,並記錄該環境的 sqlite3_libversion(),不能將主機端結果視為所有執行環境的證明。

保留兩個真正獨立的底層連線,以明確的測試同步屏障控制「首次讀取完成、寫入提交、再次讀取」三個階段,不要依賴睡眠等待。若連線集區將兩次操作排程到同一個連線,實驗就失去意義。

最後檢查四項:舊交易讀取舊值、新交易讀取新值、例外路徑也會結束交易,以及長時間讀取交易釋放後,檢查點能繼續推進。非同步查詢還必須確認,取消後游標已關閉、交易也已結束。將這些斷言保留在回歸測試中,下一次清單讀到舊資料時,就能先判斷那是正確的快照,還是交易生命週期已超出應有邊界。

常見問題

執行 BEGIN 後,讀取快照就固定了嗎?

一般的延遲交易不會只因 BEGIN 就固定快照;在本文的 WAL 實驗中,首次實際讀取才建立快照。

寫入提交後,舊讀取交易應立即看到新值嗎?

不應該。舊交易持續讀取原快照,結束交易並重新讀取後,才能看到已提交的新值。

檢查點完成後,WAL 檔案一定縮小嗎?

不一定。頁面寫回資料庫與檔案截斷是不同操作,不能只看檔案大小判斷檢查點是否推進。

把工作交給專屬雲端 Mac

租用一台專屬實體 Mac mini,依照工作安排選擇節點與計費週期。開通後可在控制台查看連線資訊;使用期間若遇到問題,也能在控制台提交工單。

選擇雲端 Mac 方案

依照工作安排選擇節點

確認好規格與計費週期,再開通專屬實體 Mac mini。