【Spring Boot 实战】使用 Server-Sent Events (SSE) 实现实时消息推送

目录

[一、什么是 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
  • 打开两个浏览器标签页,分别输入 user123user456,点击"建立连接"

  • 调用 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,记录断连原因,提升可观测性。

五、学习资料

Spring 官方支持

相关推荐
SoleMotive.2 小时前
springai和langchain4j的区别
java
子超兄2 小时前
GC/OOM问题处理思路
java·jvm
麒qiqi2 小时前
【Linux 系统编程核心】进程的本质、管理与核心操作
java·linux·服务器
小坏讲微服务2 小时前
Spring Boot 4.0 整合 Kafka 企业级应用指南
java·spring boot·后端·kafka·linq
Data_agent2 小时前
京东获得京东商品详情API,python请求示例
java·前端·爬虫·python
西门老铁2 小时前
解读 Casbin 之二:如何在 Spring项目中实现菜单权限
后端
迈巴赫车主2 小时前
蓝桥杯 20531黑客java
java·开发语言·数据结构·算法·职场和发展·蓝桥杯
CodeSheep2 小时前
这个知名编程软件,正式宣布停运了!
前端·后端·程序员
ZePingPingZe2 小时前
Spring Boot常见注解
java·spring boot·后端