
2026/04/12 1:24
Postgres キューの健全性を保ち続ける方法
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
最も重要な知見は、PostgreSQL のジョブキューが高負荷な書き込み量と「MVCC ホライズンを固定する」ようなオーバーラップしたクエリにより「デッドタプル」が蓄積され、データベース性能を著しく劣化させる点である。これにより VACUUM 操作が古くなった行のコピーをクリーンアップできず、I/O コストの増加やシステムスタールの可能性がある。Postgres 18 における最近のテストおよび履歴データの分析から、現代のバッチ処理や基本的なタイムアウト設定は部分的な緩和をもたらすことはできるものの、アクティブトランザクションが必要な保守をブロックするというコアな並行性の問題については完全には解決できないことが示された。これらの避けられないスローダウンまたは完全な障害を防ぐためには、専用のアプローチが必要である。ワークロードの種類を区別することができない受動的なツールと異なり、Traffic Control™は低優先度のワークロードを throttling することでアクティブトランザクションを制限し、維持タスクが進行できるようにして並行性を能動的に管理する。テストでは、このソリューションを有効にしたところ、キューの backlog が 155,000 から 0 に減少し、800 ジョブ/秒の重負荷テストにおいてデッドタプルを安全なサイクリング限界(0–23k)内に保持でき、データベースが常にレスポンシブに機能しながらも、絶え間ない手動介入を必要とせず、稼働を保証することが示された。
本文
Simeon Griggs 氏著 | 2026 年 4 月 10 日発表
健康な消化器系とは、廃棄物を効率的に排出できるシステムを指します。食物繊維は栄養価があるからといって健康な食事の一部となるのではなく、摂取したすべてのものがスムーズに移動するよう維持するため、重要な役割を果たします。データベースも同様です。健康的なキューテーブル(待ち行列テーブル)を実現するには、バックアップが行われる前に、クリーンアップ(不要データの削除処理)を適切に実行することを設計されたシステムを監視しておく必要があります。
Postgres( PostgreSQL )は、その用途に適していることを広く認識される以前から、キューベースのワークロードに最適な選択肢として人気を博してきました。多くの年月と複数の主要バージョンを通じて、この種類のワークロードに対する選択肢としての優位性はさらに高まってきました。しかし、ジョブキューがなぜ特に問題を引き起こしやすいのでしょうか?そして、いかに進歩が続いても、どのような罠がまだ残されているのでしょうか?これらを知ることは価値があります。なぜなら、それらはあなたのジョブキューだけでなく、複合ワークロードのデータベースやアプリケーション全体を崩壊させかねないからです。
負荷を共有する
「Postgres を使うのみ」というスローガンは、すべての種類の作業負荷が Postgres データベースに置かれるべきだという考え方を裏付けるものとなっています。それは悪くはありません。実にあらゆるものを Postgres データベースに投げ入れても機能させることができます。「 Vanilla(標準機能のみ)の Postgres」における機能不足は、豊富な拡張モジュールエコシステムによって埋め尽くされています。
その結果、同じデータベース内で同時に複数の異なる種類のワークロードが実行されることがあります。OLTP(オンライントランザクション処理)、OLAP(オンライン分析処理)、時系列データ、イベントソーシング、全文検索、ジオスフィカルデータ、およびキューベースのワークロードなどが、異なるニーズ、課題、優先順位を有しながらも、同じリソースを奪い合う形で、同一のデータベースクラスタ内で同時に実行されている場合です。
これら各種類のワークロードには、個別に使用可能な専用サービスが存在します。しかし、このブログ記事をお読みいただいているということは、それらが調和よく動作するよう最適化したいと考えている可能性がありますでしょう。PlanetScale では、それぞれのタスクに適したツールを選定することを常に推奨しています(Postgres でなくても構いません)。ただし、「Postgres 上で複合ワークロードと健やかなキューを同時に維持する方法」に興味がある方は、どうぞ続きを読むことをお勧めします。
キューベースのワークロード
ジョブキューテーブルが独特であるのは、ほとんどの行が一時的(トランジエンタル)な性質を持っていることです。すなわち、挿入され、一度読み取られ、すぐに削除されるためです。そのため、テーブルの総サイズは概ね一定のままに保たれる一方、その累積的なデータ透過量(Throughput)は膨大になります。
アプリケーションは、メール送信や請求書作成、レポート生成などの非同期アクションを追跡するためにジョブキューを利用している場合があります。Postgres でこれを扱うことの最大の利点は、ジョブの状態およびデータベース内に存在する他のロジックをトランザクションと同期に保てられることです。もしジョブが失敗すれば、トランザ全体が失敗しロールバックします。トランザクションそのものが失敗した場合、ジョブは再試行されたり削除されたりします。外部ベンダーを利用する場合アプリケーションのトランザクショナルな状態との同期を維持するためには、慎重な調整が必要となります。
今日のお題となる例
個別に実行が必要なジョブを作成するために使用されるシンプルなキューテーブルを考えてみましょう。
payload カラムには、その動作を完了させるためにアプリケーションが求めるすべての情報が含まれています。
CREATE TABLE jobs ( id BIGSERIAL PRIMARY KEY, run_at TIMESTAMPTZ DEFAULT now(), status TEXT DEFAULT 'pending', payload JSONB ); CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';
アプリケーションは定期的に「処理すべきジョブ」を検査するクエリを実行します。その際、まだ
pending 状態にある最も古いジョブを検索し、必要な作業を実行した後にそのジョブを削除します。
ワーカーがトランザクションを開始して、次に待機しているジョブを獲得する処理は以下の通りです:
BEGIN; SELECT * FROM jobs WHERE status = 'pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED;
実践上、このトランザクションを可能な限り短い期間に保つことが極めて重要です。トランザクションが開かれている時間が長ければ長いほど、真空化(vacuum)操作が待たされることになります。本記事内の例では、サブミリ秒単位でワーカーの動作完了を前提としています。
ワーカーは、ジョブが要求する作業を実行します。もし作業に失敗すればトランザクションがロールバックし、その行は一切変更されません、ロックも解放され、そのジョブは他のワーカーによって再びアクセス可能な状態になります。一方、作業が成功すれば、ワーカーはそのジョブを削除しコミットを行います:
DELETE FROM jobs WHERE id = $1; COMMIT;
並行性向上とより高速なジョブ処理のため、複数のワーカーが同時に個別のジョブを実行することも望ましい場合があります。上記の例クエリでは、
FOR UPDATE SKIP LOCKED という記述により、各ワーカーは重複して作業を行うことからの保護を受けています。同様のクエリは、トランザクションコミットされるまでその行を処理中で「ロック」し続けます。
以上のように見ると、ジョブキューベースのワークロードの性質は非常にシンプルであると言えます。行が取得され、その後削除されます。しかし、表層下にはそれ以上の要素が存在します。クリーンアップ(不要データの除去)が必要となるのです。
ジョブキューおよびそれが動作するデータベースの性能を低下させる一般的な問題は、データベースが新しいデータが生み出す速度よりも早く、これらのトランザクションによって生じたデータをクリーンアップできないことに起因します。単なる性能の問題というわけではありません;Postgres が他社によって大規模なスケールでこのワークロードを処理できると documented(文書化)されています。したがって、Postgres がジョブキューをサポートする能力については疑う余地はありません。問題は通常、ジョブキューをデータベース内の他の競合するワークロードと調和させることにあります。あなたのキューテーブルの健全さは、独自の構成だけでなく、同じ Postgres インスタンス上で実行されているすべてのトランザクションの動作にも依存します。レプリカやレプリケーションスロットも同様にキューテーブルに悪影響を与える可能性はありますが、本稿ではプライマリエリアにおける競合するクエリトラフィックに焦点を当てています。
デッドタプル( Dead Tuple)のクリーンアップが問題となる理由
行が変更された際、Postgres は同一の行の複数のバージョンを維持することができ、異なるトランザクションがその行の値を「クエリされた時点」として見ることを可能にします。これは Postgres の設計の核となる原則である「マルチバージョン同時性制御(MVCC:Multi-Version Concurrency Control)」の実装です。
つまり、私たちのジョブキューにおける Postgres データベースで
DELETE 操作の対象となる行は、即座に削除されるわけではありません。代わりに、「削除対象」としてマークされ、新しいトランザクションには見えない状態になりますが、データベース内に残り続けます。これらは「デッドタプル(Dead Tuples)」と呼ばれます。これらのまだ物理的に削除されていないが不可視な行は、手動で実行できるか健康な Postgres データベース内で定期的に起こる「真空化(vacuum)操作」によってクリーンアップされます。
- シーケンシャルスキャンの場合:エグゼキュータはヒープページからデッドタプルを読み取り、 discard 前にそれらの可視性をチェックします。
- インデックススキャンの場合(私たちのジョブキューが依存している
のようなタイプ):コストの方がより狡猾です。B-ツリーインデックス自体がデッドタプルへの参照を蓄積するため、スキャンはもはや可視ではない行を指すエントリーを辿らねばなりません。ORDER BY run_at LIMIT 1
各デッドインデックスエントリーは、ヒープページをチェックして discard する追加の I/O を意味します。このオーバーヘッドはアプリケーションからは不可視ですが、デッドタプルの数が増加すると顕著に増大します。
クリーンアップが試みられる頻度については、
autovacuum_naptime が各データベースから真空化が必要なテーブルを検査する間、ランチャーが寝ている時間を制御しており、通常はデフォルトで 1 分です。テーブルが真空化されるタイミングは、デッドタプルの閾値である autovacuum_vacuum_threshold と autovacuum_vacuum_scale_factor に依存します。
裏側のデッドタプルについて
仮想上のシナリオを考えてみましょう。さまざまなタイプのタスクが定期的に作成され、処理される
jobs テーブルがあります。また別のアプリケーションが同じデータベースにアクセスして大規模な分析クエリを実行しレポートを生成しています。これらは優先度が低く、完了までに時間がかかります。
もしあなたが次のようなクエリを実行した場合:
SELECT * FROM jobs WHERE status = 'pending'
期待するレスポンスには以下の 3 つの
pending ステータスのジョブが含まれます:
| id | run_at | status | payload |
|---|---|---|---|
| 42 | 2026-04-07 09:01:12 UTC | pending | {"type": "email", "to": "..."} |
| 43 | 2026-04-07 09:01:14 UTC | pending | {"type": "invoice", "id": 781} |
| 44 | 2026-04-07 09:01:15 UTC | pending | {"type": "report", "id": 332} |
各行の内部には、クエリエグゼキュータがレスポンスに含めるか、または現在のトランザクションに対して不可視かを判定するために読み取るメタデータが含まれています。デッドタプルを直接クエリすることはできませんが、このメタデータをあらゆる生きている(Live)タプルのレスポンスに含めることは可能です。
このトランザクションはデータベースによって ID(XID)が付与されます:
| ctid | xmin | xmax | id | status |
|---|---|---|---|---|
| (0,7) | 439821 | 0 | 42 | pending |
| (0,8) | 439825 | 0 | 43 | pending |
| (0,9) | 439830 | 0 | 44 | pending |
- ctid: テーブルのヒープ内の(ページ、オフセット)として表現される、ディス上のタプルの物理的位置。
- xmin: その行を挿入したトランザクション ID(XID);リーダーはこれを使用して、その行が自分のトランザクション開始時に存在していたか判断します。
- xmax: その行を削除またはロックした XID;値が 0 の場合はまだどのトランザクションもそれを削除対象としてマークしていないことを意味します。
また、物理的に削除されていない以前に削除されたデッドタプルが存在する可能性があります。Postgres エグゼキュータはレスポンスを返す途中にそれら仍存在する必要があります。あなたが実際には 3 つの生きている行しか見ていなくても、エグゼキュータははるかに多くのものを読み込んでいます:
(概念図:エグゼキュータがスキャンするもの)
| ctid | xmin | xmax | id | status |
|---|---|---|---|---|
| (0,1) | 439790 | 439792 | 36 | pending |
| (0,2) | 439795 | 439797 | 37 | pending |
| (0,3) | 439800 | 439803 | 38 | pending |
| (0,4) | 439804 | 439806 | 39 | pending |
| (0,5) | 439808 | 439812 | 40 | pending |
| (0,6) | 439814 | 439818 | 41 | pending |
| (0,7) | 439821 | 0 | 42 | pending |
| (0,8) | 439825 | 0 | 43 | pending |
| (0,9) | 439830 | 0 | 44 | pending |
(6 つのデッドタプル + 3 つの生きている行がスキャンされ、3 つの行が返される)
この話はヒープ(Heap)に限定されたものではありません。テーブル上の任意のインデックスも同様に、リーフエントリーをソート順に保持しており、各エントリーはヒープ上の ctid を参照しています。インデックス順でのスキャンはそれらのポインタに従ってヒープをチェックします。何らかのリーフエントリーが存在するが、それが指すヒープタプルがすでにデッドである場合、そのスキャンには無駄な作業が生じます。概念的には(クリーンアップでまだそれらのポインタが除去されていない最悪の場合):
(概念図:run_at の順に訪れられるインデックスリーフエントリー)
| run_at (pending) | tid | ヒープルックアップ後 |
|---|---|---|
| 2026-04-07 08:59:01 | (0,1) | dead — discarded |
| 2026-04-07 08:59:03 | (0,2) | dead |
| 2026-04-07 08:59:05 | (0,3) | dead |
| 2026-04-07 08:59:07 | (0,4) | dead |
| 2026-04-07 08:59:09 | (0,5) | dead |
| 2026-04-07 08:59:11 | (0,6) | dead |
| 2026-04-07 09:01:12 | (0,7) | live |
| 2026-04-07 09:01:14 | (0,8) | live |
| 2026-04-07 09:01:15 | (0,9) | live |
(6 つのデッドインデックストラゲット + 3 つの到達可能な生きている行、ヒープウォークの形状と同様)
仮想上のスケールでは、3 つのジョブと 6 つのデッドタプルは問題ありません。しかし、データベースが負荷によって生み出すスピードよりもデッドタプルを回収できない場合は、必ず失敗します。よくチューニングされ適切なリソースが確保された Postgres クラスタであれば、1 秒間に数万件のジョブというキューの透過量を処理可能です。ではなぜテーブルの膨張(bloat)が発生するのでしょうか?
通常、これは高速な書き込み頻度(high write churn)—行を挿入、更新、削除する速いサイクル—が autovacuum の速度を上回った場合に発生します。ただし、autovacume が追いつかない問題は単純な透過量の問題だけではありません。たとえ autovacuum が十分に頻繁に実行されていても、アクティブトランザクションからまだ可視であるかもしれないデッドタプルを除去できない可能性があります。
autovacuum の機能不全時
いくつかの一般的な状況で、autovacuum はデッドタプルのクリーンアップに不備を示します。特定のテーブルロックがクリーンアップを妨げ、不適切な autovacuum 設定はデッドタプルの劣化率を低下させる要因となります。
健全なデータベースでは、autovacuum は定期的に実行され、デッドタプルが可視化されるにつれてそれらをクリーンアップします。しかし最も一般的に、アクティブなトランザクションによってデッドタプルが回収不能な状態を防がれるため、クリーンアップがブロックされます。Postgres は、アクティブなトランザクションからまだ可視であるかもしれないいかなるデッドタプルも排除しません。最も古いそのようなトランザクションが「MVCC ホライズン」と呼ばれるカットオフを設定します。そのトランザクションが完了するまで、スナップショットよりも新しいすべてのデッドタプルは保持されます。
2 分を要して完了する単一のトランザクションがあると、それは 2 分間ホライズンを固定(ピン止め)します。同様の失敗モードを生み出す別の種類のワークロードとしては、それぞれが長期間実行されないものの、ホライズンを連続的にピン止めし続ける複数の重なり合うクエリがあります。例えば、各々 40 秒間実行され、20 秒おきに間隔を置いた 3 つの分析クエリを想像してください。個々のクエリが「長すぎる」というタイムアウトを引き起こすことはないでしょう。しかし常にアクティブであるため、ホライズンは決して進まず、真空化への影響は終わらないトランザクション 1 つの場合と同じとなります。
あなたのデータベースにジョブキュー以外のワークロードしかない場合、これは起こり unlikely ですが、「Postgres を使うのみ」という哲学に従っている場合、多くの重なり合うワークロードが存在します。それぞれには独自の優先順位があり、互いに邪魔にならないよう依存しています。問題点は Postgres がジョブキューに適していないか、またはジョブを十分に高速に完了できないことに不在于あります。問題は、これらの高速なジョブと、それらが急速に蓄積するデッドタプルが、他の同時進行中の重なり合う低速クエリによってクリーンアップされすぎない速度で処理されていないことです。
数年間と Postgres の主要バージョンを通じて、キューの性能を簡素化するための新しいツールが追加されました。前述のように、
autovacuum_vacuum_cost_delay や autovacuum_vacuum_cost_limit などの Postgres の autovacuum 設定をチューニングして、この操作の頻度と効果率を改善しようと試みるかもしれません。しかし我々の想定シナリオでは、修正すべきのはジョブキューの透過量ではなく、他のワークロードがどのようにそれを悪影響を与えるかです。
長時間実行されるクエリが長すぎないようにするために、いくつかのタイムアウト設定オプションがあります:
(Postgres 7.3): 指定された期間を超える任意の個々の SQL ステートメントを殺します(終了させます)。statement_timeout
(Postgres 9.6): オープン中のトランザクション内にあるセッションが、指定された期間以上にアイドル状態に陥った場合を破棄します。idle_in_transaction_session_timeout
(Postgres 17.0): 指定された期間を超えるアクティブまたは非アクティブの任意のトランザクションを殺します。transaction_timeout
ただし、これらはすべて我々の特定の問題を解決しません。これらは単一のクエリの実行時間だけを対象とする粗い手段であり、並行性や実行コストを制限できません。我々が必要とするのは、連続して MVCC ホライズンをピン止めし続けるワークロードを防ぐことです。
必要なのは、異なる「クラス」のトラフィックを区別し、高優先度のワークロードは影響を与えず、低優先度のワークロードがリソースを消費する速度を制限できるツールです。これが Database Traffic Control™ です。
Traffic Control は Insights 拡張機能の一部であり、PlanetScale 開発によるもので、PlanetScale Postgres 専有で利用可能です。個々のクエリの動作や、それらが消費できるリソース量に関する詳細な制御が必要な場合に最適です。Resource Budget(リソース予算)によって標的化されたクエリは限定的なリソースセットが割り当てられ、その制限を超えるとブロックされます。
我々の想定シナリオの解決策は、重なり合う低速クエリが実行される頻度を制限し、同時に実行可能な数を制限することです。タイムアウトはそのような粒度のある制御を提供するための粗い手段であり、十分に機能しません。これらのクエリの上限を設定することで、autovacuum が許容できる速度でデッドタプルをクリーンアップする可能性が高まります。問題の解決には特定のクエリの破棄(トミナーション)を含むため、アプリケーションに再試行ロジックが含まれていることが不可欠です。データベースがより少ない作業を行えばうまく動作するとは言えません。同じ量の仕事をしながらも、作業が行われる速度を滑らかにすることを目的としています。当アプリケーションでは、ブロックされたクエリは永遠に却下されるのではなく、より適切な時間に行うよう再試行されます。
デモの構築
この記事のインスピレーション源は、Postgres データベースにジョブキューを導入する有用性に関する内部的な議論からでした。その際に以下のブログ記事が共有されました。2015 年、Brandur Leach は Postgres Job Queues & Failure By MVCC を出版し、Postgres バックエンドのジョブキューにおける災害的な失敗モードを記録しました。このブログ記事には、未完了のトランザクションが MVCC ホライズンをピン止めし、クリーンアップを防ぐ様子をデモンストレーションするためのテストベンチも含まれています。
幸いなことに、元のテストベンチは現在
brandur/que-degradation-test で利用可能です。したがって、これまで学んだことを検証するために、それをインスピレーションとして使用できます。
問題の再現
2015 年以来大きな変化がありました。Postgres 18 を使用して同じアプリケーションワークロードを再現し、同様の問題を再現できるか見てみました。元のテストベンチは Ruby と Que gem(v0.x)を必要とし、Postgres 9.4 でテストされました。そのまま実行すると、現代的な Postgres では十数年前のライブラリをテストすることになり、現代的な Postgres 上のパターンをテストすることにはなりません。理解できるコードベース内で SQL レベルの振る舞いを孤立させるため、私はテストを TypeScript と Bun で書き直しました。
簡潔に言えば、Que と同じレкурсив CTE パターンを維持しました。同様のスキーマ、プロデューサーレート、作業期間、ワーカー数、ロングランナーパターンを使用して実行しました。PlanetScale PS-5 クラスタ(月額 $5 開始)上で動作した結果は視覚的に確認可能でしたが、管理可能な劣化を示しました。元のテストではデータベースは 15 分以内に死の螺旋(death spiral)に陥りましたが、私の PS-5 では同じ期間ワーカーキューをほぼゼロに保てました。しかし、デッドタプルには依然として顕著な線形成長があり、長期的には同様の問題が発生する可能性を示唆しました。したがって、元の問題は Postgres の新しいバージョンで軽減されています(B-ツリーインデックスのクリーンアップ、バージョンチェンジに対するボトムアップ削除、スキャン駆動でのデッドインデックストラプルの除去などによる寄与もある)が、完全には消滅していません。
修正への試み
次に、新しい Postgres バージョンではパフォーマンスが改善され、元の問題を解決できるか思いました。2015 年になかった 2026 年に利用可能な具体的な改善点は以下の 2 つです:
が、単一の SELECT(他のワーカーによってロックされた行をスキップする)でレкурсив CTE を完全に置換します。FOR UPDATE SKIP LOCKED- バッチ処理(トランザクションあたり 10 のジョブ)により、1 つのロック取得で 10 のジョブがカバーされ、インデックススキャンのコストが平準化されます。
その他はすべて同一に保ちました:8 ワーカー、50 ジョブ/秒のプロデューサー、10ms の作業時間、45s 後にスタートするロングランナーパターンです。結果はこのようになりました:
| メトリック | オリジナル(レкурсив CTE) | エンハンスド(SKIP LOCKED + バッチ) |
|---|---|---|
| ベースラインロック時間 | 2-3ms | 1.3-3.0ms |
| エンドロック時間(典型的) | 10-34ms | 9-29ms |
| 最悪のスパイク | 84.5ms (24,000 デッドタプルで) | 180ms (24,000 デッドタプルで) |
| キューの深さ | 0-100 (変動中) | 0(主に) |
| エンド時のデッドタプル数 | 42,400 | 42,450 |
| 透過量 | ~89/s | ~50/s |
劣化曲線はほぼ同一です。これらのアップデートは MVCC 劣化に影響を与えず、両方のアプローチは同じ B-ツリーインデックスをスキャンし、同じデッドタプルに遭遇するためです。主な改善点は透過量の違いですが、これはテストの設計を反映しており、ロック戦略ではありません。50 ジョブ/秒の生産では、CTE ワーカーはそれぞれ独立してジョブを獲得してプロデューサーを追い越しますが、バッチ化されたワーカーはキューを drain し、バックオフスリープ時間を過ごします。どちらのバージョンも真の圧力下にはありませんでした。
要約すると、10 年前に設計されデータベースを 15 分で殺害し得た Postgres バックエンドのキューは、今はより長く生存できますが、元の問題は依然として残っています。現代の Postgres は床を上げていますが天井を除去していません。もし 50 ジョブ/秒ではなく 500 ジョブ/秒で実行したならば、同様の問題がより速く発生し、性能が劣化し、アプリケーションが苦しみ続けます。
Traffic Control を用いた修正
Traffic Control の Resource Budget(リソース予算)は、標的化されたクエリがアクセスできるリソース数を制御するためのいくつかのレバーを提供します:
- サーバーシェアとバーストレミット: サーバーリソースの割合とその消費速度。
- クエリ単位での制限: クエリが実行可能とする時間(フルサーバー使用時間の秒数で測定)。
- 最大同時ワーカー: 利用可能なワーカープロセスの割合。
Resource Budget は、特定のワークロードが他のワークロードに悪影響を与える可能性のあるリソースを消費することを防ぐために、これら制限の一つまたは複数の組み合わせを使用して構成されます。クエリは最も一般的に、SQL
Comment タグとして追加されたメタデータによって標的化されます。当例では、分析クエリには action=analytics が設定されていました。
idle_in_transaction_session_timeout で元のベンチマークの「ロングランナー」アイドルトランザクションを捕捉して破棄できるため、劣化トリガーをより現実的なプロダクションシナリオに変更しました:複数の重なり合う分析クエリがアクティブな作業を通じてトランザクションを開き続ける状態—セッションタイムアウトで簡単に破棄できないタイプ。
Traffic Control のこの劣化抑制効果をデモンストレーションするために、
action=analytics クエリの Maximum concurrent workers(最大同時ワーカー)をすべて 1 ワーカーに制限し(max_worker_processes の 25%)、常に 1 つの分析クエリしか同時に実行されないようにしました。15 分間のテストウィンドウ内で死の螺旋を引き起こすために十分なシステム負荷を与えるため、生産性を 800 ジョブ/秒に増加させました。
同じ EC2 インスタンスに対して同じ PlanetScale データベースを「エンハンスド」ワークロードを 2 回実行しました:
- 800 ジョブ/秒
- 3 つの同時分析ワーカーが 120 秒間のクエリを実行し、連続的に重なり合うように配置
- 15 分間の期間
結果は、コアクリーンアップ問題を解決する能力を示しました。
| メトリック | Traffic Control 無効時 | Traffic Control 有効時 |
|---|---|---|
| キューの待機リスト | 155,000 ジョブ | 0 ジョブ |
| ロック時間 | 300ms+ | 2ms |
| エンド時のデッドタプル数 | 383,000 | 0-23,000(循環中) |
| 分析クエリ | 3 つ同時、重なり合う | 1 つずつ、2 つ再試行中 |
| VACUUM の効果性 | ブロック(ホライズン常にピン止め) | 正常(クエリ間の窓) |
| 結果 | 死の螺旋 | 完全に安定 |
Traffic Control は特定のワークロードを標的化し、その並行性を制限することができました。autovacuum 設定チューニングやタイムアウトでは不可能でした。分析レポートは容量に応じて引き続き実行され、15 分間のウィンドウ内に 15 個が完了しました。より多くの分析クエリを完了するには時間がかかりますが、キュー全体を通して健康状態を保っています。
まとめ
Postgres バックエンドのキューにおける MVCC デッドタプル問題は、2015 年の遺物ではありません。現代の Postgres は閾値を上げました(B-ツリー改善と SKIP LOCKED が大きな余裕を提供)が、基本メカニズムは変わっていません。VACUUM がそれらをクリーンアップできないときにデッドタプルが蓄積し、VACUUM がそれらをクリーンアップできないのは、長時間実行または重なり合うトランザクションが MVCC ホライズンをピン止めするためです。
「Postgres を使うのみ」の世界で、キュー、分析、アプリケーションロジックが単一のデータベースを共有する以上、これは理論的なリスクではありません。通常の運用状態です。危険なバージョンは劇的なクラッシュではなく、ロック時間がゆっくり増加し、ジョブが遅くなり、アラートが鳴らない静かな劣化状態のエコシステム平衡です。
Postgres はタイムアウトベースのツールを提供していますが、それらはワークロードクラスを区別したり並行性を制限したりできません。キューを他のワークロードと共に実行する場合に行える最も影響のあることは、VACUUM が追いつくようにすることです。Traffic Control はそれを簡単にしてしまいます。