Spring Boot SSE + Nginx 配置:解决 EventSource 不实时返回、连接超时、流式响应被缓冲问题

一、背景

最近在项目中接入了 SSE,也就是 Server-Sent Events。

业务场景是:后端需要持续向前端推送数据,前端页面要实时接收并展示。例如:

text 复制代码
1. AI 对话的打字机效果
2. 实时任务进度

项目技术栈如下:

text 复制代码
前端:Vue3
后端:JDK 21 + Spring Boot
代理:Nginx

一开始本地调试时,SSE 是正常的。

后端每推送一条消息,前端就能立刻收到。但是部署到线上后,经过 Nginx 代理,问题出现了:

text 复制代码
本地直连后端:SSE 正常实时返回
线上经过 Nginx:SSE 不实时,甚至出现超时异常

这篇文章记录一下完整排查过程。


二、SSE 接口设计

假设这里有两个 SSE 相关的接口。

text 复制代码
GET  /api/sse/connect?clientId=demo    创建 SSE 连接
POST /api/sse/push?clientId=demo       向客户端推送消息

第一个接口负责创建 SSE 长连接。

第二个接口用于向指定客户端推送消息。


三、后端:创建 SSE 连接

Spring Boot 中可以使用 SseEmitter 实现 SSE。

下面是一个简化版示例。

java 复制代码
@RestController
@RequestMapping("/api/sse")
public class SseController {

    private final Map<String, SseEmitter> clients = new ConcurrentHashMap<>();

    @GetMapping("/connect")
    public SseEmitter connect(@RequestParam String clientId) {
		// 设置 10 分钟超时,避免默认超时时间过短
	    SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);

        clients.put(clientId, emitter);

        emitter.onCompletion(() -> clients.remove(clientId));
        emitter.onTimeout(() -> clients.remove(clientId));
        emitter.onError(e -> clients.remove(clientId));

        try {
            emitter.send(SseEmitter.event()
                    .name("connect")
                    .data("SSE 连接成功"));
        } catch (IOException e) {
            clients.remove(clientId);
        }

        return emitter;
    }

    @PostMapping("/push")
    public String push(@RequestParam String clientId,
                       @RequestParam(defaultValue = "hello sse") String message) throws IOException {
        SseEmitter emitter = clients.get(clientId);

        if (emitter == null) {
            return "客户端未连接";
        }

        emitter.send(SseEmitter.event()
                .name("message")
                .data(message));

        return "推送成功";
    }
}

这段代码主要做了几件事:

text 复制代码
1. 前端访问 /api/sse/connect 后,后端创建 SseEmitter
2. 使用 clientId 保存当前客户端连接
3. 后续通过 /api/sse/push 向指定客户端推送消息
4. 连接完成、超时、异常时,从 Map 中移除连接

这里有一个比较重要的点:

java 复制代码
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);

我在代码里已经显式设置了 SSE 连接的超时时间,也就是 10 分钟。

所以后面看到超时异常时,我第一反应不是直接怀疑业务代码写错了,而是先思考:既然 Java 代码里已经设置了较长超时时间,为什么线上还是会提前超时?

这也是后面排查 Nginx 的关键入口。


四、SSE 响应格式说明

SSE 的响应类型是:

http 复制代码
Content-Type: text/event-stream

如果使用 Spring 的 SseEmitter,框架会帮我们处理大部分事件格式。

如果自己手动写响应流,格式通常类似这样:

text 复制代码
event: message
data: hello sse

注意最后有一个空行。

SSE 每条消息之间需要用空行分隔,否则前端可能不会立即触发消息回调。


五、前端:Vue3 接收 SSE 消息

Vue3 前端可以直接使用浏览器原生的 EventSource

vue 复制代码
import { ref, onBeforeUnmount } from 'vue'

const clientId = 'demo'
const messages = ref([])
let eventSource = null

const connectSse = () => {
  eventSource = new EventSource(`/api/sse/connect?clientId=${clientId}`)

  eventSource.addEventListener('connect', (event) => {
    console.log('连接成功:', event.data)
    messages.value.push(event.data)
  })

  eventSource.addEventListener('message', (event) => {
    console.log('收到消息:', event.data)
    messages.value.push(event.data)
  })

  eventSource.onerror = (error) => {
    console.error('SSE 连接异常:', error)
  }
}

const closeSse = () => {
  if (eventSource) {
    eventSource.close()
    eventSource = null
  }
}

onBeforeUnmount(() => {
  closeSse()
})
</script>

<template>
  <button @click="connectSse">连接 SSE</button>
  <button @click="closeSse">关闭 SSE</button>

  <div v-for="item in messages" :key="item">
    {{ item }}
  </div>
</template>

这里也要注意一点:

text 复制代码
页面销毁时,一定要调用 eventSource.close()

否则用户频繁进入页面、退出页面,可能导致后端保留大量无效 SSE 连接。


六、本地测试:直连后端是正常的

本地启动 Spring Boot 服务后,先建立 SSE 连接:

text 复制代码
GET http://localhost:8099/api/sse/connect?clientId=demo

然后通过接口模拟推送消息:

bash 复制代码
curl -X POST "http://localhost:8099/api/sse/push?clientId=demo&message=hello"

本地测试时,前端页面可以实时收到消息。

也就是说,本地链路是正常的:

text 复制代码
Vue3 EventSource
        ↓
Spring Boot SseEmitter

此时没有经过 Nginx。

所以可以初步说明:

text 复制代码
1. 前端 EventSource 基本没问题
2. 后端 SseEmitter 基本没问题
3. 本地直连时,SSE 可以实时推送

但是部署到线上后,链路变成了:

text 复制代码
前端页面
   ↓
Nginx
   ↓
Spring Boot

问题也正是在这个链路中出现的。


七、线上问题:后端出现 AsyncRequestTimeoutException

线上运行一段时间后,后端日志出现了下面这段异常:

text 复制代码
2026-06-12 14:35:07.674 [http-nio-8099-exec-7] ERROR c.xxx.xxx.AppGlobalExceptionHandler - 系统异常
org.springframework.web.context.request.async.AsyncRequestTimeoutException: null
	at org.springframework.web.context.request.async.TimeoutDeferredResultProcessingInterceptor.handleTimeout(TimeoutDeferredResultProcessingInterceptor.java:42)
	at org.springframework.web.context.request.async.DeferredResultInterceptorChain.triggerAfterTimeout(DeferredResultInterceptorChain.java:81)
	at org.springframework.web.context.request.async.WebAsyncManager.lambda$startDeferredResultProcessing$5(WebAsyncManager.java:430)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.onTimeout(StandardServletAsyncWebRequest.java:149)
	at org.apache.catalina.core.AsyncListenerWrapper.fireOnTimeout(AsyncListenerWrapper.java:44)
	at org.apache.catalina.core.AsyncContextImpl.timeout(AsyncContextImpl.java:136)
	at org.apache.catalina.connector.CoyoteAdapter.asyncDispatch(CoyoteAdapter.java:135)
	at org.apache.coyote.AbstractProcessor.dispatch(AbstractProcessor.java:243)
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:57)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1744)
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
	at java.base/java.lang.Thread.run(Thread.java:1583)
2026-06-12 14:35:07.674 [http-nio-8099-exec-7] WARN  c.xxx.xxx.AppGlobalExceptionHandler - 检测到SSE请求异常,跳过Result返回,避免再次写入text/event-stream响应体

核心异常是:

text 复制代码
org.springframework.web.context.request.async.AsyncRequestTimeoutException

从名字可以看出,这是 Spring MVC 异步请求超时异常。

但是看到这个异常时,不能马上下结论说:一定是 Java 代码写错了。

因为我的 SSE 代码里已经显式设置了超时时间:

java 复制代码
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);

并且也注册了连接释放逻辑:

java 复制代码
emitter.onCompletion(() -> clients.remove(clientId));
emitter.onTimeout(() -> clients.remove(clientId));
emitter.onError(e -> clients.remove(clientId));

同时,本地直连 Spring Boot 时,SSE 是可以正常实时返回的。

于是脑袋里就有一个大大的问号:

text 复制代码
为什么本地直连不会超时,线上经过 Nginx 后会超时?
为什么后端已经 send 了数据,前端却没有实时收到?

八、关键转折:为什么开始怀疑 Nginx

到这里,排查结果是:

text 复制代码
1. 后端代码里已经给 SseEmitter 设置了 10 分钟超时
2. onCompletion、onTimeout、onError 都做了连接释放
3. 前端页面销毁时也调用了 eventSource.close()
4. 本地直连 Spring Boot 时,SSE 正常实时返回
5. 只有线上经过 Nginx 后,才出现不实时或超时

于是我去查了一下 SSE 经过 Nginx 的相关问题,发现一个很关键的点:

text 复制代码
Nginx 默认会对上游响应进行缓冲。

普通 HTTP 接口通常是一次性返回 JSON,这种缓冲一般没有明显问题。

但 SSE 不是普通接口。

普通接口的请求过程是:

text 复制代码
客户端请求
后端处理
后端一次性返回 JSON
请求结束

而 SSE 的请求过程是:

text 复制代码
客户端建立连接
后端保持连接
后端持续写入事件
客户端持续接收
连接长时间不结束

如果 Nginx 把后端响应先缓冲起来,就会出现这种情况:

text 复制代码
后端已经 emitter.send() 了
Nginx 没有立刻转发给浏览器
前端 EventSource 收不到实时消息
等缓冲区满足条件或请求结束后,数据才一次性返回

这就和线上现象完全对上了:

text 复制代码
本地直连:实时返回
经过 Nginx:不实时,甚至超时

九、原来的 Nginx 配置

一开始,Nginx 只是普通接口代理配置,大概类似这样:

nginx 复制代码
location /api/ {
    proxy_pass http://springboot_server;

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

这类配置对普通 REST 接口是没问题的。

但是对 SSE 来说,它缺少几个关键配置:

text 复制代码
1. 没有关闭 proxy_buffering
2. 没有针对 SSE 增加 proxy_read_timeout
3. 没有给 SSE 单独配置 location

最终表现出来就是:

text 复制代码
1. 前端 EventSource 请求一直 pending
2. 后端已经发送数据,但前端不能实时收到
3. 数据最后一次性返回
4. 一段时间后后端出现 AsyncRequestTimeoutException

十、Nginx 层可能看到的日志

如果继续查看 Nginx 的 error.log,在长连接超时场景下,可能会看到类似日志:

text 复制代码
upstream timed out (110: Connection timed out) while reading upstream

这个通常表示 Nginx 在读取上游服务响应时超时。

如果客户端刷新页面、关闭页面,或者 EventSource 主动断开,也可能看到类似:

text 复制代码
client prematurely closed connection while reading upstream

第二种不一定都是服务端问题。

用户关闭页面、刷新页面、浏览器断开连接,也可能触发类似日志。

所以排查 SSE 问题时,不能只看一条日志,要结合链路判断:

text 复制代码
1. 本地直连是否正常
2. 经过 Nginx 是否不实时
3. 前端 Network 是否一直 pending
4. 后端是否出现异步请求超时
5. Nginx 是否存在 upstream timeout

十一、解决方案:给 SSE 单独配置 Nginx location

最终,我没有直接修改所有 /api/ 接口,而是给 SSE 单独加了一个 location。

修改后的 Nginx 配置如下:

nginx 复制代码
location /api/sse/ {
    proxy_pass http://springboot_server;

    # SSE 是长连接场景,建议使用 HTTP/1.1
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    # 关键:关闭代理缓冲,避免流式响应被 Nginx 攒起来
    proxy_buffering off;

    # SSE 是实时事件流,不走缓存
    proxy_cache off;

    # 增加读取超时时间,避免长时间无消息时连接被断开
    proxy_read_timeout 3600s;

    # 透传必要请求头
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

这里最关键的是:

nginx 复制代码
proxy_buffering off;

它解决的是:

text 复制代码
后端已经推送数据,但 Nginx 没有立刻转发给前端。

关闭缓冲后,Nginx 会尽量把后端返回的数据实时转发给浏览器。

另一个重要配置是:

nginx 复制代码
proxy_read_timeout 3600s;

它解决的是:

text 复制代码
SSE 长连接长时间无消息时,被 Nginx 提前断开。

十二、几个关键配置说明

1. proxy_buffering off

nginx 复制代码
proxy_buffering off;

这是 SSE 经过 Nginx 时最重要的配置。

如果不关闭代理缓冲,Nginx 可能会先把后端响应缓存起来,等缓冲区满足条件或者请求结束后,再统一返回给客户端。

2. proxy_cache off

nginx 复制代码
proxy_cache off;

SSE 是实时事件流,不应该被缓存。

如果当前 Nginx 没有启用缓存,这项不是绝对必须,但建议针对 SSE 路径显式关闭。

3. proxy_read_timeout

nginx 复制代码
proxy_read_timeout 3600s;

SSE 连接可能会持续很久。

如果后端一段时间没有新消息,Nginx 可能会因为读取上游响应超时而断开连接。

所以这里需要设置一个较大的超时时间。

4. proxy_http_version

nginx 复制代码
proxy_http_version 1.1;

SSE 是一个持续打开的 HTTP 连接,代理到后端时建议使用 HTTP/1.1。

5. proxy_set_header Connection

nginx 复制代码
proxy_set_header Connection "";

这行用于清理客户端传入的 Connection 头,避免不合适的连接头影响后端长连接。


十三、后端建议增加心跳

即使 Nginx 配置了较长超时时间,后端也建议增加心跳。

如果业务消息不是持续产生的,SSE 连接就可能长时间没有任何数据传输。

可以定期发送一个注释事件:

java 复制代码
emitter.send(SseEmitter.event()
        .comment("heartbeat"));

或者发送一个简单的 ping 事件:

java 复制代码
emitter.send(SseEmitter.event()
        .name("ping")
        .data("ping"));

心跳的作用是:

text 复制代码
1. 避免连接长时间完全空闲
2. 帮助前端判断连接是否还活着
3. 降低代理层因为空闲而断开连接的概率

心跳频率不要太高,一般十几秒到几十秒一次即可,具体根据业务调整。


十四、修改后如何验证

修改 Nginx 配置后,先检查配置是否正确:

bash 复制代码
nginx -t

确认没有问题后,重新加载 Nginx:

bash 复制代码
nginx -s reload

或者:

bash 复制代码
systemctl reload nginx

然后可以使用 curl -N 测试 SSE 是否实时返回:

bash 复制代码
curl -N "https://your-domain.com/api/sse/connect?clientId=demo"

另开一个终端推送消息:

bash 复制代码
curl -X POST "https://your-domain.com/api/sse/push?clientId=demo&message=hello"

如果 Nginx 配置生效,第一个终端应该能立即看到服务端推送的消息。

如果还是不实时,可以继续检查:

text 复制代码
1. 请求是否真的命中了 /api/sse/ 这个 location
2. proxy_buffering off 是否写在正确位置
3. 是否还有其他网关、负载均衡、CDN 在中间缓冲
4. 后端是否真的及时 send 数据
5. 浏览器 EventSource 是否正常连接

十五、最终解决方案:

修改nginx配置

nginx 复制代码
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;

其中最关键的是:

nginx 复制代码
proxy_buffering off;

这次问题可以总结成一句话:

text 复制代码
后端已经在流式输出,但 Nginx 默认可能把响应攒起来了。

相关推荐
難釋懷2 小时前
Nginx获取客户端真实IP
服务器·前端·nginx
PinkSun3 小时前
Spring AI RAG踩坑:我骂了半年的FilterExpression,其实是背锅侠
后端·ai编程
by————组态3 小时前
Ricon组态系统 - 新一代Web可视化组态平台
前端·后端·物联网·架构·组态·组态软件
云技纵横3 小时前
ThreadLocal 内存泄漏:你的应用正在悄悄 OOM
后端
小撒的私房菜3 小时前
Multi-Agent 里谁来指挥?我用一个调度员,让多个 Agent 开始协作
人工智能·后端·agent
范什么特西3 小时前
Spring boot细节
java·spring boot·后端
苍何3 小时前
高考填志愿,我做了个 Skill,300 个 Agent 同时查公司
后端
yspwf3 小时前
NestJS 配置管理完整方案
后端·架构·node.js
雪隐3 小时前
个人电脑玩AI-03让5060 Ti给你打工——paddleOCR
人工智能·后端