播放量是访客信任的第一个数字。它告诉他们一段视频是值得花 30 秒还是 30 分钟。在 TopVideoHub,这个数字变化极快。一个热门片段可能在 10 分钟内累积 4 万次播放。如果页面上的计数器卡住了,整个氛围就会变得冷清。用户会流失。

让这个数字显示在浏览器上听起来很简单,但事实并非如此。大多数团队首先想到的方案是轮询。它很容易实现,在测试环境中表现良好。但测试环境会骗人。

当轮询变成一场针对自身的 DDoS 攻击

TopVideoHub 团队构建了一个简单的 JavaScript 轮询器。它每五秒获取一次最新的播放量。在开启了三个浏览器的测试环境中,它看起来很完美。但在生产环境中,它让平台崩溃了。

八千名并发观众每五秒刷新一次,产生了每秒 1,600 个请求。每个请求都会直接冲击数据库。复制延迟激增,只读副本不堪重负,缓存层被绕过。团队不再是在提供视频服务,而是在提供自找的负载。

轮询在没出问题前是无害的。对于低流量的仪表板或管理面板,它没问题。但对于热门视频页面,它就是一个定时炸弹。团队需要一个从服务器到浏览器的持久管道,但并不需要全双工协议的复杂性。

为什么 SSE 适合单向管道

Server-Sent Events (SSE) 正是为了解决这类问题而生的:服务器拥有数据,而浏览器只需要监听。

与 WebSockets 不同,SSE 运行在普通的 HTTP 之上。这一点比听起来的影响更大。你不需要新的代理规则、升级请求头或在负载均衡器上大费周章。只要你的服务器支持 HTTP/1.1 或 HTTP/2,SSE 就能工作。调试起来毫不费力,因为流就是纯文本。你可以用 curl 指向端点,实时观察数字滚动,这比猜测为什么二进制套接字帧出错了要好得多。

浏览器会免费处理那些繁琐的部分。如果连接断开,SSE 会通过 Last-Event-ID 请求头自动重新连接,以便服务器知道从哪里恢复。JavaScript 中的 API 非常简洁:创建一个 EventSource,附加一个 onmessage 处理程序,就大功告成了。

缓存才是真正的架构核心

实时计数器中最大的架构错误,就是将每一个浏览器连接都视为查询数据库的理由。如果有 8,000 人观看同一个视频,每两秒运行 8,000 个查询简直是疯狂。你的数据库无法承受病毒式传播带来的流量。

TopVideoHub 使用 APCu(PHP 的内存 opcode 和用户缓存)解决了这个问题。流程很简单:一个后台进程(或者一个定时触发的轻量级端点)每两秒将当前的播放量写入 APCu。而成千上万个浏览器保持打开状态的 SSE 端点,则专门从 APCu 中读取数据。

结果是:无论有多少观众在观看,数据库每两秒只承受一次冲击。缓存成为了缓冲器。APCu 并不神秘,它随 PHP 一起发布,驻留在共享内存中,读取速度比任何网络往返都要快。对于一个频繁变化但不需要瞬时同步的单一数字来说,它是最合适的工具。

如果你没有运行 APCu,使用 Redis 或 Memcached 也可以。原理是一样的:将高频读取路径与数据库分离。

说服 PHP、LiteSpeed 和 Cloudflare 进行流式传输

PHP 想尽快执行完毕并结束进程。Web 服务器想缓冲输出并交付一个整洁的响应。而 SSE 需要相反的操作:一个保持开启的连接,并在字节到达时立即刷新。如果不加注意,你的“流”可能会在 30 秒后才作为一个整体块到达,从而失去了意义。

以下是 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.