生产级SSE方案:连接管理、断线重连与超时降级全解析 SSE这个话题我面试过的Java候选人没有一百也有八十能把生产级方案讲清楚的一只手数得过来。大多数人停留在“用SseEmitter能推消息”这个层面再往下问断线怎么重连、超时怎么降级、多实例部署时消息该从哪个节点推就开始含糊其辞。这不怪开发者——SSE看起来实在简单一个HTTP长连接就把服务端消息推到了浏览器可恰恰是这种“简单”的错觉让九成实现一上生产就原形毕露。我这篇就把自己在真实业务里沉淀下来的连接管理、断线恢复、超时降级全套方案拆开讲清楚既保证你面试能讲得有理有据也保证你拿回公司能直接落地。1. 裸奔的SseEmitter为什么年年翻车先理解SSE的生产环境形态1.1 SSE和普通HTTP接口的本质差异SSE全称Server-Sent Events服务端推送事件。它本质上不是一个新协议而是基于HTTP的流式响应响应头固定是Content-Type: text/event-stream服务端写数据不关闭连接浏览器通过EventSource接口持续接收。很多人拿SSE和WebSocket对比这是理解上的第一个分岔口。WebSocket是真正的双向全双工长连接SSE是单工的服务端到客户端推送。但是单向这个特点在某些业务里恰好是优势比如消息通知、订单状态变更、AI流式输出、股票行情、日志实时滚动。你只需要服务端推不需要从客户端收回传通道用SSE省掉了WebSocket的握手升级、心跳包维护和协议解析成本。我常跟团队说一句话SSE的麻烦不在于协议本身而在于它是一条“不能断”的HTTP响应。普通接口响应只要发完就完事SSE要持续占用连接、持续占用线程资源、持续和中间途经的每一层网关打交道。每多一层代理就多一层切断连接的理由。1.2 生产环境里见过最多的三个“死法”我复盘过的SSE线上事故几乎都可以归到三类。第一类空闲超时导致流断开。这是出现频率最高的。stream disconnected before completion: idle timeout waiting for sse这个报错我一开始看到也觉得头大它翻译过来就是“在SSE完成之前连接因为空闲超时被断开”。Nginx默认proxy_read_timeout是60秒Spring Cloud Gateway默认空闲超时也更短如果服务端60秒内没有往连接里写过任何数据中间层就会判定这条连接已经死掉主动断开。而业务上很多通知不是每秒都有的订单十分钟没状态变化太正常了。于是用户侧EventSource报错自动重连拉起来一个空连接继续等继续被断形成一个恶性循环。第二类连接对象泄漏导致内存和线程双双打满。你new了一个SseEmitter存在HashMap里用户关浏览器时服务端并不会立刻收到通知。如果没注册onCompletion回调去清理Map里的连接那么每次用户刷新页面、关掉标签页、切换网络都会残留一条僵尸连接。用户反复操作Map越来越大Tomcat线程池慢慢被占完最终整个应用卡死。这个坑我见过太多次尤其在中后台系统里用户开一堆标签页服务端不知不觉积累了上万条待回收的SseEmitter。第三类多实例部署导致消息找不到连接。单体应用阶段所有连接都在同一个JVM里消息来了直接遍历Map推送一切正常。一旦上了Nginx负载均衡服务扩展到多实例用户A的SSE连接落在实例1上但产生消息的业务请求被分发到了实例2实例2的本地Map里没有用户A的连接消息直接丢掉。常规的解决思路是把连接信息放到Redis统一管理但设计不好的话Redis里存的连接对象在别的实例上根本没法用还会引发序列化问题。1.3 本地不崩、生产崩差的到底是哪一环本地开发时只有一个实例没有网关超时没有负载均衡Tomcat连接数压力约等于零浏览器和服务端之间很可能还少了几层代理转发。你在本地拿SseEmitter推消息当然一切顺利。差距就在三个地方连接生命周期管理、跨实例路由、空闲/超时兜底。经验少的人会以为SSE核心代码是emitter.send()那几行实际上那几行只是最后一步生产级方案的核心工作是围绕“怎么保证这条连接一直活着”和“一旦断了怎么把消息补回来”展开的。下一个章节我先讲大家最容易忽略的连接注册与管理。2. 连接注册与消息路由多实例下让每条业务消息都找得到连接2.1 只用本地Map的问题很多教程里会教你这样写Component public class SseSessionManager { private final MapString, SseEmitter sessions new ConcurrentHashMap(); public void add(String userId, SseEmitter emitter) { sessions.put(userId, emitter); emitter.onCompletion(() - sessions.remove(userId)); emitter.onTimeout(() - sessions.remove(userId)); } public void send(String userId, Object data) throws IOException { SseEmitter emitter sessions.get(userId); if (emitter ! null) { emitter.send(SseEmitter.event().data(data)); } } }这段代码在单机环境没问题但拿去做多实例就废了。原因很简单sessions是每个JVM各一份的。用户的连接落在实例A消息被负载均衡转发到实例B实例B查自己内存里的Map啥也查不到。用户侧看到的现象就是“消息时有时无”或者“只在登录后前几秒能收到”。就算你强行把所有SseEmitter对象序列化后放进Redis那也是给自己挖坑。SseEmitter持有HTTP响应通道的引用它强绑定在某个JVM进程内别的实例根本没法拿这个对象去发送Redis存了等于没存。正确做法是拆开每个实例只负责自己的本地连接但所有实例都知道某个用户的连接挂在哪个实例上。2.2 连接注册表本地Map Redis路由元数据我常用的方案分两层。第一层是实例内的Map存放当前JVM持有的SseEmitter第二层是Redis里的注册信息记录userId - instanceId的映射关系同时配合Redis的Pub/Sub把消息广播给所有实例。流程先梳理一下用户建立SSE连接时请求落到某个实例该实例把连接存进本地Map再把userId - instanceId这份路由信息写入Redis并设置一个比应用层过期时间长一点的TTL。当业务系统需要给某个用户推送消息时发送方并不需要知道用户在哪个实例只管往Redis的频道里发一条带目标用户ID的消息。所有实例都订阅了这个频道收到消息后查一下自己的本地Map如果这个用户恰好落在自己上面就推送如果不在就什么都不做。这样设计的巧妙之处在于发送方不用感知实例拓扑每个实例都有机会尝试投递只有持有真实连接的那个实例会成功。代码骨架大概是这样的// 本地连接注册表 Component public class LocalSseRegistry { private final ConcurrentMapString, SseEmitter emitterMap new ConcurrentHashMap(); public void register(String userId, SseEmitter emitter) { emitterMap.put(userId, emitter); } public void unregister(String userId) { emitterMap.remove(userId); } public SseEmitter get(String userId) { return emitterMap.get(userId); } public boolean contains(String userId) { return emitterMap.containsKey(userId); } }Redis广播层Component public class SseNotifyService { private final RedisTemplateString, Object redisTemplate; private final LocalSseRegistry localRegistry; private static final String SSE_CHANNEL sse:notify; public void publish(String userId, MapString, Object payload) { // 不管用户在哪个实例先丢到频道里 redisTemplate.convertAndSend(SSE_CHANNEL, new SseNotification(userId, payload)); } public void onMessage(SseNotification notification) { // 消息到达每个实例实例检查本地是否有该用户 SseEmitter emitter localRegistry.get(notification.getUserId()); if (emitter ! null) { // 这里把数据真正写出去 } } }这里要重点说一个容易被忽略的细节Redis的Pub/Sub消息是即发即弃的。如果某个实例当时GC卡顿或者网络抖动消息就丢了。所以我一般在真正的生产方案里不会只依赖Pub/Sub而是让消息先落库再借助一个短TTL的Redis Stream或消息队列做主路径广播用数据库里的历史记录做补传兜底。这个“先持久化再广播最后补差额”的思路是整个断线重连方案的地基。2.3 一次消息推送的完整路径我把一次完整推送拆成五步你照着这个逻辑实现就不会乱业务系统构造通知内容生成全局唯一消息ID。消息写入持久化存储比如MySQL表或者Redis Stream作为客户端断线后的补传来源。发送方通过Redis频道广播通知内容。每个SSE实例收到广播后查询本地Map确认目标用户连接是否存在存在则直接推送。推送完成后更新消息状态为“已推送”或者记录当前已推送到的消息游标。有人会觉得这样太绕不如直接查Redis里的userId - instanceId然后只给对应实例发HTTP请求让它推。这样做的确能减少无效广播但引入的问题是实例列表的动态变化怎么处理实例挂掉时路由还准不准偏重的HTTP调用本身又会增加一层故障点。广播模式的冗余恰恰换来了简单和鲁棒性随着连接数增加本地Map查不到的大多数消息都是O(1)放弃开销完全可以接受。生产环境的方案简单和鲁棒性永远排在“看起来高效”的前面。2.4 连接回收与替代清理别把僵尸连接留在Map里本地Map实现后最危险的就是对象泄漏。SseEmitter必须注册三个回调这是我一直强调的强制项emitter.onCompletion(() - localRegistry.unregister(userId)); emitter.onTimeout(() - localRegistry.unregister(userId)); emitter.onError(e - localRegistry.unregister(userId));注意onCompletion只在正常完成时触发onTimeout在容器异步超时时触发onError在抛出异常时触发。三个回调都要做清理漏一个都不行。但光靠回调不够。用户拔网线、断电、浏览器崩溃服务端根本不会收到任何通知HttpServletResponse的底层连接可能已经断开但服务端还在傻傻地等待下一次write。所以生产环境我还会加一个定时巡检任务每隔30秒扫描一次本地Map对每条连接做一个“活跃探测”发现超过N秒没有成功写入数据的连接就主动complete掉并同步清理注册信息。这个N我一般设置成“心跳间隔乘以3”加一点余量。这里还有个小技巧SseEmitter的send方法内部会检查连接状态但它在连接已经断开时不一定会立刻抛异常。所以我在巡检时不光依赖异常还会看最后发送时间。每次成功的send都会更新lastWriteTime超过阈值就认定这条连接不健康。简单但有效。3. 断线重连方案从原生EventSource到服务端主动补发3.1 原生EventSource的自带重连到底帮到你什么EventSource确实自带自动重连机制——连接断开后浏览器会自动重新发起请求默认间隔大约是3秒到15秒之间具体由服务端返回的retry:字段控制。这让很多人误以为断线重连已经天然实现了。真上生产你就知道自动重连只是“重新拉了一条连接”它不能保证断线期间的消息不丢。用户断网的十秒钟里服务端推送了两条订单状态变更通知客户端重连成功之后拿到的是重连那一瞬间之后的新消息失去的那两条消息永远不会再出现。所以要实现真正的断线重连必须回答一个问题客户端重新连接后服务端怎么知道它上次收到哪条消息这就要用到Last-Event-ID机制。3.2 服务端感知断线的三种手段服务端想知道一条SSE连接是否还活着有三种被动手段加一种主动手段。第一种是捕获Socket写异常。当你调用emitter.send()时如果底层连接已经断开通常会抛出IOException。但是我说过了这个异常不一定每次都及时抛出存在滞后性。第二种是依赖容器回调。onCompletion、onTimeout、onError三个回调都触发时代表连接被清理了。但用户静默断网时这些回调也可能不触发只能等下一次write才发现。第三种是应用层心跳。这是我认为最可靠的方案。服务端定时往连接里写一个心跳事件如果连续多次心跳写入都失败就主动判定连接死亡。这里的心跳不是给自己看是给整个链路里的所有中间层看的。主动手段则是借助注册表里的lastHeartbeatTime由一个后台调度线程扫描判断。三条路叠加起来才能保证僵尸连接在几分钟内被清除而不是永久泄漏。3.3 last-event-id消息续传的实现完整的断线重连流程应该是这样客户端收到每条消息后记录消息中的id字段。一旦连接断开客户端重连时在HTTP请求头里带Last-Event-ID: 12345。服务端在创建SSE连接的处理逻辑里先解析这个请求头把userId对应的大于12345的消息从历史存储里查出来按顺序补齐推送给客户端然后进入实时推送阶段。后端伪代码GetMapping(path /sse/{userId}, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(PathVariable String userId, RequestHeader(value Last-Event-ID, required false) String lastEventId) { SseEmitter emitter new SseEmitter(0L); // 先补传断线期间漏掉的消息 if (StringUtils.hasText(lastEventId)) { ListNotifyMessage missedMessages notifyHistory.findAfter(userId, Long.parseLong(lastEventId)); for (NotifyMessage msg : missedMessages) { emitter.send(SseEmitter.event() .id(String.valueOf(msg.getId())) .data(msg.getPayload())); } } // 注册到本地Map进入实时推送 localRegistry.register(userId, emitter); return emitter; }要注意的是补齐消息时也要设置.id()否则客户端拿不到最新游标下次重连会重复补齐同一批消息。服务端每条消息都必须是幂等的客户端消费时最好也按业务主键去重这是分布式系统的常规要求但放到SSE里经常被人忽略。3.4 重连鉴权和参数透传EventSource不能自定义Header怎么办EventSource的API有个硬伤它不支持自定义Header所以你不能在Authorization头里塞一个token。遇到需要鉴权的系统大部分人会选择把token放到URL的Query参数里。const es new EventSource(/api/sse?token encodeURIComponent(token));这个方案简单可行但token会出现在网关访问日志、浏览器历史记录和Referer头里存在安全隐患。而且token通常是短时效的SSE是长连接如果token在连接期间过期了这条连接要不要断开处理起来很麻烦。我一般推荐两条路。第一条是先调用普通接口完成鉴权服务端把登录态写进CookieEventSource在同源情况下会自动带上Cookie。第二条是前端先用fetch拿一次带鉴权的预请求服务端返回一个一次性连接票据再用EventSource携带这个短票据去建立SSE连接。两者都能绕开EventSource的Header限制。重连时同样要面对鉴权问题。EventSource自动重连时浏览器会用原始请求的header和query所以如果依赖token重连时会带着同样的token。这要求服务端对token的过期逻辑做宽限不要用那种每次请求都校验且只能校验一次的票据否则重连必然失败。更合理的做法是初次握手用票据换长连接凭证连接建立后服务端通过lastHeartbeatTime主动管理连接生命周期。4. 超时降级与保活链路中每一层超时都要有对应策略4.1 三层常见超时和它们各自的危险SSE连接超时不只是一处的问题我习惯把超时分为三层来看。第一层是接入网关超时。Nginx、Spring Cloud Gateway、Kong这些组件普遍有proxy_read_timeout、idle timeout。默认值往往在30到120秒之间对普通HTTP接口够用对SSE长连接来说是致命的。这一层超时触发时通常表现为客户端收到stream disconnected before completion: idle timeout waiting for sse。第二层是Servlet容器超时。Spring Boot的Tomcat里有一个server.tomcat.async-timeout默认30秒。如果你用SseEmitter但又没有在这个时间内完成整个异步响应Tomcat会主动把连接结束掉。你需要把它调到一个比较长的值或者在代码里通过SseEmitter构造参数设置超时。第三层是业务层超时。比如你在推送消息时调用外部接口外部接口响应慢导致推送线程阻塞。这一层超时是最隐蔽的因为连接本身还活着但数据迟迟出不去类似“假死”用户侧看到的就是消息一直不更新。三层超时相互叠加任何一个环节出问题都会表现为一条SSE连接结束。所以我在生产部署时会从外到内统一梳理一遍超时配置保证每一层的超时时间都比内层长形成“洋葱结构”。4.2 心跳设计频率、内容、触发线程心跳是最好的保活手段。心跳要解决两个问题一个是让中间层知道连接还活着不要被空闲超时干掉另一个是让应用层知道连接状态及时发现僵尸连接。先看内容。SSE的EventSource规范里规定以冒号开头的行是注释行会被浏览器自动忽略。最轻量级的保活就是往连接里写一个注释: heartbeat但我不建议只写注释。原因很简单如果连接已经死了写注释的失败不会被应用层意识到因为没有任何状态更新。我更喜欢写一个真实的“心跳事件”emitter.send(SseEmitter.event() .name(HEARTBEAT) .data(Map.of(time, System.currentTimeMillis())));客户端收到HEARTBEAT事件后可以更新自己的最后存活时间服务端写入成功同时更新lastWriteTime。这样一条心跳既保活了连接又为两端提供了健康判断依据。再来看频率。心跳间隔不能拍脑袋定。假设Nginx的proxy_read_timeout是60秒心跳间隔就必须显著小于60秒一般在15到30秒之间。间隔太小会增加无谓的IO间隔太大会碰到上限。我常用的配置是30秒同时把各中间层的read timeout调到120秒以上留足余量。心跳由谁触发也很关键。这里最容易犯的错是直接在业务线程里sleep然后发心跳或者用主线程的空闲时间去发。正确做法是单独维护一个ScheduledExecutorService每30秒遍历一次连接Map有需要就发送心跳。线程池大小控制在2到4个线程就够因为每条连接的心跳发送都是极快的IO操作遍历几千条也没问题。4.3 推不出去怎么办降级存储和恢复补偿断线重连解决了“连接恢复后消息补传”的问题但还有一个问题消息产生的那一刻连接已经断了数据怎么办最朴素的方案是“连接断开就不推等重连后一次性补齐”。这依赖第3节的Last-Event-ID机制能够覆盖大部分场景。但它有个前提消息历史存储必须在连接恢复时还能查得到那个用户的未读消息。所以在真正落地时我会把消息持久化拆成两层一层是在内存里保存“最近一小时的消息环形缓冲”用于超快速补传适合短期断线另一层是数据库里的消息流水表保留一天以上用于长时间离线后的补传。当连接断开时实时推送路径自然失败。但我们会把那条消息标记为“待推送”而不是直接丢弃。用户重连时服务端先按Last-Event-ID捞取大于游标的消息然后统一推送给客户端最后把状态改成“已推送”。这里有一个常见的性能陷阱如果用户断线时间很长未读消息可能有上千条一次性全量推送会阻塞连接画面也会卡顿。所以补传时一定要分批每次推个50条或者100条推完一批等下一轮调度再推一批防止单条连接把线程池耗尽。整个降级链路可以用一句话概括实时推送优先失败落库重连后补差补差限速。4.4 生产SSE的推荐配置清单我把自己实际在用的配置整理成了一张清单你拿去可以直接作为参考基线配置项推荐值说明Nginxproxy_read_timeout120s必须大于心跳间隔的三倍以上Nginxproxy_bufferingoff关闭代理缓存保证数据实时到达Nginxproxy_http_version1.1配合长连接避免HTTP/1.0逐跳关闭Spring Bootserver.tomcat.async-timeout600000ms10分钟内层超时大于外层应用层心跳间隔30s用真实事件做心跳连接巡检清理周期30s扫描僵尸连接超过阈值主动complete补传批大小50条/批防止大批量消息阻塞线程Redis路由信息TTL2分钟略大于心跳间隔靠心跳续期提示这个清单不是让你原样照抄而是要理解每项配置背后的逻辑。比如你的网关如果是Spring Cloud Gateway对应的配置项就不叫proxy_read_timeout而是responseTimeout之类但“外层时间大于内层”的原则是一样的。5. 线上排障实录从日志关键字反查断连根因5.1 先用curl验证协议正确性排查SSE问题我第一步永远是先看协议是否正确。用浏览器访问会有一堆缓存、代理、跨域干扰用curl反而是最干净的链路验证方式curl -N -H Accept: text/event-stream http://localhost:8080/api/sse/10001-N参数的意思是关闭curl的缓冲让数据一到达就立即输出。正常情况下你应该看到形如event: HEARTBEAT data: {time: 1697612345678} event: message id: 42 data: {orderId:abc,status:PAID}如果curl的表现正常说明服务端协议正确问题大概率出在浏览器到服务端之间的某层代理或鉴权逻辑上。如果curl挂起后过几十秒被断开直接去查中间层的超时配置。顺带说一句很多人在本地联调时用Chrome的DevTools去看EventSource消息但DevTools的Network面板对EventSource的实时消息展示是有一定延迟的有时候会让你误判“没推送”。用curl能把服务端行为和服务端行为解耦开是排障的第一选择。5.2 面对“idle timeout waiting for sse”怎么定位stream disconnected before completion: idle timeout waiting for sse这个报错我见得最多。它的字面含义是“等待SSE响应内容时发生了空闲超时”但具体是哪个环节产生的需要按顺序排查。第一步看服务端有没有在执行定期心跳。如果服务端日志显示每30秒都在正常写数据但客户端那边还是被断开那问题大概率出在中间代理也就是Nginx或网关层。第二步看服务端的线程栈确认是不是业务代码把发送线程block住了。第三步看客户端是否处于系统待机或者网络切换状态这会导致服务端正常推但客户端收不到最终被代理判定空闲。我印象最深的一次事故是服务端代码写得没问题但Nginx上有个同事给某个location单独设置了proxy_read_timeout 60s没有沿用全局配置。结果那条路径上的SSE连接每60秒断一次断完EventSource自动重连再撑60秒再断用户侧的消息至少延迟一分钟以上。排查到这个都替当时的业务方憋屈。所以你在自己的配置里排查时一定要确认所有反向代理层都没有单独覆盖超时配置特别是那些从别的项目复制过来的Nginx配置片段。5.3 用Arthas在不重启的前提下确认线程状态线上问题最麻烦的一点是不能随意重启、不能随意打断。Arthas这个工具在生产环境排SSE问题非常好用。要注意虽然有些公司对Arthas管控严格但只做只读诊断操作风险其实很低。看线程状态thread -n 3这条命令能列出最繁忙的几个线程及堆栈。如果发现某个线程卡在SseEmitter.send的socket write上而且已经持续很久说明连接大概率已经半死。再配合thread -b可以找当前持有锁的阻塞线程排除业务锁竞争导致SSE发送线程被卡住的情况。想确认是不是异步超时在捣鬼可以用watch org.springframework.web.servlet.mvc.method.annotation.SseEmitter send returnObj观察send方法的调用频率和返回结果。如果返回值为空或者抛IOException就能定位是哪条连接在写失败进而回溯userId。提示使用Arthas时尽量选择业务低峰期并且避免执行trace到过深的方法链路否则会引入不小的性能开销。只做短时diagnostic用完马上退出。5.4 加固建议监控指标与容量压测排完一次问题如果只把配置改了就完事下次一定还会以别的形态冒出来。我在SSE链路里会加四个监控指标当前活跃连接数按实例维度上报每分钟心跳发送失败次数这个数值一旦大于零就说明有僵尸连接或网络问题补传消息的批次数和消息总数用于观察用户断线频率消息推送延迟分布特别是P99。压测阶段也要专门做SSE长连接压测不能用普通接口压测工具。写一个模拟EventSource的Java客户端或者用JMeter的WebSocket采样器改造一下把连接数量逐渐往上加观察服务端线程池、内存和连接回收情况。我压测的经验是连接数上去之后最容易先报警的不是CPU而是文件描述符和线程栈内存。到时候把server.tomcat.threads.max、server.tomcat.accept-count以及JVM的栈大小合理调整一下即可。6. 面试进阶视角把SSE项目讲出高并发方案的分寸感6.1 从“用了SseEmitter”到“讲清架构取舍”面试官问SSE最低分的回答是“我用了SseEmitter前端用EventSource后台调send接口就能推到浏览器了。”这等于只回答了API怎么用没回答任何架构问题。加分回答应该是把这个方案的完整链路讲清楚。我一般建议按照这五条线去组织连接怎么注册实例内Map加Redis路由元数据讲清楚为什么不能把SseEmitter对象直接塞Redis。连接怎么保活两层心跳应用层事件心跳加定时巡检讲清楚为什么不能只依赖浏览器原生重连。断线怎么恢复Last-Event-ID加消息历史存储补传限流讲清楚为什么每条消息要全局唯一ID。多实例怎么路由Redis Pub/Sub广播到所有实例各实例查本地Map定向推送讲清楚这个方案和点对点路由的取舍。异常情况怎么兜底Redis挂了怎么办消息队列阻塞怎么办连接长时间断线怎么办。你能把五条线串成一个完整故事面试官就已经能判断你是真的做过生产级方案而不是背八股文。6.2 面试追问“Redis挂了怎么办”时怎么答这是我最喜欢追问的一道题也是标题里“超时降级”这个关键词的高级考法。经常有候选人答“Redis挂了就挂了SSE肯定受影响。”这种答案其实就是没设计过容灾。合理答法是分几个层面讲降级。第一层本地Map始终还在当前实例上的连接仍然可以实时推送不受Redis影响。第二层跨实例的广播链路换成直连数据库轮询也就是有一个降级任务定期扫描未推送消息把归属当前实例的消息推送出去。第三层如果连数据库也出问题那就在内存里保留最近一段时间的消息等恢复后再补传。降级策略的核心不是“不发生故障”而是“故障发生时业务怎么继续往前走”。你把这个思路讲出来面试官会知道你面对的是真实线上问题而不是停留在Demo阶段。另外一个容易被追问的点是“连接数上万之后Tomcat默认线程池够用吗”。这个问题背后考察的是你对Servlet异步机制的理解。SseEmitter走的是Servlet 3.1异步处理连接挂起时并不会一直占着Tomcat工作线程真正占用的是少量异步线程和连接器的NIO线程。所以你说“1万个SSE连接需要1万个线程”就是理解有误。你需要回答的是连接数和线程数是解耦的线程池大小主要取决于业务推送调用的并发度而不是连接总数。我面试时最喜欢听到的收尾是候选人主动把心跳、重连、降级、监控四个关键词串成一个闭环。谁做到这一点我基本就会认定这个人是真正在线上趟过SSE的坑的。你自己去复盘的时候也可以拿这个标准衡量一下你的方案能不能同时回答这四个词