
2026/01/26 0:51
**PostgreSQL をイベント駆動型システムのデッドレターキューとして活用する** --- ### イントロダクション イベント駆動アーキテクチャは信頼性の高いメッセージ配信に依存しますが、失敗は避けられません。 **デッドレターレイヤ(DLQ)** は、何度も試みた結果処理できなかったメッセージを保持する仕組みです。専門の DLQ サービスは存在しますが、PostgreSQL を使えば効率的かつコスト効果の高い実装が可能です。 --- ### PostgreSQL を選ぶ理由 | 機能 | メリット | |------|----------| | ACID 対応 | 失敗したイベントのデータ整合性を保証 | | 成熟したツール(pg_dump、論理レプリケーション) | バックアップ・移行が容易 | | 強力なインデックスとクエリ性能 | 問題メッセージの高速検索 | | JSONB サポート | イベントペイロードをネイティブに格納可能 | | 柔軟なアクセス制御 | DLQ テーブルへの厳密な権限付与が実現 | --- ### 一般的なアーキテクチャ 1. **プロデューサ** が `events` というプライマリーテーブルにイベントを書き込みます。 2. **コンシューマ** は `SELECT FOR UPDATE SKIP LOCKED` を用いて行を取得し処理します。 3. 処理失敗時、コンシューマは該当行を `dead_letter` テーブルへ挿入し、必要に応じて `events` から削除します。 ```sql INSERT INTO dead_letter (event_id, payload, error_message, attempted_at) SELECT id, data, 'Processing timeout', NOW() FROM events WHERE id = $1; DELETE FROM events WHERE id = $1; ``` 4. **監視** は `dead_letter` テーブルをクエリしてアラートやダッシュボードへ情報を提供します。 --- ### ベストプラクティス - **`attempted_at` にインデックス** を張ることで古い DLQ エントリのクリーンアップが高速化。 - **日付でパーティション分割** すると各パーティションを小さく保ち、VACUUM の性能向上に寄与。 - **保持ポリシー** を設定し、X 日より古いレコードは定期的(例:毎夜)にアーカイブまたは削除。 - **スキーマバージョニング** で DLQ スキーマをメインイベントスキーマと同期。 --- ### サンプルスキーマ ```sql CREATE TABLE dead_letter ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL, payload JSONB NOT NULL, error_message TEXT NOT NULL, attempted_at TIMESTAMP WITH TIME ZONE DEFAULT now(), CONSTRAINT fk_event FOREIGN KEY (event_id) REFERENCES events(id) ); ``` --- ### モニタリングとアラート - **Prometheus エクスポーター** を用い、`dead_letter_total` や `dead_letter_age_seconds` などのメトリクスを公開。 - カウントが閾値を超えた場合や age > 24 時間になった場合にアラートを発火。 --- ### 結論 PostgreSQL を DLQ として利用することで、既存インフラをそのまま活用でき、運用負荷を低減しつつ強力なクエリ機能を手に入れられます。すでに PostgreSQL にコミットしているチームにとっては、イベント駆動型システムの失敗イベントを安全かつ簡潔に扱うための実用的で信頼性の高いソリューションとなります。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Wayfairは、処理できなかったKafkaイベントをDLQトピックから専用のPostgreSQLテーブル(
dlq_events)に移動しました。この変更により、エンジニアは単純なSQLで失敗を確認し、ステータスや再試行時間でインデックス化でき、CloudSQL の耐久性を活用できます。ShedLockで保護されたスケジューラが6時間ごとに実行され、SELECT … FOR UPDATE SKIP LOCKED クエリ(ロックタイムアウトヒント付き)を使用して最大50件の PENDING 行を選択し、各イベントを 240 回まで 再試行します。成功した場合はステータスを SUCCEEDED に更新し、失敗したものは後続実行時に再び PENDING のまま残ります。テーブル構造には、イベントタイプ、ペイロード、エラー理由/スタックトレース、再試行回数、再試行期限、タイムスタンプが格納されており、status、(status,retry_after)、event_type、created_at にインデックスがあります。このアーキテクチャにより、Kafka は高スループットの取り込みバックボーンとして機能しつつ、エンジニアはPostgreSQL内で直接クエリ可能な予測可能かつ観測可能な失敗処理を実現できます。本文
Wayfair のプロジェクトに取り組んでいたとき、複数のデータソースから流れるイベントストリームを集約し、日次ビジネスレポートを生成するシステムを構築する機会がありました。大まかな仕組みとしては、Kafka コンシューマがイベントを受け取り、下流サービスへ呼び出して追加データで補完(ハイドレーション)し、最終的に Enriched なイベントを耐久性のあるストレージ―GCP 上の CloudSQL PostgreSQL―に永続化するというものです。
すべてが正常な状態ではパイプラインは想定通り動作しました。イベントが流れ込み、補完され、確実に保存されます。しかし、分散システムにおいて問題が起きることは例外ではなく常態です。そのため次のような複数の障害シナリオに対処する必要がありました。
- 補完に依存している API が停止または遅延
- コンシューマ自体が途中でクラッシュ
- イベントが欠損や不正フォーマットで到着し、安全に処理できない
これらは私たちの直接的な制御外ですが、優雅に対処する必要があります。そこで Dead Letter Queue(DLQ)の概念を導入しました。イベントが正常に処理できないことが判明した場合、破棄せず、全コンシューマをブロックせずに DLQ へリダイレクトし、後で検査・再処理できるようにします。
最初は Kafka 自体を DLQ として利用する案を試みました。これは一般的なパターンですが、我々のニーズには合わないことが早く分かりました。Kafka はデータ移動に優れていますが、一旦 DLQ トピックにメッセージが届いてしまうと検査が難しくなります。失敗理由でクエリしたり、特定サブセットを再試行したり、「昨日何が失敗して何故か?」といった簡単な質問にも追加ツールやカスタムコンシューマが必要です。ビジネスクリティカルな日次レポートを支えるシステムにとって、この可視性の欠如は重大なデメリットでした。
PostgreSQL を DLQ として活用
我々は PostgreSQL 自体を Dead Letter Queue として扱うことにしました。
- 失敗したイベントを別の Kafka トピックへ送る代わりに、直接
テーブルに永続化dlq_events - CloudSQL を耐久ストアとして既に利用していたため、運用上ほとんど追加負担がない
- 概念的にも失敗を「流れの中で見えなくなるメッセージ」ではなく、システム内で明示的な存在にする
イベントが API の失敗・コンシューマクラッシュ・スキーマ不一致・検証エラーなどで処理できなかった場合、原始的なペイロードとともに失敗の文脈情報を保存しました。各レコードはシンプルな状態フィールドを持ちます。
| ステータス | 意味 |
|---|---|
| DLQ に最初に到着した時 |
| 再処理が成功した時 |
ステートモデルを意図的に minimal に保つことで、失敗イベントのライフサイクルを簡潔に理解できます。
DLQ テーブルスキーマ
CREATE TABLE dlq_events ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(255) NOT NULL, payload JSONB NOT NULL, error_reason TEXT NOT NULL, error_stacktrace TEXT, status VARCHAR(20) NOT NULL, -- PENDING / SUCCEEDED retry_count INT NOT NULL DEFAULT 0, retry_after TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() );
主な設計考慮点
を JSONB で保存し、厳格なスキーマを強制せず原始データを保持payload
によりライフサイクルを明示的に管理status
は下流が不安定なときの過度な再試行を防止retry_after
で外部状態を持たずにリトライ制限を実装retry_count- タイムスタンプは監査・運用分析を容易に
インデックス
CREATE INDEX idx_dlq_status ON dlq_events (status); CREATE INDEX idx_dlq_status_retry_after ON dlq_events (status, retry_after); CREATE INDEX idx_dlq_event_type ON dlq_events (event_type); CREATE INDEX idx_dlq_created_at ON dlq_events (created_at);
これらのインデックスにより、リトライスケジューラは適格なイベントを効率的に探しつつ、全テーブル走査なしで高速デバッグや時間ベース分析も可能です。
ShedLock を利用した DLQ リトライ機構
可視性の問題は解決しましたが、リトライ自体を安全かつ信頼できる方法で行う必要があります。そこで ShedLock による DLQ リトライスケジューラを導入しました。このスケジューラは定期的に DLQ テーブルから
PENDING かつリトライ可能なイベントを取得し、再処理を試みます。サービスが複数インスタンスで稼働しているため、ShedLock は「いつも一つのインスタンスだけがリトライジョブを実行する」ことを保証し、重複リトライを排除します。
リトライ設定
dlq: retry: enabled: true max-retries: 240 batch-size: 50 fixed-rate: 21600000 # 6 時間(ミリ秒)
リトライの流れ
- スケジューラは毎 6 時間に実行
- 一回の実行で最大 50 件までの適格イベントを取得
- 最大リトライ数を超えたものはスキップ
- 成功したリトライは即座に
にステータス変更SUCCEEDED - 失敗は
のまま次回実行で再試行PENDING
クエリ実装
スケジューラは
FOR UPDATE SKIP LOCKED を用いた SQL クエリで、複数インスタンス間で安全に適格イベントを選択します。
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")) @Query( value = """ SELECT * FROM dlq_events WHERE event_type = :messageType AND retry_count < :maxRetries AND (status IS NULL OR status NOT IN ('SUCCEEDED')) ORDER BY created_at ASC FOR UPDATE SKIP LOCKED""", nativeQuery = true)
FOR UPDATE SKIP LOCKED は、同時に実行されるトランザクション間で重複処理を防ぎつつスループットを確保します。ロックタイムアウト -2(永遠に待機)は SKIP LOCKED と組み合わせることで「既に他のトランザクションがロックしている行はスキップ」する意味になります。
この構成により、下流障害が長時間続いてもリトライストームや不要な負荷を抑えつつ、システムは耐久性を保てました。
運用上のメリット
- 失敗が予測可能かつ観察可能になり、破壊的ではなくなる
- エンジニアは単純な SQL で失敗を検査し、必要なイベントだけ再処理できる
- 下流依存サービスが数時間・数日停止しても、DLQ に安全に蓄積され、後で再試行
- 根本的に不良なイベントは見えたままで、静かに捨てられない
最も重要なのは、運用ストレスを大幅に軽減したことです。失敗が「恐れるべきもの」ではなく、明確で監査可能な回復経路を持つシステムの一部となりました。
私の考え
Kafka を PostgreSQL に置き換えることは決して目的ではありませんでした。Kafka は高スループットのイベント取り込みに引け目なし、PostgreSQL は耐久性・クエリ性能・失敗時の可視化を担いました。各システムが得意分野で役割を果たすことで、レジリエントかつデバッグしやすく、運用も楽なパイプラインを実現できました。
結局、PostgreSQL を Dead Letter Queue として使うことは、失敗処理を「退屈で予測可能」に変え、プロダクション環境において正に望ましい結果を生み出しました。