01 前言

哔哩哔哩直播成立于 2014 年,经过 8 年时间的发展已经从最初的业务试水成长为公司重要的业务板块之一。技术架构也从一个单体服务演进为由数百个微服务组成的复杂系统。本文将回顾 8 年来哔哩哔哩直播架构演进中一步步的变化,带你了解它是如何从 0 开始逐渐成为能够承载千万在线的微服务系统。

02 从0到1

和大多数网站一样直播也是始于一套 LAMP 架构,即 Linux + Apache + MySQL + PHP 。前端、服务端、定时任务所有功能都集中在一个叫做 "live-app-web" 的项目中。

 直播系统架构

一个典型直播后台架构由三部分组成:业务系统用于直播各种业务功能逻辑实现、推拉流系统用于主播推流和用户拉流观看、长连接系统用于在直播中的各类实时业务数据推送触达。而 live-app-web 即承担了 application server 的角色。

live-app-web 的应用架构

在 live-app-web 中既有通过 PHP 模板引擎 Smarty 渲染的页面,也有JS 写的前端页面,还有常驻后台的 PHP 消息队列处理程序(通过 Redis List 实现的生产-消费模型),这些功能被分别放在各自的代码目录中,分别由前后端开发人员进行代码开发,并最终部署到一台台物理机上。

live-app-web 最初的项目结构

虽然现在看起来这套架构非常简陋,但在最初的 2 年里我们在 live-app-web 实现了每个直播平台所必备的各类业务系统,而这些系统也在后续的演进中也在不断发展壮大成为一个个重要且独立的业务系统。

live-app-web

像任何一个高速发展的业务一样,live-app-web 的代码量也在急速增长,从 2015年中的 5W 行 PHP 代码到年末的 8W 行,再到 2016 年中已经累计到了 13W 行。我们在这期间做了一定的前后端分离改造,将前端部分拆成一个个单独的前端应用,如直播首页、直播房间页、直播个人中心页等。但随着业务增长和人员扩充单体应用带来的问题也越来越多,并行项目带来的合并冲突、发布排队、某个子模块问题导致整站挂等问题日渐突出,而这些问题也终于在一个重要事件上集中爆发了。

2016 年某一时刻的 git 状态

03 局座来了

不了解局座B站直播梗的同学可以先通过这个链接了解一下事件背景:如何看待张召忠将军7月13日的b站直播?[1],一句话总结就是:局座来了直播挂了,睿总在知乎公开道歉。

当时的直播几乎受到全网的群嘲,微博、知乎各类吐槽的声音铺天盖地。回头来看一方面是对观看人数的预估不足,另一方面受到单体服务架构的制约,监控告警、资源弹性、限流降级、故障隔离等手段都非常缺乏。痛定思痛之后直播开启了微服务化的历程。

04 微服务化

如何做微服务?这是摆在当时团队的一大难题,由于当时团队成员主要是 PHP 背景,同时业务也在快速迭代,切换语言从 0 开始显然是不现实的。而当时正好有一款在国内很火的 PHP 高性能服务框架 Swoole,通过进程常驻的方式显著提升了 PHP 服务的运行性能。经过一定的技术调研之后团队决定以 Swoole 为基础构建直播的微服务框架,并定义了以下原则:

  1. 按业务领域进行微服务拆分

  2. 每个微服务拥有自己独立的数据库、缓存

  3. 每个微服务仅能访问自己的数据库、缓存,服务间只能通过 RPC 访问

  4. 微服务负责人对自己的负责业务的服务稳定性负责

服务框架:我们基于 Swoole 开发了我们自己的微服务框架,这套微服务框架实现服务进程管理、平滑重启、ORM、Cache、Logging 等,业务只需要按照模板实现  controller 和对应 service 代码即可,能够比较快速地上手开发。

通信协议:微服务间通信我们采用了基于 TCP 上自行封装的 RPC 协议并称之为 liverpc ,这个 RPC 协议非常简单直观,通过固定长度 Header 头 + 变长JSON Body 实现,服务间以 TCP 短连接进行服务调用。

服务发现:引入微服务后还需要解决的一个问题是如何做服务发现?结合当时的背景我们选择了 zookeeper 作为服务发现组件,同时为了隔离服务注册、发现、健康检查的复杂性,我们专门开发了一个叫做 Apollo 的业务伴生程序用于服务配置拉取、服务注册、服务节点发现,业务框架通过文件监听的方式感知配置变化进行热加载。

配置管理:同样地我们采用了 zookeeper 来保存每个服务的配置文件,并通过 Apollo 进行配置拉取和变更监控。

消息队列:我们搭建专门的 kafka 机器作为消息队列,由于 PHP 直接跟 Kafka 交互较为复杂,我们搭建了专门的投递代理服务 publisher 和消息回调通知服务 notify。

统一网关:另一方面针对缺乏统一限流、降级能力的问题,我们单独开发了一个网关服务 live-api ,并且要求所有外部的访问都需要经过 live-api 转发到对应的业务服务。在这一层统一网关上我们实现了 流量转发、URL 重写、超时控制、限流、缓存、降级等能力。live-api 也是基于 swoole 实现的,不同的地方在于我们是通过 swoole 提供的纯异步 client 实现,在性能上有一定保证。

至此一整套微服务系统的雏形已经显现出来。

在这一套微服务系统上我们逐渐将 live-app-web 各自业务域的逻辑重构到对应的服务中。同时我们和 DBA 一起协作完成了直播的数据库在线拆分,将原来集中在一个库的业务表拆分到一个个独立的数据库中,实现了高速路上换轮子,也彻底解除了存储层混用的风险。

2017 年12月局座再次来到 B 站开直播,带来了比去年多得多的流量,但这一次我们稳了。

05 容器化

一直以来直播服务均采用物理机部署的方式,这种方式存在明显的缺陷:需要为每个服务分配独立的端口避免端口冲突、部署目录需要隔离、存在资源竞争、单个服务容量无法准确评估等等。而随着 B 站业务规模的扩大,公司的基建团队也提供了更为稳定的容器平台,在充分调研后我们启动了服务 Docker 化改造并在很短的时间内完成了全部服务的容器化部署。

在 Docker 化部署中面临的一个问题是:如何选择 CPU 调度方式 ?

我们知道在 Docker 中通常有两个方式 :1、CFS (完全公平调度)即通过按比例的 CPU 时间分片进行调度,这种资源分配方式比较灵活,也可以通过资源超配来提升整体的资源使用率;2、CPUSET(绑核)这种方式通过设置 CPU 亲和性将 POD 绑定到指定的一个或多个 CPU,实现资源上的独占。

我们在将 PHP 服务迁移 Docker 时发现 CFS 模式下接口超时非常严重,已经达到了无法接受的程度,因此所有的 PHP 服务均采用了 CPUSET 的方式部署,同时 PHP 服务的工作进程数也通过压测的方式得到最佳配置,为分配的 CPU 数量的 3~4 倍表现最佳。

在 CPUSET 模式下同样存在的突发流量的困扰,这类突发流量在 Prometheus 的监控图表中难以发现,因为监控数据通常是以30s 周期采集拟合生成监控曲线。但在请求日志上我们可以清晰地看到一条条秒级的请求突刺存在,而这些请求量远远超过了我们为服务配置的 CPUSET 数量,而要满足这种突发流量而调高配额显然也是不现实的,因为这会造成极大的资源浪费。

针对这种场景我们将应用资源池分成了两个分组:固定资源池和弹性资源池,固定资源池中的服务采用 CPUSET 固定分配好资源量,而弹性资源池采用多个服务混部的方式,单个服务不限制其资源使用量。并通过网关对突发流量进行分流,将突发流量引入到弹性资源池,以此来解决突发流量带来的容量瓶颈。

网关服务 live-api 会实时统计每个接口的 QPS:当 QPS 小于 X 时流量全部转发到固定资源池分组,当 QPS > X 时超出阈值的请求会被转发到弹性资源池。同时我们实现了请求打标功能,在弹性资源池内的请求会优先请求同在弹性资源池的服务。我们也可以通过观察弹性资源池的的利用率来判断固定资源池服务是否需要扩容,最终的目的是通过少量的混部弹性资源池来解决个别服务频繁的突发流量报错。

关于 CFS 超时的问题后来在阿里云公开的文章中有了更详细的阐述,并通过 CPU Burst 技术将 CFS 调度导致的超时问题大大缓解,CPU Burst  的核心是将我们常用的令牌桶限流算法引入到了 Linux 内核 CPU 调度上,当 CPU 使用率低于设定的配额时可以累计未使用的配额,并在后续的调度中允许使用累计的这部分配额来应对突出流量。随后内核团队通过内核升级、优化等方式解决了 cgoup泄露、调度不均衡、超时等问题。同时在内核上通过调度算法优化,利用  CPU Burst、Group Identity 、SMT expeller 等技术实现了在离线业务混部互不影响、全站资源合池等重大技术特性,资源容量和利用率得到极大提升。业务应用也不再通过 CPUSET 这种相对固定的资源分配方式,而是在CFS调度模式下通过 VPA、HPA 这样的弹性资源管理策略,动态、按需地获得所需要的运行资源。

06 Golang 真香

2018 年是 Golang 大火的一年,毛老师作为 Golang 布道师在哔哩哔哩主站推进 Golang 服务化演进非常成功,并通过 Golang 开发出了一系列的微服务框架和中间件,如 Kratos(Go微服务框架)、Discovery(服务发现)、Overload (缓存代理)等,相当一部分项目也同时在 github 上进行了开源。

彼时的直播正面临着下一步技术演进的抉择,因为基于 swoole 构建的 PHP 微服务体系已经不能支撑更大的流量了,其主要问题集中在 :

  1. PHP 的多进程同步模型极易因为单个下游异常而导致整个服务挂掉,因为下游响应变慢 PHP Worker 不能及时释放,新的请求来了之后只能排队等待空闲 Worker,这样的级联等待进而导致系统的雪崩。

  2. 实现 RPC 并发调用较为困难,在一些业务复杂的场景由于只能串行调用下游接口,导致最终对外的接口耗时非常高。

  3. PHP 服务扩容带来了数据库、缓存连接数的压力,当时还没有成熟的数据库代理,而是每个 PHP Worker 都会直连数据库,这直接导致了连接数的爆炸,进一步限制了 PHP 服务的扩容能力。

而 Golang 的协程模型正好可以解决这些问题,毛老师在主站 Golang 服务化演进基本完成的情况下亲自来到直播指导 Golang 服务化演进。

对于这次 Golang 服务化演进,我们将服务划分为了三种类型:

  1. 业务网关(interface):业务网关按业务场景进行划分,如 App、Web 网关,在网关内完成对应场景的 API 接入,对下游业务服务的数据聚合、App 版本差异处理、功能模块降级等。

  2. 业务服务(service):业务服务按业务领域划分,如房间服务、礼物服务,不同的业务服务完成各自的业务逻辑。

  3. 业务任务(job):业务JOB是依附于业务服务的,通常是用于定时任务处理、异步队列消费等场景。

其中特别要提到业务网关的设计,在直播首页、房间页的场景中,由于业务逻辑复杂客户端通常需要调用十个甚至数十个接口,部分接口还存在时序依赖。不仅客户端代码实现复杂,还导致了客户端页面展现的延迟。因此在新的 Golang 网关实现中我们把单一场景的展示数据统一聚合到一个接口中,即打开一个页面只需要调用 1~2 的接口即可完成页面功能渲染。随后我们还在业务网关实现了热点数据主动缓存、下游服务异常的自动降级等特性。

经过几个服务的试点后发现基于Golang 的服务无论在接口耗时还是稳定性上均远超 PHP 服务,特别是网关需要聚合 10几个下游的数据时,通过协程的并发处理接口平均耗时不到原来 PHP 服务的一半。在此后的一段时间越来越多的 Golang 服务创建,更多的 API 也通过 Golang 网关对外提供到 哔哩哔哩 Web、PC、Android、iOS 等各种设备中。

07 live-app-web 的终结

2019年直播最早的服务 live-app-web 终于完成了它的使命,所有线上功能全部完成重构迁移,实现了 live-app-web 服务整体下线。截止下线时 live-app-web 已累计了 19W 行代码、上百位 contributers,感谢他们!

08 新网关的诞生

回到 Goalng 微服务演进过程中,我们并没有让曾经的 live-api 网关承接 Golang 业务网关的流量,一方面是因为当时 swoole 没有成熟的异步 http client,另一方面则是基于 PHP 的纯异步网关也逐渐显露出性能瓶颈。而问题在 2019 年也逐渐暴露出来了:

  1. Golang 业务网关限流需要业务在各自服务内分别接入、配置修改后需要重启生效。某个紧急情况下甚至发现部分服务未接入限流组件。

  2. live-api 在更大的业务流量下表现不佳,已经成为一个瓶颈,而存量 PHP 服务在相当长一段时间还需要持续迭代和提供服务。

对新网关的需求应运而生,在调研了 Kong、Tyk、Envoy 等多个开源网关,我们决定采用 Envoy 作为数据面,自研 Golang 服务作为控制面的方式来实现新网关。Envoy 在 service mesh 领域几乎是 No.1 的存在,其非常适合作为流量转发服务。我们将新网关命名为 Ekango。

为了进一步移除 live-api,我们将原有基于 TCP 的 liverpc 协议升级支持了 HTTP 调用,这样就可以将请求从 Ekango 直接转发到对应的 PHP 服务,同时也极大地便利了研发的开发、调试成本。

在 Ekango 网关中我们实现了分布式限流、接口条件 Rewrite、接口降级、统一鉴权、接口风控、多活可用区降级等特性,并提供单机 15W+ QPS 的服务能力。

同时我们基于 Ekango 的设计开发经验,基于 Envoy 实现了 service mesh 应用:Yuumi。Yuumi 是解决 PHP、JS 等语言访问 Golang 开发的 GRPC 服务问题的解决方案,因为长期以来微服务建设围绕 Golang 生态展开,对于其他语言的支持却略显薄弱。对于直播而言我们希望 PHP 服务也一样能享受到 Golang 生态同等的服务治理能力,并且能够方便地调用 GRPC 服务。

Yuumi 的实现解决了这一问题,通过 service mesh 的方式 PHP/JS 进程以 HTTP 协议访问本地的 sidecar 进程,由 sidecar 再将请求转发到对应的 HTTP 或  GRPC 服务,并且业务服务无需关心服务节点发现、节点错误重试、节点负载均衡等等微服务治理问题。

Ekango 帮助直播支持了数个百万级、千万级的大型活动均有稳定的性能表现。但他也存在一些缺陷,如部署配置复杂、C++ 代码难以二次开发,特别是流量治理和管控能力缺乏可视化的控制面,只有少数几个开发者才能正确配置。我们在之前的文章中有介绍过微服务团队开发了 B 站统一的网关,其在支持常规的流量治理能力外,还提供了全流程可视化的接入方式和管控面、API 元数据管理、全链路灰度发布等高级特性。因此在充分评估之后直播也将网关流量全量迁移到统一网关上,由统一网关对全站的入口流量进行流量管控和治理。统一网关同时也作为 Kratos 开源项目之一在 Github 上同步更新。

至此直播的架构演进基本告一段落,在此之后我们进行了消息队列和定时任务的角色拆分、分布式任务调度的引入彻底解决服务单点部署问题。同时积极推动业务多活落地以解决更大范围的可用性问题,服务好直播业务的快速发展。在架构演进过程中我们也碰到了一些典型问题,在这里也对这些问题的处理作一定的总结,希望能启发你的思考。

09 关于热Key

热点问题无处不在,抢购、秒杀、抽奖、一次大型活动、突发事件都会形成一个个热点,而最直接的影响就是产生热数据,进而导致单节点被打挂、服务雪崩等可怕结果。对于直播业务而言,最容易产生热 Key 的就是那些热门房间,即我们称之为高在线房间。随着直播架构的迭代,我们对于热 Key 的处理方式也在发生变化,但都围绕着多级缓存、分而治之的思路进行,同时也需要考虑数据一致性、时效性,不能盲目地通过加缓存的方式来解决热 Key。

 9.1 PHP 服务的高在线热点缓存

在 PHP 微服务时代我们通过一个集中的 monitor-service 收集来自 CDN 和 弹幕长连接数据,获得当前在线人数较高的房间,并将这些房间信息推送到消息队列中。由关心热门房间的服务 job 消费到这些热门房间信息,将各自业务可能涉及的热点数据主动推送到缓存中。而服务进程内也会有一个定时器监听这些热门缓存 Key,并定时将这些数据直接拉到 PHP 进程的内存当中。这样热门房间的业务数据就会直接命中内存缓存。

 9.2 Golang 服务的高在线热点缓存

在 Golang 服务建设中我们简化了热门房间检测逻辑,直接提供了一个热门房间 SDK,业务服务可以直接通过 SDK 判断特定的 room_id / uid 是否属于热点,而由 SDK 内部定时拉取热门房间列表信息。业务再通过定时器将热点房间数据直接缓存到内存中。

这样的好处是:

  1. 热门的判断阈值可以由各个业务服务自行控制,如 A 服务认为 1W 在线属于热门,需要进行预热处理;B 服务认为超过 5W 在线的属于热门数据才需要预热处理。这样对于非热门的数据提供较高的数据时效性和一致性、对于热门的数据通过牺牲一定的一致性来实现更高的可用性。

  2. 热门的处理可模拟、可演练,通常在预期的大型活动中我们会提前将活动房间在后台标记为热门房间,再通过压测来验证热门房间处理逻辑是否生效、性能是否符合预期。

 9.3 热点数据主动探测

随着直播在 B 站主站业务的融入,我们发现热点并非仅来自于热门直播房间这一种场景,热门稿件、热门评论同样会对部分直播服务造成热 Key 问题。因此我们设计了一个更通用的热点检测和处理 SDK 。

业务在接收到用户请求后调用计数 API,SDK 异步通过滑动窗口+LFU+优先队列计算Top-K,定时向业务回调统计到的热点数据 ID ,业务基于这些热点 ID 将数据源预加载到内存。这样对于热点的统计和判断完全取决于业务自身的 QPS 情况,而无需依赖外部数据。最终我们实现了热点数据的秒级感知和数据预热缓存能力。

 9.4 代理层的内存缓存

Redis 在 6.0 中实现了客户端缓存机制来解决热点数据问题。我们的中间件团队也在内部的缓存代理上实现了客户端数据缓存,通过中间件管理后台我们可以配置正则表达式匹配一类的缓存 Key,符合规则的缓存 Key 会在代理层进行数据缓存,对该 Key 的下一次访问会直接命中本地缓存,不再需要访问缓存服务器,直到本地缓存失效。

代理层缓存特别适合于已经发现热 Key 的紧急处理流程中,直接将发现的热 Key 设置为本地缓存可以极大缓解热 Key 风险。但其并不适合作为一种通用热 Key 处理方案进行提前配置,特别是针对一类 Key 的正则匹配这会影响这类 Key 的数据一致性。

9.5 Proxyless Redis Client 

集成热点缓存

热点探测 SDK 需要业务主动接入,代理层的缓存方案过于简单。在发生多次热 Key 触发告警后,我们与基础架构同学交流探索出了以 Redis Client 内嵌热点缓存 SDK 的方式来实现业务的透明接入。在该方案中基础架构同学借鉴了 HeavyKeeper 算法重新设计了热点探测 SDK。HeavyKeeper 用于在流式数据中以较小的内存开销获得非常精确的 TopK 计算结果,统计出的 TopK 即是我们想要知道的热 Key。业务透明接入和缓存配置动态更新这两个特性的结合成了热 Key 的杀手级解决方案。

 9.6 热点写数据的处理

在直播场景中除了读热点,还存在写热点的场景。通常是由于大量用户向同一个主播赠送礼物、发送弹幕等行为产生的写操作,进而对单条记录产生大量并发写场景。进一步分析这些并发写的场景我们发现通常是针对单条记录数值的增/减操作,如经验值、积分、点赞数等,而这类场景天然是可以支持聚合的。因此我们开发了一个聚合写入 SDK,其可以采用内存聚合或 Redis 聚合的方式,将业务对数据的变更操作按设定的周期进行聚合写入,比如+1、+2、-1 这样三个操作可以直接聚合成 +2 一个操作。实现这个 SDK 需要考虑聚合窗口大小、下游 DB 压力、服务异常重启的数据一致性保证等。

10 关于请求放大

房间服务是直播流量最大也是最核心的服务之一,日常 QPS 维持在 20W+。在运营房间服务中我们发现了以下几种场景的请求放大:

 10.1 请求超出需要的数据

在分析房间服务高 QPS 调用来源方时我们发现部分业务仅需要房间信息中的一部分数据却请求了整个房间信息,比如某些业务方仅需要判断用户是否拥有直播间却调用了完整的房间信息接口,本来一个字段能解决的问题接口返回了数十个字段,造成不必要的带宽消耗和接口耗时。我们参考 FieldMask( 关于FieldMask可参考 Netflix API 设计实践: 使用FieldMask)的设计将房间信息拆分成不同的的模块,如播放相关、直播卡片展示相关等模块。业务方可根据场景需要组装 API 调用获取对应模块的数据实现按需请求。

 10.2 重复的请求

直播间承载了直播近 80% 的业务功能,用户在进入房间时会请求进房接口。在这个接口中网关会聚合多个下游的数据后统一返回给用户,我们发现这个场景存在重复请求房间信息的情况。

上图所示除了 room-gateway 会请求房间信息外,gift-panel、dm-service 也会分别再请求一次房间信息,直接导致了房间服务的请求放大。这样的下游服务越来越多后,用户一次进房将对房间服务产生 10 倍以上的流量放大。而这种流量放大显然是没有必要的。解决方案也很直接将 dm-service、gift-panel 依赖的房间信息通过接口直接传递给对应服务。调用时序调整为先调用房间服务获取房间信息,再并发调用业务服务获取业务模块数据,最后组装成业务需要的数据返回。

 10.3 业务服务的请求放大

直播间承载了数十种业务功能,用户的一次进房会分别向这数十个下游服务进行请求。对每个下游都要求按照进房 QPS 进行备量,即承担至少 2W+ 的 QPS。这对于一些小众的服务是难以承受的,从数据上看对下游的 99% 请求都是查空的无效请求。为了降低接入房间场景业务的负载、减少资源浪费,我们在房间服务上实现了一个 TAG 机制,业务服务将数据 TAG 同步到房间服务,网关、客户端 在请求房间信息后根据 TAG 标识状态决定是否请求对应的业务服务,这样就避免了大量业务需要承担用户进房级别的 QPS。

11 关于活动保障

一次大型活动的技术保障是一场技术盛宴,也是对所有研发同学的一次大考。直播技术在历年的活动保障中沉淀了一系列的工具和方法论。围绕场景梳理分析、服务容量预估、全链路压测、降级预案、现场保障等方面有一系列标准化方案、工具和平台支持。

 11.1 场景梳理

场景梳理的目的是了解一场直播活动中涉及了哪些业务功能、服务和接口,以此针对涉及的业务模块开展后续的保障工作。通常活动直播间所使用的功能是普通直播间的子集,这就需要直播间内的功能都需要有控制开关,这里的控制开关一定是需要在终端实现的,即开关关闭后客户端不会对这一功能服务产生任何请求压力。场景梳理需要基于用户的真实操作路径进行请求录制,可以通过代理抓包的方式进行自动化的场景录制,再通过录制请求对应的 Trace 链路快速生成场景依赖关系图,这个关系图就明确了该场景下涉及的服务、资源等信息。

 11.2 容量评估

容量评估用于确定活动所需要的资源,以进行采购备量和提前扩容。容量评估一定是基于历史数据和活动预估进行推算,其中针对不同的业务有不同的增长系数。

 11.3 服务压测

服务压测通常是对线上服务真实容量进行摸底,一般在服务扩容前和服务扩容后都会进行压测以验证服务容量是否满足活动需求。特别地针对数据写的场景需要通过全链路压测的手段实现压测数据和真实数据的隔离,避免压测产生的脏数据影响线上业务。

 11.4 降级预案

无预案不保障,针对可能出现的技术风险都需要有对应的 SOP,且这些 SOP 都需要通过预演的方式验证方案有效性。

 11.5 现场保障

现场值班保障时通常会遇到信息爆炸、协作难度大的问题,特别是突发的系统告警容易产生惊群效应。需要高效信息分发、实时协作,实现保障工作的有序流转、不重不漏、快速执行。基于保障场景的特殊性我们研发了活动实时保障平台。

在实时保障平台中按照业务场景划分不同的场景负责人和保障值班,所有的线上服务告警、指标异常都会以实时推送的方式展示在对应保障人员的值班页面。针对常见的告警类型,如 CPU 过高、服务限流会直接关联到 SOP 手册,值班人员可以基于手册指导完成处理预案。在保障结束后我们也可以基于实时保障平台的数据记录生成保障报告,复盘保障过程中出现的问题、响应时效、执行结果和后续 TODO。