rdiffを使ったSQLiteのバックアップ

稼働中のWebアプリのSQLiteデータベースを定期的にバックアップしたい (単なるファイルコピーではデータの一貫性を保証できない…コピー中に更新されたら?)。
従来は

sqlite3 データベース .dump > バックアップ

をcronでまわしてダンプをとっていた。

小規模なうちはこれでよかったが、データベースのサイズが100MB単位になると1分以上かかるケースもあり、その間にアプリからの更新がブロックされタイムアウトしてエラーが頻発していた。

LVM上であればスナップショットを使ってロックなしでバックアップすることができるが、通常のパーティションの上で稼働しているシステムでは (reflinkもまだないし) ロックをするほかない。

いろいろ調査・実験した結果、rdiffを使うとロック時間を最小化することができた。
rdiffはバイナリ差分をとるツールで、前回のバックアップからの差分をとるという形で使えばバックアップに使える。
rsyncアルゴリズムを使用していて、2つのファイルを直接比較するのではなく、

  1. 旧ファイルからsignatureを作る
  2. signatureと新ファイルから差分を作る

という2ステップにわけて使えるのが特徴。

SQLiteのバックアップに適用する場合、

  1. 前回のバックアップのsignatureを作る
  2. データベースをロック
  3. signatureとデータベースから差分(delta)を作る
  4. ロック解除
  5. 前回のバックアップと差分(delta)から新バックアップを作成

という手順で行えば、ロック中は現行データベースのread以外の巨大I/Oをしないので、他の方式より高速化が期待できる。

実際に試したところ、cpコマンドによるベタコピーでは30秒近くかかったデータベースに対し、rdiff方式では数秒で差分を取ることが出来た。

ロックは以下のようなPythonスクリプトを使った (シェルスクリプトでも書いてみたが、あまり堅牢にできそうにないので断念)。

#!/usr/bin/python

import sys, os
try:
    import sqlite3
except ImportError:
    from pysqlite2 import dbapi2 as sqlite3

if len(sys.argv) < 3:
    print >>sys.stderr, "Usage: %s DB CMDLINE..." % sys.argv[0]
    print >>sys.stderr, "Executes CMDLINE while locking DB"
    sys.exit(1)

src = sys.argv[1]
cmdline = sys.argv[2:]

db = sqlite3.connect(src, timeout=60.0, isolation_level=None)
db.execute("BEGIN DEFERRED")
db.execute("SELECT COUNT(*) FROM sqlite_master") # This creates SHARED lock
retval = os.spawnvp(os.P_WAIT, cmdline[0], cmdline)
db.execute("COMMIT")
if retval != 0:
    print >>sys.stderr, "Command returned %d" % retval
sys.exit(retval)

このスクリプトsqlite-lockとして

rdiff signature $LATEST $SIG
sqlite-lock rdiff delta $SIG $DB $DELTA
rdiff patch $DELTA $LATEST $NEW
ln -f $NEW $LATEST

のような感じでバックアップを行っている。
実際にはデータセンターからローカルにSSH経由でバックアップしているのだが、signatureとdeltaだけしかネットワークを通らないので高速。(rsyncがやっていることを分解したようなもの)