前言
在互联网时代,消息服务已成为必备的产品。比如微信、钉钉和QQ
等以IM
为核心功能的产品。还有一部分是直播间内用户聊天互动,形式上也是常见的IM
消息流;直播间内常见的消息流包括直播弹幕、互动聊天、信令、涂鸦、消息推送等。在线教育行业内消息服务尤其重要,直播授课过程中的互动答题、涂鸦、表演、点赞等,不过对消息可靠性有非常高的要求,而且对消息的即时性也非常敏感。
消息服务
当说到消息服务的时候大家都会想到微信等常见的IM
产品,所以直播间内的聊天消息经常类比于群聊,而1v1
的消息类比于单聊。
大部分人听到消息服务的时候都觉得消息服务很简单,不就是分为单聊和群聊两种。单聊不就是把消息转发给某个用户;群聊不就是把消息广播给聊天室内所有的用户。
那消息服务面临的挑战有哪些呢,看下图:
业务场景分析
针对上面的难点,我们来梳理一下如何构建直播的消息系统。
面临的问题
初期业务主要的场景是直播间的群聊消息以及一小部分的单聊消息。由于是教育场景,所以业务在划分聊天室的时候是以班级为单位进行划分的,假设每个聊天室的人数为500人。
问题一:用户的维护
直播场景的群聊与微信等常见的群聊在用户维护上有很大区别。微信的群用户关系相对比较固定,用户进群退群是相对低频操作,用户集合相对固定。而直播间里的用户进出是非常频繁的,而且直播间是有时效性的。实际进出直播间峰值QPS
不会超过1万,使用Redis
可以解决聊天室用户列表存储及过期清理问题。
问题二:消息转发
当一个500人的聊天室所有用户同时发送消息时,消息的转发QPS
为500*500=2.5w。从直播用户端视角考虑:
实时性
:如果消息服务做消峰处理,峰值消息的堆积会造成消息延时增大,而有些信令消息具有时效性,太大延迟会影响用户的体验及互动实时性。
用户体验
:端展示各类用户聊天和信令消息一般一屏不会超过10-20条; 如果每秒超过20条消息下发会出现持续刷屏的现象; 大量的消息也会给端上带来持续的高负荷。
因此我们为消息定义了不同的优先级。高优先级消息优先转发处理并且保证不丢弃; 低优先级消息进行一定丢弃策略后再进行转发。
问题三:历史消息
业务上需要生成回放视频,需要获取历史信令、互动聊天等消息。要求能够快速写入历史消息以保证消息转发的时效性。
消息的保存主要包含写扩散和读扩散两大类。我们采用读扩散的方式,读扩散可以减少存储空间,也可以减少消息保存的时间。考虑到回放的优先级不高,所以在存储组件的选择上我们选择了Pika
。Pika
是接口与Redis
类似可以减少学习、开发成本。同时由于它是采用追加的方式,所以写性能可以与Redis
媲美。
问题四:消息顺序
信令消息顺序的要求,需要保证同一个人发送消息的顺序,以及需要保证同一个聊天室内的用户收到消息顺序都是相同的。
解决消息顺序可以使用Kafka
之类的队列来保证,但是用Kafka
有一定的延迟。为了降低延迟我们采用一致性哈希的策略来处理消息的转发,稍后会详细介绍。
设计目标
打造稳定、高效的消息通讯服务端。
- 提供高可靠、高稳定、高性能的长连接服务;
- 支撑百万长连接同时在线;
- 支持多集群快速部暑,扩容;
服务架构
从早期快速实现业务到后期的业务量上涨服务端的架构经历了几个阶段。
架构1.0
服务介绍
服务
说明
DispatchServer
调度服务。提供HTTP
/HTTPS
接口,客户端调用请求获取TCP
长连接接入地址
AccessServer
接入服务。采用Linux
Epoll
技术,实现完全异步非阻塞方式,高性能的处理客户端连接和各类消息转发等
AuthServer
认证服务。验证用户名、密码
MessageServer
消息服务。消息转发、路由信息维护、聊天室信息维护、消息持久化等
MsgFilterServer
过滤服务。敏感词过滤、低优先级消息过滤、黑名单用户过滤等
HttpPushServer
推送服务。提供API
接口供业务方使用,调用该服务接口可以推送消息,包括单聊、群聊及其它配置等
StorageServer
存储服务。存储用户事件,将用户上、下线、进出聊天室等事件写入Kafka
StatServer
统计服务。统计各接入服务的负载信息,包括用户数量、机器负载等
服务设计
综合考虑服务分为两部分,接入服务和业务逻辑服务。其中最核心服务是AccessServer
和MessageServer
两个服务。这两个服务的交互流程大致如下:
从上图可以看出一个聊天室的人不一定全部都在同一台接入服务上,所以当一个500人的聊天室消息转发的时候服务端的QPS
为500*500=2.5w的基础上再乘接入服务的数量。
AccessServer
接入服务维护与客户端TCP
建立长连接。AccessServer
主要的目标是处理网络IO
数据包,采用异步非阻塞的方式提高并发性能。同时在内存中维护聊天室与用户的对应关系,保存聊天室相关缓存信息。解析与客户端的协议包。AccessServer
主要处理的消息有两大类:
1、客户端发送上行消息时,解析相关请求参数后将消息投递给MessageServer
,由MessageServer
获取路由信息并将消息投递给相应的AccessServer
处理。
2、MessageServer
广播下行消息时,如果是单聊直接查询对应用户的TCP
连接信息,封包、发送;如果是群聊消息时,遍历聊天室用户列表获取对应的TCP
连接信息,封包、发送;
在AccessSever
维护聊天室与用户的映射关系可以减轻MessageServer
与AccessServer
交互的压力。
MessageServer
消息服务负责与Redis
、Pika
交互。将消息持久化到Pika
。将聊天室用户列表、聊天室路由(聊天室的人分布在哪些AccessServer
)信息、用户路由(用户在哪一台AccessServer
)信息等信息更新到Redis
,需要的时候从Redis
查询相关信息。处理用户登录、退出、进出聊天室、单聊消息、群聊消息、涂鸦消息的转发逻辑。
MessageServer
如何来保证消息的顺序呢?
首先AccessServer
会根据按一致性Hash
策略将同一聊天室的消息投递到同一台MessageServer
来处理;
接着MessageServer
采用Hash
策略将同一聊天室的消息转交到同一线程处理。我们来看下服务的线程模型:
将网络数据处理和业务逻辑处理的线程隔离,避免业务逻辑处理阻塞网络线程造成TCP
阻塞。网络线程采用Epoll
的方式收发数据提高并发;业务线程专注业务逻辑处理,可以根据不同业务配置不同的线程池,比如上下线、进出聊天室、消息发送、不同优先级消息分配不同的线程组。
缓存优化
我们知道线程数越多性能不一定越好,因为线程上下文切换会带来很大一部分性能的开销。而且为了保证消息顺序性使用了线程池。如果全部依赖Redis
会面临以下问题:
- 新用户进入聊天室需要从
Redis
获取用户列表,而且用户上下线也会更新Redis
聊天室用户列表、路由等信息,聊天室用户越多对系统的压力越大;业务场景上老师会加入几百个聊天室,这种场景会导致老师开课延时增加。 - 每发送一条消息都需要从
Redis
查询聊天室的路由信息,假设网络IO
+线程调度一次查询请求0.5ms,那么QPS
也就2000,而类似涂鸦这类消息每秒15~20条消息,当负载持续一段时间后容易导致队列阻塞、任务超时等问题,容易引起雪崩。
针对上面的问题,我们在AccessServer
和MessageServer
都做了二级缓存的策略,防止内存过载也在做了缓存淘汰相关策略:
MessageServer
会缓存聊天室用户列表、聊天室路由缓存,考虑缓存一致性会定时与Redis
同步。用户进出聊天室的时候会将信息广播给对应的AccessServer
,在AccessServer
也会缓存聊天室用户列表,这样可以减少AccessServer
和MessageServer
之间的RPC压力。
集群管理
上一节介绍的服务组成一个集群,不同集群间目前不会相互通信。集群的管理是为了在业务上做隔离,因为不同的业务方在使用消息服务的时候要求性能不一样,最大程度的减少因为一个业务过载影响其它业务正常使用。同时可以根据不同业务方的业务量建设不同承载能力的集群,提高资源利用率。
多集群的管理就需要DIspatchServer
调度服务发挥作用了。客户端与服务端建立TCP
长连接需要知道接入的IP
和端口,客户端建立连接前会通过HTTP
请求调度服务,调度服务会根据配置策略分配接入点的IP
和端口给客户端。如下图:
架构2.0
当业务量增加以及业务场景多样化后,1.0架构的弊端也逐渐暴露出来:
- 不同频率的消息会抢占资源,相互影响,比较涂鸦消息和信令消息;
- 横向扩容
MessageServer
无法从根源解决不同消息相互影响问题,反而降低资源的利用率;
服务拆分
针对上面的问题将MessageServer
进行拆分,分为三个服务:
MessageServer
:处理聊天室相关逻辑。包括进出聊天室、群聊消息、聊天室缓存管理、缓存同步广播。
BinMsgServer
:涂鸦消息处理逻辑。处理涂鸦消息逻辑。
PeerMsgServer
:处理单聊逻辑。包括用户上线、下线、单聊消息转发;
服务拆分后服务间的状态同步、调用关系也发生了变化:
拆分后就能根据业务对不同消息并发量不同扩容相应的服务,提高机器资源利用。AccessServer
和HttpPushServer
将不同的消息投递给不同服务处理。
缓存升级
架构升级后对缓存的策略也做了调整,MessageServer
需要同步路由信息给BinMsgServer
,同时BinMsgServer
也需要做缓存一致性处理。新缓存策略:
为减少非必要的RPC
调用,在缓存同步的时候采取了一些规避策略:
首先,MessageServer
同步缓存给BinMsgServer
的时候不是简单的在每次用户进出聊天室都同步给BinMsgServer
,只是在聊天室路由状态发生变化时才会进行同步;
其次,MessageServer
不会将同一个聊天室路由信息同步给所有BinMsgServer
,前面介绍了AccessServer
会采用一致Hash
策略将同一聊天室消息投递给同一个服务器处理,所以MessageServer
也采用相同的一致性Hash
策略将聊天室路由同步给对应的BinMsgServer
;
以上两步可以大量减少MessageServer
和BinMsgServer
之间的RPC
调用,将资源充分用于消息转发处理上。
架构3.0
作为消息服务只局限于直播聊天的场景是不够,需要支撑更多的业务类型,比如IM
、推送、透传等。如果在当前服务里去增加相应功能不仅对当前服务影响很大,也会加大后期维护成本。
不同业务的要求都有些差别。直播聊天和IM
虽然类似,但是对消息的要求不同。比如IM
消息对消息的持久要求、一致性要求更高,而对消息延时要求会相对低一些。而推送场景和直播聊天建立连接的时机不同,直播聊天只需在用户进入直播室的时候建立连接,而推送则要求APP
启动的时候就必须建立连接。
从服务端的角度来看如果一种业务都搭建一套接入服务不仅在资源上浪费也会加大维护成本。
从客户端的角度来看如果每种业务都建立一条TCP
长连接会增加客户端性能的损耗,尤其是移动设备会增加耗电。
为了应对业务的快速变更,3.0架构应运而生:
3.0的架构是需要SDK
配合升级。
TcpProxyServer
3.0新增了TcpProxyServer
服务。该服务可以理解为是一个7层代理,不过协议不是HTTP
之类的协议,而是自定义的协议。
为了实现在一条TCP
连接上承载多业务,我们抽象出了Session
的概念,目前支持的Session
包括Chat
(直播聊天)、IM
、学研Push
、推送和透传。
考虑后续快速支撑新业务,TcpProxyServer
在设计成可动态配置业务转发路由,无需开发只需要修改配置文件就能完成业务的转发。
2.0的客户端是直接与AccessServer
建立TCP
连接的,而3.0是与TcpProxyServer
建立连接,TcpProxyServer
将请求通过RPC
转发给AccessServer
。
TcpProxyServer
可以通过动态配置控制请求投递给后端服务的策略,包括轮训、Hash
、一致性Hash
等策略。
随着客户端迭代升级,目前V3版的SDK
用户已经占了70%左右。
未来规划
连接迁移
如果AccessServer
过载过高或者异常重启后会导致用户重新登录、进聊天室,会出来大量进下线、进出聊天室消息的广播,对服务端和客户端都是不必要的性能消耗。目前我们正在做状态迁移的开发,AccessServer
会将每个用户的状态保存起来,并且实时同步用户的状态用于恢复。当用户所在AccessServer-1
因过载或重启后,对应的漂移到AccessServer-2
的时候能够恢复最近在AccessServer-1
的状态并且进行正常的消息转发处理,这种情况下对客户端是无感知的,也提升了用户的体验。
QUIC
目前,消息系统在稳定性和扩展性方面已经有了很好的表现,但是我们的学生用户遍布全国各地,用户的网络情况也千差万别,受限于TCP
协议栈和操作系统,在弱网情况下我们很难在TCP
协议的基础上进一步提高消息的实时性。
由于TCP
存在队头阻塞的问题,在弱网环境、丢包率较高的场景下消息延迟的问题就突现出来。对于QUIC
而言,由于采用UDP
可以避免上述的问题。