ククログ

株式会社クリアコード > ククログ > PGroongaでのクラッシュセーフ機能の実装

PGroongaでのクラッシュセーフ機能の実装

PGroongaにクラッシュセーフ機能を実装した須藤です。どのような設計・実装になっているかを説明します。

機能の説明

PGroongaのクラッシュセーフ機能について説明する前にPGroongaとクラッシュの関係について説明します。

PGroongaはGroongaを全文検索エンジンとして使ってPostgreSQLに全文検索機能を追加する拡張機能です。多くのPostgreSQLの拡張機能はPostgreSQLのストレージ機能を使って実装されていますが、PGroongaはGroongaのストレージ機能を使っています。

PostgreSQLのストレージ機能を使うとPostgreSQLのクラッシュセーフ機能を使えます。PostgreSQLのクラッシュセーフ機能というのはPostgreSQLのプロセスがクラッシュしたりPostgreSQLが動いているOSがクラッシュしてもデータベースの整合性を壊さずに自動で復旧する機能です。データを格納しているストレージが壊れた場合はさすがに復旧できませんが、PostgreSQLプロセス・OSのクラッシュではデータは壊れません。

PGroongaはGroongaのストレージ機能を使っているのでPostgreSQLのクラッシュセーフ機能を使えません。つまり、PostgreSQLプロセス・OSがクラッシュするとタイミングによってはデータが壊れます。ただし、PGroongaが扱っているデータはPostgreSQL内にあるデータから再構築できるので、PostgreSQLプロセス・OSのクラッシュによりPGroongaのデータが壊れても復旧することはできます。

PGroongaのクラッシュセーフ機能は次の機能を提供します。

  • PostgreSQLプロセス・OSがクラッシュしてもデータベースの整合性を壊さずに自動で復旧する機能

復旧に要する時間はデータの壊れ方によって変わりますが、少し壊れているくらいならすぐに復旧できます。OSがクラッシュした場合はPostgreSQL内のデータが復旧することになるので時間がかかりやすいです。

OSがクラッシュした場合もPostgreSQL内のデータを使わずに復旧する実装にできなくもないのですが、実際に試すと書き込み性能が非常に落ちるので諦めました。

実現方法の概要

PGroongaのクラッシュセーフ機能の実現方法の概要を説明します。

前述の通り、PGroongaはGroongaのストレージ機能を使っています。そのため、クラッシュセーフ機能のコア部分はGroonga内で実装しています。

Groonga内でのクラッシュセーフ機能はよくある実現方法のようにWrite Ahead Log(WAL)で実現しています。PostgreSQLもWALでクラッシュセーフ機能を実現しています。WALを使った実現方法では実際にデータベースを変更する前にログに変更内容を記録します。クラッシュした場合はログの中にある変更内容を使って復旧します。

PostgreSQLはWALをクラッシュセーフ機能だけではなくレプリケーションにも使っていますが、GroongaのWALはクラッシュセーフ機能でだけ使えます。レプリケーションには使いません。レプリケーションはGroonga delta1という別の仕組みで実現するからです。

GroongaのWALはクラッシュセーフ機能でだけ利用する設計にしたので、クラッシュセーフ全体の仕組みは次のように実現しました。

通常のケースは次のようになります。

  1. Groongaのデータベースを変更する場合はまず.walファイルに変更内容を記録する
  2. Groongaプロセス終了時にデータベースの内容をストレージに書き出し、.walファイルを削除する

クラッシュしたケースは次のようになります。

  1. Groongaのデータベースを変更する場合はまず.walファイルに変更内容を記録する
  2. Groongaプロセスがクラッシュ!
  3. Groongaプロセス再起動時に.walファイルがあるか確認し、ある場合は.walファイル内の変更内容を使って自動で復旧する

ポイントはクラッシュしたかどうかの判断方法です。WALをクラッシュセーフにしか使わないのでGroongaプロセスが正常終了したときに削除しています。これにより起動時にWALが残っていればクラッシュしたと判断できます。WALをレプリケーションなど他の用途にも使う場合はこのようにWALを使うことはできませんが、クラッシュセーフにしか使わないことにしたのでクラッシュ検知にも使っています。

大まかな全体の仕組みはこうなのですが、Groongaはカラムストアだったりマルチプロセスをサポートしていたりするのでもっと考えなければいけないことがあります。それでは、もっと考えなければいけないことを順番に説明していきます。

実現方法の詳細

カラム単位のWAL

Groongaはカラムストアなのでデータはレコード単位ではなくカラム単位で管理しています。データを格納するファイルもファイルごとに別々なのでクラッシュ時の復旧処理はカラムごとに独立して実行できます。データベース全体で1つのWALを使うよりカラムごとに別々のWALを使うことができるということです。

このアプローチのメリットは次の点です。

  • 実装が楽になる
  • 復旧時間を短くできる
  • WALの肥大化を防ぎやすい
  • 書き込み性能が落ちにくい

一方、「WALを見てもデータベースに対してどの順番でどの変更が適用されたかを確認できない」というデメリットがあります。これはPostgreSQLのようにWALをクラッシュセーフ以外にレプリケーションのためにも使うような場合は問題ですが、Groongaの場合はクラッシュセーフにしか使わないので問題ありません。

各メリットについてもう少し説明します。

実装が楽になる:基本的に考えることが少なかったり影響範囲が狭いほど実装は楽になります。局所的なことだけ考えて実装できるからです。そのため、データベース全体ではなく各カラム単位で復旧処理を実装できたほうが楽になります。

復旧時間を短くできる:カラム単位でWALを管理するということは、カラム単位でクラッシュしたかどうかを判断できるということです。カラム単位で復旧の要不要を判断できると復旧が不要なカラムに対する処理をスキップできます。単純に処理が少なくなって復旧時間が短くなりやすいです。

WALの肥大化を防ぎやすい:WALはデータが追記される一方なのでファイルサイズが小さくなることはありません。そのため、同じWALを使い続けると肥大化します。肥大化を防ぐには削除するしかありません。どうしたら削除できるかと言うとメモリー上にある変更をストレージに書き出したら削除できます。メモリー上の変更をストレージに書き出したらクラッシュしてもデータが壊れることはないからです。壊れることがなければ復旧する必要もないのでWALも必要ありません。つまり、WALが肥大化する前にメモリー上の変更をストレージに書き出してWALを削除してしまえば肥大化を防ぐことができます。メモリー上の変更をストレージに書き出すときに考えなければいけないことは書き出すことによる性能劣化です。データベース全体で一度に書き出すのではなくカラム単位で小分けにして書き出した方が性能劣化を分散させることができます。こうすることでより頻繁に書き出せるようになり、WALの肥大化を防ぎやすくなります。

書き込み性能が落ちにくい:同時にWALに追記するとWALが壊れるので同時に追記することはできません。データベース全体で1つのWALを使うと同時にカラムを更新することができません。WALへの追記待ちが発生するためです。カラムごとにWALがあれば同じカラムを同時に更新できないだけです。従来は複数のカラムを同時に更新できたのでこれで書き込み性能が落ちるユースケースがあります。同じカラムを同時に更新できないのはもともとそうだったのでカラム単位で別のWALにすれば書き込み性能の低下を抑えることができます。(WALに追記する処理が増えるので従来より遅くはなります。)

ということで、Groongaではデータベース全体で1つのWALではなくカラム単位でWALを用意する設計にしました。

なお、説明を単純にするためにカラムのことだけ話しましたが、各テーブルもカラムと同じようにそれぞれ独自のWALを持ちます。GroongaのテーブルはキーとIDのマッピングを管理しているだけで、カラムは管理していません。テーブルとそのテーブルに属するカラムは共通のIDで論理的に紐付いています。同じIDを持つテーブル・カラムの行をまとめてレコードと考えるということです。

物理的なテーブルとカラム:
  テーブル      カラム1     カラム2
+ーー+ー+  +ー+ー+  +ー+ー+
|キー|ID|  |ID|値|  |ID|値|
+ーー+ー+  +ー+ー+  +ー+ー+
|a   |1 |  |1 |x |  |1 |X |
|b   |2 |  |2 |y |  |2 |Y |
|c   |3 |  |3 |z |  |3 |Z |
+ーー+ー+  +ー+ー+  +ー+ー+

論理的なテーブルとカラム:
+ーー+ー+ーーーー+ーーーー+
|キー|ID|カラム1 |カラム2 |
+ーー+ー+ーーーー+ーーーー+
|a   |1 |x       |X       |
|b   |2 |y       |Y       |
|c   |3 |z       |Z       |
+ーー+ー+ーーーー+ーーーー+

ということで、Groonga内ではテーブルもカラムのように独立したデータ構造なので独自のWALを持つ設計になっています。

スレッド単位でのロール

Groongaはマルチプロセス・マルチスレッドをサポートしています。つまり、複数のプロセス・複数のスレッドが同時に同じデータベースを扱えるということです。しかし、すでに紹介した次のクラッシュセーフ機能の処理の中には同時に動いてはいけない処理があります。

通常のケースは次のようになります。

  1. Groongaのデータベースを変更する場合はまず.walファイルに変更内容を記録する
  2. Groongaプロセス終了時にデータベースの内容をストレージに書き出し、.walファイルを削除する

クラッシュしたケースは次のようになります。

  1. Groongaのデータベースを変更する場合はまず.walファイルに変更内容を記録する
  2. Groongaプロセスがクラッシュ!
  3. Groongaプロセス再起動時に.walファイルがあるか確認し、ある場合は.walファイル内の変更内容を使って自動で復旧する

これらのうち同時に動いてはいけない処理は次の処理です。

  • Groongaのデータベースを変更する場合はまず.walファイルに変更内容を記録する
  • Groongaプロセス終了時にデータベースの内容をストレージに書き出し、.walファイルを削除する
  • Groongaプロセス再起動時に.walファイルがあるか確認し、ある場合は.walファイル内の変更内容を使って自動で復旧する

端的に言うと.walファイルが関係する更新操作を同時に実行できません。整合性が壊れるからです。

たとえば、あるカラムのメモリー上のストレージへの書き出しをした後、かつ、.walファイルを削除する前に別のプロセス・スレッドがそのカラムを更新した場合、.walファイルがないにも関わらずストレージへ書き出されていない変更がメモリー上に残ります。この状態でクラッシュした場合、このカラムが壊れる可能性がありますが、.walファイルがないので復旧できません。

ということで、同時に実行できない処理を同時に実行しない仕組みが必要です。

これを実現するために各スレッドごと2に「WALロール」を設定する設計にしました。

WALロールは次のどれかです。

  • NONE
  • SECONDARY
  • PRIMARY

NONEはWAL関連の処理をなにもしません。クラッシュセーフ機能がなかった従来と同じ動きになります。互換性を壊さないためにこれがデフォルトになっています。

SECONDARYは更新時にWALに追記しますが、終了時のストレージへの書き出し+.walファイルの削除、起動時の自動復旧などはしません。クラッシュセーフ機能を使う場合、PRIMARY以外の更新する可能性のあるすべてのスレッドはこのWALロールにしなければいけません。そうしないとWALに追記する内容が足りなくて自動復旧できなくなったり、クラッシュ時に壊れている可能性のあるカラムを検出できなくなります。

PRIMARYは更新時のWALの追記だけでなく、終了時のストレージへの書き出し+.walファイルの削除、起動時の自動復旧もします。PRIMARYはすごく特別で次のことを守らなければいけません。

  • 同じデータベースを扱うすべてのプロセス・スレッド内で1つのスレッドだけがPRIMARYを設定しないといけない
    • 複数のスレッドがPRIMARYになると処理中にクラッシュしたりデータが壊れたりする
    • どのスレッドもPRIMARYになっていないとクラッシュリカバリー機能が動かない
  • PRIMARYをもつスレッドは他のすべてのスレッドよりも先にデータベースを開かないといけない
    • 他のスレッドはPRIMARYがデータベースを開いたらデータベースを開ける
  • PRIMARYをもつスレッドは他のすべてのスレッドがデータベースを閉じてからデータベースを閉じないといけない
    • PRIMARYがデータベースを閉じるときは他のすべてのスレッドがデータベースを閉じるまで待たなければいけない

他のスレッドが同時に動いていてもロックを獲得すれば大丈夫な処理はSECONDARY、そうではなく、他のスレッドが動いていてはいけない処理はPRIMARYが処理するということです。

運用方法によってはPRIMARYを設定するための条件がかなり厳しいかもしれません。ただ、今回はPGroongaで実現できればよいのでこれらを満たすことができます。

PostgreSQLはメインのプロセスが1つあり、各クライアントへの対応などは別プロセスを起動してそちらで実行するアーキテクチャーになっています。つまり、PostgreSQLのメインのプロセスでPRIMARYとして動いて、各別プロセスはSECONDARYとして動けばよいということです。実際はPostgreSQLのメインのプロセスからPRIMARYをもつプロセスを他のプロセスよりいち早く起動して、終了するときは一番最後に終了するように実装しています。PostgreSQLのメインのプロセス内でPRIMARYのスレッドを動かしているわけではありません。

Mroongaも同じように実現できるはずなので将来的にはMroongaにもクラッシュセーフ機能を組み込みたいです。

Groongaをサーバーとして起動したときもなんとかなる気はするので組み込んでいきたいです。

Groongaをライブラリーとして使う場合はどんなユースケースになるかわからないので、ユーザーがいい感じにWALロールを設定することになります。

WALからの復旧方法

WALからどうやって復旧するかを説明します。

基本的に、WALに記録された変更を最初から順番に適用していくことで復旧します。変更内容は論理的な変更内容(ID 1のレコードの値を「a」に変更するとか)ではなく、物理的な変更内容(カラムのこの場所に8バイト分のこのデータを書き込むとか)になっています。これは、復旧時は既存のカラム内のデータを信用できないからです。論理的な変更内容だと既存のカラム内のデータを使いながら実際の変更処理をすることになりますが、そうすると壊れたデータを使う可能性があり、正しく変更できないかもしれません。物理的な変更内容では既存のカラム内のデータを使わないので必ず同じ結果になります。

変更内容はたとえ同じ変更処理を何度適用しても同じ結果になるようになっています。別の言い方にすると、べき等な操作になっています。実はWALの各変更内容にはIDがついています。これをWAL IDと呼んでいます。カラム内にも、どのWAL IDまで適用したかという情報が入っています。これを使うとどの変更内容まで適用したかを判断することもできるのですが、カラム内のデータをどこまで信用したものかなんとも言えない気持ちになったので現在の実装では使っていません。つまり、すでに適用済みかもしれない変更内容も適用して復旧します。そのため、べき等な操作になるように変更内容を設計しました。

WAL IDはカラムやプロセスが違っても純増する(後に作られたWALの変更内容の方が大きいIDを持つ)ように設計してあります。純増するとどっちが新しいかを確認しやすいからです。カラムを超えて純増にする必要はなかったのですが、各カラムごとにWALを用意することにしてもWALからデータベース全体の変更内容を抽出できて便利かも?という妄想があってカラムを超えても純増になっています。が、今のところそんな需要はないので今後カラム別で純増になるかもしれません。今はタイムスタンプを使ってWAL IDを発行していますが、カラム別で純増にしたらカラムごとにカウンターを持つ実装でも十分になります。タイムスタンプの生成が性能面でしんどいとかが出てきたら考え直すと思います。

各変更内容の記録にはMessagePackフォーマットを使っています。GroongaがすでにMessagePackのライブラリーを使っていたこととMessagePackはストリームでの読み込みができることが選んだ理由です。.walファイルはただのMessagePackファイルなのでlibgroonga.soをリンクしなくても読み込めます。実際、デバッグ用に作ったdump-wal.rbはRroongaも使っていないただのRubyスクリプトです。

実はすべてのカラムがWALからの自動復旧をサポートしているわけではありません。具体的にはインデックスカラムはWALからの自動復旧をサポートしていません。インデックスカラムはインプレースでカラム内のデータを変更していて復旧可能な変更操作に分解するのが大変とか、WAL書き出しの性能劣化が大きそうとかいうことが見えていたのでWALからの自動復旧に対応していません。

ではどうやって自動復旧しているかというとデータベース内の既存データから再構築しています。インデックスカラムはインデックス対象のデータがあれば自動復旧できますし、再構築時にはより高速な静的インデックス構築を使えるので、WALからの自動復旧を諦めて再構築で自動復旧します。

なお、インデックスカラムでWALをまったく使っていないかというとそんなことはなくて、クラッシュしたかどうかを検出するためだけにWALを使っています。インデックスカラムを更新するときに空のWALを作って、インデックスカラムを閉じるときにストレージに書き出してWALを削除します。これで他のカラムと同じように「WALが残っていれば未書き出しのデータが残ったままクラッシュした」ということを検出できます。検出したらインデックスカラムを再構築して自動復旧します。

他にTABLE_NO_KEYもWALからの自動復旧に対応していません。理由はPGroongaでは使っていないからです。必要になったら実装する予定です。ちなみに、PGroongaにクラッシュセーフ機能を実装したのはお客さんから要望があったためです。もし、あなたがTABLE_NO_KEYやMroongaやGroongaサーバーでもクラッシュセーフ機能を使いたいならGroongaのサポートサービスを検討してください。

WALからの自動復旧をサポートしてはいますが、WALからの自動復旧が失敗することもあるだろうと思っています。ということで、WALからの自動復旧が失敗したら新しく同じ定義のカラムを作って壊れたカラムからデータをコピーしての自動復旧を試みます。既存のカラムが壊れていてもなんとかデータを読み込めたら復旧できるだろうということです。

それでも自動復旧できなかったら諦めます。この場合、データベースを開くことに失敗し、エラーが報告されます。

PGroongaの場合はPostgreSQLに元データが残っているのでREINDEXで自動復旧を試みます。これで確実に復旧できますが対象データが多いとそれだけ復旧時間がかかります。

まとめ

ここ1年くらいで実装したPGroongaのクラッシュセーフ機能について設計とその実装を説明しました。ソースコードレベルでの細かい実装は説明していませんが、ソースコードを読む場合でもここで説明した内容がわかっていればだいぶ読みやすくなるはずです。

今月(2022年3月)、クラッシュセーフ機能をかなりテスト・修正したのでクラッシュセーフ機能を使う場合はGroonga 12.0.2以降・PGroonga 2.3.6以降を使ってください。PGroongaでのクラッシュセーフ機能の使い方はPGroongaのドキュメントを参照してください。

Groonga・Mroongaでもクラッシュセーフ機能を使いたい!という人はGroongaのサポートサービスを検討してください。

今回は久しぶりにGroongaの技術的な話をまとめましたが、2022年4月から毎週火曜日の12:15-12:45にこのような技術的な話をGroonga開発者に直接聞ける「Groonga開発者に聞け!(グルカイ!)」というYouTube Liveを始めます!connpassのGroongaグループまたはYouTubeのGroongaチャンネルに登録しておけば通知が届くので活用してください。

  1. Groonga deltaについては別の機会に紹介します。紹介できるといいな。

  2. もう少し細かい話をすると各grn_ctxにWALロールを設定します。1つのgrn_ctxは同時に1つのスレッドでしか使えないので実質スレッドごとにWALロールが設定されます。