引言:为什么需要长连接?
传统 HTTP 是"请求-响应"模型,每次通信都需要建立 TCP 连接(即使有 Keep-Alive),无法满足低延迟、高频率、服务端主动推送的场景需求,如:
- 股票行情推送
- 实时聊天
- 监控告警通知
- 协同编辑(如 Google Docs)
为此,HTML5 引入了两种长连接技术:
| 技术 | 方向 | 协议 | 是否全双工 | 浏览器支持 | 适用场景 |
|---|---|---|---|---|---|
| SSE | 服务端 → 客户端 | 基于 HTTP/1.1 | ❌ 单向 | ✅(除 IE) | 日志流、通知、状态更新 |
| WebSocket | 双向 | 独立协议(ws/wss) | ✅ 全双工 | ✅ | 聊天、游戏、IoT 控制 |
本文将深入分析二者在 Java(Spring Boot) 中的实现细节、常见坑点及生产级解决方案。
一、Server-Sent Events (SSE):被低估的轻量级方案
1.1 原理简述
SSE 利用 HTTP 长连接 + text/event-stream MIME 类型 ,服务器持续向客户端发送以 \n\n 分隔的事件块:
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"id":1,"msg":"Hello"}\n\n
data: {"id":2,"msg":"World"}\n\n
📌 关键点:底层仍是 HTTP,但连接不关闭,由服务端控制数据流。
1.2 常见问题与解决方案
❌ 问题 1:连接意外断开后无法自动重连(或重连风暴)
现象 :网络抖动导致 SSE 断开,浏览器虽有自动重连机制(默认 3 秒),但若服务端未正确处理 Last-Event-ID,会导致数据丢失或重复。
✅ 解决方案:
- 客户端使用
EventSource自动重连(浏览器原生支持) - 服务端通过
Last-Event-IDHeader 实现断点续传
java
@GetMapping(value = "/sse/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents(HttpServletRequest request) {
String lastId = request.getHeader("Last-Event-ID");
long startFrom = (lastId != null) ? Long.parseLong(lastId) : 0;
SseEmitter emitter = new SseEmitter(30_000L); // 30秒超时
// 异步发送历史+新消息
CompletableFuture.runAsync(() -> {
try {
// 补发 missed events
for (long i = startFrom + 1; i <= startFrom + 5; i++) {
emitter.send(SseEmitter.event()
.id(String.valueOf(i))
.data(new Message(i, "Missed event #" + i))
.reconnectTime(2000)); // 建议重连间隔
}
// 继续发送新事件...
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
💡 图示:SSE 重连机制流程图
Server Client Server Client 网络中断 初始连接 event stream 自动重连 + Last-Event-ID: 5 补发 6~10 + 新事件
❌ 问题 2:Tomcat 默认线程池耗尽(每个 SSE 占用一个线程)
现象:1000 个用户在线 = 1000 个阻塞线程 → OOM 或拒绝服务。
✅ 解决方案:
- 使用 异步 Servlet 或 Spring WebFlux(Reactor) 实现非阻塞 I/O
- 配置 Tomcat 的
maxThreads和acceptCount
yaml
# application.yml
server:
tomcat:
max-threads: 200 # 默认 200,按需调整
accept-count: 100 # 排队队列
🔥 更优方案 :切换到 WebFlux + Reactor(完全非阻塞)
java
@GetMapping(value = "/sse/reactive", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Message>> reactiveStream() {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<Message>builder()
.id(String.valueOf(seq))
.event("message")
.data(new Message(seq, "Reactive msg #" + seq))
.build());
}
二、WebSocket:全双工通信的王者
2.1 协议握手流程
WebSocket 并非直接建立 TCP 连接,而是先通过 HTTP 升级:
Client:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Server:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
✅ 成功后,TCP 连接复用,进入二进制帧通信阶段。
2.2 常见问题与解决方案
❌ 问题 1:连接泄漏(未正确关闭 Session)
现象 :用户关闭浏览器但服务端未收到 onClose 事件 → 内存中残留 WebSocketSession。
✅ 解决方案:
- 注册
@OnClose回调 - 启用 心跳检测(Ping/Pong)
java
@ServerEndpoint("/chat/{userId}")
@Component
public class ChatEndpoint {
private static final Set<Session> sessions = ConcurrentHashMap.newKeySet();
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
sessions.add(session);
System.out.println("User " + userId + " connected");
}
@OnClose
public void onClose(Session session) {
sessions.remove(session);
System.out.println("Session closed");
}
@OnError
public void onError(Session session, Throwable error) {
sessions.remove(session);
error.printStackTrace();
}
}
⚠️ 注意 :某些代理(如 Nginx)会断开空闲连接,需配置
proxy_read_timeout:
nginx
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s; # 24小时
}
❌ 问题 2:集群环境下 Session 无法共享
现象:用户 A 连接到 Server1,用户 B 连接到 Server2,无法直接通信。
✅ 解决方案 :引入 消息中间件(Redis Pub/Sub)
java
// 发送消息时,不仅发给本地 session,还 publish 到 Redis
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void sendMessageToAll(String message) {
// 1. 本地广播
sessions.forEach(s -> s.getAsyncRemote().sendText(message));
// 2. 跨节点广播
redisTemplate.convertAndSend("websocket-topic", message);
}
// 监听 Redis 消息
@PostConstruct
public void subscribe() {
redisTemplate.getConnectionFactory().getConnection()
.subscribe((message, pattern) -> {
String msg = new String(message.getBody());
sessions.forEach(s -> s.getAsyncRemote().sendText(msg));
}, "websocket-topic".getBytes());
}
💡 图示:WebSocket 集群架构
Redis
Server
Client
Nginx
Nginx
|跨节点广播|
|跨节点广播|
Client A
Client C
Server 1
Server 2
Redis Pub/Sub
❌ 问题 3:消息堆积导致 OOM
现象:客户端处理慢,服务端持续 send → 内存暴涨。
✅ 解决方案:
- 使用
getAsyncRemote().sendText()(异步非阻塞) - 设置背压(Backpressure):当队列满时丢弃或限流
java
session.getAsyncRemote().setSendTimeout(5000); // 超时丢弃
session.getAsyncRemote().sendText(message, result -> {
if (!result.isOK()) {
// 处理发送失败,如记录日志或踢出用户
sessions.remove(session);
}
});
三、SSE vs WebSocket:如何选型?
| 维度 | SSE | WebSocket |
|---|---|---|
| 复杂度 | 低(基于 HTTP) | 高(需管理连接状态) |
| 防火墙穿透 | ✅(走 80/443) | ✅(但部分企业代理拦截 ws) |
| 二进制支持 | ❌(仅文本) | ✅ |
| 移动端兼容性 | 良好(iOS/Android WebView 支持) | 良好 |
| 服务端资源 | 较高(每个连接占线程) | 较低(事件驱动) |
| 适用场景 | 通知、日志、监控 | 聊天、游戏、协同操作 |
✅ 决策建议:
- 只需服务端推?→ 优先 SSE
- 需要双向高频交互?→ 必须 WebSocket
四、生产环境最佳实践
-
统一连接管理 :封装
ConnectionManager,提供注册/注销/广播接口 -
监控指标 :暴露
/actuator/metrics监控在线连接数、消息吞吐量 -
优雅关闭:应用 shutdown 时遍历所有 session 并 close
-
安全加固:
- WebSocket:校验 Origin、Token(通过子协议或 URL 参数)
- SSE:结合 Spring Security 验证 JWT
java
// WebSocket 子协议传 Token
@ServerEndpoint(value = "/ws",
configurator = JwtConfigurator.class)
public class SecureEndpoint { ... }
public class JwtConfigurator extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig config,
HandshakeRequest request,
HandshakeResponse response) {
String token = request.getParameterMap().get("token").get(0);
if (isValid(token)) {
config.getUserProperties().put("userId", parseUserId(token));
}
}
}
结语
SSE 和 WebSocket 并非"银弹",而是不同场景下的最优解。理解其底层机制、规避常见陷阱、结合中间件构建高可用架构,才是构建稳定实时系统的正道。