SSE 这个东西,刚接触 AI 应用开发的 Java 工程师大概率都经历过一个认知曲线:一开始觉得它就是个"长连接版的 HTTP",用 Spring MVC 的SseEmitter几行代码就能跑通;用着用着发现连接管理、超时、断线重连全是坑;再往后做 AI 流式对话,发现每个接口都要手写一遍事件发送逻辑,代码重复到想砸键盘;最后上了 JDK 21 的虚拟线程,才意识到之前很多"性能优化"其实是在给平台线程擦屁股。
这篇东西就是把我自己从"显式调用 SSE"到"隐式封装 SSE"再到"虚拟线程改造"这条路上踩过的坑、做过的取舍、测出来的数据,完整地摊开讲一遍。核心关键词是Java、AI、SSE、虚拟线程、JDK 21,适合已经能用 Spring Boot 跑通接口、但还没系统梳理过流式推送方案的开发者。如果你正在做 AI 对话、AI Agent、实时日志推送这类需要服务端主动推数据的场景,这篇应该能帮你少走不少弯路。
1. 先搞清楚 SSE 在 AI 场景里到底扮演什么角色
1.1 SSE 不是 WebSocket 的廉价替代品
很多人第一次接触 SSE 是在"AI 聊天打字机效果"的需求里,然后下意识地拿它和 WebSocket 对比,得出"SSE 只能服务端推、不能客户端推,所以是个阉割版"的结论。这个判断在 AI 场景下是反的。
AI 对话的交互模型本质上是:客户端发一次请求(带上完整的对话上下文),服务端持续吐 token 直到生成结束。这是一个单向流式响应,客户端在整个生成过程中不需要再发任何数据。WebSocket 的全双工能力在这里完全是浪费,反而带来了握手升级、心跳保活、连接状态机管理这一堆额外复杂度。
SSE 基于普通 HTTP,天然复用现有的鉴权、网关、负载均衡、日志体系。你不需要为它单独开端口、单独配证书、单独做连接鉴权。这一点在微服务架构里价值极大——你的 AI 服务可能藏在网关后面,SSE 走标准 HTTP 就能穿透,WebSocket 就得额外配置升级头转发。
但 SSE 也有它真实的短板,必须提前认清:
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端到客户端单向 | 全双工 |
| 底层协议 | HTTP/1.1 或 HTTP/2 | 独立协议,需升级握手 |
| 自动重连 | 浏览器原生支持 | 需自行实现 |
| 二进制支持 | 仅文本(Base64 编码) | 原生二进制 |
| 连接数限制 | HTTP/1.1 下同域约 6 个 | 无此限制 |
| 代理兼容性 | 好 | 部分代理需特殊配置 |
那个"HTTP/1.1 下同域 6 连接"的限制是真实存在的,浏览器对同一域名的并发 HTTP 连接数有上限。如果你的页面同时开多个 AI 对话窗口,第 7 个 SSE 连接就会排队。解决办法是上 HTTP/2,多路复用直接绕开这个限制。这也是为什么生产环境的 AI 产品基本都要求 HTTP/2 起步。
1.2 AI 流式响应为什么非 SSE 不可
有人会问:我直接用Transfer-Encoding: chunked分块传输不行吗?技术上可行,但你会失去 SSE 协议自带的三样东西:
第一是事件边界语义。SSE 用\n\n明确划分消息边界,每条消息可以带event:、id:、data:字段。chunked 传输只保证字节流顺序,消息边界得你自己在应用层定义和解析,客户端解析逻辑要重写一遍。
第二是浏览器原生 EventSource API。前端拿到 SSE 流,new EventSource(url)就能用,onmessage、onerror、onopen全是标准事件。你自定义 chunked 格式,前端就得手动读ReadableStream再解析,代码量和出错概率都上去了。
第三是断线重连的标准语义。SSE 协议规定了Last-Event-ID请求头,客户端重连时会自动带上最后收到的事件 ID,服务端可以据此续传。这个机制在 AI 长对话场景里非常关键——网络抖一下,用户不希望整段回答从头再来。
提示:SSE 的
data:字段里如果包含换行,需要拆成多个data:行发送,客户端会自动用\n拼接。这个细节在发送多行 Markdown 格式的 AI 回答时特别容易踩坑。
1.3 一个被忽视的事实:AI 场景的 SSE 连接是"短命"的
传统认知里 SSE 是长连接,适合股票行情、消息通知这种持续推送场景。但 AI 对话的 SSE 连接其实生命周期很短——一次生成结束,连接就该关了。典型的一次 AI 回答生成时间在 3 到 30 秒之间,长一点的推理任务可能到一两分钟。
这个特性决定了后面所有的架构选择:连接不需要长期保活,但并发连接数会非常高。一个日活十万的 AI 产品,晚高峰可能同时有几千上万个活跃的 SSE 连接。每个连接都对应一个正在生成回答的请求,每个请求都在等大模型返回 token。
这就是虚拟线程登场的背景。传统平台线程模型下,每个 SSE 连接占一个线程,几千个连接就是几千个线程,线程栈内存、上下文切换开销直接把服务器压垮。而虚拟线程让"一个连接一个线程"这个最直观的编程模型重新变得可行。
2. 显式调用 SSE:SseEmitter 的甜与苦
2.1 最小可用版本长什么样
Spring MVC 提供了SseEmitter,这是最直接的显式调用方式。一个能跑的 AI 流式接口大概长这样:
@GetMapping(value = "/ai/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(@RequestParam String question) { SseEmitter emitter = new SseEmitter(180_000L); // 3分钟超时 executor.execute(() -> { try { // 调用大模型,逐 token 回调 aiClient.streamGenerate(question, token -> { try { emitter.send(SseEmitter.event() .data(token, MediaType.TEXT_PLAIN)); } catch (IOException e) { // 客户端断开 emitter.completeWithError(e); } }); emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }这段代码能跑,但每一行都埋着雷。我逐个拆。
2.2 超时设置:那个 180 秒不是随便写的
new SseEmitter(180_000L)里的超时时间,指的是服务端等待客户端消费的最长时间,超时后 Spring 会主动关闭连接。这个值设多少,取决于你的 AI 生成任务最长要多久。
设太短,长回答生成到一半连接被掐断,用户看到半截回答。设太长,客户端异常断开后服务端要等很久才回收资源,连接泄漏。
我的经验值是:取 P99 生成耗时的 1.5 倍。如果你的 AI 回答 P99 是 60 秒,那就设 90 秒。同时前端要配合做超时提示,不能让用户干等。
还有一个隐藏坑:Spring MVC 的异步请求超时和 SseEmitter 自身的超时是两套配置。spring.mvc.async.request-timeout如果小于 SseEmitter 的超时,会先触发前者。两个值要协调好,通常让 SseEmitter 的超时略小于全局异步超时。
2.3 线程池:别用默认的,也别乱用
上面代码里的executor是个关键决策点。如果你直接用@Async或者随便一个Executors.newCachedThreadPool(),在 AI 高并发场景下会出大问题。
AI 生成任务是IO 密集型——线程绝大部分时间在等大模型的网络响应,CPU 几乎不干活。用固定大小的线程池,线程数设小了并发上不去,设大了平台线程的内存和切换开销又扛不住。
我早期用过一个配置:核心线程 200,最大线程 500,队列容量 1000。压测的时候发现,当并发 SSE 连接超过 500,新请求全部堆在队列里,用户感知就是"点了没反应"。而 500 个平台线程的栈内存(默认 1MB)就是 500MB,还没算上下文切换的 CPU 损耗。
这个矛盾在 JDK 21 之前基本无解,只能靠调参在并发和资源之间找平衡点。虚拟线程出来后,这个平衡点直接消失了——后面第 4 节详细讲。
2.4 客户端断开的检测:IOException 不是唯一信号
emitter.send()抛IOException是最常见的断开信号,但它不是唯一的。还有几种情况:
- 客户端正常关闭连接,
send()可能不抛异常,但后续complete()会静默失败 - 网络中间设备(如负载均衡)空闲超时断连,服务端可能过一会儿才发现
- 客户端调用了
emitter.complete()对应的取消操作
稳妥的做法是注册onCompletion、onTimeout、onError三个回调,在里面统一做资源清理:
emitter.onCompletion(() -> { log.info("SSE completed, requestId={}", requestId); cleanup(requestId); }); emitter.onTimeout(() -> { log.warn("SSE timeout, requestId={}", requestId); emitter.complete(); }); emitter.onError(e -> { log.error("SSE error, requestId={}", requestId, e); cleanup(requestId); });注意:
onCompletion回调里不要再调用emitter.send(),此时连接已经关闭,会抛异常。这个回调只适合做清理。
2.5 显式调用的真正痛点:重复代码
一个 AI 产品不会只有一个流式接口。对话接口、Agent 执行接口、文档生成接口、代码补全接口……每个都要写一遍SseEmitter的创建、发送、异常处理、完成逻辑。我统计过一个中等规模的 AI 项目,SSE 相关的样板代码占了整个 controller 层的 40%。
更麻烦的是,这些样板代码一旦要改(比如统一加心跳、统一加错误事件格式),就得改几十个地方。这就是"显式调用"模式的天花板——它把 SSE 的协议细节泄漏到了每一个业务方法里。
3. 隐式封装:把 SSE 从业务代码里彻底剥离
3.1 封装的核心思路:返回值即流
隐式封装的目标是让业务代码完全感知不到 SSE 的存在。理想状态下,业务方法就返回一个Flux<String>或者Stream<String>,框架自动把它转成 SSE 流推给客户端。
Spring WebFlux 天然支持这个模型:
@GetMapping(value = "/ai/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chat(@RequestParam String question) { return aiClient.streamGenerate(question); // 返回 Flux<String> }WebFlux 会自动把Flux的每个元素作为一条 SSE 消息推出去,流结束自动关闭连接。业务代码里一行 SSE 相关的代码都没有。
但 WebFlux 是响应式栈,和 Spring MVC 是两套东西。如果你的项目是 MVC 栈,全量迁移到 WebFlux 成本太高。这时候可以用 MVC 的ResponseBodyEmitter配合自定义的HandlerMethodReturnValueHandler,把普通返回值自动包装成 SSE。
3.2 自定义注解 + 返回值处理器
我实际项目里用的方案是自定义一个@SseStream注解,配合HandlerMethodReturnValueHandler:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface SseStream { long timeout() default 180_000L; String doneEvent() default "done"; }然后写一个SseStreamReturnValueHandler,在supportsReturnType里判断方法是否标注了@SseStream且返回类型是Stream或Iterator,在handleReturnValue里把流式数据逐条send出去。
这样业务代码就变成了:
@SseStream @GetMapping("/ai/chat") public Stream<String> chat(@RequestParam String question) { return aiClient.streamGenerate(question); }SSE 的协议细节、超时、异常处理、完成事件,全部收拢到SseStreamReturnValueHandler一个类里。要改行为,改一处即可。
3.3 心跳与保活:隐式封装里最容易被忽略的一环
SSE 连接如果长时间没有数据推送,中间的负载均衡、反向代理可能会认为连接空闲而主动断开。AI 生成场景里,模型"思考"阶段可能几秒到几十秒不吐 token,这段时间连接就是静默的。
隐式封装必须内置心跳机制。我的做法是在SseStreamReturnValueHandler里启动一个定时任务,每隔 15 秒往连接里发一条注释行(SSE 协议里以:开头的行是注释,客户端会忽略):
emitter.send(SseEmitter.event().comment("heartbeat"));心跳间隔要小于中间设备的空闲超时。常见的 Nginxproxy_read_timeout默认 60 秒,所以 15 到 30 秒的心跳间隔是安全的。心跳太频繁会浪费带宽,太稀疏又起不到保活作用。
3.4 错误事件的统一格式
显式调用时,每个接口抛异常的处理方式可能都不一样,前端要写多套错误解析逻辑。隐式封装可以强制统一错误事件格式:
emitter.send(SseEmitter.event() .name("error") .data(JsonUtil.toJson(ErrorEvent.of(code, message)), MediaType.APPLICATION_JSON));前端只需要监听error事件,按统一结构解析。这个统一在 AI 场景里尤其重要,因为 AI 调用可能因为限流、模型超时、内容审核等各种原因失败,错误类型多,格式必须统一。
3.5 封装带来的新问题:调试变难了
隐式封装不是没有代价。最大的代价是调试链路变长。业务代码里看不到 SSE 的任何痕迹,出问题时你得先确认请求有没有进到SseStreamReturnValueHandler,再确认流有没有正常产出元素,再确认发送环节有没有异常。
我的应对办法是在封装层加详细的 trace 日志,每个关键节点打一条带 requestId 的日志:
log.debug("[SSE] start, requestId={}, method={}", requestId, methodName); log.debug("[SSE] element sent, requestId={}, seq={}", requestId, seq); log.debug("[SSE] completed, requestId={}, totalElements={}", requestId, seq);配合 MDC 把 requestId 透传到整个调用链,排查问题时按 requestId 一过滤,整条链路清清楚楚。
4. 虚拟线程:让"一连接一线程"重新变得合理
4.1 平台线程模型下 SSE 的真实瓶颈
在讲虚拟线程之前,先把平台线程模型下 SSE 的瓶颈量化一下。假设一台 8 核 16G 的服务器,跑一个 AI 流式服务:
- 每个 SSE 连接占一个平台线程,线程栈默认 1MB(可通过
-Xss调小到 256KB) - 每个线程在等待大模型响应时处于阻塞状态,不消耗 CPU
- 8 核 CPU 理论上能同时跑 8 个活跃线程,但 IO 阻塞的线程不占 CPU
问题在于:平台线程的创建和调度由操作系统负责,数量上去后上下文切换开销急剧增加。实测下来,当平台线程数超过 2000,即使大部分在阻塞,CPU 的sys时间也会明显上升,吞吐量开始下降。
我做过一组压测,同样的 AI 流式接口,模拟每个请求 5 秒生成时间:
| 线程模型 | 并发连接数 | 平均响应时间 | CPU 使用率 | 内存占用 |
|---|---|---|---|---|
| 平台线程池(500) | 500 | 5.2s | 45% | 1.8G |
| 平台线程池(500) | 1000 | 12.8s | 78% | 2.1G |
| 平台线程池(500) | 2000 | 超时严重 | 95% | 2.3G |
| 虚拟线程 | 2000 | 5.4s | 52% | 1.2G |
| 虚拟线程 | 5000 | 5.8s | 68% | 1.5G |
数据很直白:平台线程池在 1000 并发时响应时间就翻倍了,2000 并发直接崩。虚拟线程在 5000 并发下还能保持接近线性的响应时间。
4.2 虚拟线程为什么适合 SSE
虚拟线程的核心机制是:JVM 层面的轻量级线程,由 JVM 调度而非操作系统调度。当虚拟线程遇到阻塞操作(如网络 IO、Thread.sleep),JVM 会把它从载体线程(carrier thread,即平台线程)上卸载,让载体线程去跑其他虚拟线程。阻塞结束后再重新挂载。
这个机制完美匹配 SSE 的工作模式:线程绝大部分时间在等大模型响应(阻塞),真正干活(发送数据)的时间极短。虚拟线程让"每个连接一个线程"这个最符合直觉的编程模型重新变得可行,而且资源开销极低——一个虚拟线程初始只占几百字节,不是 1MB。
在 Spring Boot 3.2+ 里启用虚拟线程简单到离谱:
spring.threads.virtual.enabled=true这一行配置会让 Spring 的异步任务执行器、@Async、WebMvc 的异步请求处理全部切换到虚拟线程。你之前写的SseEmitter代码一行不用改,底层线程模型就换了。
4.3 虚拟线程不是银弹:这些坑必须知道
坑一:synchronized 会 pin 住载体线程。虚拟线程在synchronized块里遇到阻塞时,无法被卸载,会一直占着载体线程。JDK 21 里这个问题依然存在(JDK 24 才通过 JEP 491 解决)。如果你的 SSE 发送逻辑里有synchronized,虚拟线程的优势会大打折扣。
替代方案是用ReentrantLock代替synchronized。ReentrantLock的阻塞能被虚拟线程正确识别和卸载。
// 不推荐:会 pin 载体线程 synchronized (lock) { emitter.send(data); } // 推荐:虚拟线程可正常卸载 lock.lock(); try { emitter.send(data); } finally { lock.unlock(); }坑二:ThreadLocal 的内存开销。虚拟线程数量可能上万,如果每个线程都持有大的 ThreadLocal 对象,内存会爆。AI 场景里常见的坑是把整个对话上下文塞进 ThreadLocal。正确做法是用方法参数传递,或者用ScopedValue(JDK 21 预览特性)。
坑三:CPU 密集型任务不要用虚拟线程。虚拟线程的优势在 IO 阻塞场景。如果你的 AI 服务里有大量的本地推理、向量计算,这些 CPU 密集任务用虚拟线程反而会因为调度开销降低性能。这类任务应该用固定大小的平台线程池。
4.4 虚拟线程 + SSE 的实测调优
启用虚拟线程后,有几个参数需要重新审视:
线程池大小不再是瓶颈,但连接数上限还在。虚拟线程解决了线程资源问题,但操作系统的文件描述符(fd)数量、TCP 连接数上限依然存在。一台服务器默认 fd 上限可能是 65535,每个 SSE 连接占一个 fd,加上其他开销,实际能支撑的并发连接数在几万级别。要支撑更高并发,得调ulimit -n。
心跳机制要重新评估。虚拟线程下连接数多了,心跳定时任务的开销也会放大。如果每个连接一个独立的定时任务,上万个连接就是上万个定时任务。更好的做法是用一个全局的调度器,批量扫描需要心跳的连接。
背压处理变得更重要。虚拟线程让服务端能轻松维持大量连接,但如果客户端消费速度跟不上,服务端发送的数据会堆积在缓冲区。SSE 没有内置的背压机制,需要应用层自己控制。我的做法是在发送前检查emitter的缓冲区状态,或者用有界队列 + 丢弃策略。
5. 从显式到隐式再到虚拟线程的完整落地路径
5.1 迁移顺序:别一步到位
如果你现在维护的是一个用SseEmitter显式调用的老项目,不要想着一次性重构成隐式封装 + 虚拟线程。我的建议是分三步走:
第一步,先上虚拟线程。这一步改动最小,加一行配置就行,风险最低,收益立竿见影。先让系统在高并发下稳住,再谈代码优雅。
第二步,抽离公共逻辑。把散落在各个 controller 里的 SSE 样板代码抽到一个工具类或者基类里。这一步不改变调用方式,只是减少重复。抽的时候注意保留原有的行为,别顺手改逻辑。
第三步,引入隐式封装。在公共逻辑稳定运行一段时间后,再引入@SseStream注解和返回值处理器,把 SSE 细节彻底从业务代码里剥离。这一步改动面大,要有完善的测试覆盖。
5.2 一个完整的隐式封装实现骨架
下面是我实际项目里用的封装骨架,去掉业务细节后的核心结构:
public class SseStreamReturnValueHandler implements HandlerMethodReturnValueHandler { private final ScheduledExecutorService heartbeatScheduler; @Override public boolean supportsReturnType(MethodParameter returnType) { return returnType.hasMethodAnnotation(SseStream.class) && Stream.class.isAssignableFrom(returnType.getParameterType()); } @Override public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception { mavContainer.setRequestHandled(true); SseStream annotation = returnType.getMethodAnnotation(SseStream.class); SseEmitter emitter = new SseEmitter(annotation.timeout()); HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class); response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); response.setCharacterEncoding("UTF-8"); // 注册心跳 ScheduledFuture<?> heartbeat = heartbeatScheduler.scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event().comment("hb")); } catch (IOException e) { // 连接已断,忽略 } }, 15, 15, TimeUnit.SECONDS); emitter.onCompletion(() -> heartbeat.cancel(true)); emitter.onTimeout(() -> heartbeat.cancel(true)); // 异步消费流 Thread.startVirtualThread(() -> { try (Stream<?> stream = (Stream<?>) returnValue) { stream.forEach(item -> { try { emitter.send(SseEmitter.event().data(item, MediaType.APPLICATION_JSON)); } catch (IOException e) { throw new UncheckedIOException(e); } }); emitter.send(SseEmitter.event().name(annotation.doneEvent()).data("[DONE]")); emitter.complete(); } catch (Exception e) { try { emitter.send(SseEmitter.event().name("error") .data(JsonUtil.toJson(ErrorEvent.of("STREAM_ERROR", e.getMessage())))); } catch (IOException ignored) {} emitter.completeWithError(e); } }); } }注意几个细节:心跳用全局调度器而不是每连接一个线程;流消费用Thread.startVirtualThread显式启动虚拟线程;异常时先发 error 事件再completeWithError,保证前端能收到结构化错误。
5.3 前端配合:EventSource 的正确用法
后端封装得再好,前端用错EventSource一样白搭。几个关键点:
const es = new EventSource('/ai/chat?question=' + encodeURIComponent(q)); es.onmessage = (e) => { // 默认事件,data 是 token appendToken(e.data); }; es.addEventListener('done', (e) => { es.close(); // 收到完成事件主动关闭,别等服务端关 finishRender(); }); es.addEventListener('error', (e) => { // 注意:这里的 error 事件既可能是业务错误,也可能是连接错误 // 业务错误通过 event name 区分 if (e.data) { showError(JSON.parse(e.data)); } es.close(); }); es.onerror = (e) => { // 连接层面的错误,EventSource 会自动重连 // 如果不想重连,在这里 close if (es.readyState === EventSource.CLOSED) { showDisconnected(); } };注意:
EventSource的自动重连是双刃剑。AI 对话场景下,连接断了自动重连可能导致重复请求,用户看到重复的回答。建议在onerror里判断readyState,必要时主动close()阻止重连。
5.4 监控与可观测性
SSE 接口的监控和普通接口不一样,普通接口看 QPS 和响应时间,SSE 接口要看:
- 活跃连接数:当前有多少个 SSE 连接开着
- 连接平均存活时长:太短说明频繁断连,太长可能是连接泄漏
- 首字节时间(TTFB):从请求到第一个 token 推送的时间,直接影响用户感知
- token 推送速率:每秒推送多少条消息,反映生成速度
- 异常断开率:非正常关闭的连接占比
这些指标用 Micrometer 埋点,配合 Prometheus + Grafana 看板。我踩过的坑是只监控了连接数,没监控首字节时间,结果模型侧变慢导致用户体验下降,但监控上看不出异常。
6. 那些文档里不会写的实战经验
6.1 关于超时,我交过的学费
最开始我把 SseEmitter 超时设成 30 秒,觉得 AI 回答 30 秒足够了。结果上线后大量用户反馈"回答到一半就断了"。排查发现,长回答(比如让 AI 写一篇长文)生成时间经常超过 30 秒,连接被服务端主动关闭。
后来改成 180 秒,又出现新问题:客户端异常断开后,服务端要等 180 秒才回收资源,高峰期连接数虚高。最终的方案是动态超时——根据请求类型设置不同超时,简单问答 60 秒,长文生成 300 秒,同时在客户端做超时提示。
6.2 关于线程池,别迷信"越大越好"
我见过有项目把 SSE 的线程池核心线程数设成 2000,觉得这样能支撑高并发。实际压测发现,2000 个平台线程的上下文切换开销让 CPU 的sys时间飙到 40%,有效吞吐反而下降。
平台线程池的合理大小,经验公式是CPU核数 * (1 + 平均等待时间 / 平均计算时间)。AI 场景等待时间远大于计算时间,这个公式算出来会很大,但受限于平台线程的开销,实际不能真按这个设。这就是为什么虚拟线程是更优解——它让这个公式不再需要人工权衡。
6.3 关于断线重连,Last-Event-ID要用起来
SSE 协议支持Last-Event-ID,但很多人不用。在 AI 长对话场景里,这个机制能救命。服务端给每条消息带上递增的id,客户端重连时浏览器自动带上Last-Event-ID请求头,服务端据此从断点续传。
实现要点:服务端要缓存已发送的消息(按 requestId + seq),重连时根据Last-Event-ID找到断点,把之后的消息补发。缓存要有过期策略,不能无限增长。
6.4 关于虚拟线程,-Xss参数不再重要
平台线程时代,调小-Xss是提升并发连接数的常用手段。虚拟线程时代,这个参数对虚拟线程无效——虚拟线程的栈是堆上的对象,按需增长。你不需要再为-Xss纠结,但要注意堆内存的监控,因为虚拟线程的栈现在占的是堆空间。
6.5 一个反直觉的结论:SSE 不一定比轮询好
最后说个可能得罪人的观点。如果你的 AI 场景对实时性要求不高(比如生成结果可以等几秒),且并发量不大,短轮询可能比 SSE 更简单可靠。SSE 带来的连接管理、心跳、断线重连、代理兼容性这些问题,在低并发场景下是不必要的复杂度。
SSE 真正的价值区间是:高并发 + 强实时性 + 单向推送。三个条件缺一个,都要重新评估是否值得上 SSE。我见过不少项目为了"技术先进"硬上 SSE,结果维护成本远超收益。
选择技术方案的标准从来不是"先不先进",而是"合不合适"。虚拟线程让 SSE 在高并发场景下的成本大幅降低,但这不意味着所有场景都该用 SSE。想清楚你的真实需求,再决定用哪套方案。