直播弹幕系统开发总结

Posted on By wentao

前言

直播弹幕是直播系统的核心功能之一,它有高并发,高可用,低时延的基本要求,而在真实场景中,开发一个弹幕系统往往会面临着许多挑战,如何开发出一个具有良好扩展性的弹幕系统?这篇文章将结合真实场景还原出一个弹幕系统从无到有的过程。

背景

直播弹幕系统要求能支撑百万级用户同时在线的直播间,即时性要求高,允许少量消息丢失,支持多种消息类型以及多种缓存过期策略。

系统设计

考虑到系统的快速上线以及东南亚的网络基础设施不太稳定,我们没有采用推模式来推送弹幕消息,而是改用轮询拉模式,客户端每隔数秒往服务器发送拉取房间消息的请求,服务端返回这段时间段内最新的消息列表,针对网络造成的消息延迟问题,我们采用的策略是保证弹幕的即时性,优先保证较新消息到达,放弃较旧消息。除了传统弹幕消息之外,房间内还有其他消息类型,例如系统消息和用户端消息,我们统一当作基本消息来看待。

系统架构

我们在设计时把弹幕消息系统分为了四个服务:

  • API服务:处理用户创建/加入/退出聊天室以及处理用户发送消息的请求,做参数校验/用户认证处理,将对应请求转发到对应的业务层。
  • Fetch服务:定期从Message服务拉取消息,缓存在本地;接收用户轮询拉取消息的请求,返回序列化的消息结构体。
  • Room服务:处理房间相关的业务请求,还有读取和写入用户信息。
  • Message服务:处理发送和接收消息的请求,处理消息的限流,敏感词过滤和分片写入消息的过程。

把服务进行拆分不仅是为了划清服务边界,简化开发和部署,还有一个考虑因素是为了不让服务间相互影响,对于这种系统服务,不同服务的qos往往是不对等的,例如像拉取消息的服务的请求频率和负载通常会比发送消息服务或者是加入房间服务高1到2个数量级,在这种情况下不能让拉消息服务把发消息服务搞垮,反之亦然,最大限度地保证系统的可用性,同时也更加方便对各个服务做Scale-Up和Scale-Out。

在拉取消息服务端,引入了本地缓存的概念,本地缓存是为了防止大直播间在高并发请求下冲击到Redis集群,导致Redis服务出现性能瓶颈,同时也为了缩短调用链路,把数据放到离用户最近的地方,所以我们的策略是拉取服务会定期发起RPC调用从消息服务拉取数据,拉取到的房间消息缓存到内存中,这样后续的请求过来时便能直接走本地内存的读取,大幅降低了调用时延。

实现方案

消息存储

消息使用Redis集群来存储,根据消息时间戳分片,其中每个分片是一个以时间戳排序的SortedSet。Message服务处理API传过来的发送消息请求,将消息序列化写入到Redis中。同时Fetch服务后台任务定期发起RPC调用到Message服务拉取消息。

消息存储

但如上图所示,这里可能包含了一个隐含的问题,由于上游拉取服务的时间戳是由下游指示的,当上游的轮询任务来拉取1~3秒的数据时,这时候就可能出现第3秒的数据还没写入完整,而返回了不完整的数据,这样下次从第4秒开始轮询时就会丢失了第3秒的部分消息,因此我们在实现时会去判断最大时间戳对应的消息是否完整的,如果不完整则只返回前面的消息。

本地缓存

本地缓存是拉取消息服务中不可或缺的一部分,在高并发的场景下本地缓存能有效地减轻下游的压力。而对于不同类型的消息,合适的本地缓存实现也不一样,例如有的消息类型对时效性要求更高,有的消息则重要性比较高因此要求消息不能丢,在设计本地缓存的时候我们针对不同的消息渠道实现不同的消息存储组件,上层组件只需要透明地调用即可,消息在读写过程会根据类型路由到对应组件。

在直播弹幕这个场景中,弹幕这类消息有几个特点:时效性高,过旧的消息对用户来说相对不重要;优先级较低,允许少量消息丢失;读写比例达到10000:1,传输量大,并发读取请求数量多。受到Ring Buffer的启发,我们设计了一个高速环形缓冲的数据结构,以时间维度来组织存储在内存的结构化数据。

Ring Buffer

如图所示,与传统的Ring Buffer不一样的是,我们只保留了尾指针,它随着时间向前移动,每一秒向前移动一格,把时间戳和对应消息列表并写到这一个区块当中,因此在这里最多保留60秒的数据。同时,如果这时候来了一个读请求,那么缓冲环会根据客户端传入的时间戳计算出指针的索引位置,并从尾指针的副本区域往回遍历直至跟索引重叠,收集到一定数量的消息列表返回,这种机制保证了缓冲区的区块是整体有序的,因此在读取的时候只需要简单地遍历一遍即可,加上使用的是数组作为存储结构,带来的读效率是相当高的。

下面再来考虑可能出现数据竞争的情况。先来说写操作,由于在这个场景下,写操作是单线程的,因此大可不必关心并发写带来的数据一致性问题。再来说读操作,由图可知写的方向是从尾指针以顺时针方向移动,而读方向是从尾指针以逆时针方向移动,而决定读和写的位置是否出现重叠取决于index的位置,由于我们保证了读操作最多只能读到30秒内的数据,因此缓冲环完全可以做到无锁实现,为了避免Race Condition的出现,在这里使用了自旋锁来读取数据,一旦出现冲突立即结束返回,另外在锁的实现上做到了写优先,避免了由于读写比例过大导致的写饥饿问题。

上面这种存储结构对于弹幕消息是十分奏效的,但对于用户端的消息,那么这种结构就不是那么合适了,这种消息往往优先级较高并且要求不能丢,这时候就要引入状态化存储。

状态化存储

如图所示,当一个用户读取端到端消息时,会通过hash路由到分区表中,每个分区表存储了一部分用户的消息队列,当用户ID为2,时间偏移量为3的请求到来时,路由到该消息队列时,会从队头开始遍历,把之前已读消息进行删除,获取时间大于3的消息,这部分读取的消息在下次请求到来前都是已读状态,防止了因为网络原因导致消息没有到达的情况,当下次请求过来时,根据传入的时间戳判定边界,边界之前的消息确保已经被消费过了,因此可以把他们删除掉,考虑到这类消息不会很多,因此每个队列维护的最大长度有限,占用内存可控。

高可用保障

弹幕服务在上线第一版前遇到了不少可用性问题,我们用了各种方案进行了解决。

发送频率

在一个热门的直播间里,势必会有海量的用户同时发言,这样带来了一个问题,就是在消息数量过多的时候,那么在一个时间段的消息到了用户的客户端上看就可能就会出现展示得过于密集甚至是展示不完的状况,这无疑是一个对用户非常不友好的体验。因此我们的做法是对用户发送消息进行限制,经考虑后我们采用了每秒最多存储40条消息,单用户每秒最多发送1条消息的策略,这样一来,除了这部分消息外,其他被限流的消息都不会展示在其他用户的客户端上。

弱网络

对于直播间内的用户,通常会有部分人处于弱网络的环境下,这时候他们在与弹幕服务进行交互时便极有可能出现延迟或者超时的情况,在这种情况下,弹幕系统会优先保证消息的即时性,对客户端传入的时间戳(消息偏移量)进行检测,如果认定时间过旧,则会将偏移量拉近到最近的一段时间,拉取一定量的最新消息,同时服务端也可以动态更改客户端的轮询间隔,以告诉客户端下一次的轮询在何时发起,优化网络效率。

性能排查

调优应用程序的性能是保障高可用的重要一环,我们在测试环境会对应用开启pprof探针采集应用的运行信息,接着对服务进行压测,根据分析器文件生成火焰图,寻找耗时瓶颈。有一次我们在压测时发现QPS上不去,后来根据火焰图发现部分服务相当大的一部分的时间占用出现在了日志库的打印上,后面发现是日志库在打印日志时使用了反射来获取线程ID、文件名和行号,导致耗时变高,我们的做法是调整日志级别,禁用Info日志的反射功能,减少不必要日志的打印,这个举措使服务获得了30%以上的性能提升。

流量控制

对于弹幕来说,当用户量到达一定规模时,流量带宽便有可能成为瓶颈,在服务遭遇紧急情况时(如资源占用超出预期),必须要有一种降级手段来保证服务的稳定性,除了前面提到的动态更改轮询间隔,防止请求过多而雪崩,我们还会对返回的最大消息数目进行动态调整,降低CPU和带宽占用,此外在网关层,我们还对接口进行限速,当QPS超出阈值时丢弃掉请求。结合多种策略我们便能有效地对服务的运行情况进行一个有效的把控,提升系统的可用性。

业务监控

为了方便排查突发问题,我们建立了一套完善的线上监控,对每个服务的系统指标进行收集,除了一些通用的基础指标,例如访问QPS,错误码,平均耗时,存活状态等,同时各个业务也会上报自身的关键指标,比如对于拉取服务,就会上报平均消息体大小,平均消息条数,轮询任务存活状态等等。