客户要求交付版 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 增长的影响。