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

バックプレッシャー

Backpressure ・ ばっくぷれっしゃー

処理が追いつかない受け手が上流へ減速を伝え、溢れる前に入口で流量を絞る仕組み。

✦ 30秒で試す — 航海速い送り手と遅い受け手。差の分は、どこへ消える?このまま動かし続けると、どうなる?予想してみる

概要

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

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

なぜ生まれたか

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

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

詳細

溢れへの3つの選択肢

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

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

溜める — 上限のないキュー生産者1000件/秒キュー消費者200件/秒毎秒800件ずつ膨らみ、遅延は伸び続け、最後はメモリ枯渇で落ちる捨てる — 上限で溢れた分を捨てる生産者1000件/秒キュー(上限あり)✕ 800件/秒を捨てる消費者200件/秒遅延とメモリは一定、データは失う減速を伝える — 処理できる量だけ要求する生産者200件/秒に減速キュー(上限あり)消費者200件/秒減速要求 — 「あとN件なら受け取れる」を上流へ返す
上限のないキューは崩壊を先送りするだけ。捨てればデータを失い、減速を伝えれば入口で流量が釣り合う

3つの違いは、下流が詰まったときに初めて目に見えます。下の部品の右端にある書き込み先を押して詰まらせ、上のボタンで3つのやり方を切り替えて確かめてください。

⚡ 体験: 詰まった下流の圧力を、どこで受け止めるか
溢れへの対処
取り込み5件/秒送出 0.0件/秒キュー1 0件加工9件/秒キュー2 0件書き込み先押すと詰まるメモリ(キューの中身)0%メモリ枯渇で落ちた 0回取り込みから書き込みまでの秒数
送出0.0件/秒溜まっている0件捨てた0件落ちた0回

取り込み(5件/秒)と書き込み先(6件/秒)が釣り合っていて、キューはほぼ空です。右端の書き込み先を押して詰まらせてください。

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

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

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

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

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

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

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

試問

左の取り込み(毎秒5件)から、キュー1・加工・キュー2を通って、右の書き込み先へデータの粒が流れるパイプラインです。書き込み先を押すと詰まって毎秒1.2件しか書けなくなり、上の切り替えで、溢れへの対処を「溜める(上限なし)」「捨てる(上限あり)」「減速を伝える」から選べます。

✦ 命題挑戦中

書き込み先が詰まったまま、1件も捨てずプロセスも落とさずに、取り込みの流量を書き込み先の速さまで絞れ

  • 書き込み先を詰まらせたまま(1.2件/秒)
  • 1件も捨てず、プロセスも落とさない
  • 取り込みの送出が 2件/秒以下に落ち着いている(全速は 5件/秒)
⚡ 体験: 詰まった下流を、何も失わずに受け止める
溢れへの対処
取り込み5件/秒送出 0.0件/秒キュー1 0件加工9件/秒キュー2 0件書き込み先押すと詰まるメモリ(キューの中身)0%メモリ枯渇で落ちた 0回取り込みから書き込みまでの秒数
送出0.0件/秒溜まっている0件捨てた0件落ちた0回

取り込み(5件/秒)と書き込み先(6件/秒)が釣り合っていて、キューはほぼ空です。右端の書き込み先を押して詰まらせてください。