1 简介
Early Home C端产品的即时通讯功能直接使用第三方商业软件服务(SaaS)。 功能可扩展性受到很大限制。 一些定制化的业务需求很难实现。 考虑到后续业务发展的需求和数据安全、内容实时审核和性能、自主可控高可用架构等因素,最终决定自主开发IM即时通讯平台并逐步迭代替代。
2. 网络通信框架和协议
2.1
网络通信框架
网络通信系统通常可以选择原生NIO库或者第三方网络框架进行开发。 原生NIO类库API比较基础,缺乏对常用操作的封装,如粘贴、解码、重连等,增加了开发工作量和维护成本。 会比较高,需要注意很多底层的东西。 我们选择了目前非常流行的网络框架Netty进行开发。 Netty功能强大,开发门槛低。 预置多种主流协议编解码功能。 它成熟稳定,修复了大量已发现的JDK NIO BUG。
► Netty具有以下优点:
1、上手简单,文档齐全,无其他依赖,只依赖JDK就够了;
2、使用方便,具有多种预设编解码功能,支持多种主流协议,封装大量常用操作,减少开发周期;
3、高性能、高吞吐量、低延迟、低资源消耗;
4、灵活的线程模型,支持阻塞和非阻塞I/O模型;
5、代码质量高,目前主流版本基本没有bug;
6、社区活跃,版本迭代周期短,发现的Bug能及时修复,并会增加更多新功能;
7、经历过大规模商业应用测试,稳定性得到验证。
2.2
协议书
TCP是一种“流”协议,是一串没有边界的数据。 数据传输过程中,可能会出现多包或半包传输,原因如下:
1、应用程序写入的字节大小大于socket发送缓冲区的大小;
2. 执行MSS大小的TCP分段;
3. 以太网帧的净荷大于MTU,进行IP分片。
► 解决策略
1、消息长度固定,例如每条消息的大小固定为200字节。 如果不够,将填补空白。
2、使用回车符分隔,如FTP协议。
3. 同意使用特殊字符作为消息结束标记。
4、将消息分为消息头和消息体。 消息头包含消息的总长度(或消息体长度)。
5、应用层协议更加复杂。
按照约定的方法进行编码和解码,保证数据完整性是一种通信协议。 将消息分为消息头和消息体是最常见的协议设计方法(定长消息头,变长消息体),如下图:
► 消息头
固定长度字节来标记消息类型和消息正文的长度(以字节为单位)。
►消息正文
您可以使用文本、XML、JSON、Protobuf 和其他可扩展的数据格式。 尝试考虑以下几点:
1、精简消息体大小,避免冗余数据,减少网络带宽占用(尤其是大量用户发送消息群聊的场景),提高传输效率。
2.数据安全(如加密传输)
3. 编码效率和可扩展性
► 除了自己设计实现通信协议外,还可以直接使用现成的公共协议,比如目前流行的websocket协议,它相对于私有协议有以下优势:
1.直接浏览器支持,方便网页访问
2、降低接入成本。 Websocket是开放协议,访问时不需要在协议规范上花费太多的通信时间。
3.很多框架,包括Netty,都带有websocket协议解码器。
3、架构设计
客户端:用户发送和接收消息的终端
接入层:为客户端连接、发送和接收消息、建立关系提供入口。
数据层:负责各种业务逻辑数据和消息数据的缓存或持久化存储,以及消息指令的分发通道。
3.1
消息设置
聊天场景中常见的消息类型通常包括:文字、图片、表情、语音、视频。 我们将消息分为两部分:消息类型和消息内容。 客户端解析消息内容并通过识别消息类型来显示。 图片、视频等消息不需要立即通过消息传输其内容。 相反,图片或视频首先要上传到文件服务器,只需带上消息中的 URI 和 base64 编码的缩略图即可,如下例所示:
消息类型:msg_image
msgContent: { user: { id:1,name:xxx,portrait:xxxx }, content: "缩略图base64编码", url: "大图地址", extra: "" }
消息传递过程如下:
如图所示,总体思路是通过webapi将上游消息写入消息队列,socket服务器通过消息队列或者离线消息库进行增量消息分发和同步。
Socket服务承载并连接所有在线用户。 部署上线会导致所有在线用户断开连接并重新连接,这会瞬间增加其他服务器的连接压力,并可能对某些用户行为产生短期影响。 上行消息通过webAPI实现。 它统一了消息入口,方便了各个渠道的消息的访问,也使得socket服务的职责变得简单、单一。 它仅用于维护连接和推送消息。 当上游消息业务逻辑频繁变化时,只需要重新部署webAPI,这对用户来说基本是有利的。 没有感知。
还有一些建议:合并消息和压缩可以大大提高消息推送吞吐量,减少带宽占用。 经测试,十余条消息的压缩率可达80%左右; 对消息队列进行细分,按照不同维度拆分消息队列。 可以分散压力,避免高峰期某些类型的消息延迟其他业务消息的推送,保证其他消息的时效性。 例如可以根据场景用户消息、系统指令等使用独立的消息队列。
3.2
消息传播
消息的传播和分发是IM设计的重点,尤其是在一对多的场景中,比如群聊。 简单地说,每条消息都需要传播给小组中的每个人。 通常有两种方法,读扩散和写扩散。 什么是读扩散? 什么是写扩散?
► 阅读扩散:
说明:每条消息只存储一份,组内所有成员都可以读取这份数据。
优点:节省存储空间,无书写压力。
缺点:
1、获取离线增量消息逻辑复杂。 需要根据所有用户会话关系来遍历获取,并且不知道会话是否有增量消息,这会导致大量无效空读,速度极慢。
2、针对单个用户对消息进行个性化操作时,设计比较麻烦。 例如,当一个用户删除消息、阅读回执等时,不能影响其他用户的视角。
► 写扩散:
描述:每个用户都有独立的消息列表,每条消息都会同步到所有消息关联者。
优点:读取逻辑简单,效率极高。 所有离线增量消息可以通过用户自己的消息列表一次性获取。 用户个性化操作实现简单,不会影响其他用户的观点。
缺点:存储空间增加,书写压力较大。
两个选项如图所示:
对比两种消息扩散方案,优缺点都比较明显。 同时,他们也面临着不可接受的极端情况,比如阅读扩散。 如果一个用户的会话量巨大,那么他每次上线都要读消息,那将是一场灾难。 另一个例子是写扩散。 ,如果遇到几万人的人群,每条消息都会生成一万份。 作为设计师,必须根据实际情况,将两种方案逻辑组合起来,平衡读写压力。
消息写入扩散按需延迟。 我们按照登录时间将用户分为活跃用户和非活跃用户,并且只对活跃用户进行即时写扩散。 非活跃用户上线时进行补偿同步操作,可以有效分散消息写入压力。 它还可以同步无意义的消息副本,以减少僵尸帐户。
3.3
IMSDK架构组成
IMSDK架构按照模块分工可分为中间件层、核心层和协议层。
►中间件层
负责请求连接时所需的Token,以及Token缓存、Token过期更新等逻辑。App启动时,平台层会将用户信息设置给中间件。 中间件会根据设置的用户信息判断本地是否缓存了用户的Token。 如果有,则直接使用Token进行连接; 如果没有,则向接口请求获取Token。 获取后,使用获取到的Token进行连接。 连接成功后,Token会缓存在本地,方便下次使用。 如果在连接过程中服务器返回令牌过期错误,客户端将删除本地缓存的令牌并重新请求令牌再次连接。
►核心层
包含连接模块、监控模块、API封装模块、日志模块、本地数据库模块。 连接模块负责Socket连接、保活、断线重连、断线、发送和接收消息等。连接模块每50秒发送一次ping来保活。 连接模块重连机制为2秒、4秒、8秒。 ,16秒等等im即时通讯客服软件,2的n次方逐渐增大。 当重试次数达到10次时,就会认为当前网络有问题,不再重试。 会等待监控网络变化或者前后切换再尝试; 监控模块收到会话变化、消息变化或连接状态变化后会再次尝试,将变化通知给所有注册的监听业务层,业务层根据收到的通知进行相应处理。
API封装模块向业务层提供SDK的一些基本功能的API,如发送消息、撤回消息、删除消息、获取未读会话、会话草稿等,业务层调用提供的API完成相关功能。 日志模块提供日志采集API,将每条日志按顺序记录到日志文件中。 日志模块还负责日志文件的创建、删除、保存和上传。 本地数据库模块负责数据库相关的工作。 当上层设置用户信息时,数据库模块会打开对应的数据库,如果不存在则新建一个。 数据库模块负责会话表和消息表的创建,以及会话和消息的增、删、改、查。 当数据库模块进行操作时,会添加一些必要的逻辑。 例如,插入消息时,需要更新会话的未读值和会话的最后一条消息; 当删除消息时,需要更新会话的未读值和会话的最后一条消息。 在消息读取功能中,需要修改会话的未读消息等。
►协议层
主要负责与服务器通信内容的编解码,包括消息编解码、会话编解码、命令编解码等。协议层会对收到的数据进行解析,区分收到的新消息和删除的消息。 、撤回消息、添加会话、删除会话、会话更新等行为,同时将接收到的数据解码成消息或会话模型传递给上层,通知业务层。
3.4
连接流程图
App需要与App Server交互,获取IM连接所需的Token数据,App Server负责维护业务数据,如用户数据、会话数据、好友关系等;
App通过Token数据连接IM Server,建立数据通道,实现消息的实时接收和推送功能;
IM Server维护App的连接状态,接收实时消息时判断用户是否在线,并将消息转发到目标设备或保存为离线消息;
►对话和气泡:
当特殊用户会话较多时,我们每次从服务中拉取会话信息时都会面临较大的压力。 我们使用本地和服务器解决方案的组合来实现这一目标。 我们在本地缓存会话,接收消息以叠加会话气泡,并设置本地会话。 请勿打扰、隐藏对话、已读消息等变化都会报告给服务器。 服务器在发生好友关系或者加入群组时生成会话,或者当会话信息发生变化时,也会同步本地。 服务器与本地Session之间的同步包括连接时同步、实时同步和主动同步。
连接时同步:每次用户连接时,会话唯一标识符都会传递到服务器。 服务器会根据用户传递的标识进行逻辑判断,向用户推送会话数据,包括所有会话和增量会话。 SDK收到Session数据后,会进行本地的添加、修改、删除操作,并通知UI层。 本地会话“身份”由服务器每次同步会话时提供。
实时同步:本地会话修改会通知服务器。 当服务器收到会话信息变化时,会立即通过IM推送到SDK,包括添加、修改、删除会话等操作。 SDK收到会话数据后会进行本地修改。 添加、修改、删除操作并通知UI层。
主动同步:当客户端收到不存在的会话消息时,会主动从服务器获取会话信息,保存在本地并通知UI层。
会话设计图:
会话部分字段:
单聊:两个用户之间的一对一聊天,聊天消息可以持久保存在本地供查看。
群组:两个或更多用户在同一会话中聊天。 发送的消息将被群组的所有成员接收,并且可以持久保存在本地以供查看。
公众账号:企业或官方通过系统账号向单个或多个账号推送消息,消息可持久保存在本地供查看。
聊天室:一个或多个用户在同一会话中聊天。 发送的消息会推送给当前聊天室的所有用户,用户接收端的消息不会保存在本地。
每个会话的同步时间都记录在本地。 连接时,服务器会比较本地上报的最后更新时间,增量同步变化的会话记录,以保证本地会话与服务器一致。 智家的对话列表反映在个人主页的留言部分,如下:
4、服务器优化
我们系统的设计要求是支持每台机器100万个连接,下行100万条消息QPS。 由于一个连接会占用一个文件描述符,所以首先要调整系统的文件描述符上限,使服务器能够支持百万级连接的建立;
# /etc/security/limits.conf
*软nporc 1500000
*硬nporc 1500000
*软nofile 1500000
*硬nofile 1500000
# /etc/sysctl.conf
fs.nr_open = 3000000
fs.文件最大值 = 3000000
我们使用Nginx作为七层负载,并启用TLS来保证数据传输安全。 Nginx层作为客户端与后端应用服务器建立连接时,会遇到本地端口瓶颈。 您可以根据TCP四倍规则增加后端应用服务器的监听端口,实现本地端口的复用。 突破本地港口资源限制;
在实际压测中,客户端接收消息的故障问题相当严重。 经过排查,发现nginx服务器出现丢包,CPU使用率很不均匀,尤其是软中断,只集中在少数CPU上。 在解决上述问题之前,首先了解一下网卡数据包收集流程以及一些相关概念。
网卡收到数据帧后,通过DMA将数据帧复制到内存中的Ring Buffer中。 该步骤不需要CPU的参与。 当复制完成后,网卡会触发网卡硬件中断。 CPU必须立即响应硬件中断。 CPU根据中断类型,在中断注册表中查找对应的中断处理程序,然后调用网卡(网卡驱动程序)注册的中断处理程序后,网卡驱动程序触发软中断,然后硬件中断返回。 硬中断不会做太多事情。 它只负责通知驱动程序数据已经到达。 具体操作由软中断进程处理。
DMA是Direct Memory Access,直接内存访问。 在DMA出现之前,CPU与外设之间的数据传输方式包括程序传输和中断传输。 CPU通过系统总线与其他部件连接并进行数据传输。 DMA是指允许外部设备直接与系统内存交换数据而无需经过CPU的接口技术。
环形缓冲区 网卡环形缓冲区。 如果缓冲区已满,新到达的数据包将被丢弃,导致丢包。 将缓冲区从原来的512调整为2048后,丢包问题得到解决。 方法如下:
查看当前缓冲区
ethtool -g em1
em1 的环参数:
预设最大值:
接收:4096
迷你接收器:0
接收巨型:0
发送号:4096
当前硬件设置:
接收:4096
迷你接收器:0
接收巨型:0
发送号:4096
ethtool -G em1 rx 2048
ethtool -G em1 tx 2048
修订
ethtool -G em1 rx 4096
ethtool -G em1 tx 4096
4.1
设置网卡队列
CPU软中断的不平衡与网卡的设置以及CPU的亲和力绑定有直接关系。 用一张网络图先了解一下RSS多队列网卡,如下:
网卡收到消息后,通过Hash包头中的SIP、SPort、DIP、DPort四元组信息将数据投递到对应的网卡队列中。 同时触发队列绑定中断,通知CPU进行进一步处理; 由此可以看出,CPU使用不平衡的原因有两个:队列收到的数据包不平衡或者CPU与网卡队列绑定不合理;
# 设置网卡队列
ethtool -L em1 组合 16
4.2
网卡中断号
中断请求,简称IRQ,中断是由硬件或软件发出的中断请求信号。 系统上的每个硬件设备都会分配一个 IRQ 号。 通过这个唯一的IRQ号,你可以区分它来自哪个硬件。 对于启用了多个队列的网卡,每个队列都会有一个唯一的中断号。
# 查看网卡队列中断号
cat /proc/interrupts |grep em1 |awk '{print $1 $NF}'
4.3
CPU亲和力绑定
将各个网卡队列与CPU一一绑定,平衡CPU使用率。
#CPU绑定
回显/proc/irq/107/smp_affinity_list 0
回显/proc/irq/108/smp_affinity_list 1
中断号为107的队列绑定到0号CPU,中断号为108的队列绑定到1号CPU,以此类推。 每个网卡队列需要绑定到不同的CPU核心。 这样我们就完成了网卡队列。 设置并绑定到CPU。
这里的另一个问题是CPU不仅处理网络数据,还处理Nginx应用程序。 这时就必须根据具体的压测情况来调整CPU资源的分配;
4.4
英特尔流程总监
上面提到了传递到网卡队列的数据包是均衡的。 怎样才能平衡呢? 一种方式是利用Intel以太网FD(Flow Director)技术定制数据包传送规则,应用于监控多个端口,根据既定的规则将不同目的端口的数据包传送到对应的网卡队列,从而实现数据包的均衡。 目的是平衡CPU使用率;
# 数据投递规则根据目的端口将数据投递到不同的队列
ethtool --功能 em1 ntuple 开启
ethtool --config-ntuple em1 流类型 tcp4 dst 端口 9500 操作 0 loc 1
ethtool --config-ntuple em1 流类型 tcp4 dst 端口 9501 操作 1 位置 2
至此,网卡丢包、CPU占用不均的问题已经解决,基本解决了客户端接收数据毛刺的问题;
4.5
其他优化
对于一些时效性和稳定性要求较高的用户,如客服、商户账户等,可以添加专用服务器。 专用服务器通常访问量较小,连接和消息推送会更稳定、更快。
添加备份域名(不同CDN)并在连接失败重试时使用im即时通讯客服软件,可以有效减少外部网络波动的影响,使系统更加稳定可靠。 我们之前也遇到过上网失败的情况,使用了第三方即时通讯服务。
当时,服务提供商的一个关键域名被错误屏蔽,导致整个即时通讯服务无法使用。 由于处理过程复杂,故障持续了半天才恢复。 基于此,我们将这部分设计成了我们的自研。
为了提高连接效率和服务器连接压力,SDK增加了token缓存机制。 IM连接成功后,token会缓存在本地。 当设备再次触发连接时,会首先检查本地是否存在token。 如果有,将立即使用缓存的数据进行连接。 ,如果不存在或过期,则会从服务器获取新的token数据进行连接。 连接成功后,新的token会缓存在本地。
SDK具有自动重连机制。 在整个应用程序中您只需要调用一次连接。 连接异常断开后,会启动重连机制,进行多次重连。 之后,如果仍然连接不成功,当设备网络状态发生变化时,再次重新连接时会检测到。
心跳保持活力。 为了保持客户端和服务器之间的实时双向通信,需要保证客户端和服务器之间的TCP通道保持连接。 IM 连接成功后,SDK 会每隔 50 秒向服务器发送 ping 包。 服务器收到ping包后立即响应pong包。 如果服务检查在120秒内没有收到ping数据包,它将立即断开连接并释放资源。 SDK检测到断线后会自动触发重连机制。
发送者->接收者: ping
接收者->发送者:pong
5. 总结
本文介绍了智家IM即时通讯平台的一些设计策略。 我们借此机会总结一下设计方案和技术实践,和大家一起学习和提高。 目前,该项目已在智嘉实施两年多了。 接入十多个业务线,包括单聊、群聊、聊天室、公众号、通用信令服务等场景。 三端用户日均服务量在千万级。 ,为智嘉三端产品提供全双工消息总线基础设施支持; 目前一些个性化的产品需求以及相关的操作平台还在完善中。 如果您对此感兴趣,欢迎加入我们。
关于作者
林道辉
■ C端及中端产研中心-看轩技术团队
■2012年加入汽车之家,目前主要负责参与热聊业务以及汽车之家IM通讯平台的架构和研发。