在基于 Spring Boot 的服务端推送场景中,我们使用 SSE(Server-Sent Events)向前端实时推送数据。上线后却发现客户端迟迟收不到消息,甚至连接建立后立即断开。本文完整记录了从问题发现到定位再到修复的全过程,涉及 ResponseWrapper 缓冲、TraceIdFilter 拦截、Nginx 代理缓冲、Tomcat 输出缓冲等多层"关卡",希望对遇到类似 SSE 问题的同学有所帮助。
目录
1. 问题发现
1.1 异常现象
SSE 功能开发完成部署后,发现以下异常现象:
- 客户端连接后收不到消息:浏览器
EventSource成功建立连接,但始终收不到任何 SSE 事件 - 消息被"吞掉":服务端日志显示消息已成功发送,但客户端
onmessage回调从未触发 - 连接偶发性断开:部分场景下 SSE 连接建立后很快超时断开
- 只有周期性测试消息能到达:定时任务推送的测试消息偶尔能收到,但实时业务数据始终不到达
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 路径
/sse和Accept: 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;
}
}
废弃原因:
- 响应头在 Controller 中就可以设置,不需要额外的拦截器
- 拦截器的执行时机在 Filter 之后,设置头可能被后续处理覆盖
- 增加了不必要的组件复杂度
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 关键诊断手段
-
curl -N 直连测试:绕过 Nginx 和浏览器,直接验证 Tomcat 端的 SSE 行为
curl -N -H "Accept: text/event-stream" http://localhost:port/path/to/sse/subscribe/key -
检查 Response 实际类型:在 Controller 中打印
response.getClass().getName(),确认是否有被意外包装log.info("Response type: {}", response.getClass().getName()); // 如果输出包含 "ContentCachingResponseWrapper" 或 "ResponseWrapper",说明被缓冲了 -
检查 Filter 链:梳理所有
@WebFilter和@Component(Filter.class)的实现,特别关注对 response 做了包装的 Filter -
对比直连 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 响应
客户端
- 浏览器端使用标准
EventSourceAPI,自动处理重连和事件解析 - 监听
error事件,在连接异常时进行重连或提示 - 注意跨域配置,SSE 请求受浏览器同源策略约束
监控
- SSE 连接数监控:暴露当前连接数和 key 数的监控接口
- 消息发送成功率监控:统计
emitter.send()成功和失败次数 - 连接生命周期日志:记录连接创建、断开、超时等关键事件
一句话总结:SSE 消息下发异常的核心矛盾是——SSE 需要流式实时传输,但应用服务器各层的默认行为都是缓冲批量传输。解决思路就是:在每一层识别 SSE 请求,关闭缓冲,让数据直通。