HN 日本語サマリー

← 一覧へ戻る
プログラミング

Postgres Queues Actually Scale

Postgres Queues Actually Scale (dbos.dev)

99 pointsby KraftyOne24 コメント

要約

Postgresをキューシステムとして利用する際の従来の常識は、スケーラビリティに限界があるというものでした。しかし、適切な最適化を施すことで、Postgresは大規模なワークロードにも対応可能であることが示されています。本記事では、Postgresベースのキューを最適化し、数千台のサーバーで毎秒3万件のワークフロー実行を達成した方法を解説します。`FOR UPDATE SKIP LOCKED`句の活用、トランザクション分離レベルの調整、そしてインデックスの効率化が鍵となります。

全文翻訳

Postgresベースのキューがスケーリングしないという従来の常識があります。大規模なワークロードを処理するには、Postgresではなく、RabbitMQ + CeleryやRedis + BullMQのような専用のキューイングシステムが必要だと考えられています。人々がそう言うのには理由があります。キューはPostgresにとって非常に要求の厳しいワークロードなのです。大規模になると、数千ものワーカーが同時にキューテーブルをポーリングし、競合が発生し、インデックスが断片化します。しかし、適切な最適化を行えば、Postgresはそれを処理できます。この記事では、Postgresベースのキューを大規模に最適化した方法を紹介し、数千台のサーバーで毎秒3万件のワークフロー実行を達成しました。 レッスン1: SKIP LOCKEDの再発見 Postgresベースのキューを機能させるための最初の問題は、複数のワーカーが同じワークフローをデキューしようとする際の競合を解決することです。大まかに言うと、Postgresベースのキューの仕組みは、クライアントがワークフローをキューテーブルに追加してエンキューし、ワーカーが最も古いエンキューされたワークフローをデキューして処理する(FIFOキューを想定)というものです。単純な方法では、各ワーカーは次のようなクエリを実行して、最も古いN個のエンキューされたワークフローを見つけ、それらをデキューします。 ```sql SELECT id FROM queues WHERE status = 'ENQUEUED' ORDER BY created_at LIMIT N; UPDATE queues SET status = 'PROCESSING' WHERE id IN (...); ``` 複数のワーカーがこのクエリを同時に実行すると、すぐに競合が発生します。すべてのワーカーが同じ最も古いキューワークフローを見て、同時にデキューしようとします。しかし、各ワークフローは単一のワーカーによってのみデキューできるため、ほとんどのワーカーは新しいワークを見つけられず、再試行する必要があります。大規模になると、この競合はシステムにボトルネックを生み出し、タスクをデキューできる速度を制限します。 幸いなことに、Postgresは、この問題を解決するために必要なプリミティブを提供しています。それがロック句です。FOR UPDATE SKIP LOCKED を使用したクエリの例を以下に示します。 ```sql SELECT id FROM queues WHERE status = 'ENQUEUED' ORDER BY created_at LIMIT N FOR UPDATE SKIP LOCKED; ``` このように行を選択すると、2つのことが行われます。第一に、他のワーカーがそれらを選択できないように行をロックします。第二に、既にロックされている行をスキップし、最も古いN個のエンキューされたワークフローではなく、他のワーカーによって既にロックされていない最も古いエンキューされたワークフローを選択します。これにより、多くのワーカーが競合なしに同時に新しいワークフローをプルできます。1つのワーカーが最も古いN個のワークフローを選択してロックし、2番目のワーカーが次の最も古いN個のワークフローを選択してロックし、というようになります。ロック句により、Postgresベースのキューが可能になります(だからSKIP LOCKEDは、しばしば再発見される古いPostgresのトリックの1つなのです)。これがないと、ワーカー間の競合により、毎秒約100ワークフロー以上にスケーリングできなくなります。これがあれば、Postgresはさらに大きくスケーリングできますが、そのスケーリングを実現するには、さらなる最適化が必要です。 レッスン2: トランザクション分離レベルに注意する ロック句はパフォーマンスを劇的に向上させますが、すぐに別の競合関連のボトルネックに達しました。大規模になると、デキュー操作がPostgresの「シリアライゼーションエラー」例外で頻繁に失敗し、再試行が必要になりました。毎秒約1000ワークフロー以上をデキューすると、デキュー操作の大部分がシリアライゼーションエラーに遭遇し、パフォーマンスのボトルネックとなりました。 原因はPostgresのトランザクション分離レベルにあることが判明しました。デキュートランザクションは、当初、グローバルなキュー制限(例:「すべてのワーカーで最大Nワークフローしか実行できない」)をサポートするために、REPEATABLE READで実行されていました。これらのグローバル制限を強制するには、ワーカーがキュー状態のグローバルに一貫したビューを共有する必要があります。そして、PostgresのREPEATABLE READは、トランザクションが開始された時点のデータベースのスナップショットで動作することを保証し、実行中に完了した同時トランザクションの影響を「見ない」ようにします。 問題は、高同時実行性ではREPEATABLE READが高価になることです。複数のワーカーが重複する行を同時に変更すると、Postgresはそのうちの1つをシリアライゼーションエラーで中止します。大規模になると、ワーカーはワークフローを処理するよりもトランザクションを再試行するのに多くの時間を費やしました。 重要な発見は、最大のキューではグローバルフロー制御がほとんど使用されないことでした。大規模になると、ユーザーは通常、「ワーカーあたり最大10ワークフロー」のようなローカル制限を好みます。これらはワーカー間の調整を必要としません。 そこで、分離レベルを条件付きにしました。 ```sql -- Global flow control: REPEATABLE READ -- Local flow control: READ COMMITTED ``` グローバルフロー制御を持つキューは引き続きREPEATABLE READを使用しますが、それを持たないキューはREAD COMMITTEDを使用します。これにより、シリアライゼーションエラーが完全に解消され、スループットが劇的に向上します。 レッスン3: インデックスは無料ではない ロック句と低い分離レベルの両方で、数千のワーカーを使用しても競合はほぼ消滅しました。しかし、毎秒約8000ワークフロー以上を実行すると、新しいボトルネック、つまり高いCPU使用率が見られました。これは、デキュークエリ自体とPostgresの自動バキュームという、一見無関係な2つの場所から発生しました。両方のソースが同じ根本原因を共有していることがわかりました。それは非効率なインデックスです。 ワークフローステータステーブルには、クエリを高速化するためにいくつかのセカンダリインデックスがありました。1つのインデックスは、デキュークエリ専用に設計されており、queue_nameとstatusをインデックス化して、Postgresが特定のキューのすべてのENQUEUEDワークフローを迅速に見つけられるようにしていました。 ```sql CREATE INDEX ON queues (queue_name, status, created_at); ``` 他のインデックスは、主にオブザーバビリティのために存在していました。例えば、ワークフロー階層の効率的なクエリを可能にするための、親ワークフローIDのインデックスです。 ```sql CREATE INDEX ON queues (parent_workflow_id); ``` 大規模になると、これらのインデックスは非効率的でした。 デキューインデックスは、すべてのエンキューされたワークフローを見つけるのに役立ちますが、特定の順序で返しません。その結果、Postgresがデキュークエリを実行する際に、最も古いエンキューされたワークフローを見つけるために、返されたワークフローをタイムスタンプでソートする必要があり、クエリのCPU使用率が増加します。 一方、多くのインデックスの維持はコストがかかります。すべてのワークフローステータス更新(エンキュー、デキュー、完了)は、すべてのインデックスの更新を必要とします。さらに、インデックスが更新された後、Postgresの自動バキュームは古いインデックスエントリをクリーンアップする必要があります。高スループットでは、インデックスのメンテナンスとバキューム処理がデータベースCPUのかなりの部分を消費します。 解決策は、インデックスをより選択的にすることです。 まず、メインのデキューインデックスを更新し、特定のキュー名のすべてのエンキューされたワークフローを返すだけでなく、それらを優先度とタイムスタンプでソートするようにしました。さらに、ワークフローステータスがENQUEUEDの場合にのみ維持される部分インデックスに変換しました。これにより、パフォーマンスが2つの理由で向上します。第一に、デキュークエリはもはや高価なソートステップを必要としません。第二に、ワークフローがデキューされると、Postgresはワークフローのライフタイム全体でそれを維持するのではなく、単にインデックスエントリを削除できるため、メンテナンスと自動バキュームのコストが削減されます。 オブザーバビリティインデックスのほとんどにも同様の原則を適用しました。例えば、親ワークフローIDのインデックスは、実際に親を持つワークフローに対してのみ維持されます。 ```sql CREATE INDEX ON queues (parent_workflow_id) WHERE parent_workflow_id IS NOT NULL; ``` これらの最適化を組み合わせることで、CPU使用率が劇的に削減され、キューは毎秒3万件以上のワークフロー、つまり月間800億件までスケーリングできるようになりました。 さらに学ぶ スケーラブルで信頼性の高いシステムを構築するのが好きなら、ぜひお声を聞かせてください。DBOSでは、Postgresベースの耐久性のある実行を可能な限りシンプルかつ高性能にすることを目指しています。ぜひチェックしてみてください。 クイックスタート: https://docs.dbos.dev/quickstart GitHub: https://github.com/dbos-inc Discordコミュニティ: https://discord.gg/eMUHrvbu67 Postgresベースのキューがスケーリングしないという従来の常識があります。大規模なワークロードを処理するには、Postgresではなく、RabbitMQ + CeleryやRedis + BullMQのような専用のキューイングシステムが必要だと考えられています。人々がそう言うのには理由があります。キューはPostgresにとって非常に要求の厳しいワークロードなのです。大規模になると、数千ものワーカーが同時にキューテーブルをポーリングし、競合が発生し、インデックスが断片化します。しかし、適切な最適化を行えば、Postgresはそれを処理できます。この記事では、Postgresベースのキューを大規模に最適化した方法を紹介し、数千台のサーバーで毎秒3万件のワークフロー実行を達成しました。 レッスン1: SKIP LOCKEDの再発見 Postgresベースのキューを機能させるための最初の問題は、複数のワーカーが同じワークフローをデキューしようとする際の競合を解決することです。大まかに言うと、Postgresベースのキューの仕組みは、クライアントがワークフローをキューテーブルに追加してエンキューし、ワーカーが最も古いエンキューされたワークフローをデキューして処理する(FIFOキューを想定)というものです。