SSE 实战:在 SpringBoot + Vue 项目中零依赖实现轻量级实时消息推送

SSE 实战:在 SpringBoot + Vue 项目中零依赖实现轻量级实时消息推送

项目背景:一个基于 SpringBoot 2.3.5 + Vue 2 + Element UI 的智慧旅游出行推荐与分享系统,需要实现"管理员发布旅游资讯 → 所有在线用户实时收到弹窗通知"。

技术选型:不引入 WebSocket、不引入 MQ,用 HTML5 原生的 SSE 完成,后端零新依赖 。

本文从原理、选型、落地到踩坑,完整记录这次接入过程。全文以"旅游资讯发布"为例,实现系统通知/公告的实时推送,为表述统一,下文除特指外统称"资讯"。


一、前言:一个"资讯要实时弹出来"的需求

系统上线后,运营提了一个很朴素的需求:

管理员在后台发布一条旅游资讯,正在浏览网站的用户应该立刻在右上角看到通知,而不是刷新页面才看到。

这种"服务器主动告诉浏览器"的需求,常见方案有三种:

  1. 定时轮询:前端每 5 秒请求一次接口。实现最简单,但请求量大、实时性差、大部分请求是空结果;
  2. WebSocket :真正的双向实时通信,但要引入 spring-boot-starter-websocket,处理握手、心跳、鉴权、断线重连,成本高;
  3. SSE(Server-Sent Events) :基于普通 HTTP 的单向推送,浏览器原生 EventSource 支持、自动重连,后端 Spring MVC 内置 SseEmitter,零新依赖。

我们的场景是典型的"服务器单向广播"------资讯发布、点赞通知、系统公告,客户端不需要通过这条通道发数据。用 SSE,是最轻量的选择。

下面把整个接入过程完整梳理一遍。


二、SSE 是什么

2.1 一句话解释

SSE(Server-Sent Events,服务器发送事件)是 HTML5 规范的一部分,是一种基于 HTTP 长连接 、服务器向浏览器单向推送文本数据的技术。

2.2 工作原理

复制代码
浏览器                          服务器
  | ------ GET /sse/subscribe -----> |
  | <-- Content-Type: text/event-stream
  |                                  |
  | <====== data: 资讯1 ============ |   (连接保持不断开)
  | <====== data: 资讯2 ============ |
  | <====== data: 资讯3 ============ |
  |                                  |
  |  (连接断开时)浏览器自动重连 -----> |

几个关键点:

  • 浏览器发起的是一个普通 HTTP GET 请求;
  • 服务器响应头设置 Content-Type: text/event-stream,并且不关闭连接;
  • 之后服务器可以随时往这个连接里"追加"写入数据,浏览器实时收到;
  • 连接断了浏览器会自动重连 (默认 3 秒,可由服务端 retry 字段控制)。

注意:它本质上还是 HTTP/1.1 的"一个请求一个响应",只不过响应体是流式、不结束的。所以它能穿过大多数防火墙和代理,不需要像 WebSocket 那样升级协议。

2.3 协议数据格式

服务器推送的每条消息是纯文本,格式如下:

复制代码
data: 这是一条消息\n
\n

完整格式支持 4 个字段(字段名后跟 : ,两个换行表示一条消息结束):

字段 作用
data 消息体(多行 data 会拼接,每行一个 data:)
event 自定义事件名,前端可按事件名监听
id 消息 ID,断线重连时浏览器会带上 Last-Event-ID 请求头
retry 告诉浏览器断线后多少毫秒重连

示例:

复制代码
id: 1001
event: notice
retry: 5000
data: {"title":"系统公告","content":"今晚 24:00 系统维护"}

还有一个"隐藏用法":以 : 开头的行是注释,浏览器会忽略内容,但可以用来保活:

复制代码
: heartbeat

这一点在心跳设计里很有用,后文会讲。

2.4 与轮询、WebSocket 对比

对比项 定时轮询 WebSocket SSE
通信方向 客户端拉取 双向 服务器 → 客户端单向
协议 HTTP ws:// 新协议 普通 HTTP
实时性 差(有间隔) 实时 实时
自动重连 需自己实现 需自己实现 浏览器原生支持
实现成本 低(但浪费请求) 高(服务端需额外配置) 低(就是普通 HTTP)
二进制 支持 支持 不支持(只能文本)
适用场景 低频、不要求实时 聊天室、协同编辑 通知、公告、进度推送

结论:资讯推送、点赞/评论通知、系统公告这类服务器单向广播场景,SSE 是最轻量的选择。

2.5 SSE 的局限(选型前必须知道)

  • 只能服务器 → 客户端单向推送。客户端要发数据,仍然走普通 HTTP 请求;
  • HTTP/1.1 下浏览器对同一域名的连接数有限制(Chrome 约 6 个)。开多个 SSE 页面会占满,甚至影响同域名其他请求;
  • 不支持二进制,二进制内容需要 Base64;
  • EventSource 不能自定义请求头,token 只能放 URL query 或 cookie;
  • 单机内存存连接,集群部署需要额外做跨节点广播(Redis Pub/Sub 或 MQ)。

三、SpringBoot 侧:SseEmitter 原理与用法

3.1 最小示例

Spring MVC 4.2+ 内置了 SseEmitter,无需引入任何新依赖 (spring-boot-starter-web 自带)。

java 复制代码
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@GetMapping("/sse/subscribe")
public SseEmitter subscribe(String userId) {
    // 超时时间:30 分钟;0 表示永不超时(不推荐,连接泄漏难排查)
    SseEmitter emitter = new SseEmitter(30 * 60 * 1000L);
    try {
        emitter.send(SseEmitter.event()
                .id("1001")                    // 消息 ID
                .name("notice")                // 自定义事件名
                .data("{\"msg\":\"hello\"}")   // 消息体
                .reconnectTime(5000));         // 断线 5 秒后重连
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
    return emitter;   // 返回给 Spring 后,连接保持,异步线程可继续往里写
}

要点:

  • 接口返回 SseEmitter 对象(不是 JSON),Spring 会自动设置响应头并保持连接;
  • 需要把 emitter 存起来(如 ConcurrentHashMap),业务发生时再调用 emitter.send() 推送;
  • 必须处理 onCompletion / onTimeout / onError 回调,从 map 中移除,防止内存泄漏;
  • 必须加心跳,否则中间代理/浏览器可能因长时间无数据断开空闲连接。

3.2 原理:SseEmitter 的线程模型与超时(容易踩坑)

理解这一点,很多问题就通了:

  1. 异步 Servlet :SseEmitter 基于 Servlet 3.0 的异步请求。Controller 返回后,请求进入异步模式,Tomcat 工作线程被释放 回线程池,连接本身由 NIO 的 Poller 管理。所以几百上千个 SSE 连接不会 占满 maxThreads(默认 200),但会占 maxConnections(默认 8192)和内存。
  2. send 是同步且线程安全的 :ResponseBodyEmitter.send() 内部加了 synchronized,并且直接往响应流写。这意味着:
    • 多个线程同时给同一个 emitter 发消息不会串,但会互相等待;
    • 如果客户端网络慢、不读数据,send 会阻塞,进而拖慢调用它的业务线程。这是后文"发送阻塞"坑的根源。
  3. 超时 :new SseEmitter(timeout) 不传时,使用容器的默认异步超时(Tomcat 默认 30 秒),到点会触发 onTimeout 并结束请求。所以一定要显式指定一个合理的超时时间 ,否则连接 30 秒就断了,前端虽然会自动重连,但体验很差、日志也难看。
    也可以用全局配置:spring.mvc.async.request-timeout: 1800000(单位毫秒),但只对未指定超时的 emitter 生效。
  4. complete() 与 completeWithError() :前者正常结束(浏览器视为连接关闭,不会自动重连),后者带错误结束(浏览器会尝试重连)。服务端主动踢人用前者,异常用后者。

3.3 三个回调:防内存泄漏的"三件套"

java 复制代码
emitter.onCompletion(() -> emitterMap.remove(userId));
emitter.onTimeout(() -> {
    emitter.complete();
    emitterMap.remove(userId);
});
emitter.onError(e -> emitterMap.remove(userId));

这三个回调在连接完成、超时、出错 时触发,是清理 map 的唯一可靠时机。少写一个,长跑之后 map 里就会积累大量已失效的 emitter,最终 OOM。

3.4 心跳

空闲连接会被中间代理(Nginx 默认 proxy_read_timeout 60s)、运营商网关、甚至浏览器自己断开。所以需要一个定时任务定期发点东西。

两种写法:

java 复制代码
// 写法一:发一个自定义 ping 事件(前端需注册监听,否则事件被丢弃)
emitter.send(SseEmitter.event().name("ping").data(String.valueOf(System.currentTimeMillis())));

// 写法二(更推荐):发注释行,不触发任何前端事件,最省
emitter.send(SseEmitter.event().comment("heartbeat"));

注意:SseEmitter.event().comment() 在 Spring 4.2+ 就可用。心跳频率一般 15~30 秒一次,太频繁浪费带宽,太稀疏保不住连接。


四、Vue 侧:EventSource 封装

4.1 原生 API

javascript 复制代码
// 建立连接(就是一个 GET 请求)
const source = new EventSource('http://localhost:8080/sse/subscribe?userId=1')

// 监听默认消息(服务端没指定 event 字段时走这里)
source.onmessage = (event) => {
  console.log('收到消息:', event.data)      // 字符串
  console.log('消息ID:', event.lastEventId)
}

// 监听自定义事件(服务端指定了 event: notice 时走这里)
source.addEventListener('notice', (event) => {
  const data = JSON.parse(event.data)
  console.log('收到公告:', data)
})

// 连接打开
source.onopen = () => console.log('SSE 连接已建立')

// 出错(断线后浏览器会自动重连,无需手动处理)
source.onerror = (err) => {
  console.error('SSE 异常', err)
  // source.readyState: 0=CONNECTING 1=OPEN 2=CLOSED
}

// 主动关闭(页面销毁时必须调,否则连接一直占用)
source.close()

两个必须记住的点:

  • EventSource 不能自定义请求头 (不能加 token: xxx),只能把参数拼在 URL query 上,或依赖 cookie;
  • 手动 close() 后浏览器不会自动重连 ,只有连接"意外"断开才会。所以退出登录/组件销毁要主动 close(),避免连接泄漏。

4.2 封装

javascript 复制代码
// util/sse.js
export function createSse(url, handlers) {
  const source = new EventSource(url)
  source.onmessage = (e) => handlers.onMessage && handlers.onMessage(e)
  source.onerror = (e) => handlers.onError && handlers.onError(e)
  if (handlers.events) {
    Object.keys(handlers.events).forEach(name => {
      source.addEventListener(name, handlers.events[name])
    })
  }
  return source
}
javascript 复制代码
// 某 Vue 组件中
export default {
  data() {
    return { sseSource: null }
  },
  created() {
    this.sseSource = createSse('http://localhost:8080/sse/subscribe?userId=' + this.userId, {
      onMessage: (e) => {
        this.$notify({ title: '新消息', message: e.data, type: 'success' })
      }
    })
  },
  beforeDestroy() {
    if (this.sseSource) this.sseSource.close()
  }
}

如果是 Vue 2.7+ 或 Vue 3,生命周期换成 beforeUnmount / onBeforeUnmount 即可。


五、完整落地:智慧旅游系统接入方案

5.1 现状分析

项目 现状 对 SSE 的影响
后端 SpringBoot 2.3.5,spring-boot-starter-web SseEmitter 开箱即用,零新依赖
跨域 WebConfigurer.java 已对 /** 配置 CORS SSE 接口自动被覆盖,无需改动
前端 Vue 2,devServer 端口 8081,未配置代理 EventSource 直接连 http://localhost:8080(即 VUE_APP_BASE_API)
鉴权 前端把 token 放 header,但后端没有拦截器校验 EventSource 不能设 header 也不受影响,用 userId query 参数标识用户即可
返回体 统一 ResponseBean / ResultUtil SSE 接口不走统一返回体,直接返回 SseEmitter

5.2 推荐落地场景

结合旅游系统业务,选**"旅游资讯/系统通知实时推送"**作为切入点,最直观、最好演示:

  • 管理员在后台发布旅游资讯(/news/add)→ 实时推送给所有在线用户,前端右上角弹出通知;
  • 用户攻略被点赞/评论 → 实时推送给该用户;
  • 系统公告广播。

5.3 后端改造(3 个新文件,不改任何现有代码)

文件 1:连接管理器 com/travel/common/sse/SseEmitterManager.java

统一管理所有在线连接,避免散落在各 Controller。下面这版已结合生产实践做了并发遍历安全 与应用关闭清理两项加固:

java 复制代码
package com.travel.common.sse;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.PreDestroy;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * SSE 连接管理器:保存所有在线用户的 SseEmitter
 */
@Slf4j
@Component
public class SseEmitterManager {

    /** key = 用户ID(字符串),value = 该用户的连接 */
    private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();

    /** 超时时间:30 分钟 */
    private static final long TIMEOUT = 30 * 60 * 1000L;

    /**
     * 用户建立连接
     */
    public SseEmitter connect(String userId) {
        // 如果该用户已有旧连接(比如开了两个标签页/刷新页面),先关掉旧的
        SseEmitter old = emitterMap.get(userId);
        if (old != null) {
            old.complete();
        }

        SseEmitter emitter = new SseEmitter(TIMEOUT);

        // 三个回调都必须移除 map,否则连接泄漏
        emitter.onCompletion(() -> emitterMap.remove(userId));
        emitter.onTimeout(() -> {
            emitter.complete();
            emitterMap.remove(userId);
        });
        emitter.onError(e -> emitterMap.remove(userId));

        emitterMap.put(userId, emitter);
        log.info("SSE 用户[{}]已连接,当前在线连接数:{}", userId, emitterMap.size());

        // 先推一条连接成功的消息,防止代理因无数据提前断连
        sendToUser(userId, "connect", "SSE 连接成功");
        return emitter;
    }

    /**
     * 推送给指定用户
     * @param event 事件名(前端 addEventListener 监听)
     * @param data  消息体(建议 JSON 字符串)
     */
    public void sendToUser(String userId, String event, String data) {
        SseEmitter emitter = emitterMap.get(userId);
        if (emitter == null) return;
        try {
            emitter.send(SseEmitter.event().name(event).data(data));
        } catch (IOException | IllegalStateException e) {
            // 客户端已断开
            emitterMap.remove(userId);
            log.warn("SSE 推送给用户[{}]失败,连接已移除:{}", userId, e.getMessage());
        }
    }

    /**
     * 广播给所有在线用户
     * 说明:ConcurrentHashMap.forEach 提供弱一致性遍历,遍历期间 map 结构的增删
     * 不会抛 ConcurrentModificationException,也不会阻塞写入;
     * 这里用 forEach + entrySet 直接拿到 userId,推送失败时才能精准 remove。
     */
    public void sendToAll(String event, String data) {
        emitterMap.forEach((userId, emitter) -> {
            try {
                emitter.send(SseEmitter.event().name(event).data(data));
            } catch (IOException | IllegalStateException e) {
                emitterMap.remove(userId);
                log.warn("SSE 广播给用户[{}]失败,连接已移除:{}", userId, e.getMessage());
            }
        });
    }

    /**
     * 心跳:防止空闲连接被中间件断开
     * 同样使用 forEach 遍历 entrySet,保持与 sendToAll 一致的安全语义
     */
    public void heartbeat() {
        emitterMap.forEach((userId, emitter) -> {
            try {
                // 发注释行,前端无感知,最省资源
                emitter.send(SseEmitter.event().comment("heartbeat"));
            } catch (IOException | IllegalStateException e) {
                emitterMap.remove(userId);
                log.warn("SSE 心跳发送给用户[{}]失败,连接已移除", userId);
            }
        });
    }

    /**
     * 应用关闭时清理所有连接,避免 shutdown 过程中定时任务继续往已关闭的流写数据
     */
    @PreDestroy
    public void destroy() {
        log.info("应用关闭,清理所有 SSE 连接,共 {} 个...", emitterMap.size());
        emitterMap.values().forEach(emitter -> {
            try {
                emitter.complete();
            } catch (Exception ignored) {
                // 关闭阶段的异常无需处理
            }
        });
        emitterMap.clear();
    }
}

这里两处加固值得注意:

  1. 并发遍历 :ConcurrentHashMap 本身不会抛 ConcurrentModificationException,但 keySet() 遍历后再去 get 属于"两步操作",高并发下可能出现 key 还在、value 已经被 remove 的情况。改用 forEach((userId, emitter) -> ...) 直接在遍历回调里拿到 entry 的快照,语义更严谨;推送失败时也能精准 remove(userId),避免无效连接残留。
  2. 优雅关闭 :加 @PreDestroy,在 Spring 容器关闭前把连接全部 complete() 并清空 map。否则应用 shutdown 瞬间,定时任务可能仍在往已经关闭的响应流写数据,抛出一堆 IllegalStateException,日志很脏,排查问题时也容易误判。
文件 2:订阅接口 com/travel/controller/SseController.java
java 复制代码
package com.travel.controller;

import com.travel.common.sse.SseEmitterManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

/**
 * SSE 订阅入口
 */
@RestController
@RequestMapping("/sse")
public class SseController {

    @Autowired
    private SseEmitterManager sseEmitterManager;

    /**
     * 前端建立 SSE 连接
     * 注意:EventSource 无法自定义请求头,用户标识通过 query 参数传递
     * 示例:GET /sse/subscribe?userId=1
     */
    @GetMapping("/subscribe")
    public SseEmitter subscribe(String userId) {
        return sseEmitterManager.connect(userId);
    }
}
文件 3:心跳定时任务 com/travel/common/sse/SseHeartbeatTask.java
java 复制代码
package com.travel.common.sse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * SSE 心跳:每 30 秒给所有连接发一次,防止连接被判定空闲而断开
 */
@Component
public class SseHeartbeatTask {

    @Autowired
    private SseEmitterManager sseEmitterManager;

    @Scheduled(fixedRate = 30 * 1000)
    public void ping() {
        sseEmitterManager.heartbeat();
    }
}

并在启动类 TravelApplication.java 上加注解开启定时任务:

java 复制代码
@EnableScheduling   // 新增这一行
@SpringBootApplication
public class TravelApplication { ... }
在业务中触发推送(以发布资讯为例)

只改 TravelNewsController.java 的 add 方法,加两行:

java 复制代码
@Autowired
SseEmitterManager sseEmitterManager;   // 新增注入

@PostMapping("/add")
public ResponseBean add(TravelNews news) {
    if (news == null) {
        return ResultUtil.error();
    }
    newsService.save(news);
    // 新增:实时广播给所有在线用户
    sseEmitterManager.sendToAll("news",
        "{\"title\":\"" + news.getTitle() + "\",\"id\":" + news.getId() + "}");
    return ResultUtil.successData(news);
}

更优雅的做法是用 Jackson 序列化:objectMapper.writeValueAsString(news),避免手拼 JSON 在标题含引号时出问题。关于"推送逻辑和业务逻辑解耦"的进阶写法,见后文 6.2。

同理,点赞攻略、评论等任何业务发生处,注入 SseEmitterManager 调一行 sendToUser / sendToAll 即可。

5.4 前端改造(1 个新文件 + 1 处引用)

新文件:vuetravel/src/util/sse.js

与现有 util/http.js 同级,风格保持一致:

javascript 复制代码
/**
 * 封装 SSE(EventSource)
 * 用法见下方注释,组件销毁时必须调 close()
 */
import Vue from 'vue'

const BASE_API = process.env.VUE_APP_BASE_API  // http://localhost:8080/

let source = null

/**
 * 建立 SSE 连接(全局只保留一条)
 * @param {string|number} userId 当前登录用户ID,未登录可传 'guest'
 */
export function connectSse(userId) {
  // 防止重复连接
  if (source) {
    source.close()
  }
  source = new EventSource(BASE_API + 'sse/subscribe?userId=' + userId)

  // 心跳事件,忽略即可(仅用于保活)
  source.addEventListener('ping', () => {})

  // 连接成功回执
  source.addEventListener('connect', (e) => {
    console.log('SSE:', e.data)
  })

  // 新资讯推送
  source.addEventListener('news', (e) => {
    const data = JSON.parse(e.data)
    Vue.prototype.$notify({
      title: '新资讯发布',
      message: data.title,
      type: 'success',
      duration: 5000
    })
  })

  source.onerror = () => {
    // 断线浏览器会自动重连,这里只打日志
    console.warn('SSE 连接异常,等待自动重连...')
  }
  return source
}

/** 关闭连接(退出登录/页面卸载时调用) */
export function closeSse() {
  if (source) {
    source.close()
    source = null
  }
}
引用:登录成功后建立连接

在 App.vue(或登录成功回调、前台 Index.vue 的 created)中:

javascript 复制代码
import { connectSse, closeSse } from '@/util/sse'

export default {
  created() {
    // 从 cookie 或 vuex 取当前登录用户ID;未登录用 guest 接收广播
    const userId = this.$cookies.get('userId') || 'guest'
    connectSse(userId)
  },
  beforeDestroy() {
    closeSse()
  }
}

注意:项目 devServer 没有配代理,所以 EventSource 和 axios 一样,直接通过

VUE_APP_BASE_API(http://localhost:8080/)连后端,后端 CORS 已放行 /**,无需额外配置。

5.5 联调测试

  1. 启动后端 travel(端口 8080),启动前端 vuetravel(npm run dev,端口 8081);
  2. 浏览器打开前台首页,F12 → Network 面板应看到 sse/subscribe 请求一直处于 pending 状态,EventStream 标签页可见每 30 秒一条心跳;
  3. 用 Postman 或 swagger 调 POST /news/add 发布一条资讯;
  4. 前端页面右上角应立即弹出 Element UI 通知。

也可以直接在浏览器控制台快速验证后端(不依赖前端代码):

javascript 复制代码
const s = new EventSource('http://localhost:8080/sse/subscribe?userId=test')
s.onmessage = e => console.log(e.data)

六、踩坑与最佳实践(重点)

这部分是上线后真实踩出来的,按坑的"杀伤力"排序。

6.1 坑一:不指定超时,连接 30 秒就断

new SseEmitter() 不传超时,Spring MVC 会使用容器默认的异步超时(Tomcat 默认 30 秒),到点触发 onTimeout,连接结束。前端 EventSource 虽然会自动重连,但每 30 秒断一次,日志刷屏、体验极差。

解决:显式指定超时(如 30 分钟),并用心跳保活。全局配置也可以:

yaml 复制代码
spring:
  mvc:
    async:
      request-timeout: 1800000   # 30 分钟,仅对未显式指定 timeout 的 emitter 生效

6.2 坑二:send 阻塞业务线程(附进阶解耦方案)

ResponseBodyEmitter.send() 是同步写响应流 的。如果某个客户端网络慢、TCP 缓冲区满,send 会阻塞,而这个调用发生在业务线程里(比如 /news/add 的 Controller 线程),会导致发布资讯接口变慢。

方案 A(基础):独立线程池隔离

把推送放到独立线程池,业务线程只负责"投递任务":

java 复制代码
private final ExecutorService pushPool = Executors.newFixedThreadPool(4);

public void sendToAllAsync(String event, String data) {
    pushPool.submit(() -> sendToAll(event, data));
}

连接数不多时问题不明显,但这是一个"量上来就炸"的隐患,建议一开始就隔离。

方案 B(进阶):Spring 事件 + @Async,业务与推送彻底解耦

更符合 Spring 风格的做法,是利用应用事件(ApplicationEvent)+ @Async 监听器,让业务代码完全不需要感知 SSE 的存在:

java 复制代码
// 1. 定义事件
public class NewsAddedEvent {
    private final Long newsId;
    private final String title;
    // 构造器 / getter 略
}

// 2. 业务里只发布事件,不直接调推送
@Service
public class TravelNewsService {
    @Autowired
    private ApplicationEventPublisher publisher;

    public void add(TravelNews news) {
        // ... 保存逻辑
        publisher.publishEvent(new NewsAddedEvent(news.getId(), news.getTitle()));
    }
}

// 3. 异步监听器里做推送
@Component
public class NewsSseListener {
    @Autowired
    private SseEmitterManager sseEmitterManager;

    @Async
    @EventListener
    public void onNewsAdded(NewsAddedEvent event) {
        sseEmitterManager.sendToAll("news",
                "{\"id\":" + event.getNewsId() + ",\"title\":\"" + event.getTitle() + "\"}");
    }
}

并在启动类或配置类加 @EnableAsync。

这样改的好处:

  • 解耦:业务层只关心"发布了一条资讯",不关心要不要推送、推给谁;
  • 异步 :@Async 让推送在独立线程执行,天然规避 6.2 的阻塞问题;
  • 可扩展 :将来新增"发短信""写日志""推送到 MQ"等下游动作,只需再加一个 @EventListener,业务代码零改动。

注意:@Async 默认使用 SimpleAsyncTaskExecutor,每次都会新建线程,生产环境建议显式配置一个 ThreadPoolTaskExecutor 作为默认异步执行器。

6.3 坑三:心跳到底发什么

心跳有几种写法,效果不同:

写法 前端表现 推荐度
event().name("ping").data(...) 触发前端 ping 监听(需注册,否则丢弃) 一般
event().comment("heartbeat") 前端完全无感知,不触发任何事件 推荐
直接往流里写 :\n\n 同上,但绕过了 SseEmitter 的封装 不推荐

心跳频率 15~30 秒即可。另外要注意:heartbeat() 里循环 send,如果连接很多且某个连接阻塞,会拖慢整个心跳循环。连接量大时同样建议用线程池并发发送。

6.4 坑四:CORS 与 withCredentials

EventSource 默认 不带 cookie (withCredentials = false)。如果你的鉴权依赖 cookie,需要:

javascript 复制代码
new EventSource(url, { withCredentials: true })

同时服务端 CORS 必须满足:

  • Access-Control-Allow-Credentials: true
  • Access-Control-Allow-Origin 不能是 *,必须是具体 origin

而很多项目(包括我们最初)的 CORS 配置是 allowedOrigins("*"),一旦开启 credentials 就会报错。这是 Spring 5.3 / Spring Boot 2.4 之后的强制规定 ,会直接抛 IllegalArgumentException。

如果确实需要"允许所有域名 + 携带 Cookie" ,Spring Boot 提供了 allowedOriginPatterns 作为替代:

java 复制代码
registry.addMapping("/**")
        .allowedOriginPatterns("*")        // 代替 allowedOrigins("*")
        .allowCredentials(true)
        .allowedMethods("*")
        .allowedHeaders("*");

allowedOriginPatterns 允许使用通配符,并能与 allowCredentials(true) 共存,是 Spring 官方推荐的写法。

我们的做法是用 query 参数传 userId,不依赖 cookie,绕开这个坑。但生产环境更安全的做法是 query 传 token 并在服务端校验(见 7.2)。

6.5 坑五:浏览器 6 连接限制(含 HTTP/2 的真相)

HTTP/1.1 下浏览器对同一域名最多约 6 个并发连接。如果用户开了多个标签页,每个页面一条 SSE,很快就占满,导致同域名其他请求排队。

很多文章会说"升级到 HTTP/2 就好了",但这里有一个常被误解的关键前提:

升级到 HTTP/2 后,原生 EventSource 并不一定自动享受多路复用红利。 浏览器底层是否为每个 EventSource 实例建立独立的 HTTP/2 流、是否合并连接,取决于浏览器实现。在部分浏览器版本中,EventSource 仍然会独占一条 TCP 连接。要真正稳定地利用 HTTP/2 多路复用,通常需要改用基于 fetch 的 SSE 实现 (如 @microsoft/fetch-event-source),由前端库主动复用连接。

所以,缓解手段分两种思路:

  • 不升级协议 :用 BroadcastChannel / SharedWorker 让多个标签页共享一条连接(进阶方案,适合强需求场景);
  • 升级协议 :升级到 HTTP/2 后,优先验证浏览器对 EventSource 的连接合并行为,必要时切换到 fetch 版 SSE 库;
  • 务实方案:接受现状------普通用户很少开 6 个以上标签页,6 个连接的上限在多数 C 端场景下够用。

6.6 坑六:Nginx 缓冲导致"消息攒一批才到"

Nginx 默认开启 proxy_buffering,会把上游响应缓冲起来再发,SSE 的实时性就没了。

解决(二选一或都加):

nginx 复制代码
location /sse/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;   # 大于心跳间隔
}

或者在后端响应头加 X-Accel-Buffering: no,让 Nginx 对该响应关闭缓冲。

6.7 坑七:内存泄漏

emitterMap 只增不减是 SSE 最经典的问题。必须保证 onCompletion / onTimeout / onError 三个回调都执行 remove。另外:

  • 用户重复连接时,旧 emitter 要 complete();
  • 前端组件销毁、退出登录时必须 close();
  • 应用关闭时用 @PreDestroy 统一清理(见 5.3 文件 1);
  • 建议加一个监控日志,定期打印 emitterMap.size(),发现只增不减就要查。

6.8 坑八:多标签页只有一个收到消息

我们的方案是 key = userId,同一个用户开两个标签页,后连接的会顶掉前一个(代码里 old.complete())。这是有意为之,避免同一用户重复弹窗。

如果业务要求多标签页都收,可以把 key 改成 userId + 随机串,sendToUser 改成遍历该用户的所有连接。代价是连接数变多,需要配合 6.5 的共享连接方案。

6.9 坑九:后端重启后前端"不推了"

后端重启,内存里的 emitter 全部失效。前端 EventSource 会检测到连接断开并自动重连 ,重新走 /sse/subscribe,一般几秒内恢复。这是正常现象,不需要特殊处理。

但要注意:如果后端重启期间前端处于"手动 close()"状态,就不会重连。所以只在退出登录/页面销毁时 close(),不要在其他地方随意关闭。

6.10 坑十:想传 token 到 header

EventSource 不支持自定义请求头。三种方案:

  1. query 参数传 token(最简单,但 token 会出现在 URL/日志里,注意脱敏);
  2. cookie 鉴权 + withCredentials(受 6.4 的 CORS 限制,注意 allowedOriginPatterns);
  3. 引入 @microsoft/fetch-event-source ,它用 fetch 实现 SSE,支持自定义 header,但需要额外安装,且要自己处理重连逻辑。

我们当前项目后端没有拦截器校验 token,所以直接用 userId query 参数,后续接入鉴权时建议升级到方案 1 + 服务端校验。


七、进阶:集群、安全、压测

7.1 集群部署:Redis Pub/Sub 广播

单机版 emitterMap 存在 JVM 内存里,集群部署时用户 A 连在节点 1,业务在节点 2 触发推送,A 就收不到。

整体架构:

复制代码
                       ┌─────────────────────────────┐
                       │  Redis Pub/Sub              │
                       │  channel: sse:broadcast     │
                       └──────────────▲──────────────┘
                                      │
               ┌──────────────────────┼──────────────────────┐
               │ publish              │ subscribe            │ subscribe
               ▼                      │                      ▼
      ┌─────────────────┐             │            ┌─────────────────┐
      │  节点 2         │             │            │  节点 3         │
      │  触发推送       │             │            │  用户 C 连接    │
      │  /news/add      │             │            └─────────────────┘
      └─────────────────┘             │
                                      │
                             ┌─────────────────┐
                             │  节点 1         │
                             │  用户 A、B 连接 │
                             └─────────────────┘

流程:节点 2 收到发布资讯请求
  → publish 到 Redis 频道 sse:broadcast
  → 所有节点(1、2、3)都收到该消息
  → 各节点查本机 emitterMap,有连接的用户就推送
  → 用户 A、B、C 同时收到通知

思路 :每个节点订阅同一个 Redis 频道,业务触发时把消息 publish 到频道,所有节点收到后各自推给本机连接的用户。

java 复制代码
// 业务侧:把广播消息发到 Redis,而不是直接遍历本地 map
public void sendToAll(String event, String data) {
    String payload = JSON.toJSONString(Map.of("event", event, "data", data));
    redisTemplate.convertAndSend("sse:broadcast", payload);
}

// 每个节点启动时订阅,收到后推送给本机连接
@Component
public class SseRedisSubscriber implements MessageListener {
    @Autowired
    private SseEmitterManager sseEmitterManager;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        Map<String, String> msg = JSON.parseObject(
                new String(message.getBody()), Map.class);
        sseEmitterManager.sendToAll(msg.get("event"), msg.get("data"));
    }
}

注意:sendToAll 在这里是"推给本机 的所有连接",不是全局广播。真正的全局广播由 Redis 频道完成。所以 SseEmitterManager 里的 sendToAll 方法语义不用改,仍然只操作本机 map,集群广播是上层职责。

定向推送给某个用户时,可以把消息发到 sse:user:{userId} 频道,但更简单的是所有节点都收,各自查本机 map,有就推、没有就忽略------对在线用户量不大的系统,这种"广播式定向"成本完全可接受。

7.2 安全

  • 鉴权 :userId 通过 query 传,可伪造。生产环境应在 subscribe 接口校验 token(query 或 cookie),从 token 解析出真实 userId,而不是信任前端传的;
  • 限流:限制单 IP / 单用户的 SSE 连接数,防止恶意占满连接;
  • guest 连接 :未登录用户用 guest 作为 key 会互相顶掉,只保留一条。如果要做匿名广播,建议匿名用户不建立 SSE 连接,或者给每个匿名会话生成唯一 ID;
  • 敏感信息:推送内容不要包含敏感数据,SSE 是明文文本(HTTPS 下加密传输,但服务端日志可能记录)。

7.3 压测与容量

  • 线程 :异步 Servlet 释放了工作线程,maxThreads(默认 200)不是瓶颈;
  • 连接数 :受 Tomcat maxConnections(默认 8192)限制,可调大;
  • 内存:每个连接占一个 socket + emitter 对象 + 缓冲区,单连接约几十 KB,1 万连接约几百 MB,需实测;
  • 文件句柄 :每个连接占一个 fd,注意 ulimit -n;
  • 压测工具 :可以用 curl -N http://.../sse/subscribe?userId=x 批量起连接,或用 JMeter 的 SSE 插件。

单机支撑几千到上万连接是合理的,再往上就要考虑多节点 + 网关层连接复用。


八、常见问题速查表

问题 原因 解决
消息攒一批才到、不实时 中间有 Nginx/代理开了响应缓冲 Nginx 加 proxy_buffering off;,或响应头加 X-Accel-Buffering: no
长时间无消息后连接断了 连接空闲被断开 心跳(30 秒一次 comment / ping)
连接 30 秒就断 未指定超时,用了容器默认 new SseEmitter(30 * 60 * 1000L) 或配置 spring.mvc.async.request-timeout
发布接口变慢 send 同步阻塞业务线程 推送放独立线程池,或 ApplicationEvent + @Async 解耦
重启后端后前端不推了 内存 emitter 失效 正常现象,浏览器自动重连重新 subscribe
多标签页只有一个收到消息 同 userId 后连接顶掉旧连接 本方案按用户存一条;如需多标签页都收,key 改成 userId + 随机串
想传 token 到 header EventSource 不支持自定义 header query 传 token;或引入 @microsoft/fetch-event-source
集群部署只有部分用户收到 emitter 存在单机内存 引入 Redis Pub/Sub 或 MQ 做跨节点广播
开了 SSE 后其他请求变慢 浏览器同域名 6 连接限制被占满 多标签页共享连接(BroadcastChannel/SharedWorker),或升级 HTTP/2(注意 EventSource 的合并行为)
应用关闭日志大量异常 shutdown 时定时任务仍在写已关闭的流 加 @PreDestroy 统一 complete() 并清空 map
allowCredentials(true) 报错 Spring 5.3+ 禁止 allowedOrigins("*") + credentials 改用 allowedOriginPatterns("*")

九、总结

  • SSE = 基于 HTTP 的服务器单向推送 ,浏览器原生 EventSource 支持、自动重连,比 WebSocket 轻得多;
  • SpringBoot 用内置 SseEmitter 即可,本工程零新依赖;
  • 接入成本:后端新增 3 个文件 + 启动类加 @EnableScheduling + 业务处调一行推送;前端新增 util/sse.js + 入口建立连接;
  • 真正需要花心思的是踩坑部分:超时、阻塞、心跳、CORS、Nginx 缓冲、内存泄漏、集群广播;
  • 两个值得一开始就做对的细节:ConcurrentHashMap 用 forEach((k, v) -> ...) 遍历而不是先 keySet 再 get 、用 @PreDestroy 在应用关闭时清理连接;
  • 适合场景:资讯发布广播、点赞/评论通知、系统公告、进度推送;不适合:聊天、协同编辑(这类用 WebSocket)。

SSE 的定位很清晰:"我不需要客户端跟我说话,我只需要它安静地听"。如果你的需求符合这句话,SSE 就是比 WebSocket 更轻、更省心的答案。


十、参考资料

相关推荐
upala1 小时前
【仓颉语言入门 · 第二篇 第 9 课】
后端
执子念的飞鱼1 小时前
纯前端预览 275 种文件格式是怎么做到的
前端·vue.js·react.js
Rain的Java大神之路1 小时前
别再死磕 MySQL 了:向量数据库与 RAG 生产落地实录(Java 工程师专场)
java·后端·面试
雨中飘荡的记忆2 小时前
SDD × Claude Code:先写清楚,再让 AI 写代码
后端
Thneonl2 小时前
主从延迟 0 秒的谎言:SBM 的三个盲区
后端·架构
ly76892 小时前
Spring Boot 集成 PostgreSQL 的连接池调优:HikariCP、prepared statement 与服务器资源边界
spring boot·postgresql·hikaricp·连接池调优
孙浩君2 小时前
0行手写代码,我用AI做了一个打工人模拟器(含3D霓虹城市漫游)
前端·后端
羑悻2 小时前
超越Bridge:全面掌握Docker网络隔离与通信的底层逻辑!
后端
他日若遂凌云志2 小时前
深入剖析 ET8 框架的 ETTask 机制:await 的挂起、唤醒、异常与零 GC
后端