アーキテクチャ●●●●○

バックプレッシャー

Backpressureバックプレッシャー

処理が追いつかないとき受け取り側が上流に減速を伝える仕組み。溢れる前に絞る。

概要

バックプレッシャー(backpressure、背圧)は、データの受け取り側が処理しきれなくなったとき、その状況を上流の送り手に伝えて流量を絞ってもらう仕組みです。配管の中で下流が詰まると圧力が上流へ押し返される現象になぞらえて、この名前が付いています。「速く作る側」と「遅く処理する側」が直結されたパイプラインなら、どこにでも登場する概念です。

問題の構図はいつも同じです。生産者(データを送る側)が毎秒1000件を送り、消費者(受け取って処理する側)が毎秒200件しかさばけないとします。差分の毎秒800件はどこかに溜まるしかありません。溜め場所が無限にない以上 — そして現実のメモリは有限です — どこかで「溜める・捨てる・遅らせる」のいずれかを選ばされます。バックプレッシャーは、この選択を偶発的なクラッシュに任せず、「消費者の都合を上流に伝播させて、入口で流量を合わせる」という形で設計に組み込むアプローチです。メッセージキューを挟んだ非同期処理、ストリーム処理、並行処理のパイプラインなど、速度の異なる工程をつなぐあらゆる場所で問われます。

なぜ生まれたか

バックプレッシャーがない世界で何が起きるかは、単純な算数で予測できます。毎秒800件の超過分をメモリ上のキューに積むと、1件1KBでも毎秒800KB、1時間で約2.8GBの成長です。キューが伸びるほど1件あたりの滞留時間も伸び、応答は遅くなり続け、最後はメモリ枯渇でプロセスごと落ちます。皮肉なことに、キューを大きくするほど「死までの猶予」が延びるだけで、死に方はむしろ派手になります。溜まった仕事もろとも消えるからです。落ちた消費者を再起動しても、上流は何も知らずに全速力で送り続けているので、同じ崩壊が繰り返されます。

この問題は新しいものではなく、最古の実装はネットワークの世界にあります。TCP/IP のフロー制御(受信ウィンドウ)は、受信側が「あと何バイト受け取れるか」を ACK に載せて送信側に伝え、送信側はその分しか送らない — 1980年代から動き続けている、世界でもっとも成功したバックプレッシャーです。アプリケーション層でこの発想が強く再認識されたのは2010年代で、非同期ストリーム処理の普及に伴い「速い生産者が遅い消費者を圧殺する」事故が頻発したことから、需要駆動の標準仕様 Reactive Streams(2015年)が策定され、JVM の Flow API や RxJava、Akka Streams、Node.js のストリームなどに同じ原理が組み込まれていきました。

詳細

溢れへの3つの選択肢

速度差に直面したとき、取れる手は本質的に3つしかありません。第一は「バッファする」 — キューに溜めて速度差を吸収する方法で、差が一時的なバースト(瞬間的な流入の山)なら最良の答えです。ただしバッファは時間を買っているだけなので、平均流入が処理能力を上回り続ける限り、いつか必ず溢れます。上限のないバッファは対策ではなく、事故の先送りです。

第二は「捨てる」 — 上限に達したら新しいデータ(または古いデータ)をドロップする方法です。乱暴に聞こえますが、リアルタイムの位置情報やメトリクスのように「最新値だけに意味があるデータ」では合理的な選択で、過負荷時に一部リクエストを意図的に拒否するロードシェディングもこの系統です。全員を遅くするより一部を断って残りを守るという判断は、グレースフルデグラデーションの一形態でもあります。第三が「減速を要求する」 — 消費者の処理能力を上流へ伝え、生産のペース自体を落とさせる方法で、狭義のバックプレッシャーはこれを指します。データを失えない処理では、これが唯一の持続可能な答えになります。

バックプレッシャーなし生産者1000件/秒キュー(上限なし)+800件/秒 で成長し続ける消費者200件/秒遅延は伸び続け、最後はメモリ枯渇でクラッシュバックプレッシャーあり生産者200件/秒に減速キュー(上限あり)バースト吸収分だけ滞留消費者200件/秒減速要求 — 処理できる量だけ要求する入口で流量が釣り合い、遅延もメモリも一定に収まる
上限のないキューは崩壊を先送りするだけ。減速要求を上流へ伝えれば入口で流量が釣り合う

減速要求はどう実装されるか

「減速を伝える」実装には二つの型があります。一つはプル型(需要駆動)で、消費者が「あとN件送ってよい」という需要(demand)を明示的に上流へ要求し、生産者はその枠内でしか送らない方式です。Reactive Streams はまさにこの契約を仕様化したもので、request(n) の連鎖がパイプライン全体を末端の消費速度に同期させます。TCP/IP の受信ウィンドウも、HTTP/2 のストリーム単位のフロー制御も、Kafka のコンシューマが自分のペースでポーリングする設計も、同じ「受け手が引く」原理です。

もう一つはブロッキング型で、上限付きキューへの書き込みを「満杯なら待たせる」ことで、生産者を物理的に減速させる方式です。Go のチャネルや Java の BlockingQueue への送信が詰まるのはこれで、待ちがパイプラインを逆流して最上流まで伝わります。素朴ですが強力で、同期的な呼び出し連鎖なら自然にバックプレッシャーが成立します。逆にいえば、間に「無限に受け付ける非同期の口」を一つでも挟むと圧力の伝播はそこで途切れます。メッセージキューを挟んで安心していたら、キューの長さ上限もアラートもなく溜まり続けていた、というのが典型的な事故です。

伝播の終着点、つまり最上流がシステム外部(人間のユーザーや外部システム)である場合は、減速要求は最終的に「受付の拒否」という形を取ります。レートリミットで流入そのものを制限し、あふれた分には HTTP 429 や 503 を返してリトライ(指数バックオフ)で出直してもらう — これはシステムの境界で行うバックプレッシャーです。拒否すら間に合わないほど下流が壊れているならサーキットブレーカーで呼び出し自体を遮断します。回復性の道具立ての中でバックプレッシャーは、「障害が起きてから守る」のではなく「過負荷を障害になる前に上流へ返す」という、いちばん手前の防衛線に位置づけられます。

実務の使いどころと落とし穴

設計の第一歩は、パイプラインのすべてのキューに上限を付け、キュー長と滞留時間をメトリクスとして観測することです。キュー長が伸び続けるグラフは「消費が生産に追いついていない」ことの最も早いシグナルで、モニタリングでここに気づければ、クラッシュの何時間も前に手を打てます。恒常的に追いつかないなら、それはもうバックプレッシャーで凌ぐ局面ではなく、消費者側のスケーリング(コンシューマの増設や処理の並列化)で処理能力そのものを上げるべき局面です。バックプレッシャーはあくまで一時的な速度差の安全弁であり、恒常的な能力不足の解決策ではありません。

落とし穴として知っておきたいのは、減速の副作用が上流のさらに上流へ波及することです。下流の詰まりが伝播した結果、最上流のWebサーバでリクエストが滞留してユーザー全員の応答が遅くなる — 圧力を正しく伝えた結果として、障害の現れる場所が変わるのです。だからこそ「どこまで待たせ、どこから捨てるか」を境界ごとに決めておく必要があります。すべてを待たせる設計は全体を道連れにし、すべてを捨てる設計はデータを失う。バッファ・ドロップ・減速の3つを、データの性質(失えるか、鮮度が命か)に応じて場所ごとに使い分けることが、バックプレッシャー設計の実質です。