字节跳动极高可用 KV 存储系统详解

时间:2022-07-21 00:59:10


导读 Abase 是字节跳动在线推荐的底层存储,也是字节跳动最大规模的在线 KV 存储系统,承担着 90% 以上的 KV 存储需求、支持多个字节跳动产品和业务。本次分享详细介绍了 Abase 的技术实现和高可用等关键技术。

今天的介绍围绕下面 4 点展开:

1. Abase 简介

2. 高可用挑战

3. 解决方案

4. 关键技术

分享嘉宾|刘健 字节跳动 研发工程师

编辑整理|张德通

出品社区|DataFun


01

Abase 简介

Abase 是什么?Abase 最初用作字节跳动在线推荐的底层存储。

字节跳动极高可用 KV 存储系统详解

刷抖音的时候,服务端需要记录已经给用户推荐过的视频列表,再推荐给用户更符合需求的其他视频,用户的浏览历史列表就存储在 Abase 内。

字节跳动极高可用 KV 存储系统详解

在 2016 年时 Abase 正式立项。Abase 的 A 取自字节跳动的办公地点中航广场英文名的第一个字母,无特殊含义。2017 年 Abase 作为字节跳动推荐的核心存储大规模上线。

2018-2019 年,随着字节跳动业务快速扩张,Abase 也从支持推荐存储变成了支持全公司基本所有业务线的在线的 KV 存储。这期间 Abase 从单纯地支持一个 KV 接口扩展到支持各种 Redis 复杂命令和数据结构,也支持了多机房容灾的功能。

2020 年 Abase 在字节跳动已经有相当大的规模了,与立项时 Abase 定位是单集群高性能 KV 接口的设计初衷大不相同,此时已经不再完全符合字节跳动大规模的业务需求。于是我们启动了 Abase 的第二代项目,第二代 Abase 核心是做高可用。2021 年 Abase 2.0 上线,2022 年支持多租户、以 Serverless 方式提供服务,大幅降低线上存储成本。

字节跳动极高可用 KV 存储系统详解

目前 Abase 是字节跳动最大规模的在线 KV 存储系统,承担了字节 90% 以上的 KV 存储需求。Abase 支持的字节产品线包括推荐、搜索、广告、电商、抖音、飞书、懂车帝等等。它的核心特点是大容量、大吞吐、低延时、高可用、易扩展,Abase 的集群规模比通常的 KV 存储更大,容量和吞吐方面做的优化更多。

使用场景方面,Abase 可以用来做大容量的缓存,以及持久化 KV 的场景。如 Reids 集群的内存规模受限、需要用磁盘缓存数据的场景,Abase 兼容 Redis 协议,如果用内存版 Redis 成本太高,可以用 Abase 替代。

Abase 兼容多种数据生态,支持 Hive 通过 bulk load 把数据导入到 Abase。

Abase 为了极致地容灾,支持跨地区数据同步,可以做异地多活。Abase 在一个集群内支持异地多活,降低跨集群之间的链路传输代价。

目前 Abase 在字节跳动已经部署超过 5 万台服务器,QPS 在百亿级别。Abase 支持的业务数超过 5000,基本覆盖了字节的全部产品线,有超过百 P 级别的数据量。

字节跳动极高可用 KV 存储系统详解

Abase 第二代架构针对第一代架构的痛点进行了优化。其特点如下:

(1)Abase 2.0 是一套多写架构,可以做到极致高可用。多写的架构没有了主从架构的切换主节点的时间,也没有秒级别的主从切换不可用问题;多写架构也从架构层面屏蔽了慢节点,规避了慢节点问题。

(2)Abase 2.0 解决多写架构的写冲突方面,对于 KV 结构支持 last write win 这种通过时间戳的方式解决冲突;对于一些复杂数据结构,如 string 的 incr、append 或者哈希结构,支持 CRDT 的解决方案。此外 Abase 2.0 还会做快速的数据一致。

(3)Abase 2.0 没有用纯异步的编程框架,我们用协程的方式让所有请求都在单线程内完成,让请求尽量 RunToComplete,没有线程切换的开销和代价。

(4)Abase 2.0 原生支持多租户。虽然 SSD 的随机 IO 性能很好,但如果 IO 模式过于离散会导致性能变差,因此最好保证有单一的写入流。多租户会把不同用户的写入做聚合,而且对用户使用的资源用资源池进行限制,防止部分用户使用 IO 过高占用资源过多影响整个集群用户使用的问题。Abase 多租户的功能使用了资源池,把负载均衡做的很好,降低了硬件成本。

Abase 2.0 原生支持异地多活架构,常见的异地多活指的是在不同地域搭建多套集群,如在华东、华北分别搭建两个不同 KV 集群,但两个集群之间需要通过中间件同步数据,难以保证数据最终一致性。后面会介绍到这方面的解决方案。

02

字节跳动 Abase 面临的高可用挑战

1. 高可用的分布式存储

一般高可用指的是同一分片的数据有多个副本。

以写举例,一般是主从架构的模式,有一个主节点负责写入、两个从节点负责跟进写入的数据以及作为写节点的热备。如果写入的节点宕机或挂掉,可通过检测或心跳探测,快速地把主节点切换为其他节点。

一般做到这个就可以自称为是“一个高可用的系统”了。但这样的系统会有一些问题。

那么 Abase 自称为“极高可用”是指什么呢?

字节跳动极高可用 KV 存储系统详解

主从通过心跳或者其他探测模式切换,必然存在主从切换的时间,这段时间内是不可写的。所有工程界实践的主从切换时间一般都在秒级别。

Abase 所服务的字节跳动用户,对可用性要求非常高。平均延迟需要在毫秒左右或者P99 在 10 毫秒以内。秒级别的服务不可用也是用户希望能够尽量避免的。

选主我们还可以逐渐优化,可以从 30 秒优化到 1 秒,甚至 500 毫秒。更难解决的问题是,在主从架构下写是一个单点,如果写节点完全不可用,可以立刻进行切主。但如果写节点没有彻底挂,由于磁盘温度过高导致 IO 性能变差,CPU 负载过高导致线程调度变差,网络延迟上升或者是网卡插松了等等,各种各样的原因导致节点的延迟上升,比其他的节点更慢的情况出现。此时虽然可以通过一些经验设置阈值,判断节点的指标超过什么阈值就踢掉节点,但这种通过经验设置阈值的手段存在很多问题。

如果阈值设低了,可能导致大量线上节点被误踢,那影响范围更大、引起更大问题;而如果阈值设得太高,对慢节点的处理也没有什么帮助。我们之前做了很多监控,分析了不同节点的方差来发现慢节点。

但对于一个写入节点来说,所有写都经过这个单点,很难判断问题是慢节点导致的,还是由于负载高导致的。

相对来说,读请求的慢节点更容易处理,读请求可以选择 backup request 策略。当查一个副本的平均延迟在 P99 或是 80 分位没有返回数据时,可以通过向其他部分发请求完成读请求。但如果写请求处理慢了,把请求发给其他节点也无济于事。切主对于所有系统都是有一定代价的,频繁切主可能引起更大的问题。

这就是传统的这种高可用架构不能满足字节跳动业务场景下对更极致可用性的需求。我们希望从架构上彻底解决慢节点,线上某些延迟升高、抖动,运维的痛点问题。

2. 极端故障情况

上一代系统中,解决机房彻底断掉、网络孤岛的策略之一是把所有数据进行 3AZ 部署。具体方法是在同一个地域有相互临近的三个机房,副本分布在三个机房内。当极端故障情况发生时做自动切主,一般存储系统中会有 safe mode 模式,如果大多数节点同时故障,判定检测故障的逻辑有问题,系统会进行熔断。

在这种极端故障情况下,大多数情况会触发集群的 HA 或 fail over 熔断,这时需要人工决策是否进入容灾模式,或是否把所有主节点从一个地域切换到另一个地域,这个切换过程比较平滑、但需要人工响应,耗时在 5 分钟到半小时,但这段时间内服务会受影响。

另一种处理极端故障的方法是搭建多活模式集群通过中间件同步数据。但这种方案存在的问题是集群间数据难以保障最终一致性,一旦数据有冲突就会对用户造成困扰。

03

Abase 2.0 应对业务高可用挑战的解决方案

1. Abase 2.0 架构

我们的思路源于 Dynamo 多年前的一篇论文中做到了极致高可用的 Column 读写方案。但这一方案又不完全满足我们的需求,我们提出了自己的方案。

Abase 2.0 架构如下图,和多数 KV 存储架构类似,有专门节点负责数据 location 信息,Data Node(数据节点)承载实际数据读写,有一个 Client 直连 Node、或通过 Proxy 处理多语言的请求转发。核心是中心节点、数据节点、Proxy 三大模块。

字节跳动极高可用 KV 存储系统详解

数据部署时,Abase 是一个多地域系统,一个 Abase 集群跨多地域部署,一个 Abase 可能包含一个华东 Region 和一个华北 Region。华北 Region 又被分为 3 个 AZ/IDC,其中的 Region 可能是由字节在A、B、C或者其他华北附近 300 公里内的机房 IDC 内的物理机共同组成。

字节跳动极高可用 KV 存储系统详解

POD 是介于 IDC 和实体 RAC 机器之间的一个网络概念,是 Abase 2.0 的一层抽象,不是 K8S 中的 pod 概念。

如果一个机房某房间的空调故障,这个房间的所有机器都可能因为过热宕机。Abase 2.0 会保证多副本不部署在同一个 POD 中。一般一个房间的所有机器都不会跨 POD,机器所在房间空调故障或机房过热、甚至房间失火都不会影响数据所有的副本。

POD 下绑定具体的路由器、交换机和具体的一台服务器,机器上有不同 DataNode。DataNode 上挂着磁盘。

对应的机器上 Abase 2.0 为每个 CPU 核(Core)都起了工作线程,所有的请求从处理到执行、最终返回,都是在这个 CPU Core 的工作线程下运行的。CPU Core 下面有一个副本。

逻辑层面上,Abase 集群有很多用户的库,我们称为 Namespace。用户在一个 Namespace 中会分很多逻辑表。数据库把逻辑表分给很多 Partition(分片)。为了做高可用、让数据高可靠,一个分片要有多个副本,每个副本称为一个 Replica。

2. Abase 2.0 的高可用方案

Abase 2.0 借鉴了 Dynamo 无主架构多点写入的一套方案,为什么这套方案对可能性提升很大呢?

字节跳动极高可用 KV 存储系统详解

在有主时,任何一个单点故障都需要一秒或者几秒的心跳探测时间,这段时间内集群不可写。无主的架构下就没有主节点不可用的影响。

如果写节点是个慢节点,上层可以做一些 backup retry 或重试时写到其他节点,该过程对用户基本无感,以更低代价把慢节点规避掉,缩短 P99 延迟。

Dynamo 方案中存在的问题是数据 Diff 代价大、读 QPS 放大,且数据修复周期时间较长。字节跳动百亿级别的数据量不能忍受读 QPS 放大和长时间的数据修复。

字节跳动极高可用 KV 存储系统详解

做分布式系统常说的 CAP 定理认为,在分布式系统中,一致性、可用性和分区容错三个特性最多有两个。虽然 CAP 定理对分布式系统开发有很好的指导性,但我们不能被 CAP 定理限制思路。

一般分布式系统的分区容错是必须解决和不能避免的问题,绝大多数分布式系统会在一致性和可用性之间做选择。但我们认为,系统在一致性和可用性之间做取舍后,虽然能做到强一致,但可用性会打折扣。

一致性协议,如果想做到强一致,可以用传统的两阶段提交 2PC 协议实现。但 2PC 协议在任何一个节点故障的情况下都无法成功发送,这时单节点故障就会导致整个系统不可用。

如果用 Raft 这类共识协议,我们可以做到对用户表现出读写的强一致性,单个节点网络隔离时整个系统依然可用。我们认为 Raft 这类协议比 2PC 协议可用性高。对于没有更新的数据可以用 quorum 协议,也可以做到强一致。我们认为 quorum 协议由于没有选主,可用性更高。

字节跳动极高可用 KV 存储系统详解

如果做的是任何时刻都可用的系统, 就无法做到任何时刻都是一致的;但我们可以做一个弱一致的、最终一致的系统。也可以做不完全强一致、不完全保证可用性的系统,在这之间进行取舍,只要用户可以接受即可。因此设计系统时不一定在 CA 之间做非此即彼的选择。

字节跳动极高可用 KV 存储系统详解

Abase 方案是无主架构,参考了多主架构的优点。

在一些缓存场景下,可以接受个别数据丢失和异步复制,但要求极致性能,这时可以设置 W=1(写),这时 Abase 提供的方案是不完全保证写请求成功,发出写请求后立刻返回。

R(读)也可以设置为 1,即用户没能拿到最新数据,但我们可以在 6-7 毫秒内让数据达到最终一致。这里的实现借鉴了多主架构的快速同步数据的方式。

什么是无主架构、什么是多主架构?

Dynamo 的客户端可以直接写多个副本,取最快的几个副本的成功即可返回客户端成功,这就是一个无主架构。但无主架构中的核心特征是 coordinator 层和 write pointer 层接收用户写入,再把数据分发给多副本。

多主架构中一个副本中有一个或多个主,但每个副本都可以是主,每个副本都能够接受写入再转发给其他副本。

多主架构和无主架构很相似,但无主架构可以允许乱序提交。最新写入的数据写多数或某个节点成功,不依赖于任何之前写入的数据,只要当前可以写成功、写入流程就算成功。

但有主架构的数据同步过程有一个数据同步序列。单主架构下用 Raft 组成的多副本的数据是严格按照日志的 sequence id 递增同步的。

无主架构下由于需要比对所有数据,数据达到一致的代价更大;但无主架构的优势是消除了慢节点和不依赖之前的数据同步。有主架构的优势是数据同步简洁,只要关注 session id 不断拉数据即可,不需要像无主架构一样用 Merkle tree 做全量数据 Diff。但缺点是如果前面有任何数据没有同步成功,后面的数据同步会被卡住,可用性有所下降。

Abase 2.0 是一套参考了多主架构设计优势的无主架构系统。首先 Abase 把写入点收敛到了每个副本上,write point 的记录和副本节点同级部署。另外我们也尽量让数据保持连续,如写入 A 副本的数据递增地同步到其他副本、减少了数据 Diff 的代价。

但在数据恢复时是否需要等所有数据同步完成后才能接受写请求呢?针对这个问题,我们对部分场景做了优化,让 Abase 允许乱序提交,但只有在主从落后太多时才允许乱序提交。这样我们就保证了整体一致性的算法效率较高的同时保证了可用性。

Abase 2.0 解决了接受多写面临的写冲突问题。两副本都可以写,在发生网络隔离后,如下图中的副本 1、2 网络隔离时发生了两个更新、网络恢复后要以哪个为准呢?

字节跳动极高可用 KV 存储系统详解

我们采用 Last Write Win 的方式处理这个问题。对所有数据写入都分配时间戳记录 write point,以最后写入为准。但分配物理时间戳是不准的,不同机器物理时间戳之间没有保障,且物理时间戳也可能重复。

Abase 2.0 使用了混合逻辑时钟加物理位置,作为全球和全局唯一的时间戳。混合逻辑时钟指的是如果两节点之间有交互,副本 1 的物理时钟比副本 2 的物理时钟快。

不使用混合逻辑时钟可能导致 t1 比 t2 时间更晚,数据 Merge 后以 t1 为准造成混乱。使用混合逻辑时钟后,只要和 Client 有交互就可以让后写入的副本一定在时间戳上大于前面写入的副本。

如果发生网络隔离,位置信息可以做到副本时间戳全局、全球唯一。

字节跳动极高可用 KV 存储系统详解

使用无主架构的同时,不同用户会有不同需求和场景。例如飞书的业务特点是不需要极致的吞吐量,但要求数据一致性和高可用;对于一些离线业务不需要高性能和可用性,但要保证数据一致性。

为了支持多样的用户需求,Abase 2.0 也开放了用户可配置的能力,有主无主模式和 quorum 数都开放给用户配置。

Abase 2.0 既支持多主模式、也支持单主模式。多主是单主的集,首先集群会进行选主,用户如果要求时效性则读写都可以访问主节点,否则读写可以访问任何副本。

Abase 2.0 还可以让用户配置 quorum 数。用户如果想要更好的可靠性,配置 quorum 数量为 2 时意味着数据至少同步到 2 个节点、落盘后才会返回成功;缓存场景下追求极致性能,可接受以不同步方式完成数据同步,则可以设置 quorum 数为 1,即写一个副本落盘成功后就返回用户成功,再异步地执行同步。

04

Abase 关键技术

1. 多写下的一致性效率问题

下图中是 Abase 的用户数据流转流程。首先用户的写请求会被发到 Proxy,副本 1、2、3 都有 coordinator 可以接受写请求。Proxy 会随机找到一个 coordinator,coordinator 为这个写请求打上全局唯一的混合逻辑时钟时间戳,再把请求并发地发给副本 1、2、3。

字节跳动极高可用 KV 存储系统详解

如果用户配置了 quorum 为 2,在任意两个节点写 WAL 成功就会返回用户成功、同时把数据异步地同步到其他副本上。

下图中,副本 A 写入数据,正常情况下它的 Log 是有一定序号的,可以认为像 Raft 的主一样有 A1、A2、A3、A4。但副本 B 本来接收的顺序是 B1、B2、B3、B4,副本 A 中这个递增的 Log 流和副本 B 的 Log 流会在网络正常连通的情况下互相地转发同步数据。

字节跳动极高可用 KV 存储系统详解

每个数据都有自身混合逻辑时钟,和自然时钟有一定的关系,我们会定期进行数据的比对,如果所有节点都收到了一定时间内的所有数据,节点就会把数据进行冲突的解决、打平、落盘。

2. 多写下的性能问题

Abase 使用 last write win 解决写冲突,因此需要保留数据写入 Key 时的时间戳。如两个副本,副本 A 和副本 B 中都写入了某个 Key,在比较时间戳写入时间后返回给用户更新的数据。

我们第一期实现的方案是把时间戳直接拼在 Key 后作为编码,数据存储到 RocksDB 中。这个实现带来的问题是用户需要查某个 Key 时,RocksDB 中只能通过 Scan 操作查询数据,而 Scan 操作比点查开销大、性能差。

字节跳动极高可用 KV 存储系统详解

我们的优化方案是定期地处理数据冲突和打平,在正常网络状况下秒级别即可同步所有数据。某个时间戳之前的数据已经完全一致,即可把多版本进行合并。

Abase 把引擎分为两层,把多版本数据合并后唯一的单版本数据存储进 KV 引擎。目前 KV 引擎支持 RocksDB 和字节的 RocksDB 优化版和哈希引擎。

未打平的数据存储在 Log 内,而 Log 不支持查询,Abase 就在内存中建了索引,在内存中指向 Log 支持查询。这样一来,在 LSM 引擎内的查询是点查,在内存中的数据有内存索引,查询效率非常高。

05

问答环节

Q1:整体 Abase 的 QPS、响应延迟存储空间指标如何?如何解决瓶颈?

A1:Abase 的 QPS 是百亿级别,数据量在百 P 级别,延迟 P99 在 50ms 内。SLA 是 99.95% 左右。对于高优集群由于独立部署,P99 在 10ms 内。

性能瓶颈需要具体业务具体分析。一些用户的 Value 很大,可能每条数据 1M,读写吞吐非常高,对于此类场景吞吐就是瓶颈。且对于大 Value,LSM tree 有很大的读写放大问题,一般用读写分离的方式缓解读写放大问题。但仍然有可能存在 2-3 倍写放大。

对于带固定 TTL 数据,对于大 Value 还带有 TTL 的数据我们不写入 LSM 引擎、只写入 Log 后等待失效即可。在 Abase 上线了两层引擎后,为大 Value 场景提供了很好的支撑。

另一类用户场景是 Value 实际不大,这类场景瓶颈在 CPU 上。

Q2:考虑过用傲腾作持久化内存作为缓冲来解决小 Value 写入的性能问题么?

A2:我们还没有用到 Pmem,它的优势首先是使用了内存接口响应快。

Pmem 在吞吐方面虽然优势不是那么的大,它可能能到,比如 6G 带宽 Nvme 盘也可能勉强也能到,但它的接口是基于内存的,每次读写 catch line,这个开销与块设备每次读写一个扇区相比,对小 Value 的 IOPS 提升很大。我们有计划开发 Pmem 的原生引擎支持。