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 Map<String, 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 ConcurrentMap<String, 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 RedisTemplate<String, Object> redisTemplate; private final LocalSseRegistry localRegistry; private static final String SSE_CHANNEL = "sse:notify"; public void publish(String userId, Map<String, 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)) { List<NotifyMessage> 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在连接期间过期了,这条连接要不要断开?处理起来很麻烦。
我一般推荐两条路。第一条是先调用普通接口完成鉴权,服务端把登录态写进Cookie,EventSource在同源情况下会自动带上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_timeout | 120s | 必须大于心跳间隔的三倍以上 |
Nginxproxy_buffering | off | 关闭代理缓存,保证数据实时到达 |
Nginxproxy_http_version | 1.1 | 配合长连接,避免HTTP/1.0逐跳关闭 |
Spring Bootserver.tomcat.async-timeout | 600000ms | 10分钟,内层超时大于外层 |
| 应用层心跳间隔 | 30s | 用真实事件做心跳 |
| 连接巡检清理周期 | 30s | 扫描僵尸连接,超过阈值主动complete |
| 补传批大小 | 50条/批 | 防止大批量消息阻塞线程 |
| Redis路由信息TTL | 2分钟 | 略大于心跳间隔,靠心跳续期 |
提示:这个清单不是让你原样照抄,而是要理解每项配置背后的逻辑。比如你的网关如果是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的坑的。你自己去复盘的时候也可以拿这个标准衡量一下:你的方案,能不能同时回答这四个词?