Spring Boot SSE 消息下发异常排查与修复实录

3 阅读14分钟

在基于 Spring Boot 的服务端推送场景中,我们使用 SSE(Server-Sent Events)向前端实时推送数据。上线后却发现客户端迟迟收不到消息,甚至连接建立后立即断开。本文完整记录了从问题发现到定位再到修复的全过程,涉及 ResponseWrapper 缓冲、TraceIdFilter 拦截、Nginx 代理缓冲、Tomcat 输出缓冲等多层"关卡",希望对遇到类似 SSE 问题的同学有所帮助。


目录


1. 问题发现

1.1 异常现象

SSE 功能开发完成部署后,发现以下异常现象:

  1. 客户端连接后收不到消息:浏览器 EventSource 成功建立连接,但始终收不到任何 SSE 事件
  2. 消息被"吞掉":服务端日志显示消息已成功发送,但客户端 onmessage 回调从未触发
  3. 连接偶发性断开:部分场景下 SSE 连接建立后很快超时断开
  4. 只有周期性测试消息能到达:定时任务推送的测试消息偶尔能收到,但实时业务数据始终不到达

1.2 问题复现

使用 curl 命令即可复现:

# 正常情况下应立即收到 welcome 消息,然后持续收到实时数据
# 异常情况下:连接建立后无任何输出,长时间挂起
curl -N -H "Accept: text/event-stream" http://localhost:8537/app/sse/subscribe/live/111

-N 参数禁用 curl 的输出缓冲,确保数据到达后立即打印,排除客户端缓冲干扰。


2. SSE 核心代码

2.1 SSE 订阅入口

@Slf4j
@RestController
@RequestMapping("/sse")
public class SseController {

    @Autowired
    private SsePushManager ssePushManager;

    @GetMapping(value = "/subscribe/live/{liveId}", produces = "text/event-stream")
    public SseEmitter subscribeLive(@PathVariable Long liveId, HttpServletResponse response) {
        // 设置SSE必要的响应头
        response.setContentType("text/event-stream");
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Cache-Control", "no-cache");
        response.setHeader("Connection", "keep-alive");
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("X-Accel-Buffering", "no"); // 防止 Nginx 缓冲 SSE 响应
        response.setBufferSize(0);  // 禁用 Tomcat 输出缓冲

        String key = "live_" + liveId;
        return ssePushManager.createSseEmitter(key);
    }
}

2.2 SSE 连接管理器

@Slf4j
@Component
public class SsePushManager {

    private static final ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>> sseEmitterMap
            = new ConcurrentHashMap<>();

    private static final ExecutorService pushExecutor = Executors.newFixedThreadPool(
            Math.min(Runtime.getRuntime().availableProcessors() + 1, 16),
            r -> {
                Thread t = new Thread(r, "SSE-Push-Worker");
                t.setDaemon(true);
                return t;
            });

    private static final long DEFAULT_TIMEOUT = 5 * 60 * 1000L;

    public SseEmitter createSseEmitter(String key) {
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        sseEmitterMap.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(emitter);

        emitter.onCompletion(() -> removeEmitter(key, emitter));
        emitter.onTimeout(() -> removeEmitter(key, emitter));
        emitter.onError(e -> removeEmitter(key, emitter));

        // 异步延迟发送 welcome 消息,确保连接已完全建立
        pushExecutor.execute(() -> {
            try {
                Thread.sleep(50);
                emitter.send(SseEmitter.event().name("message").data("welcome"));
            } catch (Exception e) {
                log.error("SSE welcome消息发送失败 key={}", key, e);
            }
        });

        return emitter;
    }

    public void pushToClient(String key, String message) {
        List<SseEmitter> emitters = sseEmitterMap.get(key);
        if (emitters == null || emitters.isEmpty()) return;

        pushExecutor.execute(() -> {
            for (SseEmitter emitter : emitters) {
                try {
                    emitter.send(SseEmitter.event().name("message").data(message));
                } catch (IOException e) {
                    removeEmitter(key, emitter);
                }
            }
        });
    }
}

3. 逐层排查

SSE 消息下发异常的根因不是单一问题,而是多层缓冲机制叠加导致的。从请求进入服务器到消息到达浏览器,要经过 Servlet Filter -> Spring Interceptor -> Controller -> Tomcat -> Nginx -> 浏览器等多道"关卡",每一层都可能有缓冲行为阻断 SSE 流式传输。

以下是逐层排查的完整过程:

3.1 第一层:ResponseWrapper 响应缓冲

现象:SSE 连接建立后,服务端日志显示 emitter.send() 调用成功,但客户端始终收不到消息。

排查方法:在 ResponseFilter.doFilter() 中加日志,确认 SSE 请求是否被 ResponseWrapper 包装。

根因:项目中有一个 ResponseFilter,对所有匹配 URL 模式的请求(包括 SSE 路径)都用 ResponseWrapper 进行包装:

@WebFilter(filterName = "accessLog", urlPatterns = {"/app/**", "/rpc/**", "/admin/**",
    "/insurance/**", "/live/**", "/sse/**"})  // <-- SSE 路径也在其中!
public class ResponseFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain filterChain) throws IOException, ServletException {
        // 所有请求都被 ResponseWrapper 包装
        ResponseWrapper wrapper = new ResponseWrapper((HttpServletResponse) response);
        filterChain.doFilter(request, wrapper);
        // 整个响应完成后才一次性写回!
        String result = wrapper.getResponseData(response.getCharacterEncoding());
        response.getOutputStream().write(result.getBytes());
    }
}

ResponseWrapper 内部使用 ByteArrayOutputStream 缓冲整个响应:

public class ResponseWrapper extends HttpServletResponseWrapper {
    private ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    @Override
    public ServletOutputStream getOutputStream() {
        return new WapperedOutputStream(buffer);  // 输出写入 buffer,而非真正的网络流
    }
}

核心矛盾:SSE 本质是流式传输,消息需要立即发送到客户端。但 ResponseWrapper 把所有输出截流到 ByteArrayOutputStream,等整个请求处理完毕后才一次性写回。对于 SSE 这种长连接场景,doFilter() 永远不会"处理完毕",消息就被永远锁在 buffer 中。

3.2 第二层:TraceIdFilter (ContentCachingResponseWrapper) 缓冲

现象:修复了 ResponseFilter 之后,部分 SSE 消息仍然无法到达客户端。

排查方法:在 Controller 中打印 response 的实际类型,发现输出流被 ContentCachingResponseWrapper 包装。

根因:项目引入了日志链路追踪组件,其 TraceIdFilter 会将 HttpServletResponse 包装为 ContentCachingResponseWrapper。这个 Spring 提供的包装类同样会缓冲所有响应内容,直到请求结束时才写入底层输出流,用于日志记录请求体和响应体。

Request -> TraceIdFilter -> ContentCachingResponseWrapper -> ... -> SseController
                                                                    |
                                                              emitter.send()
                                                                    |
                                                         ContentCachingResponseWrapper
                                                         (缓冲所有输出,不立即写入)

ContentCachingResponseWrapper 的 javadoc 明确说明:

This wrapper buffers all content written to the output stream... and makes it available as a byte array.

对于普通 REST 接口,这是日志链路追踪的好帮手;但对于 SSE 流式推送,它就是消息的"黑洞"。

3.3 第三层:Nginx 反向代理缓冲

现象:本地直接访问 Tomcat 端口 SSE 正常,但通过 Nginx 代理访问时消息延迟或丢失。

排查方法:对比直连和代理两种方式的响应行为。

根因:Nginx 默认会对代理的后端响应进行缓冲(proxy_buffering on)。当后端发送 SSE 数据时,Nginx 不会立即转发给客户端,而是等缓冲区填满或连接关闭后才批量发送。对于 SSE 这种需要实时传输的场景,默认行为会导致消息严重延迟甚至积压。

Nginx 缓冲机制:

后端 -> [Nginx 缓冲区] -> 客户端
         |
   等缓冲区满或连接关闭才转发

3.4 第四层:Tomcat 输出缓冲

现象:即使绕过了 Filter 和 Nginx,消息仍可能有短暂延迟。

排查方法:对比 response.setBufferSize(0) 前后的消息到达时间。

根因:Tomcat 的 HttpServletResponse 默认有输出缓冲区(默认 8KB)。SseEmitter.send() 写入的数据可能先停留在 Tomcat 的缓冲区中,未立即 flush 到网络。

3.5 第五层:SSE 事件格式不规范

现象:部分消息客户端收到后无法被 EventSource 正确解析。

排查方法:使用 curl 观察原始响应数据格式。

根因:最初的 SSE Manager 直接调用 emitter.send(data) 发送原始数据,没有按 SSE 规范构建事件格式。SSE 协议要求消息遵循以下格式:

event: message\n
data: content\n
\n

直接发送原始字符串可能导致客户端无法正确解析事件。

3.6 第六层:Welcome 消息发送时机问题

现象:偶尔出现 SSE 连接建立后第一条消息发送失败。

排查方法:观察 SseEmitter 创建后立即发送 welcome 消息的异常日志。

根因:在 createSseEmitter() 方法中同步发送 welcome 消息时,SSE 连接可能还未完全建立(响应头未提交),导致 emitter.send() 抛出异常。


4. 最终修复方案

4.1 修复一:ResponseFilter 排除 SSE 请求

ResponseFilter.doFilter() 中添加 SSE 请求识别逻辑,对 SSE 请求跳过 ResponseWrapper 包装,直接透传:

@Override
public void doFilter(ServletRequest request, ServletResponse response,
        FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) request;
    HttpServletResponse res = (HttpServletResponse) response;

    // 明确排除SSE请求:检查URI路径或Accept请求头
    String acceptHeader = req.getHeader("Accept");
    if (req.getRequestURI().contains("/sse")
            || (acceptHeader != null && acceptHeader.contains("text/event-stream"))) {
        log.info("SSE请求直接通过: {}", req.getRequestURI());
        filterChain.doFilter(request, response);
        return;
    }

    // 其他请求正常走 ResponseWrapper 缓冲
    ResponseWrapper wrapper = new ResponseWrapper(res);
    filterChain.doFilter(request, wrapper);
    String result = wrapper.getResponseData(response.getCharacterEncoding());
    response.getOutputStream().write(result.getBytes());
}

关键点

  • 通过 URI 路径 /sseAccept: text/event-stream 请求头双重判断
  • SSE 请求直接调用 filterChain.doFilter(request, response) 传递原始 response,不做任何包装
  • 后续又将 @WebFilter 的 URL 模式中移除 SSE 路径,从源头避免 SSE 请求进入该 Filter

4.2 修复二:TraceIdFilter 排除 SSE 路径

在配置文件中添加日志追踪组件的路径排除配置:

# TraceIdFilter 排除 SSE 路径,防止 ContentCachingResponseWrapper 缓冲导致 SSE 消息无法发送
# 使用 /**/sse/** 因为 request.getRequestURI() 可能包含 servlet path 前缀
xxxx.logtrace.config.exclusions=/**/sse/**

关键点

  • 排除路径使用 /**/sse/** 而非 /sse/**,因为配置了 spring.mvc.servlet.path 时,实际请求 URI 会包含前缀
  • 注释清楚说明了排除原因,避免后续开发者误删此配置

4.3 修复三:禁用 Nginx 缓冲

在 Controller 中为 SSE 响应添加 X-Accel-Buffering: no 响应头:

response.setHeader("X-Accel-Buffering", "no"); // 防止 Nginx 缓冲 SSE 响应

原理X-Accel-Buffering 是 Nginx 识别的特殊响应头,设置为 no 时,Nginx 会对该响应禁用 proxy_buffering,立即将后端返回的数据转发给客户端,而不等待缓冲区满。

也可以在 Nginx 配置文件中针对 SSE 路径单独设置 proxy_buffering off;,但通过响应头控制更加灵活,不需要修改 Nginx 配置。

4.4 修复四:禁用 Tomcat 输出缓冲

在 Controller 中设置 response.setBufferSize(0)

response.setBufferSize(0);  // 禁用 Tomcat 输出缓冲,SSE 消息立即 flush

原理setBufferSize(0) 告知 Servlet 容器不要对输出进行缓冲,每次写入后立即 flush 到网络层。

4.5 修复五:规范 SSE 事件发送格式

使用 SseEmitter.event() 构建标准 SSE 事件格式:

// 修复前(不规范的发送方式)
emitter.send(data);  // 直接发送原始数据

// 修复后(标准 SSE 事件格式)
emitter.send(SseEmitter.event()
        .name("message")   // event: message
        .data(message));   // data: message内容

SSE 协议要求每条消息必须包含 data: 前缀,以空行 \n\n 结尾。SseEmitter.event() 会自动按照规范格式化输出:

event:message
data:actual message content

4.6 修复六:异步延迟发送 Welcome 消息

将 welcome 消息的发送改为异步延迟方式,确保 SSE 连接完全建立后再发送:

// 修复前(同步发送,连接可能未完全建立)
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitter.send("welcome");  // 可能因连接未就绪而失败

// 修复后(异步延迟发送)
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
// ... 注册回调 ...

pushExecutor.execute(() -> {
    try {
        Thread.sleep(50);  // 确保响应头已提交
        emitter.send(SseEmitter.event().name("message").data("welcome"));
    } catch (Exception e) {
        log.error("SSE welcome消息发送失败 key={}", key, e);
    }
});

关键点

  • 使用独立线程池异步发送,不阻塞主线程
  • 50ms 延迟确保 Tomcat 已完成响应头提交和连接建立
  • 失败时仅记录日志,不影响 SseEmitter 的创建和返回

5. 踩过的坑与废弃方案

在排查过程中,有一些尝试过的方案最终被废弃,记录下来以供参考:

5.1 NoResetResponseWrapper(已废弃)

思路:创建一个禁止 reset()resetBuffer() 的 ResponseWrapper,防止其他组件清除 SSE 响应头。

public class NoResetResponseWrapper extends HttpServletResponseWrapper {
    @Override
    public void reset() {
        // 禁止重置,避免头被清除
    }
    @Override
    public void resetBuffer() {
        // 禁止重置缓冲区
    }
}

废弃原因:这个方案是治标不治本——即使保护了响应头不被重置,NoResetResponseWrapper 仍然是对原始 response 的包装,无法解决底层缓冲问题。最终选择了更直接的方案:SSE 请求完全绕过 ResponseWrapper

5.2 SseResponseInterceptor(已废弃)

思路:通过 Spring HandlerInterceptor 在请求进入 Controller 之前设置 SSE 响应头。

@Component
public class SseResponseInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request,
            HttpServletResponse response, Object handler) {
        if (request.getRequestURI().startsWith("/sse/")) {
            response.setContentType("text/event-stream;charset=UTF-8");
            response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
            response.setHeader("X-Accel-Buffering", "no");
        }
        return true;
    }
}

废弃原因

  1. 响应头在 Controller 中就可以设置,不需要额外的拦截器
  2. 拦截器的执行时机在 Filter 之后,设置头可能被后续处理覆盖
  3. 增加了不必要的组件复杂度

5.3 WebMvcConfig(已废弃)

配套 SseResponseInterceptor 注册的 Spring MVC 配置类,随拦截器一起废弃。

教训:排查 SSE 问题时,容易陷入"加一层包装/拦截"的思维惯性。但 SSE 的核心需求是减少中间层,让数据从 Controller 直达网络层。每多一层包装,就多一层缓冲风险。


6. 排查方法论总结

回顾整个排查过程,可以总结出一套 SSE 问题排查的方法论:

6.1 自底向上排查法

当 SSE 消息无法到达客户端时,按以下顺序逐层排查:

浏览器 EventSource
    ^
Nginx 反向代理(proxy_buffering)
    ^
Tomcat 输出缓冲(response.bufferSize)
    ^
Spring MVC 异步配置(asyncTimeout)
    ^
Servlet Filter 链(ResponseWrapper / ContentCachingResponseWrapper)
    ^
Controller(响应头设置、SseEmitter 创建)
    ^
消息源(是否真的调用了 send)

6.2 关键诊断手段

  1. curl -N 直连测试:绕过 Nginx 和浏览器,直接验证 Tomcat 端的 SSE 行为

    curl -N -H "Accept: text/event-stream" http://localhost:port/path/to/sse/subscribe/key
    
  2. 检查 Response 实际类型:在 Controller 中打印 response.getClass().getName(),确认是否有被意外包装

    log.info("Response type: {}", response.getClass().getName());
    // 如果输出包含 "ContentCachingResponseWrapper" 或 "ResponseWrapper",说明被缓冲了
    
  3. 检查 Filter 链:梳理所有 @WebFilter@Component(Filter.class) 的实现,特别关注对 response 做了包装的 Filter

  4. 对比直连 vs 代理:分别通过直连 Tomcat 端口和 Nginx 代理访问,判断问题出在应用层还是代理层

6.3 SSE 请求的特征识别

在排查时,需要确保各层都能正确识别 SSE 请求。可靠的识别方式:

识别方式说明
URI 路径包含 /sse最简单直接,但依赖路径命名规范
Accept: text/event-stream 请求头HTTP 协议标准,更通用
produces = "text/event-stream"Spring MVC 注解级别声明
自定义请求头X-Request-Type: SSE,最可靠但需前端配合

建议至少使用 URI + Accept 双重判断,避免误判。


7. SSE 最佳实践清单

基于本次排查经验,总结 SSE 在 Spring Boot 项目中的最佳实践:

应用层

  • 所有 Servlet Filter 必须排除 SSE 请求,对 SSE 请求传递原始 response,不做任何 HttpServletResponseWrapper 包装
  • 日志追踪组件(如 TraceIdFilter)必须排除 SSE 路径,避免 ContentCachingResponseWrapper 缓冲
  • 设置 response.setBufferSize(0),禁用 Tomcat 输出缓冲
  • 使用 SseEmitter.event().name("message").data(data) 标准格式发送,而非直接 emitter.send(data)
  • 异步延迟发送首条消息,确保连接完全建立后再写入数据
  • SseEmitter 超时时间与 Spring MVC async timeout 协调,async timeout 必须 >= SseEmitter timeout
  • 连接管理使用 ConcurrentHashMap + CopyOnWriteArrayList,保证并发安全
  • emitter 异常回调中清理连接,避免内存泄漏

代理层

  • 设置 X-Accel-Buffering: no 响应头,禁用 Nginx 缓冲
  • Nginx 配置中 SSE 路径设置 proxy_buffering off;proxy_cache off;
  • Nginx 配置 proxy_read_timeout 大于 SSE 超时时间,避免 Nginx 提前断开
  • 如使用 CDN,确保 CDN 也不缓冲 SSE 响应

客户端

  • 浏览器端使用标准 EventSource API,自动处理重连和事件解析
  • 监听 error 事件,在连接异常时进行重连或提示
  • 注意跨域配置,SSE 请求受浏览器同源策略约束

监控

  • SSE 连接数监控:暴露当前连接数和 key 数的监控接口
  • 消息发送成功率监控:统计 emitter.send() 成功和失败次数
  • 连接生命周期日志:记录连接创建、断开、超时等关键事件

一句话总结:SSE 消息下发异常的核心矛盾是——SSE 需要流式实时传输,但应用服务器各层的默认行为都是缓冲批量传输。解决思路就是:在每一层识别 SSE 请求,关闭缓冲,让数据直通。