最近看了不少技术书籍及文章,但自己从未产出过,究其原因,主要如下:
- 菜 在某个技术领域积累不够,难以有高质量的干货沉淀成文字
- 懒 这个不用解释...
所以闲言少叙,放码过来
本文章主要包括以下几个方面
- 为什么是IM?
- 协议选择
- 网络模型
- 客户端异步编程
- 连接管理
- 可靠消息模型
- 架构设计
- 信息安全
- 问题及优化
1. 为什么是IM?
在一家做海外即时通讯市场的公司(注册用户数千万、同时在线用户数百万)做过两年研发,有一定实践经验
IM系统综合性相对较强,相关的技术包括但不限于网络编程、服务化架构、信息安全等
有一定技术挑战,十万/百万/千万级不同在线规模带来的是差异化的技术场景,需要从具体的协议实现到整体的架构设计来进行优化
2. 协议选择
IM实现上主要有两种方式,即利用已有的IM协议和自定义协议,不同的实现方式各有利弊
2.1 成熟的IM协议
包括XMPP、MQTT等,具体特点如下:
XMPP 的优点在于技术成熟,相关框架丰富,利于快速构建;缺点也很明显,基于XML导致数据冗余大且交互复杂,是PC时代的产物,并不适用于移动互联网
MQTT 的优势在于协议简洁轻巧,对弱网络容忍度好;不足在于基于订阅/发布模型,不适用于单聊、群聊等场景丰富的IM应用
2.2 自定义协议
本文重点讲解基于自定义协议的IM实现,自定义协议包括网络协议和数据交换协议
2.2.1 网络协议
即客户端和服务端通过什么协议进行网络通信,其实选择很多,比如Comet(HTTP Long Polling/Streaming)、WebSocket、Socket等。
在此我们基于Socket长连接实现消息收发,原因在于每层协议消息头中都要携带跟协议本身相关的信息,而越是上层的协议消息中payload占比越低,使用越低层的协议可以使报文更小。同时Socket是全双工通道,维持长连接任意一端可随时主动发送报文,这是保证消息即时的基础。
PS: Socket不属于OSI七层网络模型,是对于TCP的一个抽象,便于程序中方便的使用TCP协议
2.2.2 数据交换协议
即规定网络中的字节流数据如何与应用程序需要的结构化数据相互转换,实现的功能是序列化和反序列化,其中按类型可以分为文本协议和二进制协议。
文本协议
常见的文本协议包括JSON、XML等,可读性好,便于调试,方便扩展。但文本协议的缺点在于解析效率一般,数据冗余大(XML尤其严重)
二进制协议
成熟的二进制协议也不少,比如Prototol Buffers、Thrift等,自带数据压缩,编解码效率高,同时兼具扩展性。不足之处就是可读性差不利于调试
移动互联网面临的问题是网络不稳定是常态、弱网络是常态、流量敏感、电量敏感,因此二进制协议显然是更好的选择。
当然也可以自定义Packet,通常采用的是定长包头加变长包体的方式。但其实直接利用现有的二进制协议满足需求。作者在参与开发过的IM产品中直接使用的Protocol Buffers,据说微信也是如此,所以本文选择Protocol Buffers做为数据交换协议进行讲解。
网络模型
上面讲到利用Socket长连接和Protocol Buffers做为服务端和客户端的通信基础。那么如何维持大量的长连接呢?
先看看经典的C10K问题
网络服务在处理上万客户端并发连接时,往往出现效率低下甚至完全瘫痪,这被称为C10K问题
要想了解C10K问题出现的原因以及解决方案,先要熟悉操作系统的IO模型。说到IO模型,自然会想到同步/异步、阻塞/非阻塞,这些分别指的是什么呢,到底有何区别,下面就此简述一下。
当一个IO操作发生时,它会历经两个阶段:
- 等待数据就绪
- 将数据从内核空间拷贝到用户空间
各IO模型就是在这两个过程处理的不同
阻塞IO(blocking IO)
两个阶段都被阻塞,为了解决并发连接问题,通常是为每个连接分配一个线程,当连接数上升时,线程数也会快速膨胀。而线程是一种宝贵的资源,也是CPU调度的最小单元,大量的线程会占用大量的内存资源,同时也会导致频繁的上下文切换,整体性能会随着连接数的增多快速降低,并最终不可用。为了解决线程膨胀带来的问题,很多地方都引入了线程池,但这只适用于提供短连接的服务,无法管理大量长连接,因为本质是一个线程管理一个连接。
非阻塞IO(non-blocking IO)
第一阶段不会阻塞,内核直接返回结果,用户进程通过不断轮询内核去判断数据是否就绪,此过程将消耗大量的CPU,虽然这种方式能在一个线程实现对多个连接就绪监测及数据的接收,但并不被推荐直接使用,而是在其他IO模型中使用这种非阻塞特性。
多路复用IO(IO multiplexing)
第一阶段不会阻塞。IO事件注册到Selector(多路复用选择器)上,Selector负责轮询注册到它上面的socket,当检测到有IO事件后,通知用户进程,用户进程再进行读写操作。这种模型下,对于每一个socket,一般都设置为Non-Blocking的,但是用户进程调用Selector的select方法时是阻塞的。该模型的特点在于能用单个Selector管理多个连接。
基于这种IO模型,典型的有select/poll。但在面对大量连接时,依然会有致命的问题,因为每次调用select都会向内核拷贝事件描述符,同时需要轮询已注册的文件句柄集合,当集合很大时,这里将会消耗大量的时间,会导致IO事件响应不及时。
针对上述问题,很多操作系统提供了更高效的接口,比如epoll/kqueue/dev/poll等,主要是减少事件描述符的拷贝,通过在注册的文件描述符上注册回调来替代轮询。
信号驱动IO(signal driven IO)
第一阶段不会阻塞。通过注册信号处理函数,当监控的文件句柄等待就绪时,内核通知触发信号处理函数,将数据从内核空间拷贝到用户空间。该IO模型通常在UDP中使用。
异步IO(asynchronous IO)
两个阶段都不会阻塞。在多路复用IO中,用户进程获取到IO事件就绪通知后,需要去自行读取数据,而在异步IO模型中,用户进程获取的是IO事件完成的通知,操作系统会把数据从内核空间拷贝到用户进程。
通过对以上5种IO模型的介绍,我们不难看出多路复用IO和异步IO能满足少量线程管理大量连接的需求。进一步考虑到IM系统虽然要管理大量长连接,但主要还是轻量级数据交换,每个报文数据量较小,使用多路复用IO模型足矣,以Linux服务器为例,则是epoll。
经典的Reactor模式
Reactor(反应堆)是基于多路复用IO的一个经典模式,如下图:
上图是一个多线程的Reactor,每个Reactor有多个IO线程,每个IO线程有一个Selector(多路复用选择器),Main Reactor用来接收客户端的Connect事件,Accept连接后,分配给Sub Reactor上的某个IO线程,在相应的Selector上注册读写事件。
同时在IO线程中只进行读写、编解码操作,业务逻辑放在业务线程池中执行,防止IO线程长时间阻塞(会延迟下一次select的执行,导致IO事件响应不及时)。
通过该模式可以方便的利用少量线程管理大量长连接,通常在普通的Linux服务器上,利用该种模式能比较容易的实现20至30万长连接的接入。
客户端异步编程
协议选择和网络模型讲述完毕后,我们知道了基于何种IO模型在客户端和服务端建立连接,采用何种形式进行数据交换。
对于客户端而言,有3种报文类型:
- 主动发出的请求报文,我们定义为req
- 服务端对请求的响应报文,我们定义为resp
- 服务端的主动通知报文,我们定义为notify
和HTTP这种请求响应模式的不一样,TCP是双工通道,连接两端可自主发送报文,在通道中上行下行数据源源不断。比如客户端发出了两个报文req1
和req2
,然后又依次收到了四个报文notify1
、resp2
、resp1
、notify2
,那么客户端如何鉴别resp2
报文是对req2
的响应,resp1
是req1
的响应呢?
很简单,通常是在Req中带上一个sequence(序列号),同时保证同一个连接中sequence唯一即可,在发送req之前,把sequence和callback的关系存在map/dict
中。服务端在返回resp时回传sequence,这样客户端在收到resp报文时先解析出sequence,从map/dict
获取响应请求的callback,然后callback.success(data)即可。
在客户端的请求中通常都会设置一个timeout(超时时间),这也很容易实现。在上述基础上,记录sequence和callback关系之后,封装一个task(延迟任务)并丢入超时任务队列,如果服务端没有在指定时间内返回resp,从task中获取sequence,再从map/dict
中获取callback,然后callback.timeout即可。
如此一来,客户端就能实现异步模式的请求/响应,但按照上述实现时要注意,在resp正常返回时需要delete超时任务队列中的task,在resp未如期返回执行超时逻辑时同样需要deletemap/dict
中对应的关系,在连接异常时map/dict
中的callback要全部清空并执行callback.error(),同时清空超时任务队列。
连接管理
在上节中讲了选用怎样的IO模型能实现客户端的大量接入。而针对每个客户端而言,该如何管理自己与服务器之间的长连接?而服务端又如何管理和每一个客户端的连接?
详述之前,我们先引出一个服务:Gate Server,即网关服务,有的地方也叫接入服务。Gate Server的主要作用就是用来和客户端建立并维持长连接。
IM系统消息的即时性是基于长连接的,因此长连接管理是重中之重,移动网络如此复杂,连接保活、连接检测、自动重连、接入决策是必须要考虑的,下面一一道来。
客户端连接管理
对于一个典型的IM APP而言,连接的建立过程是这样的:
- 通过账号/密码、手机号/验证码登录,服务端返回uid和token
- 通过一定策略获取目标Gate Server的IP地址和端口号列表
- 从地址列表中选择一个目标地址,尝试连接
- 连接成功,发送鉴权请求,鉴权成功则授权的长连接正式建立
- 客户端按照策略发送心跳包(用于连接检测及保活)
可靠消息模型
TODO
架构设计
TODO
信息安全
TODO
问题及优化
TODO