斗鱼:如何打造一个高性能、高可用直播系统架构

近几年来,国内直播行业发展迅猛,网络直播平台也成为了一种崭新的社交媒体。直播火热的同时,也给直播平台的技术与架构带来了诸多挑战。作为行业领头人,斗鱼在架构上不断迭代、改造与优化,形成当前能支撑千万级用户同时在线观看的架构平台。在这个过程中,斗鱼的直播系统架构踩过哪些坑,演进出哪些特性呢?本文整理自斗鱼房间中台负责人彭友顺近日在TGO鲲鹏会武汉分会活动《大规模互联网系统架构设计与实现》上的演讲,内容如下。

目前斗鱼直播系统每天有20亿+的请求量,并且在今年3月份,PDD入驻斗鱼的时候还曾达到40万+的瞬时QPS,同时我们的服务增长到了上千实例的规模,在全国不同地区和不同机房进行了部署,提供给上百个内部业务方使用。总结下来,斗鱼直播系统有三个特点——流量大、服务多、架构复杂。针对这些问题,要确保整个系统的高性能和高可用,对于我们的技术开发团队来说也是一个比较大的挑战。

高性能业务架构演进中的挑战与解决方案

三大挑战

早在5年前,我们和其他公司一样处於单体应用时期,主要使用“Nginx+PHP+Memcache+MySQL”,当时遇到最大的一个问题,是如果一个用户进入到直播间访问Memcache的时候,如果刚好Memcache里面缓存数据失效了,那么请求就会穿透到MySQL,会对服务造成很大的压力。

所以从2016年开始,我们将Memcache换成了Redis,将全量的直播间的数据缓存到Redis的内存缓存,解除服务对MySQL的直接依赖,同时还做了一些业务隔离:将业务进行了垂直拆分。保证了那个时期的服务稳定。

但是随着斗鱼体量的日益增长,请求量越来越大,我们没想到Redis也会成为一个新的瓶颈。所以从去年开始斗鱼着手对系统做了一些改造,将PHP换成了Golang,然后在我们Golang里面做了一些内存缓存池和Redis连接池优化,经过这一系列的改造,目前斗鱼的直播系统无论是在性能上还是可用性上都有很显著的一个提升。

首先给大家介绍一下我们斗鱼的Memcache时期。在理想的情况下,当一位用户进入到我们直播间页面,请求会先到Memcache,如果Memcache里面有缓存数据,那么就会直接将数据返给用户。但是通常情况下并不总是理想状况,我们现在举一个具体案例进行介绍。

假设有一位大主播开播了,他就会发开播信息给他的粉丝们,这些粉丝会在短时间内进入到该主播的直播间,这样就带来第一个问题——瞬时流量。 紧接着大量请求会并发到Memcache里,由于Memcache采用是一致性hash的算法,所以同一个直播间的缓存key会落在同一个Memcache节点上,这就会造成某个Memcache节点在短时间内负载过高,导致第二个问题——热点房间问题。同时又由于直播间刚开播,之前没有人访问过这个页面,Memcache里是没有这个直播间信息,那么又会有大量的请求穿透到MySQL,对MySQL造成性能影响,所以就造成了第三个问题——缓存穿透问题。

解决之道

针对上述三大挑战,我们做了一些优化。

首先要解决的是缓存穿透问题。斗鱼直播系统里写操作都是主播进行触发,而大量读服务接口则是用户进行触发,所以我们在业务层面上做了一个读写分离,规定:只有主播接口才可以对MySQL和Redis做写入操作;并且将直播间基础数据全量且不过期的缓存到Redis里。这样就去除了用户请求对MySQL的依赖。也就是说用户请求无论如何都不会穿透到MySQL。

针对于第二个问题——瞬时流量,我们主要在Nginx的配置上面做了一些优化。首先,将直播系统单独分配一个nginx的proxy cache,做一些隔离,避免非核心业务的cache占用了核心业务cache的空间;其次,对突发流量,在服务返回502,504状态码(服务容量不够了)时,返回上一次缓存的数据,避免直播间白屏,保证其可用性;最后,做了proxy lock on的配置,确保瞬时同样的请求,只会有一个请求到后端,降低后端和数据源的负载。

最后针对热点房间问题,我们将一致性hash的Memcache换成了主从结构的Redis。多从库的Redis,每个里面都有大主播的数据信息,可以有效的分担大主播流量,从而使得后端数据源的负载均衡,不会出现单节点的性能问题。

新的问题

优化之后,大家都觉得还挺不错的,但是,我们发现把Memcache换成Redis之后又带来了新问题:

带宽过高

首先我们遇到的是内网带宽过高的问题。以前Memcache把响应数据给客户端的时候,它会将这些数据做一个压缩。我们将Memcache迁移到Redis后,由于Redis没有这个能力,内网带宽翻了4到5倍,引起了网卡负载较高,出现一些丢包和性能问题。

使用不当

随着我们斗鱼的体量越来越大,我们的请求量越来越大,Redis逐渐成为了我们的性能瓶颈,大量的业务共用同一组Redis,如果某个业务方出现Redis用法不当,阻塞了单线程的Redis,会影响其他业务。同时由于Redis没有限流功能,很容易Redis被打垮,导致所有业务全部瘫痪。

时延过高

因为业务需求的升级,我们想带给用户更好的体验,将许多曾经的缓存接口变成了实时接口。如何降低我们请求的时延,考验着我们开发的能力。

微服务化

为了解决这三个问题,我们步入了一个新的时期——微服务。首先是在架构上做了一些改变:PHP换成了Golang、 将Redis主从结构变成Redis分片的主从结构。其次我们针对业务做了一些区分:大主播和小主播采用不同的存储策略,小主播的信息我们是通过hash算法放到一个节点里,这样是为了降低Redis内存开销;大主播的信息在所有分片均会存放,保证大主播在突发流量时有很多节点进行分担。

架构改变说完后,我们再聊下,我们斗鱼对Redis的一些理解和经验。

最佳实践

刚才也说了,我们之前业务上共用Redis造成了一些问题,为了解决这些问题,首先要做的是对Redis做好隔离;其次针对带宽过高的问题,一方面将Redis里的key做了精简,降低流量和存储大小;另一方面从业务上规范了房间字段的按需索取。最后从代码层次规范了Redis的使用方法:我们根据线上全链路压测的压测结果,调整了Redis连接池空闲连接数,这样可以解决线上的突发流量问题;并且规范了获取Redis数据的方式,严格限定了获取Redis的每次包尽量不要超过1KB,并且尽量使用pipeline,减少网络io。

再快一点

刚才说过我们后来有许多接口变成了实时性接口,怎么让服务更快一点呢。我们就把一些热门主播通过任务提前算好,服务通过异步获取热门主播名单缓存这些直播间数据,用户再看这些热门主播的时候速度可以更快。同时对于获取批量直播间数据,通过计算redis从库个数,并发去拉取直播间数据,来降低业务方的时延。

实验数据

为了让大家有个直观对优化有个直观的理解,下面第一个图,是我们斗鱼在对批量直播间数据做的对比试验。大家可以看到精简key和按需索取,其性能要好很多。下面第二个图,是官方做的Redis包大小的对比图。当获取Redis数据包大小在1KB以下,性能比较平稳,但超过1KB,性能急剧下降。所以我们业务场景在使用Redis的时候,一定要注意些这些细节。才能使得我们系统性能更好。

高可用技术架构

为了让大家知道高可用的必要性,我们先来看下斗鱼的技术架构图。这是一个多机房架构的部署图。我们先来看一个服务区域,首先必不可少的是我们的应用(客户端和服务端),服务端和客户端靠etcd来发现彼此。不同的服务区域则是通过我们自己写的sider来相互通信和感知。然后prometheus和日志都会把数据采集到微服务管理平台,一个服务从开始部署上线、到运行监控、故障定位及修复操作都可以在微服务管理平台进行。

高可用

从刚才介绍大家应该可以知道目前斗鱼的架构和部署的复杂性。如果没有好的高可用的解决方案,那么势必会造成系统的不可靠,导致严重的线上问题。

因此我们提出了高可用的一些解决方案。我们将高可用分为两个层次。第一层是自动挡,比如像负载均衡、故障转移、超时传递、弹性扩容、限流熔断等,这些都是可以通过代码层面或者运维自动化工具,不需要人工干预,做到系统的自愈。第二层是手动档,这些主要就是监控报警、全链路压测、混沌工程、sop等,我们能通过一些手段提前预知可能存在的问题,或者线上出现问题无法自愈,我们怎么快速发现,快速解决。

因为高可用的内容比较多,限于篇幅有限。我们今天就主要讲两个内容负载均衡和监控报警。

负载均衡

通常情况微服务都会采用roundrobin的负载均衡算法,它实现起来比较简单,但是它的问题也不小。在这里给大家看一个负载均衡roundrobin的案例。为了方便大家理解这个算法,我们对模型做了一些简化。大家可以看这个图,有个定时任务,每秒都会先调用一个消耗低cpu的单个房间信息接口,然后再调用一个消耗高cpu的批量房间信息接口。如果刚好服务端就两台实例,根据roundrobin算法,这个请求的调用方式就是:奇数次单个房间信息接口都调用到a节点,偶数次批量房间信息接口都调用到b节点。显然易见的就是a节点负载会远远低于b节点。

为了解决以上问题,我们可不可以根据系统的负载动态的调度呢。于是我们会在客户端用gRPC调用服务端的时候,让服务端不仅返回业务数据,同时在header头里会将服务端cpu的load返给客户端。客户端根据服务端的cpu load和请求的耗时,异步算出下次客户端需要请求的服务端实例。这个调度算法看似挺美好,但是他会带来“马太效应”。假设服务端某个节点此时负载比较小,客户端可能会因为算法一窝蜂去请求他,使它负载变高。这样服务端节点cpu会变得忽低忽高,这也不是我们想要的结果。

所以这个时候,我们通过调研采用了业界流行的p2c算法。我们在客户端在做调度的时候做了一个随机数。 如图左边所示。可以看到加了随机数后,可以避免这种“马太效应”的调度问题,负载较低的多调度点,负载较高的少调度点。当然如果服务端有问题,是直接剔除的。

通过优化负载均衡的算法,我们平滑了服务端的cpu负载,同时可以快速剔除有问题的服务节点。当然还有个最关键的成效,就是有了这个算法,我们才可以把多机房的etcd注册数据进行同步,做多数据中心,然后客户端根据算法自动选择不同机房的节点,实现同城双活。

这个图就是我们通过wrr算法切到p2c算法后的对照图,可以看到在这个图的前半部分,每个实例之间的负载差距都比较大,并且同一个实例的负载波动也比较大。换成p2c算法后,趋势图看起来就比较稳定了。

监控报警

先给大家看一个线上报警的架构图。通常线上出现问题后,我们的日志系统和监控系统,会将对应的数据上报到报警系统,但是报警的信息可能五花八门,需要一个人工过滤器系统去筛选这些信息,才能找到真正负责业务的人员。

我们之前在没有进行服务错误收敛的时候,经常会出现各种报警狂轰乱炸,出现陌生的报警信息,不知道该如何处理,业务报警和系统报警交织在一起,很难分辨出报警的轻重缓急。不得已需要有专门几个人处理和筛选报警信息。但是人工为什么要成为报警的一个系统?

这是因为我们没有从源头做好,没有很好的对服务进行错误收敛,导致错误信息泛滥,报警轰炸。

错误收敛

要想将错误收敛做好,首先要制定好的规范。我们规范了系统和业务的统一错误码,监控,日志应用同一套错误码,方便关联和查看。并将监控,日志索引做了统一规范,例如之前redis的命令叫command,mysql语句叫sql,http请求叫url,这样导致我们的日志很难收敛做分析和发现问题,我们后来将一些共性的指标统一成一个名字,例如将刚才所说的在日志和监控都取名叫method。

做好规范后,接下来,我们就开始治理我们的服务。我们认为系统错误的重要程度要高于业务错误。监控应该更多的区分系统级别的错误。所以右图第二个,因为我们之前做了统一错误码规范,code小于10000的系统错误码全量记录到prometheus里,对于业务错误码,我们都收敛成biz err。如果出现biz err,业务方应该通过这个状态码,去日志里看详细信息。

在右边第三个图,介绍的是我们收敛redis的一个代码片段,像redis,mysql也有很多错误码,例如查不到信息非系统级别的错误,我们统一收敛到unexpected err,做一个warning告警。但对于像图中上面的read timeout,write timeout等则是记录error,做一个高级别的报警。
还有个问题,虽然我们统一了错误码,但如果出现错误码的时候,没有文档和解决方案,我们还是一筹莫展。

所以我们提出代码既是文档的解决方案。通过自动化的语法树解析代码里的状态码和注释,自动的生成对应的错误码文档,告诉我们错误码在代码哪个地方,可能存在的问题。

有了错误收敛后,我们才很好的去做我们的监控。

监控指标

我再提出一个问题,有了错误收敛,我们监控就可以很好的发现服务问题吗?

这里再举个例子,这里有个汽车,大家看这个汽车,觉得这个汽车是可以使用的吗?如果仅从一个平面去观察这个汽车,有可能你觉得汽车是正常的。但是如果你从多个维度去观察这个汽车,你可能会发现他尾部存在问题。这个和我们服务监控一样,我们如果要做监控,那么这个监控指标一定要立体。

所以我们将监控维度上做了一些要求。从系统维度上,我们需要知道服务的上下游的关联监控;从应用维度上,我们要能够看到应用实例监控、应用大盘监控、全部应用大盘监控、各种不同指标的top榜。

在指标上,我们按照SRE文档里要求,对四个黄金指标延迟、流量、错误和饱和度做了很好的落实。

下图是我们一个服务,上面展示了一些基础监控数据,中间的一部分是我们服务的关键指标,分别是服务的错误码分布,服务的qps,服务的p99耗时,这三个刚好对应的就是黄金指标的错误、流量、延迟。流量和延迟做开发的其实接触的比较多,对这些指标还是比较敏感的,我就不详细介绍。主要来说下黄金指标的错误和饱和度。

监控的错误识别,上面我已经介绍了错误收敛怎么处理的,如果服务做到很好的错误收敛当发现问题的时候,可以很快发现和解决,如下所示,我们可以很快知道我们错误日志和慢日志的一些情况。但是这个并不能很好的预防不必要的错误发生。

所以为了更好的识别错误,预防错误发生,可以使用混沌工程进行一些故障注入。一方面可以尽早的识别一些错误,另一方面也可以让我们开发人员积累一些错误经验,方便后续处理线上故障问题。

最后在介绍下,可能容易被大家忽视的一个监控指标–饱和度。通常有好多业务方,容易忽视饱和度的监控和报警,导致服务达到了性能瓶颈之后被动的去做一些应急方案。

并且就算知道这个指标,也很难预估出来服务的饱和度。需要研发人员具有一定的经验、一定的能力,很多时候我在线下压测后发现线下压测和线上压测完全不一样。因为线下是很简单的,线上环境是很复杂的,所以饱和度最好是通过线上全链路压侧。这样就可以知道线上的水位是多少,知道一个核心数可以提供多少的qps,什么时候会达到性能瓶颈。下图就是我们斗鱼做全链路压测之后,得到各个机房某个服务的qps饱和度图

和时间赛跑

无论系统如何高可用,但系统还是会出现线上故障。如果出现了问题,我们就要想办法如何快速解决。

系统聚合

我们斗鱼之前微服务系统都是按系统拆分,监控系统、日志系统、报警系统等,如果线上如果出现问题,那我首先去需要打开多个系统的页面,不停去登录账号,非常影响排查效率。所以后来就是对微服务做聚合的一个操作,把所有的系统按应用维度聚合到一起。我们查看一个应用,可以很快在里面看到监控、注册、配置、性能和日志等信息,这个小小的改动,其实能够很快的帮助业务方进行排查问题。

数据分析

我们要对数据做分析,杂乱无章的数据对排查速度影响很大。避免人工的一些检索和判断。我们根据之前所说的收敛工作和监控的立体维度。可以很快的查看各个应用各种指标的top榜,并且能够知道报警的错误码是多少,索引到确切的解决方案。

SOP手册

我们需要通过混沌工程,全链路压测等方式,提前想好可能出现的严重问题,做好sop操作手册,这样当问题真正来领的时候,我们不会慌张,可以很从容的处理。就像这个图里的一样。

嘉宾介绍:
彭友顺,斗鱼房间中台负责人,2015年加入斗鱼,跟随着斗鱼成长,经历了斗鱼直播系统架构的演进历程,积累了大量高并发、高可用的项目经验,并主导GO微服务的架构建设,见证了斗鱼直播系统微服务的发展成果

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章