Postgresキュー技術の選び方
(adriano.fyi)- 別個のキューシステムを導入する前に、すでに使っているPostgresだけでバックグラウンドジョブキューを構成できるなら、運用の単純さとチームの習熟を大きく活かせる
- Postgres 9.5以降の
NOTIFY/LISTENとFOR UPDATE SKIP LOCKEDは、新しいジョブの通知とワーカー間の重複処理防止を同時に解決する - Redis、Kafka、RabbitMQ、Amazon SQSは強力な選択肢だが、新たな依存関係は開発・テスト・運用環境に障害モードと学習コストを追加する
- Postgresキューも万能ではなく、Redisよりpush/popが遅いことがあり、専用のキューデータベースやサーバー、より頻繁な
VACUUMといった運用上の代償が生じることがある - バックグラウンドジョブのコードはキューバックエンドに縛られないよう抽象化し、慣れた技術で要件を満たせないときにだけ別のキュー技術を検討するのが安全
Postgresキューの仕組み
- Postgresキュー技術は2つの要素で構成される
- 新しいジョブを通知し受け取るpub/sub
- 複数のワーカーが同じジョブを処理しないようにする行ロック
- これら2つの機能は2016年にリリースされたPostgres 9.5から標準で提供されている
NOTIFYとLISTENを組み合わせることで、アプリケーションにpub/subを組み込めるFOR UPDATE SKIP LOCKEDは条件に合うレコードに行ロックを取得し、すでにロックされているレコードは飛ばす- ジョブレコードに適用すると、
SELECT * FROM jobs ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1のようなキュー処理クエリを作れる
- ジョブレコードに適用すると、
SKIP LOCKEDはデータの「一貫しない」ビューを提供するが、キューではこの特性が有用- すでに処理中のジョブは行ロックのため、他のワーカーから見えない
- この動作により分散相互排他が可能になる
NOTIFYでLISTEN中のワーカーに新しいジョブを知らせれば、定期的なバッチ処理とリアルタイム処理の両方を構成できる
Redisがバックグラウンドジョブのデフォルトになった流れ
- 長時間実行されるジョブをどう処理するかは、さまざまなトレードオフが絡む技術選定である
- 広く使われているキュー・メッセージブローカーの選択肢は次のとおり
- Redis: インメモリデータストアであり、多くのバックグラウンドジョブライブラリのバックエンド
- Apache Kafka: Apache Foundationが管理する分散イベントストリーミングプラットフォーム
- RabbitMQ: 広く導入されているメッセージブローカー
- Amazon SQS: 高いスケーラビリティを持つキューを提供するAmazonのSaaS
- GitHubのbackground jobs topicで人気上位5件のライブラリはすべてRedisベース
- Redisはデータをメモリに保存するため挿入と取得が高速で、pub/sub APIや
list、setデータ構造を備えており、キュー実装に向いている - Redisのスケーラビリティは多くの開発者にとってデフォルトの選択肢となっており、デフォルトは技術選定で強い影響力を持つ
スケーラビリティより先に見るべき基準
- 技術業界における「scale」への執着は、単純さ、保守のしやすさ、開発者の認知負荷の低減を後回しにしがちだ
- 多くのアプリケーションはGoogle、Facebook、Uber級のスケールを必要としない
- プロジェクトやビジネスを始めるときは、最初からスケーラビリティだけを最適化するより、まず次の基準を見る必要がある
- チームがよく理解している技術
- ユーザー要件を満たす十分に良い技術
- チームの力量に合い、手間が最も少ない解決策
- Postgresも実際には十分スケールするが、キュー用途で最も大規模にスケールする専用キューシステムではない
- 汎用ソフトウェアであるPostgresは多くの仕事をかなりうまくこなせ、その1つとしてキューも扱える
「退屈な技術」で判断する
- キュー技術を選ぶ際に最も重要な問いは、いま使っていてよく理解している技術は何か、ということ
- すでに使っていてよく知っている技術は、ソフトウェアスタックに追加される負担が小さい
- すでにリレーショナルデータベースを使っていて、それがPostgresなら、他のソフトウェアより先にPostgresキューを検討できる
- 「退屈な技術」は固定された一覧ではなく、現在使っている技術によって変わる
- メッセージ配送中心のアプリケーションではRabbitMQが退屈な技術かもしれない
- キャッシュ中心のアプリケーションではRedisが退屈な技術かもしれない
- リレーショナルデータが多いアプリケーションではPostgresが退屈な技術かもしれない
- Redis、Kafka、RabbitMQ、SQSをバックグラウンドジョブのためだけに新規導入するとコストが大きい
- 開発・テスト・本番環境に新しいシステム依存関係が追加される
- 将来、Developer、DBA、SREが新システムの障害モードや設定を理解する必要がある
- 障害復旧、問題診断、性能監視の知識が必要になる
- チームがまだ知らないunknown unknownsがリスクとして残る
Postgresキューの代償と選定基準
- 退屈な技術は万能ではなく、Postgresも例外ではない
- Postgresキューを選ぶと、習熟、既知の障害モード、コスト分散といった利点が得られる一方で、次の代償を払う可能性がある
- Postgresキューのpush/popはRedisよりかなり遅い
- 単一のリレーショナルデータベースではなく、アプリケーション用データベースとキュー用データベースが必要になることがある
- バックグラウンドジョブを独立してスケールさせるために、別個のデータベースサーバーが必要になることがある
- より頻繁な
VACUUMが必要となり、性能面の負担が生じる可能性がある
- Postgres、Redis、Kafka、RabbitMQ、SQSのどれも自動的なデフォルトであるべきではない
- 技術選定は継続的なトレードオフであり、チームとアプリケーションの要件に合わせて判断すべきである
- 判断に迷うときは、「退屈な技術が要件を満たせないと証明されたときにだけ代替案を検討する」という基準を適用できる
キューを切り替えられるバックグラウンドジョブ構造
- バックグラウンドジョブ処理のコードは、特定のキュー技術に依存すべきではない
- アプリケーションが成長すれば、必要に応じてmemcachedやRedisのような技術が追加され、時間がたつとその技術もチームにとって馴染みのある「退屈な技術」になりうる
- キュー技術を抽象化すれば、利用者はジョブに合ったキューを選べる
- GitHubで人気上位のバックグラウンドジョブライブラリのうち、Hangfireを除くと、Redis以外のキュー技術へ逃げる手段を提供していない
- このような構造では、キューを変えるにはアプリケーションコードを書き直す必要がある
- Neoqはこの哲学に基づいて作られたGo向けライブラリ
- キューバックエンドとしてインメモリ、Postgres、Redisを使える
- 利用者はアプリケーションコードを変更せずに、別のキューバックエンドで初期化できる
- インメモリ実装とPostgres実装はファーストパーティ提供で、Redis実装にはasynqを使う
- Redis、Kafka、RabbitMQ、SQSは優れた技術かもしれないが、常にそのジョブに適したキューであるとは限らず、複雑さの水準も常に適切とは限らない
1件のコメント
Hacker News のコメント
これまでに分散ジョブシステムを3回作ってきたが、数年来勧めている経験則は「現在の規模の10倍を基準に設計せよ」というもの
秒間70リクエストなら700リクエストを、バッチジョブサーバーが20台必要なら200台を基準に見積もる、という具合。毎年100%成長するスタートアップでも3年後には8倍規模なので、成長しながら書き直す時間はある
最初に作ったシステムでは「スケーラビリティ」のためにSQLを避けたが、トランザクション整合性が必要な例外ケースに何度も遭遇して苦労した
その後に作った2つの分散ジョブシステムでは、PostgreSQLをコーディネーターとして使い、文字どおり
SELECT FOR UPDATE SKIP LOCKEDを中心に構成した。片方は通常350個のワーカーを制御し、もう片方は数千件のジョブに複雑な優先順位を適用するどちらも年商数百万ドル規模になるまでは問題なく持つはずで、たとえば350ワーカーの方は少し手を入れればCPU約2,000個まで拡張できる
超大規模向け技術は、トランザクションが必要だと気づくまではかなり安く見える。しかしその瞬間から、結果整合性のストア上でトランザクションのセマンティクスをまねる作業はエンジニアリング上の悪夢になる
だから座って計算してみるべき。会社が年1億ドルを稼ぐようになったら分散システムはどれくらい大きくなる必要があるのか、その負荷を処理できるPostgreSQLインスタンスを簡単に用意できるのか、あるいはクライアント別シャーディングで十分なのかを検討し、可能ならPostgreSQLを強く考慮する価値がある。些細なことが100個ほど楽になる
SQLでの解法はたいてい、より単純で、計算リソースも少なく済み、運用サポートもしやすい
ただし動かすには、
SELECT FOR UPDATE SKIP LOCKEDのような機能がそもそも存在することを知っている程度にはデータベースに詳しくなければならない。最近は重いORMがDBMS本来の能力を覆い隠す環境で育つエンジニアが増えており、こうした知識はかなり珍しくなっているシステムはピーク負荷に耐える必要があり、ピークが分からないなら安全マージンを取るか、必要に応じてジョブを捨てる、または後回しにする手段を入れておくべき
すべてはトレードオフであり、最適化が必要なものだけを最適化すべきで、何がその対象なのかを判断できることが優れたエンジニアの特徴だ
これが新しい知見なら、勤務時間中にHNを1週間読んだ分の価値を今得たようなものなので、紙に書いてATMカードに貼っておく価値がある
複数のプロジェクトでより単純なアプローチを取った。どの言語のORM/クエリDSLフレームワークでもそのまま扱える通常のテーブルと
SELECT FOR UPDATE SKIP LOCKEDを使う方式だhttps://www.pgcasts.com/episodes/the-skip-locked-feature-in-...
「Webスケール」ではないが、私の経験では数千件のバックグラウンドジョブまでは容易にスケールする
大きな組織のジョブにもこの方式を問題なく使った。特別なデプロイや新しいインフラは不要で、アプリ内でワーカースレッドをいくつか立てるだけでよい。放棄されたジョブをリセットするスレッドを置くこともできる
ただし3年間、実際にそうした状況はなく、すべてが
try/catchの中にあったので失敗すればキューに戻していたし、Javaアプリも非常に安定していたdelete from taskwhere task_id in( select task_idfrom taskorder by random() -- use tablesample for better performancefor updateskip lockedlimit 1)returning task_id, task_type, params::jsonb as params[1] https://taylor.town/pg-task
「数千件のバックグラウンドジョブ」と言ったが、キューは通常、リトルの法則に基づいて見るべきで、秒間の平均ジョブ投入率や平均ジョブ継続時間のような比率で語る必要がある。生の件数だけでは大きな意味はない
最初は素朴な
UPDATE ... SETでも動くが、あまりに多くのロックを取得する。UPDATEにSELECTサブクエリを使ったり、SELECT FOR UPDATE SKIP LOCKEDで取り出し時のロックを効率化したりはできるが、結局、取り出しクエリ同士が互いのロックをブロックしてキューが止まることがあるそのとき投入を止めてDBに息をつかせることはできるが、失われた投入によってデータ損失が発生し、たいていは取り出し同士が互いにブロックする問題こそが核心だ
ジョブテーブルを急いでシャーディングしてロックを避けることもできるが、複数ワーカーにまたがるデプロイでは脆弱で、データ損失につながり得る。任意のジョブの一部を捨てることもできるが、当然それはデータ損失だ
こうした選択肢は運用中には極度にストレスが大きく、ゼロから再設計しなければ復旧も非常に難しい。顧客が数社だけで、永遠に秒間数十ジョブ程度だと確信できるなら別だが、こうした状況を実際に経験してからは、可能ならデータベースより本物のキュー技術を選ぶ
「エンジニアは自分のニーズとはまったく違うのに、格好よく見せようとしてFAANGのインフラをまねる」といった批判をよく見るが、かなりの部分は知識と文書化の問題だと思う
FAANGやほかのスタートアップをまねて無限にスケール可能なキューベースのアーキテクチャを作りたいなら、RedisやSQSのようなスケーラブルなソリューションを数時間で設定できるようにしてくれる高品質なガイド、チュートリアル、ホワイトペーパーが何十本もある
保守コストは高くなるが、評判のよい出典にあるコマンド・コード・設定をコピペして始められる
逆にPostgreSQLの
NOTIFYを使いたくて「SQLALchemy notify listen postgres」と検索すると、答えのないStack Overflowの質問がいくつかと、文脈のないGitHub Gistくらいしか出てこないサイドプロジェクトでこの方式を本当に試してみたいが、一人で2〜3日かけて調べる余裕はない
表面的には「単純だがスケールしない、つまりPostgreSQLだけを使う」か「複雑だがスケールする、つまりRedis/SQSなど」の選択に見え、そうなると自分が格好いい技術とFAANGに目がくらんで、不要なスケーラビリティを選んでいる、という話になる
しかしガイドや資料まで考慮すると、実際の選択肢は「複雑で、スケーラビリティも予測できない。自分が実装方法や落とし穴を知らないから」と「単純でスケール可能。みんなが実際にやっている方法」に近い。そうなると、FAANGをまねるエンジニアの選択ははるかに合理的に見える
NOTIFYを使うなら、単にhttps://www.postgresql.org/docs/current/sql-notify.htmlを読めばよく、検索スキルは必要ないたとえば負荷の低いPostgreSQLを使っていれば、スキーママイグレーション、新しい制約の追加、分析などはほとんど些細な作業になる
SQSやCassandraなどを使えばスケーラビリティ/可用性は得られるが、元の設計が合っていないと分かったときに変更する作業ははるかに時間がかかる。ビジネス側から「fooタイプのユーザーはbar値を同時に組み合わせてはいけない」といった制約を追加してほしいと言われるような場合がそれだ
PostgreSQLなしでも実装は可能だが、特に変更が必要になると、簡単でも単純でもない
だから柔軟性を保ちたいならPostgreSQLを使い、変わらないと分かっているときにPostgreSQLの上に別のものを併用するほうがよいと思う。もちろんこの場合、インフラと保守のオーバーヘッドが追加される
結局は常にトレードオフであり、何をいつ何と引き換えにするのかを理解しておく必要がある
すべての人がアプリケーション内でPostgreSQLバックエンドであれ別のキューであれ、カスタムのバックグラウンドジョブワーカーを自前で実装するのは理想的ではないと思う
バックグラウンドジョブには間違えやすい微妙な点や実装の詳細が多く、通常はより包括的な専用ライブラリやフレームワークに任せるほうがよい
すべてのRailsアプリケーションにSidekiq/Active Jobsがなく、それぞれがカスタムのバックグラウンドワーカーを実装していたなら、Railsアプリは信頼性の面で今よりはるかに悪い評判を得ていた可能性が高い
主目的からあまり離れずに、最大の利益をもたらす解決策を選べばよい
開発者たちが時間の約80%をツールやインフラとの格闘に費やしていたスタートアップを見たことがある。ランウェイは3か月で、今ではその先に大きな穴だけが残っている。思い出すだけで今でもぞっとする
複雑性への敬意が十分ではないように思う
PostgreSQLを発行/購読バスとして
LISTEN/NOTIFYと一緒に使うときの最大の欠点は、LISTENがセッション機能であり、文単位のコネクションプーリングと相性が悪いことだこの方式を使うなら、アドバイザリロックを使うのがよい。他の明示的ロックはデータベースにより大きな負担をかけるが、アドバイザリロックは意図的に非常に軽量に作られている
好きな実装例は、複数の言語に移植されているqueだ
NOTIFY/LISTENが嫌いな理由の一つは、問題が起きたときに診断が難しいことだ最近も、しばらくするとすべての
NOTIFY/LISTENが止まり、データベースを再起動しないと解決しない問題があり、使用をやめざるを得なかった https://dba.stackexchange.com/questions/325104/error-could-n...Skypeは数年前、すべてのCDRを処理するために、小さなプラグインとともにPostgreSQLをキューとして使っていた。今も使っているかは分からないが、10年前の基準では「Webスケール」だったし、インターネット上でデータベースをキューとして使うのはアンチパターンだと議論されている間も、普通にうまく動いていた
トランザクションがあるのはかなり便利
https://wiki.postgresql.org/wiki/SkyTools
仕事でかなり使っていたので、Sydpyでこのテーマについて何度か発表もした。PostgreSQLをすでにうまく運用・サポートしているなら有用
ただ、今なら専用キューを使うと思うし、RabbitMQだけは避ける
ベンダーロックインを心配するとしても、キューの基本操作は実質的にpush/pop程度と小さいので、必要なら移行しやすいように書くのも比較的簡単
すでにデータベースを運用しているという理由でキューまでデータベースに入れたのなら、その理屈は諸刃の剣。喜ばしいことではないが、キューの混乱が今度は主データストアまで巻き添えにできる
PostgreSQLをアプリケーションキューとして使うときの最大の利点の1つは、予約したすべての非同期ジョブがトランザクション性の恩恵を受ける点
たとえば、ユーザー登録後にメールを送る複雑なバックエンド変更処理があり、メール送信ジョブを入れたあと、同じトランザクションの後半で失敗してロールバックされると、そのメールジョブはそもそもキューに入らない
メールの場合、送信確認を待っていて失敗したら、トランザクションを失敗させてメール重複の可能性を受け入れるか、そのまま進めてメール喪失の可能性を受け入れるかを選ぶ必要がある
大きな利点は、非同期でDBを変更するコードパスから生まれる。ジョブの消費とDB更新が同じトランザクション内にあるため、Exactly-onceセマンティクスを完全にトランザクションで処理できる
PostgreSQLには、トリガーされたイベントとそのトランザクションの
pg_current_xact_id()を格納するトランザクションログテーブルを置く。行に組み込みのxminを使うこともできるが、トランザクションIDのラップアラウンドを気にする必要がある。この行に挿入するとNOTIFYが発生するバックグラウンドプロセスが繰り返し実行され、前回実行時の
xminと現在のpg_snapshot_xmin(pg_current_snapshot())の間のトランザクションIDを持つすべての行をトランザクションテーブルから選ぶ。それらのイベントをジョブにマッピングしてSQSに送信し、xminを記録したうえでLISTENで次のNOTIFYを待つPostgreSQLキューでは、メール送信ジョブを入れたあとに後でトランザクションが失敗すると、メールがキューに入らないという点は正しい
完全に別であるべきデータベースコードは、別々のトランザクションに分ける価値がある。逆に、ユーザーがDBに作成されていないなら登録メールも送りたくないかもしれないので、状況次第
Redisベースのキューや実質的に他のどんなキューでも、トランザクションがコミットされる前に、データベースレコードが存在すると仮定したジョブが実行される状況にすぐ遭遇する。これを直すコードはたいてい不自然で複雑になる
Kafkaで気に入っている点は、単なる追記専用ログであり、クライアントは本質的にオフセットだけを持つということ
概念的に理解するのが非常に単純で、永続的で、障害にもかなり強い。任意のオフセットに戻って読み直せるから
残念ながらKafkaは分散特性のために十分な複雑さを伴い、ほとんどのユースケースでは結局それに見合わなくなる
個人的には、運用しやすい似たような何かがあればいいと思う。単一ノードで毎秒数百、もしかすると数千イベントを処理できるはずで、分散の複雑さがなければかなり良いはず
理論上はPostgreSQLでもできるし、行を絶対に削除しなければよい。もしかするとそれが答えかもしれない
コンシューマーグループやパーティションのような全機能が必要ないなら、コンシューマー設定もずっと単純になるはず
Windmillではまさにこの方式を使っている。WindmillはオープンソースのRetool代替であり、モダンなAirflowに近いツールで、毎日ベンチマークを走らせている
適度なGitHub CIインスタンスでWindmillワーカー1つとPostgreSQLをコンテナとして実行すると、ベンチマークは毎秒1,200ジョブを処理する。ワーカーを追加すると、毎秒5,000ジョブまで安定してスケールする
マルチテナントインスタンスで毎秒5,000ジョブの壁を超えるために、Citusの利用を検討中
https://github.com/windmill-labs/windmill/tree/benchmarks
1秒あたり約10メッセージ程度だった頃、一部のキューにPostgreSQLを使っていて、かなりスケールはしました。ただ正直、AWS、GCP、AzureでSQSや他のキュースタックを設定するのはあまりに簡単で、その目的に合わせて作られており、DLQのようなものも組み込まれています。
わざわざそのシステムが崩れて残りのDBの状態にまで影響することを心配しながら、この道を選ぶ理由が分かりません。
「最も単純な道具を使え」という原則は好きですが、ときにはエンジニアが行き過ぎて、主流の代替手段が比較的安価で単純なのに、曖昧な注意点付きの最も愚かな道具だけを残してしまうように思います。
ジョブ状態をDBに保存すれば、状態を見やすくクエリできます。実際の状態を正確に示すわけではなくても、運用中の障害を分析する際に役立ちます。特に、ほとんどのジョブキューは処理済みレコードを単に削除してしまいます。
そしてバックグラウンドジョブをすべて冪等にしておけば、「このジョブを処理せよというメッセージをジョブキューに送る」といった方式はほぼ常に安全です。
メッセージキューだけに依存すると、性能問題が起きたときに何が起こっているのか分かりにくい場合が多いです。例えばRabbitMQはキューサイズは教えてくれても、内部データを点検する機能はほとんどないか、非常に限定的です。
ある種のソフトウェアは、PostgreSQLの能力を絶対に超えないと見込めることもあり、超えたとしても別のサービスへ移しやすく作ることもできます。
そして、トランザクションを持つジョブシステムをPostgreSQLの中で単純に作りたい場合もあります。
私たちはElixirのObanを使い、PostgreSQLの中で毎日数十万から数百万件のジョブを処理しています。
トランザクションが成功したときだけメールを予約する、といった形で、バックグラウンドジョブの周辺にトランザクション意味論があるのは非常に便利です。
autovacuumのチューニングを少しする必要はありますが、整理してみると、私たちにはとてもよく合っていました。