云端 Mac 上的 iOS SQLite 在线备份一致性验收

云端 Mac 上的 iOS SQLite 在线备份一致性验收

客户要求交付版 App 增加“导出数据库”,同时不能暂停订单录入。工作室在云端 Mac 上测出文件能下载,却发现恢复后少了几笔记录:问题未必是传输,而是把正在使用的 SQLite 主文件直接复制走了。应该先选定一致性备份方式,再验证恢复;不能把“复制成功”当成交付结论。

先选接口,不直接搬活动文件

适合在线导出的是 sqlite3_backup:它通过数据库连接读取源库,把内容写入目标库,并支持分批复制页面。不适合的是对活动数据库执行普通文件复制,尤其源库使用 WAL 时,已提交内容可能还在日志文件里。

把主文件与旁边的 WAL、SHM 依次复制,也不能保证它们来自同一时刻。云端 Mac 上的命令行实验用于验证数据库行为;接入 iOS 时,要使用应用已有的数据库路径和连接管理方式。

在线备份保证的是数据库一致性,不保证业务流程已经结束。跨多个事务完成的“订单加明细”,仍可能在两个事务之间被备份。

因此先写清交付契约:需要任意一致状态,还是指定批次完成后的状态。后者应以业务屏障配合明确的快照策略实现,而不是猜测复制时间。

用 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;'

十秒是实验用总预算,不是通用服务标准。目标连接不能同时被其他线程操作。示例没有安装忙等待处理器;若应用自行配置了处理器,其内部等待也要纳入预算。

用并发写入验收事务边界

准备两张表:批次表记录批次号和预期明细数,明细表引用批次号。写入器每轮在一个事务内插入一条批次和固定数量明细,然后提交。另一个连接反复执行备份,每份使用新目标路径。

每份备份都检查:批次声明的数量等于实际明细数,没有孤立明细,批次号不重复。允许各份快照包含不同数量的完整批次,不允许出现半个事务。

不把进度当作冻结快照

分步备份期间,其他连接继续写入可能让备份进度重新开始;因此页数进度不保证单调增长,也不能断言结果就是调用开始时的状态。

若要求固定读取时点,可在源连接显式开启读事务并执行读取来建立快照,再进行备份,结束后及时释放事务。WAL 模式下长读事务可能阻碍检查点推进,要同时观察日志增长;不同日志模式的锁影响也要单独测试。

故障验收要证明不会误交付

在一次性夹具目录中测试三类失败:目标目录不可写、其他连接长期占用写锁、备份进程中途结束。空间不足测试只在受控测试卷做,不要填满承载其他客户任务的磁盘。

每类都要求非成功退出,且交付目录不能出现被误认作完整备份的文件。示例仅在正常错误返回时删除目标,进程被强制结束时不会执行清理,因此实际集成应先写暂存文件,校验成功后再发布。

integrity_check 预期只有 ok;foreign_key_check 预期没有结果行。二者都通过仍需检查业务汇总与 user_version,结构正常不等于业务正确。

从独立路径恢复才算完成

关闭备份连接,将结果复制到新的任务目录,在不携带源库 WAL、SHM 的条件下重新打开。先执行只读查询,再在可丢弃的副本上完成新增、修改、删除和再次打开,确认应用确实能继续使用。

交付记录保留源代码提交号、数据库版本、备份耗时、文件摘要和检查结果,不保留客户明文数据。应用恢复时还应先关闭所有旧连接,避免把新文件放回后仍有连接操作旧数据库。

最后把“无并发写入”和“持续写入”都纳入回归。验收目标不是每次得到相同文件,而是每次都得到可独立打开、满足事务与业务约束的数据库;失败时则明确拒绝发布。

常见问题

启用 WAL 后,可以只复制主数据库文件作为备份吗?

不能把这种做法当成可靠的在线备份。已提交数据可能仍在 WAL 中;应使用 SQLite 在线备份接口或经过验证的其他一致性导出方案。

备份完成后执行 integrity_check 就足够了吗?

不够。还要执行 foreign_key_check,并检查业务不变量、预期 schema 版本以及从独立路径打开和恢复的结果。

在线备份一定对应调用开始时的数据库状态吗?

不一定。分步备份遇到其他连接写入时可能重启进度。若需要固定快照,应明确设计源连接的读事务,并评估长读事务对 WAL 增长的影响。

让租期跟着项目排期走

适合临时出包、交付周加机与多客户项目分机。若团队常年满负荷构建且已有自有设备,先比较自购与租用,再做决定。

VMSSD 提供独享物理 Mac mini,非虚拟机。按天、周、月、季选择周期,到期不续即停;机型与节点可用性以控制台实时返回为准。

把阅读变成下一步安排

先核对项目工期与构建负载,再决定机型和租期。首次使用,可以从下单清单开始。