AlloyDB の読み取りプール導入とレプリケーションラグへの対応
AlloyDB for PostgreSQL には読み取りプールという機能があります。 クラスタに読み取り専用のノードを足し、読み取りだけをそちらへ逃がす仕組みです。
読み取りを捌くだけならプライマリインスタンスをスケールアップする手もあります。 ただしこちらは、ほぼ無停止で変更できるかわりに反映までに数十分かかるので、リクエストの増減にその場で追随する用途には向きません。 読み取りプールならノード数を短い時間で増減できるため、読み取り能力のほうだけを需要に合わせることができます。
OLTP が中心の web サービスでは、書き込みの量は読み取りに比べて圧倒的に少ないのが普通です。 負荷の大半を占めるほうを弾力的に動かせるのは、それだけで大きな利点になります。
そのかわり、読み取りの一部がプライマリではないノードに向かうことになります。 書いた値がそのノードに届くまでの遅れ、つまりレプリケーションラグを、アプリケーション側で扱う必要が出てきます。
今回は、読み取りプールを導入したときのレプリケーションラグの問題に HQ でどう対応したかを紹介します。
AlloyDB のレプリケーションはどこまで速いのか
AlloyDB はコンピュートとストレージを分離しています。 プライマリがコミットした WAL はリージョナルログストレージへ同期的に永続化され、ログ処理サービスがそれを非同期に拾って、ブロックを materialize しながらリージョナルブロックストレージへ書き出します。 読み取りプールのノードは、このブロックストレージをプライマリと共有しています。
共有しているぶん、読み取りプールは全ての変更を自分でリプレイする必要がありません。 リプレイの並列化やブロックの先読みも入っており、Google が公開しているベンチマークでは、ラグの中央値が標準の PostgreSQL 14 の 25 分の 1 以下、書き込みと読み取りを同時にかけた条件でも 100 ミリ秒以下に留まっています。
HQ の本番環境でも、実測のラグは 14 日間で p99 がおよそ 1 ミリ秒、通常時の最大が 53 ミリ秒でした。 それでもゼロではありません。 同じ 14 日間のうちに、3 秒近くまで跳ね上がった瞬間が一度ありました。
レプリケーションラグへの一般的な対応
書いた値が次の読み取りで必ず見えるという性質を read-after-write と呼びます。 非同期にレプリケーションする以上、これはアプリケーション側で作るしかありません。 以下の四つのアプローチを検討しました。
- 種別による静的な振り分け:書き込みを伴う操作はプライマリ、読み取りだけの操作は読み取りプールへ、と入口で決め打ちする方式です。 実装は最も簡単ですが、書き込みを含む操作の直後に別の読み取り操作が来た場合、書き込んだ結果が読めない可能性があります。
- sticky window:ある利用者が書き込んだら、その利用者の読み取りを一定時間だけプライマリに向ける方式です。 静的な振り分けと組み合わせて使います。 ラグが「一定時間」内に収まっている限り正しく動作しますが、超えると書き込みが読めなくなります。
- LSN token:コミットの時点の LSN(WAL 上の位置)を取り、レプリカの再生位置がその LSN に追いついたことを確かめてから読む方式です。
時間の見積もりに依存しないぶん確実ですが、LSN をクライアントとサーバーのあいだで受け渡す仕組みが要ります。
PostgreSQL では
pg_current_wal_lsn()で現在の WAL の位置を取得できます。 - 同期レプリケーション:レプリカへの反映を待ってからコミットを返す方式です。 ラグそのものが無くなるかわりに、コミットのレイテンシがレプリカの反映時間に引きずられ、レプリカを増やすほど不利になります。 読み取りを逃がすために足した仕組みの代償を書き込み側が払うことになります。 調べた限り AlloyDB はサポートしていません。
HQ での実装
前述の通り実測のラグは以下のような値を示しました:
- p99 は安定して 1 ミリ秒程度に収まる
- 一方で、ごく稀に 3 秒近くスパイクすることもある
以上を踏まえ、今回は以下のように対応することとしました:
- デフォルトをプライマリにして、GraphQL の Query だけ読み取りプールへ
- 5 秒の sticky window
NOTE
Rails も静的な振り分けと sticky window を組み合わせた形を ActiveRecord::Middleware::DatabaseSelector として標準で持っています。
静的な振り分けの判定は、デフォルトでは HTTP メソッドで行います。 GET、HEAD、QUERY のいずれかなら読み取りとして扱い、それ以外は書き込みとしてプライマリに向けます。 書き込み側を通ったときは最終書き込み時刻をセッションへ記録し、そこからデフォルトで 2 秒のあいだは読み取りもプライマリに向けます。
GraphQL の Query だけ読み取りプールへ
HQ では API の実装に GraphQL を使っています。 GraphQL には大きく分けて Query と Mutation の二つの operation があり、Query では読み取りだけを行います。
デフォルトでプライマリを使い、Query だけを選択的に読み取りプールへ向けることにしました。
デフォルトでプライマリを使うということは、何も意識せずとも正しく動作するということです。 特に読み取りプールは開発環境では使われないため、本番環境だけの落とし穴を未然に防ぐ上で重要です。 一方で、プライマリに向けた読み取りは読み取りプールの恩恵を受けられません。 プライマリに負荷がかかることにはなりますが、DB トラフィックの大部分を占めるのは GraphQL Query であり、GraphQL 以外の処理(webhook や非同期タスク)の読み取りは相対的にかなり少ないため問題ないと判断しました。
大部分を占める GraphQL Query のパフォーマンスはユーザー体験に直結するものであり、リクエスト急増に柔軟に対応できることは大きな価値を持ちます。 そして GraphQL は HTTP リクエストの往復を伴うため、連続した Mutation と Query のあいだにも数十ミリ秒の間隔が入ります。 1 ミリ秒のラグであれば、Query を読み取りプールに向けても影響を受けにくいと考えました。
5 秒の sticky window
影響を受けにくいとはいえ、ゼロではありません。 ラグがスパイクする直前に Mutation を実行したユーザーは、後続の Query で書き込んだ結果を読めない可能性があります。
sticky window と LSN token を検討しましたが、ラグの p99 がある程度安定していることから実装の容易さを優先しました。 観測された最大の 3 秒に余裕を持たせて、5 秒の sticky window を採用しています。
最終書き込み時刻をプライマリに置く
Rails は最終書き込み時刻をセッションに置きますが、HQ ではクライアントとの間に立つ BFF にセッション管理の仕組みがまだ整っていないため、プライマリに保持することとしました。
- Mutation 内のトランザクションを終了するときに追加で書き込む
- Query の際に最終書き込み時刻を取得する
この二つのクエリがプライマリとの間で余分に発生することになり、セッション管理と比べて落ち着きの悪い形ではあります。 それでも、主キーによるルックアップは十分に高速であり、これで防げる問題の大きさと比べれば許容範囲と判断しました。
いずれセッション管理を整えて、リクエストに紐づく状態をクライアントと共有できるようになれば、この二つのクエリは要らなくなります。
LSN token に切り替える条件
sticky window は時間による近似なので、ラグが 5 秒を定常的に超えるようになれば外れ始めます。 そのときは window を伸ばして誤魔化すのではなく、LSN token に切り替えることになります。
そう判断できるように、Google のドキュメントに従ってレプリケーションラグをモニタリングし、大きなラグが出たら通知が飛ぶようにしました。 一度きりのスパイクなら window の中に収まりますが、何度も出るようであれば LSN token への切り替えを検討する必要があるでしょう。
まとめ
読み取りプールは、読み取り能力を短時間で増減できるかわりに、レプリケーションラグをアプリケーションの問題として持ち込みます。 AlloyDB のラグは標準の PostgreSQL と比べればはるかに小さいものの、ゼロではないので、read-after-write は自分で作る必要があります。
どの手当てを選ぶかは、ラグの実測分布と、手当てが外れたときに何が起きるかで決まります。 HQ の場合、読み取りプールに向かうのは GraphQL の Query だけなので、外れたときの結果は書き込んだ値が読めないことであって、書き込みそのものが失われるわけではありません。 p99 が 1 ミリ秒、観測された最大が 3 秒という分布に対しては、5 秒の sticky window を採用しました。 デフォルトをプライマリにしてあるので、新しい経路が増えても古い値は返りません。
気になっているのは、最終書き込み時刻を DB に置いているという落ち着きの悪さです。 今後はバックエンドでセッションを管理できるようにしたいと考えています。

