本文实际上算是对上一篇的补充。
上一篇记录了由Socket accept failed问题,找到并处理了关于BufferedReader未及时关闭的代码漏洞。
后续自然是把存在该漏洞的服务全部修复并更新投产。
但在后续运行过程中,仍有服务出现该报错,排查发现与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 对象超时时执行的回调函数
前端传入 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 对象的情形有几种,包含但不限于:
public static void batchSendMessage(String message) {
sseEmitterMap.forEach((k,v)->{
try{
v.send(message,MediaType.APPLICATION_JSON);
}catch (IOException e){
removeUser(k);
}
});
}
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);
};
}
当同一个SSE连接前端在断线重连发生后,会重新调用 connect 方法。
在此时,会新生成一个 SseEmitter 对象,代替原有的 SseEmitter 对象,被保存在 sseEmitterMap 中。
但这个过程中原有的 SseEmitter 并不会被释放,而随着重连的次数增加,存在的 SseEmitter 对象越来越多,最终,在触发 socket accept failed 报错时,通过命令查询,该服务的句柄数已经远超阈值
lsof -p [pid] | wc -l
4116
而单个服务的句柄限定值为
ulimit -a
...
open files 1024
...
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 对象。
生成 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执行脚本内容,在上一篇文章的最后。链接放在下面了。
点击此处跳转到上一篇