1. SSE是什么:先弄明白你在调一个什么东西
SSE全称Server-Sent Events,中文一般叫“服务器推送事件”。它最大的特点是:连接一旦建立,服务器可以持续不断地往客户端推数据,而不是像普通HTTP请求那样“一问一答”就结束。
先说一个很多人上来就会混淆的点:SSE本质还是HTTP协议,不是WebSocket那种独立的协议栈。它复用了HTTP的连接,只是通过特殊的响应头Content-Type: text/event-stream告诉客户端“我这个响应不会马上结束,你要一行一行往上读”。客户端拿到这个响应头之后,连接会被保持住,服务器每次写一段数据,客户端就能实时收到一段。这个过程是单向的——服务器往客户端推,客户端不能通过同一个连接往服务器发数据。如果需要双向通信,那还是得用WebSocket。
和它最像的是我们常见的“轮询”。轮询是客户端每隔几秒发一次请求,问服务器“有没有新数据”。SSE是建立连接之后服务器主动推,客户端不用反复发起请求。从服务端资源占用角度讲,SSE明显更节省,因为它只占用一个连接,而且数据是“有更新才推”,不是每次轮询都带着整包数据跑一遍。
实际工作中我遇到过两类最常见的SSE使用场景:
第一类是任务进度实时推送。比如上传一个很大的文件,后端在做异步处理,处理进度是多少、到哪一步了,通过SSE实时推给前端进度条。这类场景用轮询也能做,但SSE的实时性更好,而且不用频繁创建HTTP连接。
第二类是AI对话流式输出。也就是现在大模型应用里最常见的“打字机”效果。模型的回答是一段一段生成的,后端拿到一部分就通过SSE推给前端,用户看到的就是一个字一个字往外蹦。这种体验用轮询基本做不到,因为每次轮询都会把已经生成的文本重复传一遍,浪费流量不说,体验也跟不上。
回到这个项目标题本身——“javaWeb调用SSE接口”,我要特别强调一下这里的视角。大多数讲SSE的文章,都在讲浏览器端怎么用EventSource监听,或者后端怎么写一个SSE接口。但“javaWeb调用SSE接口”说的不是前端页面在调,而是一个Java后端服务,作为客户端,去调用另一个系统提供的SSE接口。这种场景在微服务架构、服务间数据同步、网关代理层、第二方系统对接时非常常见。
举个例子,你的Java服务要对接一个第三方AI平台,对方提供了一个SSE接口用于流式返回对话结果,那你的Java服务就得自己写一套“SSE客户端”逻辑:建立连接、逐行读取、解析事件、超时处理、断线重连。这一套东西,和浏览器里的EventSource完全是两码事。官方的JDK没给现成的API,需要自己封装或者借助第三方库来实现。
本文后面所有的内容,都围绕这个视角展开:服务端的SSE接口怎么写,Java客户端怎么调,遇到了哪些坑,怎么排查。前端Vue3的部分也会带上,因为很多场景是前台页面先调你的Java服务,你的Java服务再去调外部的SSE接口,整条链路需要打通。
1.1 SSE的报文格式:看懂它才能真正“吃”到数据
刚开始做SSE的时候,很多人容易在解析这一步卡住。因为SSE接口返回的内容不是JSON,而是一个基于文本的流式格式,每一帧由若干行组成,每行一个字段。
一个标准的SSE消息长这样:
code复制id: 1
event: message
data: {"content": "你好"}
data: {"content": "我是第二行"}
这里要注意几个关键点:
- 每个字段一行,格式是
字段名: 空格 + 值。那个冒号后面的空格不是必须的,但建议写上,兼容性更好。 data可以有多行,多行data会被拼接成一个字符串,中间用换行符连接。也就是说上面这个例子,你最终收到的data其实是{"content": "你好"}\n{"content": "我是第二行"}这个整体。- 帧与帧之间,用一个空行隔开。这是整个SSE协议里最容易出问题的地方——服务端如果忘了发这个空行,客户端会一直等,以为一帧数据还没结束。
event表示事件类型,默认是message。如果服务端发的是event: result,客户端只有监听了result这个事件才能收到。id用于断线重连。客户端重连的时候,可以带上Last-Event-ID头告诉服务器“我从哪一条消息之后开始补给我”。retry字段用来告诉客户端重连的间隔时间,单位是毫秒。- 还有一类特殊消息叫注释,以
:开头。服务端经常会用注释行来做“心跳保活”,因为注释行不会触发任何事件,但能让连接保持活跃,防止中间设备超时断开。
所以在写Java客户端解析SSE时,核心逻辑就是:按行读取,遇到空行说明一帧结束了,把这一帧里的data、event、id等字段组装起来交给上层业务处理。如果服务端不按套路出牌,少发了空行或者把JSON挤在一行里,解析就会出问题,我后面会专门讲这些坑。
1.2 轮询、SSE、WebSocket到底怎么选
这里给一个比较直观的选型参考,遇到类似需求别选错了方向:
| 方案 | 通信方向 | 实时性 | 连接开销 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 客户端拉取 | 取决于频率 | 高,每次都要建连 | 低频数据刷新 |
| 长轮询 | 客户端拉取,服务端挂起 | 中等 | 较高 | 兼容性要求高的老系统 |
| SSE | 服务端推送(单向) | 高 | 低,一条长连接 | 实时通知、进度推送、AI流式输出 |
| WebSocket | 双向通信 | 最高 | 低,一条长连接 | 聊天、协同编辑、游戏 |
我在实际项目里一般这么判断:只需要服务器单方面推数据,就用SSE。别看WebSocket功能更强大,但它带来了协议升级、心跳维护、状态管理等一系列额外复杂度。而SSE是纯HTTP,天然能穿透绝大多数防火墙和代理,服务端实现也简单得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搭一个能跑的SSE服务端:Spring Boot + SseEmitter
既然要“调用”SSE接口,首先得有一个目标接口。我建议先把服务端搭起来,用浏览器或者命令行验证接口本身没问题,再去写Java客户端调用。不然客户端写完了,连个测试环境都没有,排错都没法排。
Spring Boot 2.x+ 提供了一个非常好用的类SseEmitter,专门用来实现SSE推送。下面是一个最简版本,如果你用的是Spring Boot 3.x,包路径和用法基本一致,只是javax要换成jakarta。
java复制@RestController
@RequestMapping("/api/sse")
public class SseController {
/**
* 建立SSE连接,返回SseEmitter
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
// 0L 表示不超时,生产环境建议设置一个合理值,比如60_000L
SseEmitter emitter = new SseEmitter(0L);
// 异步推流:这里用线程池模拟真实场景
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
try {
for (int i = 0; i < 100; i++) {
// 发送一条消息,SseEventBuilder可以自定义事件名、id、data
emitter.send(SseEmitter.event()
.id(String.valueOf(i))
.name("message")
.data("{\"index\": " + i + ", \"msg\": \"hello sse\"}"));
Thread.sleep(1000);
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
看起来很简单是吧?但这个简单版本里藏着几个影响成败的细节,我逐个拆解。
2.1 为什么produces要指定text/event-stream
MediaType.TEXT_EVENT_STREAM_VALUE对应的就是text/event-stream,这是SSE的“身份证”。客户端拿到这个响应头,才知道要按SSE的规则去解析后续的数据。如果你是手动写ServletResponse来输出SSE,一定要记得:
java复制response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
第二个setCharacterEncoding("UTF-8")特别容易被忽略,尤其是中文场景。如果不显式指定,Tomcat默认的字符编码是ISO-8859-1,中文推出去直接乱码。
2.2 自定义事件名有什么用
上面代码里.name("message")其实可以不写,因为默认就是message。那为什么要自定义事件名?
举个例子:你的SSE接口既想推“任务进度”,又想推“任务完成”,还想推“心跳保活”。如果全用默认的message,客户端就得在每个消息里自己判断类型。但如果服务端用不同的事件名来区分,客户端就能更灵活地分开监听。
比如Vue3的前端代码可以这样收:
javascript复制const es = new EventSource('/api/sse/stream');
es.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
console.log('进度:', data.percent);
});
es.addEventListener('complete', (e) => {
console.log('完成:', e.data);
});
所以事件名本质上是一个“消息路由标记”,在写客户端解析的时候也要特别注意——不能只处理message事件,要把其他自定义事件也考虑进去。
2.3 服务端心跳:和客户端“拍手”防止连接被断开
SSE有一个特性,就是连接建立之后,如果长时间没有数据流动,中间的网络设备(Nginx、F5、运营商路由器)可能会把这个连接当成“空连接”给掐断。
解决办法就是心跳机制。服务端每隔一定时间发一个注释行或者一个空消息,告诉中间设备和客户端“我还活着”。注释行的格式很简单,就是一个冒号加任意内容,比如:
code复制: ping
这里注意,注释行结尾也必须空一行,也就是实际发出去的内容是': ping\n\n'。注释行会被客户端忽略,不会触发任何事件,但能维持连接活跃。
在Spring Boot里,心跳通常放在SseEmitter的send里,和业务消息一起循环发,或者在业务消息间隔太久时单独发。我自己的经验是:30秒没有业务数据,就补发一条注释消息保活。
3. Java后端调用SSE接口:四种方式选型与实战
接下来进入正题。作为一个Java后端,要去调用外部系统的SSE接口,直接拿HttpURLConnection连上去会发现一个尴尬的事情:普通HTTP请求会在服务器返回响应体之后一次性把所有数据读回来,但SSE的数据是流式输出的,响应永远不会立刻结束,数据会分很多次写到响应流里。
要正确处理SSE,核心就一个思路:拿到InputStream之后,不要一次性读完,而是按行持续读取,每读到一帧就处理一帧。明白了这一点,剩下的就是选哪种HTTP客户端的问题。
3.1 方案对比:WebClient、OkHttp、HttpURLConnection怎么选
| 方式 | 是否原生支持SSE | 代码复杂度 | 依赖 | 推荐指数 |
|---|---|---|---|---|
| Spring WebClient | 支持,专用retrieve().bodyToFlux |
中 | spring-webflux | 推荐 |
| OkHttp + EventSource | 支持,官方SSE封装 | 低 | okhttp-sse | 很推荐 |
| HttpURLConnection | 不支持,需自行按流读取 | 中 | JDK自带 | 轻量场景可用 |
| Apache HttpClient | 不支持,需自行按流读取 | 中 | httpclient | 不推荐,需手动处理太多 |
先说结论:如果是新项目,优先考虑OkHttp的SSE支持,它把连接管理、断线重连、事件解析都封装好了,用起来最省心。如果项目已经用了Spring WebFlux或者WebClient,那就直接用WebClient,响应式流式处理正好和SSE是天然搭配。HttpURLConnection虽然不需要额外依赖,但各种细节都要自己处理,适合实在不能引第三方包的老项目。
3.2 实战:OkHttp + EventSource 调用SSE接口
先加依赖,Maven坐标:
xml复制<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-sse</artifactId>
<version>4.12.0</version>
</dependency>
OkHttp的SSE封装叫做EventSource,使用方式很像浏览器里的JavaScript API。下面是一个完整的调用示例,我加了不少生产环境才需要考虑的细节:
java复制import okhttp3.*;
import okhttp3.sse.EventSource;
import okhttp3.sse.EventSourceListener;
import okhttp3.sse.EventSources;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class SseClientDemo {
public static void main(String[] args) {
// 1. 构建一个带连接池的OkHttpClient
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // 关键:读超时要设为0,不要自动断开
.retryOnConnectionFailure(true) // 网络抖动时自动重试
.pingInterval(20, TimeUnit.SECONDS) // OkHttp自己的心跳,20秒一次
.build();
// 2. 构建SSE请求
Request request = new Request.Builder()
.url("http://your-server.com/api/sse/stream")
// 带上鉴权信息,很多生产环境的SSE接口都需要token
.header("Authorization", "Bearer your-token")
// 关键:声明可接收的事件流类型
.header("Accept", "text/event-stream")
.build();
// 3. 创建EventSource
EventSource.Factory factory = EventSources.createFactory(client);
EventSource eventSource = factory.newEventSource(request, new EventSourceListener() {
@Override
public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {
System.out.println("SSE连接已建立,code=" + response.code());
}
@Override
public void onEvent(@NotNull EventSource eventSource,
@Nullable String id,
@Nullable String type,
@NotNull String data) {
// 这里就是每一帧数据到达的地方
// type就是event字段的值,默认是message
System.out.println("收到事件,id=" + id + ", type=" + type);
System.out.println("数据内容:" + data);
// 把data转成JSON,交给业务方法处理
handleBusinessData(data);
}
@Override
public void onClosed(@NotNull EventSource eventSource) {
// 连接正常关闭,比如服务端调用了emitter.complete()
System.out.println("SSE连接已关闭");
}
@Override
public void onFailure(@NotNull EventSource eventSource,
@Nullable Throwable t,
@Nullable Response response) {
// 连接异常断开,这里要做重连或者告警
System.err.println("SSE连接异常:" + t.getMessage());
if (response != null) {
System.err.println("HTTP状态码:" + response.code());
}
// 注意:OkHttp的EventSource默认不会自动重连,需要自己实现
scheduleReconnect();
}
});
// 4. 程序保持运行,等待事件到达
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void handleBusinessData(String data) {
// 业务逻辑:解析JSON、入库、转发、更新缓存等等
// JSON解析推荐用Jackson或Fastjson2
// 注意:SSE的data可能不是完整JSON,要处理“半包”的情况
}
private static void scheduleReconnect() {
// 断线重连:建议使用指数退避,不要一断开就立即重连
}
}
这段代码里有两个非常关键的参数,我要单独强调一下:
第一,readTimeout(0, TimeUnit.MILLISECONDS)。 这是无数人踩过的坑。默认的OkHttp读超时是10秒,如果10秒内没有数据到达,客户端会直接抛异常断开。而SSE有些场景下,服务器可能一两分钟都没有数据推过来(比如AI正在思考的时候),这就会被误判成超时。所以做SSE客户端,读超时基本都设为0,表示“无限等待”。
第二,重连机制一定要自己实现。 OkHttp的EventSourceListener里虽然有onFailure回调,但它不会像浏览器里的EventSource那样自动重连。我在生产环境用的重连策略是:第一次失败等1秒,第二次等2秒,第三次等4秒,最多等30秒。同时要限制最大重连次数或者加入熔断逻辑,不然服务端一直不可用,你的客户端会变成一台“重连风扇机”,疯狂打请求。
3.3 实战:Spring WebClient 调用SSE接口
如果项目里已经引了spring-boot-starter-webflux,用WebClient是最省事的。它内置了对SSE的解析,不需要关心data、空行这些协议细节,直接拿到一个个对象。
java复制import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
@Service
public class SseWebClientService {
private final WebClient webClient;
public SseWebClientService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("http://your-server.com")
.build();
}
public void consumeSse() {
Flux<String> stream = webClient.get()
.uri("/api/sse/stream")
.header("Authorization", "Bearer your-token")
.retrieve()
.bodyToFlux(String.class);
// subscribe是异步的,不会阻塞当前线程
stream.subscribe(
data -> {
System.out.println("收到数据:" + data);
handleBusinessData(data);
},
error -> {
System.err.println("SSE流发生错误:" + error.getMessage());
scheduleReconnect();
},
() -> {
System.out.println("SSE流正常结束");
}
);
}
}
bodyToFlux(String.class)会把SSE流里的每一帧data作为一个String发出来。如果你希望它自动反序列化成对象,可以换成bodyToFlux(MyEvent.class),WebClient会自动处理JSON解析。这个体验确实是最流畅的。
不过WebClient毕竟是响应式编程,如果你的项目是传统的Servlet堆栈,引入WebFlux意味着要接受一套新的异步编程模型,团队没有响应式基础的话建议谨慎。
3.4 实战:如果不是SSE协议标准,而是“看起来像流式响应”
还要说一种特殊情况。我遇到过很多“伪SSE”接口,也就是服务端返回Content-Type: application/json或者text/plain,但实际响应也是流式的,数据不断追加着返回。这种情况就不能用OkHttp的EventSource了,因为它会严格按照text/event-stream去解析空行和data:前缀,遇到不标准的格式直接抛异常。
对于这种接口,我的处理方案是退回到最底层的HttpURLConnection,手动按行读流:
java复制import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
public class RawStreamClient {
public static void main(String[] args) throws Exception {
URL url = new URL("http://your-server.com/api/stream");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("Authorization", "Bearer token");
conn.setConnectTimeout(10000);
// 注意:读超时不要设,或者设为0
conn.setReadTimeout(0);
int code = conn.getResponseCode();
System.out.println("HTTP状态码:" + code);
if (code == 200) {
InputStream inputStream = conn.getInputStream();
BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {
// 最关键的一步:判断空行,空行就是一帧的结束
if (line.isEmpty()) {
// 到这里说明一帧SSE消息已经结束,可以处理前面攒下的data
System.out.println("---- 帧结束 ----");
continue;
}
System.out.println("原始行:" + line);
// 实际开发中要解析data: 、event: 、id: 等字段
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
handleChunk(data);
}
}
reader.close();
}
conn.disconnect();
}
private static void handleChunk(String chunk) {
// 处理每一块数据
}
}
这个方式最灵活,什么格式都能解析,但坏处是所有的协议细节你都要自己处理:心跳、重连、事件缓冲、半包拼装。所以我通常只在“对方接口不规范”的时候才会退回这个方案。
4. 前端Vue3怎么对接线上SSE接口
很少有人说清楚一件事:如果你的Java服务已经接好了外部的SSE接口,那么前端页面通常无需直接去连那个外部接口,而是由你的Java服务把数据“转发”给前端。前端连的是你自己的Java服务。这样一来,鉴权、过滤、数据加工都可以在Java层做掉。
但有时候项目简单,确实需要前端直连外部SSE接口——比如第三方AI平台提供了一个SSE接口用于流式返回回答,前端页面想直接对接。那我们就得了解浏览器端的SSE用法。
4.1 EventSource:浏览器自带的SSE客户端
现代浏览器都内置了EventSource,不需要引任何第三方库。基本用法如下:
javascript复制// 创建一个SSE连接
const es = new EventSource('/api/sse/stream', {
// 构造函数里不能自定义请求头,这是浏览器限制
// 如果需要带token,国内很多项目是放在query参数里:/api/sse/stream?token=xxx
});
// 监听默认message事件
es.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到数据:', data);
};
// 监听自定义事件:服务端event字段为ping时触发
es.addEventListener('ping', (event) => {
console.log('心跳:', event.data);
});
// 连接打开
es.onopen = () => {
console.log('SSE连接已建立');
};
// 错误处理:包含连接意外断开
es.onerror = (err) => {
console.error('发生错误:', err);
// 注意:EventSource会自动重连,这里一般不需要手动处理
};
// 手动关闭
// es.close();
这里有一个很大的坑:浏览器的EventSource不能自定义请求头。如果你需要带Authorization这种header,浏览器会拒绝让你设置。变通办法有三个:
- 把token放在URL的query参数里:
new EventSource('/api/sse/stream?token=xxx') - 用Cookie做鉴权
- 放弃浏览器EventSource,改用
fetch+ReadableStream自己实现流式读取
第3种方式现在也很常用,尤其是有些接口返回的不是标准SSE格式,用fetch的ReadableStream反而更灵活:
javascript复制async function fetchSSE(url) {
const response = await fetch(url, {
headers: {
'Authorization': 'Bearer token'
}
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 按行切分处理
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5).trim();
console.log(data);
}
}
}
}
4.2 让流式输出渲染成“打字机”效果
前端拿到SSE流式数据后,最典型的场景是让大模型的回答像打字机一样逐字出现。实现思路不复杂:在收到每一块新数据时,把它追加到当前显示的文本后面,而不是替换。
vue复制<template>
<div class="chat-content">{{ displayText }}</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const displayText = ref('');
let es = null;
function connectSse() {
es = new EventSource('/api/ai/chat?question=' + encodeURIComponent(searchText.value));
es.addEventListener('delta', (e) => {
// 假设服务端每次推送一个增量片段
const delta = JSON.parse(e.data).content;
displayText.value += delta;
});
es.addEventListener('end', () => {
es.close();
});
}
onBeforeUnmount(() => {
if (es) es.close();
});
</script>
这里要注意Vue的响应式更新频率。如果SSE推送的频率很高,比如每50毫秒推一个小片段,Vue的响应式系统可能会跟不上,导致页面卡顿。一个优化办法是用requestAnimationFrame做节流,把数据积累起来,每帧只更新一次DOM。
5. 常见问题与排查实录:我在实际项目中踩过的坑
把前面讲的整个流程跑通之后,总会遇到一些“看起来哪里都对,但结果不对”的情况。下面这些坑是我在真实项目中挨个踩过、并且现场排查过的,写出来帮你省掉这些弯路。
5.1 连接一开就断:报read timeout
这个现象一般有两种原因。
第一种,客户端读超时设得太短。 就像我前面提到的,OkHttp的默认readTimeout是10秒,如果你的SSE服务端10秒内没有推送任何数据(包括心跳),客户端就会认为连接超时了,主动断开。排查方法很简单:把客户端的readTimeout调大,或者干脆设为0,让连接一直挂着等数据。
第二种,服务端没有发心跳,被中间设备掐断了。 如果你的客户端配置一切正常,但连接还是会在固定时间(比如60秒、90秒)被断开,那大概率是中间有一层代理设备(Nginx、云负载均衡)在“收管理费”。这些设备默认会把长时间空闲的连接回收掉,解决办法就是在服务端加心跳,30秒左右发一条注释消息,把连接“喂饱”。
5.2 数据一直收不到,但连接也没报错
这个坑出现的频率极高。现象是你的SSE连接正常建立了(服务端日志能看到的连接进来了),但客户端这里的onEvent一直不触发,或者readLine一直阻塞着。
我排查过的大多数案例,最后都指向同一个问题:服务端忘了发空行,或者没有flush输出流。
SSE协议规定,每条事件必须以空行结尾。很多同学在Servlet里手写SSE的时候,写了data: xxx\n就完事了,没有写结束的空行,客户端读了一行之后就一直等着第二行,事件永远不会被结算。正确写法是:
java复制PrintWriter writer = response.getWriter();
writer.write("data: 你好\n\n"); // 注意是两个\n
writer.flush();
为什么不flush也有问题?因为Java的输出流有缓冲区,数据量不到缓冲大小就不会真正发送出去。flush的作用是强制把缓冲区的数据写到网络里。刚写SSE那会儿我犯过这个毛病,数据推了半天前端一个字符都看不到,加一行flush()立马就好了。
5.3 中文乱码
SSE返回的中文变成一堆问号或者乱码,基本上可以断定是服务端响应的字符编码设成了ISO-8859-1。不管是Spring Boot还是原生Servlet,都要显式设置:
java复制response.setCharacterEncoding("UTF-8");
如果是Spring MVC的SseEmitter,需要在application.yml里配置相关的编码,或者在每个请求上设置produces = MediaType.TEXT_EVENT_STREAM_VALUE(这个常量本身就带UTF-8)。客户端读取的时候也一样,InputStreamReader的字符集一定要和服务端保持一致,统一用UTF-8。
5.4 Nginx开启了缓冲导致数据不实时
这是一个非常经典的生产环境问题。你在本地测试SSE一切正常,一上服务器、前面挂了个Nginx,数据就变成“一下子全出来了”,完全失去了流式效果。
原因是Nginx默认开启了proxy_buffering,会先把后端返回的数据攒到自己的缓冲区里,等积累到一定量或者连接结束再一次性发给客户端。对于SSE这种长连接流式响应,这个行为是致命的。
解决办法是在Nginx的配置里关闭该端点的缓冲:
nginx复制location /api/sse/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Connection '';
chunked_transfer_encoding off;
}
如果proxy_buffering off不好使,也可以让后端在响应头里带一句:
code复制X-Accel-Buffering: no
Nginx看到这个响应头,会关闭这块的缓冲。这个头在后端代码里用response.setHeader("X-Accel-Buffering", "no")加上就行。
5.5 客户端自动重连导致事件重复消费
浏览器的事件源断线之后会自动重连,这个机制本身是好事,但如果不处理事件幂等性,就会造成重复消费。
举个例子:你的Java服务消费外部SSE接口,把数据写入数据库。连接断了一下,重连之后服务端又把断线期间的数据推了一遍,你的消费逻辑发现主键冲突,或者重复写入了两条记录。
解决思路有两种。第一是业务层面保证幂等,比如用消息唯一ID做去重;第二是利用SSE协议的Last-Event-ID机制,重连的时候带上客户端最后处理成功的消息ID,让服务端从下一条开始推送。
OkHttp自定义重连时,需要自己维护这个Last-Event-ID:
java复制Request request = new Request.Builder()
.url("http://your-server.com/api/sse/stream")
.header("Last-Event-ID", lastEventId)
.build();
5.6 网络异常怎么排查:先分清楚是哪一层的锅
SSE出问题时,我最常做的事就是逐层隔离。
第一步,用命令行工具直接连服务端接口,看接口本身有没有问题:
bash复制curl -N http://your-server.com/api/sse/stream
-N参数可以禁用curl的缓冲,让它立即显示收到的数据。如果curl也看不到数据,说明问题在服务端,先在服务端查日志、查火焰图。如果curl能看到数据,再排查客户端代码——是超时配置不对,还是解析逻辑有问题,还是中间有代理在捣乱。
这个习惯帮我解决了很多看起来毫无头绪的问题。任何“客户端接不上”的问题,先确认服务端本身是健康的,再一层一层往外查,效率最高。
5.7 连接数超过限制需要注意
SSE连接数量不受控是一个潜在的雪崩隐患。普通HTTP请求是“短连接”,用完就释放,但SSE每个客户端占用一条长连接,而且这条连接是24小时挂着的。
如果后端是Tomcat,默认最大线程数是200。意味着如果200个前端页面各挂一条SSE连接,整个服务的普通请求就全部阻塞了,这是真实发生过的生产事故。解决思路有几种:
- 把SSE接口单独部署到一台服务,或者用专门的流式网关
- 调大Tomcat的
max-threads,同时改造成NIO模式 - 如果场景允许,限制前端同时打开的SSE连接数量
- 接入层用Nginx做负载均衡,分散连接压力
5.8 用好注释消息区分“业务静默”和“连接断开”
很多时候,服务端一段时间没有新数据,客户端并不需要立刻感知,但如果服务端宕机了,客户端最好能在几秒内发现并重连。
我的做法是:服务端每15秒发一条注释消息,也就是':'开头的行。客户端如果连续3次心跳周期(也就是45秒)都没收到任何数据(包括注释),就判定连接异常,主动重连。
这个逻辑用BufferedReader手写解析的时候尤其好实现,只要在循环里记录最后一次readLine的时间,再另起一个定时任务检查超时就行。
6. 一些写SSE时的工程化建议
最后把散落在前面的经验再整理几条,方便你在写代码的时候对照检查。
第一,SSE服务端和客户端的超时设置要配合。服务端设置连接不超时时长,客户端必须对应调整,不要两边各自设不同的值导致“你觉得没问题,连接却一直断”。
第二,心跳是标配,不是选配。任何生产环境的SSE接口,都应该带心跳,不管服务端还是客户端都不要偷懒跳过这一步。
第三,上报和监控要跟上。SSE连接是长连接,出问题不像普通HTTP请求那样立刻暴露。建议在客户端维护一个连接状态,断开、重连、长时间无数据都要有日志和监控指标,这样出了问题才能第一时间发现。
第四,SSE的数据帧最好单独封装一层。不要在下游业务代码里到处用readLine和字符串切割。把“SSE传输”和“业务解析”解耦,前端也好、Java客户端也好,第一步收到的都是原始文本行,先解析成标准的事件对象,再拿给上层业务用。这样后续替换传输协议也方便。
我在另一个项目里就吃过这方面的亏。最开始图省事,直接把SSE的数据在Controller里拼字符串返回给前端,前端再自己切字符串。后来要改成消息队列推送,前端代码改了一整套,后端也翻了个底朝天。如果一开始就定义好“服务器推送事件”这个模型,后面替换传输层几乎不影响业务代码。
SSE这个技术,从协议层面上说并不复杂,它的精髓全在细节里:空行、心跳、重连、编码、缓冲、超时,任何一个细节没照顾到,线上就会出现很诡异的现象。希望这篇文章能帮你把这条链路彻底打通,少走一些我当年走过的弯路。
