
在Java后端圈子里SSEServer-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。要提升并发就得把线程池开大。但线程是操作系统资源每个线程默认栈大小1MBLinux上开1000个线程就是1GB内存而且线程上下文切换的开销会随着数量增长急剧上升。我实测过在4核8G的机器上把线程池开到800CPU有相当一部分时间花在上下文切换上有效吞吐反而下降。这就是传统阻塞式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;charsetUTF-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;charsetUTF-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.tracePinnedThreadsfull它会把钉住载体线程的堆栈打出来。上线前跑一遍能发现不少隐藏问题。5. 从显式到隐式再到虚拟线程的完整演进对照5.1 三个版本的代码量和性能对比我把三个版本的核心指标整理成表方便你判断自己的项目该走到哪一步维度显式Servlet版隐式封装版虚拟线程版业务代码行数约80行/接口约15行/接口约15行/接口并发上限4核8G约180约1805000单连接内存开销约1MB线程栈约1MB约几KB断开处理手写封装层统一封装层统一上下文切换开销高高极低代码可维护性差好好JDK要求8821可以看到隐式封装解决的是可维护性虚拟线程解决的是并发能力两者是正交的应该都做。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;charsetUTF-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要重新设计传统项目里常用MDCMapped 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导致载体线程被钉死整个服务雪崩。技术升级这件事稳比快重要。