1. 为什么需要Server-Sent Events
在传统的Web应用中,客户端想要获取最新的数据通常需要主动向服务器发起请求。这种轮询(Polling)方式不仅效率低下,还会造成不必要的网络开销。而WebSocket虽然能实现全双工通信,但对于只需要服务器向客户端单向推送数据的场景显得过于复杂。
Server-Sent Events(SSE)正是为解决这类问题而生的HTML5标准协议。它基于HTTP协议,允许服务器主动向客户端推送数据,特别适合股票行情、新闻推送、实时监控等场景。与WebSocket相比,SSE具有以下优势:
- 协议简单:直接使用HTTP协议,无需额外握手过程
- 自动重连:内置断线重连机制
- 轻量级:对于只需服务器推送的场景更高效
- 兼容性好:现代浏览器基本都支持
2. Spring Boot中的SSE实现原理
2.1 SSE协议基础
SSE协议本质上是一个长连接的HTTP响应,其Content-Type必须设置为text/event-stream。服务器通过保持这个连接开放,可以持续向客户端发送事件流。每个事件由以下几部分组成:
event: message data: {"time":"2023-05-01","value":42} id: 12345 retry: 5000event:事件类型,客户端可以根据不同类型做不同处理data:实际推送的数据内容id:事件ID,用于断线重连时同步retry:重连时间(毫秒)
2.2 Spring的SseEmitter机制
Spring框架从4.2版本开始提供了SseEmitter类来简化SSE实现。其核心原理是:
- 客户端发起SSE连接请求
- 服务器创建SseEmitter实例并保持连接
- 通过SseEmitter的send()方法推送数据
- 连接关闭或超时时自动清理资源
SseEmitter内部使用DeferredResult机制,将响应保持在挂起状态,直到有数据需要推送或超时发生。
3. 完整实现步骤
3.1 基础环境搭建
首先创建一个Spring Boot项目,添加web依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>3.2 控制器实现
创建SSE控制器:
@RestController @RequestMapping("/sse") public class SseController { private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>(); @GetMapping("/subscribe") public SseEmitter subscribe(@RequestParam String clientId) { // 设置超时时间1小时 SseEmitter emitter = new SseEmitter(3600_000L); // 保存emitter以备后续推送 emitters.put(clientId, emitter); // 注册清理回调 emitter.onCompletion(() -> emitters.remove(clientId)); emitter.onTimeout(() -> emitters.remove(clientId)); // 发送初始连接成功消息 try { emitter.send(SseEmitter.event() .name("connect") .data("Connected successfully")); } catch (IOException e) { emitter.completeWithError(e); } return emitter; } @PostMapping("/push") public void pushData(@RequestParam String clientId, @RequestBody String data) { SseEmitter emitter = emitters.get(clientId); if (emitter != null) { try { emitter.send(SseEmitter.event() .name("message") .data(data)); } catch (IOException e) { emitter.completeWithError(e); emitters.remove(clientId); } } } }3.3 前端实现
创建简单的HTML页面来接收SSE:
<!DOCTYPE html> <html> <head> <title>SSE Demo</title> </head> <body> <div id="messages"></div> <script> const clientId = 'user_' + Math.random().toString(36).substr(2, 9); const eventSource = new EventSource(`/sse/subscribe?clientId=${clientId}`); eventSource.addEventListener('connect', (e) => { console.log('Connection established', e.data); }); eventSource.addEventListener('message', (e) => { const msgDiv = document.createElement('div'); msgDiv.textContent = `Received: ${e.data}`; document.getElementById('messages').appendChild(msgDiv); }); eventSource.onerror = (e) => { console.error('Error occurred', e); // 默认会自动重连 }; </script> </body> </html>4. 高级功能实现
4.1 心跳机制
为了防止连接因长时间无数据被中断,可以添加心跳:
@Scheduled(fixedRate = 30000) public void sendHeartbeat() { emitters.forEach((clientId, emitter) -> { try { emitter.send(SseEmitter.event() .name("heartbeat") .comment("keep-alive")); } catch (IOException e) { emitter.completeWithError(e); emitters.remove(clientId); } }); }4.2 广播功能
实现向所有客户端广播消息:
@PostMapping("/broadcast") public void broadcast(@RequestBody String message) { emitters.forEach((clientId, emitter) -> { try { emitter.send(SseEmitter.event() .name("broadcast") .data(message)); } catch (IOException e) { emitter.completeWithError(e); emitters.remove(clientId); } }); }4.3 自定义事件类型
客户端可以监听特定事件类型:
emitter.send(SseEmitter.event() .name("stockUpdate") .data(stockData));前端对应监听:
eventSource.addEventListener('stockUpdate', (e) => { updateStockChart(JSON.parse(e.data)); });5. 生产环境注意事项
5.1 性能优化
- 使用线程池处理发送任务,避免阻塞主线程
- 限制单个客户端连接数
- 考虑使用Redis等中间件存储emitter状态
@Bean public Executor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("SSE-"); executor.initialize(); return executor; } // 使用时 @Async("asyncExecutor") public void sendDataAsync(SseEmitter emitter, Object data) { // 发送逻辑 }5.2 安全考虑
- 添加认证机制
- 限制消息频率
- 对消息内容进行过滤
@GetMapping("/subscribe") public SseEmitter subscribe(@RequestParam String clientId, @RequestHeader("Authorization") String token) { if (!validateToken(token)) { throw new SecurityException("Invalid token"); } // 其余逻辑... }5.3 连接管理
实现连接状态监控:
@RestController @RequestMapping("/admin") public class AdminController { @Autowired private SseController sseController; @GetMapping("/connections") public Map<String, Object> getConnectionStats() { return Map.of( "count", sseController.getConnectionCount(), "clients", sseController.getConnectedClients() ); } }6. 常见问题排查
6.1 连接立即断开
可能原因:
- 服务器未设置
text/event-streamContent-Type - 响应被缓存
- 跨域问题
解决方案:
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(...) { // ... }并添加CORS配置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/sse/**") .allowedOrigins("*") .allowedMethods("GET"); } }6.2 消息堆积
当客户端处理速度跟不上服务器推送速度时,可能导致内存问题。解决方案:
- 限制发送频率
- 添加背压机制
- 丢弃过时消息
private final RateLimiter rateLimiter = RateLimiter.create(10.0); // 每秒10条 public void sendData(SseEmitter emitter, Object data) { if (!rateLimiter.tryAcquire()) { log.warn("Rate limit exceeded"); return; } // 发送逻辑... }6.3 内存泄漏
长时间运行的SSE连接可能占用大量内存。建议:
- 设置合理的超时时间
- 定期清理无效连接
- 监控连接数量
@Scheduled(fixedRate = 60000) public void cleanupStaleConnections() { emitters.entrySet().removeIf(entry -> { if (entry.getValue().isComplete()) { return true; } // 其他清理条件... return false; }); }7. 实际应用案例
7.1 实时股票行情
@Scheduled(fixedDelay = 1000) public void pushStockData() { List<Stock> stocks = stockService.getLatestPrices(); emitters.forEach((clientId, emitter) -> { try { emitter.send(SseEmitter.event() .name("stockUpdate") .data(convertToJson(stocks))); } catch (IOException e) { // 错误处理 } }); }7.2 系统监控面板
@Scheduled(fixedRate = 5000) public void pushSystemMetrics() { SystemMetrics metrics = monitorService.collectMetrics(); emitters.forEach((clientId, emitter) -> { if (clientId.startsWith("monitor_")) { try { emitter.send(SseEmitter.event() .name("metrics") .data(metrics)); } catch (IOException e) { // 错误处理 } } }); }7.3 实时聊天应用
@PostMapping("/sendMessage") public void sendMessage(@RequestBody ChatMessage message) { // 存储消息 messageRepository.save(message); // 推送给接收者 SseEmitter receiverEmitter = emitters.get(message.getReceiverId()); if (receiverEmitter != null) { try { receiverEmitter.send(SseEmitter.event() .name("newMessage") .data(message)); } catch (IOException e) { // 错误处理 } } }8. 性能测试与优化建议
8.1 压力测试指标
- 单机连接数上限
- 消息延迟分布
- 内存占用情况
使用JMeter进行测试时,重点关注:
- 连接稳定性
- 长时间运行的可靠性
- 资源消耗
8.2 优化策略
连接数优化:
- 使用Nginx负载均衡
- 启用HTTP/2
- 调整操作系统文件描述符限制
消息效率优化:
- 使用二进制协议(如Base64编码)
- 批量发送消息
- 压缩消息内容
架构扩展:
- 使用Redis Pub/Sub跨节点同步
- 考虑使用专业的消息中间件
- 实现分区推送机制
// Redis集成示例 @Autowired private RedisTemplate<String, Object> redisTemplate; @PostConstruct public void init() { redisTemplate.getConnectionFactory().getConnection().subscribe( (message, pattern) -> { String clientId = new String(message.getBody()); pushCachedData(clientId); }, "sse:push".getBytes() ); }9. 替代方案比较
9.1 SSE vs WebSocket
| 特性 | SSE | WebSocket |
|---|---|---|
| 协议 | HTTP | 独立协议 |
| 通信方向 | 服务器→客户端 | 双向 |
| 重连机制 | 内置 | 需手动实现 |
| 数据格式 | 文本 | 二进制/文本 |
| 浏览器支持 | 广泛 | 广泛 |
| 适用场景 | 服务器推送 | 实时交互 |
9.2 SSE vs 长轮询
| 特性 | SSE | 长轮询 |
|---|---|---|
| 连接开销 | 低(持久连接) | 高(频繁建立连接) |
| 实时性 | 高 | 中等 |
| 服务器压力 | 低 | 高 |
| 实现复杂度 | 简单 | 中等 |
| 消息顺序 | 保证 | 可能乱序 |
10. 扩展知识与进阶方向
10.1 协议扩展
SSE协议支持一些高级特性:
- 自定义事件类型
- 消息ID跟踪
- 重试时间控制
- 注释消息(用于心跳)
10.2 结合其他技术
- 与Spring Security集成:
@GetMapping("/subscribe") @PreAuthorize("hasRole('USER')") public SseEmitter subscribe(Principal principal) { String username = principal.getName(); // ... }- 与Reactive编程结合:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamData() { return Flux.interval(Duration.ofSeconds(1)) .map(sequence -> "Event " + sequence); }- 前端框架集成:
- 在React/Vue中封装SSE组件
- 与Redux/Vuex状态管理结合
- 实现优雅的错误处理和重连
10.3 监控与运维
- 实现Prometheus监控:
@Bean public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() { return registry -> registry.config().commonTags( "application", "sse-demo" ); } // 记录连接数 @Autowired private MeterRegistry meterRegistry; private void updateConnectionMetrics() { Gauge.builder("sse.connections", emitters::size) .register(meterRegistry); }- 日志记录策略:
- 记录重要连接事件
- 监控异常断开
- 跟踪消息频率
@Aspect @Component public class SseLoggingAspect { @AfterReturning(pointcut = "execution(* com.example..subscribe(..))", returning = "emitter") public void logConnection(SseEmitter emitter) { log.info("New SSE connection established"); } @AfterThrowing(pointcut = "execution(* com.example..send*(..))", throwing = "ex") public void logSendError(IOException ex) { log.error("SSE send failed", ex); } }11. 个人实践心得
在实际项目中应用SSE时,有几个关键点值得特别注意:
连接生命周期管理是SSE实现中最容易出问题的地方。一定要确保在各种异常情况下(网络中断、客户端关闭、服务器重启)都能正确清理资源。我通常会实现一个ConnectionManager来统一管理所有emitter实例。
消息序列化方式对性能影响很大。对于复杂对象,经过测试发现Protobuf比JSON能减少约40%的网络传输量。但在浏览器端需要额外的解码处理。
前端兼容性虽然现代浏览器都支持SSE,但在移动网络环境下会遇到各种奇怪的问题。建议实现一个前端SDK封装重连逻辑和兼容性处理,而不是直接使用原生EventSource。
压力测试必不可少。SSE连接会占用服务器文件描述符资源,在Linux系统上需要调整
ulimit -n参数。我们曾经因为没做这个配置导致生产环境只能维持约1000个连接。监控报警系统要覆盖SSE的关键指标:连接数、消息延迟、错误率等。我们使用Grafana展示这些指标,并设置当连接数超过阈值时自动扩容。
协议扩展方面,我们自定义了
last-event-id的处理逻辑,在断线重连时能精确恢复消息流,这对金融类应用特别重要。
最后一个小技巧:在开发阶段,可以使用curl直接测试SSE接口:
curl -N http://localhost:8080/sse/subscribe?clientId=test这样可以快速验证服务器端实现是否正确,而不用每次都启动前端应用。