查看原文
其他

直播房间服务基于CQRS的架构演进实践

刘瑞洲&王清培 哔哩哔哩技术 2024-03-20

本期作者



刘瑞洲

哔哩哔哩资深开发工程师


王清培

哔哩哔哩资深开发工程师



引言


房间系统是直播业务的“基石”,开播和看播两大体系都是围绕房间场景展开。

房间系统架构也经历一系列的升级和挑战,从房间读多活、混沌流量治理、热点发现、多级缓存等,支撑了S11破千万PCU的流量洪峰冲击。

为了应对业务更大的挑战,基于CQRS思想,分离大流量的用户高读场景(Query)和注重数据强一致性的开播创建房间等写场景(Command)。对于用户端可以无状态无限制的扩容服务副本,做到支持更大线上用户同时在线的目标。


背景


直播业务的技术服务体系也实践过从单体到微服务化的演进之路,以技术视角看微服务体现单一职责和关注分离的思想,从大单体应用的进程模块拓展到分布式的应用服务模块化。同时微服务也是有额外成本的,微服务的拆分思路不仅仅是技术层面,更多会取决于组织和团队(以及组织如何去看待业务)。

如康威定律所说:


Organizations which design systems[…] are constrained to produce designs which are copies of the communication structures of these organizations.

设计系统的组织,其产生的设计和架构等价于组织间的沟通结构。


单体架构到微服务架构是这个定律很好的体现。直播业务从一开始划分了三个大的macro service domain,分别是用户、主播、营收。房间服务被划分在了主播这个Domain内,因其是主播创建房间、开播推流的基础载体,没有直播内容供给整个直播业务都无从谈起。

将单块架构先解耦成三块大的业务域,每个团队开发,测试和发布自己负责的服务,互不干扰,系统效率得到提升,满足了一个阶段内业务快速发展对于技术的要求。

本文后续将主播域(关注内容生产)简称为B端,将用户域(关注流量消费)简称为C端,方便理解。


现状分析


大单体应用拆分之初,房间服务是从中拆出的单个PHP服务room-service,不过服务中仍然耦合了过多的业务逻辑,从业务重要性/读写轻重/前后台区分/面向用户区分等几个角度上考虑都不应该继续在这个服务中继续迭代业务逻辑。

随着B站Golang微服务化演进,从room服务中陆续拆分出了几个新的微服务:

  • xroom/daoanchor:房间主体服务

  • xanchor:主播业务服务

  • xroom-management:房间管控服务

  • xroom-extend:房间扩展信息服务

从组织视角拆分是合理有效的,但是从技术视角去观察,房间服务既需要满足B端开播场景的数据强一致性要求,也需要承担来自C端用户“推荐页”、“上下滑列表页”、“进房”等业务场景的高QPS。

xroom作为底层服务,常态化晚高峰需要承担35W+ QPS,单接口最大QPS 18W,大流量Query通过服务热点主动探测+Local Cache+依赖缓存组件抗压,Redis主集群QPS达到百万级别,尽量去减少DB层面回源的请求量级。

房间读多活架构和多级缓存方案实施后,又需要一些措施能够去主动探测发现偶发的数据不一致问题。

房间服务时常需要应对保障“关键事件开播管控”和“高热赛事直播”两种不同的业务场景,前者更关注房间开播平稳管控及时有效,后者关注高流量用户进房,技术服务上的降级缓存/兜底逻辑/熔断策略也会有差异。


If the same data model is not able to satisfy the read and write patterns of a system effectively, then it makes sense to decouple the two schemas by applying CQRS.

如果同一个数据模型不能有效满足系统的读写模式,那么通过应用CQRS来解耦这两个架构是有意义的。


通过CQRS我们可以切实分离大流量的读场景和注重实时性和一致性的多写场景。尤其对于C端大流量来说,可以无状态扩容服务节点。理论上可以达到无限扩展目标,这对于千万在线的直播是尤其重要的。


The second scenario in which CQRS is helpful is in separating the read load from the write load.

第二种 CQRS 有用的情况是将读取负载与写入负载分开。


CQRS架构模式适用性


主播是房间的所有者,对房间有管理权力,能够改变直播间的状态与属性。

观看用户则是房间内容的消费者,B和C视角下都会有一个叫做“房间”的内容承载体。从面向用户和权责分离角度来说,CQRS是比较好的一种思想来指导房间服务体系和业务域的拆分演进。

拆分目的是减少B端和C端之间领域穿插,双方更加聚焦各自的业务领域,最终闭环从而提升架构稳定性规避系统性风险,并提升各自业务域内的组织效率。


业务拆分共识


围绕房间实体,业务上有生产和消费的逻辑需要盘点,从BFF/基础房间服务/直播biz服务三层进行BC Domain的拆分。

  • B/C两端,都具有完整业务领域,领域内有各自相对独立的业务上下文

  • App客户端/Web前端,属于多个领域共用的“视窗”

  • C端有两路数据流,一路是看端领域闭环的数据流,第二路数据流是B端的数据流(开关播上下文、房间状态变化上下文)



根据GRASP(General Responsibility Assignment Software Pattern)中的信息专家(Information Expert)模式,数据应该放在需要经常使用它的地方,同时某一个功能在哪里实现,取决于数据哪里。换句话讲,数据+功能=领域。


架构演进目标


房间核心系统耦合了消费端和生产端的逻辑,基于CQRS理论需要将服务和数据库完全解耦,承担高流量的xroom/dananchor服务划分为C端业务域服务,新的B端服务闭环接受内容生产的读写请求和后台管控聚合请求(写多读少)。

B端核心房间数据变更通过领域事件消息通知给到C端,C端关注数据的最终一致性,期间会有数据对账脚本主动发现数据不一致并自动修复。

总体方向按照C&B职能原则来拆分,过渡阶段允许历史请求写请求C服务,C proxy to B service。



最终目标是完全解耦,通过领域事件数据流来同步必要信息。



没有银弹


我们享受到了CQRS带来的便利,相反的也要解决引入它带来的“副作用”,这些副作用在直播领域下,表现的最核心无疑是开关播状态的延迟,但是由于用户和主播的天然隔离,反而不需要两边完全实时。

主播开播后,需要推流等一些列的动作,直到用户可以看到主播的直播画面,这个过程中很自然,符合人的直觉,而在架构层面我们通过引入消息中间件来同步数据,本身耗时在毫秒级别,这相比前面的自然过程几乎可以忽略,但是我们的技术架构上服务和数据完全拆开了。

同时因为引入了更多的数据交互环节,请求拓扑变得更加复杂,每个环节的数据正确性排查变得更困难。我们通过平台提供的Tracing+Metrics+Logging来进行问题辅助定位,双写+对账脚本保障过渡阶段数据最终一致性,灰度阶段控制读写流量各自单独放量验证。

为了应对CQRS架构带来的复杂性确实需要额外引入数据服务脚本等方式去做保障,这部分的思路更偏向于架构设计中的“风险驱动”。


执行落地过程


对于当前比较成熟的业务系统去做拆分,是一件比较有挑战的事情。我们先从横纵两个角度看下房间服务所在的层级位置。


 横向技术架构分层


  • 面向用户的终端设备:App粉版、Web端、开播App等

  • CDN -> SLB -> APIGW:内容分发边缘加速,LB层与统一网关

  • BFF:Backend for Frontend,根据终端渠道区分的业务网关入口,eg:app-room / web-room / app-interface

  • Biz Service:业务逻辑服务

  • Domain Service / Fundamental Service:业务域服务/基础服务,eg:Room Domain Service / Account Service


 纵向部署隔离


  • Region:eg sh/bj

  • Zone:eg sh001/sh002/sh003,每个Zone单元内的流量应尽可能闭环(读多活写回源 -> 读写多活、BFF failback cross Zone可选策略)

  • Cluster/Group:group1/group2/染色group,不同group可以设置服务发线上的weight权重

  • AppID:每个应用的服务发现naming id



可以看到房间服务有众多的请求上游,必须在读写切分过程中,保障好数据的一致性(B端业务域内强一致性,C端业务域内最终一致性)和服务的可用性(底层服务抖动会有放大效应)。

当然,上游业务服务fanout过多读流量到下游服务也是需要治理的,这在另一个议题中去开展了。

在具体的实现过程中,我们将整个拆分划分成三大阶段。



  • 数据对齐阶段

本阶段目标是把B端的数据库从C端复制,并且保持数据一致。并且此阶段可以拆分成增量对齐阶段和存量对齐阶段。

增量对齐,将新数据的创建和更新通过双写同步。

存量对齐,通过同步JOB将C端DB的存量数据同步到B端新DB,并且需要一种对账系统去针对全量数据进行周期性的对比,来确保数据一致性。

  • 数据同步阶段

针对数据一致性问题和业务上数据实时性问题,需要对相应的实时场景进行改造。B端构建新的房间领域事件消息,并同步到C端。

此阶段完成C&B分写逻辑,通过在DAO层控制BC表级和字段级的写入控制,将写操作分流到B和C的各自服务内,并且通过消息事件,来同步数据变更。此阶段完成了数据拆分和同步的目标。


If the choice is made to keep the updates asynchronous, the entire system is forced to deal with the fallout of eventual consistency.

如果选择保持更新为异步的,整个系统将不得不处理最终一致性所带来的后果。


  • 最终闭环阶段

此阶段作为收尾,我们将上游的调用梳理出来,并且改造读取各自领域真正的依赖服务,最终达到完全解耦的目标。


核心设计


 数据拆分 Data Division


当前直播的核心实体数据库,BC属于公用的状态,每张表和每个字端按照上述的原则是可以划分出BC属性的。所以我们一步到位,颗粒度到最小单位字段级别,确保完全解耦。

BC拆分后短时间内两套独立数据库的表字段可以保持相同,长期根据业务迭代节奏不同,两边可以变为异构shcema模式。同时要注意的是,业务数据层面切分后,需要联动大数据层面同步hive表的变更,单独重新建模或数据任务换源,这点是比较容易遗漏的。


领域事件驱动 Domain Event-Driven


BC房间业务各自划分业务域边界后,域之间的数据同步应通过领域事件驱动+观察者模式去实现;域内的核心业务逻辑,可以走应用服务编排。

本次实践过程中重新梳理制定了“房间状态变更”事件,basic room info + extra info,满足订阅者服务对房间业务域核心字段的要求。

跨微服务的事件机制要总体考虑事件构建、发布和订阅、事件数据持久化、消息中间件,甚至事件数据持久化时还可能需要考虑引入分布式事务机制等。完整实践下来还是成本还是比较高的,实施者应考虑结合业务场景和对数据的实时性/一致性要求来决定实践到哪一步。

Tip1:订阅者需要实现消费幂等,业务场景如果有诉求需要额外实现数据版本协议(eg:稿件系统BC CQRS拆分,B端稿件数据被重新编辑审核,C端已开放的版本仍然可以浏览,即使用了数据版本协议字段)。

Tip2:如果订阅者有实现接收Message后反向callback query的模式(更适合去保障最终一致性),需要关注query的数据源是来自主库or从库,不然会有因主从同步时延导致的数据不一致case。

Tip3:绝对不要将核心DB的binlog消息暴露为Domain Message,一是暴露了过多细节字段下游并不一定都需要订阅关心,要做很多filter逻辑,二是核心DB的字段变更将需要牵动所有下游,不利于变更。


灰度控制 Gray Scale Control


核心服务变更依赖一个比较完备的灰度发布方案,基于分布式KV组件(服务可以近实时地获取到KV系统中配置的开关变更)我们设计了从BFF网关到服务的开关,来控制字段的外显和关键Topic发送。

灰度策略有:功能总体关闭、白名单模式放量、百分位/千分位放量、功能全量打开,服务发布观察遵从这个流程,从APIGW+服务染色发布引入流量+功能KV开关做到谨慎放量。


可观测性建设 Observability Construction


新的架构落地只是起点,真正的考验刚刚开始。我们必须为架构的稳定性,可用性负责。

保障整个CQRS系统,需要“配套设施”,其中的首要利器就是做可观测性建设(Observability Construction),基于现有的基架能力,我们搭建了直播CQRS监控大盘,从生产方到消费方,全链路监控核心指标。

其中CQRS中的数据同步的相关指标,在数据保证数据最终一致性的背景下,尤其重要。整个实时性由三个部分组成,pub时间,网络传输耗时,sub处理数据,其中在我们的CQRS大盘中,就包含B端业务pub的时间监控,和C端sub业务处理的时间监控,目前网络传输耗时在毫秒级别,并且这块指标也已经在灰度阶段。



系统鲁棒性  System Robustness


CQRS的引入帮我们解耦了截然不同两种场景的系统,但是也确实引入了mq,从全局视角看又增加了一个依赖,所以系统的复杂度是增加的。为了增强系统架构的鲁棒性,我们考虑到引入另外一种备选手段来做数据同步,通过直连服务接口调用的方式,这块我们使用了我站自研的railgun消息处理组件。当两种本身可用性就很高的方法互为补充时,那么出现问题的可能,相当于两个系统同时出问题的概率,这种概率是极低的。

在整个CQRS数据链路上,我们还针对一些写场景做了异步重试来系统自愈,抵抗服务可用性的长尾不可用,另外我们也考虑到异常场景下,虽然降级到http调用同步数据,但是存量消息恢复时,数据不是最新的,所以加入过时消息走回源,保障数据正确性的设计,来尽可能让系统在各个环节的抗风险能力提升。


数据对账脚本 Data Verify Job


有一种比较常见的方式,即流式对账,依靠我们数据流监控组件去实现,在设定一个经验值的时间窗口阈值内,对两边数据源的流式binlog做对比。这种对账方式比较适合终态业务对账,而我们实时直播属于反复跳变场景,目前我们利用最简单有效的方式,连接双方从库,以B端库为准进行数据对账,并且满足30s内数据一致比较,来兼容数据最终一致性,当对账脚本发现不一致后,通过日志+主动告警+机器人等手段,配合自动化修复任务做自愈的设计,从而cover住大多数异常case,做到平常0职守。


 线上事故响应SOP Incident Response SOP


上文的系统鲁棒性设计,最大程度保障服务的健壮稳定,以及上文兜底的数据对账机制,最大程度客观地帮助系统发现异常,而线上永远有我们意想不到的情况,所以我们设计了一套线上事故响应机制,来应对“意外”。

首先我们从CQRS和BC服务的角度,预设配置了不同领域的关键日志或者指标告警,而且划分了不同的紧急程度。二是我们提前管理规划了告警组成员,覆盖两边领域的一线研发,并且配置不同的通知渠道,可以让最合适的同学最快地感知异常。三是我们从不同角度预设了我们可以枚举异常现象,再去枚举不同现象发生的根因,再输出可以解决的方案list,所以基于这套sop,配合我站alchemy平台tracing链路追踪能力可以迅速定位故障点,以最快速度执行预设标准步骤,达到最快恢复可用性的目的。


生产配套 Production Support


一个安全的生产系统是需要一整套的“生产配套”体系,可以快速定位排障。这块我们借鉴了很多类似系统,参考了医院体系的”问诊台“,目前发育出开播互动问诊台生产配套,提升问题排障效率几乎80%。



技术项目管理


最后想聊聊技术项目的价值和实施周期。技术项目有些时候由于不会带来明显的业务增量价值,往往会被质问“为什么要做如此变更,不做这个变更业务难道不能用吗?”诸如此类的灵魂拷问。

每个阶段技术建设需要有一条经过设计的baseline,这条线应该略快于业务发展的基线一步。建设落后,技术跟不上业务,如同沙地之上建高楼,业务连续性会受到技术系统稳定性可用性的lost而直接受损。

建设过快,又有Over Design/Over Engineering的问题,所以略快过一步是合适的,保留了弹性扩展的余地,可以在需要时适配业务快速调整。

架构师和Tech Leader需要协同阶段性review当前技术建设baseline和业务的适配情况,并决定是否投入有效资源进行技术架构迭代。

技术项目从立项之日起,就需要更严格于业务项目的管理机制。业务项目的业务目标(试错/AB实验/明确性收益延展)往往不由工程师来制定,而技术项目的目标感也是需要从开始就建立起来的,这有助于关键行为路径拆解,并在项目收尾阶段进行目标&结果比对。

技术项目要有阶段性Milestone管理,技术立项 -> (原型方案讨论) -> 技术方案确定 -> 技术实施(大项目应分阶段实施,过程指标也被Track) -> 测试/验证方案(测试用例收集&review) -> 发布方案 -> 线上验收方案 -> (线上问题处理预案) -> 项目结果复盘。

立项、技术方案确认等阶段需要有正式官宣(仪式感/目标感/参与感),避免流于私下的技术Topic探讨,导致无法被正式地投入资源到实施阶段。


End


最后,该项目实践上仍然有诸多细节无法在文章中一一展示,文本在撰写过程中也难免犯错,希望大家可以指正,欢迎大家可以一起来进行技术交流。


参考


  • https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs

  • https://kislayverma.com/software-architecture/architecture-pattern-cqrs/?fileGuid=0IWvR8dLbi0m7fi4

  • https://en.wikipedia.org/wiki/GRASP_(object-oriented_design)


以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!


往期精彩指路

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存