一、背景:为什么我们离不开"实时通信"?
传统 Web 是典型的 Request/Response 模式:
浏览器有需求 → 发起 HTTP 请求 → 服务端返回结果 → 连接结束。
但现在的应用越来越"活"了:
- IM / 聊天系统:消息要秒级到达
- 实时监控大屏:指标曲线、日志流滚动刷新
- 在线协作:多人编辑文档、在线白板
- 播放/交易类:弹幕、订单状态、股票价格、盘口数据......
如果还用"有事我问你"的拉模式(轮询),要么延迟高,要么 HTTP 请求堆成山,服务端和网络都顶不住。
我们真正想要的是:
建立一条长期存在 的通道,让服务器可以随时主动推送数据给客户端,甚至双方都能随时说话。
在这个诉求下,逐渐演化出:
- 基于 HTTP 的 SSE(Server-Sent Events):单向服务器推送
- 基于独立协议的 WebSocket:全双工双向通信
很多人下结论:"WebSocket 更高级,SSE 是过时玩意"。
其实更准确的说法是:两者定位不同,是互补,而不是替代。
二、从轮询到长连接:问题是怎么一步步暴露出来的?
2.1 短轮询(Short Polling)
最原始方案:
js
setInterval(async () => {
const res = await fetch('/api/messages/latest');
const data = await res.json();
render(data);
}, 3000);
问题:
-
大量"空跑"请求:绝大多数时候是
"没新消息" -
延迟 vs 压力的矛盾:
- 想低延迟 → 间隔变短 → QPS 暴涨
- 想省资源 → 间隔变长 → 延迟明显
2.2 长轮询(Long Polling / Comet)
改进版:一次请求挂久一点。
js
async function longPoll() {
try {
const res = await fetch('/api/messages/stream');
const data = await res.json();
render(data);
} finally {
// 无论成功失败,都紧接着再发下一次
longPoll();
}
}
longPoll();
服务端逻辑类似:
- 如果暂时没有消息,就挂起请求
- 有消息 / 超时再返回
长轮询减少了一部分空响应,但核心矛盾仍在:
- 还是要一次一次 HTTP 请求
- 每次都走完整 HTTP 头、路由、鉴权
- 中间代理有超时、最大连接数等限制
这时,自然会产生一个想法:
能不能建立一条真正的长连接 ,
让服务器想发就发,而不是"一个消息对应一个 HTTP 请求/响应"?
答案就是:SSE 和 WebSocket。
三、SSE:简单纯粹的服务器推送(Server-Sent Events)
3.1 定义与特点
SSE 全称 Server-Sent Events,由 HTML5 标准引入,特点:
- 完全基于 HTTP 协议,不需要额外的协议升级
- 连接方向是 单向:Server → Client
- 浏览器提供原生对象
EventSource - 消息格式:
text/event-stream的文本流 - 内建自动重连和
Last-Event-ID断点续传机制 - 实现成本低,适合简单的流式输出场景
非常适合:
- 实时日志流、监控指标、报警流
- 实时通知中心、站内信提醒
- 股票/榜单/业务指标刷榜
3.2 SSE 消息格式长什么样?
Content-Type: text/event-stream,每条消息由若干行组成,以空行结束:
text
id: 1001
event: notice
data: {"msg":"hello","level":"info"}
id: 1002
event: metrics
data: {"cpu":0.3,"mem":0.61}
: 这是注释行,浏览器不会触发事件
常用字段:
id:事件 ID,浏览器断线重连时会给服务器带上Last-Event-ID头event:事件名,前端可以按事件类型做不同处理data:真正的数据内容,可以多行(会自动拼成一行)
3.3 前端:EventSource API 详解
js
const source = new EventSource('/sse/stream', {
withCredentials: true, // 是否携带 Cookie
});
// 连接状态:0-connecting,1-open,2-closed
console.log(source.readyState);
// 默认事件(event: message 或未指定 event)
source.onmessage = (event) => {
console.log('默认事件:', event.data);
const payload = JSON.parse(event.data);
render(payload);
};
// 自定义事件
source.addEventListener('notice', (event) => {
console.log('notice 事件:', event.data);
});
// 打开
source.onopen = () => {
console.log('SSE 连接已建立');
};
// 错误 & 断线(EventSource 会自动重连)
source.onerror = (err) => {
console.error('SSE 出错或断线:', err);
// 一般不建议这里自己重新 new EventSource
// 否则可能和浏览器内建重连冲突
};
// 主动关闭
function closeSSE() {
source.close();
}
几点注意:
- 自动重连 :底层会按一定策略重连,可以通过服务端
retry: 5000修改重连间隔。 - 多事件类型 :可以根据
event: xxx在前端用addEventListener('xxx', ...)分发。 - 跨域 :SSE 一样需要处理 CORS;如果有 Cookie 鉴权,记得
withCredentials: true。
3.4 服务端:Spring MVC 阻塞版示例
真实生产不推荐用阻塞循环,这里只是演示 SSE 格式。
java
@RestController
@RequestMapping("/sse")
public class SseController {
@GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
public void stream(HttpServletResponse response) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
response.setContentType("text/event-stream;charset=UTF-8");
PrintWriter writer = response.getWriter();
for (int i = 0; i < 10; i++) {
String data = "{\"time\":" + System.currentTimeMillis() + ",\"value\":" + i + "}";
writer.write("id: " + i + "\n");
writer.write("event: metrics\n");
writer.write("data: " + data + "\n\n");
writer.flush();
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
}
}
}
3.5 服务端:Spring WebFlux / Reactor 版(推荐)
使用 WebFlux 可以更自然地返回一个无限流:
java
@RestController
@RequestMapping("/sse")
public class SseReactiveController {
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream() {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<String>builder()
.id(String.valueOf(seq))
.event("metrics")
.data("{\"time\":" + System.currentTimeMillis() + ",\"value\":" + seq + "}")
.build());
}
}
MediaType.TEXT_EVENT_STREAM_VALUE就是text/event-streamFlux.interval只是示例,真实场景通常是从 MQ / 内存通道 / 监控系统订阅数据源。
3.6 Last-Event-ID 与断点续传
当服务器为每条消息设置 id 字段时:
text
id: 42
event: metrics
data: {...}
浏览器断线重连时,会在请求头带上:
http
Last-Event-ID: 42
服务器可以据此:
- 从 ID 之后的消息继续推送(如果你有持久化)
- 或者记录用户上次消费位置,做类"消费位点"管理
这是很多人用 SSE 做"轻量日志/监控消费"的一个重要能力。
3.7 SSE 的优点整理
- 实现简单:HTTP + 文本流,框架 out-of-box
- 穿透性好:大部分代理/防火墙对 HTTP 长连接都较友好
- 自动重连:浏览器内建,不需要写复杂逻辑
- 天然适配流式场景:日志流、指标流、通知流
3.8 SSE 的局限性
- 单向:只能 Server → Client,Client → Server 仍需 HTTP 请求
- 不支持原生二进制:需要自己 base64 或其它编码
- 一些老旧环境兼容性略差(老 IE 等)
- 受限于反向代理 / 网关对 HTTP 长连接的配置(超时、并发连接数)
一句话概括:
如果你的需求是 "后端不停往前端推数据,前端很少主动发实时消息",SSE 是非常优雅的选择。
四、WebSocket:为实时交互而生的全双工协议
4.1 协议与握手
WebSocket 依托 HTTP 做握手 ,然后升级为独立的 ws:// / wss:// 协议。
典型握手请求(浏览器→服务器):
http
GET /ws/chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, superchat
Origin: https://example.com
服务器响应:
http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
之后,两端就不再用 HTTP 报文,而是使用 WebSocket 帧进行通信。
特点:
- 通道是 全双工 的:任意时刻 Client/Server 都可以主动发送数据帧
- 支持文本帧、二进制帧、Ping/Pong、Close 等类型
- 一次握手、多次通信,头部开销几乎可以忽略
这使它成为 IM、游戏、协作等强交互场景的首选。
4.2 浏览器端 WebSocket API 详解
js
const ws = new WebSocket('wss://example.com/ws/chat?token=xxx');
// 连接打开
ws.onopen = () => {
console.log('WebSocket 打开');
// 连接建好后可以立刻发认证/加入房间消息
ws.send(JSON.stringify({
type: 'auth',
token: 'xxx',
}));
};
// 收到消息
ws.onmessage = (event) => {
// 可能是文本,也可能是二进制(取决于服务端)
const msg = JSON.parse(event.data);
handleMessage(msg);
};
// 连接关闭
ws.onclose = (event) => {
console.log('WebSocket 关闭', event.code, event.reason);
// 可在这里发起重连逻辑
};
// 错误
ws.onerror = (error) => {
console.error('WebSocket 错误: ', error);
};
// 封装一个简单发送函数
function sendMessage(payload) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
} else {
console.warn('连接未就绪,消息被丢弃', payload);
}
}
几点实战注意:
-
readyState:0:CONNECTING1:OPEN2:CLOSING3:CLOSED
-
可以通过 URL 参数、Header 或
Sec-WebSocket-Protocol携带鉴权信息 / 版本号。 -
若网络不稳定,要自己实现 重连策略(指数退避、最大重试次数等)。
4.3 Spring Boot + STOMP 方案(适合业务系统)
配置:
java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS(); // 可选:提供降级(长轮询/iframe 等)
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue"); // 订阅前缀
registry.setApplicationDestinationPrefixes("/app"); // 客户端发送消息的目的前缀
}
}
消息处理:
java
@Controller
public class ChatController {
@MessageMapping("/chat.send") // 客户端发到 /app/chat.send
@SendTo("/topic/room") // 广播到订阅 /topic/room 的所有客户端
public ChatMessage send(ChatMessage message) {
message.setTimestamp(System.currentTimeMillis());
return message;
}
}
@Data
public class ChatMessage {
private String from;
private String content;
private long timestamp;
}
前端可以用 @stomp/stompjs / sockjs-client 等库进行连接订阅。
4.4 Netty 手写 WebSocket(适合你做 IM 网关)
简化版示例(仅演示关键 Handler):
java
public class WebSocketServer {
public static void main(String[] args) throws InterruptedException {
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(new HttpServerCodec());
p.addLast(new HttpObjectAggregator(65536));
p.addLast(new WebSocketServerProtocolHandler("/ws", null, true));
p.addLast(new SimpleChannelInboundHandler<TextWebSocketFrame>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) {
String text = msg.text();
// TODO: 解 JSON、路由、广播、持久化...
ctx.writeAndFlush(new TextWebSocketFrame("echo: " + text));
}
});
}
});
Channel ch = b.bind(8080).sync().channel();
ch.closeFuture().sync();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}
在真正的 IM / 网关场景,你会在此基础上:
- 增加连接认证(token / cookie)
- 建立 UserId → Channel 的映射
- 实现房间/群组的 ChannelGroup 管理
- 接入 MQ / RPC 与逻辑层、存储层交互
4.5 WebSocket 的优点总结
- 全双工:前端/后端都能随时主动发消息
- 支持二进制:适合音视频片段、protobuf、压缩格式
- 高频通信下头部开销极小
- 生态丰富:STOMP、Socket.io、自研协议都可以构建在其之上
4.6 WebSocket 的挑战
-
协议/部署复杂度高于 SSE
-
对代理 / 网关的配置要求更严(必须支持 Upgrade、keepalive 等)
-
需要自己设计:
- 心跳 / 断线检测
- 重连策略
- 订阅模型 / 房间管理
- 鉴权与授权(哪些用户能收哪些消息)
一句话概括:
如果你的项目是 IM、游戏、协同编辑、多终端实时协作,WebSocket 几乎是标配。
五、SSE vs WebSocket:多维度对比
5.1 快速对比表
| 维度 | SSE | WebSocket |
|---|---|---|
| 协议 | HTTP + text/event-stream |
独立协议(ws/wss,HTTP 101 升级) |
| 通信方向 | 单向(Server → Client) | 全双工(Client ↔ Server) |
| 数据类型 | 文本(UTF-8) | 文本 + 二进制 |
| 浏览器支持 | 现代浏览器支持,IE 较差 | 现代浏览器支持良好 |
| API | EventSource |
WebSocket |
| 重连机制 | 内建自动重连 + Last-Event-ID |
需自行实现重连逻辑 |
| 代理穿透性 | 很好(HTTP 长连接) | 依赖代理/防火墙是否支持 WebSocket |
| 典型场景 | 日志/监控/通知/行情 | IM/游戏/协作/复杂实时业务 |
| 实现复杂度 | 服务端较简单 | 服务端要考虑连接管理、心跳、路由等 |
| 消息可靠性 | 需自建 ack/offset 机制(可用 id) | 同样需自建 ack/重试机制 |
5.2 性能与资源
两者都是长连接,本质压力主要来自:
- 并发连接数(File Descriptor / 线程 / EventLoop)
- 消息编解码 & 广播逻辑
- 下游服务瓶颈(数据库、MQ 等)
一般规律:
-
仅服务端推 → 客户端收的场景,SSE 足够且实现简单;
-
双向频繁通信、高并发场景,WebSocket 更合适;
-
真正的性能瓶颈往往不在协议,而在:
- 架构设计(接入层 / 逻辑层 / 存储层)
- 消息路由 & 广播算法
- 有无合理限流、背压机制
5.3 网络环境与企业内网
- SSE 更像普通 HTTP 流,企业内代理、出口防火墙大多能容忍;
- WebSocket 要求代理支持
Upgrade,部分老设备/保守配置场景会"直接掐掉",这时要么配置调整,要么降级到 SockJS / 长轮询。
在 ToB SaaS 场景(尤其连接各类甲方内网)时,这点非常关键。
六、真实项目中的选型:用什么、怎么用?
6.1 决策思路
可以简单套一个"选型决策树":
-
前端是否需要频繁主动发实时消息?
- 是 → 优先 WebSocket
- 否 → 看下一条
-
消息是否只需要从后端推向前端?
- 是 & 数据以文本为主 → 优先 SSE
- 否 → WebSocket
-
目标用户的网络环境是否对 WebSocket 不够友好?
- 是 → 十分建议以 SSE / 长轮询 为基础,再按需引入 WebSocket
- 否 → WebSocket + SSE 混用是不错的架构
6.2 场景示例
1)IM / 聊天 / 游戏大厅
- 客户端:不停发聊天消息、操作指令
- 服务端:推送群聊消息、在线列表、系统通知
👉 WebSocket 是主力,甚至你会做出一个"IM 接入层(Access)"作为独立服务。
2)监控大屏 / 日志中心
- 日志/监控系统往前端持续推流
- 前端只是查看,很少主动互动
👉 SSE 非常适合。配合 Kafka / Flink / TSDB 做后端数据源会很自然。
3)SaaS 管理后台 + 实时通知
- 90% 页面只是 CRUD / 报表 / 查询
- 偶尔会弹"有新工单"、"有新审批"、"有系统告警"
👉 两种实战方案:
- 早期:基于 SSE 做通知/告警推送,简单好维护;
- 后期:当你引入 WebSocket(比如在线客服、协同编辑)后,可以考虑统一抽一个"实时通道层",通过一个 WebSocket 上 multiplex 多种消息类型。
七、统一消息协议 & 前端封装:减少未来返工
无论 SSE 还是 WebSocket,你都可以用同一套业务消息协议,后面切换/混用时几乎不改业务层。
7.1 建议的消息 Envelope
json
{
"type": "chat_message", // 消息类型
"version": 1, // 协议版本
"traceId": "xxx", // 可选:链路追踪
"payload": { // 业务内容
"from": "alice",
"to": "room-1",
"content": "hello"
}
}
好处:
- 避免"满世界 if/else 判断结构不同的 JSON"
- 方便做灰度 / 协议升级(version 字段非常关键)
- 可以在边车 / 网关层基于 type 做路由
7.2 前端统一封装:以 WebSocket 为例
js
class WSClient {
constructor(url, { autoReconnect = true, maxRetry = 10 } = {}) {
this.url = url;
this.autoReconnect = autoReconnect;
this.maxRetry = maxRetry;
this.retryCount = 0;
this.handlers = {};
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('ws open');
this.retryCount = 0;
this.emit('open');
};
this.ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
this.emit(msg.type, msg);
} catch (e) {
console.error('parse msg error', e, evt.data);
}
};
this.ws.onclose = () => {
this.emit('close');
if (this.autoReconnect && this.retryCount < this.maxRetry) {
const delay = Math.min(30000, 1000 * Math.pow(2, this.retryCount));
this.retryCount++;
setTimeout(() => this.connect(), delay);
}
};
this.ws.onerror = (err) => {
this.emit('error', err);
};
}
send(type, payload) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload }));
}
}
on(type, handler) {
(this.handlers[type] = this.handlers[type] || []).push(handler);
}
emit(type, ...args) {
(this.handlers[type] || []).forEach((h) => h(...args));
}
close() {
this.autoReconnect = false;
this.ws.close();
}
}
对于 SSE,也可以封一个 SSEClient,统一 on(type, handler) 风格,后端只是把 event: type 对上就行。
八、与 HTTP/2、gRPC、GraphQL Subscriptions 的关系(简要延伸)
现实中,你可能还会遇到这些名词:
- HTTP/2 server push:设计上有"推送"能力,但在浏览器中已基本被废弃(Chrome 也移除了支持)。
- gRPC streaming:基于 HTTP/2 的流式 RPC,更适合服务间通信,不是浏览器直连方案(除非 gRPC-Web)。
- GraphQL Subscriptions:常见实现底层多用 WebSocket 或 SSE,自身是"语义层"的协议。
可以简单记住:
浏览器层面,"实时"主要还是 SSE + WebSocket,两者是基础。
更高层方案(GraphQL Subscriptions、SignalR、Socket.io)往往只是封装 & 协议之上的抽象。
九、安全、运维与监控:上线后才是"真正的开始"
9.1 鉴权与授权
无论是 SSE 还是 WebSocket:
- 推荐用 短期 Token(JWT / 自研),而不是单纯依赖 Cookie Session;
- 对每个连接建立时进行认证,认证失败直接关闭;
- 对消息还要做授权校验(用户是否有权限订阅某房间/某数据流)。
WebSocket 鉴权策略举例:
- 握手 URL 携带
?token=xxx; - 服务器解析并验证 token,绑定 userId;
- 建立
userId → Channel的映射; - 业务层发送消息时,通过 userId 找到对应 Channel 进行推送。
9.2 连接数与限流
-
每个连接都占用 FD + 内存(缓冲区):
- 要设置连接数上限(按机器规格和业务模型);
- 对单 IP / 单用户的连接数做限流。
-
对消息发送:
- 限制每秒消息数;
- 对恶意/异常客户端进行断连或黑名单处理。
9.3 日志与监控指标
建议至少监控:
- 当前在线连接数(按节点、按用户类型)
- 每秒发送/接收消息数
- 鉴权失败次数、握手失败次数
- 断线重连频率(可用来判断网络质量/代理问题)
对于 SSE:
- 统计每条 SSE 连接的平均持续时间;
- 统计
Last-Event-ID使用情况(是否频繁断线重连)。
十、常见坑与踩坑经验
10.1 SSE 被代理缓存 / 超时
- 忘了加
Cache-Control: no-cache,结果浏览器直接从缓存读,根本不走后端; - 反向代理(Nginx、网关)默认有
proxy_read_timeout,时间一到就断连接 → 要配置足够长,或者前端/后端定期发心跳。
10.2 WebSocket 在 Nginx / 云原生环境下"莫名其妙断"
-
没有正确设置:
proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";
-
上游服务重启需要优雅处理断线 & 重连;
-
Kubernetes / Service Mesh 中可能有额外的超时、连接数限制,需要逐层排查。
10.3 浏览器多 Tab 的处理
-
一个用户开 10 个 Tab,每个 Tab 开一个 WebSocket/SSE,后端就会有 10 条连接;
-
对于某些类型的通知流,可以:
- 只允许一个"主 Tab"持有连接,其他 Tab 通过
BroadcastChannel/localStorage事件接收消息; - 或者服务端按 userId 合并推送,前端自己决定如何展示。
- 只允许一个"主 Tab"持有连接,其他 Tab 通过
10.4 重连风暴
- 服务端重启、网络抖动时,成千上万客户端同时重连;
- 需要前端采用 指数退避 + 随机抖动 的重连策略,避免连接雪崩。
十一、总结:正确使用"工具箱",而不是迷信某个"银弹"
最后再强调一次本文想传达的观点:
-
SSE 和 WebSocket 是两个定位不同的工具:
- SSE:简单、轻量、单向流,非常适合日志/监控/通知。
- WebSocket:强大、通用、双向,适合 IM/游戏/协同等高交互场景。
-
选型时可以问自己三个问题:
- 前端是否频繁主动发实时消息?
- 是否需要双向通信 & 二进制?
- 目标网络环境是否对 WebSocket 友好?
-
真正的难点不在"选 SSE 还是 WebSocket",而在:
- 连接网关 / Access 层 的设计与扩展性
- 心跳 / 重连 / 限流 / 监控 等"工程细节"
- 与业务系统(IM、监控、告警、工单等)的解耦和演进
-
在一个成熟系统里,同时存在 SSE 和 WebSocket 是非常正常的:
-
例如:
- WebSocket 用于 IM、协同编辑;
- SSE 用于运维大屏、实时监控、系统通知。
-