2011年,当一个聊天应用需要显示对方"正在输入"的状态时,开发者不得不让客户端每隔几秒向服务器发一次请求——问一句"有新消息吗?",服务器回答"没有",然后重复。这种愚蠢的对话在HTTP的世界里持续了整整十五年。

WebSocket的出现本该终结这一切。全双工、低延迟、单一连接,听起来像是完美答案。但十五年后的今天,我们依然在纠结:为什么WebSocket在企业网络里频频断开?为什么SSE在某些场景下比WebSocket更受欢迎?WebTransport又是来干什么的?

这不是一个非黑即白的选择题。每种技术都是在特定历史条件下,针对特定问题的权衡产物。理解它们的设计哲学,才能做出正确的选型。

长轮询:被逼出来的"黑科技"

在WebSocket诞生之前,实时Web应用是个笑话。

传统轮询的逻辑简单粗暴:客户端每隔N秒发一个HTTP请求,问服务器"有新数据吗?"。如果N是5秒,那么最坏情况下,用户要等5秒才能收到消息。把间隔缩短到1秒?那每分钟就是60个请求,一万个用户就是每分钟60万个请求,服务器会告诉你什么叫"负载爆炸"。

长轮询的改进思路很聪明:与其频繁地问"有吗?没有就告诉我",不如改成"有吗?没有就等着,有了再告诉我"。

// 长轮询客户端实现
function longPoll() {
    fetch('/poll')
        .then(response => response.json())
        .then(data => {
            console.log("收到数据:", data);
            longPoll(); // 立即发起新的轮询请求
        })
        .catch(error => {
            setTimeout(longPoll, 10000); // 出错后延迟重试
        });
}
longPoll();

服务器收到请求后,不立即响应,而是挂起这个连接,直到有新数据或超时。这样,消息的延迟不再是固定的轮询间隔,而是网络往返时间。

但长轮询的问题也很明显。每个"长"请求最终都会变成一个完整的HTTP响应,头部开销无法避免。更糟糕的是,当客户端重连时(比如网络抖动),服务器可能已经发送了新消息,而客户端完全错过了。

图片来源: Socket.IO Documentation

长轮询不是优雅的解决方案,它是HTTP协议不支持服务端推送时的"黑科技"。但它有一个无可比拟的优势:兼容性。任何HTTP基础设施——代理、防火墙、负载均衡器——都能正常处理长轮询请求。

这就是为什么即使在WebSocket普及的今天,Socket.IO等库仍然将长轮询作为默认传输方式,WebSocket作为升级选项。不是为了性能,而是为了可靠性。

WebSocket:打破HTTP的请求-响应范式

WebSocket的设计目标很明确:在浏览器和服务器之间建立一个真正的全双工通信管道,就像TCP socket一样,但要能在HTTP端口上工作。

握手:从HTTP到WebSocket的协议升级

WebSocket连接始于一个看起来像HTTP的请求:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Version: 13

关键在于 Upgrade: websocketConnection: Upgrade 头部,告诉服务器"我想切换协议"。服务器如果同意,返回101状态码:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept 的值是服务器对 Sec-WebSocket-Key 加上固定GUID后做SHA-1哈希再Base64编码的结果。这个看似繁琐的步骤有重要意义:它证明服务器确实理解WebSocket协议,而不是一个普通的HTTP服务器被欺骗后返回了意外响应。

握手完成后,连接就从HTTP切换到了WebSocket协议。此后,双方可以随时发送数据帧,不再遵循请求-响应模式。

数据帧:最小化的封装

WebSocket帧的结构设计得非常紧凑:

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +

几个关键字段:

  • FIN:是否是消息的最后一帧(支持消息分片)
  • opcode:帧类型(0x1文本、0x2二进制、0x8关闭、0x9 Ping、0xA Pong)
  • MASK:是否掩码(客户端发送的帧必须掩码,服务端发送的不需要)
  • Payload length:载荷长度,7位表示125字节以下,126表示16位扩展长度,127表示64位扩展长度

掩码机制是出于安全考虑。早期有人发现恶意网页可以通过WebSocket向任意服务器发送看起来像其他协议的数据,掩码可以防止这种攻击。

心跳:让连接保持"活着"

WebSocket建立在TCP之上,理论上只要TCP连接存在,WebSocket连接就存在。但现实很骨感:NAT设备会清理长时间没有数据传输的连接,代理服务器可能有空闲超时,移动网络切换会导致IP变更。

WebSocket协议内置了Ping/Pong帧用于心跳检测。但问题是,浏览器端的WebSocket API没有暴露发送Ping帧的能力——这是一个协议层面的功能,只能由浏览器实现。

// 浏览器端无法发送Ping帧
// 但服务端可以发送Ping,浏览器会自动回复Pong
const ws = new WebSocket('wss://example.com');
// 没有ws.ping()这样的API

这就是为什么生产环境的WebSocket实现通常需要应用层心跳:客户端定期发送一条"心跳消息",服务器收到后回复确认。如果连续N次心跳没有响应,就认为连接已断开。

// 应用层心跳实现
let heartbeatInterval;
let missedHeartbeats = 0;

function startHeartbeat() {
    heartbeatInterval = setInterval(() => {
        if (missedHeartbeats >= 3) {
            ws.close();
            return;
        }
        ws.send(JSON.stringify({ type: 'ping' }));
        missedHeartbeats++;
    }, 25000);
}

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (data.type === 'pong') {
        missedHeartbeats = 0;
    }
};

Socket.IO的心跳机制更精细:服务端发送PING包,客户端需要在 pingTimeout(默认5秒)内回复PONG,否则认为连接断开。客户端如果在 pingInterval + pingTimeout(默认30秒)内没收到PING,也会主动断开。

WebSocket的致命伤

WebSocket很好,但在企业环境中,它经常会遇到"莫名其妙"的连接失败。

问题出在握手阶段。WebSocket的握手虽然模拟HTTP请求,但 UpgradeConnection 头部对于很多企业代理和防火墙来说是陌生的。这些设备可能会:

  • 完全阻止非HTTP流量
  • 缓存响应,导致后续请求拿到错误数据
  • 长时间不转发响应,等待超时
  • 修改头部,破坏握手

更糟糕的是,WebSocket连接通常使用wss://协议(WebSocket over TLS),代理无法检查内容,可能直接阻止。

这就是为什么Socket.IO选择长轮询作为默认传输方式,只有在确认WebSocket可用后才升级。这个策略被称为"可靠优先,性能其次"。

SSE:为单向推送而生

WebSocket是全能选手,但不是所有场景都需要双向通信。股票行情、新闻推送、日志流——这些场景只需要服务器往客户端推数据,客户端不需要(或很少需要)往服务器发数据。

Server-Sent Events(SSE)就是为这种场景设计的。

SSE的本质:一个永不结束的HTTP响应

SSE的实现极其简单。服务器返回一个 Content-Type: text/event-stream 的响应,然后保持连接打开,持续写入数据:

// 服务端SSE实现(Node.js Express)
app.get('/events', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
    });

    const sendEvent = (data) => {
        // 每条消息必须以"data: "开头,以两个换行结束
        res.write(`data: ${JSON.stringify(data)}\n\n`);
    };

    // 每2秒发送一条消息
    const interval = setInterval(() => {
        sendEvent({ time: new Date().toISOString() });
    }, 2000);

    req.on('close', () => {
        clearInterval(interval);
    });
});

客户端代码同样简洁:

// 客户端SSE实现
const evtSource = new EventSource('/events');

evtSource.onmessage = (event) => {
    console.log('收到消息:', event.data);
};

// 可以监听命名事件
evtSource.addEventListener('custom', (event) => {
    console.log('自定义事件:', event.data);
});

SSE消息格式定义在W3C规范中,支持以下字段:

  • data:消息内容(可多行)
  • event:事件类型(默认为"message")
  • id:事件ID(用于断点续传)
  • retry:重连间隔(毫秒)

自动重连:SSE的杀手锏

SSE比WebSocket更"智能"的一点是内置了自动重连机制。当连接断开时,EventSource 会自动尝试重新连接。如果服务器发送了 id 字段,客户端重连时会带上 Last-Event-ID 头部,服务器可以从断点继续发送。

# 服务器发送的消息
id: 123
data: {"message": "Hello"}

id: 124
data: {"message": "World"}

# 客户端重连时发送的请求
GET /events HTTP/1.1
Last-Event-ID: 124

相比之下,WebSocket连接断开后,客户端需要自己实现重连逻辑,还需要处理重连期间可能丢失的消息。

SSE的致命限制:6个连接

HTTP/1.1规范(RFC 2616)规定,浏览器对同一域名最多同时保持2个持久连接(实际实现中大多是6个)。这个限制是为了防止客户端对服务器发起DoS攻击。

SSE会占用一个持久连接,而且这个连接会一直保持打开。如果页面上打开6个SSE连接,这个域名下的所有HTTP请求都会被阻塞。

图片来源: MDN Web Docs

这个限制在HTTP/2时代得到了解决。HTTP/2支持多路复用,单一TCP连接可以承载多个并发流。理论上,一个HTTP/2连接可以支持无限多个SSE连接(实际受限于 SETTINGS_MAX_CONCURRENT_STREAMS,默认约100)。

但这里有个陷阱:HTTP/2的多路复用是在单个TCP连接上实现的,如果某个流发生丢包,整个连接上的所有流都会受到影响。这就是TCP的队头阻塞问题。

SSE vs WebSocket:95%的场景其实不需要WebSocket

Timeplus在2024年做了一个详细的性能测试,对比了WebSocket和SSE在相同条件下的表现:

指标 WebSocket SSE
最大吞吐量(EPS) 4,000,000 3,100,000
客户端CPU使用率 略低 略高
延迟 略低(差异约3ms) 略高

测试条件:HTTP/2、每批50事件、并发10连接、Apple M1 Pro。

结论是:对于大多数单向推送场景,SSE和WebSocket的性能差异可以忽略不计。而SSE的优势在于:

  1. 实现简单:几十行代码就能搞定
  2. 自动重连:不需要自己实现
  3. 兼容性好:标准HTTP,不会被代理阻止
  4. 浏览器支持:到处可用

这就是为什么有人说"SSE打败了WebSocket在95%的实时应用中"。

但SSE的局限也很明显:

  • 单向通信:客户端只能通过发送新的HTTP请求来与服务器通信
  • 仅UTF-8:不支持二进制数据
  • EventSource API限制:不能发送POST请求体或自定义头部(需要polyfill)

WebTransport:HTTP/3时代的实时通信

WebSocket和SSE都建立在TCP之上,而TCP有一个根本性问题:队头阻塞。当TCP连接上的一个数据包丢失时,后续所有数据包都要等待重传,即使它们属于完全不同的请求或流。

HTTP/3用QUIC协议解决了这个问题。QUIC基于UDP,每个流都是独立的,一个流的丢包不会影响其他流。WebTransport就是建立在HTTP/3之上的新一代实时通信API。

多流架构:真正的并行

WebSocket是单一连接、单一流。你可以在一个WebSocket连接上发送多个逻辑消息,但它们在底层是串行的。

WebTransport允许在单个连接上创建多个独立的流:

// WebTransport客户端示例
const transport = new WebTransport('https://example.com/transport');

// 创建一个单向发送流
const writable = await transport.createUnidirectionalStream();
const writer = writable.getWriter();
await writer.write(new TextEncoder().encode('Hello'));
await writer.close();

// 接收单向流
const readable = transport.incomingUnidirectionalStreams;
const reader = readable.getReader();
while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    // 处理接收到的流
}

// 创建双向流
const bidi = await transport.createBidirectionalStream();
// bidi.readable 用于接收数据
// bidi.writable 用于发送数据

数据报:不可靠传输的价值

WebTransport支持两种数据传输模式:

  1. 流(Stream):可靠、有序,类似TCP
  2. 数据报(Datagram):不可靠、无序,类似UDP

数据报模式听起来很危险——数据可能会丢失、乱序。但对于某些场景,这正是需要的:

  • 视频流:丢几帧没关系,延迟更重要
  • 游戏状态同步:老状态会被新状态覆盖,重传没意义
  • 传感器数据:实时数据比历史数据更有价值
// 数据报API
const datagram = transport.datagrams;
const datagramWriter = datagram.writable.getWriter();
await datagramWriter.write(new TextEncoder().encode('Fast data'));

// 接收数据报
const datagramReader = datagram.readable.getReader();
const { value } = await datagramReader.read();

WebTransport的致命缺陷:Safari不支持

截至2026年初,WebTransport的浏览器支持情况:

浏览器 版本
Chrome 97+ ✅
Edge 98+ ✅
Firefox 114+ ✅
Safari 不支持 ❌
iOS Safari 不支持 ❌

图片来源: Can I Use

这意味着WebTransport在iOS生态系统中完全不可用。对于需要广泛覆盖的Web应用,这是一个致命缺陷。

此外,WebTransport还要求:

  1. 必须使用HTTPS(不允许HTTP)
  2. 服务器必须支持HTTP/3
  3. 需要正确的CORS配置

虽然Chrome、Firefox都已经支持WebTransport,但服务器端的生态还不成熟。Nginx的HTTP/3支持仍处于实验阶段,很多CDN也没有完全支持HTTP/3。

性能基准:数字背后的真相

抛开理论,让我们看看实际的性能数据。

吞吐量对比

Timeplus的测试给出了一个清晰的图景:

测试场景1:大批量、少连接

  • 并发:20连接
  • 每批事件数:250
  • 目标:100,000 EPS
协议 客户端CPU 服务端CPU
WebSocket 35% 6%
SSE 23% 8%

测试场景2:小批量、多连接

  • 并发:100连接
  • 每批事件数:50
  • 目标:100,000 EPS
协议 客户端CPU 服务端CPU
WebSocket 30% 12%
SSE 30% 11%

结果有些反直觉:在某些场景下,SSE的CPU效率更高。原因是SSE使用标准的HTTP请求,浏览器和服务器对其优化更充分。WebSocket需要处理帧解析、掩码等额外开销。

延迟对比

对于实时应用,延迟往往比吞吐量更重要。WebSocket的理论延迟优势在于:

  1. 无需重复建立连接
  2. 消息帧开销小(2-14字节头部)
  3. 全双工,无需等待

SSE的劣势在于:

  1. 如果客户端需要发送数据,必须发起新的HTTP请求
  2. 每条消息需要 data: 前缀和 \n\n 后缀

但在实际测试中,这个差异大约只有3毫秒。对于大多数应用,这个差异可以忽略不计。

压缩:WebSocket的隐藏优势

WebSocket支持 permessage-deflate 扩展,可以对消息进行压缩。对于文本密集的应用,压缩率可以达到70-80%。

// WebSocket压缩协商
// 客户端请求
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

// 服务端响应
Sec-WebSocket-Extensions: permessage-deflate

但压缩是有代价的:CPU开销增加,内存占用增加。对于已经压缩过的数据(如图片、视频、gzip压缩的JSON),再压缩几乎没效果,只会浪费CPU。

SSE也可以通过HTTP的gzip压缩来减少传输量,但这是连接级别的压缩,不如WebSocket的逐消息压缩灵活。

技术选型决策框架

没有万能的解决方案,只有特定场景下的最优选择。以下是一个决策流程:

第一步:确定通信模式

是否需要客户端主动发送数据?
├── 是 → WebSocket 或 WebTransport
└── 否 → SSE

如果只是服务器往客户端推送,SSE在大多数情况下是更好的选择。代码更简单,自动重连,兼容性更好。

第二步:评估可靠性要求

消息是否可以丢失?
├── 可以接受部分丢失 → WebTransport数据报模式
└── 必须可靠送达 → WebSocket/WebTransport流模式

视频流、游戏状态同步等场景可以考虑不可靠传输,延迟更重要。

第三步:考虑部署环境

是否在企业环境或有严格防火墙的网络中?
├── 是 → SSE 或 带长轮询降级的WebSocket
└── 否 → WebSocket

企业代理和防火墙是WebSocket的最大敌人。如果用户可能在不友好的网络环境中,务必准备降级方案。

第四步:检查目标平台

是否需要支持iOS/Safari?
├── 是 → 避免WebTransport
└── 否 → 可以考虑WebTransport

WebTransport在iOS生态中完全不可用。如果目标用户可能使用iPhone或iPad,WebTransport不是选项。

第五步:评估服务器能力

服务器是否支持HTTP/3?
├── 是 → WebTransport可选
└── 否 → WebSocket或SSE

WebTransport需要HTTP/3支持。如果服务器基础设施还没有准备好,不要选择WebTransport。

最佳实践总结

使用SSE的场景

  • 新闻推送、股票行情、社交媒体通知
  • 服务器日志流、监控数据
  • 单向数据流,客户端很少发消息
  • 需要最大兼容性
  • 企业网络环境
// SSE最佳实践:使用HTTP/2避免连接限制
const evtSource = new EventSource('/events', {
    withCredentials: true // 如果需要发送Cookie
});

// 监听连接状态
evtSource.onerror = (e) => {
    if (evtSource.readyState === EventSource.CLOSED) {
        console.log('连接已关闭');
    } else if (evtSource.readyState === EventSource.CONNECTING) {
        console.log('正在重连...');
    }
};

使用WebSocket的场景

  • 聊天应用、实时协作
  • 多人游戏、需要低延迟双向通信
  • 需要传输二进制数据
  • 消息频率高,需要压缩
// WebSocket最佳实践:心跳+重连
class RobustWebSocket {
    constructor(url) {
        this.url = url;
        this.reconnectDelay = 1000;
        this.maxReconnectDelay = 30000;
        this.connect();
    }

    connect() {
        this.ws = new WebSocket(this.url);
        this.ws.onopen = () => {
            this.reconnectDelay = 1000;
            this.startHeartbeat();
        };
        this.ws.onclose = () => {
            this.stopHeartbeat();
            setTimeout(() => this.connect(), this.reconnectDelay);
            this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
        };
    }

    startHeartbeat() {
        this.heartbeat = setInterval(() => {
            if (this.ws.readyState === WebSocket.OPEN) {
                this.ws.send(JSON.stringify({ type: 'ping' }));
            }
        }, 25000);
    }
}

使用WebTransport的场景

  • 高吞吐量视频/音频流
  • 云游戏、需要极低延迟
  • 可以接受不可靠传输
  • 不需要支持iOS/Safari
  • 服务器支持HTTP/3
// WebTransport最佳实践:混合使用流和数据报
async function createTransport(url) {
    const transport = new WebTransport(url);
    await transport.ready;

    // 可靠控制通道
    const controlStream = await transport.createBidirectionalStream();

    // 快速数据通道(数据报)
    const datagramWriter = transport.datagrams.writable.getWriter();

    return { transport, controlStream, datagramWriter };
}

使用长轮询的场景

  • 兼容老旧浏览器
  • 作为WebSocket/SSE的降级方案
  • 消息频率很低(每分钟几条)
  • 基础设施严格限制非HTTP流量

结语

从长轮询到WebTransport,实时通信技术的演进反映了Web平台的成熟过程。每一次迭代都不是简单的替代,而是针对特定痛点的权衡。

长轮询虽然笨拙,但在最恶劣的网络环境中依然可靠。WebSocket虽然强大,但需要处理心跳、重连、代理兼容等问题。SSE虽然单向,但简单到几乎不会出错。WebTransport虽然先进,但生态还不成熟。

选择技术时,不要被"新技术更好"的偏见影响。问问自己:我的用户是谁?他们的网络环境如何?我需要多高的可靠性?我的团队有多少时间处理边缘情况?

答案往往比你想象的更简单。如果你的应用只需要从服务器推送通知,SSE可能就是最好的选择——简单、可靠、够用。如果一个聊天功能占用了你60%的开发时间来处理WebSocket的各种边缘情况,那可能就是过度设计了。

实时通信不是一个技术问题,而是一个权衡问题。理解每种技术的边界,才能做出正确的决策。


参考资料

  1. RFC 6455 - The WebSocket Protocol - IETF官方WebSocket协议规范
  2. Server-Sent Events - W3C - W3C SSE规范
  3. WebTransport API - MDN - MDN文档
  4. WebSockets vs Server-Sent-Events vs Long-Polling vs WebRTC vs WebTransport - RxDB深度技术对比
  5. WebSocket vs. Server-sent Events: A Performance Comparison - Timeplus性能基准测试
  6. What is WebTransport and can it replace WebSockets? - Ably技术分析
  7. How it works - Socket.IO - Socket.IO传输机制详解
  8. RFC 7692 - Compression Extensions for WebSocket - WebSocket压缩扩展规范
  9. Thoughts on Server-Sent Events, HTTP/2, and Envoy - HTTP/2对SSE的影响分析
  10. Can I use WebTransport? - 浏览器兼容性数据