wkfvawl

第一章 前言

前面介绍的GFS 和 MapReduce 通过非常简单的设计,帮助我们解决了海量数据的存储、顺序写入,以及分布式批量处理的问题。

不过我们也要看到,GFS 和 MapReduce 的局限性也很大。

在 GFS 里,数据写入只对顺序写入有比较弱的一致性保障。而对于数据读取,虽然 GFS 支持随机读取,但是考虑到当时 Google 用的是孱弱的 5400 转的机械硬盘,实际上是支撑不了真正的高并发地读取的。

而 MapReduce,它也是一个批量处理数据的框架,吞吐量(throughput)确实很大,但是延时(latency)和额外开销(overhead)也不小。

所以,即使有了 GFS 和 MapReduce,我们仍然有一个非常重要的需求没有在大型的分布式系统上得到满足,那就是可以高并发保障一致性,并且支持随机读写数据的系统。而这个系统,就是接下来我们会深入讲解的 Bigtable。

本文主要通过下面三个问题,来深入理解Bigtable。

  • Bigtable 想要解决什么问题?我们不能用 MySQL 这样的关系型数据库,搭建一个集群来解决吗?
  • Bigtable 的架构是怎么样的?它是怎么来解决可用性、一致性以及容易运维这三个目标的?
  • Bigtable 的底层数据结构是怎么样的?它是通过什么样的方式在机械硬盘上做到高并发地随机读写呢?

第二章 Bigtable的设计目标

Bigtable想要解决的问题是系统的可伸缩性可运营性

Bigtable最基础的目标自然是应对业务需求的,能够支撑百万级别随机读写 IOPS(input/Output Operations Per Second,即每秒进行读写(I/O)操作的次数),并且伸缩到上千台服务器的一个数据库。但是光能撑起 IOPS 还不够。在这个数据量下,整个系统的“可伸缩性”和“可运维性”就变得非常重要。

这里的伸缩性,包括两点:

  • 第一个,是可以随时加减服务器,并且对添加减少服务器数量的限制要小,能够做到忙的时候加几台服务器,过几个小时峰值过去了,就可以把服务器降下来。
  • 第二个,是数据的分片会自动根据负载调整。某一个分片写入的数据多了,能够自动拆成多个分片来平衡负载。而如果负载大了,添加了服务器之后,也能很快平衡数据,让各个节点均匀承担压力。

而可运维性,则除了上面的两点之外,小部分节点的故障,不应该影响整个集群的运行,我们的运维人员也不用急匆匆地立刻去恢复。集群自身也要有很强的容错能力,能够把对应的请求和服务,调度到其他节点去。

至于为什么不能使用MySQL来搭建集群?

MySQL如果要扩展成分布式的,需要分库分表,这需要一开始就对如何切分数据做好精心设计,一旦稍有不慎,设计上出现了数据倾斜,就很容易造成服务器忙得忙死,闲得闲死的现象。并且即使你已经考虑得非常仔细了,随着业务本身的变化,比如要搞个双十一,也会把你一朝打回原形。

以 MySQL 用户表分表作为例子,分到 4 台机器上,用了用户出生的月份“模”上个 4。这个时候,很幸运,一年是有 12 个月,正好可以均匀分布到 4 台不同的机器上。但是当我们进行扩容,变成 8 台机器之后,问题就出现了。我们会发现,服务器 A 分到了 1 月和 9 月生日的用户,而服务器 B 只分到了 6 月生日的用户。在扩容之后,服务器 A 无论是数据量,还是日常读写的负载,都比服务器 B 要高上一倍。而我们只能按照服务器 A 的负载要求来采购硬件,这也就意味着,服务器 B 的硬件性能很多都被浪费了。

而且,不但用月份不行,用年份和日也不行。比如公司是 2018 年成立,2019 年和 2020 年快速成长,每年订单数涨 10 倍,如果你用年份来进行订单的分片,那么服务器之间的负载就要差上十倍。而用日的话,双十一这样的大促也会让你猝不及防。

当然,除了这些目标之外,Bigtable 也放弃了很多目标,其中有两个非常重要:

  • 第一个是放弃了关系模型,也不支持 SQL 语言;
  • 第二个,则是放弃了跨行事务,Bigtable 只支持单行的事务模型。

第三章 Bigtable的整体架构

 核心问题:

  • Bigtable 是如何进行数据分区,使得整个集群灵活可扩展的
  • Bigtable 是如何设计,使得 Master 不会成为单点故障,乃至单点性能的瓶颈
  • 整个 Bigtable 的整体架构和组件由哪些东西组成

3.1 基本数据模型

Bigtable 在一开始,也不准备先考虑事务、Join 等高级的功能,而是把核心放在了“可伸缩性”上。因此,Bigtable 自己的数据模型也特别简单,是一个很宽的稀疏表。

每一张 Bigtable 的表都特别简单,每一行就是一条数据:

  • 一条数据里面,有一个行键(Row Key),也就是这条数据的主键,Bigtable 提供了通过这个行键随机读写这条记录的接口。因为总是通过行键来读写数据,所以很多人也把这样的数据库叫做 KV 数据库
  • 每一行里的数据呢,你需要指定一些列族(Column Family),每个列族下,你不需要指定列(Column)。每一条数据都可以有属于自己的列,每一行数据的列也可以完全不一样,因为列不是固定的。这个所谓不是固定的,其实就是列下面没有值。因为 Bigtable 在底层存储数据的时候,每一条记录都要把列和值存下来,没有值,意味着对应的这一行就没有这个列。这也是为什么说 Bigtable 是一个“稀疏”的表。
  • 列下面如果有值的话,可以存储多个版本,不同版本都会存上对应版本的时间戳(Timestamp),你可以指定保留最近的 N 个版本(比如 N=3,就是保留时间戳最近的三个版本),也可以指定保留某一个时间点之后的版本。

列族,这个名字很容易让人误解 Bigtable 是一个基于列存储的数据库。但事实完全不是这样,对于列族,更合理的解读是,它是一张“物理表”,同一个列族下的数据会在物理上存储在一起。而整个表,是一张“逻辑表”。

3.2 数据分区

 前面讲过MySQL 集群的水平分区之所以遇到种种困难,是因为我们通过取模函数来进行分区,也就是所谓的哈希分区。我们会拿一个字段哈希取模,然后划分到预先定好 N 个分片里面。这里最大的问题,在于分区需要在一开始就设计好,而不是自动随我们的数据变化动态调整的。

所以,在 Bigtable 里,我们就采用了另外一种分区方式,也就是动态区间分区。我们不再是一开始就定义好需要多少个机器,应该怎么分区,而是采用了一种自动去“分裂”(split)的方式来动态地进行分区。

整个数据表,会按照行键排好序,然后按照连续的行键一段段地分区。如果某一段行键的区间里,写的数据越来越多,占用的存储空间越来越大,那么整个系统会自动地将这个分区一分为二,变成两个分区。而如果某一个区间段的数据被删掉了很多,占用的空间越来越小了,那么我们就会自动把这个分区和它旁边的分区合并到一起。

Bigtable 里的数据分区叫做 Tablet

在 Bigtable 里,我们是通过 MasterChubby 这两个组件来存储、管理分区信息。这两个组件,加上每个分片提供服务的 Tablet Server,以及实际存储数据的 GFS,共同组成了整个 Bigtable 集群。

Chubby 为了解决数据一致性,使用 5 台服务器组成的一个集群,它会通过 Paxos 这样的共识算法,来确保不会出现误判。

Bigtable 需要 Chubby 来搞定这么几件事儿:

  • 确保只有一个 Master
  • 存储 Bigtable 数据的引导位置(Bootstrap Location)
  • 发现 Tablet Servers 以及在它们终止之后完成清理工作
  • 存储 Bigtable 的 Schema 信息
  • 存储 ACL,也就是 Bigtable 的访问权限

分区和 Tablets 的分配信息没有放在 Master中,而是存成了 Bigtable 的一张 METADATA 表,直接存放在 Bigtable 集群里面的,究竟存放在哪个 Tablet Server 里,这个就需要通过 Chubby 来告诉我们。

  • Bigtable 在 Chubby 里的一个指定的文件里,存放了一个叫做 Root Tablet 的分区所在的位置。
  • 然后,这个 Root Tablet 的分区,是 METADATA 表的第一个分区,这个分区永远不会分裂。它里面存的,是 METADATA 里其他 Tablets 所在的位置。
  • 而 METADATA 剩下的这些 Tablets,每一个 Tablet 中,都存放了用户创建的那些数据表,所包含的 Tablets 所在的位置,也就是所谓的 User Tablets 的位置。

这样,三层结构让 Bigtable 可以“伸缩”到足够大。

而三层结构让 Bigtable 可以“伸缩”到足够大。

查询 Tablets 在哪里这件事情,尽可能地被分摊到了 Bigtable 的整个集群,而不是集中在某一个 Master 节点上。

在整个数据读写的过程中,客户端是不需要经过 Master 的。即使 Master 节点已经挂掉了,也不会影响数据的正常读写,让 Bigtable 更加“高可用”了。

在单纯的数据读写的过程中不需要 Master。Master 只负责 Tablets 的调度而已,Master 一共会负责 5 项工作:

  • 分配 Tablets 给 Tablet Server
  • 检测 Tablet Server 的新增和过期
  • 平衡 Tablet Server 的负载
  • 对于 GFS 上的数据进行垃圾回收(GC)
  • 管理表(Table)和列族的 Schema 变更,比如表和列族的创建与删除。

3.3 小结

整个 Bigtable 是由 4 个组件组成的,分别是:

  • 负责存储数据的 GFS
  • 负责作为分布式锁和目录服务的 Chubby
  • 负责实际提供在线服务的 Tablet Server
  • 负责调度 Tablet 和调整负载的 Master

而通过动态区域分区的方式,Bigtable 的分区策略需要的数据搬运工作量会很小。在 Bigtable 里,Master 并不负责保存分区信息,也不负责为分区信息提供查询服务。

Bigtable 是通过把分区信息直接做成了三层树状结构的 Bigtable 表,来让查询分区位置的请求分散到了整个 Bigtable 集群里,并且通过把查询的引导位置放在 Chubby 中,解决了和操作系统类似的“如何启动”问题。而整个系统的分区分配工作,由 Master 完成。通过对于 Chubby 锁的使用,就解决了 Master、Tablet Server 进出整个集群的问题。

第四章 Bigtable的底层数据结构

4.1Bigtable 的读写操作

在前面解读 GFS 的课程里,我们看到 GFS 这个文件系统本身,对随机读写是没有任何一致性保障的。而在上一讲里,我们又了解到 Bigtable 是一个支持随机读写的 KV 数据库,而且它实际的数据存储是放在 GFS 上的。这两点,听起来似乎是自相矛盾的,为什么一个对随机读写没有一致性保障的文件系统,可以拿来作为主要用途是随机读写的数据库的存储系统呢?

Bigtable 为了做到高性能的随机读写,采用了下面这一套组合拳,来解决这个问题:

  • 首先是将硬盘随机写,转化成了顺序写,也就是把 Bigtable 里面的提交日志(Commit Log)以及将内存表(MemTable)输出到磁盘的 Minor Compaction 机制。
  • 其次是利用“局部性原理”,最近写入的数据,会保留在内存表里。最近被读取到的数据,会存放到缓存(Cache)里,而不存在的行键,也会以一个在内存里的布隆过滤器(BloomFilter)进行快速过滤,尽一切可能减少真正需要随机访问硬盘的次数。

Bigtable 实际写入数据的过程是这样的:

  1. 当一个写请求过来的时候,Tablet Server 先会做基础的数据验证,包括数据格式是否合法,以及发起请求的客户端是否有权限进行对应的操作。这个权限设置,是 Tablet Server 从 Chubby 中获取到,并且缓存在本地的。
  2. 如果写入的请求是合法的,对应的数据写入请求会以追加写的形式,写入到 GFS 上的提交日志文件中,这个写入对于 GFS 上的硬盘来说是一个顺序写。这个时候,我们就认为整个数据写入就已经成功了。
  3. 在提交日志写入成功之后,Tablet Server 会再把数据写入到一张内存表中,也就是我们常说的 MemTable
  4. 而当我们写入的数据越来越多,要超出我们设置的阈值的时候,Tablet Server 会把当前内存里的整个 MemTable 冻结,然后创建一个新的 MemTable。被冻结的这个 MemTable,一般被叫做 Immutable MemTable,它会被转化成一个叫做 SSTable 的文件,写入到 GFS 上,然后再从内存里面释放掉。这个写入过程,是完整写一个新文件,所以自然也是顺序写。
  5. 如果在上面的第 2 步,也就是提交日志写入完成之后,Tablet Server 因为各种原因崩溃了,我们会通过重放(replay)所有在最后一个 SSTable 写入到 GFS 之后的提交日志,重新构造起来 MemTable,提供对外服务。

在整个数据写入的过程中,只有顺序写,没有随机写。在插入数据和更新数据的时候,其实只是在追加一个新版本的数据。在删除数据的时候,也只是写入一个墓碑标记,本质上也是写入一个特殊的新版本数据。

而对于数据的“修改”和“删除”,其实是在两个地方发生的。

  • 第一个地方,是一个叫做 Major Compaction 的机制。按照前面的数据写入机制,随着数据的写入,我们会有越来越多的 SSTable 文件。这样我们就需要通过一个后台进程,来不断地对这些 SSTable 文件进行合并,以缩小占用的 GFS 硬盘空间。而 Major Compaction 这个名字的由来,就是因为这个动作是把数据“压实”在一起。
  • 第二个地方,是在我们读取数据的时候。在读取数据的时候,我们其实是读取 MemTable 加上多个 SSTable 文件合并在一起的一个视图。也就是说,我们从 MemTable 和所有的 SSTable 中,拿到了对应的行键的数据之后,会在内存中合并数据,并根据时间戳或者墓碑标记,来对数据进行“修改”和“删除”,并将数据返回给到客户端。

4.2 高性能的随机数据读取

随机写入被转化成了顺序写,但是随机读我们还是避免不了的。随机读的代价可不小。一次数据的随机查询,我们可能要多次访问 GFS 上的硬盘,读取多个 SSTable。

MemTable 是选择使用跳表来作为自己的数据结构,主要是因为 MemTable 只有三种操作:

  • 根据行键的随机数据插入,这个在数据写入的时候需要用到
  • 根据行键的随机数据读取,这个在数据读取的时候需要用到
  • 根据行键有序遍历,这个在我们把 MemTable 转化成 SSTable 的时候会被用到

跳表在这三种操作上,性能都很好,随机插入和读取的时间复杂度都是 O(logN),而有序遍历的时间复杂度,则是 O(N)。

跳表全称为跳跃列表,它允许快速查询,插入和删除一个有序连续元素的数据链表。跳跃列表的平均查找和插入时间复杂度都是O(logn)。快速查询是通过维护一个多层次的链表,且每一层链表中的元素是前一层链表元素的子集。一开始时,算法在最稀疏的层次进行搜索,直至需要查找的元素在该层两个相邻的元素中间。这时,算法将跳转到下一个层次,重复刚才的搜索,直到找到需要查找的元素为止。

当 MemTable 的大小超出阈值之后,我们会遍历 MemTable,把它变成一个叫做 SSTable 的文件。SSTable 的文件格式其实很简单,本质上就是由两部分组成:

  • 第一部分,就是实际要存储的行键、列、值以及时间戳,这些数据会按照行键排序分成一个个固定大小的块(block)来进行存储。这部分数据,在 SSTable 中一般被称之为数据块(data block)
  • 第二部分,则是一系列的元数据和索引信息,这其中包括用来快速过滤当前 SSTable 中不存在的行键盘的布隆过滤器,以及整个数据块的一些统计指标,这些数据我们称之为元数据块(meta block)。另外还有针对数据块和元数据块的索引(index),这些索引内容,则分别是元数据索引块(metaindex block)数据索引块(index block)

SSTable 里面的数据块是顺序存储的,所以要做 Major Compaction 的算法也很简单,就是做一个有序链表的多路归并。在这个过程中,无论是读输入的 SSTable,还是写输出的 SSTable,都是顺序读写,而不是不断地去随机访问 GFS 上的硬盘。Major Compaction 会减少同一个 Tablet 下的 SSTable 文件数量,也就是会减少每次随机读的请求需要访问的硬盘次数。

而当我们要在 SSTable 里查询数据的时候,我们先会去读取索引数据,找到要查询的数据在哪一个数据块里。然后再把整个数据块返回给到 Tablet Server,Tablet Server 再从这个数据块里,提取出对应的 KV 数据返回给 Bigtable 的客户端。

那么在这个过程中,Bigtable 又利用了压缩和缓存机制做了更多的优化,下面我就来给你介绍下这些优化步骤。

  • 首先,是通过压缩算法对每个块进行压缩。这个本质上是以时间换空间,通过消耗 CPU 的计算资源,来减少存储需要的空间,以及后续的缓存需要的空间。
  • 其次,是把每个 SSTable 的布隆过滤器直接缓存在 Tablet Server 里。布隆过滤器本质是一个二进制向量,它可以通过一小块内存空间和几个哈希函数,快速检测一个元素是否在一个特定的集合里。在 SSTable 的这个场景下,就是可以帮助我们快速判断,用户想要随机读的行键是否在这个 SSTable 文件里。
  • 最后,Bigtable 还提供了两级的缓存机制。高层的缓存,是对查询结果进行缓存,我们称之为 Scan Cache。低层的缓存,是对查询所获取到的整个数据块进行缓存,我们称之为 Block Cache

4.3 小结

对于 Bigtable 的数据随机写入,我们采用了三个简单的步骤来实现高性能:

  • 首先是将随机写变为顺序写,将数据写入变成追加一条提交日志
  • 然后是将数据写入到内存而非硬盘上,也就是插入记录到通过跳表实现的 MemTable 里
  • 最后是定期将太大的 MemTable 冻结起来,变成一个根据行键排好序的 SSTable 文件

Bigtable 的数据,是由内存里的 MemTable 和 GFS 上的 SSTable 共同组成的。在 MemTable 里,它是通过跳表实现了 O(logN) 时间复杂度的单条数据随机读写,以及 O(N) 时间复杂度的数据顺序遍历。而 SSTable 里,则是把数据按照行键进行排序,并分成一个个固定大小的 block 存储。而对应指向 block 的索引等元数据,也一样存成了一个个 block

另外,对于数据的读取,Bigtable 也采用了三个办法来实现高性能:

  • 首先是定期在后台合并 SSTable,以减少读请求需要访问的 SSTable 的数量
  • 其次是通过在内存里缓存 BloomFilter,使得对于不存在于 SSTable 中的行键,可以直接过滤掉,无需访问 SSTable 文件才能知道它并不存在
  • 最后是通过 Scan Cache 和 Block Cache 这两层缓存,利用局部性原理,使得查询结果可以在缓存中找到,而无需访问 GFS 上的硬盘。

回顾这整个存储引擎的实现方式,我们会发现,我们看到的 Bigtable 的数据模型,其实是一系列的内存 + 数据文件 + 日志文件组合下封装出来的一个逻辑视图

 

分类:

技术点:

相关文章:

  • 2021-12-02
  • 2021-09-08
  • 2021-10-11
  • 2021-11-21
  • 2021-10-03
  • 2021-11-27
  • 2022-03-02
  • 2021-04-30
猜你喜欢
  • 2021-10-21
  • 2021-07-02
  • 2021-12-13
  • 2021-10-03
  • 2021-06-02
  • 2022-12-23
  • 2021-09-16
相关资源
相似解决方案