ククログ

株式会社クリアコード > ククログ > PGroongaでのカスタムWALリソースマネージャーの実装 #postgresql

PGroongaでのカスタムWALリソースマネージャーの実装 #postgresql

PGroonga用のカスタムWALリソースマネージャーを実装した須藤です。どのような設計・実装になっているかを説明します。

カスタムWALリソースマネージャーについて

カスタムWALリソースマネージャー日本語)とはWALの適用方法を拡張するための仕組みです。他にもWALをロジカルデコードする方法を拡張することもできますが、PGroongaでは不要なのでここでは「WALの適用方法を拡張」できることだけに注目します。

一応、どうしてPGroongaではロジカルデコードする方法の拡張が不要なのかを簡単に説明しておきます。PGroongaはインデックスです。インデックス対象のデータを「普通に」変更するとインデックスも自動的に更新されます。ロジカルデコードの主な用途の一つはロジカルレプリケーションですが、ロジカルレプリケーションでは「普通に」テーブルを変更するのでPGroongaのデータも自動で更新されます。既存の仕組みのままで期待通り動くので拡張しなくてもいいのです。

話を戻して、WALの適用方法の拡張がどうして必要なのかを説明します。

PostgreSQLのWALには物理的な変更情報が入っています。物理的な変更情報というのは、このファイルのこの位置のデータをこう変更する、というような情報です。WALはクラッシュリカバリーやストリーミングレプリケーションに使われているのですが、この変更情報を適用することで実現されています。そしてこの適用処理はPostgreSQL本体の機能として実装されています。PostgreSQLには拡張機能でもWALを書き出せる機能(Generic WAL/日本語)があり、これを使うとPostgreSQL本体が必要に応じてWALを適用してくれます。「普通」のインデックスはこれで大丈夫です。「普通」のインデックスは。。。

ここでの「普通」のインデックスとはPostgreSQLが提供しているIO機能を使ってPostgreSQLが管理しているストレージに必要なデータを読み書きするインデックスです。たとえば、PostgreSQL組み込みのGINのようなインデックスであるRUMが「普通」のインデックスです。

PGroongaは「普通」のインデックスではなく、PostgreSQLのIO機能を使わずにデータを読み書きしています。そのため、WALに変更情報を書いてもPGroongaが独自管理しているデータをPostgreSQLは変更してくれません。ということで、今まではゴニョゴニョしてストリーミングレプリケーションできるようにしていました。もろもろ簡素化すると次のような処理をしていました。

  1. プライマリー:PostgreSQLのWALにPGroongaのWALを書き込む
  2. スタンバイ:PostgreSQLがPostgreSQLのWALをPostgreSQL管理のストレージに書き込む
  3. スタンバイ:PGroongaがPostgreSQL管理のストレージからPGroongaのWALを読み込んで適用

ちょっとどういうことかピンとこない人の方が多いとは思いますが、なんか面倒くさいことをしていることがわかれば十分です。

この面倒くさいことを解消できるのがカスタムWALリソースマネージャーなのです。従来はPostgreSQL本体がWALを適用する必要があったので、PostgreSQL管理のストレージ以外へWALを適用することができませんでした。カスタムWALリソースマネージャーを使うと、WALの適用方法をカスタマイズできるので、次のようにできます。

  1. プライマリー:PostgreSQLのWALにPGroongaのWALを書き込む
  2. スタンバイ:PGroongaがPGroongaのWALを適用

すごい!そうだよね、こうやりたいよね!という処理になっていますね。

他にも従来のアプローチでの次のような課題も解決しています。

  • 適用済みの不要になったWALを削除するためには職人技が必要
  • でも、↑をしないとWALが増え続けてストレージを圧迫しちゃう
  • PGroongaのWALをすぐに適用できない
  • スタンバイのクラッシュリカバリーがPostgreSQLが接続を受け付けるようになってから始まるので、接続できるけど検索できない状態ができる

なお、カスタムWALリソースマネージャーはPostgreSQL 15から使えるようになった機能なので、使いたい場合は新しいPostgreSQLを使わないといけません。

使い方

それでは実装の説明を、といきたいところですが、どのように使うかがわかっていた方がピンときやすいと思うので、最初に使い方を簡単に説明します。詳しい使い方は公式ドキュメントに整備する予定です。

プライマリーとスタンバイで少し設定が違うのでプライマリーから説明します。

一番大事な設定は次の設定です。

shared_preload_libraries = 'pgroonga_wal_resource_manager'

この設定でPGroongaのカスタムWALリソースマネージャー実装を読み込みます。PGroonga用の実装に限らず、すべてのカスタムWALリソースマネージャーはshared_preload_librariesで読み込まないといけません。PostgreSQLを起動してからCREATE EXTENSIONなどで読み込むということはできません。それだと遅すぎるのです。カスタムWALリソースマネージャーはクラッシュリカバリー処理でも使われるのでPostgreSQLの起動処理のような初期の段階から読み込まれていないといけないのです。

他にも次の設定が必要ですが、これはPGroongaではカスタムWALリソースマネージャーを必須にしていないからです。明示的に有効にしなければ無効になります。

pgroonga.enable_wal_resource_manager = yes

うーん、これを書きながら思いましたが、この設定はいらない気がしますね。pgroonga_wal_resource_managerが読み込まれていたら有効、そうでなければ無効でいい気がしますね。次のリリースではこの設定は無視するようになるかも。ドキュメントを書くとこういうところに気づけるのがいいですね!

これらの設定があれば、あとはいつもどおり使うだけです。

スタンバイでは次の設定のみが必要です。

shared_preload_libraries = 'pgroonga_wal_resource_manager'

スタンバイでは更新操作はないのでpgroonga.enable_wal_resource_manager = yesは不要です。なお、設定されても使われないので害はありません。つまり、プライマリーと同じ設定を使えます。

ということで、いつもどおりpg_basebackupでスタンバイを用意すれば大丈夫です。ただ、次のようにレプリケーションスロットを作ることをおすすめします。レプリケーションスロットを作れば必要なWALが削除されることはありませんし、不要なWALは自動で削除されます。

pg_basebackup --create-slot --slot ${スロット名} ...

これだけです。shared_preload_libraries = 'pgroonga_wal_resource_manager'を設定するだけであとはいつもどおりです。これでレプリケーションが動きますし、スタンバイはクラッシュセーフになります。PGroonga独自のクラッシュセーフモジュールpgroonga_crash_saferモジュールを設定する必要はありません。ただし、プライマリーはクラッシュセーフにはなっていません。プライマリーもクラッシュセーフにするには従来どおりpgroonga_crash_saferモジュールを設定する必要があります。

実装の概要

それではこのように使えるカスタムWALリソースマネージャーの実装方法を説明します。自分もカスタムWALリソースマネージャーを実装したい!という人は参考にしてください。

ざっくり次のような流れになります。難しいのは2.だけです。

  1. リソースマネージャーIDを取得
  2. struct RmgrDataの各コールバックを実装
  3. _PG_init()RegisterCustomRmgr()を使って登録

2.は後でもう少し細分化して説明するとして、ここで簡単な1.と3を説明してしまいます。

まず1.です。

リソースマネージャーIDというのは各WALをだれが管理しているのかというのを示すIDです。各WALにはこのIDが入っていて、PostgreSQLはそのIDを見て適切なモジュールが各WALを処理するようにしています。このIDはすべてのWALでユニークである必要があります。たとえば、拡張機能Aと拡張機能Bが同じIDを使ってしまうと適切なモジュールを選べなくなってしまうからです。

では、どのようにユニークであることを保証するかというと、PostgreSQLのWikiにIDのリストを管理しているページがあるので、そこに「自分はこのIDを使う!」と書き込みます。書き込むためにはpostgresql.orgコミュニティーアカウントを取得してpgsql-wwwメーリングリストに書き込み権限ちょうだいとお願いします。しばらくすると権限を付与してもらえるので、該当Wikiページを更新します。

なお、開発中はRM_EXPERIMENTAL_IDという特別なIDを使えます。しばらくはこれで開発して、リリースできそうになったらIDを取得するとよいでしょう。

次に3.の「_PG_init()RegisterCustomRmgr()を使って登録」です。これは特に説明することもないのですが、拡張機能で一般的に使われている初期化関数_PG_init()内でRegisterCustomRmgr(ID, コールバック)で登録しておかないと、せっかく実装したコールバックを使ってもらえないというだけです。

実装の詳細

それでは実装の詳細、つまり、2.の「struct RmgrDataの各コールバックを実装」を説明します。

struct RmgrDataは次のようになっていて、rm_name以外がコールバックです。nm_nameはリソースマネージャー名なのでいい感じの名前を設定してください。ちなみに、PGroonga実装では"PGroonga"にしています。

typedef struct RmgrData
{
	const char *rm_name;
	void		(*rm_redo) (XLogReaderState *record);
	void		(*rm_desc) (StringInfo buf, XLogReaderState *record);
	const char *(*rm_identify) (uint8 info);
	void		(*rm_startup) (void);
	void		(*rm_cleanup) (void);
	void		(*rm_mask) (char *pagedata, BlockNumber blkno);
	void		(*rm_decode) (struct LogicalDecodingContext *ctx,
							  struct XLogRecordBuffer *buf);
} RmgrData;

各コールバックは次のことをします。

  • rm_redo(): 引数で渡されたWALの内容を適用
  • rm_desc(): 引数で渡されたWALの内容を文字列化
  • rm_identify(): 引数で渡されたWALの種類を文字列で返す
  • rm_startup(): 初期化
  • rm_cleanup(): 終了処理
  • rm_mask(): 一貫性チェック対象を限定
  • rm_decode(): ロジカルデコーディング用にデコード

一番大事で大変なのがrm_redo()です。他はrm_mask()rm_decode()が大変かもしれないくらいです。PGroongaはrm_mask()rm_decode()は不要なのでがんばりませんでした。

rm_redo()は後で詳しく説明するとして、他のコールバックについてもう少し説明します。

rm_desc()はWALの情報を出力するときに使われます。たとえば、pg_waldumpが使います。使うんですが、使われません。なにを言っているかわからないと思いますが、pg_waldumpでは使われないんですよねぇ。

拡張機能として実装されたWALリソースマネージャーは前述の通りshared_preload_librariesでPostgreSQLに組み込まれます。pg_waldumpにはそういう読み込む機能がないのでpg_waldump処理中には使われません。そのため、rm_desc()の代表的なユースケース(だよね?)であるpg_waldumpでは使われません。

ただ、まったく使われる機会がないというわけではなくて、WAL適用中にエラーが発生した場合に使われます。どんなWALの適用に失敗したかを示すエラーメッセージを生成するために使われます。本番運用中はそのような状況に遭遇する可能性は低いはずですが、開発中はそんなことはないのでマジメに実装しておくと開発が捗るはずです。

rm_identify()rm_desc()と一緒に使われます。違いは、rm_identify()はWALの種類だけを返して、rm_desc()はもっと詳細を返せるという点です。

rm_startup()rm_cleanup()はクラッシュリカバリー時に呼ばれます。クラッシュリカバリー処理ではWALを適用することでリカバリーしますが、そのWAL適用の前にrm_startup()が呼ばれて、すべて適用したらrm_cleanup()が呼ばれます。

PGroongaはデータベースごとに初期化・終了処理を実行したいので、それにrm_startup()/rm_cleanup()を使えるといいなぁと思ったのですが、向いていませんでした。rm_startup()/rm_cleanup()はPostgreSQLのインスタンス単位での初期化・終了処理用なんですよねぇ。

rm_mask()はWALが期待通りに適用されたかをチェックするときだけ使われます。このチェックは普通のユーザーは使いません。wal_consistency_checking日本語)という開発者向けオプションを指定したときだけ動きます。PostgreSQL管理のストレージを使っている場合はrm_mask()を実装してこのオプションを指定すればこのチェックを使えます。PGroongaはPostgreSQL管理のストレージを使っていないのでよくわかりませんが、たぶん、開発が捗るのだと思います。

rm_decode()はロジカルデコーディング用にデコードするとは思うのですが、PGroongaでは使わなかったのでよくわかりません。PostgreSQL内のコードをざっと眺めた感じではReorderBufferChangeをいい感じに準備してReorderBufferQueueChange()を呼ぶといいんじゃない?という気持ちになりました。

rm_redo()以外のコールバックについてはこれで一通り説明しました。

rm_redo()の詳細

それでは一番大事なrm_redo()の実装を説明します。

rm_redo()はWALを適用します。どう適用するかはWALの中身次第です。WALの中身を作るのはPGroongaです。つまり、書き込み時にどのようなWALを生成して、適用時にどう適用するかを対応づけて考えて実装する必要があります。では、どのように考えるとよいかをPGroongaの例で説明します。

まず、WALの中身を説明します。WALを書き込む処理から見ると、WALは次の3つのデータから構成されています。

  • リソースマネージャーID
  • 8bitのフラグ
  • 変更内容

「リソースマネージャーID」は常に取得したものを指定することになります。そうすれば自分で実装したrm_redo()を呼んでもらえます。ここは特に考えることはありません。

「8bitのフラグ」には次のフラグを指定します。

  • WALの種類を示すフラグ(上位4bitを使えるので最大15種類使える)
  • XLR_SPECIAL_REL_UPDATE

PGroongaのようにPostgreSQL管理ではないストレージを使う場合は必ずXLR_SPECIAL_REL_UPDATEを指定しないとダメなはず。ダメなはずなんだけど、PostgreSQLのソースコードを眺めてもどうダメになるのかよくわからないんですよねぇ。PostgreSQL管理のストレージを使っている場合でも、普通じゃない(?)方法でデータを書き換える場合は指定しないとダメなはず。

「WALの種類を示すフラグ」は、PGroongaは次のように変更方法ごとに別々にしています。上位4bitを使わないといけないのですべて0x10以上の値になっています。

#define PGRN_WAL_RECORD_CREATE_TABLE 0x10
#define PGRN_WAL_RECORD_CREATE_COLUMN 0x20
#define PGRN_WAL_RECORD_SET_SOURCES 0x30
#define PGRN_WAL_RECORD_RENAME_TABLE 0x40
#define PGRN_WAL_RECORD_INSERT 0x50
#define PGRN_WAL_RECORD_DELETE 0x60
#define PGRN_WAL_RECORD_REMOVE_OBJECT 0x70
#define PGRN_WAL_RECORD_REGISTER_PLUGIN 0x80

rm_redo()ではこのフラグを見て適用方法を振り分けることになります。フラグを節約するために関係性が低い処理を同じフラグで処理するようにしてしまうと、rm_redo()での処理が面倒になるので、バランスを見て設計しましょう。PGroongaの場合は処理ごとにすべて別々のフラグを使っています。

「変更内容」は「WALの種類を示すフラグ」に合わせた内容にして、書き出す側とrm_redo()側で対応させる必要があります。書き出す側でシリアライズしてrm_redo()側でデシリアライズするイメージです。

PGroongaではすべてのフラグで共通のデータと各フラグ固有のデータを「変更内容」に入れています。

共通のデータは次の通りです。

  • DBのID
  • DBのエンコーディング番号
  • DBのテーブルスペースID

フラグ固有のデータは、たとえば、テーブルを作るPGRN_WAL_RECORD_CREATE_TABLEだと次の通りです。

  • PGroongaのインデックスのテーブルスペースID
  • テーブル名
  • テーブル作成時に使うフラグ
  • テーブルのキーの型
  • テーブルで使うトークナイザー
  • テーブルで使うノーマライザー
  • テーブルで使うトークンフィルター

後半のデータはPGroongaが全文検索エンジンなので必要なデータになるのですが、テーブルのメタデータくらいの認識で大丈夫です。重要なポイントはテーブル作成に必要なデータすべてが入っているということです。rm_redo()では1つのWALを適用するときに他のWALを参照することはできません。そのため、1つのWAL内に処理に必要な情報すべてを詰め込む必要があります。(WAL適用処理を複数のステップに分割して複数のWALで実現することもできるでしょうが面倒だと思います。)

WALを書き出すときはこれをシリアライズしないといけません。つまり、ファイルやソケットに書き出すようにバイナリーデータのストリームにしないといけません。Generic WALベースの実装では自分でデータの切れ目を判断しないといけなくて面倒だったのでMessagePackを使っていましたが、WALリソースマネージャー実装ではそこは気にしなくてよいので独自の方法でシリアライズしています。どのようにシリアライズしてもいいのですが、大事なことはrm_redo()側でここで書き出されたデータをデシリアライズできるように、シリアライズ方法と対応づけて実装することです。

rm_redo()では「8bitのフラグ」から「WALの種類を示すフラグ」を取り出して、そのフラグに合わせて「変更内容」をデシリアライズして、実際に変更内容を処理します。PGroongaでは次のようになっています。

static void
pgrnwrm_redo(XLogReaderState *record)
{
	uint8 info = XLogRecGetInfo(record) & XLR_RMGR_INFO_MASK;
	switch (info)
	{
	case PGRN_WAL_RECORD_CREATE_TABLE:
		pgrnwrm_redo_create_table(record);
		break;
	case PGRN_WAL_RECORD_CREATE_COLUMN:
		pgrnwrm_redo_create_column(record);
		break;
	...
	}
}

rm_redo()を実装する時の注意点は、同じWALを何度も処理することがあるという点です。これはクラッシュリカバリー中に発生しえます。クラッシュリカバリー処理では、1度適用されたWALでも反映されていない可能性があるWALは再度適用されます。最後にチェックポイントが実行されたあとのWALすべてなのかな。ちゃんと確認していないのであまりわかっていません。少なくとも、正常にシャットダウンするとシャットダウン前のWALは再適用されません。

PGroongaではこれがどういうときに問題になるかというと、たとえば、テーブル作成が2回実行される場合です。通常のWAL適用時にテーブルが作成されているので、クラッシュリカバリー中に再度同じテーブルを作成しようとします。この操作はすでに同じ名前のテーブルが存在するので失敗します。rm_redo()が失敗するとクラッシュリカバリー処理は失敗したとみなされて、スタンバイは起動してくれません。。。

ということで、なんとかしないといけません。何度同じWALを適用しても同じ状態になる、いわゆるべき等な処理にしないといけません。PGroongaは、WAL適用前に同じ名前の既存のテーブルがあったら削除するようにしました。これで何度同じWALを適用しても同じテーブルが作成されます。

まとめると、rm_redo()の実装は大変ですが、次のポイントに注意して実装すればなんとかなるでしょう。

  • WALの種類は16種類までに抑える
  • WALの種類ごとにシリアライズ・デシリアライズ処理を対応づけて実装する
  • 同じWALを何度適用されても同じ結果になるように実装する

rm_redo()の実装よりの補足

rm_redo()の実装方法について、あまりコードを使わずに説明しましたが、実装する人向けにコード関連の情報をまとめておきます。

PGroongaの関連処理はここらへんにあります。

WALを書き出す方法は次のようにXLogBeginInsert()XLogRegisterData()→...→XLogRegisterData()XLogInsert()となります。

XLogBeginInsert();
XLogRegisterData(data1, data1_size);
XLogRegisterData(data2, data2_size);
// ...
XLogRegisterData(dataN, dataN_size);
XLogInsert(PGRN_WAL_RESOURCE_MANAGER_ID, // リソースマネージャーID
           PGRN_WAL_RECORD_CREATE_TABLE | XLR_SPECIAL_REL_UPDATE); // フラグ

まとめ

PGroongaのWALリソースマネージャー実装を説明しました。WALリソースマネージャーを実装したい人は参考にしてね。

PGroonga 3.2.1以降で使えるのでPGroongaユーザーは試してみてください。もし、なにか問題があったらフィードバックしてください。

Groonga・PGroongaのコンサルティング・技術サポートをして欲しい!という人はGroongaのサポートサービスを検討してください。

毎週火曜日の12:15-12:45にこのような技術的な話をGroonga開発者に直接聞ける「Groonga開発者に聞け!(グルカイ!)」というYouTube Liveをやっています。connpassのGroongaグループまたはYouTubeのGroongaチャンネルに登録しておけば通知が届くので活用してね。