Redis 和 MySQL 扛不住,B 站分布式存储系统如何演进?
前言
业务高速发展,B 站的存储系统如何演进以支撑指数增长的流量洪峰?随着流量进一步暴增,如何设计一套稳定可靠易拓展的系统,来满足未来进一步增长的业务诉求?同时,面对更高的可用性诉求,KV 是如何通过异地多活为应用提供更高的可用性保障?文章的最后,会介绍一些典型业务在 KV 存储的应用实践。
全文将围绕下面 4 点展开:
一、存储演进
二、设计实现
三、场景 &问题
四、总结思考
一、存储演进
首先介绍一下 B 站早期的存储演进。
针对不同的场景,早期的 KV 存储包括 Redix/Memcache,Redis+MySQL,HBASE。
但是随着 B 站数据量的高速增长,这种存储选型会面临一些问题:
首先,MySQL 是单机存储,一些场景数据量已经超过 10 T,单机无法放下。当时也考虑了使用 TiDB,TiDB 是一种关系型数据库,对于播放历史这种没有强关系的数据并不适合。
其次,是 Redis Cluster 的规模瓶颈,因为 redis 采用的是 Gossip 协议来通信传递信息,集群规模越大,节点间的通信开销越大,并且节点之间状态不一致的存留时间也会越长,很难再进行横向扩展。
另外,HBase 存在严重长尾和缓存内存成本高的问题。
基于这些问题,我们对 KV 存储提出了如下要求:
易拓展:100x 横向扩容;
高性能:低延时,高 QPS;
高可用:长尾稳定,故障自愈;
低成本:对比缓存;
高可靠:数据不丢。
二、设计实现
接下来介绍我们是如何基于上述要求进行具体实现的。
1、总体架构
总体架构共分为三个部分 Client,Node,Metaserver。Client 是用户接入端,确定了用户的接入方式,用户可以采用 SDK 的方式进行接入。Metaserver 主要是存储表的元数据信息,表分为了哪些分片,这些分片位于哪些 node 之上。用户在读写操作的时候,只需要 put、get 方法,无需关注分布式实现的技术细节。Node 的核心点就是 Replica,每一张表会有多个分片,而每个分片会有多个 Replica 副本,通过 Raft 实现副本之间的同步复制,保证高可用。
2、集群拓扑
Pool:资源池。根据不同的业务划分,分为在线资源池和离线资源池。
Zone:可用区。主要用于故障隔离,保证每个切片的副本分布在不同的 zone。
Node:存储节点,可包含多个磁盘,存储着 Replica。
Shard:一张表数据量过大的时候可以拆分为多个 Shard。拆分策略有 Range,Hash。
3、Metaserver
资源管理:主要记录集群的资源信息,包括有哪些资源池,可用区,多少个节点。当创建表的时候,每个分片都会记录这样的映射关系。
元数据分布:记录分片位于哪台节点之上。
健康检查:注册所有的 node 信息,检查当前 node 是否正常,是否有磁盘损坏。基于这些信息可以做到故障自愈。
负载检测:记录磁盘使用率,CPU 使用率,内存使用率。
负载均衡:设置阈值,当达到阈值时会进行数据的重新分配。
分裂管理:数据量增大时,进行横向扩展。
Raft 选主:当有一个 Metaserver 挂掉的时候,可进行故障自愈。
Rocksdb:元数据信息持久化存储。
4、Node
作为存储模块,主要包含后台线程,RPC 接入,抽象引擎层三个部分。
1)后台线程
Binlog 管理,当用户进行写操作的时候,会记录一条 binlog 日志,当发生故障的时候可以对数据进行恢复。因为本地的存储空间有限,所以 Binlog 管理会将一些冷数据存放在 S3,热门的数据存放在本地。数据回收功能主要是用来防止误删数据。当用户进行删除操作,并不会真正的把数据删除,通常是设置一个时间,比如一天,一天之后数据才会被回收。如果是误删数据,就可以使用数据回收模块对数据进行恢复。健康检查会检查节点的健康状态,比如磁盘信息,内存是否异常,再上报给 Metaserver。Compaction 模块主要是用来数据回收管理。存储引擎 Rocksdb,以 LSM 实现,其特点在于写入时是 append only 的形式。
2)RPC 接入
当集群达到一定规模后,如果没有自动化运维,那么人工运维的成本是很高的。所以在 RPC 模块加入了指标监控,包括 QPS、吞吐量、延时时间等,当出现问题时,会很方便排查。不同的业务的吞吐量是不同的,如何做到多用户隔离?通过 Quota 管理,在业务接入的时候会申请配额,比如一张表申请了 10K 的 QPS,当超过这个值得时候,会对用户进行限流。不同的业务等级,会进行不同的 Quota 管理。
3)抽象引擎层
主要是为了应对不同的业务场景。比如大 value 引擎,因为 LSM 存在写放大的问题,如果数据的 value 特别大,频繁的写入会导致数据的有效写入非常低。这些不同的引擎对于上层来说是透明的,在运行时通过选择不同的参数就可以了。
5、分裂-元数据更新
在 KV 存储的时候,刚开始会根据业务规模划分不同的分片,默认情况下单个分片是 24G 的大小。随着业务数据量的增长,单个分片的数据放不下,就会对数据进行分裂。分裂的方式有两种,rang 和 hash。这里我们以 hash 为例展开介绍:
假设一张表最开始设计了 3 个分片,当数据 4 到来,根据 hash 取余,应该保存在分片 1 中。随着数据的增长,3 个分片放不下,则需要进行分裂,3 个分片会分裂成 6 个分片。这个时候数据 4 来访问,根据 Hash 会分配到分片 4,如果分片 4 正处于分裂状态,Metaserver 会对访问进行重定向,还是访问到原来的分片 1。当分片完成,状态变为 normal,就可以正常接收访问,这一过程,用户是无感知的。
6、分裂-数据均衡回收
首先需要先将数据分裂,可以理解为本地做一个 checkpoint,Rocksdb 的 checkpoint 相当于是做了一个硬链接,通常 1ms 就可以完成数据的分裂。分裂完成后,Metaserver 会同步更新元数据信息,比如 0-100 的数据,分裂之后,分片 1 的 50-100 的数据其实是不需要的,就可以通过 Compaction Filter 对数据进行回收。最后将分裂后的数据分配到不同的节点上。因为整个过程都是对一批数据进行操作,而不是像 redis 那样主从复制的时候一条一条复制,得益于这样的实现,整个分裂过程都在毫秒级别。
7、多活容灾
前面提到的分裂和 Metaserver 来保证高可用,对某些场景仍不能满足需求。比如整个机房的集群挂掉,这在业界多是采用多活来解决。我们 KV 存储的多活也是基于 Binlog 来实现,比如在云立方的机房写入一条数据,会通过 Binlog 同步到嘉定的机房。假如位于嘉定的机房的存储部分挂了以后,proxy 模块会自动将流量切到云立方的机房进行读写操作。最极端的情况,整个机房挂掉了,就会将所有的用户访问集中到里一个机房,保证可用性。
三、场景 &问题
接下来介绍 KV 在 B 站应用的典型场景以及遇到的问题。
最典型的场景就是用户画像,比如推荐,就是通过用户画像来完成的。其他还有动态、追番、对象存储、弹幕等都是通过 KV 来存储。
1、定制优化
基于抽象实现,可以很方便地支持不同的业务场景,并对一些特定的业务场景进行优化。
Bulkload 全量导入的场景主要是用于动态推荐以及用户画像。用户画像主要是 T+1 的数据,在没有使用 Bulkload 以前,主要是通过 Hive 来逐条写入,数据链路很长,每天全量导入 10 亿条数据大概需要 6、7 个小时。使用 Bulkload 之后,只需要在 hive 离线平台把数据构建成一个 rocksdb 引擎,hive 离线平台再把数据上传到对象存储。上传完成之后通知 KV 来进行拉取,拉取完成后就可以进行本地的 Bulkload,时间可以缩短到 10 分钟以内。
另一个场景就是定长 list。大家可能发现你的播放历史只有 3000 条,动态也只有 3000 条。因为历史记录是非常大的,不能无限存储。最早是通过一个脚本,对历史数据进行删除,为了解决这个问题,我们开发了一个定制化引擎,保存一个定长的 list,用户只需要往里面写入,当超过定长的长度时,引擎会自动删除。
2、面临问题——存储引擎
前面提到的 compaction,在实际使用的过程中,也碰到了一些问题,主要是存储引擎和 raft 方面的问题。存储引擎方面主要是 Rocksdb 的问题。第一个就是数据淘汰,在数据写入的时候,会通过不同的 Compaction 往下推。我们的播放历史,会设置一个过期时间。超过了过期时间之后,假设数据现在位于 L3 层,在 L3 层没满的时候是不会触发 Compaction 的,数据也不会被删除。为了解决这个问题,我们就设置了一个定期的 Compaction,在 Compaction 的时候回去检查这个 Key 是否过期,过期的话就会把这条数据删除。
另一个问题就是 DEL 导致 SCAN 慢查询的问题。因为 LSM 进行 delete 的时候要一条一条地扫,有很多 key。比如 20-40 之间的 key 被删掉了,但是 LSM 删除数据的时候不会真正地进行物理删除,而是做一个 delete 的标识。删除之后做 SCAN,会读到很多的脏数据,要把这些脏数据过滤掉,当 delete 非常多的时候,会导致 SCAN 非常慢。为了解决这个问题,主要用了两个方案。第一个就是设置删除阈值,超过阈值的时候,会强制触发 Compaction,把这些 delete 标识的数据删除掉。但是这样也会产生写放大的问题,比如有 L1 层的数据进行了删除,删除的时候会触发一个 Compaction,L1 的文件会带上一整层的 L2 文件进行 Compaction,这样会带来非常大的写放大的问题。为了解决写放大,我们加入了一个延时删除,在 SCAN 的时候,会统计一个指标,记录当前删除的数据占所有数据的比例,根据这个反馈值去触发 Compaction。
第三个是大 Value 写入放大的问题,目前业内的解决办法都是通过 KV 存储分离来实现的。我们也是这样解决的。
3、面临问题——Raft
Raft 层面的问题有两个:
首先,我们的 Raft 是三副本,在一个副本挂掉的情况下,另外两个副本可以提供服务。但是在极端情况下,超过半数的副本挂掉,虽然概率很低,但是我们还是做了一些操作,在故障发生的时候,缩短系统恢复的时间。我们采用的方法就是降副本,比如三个副本挂了两个,会通过后台的一个脚本将集群自动降为单副本模式,这样依然可以正常提供服务。同时会在后台启动一个进程对副本进行恢复,恢复完成后重新设置为多副本模式,大大缩短了故障恢复时间。
另一个是日志刷盘问题。比如点赞、动态的场景,value 其实非常小,但是吞吐量非常高,这种场景会带来很严重的写放大问题。我们用磁盘,默认都是 4k 写盘,如果每次的 value 都是几十个字节,这样会造成很大的磁盘浪费。基于这样的问题,我们会做一个聚合刷盘,首先会设置一个阈值,当写入多少条,或者写入量超过多少 k,进行批量刷盘,这个批量刷盘可以使吞吐量提升 2~3 倍。
四、总结思考
1、应用
应用方面,我们会做 KV 与缓存的融合。因为业务开发不太了解 KV 与缓存资源的情况,融合之后就不需要再去考虑是使用 KV 还是缓存。
另一个应用方面的改进是支持 Sentinel 模式,进一步降低副本成本。
2、运维
运维方面,一个问题就是慢节点检测,我们可以检测到故障节点,但是慢节点怎么检测呢,目前在业界也是一个难题,也是我们今后要努力的方向。
另一个问题就是自动剔盘均衡,磁盘发生故障后,目前的方法是第二天看一些报警事项,再人工操作一下。我们希望做成一个自动化机制。
3、系统
系统层面就是 SPDK、DPDK 方面的性能优化,通过这些优化,进一步提升 KV 进程的吞吐。
评论