豪鬼メモ

一瞬千撃

DBMの設計と実装 その19 バックアップ

DBMでサービスを提供している最中に、データベースファイルのバックアップを取りたくなることがあるだろう。当然、サービスのダウンタイムにならないように、データベースにアクセスする他のスレッドはブロックしないで行いたい。それにはどうするか。


バックアップの最も単純な方法は、DBMクラスが提供するCopyFileメソッドを呼ぶことである。データベースオブジェクトが持つメタデータやキャッシュ等の内部状態を全てデータベースファイルと同期させた上で、ファイルの内容を指定したパスにコピーしてくれる。コピーにはその処理系で最速と思われる実装を用いる。Linuxではsendfileシステムコールを使うことになるだろう。

dbm.CopyFile("casket-backup-20200515.tkh");

残念ながら、このCopyFileメソッドは、他のスレッドをブロックさせてしまう。コピー中にファイルが書き換えられると一貫性が壊れるからだ。いかに高速にコピーする実装を用いたとしても、ファイルサイズに比例した時間がかかるのは避けようがない。3秒で済むかもしれないし、10分かかるかもしれない。これはオンラインサービスでは使いにくい。

もう一つの方法は、レコードをひとつずつ巡って別のDBMオブジェクトにエクスポートすることだ。内部イテレータを使って各レコードにアクセスして、それを指定されたDBMに複製するExportというメソッドを提供する。処理中に他のスレッドがブロックすることはない。複製先はDBMの子クラスだったらなんでも良いので、ハッシュデータベースの中身をツリーデータベースにエクポートしたり、ツリーデータベースの中身をスキップデータベースにエクスポートしたりもできる。複製先のデータベースにチューニングパラメータを設定してからエクスポートできるところが運用上のポイントだ。

HashDBM backup_dbm;
backup_dbm.Open("casket-backup-20200515.tkh", true);
dbm.Export(&backup_dbm);
backup_dbm.Close();

ただ、Exportの欠点は、いわゆるスナップショットを取っているわけじゃないということだ。あるレコードは12時25分の時の値かもしれないし、別のレコードは12時26分の時の値かもしれない。エクスポートの処理中に追加されたり削除されたりしたレコードがエクスポート先に入るのか入らないのかはわからない。

ところで、冒頭で述べたCopyFileメソッドは、Synchronizeメソッドがサポートするコールバック機能を使って実装されている。FileProcessorというインターフェイスを継承したクラスを実装して、そのオブジェクトのポインタをSynchronizeメソッドに渡すと、データベースファイル全体をロックした状態で、Processというメソッドをコールバックしてくれる。ファイルシステムがファイルのスナップショットを取る機能を備えている場合、それを使って一瞬でスナップショットを取ることもできるだろう。以下のようなコードになるはずだ。

class Snapshooter : public DBM::FileProcessor {
 public:
  explicit Snapshooter(const std::string& snapshot_path) : snapshot_path_(snapshot_path) {}
  void Process(const std::string& path) override {
    SomehowMakeSnapShot(path, snapshot_path_);
  }
 private:
  std::string snapshot_path_;
} snapshooter("casket-backup-20200515.tkh");
dbm.Synchronize(false, &snapshooter);

ではExportはどうやって実装しているのかというと、ProcessEachというメソッドを使って実装される。これは内部イテレータを使って全てのレコードにアクセスするものだ。アトミック操作の記事でも言及したRecordProcessorクラスのオブジェクトを受け取ると、各レコードのキーと値を渡してProcessFullメソッドを読んでくれる。その中で行先のDBMのSetメソッドを呼ぶのだ。こんな感じの実装になるかな。

class RecordProcessorExport final : public RecordProcessor {
 public:
  RecordProcessorExport(Status* status, DBM* dbm) : status_(status), dbm_(dbm) {}
  std::string_view ProcessFull(std::string_view key, std::string_view value) override {
    *status_ |= dbm_->Set(key, value);
    return NOOP;
  }

 private:
  Status* status_;
  DBM* dbm_;
};

Status DBM::Export(DBM* dest_dbm) {
  Status impl_status(Status::SUCCESS);
  RecordProcessorExport proc(&impl_status, dest_dbm);
  const Status status = ProcessEach(&proc, false);
  if (status != Status::SUCCESS) {
    return status;
  }
  return impl_status;
}

任意の実装をアドオン的に注入できまくるというのがこのDBMの売りである。DBMにエクスポートする他に、ネットワーク経由で他のサービスに送ることだってできるし、圧縮しながらファイルに書き込んだっていい。ところで、内部イテレータと外部イテレータは異なる。DBMクラスのMakeIteratorメソッドで外部イテレータを作れるが、外部イテレータは途中で無効化される可能性がある。別のスレッドがRebuildやClearを呼ぶ可能性があるし、Closeされることだって許されている。内部イテレータはそういったメタな操作を排除するロックをかけてくれるので、確実にイテレーションを最初から最後までやってくれる。ただし、既に述べたが、イテレーションの途中でレコードが更新される可能性はあるので、データベース全体のアトミック性は保証されない。

結論としては、バックアップ操作にはExportを使うのが最善だ。DBMのバックアップにアトミック性は必要ない。更新ログを別に取っていて、バックアップファイルに差分適用するようなケースでは、バックアップファイルがいつの瞬間のものなのかを知りたくなるかもしれない。その際にアトミック性がないと混乱しそうな気もする。しかし、そうでもないケースの方が多いだろう。SetやRemoveは冪等(=アイデムポテント)な更新操作であり、つまり同じ操作を何度適用しても結果が同じになる。したがって、バックアップを開始した時刻からの更新ログを再生すれば、アトミック性がないバックアップファイルをベースにしても同じ結果が得られるはずだ。

そういえば、Redisだったか忘れたけど、オンメモリデータベースのサービスのプロセスをforkしてからバックアップするという荒技があったな。forkするとメモリの内容がCopy-on-Writeで複製されるので、子プロセス側でイテレータを回して適当にファイルに書き出せばいい。その間に親プロセスのメモリが変更されても子プロセス側には影響がないので、スナップショットを簡単に作れる。この方法も面白いと思うが、アプリケーション側の責任でやってもらおう。DBMをサービス化することがあれば、機能の一部として考えても良いかもしれない。

話題はガラッと変わるが、マルチプロセスについての考え方を書いておきたい。このDBMはマルチプロセス対応はしない。データベースファイルを開く際には、flockシステムコールでreader-writerロックをかける。読み込みモードでデータベースファイルを開く場合、複数のプロセスが同時に同じファイルを開くことができる。書き込みモードでデータベースファイルを開いているプロセスがいるなら、他のプロセスが同じデータベースファイルを開こうとした時には、ブロックされる。ロックを持っていたプロセスがファイルを閉じるか終了すれば、次のプロセスがロックを獲得できる。したがって、複数プロセスでデータを共有する手段としてこのDBMを用いることは得策ではない。個々のオペレーション毎に開いて閉じてを繰り返せばできないこともないが、効率はかなり悪いだろう。古き良きCGIスクリプトとかでDBMを使う場合、DBMの読み取りだけが必要なシーンではできるだけ読み取りモードで開いて、更新が必要なシーンのその一瞬だけ書き込みモードで開き直してまた閉じるという書き方をすると良いだろう。

プロセス間でデータを共有したいなら、やはりデータベースサービスを立ててRPC経由でSetやGetやRemoveやCompareExchangeができるようにするのが筋だろう。そうすれば、プロセス間と言わず、異なるマシン間でもデータを共有できる。その需要を満たすべく、TTやKTのようなサービスを作ることもいずれ検討はするだろう。今すぐには着手できないけども。