はじめに — リアルタイムという要求を受け取ったとき
「通知をリアルタイムで見せてください」という要求が来ると、たいていWebSocketの話から始まります。ライブラリを選び、再接続ロジックを書き、ロードバランサーの設定を変え、水平拡張のためにRedisアダプタを付けます。ところが実際にやり取りするデータは、サーバーからクライアントへ向かう通知一種類だけ、という場合が非常に多いのです。
まず問うべき質問は三つです。データは双方向に流れるのか、クライアントからサーバーへ向かうメッセージの頻度はどれくらいか、許容できる遅延は何秒か。この答えによって必要な技術が分かれます。そして相当数の答えはWebSocketではありません。
四つの方式の動作と費用
| 方式 | 遅延 | サーバーのコネクション | メッセージあたりのオーバーヘッド | 方向 | 再接続 | HTTPインフラとの互換 |
|---|---|---|---|---|---|---|
| ショートポーリング | 0から周期まで | リクエスト時のみ | リクエストとレスポンスのヘッダー全体 | 単方向 | 不要 | 完全 |
| ロングポーリング | ほぼ即時 | 待機中はずっと占有 | メッセージごとにヘッダー全体と再接続 | 単方向 | 毎回新しいリクエスト | 完全 |
| SSE | ほぼ即時 | ずっと占有 | イベントフレームの数バイト | 単方向 | 自動 | 完全 |
| WebSocket | ほぼ即時 | ずっと占有 | フレームヘッダー2から14バイト | 双方向 | 自前で実装 | アップグレード以降はなし |
表で注目すべき欄は最後の二つです。SSEは再接続が規格に入っていてHTTPインフラをそのまま使います。WebSocketはどちらも自分で作らなければなりません。
ショートポーリングの費用を具体的に見るとこうです。同時ユーザー1万人が5秒ごとに確認すれば毎秒2000リクエストです。大半は「変わったものなし」を返す空のレスポンスです。その代わりサーバーは何の状態も持たず、キャッシュとロードバランサーがふだんどおり動き、障害時はただ次の周期で復旧します。30秒に一度更新されれば十分なダッシュボードなら、これが正解です。リアルタイムに見えないという理由で外されるだけで、技術的には最も堅牢です。
ロングポーリングはリクエストを掴んでおいて、データが生まれたら応答します。遅延はほぼ消えますが、メッセージごとにレスポンスを閉じて新しいリクエストを開く往復が必要で、ヘッダーが丸ごとまた行き来します。メッセージ頻度が高いとSSEよりはっきり高価です。いまはSSEが使えない環境のためのフォールバック程度の位置づけです。
単方向ならSSEが勝つ
SSEはごく普通のGETリクエストひとつです。レスポンスのContent-Typeがtext/event-streamで、サーバーが接続を閉じないままテキストを流し続けます。
GET /api/notifications/stream HTTP/1.1
Accept: text/event-stream
Cookie: sid=8f3c...
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-store
X-Accel-Buffering: no
retry: 3000
id: 1042
event: order.updated
data: {"orderId":"ord_8812","status":"SHIPPED"}
: keep-alive
id: 1043
event: order.updated
data: {"orderId":"ord_8813","status":"PAID"}
クライアントのコードも短いです。
const es = new EventSource('/api/notifications/stream')
es.addEventListener('order.updated', (e) => {
const payload = JSON.parse(e.data)
applyOrderUpdate(payload)
})
// 接続が切れるとブラウザが勝手にretry間隔のあとで再び繋ぎます
es.onerror = () => console.warn('stream interrupted, browser will retry')
ここで得られるものがWebSocketに対する実質的な差です。
再接続がただです。ネットワークが切れたりサーバーが再起動したりすると、ブラウザがretryの値に従って自動的に繋ぎ直します。WebSocketではこのロジックを自分で書かなければならず、たいていの最初の実装にはバックオフもジッターもありません。
再開が規格にあります。サーバーがidフィールドを送るとブラウザがその値を覚えていて、再接続時にLast-Event-IDヘッダーで送ります。サーバーはその地点以降のイベントだけを送り直せば済みます。切れているあいだの通知が消えてしまう問題を標準的な方法で解決できます。
認証とミドルウェアがそのまま動きます。クッキーが載り、既存の認証ミドルウェアが通過の可否を判断し、アクセスログが残り、レートリミットが掛かります。WebSocketはアップグレード以降はHTTPではないので、これらすべてをプロトコルの上で作り直す必要があります。
LLMのレスポンスストリーミングがほとんどSSEで実装されているのも偶然ではありません。サーバーがトークンを押し出すだけでよい、典型的な単方向の問題だからです。
SSEの実質的な弱点も正確に知っておくほうがよいです。EventSource APIはカスタムヘッダーをサポートしません。Authorizationヘッダーを付けられないという意味です。クッキー認証なら問題ありませんが、ベアラートークンを使う構造ならトークンをクエリ文字列に入れる誘惑に駆られます。クエリ文字列はアクセスログとプロキシログにそのまま残るので避けるべきです。代案はfetchでストリームを直接読むことです。
const res = await fetch('/api/notifications/stream', {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
})
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
// この方式は再接続とLast-Event-IDの処理を自分で実装する必要があります
ヘッダーは自由になりますが、規格がくれていた自動再接続と再開は失います。その二つを自分で実装する覚悟があるなら選ぶ価値があります。
もうひとつ、SSEはテキストしか送れません。バイナリを送るにはbase64で膨らませる必要があるので33パーセントの無駄が生じます。大容量のバイナリが行き来するならSSEは合いません。
WebSocketが本当に必要な場合
境界はクライアントからサーバーへ向かうメッセージの頻度と遅延の要求です。
チャットで入力中の表示を送るように毎秒何度もクライアントが話さなければならない場合、共同編集エディタでカーソル位置と編集操作を流す場合、マルチプレイヤーゲームの入力同期、バイナリフレームをやり取りする場合がここに当たります。こうした場所ではリクエストごとに付くHTTPのオーバーヘッドが実際に問題になり、双方向フレーミングが与える利得が明確です。
一方、クライアントからサーバーへ向かうイベントが毎秒一度未満なら、そのままPOSTで送れば済みます。サーバーからクライアントへはSSE、クライアントからサーバーへは普通のPOSTという組み合わせは、思っているよりずっと頻繁に正解です。HTTP/2やHTTP/3ではリクエストがひとつのコネクションに多重化されるので、コネクション確立の費用も繰り返されません。
勘違いしやすい部分もあります。「チャットだからWebSocket」という判断は半分だけ正しいです。メッセージを送る行為は毎秒何度もあることではないのでPOSTで十分であり、受け取る側だけストリームであればよいのです。入力中の表示やリアルタイムカーソルのような高頻度の信号を入れると決めた瞬間にWebSocketが必要になります。つまりWebSocketを呼ぶのはチャットというドメインではなく、高頻度の上り方向トラフィックという性質です。
インフラの現実 — タイムアウト、バッファリング、コネクション上限、スティッキーセッション
長寿命の接続は中間の機器とぶつかります。開発環境では見えず本番で現れる問題のほとんどがここにあります。
アイドルタイムアウトが最もよくあります。AWSのアプリケーションロードバランサーは既定のアイドルタイムアウトが60秒で、nginxのプロキシ読み取りタイムアウトも既定で60秒です。60秒のあいだ1バイトも流れなければ接続が切れます。通知がまばらな時間帯に接続が切れ続ける症状はここから出ます。
解法はハートビートです。SSEではコロンで始まるコメント行を定期的に送ります。クライアントはこの行を無視しますが、中間の機器はトラフィックが流れていると判断します。
// SSEのハートビート — 最も短いタイムアウトの半分以下の周期で送ります
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25_000)
req.on('close', () => clearInterval(heartbeat))
WebSocketにはpingとpongの制御フレームがあります。サーバーがpingを送り、決められた時間内にpongが来なければ死んだ接続とみなして片付けます。この片付けをしないと、実際には切れている接続の状態がサーバーのメモリに残り続けます。TCP接続が静かに消える場合は珍しくないので、ハートビートはタイムアウト回避よりも死んだ接続の検出という目的のほうが大きいです。
バッファリングもよく引っかかります。nginxは既定でプロキシのレスポンスをバッファリングするので、SSEのイベントが即座に出ずまとまって出ます。レスポンスにX-Accel-Buffering: noを付けるか、該当ロケーションでプロキシバッファリングを切ればよいです。圧縮ミドルウェアも同じ問題を作ります。イベントごとにフラッシュしないgzipミドルウェアを通すと、ストリームが停止したように見えます。text/event-streamは圧縮の対象から外すほうが安全です。
ブラウザのコネクション上限はSSEについて最もよく引用される弱点です。HTTP/1.1でブラウザはオリジンあたり6個程度のコネクションしか開きません。タブごとにSSEストリームをひとつずつ開くと六つのタブで上限が埋まり、七つめのタブはもちろん、そのオリジンへ向かう他のリクエストまで全部待たされます。原因を知らないと、たいへん奇妙な症状に見えます。
この問題はHTTP/2で消えます。ひとつのTCPコネクションの上にストリームが多重化され、同時ストリーム数は既定値が100以上です。TLSを使う現代的なCDNやロードバランサーの後ろなら、すでにHTTP/2で配信されている可能性が高いです。SSEを検討するときは実際のプロトコルバージョンから確認すればよいのです。参考までに、WebSocketはこの6個の上限の適用を受けず、別のずっと大きな上限を使います。
スティッキーセッションは選択ではなく性質です。接続がいったん結ばれると、その寿命のあいだ特定のノードに縛られます。ここから派生する問題がデプロイです。ローリングデプロイでノードをひとつ落とすとそのノードのすべての接続が同時に切れ、クライアントたちが同時に再接続を試みます。ノードを順次落とすと、この波がデプロイの時間じゅう繰り返されます。緩和策は、コネクションドレイニングの時間を十分に取り、クライアントの再接続にジッターを入れ、サーバーが終了前に再接続の遅延を大きくした値を送り出すことです。
水平拡張にはパブサブが必要だ
ノードが二つ以上あると即座にぶつかる問題があります。ユーザーAはノード1に、ユーザーBはノード2に接続しているのに、Aが作ったイベントをBへ送らなければなりません。ノード1はBのソケットを持っていません。
だからノード間の伝播レイヤーが必要です。Redisのパブサブ、NATS、Kafkaがよくある選択です。各ノードが関連チャネルを購読し、受け取ったイベントを自ノードに繋がっている接続へ流します。
選ぶときに見るべきは配送保証です。Redisのパブサブは、発行時点で購読していないノードには配送されません。再起動中だったノードはそのあいだのメッセージを永久に取り逃します。通知の欠落が許されないなら、RedisストリームやKafkaのような保存されるログを使い、クライアントの再接続時にLast-Event-IDを基準に抜けた区間を埋めてやる必要があります。
ファンアウトの費用も計算しなければなりません。すべてのノードがすべてのイベントを受け取る構造なら、ノード数が増えてもノードあたりの処理量は減らずそのままです。チャネルを細かく分けて、関心のあるノードだけが購読するようにすることが拡張の鍵です。
再接続、ハートビート、バックプレッシャー
再接続の嵐はリアルタイムサービスの代表的な自傷行為です。デプロイやネットワーク障害で10万個の接続が同時に切れると、その10万個が同時に繋ぎ直そうとします。サーバーは接続確立と認証と初期状態の送信を一度に処理しなければならず、たいていその負荷でまた崩れます。
対策はクライアント側のジッター付き指数バックオフです。
let attempt = 0
function scheduleReconnect() {
const cap = 30_000
const delay = Math.random() * Math.min(cap, 500 * 2 ** attempt)
attempt += 1
setTimeout(connect, delay)
}
function onOpen() {
attempt = 0 // 成功したら必ず初期化します
}
SSEを使えばretryフィールドでサーバーがこの値をある程度制御できます。過負荷の状況で大きな値を送り出して再接続を遅らせる、という具合です。
バックプレッシャーはもっと静かに問題になります。遅いクライアントに毎秒数十個のイベントを押し込むと、送信できなかったデータがサーバーのメモリに溜まります。Nodeでres.write()は内部バッファが一杯になるとfalseを返しますが、この戻り値を無視して書き続けるとメモリが伸び続けます。WebSocketのライブラリではソケットのバッファ待機量を確認できます。
// 溜まったデータが閾値を超えたらポリシーを適用します
if (socket.bufferedAmount > 1_000_000) {
// 1) 古いデルタは捨てて最新のスナップショットひとつにまとめるか
// 2) このクライアントを切って再接続時に全体の状態を受け取り直させます
socket.close(1013, 'client too slow')
}
選択肢は三つのうちのひとつです。捨てる、まとめる、切る。無制限にバッファリングすることだけは答えになりません。とりわけ位置や相場のように最新の値だけが意味を持つデータは、溜まったものをまとめて最後の状態ひとつだけを送るほうが、正確さと資源を同時に守れます。
WebSocketを使うなら、上り方向のメッセージに対するレートリミットも自分で入れなければなりません。HTTPミドルウェアの保護がアップグレード以降は適用されないからです。メッセージサイズの上限と毎秒のメッセージ数の上限を置かなければ、クライアントひとつでノードを占有できてしまいます。
サーバー資源の計算 — 同時接続数とメモリ
容量の見積もりは単純な掛け算ですが、項目を漏らしやすいです。
接続ひとつごとにカーネルのソケット送受信バッファが付きます。既定の設定で数KBから数十KBのあいだであり、チューニング可能です。TLSを使うとセッションごとのバッファが追加で付き、既定の設定では接続あたり数十KBに達します。そこにアプリケーションが接続ごとに持っているユーザー情報、購読リスト、送信キューが加わります。
現実的な値は接続あたり10KBから50KBです。同時接続10万個なら1GBから5GBであり、100万個をひとつのノードに収めようとする試みはたいていこの計算で止まります。ノードを増やすほうがほぼ常に優れています。
メモリ以外に引っかかる限界もいくつかあります。ファイルディスクリプタの上限はプロセスとシステムの両方で上げる必要があります。プロキシがバックエンドへ出ていくときに使う一時ポートは、宛先の組み合わせごとにおおよそ6万個台で枯渇するので、プロキシとバックエンドのあいだのコネクション再利用やバックエンドアドレスの多様化が必要です。NATゲートウェイの接続追跡テーブルも同じ理由で上限になります。
ハートビートのパケット費用も無視してはいけません。接続10万個に25秒周期のハートビートを掛けると毎秒4000個の小さなパケットが行き来します。帯域はごくわずかですが、割り込みとコンテキストスイッチは実際の費用であり、イベントがほとんどないサービスではハートビートがトラフィックの大半を占めるようになります。周期は必要なぶんだけ短く取ります。
おわりに — 必要なぶんだけリアルタイムに
方式を選ぶ順序はこうです。更新周期が数十秒でもよいか。ならばポーリングで終わりです。データがサーバーからクライアントへだけ流れるか。ならばSSEです。自動再接続と再開が規格にあり、認証もロギングもレートリミットもふだん使っているものそのままです。クライアントからサーバーへ毎秒何度も話さなければならないか、バイナリをやり取りしなければならないか。そのときがWebSocketです。
WebSocketを導入した瞬間にHTTPがくれていたものを全部自分で作らなければならず、その費用はライブラリを付ける時点ではなく運用6か月目に請求されます。再接続の嵐、死んだ接続の漏れ、ノード間の伝播、上り方向メッセージの濫用、デプロイ時の大量切断が順番にやってきます。その費用を払う理由が明確なときにだけ払うのがよいです。
현재 단락 (1/96)
「通知をリアルタイムで見せてください」という要求が来ると、たいていWebSocketの話から始まります。ライブラリを選び、再接続ロジックを書き、ロードバランサーの設定を変え、水平拡張のためにRedis...