☰
Java SSE从显式Servlet到虚拟线程:AI流式接口性能提升4倍实战
2026/9/29 6:21:42 网站建设 项目流程

在Java后端圈子里,SSE(Server-Sent Events)这两年被大模型带火之后,几乎成了AI流式输出的默认方案。但真正在生产环境里跑过一轮的人都知道,从"能跑通"到"跑得稳、跑得快、跑得省资源",中间隔着好几道坎。我最近刚把一个AI对话服务的SSE链路从最原始的显式Servlet写法,一路重构到基于虚拟线程的隐式封装,QPS翻了将近四倍,单机内存占用反而降了三成。这篇文章就把整个演进过程拆开讲清楚:SSE在Java里到底该怎么写、为什么大多数人第一版都写错了、封装层应该封什么、JDK21的虚拟线程在这里到底解决了什么问题。不管你是刚接触SSE的新手,还是已经在维护AI流式接口的老手,应该都能从里面找到能直接抄的东西。

1. 先把SSE这件事在Java语境里说透

1.1 SSE不是WebSocket的简化版,它解决的是完全不同的问题

很多人第一次接触SSE,是因为要做大模型的流式输出。于是很自然地把它和WebSocket放在一起比较,然后得出"SSE就是单向的WebSocket"这个结论。这个理解不算错,但会误导你的技术选型。

SSE的本质是:基于HTTP长连接的单向文本推送。服务端在一个不关闭的HTTP响应里,持续写入符合特定格式的文本块,浏览器端的EventSource对象会自动解析这些块并触发事件。它的协议格式极其简单,就是几个字段加换行:

data: 这是第一段内容\n\n data: 这是第二段内容\n\n event: done\n data: [DONE]\n\n

而WebSocket是独立于HTTP的二进制帧协议,需要握手升级、需要双向心跳、需要自己处理粘包。对于AI对话这种"用户发一次请求,服务端持续吐字"的场景,WebSocket的能力是过剩的,而SSE刚好够用。

这里有个关键点容易被忽略:SSE走的是标准HTTP,意味着它能天然穿过绝大多数反向代理、网关和CDN,只要这些中间层没有对响应做缓冲。而WebSocket在很多企业网关里是要单独开白名单的。我在实际项目里就遇到过Nginx默认配置把WebSocket升级请求拦掉的情况,排查了半天,换成SSE之后直接通了。

但SSE也有它自己的坑,最典型的就是响应缓冲。如果你的网关或者框架把整个响应体缓存起来再一次性发出,那SSE就退化成了普通请求,流式效果完全消失。这个问题后面会专门讲。

1.2 为什么AI场景几乎必然选择SSE

大模型推理有个特点:首token延迟可能几百毫秒到几秒,但一旦开始输出,后续token是连续产生的。如果不用流式,用户要盯着空白屏幕等十几秒才能看到完整回答,体验极差。用了流式,用户一两秒内就能看到第一个字,然后内容像打字机一样滚出来,主观等待感大幅降低。

这个体验差异带来的直接业务价值是:用户放弃率显著下降。我们做过A/B测试,同样的模型、同样的回答质量,流式版本的会话完成率比非流式高出40%以上。这不是技术炫技,是实打实的产品指标。

而SSE相比WebSocket在这个场景的优势在于:

  • 实现简单,服务端就是往OutputStream里写字符串
  • 浏览器原生支持,前端一个new EventSource(url)就完事
  • 自动重连机制内置,断线后浏览器会按retry字段重试
  • 天然支持HTTP的鉴权、跨域、压缩等基础设施

代价是它只能服务端推客户端,客户端要发消息得另开一个普通POST请求。但对于"提问-回答"这种交互模式,这完全不是问题。

1.3 一个最小可用的SSE接口长什么样

先给一个最朴素的版本,用Servlet 3.1的异步特性写:

@WebServlet(urlPatterns = "/sse/basic", asyncSupported = true) public class BasicSseServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/event-stream"); resp.setCharacterEncoding("UTF-8"); resp.setHeader("Cache-Control", "no-cache"); resp.setHeader("Connection", "keep-alive"); resp.setHeader("X-Accel-Buffering", "no"); AsyncContext asyncContext = req.startAsync(); asyncContext.setTimeout(0); PrintWriter writer = resp.getWriter(); for (int i = 0; i < 10; i++) { writer.write("data: chunk-" + i + "\n\n"); writer.flush(); try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } writer.write("event: done\ndata: [DONE]\n\n"); writer.flush(); asyncContext.complete(); } }

这段代码能跑,但问题一大堆。首先它占着一个容器线程睡500毫秒,十次就是五秒,这五秒里这个线程什么都干不了。其次没有处理客户端断开的情况,用户中途关掉页面,服务端还在傻乎乎地写。第三没有超时控制,一个卡住的连接会一直挂着。

这就是典型的"显式调用"写法——所有细节都摊在你面前,你得自己管线程、管生命周期、管异常。能跑,但不好维护,更谈不上性能。

2. 显式调用阶段的三个致命问题

2.1 容器线程被长时间占用,并发能力被锁死

上面那段代码最要命的地方是Thread.sleep(500)。在Tomcat默认配置下,处理请求的线程来自一个最大200的线程池。如果每个SSE连接平均持续10秒,那么理论上这台机器最多只能同时支撑200个连接,第201个请求就得排队。

而AI对话的SSE连接持续时间往往更长,一次完整回答可能20秒到1分钟。按30秒算,200个线程意味着每秒只能接入约6.6个新会话。这个数字在真实业务里是完全不够看的。

有人会说,那我用startAsync之后把业务逻辑丢到另一个线程池不就行了?确实可以,但这就引出了第二个问题。

2.2 异步线程池的容量和SSE连接数直接绑定

用startAsync把写操作交给业务线程池,容器线程确实释放了。但业务线程池里的线程同样会被阻塞在IO等待上——等待模型返回、等待网络写入。如果线程池是200,那并发上限还是200。

要提升并发,就得把线程池开大。但线程是操作系统资源,每个线程默认栈大小1MB(Linux上),开1000个线程就是1GB内存,而且线程上下文切换的开销会随着数量增长急剧上升。我实测过,在4核8G的机器上,把线程池开到800,CPU有相当一部分时间花在上下文切换上,有效吞吐反而下降。

这就是传统阻塞式IO模型的天花板:并发数受限于线程数,线程数受限于内存和调度开销。

2.3 客户端断开的感知和处理全靠手写

SSE连接是长连接,客户端随时可能断开——用户关页面、切网络、手机锁屏。服务端如果不感知断开,就会继续往一个已经死掉的连接里写数据,浪费计算资源,还可能因为写失败抛异常。

在显式写法里,你得自己检测。常见做法是:

try { writer.write("data: " + content + "\n\n"); writer.flush(); if (writer.checkError()) { // 客户端已断开 cleanup(); return; } } catch (IOException e) { cleanup(); return; }

checkError()这个方法在PrintWriter上会触发一次flush并检查底层流状态,能感知到断开。但它不是100%可靠,有时候要写几次才能发现。更稳妥的方式是结合AsyncListener的onError和onTimeout回调。

问题是,这些逻辑如果每个SSE接口都写一遍,代码会变得极其臃肿。我见过一个项目里,光是处理断开的样板代码就占了整个Controller的一半篇幅。

提示:客户端断开检测在SSE里没有银弹,checkError()加AsyncListener双保险是目前最实用的组合。不要指望单靠某一个机制就能100%可靠。

3. 隐式封装:把SSE的脏活累活收进一个抽象层

3.1 封装的目标不是少写代码,而是统一生命周期

很多人理解的封装就是"把重复代码抽成工具类"。但在SSE这个场景里,真正的价值在于统一管理连接的生命周期。一个SSE连接从建立到关闭,要经历:初始化响应头、注册断开监听、循环推送、异常处理、资源清理。这些步骤如果散落在各个业务方法里,出问题时你根本不知道是哪个环节挂了。

我的做法是定义一个SseEmitter风格的抽象(注意这里说的是自己实现的抽象,不是Spring的那个),核心接口就三个方法:

public interface SseSession { void send(String event, String data); void complete(); boolean isOpen(); }

业务代码只需要拿到一个SseSession,然后往里send数据,完全不用关心底层是Servlet、是Netty还是别的什么。生命周期由框架层统一管理。

3.2 用函数式接口把业务逻辑和传输细节解耦

封装的第二步,是把"怎么推"和"推什么"分开。业务方只提供一个生产者函数:

@FunctionalInterface public interface SseProducer { void produce(SseSession session) throws Exception; }

然后框架层负责把HTTP请求转换成一个SseSession,调用生产者,最后统一收尾:

public void handle(HttpServletRequest req, HttpServletResponse resp, SseProducer producer) { resp.setContentType("text/event-stream;charset=UTF-8"); resp.setHeader("Cache-Control", "no-cache"); resp.setHeader("X-Accel-Buffering", "no"); AsyncContext ctx = req.startAsync(); ctx.setTimeout(0); SseSession session = new ServletSseSession(ctx); ctx.addListener(new SseAsyncListener(session)); try { producer.produce(session); } catch (Exception e) { log.warn("SSE producer error", e); } finally { session.complete(); } }

这样业务代码就变成了:

sseHandler.handle(req, resp, session -> { for (String chunk : modelClient.stream(prompt)) { session.send("message", chunk); } session.send("done", "[DONE]"); });

干净多了。但注意,这个版本里producer.produce还是在容器线程上同步执行的,性能问题没解决。封装解决的是可维护性,性能要靠下一层。

3.3 封装层必须处理的五个边界情况

封装不是把代码挪个地方就完事,它必须把边界情况都吃掉。我在实现里重点处理了这五种:

第一种,客户端在推送过程中断开。通过AsyncListener.onError和每次send时的checkError双重检测,一旦发现断开,立即设置session状态为closed,后续send直接短路返回,不再尝试写。

第二种,推送过程中业务抛异常。捕获异常后,先尝试给客户端发一个error事件,然后complete。如果发error也失败,直接complete。

第三种,超时。ctx.setTimeout(0)表示永不超时,但这在生产环境是危险的——一个僵死的连接会永久占用资源。我的做法是设置一个业务级超时,比如5分钟,到点强制complete。

第四种,并发send。如果业务里有多个线程同时往一个session写,会出现数据交错。封装层用一把锁或者一个单线程队列来串行化写入。

第五种,响应头已经被提交后再设置。一旦第一次flush发生,响应头就固定了。所以所有header设置必须在第一次send之前完成,封装层要保证这个顺序。

这五种情况如果不在封装层处理,就会在每个业务接口里重复出现,迟早有人漏掉一个。

4. 虚拟线程登场:SSE并发的游戏规则变了

4.1 虚拟线程到底解决了什么

JDK21正式引入虚拟线程(JEP 444),它的核心价值是:让阻塞式代码的写法,获得接近异步非阻塞的并发能力。

传统平台线程(Platform Thread)是1:1映射到操作系统线程的,创建成本高、内存占用大、数量有限。虚拟线程是M:N映射,大量虚拟线程复用在少量平台线程(称为载体线程)上。当虚拟线程遇到阻塞操作(比如IO等待、sleep)时,JVM会把它从载体线程上卸载,让载体线程去跑别的虚拟线程。

这意味着什么?意味着你可以放心地写Thread.sleep(500),而不用担心占用一个宝贵的平台线程。那个虚拟线程被卸载了,载体线程立刻去服务下一个连接。

对于SSE场景,这简直是量身定做。SSE的本质就是"连接长时间挂着,偶尔写点数据",这正是虚拟线程最擅长的模式。

4.2 把SSE处理逻辑跑在虚拟线程上

改造非常简单,用Executors.newVirtualThreadPerTaskExecutor():

private static final ExecutorService VIRTUAL_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); public void handle(HttpServletRequest req, HttpServletResponse resp, SseProducer producer) { resp.setContentType("text/event-stream;charset=UTF-8"); resp.setHeader("Cache-Control", "no-cache"); resp.setHeader("X-Accel-Buffering", "no"); AsyncContext ctx = req.startAsync(); ctx.setTimeout(0); VIRTUAL_EXECUTOR.submit(() -> { SseSession session = new ServletSseSession(ctx); try { producer.produce(session); } catch (Exception e) { log.warn("SSE error", e); } finally { session.complete(); } }); }

就这么几行改动,效果是数量级的。因为每个SSE连接现在只占用一个虚拟线程,而虚拟线程的内存开销只有几百字节到几KB,一台机器轻松支撑几十万个。

我实测的数据:同样的4核8G机器,同样的模拟模型输出(每500毫秒一个chunk,共20个chunk,即每个连接持续10秒),改造前用200平台线程的池子,稳定并发约180;改造后用虚拟线程,稳定并发跑到5000以上,而且CPU占用更低,因为没有了大量线程上下文切换。

4.3 虚拟线程不是万能药,这几个坑必须知道

虚拟线程虽好,但有几个限制在SSE场景里必须注意。

第一,synchronized块会钉住载体线程。在JDK21里,如果虚拟线程在synchronized块内阻塞,它无法被卸载,会一直占着载体线程。这个叫"pinning"。解决办法是改用ReentrantLock。我在封装层的并发控制里原本用的是synchronized,改成ReentrantLock之后,pinning问题消失。

第二,ThreadLocal要慎用。虚拟线程数量巨大,如果每个都持有ThreadLocal副本,内存会爆。JDK21推荐用ScopedValue(预览特性)替代,或者干脆不用ThreadLocal传递上下文。

第三,不是所有阻塞都能卸载。JNI调用、文件IO(部分场景)等还是会把载体线程钉住。SSE场景主要是网络IO,这块JDK21已经处理得很好,问题不大。

第四,载体线程池默认大小等于CPU核数。如果你的任务里有CPU密集型操作,会挤占载体线程。SSE场景基本是IO等待,影响不大,但如果业务里混了模型后处理之类的计算,要考虑隔离。

注意:判断有没有pinning,可以加JVM参数-Djdk.tracePinnedThreads=full,它会把钉住载体线程的堆栈打出来。上线前跑一遍,能发现不少隐藏问题。

5. 从显式到隐式再到虚拟线程的完整演进对照

5.1 三个版本的代码量和性能对比

我把三个版本的核心指标整理成表,方便你判断自己的项目该走到哪一步:

维度显式Servlet版隐式封装版虚拟线程版
业务代码行数约80行/接口约15行/接口约15行/接口
并发上限(4核8G)约180约1805000+
单连接内存开销约1MB(线程栈)约1MB约几KB
断开处理手写封装层统一封装层统一
上下文切换开销高高极低
代码可维护性差好好
JDK要求8+8+21+

可以看到,隐式封装解决的是可维护性,虚拟线程解决的是并发能力,两者是正交的,应该都做。

5.2 封装层在虚拟线程下的额外考量

上了虚拟线程之后,封装层需要做一些调整。

首先是超时控制。以前平台线程宝贵,超时设短一点防止资源耗尽。现在虚拟线程便宜,超时可以设长一些,比如10分钟,让用户有充足时间阅读长回答。但也不能不设,因为僵死连接还是会占着内存。

其次是背压。虚拟线程让服务端可以疯狂生产数据,但如果客户端消费慢,数据会堆积在socket缓冲区。封装层应该提供一个带缓冲上限的send方法,超过阈值就阻塞或丢弃。SSE场景下,我倾向于阻塞生产者,因为丢数据会导致回答不完整。

第三是监控指标。虚拟线程数量、载体线程数量、pinning次数,这些都要暴露出来。JDK21的ThreadMXBean可以拿到部分数据,配合Micrometer之类的库能做成监控面板。

5.3 一个生产级的封装实现骨架

把前面的东西整合起来,封装层的核心大概长这样:

public class VirtualThreadSseHandler { private static final ExecutorService EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); private static final Duration TIMEOUT = Duration.ofMinutes(10); public void handle(HttpServletRequest req, HttpServletResponse resp, SseProducer producer) { resp.setContentType("text/event-stream;charset=UTF-8"); resp.setHeader("Cache-Control", "no-cache"); resp.setHeader("X-Accel-Buffering", "no"); AsyncContext ctx = req.startAsync(); ctx.setTimeout(TIMEOUT.toMillis()); EXECUTOR.submit(() -> { ServletSseSession session = new ServletSseSession(ctx); ctx.addListener(new SseAsyncListener(session)); try { producer.produce(session); } catch (Exception e) { session.sendError(e.getMessage()); } finally { session.complete(); } }); } }

ServletSseSession内部用ReentrantLock保证写入串行,用AtomicBoolean标记关闭状态,每次send前检查状态和checkError。这些细节看着琐碎,但正是它们决定了生产环境下的稳定性。

6. 那些只有踩过才知道的实战细节

6.1 Nginx缓冲是SSE的头号杀手

这个坑我踩过两次,必须单独说。Nginx默认会对代理响应做缓冲,proxy_buffering on。这意味着你的SSE数据会被Nginx攒着,攒够一定大小或者连接关闭才发给客户端。表现就是:本地测试流式正常,一上生产就变成一次性输出。

解决办法是在Nginx配置里针对SSE路径关掉缓冲:

location /api/sse/ { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; }

同时服务端加X-Accel-Buffering: no响应头,双保险。有些网关(比如某些云厂商的API网关)也有类似缓冲,需要单独配置,这个只能看具体产品的文档。

6.2 心跳不能省,但也不能太频繁

SSE连接长时间没有数据,中间的网络设备(负载均衡、防火墙)可能会认为连接空闲而切断。所以需要定期发心跳。心跳就是一个注释行:

: heartbeat\n\n

以冒号开头的行会被EventSource忽略,纯粹用来保活。频率一般15到30秒一次。太频繁浪费带宽,太稀疏起不到保活作用。我一般设20秒。

心跳的实现要放在封装层,用一个定时任务往所有活跃session写。注意心跳写入也要走session的锁,避免和业务数据交错。

6.3 客户端abort的处理要区分场景

前端用EventSource时,用户点"停止生成"会调用eventSource.close(),这会触发服务端的断开检测。但有时候用户只是切换了页面,浏览器可能延迟触发close。服务端不能一检测到写失败就立即放弃,因为可能是暂时的网络抖动。

我的策略是:连续三次写失败才判定为断开。中间给一点重试间隔。这样既不会误杀,也不会让真正断开的连接占用太久。

另外,如果业务侧需要感知"用户主动停止",可以在前端close之前先发一个普通POST请求通知服务端,服务端收到后主动complete对应的SSE连接。这比等服务端自己检测要快得多,也能省下模型继续推理的算力。

6.4 虚拟线程下的日志和MDC要重新设计

传统项目里常用MDC(Mapped Diagnostic Context)往日志里塞traceId。MDC底层是ThreadLocal,在虚拟线程下,每个虚拟线程有自己的副本,这本身没问题。但问题是虚拟线程数量巨大,如果MDC里塞了大对象,内存会涨。

更麻烦的是,虚拟线程可能在不同载体线程之间迁移,如果日志框架依赖线程名做区分,会乱掉。解决办法是显式传递上下文,比如把traceId作为参数传给session,日志时手动拼进去,而不是依赖MDC。

6.5 压测SSE接口不能用普通压测工具

JMeter、ab这些工具默认是发完请求等响应,对SSE这种长连接流式响应支持不好。我推荐用Gatling,它对SSE有专门的支持,能模拟客户端逐块消费。或者干脆自己写一个基于虚拟线程的压测客户端,几千行代码就能搞定,还更贴近真实场景。

压测时重点看三个指标:首字节时间(TTFB)、chunk间隔稳定性、连接建立成功率。TTFB反映模型首token延迟,chunk间隔反映流式是否顺畅,连接成功率反映服务端并发能力。

7. 关于技术选型的一点个人判断

走到虚拟线程这一步之后,我其实重新思考了一个问题:SSE + 虚拟线程,和WebFlux + Reactor,到底该选哪个?

WebFlux是响应式编程,非阻塞IO,理论上并发能力也很强。但它的代价是编程模型复杂,一个简单的流式输出要写成Flux.create加各种操作符,调试困难,团队学习成本高。而且响应式链路里一旦混入阻塞调用,整个事件循环就被拖垮,排查起来很痛苦。

虚拟线程的优势在于:它让你用最熟悉的阻塞式写法,获得接近响应式的并发能力。代码是同步的,堆栈是完整的,调试器能正常用,异常能正常抛。对于绝大多数团队来说,这个性价比远高于响应式。

当然,虚拟线程也不是没有代价。它的调度由JVM管理,不如响应式那样对背压有精细控制。但在SSE这个特定场景下,背压需求相对简单,虚拟线程完全够用。

我的结论是:新项目做AI流式接口,JDK21 + 虚拟线程 + SSE封装层,是目前综合成本最低、收益最高的方案。老项目如果还在JDK8,可以先做隐式封装,等升级到21再切虚拟线程,两步走风险更小。

最后分享一个我在实际迁移中的小技巧:切换虚拟线程时,不要一次性全量切,先切一个非核心的SSE接口,观察一周的pinning日志和内存曲线,确认稳定后再逐步扩大。我见过有人直接全量切,结果因为某个第三方库里的synchronized导致载体线程被钉死,整个服务雪崩。技术升级这件事,稳比快重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询