聊聊IM系统的即时性和可靠性

因为工作的原因,对IM相关的技术接触比较多,这篇文章来聊一聊IM系统最重要的两个特性:即时性和可靠性。

即时性

即时性,通俗来讲就是指低延迟。在双方通信过程中,一方发送的消息,另一方需要在短时间内立即收到。IM系统的即时性,主要是靠长连接和断线重连机制来保证。
(还有一种实时性的提法,一般是针对语音、视频通话、直播等场景,这些场景对低时延的要求更高,但允许少量的信息丢失,因此通常基于UDP协议)

websocket长连接

对于web客户端来说,目前与服务器通信的方式主要有http request/SSE/websocket三种。

  1. http协议是web端最常用的通信协议,可能和很多人的理解不同,http连接并不一定是“短连接”,实际上在1.1版本后已经实现了对长连接的支持。在http1.0协议中,每次请求都会建立一个新的TCP连接,响应完成后立即关闭。具备一些网络知识的同学都知道,TCP连接的建立和关闭是很耗资源的(还记得三次握手和四次挥手吗);因此http协议在1.1版本对此进行了改进,增加了连接复用的keep-alive协议,在一个TCP连接中可以发送多个Request,接收多个Response。
    但是,虽然http1.1可以在一个连接中完成多个http请求,但由于协议自身的无状态性,每个请求均要携带完整的http header,造成信息交换的浪费;同时,http协议的通信方式只能是客户端发送request->服务器返回response,服务器无法主动向客户端推送信息,如果客户端想要实时获取信息,只能通过轮询的方式。

  2. SSE,全称Server-Sent Events,用于服务器向客户端推送消息。SSE基于http协议,连接建立后不会断开,客户端会一直等待接收服务器发送过来的数据流。SSE的通信是单向的,只能由服务器向客户端发送;如果浏览器向服务器发送信息,就变成了另一次请求。

  3. websocket是html5提出的一个新的通信协议,用于在客户端和服务器之间建立一个全双工的持续连接。websocket基于TCP,因此能够实现可靠通信;出于兼容性的考虑,websocket借用http协议来完成连接建立的握手阶段,连接建立后双方的信息通过数据帧传输,不再需要复杂的header。

基于上面的分析,websocket无疑是最符合IM系统即时性要求的通信方式,它能够提供低时延、高性能、全双工的“真·长连接”。

知识补充:websocket是如何建立连接的

  1. 客户端发送一个http握手请求
GET /chat HTTP/1.1
GET wss://ws.qiyukf.com/socket.io/?events=refresh&code=ysftest4&EIO=3&transport=websocket HTTP/1.1
Host: ws.qiyukf.com
Connection: Upgrade
Upgrade: websocket
Origin: http://ysftest4.qiyukf.com
Sec-WebSocket-Version: 13
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
Sec-WebSocket-Key: gtWgniZtQRUVLSVVJiAQ3g==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

Connection: Upgrade Upgrade: websocket 字段告诉服务器当前发起的是Websocket协议

  1. 服务器发现自己支持websocket服务,则建立websocket连接并返回响应报文
HTTP/1.1 101 Switching Protocols
Server: nginx
Connection: upgrade
upgrade: websocket
sec-websocket-accept: ddUBPRBVf4vZ+goyOJpfUhss3Fk=
sec-websocket-extensions: permessage-deflate

101 Switching Protocols告诉客户端已成功切换协议,建立了websocket连接

  1. 客户端升级到websocket协议,后续的数据交换基于websocket协议

基于心跳机制的断线重连

名词解析:心跳
心跳一般是指某端每隔一定时间向对端发送自定义指令,以判断双方是否存活,因其按照一定间隔发送,类似于心跳,故被称为心跳指令。

断线重连很好理解,出于即时性的考虑,一旦连接断开需要尽快重连,以恢复正常的消息收发。而对连接是否断开的判断,则需要靠心跳机制来完成。上一节我们提到,websocket是基于TCP的,那么难道不能依靠TCP协议自身来判断连接状态,一定要在应用层加一个额外的心跳机制吗?为了解答这个疑问,我们可以先做几个实验。

首先实现一个简单的websocket应用:
server.js

// 基于ws库
var WebSocketServer = require('ws').Server,
    wss = new WebSocketServer({ port: 4000 });

wss.on('connection', function (ws) {
    console.log('client connected');
    ws.on('message', function (message) {
        console.log(message);
    });
    ws.on('close', function () {
        console.log('disconnect');
    });
});

client.html

<script>
    ws = new WebSocket("ws://localhost:4000");

    ws.onopen = function (evt) {
        console.log("Connection open ...");
    };
    ws.onclose = function (evt) {
        alert("Connection close ...");
    };

</script>

在浏览器打开client页面,建立连接后,进行以下几个实验:
1. 浏览器关闭页面
结果:连接断掉,且服务端能够感知
2. 客户端断网
结果:客户端本身会收到close事件,但服务器无感知
3. 服务器终止socket服务/kill掉进程
结果:连接断掉,客户端能够感知
4. 链路中间的网络设备出现问题
连接实际上断开了,但客户端和服务器均无感知

2和4的结果说明,TCP连接的断开有时是无法探知的(起码是无法瞬时探知)。TCP连接实际上是一个虚拟的连接,只要通信双方完成了三次握手,连接就成功建立了;除非其中一方发出断开连接的报文,否则这个状态不会改变。这条通路上真实网络情况的变化,并不会被TCP感知。一个最简单的例子,假设我们通过ssh登录了远程主机,一不小心踢掉了网线,重新插上之后会发现连接仍然是可用的。

知识复习:TCP的三次握手

那么,TCP协议完全没有探测连接状态的能力吗?并不是,TCP提供了KeepAlive机制,开启状态下一旦连接长时间空闲,TCP协议会自动发送一个KeepAlive探针来检测连接是否断开。那么是否可以使用KeepAlive机制来替代应用层的心跳机制呢?答案是否定的。

一方面TCP KeepAlive的超时时间非常长,另一方面,KeepAlive是用于检测连接的死活,而心跳机制则附带一个额外的功能:检测通讯双方的存活状态。两者听起来似乎是一个意思,但实际上却大相径庭。考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用TCP探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态,一直向当前服务器发送些必然会失败的请求。同样的,假如客户端因为某些原因崩溃了,服务器也需要停止向客户端推送消息,并回收连接和资源。举一个现实生活中的例子,假设你和朋友小明打电话,结果打到一半小明睡着了,这时候通讯连接仍然是通畅的,但为了你的话费着想,你最好主动挂掉这通电话。

此外,心跳机制还有一个作用,那就是防止空闲的连接被网络运营商回收。因为IPv4的地址数量有限,运营商分配给手机终端的IP是运营商内网的IP ,手机要连接Internet就需要通过运营商的网关做一个网络地址转换(Network Address Translation,简称:NAT)。因此,大部分移动无线网络运营商都在链路一段时间没有数据通讯时,会淘汰 NAT 表中的对应项,造成链路中断。心跳机制能够有效避免这种情况。

总结:为什么基于TCP的websocket长连接仍然需要心跳机制
1. TCP连接的断开有时是无法瞬时探知的,因此不适合实时性高的场合
2. TCP协议的KeepAlive机制只能检测连接存活,而不能检测连接可用
3. 心跳机制能够避免连接被网络运营商回收

云信sdk相关代码

/*
 * 重新建立连接
 *   - 如果还有可用地址, 那么尝试下一个地址;
 *   - 如果没有可用地址, 但是lbs重试次数还没达到上限, 那么重试lbs;
 * - 如果既没有可用地址, 重试次数也达到上限, 那么不再重新连接。
 */
ProtocolFn.reconnect = function() {
    // debugger;
    var self = this;
    if (self.willReconnect()) {
        // 重置 socket, 这样才能换地址
        self.socket = null;
        self.retryCount++;
        // 指数退避算法计算delay时间
        var duration = self.backoff.duration();
        ...
        setTimeout(function() {
            self.connect();
        }, duration);
    } else {
        self.notifyDisconnect();
    }
};

ProtocolFn.connect = function() {
    var self = this;
    // 防止重复建立连接
    if (self.isConnected() || self.connecting) { return; }
    self.connecting = true;
    if (!self.socket) {
        // 如果有 url, 那么连此 url, 否则刷新 url
        var url = self.getNextSocketUrl();
        if (!!url) {
            self.connectToUrl(url);
        } else {
            self.refreshSocketUrl();
        }
    } else {
        self.socket.socket.connect();
    }
};

ProtocolFn.getNextSocketUrl = function() {
    return this.socketUrls.shift();
};

可靠性

IM系统的可靠性,通常就是指消息投递的可靠性,即我们经常听到的“消息必达”。消息的可靠性(不丢失、不重复)无疑是IM系统的重要指标,也是IM系统实现中的难点之一。

在线消息收发流程

我们应该听过这样一个结论:TCP是一种可靠的传输层协议。那么上图的收发过程能够保证消息必达吗?答案是不能,因为网络层的可靠性不等于应用层的可靠性。上图中,clientA-server、server-clientB两条信道是可靠的,但clientA、server、clientB本身却无法保证可靠性。

如图,数据可靠抵达网络层之后,还需要一层层往上移交处理,在整个过程中,各种极端情况都可能出现(断网,用户 logout,disk full,OOM,crash,关机。。)等等,网络层以上的处理步骤越多,出错的可能性就越大。拿clientB举例,假设消息成功到达了网络层,但在应用程序消费之前,客户端崩溃了;这种情况下,网络层不会进行重发,如果不在应用层增加可靠性保障,这条消息对于clientB来说就丢失了。

那么怎样在应用层增加可靠性保障呢?有一个现成的机制可供我们借鉴:TCP协议的超时、重传、确认机制。具体来说,就是在应用层构造一种ACK消息,当接收方正确处理完消息后,向发送方发送ACK;假如发送方在超时时间内没有收到ACK,则认为消息发送失败,需要进行重传或其他处理。增加了确认机制的消息收发过程如下:

我们可以把整个过程分为两个阶段:

clientA->server

1-1、clientA向server发送消息(msg-Req);
1-2、server收取消息,回复ACK(msg-Ack)给clientA;
1-3、一旦clientA收到ACK即可认为消息已成功投递,第一阶段结束。

无论msg-A或ack-A丢失,clientA均无法在超时时间内收到ACK,此时可以提示用户发送失败,手动进行重发。

server->clientB

2-1、server向clientB发送消息(Notify-Req);
2-2、clientB收取消息,回复ACK(Notify-ACk)给server;
2-3、server收到ACK之后将该消息标记为已发送,第二阶段结束。

无论msg-B或ack-B丢失,server均无法在超时时间内收到ACK,此时需要重发msg-B,直到clientB返回ACK为止。

云信sdk相关代码

// 调用 socket 发送命令
ProtocolFn.doSendCmd = function(cmd) {
    var self = this;
    var ser = cmd.SER;
    // 存储超时
    self.timerMap[ser] = setTimeout(function() {
        self.markCallbackInvalid(ser, NIMError.newTimeoutError());
    }, config.cmdTimeout);
    // 发送命令
    self.socket.send(JSON.stringify(cmd));
};

/**
 * 接收到服务端的消息
 *
 * @private
 * @param {String} data 包数据
 * @return {Void}
 */
ProtocolFn.onMessage = function(data){
    var self = this;
    self.heartbeat()
    var packet = self.parser.parseResponse(data);
    ...
    self.callPacketAckCallback(packet);
};

// 调用回包确认回调函数
ProtocolFn.callPacketAckCallback = function(packet) {
    var self = this;
    if (!!packet && !!packet.raw) {
        var ser = packet.raw.ser;
        if (!!ser) {
            self.clearTimerWithSer(ser);
            var callback = self.getCallbackWithSer(ser);
            ...
            callback(packet.error, packet.obj);
        }
    }
};
离线消息收发流程

和在线消息收发流程类似,离线消息收发流程也可划分为两个阶段:

clientA->server

1-1、clientA向server发送消息(msg-Req)
1-2、server发现clientB离线,将消息存入offline-DB

server->clientB

2-1、clientB上线后向server拉取离线消息(pull-Req)
2-2、server从offline-DB检索相应的离线消息推送给clientB(pull-res),并从offline-DB中删除

显然,这个过程同样存在消息丢失的可能性。举例来说,假设pull-res没有成功送达clientB,而offline-DB中已删除,这部分离线消息就彻底丢失了。与在线消息收发流程类似,我们同样需要在应用层增加可靠性保障机制。

与初始的离线消息收发流程相比,上图增加了1-3、2-4、2-5步骤:

1-3、server将消息存入offline-DB后,回复ACK(msg-Ack)给clientA,clientA收到ACK即可认为消息投递成功;
2-4、clientB收到推送的离线消息,回复ACK(res-Ack)给server;
2-5、server收到res-ACk后确定离线消息已被clientB成功收取,此时才能从offline-DB中删除。

性能优化:
当离线消息的量较大时,如果对每条消息都回复ACK,无疑会大大增加客户端与服务器的通信次数。这种情况我们通常使用批量ACK的方式,对多条消息仅回复一个ACK。在云信的实现中是将所有的离线消息按会话进行分组,每组回复一个ACK;假如某个ACK丢失,则只需要重传该会话的所有离线消息。

消息去重

在应用层加入重传、确认机制后,我们完全杜绝了消息丢失的可能性;但由于重试机制的存在,我们会遇到一个新的问题,那就是同一条消息可能被重复发送。举一个最简单的例子,假设client成功收到了server推送的消息,但其后续发送的ACK丢失了,那么server将会在超时后再次推送该消息;如果业务层不对重复消息进行处理,那么用户就会看到两条完全一样的消息。
消息去重的方式其实非常简单,一般是根据消息的唯一标志(id)进行过滤。具体过程在服务端和客户端可能有所不同:

  1. 客户端
    我们可以通过构造一个map来维护已接收消息的id,当收到id重复的消息时直接丢弃;
  2. 服务端
    收到消息时根据id去数据库查询,若库中已存在则不进行处理,但仍然需要向客户端回复Ack(因为这条消息很可能来自用户的手动重发)。
  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值