HTTP/2 は接続を1本にまとめたのに、なぜ遅くなることがあるのか?
あなたも昨日、Chrome の DevTools で Network タブを開き、大量のリクエストが一斉に降ってくるウォーターフォールを眺めたはずだ。Protocol 列に並ぶ h2 という文字が何を表しているか、説明できるだろうか。HTTP/1.1 時代にブラウザが同じサイトへ何本も接続を張っていた理由まで遡って、その裏側を覗いてみる。
- HTTP/1.1 は1本の接続内でリクエストが順番待ちになる(アプリケーション層の Head-of-Line Blocking)ため、ブラウザは同一オリジンへ最大 6 本程度の TCP 接続を並行して張ることでこれを避けてきた
- HTTP/2 はバイナリフレーミングレイヤーでストリームを多重化し、1本の TCP 接続の中で複数のリクエスト・レスポンスを並行して進められるようにした。HPACK によるヘッダー圧縮も同時に導入された
- ただし TCP はバイト列の順序を保証するプロトコルであり、1本の接続に集約した代償として、パケットが1つ失われるだけで全ストリームが足止めされる「TCP層の Head-of-Line Blocking」が新たに生まれている
HTTP/1.1: 1本の接続の中でリクエストは順番を待つ
HTTP/1.1 の接続は Keep-Alive で使い回されるが、1本の接続で同時に運べるリクエスト・レスポンスの組は1つだけだ。仕様上は複数のリクエストを続けて送りつける「パイプライニング」という仕組みもあるが、サーバーは受け取った順番通りにしかレスポンスを返せない。先頭のレスポンスの生成が遅れると、後続のレスポンスがすでに完成していても待たされてしまう。この「先頭が詰まると後続まで止まる」現象をHead-of-Line Blocking(先頭ブロッキング)と呼ぶ。
この問題があるため、パイプライニングは実質ほとんどのブラウザ・サーバーで使われず、代わりにブラウザは同じオリジンへ複数の TCP 接続を並行して張るという力技で対応してきた。多くのブラウザは1オリジンあたり最大 6 本程度に接続数を制限している。これは仕様が定めた固定値ではなくブラウザの実装判断で、接続を増やすほど TCP のハンドシェイクやスロースタートのコストもかさむため、無制限に増やせば速くなるわけではない。
HTTP/2: バイナリフレーミングで1本の接続に多重化する
HTTP/2 は、テキストベースだった HTTP/1.1 を、フレームという単位でやり取りするバイナリプロトコルに作り替えた。これをバイナリフレーミングレイヤーと呼ぶ。1つのリクエスト・レスポンスのやり取りはストリームという独立した論理的な流れに割り当てられ、各フレームにはどのストリームに属するかを示す ID が付く。異なるストリームのフレームは同じ TCP 接続の中で入り乱れて送られてもよく、受信側は ID を見て元のストリームに組み立て直す。これが多重化であり、1つのリクエストの処理が遅れても、他のストリームの処理はブロックされない。
接続の確立時には、TLS ハンドシェイクに含まれるALPN(Application-Layer Protocol Negotiation)という拡張を使って、クライアントとサーバーがその場で「HTTP/2 で話せるか」をすり合わせ、対応していなければ HTTP/1.1 にフォールバックする。1本の接続に集約する設計になったため、HTTP/1.1 時代にドメインを分割して接続数を稼いでいた「ドメインシャーディング」という小細工は、HTTP/2 ではむしろ逆効果になる。
┌ HTTP/1.1: オリジンへ複数のTCP接続 ──┐ │ [conn1][conn2][conn3]…[conn6] │ │ 各接続は1リクエストずつ順番待ち │ └─────────────────────────────────────┘ ┌ HTTP/2: 1本のTCP接続に多重化 ───────┐ │ [stream1][stream2][stream3]… │ │ 1本の接続の中を並行して流れる │ │ ただしパケットロス1つで全停止 ⚠️ │ └─────────────────────────────────────┘
ついでに削られた無駄: HPACK によるヘッダー圧縮
HTTP/1.1 では、cookie や user-agent などほぼ同じ内容のヘッダーを、リクエストのたびにテキストのまま送り直していた。HTTP/2 はこの重複をHPACK という圧縮方式で圧縮する。頻出するヘッダー名・値をあらかじめ登録した静的テーブルと、接続中に一度出てきた値を覚えておく動的テーブルを組み合わせ、2回目以降は差分やテーブルの参照番号だけを送ればよくなる。
高速道路にたとえると、HTTP/1.1 の「最大6本の接続」は、6本の別々の道路を引くようなものだ。それぞれの道路は1台の車(リクエスト)ずつしか進めず順番待ちになるが、道路が分かれているので1本で事故が起きても他の道路には影響しない。HTTP/2 はこの6本の道路を1本の広い道路に統合し、車線(ストリーム)を増やして多くの車を同時に走らせる。効率は上がるが、道路そのもの(TCP接続)で事故が起きると、今度は全車線が一斉に止まってしまう。
それでも消えていない TCP層の Head-of-Line Blocking
ここまでだけ聞くと、HTTP/2 は文句なしに速いように思える。しかし実際には、1本の接続に集約したことが別の問題を生んでいる。TCP はバイト列を送信した順序のまま届けることを保証するプロトコルで、途中のパケットが1つ失われると、再送が完了するまでは、それより後に届いたデータがたとえ別のストリームに属していてもアプリケーションに渡されない。HTTP/1.1 が複数の独立した接続を張っていたときは、1本の接続でパケットが失われても他の接続には影響しなかった。HTTP/2 は接続を1本にまとめたことで、この独立性を手放している。
影響が顕著に出るのは、Wi-Fi の切り替わりやモバイル回線などパケットロス率が高いネットワークだ。多重化によってアプリケーション層のブロッキングは解消されたのに、接続を集約したことで生まれた別のブロッキングが足を引っ張るという逆転が起こり得る。
次の一手: UDP の上に作り直した HTTP/3
この TCP層の Head-of-Line Blocking を解決するために設計されたのが、UDP の上に QUIC という新しいトランスポートを載せる HTTP/3 だ。QUIC はストリームごとに独立して順序管理と再送を行うため、あるストリームでパケットが失われても他のストリームは影響を受けずに進める。HTTP/2 がアプリケーション層で多重化を実現したのに対し、HTTP/3 はトランスポート層まで多重化を持ち込んだ設計だと言える。
CDN やロードバランサーの設定で HTTP/2 を有効化・無効化する場面、あるいはバックエンドとの接続が HTTP/1.1 のままボトルネックになっていないか調査する場面で登場する。DevTools の Network タブの Protocol 列や、後述の curl での確認は、実際に想定通りのプロトコルで通信できているかを切り分ける基本手段になる。モバイル向け API でレイテンシが不安定という報告を受けたときは、パケットロスの多い回線特有の TCP層 Head-of-Line Blocking を疑う視点が HTTP/3 への移行判断にもつながる。
実際のサーバーが、指定したプロトコルでどう応答するかを curl で確認する。--http2 と --http1.1 でクライアント側から希望するプロトコルを指定し、同じサーバー・同じ URL に対して実際に使われたプロトコルを確認する。
curl -s -o /dev/null -w "requested http2 -> negotiated: HTTP/%{http_version}\n" \
--http2 https://developer.mozilla.org/
curl -s -o /dev/null -w "requested http1.1 -> negotiated: HTTP/%{http_version}\n" \
--http1.1 https://developer.mozilla.org/
1つ目は HTTP/2、2つ目は HTTP/1.1 と表示されるはずだ。同じサーバー・同じ URL でも、クライアントが ALPN でどちらを提示するかによって実際に使われるプロトコルが変わることを確認できる。Chrome の DevTools でも、Network タブのリクエストを選び Protocol 列を表示すると、同様に h2 / http/1.1 が見える。
- HTTP/2 は複数の TCP 接続を使って並列化を強化したものだ — 実際は逆で、HTTP/2 は1本の TCP 接続に集約し、その内部でストリームを多重化する設計だ。接続を増やすのではなく、減らした上で中身を並行化している。
- HTTP/2 に対応させれば、通信は常に速くなる — パケットロス率の高い回線では TCP層の Head-of-Line Blocking により、複数接続を使う HTTP/1.1 よりかえって不利になる場面がある。無条件に速くなるとは限らない。
- HTTP/2 のストリーム優先度を設定すれば、重要なリソースを確実に先に届けられる — RFC 9113 ではこの優先度の仕組み自体が非推奨化されており、実際の配信順序はサーバー・ブラウザの実装依存で保証されない。
- ストリーム
- HTTP/2 で1つのリクエスト・レスポンスのやり取りに割り当てられる、独立した論理的な通信の流れ。
- HPACK
- HTTP/2 で使われるヘッダー圧縮方式。静的・動的テーブルで重複するヘッダーを差分や参照番号に置き換える。
- Head-of-Line Blocking
- 先頭の処理が詰まることで後続の処理まで待たされる現象。HTTP/1.1 ではアプリケーション層で、HTTP/2 では TCP層で発生する。
- ALPN
- TLS ハンドシェイク中にクライアントとサーバーが使用するアプリケーション層プロトコルをすり合わせる拡張。
- バイナリフレーミングレイヤー
- HTTP/2 で HTTP メッセージをバイナリの「フレーム」単位に分解して送受信する層。テキストベースの HTTP/1.1 との違いの土台になる。
- RFC 9113: HTTP/2 — ストリーム多重化とバイナリフレーミングの正式仕様、PRIORITY frame 非推奨化の一次情報
- Evolution of HTTP — MDN — HTTP/0.9 から HTTP/3 までの変遷と、各バージョンが何を解決したかの整理
- Connection management in HTTP/1.x — MDN — Keep-Alive・パイプライニング・ブラウザの接続数上限の挙動