基于Tars高并发IM系统的设计与实现-基础篇2
三大指标
高可用
分为服务高可用与存储高可用。
服务高可用
服务高可用要做到高可用必须具备两个特点:
- 负载均衡
- 可横行扩展
当服务的请求量比较高的时候,一台服务不能满足需求,这时候需要多台机器提供同样的服务,将所有请求分发到不同机器上。
高可用架构中应该具有丰富的负载均衡策略和易调节负载的方式。
甚至可以自动化智能调节,例如由于机器性能的原因,响应时间可能不一样,这时候可以向性能差的机器少一点分发量,保证各个机器响应时间的均衡。
负载均衡;
-
外部负载均衡
- 由nginx负责,nginx本是一个反向代理服务器,但由于丰富的负载均衡策略,常常被用于客户端可真实的服务器之间,作为负载均衡的实现。
- 用nginx做实现服务的高可用,nginx本身可能成为单点,遇见的两种解决方案,一种是公司搭建自己的DNS,将请求解析到不同的NGINX,另一种是配合keepalive实现服务的存活检测。
-
内部负载均衡
IM子服务之间RPC调用的负载均衡,由Tars框架的注册发现服务来负责,根据设定策略进行服务节点维护和负载均衡操作;
横向扩展
当用户量越来越多,已有服务不能承载更多的用户的时候,便需要对服务进行扩展,扩展的方式最好是不触动原有服务,对于服务的调用者是透明的。
要达到这个目标,需要做到每个服务可以动态扩容,根据业务流量的需要,随时都能过增加/减少部署相应服务节点,要做到这点,整个系统的服务要无状态。
无状态很重要!无状态很重要!无状态很重要!重要的事情说三遍。
长链接如何做到无状态
有朋友就问了,对于长连接如何做到无状态,服务做到无状态,连接状态信息和数据可以存储到redis集群中就可以了;有服务用此信息直接从redis集群中获取即可。
存储高可用
- 存储高可用比较简单,redis作为IM系统的核心存储部件,可以采用集群或者主从模式,用户量较小时可以采用主从模式,较大时建议采用集群模式;
- mysql数据存储作为冷存储用主从模式,读写分离进行操作即可解决问题;
- IM服务要做到即使redis,mysql挂了,在线发送消息也要正常,这个就需要消息流转系统的设计和建设不能对redis,mysql进行强依赖。
高并发
高并发(High Concurrency)是互联网分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计保证系统能够同时并行处理很多请求。
提高系统并发能力的方式主要有3种:
- 纵向扩展
- 横向扩展
- 提高服务处理能力
纵向扩展
纵向扩展可以通过提升单机硬件性能,或者提升单机架构性能,来提高并发性,但单机性能总是有极限的,互联网分布式架构设计高并发终极解决方案还是后者:横向扩展和提高服务请求处理速度。
横向扩展前文已经描述,此处不再赘述。
如何提高服务请求处理速度?
影响服务器处理速度一般有如下原因
- 大批量数据处理,比如群聊消息分发
- 频繁访问I/O;
- RPC同步请求等等
- 服务异常导致雪崩
针对以上原因有逐个击破进行解决:
-
大批量数据处理
大批量数据处理耗时较长可以进行异步线程处理;比如500人的群,每发一条消息都需要分发给500人,逐个操作可以放到一个线程或者线程池进行处理。
对于一些高频调用接口,可以考虑对请求数据合法性验证后,将数据先缓冲起了,后续异步进行处理,提高对外接口的吞吐能力。 -
频繁访问I/O
减少IO的访问,能不用直接访问IO尽量不直接访问IO,优先访问内存和缓存进行处理;比如保存数据到mysql中,可以先将数据保存到redis中,由一个独立的进程进行redis到mysql同步或者异步调用该进程进行写入mysql服务中
读取mysql数据,如果该调用量比较大,需要根据情况将数据放到redis或者内存中比较合适。 -
RPC同步请求
在一个请求中不可避免要通过RPC调用别的服务接口,如果非必要尽量采用异步调用来解决问题,这样服务进程不会等待占用资源,增加服务吞吐量; -
服务异常导致雪崩
每个服务都有处理能力,服务要有限流和熔断机制,避免由于局部异常或者流量突然暴增导致整个系统崩溃。
低延时
低延迟是指计算机系统或通信网络中的较短时间延迟;低延时并不是无延时,只要延时在可接受的时间范围内就ok。
要做到低延时,主要有如下几个考虑要点:
- 每个子服务尽量简单
- 操作尽量在内存中进行,减少IO的访问
- 尽量异步处理
- 耗时操作尽量放在后台线程或者进程进行处理
- 网络传输数据尽量少
基于以上几点进行服务设计和开发基本能做到低延迟,当然低延迟是相对概念,跟系统的负载能力和用户量有很大关系,做到低延时需要在很多细节上进行检测调整,建议开发系统时做好相应的数据收集,以便后续出现高耗时操作时方便优化。
四大模块
四大模块包含
- 连接管理
- 用户及好友管理
- 消息管理
- 离线推送
连接管理
连接管理主要包含消息处理,连接处理器逻辑,用户登录记录,消息流转
消息处理流程
当客户端A需要将消息发送给客户端B时需要经历如下步骤:
- 客户端A,客户端B分别建立一个TCP连接到接入服务S,S会有多台,不同用户可能会连接到不同的S
- 服务端S检查客户端A是否合法,如果合法,将其记录到连接服务中的长连接管理器中,如果失败直接返回前端相应错误信息,并关闭连接;
- 客户端A 发消息到接入服务S,S根据业务类型RPC调用相应的消息处理服务S1,S1对消息进行处理并存储,同时发送ACK给客户端A表示该消息S1已经收到;
- S1根据A消息中目标用户B,在长连接管理器中寻找到B的长连接,将A发的消息推送给B;
如图:
- 连接管理功能特点
上文中提到的TCP长连接管理器就是负责管理维护用户长连接;
连接管理器有以下功能:- 记录每个在线用户连接及相关信息:
- 收发消息需要的相关信息都需要保存到管理器中,比如用户id,连接id,设备id,设备类型,登录时间,连接所在服务id(可以根据该id投送消息到某个服务器);
- 根据用户id获取该连接信息;
- 消息投送时,根据目标用户id能够快速找到连接信息,并将消息数据发送出去;
- 根据服务id获取当前服务上上所有连接信息;
- 当服务升级重启时,能够清理连接信息,防止错误的在线数据导致其他逻辑问题。
- 离线用户删除相关长连接记录;
- 用户离线后,将不能再接收任何数据,准确的在线连接数据是IM系统的基石,如果在线用户连接数据不准确,会导致整个IM系统低效运行。
- 连接保活(心跳处理);
- 确保用户长连接能够最大限度维持,需要按照客户端按照一定时间频率发送心跳报文到连接管理器来说明客户端还在继续工作,还活着;
- 超时连接清理:如果超过2个心跳周期还未收到某个客户端的心跳或者正常请求数据,说明该客户端已掉线或者非正常结束,服务端会认为该客户端已经死掉,将主动关闭连接并从连接管理器中移除相应连接;
- 根据连接id找到连接信息,
连接器处理逻辑
连接管理器使用频率很高,所以在性能上需要很高要求,查询速度要快,跨机器查询访问;支持高并发;为了达到这个目标,采用内存双hashmap+redis的数据存储设计;
内存双hashmap:一个是userId-连接信息,另一个是连接id-连接信息;
redis使用到两个数据结构:
hash保存每个连接的信息,key是“connect:+userId”,value为:连接相关信息;
Set保存某个服务器上的登录用户信息:key是“connectUsers:+服务id,value为当前服务上所有在线用户userId;
用户登录记录
消息流转
用户及好友相关模块
该模块主要有用户认证,用户资料记录维护,用户好友关系维护
该部分相对来说逻辑比较简单,主要进行数据的存取,本系统对与数据的存取采用两级存储模式:redis+mysql;
主要存储一下数据:
- 用户资料
- 用户id,名称,头像,出生年月,地区,性别等等
- 好友关系
消息处理管理
消息处理管理主要负责消息顺序时序处理,消息存储,消息转发,历史消息获取,消息撤回,未读数计算等;
- 消息处理流程
主要流程如图:
消息时序处理
消息顺序处理在消息收到服务端之后立马进行,有序列号seqId和时间戳字段;
时间戳并且获取当前时间戳(毫秒级)即可;
序列号从序列号服务中获取一个唯一递增的序列号,序列号如何生成,有专门文章有介绍;
有同学问了,序列号直接用时间戳不行?当然不行,分布式系统中时间可能不能保证完全同步,可能会造成消息顺序不准确;消息顺序在消息逻辑中很重要,要确保准确;
消息存储
包含缓存和冷存储,缓存直接调用redis接口存储即可;redis与mysql如何进行结合,此处不再赘述,做专项介绍。
消息转发
- 单聊/服务通知消息转发步骤:
- 在redis中查找目标用户是否在线;
- 如果在线,将消息直接投送到用户在线的连接服务器上,
- 连接服务器推送到用户客户端;
- 群聊消息转发步骤:
- 获取到群的群成员;
- 在redis中查找群每个成员是否在线;
- 如果成员在线,将消息直接投送到群成员在线的连接服务器上,
- 连接服务器推送到群成员客户端;
历史消息获取
当客户端需要查看某个会话中的聊天记录时,客户端的聊天信息有可能并不完整,需要从服务段根据情况实时获取;由于整个消息系统支持多端,每个客户端本地存储的消息数量不一定相同,所以需要设计一套机制来保障客户端获取消息重复率低而且不能漏掉消息;
MQTT协议本身不具备消息历史记录功能,用户登录时将离线时的消息全部推送到客户端,这种方法针对轻量级的控制类系统使用尚且可以,针对IM重聊天数据的系统显然不合适;我们早期使用MQTT协议时碰到过一个真实案例,一个6000人的群,产生20万条消息,当客户端登录时采用全部更新,app启动后无法使用;所以要解决离线消息历史记录问题,此方案断然不能采用。
从根本上讲,历史消息的获取就是广义上的数据同步方法,提到数据同步方法,无非就两种:全量同步和部分同步;
-
全量同步:
由客户端发起请求,从服务端获取该会话的所有聊天记录;当然客户端获取时需要明确告知服务端是全量数据同步;
此方法实现看起来起来简单,在IM的实际场景中会有很多局限,单聊相对来说数据比较重要,数据量不是很大倒也无妨,群聊的数据量比较大,如果一个300人的群,这个群又比较活跃,一两天不上线会有很多消息,如果全量同步的话将耗费大量时间和客户端资源,未等到数据全部到位,用户已经没有耐心直接关闭app了; -
部分同步:
从用户使用app的习惯角度来看,用户每次最多看几十条消息,着实没有必要一次性把所有消息都拉下来,把所有消息都全部同步下来也是一种浪费;
客户端根据需要从服务端获取历史消息比较合理;当用户需要查看更多时调用接口获取一定数量的历史消息;
客户端可以根据业务场景对本地数据进行灵活处理;
消息撤回
在IM系统中,消息撤回是一个很重要的功能,一个消息发出后,发现有错误,在一定时间内可以撤回;
这种撤回操作作为目前刚性需求几乎是每个IM系统必备的功能;
完成撤回消息功能,本质就是撤回消息方发出一条指令给目标用户,告诉前面发的某条消息需要撤回;
执行撤回指令需要考虑几种情况进行处理:
- 对方未收到消息;针对这种情况,服务端收到撤回指令后,对原消息进行修改即可。
- 对方已收到消息,且目前在线;针对这种情况,除了完成A的操作,还需要将撤回指令推送给对方,对方客户端也需要修改已收到的消息和展示方式。
- 对方已收到消息,切目前已离线;针对这种情况完成A后,要确保对方客户端上线后第一时间能收到撤回指令,并修改已收到的消息和展示方式。
- 至于如何确保上线后第一时间能收到撤回指令,这就牵扯到高优先级消息,后续专门介绍相关设计逻辑。
未读数计算
未读数的显示对于IM系统来说也是一个基本功能,首先对未读数进行一个定义:
- 用户未阅读的消息数量
- 每个会话有个独立数量
- 只要用户在某一个设备上阅读某个会话的消息,都算已读
要实现以上未读数功能,有两种方式:
- 客户端计数
客户端计数实现针对多个设备上的数据同步比较复杂,需要 客户端-》服务端->客户端;不能保证数据最大的准确性;
服务端计数只需要维护服务端的数据正确性,每个客户端根据情况进行数据同步就行,少一步 客户端向服务器的同步操作,别小看这一步,针对一个多端多活的异步系统,能减少很多数据同步和完整性的维护操作,也减少很多逻辑。 - 服务端计数
服务端未读数存储采用redis 中zset结构,key每个用户id+会话id,数据为未读消息id,seqid
增加未读数:有新消息时向该key数据结构增加新消息的packId,seqid
计算未读数:获取该key中的数量;
减少未读数:通过已读消息的seqid,删除<=seqid所有数据
假设用户id为22,会话id为s1,未读数变化如图:
离线消息推送
离线消息推送主要处理客户端不在线的情况,客户端不在线,可以根据用户最近一次登录的设备类型,利用厂商通道进行消息推送,不同厂商提供不通的API和SDK;
- 苹果设备通过APNS;
- Android有FCM,华为,小米,vivo,oppo等渠道,根据业务需要进行逐个对接即可;
当然市场上也有一些第三方厂商比如umeng,极光等可以接入。