站内信小红点、行情刷新、协同编辑,都要求”服务端主动找浏览器”。方案从定时打问的短轮询,到 hold 住请求的长轮询,再到单向流式推送的 SSE 和全双工的 WebSocket,实时性递增,复杂度也递增。选型的本质是拿复杂度换时效。

四种方案的原理与代价

短轮询:定时打问

客户端用定时器每隔几秒发一次 HTTP 请求,服务端立即返回当前数据。实现只要一个 setInterval,但消息不频繁时绝大部分请求都在空转:每次请求都带着完整的 HTTP 头开销,在线人数一多,服务器大量 CPU 花在回答”没有新消息”上。适合实时性要求秒级以上、用户量小的场景,也常作为降级兜底通道。

长轮询:没有消息就挂着

客户端发出请求后,服务端不立即返回,而是 hold 住直到有新数据或超时(通常 30 秒左右)才响应;客户端拿到响应后立刻发起下一次请求。空转消失了,消息延迟接近实时,代价是每个挂起请求都长期占用服务端一个连接资源,需要异步 Servlet 或 NIO 来缓解线程占用。

实现关键是版本号协议:客户端带上自己已知的版本号,服务端发现版本不一致立刻返回,一致才挂起;发布方更新版本并唤醒挂起的请求。版本比较、监听注册、版本递增必须放在同一个临界区里,否则”发布恰好发生在比较与注册之间”会丢通知。Spring 的 DeferredResult 就是为这种”先释放容器线程、后由事件线程回填结果”的模式设计的,Nacos、Apollo 配置中心走的正是这条路。缺陷也很明显:消息频繁时会退化成高频短轮询,且每次数据到达都要重建请求。

SSE:单向事件流

Server-Sent Events 让客户端发起一次普通 HTTP 请求,服务端不关闭响应,以 text/event-stream 格式持续写入事件文本。浏览器原生 EventSource API 自带断线重连,协议还支持事件 ID 与自定义事件类型,重连时通过 Last-Event-ID 请求头告诉服务端从哪条续传。AI 对话的流式输出就是 SSE 的典型应用。

限制:只能服务端向客户端单向推文本(二进制得 Base64 编码);基于 HTTP/1.1 时浏览器对同一域名的 SSE 连接数受限(约 6 条),HTTP/2 下按流复用可大幅放宽。站内信、未读数、行情这类”只推不收”的场景,SSE 是性价比之王。

WebSocket:一次握手,全双工长连

HTTP 只负责”开门”,之后的通信走独立的帧协议。双向、低延迟、原生支持二进制,是 IM、游戏、协同编辑的唯一正经选项,但协议解析、心跳、重连、背压都要自己实现。

WebSocket:握手与帧协议

握手是一个带 Upgrade 头的普通 HTTP 请求:

GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

服务端返回 101 Switching Protocols,并带上 Sec-WebSocket-Accept——它由客户端的 Key 拼接固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 后做 SHA-1 再 Base64 得到。这个约定不是为了加密,而是让两端确认”对方真的懂 WebSocket”,防止误把普通 HTTP 服务当成 WS 端点。

握手成功后数据以帧(frame)为单位传输,帧头核心字段:

  • FIN 位 + opcode:标记消息类型(0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong)以及是否为消息的最后一片。
  • Mask 位 + 掩码:客户端发往服务端的帧必须做掩码,防止恶意脚本构造的字节流被中间代理误读成 HTTP 请求;服务端下发的帧则禁止掩码。
  • 变长 payload length:7 位起步,不够则扩展为 16 位或 64 位,单帧最大可表达 2^63 字节。

一条消息可以拆成多个帧分片传输,控制帧(Ping/Pong/Close)允许插在分片中间。关闭是双向确认:一方发 Close 帧,另一方回 Close 帧,然后各自关闭 TCP。相比 HTTP 每次请求动辄数百字节的头部,WebSocket 帧头最小只有 2 字节,这正是它高频交互下省流量的原因。

心跳与断线重连

TCP 连接是”半开”的:对端断电、拔网线时,本端不主动发包就永远以为连接活着。NAT 网关和运营商还会静默回收长时间无数据的映射表项。所以长连接必须靠心跳探活:

  • 心跳:WebSocket 协议层自带 Ping/Pong 帧,但浏览器 JS API 无法主动发 Ping,实际项目多用应用层心跳——客户端每隔 20~30 秒发一条 ping 消息,服务端回 pong,连续若干次没响应就判定死亡、主动断开重连。
  • 重连:监听 onclose 后用指数退避重连(1s、2s、4s……封顶并加随机抖动),避免服务端重启后被全体客户端在同一时刻打爆。
  • 补偿:每条消息带单调递增序号或业务游标,重连成功后带上”我最后收到 seq=1024”,服务端补发缺口。推模型只保证”尽快送达”,最终可靠性要靠拉式补偿兜底。

集群下的连接归属问题

单机时,一个 Map<userId, Session> 就能找到连接。集群化后问题来了:用户的 WebSocket 经负载均衡落在节点 A 上,而产生消息的业务请求却由节点 B 处理——B 的内存里根本没有这个用户的连接。两种解法:

  1. 消息队列广播:每个节点订阅广播 topic,B 把消息发到 MQ,所有节点收到后检查本地连接表,谁持有连接谁推送。实现最简单,代价是每个节点都要消费全量消息,节点多时浪费明显。
  2. 连接注册表 + 定向投递:连接建立时把 userId → nodeId 写入 Redis,B 查到归属节点后通过 MQ 定向投递(或内网 RPC)只发给节点 A。节省带宽,但要处理注册表与真实连接的一致性——节点宕机后残留的脏路由要能被清理或超时失效。

生产环境常用”注册表定路由 + MQ 做投递”的组合;节点数量少时,纯广播也完全够用。

选型对比

方案 通信方向 实时性 服务端开销 客户端成本 适用场景
短轮询 客户端拉 差(秒级) 高(空转请求) 极低 低频通知、降级兜底
长轮询 客户端拉 较好 中(挂起连接) 低 配置中心、老环境兼容
SSE 服务端单向推 好 低 低(原生 API) 站内信、行情、流式输出
WebSocket 全双工 极好 低(但需治理) 高 IM、游戏、协同编辑

经验法则:单向推送优先 SSE;需要双向交互才上 WebSocket;短轮询留作降级通道;长轮询只在不能动协议栈的存量系统里出现。技术没有高下,只有是否匹配业务的时效要求。