公司的一个项目要上线,某天早上查看日志,发现websocket这一块的代码在多个客户端连接大概半个小时后,就频繁报错“The remote endpoint was in state [TEXT_PARTIAL_WRITING]”,导致客户端这边没法进行任何操作了。
这是原始的类的代码,我只贴重要的部分了:
@RestController
public class WebSocketController extends TextWebSocketHandler {
private final static ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
//..中间省略
@Override
public void handleMessage(WebSocketSession wsSession, WebSocketMessage<?> message) throws Exception {
log.info("WebSocket服务端接受:接受来自客户端发送的信息: "+message.getPayload().toString());
cachedThreadPool.execute(new Runnable() {
@Override
public void run() {
onMessage(wsSession, message.getPayload().toString());
}
});
}
public static void onMessage(WebSocketSession session, String msg) {
//..中间省略
//心跳回应
if ("ping".equals(msg)){
log.info("获取的msg信息: "+msg);
session.sendMessage(new TextMessage("pong"));
log.info(token+"已发送pong信息 to "+deviceId);
DeviceIdSessionCahce.putIfAbsent(deviceId,session);
Integer count = DeviceIdSessionCahce.getCount();
log.info("deviceId onMessage is add success, DeviceIdSessionCahce size is " + count);
return;
}
}
这是报的错的截图

从报的错的提示行看出,程序是在运行到onMessage方法的 session.sendMessage(new TextMessage("pong"));这一行报的错,也就是在向客户端发送心跳回应包的时候报了这个错。
直接百度,百度上给的答案是:发现是多个线程同时使用同一session发送导致的,就是没有给session加锁,所以我直接加了个锁,改为如下代码:
//心跳回应
synchronized (session) { //加锁
session.sendMessage(new TextMessage("pong"));
}
然后本地做了简单测试,下班前我就重新部署了上去。原本以为问题已经解决了,可是第二天早上看日志还是报了这个错,这就奇怪了,我都给session加锁了,怎么还是抱这个错啊。
这个时候的我已经有点没有头绪了,我又继续查询这个问题的相关博客,发现解决这个问题的方案都是给session加锁。此时我更加疑惑了,我已经加锁了,难道是昨天打的包有缓存吗,所以我重新打了一个,可是跑到下午,这个错误又发生了。
这个时候,我相信很多程序员都有某些时刻,遇到一些奇奇怪怪的小问题,按照网上给的方案去解决,可是根本不管用,这个时候真是头疼啊,觉得自己已经无能为力了。
我又查阅了一些资料,发现出这个错就是因为没有给session加锁,导致多线性环境下对同一个session并发发送消息产生的错误。这时我理清了头绪,2个关键词:1.看来是加的锁没有起到作用,2.然后另一个关键词是多线程环境。这时我仿佛想到了什么,我又去查看以前同事留下的代码,如下:
@Override
public void handleMessage(WebSocketSession wsSession, WebSocketMessage<?> message) throws Exception {
log.info("WebSocket服务端接受:接受来自客户端发送的信息: "+message.getPayload().toString());
cachedThreadPool.execute(new Runnable() {
@Override
public void run() {
onMessage(wsSession, message.getPayload().toString());
}
});
}
真是坑啊,难怪加了锁不起作用啊,这里用了cachedThreadPool线程池,每收到一条消息,就单独new一个新的线程去处理客户端发送来的消息,我在session.sendMessage(new TextMessage("pong"));这一行加了锁,只是对单个线程中的方法加了锁而已,压根没有做到线程安全!
还有他用的是cachedThreadPool线程池, cachedThreadPool只有非核心线程,最大线程数很大,它会为每一个任务添加一个新的线程,说白了,只要池中没有空闲线程,他就会一直new新的线程!这样处理一条消息就new一个线程,难怪跑几个时辰就频繁报错了!能不报错吗!内存估计都快耗尽了,所以就是运行了几个小时后固定的报之前的错!
弄明白问题的原因后,我一方面觉得真是太坑了,前同事根本就没有做过线程池选型,没有想过线程安全的问题就直接瞎用,瞎写代码了,一方面觉得自己菜,太依赖百度,而且也先入为主的认为之前的人写的代码(已经上线了该项目)应该没有啥大问题。
这个时候,我开始想解决方案。问题产生的根源是并发条件下每接收一条客户端的消息,就启动一个线程去处理,1是有线程安全隐患,2对系统的内存是个巨大的威胁。
好吧,这里正确的选型的一种应该是消息队列。就是我每收到一条消息,先放入队列中,然后有个专门的线程从队列中取消息,然后再去解析消息,这样一来就可以解决之前的问题了,可是我又想到了2个问题:
1,如果有个专门的线程一直去队列中轮询,也是很耗费cpu的,其实相当于是引入了一个新的问题而已,所以我要想到一个方案,就是在队列中没有消息时,我先跳出while(true){},避免无必要的耗费cpu,我不能留坑啊!
2.收消息这块为了解决bug,必须引入队列,那写消息这块需要先把待发送的消息写入一个队列,然后再有一个专门的线程去poll()消息,然后发送给客户端呢?如果也加队列,其实也是挺费时间的,因为直接发消息这块的代码没有封装过,所以要改很多类!可是,这一块理论上也应该是用队列的,这样在设计上才是科学的,所以我还是觉得这一块也用队列!
3.虽然我解决了上述的问题,可是现在是一个线程去处理所有客户端的消息,跟之前的比较起来,如果不考虑线程安全问题,其实性能上是相当于降低了的,但是转念一想,就这么点改造时间,还是先优先保障程序功能没有bug吧!NIO原理上不是也是一个线程一直去轮询Selector吗,后面再给它优化吧!
最终改造的代码如下:
@RestController
public class WebSocketController extends TextWebSocketHandler {
private ScheduledExecutorService executorReceive; //解析消息线程池
private static ScheduledExecutorService executorSend; //发送消息线程池
public WebSocketController() { //构造方法初始化线程池
this.executorReceive = new ScheduledThreadPoolExecutor(1);
this.executorSend = new ScheduledThreadPoolExecutor(1);
}
//待解析的消息队列
public static ArrayDeque<WebSocketMessageQueue> receiveQueue = new ArrayDeque<WebSocketMessageQueue>();
//待发送的消息队列
public static ArrayDeque<WebSocketMessageQueue> sendQueue = new ArrayDeque<WebSocketMessageQueue>();
@Override
public void handleMessage(WebSocketSession wsSession, WebSocketMessage<?> message) throws Exception {
log.info("message =>" + message);
PutQueue.PutMessageToQueue(wsSession, message, 1); //放人 待解析消息到 队列
executorReceive.execute(receiveRunnable); //启动解析消息线程
}
//发送消息由于原来的代码没有重构,发送消息的代码块分散在许多类中,大致替换就是如下两行
//放人 待发送消息到 队列
PutQueue.PutMessageToQueue(LessonSessionCache.getStudentSession(classId, userId), MessageGZIP.gzip(json), 2);
executorSend.execute(sendRunnable); //启动发送消息线程
//解析消息线程,负责从 待解析的消息队列receiveQueue中 取出消息,然后进行解析
private final Runnable receiveRunnable = new Runnable() {
@Override
public void run() {
while (true) {
WebSocketMessageQueue webSocketMsg = receiveQueue.poll(); //从队列中取消息
if (webSocketMsg != null) {
log.info("webSocketMsg.getMessage =>" + webSocketMsg.getMessage());
if (webSocketMsg.getMessage() instanceof PingMessage) {
onPingMessage(webSocketMsg.getSession(), (PingMessage)webSocketMsg.getMessage()); //处理心跳包
} else if (webSocketMsg.getMessage() instanceof TextMessage) {
onTextMessage(webSocketMsg.getSession(), (TextMessage)webSocketMsg.getMessage()); //处理其他消息
} else {
log.info("webSocketMsg.getMessage =>" + webSocketMsg.getMessage());
}
} else { //如果取不到消息时就跳出while (true)循环,避免无必要的消耗cpu
break;
}
}
}
};
//发送消息线程,负责从 待发送的消息队列sendQueue中 取出消息,然后进行发送
private static final Runnable sendRunnable = new Runnable() {
@Override
public void run() {
while (true) {
WebSocketMessageQueue webSocketMsg = sendQueue.poll(); //从队列中取消息
if (webSocketMsg != null) {
try {
System.out.println("执行发送消息");
webSocketMsg.send();
} catch (Exception e) {
e.printStackTrace();
System.out.println("处理client的消息异常 onMessage error : " + e.getMessage());
}
} else { //如果取不到消息时就跳出while (true)循环,避免无必要的消耗cpu
break;
}
}
}
};
// PutQueue 类是我专门写的公共方法,如下:
/**
* 统一的 put 消息到 队列 的公共类 对外提供唯一的方法
*
*/
public class PutQueue {
/**
*
* @param session socket链接
* @param message 消息
* @param type 1 放人 receiveQueue (待解析消息)队列 2 放入 sendQueue (待发送消息) 队列 注意 不要放错队列!
*/
public static void PutMessageToQueue(WebSocketSession session , WebSocketMessage message, int type){
WebSocketMessageQueue queue = new WebSocketMessageQueue();
queue.setSession(session);
queue.setMessage(message);
if (type == 1){
//将消息放入 待解析的消息 队列
WebSocketController.receiveQueue.add(queue);
}else if (type == 2){
//将消息放入 待发送的消息 队列
WebSocketController.sendQueue.add(queue);
}
}
public static void PutMessageToQueue(WebSocketSession session , String message, int type){
PutMessageToQueue(session, new TextMessage(message), type);
}
}
//WebSocketMessageQueue 如下
public class WebSocketMessageQueue {
private WebSocketSession session; //socket会话信息
private WebSocketMessage message; //消息
public void send() throws IOException{ //统一的发送消息方法
if(session != null && session.isOpen()){
session.sendMessage(message);
}
}
public WebSocketSession getSession() {
return session;
}
public void setSession(WebSocketSession session) {
this.session = session;
}
public WebSocketMessage getMessage() {
return message;
}
public void setMessage(WebSocketMessage message) {
this.message = message;
}
}
OK,改造完毕!这样一来,使用队列替换之前使用cachedThreadPool线程池(如果没有空闲的线程,就一直会创建新的线程)方案,完美的解决了内存消耗隐患,和从根源上保证了线程安全!
以前的架构:

现在的架构:


虽然问题是解决了,但是也给我提了一个醒:
1.不要盲目百度,多看看出问题的代码吧,最好是相关的整个类,尤其是不是自己写的代码;
2.不要先入为主的以为公司之前已经上线的项目就一定没有坑,只要你接手了,肯定会坑你,所以不要迷信不要怕,早发现问题对谁都有好处!
3.不要乱选型!比如以前的代码用了cachedThreadPool线程池,明显不合适!就算不用消息队列,用fixedThreadPool 线程池(fixedThreadPool 线程池可以控制线程最大并发数,超出的线程会在队列中等待)都比cachedThreadPool线程池好!