OkHttp 这个名字很多人第一反应是 Android 里的网络库。但把它放到 Spring Boot 服务端配合 SSE 做流式推送我自己实测下来吞吐和内存表现都非常能打。这篇文章不是纯概念科普而是把我从选型到落地、从压测到排障的完整过程记录下来包含可以直接照抄的代码、参数和踩坑清单适合正在做 AI 流式应答对接、或者被轮询接口压垮过的同学参考。我会从方案选择、依赖配置、服务端 SseEmitter、客户端 OkHttp 消费、性能调优、常见排障六个部分展开把每个关键选择的为什么讲清楚。1. 流式推送的选型逻辑为什么是 SSE 和 OkHttp1.1 三种推送方案的对比轮询、WebSocket、SSE做实时推送的时候大部分人第一个想到的是 WebSocket第二个想到的是定时轮询。但在这套 Spring Boot 流式推送方案里我选了 SSEServer-Sent Events服务端推送事件原因很直接这是个单向流式传输场景。方案方向连接开销协议复杂度重连机制适合场景轮询客户端主动拉高频繁建连无无天然重试低频、无实时性要求WebSocket双向中高需处理握手/分帧需自己写聊天、实时协作等高交互场景SSE服务端推低一条 HTTP 长连接低纯文本协议规范自带 Last-Event-ID流式文本、事件通知、AI 增量响应SSE 是建立在 HTTP 协议之上的服务端只要返回text/event-stream类型的响应保持连接不断开就能持续往客户端写数据。它的文本协议很简单每条事件以注释行:、event:、data:、id:等字段构成以空行分隔。这不光是给浏览器用的任何一个 HTTP 客户端都能消费这种协议。为什么不用 WebSocket因为这里只有一个方向的数据流——服务端把上游 AI 模型或事件源的输出推给调用方调用方不需要回传消息。如果用 WebSocket等于把简单场景做复杂了握手、心跳、帧格式都要自己照顾。当然如果后续业务需要双向交互再升级到 WebSocket 也来得及但没必要一开始就上重武器。1.2 把 OkHttp 从移动端搬到服务端它解决什么OkHttp 在移动端是家喻户晓的 HTTP 客户端但很多人忽略了它同样是很优秀的服务端 HTTP 客户端。Spring Boot 生态里大家默认会用 RestClient 或 WebClient这两种工具学习曲线不高但在流式长连接场景下OkHttp 有几点明显优势。第一是连接复用效率高。OkHttp 内部自带连接池ConnectionPool默认支持 5 个保持长连接的主机每个主机的空闲连接可以复用。对于频繁访问上游 SSE 接口的服务端程序来说连接建连成本被压得很低。第二是异步模型成熟。OkHttp 的异步回调基于 Dispatcher 线程池 Callback不阻塞业务线程而且可以通过enqueue快速发起大量请求。它不像 WebClient 那样强依赖 Reactor 的响应式编程思维对从传统 IO 模型迁移过来的团队更友好。第三是 SSE 的原生支持。OkHttp 官方有okhttp-sse扩展包提供了EventSource抽象直接按标准解析event:、data:、id:字段。虽然我们自己用纯 HTTP 流式读取也能解析 SSE但EventSource省掉了很多协议边缘情况比如多行 data 的拼接、注释行的忽略、重连 ID 的跟踪。我实际项目里的场景是这样Spring Boot 服务作为中间层上游是大模型的流式接口下游是前端页面。前端用浏览器原生 EventSource 接收服务端要用 HTTP 客户端去把上游的流接住。这一接一推正好是 SSE OkHttp 的重点。2. 依赖落位与基础配置Spring Boot 3.x OkHttp 4.x2.1 pom 坐标与版本选择先看依赖配置。我用的是 Spring Boot 3.2 OkHttp 4.12两个版本都是各自稳定版里比较新的。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- OkHttp 核心 -- dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency !-- SSE 事件流解析扩展 -- dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp-sse/artifactId version4.12.0/version /dependency这里说一个选版本的思路。OkHttp 4.x 从 Java 8 开始支持包名从 3.x 的okhttp3延续到 4.x代码风格变化不大。4.12.0 是 OkHttp 4 系列最后一个比较稳定的版本后续官方重心转移到 OkHttp 5.x。如果你的 JDK 版本较新也可以直接用 5.x但我建议生产环境先用 4.12.0因为它的社区案例最多坑基本都被踩平了。okhttp-sse这个扩展包不能少EventSource相关类都在这个模块里。如果你只引入okhttp核心包是找不到okhttp3.sse.EventSources这个工厂类的。2.2 读超时为什么必须设成 0配置 OkHttpClient 的时候有一个参数极其容易踩坑readTimeout。我记得第一次对接上游 SSE 接口压测大概跑了 20 秒连接就断了客户端上报SocketTimeoutException。排查了半天发现是 OkHttp 默认读超时 10 秒。SSE 是长连接但不是每条消息都会在固定时间内到达。假设上游两批数据之间间隔 30 秒而你的读超时是 10 秒那这个连接必然被判死。OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须关掉读超时 .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .connectionPool(new ConnectionPool(20, 5, TimeUnit.MINUTES)) .build();readTimeout(0)表示无限等待对于 SSE 长连接来说这才是正确姿势。但注意这只是不主动掐断空闲连接不意味着连接永远不会出问题——TCP 层断网、服务端宕机这些异常仍会导致连接死掉这种问题要靠在服务端不断发送心跳帧来规避第 3 部分会讲。3. 服务端 SseEmitter 的实现细节从能跑到跑稳3.1 一个可运行的核心链路代码Spring Boot 服务端推送 SSE 的标准方式是用SseEmitter它由 Spring MVC 框架提供。看一个最基本的流式接口RestController RequestMapping(/sse) public class SseController { private final ChatService chatService; public SseController(ChatService chatService) { this.chatService chatService; } GetMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(RequestParam String prompt) { return chatService.streamChat(prompt); } }Controller 返回类型是SseEmitterproduces text/event-stream表示响应按 SSE 格式发送。核心逻辑在 Service 里Service public class ChatService { private final ExecutorService executor Executors.newFixedThreadPool(16); public SseEmitter streamChat(String prompt) { SseEmitter emitter new SseEmitter(120_000L); executor.execute(() - { try { emitter.send(SseEmitter.event() .name(start) .data(Map.of(status, begin))); for (int i 0; i 100; i) { String chunk queryModel(prompt, i); emitter.send(SseEmitter.event() .id(String.valueOf(i)) .name(delta) .data(Map.of(index, i, content, chunk))); TimeUnit.MILLISECONDS.sleep(100); } emitter.send(SseEmitter.event() .name(done) .data(finished)); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } private String queryModel(String prompt, int index) { // 访问上游模型接口或者读取本地生成结果 return chunk- index; } }这段代码有几个细节值得注意。SseEmitter(120_000L)构造参数是超时时间单位毫秒。它决定这个异步请求最多能挂多久超过 120 秒没有 complete服务端会自动断开。这个值要根据你的业务量来设如果流式输出可能超过 2 分钟就继续调大。为什么SseEmitter要配合线程池使用因为 SSE 连接留在 servlet 容器的工作线程上会直接把线程占死。假设 Tomcat 默认 200 个线程你来了 200 个 SSE 连接应用就挂在那里了。所以正确的玩法是把耗时任务丢到独立线程池执行SseEmitter立刻返回servlet 容器线程去处理下一个请求。3.2 连接生命周期onCompletion、onTimeout、onErrorSseEmitter提供了三个回调注册方法分别是onCompletion正常完成、onTimeout超时、onError异常。很多初写 SSE 的同学会忽略这三个回调但这三个回调决定了连接断了之后系统能不能及时清理资源。SseEmitter emitter new SseEmitter(120_000L); emitter.onCompletion(() - { log.info(SSE 连接正常结束); // 释放上游连接、清理线程池任务 }); emitter.onTimeout(() - { log.warn(SSE 连接超时主动关闭); emitter.complete(); }); emitter.onError(ex - { log.error(SSE 连接异常, ex); emitter.completeWithError(ex); });我的经验是onTimeout里必须调用complete或completeWithError否则连接会被容器挂在那里直到 tomcat 的异步请求超时机制再次强制回收。资源泄露往往就是这么产生的一次两次看不出来连接多了之后文件句柄和内存占用会悄悄往上涨。还有一个隐藏问题SseEmitter的 send 方法不是线程安全的。如果你在任务线程里发送事件的同时超时线程也在触发回调可能会出现并发异常。所以多个线程共享同一个SseEmitter时要小心最好就把发送逻辑收敛在单一线程内执行或者对 send 方法加锁。3.3 心跳注释帧和背压控制SSE 最怕的问题就是连接没死、但链路中间设备觉得它死了。Nginx、SLB、防火墙这些中间组件往往会设置空闲超时默认几百秒不等。如果应用长时间不发送数据就会被中间层掐断。解法是定时发送心跳。SSE 协议里有一个特殊设计以冒号开头的信息被称为注释帧客户端解析时会忽略不触发任何事件但它足以证明连接还活着。ScheduledExecutorService heartbeatExecutor Executors.newSingleThreadScheduledExecutor(); ScheduledFuture? heartbeatTask heartbeatExecutor.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().comment(heartbeat)); } catch (IOException e) { emitter.completeWithError(e); } }, 15, 15, TimeUnit.SECONDS); emitter.onCompletion(() - heartbeatTask.cancel(true));这段代码里我用comment(heartbeat)发送了一条:heartbeat注释帧间隔 15 秒。这个时间要小于中间组件的空闲超时阈值一般设置 10-30 秒都是合理的。但心跳不是越多越好。每个心跳包都要经过线程池调度、网络传输、客户端解析纯属无意义开销。我曾经为了保险把心跳间隔设成 3 秒一次结果压测时多出了不少无谓的小包白白增加了网络 IO。后来改成 15 秒一次完全够用。再说背压。SSE 服务端往外推数据如果生产速度远大于消费速度缓冲区会不断堆积。比如上游模型每秒生成 1000 个 token而下游客户端的 TCP 窗口只有 100 KB堆积到一定程度就会导致内存溢出或者连接假死。控制背压最直接的办法是限速或分块发送。我通常的做法是在循环里加一个简单的速率控比如每发送 N 条数据后主动Thread.sleep(10)把单条事件之间的间隔拉开。更精确的方法是用令牌桶或信号量做速率限制但大多数场景下sleep 大法已经足够。// 每发送 20 条数据休息 20 毫秒限制生产速率 if (i % 20 0) { TimeUnit.MILLISECONDS.sleep(20); }4. 客户端用 OkHttp EventSource 消费流代码与重连策略4.1 EventSource 与普通异步请求的区别服务端能推客户端要能接。OkHttp 消费 SSE 有两种路子一种是用普通异步请求 手动读取响应体另一种是用okhttp-sse提供的EventSource。强烈推荐第二种。EventSource的用法非常直观import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import okhttp3.sse.EventSources; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class SseClient { private final OkHttpClient client; public SseClient(OkHttpClient client) { this.client client; } public void subscribe(String url, EventHandler handler) { Request request new Request.Builder() .url(url) .header(Accept, text/event-stream) .header(Cache-Control, no-cache) .build(); EventSources.createFactory(client).newEventSource(request, new EventSourceListener() { Override public void onOpen(NotNull EventSource eventSource, NotNull Response response) { log.info(SSE 已连接, status{}, response.code()); } Override public void onEvent(NotNull EventSource eventSource, Nullable String id, Nullable String type, NotNull String data) { handler.onEvent(id, type, data); } Override public void onClosed(NotNull EventSource eventSource) { log.info(SSE 连接正常关闭); } Override public void onFailure(NotNull EventSource eventSource, Nullable Throwable t, Nullable Response response) { log.error(SSE 连接失败, t, response); } }); } }onEvent里的type对应服务端发送的事件名data是完整的事件数据字段。如果服务端用SseEmitter.event().name(delta).data(payload)推送这里收到的type就是deltadata就是payload的字符串形式。这里有个细节OkHttp 解析 SSE 时会自动把多行data:字段拼接并用换行符分隔。如果你往服务端发送 JSON 数据建议把整个 JSON 放到一行data:字段里避免解析端还要做额外的拼接处理。4.2 断线重连指数退避与 Last-Event-IDOkHttp 的EventSource只负责帮你把协议解析好不负责断线重连。这是容易翻车的地方。来看重连逻辑。标准做法是连接失败或异常断开后等待一段时间再重试重试间隔按指数退避递增避免对服务端造成连接风暴。import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class ResilientSseClient { private final OkHttpClient client; private final ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); private final AtomicInteger retryCount new AtomicInteger(0); public void connectWithRetry(String url, EventHandler handler) { Request request new Request.Builder() .url(url) .header(Accept, text/event-stream) .header(Cache-Control, no-cache) .header(Last-Event-ID, handler.getLastEventId()) // 断点续传 .build(); EventSources.createFactory(client).newEventSource(request, new EventSourceListener() { Override public void onEvent(EventSource eventSource, String id, String type, String data) { if (id ! null) { handler.setLastEventId(id); // 记录最近一条事件 ID } handler.onEvent(id, type, data); } Override public void onClosed(EventSource eventSource) { retryCount.set(0); } Override public void onFailure(EventSource eventSource, Throwable t, Response response) { if (response ! null response.code() 503) { // 服务端过载可以读 Retry-After 决定等待时间 String retryAfter response.header(Retry-After); long delay retryAfter ! null ? Long.parseLong(retryAfter) : 1; scheduleReconnect(url, handler, delay); } else { long baseDelay Math.min(60, 1 retryCount.getAndIncrement()); scheduleReconnect(url, handler, baseDelay); } } }); } private void scheduleReconnect(String url, EventHandler handler, long delaySeconds) { scheduler.schedule(() - connectWithRetry(url, handler), delaySeconds, TimeUnit.SECONDS); } }Last-Event-ID是 SSE 协议里专门为断点续传设计的头。服务端在发送事件时可以带上id字段客户端重连时把这个值带到请求头里服务端就能知道从哪条事件继续发。但这个机制要真正生效服务端逻辑也得配合收到Last-Event-ID请求头后要跳过已经发送过的事件。如果服务端是无状态的临时流比如一次性的大模型应答就不需要考虑Last-Event-ID断了下一次重连重新请求即可。还有一个坑是EventSourceListener.onFailure里response有时是 null比如纯网络层错误。做重连逻辑时一定要判空不然你会在崩溃边缘疯狂试探。4.3 端到端场景大模型流式应答转发到前端把服务端和客户端串起来看一个完整场景。假设你的 Spring Boot 中间服务要调用一个上游大模型接口上游返回 SSE 流式数据你解析后把增量内容以 SSE 形式再推给前端浏览器。这个场景的核心代码并不复杂关键是线程模型要对Service public class AIService { private final SseClient sseClient; public SseEmitter forwardToFrontend(String prompt) { SseEmitter emitter new SseEmitter(180_000L); // 服务端收到浏览器请求后立刻去异步请求上游大模型 sseClient.subscribe(https://llm.example.com/v1/chat?prompt prompt, new EventHandler() { Override public void onEvent(String id, String type, String data) { try { if (delta.equals(type)) { emitter.send(SseEmitter.event() .name(delta) .data(data)); } else if (done.equals(type)) { emitter.send(SseEmitter.event() .name(done) .data(data)); emitter.complete(); } } catch (IOException e) { throw new RuntimeException(e); } } Override public void onError(Throwable t) { emitter.completeWithError(t); } }); return emitter; } }这里有个要点SseEmitter的事件发送是在 OkHttp 的回调线程里执行的而emitter.complete()一旦执行连接就进入关闭流程再调用send就会抛异常。所以上游完成后要做的第一件事是发送最后一个事件然后立刻 complete。这种转发模型下前端拿到的是标准 SSE 流浏览器EventSource直接可以消费整个过程没有轮询、没有 WebSocket 握手HTTP 语义也天然支持链路追踪。5. 性能压榨实战线程、连接、缓冲区的平衡5.1 Dispatcher 线程池与 ConnectionPool 调参性能优化要动刀先搞清楚 OkHttp 的内部结构。OkHttp 的异步请求是由Dispatcher调度的它维护一个线程池和两个队列同步队列、异步队列。默认配置是参数默认值作用maxRequests64全局最大并发请求数maxRequestsPerHost5单个主机最大并发请求数ConnectionPool.maxIdleConnections5每个主机最大空闲连接数ConnectionPool.keepAliveDuration5 分钟空闲连接存活时间SSE 长连接场景下maxRequestsPerHost默认 5 是个很大的瓶颈。因为 SSE 连接会一直占着连接不放5 个配额意味着同时对同一个上游主机最多只有 5 条 SSE 流一超过就排队。我当时压测到第 6 个并发请求就发现连接建立不起来了。调优思路Dispatcher dispatcher new Dispatcher(); dispatcher.setMaxRequests(500); dispatcher.setMaxRequestsPerHost(200); OkHttpClient client new OkHttpClient.Builder() .dispatcher(dispatcher) .connectionPool(new ConnectionPool(200, 5, TimeUnit.MINUTES)) .build();注意maxRequestsPerHost不是越大越好。每个并发都会占用一个线程、一个 socket 连接如果上游服务端扛不住这么大的并发调大这个值反而会把上游打挂。合理做法是根据上游服务的吞吐能力、网络带宽和你自己的机器资源综合评估然后在压测中逐步调大。Dispatcher 线程池也值得关注。它的默认线程数公式是max(3, maxRequests / 2)如果调高了 maxRequests线程数也会相应增加。SSE 回调本身是 IO 密集型的线程数可以适当多一些但要注意 JVM 线程栈默认 1MB200 个线程就额外占 200MB 虚拟内存。要真的追求极致性能可以考虑 Java 21 虚拟线程。5.2 Tomcat 异步超时、虚拟线程和 flush 策略服务端 SseEmitter 跑在 Tomcat 上有三个参数直接影响推送的稳定性spring: mvc: async: request-timeout: 120000 threads: virtual: enabled: true server: tomcat: max-threads: 100第一个是spring.mvc.async.request-timeout控制异步请求的超时时间。如果 SseEmitter 构造器设置的 timeout 和这个配置不一致实际生效的逻辑是容器在两者之间取较早超时的那个。比如 SseEmitter 设为 120 秒但 async.request-timeout 是 30 秒那连接在 30 秒时就会被容器切断。所以两个值要保持一致或者把spring.mvc.async.request-timeout设得更大。第二个是虚拟线程。Spring Boot 3.2 开始支持spring.threads.virtual.enabledtrue这个开关的影响范围是那些用虚拟线程执行的异步任务。对 SSE 场景的价值在于如果你的服务端在推送之前或者同时还需要调用上游多个 HTTP 接口做聚合虚拟线程能把大量阻塞型 IO 的线程开销压到最低。我实测下来同样 100 个并发 SSE 连接传统线程池的方式线程数飙到 100虚拟线程方式线程数只有几个内存差异肉眼可见。第三个是 flush 策略。SSE 的本质是流式输出如果响应数据被缓冲在 Tomcat 的缓冲区里没有及时 flush客户端拿到数据的时间会远超预期。SseEmitter 每次调用 send 内部会处理 flush但如果你在 Controller 里直接操作ServletResponse的 OutputStream 写 SSE一定要记得手动flush()。另外建议确认 Tomcat 的压缩配置server.compression.enabledfalse。压缩对文本流的收益很大但会引入缓冲延迟实时性要求高的场景里压出来的等待时间比省下的带宽更疼。5.3 一组压测数据和瓶颈观察给大家一组真实项目里参考过的数据。场景是中间服务对接上游大模型流式接口每个 SSE 连接持续 20 秒每秒输出 10 条事件。优化前的配置RestTemplate 轮询 默认 OkHttpmaxRequestsPerHost5100 并发压测结果HTTP 请求失败率25%响应首字节延迟1200ms服务线程池占用98%单连接内存开销约 1.2MB线程栈 缓冲优化后的配置OkHttp EventSource Dispatcher(maxRequests500, maxRequestsPerHost100) 连接池 200 SseEmitter 独立线程池同样 100 并发压测HTTP 请求失败率0%响应首字节延迟350ms服务线程池占用40%单连接内存开销约 200KB主要来自异步上下文对象瓶颈从客户端连接配额不足转移到了上游模型的吞吐能力。这也是为什么我在优化时没有一味把并发参数往上拉而是结合上游的压测曲线确定了一个双方都能接受的并发区间。数据仅供参考不同的机器、不同的网络环境、不同的上游模型性能结果会差很多但调优方向是一致的先解决连接配额再优化线程模型最后才是微调缓冲区和心跳参数。6. 常用场景踩坑与排查链路6.1 stream disconnected before completion 的根因分析这类报错是 SSE 连接问题里最高频的尤其是申请了 AI 大模型接口之后很多人会遇到上游 SDK 抛出的这句stream disconnected before completion: idle timeout waiting for sse先说结论这是上游服务端在空闲超时后主动断开了连接。SSE 规范不要求服务端一直发数据但大多数下游客户端包括 OkHttp对待空闲连接的态度是如果长时间没有任何字节进来它可能会在本地判断连接已死或者通知上游我要断开。这个报错我排查过一次过程可以给大家参考。第一步确认是不是本地读超时。看 OkHttp 配置里readTimeout如果是默认的 10 秒或 30 秒先改成 0。这能解决掉 50% 以上的 case。第二步抓包看有没有数据到达。用tcpdump或在 OkHttp 的日志拦截器里观察如果连接建立后长时间没有任何数据包说明问题可能出在上游——上游生成内容太慢超过了它自己设定的空闲阈值主动断开了连接。第三步给上游发心跳。如果你能控制上游服务让上游每隔 10-15 秒发送一条注释帧:keepalive维持连接。如果控制不了上游那就只能在客户端做重试尽可能在上游断开后快速重连。第四步检查中间代理。如果请求要经过 Nginx、API 网关查看这些组件的proxy_read_timeout或等价配置。默认值常有 60 秒SSE 连接空闲超过 60 秒就会被网关切断。这里要说明的是OkHttp 客户端收到的报错信息不一定是stream disconnected before completion也可能是 SocketTimeout、Connection reset 等但排查路径是一样的。6.2 Nginx 代理缓冲与读超时问题服务端 SSE 如果放在 Nginx 后面有两处配置必须调整否则你会在生产环境吃大苦头。location /sse/ { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; proxy_read_timeout 3600s; }proxy_buffering off是第一个关键。Nginx 默认开启响应缓冲它会等后端把整个响应全部生成完才一次性发给客户端。SSE 是边生成边发送的如果开了缓冲数据全被憋在 Nginx 里客户端永远拿不到实时内容。proxy_read_timeout是第二个关键。Nginx 默认 60 秒如果 60 秒内后端没给 Nginx 发数据Nginx 就断开连接。这就是明明 OkHttp 设置了 readTimeout(0)、服务端心跳也在跑但连接还是断掉的一个隐藏原因。这两个配置跟 OkHttp 和服务端本身无关属于经典的链路中间件问题。出现问题时建议先按照 服务端 → 客户端 → 中间代理 的顺序逐层排查不要一上来就怀疑应用代码。6.3 压测时文件句柄和内存的排查最后分享一个压测时容易忽视的事。SSE 长连接比普通 HTTP 请求更耗费系统资源尤其是文件句柄。每条 SSE 连接至少对应一个 socket 文件描述符压测时连接数一高系统ulimit -n如果没调大就会报Too many open files。排查命令先看全局ss -s netstat -ant | grep ESTABLISHED | wc -l如果 established 连接数远远低于你预期的并发数先看系统级的文件句柄限制ulimit -n cat /proc/sys/fs/file-nr再看进程级ls /proc/{pid}/fd | wc -l线上环境建议把ulimit -n至少调到 65535。另外 JVM 侧要监控堆内存和 GCSseEmitter 和 OkHttp 回调里创建的对象如果不断堆积GC 压力会很大。我记得有一次压测 200 并发JVM Old 区一直在涨最后发现是onEvent回调里把每个 chunk 都存到了一个静态 List 里做全量备份这种写法的内存膨胀速度是按小时翻倍的。还有一个小细节连接关闭后的对象释放是有滞后性的调高并发后短时间内大量连接断开服务端的 TIME_WAIT 状态 socket 会暴增。TCP 参数调节是有必要了解的但这个属于操作系统层面根据实际现象针对性处理即可。用 OkHttp 配合 SSE 这套组合我从接入大模型流式应答到做通用事件推送平台前后用了三四个月时间。回头看在 Spring Boot 生态里实现流式推送SSE OkHttp 确实是一条兼具简单和高性能的路。如果让我重新选一次我还是会做同样的选型只是有一句话想留给读者SSE 是 HTTP 上的长连接它的一切性能边界都建立在连接管理、心跳机制和背压控制之上这三件事做得越扎实你的流式推送就越稳定。
