Redis Pub/Sub メッセージ損失:原因と信頼性の高い代替手段
ネットワーク切断や低速なコンシューマー時にRedis Pub/Subがメッセージをドロップする理由を解明し、Redis Streamsやリストベースのキューを用いた確実な配信パターンを探ります。
Redis Pub/Sub メッセージ損失:原因と信頼性の高い代替手段
初めてRedis Pub/Subに痛い目を見せられた時のことを覚えています。夜の11時頃、通知システムがメッセージを落とし始めました。全部ではありませんが、ユーザーが気づくほど十分な量でした。オンコールエンジニア(残念ながら私です)は2時間かけてアプリケーションログを調べ、ようやく明白な真実にたどり着きました。Redis Pub/Subは何もキューイングしないのです。メッセージブローカーではありません。それは放水ホースのようなもので、あなたがその前に立って口を開けていなければ、何かを見逃すことになります。
これが、初めてRedis Pub/Subを使うときに誰も教えてくれないことです。ドキュメントには確かに書いてありますが、APIのシンプルさに興奮していると見落としがちです。一方でパブリッシュし、もう一方でサブスクライブすれば、うまくいきます。うまくいかなくなるまでは。
ファイア・アンド・フォーゲットの現実
Redis Pub/Subは極めて単純な原則で動作します。メッセージをパブリッシュすると、Redisはその瞬間にそのチャンネルに接続しているすべてのサブスクライバーにメッセージをプッシュします。サブスクライバーが接続していない場合、または接続していても追いつけない場合、メッセージは消えます。永続化層、確認応答メカニズム、デッドレターキューはありません。メッセージは転送中にのみ存在します。
具体的な例を挙げましょう。注文ステータスの更新をパブリッシュするサービスと、確認メールを送信するためにサブスクライブする別のサービスがあるとします。通常の負荷では、すべてが順調に進みます。その後、メールサービスが問題を起こします。SMTPリレーが遅いか、ガベージコレクションの一時停止が発生するかもしれません。その間、Redisはメッセージをプッシュし続けます。サブスクライバーのTCPバッファが満杯になります。やがて接続が切断されます。サブスクライバーが再接続すると、中断したところからではなく、現在から再開します。切断中にパブリッシュされたすべてのメッセージは失われます。
私は実際に簡単なテストセットアップでこれを測定しました。パブリッシャーが毎秒10,000メッセージを送信し、サブスクライバーが時々50ミリ秒ブロックするというものです。たった一度の短い一時停止でも、数十のメッセージが失われます。サブスクライバーはそれらが送信されたことを知りません。パブリッシャーはそれらが失われたことを知りません。Redisは完全に満足しています。設計されたとおりに動作したからです。
実際にメッセージ損失を引き起こすもの
Pub/Subがメッセージをドロップする主なシナリオは3つあり、それぞれ異なる形で現れるため、理解しておく価値があります。
ネットワークの不安定性は最も明白なものです。サブスクライバーとRedisの間の一時的なネットワーク分割は、接続を切断します。Redisはクライアントタイムアウト(デフォルト60秒ですが、もっと短く設定しているかもしれません)を介してこれを検出します。その間、パブリッシュされたすべてのメッセージはそのサブスクライバーに対して失われます。他のサブスクライバーは問題なく受信する可能性があるため、デバッグはさらに楽しくなります。サービス間で一貫性のない状態が発生し、自分がおかしくなったのではないかと疑問に思うでしょう。
低速なコンシューマーはより厄介です。接続が開いたままになるからです。Redisはプッシュモデルを使用しており、パブリッシャーが生成するのと同じ速さでサブスクライバーのソケットに書き込みます。サブスクライバーがメッセージを十分な速さで処理できない場合、カーネルのTCP受信バッファが満杯になります。そのバッファが一杯になると、Redisはそれ以上データを書き込めなくなり、最終的に接続が失敗します。サブスクライバーは、切断が発生するまで自分が遅れていることに気づかないかもしれません。
私はこれが、各メッセージに対して同期的なデータベース書き込みを行うサブスクライバーで発生するのを見てきました。低ボリュームでは問題ありません。ピーク時には、データベースがボトルネックになり、サブスクライバーは遅れ、メッセージがTCPバッファに蓄積されます。そのバッファがオーバーフローすると、接続がリセットされ、サブスクライバーはソケットからまだ読み取っていなかったすべてのものを失います。
デプロイメントや再起動中のクライアントの切断は、3番目の大きなカテゴリです。ローリングデプロイメントを行っていてサブスクライバーインスタンスがダウンすると、その不在中にパブリッシュされたすべてのメッセージを見逃します。「キャッチアップ」メカニズムはありません。オンラインに戻ると、新たに開始します。
私を驚かせたことの1つは、クリーンなシャットダウンでも役に立たないことです。サブスクライバーが終了前に正常にサブスクライブを解除しても、サブスクライブ解除から復帰までの間にパブリッシュされたメッセージを見逃します。サブスクライブ解除は瞬時に行われます。「メッセージを一時的に保持する」オプションはありません。
Pub/Subが実際に問題ない場合
Redis Pub/Subが役に立たないと言いたいわけではありません。特定のユースケースには優れており、今でも定期的に使用しています。重要なのは、それらのユースケースが何かを理解することです。
時折の損失が許容されるリアルタイム通知は、美しく機能します。ライブスポーツのスコア、株価ティッカー、チャットアプリのタイピングインジケーターなどを考えてみてください。ユーザーがスコア更新を見逃しても、次の更新が数秒後に来ます。データは寿命が短く、耐久性の要件はありません。
サービスディスカバリーと設定のブロードキャストは、もう1つの得意分野です。フィーチャーフラグを変更してすべてのアプリケーションインスタンスにパブリッシュする場合、現在再起動中のインスタンスが更新を見逃しても問題ありません。オンラインに戻ったとき、または次の定期的なリフレッシュで現在の状態を取得します。
また、複数のアプリケーションサーバー間でのキャッシュ無効化にもPub/Subを正常に使用してきました。キャッシュキーを無効化するためにパブリッシュすると、すべてのサーバーがローカルキャッシュをクリアします。1つのサーバーがメッセージを見逃した場合、最悪のケースは、キャッシュエントリが自然に期限切れになるまで古いデータを提供することです。理想的ではありませんが、壊滅的でもありません。
ここでの共通点は、Pub/Subはメッセージが本質的に一時的であり、損失が他のメカニズムで回復可能であり、順序保証や正確に1回の配信が必要ない場合に機能するということです。
Redis Streams:組み込みの代替手段
Redis 5.0で導入されたRedis Streamsは、信頼性の高いメッセージ配信が必要な場合に今私が使うものです。永続性を後付けしたPub/Subではありません。これは根本的に異なるモデルであり、ブロードキャストメカニズムというよりもKafkaのような分散ログに近いものです。
Streamsでは、メッセージはログに追加され、明示的に確認応答されるまでそこに残ります。コンシューマーは切断、再起動、遅延、そしてキャッチアップが可能です。ストリームは、最大長または保持期間に基づいてメッセージを保持するため、保持する履歴の量を制御できます。
メンタルモデルの違いは次のとおりです。Pub/Subでは、チャンネルにサブスクライブするとメッセージが流れてきます。Streamsでは、自分のペースでメッセージをプルします。コンシューマーグループは、各コンシューマーが確認応答したメッセージを追跡するため、複数のコンシューマーが重複なしで(または意図的な重複で、ファンアウトが必要な場合)同じストリームから読み取ることができます。
基本的なStreamsのセットアップは次のようになります。
XADD orders * status confirmed order_id 12345
これにより、ordersストリームにメッセージが追加されます。*はRedisにIDを自動生成するように指示します。次に、コンシューマーは次のように読み取ります。
XREADGROUP GROUP email-processor worker-1 COUNT 10 STREAMS orders >
>は、「このグループのどのコンシューマーにもまだ配信されていないメッセージをください」という意味です。処理後、コンシューマーは確認応答します。
XACK orders email-processor <message-id>
コンシューマーが確認応答前にクラッシュした場合、メッセージは保留中のままになります。グループ内の別のコンシューマーが、タイムアウト後にXCLAIMでそれを要求できます。これが、Pub/Subには完全に欠けている確認応答と再配信のメカニズムです。
実際のコンシューマーグループモデル
コンシューマーグループは、Streamsを信頼性の高い処理に真に役立つものにします。各グループはストリーム内で独自の位置を維持するため、メール通知用のグループ、分析用のグループ、監査ログ用のグループなど、すべて同じストリームを独立して読み取ることができます。
グループ内では、メッセージはコンシューマー間で分散されます。これにより、水平方向のスケーラビリティが得られます。コンシューマーインスタンスを追加すると、負荷が分散されます。1つのインスタンスがダウンすると、その保留中のメッセージは他のインスタンスが要求できるようになります。
保留中のエントリリストは監視に非常に役立つことがわかりました。XPENDINGを実行すると、どのメッセージが確認応答されていないか、どれだけ経過しているかを確認できます。これにより、低速なコンシューマーがすぐに明らかになります。ユーザーの苦情を通じて数日後にメッセージ損失を発見するよりもはるかに優れています。
Streamsの1つの注意点は、メッセージIDが単調増加するタイムスタンプであるため、メッセージを順序不同で簡単に挿入できないことです。ストリーム内で厳密な順序が必要な場合、これは実際には機能です。特定のメッセージに優先順位を付ける必要がある場合は、複数のストリームまたは別のアプローチが必要になります。
よりシンプルなニーズのためのリストベースのキュー
Streamsが存在する前は、Redisでの信頼性の高いメッセージングの標準パターンは、ブロッキングポップを使用したリストベースのキューでした。このパターンは、特に古いRedisバージョンを使用している場合や、非常にシンプルなものを求めている場合に、今でも完全に実行可能です。
考え方は簡単です。プロデューサーはメッセージをリストにLPUSHまたはRPUSHし、コンシューマーはBLPOPまたはBRPOPでメッセージが到着するまでブロックします。ブロッキングポップは重要です。これがないとポーリングすることになり、CPUを浪費し、レイテンシが増加します。
信頼性は、セカンダリの「処理中」リストからもたらされます。コンシューマーは、BRPOPLPUSH(Redis 6.2以降ではLMOVE)を使用して、メッセージを保留キューから処理キューにアトミックに移動します。処理後、処理キューからメッセージを削除します。コンシューマーがクラッシュした場合、処理キューはメッセージを保持し、モニタープロセスが古いアイテムを保留キューに戻すことができます。
私はこのパターンを何度か構築しましたが、期待されるよりも多くのコードが必要です。タイムアウトの処理、メッセージが処理キューにどれだけ留まることができるかの決定、重複処理に関するエッジケースへの対処が必要です。Streamsはこれらすべてを正式に定式化しているため、手作りのリストキューからはほとんど移行しました。
私がまだリストベースのキューを使用している唯一の場所は、処理順序が重要ではなく、可能な限り最もシンプルな実装を求めているワークキューの場合です。時には、リストとBLPOPループだけで十分であり、Streamsを追加することはやり過ぎになるでしょう。
Redis 7のPub/Subシャーディング
Redis 7ではシャーディングされたPub/Subが導入されました。これはメッセージ損失とは異なる問題を解決するため、言及する価値があります。通常のPub/Subでは、すべてのメッセージがクラスター内のすべてのノードにブロードキャストされます。たとえ特定のノードにそのチャンネルに関心のあるサブスクライバーがいなくてもです。これはクラスターインターコネクト帯域幅を浪費します。
シャーディングされたPub/Subは、チャンネルを特定のクラスタースロットに結び付けるため、メッセージはそのチャンネルのサブスクライバーが実際に存在するノードにのみ伝播します。これはパフォーマンスの最適化であり、信頼性機能ではありません。切断時には依然としてメッセージが失われます。ただし、クラスター環境で大規模にPub/Subを実行している場合は、知っておく価値があります。
選択:Pub/Sub vs Streams vs リスト
これらのパターンと長年付き合ってきた結果、私の意思決定プロセスはいくつかの質問に簡略化されました。
第一に、メッセージ損失を許容できますか?はいの場合、かつデータが一時的な場合、Pub/Subでおそらく問題ありません。最も低いレイテンシと最もシンプルな運用モデルが得られます。
第二に、メッセージの永続化とリプレイが必要ですか?はいの場合、Streamsが答えです。コンシューマーのバグ修正後にメッセージを再処理できる機能は、何度も私を救ってきました。Pub/Subでは、コンシューマーにバグがあり1時間メッセージを誤って処理した場合、それらのメッセージは永久に失われます。Streamsでは、コンシューマーグループの位置をリセットしてリプレイできます。
第三に、同じデータを読み取る複数の独立したコンシューマーグループが必要ですか?Streamsはこれをネイティブに処理します。Pub/Subでは、すべてのサブスクライバーがすべてのメッセージを受信します。これは望ましい場合もありますが、異なるグループのサブスクライバーが独立した位置を維持する方法はありません。
第四に、Redisのバージョンは何ですか?5.0より古いバージョンに固定されている場合、Streamsは利用できず、リストベースのキューまたは外部メッセージブローカーを検討することになります。私はこの状況に陥ったことがありますが、正直なところ、信頼性の高いメッセージングが必要でStreamsが使用できない場合、Redisが適切なツールであるかどうかを検討する必要があります。RabbitMQやNATSの方が適しているかもしれません。
誰も語らない運用面
ここで苦労して学んだことがあります。Pub/Subの監視は欺瞞的に難しいということです。PUBSUB NUMSUBで接続数とチャンネルサブスクリプションを監視できますが、失われているメッセージの数は確認できません。Redisがそれを追跡しないため、「パブリッシュされたが受信されなかったメッセージ」のメトリクスはありません。
Streamsでは、可視性が得られます。XINFO GROUPSはコンシューマーの遅延を表示します。XPENDINGは未確認応答のメッセージを表示します。遅延がしきい値を超えたときにアラートを設定できます。この運用上の可視性だけで、私にとってStreamsへの切り替えは価値がありました。
メモリ管理も別の考慮事項です。Pub/Subメッセージはメモリ内にのみ、転送中にのみ存在するため、メモリ使用量はパブリッシュレートとコンシューマー速度によって制限されます。Streamsはトリミングされるまでメッセージを保存するため、保持ポリシーについて考える必要があります。通常、予想されるスループットと利用可能なメモリに基づいて最大ストリーム長(MAXLEN)を設定し、ストリーム長を監視して予期しない蓄積を検出します。
現在実際に行っていること
最近では、信頼性が必要な新しいメッセージングユースケースには、デフォルトでRedis Streamsを使用しています。APIはPub/Subよりも少し複雑ですが、それほどではなく、信頼性の保証は価値があります。Pub/Subは一時的なもの(キャッシュ無効化、リアルタイムプレゼンスなど)のために残しています。
特に重要なメッセージング(支払い処理、注文フルフィルメント)については、Redisから完全に移行し、専用のメッセージブローカーを使用しています。Redisは多くの点で優れていますが、ディスクベースの永続化を伴う大量のメッセージキューには最適化されていません。メッセージがRedisの完全な再起動をゼロ損失で生き残る必要がある場合、appendfsync alwaysでAOF永続化を構成する必要がありますが、これにより書き込みパフォーマンスが低下します。その時点では、KafkaやPulsarのようなものの方が理にかなっています。
しかし、大部分の中間領域(メッセージ損失は厄介またはコストがかかるが壊滅的ではなく、すでに知っているRedisエコシステム内に留まりたい場合)では、Streamsはスイートスポットを捉えています。本番環境で私にとって十分に信頼性が高く、新しいインフラストラクチャコンポーネントを導入しない運用のシンプルさには真の価値があります。
Pub/Subで私が犯した当初の間違いは、実際にはテクノロジーに関するものではありませんでした。それは、細かい印刷を読まなかったこと、「メッセージング」が「メッセージ配信保証」を意味すると想定したことでした。Redis Pub/Subはそのような保証を一切行わず、そのふりもしていません。それを理解すれば、適切に使用し、より多くの機能が必要な場合にStreamsに手を伸ばすことができます。