深入剖析 Java 长连接:SSE 与 WebSocket 的实战陷阱与优化策略

引言:为什么需要长连接?

传统 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-ID Header 实现断点续传
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 或拒绝服务。

解决方案

  • 使用 异步 ServletSpring WebFlux(Reactor) 实现非阻塞 I/O
  • 配置 Tomcat 的 maxThreadsacceptCount
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

四、生产环境最佳实践

  1. 统一连接管理 :封装 ConnectionManager,提供注册/注销/广播接口

  2. 监控指标 :暴露 /actuator/metrics 监控在线连接数、消息吞吐量

  3. 优雅关闭:应用 shutdown 时遍历所有 session 并 close

  4. 安全加固:

    • 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 并非"银弹",而是不同场景下的最优解。理解其底层机制、规避常见陷阱、结合中间件构建高可用架构,才是构建稳定实时系统的正道。

相关推荐
期待のcode2 小时前
Java 共享变量的内存可见性问题
java·开发语言
yutian06062 小时前
TI-C2000 系列 TMS320F2837X 控制律加速器(CLA)应用
开发语言·ti·ti c2000
夕阳之后的黑夜2 小时前
Python脚本:为PDF批量添加水印
开发语言·python·pdf
lllljz2 小时前
blenderGIS出现too large extent错误
java·服务器·前端
女王大人万岁2 小时前
Go标准库 path 详解
服务器·开发语言·后端·golang
小马_xiaoen2 小时前
WebSocket与SSE深度对比与实战 Demo
前端·javascript·网络·websocket·网络协议
qq_12498707532 小时前
基于spring boot的调查问卷系统的设计与实现(源码+论文+部署+安装)
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
一路往蓝-Anbo2 小时前
第 2 篇:单例模式 (Singleton) 与 懒汉式硬件初始化
开发语言·数据结构·stm32·单片机·嵌入式硬件·链表·单例模式
321.。2 小时前
从 0 到 1 实现 Linux 下的线程安全阻塞队列:基于 RAII 与条件变量
linux·开发语言·c++·学习·中间件