SSE(Server-Sent Events)快速入门:从零到部署一个实时推送服务
目录一、为什么需要 SSE传统 HTTP 的问题SSE 的解决方案二、SSE 是什么底层怎么跑2.1 定义2.2 协议格式2.3 浏览器端 API三、SSE vs WebSocket vs 轮询怎么选四、Spring Boot SSE 实战实时消息推送4.1 项目结构4.2 pom.xml只用 spring-boot-starter-web4.3 启动类4.4 SSE 连接管理服务核心4.5 SSE Controller对外接口4.6 前端页面static/index.html4.7 application.yml五、部署与验证5.1 启动项目5.2 打开浏览器5.3 用 curl 验证理解底层协议六、进阶用法6.1 定时推送模拟实时数据6.2 推送 JSON 对象6.3 带重试间隔的推送6.4 前端拿到断线重连的 Last-Event-ID七、避坑清单Nginx 代理 SSE 的正确配置八、总结核心口诀一句话速记技术选型决策树自检清单下一步学习路线一句话读懂SSE 是浏览器内置的服务器单向推送技术比 WebSocket 轻量比轮询高效Spring Boot 一行代码就能用。 适合人群用过 HTTP 请求-响应模式想了解实时推送但不想学 WebSocket 全套的 Java 开发者 难度等级⭐⭐☆☆☆入门 ⏱阅读时长约 15 分钟 前置知识Spring Boot 基础、HTTP 协议基本概念一、为什么需要 SSE传统 HTTP 的问题HTTP 是请求-响应模式客户端不问服务器就不说。客户端有新消息吗 → 服务器没有。 客户端有新消息吗 → 服务器没有。 客户端有新消息吗 → 服务器有给你。 ← 第 3 次才问到 客户端有新消息吗 → 服务器没有。 ...这叫轮询Polling问题很明显大量请求是无效的没有占了 99%浪费带宽和服务器资源实时性取决于轮询间隔间隔短→浪费间隔长→延迟SSE 的解决方案SSE 让服务器主动推送客户端只需要建立一次连接之后服务器想发就发客户端我要建立 SSE 连接 → 服务器好的连接保持 服务器给你一条消息 服务器再给你一条 服务器又来一条 ... 连接一直保持着一句话SSE 服务器单向推送 自动重连 纯 HTTP 协议。二、SSE 是什么底层怎么跑2.1 定义SSEServer-Sent Events是 W3C 标准HTML5 的一部分。浏览器通过EventSourceAPI 建立一个持久的 HTTP 连接服务器通过这个连接持续推送文本数据。2.2 协议格式SSE 的底层就是HTTP 响应头Content-Type: text/event-stream服务器返回的是一种简单的文本格式data: 第一条消息\n \n data: 第二条消息\n \n event: close\ndata: 服务器要关闭了\n \n格式规则字段说明示例data:消息内容必须data: Hello SSEevent:事件类型可选event: notificationid:消息 ID可选用于断线重连id: 1001retry:重连间隔毫秒可选retry: 5000\n\n每条消息之间用两个换行分隔多行数据用多个data:字段data: 第一行内容\n data: 第二行内容\n \n浏览器收到后会用换行符拼接成一个字符串。2.3 浏览器端 API// 创建 SSE 连接 const eventSource new EventSource(/api/sse/stream); // 监听默认消息 eventSource.onmessage function(event) { console.log(收到消息:, event.data); }; // 监听自定义事件 eventSource.addEventListener(notification, function(event) { console.log(通知:, event.data); }); // 连接建立 eventSource.onopen function() { console.log(SSE 连接已建立); }; // 错误处理浏览器会自动重连 eventSource.onerror function(event) { console.log(连接断开浏览器会自动重连...); }; // 关闭连接 eventSource.close();关键特性✅自动重连连接断了浏览器自动重新连接默认 3 秒间隔✅基于 HTTP不需要特殊协议能走 HTTP 的地方就能用 SSE✅纯文本只能传文本不能传二进制图片、文件等✅浏览器内置原生支持不需要第三方库三、SSE vs WebSocket vs 轮询怎么选维度轮询 (Polling)SSE (Server-Sent Events)WebSocket通信方向客户端 → 服务器服务器 → 客户端单向双向协议HTTPHTTPWS/WSS独立协议连接数每次请求一个一个长连接一个长连接实时性差取决于间隔好秒级最好毫秒级浏览器支持全部全部IE 除外全部自动重连不需要✅ 内置❌ 需要自己实现二进制传输✅❌ 只支持文本✅实现复杂度⭐ 最简单⭐⭐ 简单⭐⭐⭐ 较复杂适合场景低频查询通知、进度、日志推送聊天、游戏、协同编辑一句话决策只需要服务器推送消息给客户端 → SSE ✅ 需要客户端和服务器双向实时通信 → WebSocket 数据更新不频繁分钟级 → 轮询就够了四、Spring Boot SSE 实战实时消息推送4.1 项目结构sse-demo/ ├── src/main/java/com/example/sse/ │ ├── SseDemoApplication.java // 启动类 │ ├── controller/SseController.java // SSE 接口 │ └── service/SseEmitterService.java // 连接管理 ├── src/main/resources/ │ ├── application.yml │ └── static/index.html // 前端页面 └── pom.xml4.2 pom.xml只用 spring-boot-starter-web?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.0/version /parent groupIdcom.example/groupId artifactIdsse-demo/artifactId version1.0.0/version namesse-demo/name descriptionSSE 快速入门示例/description dependencies !-- 就这一个依赖就够了 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project4.3 启动类package com.example.sse; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class SseDemoApplication { public static void main(String[] args) { SpringApplication.run(SseDemoApplication.class, args); } }4.4 SSE 连接管理服务核心package com.example.sse.service; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * SSE 连接管理器 * 用 ConcurrentHashMap 管理所有客户端的 SseEmitter 连接 */ Service public class SseEmitterService { // 存储所有客户端连接key 用户IDvalue SseEmitter private final MapString, SseEmitter emitterMap new ConcurrentHashMap(); /** * 创建 SSE 连接 * param userId 用户唯一标识 * param timeout 超时时间毫秒0 表示不超时 */ public SseEmitter createEmitter(String userId, long timeout) { // 设置超时时间0 表示永不超时 SseEmitter emitter new SseEmitter(timeout); // 注册回调连接完成、超时、异常时移除连接 emitter.onCompletion(() - emitterMap.remove(userId)); emitter.onTimeout(() - emitterMap.remove(userId)); emitter.onError(e - emitterMap.remove(userId)); // 存入连接池 emitterMap.put(userId, emitter); System.out.println(用户 userId 建立 SSE 连接当前在线: emitterMap.size()); return emitter; } /** * 向指定用户推送消息 */ public void sendToUser(String userId, String eventName, Object data) { SseEmitter emitter emitterMap.get(userId); if (emitter null) { System.out.println(用户 userId 不在线消息丢弃); return; } try { emitter.send(SseEmitter.event() .name(eventName) // 事件类型 .data(data)); // 消息内容 } catch (IOException e) { System.out.println(推送给 userId 失败: e.getMessage()); emitterMap.remove(userId); } } /** * 广播给所有在线用户 */ public void broadcast(String eventName, Object data) { emitterMap.forEach((userId, emitter) - { try { emitter.send(SseEmitter.event() .name(eventName) .data(data)); } catch (IOException e) { System.out.println(广播给 userId 失败: e.getMessage()); emitterMap.remove(userId); } }); } /** * 关闭指定用户的连接 */ public void closeEmitter(String userId) { SseEmitter emitter emitterMap.get(userId); if (emitter ! null) { emitter.complete(); // 正常关闭 emitterMap.remove(userId); } } /** * 获取当前在线人数 */ public int getOnlineCount() { return emitterMap.size(); } }4.5 SSE Controller对外接口package com.example.sse.controller; import com.example.sse.service.SseEmitterService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; RestController RequestMapping(/api/sse) public class SseController { Autowired private SseEmitterService sseEmitterService; /** * 建立 SSE 连接 * GET /api/sse/connect?userIduser1 * * produces MediaType.TEXT_EVENT_STREAM_VALUE 是关键 * 告诉浏览器这是一个 SSE 流 */ GetMapping(value /connect, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter connect(RequestParam String userId) { // 0 表示永不超时 return sseEmitterService.createEmitter(userId, 0L); } /** * 向指定用户推送消息 * POST /api/sse/send?userIduser1message你好 */ PostMapping(/send) public String sendToUser(RequestParam String userId, RequestParam String message) { sseEmitterService.sendToUser(userId, message, message); return 消息已推送给 userId; } /** * 广播消息给所有在线用户 * POST /api/sse/broadcast?message全体通知 */ PostMapping(/broadcast) public String broadcast(RequestParam String message) { sseEmitterService.broadcast(notification, message); return 已广播给 sseEmitterService.getOnlineCount() 个用户; } /** * 关闭指定用户的连接 * POST /api/sse/close?userIduser1 */ PostMapping(/close) public String close(RequestParam String userId) { sseEmitterService.closeEmitter(userId); return 已关闭 userId 的 SSE 连接; } /** * 查看当前在线人数 * GET /api/sse/count */ GetMapping(/count) public int getOnlineCount() { return sseEmitterService.getOnlineCount(); } }4.6 前端页面static/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleSSE 快速入门演示/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Microsoft YaHei, sans-serif; padding: 20px; background: #f5f5f5; } .container { max-width: 800px; margin: 0 auto; } h1 { color: #333; margin-bottom: 20px; } .card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .card h3 { color: #666; margin-bottom: 12px; font-size: 14px; } .status { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 13px; } .status.connected { background: #d4edda; color: #155724; } .status.disconnected { background: #f8d7da; color: #721c24; } input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; width: 300px; } button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; margin-left: 8px; font-size: 14px; } .btn-primary { background: #007bff; color: #fff; } .btn-danger { background: #dc3545; color: #fff; } .btn-success { background: #28a745; color: #fff; } button:hover { opacity: 0.85; } #messages { max-height: 400px; overflow-y: auto; } .msg-item { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 14px; } .msg-item .time { color: #999; margin-right: 8px; } .msg-item .type { color: #007bff; margin-right: 8px; font-weight: bold; } /style /head body div classcontainer h1 SSE 实时消息推送演示/h1 !-- 连接控制 -- div classcard h3连接控制/h3 div input typetext iduserId placeholder输入用户ID如 user1 valueuser1 button classbtn-primary onclickconnectSSE()建立连接/button button classbtn-danger onclickcloseSSE()断开连接/button span idstatus classstatus disconnected未连接/span /div /div !-- 发送消息 -- div classcard h3推送消息/h3 div stylemargin-bottom: 8px; input typetext idtargetUser placeholder目标用户ID valueuser1 input typetext idmessage placeholder消息内容 value你好这是测试消息 button classbtn-success onclicksendToUser()推送/button /div div button classbtn-primary onclickbroadcast() 广播给所有人/button /div /div !-- 消息列表 -- div classcard h3消息记录/h3 div idmessages div classmsg-item stylecolor:#999;等待连接.../div /div /div /div script let eventSource null; // 建立 SSE 连接 function connectSSE() { const userId document.getElementById(userId).value; if (!userId) { alert(请输入用户ID); return; } if (eventSource) { eventSource.close(); } // 关键创建 EventSource 对象指向后端 SSE 接口 eventSource new EventSource(/api/sse/connect?userId userId); // 连接建立 eventSource.onopen function() { updateStatus(true); addMessage(系统, SSE 连接已建立); }; // 监听默认 message 事件 eventSource.onmessage function(event) { addMessage(默认, event.data); }; // 监听自定义 message 事件对应后端的 .name(message) eventSource.addEventListener(message, function(event) { addMessage(消息, event.data); }); // 监听自定义 notification 事件对应后端的 .name(notification) eventSource.addEventListener(notification, function(event) { addMessage( 通知, event.data); }); // 错误处理浏览器会自动重连 eventSource.onerror function() { updateStatus(false); addMessage(系统, 连接断开正在自动重连...); }; } // 断开连接 function closeSSE() { if (eventSource) { eventSource.close(); eventSource null; updateStatus(false); addMessage(系统, 已主动断开连接); } } // 推送给指定用户 function sendToUser() { const targetUser document.getElementById(targetUser).value; const message document.getElementById(message).value; fetch(/api/sse/send?userId targetUser message encodeURIComponent(message), { method: POST }).then(r r.text()).then(t addMessage(系统, t)); } // 广播 function broadcast() { const message document.getElementById(message).value; fetch(/api/sse/broadcast?message encodeURIComponent(message), { method: POST }).then(r r.text()).then(t addMessage(系统, t)); } // 更新连接状态 function updateStatus(connected) { const el document.getElementById(status); el.textContent connected ? 已连接 : 未连接; el.className status (connected ? connected : disconnected); } // 添加消息到列表 function addMessage(type, content) { const container document.getElementById(messages); const time new Date().toLocaleTimeString(); container.innerHTML div classmsg-itemspan classtime time /spanspan classtype[ type ]/span content /div container.innerHTML; } /script /body /html4.7 application.ymlserver: port: 8080 spring: application: name: sse-demo五、部署与验证5.1 启动项目cd sse-demo mvn spring-boot:run5.2 打开浏览器访问http://localhost:8080/index.html操作步骤在页面输入user1点击「建立连接」→ 状态变为「已连接」打开第二个浏览器标签页输入user2也建立连接在第一个标签页输入消息点击「推送」→ user1 收到消息点击「广播」→ 两个标签页都收到消息5.3 用 curl 验证理解底层协议# 建立 SSE 连接 curl -N http://localhost:8080/api/sse/connect?userIdtest # 另开一个终端推消息 curl -X POST http://localhost:8080/api/sse/send?userIdtestmessagehello第一个终端会看到event:message data:hello这就是 SSE 的原始协议格式——event:data: 两个换行分隔。六、进阶用法6.1 定时推送模拟实时数据在SseEmitterService中加一个定时推送方法/** * 模拟定时推送比如每 3 秒推送一次系统状态 * 配合 Scheduled 使用 */ Scheduled(fixedRate 3000) public void pushSystemStatus() { String status 在线用户: emitterMap.size() , 时间: LocalDateTime.now(); broadcast(system-status, status); }启动类加EnableSchedulingSpringBootApplication EnableScheduling // 启用定时任务 public class SseDemoApplication { ... }6.2 推送 JSON 对象// 推送复杂对象 MapString, Object data new HashMap(); data.put(orderId, ORD-20260616-001); data.put(status, 已发货); data.put(expressNo, SF1234567890); sseEmitterService.sendToUser(user1, order-update, data); // 自动序列化为 JSON{orderId:ORD-20260616-001,status:已发货,...}前端接收eventSource.addEventListener(order-update, function(event) { const order JSON.parse(event.data); // 解析 JSON console.log(订单更新:, order.orderId, order.status); });6.3 带重试间隔的推送emitter.send(SseEmitter.event() .name(message) .id(msg-1001) // 消息 ID浏览器断线重连时会带上 Last-Event-ID .reconnectTime(5000) // 断线后 5 秒重连 .data(Hello));6.4 前端拿到断线重连的 Last-Event-ID浏览器自动重连时会在请求头里带上Last-Event-ID后端可以据此补发漏掉的消息GetMapping(value /connect, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter connect(RequestParam String userId, RequestHeader(value Last-Event-ID, required false) String lastId) { System.out.println(用户 userId 重连上次收到的消息ID: lastId); // 可以根据 lastId 补发消息 return sseEmitterService.createEmitter(userId, 0L); }七、避坑清单#坑点现象原因✅ 避坑方案1连接数打满浏览器同域名最多 6 个 SSE 连接HTTP/1.1 浏览器限制同域并发连接数为 6多用户场景用 WebSocket或用 HTTP/2无此限制2Nginx 代理超时推送几分钟后连接断开Nginx 默认proxy_read_timeout 60s配置proxy_read_timeout 3600s;或按需调整3Spring 返回 406建立连接报 406 Not Acceptable忘了加produces MediaType.TEXT_EVENT_STREAM_VALUEController 方法加produces属性4中文乱码消息中文变成???没指定编码data方法加 charset.data(中文, MediaType.APPLICATION_JSON)5连接不释放用户关闭页面后连接还在服务端不知道客户端走了配合心跳检测或监听emitter.onCompletion6生产环境连接数爆满大量用户导致服务器线程耗尽SSE 是长连接每个连接占一个线程用 WebFlux响应式替代 Servlet 模式7浏览器自动重连导致雪崩服务器挂了所有客户端同时重连没有设置retry间隔服务端设置合理的reconnectTime客户端加随机延迟Nginx 代理 SSE 的正确配置location /api/sse/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; # 清除 Connection 头 proxy_buffering off; # 关闭缓冲否则消息会攒一批才发 proxy_cache off; # 关闭缓存 proxy_read_timeout 3600s; # 超时时间拉长 chunked_transfer_encoding off; # 关闭分块传输 }八、总结核心口诀SSE 是服务器单向推一条 HTTP 连接永不关。浏览器内置自动重连Spring 一个注解就能用。一句话速记SSE Content-Type: text/event-stream EventSource API 自动重连 最轻量的服务器推送方案技术选型决策树需要服务器实时推送数据 ├─ 只需要服务器 → 客户端推送 │ ├─ 是 → SSE ✅通知、进度条、日志流、实时数据 │ └─ 需要双向通信 │ ├─ 是 → WebSocket聊天、游戏、协同编辑 │ └─ 否 → SSE └─ 数据更新频率 ├─ 分钟级 → 普通轮询就够了 ├─ 秒级 → SSE ✅ └─ 毫秒级 → WebSocket自检清单我能说清楚 SSE 和 WebSocket 的区别我知道 SSE 底层就是Content-Type: text/event-stream我能用 Spring Boot 的SseEmitter写一个推送接口我知道浏览器同域最多 6 个 SSE 连接HTTP/1.1我知道 Nginx 代理 SSE 必须关proxy_buffering我知道生产环境推荐用 WebFlux 避免线程耗尽下一步学习路线阶段一入门本文 → 跑通 Spring Boot SSE demo 阶段二实战在 RuoYi 项目中用 SSE 实现订单状态推送 / 导出进度通知 阶段三进阶学习 WebFlux 响应式 SSE高并发场景 阶段四扩展了解 SSE 消息队列Redis Stream / RabbitMQ的架构