SseEmitter连接断开与清除

文章讲述了作者在处理之前BufferedReader未关闭的代码漏洞后,遇到SSE连接引发的Socketacceptfailed问题。通过分析SSE连接的创建、管理机制,提出两种解决方式:一是检查并释放不再使用的SseEmitter,二是为每个连接设置超时以避免资源占用过高。测试结果显示,第二种方式更为稳定,能有效控制句柄数,确保服务正常运行。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文实际上算是对上一篇的补充。
上一篇记录了由Socket accept failed问题,找到并处理了关于BufferedReader未及时关闭的代码漏洞。
后续自然是把存在该漏洞的服务全部修复并更新投产。
但在后续运行过程中,仍有服务出现该报错,排查发现与SSE连接有关。
在此简单介绍一下我的服务中使用到的SSE。

SSE连接

首先是SSE的连接,具体如下:

private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
public static SseEmitter connect(String id){
	SseEmitter sseemitter = new SseEmitter(0L);
	sseemitter.onCompletion(completionCallBack(id));
	sseemitter.onError(errorCallBack(id));
	sseemitter.onTimeout(timeoutCallBack(id));
	sseEmitterMap.put(id,sseemitter);
	log.info("create new sse connect ,current id:{}",id);
	return sseemitter;
}

创建 SseEmitter 对象,并将对象返回给前端,供前端建立SSE连接。

sseemitter.onCompletion(completionCallBack(id));

该方法为 SseEmitter 对象结束后执行的回调函数

sseemitter.onError(errorCallBack(id));

该方法为 SseEmitter 对象报错时执行的回调函数

sseemitter.onTimeout(timeoutCallBack(id));

该方法为 SseEmitter 对象超时时执行的回调函数

sseEmitterMap

前端传入 id 作为该条SSE连接的标识,连接成功后,放入变量 sseEmitterMap 中。
sseEmitterMap 中保存的是每一个id以及对应的 SseEmitter 对象。
每一次断开SSE连接时,执行removeUser方法,将对应的 SseEmitter 对象从 sseEmitterMap 中移除。

public static void removeUser(String id){
    sseEmitterMap.remove(id);
    log.info("remove id:{}",id);
}

移除 SseEmitter 对象的情形有几种,包含但不限于:

  1. 发送消息失败
public static void batchSendMessage(String message) {
    sseEmitterMap.forEach((k,v)->{
        try{
            v.send(message,MediaType.APPLICATION_JSON);
        }catch (IOException e){
            removeUser(k);
        }
    });
}
  1. 触发三种回调函数
private static Runnable completionCallBack(String id) {
    return () -> {
    	log.info("结束连接,{}", id);
        removeUser(id);
    };
}
private static Runnable timeoutCallBack(String id){
    return ()->{
    	log.info("连接超时,{}", id);
        removeUser(id);
    };
}
private static Consumer<Throwable> errorCallBack(String id){
    return throwable -> {
    	log.info("连接失败,{}", id);
        removeUser(id);
    };
}

socket accept failed

当同一个SSE连接前端在断线重连发生后,会重新调用 connect 方法。
在此时,会新生成一个 SseEmitter 对象,代替原有的 SseEmitter 对象,被保存在 sseEmitterMap 中。
但这个过程中原有的 SseEmitter 并不会被释放,而随着重连的次数增加,存在的 SseEmitter 对象越来越多,最终,在触发 socket accept failed 报错时,通过命令查询,该服务的句柄数已经远超阈值

lsof -p [pid] | wc -l
4116

而单个服务的句柄限定值为

ulimit -a
...
open files 1024
...

complete()

complete() 方法源自于 SseEmitter 父类

public class ResponseBodyEmitter {
	...
	public synchronized void complete() {
        if (!this.sendFailed) {
            this.complete = true;
            if (this.handler != null) {
                this.handler.complete();
            }
        }
    }
    ...
    interface Handler {
        ...
        void complete();
        ...
    }
}

而 Handler 接口的实现类是 HttpMessageConvertingHandler,代码如下:

private class HttpMessageConvertingHandler implements Handler {
	...
	private final DeferredResult<?> deferredResult;
	public HttpMessageConvertingHandler(ServerHttpResponse outputMessage, DeferredResult<?> deferredResult) {
            this.outputMessage = outputMessage;
            this.deferredResult = deferredResult;
    }
    ...
    public void complete() {
        this.deferredResult.setResult((Object)null);
    }
    ...
}

最终调用 DeferredResult.setResult() 方法,响应请求。
所以,可以通过调用complete()方法来结束 SseEmitter 对象。

timeout

生成 SseEmitter 对象时,可通过传参方式,设置超时时间。

SseEmitter sseemitter = new SseEmitter(0L);

传参类型为Long,若参数为0,则长期有效;若无参,则默认30秒;若为其它值,则代表超时限制时长。

public SseEmitter(Long timeout) {
    super(timeout);
}

解决方式一

过多的 SseEmitter 对象同时存在,是造成 Socket accept failed 情形的原因。
所以第一种方式,选择释放不再使用的 SseEmitter 对象。
在 connect() 方法中,首先进行判断,若在连接时,已存在相同 id 的对象,释放原对象,随后再创建新对象并操作。

public static SseEmitter connect(String id){
	if (sseEmitterMap.containsKey(id)){
		log.info("sse connect exits already, delete current id: {}", id);
	    sseEmitterMap.get(id).complete();
	}
	...
}

解决方式二

不再使用长期的连接对象,为每个 SseEmitter 对象设置超时时间。超时后,对象消亡,不再占据句柄。

SseEmitter sseemitter = new SseEmitter();

设置为空,默认30秒。
对象超时后,前端会触发回调方法,自动进行重连,不会影响到前端连接。
谨慎起见,每次 connect() 方法执行时,依旧判断是否已存在连接,若存在,则从集合中移除。

if (sseEmitterMap.containsKey(id)){
    removeUser(id);
}

测试结果

为了更明显得到测试结果,将前端页面写成了死循环,一直用相同的 id 调用 connect() 方法,进行SSE连接。

方式一

使用方式一进行测试,句柄数会在一定时间内持续增加,开启多个前端循环调用后,服务依然报错,无法再提供HTTP服务。
查看句柄数发现升至最高值,一段时间后回落

[root@localhost service]$ ./getHandleNum.sh
3819
[root@localhost service]$ ./getHandleNum.sh
2343
[root@localhost service]$ ./getHandleNum.sh
1831
[root@localhost service]$ ./getHandleNum.sh
859
[root@localhost service]$ ./getHandleNum.sh
477

停止循环连接后,一段时间后服务恢复正常。
总的来说,这种方式虽然能够恢复服务,但仍会严重影响服务。

方式二

使用方式二,进行相同测试。
由于SseEmitter对象超时时间设置为30秒,所以仅在最开始的30秒内,句柄数来到了峰值,随后开始有增有减,整体维持在阈值以下,仍可对外提供正常服务。

[root@localhost service]$ ./getHandleNum.sh
144
[root@localhost service]$ ./getHandleNum.sh
425
[root@localhost service]$ ./getHandleNum.sh
684
[root@localhost service]$ ./getHandleNum.sh
966
[root@localhost service]$ ./getHandleNum.sh
972
[root@localhost service]$ ./getHandleNum.sh
958

显然,选择方式二,能够更加稳定保证服务运行。

后续

在后续的使用中,从日志中发现方式一实际上存在额外的问题。

[INFO] [2024-01-02 15:10:13.512] sse connect exits already, delete current id: test001
[INFO] [2024-01-02 15:10:13.512] remove id: test001
[INFO] [2024-01-02 15:10:13.515] create new sse connect ,current id: test001
[INFO] [2024-01-02 15:10:13.515] 结束连接, test001
[INFO] [2024-01-02 15:10:13.515] remove id: test001

在每一次重新连接发生时,complete() 方法的完成会发生在新的 SseEmitter 对象生成之后,从而出现关闭新的 SseEmitter 对象的情况。导致前端提示连接成功,但并没有数据推送。直到下一个30秒,重新连接,SSE连接正常运行。

总结

毫无疑问,使用方式二作为该问题的处理方式。
另外,文中涉及的 getHandleNum.sh执行脚本内容,在上一篇文章的最后。链接放在下面了。
点击此处跳转到上一篇

<think>嗯,用户问的是在使用Java Spring Boot开发的SSE消息推送功能时,通过Nginx代理API后,为什么接口一直处于连接状态。首先,我需要回忆一下SSE的基本原理和Nginx的配置可能带来的影响。 SSE(Server-Sent Events)是一种允许服务器向客户端实时推送数据的技术,基于HTTP连接。在Spring Boot中,通常使用SseEmitter来实现。当客户端发起SSE连接时,服务器会保持这个连接打开,直到主动关闭或发生超时。 接下来,用户的问题出现在使用Nginx代理后,连接一直处于挂起状态。可能的原因有几个方面: 1. **Nginx的缓冲配置**:默认情况下,Nginx可能会缓冲响应数据,尤其是当proxy_buffering开启时。对于SSE这种需要实时流式传输的情况,缓冲会导致数据不能及时发送到客户端,可能让客户端认为连接没有活动,从而保持挂起状态。 2. **超时设置**:Nginx的proxy_read_timeout默认是60秒,如果这个时间设置过短,可能会导致Nginx在超时后关闭连接,但用户的情况是连接一直存在,可能超时时间没有被触发,或者服务器端没有正确关闭连接。 3. **连接保持**:SSE需要保持长连接,而Nginx默认可能对keep-alive连接的处理方式有问题,或者没有正确传递相关的HTTP头,比如Connection头。 4. **响应头配置**:SSE需要特定的响应头,如Content-Type: text/event-stream,以及缓存控制头。如果Nginx修改或未正确传递这些头,客户端可能无法正确识别SSE流,导致连接挂起。 5. **Spring Boot配置**:检查Spring Boot应用是否有正确的超时设置,比如SseEmitter的超时时间是否设置过长,或者没有正确关闭Emitter,导致连接一直不释放。 接下来需要思考如何验证这些可能性。例如,检查Nginx的配置文件中是否有proxy_buffering off;,以及是否正确设置了proxy_read_timeout为一个较大的值,或者设置为0表示不超时。同时,查看响应头是否正确,确保没有缓存相关的头干扰。 另外,可能需要检查Spring Boot应用的日志,看看是否有异常或错误导致连接未能正确关闭。还有,客户端的实现是否正确,比如是否正确处理SSE事件,是否有重连机制等。 总结可能的解决步骤:首先调整Nginx配置,关闭缓冲,设置合适的超时时间,确保响应头正确。然后检查Spring Boot应用是否正确处理SSE连接,包括超时和异常处理。最后,验证客户端是否能正确接收和处理事件。</think>在Java Spring Boot中使用SSE(Server-Sent Events)实现消息推送时,若通过Nginx代理后连接一直处于挂起状态,通常**Nginx配置**和**SSE特性冲突**有关。以下是逐步分析及解决方案: --- ### 一、核心问题原因 1. **Nginx的缓冲机制** Nginx默认开启`proxy_buffering`,会将后端响应数据缓存到内存或磁盘,达到阈值后才发送给客户端。而SSE需要**实时流式传输**,缓冲会阻塞数据推送,导致客户端无法及时接收事件。 2. **超时配置不兼容** Nginx的`proxy_read_timeout`默认60秒,若未调整,可能因超时主动断开SSE长连接。而SSE设计为持久连接,需保持长时间存活。 3. **HTTP头丢失或篡改** SSE依赖特定的响应头(如`Content-Type: text/event-stream`),若Nginx未正确透传或覆盖这些头,客户端无法识别SSE流。 --- ### 二、解决方案步骤 #### 1. 调整Nginx配置 在Nginx的`server`或`location`块中添加以下配置: ```nginx location /your-sse-endpoint { proxy_pass http://backend-server; proxy_http_version 1.1; # 必须使用HTTP/1.1 proxy_set_header Connection ''; # 清除Connection头 proxy_buffering off; # 关闭缓冲 proxy_cache off; # 关闭缓存 proxy_read_timeout 24h; # 设置长超时(或按需调整) } ``` - **关键配置解释** - `proxy_buffering off`:禁用缓冲,确保数据实时推送。 - `proxy_read_timeout`:设置为一个极大值(如24小时),避免Nginx主动断开连接。 - `Connection ''`:强制使用HTTP/1.1持久连接。 #### 2. 验证Spring Boot代码 确保SSE接口符合规范: ```java @GetMapping("/sse") public SseEmitter streamEvents() { SseEmitter emitter = new SseEmitter(24 * 60 * 60 * 1000L); // 设置超时时间 // 模拟定时发送消息 Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event().data("Ping")); } catch (IOException e) { emitter.completeWithError(e); } }, 0, 1, TimeUnit.SECONDS); return emitter; } ``` - **注意事项** - 显式设置`SseEmitter`的超时时间(避免使用默认值)。 - 主动处理连接关闭或异常(如调用`emitter.complete()`)。 #### 3. 检查HTTP响应头 通过浏览器开发者工具或`curl -v`验证响应头是否包含: ```http Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive ``` 若缺失,需检查Nginx是否透传了Spring Boot的原始响应头。 --- ### 三、常见陷阱 1. **Nginx版本过低** 旧版Nginx对HTTP/1.1长连接支持不完善,建议升级到最新稳定版。 2. **客户端未正确关闭连接** 前端代码需在页面卸载时主动关闭`EventSource`: ```javascript const eventSource = new EventSource('/sse'); window.addEventListener('beforeunload', () => eventSource.close()); ``` 3. **防火墙或负载均衡器干扰** 某些云服务商的LB(如AWS ALB)默认配置可能中断长连接,需检查中间网络设备策略。 --- ### 四、测试验证 1. **直接访问后端服务** 绕过Nginx直接调用Spring Boot的SSE接口,确认功能正常。 ```bash curl http://springboot-server:8080/sse ``` 2. **监控Nginx日志** 检查是否有错误日志(如超时、连接重置): ```bash tail -f /var/log/nginx/error.log ``` --- 通过上述配置和验证,可解决因Nginx代理导致的SSE连接挂起问题,确保消息实时推送。
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值