クラウドMacで検証するiOS SQLiteオンラインバックアップの整合性
納品するアプリに「データベースのエクスポート」を追加したい。ただし、受注入力は止められない。開発チームがクラウドMacで検証したところ、ファイルのダウンロードには成功したものの、復元すると数件のレコードが欠けていました。原因は転送とは限りません。使用中のSQLiteデータベースのメインファイルを、そのままコピーしたことにある可能性があります。まず整合性を確保できるバックアップ方式を選び、そのうえで復元を検証する必要があります。「コピーできた」だけでは、納品可能とは判断できません。
稼働中のファイルを直接コピーせず、バックアップAPIを選ぶ
稼働中のエクスポートに適しているのは 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;'
10秒という値は、この実験で設定した処理全体の制限時間であり、汎用的なサービス基準ではありません。コピー先の接続を、別のスレッドから同時に操作してはいけません。この例ではビジーハンドラを設定していません。アプリ側でハンドラを設定している場合は、その内部で発生する待機時間も制限時間に含める必要があります。
同時書き込みでトランザクション境界を検証する
テーブルを2つ用意します。バッチテーブルにはバッチ番号と想定明細件数を記録し、明細テーブルからバッチ番号を参照します。書き込み処理は毎回、1つのトランザクション内でバッチを1件と固定件数の明細を挿入してからコミットします。別の接続ではバックアップを繰り返し実行し、毎回新しい出力先パスを使います。
各バックアップについて、バッチに記録された件数と実際の明細件数が一致すること、参照先のない明細がないこと、バッチ番号に重複がないことを確認します。スナップショットごとに含まれる完全なバッチの数が異なるのは問題ありません。ただし、トランザクションの一部だけが含まれる状態は認められません。
進捗表示を固定時点のスナップショットと混同しない
分割してバックアップしている間に、別の接続から書き込みが続くと、バックアップの進捗が最初に戻ることがあります。そのため、ページ数による進捗が単調に増えるとは限りません。また、結果が呼び出し開始時点の状態であるとも断定できません。
読み取り時点を固定する必要がある場合は、コピー元の接続で明示的に読み取りトランザクションを開始し、読み取りを実行してスナップショットを確立してからバックアップします。完了後は速やかにトランザクションを終了します。WALモードでは、長時間の読み取りトランザクションがチェックポイントの進行を妨げる可能性があるため、ログの増加も併せて監視します。ジャーナルモードによってロックへの影響も異なるため、それぞれ個別に検証する必要があります。
障害時に不完全なバックアップを納品しないことを確認する
使い捨てのテスト用ディレクトリで、3種類の障害を試します。出力先ディレクトリに書き込めない場合、別の接続が書き込みロックを長時間保持している場合、バックアップの途中でプロセスが終了した場合です。空き容量不足のテストは、管理されたテスト用ボリュームだけで行います。他の顧客の処理にも使っているディスクをいっぱいにしてはいけません。
いずれの場合も異常終了すること、そして納品用ディレクトリに完全なバックアップと誤認されるファイルが残らないことを確認します。この例で出力先を削除できるのは、通常のエラー処理を経て終了した場合だけです。プロセスが強制終了されると後処理は実行されません。そのため、実際の組み込みでは、まず一時ファイルに書き込み、検証に成功してから正式な成果物として配置します。
integrity_check の期待結果は ok のみで、foreign_key_check は結果行が返らないことが期待されます。両方に合格しても、業務上の集計値と user_version の確認は必要です。構造が正常であることと、業務上正しいことは同じではありません。
独立したパスから復元できて初めて完了とする
バックアップ用の接続を閉じ、成果物を新しい作業ディレクトリにコピーします。コピー元のWAL、SHMを持ち込まずに、そこでデータベースを開き直します。まず読み取り専用のクエリを実行し、次に破棄可能なコピーで追加・更新・削除と再オープンまで行い、アプリが実際に利用を継続できることを確認します。
納品記録には、ソースコードのコミットID、データベースのバージョン、バックアップ所要時間、ファイルのハッシュ値、検証結果を残します。顧客の平文データは残しません。アプリで復元する際も、先に既存の接続をすべて閉じる必要があります。ファイルを差し替えた後も、古いデータベースを操作する接続が残る事態を防ぐためです。
最後に、「同時書き込みなし」と「継続的な書き込みあり」の両方を回帰テストに組み込みます。受け入れの目標は、毎回同じファイルを得ることではありません。毎回、単独で開くことができ、トランザクションと業務上の制約を満たすデータベースを得ることです。失敗した場合は、成果物としての公開を明確に拒否します。
よくある質問
WALモードでは本体ファイルだけコピーすればよいですか?
信頼できるオンラインバックアップにはなりません。確定済みデータがWALに残っている可能性があるため、SQLiteのバックアップAPIなど、整合性を確認した方法を使います。
integrity_checkが成功すれば十分ですか?
十分ではありません。foreign_key_check、業務上の不変条件、想定するスキーマバージョンを確認し、独立した場所から開いて復元も試験します。
バックアップは必ず呼び出し開始時点の状態になりますか?
必ずしもそうではありません。段階的なコピー中に別接続が書き込むと、処理がやり直される場合があります。固定スナップショットが必要なら、読み取りトランザクションとWAL増大への影響を設計します。
プロジェクトのスケジュールに合わせたレンタル期間
一時的なビルド環境が必要なときや、納品前の増強、複数のクライアント案件で環境を分けたいときに便利です。チームで年間を通じて高負荷のビルドを行い、すでに自社の機材をお持ちの場合は、購入とレンタルを比較したうえでご検討ください。
VMSSDでは、仮想マシンではなく専有の物理Mac miniをご利用いただけます。日・週・月・四半期から期間を選べ、満了後は自動更新されず停止します。利用可能なモデルと拠点は、コントロールパネルに表示される最新情報をご確認ください。