前言 :很多人第一次接触 text/event-stream 时会困惑:SSE 是不是一种新协议?为啥还要用 HTTP?为什么要心跳 ping?以及我在 Java 里为什么会遇到 No converter ... text/event-stream 这种报错?
本文用"尽量通俗"的方式讲清楚 SSE 的底层逻辑,并给出 Spring MVC 里"代理/转发上游 SSE 流"的实现思路与可执行代码示例。
1. SSE 和 HTTP 一样吗?是不是新协议?
结论:SSE 不是新协议,它仍然是 HTTP。
更准确一点的说法是:
- HTTP:负责"请求怎么发、响应怎么返回、连接什么时候结束、字节怎么传输"
- SSE :规定"当 HTTP 响应的
Content-Type是text/event-stream时,响应体的格式应该长什么样",以及如何把多个事件连续推送给客户端
所以 SSE 的核心理解是:
SSE = HTTP 响应体里的一种"事件流格式",让服务端可以在同一条连接上持续不断地推数据。
可以把它想成"长连接 + 事件消息"。
2. SSE 的请求一定是 GET 吗?
通常学习 SSE 时大家会看到 GET /xxx,因为浏览器原生 EventSource 只能 GET。
但协议层面并不强制必须 GET:
- 客户端只要能发起 HTTP 请求
- 服务端返回
Content-Type: text/event-stream - 响应体不立刻结束,而是持续写入
那么这个就符合 SSE 的使用方式。
3. SSE 的"长链接"到底是什么意思?
你可以把 SSE 理解为:
- 客户端发起请求
- 服务端立刻返回 HTTP 响应头(其中
Content-Type: text/event-stream) - 然后响应体不断追加数据(事件帧)
- 服务端不关闭连接,直到流程结束/超时/断开
这就是"HTTP 长连接"的体验:客户端在等待期间,数据一到就能看到(前提是要 flush(),以及中间代理不要缓冲)。
4. SSE 的数据长什么样?
SSE 采用文本协议,每个事件以空行 \n\n 作为分隔。常见字段如下:
data: ...事件数据(最重要)event: ...事件类型(可选)id: ...事件 id(可选)retry: ...断线重连建议(可选)
示例:
text
data: {"event":"task_started","task_id":"xxx"}
event: ping
data: {"event":"chunk","task_id":"xxx","text":"hello"}
上游实现侧通常是:
- 正常事件:
data: {json}\n\n - 心跳:
event: ping\n\n
5. 为什么要 ping(心跳)?
长连接会遇到各种"中间层":
- 负载均衡器/网关的 idle timeout
- Nginx 缓冲或超时策略
- 网络 NAT 的空闲映射回收
- 客户端自身超时
如果长时间没有写数据,有些链路会把连接当成"死连接"关掉。
所以服务端会定时写一个小事件(比如 event: ping\n\n),让连接保持"活着"。
6. Spring MVC 怎么实现流式代理 SSE(通用思路)
整体链路可以理解为:
客户端发起业务请求(要求 streaming 输出)
→ Java/Spring 服务作为 SSE 代理,向上游发起请求并获取 text/event-stream 响应
→ 服务端使用支持流式读取的 HTTP Client 读取上游 InputStream
→ 服务端逐块写回下游(OutputStream)并及时 flush()
→ 客户端按 data: / event: 解析
关键点在于:不要先缓存完整响应再返回,而要"边读边写",让客户端立刻收到增量事件。
7. 常见报错:No converter ... text/event-stream 是怎么来的?
报错常见类似:
No converter ... text/event-stream
这通常意味着:Controller 的返回值被 Spring MVC 当成"普通响应 body"走消息转换器链路(HttpMessageConverter),但 SSE 的流式输出不适合用普通序列化方式去处理。
通俗讲:让 Spring 做"序列化",但你真正需要的是"直接写响应体流"(并及时 flush())。
工程上更稳的做法是:
- streaming 分支里直接操作
HttpServletResponse.getOutputStream() - 设置 SSE 响应头
- 调用上游流转写,并
flush() - 让 Spring 不走 JSON converter
7.1 开发经验场景(示例)
在一次开发中,我要把"上游 SSE 事件流"转发给调用方。最开始为了封装,我让 Controller 返回一个流式封装对象,结果 Spring 报 No converter ... text/event-stream。根因是返回值走了普通消息转换器链路,无法把流式封装正确输出为 text/event-stream。
最终改成 streaming 分支直接写 HttpServletResponse.getOutputStream():先设置 SSE 响应头,再把上游 InputStream 边读边写回下游,并在写入过程中持续 flush()。这样 Spring 不再尝试做 JSON/body 转换,客户端也能立刻收到实时事件。
8. 可执行代码示例(Spring MVC:Java 透传上游 SSE)
下面这个示例是"核心思想复刻":Java 作为 SSE 代理,透传上游 SSE。
8.1 Controller:写 SSE 响应头 + 直接写 OutputStream
java
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.Map;
@RestController
public class SseProxyController {
private final SseUpstreamProxy upstreamProxy;
public SseProxyController(SseUpstreamProxy upstreamProxy) {
this.upstreamProxy = upstreamProxy;
}
@PostMapping("/sse-proxy")
public void proxy(@RequestBody Map<String, Object> body,
HttpServletResponse response) throws IOException {
response.setStatus(200);
response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("X-Accel-Buffering", "no"); // 有 Nginx 时非常有用
upstreamProxy.forwardAsSse(body, response.getOutputStream());
response.flushBuffer();
}
}
8.2 Service:JDK HttpClient 流式读取上游 InputStream,逐块写回下游
java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
@Service
public class SseUpstreamProxy {
private static final Logger logger = LoggerFactory.getLogger(SseUpstreamProxy.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
// 上游 SSE URL(示例,请替换成你的真实上游地址)
private final String upstreamUrl = "UPSTREAM_SSE_URL";
public void forwardAsSse(Map<String, Object> body, OutputStream downstream) {
long startNs = System.nanoTime();
try {
String json = objectMapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(upstreamUrl))
.timeout(Duration.ofMinutes(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<InputStream> upstreamResp = httpClient.send(
req,
HttpResponse.BodyHandlers.ofInputStream()
);
logger.info("upstream status={}", upstreamResp.statusCode());
// 非 2xx:也建议写入 SSE error,让前端统一处理
if (upstreamResp.statusCode() / 100 != 2) {
String error = "data: {\"event\":\"error\",\"message\":\"upstream http error\"}\n\n";
downstream.write(error.getBytes(StandardCharsets.UTF_8));
downstream.flush();
return;
}
byte[] buf = new byte[4096];
int n;
long totalBytes = 0;
while ((n = upstreamResp.body().read(buf)) != -1) {
downstream.write(buf, 0, n);
downstream.flush(); // 核心:让客户端尽快收到增量
totalBytes += n;
}
long totalMs = (System.nanoTime() - startNs) / 1_000_000;
logger.info("stream end totalBytes={} totalMs={}", totalBytes, totalMs);
} catch (Exception e) {
logger.warn("forwardAsSse exception: {}", e.getMessage());
try {
String error = "data: {\"event\":\"error\",\"message\":\"" + e.getMessage() + "\"}\n\n";
downstream.write(error.getBytes(StandardCharsets.UTF_8));
downstream.flush();
} catch (IOException ignored) {
}
}
}
}
8.3 curl 验证(建议加 -N 禁用缓冲)
bash
curl -N -X POST "http://127.0.0.1:PORT/sse-proxy" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "your question",
"stream": true
}'
如果你不开 -N,很多终端会缓冲输出,你会误以为"没有流式"。
9. 工程排障清单(最常见坑)
-
客户端看到的都是最后一刻才输出?
- 检查 Java 侧是否
flush() - 检查 Nginx/网关是否开启了 buffering(加
X-Accel-Buffering: no)
- 检查 Java 侧是否
-
Spring 报
No converter ... text/event-stream?- 避免 streaming 分支走 JSON converter / 消息转换
- streaming 分支用
HttpServletResponse.getOutputStream()直写
-
一会儿就断连?
- Spring async 超时是否过小
- 网关 idle timeout 是否过小
- 是否缺少
event: ping心跳