“流式对话接口”这几个字第一次让我熬夜的原因,不是协议本身,而是一个看起来无关的现象:前端光标停在输入框上等了七八秒,页面一动不动,用户以为卡死了,连续点了几次发送,后端队列里堆了四五个重复请求。后来把“等一整段”改成“逐字吐”,这个体验问题当场消失。标题里讲的“一个 Java 项目里用 SSE 构造流式对话接口”,落到工程上就是这一件事:把上游模型逐块吐出来的文本,稳定、低延迟、可控地送到浏览器,中间不能缓冲、不能积压、不能把线程池挂死。
SSE(Server-Sent Events)是浏览器原生支持的服务端推送协议,基于普通 HTTP 长连接,服务端以text/event-stream格式持续写出数据帧,客户端用EventSource或fetch的流式读取接收。它天生适合“一问一答、服务端单向持续输出”的对话场景,比 WebSocket 轻,比轮询省。这篇内容面向已经会写 Spring Boot 接口、但对流式输出、异步写出、网关配合还没跑通的开发者,也适合需要评估方案选型的技术负责人。下面我按选型、协议细节、服务端实现、工程细节、联调、排错六个部分展开,代码骨架和参数都能直接拿去改。
1. 先搞清楚:一次流式对话在链路上到底发生了什么
很多人一上手就写SseEmitter,写完之后发现“本地能出字、测试环境卡住不动”,然后开始怀疑框架。真正的问题往往在链路认知上:一次流式对话不是“一个接口返回数据”,而是“一条跨越浏览器、网关、Java 应用、上游模型服务的长连接,在这条连接上持续搬运文本增量”。这条链路上任何一环做了缓冲,流式就退化成一次性返回。
1.1 从“等一整段”到“逐字吐”的差别
非流式的对话接口很好理解:请求进来,服务端调上游模型,等完整结果返回,序列化成 JSON,一次性响应。它的优点是实现简单、错误处理清晰;缺点是首字节时间等于整个生成时间,用户感知的等待被放大到极限,而且长文本生成很容易触发浏览器或网关的超时。
流式对话的本质是把“生成”和“传输”重叠起来。上游每产生一个片段(通常是一个 token 或者几个 token 组成的 chunk),服务端就立刻往下游写一帧,浏览器收到就渲染。整条连接的生命周期内,HTTP 状态码早已在响应头阶段确定,后面的内容全部是“同一响应体里的增量数据”。这意味着两件事:一是错误一旦发生,你没法再改状态码,只能通过事件内容告知前端;二是任何一个中间环节只要攒着不发送,前端就只能干等。
我在项目里做过一个对比测试,同样一段 800 字左右的回答,非流式接口的首字节时间是 6.8 秒,流式接口的首字节时间是 0.9 秒,虽然总耗时只差不到一秒,但用户的主观感受完全是两回事。对话类产品的体验门槛就在首字节,这个数字比总时长重要得多。
1.2 SSE、WebSocket、长轮询的横向对比
选型的时候别只看“能不能用”,要看“代价是什么”。我整理了一张表,是这几年在不同项目里反复验证过的结论:
| 维度 | SSE | WebSocket | 长轮询 |
|---|---|---|---|
| 传输层 | 普通 HTTP/1.1 或 HTTP/2 | 独立协议,需 Upgrade 握手 | 普通 HTTP |
| 方向 | 服务端到客户端单向 | 双向 | 客户端发起,服务端挂起 |
| 浏览器支持 | 原生 EventSource,自动重连 | 原生支持 | 全支持 |
| 鉴权方式 | 沿用 HTTP 头或 Cookie | 握手阶段处理,略麻烦 | 同普通请求 |
| 中间件友好度 | 好,本质是 HTTP | 部分网关需额外放行 | 好 |
| 适合场景 | 单向增量输出、通知 | 双向实时互动、协同编辑 | 兼容性兜底 |
对话场景的流量方向非常明确:客户端发一次请求,服务端持续往客户端推文本。这种“单向下行 + 一次上行”的形态,用 WebSocket 是杀鸡用牛刀,你还得额外维护连接状态、心跳、重连、鉴权透传。SSE 用的是标准 HTTP,天然复用现有的鉴权、限流、日志体系,出问题的时候抓包也简单——它就是一段格式特殊的文本响应。
真正需要警惕的是 HTTP/1.1 下同域名的连接数限制。浏览器对同一域名的并发连接通常限制在 6 个左右,SSE 连接会长期占用一个。如果一个页面同时开多个流式连接,很容易把配额吃满,导致其他请求排队。常规做法是把对话流放在独立子域,或者直接上 HTTP/2,多路复用下这个问题基本消失。
1.3 标题里那个“接口”的职责边界
我见过不少实现把职责搅在一起:Controller 里直接拼上游请求、解析响应、写 SSE 帧,一个方法两百行。这种写法能跑,但没法维护。我的习惯是把一条流拆成四段职责:接收与校验参数、组装上游请求、解析上游流、写出下游帧。前三段是纯逻辑,可单测;最后一段才涉及线程和 IO。
还有一层容易忽略的边界:会话上下文。对话接口通常要带历史消息,历史消息的裁剪、系统提示词的拼接、参数透传,这些都应该在“组装上游请求”这一段完成,而不是散落在流式循环里。流式循环只做一件事——把上游的字节流翻译成下游的文本帧。职责越单一,出问题的时候定位越快。
注意:不要在流式循环里做数据库写入、日志落盘这类阻塞操作。一个 chunk 一次数据库调用,高并发下会直接把连接池打满。
2. 把 SSE 规范拆成能直接写进代码的几条规则
SSE 的协议文本很短,但真正写代码时踩坑的地方集中在几个细节上:响应头的字符集、帧的分隔符、注释行的心跳、事件 ID 的重连语义。这几条搞清楚了,剩下的都是工程问题。
2.1 响应头与报文格式:三个不能错的点
服务端返回 SSE 时,响应头必须包含Content-Type: text/event-stream,通常还要显式带charset=UTF-8,否则某些容器或网关会按 ISO-8859-1 处理,中文直接变乱码。另外两个头也建议加上:Cache-Control: no-cache防止中间层缓存,X-Accel-Buffering: no是给 Nginx 看的,告诉它这条响应不要缓冲。
报文格式的规则很简单但严格:每一帧由若干行组成,行与行之间用换行分隔,帧与帧之间用一个空行分隔。字段有四种:
data:数据内容,可以出现多次,客户端会按换行拼接event:事件名,客户端可以按名字分别监听id:事件 ID,断线重连时浏览器会通过Last-Event-ID头带回来retry:重连间隔毫秒数
最容易错的是分隔符。帧结束必须是两个换行,也就是一个空行。我见过把\n写成\r\n然后在某些客户端上不生效的,也见过只发一个\n导致所有数据被当成一帧的。稳妥的做法是:行内用\n,帧尾固定发\n\n。
data: {"delta":"你好"} data: {"delta":",我在"}还有一个隐藏细节:如果data的内容本身包含换行,必须拆成多个data:行,浏览器会把它们用\n拼起来。对话场景里模型输出经常带换行,尤其是代码块,这里处理不好,前端渲染的 Markdown 就会错位。
2.2 心跳、断线与事件 ID 的实战用法
长连接最怕的是“看起来还连着,实际上已经死了”。中间设备(负载均衡、代理、防火墙)通常有闲置超时,30 秒到 5 分钟不等,连接上长时间没有字节流动就会被掐断,而客户端可能毫无感知,一直等下去。
解决办法是发心跳。SSE 支持以冒号开头的注释行,客户端会忽略内容但连接上产生了流量:
: ping我一般设 15 秒一次心跳,比大多数中间设备的闲置超时都短。心跳不要和数据帧抢同一个写入口,否则并发写会把帧切碎,直接导致客户端解析失败。
id字段和自动重连是一对。浏览器在连接断开后会自动重连(默认 3 秒,可以用retry:调整),并把最后收到的事件 ID 通过Last-Event-ID请求头带回来。对话场景里这条机制可以做得很有价值:把已经发出的文本片段编号,重连时服务端读取这个头,从断点继续推。不过说实话,模型生成是一次性的流,服务端很难“续传”,所以大多数项目里我选择不让浏览器自动重连,而是在前端捕获onerror后决定是重试整个请求还是提示用户。
提示:如果业务上不需要断点续传,建议在前端手动
close()连接,避免浏览器无脑重连造成服务端压力。
2.3 中文、编码与多行数据的处理坑
中文问题的根源几乎都在字符集上。响应头没有声明charset=UTF-8,或者输出流用的不是 UTF-8 编码,或者网关做了转码,三种情况表现都是乱码但排查路径完全不同。我的排查顺序是:先用 curl 直接打服务端,看原始字节;如果 curl 正常、浏览器乱码,那是响应头或网关;如果 curl 也乱码,那是服务端输出流编码。
多行数据是另一个高频坑。模型输出的内容里带\n是常态,如果直接把原始字符串塞进data:后面,客户端只会收到第一行,后面的内容要么被当成新字段解析失败,要么被丢弃。正确做法是先把内容里的换行替换成\n字面量,或者拆成多个data:行。我通常选择前者,在 JSON 序列化时自然就转义了——把增量包成 JSON 再发,是最省心的做法:
{"id":"c12","delta":"第一行\n第二行"}这样前端拿到的是一个合法的 JSON 字符串,JSON.parse之后换行原样保留,不需要在前端再做任何拆分逻辑。
3. Java 服务端落地:三种实现的取舍与核心代码
Java 侧做 SSE 有三条主流路线:Spring MVC 的SseEmitter、WebFlux 的Flux<ServerSentEvent>、以及原生 Servlet 3 的异步输出。选哪条不是看哪条新,而是看你的项目底子和团队熟悉度。
3.1 SseEmitter 还是 WebFlux:先看项目底子
如果项目是典型的 Spring Boot MVC + 阻塞式数据库访问,我强烈建议用SseEmitter。它建立在 Servlet 3 的异步请求之上,不引入响应式栈,团队不需要理解背压、事件循环这些概念,出问题也好排查。
如果项目本来就是 WebFlux,或者需要同时支撑大量长连接(几千以上),那Flux<ServerSentEvent>更合适。响应式栈的优势在于用少量线程扛住大量空闲连接,但代价是调试成本高,一个阻塞调用混进去就会拖垮整个事件循环。
原生 Servlet 的方式我不太推荐,除非你有特殊需求。SseEmitter本身就是对它的封装,把超时、完成回调、异常回调都处理好了,没必要重造。
| 方案 | 适用场景 | 线程模型 | 上手成本 |
|---|---|---|---|
| SseEmitter | 常规 MVC 项目 | 一连接一异步任务 | 低 |
| WebFlux Flux | 高并发长连接 | 事件循环,少量线程 | 中高 |
| Servlet 异步 | 特殊定制 | 手动管理 | 高 |
3.2 SseEmitter 版本的完整骨架
下面这个骨架是我在多个项目里反复改出来的,去掉了业务细节,保留了所有关键结构。核心思路是:Controller 立刻返回SseEmitter,把实际的流式处理丢给独立线程池,处理过程中随时检查连接是否还有效。
@RestController @RequestMapping("/api/chat") public class ChatStreamController { private final ChatStreamService chatStreamService; private final ThreadPoolExecutor streamExecutor; public ChatStreamController(ChatStreamService chatStreamService, ThreadPoolExecutor streamExecutor) { this.chatStreamService = chatStreamService; this.streamExecutor = streamExecutor; } @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@RequestBody ChatRequest request) { // 0 表示不设超时,实际项目里建议设一个上限,比如 5 分钟 SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); AtomicBoolean closed = new AtomicBoolean(false); emitter.onCompletion(() -> closed.set(true)); emitter.onTimeout(() -> { closed.set(true); emitter.complete(); }); emitter.onError(e -> closed.set(true)); streamExecutor.execute(() -> { try { chatStreamService.streamChat(request, delta -> { if (closed.get()) { throw new StreamAbortedException("client gone"); } try { emitter.send(SseEmitter.event() .name("delta") .data(ChatChunk.of(delta), MediaType.APPLICATION_JSON)); } catch (IOException e) { throw new StreamAbortedException("write failed", e); } }); emitter.send(SseEmitter.event().name("done").data("{}")); emitter.complete(); } catch (StreamAbortedException e) { emitter.completeWithError(e); } catch (Exception e) { try { emitter.send(SseEmitter.event().name("error") .data(ErrorPayload.of("生成中断"))); } catch (IOException ignored) { // 连接已断,忽略 } emitter.complete(); } }); return emitter; } }几个地方值得单独说。produces必须声明为text/event-stream,否则 Spring 可能按 JSON 处理。onCompletion、onTimeout、onError三个回调一定要注册,它们是判断“客户端还在不在”的唯一可靠信号。emitter.send是同步写出,在多线程并发调用时会出现帧交错,所以必须保证同一条连接只有一个线程在写——这也是我把整个流处理放在单个任务里的原因。
MediaType.APPLICATION_JSON这个参数也建议带上。不带的话,Spring 会按默认转换器写,中文转义行为可能不符合预期;带上之后,序列化器明确,前端解析也稳定。
3.3 WebFlux 版本:响应式栈的写法
如果走 WebFlux,代码会短很多,但理解成本在别处。核心是把上游的流式响应包装成Flux<String>,再映射成ServerSentEvent:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestParam String q) { return chatClient.stream(q) .map(delta -> ServerSentEvent.<String>builder() .event("delta") .data(delta) .build()) .concatWith(Flux.just(ServerSentEvent.<String>builder() .event("done").data("{}").build())) .onErrorResume(e -> Flux.just(ServerSentEvent.<String>builder() .event("error").data("{\"msg\":\"生成中断\"}").build())); }关键在于chatClient.stream必须返回真正的响应式流,也就是基于WebClient的bodyToFlux或者支持响应式的 HTTP 客户端。如果底层用的是阻塞式客户端,整个事件循环会被卡住,几千连接的优势瞬间归零。这一点我在评审时必查:只要看到响应式方法里出现block(),直接打回。
3.4 上游流式读取的解析器怎么写
不管用哪条路线,都要面对“解析上游流”这件事。上游模型服务的流式响应通常也是 SSE 格式或者按行分隔的 JSON,你需要自己写解析器。这部分看似简单,实际坑最多。
核心原则是按行读取、按帧聚合。HTTP 响应体是一个字节流,BufferedReader.readLine()能帮你处理行边界,但不能假设“一次 read 就是一行”,网络包会被任意切分。用readLine循环是最稳的:
try (BufferedReader reader = new BufferedReader( new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { continue; } if (line.startsWith("data:")) { String payload = line.substring(5).trim(); if ("[DONE]".equals(payload)) { break; } String delta = extractDelta(payload); if (delta != null && !delta.isEmpty()) { consumer.accept(delta); } } } }这里有两个细节。第一,data:后面可能有一个空格,也可能没有,trim()能兼容。第二,结束标记各家不一样,有的是[DONE],有的是一个特定的字段,必须明确判断,否则循环会一直等到连接超时。第三,extractDelta里解析 JSON 时要注意字段可能不存在,尤其是第一个 chunk 通常只带角色信息不带内容,直接取内容字段会抛空指针。
4. 工程细节:线程池、超时、取消与资源回收
流式接口和普通接口最大的区别是“生命周期长”。普通接口几百毫秒就结束,流式接口可能持续一分钟。这一分钟里,线程、连接、内存都在被占用,任何一个维度失控都会放大成线上故障。
4.1 线程池怎么配才不会被一次对话拖垮
SseEmitter的实现里,每个活跃连接都要占一个工作线程。这个线程从开始处理到流结束都不会释放,因为它要持续写数据。这意味着线程池大小直接决定了并发对话数上限。
我的做法是给流式接口单独的线程池,和业务线程池隔离。核心线程数按“平均并发对话数 × 1.2”估,最大线程数按峰值估,队列不要设太大——队列积压意味着用户已经在等了,排在队里没有任何意义,不如快速失败让前端提示稍后重试。
@Bean("streamExecutor") public ThreadPoolExecutor streamExecutor() { return new ThreadPoolExecutor( 16, 64, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(128), new ThreadFactoryBuilder().setNameFormat("chat-stream-%d").build(), new ThreadPoolExecutor.AbortPolicy()); }拒绝策略用AbortPolicy,在处理入口捕获RejectedExecutionException,返回一个明确的“当前排队较多”事件。这种做法比无限排队诚实得多。另外,线程名一定要带业务前缀,出问题的时候jstack一眼就能看出流式线程占了多少。
4.2 超时链路的三层对齐
流式接口的超时不是一个值,而是三个值必须对齐,否则会出现“前端还在等、服务端已经断”的诡异现象:
| 层级 | 配置项 | 建议值 |
|---|---|---|
| SseEmitter | 构造参数 timeout | 5 分钟 |
| Spring MVC 异步 | spring.mvc.async.request-timeout | 略大于上者 |
| 网关/代理 | proxy_read_timeout | 略大于上者 |
| 上游 HTTP 客户端 | readTimeout | 按模型最长生成时间估 |
顺序上,越靠外的超时必须越大。我见过把网关超时设成 60 秒、应用设成 5 分钟的配置,结果第 61 秒网关先断,应用侧还在傻乎乎地写,日志里一片Broken pipe。统一原则:应用层最先感知并主动收尾,网关层兜底。心跳机制在这里也有价值,它能让网关的闲置超时计时器不断重置,避免长回答被误杀。
4.3 客户端断开的检测与资源回收
用户关掉页面、切换标签、网络抖动,都会导致连接断开。服务端如果不感知,就会继续调上游、继续生成、继续占线程,白白烧钱。
SseEmitter的onCompletion、onError是主要信号,但它们不是实时的,有时要等到下一次写失败才触发。所以我在每次写之前都检查一个AtomicBoolean标志,写失败也立刻置位,两层保险。上游那边的 HTTP 连接也要主动关闭,InputStream要放在 try-with-resources 里,否则连接池里的连接会一直挂着。
还有一个容易忽略的点:用户主动点“停止生成”时,前端调用close(),服务端感知到写失败后,要记得把上游请求也取消掉。如果用的是支持取消的 HTTP 客户端,直接cancel();如果是阻塞式客户端,就得靠关闭输入流让读取循环抛异常退出。这一步不做,一个用户点停止,后端还在跑完整段生成,成本是实打实的。
5. 联调现场:前端、网关、压缩这三道坎
代码写完只是开始,真正让人抓狂的是联调阶段。我统计过自己遇到的流式问题,八成集中在三个地方:前端接收方式、网关缓冲、响应压缩。这三块任何一个没配好,表现都是“本地好的,环境上不出来”。
5.1 前端怎么接:EventSource 与 fetch 流的选择
EventSource是最省事的,浏览器帮你处理解析、重连,代码几行就够:
const es = new EventSource('/api/chat/stream?q=' + encodeURIComponent(question)); es.addEventListener('delta', e => { appendText(JSON.parse(e.data).delta); }); es.addEventListener('done', () => es.close()); es.addEventListener('error', () => es.close());但它有两个硬限制:只支持 GET,不能自定义请求头。这意味着你没法在 Header 里放 Authorization,也没法传复杂的请求体。绕法有两种:把参数和令牌放 query string,或者用 Cookie 鉴权。前者要注意令牌泄露到日志里,后者要处理跨域 Cookie 的问题。
所以我更常用fetch加流式读取,自由度更高:
const resp = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ q: question }) }); const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const frames = buffer.split('\n\n'); buffer = frames.pop(); for (const frame of frames) handleFrame(frame); }这段代码里有两个关键点。decoder.decode的stream: true参数必须加,否则一个中文字符被网络包切成两半时会解出乱码。buffer的作用是处理帧被切断的情况——网络不保证一次 read 就是一个完整帧,必须自己攒到\n\n再解析。
5.2 反向代理与压缩:两个默认开启的“杀手”
Nginx 默认会对响应做缓冲,它会把上游发来的数据攒在自己的缓冲区里,攒够一定大小或者连接结束才往下发。对流式接口来说这是灾难。必须显式关掉:
location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 300s; }proxy_buffering off是核心,其他几项是配套。proxy_set_header Connection ''是为了保持长连接,避免每个请求都重新握手。
压缩是第二个杀手。gzip 为了压缩率会攒一批数据再压,天然和流式冲突。如果全局开了 gzip,SSE 响应会被攒住,前端看到的还是“一次性返回”。解决办法是在 Nginx 里对这条路径关掉压缩,或者后端在响应头里明确声明不压缩。有些团队用Content-Encoding: identity兜底,也能起作用。
注意:应用层的日志过滤器、链路追踪组件如果包装了 Response,也可能引入缓冲。我遇到过某个日志组件把响应体全部缓存下来做审计,导致流式彻底失效,排查了大半天。
5.3 鉴权怎么带上,以及跨域的两个细节
鉴权这块,POST + fetch 的方案最直接,Authorization 头照常带。用 EventSource 的话就只能靠 Cookie 或者 query 参数。query 参数的问题在于会进访问日志、会进浏览器历史,如果是长期有效的令牌,风险不小。折中方案是用短时效的一次性票据,前端先调一个普通接口换票,再拿票去建流。
跨域时要注意withCredentials。用 Cookie 鉴权必须带上这个标志,同时服务端的 CORS 配置里allowCredentials要为 true,而且allowedOrigins不能是通配符*,必须是具体域名。这个限制很多人第一次遇到会懵,其实规范就是这么定的。
还有一个坑:某些网关或 CDN 会对text/event-stream响应做特殊处理,比如强制加上Content-Length,或者把 chunked 编码转成固定长度。一旦响应带上Content-Length,流式就不可能了。联调时用curl -N -v看响应头,如果没有Transfer-Encoding: chunked而是Content-Length,那问题就在中间层。
6. 问题排查速查表与几个反直觉的坑
流式接口的问题有个特点:现象都差不多(“不出来”或者“一下全出来”),但根因可能在任何一层。这时候靠猜是没用的,得按链路分段定位。
6.1 常见现象与根因对照
| 现象 | 最可能的根因 | 快速验证方式 |
|---|---|---|
| 本地正常,环境一次性返回 | 网关或压缩缓冲 | curl -N看是否逐块输出 |
| 首帧正常,之后长时间无数据 | 上游解析卡住或心跳缺失 | 看服务端日志时间戳 |
| 中文乱码 | 响应头缺 charset | 检查 Content-Type |
| 前端报解析错误 | 帧分隔符不对或多行 data 未处理 | 抓包看原始报文 |
| 第 60 秒整断开 | 某层超时设置过小 | 对齐三层超时配置 |
| 并发上去后大量超时 | 流式线程池耗尽 | jstack看线程数 |
| 用户关闭页面后仍在计费 | 未检测断开 | 检查写失败的异常处理 |
用这张表的时候有个技巧:先用curl -N直接打应用端口,绕过网关。如果这一步是流式的,问题就在网关;如果这一步就不是,问题在应用。这一步能把排查范围砍掉一半。
6.2 三个反直觉的坑
第一个坑:SseEmitter的send调用如果抛异常,不代表连接一定断了。有时候是序列化失败,有时候是临时写阻塞。我的处理是捕获后先检查onError标志,再决定是重试一次还是直接收尾。无脑当成断开会丢数据,无脑重试又可能死循环。
第二个坑:Spring 的@ResponseBody和SseEmitter混用会出问题。有些同学在同一个 Controller 里既返回SseEmitter又配置了全局的消息转换器,结果流式响应的内容被 JSON 转换器二次处理,前端收到的是转义过的字符串。检查方法很简单:看原始响应里是不是\"满天飞。
第三个坑:HTTP/2 下某些网关对流的处理行为和 HTTP/1.1 不同。我遇到过一次,HTTP/1.1 下正常的流,切到 HTTP/2 后第一帧之后就没数据了,最后定位到是网关对 HTTP/2 流的窗口更新有问题,临时降级协议解决。这类问题没有通用答案,只能靠分段验证锁定层级。
6.3 上线前的自检清单
我在每次上线流式接口前都会走一遍这个清单,基本能挡掉九成问题:
- 响应头是否包含
text/event-stream;charset=UTF-8、no-cache、X-Accel-Buffering: no - 帧尾是否固定为两个换行,数据中的换行是否做了转义
- 心跳是否开启,间隔是否小于所有中间层的闲置超时
- 三层超时是否从内到外递增
- 流式线程池是否独立、是否有拒绝策略、线程名是否可辨识
- 客户端断开后,上游请求是否被取消
- 网关是否关闭了缓冲和压缩
- 异常情况下是否还能给前端发一条
error事件
最后分享一个我在联调时常用的小手段:在服务端每个 chunk 写出时打一行带毫秒时间戳的日志,同时在浏览器控制台打印收到时间。两边一对比,就能立刻看出延迟发生在哪一段——是上游吐得慢,还是服务端写出去被堵住。这个办法比任何抓包工具都快,尤其是当你面对的是“用户说卡,但你本地复现不了”这类问题时。我个人的习惯是把这个日志开关做成配置项,只在联调期打开,线上关掉,既不影响性能,又能在出问题时临时开启定位。