【IM】短轮询和长连接(消息实时到达)

1,792 阅读6分钟

「这是我参与2022首次更文挑战的第15天,活动详情查看:2022首次更文挑战

一、前言

IM 系统中需要保障消息到达的实时性,一般会采用以下三种方式:

  1. 短轮询:客户端定时去请求服务端拉取消息
  2. 长轮询:当请求没有获取到新消息时,并不会马上结束返回,而是会在服务端 “悬挂(hang)”
  3. 长连接:当有新消息产生,服务端直接向客户端推送

下面来分析下这三种方式的适用场景。

(1)短轮询

短轮询:即平时使用的 “请求响应” 模式。

2022-02-0117-38-58.png

适用场景:

  1. 扫码登录:短时间内频繁查询二维码状态
  2. 弱网请求:弱网情况下,频繁重试

缺点:

  1. 大部分请求无用:在网络正常情况下,大部分请求无用
  2. 服务端请求压力大:频繁访问,提高服务器的 QPS

(2)长轮询

为避免 “短轮询” 高频无用请求的问题,可使用 “长轮询” 的消息获取模式。

长轮询原理:

  • 当本次请求没有获取到新消息时,并不会马上结束返回,而是会在服务端 “悬挂(hang)”,等待一段时间;
  • 如果在等待的这段时间内有新消息产生,就能马上响应返回。

2022-02-0117-45-28.png

适用场景:

  1. 扫码登录
  2. 弱网请求

举个栗子:扫码登录,短轮询和长轮询不同之处:

  • 短轮询:请求处理完,服务端就立马返回

2022-02-0212-10-29.png

  • 长轮询:服务端挂起,直至请求处理完成 或 超时

2022-02-0212-08-59.png

优点:相比短轮询模式

  1. 大幅降低短轮询模式中客户端高频无用的轮询导致的网络开销和功耗开销
  2. 降低了服务端处理请求的 QPS

缺点:

  1. 无效请求:长轮询在超时时间内没有获取到消息时,会结束返回,因此仍然没有完全解决客户端“无效”请求的问题。

  2. 服务端压力大:服务端悬挂(hang)住请求,只是降低了入口请求的 QPS,并没有减少对后端资源轮询的压力。

    假如有 1000 个请求在等待消息,可能意味着有 1000 个线程在不断轮询消息存储资源。


(3)长连接

因为服务端无法直接向客户端进行推送,所以有短轮询和长轮询这两种 “曲线救国” 方式。

实现原理:客户端和服务器之间维持一个 TCP/IP 长连接,全双工通道。

例如:HTML5 下,全双工的 WebSocketWeb 原生支持,实现相对简单。

2022-02-0118-54-35.png

优点:

  1. 支持服务端推送的双向通信,大幅降低服务端轮询压力;
  2. 数据交互的控制开销低,降低双方通信的网络开销;



二、实战

主要实战项目有两个:

  1. 长轮询
  2. websocket 使用

(1)长轮询

实现思路有两种:

  1. 方法一:阻塞,等到时间再返回
  2. 方法二:建立连接和请求处理分离

1)方法一:阻塞

代码实现:

@Slf4j
@RestController
@RequestMapping("/async")
public class AsyncController {
    @GetMapping("/sync")
    public String sync() {
        log.info("===> 开始 sync");
        
        // 模拟处理请求
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        log.info("===> 结束 sync");
        return "sync";
    }
}

测试:

$ curl http://localhost:8080/async/sync
sync

日志输出结果:

2022-02-02 12:03:35.387 - INFO 10699 [nio-8080-exec-1] AsyncController   : ===> 开始 sync
2022-02-02 12:03:40.387 - INFO 10699 [nio-8080-exec-1] AsyncController   : ===> 结束 sync

2)方法二:建立连接和请求处理分离

这里使用 Spring MVC 的异步请求处理方式:DeferredResult

Spring MVC 在调用处理器方法后会对返回值做处理,如果发现返回值为异步请求类型,则不会立即响应客户端,而是直接将请求挂起,中断当前请求的处理流程,直到异步任务超时或者被设置了结果值才响应客户端。

DeferredResult 适用于处理客户端轮询的场景,可以实现延时响应客户端的效果,有效避免轮询请求过于频繁造成服务器压力。

配合 DeferredResult 使用:

  1. 客户端发起请求
  2. 服务端接收,并将请求挂起(“hang”),满足以下两种情况之一则返回:
    1. 主动:DeferredResult#setResult() 调用
    2. 被动:超时
  3. 客户端你接收响应

代码实现:

@Slf4j
@RestController
@RequestMapping("/async")
public class AsyncController {

    @Autowired
    private Service service;
    
    @GetMapping(value = "/deferred")
    public DeferredResult<String> DeferredWay() {
        log.info("===> 开始 deferred");
        DeferredResult<String> result = new DeferredResult<>(5000L, "默认值");
        result.onTimeout(() -> log.info("deferred 调用超时"));
        result.onCompletion(() -> log.info("deferred 调用完成"));

        // 异步调用:业务处理
        // 比如多次循环访问 redis ,查询二维码(qrcode)状态
        service.scanQrCode(result);

        log.info("===> 结束 deferred");
        return result;
    }
}
@Slf4j
@Service
public class Service {

    @Async
    @Override
    public void scanQrCode(DeferredResult<String> result) {

        log.info("---> 开始业务调用");

        // 模拟多次查询
        // 例如:每秒查询1次 redis
        try {
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        log.info("---> 结束业务调用");

        result.setResult("扫描成功");
    }
}

测试:

2022-02-0212-52-54.png

日志输出结果:

2022-02-02 12:52:12.428 - INFO 12275 [nio-8080-exec-3] AsyncController   : ===> 开始 deferred
2022-02-02 12:52:12.428 - INFO 12275 [nio-8080-exec-3] AsyncController   : ===> 结束 deferred
2022-02-02 12:52:12.428 - INFO 12275 [  AsyncThread-2] Service   : ---> 开始业务调用
2022-02-02 12:52:16.428 - INFO 12275 [  AsyncThread-2] Service   : ---> 结束业务调用
2022-02-02 12:52:16.434 - INFO 12275 [nio-8080-exec-4] AsyncController   : deferred 调用完成



(2)wss 使用

步骤如下: 0. 添加依赖

  1. 新建 websocket 配置文件
  2. 编写对外接口
  3. 前端页面

添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

配置文件:

@Configuration
public class WebsocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

编写对外接口:

@Slf4j
@Controller
@ServerEndpoint(value = "/wss")
public class WssController {

    private static AtomicInteger onlineCount = new AtomicInteger(0);


    @OnOpen
    public void onOpen(Session session) {
        onlineCount.incrementAndGet();
        log.info("有新连接加入:{},当前在线人数为:{}", session.getId(), onlineCount.get());
        System.out.println("session open. ID:" + session.getId());
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose(Session session) {
        onlineCount.decrementAndGet(); // 在线数减1
        log.info("有一连接关闭:{},当前在线人数为:{}", session.getId(), onlineCount.get());
        System.out.println("session close. ID:" + session.getId());
    }

    /**
     * 收到客户端消息后调用的方法
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("服务端收到客户端[{}]的消息:{}", session.getId(), message);
        this.sendMessage("Hello, " + message, session);
    }

    private void sendMessage(String message, Session toSession) {
        try {
            log.info("服务端给客户端[{}]发送消息{}", toSession.getId(), message);
            toSession.getBasicRemote().sendText(message);
        } catch (Exception e) {
            log.error("服务端发送消息给客户端失败:", e);
        }
    }

    /**
     * 发生错误时调用
     */
    @OnError
    public void onError(Session session, Throwable error) {
    }
}

前端页面:

<!DOCTYPE HTML>
<html>
<head>
<title>My WebSocket</title>
</head>

<body>
    <input id="text" type="text" />
    <button onclick="send()">Send</button>
    <button onclick="closeWebSocket()">Close</button>
    <div id="message"></div>
</body>

<script type="text/javascript">
    var websocket = null;

    if ('WebSocket' in window) {
        websocket = new WebSocket("ws://localhost:8080/wss");
    } else {
        alert('Not support websocket')
    }

    //连接发生错误的回调方法
    websocket.onerror = function() {
        setMessageInnerHTML("error");
    };

    //连接成功建立的回调方法
    websocket.onopen = function(event) {
        //setMessageInnerHTML("open");
    }

    //接收到消息的回调方法
    websocket.onmessage = function(event) {
        setMessageInnerHTML(event.data);
    }

    //连接关闭的回调方法
    websocket.onclose = function() {
        setMessageInnerHTML("close");
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function() {
        websocket.close();
    }

    //将消息显示在网页上
    function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }

    //关闭连接
    function closeWebSocket() {
        websocket.close();
    }

    //发送消息
    function send() {
        var message = document.getElementById('text').value;
        websocket.send(message);
    }
</script>
</html>

打开 index.html 发送消息:

2022-02-0221-05-34.png

日志输出结果:

2022-02-02 20:58:00.423 - WssController : 有新连接加入:1,当前在线人数为:1
session open. ID:1
2022-02-02 20:58:18.841 - WssController : 服务端收到客户端[1]的消息:1
2022-02-02 20:58:18.841 - WssController : 服务端给客户端[1]发送消息Hello, 1
2022-02-02 20:58:36.065 - WssController : 服务端收到客户端[1]的消息:2
2022-02-02 20:58:36.065 - WssController : 服务端给客户端[1]发送消息Hello, 2