消息服务
    当说到消息服务的时候大家都会想到微信等常见的IM产品,所以直播间内的聊天消息经常类比于群聊,而1v1的消息类比于单聊。
    大部分人听到消息服务的时候都觉得消息服务很简单,不就是分为单聊和群聊两种。单聊不就是把消息转发给某个用户;群聊不就是把消息广播给聊天室内所有的用户。
    那消息服务面临的挑战有哪些呢,看下图:
    直播消息服务架构最佳实践分享 - 图1
    02
    业务场景分析
    针对上面的难点,我们来梳理一下如何构建直播的消息系统。
    面临的问题
    初期业务主要的场景是直播间的群聊消息以及一小部分的单聊消息。由于是教育场景,所以业务在划分聊天室的时候是以班级为单位进行划分的,假设每个聊天室为500人。
    问题一:用户的维护
    直播场景的群聊与微信等常见的群聊在用户维护上有很大区别。微信的群用户关系相对比较固定,用户进群退群是相对低频操作,用户集合相对固定。而直播间里的用户进出是非常频繁的,而且直播间是有时效性的。实际进出直播间峰值QPS不会超过1万,使用Redis可以解决聊天室用户列表存储及过期清理问题。
    问题二:消息转发
    当一个500人的聊天室所有用户同时发送消息时,消息的转发QPS为500500=2.5w。从直播用户端视角考虑:
    实时性:如果消息服务做消峰处理,峰值消息的堆积会造成消息延时增大,而有些信令消息具有时效性,太大延迟会影响用户的体验及互动实时性。
    用户体验:端展示各类用户聊天和信令消息一般一屏不会超过10-20条;如果每秒超过20条消息下发会出现持续刷屏的现象;大量的消息也会给端上带来持续的高负荷。
    因此我们为消息定义了不同的优先级。高优先级消息优先转发处理并且保证不丢弃;低优先级消息进行一定丢弃策略后再进行转发。
    问题三:历史消息
    业务上需要生成回放视频,需要获取历史信令、互动聊天等消息。要求能够快速写入历史消息以保证消息转发的时效性。
    消息的保存主要包含写扩散和读扩散两大类。我们采用读扩散的方式,读扩散可以减少存储空间,也可以减少消息保存的时间。考虑到回放的优先级不高,所以在存储组件的选择上我们选择了Pika。Pika是接口与Redis类似可以减少学习、开发成本。同时由于它是采用追加的方式,所以写性能可以与Redis媲美。
    问题四:消息顺序
    信令消息顺序的要求,需要保证同一个人发送消息的顺序,以及需要保证同一个聊天室内的用户收到消息顺序都是相同的。
    解决消息顺序可以使用Kafka之类的队列来保证,但是用Kafka有一定的延迟。为了降低延迟我们采用一致性哈希的策略来处理消息的转发,稍后会详细介绍。
    03
    设计目标
    *打造稳定、高效的消息通讯服务端。

    • 提供高可靠、高稳定、高性能的长连接服务;
    • 支撑百万长连接同时在线;
    • 支持多集群快速部署,扩容;

    04
    服务架构
    从早期快速实现业务到后期的业务量上涨服务端的架构经历了几个阶段。
    架构1.0
    直播消息服务架构最佳实践分享 - 图2
    服务介绍
    直播消息服务架构最佳实践分享 - 图3
    服务设计
    综合考虑服务分为两部分,接入服务和业务逻辑服务。其中最核心服务是AccessServer和MessageServer两个服务。这两个服务的交互流程大致如下:
    直播消息服务架构最佳实践分享 - 图4
    从上图可以看出一个聊天室的人不一定全部都在同一台接入服务上,所以当一个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策略将同一聊天室的消息转交到同一线程处理。我们来看下服务的线程模型:
    直播消息服务架构最佳实践分享 - 图5
    将网络数据处理和业务逻辑处理的线程隔离,避免业务逻辑处理阻塞网络线程造成TCP阻塞。网络线程采用Epoll的方式收发数据提高并发;业务线程专注业务逻辑处理,可以根据不同业务配置不同的线程池,比如上下线、进出聊天室、消息发送、不同优先级消息分配不同的线程组。

    • 缓存优化

    我们知道线程数越多性能不一定越好,因为线程上下文切换会带来很大一部分性能的开销。而且为了保证消息顺序性使用了线程池。如果全部依赖Redis会面临以下问题:

    1. 新用户进入聊天室需要从Redis获取用户列表,而且用户上下线也会更新Redis聊天室用户列表、路由等信息,聊天室用户越多对系统的压力越大;业务场景上老师会加入几百个聊天室,这种场景会导致老师开课延时增加。
    2. 每发送一条消息都需要从Redis查询聊天室的路由信息,假设网络IO+线程调度一次查询请求0.5ms,那么QPS也就2000,而类似涂鸦这类消息每秒15~20条消息,当负载持续一段时间后容易导致队列阻塞、任务超时等问题,容易引起雪崩。

    针对上面的问题,我们在AccessServer和MessageServer都做了二级缓存的策略,防止内存过载也在做了缓存淘汰相关策略:
    直播消息服务架构最佳实践分享 - 图6
    MessageServer会缓存聊天室用户列表、聊天室路由缓存,考虑缓存一致性会定时与Redis同步。用户进出聊天室的时候会将信息广播给对应的AccessServer,在AccessServer也会缓存聊天室用户列表,这样可以减少AccessServer和MessageServer之间的RPC压力。
    集群管理
    上一节介绍的服务组成一个集群,不同集群间目前不会相互通信。集群的管理是为了在业务上做隔离,因为不同的业务方在使用消息服务的时候要求性能不一样,最大程度的减少因为一个业务过载影响其它业务正常使用。同时可以根据不同业务方的业务量建设不同承载能力的集群,提高资源利用率。
    多集群的管理就需要DIspatchServer调度服务发挥作用了。客户端与服务端建立TCP长连接需要知道接入的IP和端口,客户端建立连接前会通过HTTP请求调度服务,调度服务会根据配置策略分配接入点的IP和端口给客户端。如下图:
    直播消息服务架构最佳实践分享 - 图7
    架构2.0
    当业务量增加以及业务场景多样化后,1.0架构的弊端也逐渐暴露出来:
    不同频率的消息会抢占资源,相互影响,比较涂鸦消息和信令消息;
    横向扩容MessageServer无法从根源解决不同消息相互影响问题,反而降低资源的利用率;
    服务拆分
    针对上面的问题将MessageServer进行拆分,分为三个服务:
    MessageServer:处理聊天室相关逻辑。包括进出聊天室、群聊消息、聊天室缓存管理、缓存同步广播。
    BinMsgServer:涂鸦消息处理逻辑。处理涂鸦消息逻辑。
    PeerMsgServer:处理单聊逻辑。包括用户上线、下线、单聊消息转发;
    服务拆分后服务间的状态同步、调用关系也发生了变化:
    直播消息服务架构最佳实践分享 - 图8
    拆分后就能根据业务对不同消息并发量不同扩容相应的服务,提高机器资源利用。AccessServer和HttpPushServer将不同的消息投递给不同服务处理。
    缓存升级
    架构升级后对缓存的策略也做了调整,MessageServer需要同步路由信息给BinMsgServer,同时BinMsgServer也需要做缓存一致性处理。新缓存策略:
    直播消息服务架构最佳实践分享 - 图9
    为减少非必要的RPC调用,在缓存同步的时候采取了一些规避策略:
    首先,MessageServer同步缓存给BinMsgServer的时候不是简单的在每次用户进出聊天室都同步给BinMsgServer,只是在聊天室路由状态发生变化时才会进行同步;
    其次,MessageServer不会将同一个聊天室路由信息同步给所有BinMsgServer,前面介绍了AccessServer会采用一致Hash策略将同一聊天室消息投递给同一个服务器处理,所以MessageServer也采用相同的一致性Hash策略将聊天室路由同步给对应的BinMsgServer;
    以上两步可以大量减少MessageServer和BinMsgServer之间的RPC调用,将资源充分用于消息转发处理上。
    架构3.0
    作为消息服务只局限于直播聊天的场景是不够,需要支撑更多的业务类型,比如IM、推送、透传等。如果在当前服务里去增加相应功能不仅对当前服务影响很大,也会加大后期维护成本。
    不同业务的要求都有些差别。直播聊天和IM虽然类似,但是对消息的要求不同。比如IM消息对消息的持久要求、一致性要求更高,而对消息延时要求会相对低一些。而推送场景和直播聊天建立连接的时机不同,直播聊天只需在用户进入直播室的时候建立连接,而推送则要求APP启动的时候就必须建立连接。
    从服务端的角度来看如果一种业务都搭建一套接入服务不仅在资源上浪费也会加大维护成本。
    从客户端的角度来看如果每种业务都建立一条TCP长连接会增加客户端性能的损耗,尤其是移动设备会增加耗电。
    为了应对业务的快速变更,3.0架构应运而生:
    直播消息服务架构最佳实践分享 - 图10
    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%左右。
    05
    未来规划
    连接迁移
    如果AccessServer过载过高或者异常重启后会导致用户重新登录、进聊天室,会出来大量进下线、进出聊天室消息的广播,对服务端和客户端都是不必要的性能消耗。目前我们正在做状态迁移的开发,AccessServer会将每个用户的状态保存起来,并且实时同步用户的状态用于恢复。当用户所在AccessServer-1因过载或重启后,对应的漂移到AccessServer-2的时候能够恢复最近在AccessServer-1的状态并且进行正常的消息转发处理,这种情况下对客户端是无感知的,也提升了用户的体验。
    QUIC
    目前,消息系统在稳定性和扩展性方面已经有了很好的表现,但是我们的学生用户遍布全国各地,用户的网络情况也千差万别,受限于TCP协议栈和操作系统,在弱网情况下我们很难在TCP协议的基础上进一步提高消息的实时性。
    由于TCP存在队头阻塞的问题,在弱网环境、丢包率较高的场景下消息延迟的问题就突现出来。对于QUIC而言,由于采用UDP可以避免上述的问题。