记一次 websocket [TEXT_PARTIAL_WRITING] 问题解决经历

3,374 阅读8分钟

公司的一个项目要上线,某天早上查看日志,发现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线程池好!