SSE 到底是什么?它和 HTTP 有什么关系?Java/Spring 怎么实现流式输出(可直接上手)

前言 :很多人第一次接触 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-Typetext/event-stream 时,响应体的格式应该长什么样",以及如何把多个事件连续推送给客户端

所以 SSE 的核心理解是:

SSE = HTTP 响应体里的一种"事件流格式",让服务端可以在同一条连接上持续不断地推数据。

可以把它想成"长连接 + 事件消息"。


2. SSE 的请求一定是 GET 吗?

通常学习 SSE 时大家会看到 GET /xxx,因为浏览器原生 EventSource 只能 GET。

但协议层面并不强制必须 GET:

  • 客户端只要能发起 HTTP 请求
  • 服务端返回 Content-Type: text/event-stream
  • 响应体不立刻结束,而是持续写入

那么这个就符合 SSE 的使用方式。


3. SSE 的"长链接"到底是什么意思?

你可以把 SSE 理解为:

  1. 客户端发起请求
  2. 服务端立刻返回 HTTP 响应头(其中 Content-Type: text/event-stream
  3. 然后响应体不断追加数据(事件帧)
  4. 服务端不关闭连接,直到流程结束/超时/断开

这就是"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. 工程排障清单(最常见坑)

  1. 客户端看到的都是最后一刻才输出?

    • 检查 Java 侧是否 flush()
    • 检查 Nginx/网关是否开启了 buffering(加 X-Accel-Buffering: no
  2. Spring 报 No converter ... text/event-stream

    • 避免 streaming 分支走 JSON converter / 消息转换
    • streaming 分支用 HttpServletResponse.getOutputStream() 直写
  3. 一会儿就断连?

    • Spring async 超时是否过小
    • 网关 idle timeout 是否过小
    • 是否缺少 event: ping 心跳
相关推荐
de_wizard2 小时前
Spring Boot 整合 Keycloak
java·spring boot·后端
Re_zero2 小时前
throws 还是 try-catch?Code Review 里被我打回最多的异常处理
java·代码规范
稽稽稽稽不如人2 小时前
《从零开始的java从入门到入土的学习生活——JavaWeb后端篇》Chapter19——JavaWeb后端篇学习记录——Spring事务管理、异常处理
java·学习·生活
皙然2 小时前
深度解析 “池化思想”:从设计模式到 Java 技术栈的落地与实践
java·开发语言·设计模式
云烟成雨TD2 小时前
Spring AI 1.x 系列【12】Advisors API:AI 交互拦截增强
java·人工智能·spring
渔民小镇2 小时前
告别 if-else 地狱 —— JSR380 参数验证在 ionet 中的应用
java·服务器·分布式·游戏
华科易迅2 小时前
Spring 单元测试
java·spring·单元测试
俄城杜小帅2 小时前
C++线程异步和wpf中比较
java·c++·wpf
umeelove352 小时前
Springboot的jak安装与配置教程
java·spring boot·后端