再生回数は、訪問者が最初に信頼する数字です。その動画に30秒を費やす価値があるのか、それとも30分なのかを判断する基準となります。TopVideoHubでは、その数字は激しく動きます。トレンドのクリップなら、わずか10分で4万回再生されることもあります。もしページ上のカウンターが止まってしまったら、その場は空虚に感じられ、ユーザーは離脱してしまいます。

その数字をブラウザに届けることは、一見簡単そうに思えます。しかし、そうではありません。多くのチームが最初に検討するのはポーリングです。実装は容易で、ステージング環境では問題なく動作します。しかし、ステージング環境は現実を反映していません。

ポーリングが自己へのDDoS攻撃に変わる時

TopVideoHubのチームは、シンプルなJavaScriptポーラーを作成しました。5秒ごとに最新の再生回数を取得する仕組みです。3つのブラウザを開いたテスト環境では、完璧に見えました。しかし、本番環境ではプラットフォームを崩壊させました。

8,000人の同時視聴者が5秒ごとにリフレッシュすると、毎秒1,600リクエストが発生します。すべてのリクエストがデータベースに突き刺さります。レプリケーション遅延が急増し、リードレプリカは悲鳴を上げ、キャッシュレイヤーはバイパスされました。チームは動画を配信しているのではなく、自ら招いた負荷を配信していたのです。

ポーリングは、問題が起きるまでは無害です。低トラフィックのダッシュボードや管理パネルなら問題ありません。しかし、バイラル動画のページにおいては、それは時限爆弾となります。チームにはサーバーからブラウザへの持続的なパイプが必要でしたが、フル二重プロトコルの複雑さまでは必要ありませんでした。

なぜSSEが単方向パイプに適しているのか

Server-Sent Events (SSE) は、まさにこのような問題のために設計されています。サーバーがデータを持っており、ブラウザはそれをリッスンするだけでよい、という状況です。

WebSocketとは異なり、SSEは通常のHTTP上で動作します。これは、想像以上に重要なことです。新しいプロキシルールやUpgradeヘッダー、ロードバランサーの複雑な調整は必要ありません。サーバーがHTTP/1.1またはHTTP/2をサポートしていれば、SSEは動作します。ストリームは単なるテキストであるため、デバッグも容易です。curlでエンドポイントを指定すれば、数字がリアルタイムで流れるのを観察できます。バイナリのソケットフレームがなぜ狂ったのかを推測するよりも、ずっと簡単です。

ブラウザが面倒な部分を無料で処理してくれます。接続が切れても、SSEはLast-Event-IDヘッダーを使用して自動的に再接続するため、サーバーはどこから再開すべきかを知ることができます。JavaScriptのAPIは非常にシンプルです。EventSourceを作成し、onmessageハンドラをアタッチするだけで完了です。

キャッシュこそが真のアーキテクチャである

ライブカウンターにおける最大の設計ミスは、ブラウザの接続ごとにデータベースにクエリを投げることです。8,000人が同じ動画を見ている場合、2秒ごとに8,000回のクエリを実行するのは狂気の沙汰です。データベースはバイラルなトラフィックに耐えられません。

TopVideoHubはこの問題を、PHPのインメモリ・オペコードおよびユーザーキャッシュであるAPCuを使用して解決しました。フローはシンプルです。バックグラウンドプロセス(またはタイマーで叩かれる軽量なエンドポイント)が、2秒に一度、現在の再生回数をAPCuに書き込みます。数千のブラウザが接続を維持しているSSEエンドポイントは、APCuからのみ読み取ります。

その結果、視聴者が何人いようとも、データベースへの負荷は2秒に1回だけになります。キャッシュがショックアブソーバー(緩衝材)となるのです。APCuは特殊なものではありません。PHPに同梱されており、共有メモリ内に存在し、いかなるネットワークのラウンドトリップよりも高速に読み取れます。頻繁に、かつ即時ではない程度に変化する単一の数字を扱うには、最適なツールです。

APCuを使用していない場合は、RedisやMemcachedでも代用可能です。原理は同じです。高頻度な読み取りパスをデータベースから切り離すのです。

PHP、LiteSpeed、Cloudflareにストリーミングさせる

PHPは処理を終えて終了したがります。Webサーバーは出力をバッファリングして、整ったレスポンスを返そうとします。SSEに必要なのはその逆です。接続を維持し、バイトが到着するたびにフラッシュ(放出)することです。注意を払わないと、「ストリーム」は30秒後に一つの塊(blob)として届いてしまい、目的を果たせません。

TopVideoHubがどのようにパイプの詰まりを防いだのか、その方法を紹介します。

出力バッファリングを無効化する。 SSEスクリプトの開始時に、PHPが有効にしている可能性のあるすべてのバッファリングレイヤーを無効にします。バッファがアクティブな場合はob_end_flush()を呼び出し、ヘッダー送信後はob_implicit_flush(true)で暗黙的なフラッシュをオフにします。

プロキシに干渉させない。 X-Accel-Buffering: noヘッダーを送信します。NginxもLiteSpeedもこれに従います。これは、レスポンスをバッファリングしたり、キャッシュ可能な塊として圧縮したりしないように指示するものです。

短い生存期間を設定する。 各SSE接続はPHPのワーカーを占有します。TopVideoHubでは、ストリームの期間を55秒に制限しています。タイマーが作動すると、サーバーは最後にコメントを送信してストリームを閉じ、ブラウザは自動的に再接続します。この再接続によって新しいワーカーが割り当てられるため、単一のプロセスが永遠に居座り続けることを防げます。

Ping to stay alive. Send a comment line—something like : ping—every twenty seconds. Comments in SSE are ignored by the browser’s message handler, but they keep the TCP connection warm. Load balancers and CDNs often drop silent connections after thirty or sixty seconds. A cheap newline saves you from that axe.

Respect the user’s tab. When a visitor minimizes or hides the tab, bail out. Listen for visibilitychange in the browser and call eventSource.close(). The server should also detect a client disconnect and terminate the loop. PHP can check connection_aborted() inside a loop. Do not let ghost connections burn workers for people who left ten minutes ago.

The Hard Ceiling: PHP Workers

SSE in PHP is honest about its limits. Every open SSE connection consumes one PHP worker. If your pool has a hundred workers, you have a hundred streams. Full stop. There is no async workaround while you are inside an Apache or PHP-FPM process model. You can tune pm.max_children, but memory and CPU set the real boundary.

That limit bites fast if you are also using workers for regular page loads, API calls, and asset generation. Monitor your worker saturation carefully. If your SSE endpoint starts queuing because all workers are locked in twenty-minute streams, your entire site slows down.

When the numbers no longer fit, move. Go is the usual next step, though Rust, Node.js, or Erlang can play the same role. Go’s goroutines are the key. A goroutine costs a few kilobytes. You can hold tens of thousands of streams on modest hardware without breaking a sweat. The core logic stays identical—read from cache, write to socket—but the runtime switches from heavy processes to lightweight threads.

Do not start there, though. PHP gets you surprisingly far. Validate the product first. When the metrics page shows worker exhaustion instead of database overload, you have outgrown the stack. That is a good problem.

Takeaway

Live counters are not about raw technology. They are about protecting your database from your own users. Start with SSE because it is simpler than it looks. Cache aggressively between the stream and the database so connection count does not become query count. Watch your worker limits like a hawk. And start simple. PHP is enough until it is not, and by then you will know exactly why you are rewriting.

For your next project:

  • Use SSE when the data flows one way, from server to browser.
  • Put a cache layer in front of the database. One query every few seconds beats thousands.
  • Cap SSE connections to under a minute and let the browser reconnect.
  • Close streams on tab hide. Do not fund idle connections with live workers.
  • Monitor PHP worker usage. When you max out, port the streaming layer to Go.