雲端 Mac 上的 iOS SQLite 線上備份一致性驗收

雲端 Mac 上的 iOS SQLite 線上備份一致性驗收

雲端 Mac 上的 iOS SQLite 線上備份一致性驗收

客戶要求在交付版 App 加入「匯出資料庫」功能,同時不能中斷訂單輸入。工作室在雲端 Mac 上測試,確認檔案可以下載,卻發現還原後少了幾筆紀錄:問題不一定出在傳輸,而可能是直接複製了使用中的 SQLite 主檔案。應先選定能確保一致性的備份方式,再驗證還原結果,不能把「複製成功」當成交付結論。

先選 API,不直接複製使用中的檔案

適合線上匯出的是 sqlite3_backup:它透過資料庫連線讀取來源資料庫,將內容寫入目標資料庫,並支援分批複製頁面。不適合的做法,是對使用中的資料庫進行一般檔案複製;尤其當來源資料庫使用 WAL 時,已提交的內容可能仍留在日誌檔案中。

依序複製主檔案及旁邊的 WAL、SHM,也無法保證這些檔案反映的是同一時間點的狀態。雲端 Mac 上的命令列實驗是用來驗證資料庫行為;整合到 iOS 時,應沿用 App 既有的資料庫路徑與連線管理方式。

線上備份保證的是資料庫一致性,不代表業務流程已經結束。若「訂單加明細」分成多個交易完成,備份仍可能落在兩個交易之間。

因此,應先寫清楚交付契約:需要的是任意一致狀態,還是指定批次完成後的狀態。後者應透過業務層的同步屏障,搭配明確的快照策略來實現,而不是猜測複製時機。

用 Swift 實作有時間上限的備份器

以下範例只備份 main 資料庫。目標檔案放在任務專屬目錄中,而且必須尚未存在;來源檔案則必須已經存在。將程式碼儲存為 backup.swift,在已安裝命令列工具的雲端 Mac 上執行。

import Foundation
import SQLite3

enum BackupFailure: Error {
    case invalidArguments
    case sqlite(Int32)
    case timeout
}

func openDB(_ path: String, flags: Int32) throws -> OpaquePointer {
    var db: OpaquePointer?
    let rc = sqlite3_open_v2(path, &db, flags, nil)
    guard rc == SQLITE_OK, let handle = db else {
        if let db = db { sqlite3_close(db) }
        throw BackupFailure.sqlite(rc)
    }
    return handle
}

func backup(source: String, target: String) throws {
    guard !FileManager.default.fileExists(atPath: target) else {
        throw BackupFailure.invalidArguments
    }
    let src = try openDB(source, flags: SQLITE_OPEN_READONLY)
    defer { sqlite3_close(src) }
    let dst = try openDB(
        target,
        flags: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE
    )
    var complete = false
    defer {
        sqlite3_close(dst)
        if !complete {
            try? FileManager.default.removeItem(atPath: target)
        }
    }
    guard let job = sqlite3_backup_init(dst, "main", src, "main") else {
        throw BackupFailure.sqlite(sqlite3_errcode(dst))
    }
    let deadline = ProcessInfo.processInfo.systemUptime + 10
    var rc: Int32 = SQLITE_OK
    repeat {
        if ProcessInfo.processInfo.systemUptime >= deadline {
            rc = SQLITE_INTERRUPT
            break
        }
        rc = sqlite3_backup_step(job, 64)
        if rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED {
            Thread.sleep(forTimeInterval: 0.02)
        }
    } while rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED
    let finish = sqlite3_backup_finish(job)
    guard rc == SQLITE_DONE else {
        if rc == SQLITE_INTERRUPT { throw BackupFailure.timeout }
        throw BackupFailure.sqlite(rc)
    }
    guard finish == SQLITE_OK else {
        throw BackupFailure.sqlite(finish)
    }
    complete = true
}

guard CommandLine.arguments.count == 3 else {
    throw BackupFailure.invalidArguments
}
try backup(source: CommandLine.arguments[1], target: CommandLine.arguments[2])
xcrun swiftc backup.swift -o backup -lsqlite3
mkdir -m 700 backup-run
./backup source.sqlite backup-run/snapshot.sqlite
sqlite3 backup-run/snapshot.sqlite \
  'PRAGMA integrity_check; PRAGMA foreign_key_check;'

十秒是這次實驗的總時間預算,不是通用的服務標準。目標連線不能同時由其他執行緒操作。範例未設定忙碌等待處理常式;若 App 自行設定了處理常式,其中的等待時間也必須納入預算。

以並行寫入驗收交易邊界

準備兩張資料表:批次表記錄批次編號與預期明細數,明細表則參照批次編號。寫入程式每一輪都在同一個交易內新增一筆批次及固定數量的明細,然後提交。另一個連線反覆執行備份,每份備份都使用新的目標路徑。

每份備份都要檢查:批次宣告的數量與實際明細數相符、沒有孤立明細,而且批次編號不重複。各份快照可以包含不同數量的完整批次,但不能只包含半個交易。

不把進度視為已固定的快照

分步備份期間,其他連線持續寫入,可能導致備份進度重新開始。因此,頁數進度不保證只增不減,也不能斷言結果就是呼叫開始時的狀態。

若要求固定的讀取時間點,可以在來源連線明確開啟讀取交易,並執行讀取以建立快照,再進行備份,完成後及時結束交易。WAL 模式下,長時間的讀取交易可能阻礙檢查點推進,因此也要觀察日誌成長情況;不同日誌模式下的鎖定影響,也必須分別測試。

故障驗收必須證明不會誤交付

在用完即丟的測試資料目錄中,測試三類失敗情境:目標目錄無法寫入、其他連線長時間持有寫入鎖定,以及備份程序中途結束。空間不足測試只能在受控的測試磁碟區進行,不要填滿承載其他客戶任務的磁碟。

每一類測試都必須以非成功狀態結束,而且交付目錄中不能出現可能被誤認為完整備份的檔案。範例只會在正常回傳錯誤時刪除目標檔案;程序遭強制終止時不會執行清理。因此,實際整合時應先寫入暫存檔案,驗證成功後再發布。

integrity_check 預期只回傳 ok;foreign_key_check 預期不回傳任何結果列。即使兩項檢查都通過,仍須檢查業務彙總資料與 user_version,因為結構正常不代表業務資料正確。

從獨立路徑還原才算完成

關閉備份連線,將結果複製到新的任務目錄,在不帶入來源資料庫 WAL、SHM 的情況下重新開啟。先執行唯讀查詢,再於可丟棄的副本上完成新增、修改、刪除與重新開啟,確認 App 確實能繼續使用。

交付紀錄應保留原始碼提交識別碼、資料庫版本、備份耗時、檔案雜湊值與檢查結果,不保留客戶的明文資料。App 還原時,也應先關閉所有舊連線,避免放回新檔案後,仍有連線繼續操作舊資料庫。

最後,將「無並行寫入」與「持續寫入」都納入迴歸測試。驗收目標不是每次都產生相同的檔案,而是每次都取得可獨立開啟、符合交易與業務約束的資料庫;失敗時則明確拒絕發布。

常見問題

啟用 WAL 後,只複製主資料庫檔案就能備份嗎?

不能將此視為可靠的線上備份方式。已提交資料可能仍在 WAL 中,應使用 SQLite 線上備份介面或其他經過一致性驗證的匯出方案。

通過 integrity_check 就算驗收完成嗎?

還不夠。也要執行 foreign_key_check、核對業務不變條件與預期 schema 版本,並從獨立路徑開啟資料庫進行還原測試。

線上備份一定是呼叫開始當下的資料嗎?

不一定。分步備份期間,其他連線寫入可能讓進度重新開始。若需要固定快照,應明確設計來源連線的讀取交易,並評估長交易對 WAL 成長的影響。

租用期間,配合專案時程安排

適合臨時打包、交付週增加主機,以及多客戶專案分開使用。若團隊長期滿載建置,且已有自有設備,建議先比較購買與租用的成本,再做決定。

VMSSD 提供獨享實體 Mac mini,並非虛擬機。可選擇日租、週租、月租或季租;租期到期後未續租便會停止服務。機型與節點的供應狀況,請以控制台即時顯示為準。

讀完文章,接著安排下一步

先確認專案時程與建置負載,再決定機型和租期。第一次使用的話,可以先查看下單清單。