豪鬼メモ

一瞬千撃

DBサービスを作ろう その10 更新ログによるインクリメンタルバックアップ

Tkrzw-RPCのレプリケーション機能を実現するために、Tkrzw本体に更新ログ機能をつけた。更新ログ機能は任意のコールバックを呼び出す実装になっていて、それにメッセージキューを接続すると、マルチリーダで任意の後処理ができるようになる。今回は、メッセージキューのレコードとして保存した更新ログを使って、ローカル環境でインクリメンタルバックアップを実現する方法について説明する。
f:id:fridaynight:20210917232201p:plain


そもそも更新ログとは何かと言うと、データベースの個々のレコードを追加したり値を設定したりするSet操作と、個々のレコードを削除するRemoveメソッド操作と、全てのレコードを削除するClearメソッドを記録したものである。データベース内部にロガーを仕掛けることで、個々の更新が起きた時に、その事態を記録することができる。それを後で再生すれば、元のデータベースが復元できるというわけだ。

データベースの全体のバックアップファイルを定期的に作れば、万が一、元のデータベースファイルが壊れた場合にも、バックアップファイルを使って復旧すれば、バックアップの作成時点での情報を取り戻すことができる。しかし、最後にバックアップを作成してから障害が発生した時点までの更新内容は失われてしまう。この問題を解決するのが、更新ログである。バックアップファイルと更新ログが生き残っていれば、バックアップ作成時点以降の更新ログをバックアップファイルに対して適用することで、障害発生時点までの完全な情報が復元できる。前回のバックアップ作成時からの差分を記録するという意味では、連続的にインクリメンタルバックアップを取る仕組みだとみなすことができる。

更新ログの仕組みを端的に説明するために、更新内容を端末に表示するロガーをまずは実装して、注入してみる。

// DBM::UpdateLoggerを継承してロガークラスを実装する
// WriteSet、WriteRemove、WriteClearをオーバライドする。
class MyLogger : public DBM::UpdateLogger {
  Status WriteSet(std::string_view key, std::string_view value) override {
    std::cout << "SET:" << key << ":" << value << std::endl;
    return Status(Status::SUCCESS);
  }
  Status WriteRemove(std::string_view key) override {
    std::cout << "REMOVE:" << key << std::endl;
    return Status(Status::SUCCESS);
  }
  Status WriteClear() override {
    std::cout << "CLEAR" << std::endl;
    return Status(Status::SUCCESS);
  }
};

// ロガークラスのオブジェクトを、DBMのインスタンスに注入する。
MyLogger ulog;
dbm.SetUpdateLogger(&log);

// 普通にデータベースを操作すると、内部的にロガーの各関数が呼ばれる。
dbm.Set("japan", "tokyo");
dbm.Set("china", "beijing");
dbm.Remove("japan");
dbm.Clear();

上記のコードを実行すると、以下のような出力が得られる。

$ ./myloggersample
SET:japan:tokyo
SET:china:beijing
REMOVE:japan
CLEAR

更新ログを応用して、あるデータベースの更新を別のデータベースにそのまま流して同期させることもできる。二重化という意味ではこれが最も単純な実装だ。

// バックアップ用のデータベースオブジェクトを作成する。
HashDBM backup_dbm;
backup_dbm.Open("casket-backup.tkh", true);

// バックアップ用データベースを登録したロガーを注入する。
DBMUpdateLoggerDBM ulog(&backup_dbm);
main_dbm.SetUpdateLogger(&ulog);

// メインのデータベースを普通に運用する。
main_dbm.Set("japan", "tokyo");

ただし、更新ログを別のデータベースで運用するのは、あまり利点はない。メインのデータベースもバックアップのデータベースも両方オンラインでないと正常動作しないのだから、バックアップデータベースを止めてファイルを転送するといったことができない。また、更新にかかる負荷も2倍になってしまう。

その代わりに、DBMUpdateLoggerMQというロガークラスを使って、フラットな構造のファイルにデータを追記していくのがよい。構成が単純なので、ログを記録するためのオーバーヘッドが小さい。さらに、ファイルローテーションを実装していて、ファイルサイズが閾値を超えた場合に新しいファイルを作って、以後の書き込みをそれに対して行ってくれる。また、個々の更新ログには発生時点のタイムスタンプがつけられる。これによって、任意のタイムスタンプ以後のログのみを取り出すことが可能となる。

DBMUpdateLoggerMQクラスはメッセージキューの実装であるMesssageQueueクラスを内部的に用いる。前回の記事で述べたが、これによってマルチスレッドで分割ファイルの読み書きができる。MesssageQueueのコンストラクタにファイルの接頭辞と最大サイズを指定してインスタンスを作り、それをDBMUpdateLoggerMQクラスのインスタンスに注入する。さらにそれをDBMに注入すれば、更新ログがファイルに記録されるようになる。

メッセージキューを作成する。
MessageQueue mq;
mq.Open("casket-ulog", 512<<20, MessageQueue::OPEN_TRUNCATE).OrDie();

// データベースを作成する
TreeDBM dbm;
dbm.Open("casket.tkt", true).OrDie();

// ロガーの作成してデータベースに注入する。
DBMUpdateLoggerMQ ulog(&mq);
dbm.SetUpdateLogger(&ulog);

// 普通にデータベースを運用する。
dbm.Set("two", "step");

システムがクラッシュしたり、間違ってrmコマンドを打つなどして、なぜかデータベースファイルが消えたとしよう。その場合、最後のバックアップファイルに更新ログを適用する復旧作業を行う。バックアップファイルに直接更新ログを適用するのではなく、復旧用のファイルをコピーしてから作業を行うのが無難だ。

CopyFileData("casket-backup.tkt", "casket-restored.tkt").OrDie();
TreeDBM dbm;
dbm.Open("casket-restored.tkt", true).OrDie();
DBMUpdateLoggerMQ::ApplyUpdateLogFromFiles(&dbm, "casket-ulog");
dbm.Close().OrDie();

まとめると、更新ログをインクリメンタルバックアップとして扱う運用は以下のようになる。運用開始時点から更新ログを取り続けていた場合、フルバックアップを取る必要はない。空のファイルに更新ログを適用すればよいからだ。

  • フルバックアップを取る。
  • 通常の運用で、更新ログを取りながら、更新操作を続ける。
  • システムが死んで、何らかの理由でメインのデータベースが失われる。
  • 最後のバックアップに更新ログを適用する。
  • 更新ログをアーカイブしたり消したりする

幸いにしてシステムがクラッシュしなかったら、更新ログがいつまでも増え続けてしまう。それだと困るので、一定の間隔でバックアップに更新ログをマージして、古いものを捨てたい。そのためには、現在の更新ログを全てバックアップに適用してしまえばよい。上述のC++コードと同等の操作はコマンドラインでも行える。

$ cp casket-backup.tkt casket-restored.tkt
$ tkrzw_dbm_util import --ulog 0 casket-restored.tkt casket-ulog

tkrzw_dbm importサブコマンドの-ulogオプションは、提供する最小タイムスタンプを指定する。ゼロの場合、手持ちの全てのログを適用することになる。現状の更新ログの全てを適用するということは、最新の、書き込み途中かもしれないファイルを別プロセスから読むという、おっかない操作を行うことになる。しかし、メタデータや番兵を駆使して、不整合が起きないように配慮している。

バックアップに全ての更新ログを適用したならば、最新以外の更新ログファイルはrmコマンドで消して構わない。注意深い人はここで気付くと思うが、消されなかった現在の最新のファイルは次回の同等の操作でもまた読まれるので、同じ更新ログが複数回適用される。ここで、みんな大好きな冪等性(idempotence)という性質が生きてくる。レコードの操作をSetとRemoveとClearに限定した場合、個々のレコードの操作は冪等になる。すなわち、同じ操作を1回実行するのと、2回以上実行するのでは、同じ結果が得られる。この冪等性は、時系列で並べられた複数のレコード操作に関しても成立する。よって、同じ更新ログファイルを2度以上適用しても、同じ結果が得られるのだ。冪等であるためには、該当の操作の結果が自身のパラメータ以外の要因に左右されないことが必要となるが、時系列で並べられたSetとRemoveとClearのシーケンスはそれを満たしている。

更新ログを、目的の更新操作の実行前に記録するべきか、実行後に記録するべきかは、議論の余地がある。現状では実行前に記録しているのだが、そうすると、更新ログには記録したのにデータベースに更新を反映する前にシステムが死ぬかもしれない。ただし、更新ログが意味をなす時には、データベースは失われていて、更新操作がデータベースに適用されていたかどうかはもはや観測不能なので、どうでもいい。問題は、更新操作の成功が呼び出し側に通知されているかどうかだ。成功が通知されていないのに成功しているのも問題だし、成功しているのに成功が通知されていないのも問題だ。しかし、この問題は、更新ログの適用順序とは関係ない。更新に成功したのに通知に失敗するというのは通常運用でも起こりうるので、その対策はアプリケーション側のビジネスロジックに任せる。

ところで、Tkrzwのデータベースは壊れないようにできている。HashDBMとTreeDBMを追記更新モードで運用すれば、データベース自身を更新ログとみなせるので、任意の時点にロールバック可能である。SkipDBMは一時ファイルによるバッチ処理とアトミックなrenameで更新するので、そもそも不整合が起きうる機会がない。よって、少なくともHashDBM/TreeDBM/SkipDBMに関して、マシン単体のローカルレベルでの堅牢性を高めるという目的では、更新ログに実用的な意味はない。オンメモリデータベースを更新ログ付きで運用するというのも面白いとは思うが、更新より参照が圧倒的に多いという場合以外では、普通にファイルデータベースを使った方が効率的だ。

さらに言えば、メインのデータベースファイルが回復不能なまでに壊れている状況で、更新ログだけが信頼できるということは、想定しづらい。バグや運用ミスでデータベースファイルを消してしまった場合のロールバックには役立つかもしれないが、それを言ったら更新ログの管理でバグやミスをする可能性もあるので、実用上で利点があるかというと微妙だ。

とはいえ、それでも、実運用上は、バックアップファイルを作らねばならないことが多い。最終的には金に直結する労力と計算コストを掛けてでも、安心と信頼を買わねばならん。フルバックアップを作るためにシステムのダウンタイムができたり、スループットが落ちる時間帯を作りたくない場合は、更新ログを使ったインクリメンタルバックアップの作成が有用だ。作成したバックアップファイルは、別サーバまたは別ストレージに転送するべきだ。それで初めて、マシンやストレージが壊れる事態に備えることができる。メインのデータベースが壊れたのに同じストレージにある更新ログが生きていて、最新状態に追従できる「かもしれない」のは付加価値ではあるが、それに依存したサービスを設計するべきではない。

結局のところ、ハードウェアの故障に耐えるためにはハードウェアを冗長化するしかない。それをデータベースの文脈で実現するのが、更新ログの即時転送機能であるところの、レプリケーション機能である。次回こそ、いよいよTkrzw-RPCにレプリケーションを実装するぞ。