目录
[一、什么是 SSE?](#一、什么是 SSE?)
[SSE 的特点:](#SSE 的特点:)
[二、SSE 与 WebSocket 的区别](#二、SSE 与 WebSocket 的区别)
[三、Spring Boot 实现 SSE 示例](#三、Spring Boot 实现 SSE 示例)
[1. 创建 Spring Boot 项目](#1. 创建 Spring Boot 项目)
[2. 编写 SSE 控制器](#2. 编写 SSE 控制器)
[3.SSE 工具类:SseEmitterManager](#3.SSE 工具类:SseEmitterManager)
[4. 前端测试页面(HTML)](#4. 前端测试页面(HTML))
[5. 启动应用并测试](#5. 启动应用并测试)
一、什么是 SSE?
SSE(Server-Sent Events) 是一种基于 HTTP 的单向通信技术,允许服务器主动向客户端推送实时数据。与 WebSocket 不同,SSE 是单向的(服务器 → 客户端),且基于标准的 HTTP 协议,天然支持 REST 风格,非常适合用于实时通知、股票行情、日志流等场景。
SSE 的特点:
- 基于 HTTP/HTTPS,无需额外协议
- 自动重连机制(浏览器自动处理)
- 数据格式为
text/event-stream - 仅支持服务器 → 客户端通信
- 兼容性良好(除 IE 外主流浏览器均支持)
二、SSE 与 WebSocket 的区别
| 特性 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务端 → 客户端) | 双向 |
| 协议 | HTTP | 独立协议(ws/wss) |
| 连接开销 | 轻量 | 较重 |
| 自动重连 | 内置支持 | 需手动实现 |
| 浏览器兼容性 | 不支持 IE | 现代浏览器广泛支持 |
如果你的场景只需要服务器推数据给前端,SSE 是更轻量、更简单的选择!
三、Spring Boot 实现 SSE 示例
下面我们用 Spring Boot 快速搭建一个 SSE 服务,模拟服务器每隔 2 秒向客户端推送一条消息。
1. 创建 Spring Boot 项目
使用 Spring Initializr 创建项目,添加以下依赖:
- Spring Web
- Lombok(可选)
Maven 依赖(pom.xml):
XML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.13-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.hui</groupId>
<artifactId>studysse</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>studysse</name>
<description>studysse</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</pluginRepository>
</pluginRepositories>
</project>
2. 编写 SSE 控制器
java
package com.hui.studysse.controller;
import com.hui.studysse.manager.SseEmitterManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.UUID;
/**
* SSE 控制器
* 客户端通过 /sse/connect?userId=xxx 建立连接
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/sse")
public class SseController {
private final SseEmitterManager sseEmitterManager;
/**
* 建立 SSE 连接
* @param userId 用户唯一标识(如登录用户ID)
* @return SseEmitter
*/
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect(@RequestParam String userId) {
if (userId == null || userId.trim().isEmpty()) {
throw new IllegalArgumentException("userId 不能为空");
}
// 创建 emitter,设置超时时间为 30 分钟
SseEmitter emitter = new SseEmitter(30 * 60 * 1000L);
// 注册到管理器
sseEmitterManager.addEmitter(userId, emitter);
// 发送连接成功消息
try {
emitter.send(SseEmitter.event()
.name("connect")
.data("SSE 连接已建立,用户: " + userId));
} catch (Exception e) {
log.error("发送初始消息失败", e);
}
return emitter;
}
/**
* 测试接口:向指定用户推送消息(模拟业务触发)
*/
@PostMapping("/send/{userId}")
public String sendMessage(@PathVariable String userId, @RequestBody String message) {
sseEmitterManager.sendToUser(userId, "【系统通知】" + message);
return "消息已发送给用户: " + userId;
}
/**
* 广播测试
*/
@PostMapping("/broadcast")
public String broadcast(@RequestBody String message) {
sseEmitterManager.broadcast("【广播】" + message);
return "已广播消息";
}
/**
* 查看连接状态
*/
@GetMapping("/status")
public String status() {
return "当前总连接数: " + sseEmitterManager.getTotalConnectionCount();
}
}
3.SSE 工具类:SseEmitterManager
该类负责:
- 存储用户 ID 与
SseEmitter的映射 - 提供发送、移除、广播等方法
- 自动清理失效连接
java
package com.hui.studysse.manager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* SSE 连接管理器(线程安全)
* <p>
* 负责管理用户与SSE连接之间的映射关系,支持同一用户多个页面的连接。
* 提供添加、移除连接以及向指定用户或全体用户推送消息的功能。
* </p>
*/
@Slf4j
@Component
public class SseEmitterManager {
// 用户ID -> SseEmitter 集合(支持同一用户多标签页)
private final Map<String, CopyOnWriteArraySet<SseEmitter>> userEmitters = new ConcurrentHashMap<>();
/**
* 添加一个新的 SSE 连接到指定用户的连接集合中。
* <p>
* 若该用户尚无连接记录,则创建新的连接集合;同时设置连接超时和完成回调,
* 以便在连接异常断开或主动关闭时进行资源清理。
* </p>
*
* @param userId 用户唯一标识符
* @param emitter SSE 发射器对象,代表一个客户端连接
*/
public void addEmitter(String userId, SseEmitter emitter) {
userEmitters.computeIfAbsent(userId, k -> new CopyOnWriteArraySet<>()).add(emitter);
log.info("用户 {} 建立 SSE 连接,当前连接数: {}", userId, getUserConnectionCount(userId));
// 设置超时和完成回调(自动清理)
emitter.onTimeout(() -> {
log.warn("SSE 连接超时,用户: {}", userId);
removeEmitter(userId, emitter);
});
emitter.onCompletion(() -> {
log.info("SSE 连接正常关闭,用户: {}", userId);
removeEmitter(userId, emitter);
});
}
/**
* 移除指定用户的某个 SSE 连接,并释放相关资源。
* <p>
* 当该用户的所有连接都被移除后,将从用户连接映射表中删除该用户条目。
* </p>
*
* @param userId 用户唯一标识符
* @param emitter 需要被移除的 SSE 发射器实例
*/
public void removeEmitter(String userId, SseEmitter emitter) {
// 获取指定用户的所有SSE发射器集合
CopyOnWriteArraySet<SseEmitter> emitters = userEmitters.get(userId);
if (emitters != null) {
// 从集合中移除指定的发射器
emitters.remove(emitter);
try {
// 完成该发射器,释放相关资源
emitter.complete();
} catch (Exception ignored) {}
// 如果该用户的所有发射器都已移除,则从用户映射中删除该用户条目
if (emitters.isEmpty()) {
userEmitters.remove(userId);
}
}
}
/**
* 向指定用户的所有活跃 SSE 连接发送消息。
* <p>
* 消息以事件名称 "message" 推送给客户端。若某次发送失败,则会移除对应连接。
* </p>
*
* @param userId 目标用户 ID
* @param message 待发送的消息内容,可为任意类型对象
*/
public void sendToUser(String userId, Object message) {
CopyOnWriteArraySet<SseEmitter> emitters = userEmitters.get(userId);
if (emitters == null || emitters.isEmpty()) {
log.warn("用户 {} 无活跃 SSE 连接", userId);
return;
}
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event()
.name("message")
.data(message));
} catch (IOException e) {
log.error("向用户 {} 推送消息失败,移除连接", userId, e);
removeEmitter(userId, emitter);
}
});
}
/**
* 向所有在线用户广播一条消息。
* <p>
* 使用事件名 "broadcast" 将消息推送给所有活跃连接。如果某个连接发送失败,
* 则将其从连接池中清除。
* </p>
*
* @param message 要广播的内容,可以是任意类型的对象
*/
public void broadcast(Object message) {
userEmitters.forEach((userId, emitters) -> {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event()
.name("broadcast")
.data(message));
} catch (IOException e) {
log.error("广播消息失败,移除用户 {} 的连接", userId, e);
removeEmitter(userId, emitter);
}
});
});
}
/**
* 查询指定用户当前持有的 SSE 连接数量。
*
* @param userId 用户唯一标识符
* @return 当前连接数,如无连接则返回 0
*/
public int getUserConnectionCount(String userId) {
CopyOnWriteArraySet<SseEmitter> emitters = userEmitters.get(userId);
return emitters == null ? 0 : emitters.size();
}
/**
* 统计系统中所有用户的 SSE 连接总数。
*
* @return 所有活跃连接的数量之和
*/
public int getTotalConnectionCount() {
return userEmitters.values().stream().mapToInt(CopyOnWriteArraySet::size).sum();
}
}
4. 前端测试页面(HTML)
创建 src/main/resources/static/index.html:
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>SSE 多用户演示</title>
<style>
#log { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin: 10px 0; }
button { margin: 5px; padding: 8px 12px; }
</style>
</head>
<body>
<h2>SSE 实时推送演示</h2>
<p>用户ID: <input type="text" id="userId" value="user123"></p>
<button onclick="connect()">建立连接</button>
<button onclick="closeConn()">断开连接</button>
<div id="status">未连接</div>
<div id="log"></div>
<script>
let eventSource = null;
function log(msg) {
const logDiv = document.getElementById('log');
const line = document.createElement('div');
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
}
function connect() {
const userId = document.getElementById('userId').value.trim();
if (!userId) {
alert('请输入用户ID');
return;
}
// 关闭旧连接
if (eventSource) eventSource.close();
const url = `/sse/connect?userId=${encodeURIComponent(userId)}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
document.getElementById('status').textContent = '✅ 已连接';
log('SSE 连接已打开');
};
eventSource.onmessage = (event) => {
log('通用消息: ' + event.data);
};
eventSource.addEventListener('connect', (event) => {
log('连接事件: ' + event.data);
});
eventSource.addEventListener('message', (event) => {
log('业务消息: ' + event.data);
});
eventSource.addEventListener('broadcast', (event) => {
log('📢 广播消息: ' + event.data);
});
eventSource.onerror = (err) => {
document.getElementById('status').textContent = '❌ 连接断开';
log('SSE 连接出错或断开');
// 浏览器会自动重连,但这里我们手动控制
eventSource.close();
eventSource = null;
};
}
function closeConn() {
if (eventSource) {
eventSource.close();
eventSource = null;
document.getElementById('status').textContent = '手动断开';
log('手动关闭连接');
}
}
</script>
</body>
</html>
5. 启动应用并测试
启动 Spring Boot 应用后,访问:
http://localhost:8080
-
打开两个浏览器标签页,分别输入
user123和user456,点击"建立连接"

-
调用 POST 接口测试推送:
POST http://localhost:8080/sse/send/user123 -d "你好,123!"

- 广播测试:


- 查看连接状态:

四、注意事项与最佳实践
在实际项目中使用 SSE 时,若处理不当,容易引发连接泄漏、安全漏洞或扩展性问题。以下是关键风险点及对应的解决方案:
| 风险点 | 解决方案 |
|---|---|
| 内存泄漏 | SseEmitter 对象会常驻内存直至连接关闭。务必在 onTimeout() 和 onCompletion() 回调中主动从管理器(如 SseEmitterManager)中移除对应实例,并调用 emitter.complete() 确保资源释放。 |
| 多标签页/多设备支持 | 同一用户可能在多个浏览器标签页或设备上建立连接。应使用线程安全的集合(如 CopyOnWriteArraySet<SseEmitter>)按用户 ID 维护多个连接,避免消息丢失。 |
| 连接超时过短 | Spring 默认的 SseEmitter 超时时间为 30 秒,远低于实际业务需求。建议根据场景显式设置合理超时(如 30 分钟),并通过 server.tomcat.connection-timeout 调整底层 HTTP 连接超时,防止被 Web 容器提前断开。 |
| 身份认证缺失 | /sse/connect 接口必须进行身份校验(如验证 JWT Token 或 Session),防止未授权用户建立连接或伪造用户 ID 接收敏感消息。建议结合 Spring Security 实现权限控制。 |
| 单机部署限制 | 在集群环境下,客户端连接可能落在不同服务实例上。此时需引入 Redis 的 Pub/Sub 机制:任一节点收到推送请求后发布消息,所有节点订阅并转发给本地连接的用户,实现跨节点广播。 |
💡 额外建议:
- 记录连接日志,便于监控和排查问题;
- 提供
/sse/status接口暴露当前连接数,辅助容量规划;- 前端使用
EventSource时监听onerror,记录断连原因,提升可观测性。