引言 — 当你接到一个「实时」需求
一收到「把通知实时显示出来」的需求,讨论通常就从 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头发回来。服务器只要把那个点之后的事件重发一遍就行。断线期间通知丢失这个问题,有了标准的解法。
认证和中间件原样生效。Cookie 会带上,既有的认证中间件照常判断是否放行,访问日志照常记录,限流照常作用。WebSocket 在升级之后就不是 HTTP 了,所以这一切都得在协议之上重新造一遍。
LLM 的响应流式输出大多用 SSE 实现,也不是偶然。那是服务器只管把 token 推出去的典型单向问题。
SSE 实打实的弱点也最好准确知道。EventSource API 不支持自定义头,也就是说加不了 Authorization 头。如果用的是 Cookie 认证那没问题,但如果架构上用的是 bearer 令牌,就容易被诱惑去把令牌塞进查询字符串。查询字符串会原样留在访问日志和代理日志里,所以要避免。替代方案是用 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 秒内一个字节都不流动,连接就会被切断。通知稀疏的时段里连接总是断掉,症状就出自这里。
解法是心跳。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,或者在对应 location 里关掉代理缓冲即可。压缩中间件会造成同样的问题。经过一个不按事件 flush 的 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 Streams 或 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 原本提供的一切都得自己重造,而这笔账不是在接入库的时候结,而是在运维第六个月时结。重连风暴、死连接泄漏、节点间传播、上行消息滥用、发布时的大规模断连,会依次找上门来。只有在付这笔成本的理由足够清楚时才去付,才是明智的。
현재 단락 (1/96)
一收到「把通知实时显示出来」的需求,讨论通常就从 WebSocket 开始。挑一个库,写重连逻辑,改负载均衡器配置,为了水平扩展再接上 Redis 适配器。可真正要来回传的数据,很多时候只有一种:从服...