分布式数据库基础
数据分片:存储超大规模的数据
数据分片的方式一般有两种:
- 水平分片:在不同的数据库节点中存储同一表的不同行。
- 垂直分片:在不同的数据库节点中存储表不同的表列。
分片算法
分片算法一般指代水平分片所需要的算法。经过多年的演化,其已经在大型系统中得到了广泛的实践。
哈希分片
首先需要获取分片键,然后根据特定的哈希算法计算它的哈希值,最后使用哈希值确定数据应被放置在哪个分片中。数据库一般对所有数据使用统一的哈希算法(例如 ketama),以促成哈希函数在服务器之间均匀地分配数据,从而降低了数据不均衡所带来的热点风险。通过这种方法,数据不太可能放在同一分片上,从而使数据被随机分散开。
- 优点:适合随机读写的场景
- 缺点:不利于范围扫描查询操作
范围分片
范围分片根据数据值或键空间的范围对数据进行划分,相邻的分片键更有可能落入相同的分片上。每行数据不像哈希分片那样需要进行转换,实际上它们只是简单地被分类到不同的分片上。
- 优点:适合范围扫描查询操作
- 缺点:不利于随机读写的场景
融合算法
融合哈希分片和范围分片。
地理位置算法
该算法一般用于 NewSQL 数据库,提供全球范围内分布数据的能力。
在基于地理位置的分片算法中,数据被映射到特定的分片,而这些分片又被映射到特定区域以及这些区域中的节点。
目前水平和垂直分片有进一步合并的趋势, TiDB 正代表着这种融合趋势。
TiDB 就是一个垂直与水平分片融合的典型案例,同时该方案也是 HATP 融合方案。
其中水平扩展依赖于底层的 TiKV,如下图所示。
TiKV 使用范围分片的模式,数据被分配到 Region 组里面。一个分组保持三个副本,这保证了高可用性(相关内容会在“05 | 一致性与 CAP 模型:为什么需要分布式一致性?”中详细介绍)。当 Region 变大后,会被拆分,新分裂的 Region 也会产生多个副本。
TiDB 的水平扩展依赖于 TiFlash,如下图所示。
从图 中可以看到 TiFlash 是 TiKV 的列扩展插件,数据异步从 TiKV 里面复制到 TiFlash,而后进行列转换,其中要使用 MVCC 技术来保证数据的一致性。
上文所述的 Region 会增加一个新的异步副本,而后该副本进行了数据切分,并以列模式组合到 TiFlash 中,从而达到了水平和垂直扩展在同一个数据库的融合。这是两种数据库引擎的融合。
以上的融合为 TiDB 带来的益处主要体现在查询层面,特别对特定列做聚合查询的效率很高。TiDB 可以很智能地切换以上两种分片引擎,从而达到最优的查询效率。
数据复制:保证数据在分布式场景下的高可用
复制的主要目的是在几个不同的数据库节点上保留相同数据的副本,从而提供一种数据冗余。这份冗余的数据可以提高数据查询性能,而更重要的是保证数据库的可用性。
单主复制(主从复制)
写入主节点的数据都需要复制到从节点,即存储数据库副本的节点。当客户要写入数据库时,他们必须将请求发送给主节点,而后主节点将这些数据转换为复制日志或修改数据流发送给其所有从节点。从使用者的角度来看,从节点都是只读的。
复制同步模式
-
同步复制:如果由于从库已崩溃,存在网络故障或其他原因而没有响应,则主库也无法写入该数据。
-
半同步复制:其中部分从库进行同步复制,而其他从库进行异步复制。也就是,如果其中一个从库同步确认,主库可以写入该数据。
-
异步复制:不管从库的复制情况如何,主库可以写入该数据。而此时,如果主库失效,那么还未同步到从库的数据就会丢失。
复制延迟
如果使用同步复制,每次写入都需要同步所有从节点,会造成一部分从节点已经有数据,但是主节点还没写入数据。而异步复制的问题是从节点的数据可能不是最新的。
复制与高可用性
高可用(High availablity)指系统无中断地执行其功能的能力。
两种可能的故障及其处理方案:
- 从节点故障。由于每个节点都复制了从主库那里收到的数据更改日志,因此它知道在发生故障之前已处理的最后一个事务,由此可以凭借此信息从主节点或其他从节点那里恢复自己的数据。
- 主节点故障。在这种情况下,需要在从节点中选择一个成为新的主节点,此过程称为故障转移,可以手动或自动触发。其典型过程为:第一步根据超时时间确定主节点离线;第二步选择新的主节点,这里注意新的主节点通常应该与旧的主节点数据最为接近;第三步是重置系统,让它成为新的主节点。
复制方式
1. 基于语句的复制
主库记录它所执行的每个写请求(一般以 SQL 语句形式保存),每个从库解析并执行该语句,就像从客户端收到该语句一样。但这种复制会有一些潜在问题,如语句使用了获取当前时间的函数,复制后会在不同数据节点上产生不同的值。
2. 日志(WAL)同步
WAL 是一组字节序列,其中包含对数据库的所有写操作。它的内容是一组低级操作,如向磁盘的某个页面的某个数据块写入一段二进制数据,主库通过网络将这样的数据发送给从库。
这种方法避免了上面提到的语句中部分操作复制后产生的一些副作用,但要求主从的数据库引擎完全一致,最好版本也要一致。
3. 行复制
它由一系列记录组成,这些记录描述了以行的粒度对数据库表进行的写操作。它与特定存储引擎解耦,并且第三方应用可以很容易解析其数据格式。
4. ETL 工具
该功能一般是最灵活的方式。用户可以根据自己的业务来设计复制的范围和机制,同时在复制过程中还可以进行如过滤、转换和压缩等操作。但性能一般较低,故适合处理子数据集的场景。
多主复制(主主复制)
数据库集群内存在多个对等的主节点,它们可以同时接受写入。每个主节点同时充当主节点的从节点。
设计该类系统的目的在于以下几点:
- 获得更好的写入性能:使数据可以就近写入。
- 数据中心级别的高可用:每个数据中心可以独立于其他数据中心继续运行。
- 更好的数据访问性能:用户可以访问到距离他最近的数据中心。
一致性与 CAP 模型
高可用必须要尽可能满足业务连续性和数据一致性这两个指标。
CAP: 一致性,可用性,容忍网络分区
分布式系统有 AP/CP ,CA 类系统是不存在的:
- CP 系统:一致且容忍分区的系统。更倾向于减少服务时间,而不是将不一致的数据提供出去。一些面向交易场景构建的 NewSQL 数据库倾向于这种策略,如 TiDB、阿里云 PolarDB、AWS Aurora 等。但是它们会生成自己的 A,也就是可用性很高。
- AP 系统:可用且具有分区容忍性的系统。它放宽了一致性要求,并允许在请求期间提供可能不一致的值。一般是列式存储,NoSQL 数据库会倾向于 AP,如 Apache Cassandra。但是它们会通过不同级别的一致性模式调整来提供高一致性方案。
一致性模型
严格一致性
严格的一致性类似于不存在复制过程:任何节点的任何写入都可立即用于所有节点的后续读取。它涉及全局时钟的概念,如果任何节点在时刻 T1 处写入新数据 A,则所有节点在 T2 时刻(T2 满足 T2>T1),都应该读到新写入的 A。
线性一致性
线性一致性是最严格的且可实现的单对象单操作一致性模型。在这种模型下,写入的值在调用和完成之间的某个时间点可以被其他节点读取出来。
- 需要有全局时钟,来实现所谓的“最近”。因为没有全局一致的时间,两个独立进程没有相同的“最近”概念。
- 任何一次读取都能读到这个“最近”的值。
下图正是线性一致性的直观展示:
顺序一致性
顺序一致性是指所有的进程以相同的顺序看到所有的修改。读操作未必能及时得到此前其他进程对同一数据的写更新,但是每个进程读到的该数据的不同值的顺序是一致的。
区分线性一致和顺序一致:
-
图 a 满足了顺序一致性,但是不满足线性一致性。原因在于,从全局时钟的观点来看,P2 进程对变量 x 的读操作在 P1 进程对变量 x 的写操作之后,然而读出来的却是旧的数据。但是这个图却是满足顺序一致性,因为两个进程 P1 和 P2 的一致性并没有冲突。
-
图 b 满足线性一致性,因为每个读操作都读到了该变量的最新写的结果,同时两个进程看到的操作顺序与全局时钟的顺序一样。
-
图 c 不满足顺序一致性,因为从进程 P1 的角度看,它对变量 y 的读操作返回了结果 0。那么就是说,P1 进程的对变量 y 的读操作在 P2 进程对变量 y 的写操作之前,x 变量也如此。因此这个顺序不满足顺序一致性。
因果一致性
相比于顺序一致性,因果一致性的要求会低一些:它仅要求有因果关系的操作顺序是一致的,没有因果关系的操作顺序是随机的。
- 本地顺序:本进程中,事件执行的顺序即为本地因果顺序。
- 异地顺序:如果读操作返回的是写操作的值,那么该写操作在顺序上一定在读操作之前。
- 闭包传递:和时钟向量里面定义的一样,如果 a->b、b->c,那么肯定也有 a->c。
存储引擎
存储引擎重要的几个功能:
- 事务管理器:用来调度事务并保证数据库的内部一致性(这与模块一中讨论的分布式一致性是不同的);
- 锁管理:保证操作共享对象时候的一致性,包括事务、修改数据库参数都会使用到它;
- 存储结构:包含各种物理存储层,描述了数据与索引是如何组织在磁盘上的;
- 内存结构:主要包含缓存与缓冲管理,数据一般是批量输入磁盘的,写入之前会使用内存去缓存数据;
- 提交日志:当数据库崩溃后,可以使用提交日志恢复系统的一致性状态。
内存与磁盘
存储引擎中最重要的部分就是磁盘与内存两个结构。根据数据在它们之中挑选一种作为主要的存储,数据库可以被分为内存型数据库与磁盘型数据库。
除了内存和磁盘的取舍,存储引擎还关心数据的组合模式,现在让我们看看两种常见的组合方式:行式与列式。
行式存储与列式存储
数据一般是以表格的形式存储在数据库中的,所以所有数据都有行与列的概念。但这只是一个逻辑概念,我们将要介绍的所谓“行式”和“列式”体现的其实是物理概念。
- 行式存储把每行的所有列存储在一起,从而形成数据文件。当需要把整行数据读取出来时,这种数据组织形式是比较合理且高效的。但是如果要读取多行中的某个列,这种模式的代价就很昂贵了,因为一些不需要的数据也会被读取出来。
- 列式存储不同行的同一列数据会被就近存储在一个数据文件中。同时除了存储数据本身外,还需要存储该数据属于哪行。而行式存储由于列的顺序是固定的,不需要存储额外的信息来关联列与值之间的关系。(列式存储非常适合处理分析聚合类型的任务)
数据文件与索引文件
上文介绍了内存与磁盘之间的取舍,从中可看到磁盘其实更为重要的,因为数据库是提供数据持久化存储的服务。故我们开始介绍磁盘上最为重要的两类文件:数据文件(存放原始数据)和索引文件(存放索引数据)。
数据文件最传统的形式为堆组织表(Heap-Organized Table),数据的放置没有一个特别的顺序,一般是按照写入的先后顺序排布。这种数据文件需要一定额外的索引帮助来查找数据。
另外有两种数据表形式自带了一定的索引数据能力:哈希组织表和索引组织表(采用索引文件的形式来存储数据,以 B+树为例,数据被存储在叶子节点上)
索引文件的分类模式一般为主键索引(主键索引与数据是一对一关系)与二级索引(可能是一对多的关系,即多个索引条目指向一条数据)两类。
二级索引需要保存指向最终数据的“引用”。从实现层面上,这个引用可以是数据的实际位置,也可以是数据的主键。前者的好处是查询效率高,而写入需要更新所有索引,故性能相对较低。而后者就恰好相反,查询需要通过主键索引进行映射,效率稍低,但写入性能很稳定,如 MySQL 就是选用后者作为其索引模式。
分布式索引
分布式数据库的数据被分散在多个节点上。大部分分布式数据库的场景是为查询服务的。数据库牺牲了部分写入的性能,在存入数据的时候同时生成索引结构。故分布式数据库的核心是以提供数据检索服务为主,数据写入要服务于数据查询。
读取路径
掌握分布式数据库存储引擎,一般需要明确其写入路径与读取路径。
-
寻找分片和目标节点;
-
检查数据是否在缓存与缓冲中;
-
检查数据是否在磁盘文件中;
存储引擎为了写入性能,会把数据拆分在众多的数据文件内部。所以需要在一系列文件中去查找数据,即使有索引的加成,查找效率一般。可以引入布隆过滤,来快速地定位目标文件,提高查询效率。
-
合并结果。
布隆过滤
在查询路径中,除了向所有数据文件请求查询(也被称作读放大)外,还可以利用布隆过滤快速定位目标数据文件。
布隆过滤的原理是,我们有一个非常大的位数组,首先初始化里面所有的值为 0;而后对数据中的键做哈希转换,将结果对应的二进制表示形式映射到这个位数组里面,这样有一部分 0 转为 1;然后将数据表中所有建都如此映射进去。
查找的时候,将查询条件传入的键也进行类似的哈希转换,而后比较其中的 1 是否与数组中的匹配,如果匹配,说明键有可能在这个数据表中。
可以看到,这个算法是一个近似算法,存在误判的可能。也就是所有位置都是 1,但是键也可能不在数据表内,而这些 1 是由于别的键计算产生的。
但是在查找数据文件的场景中,这个缺陷可以忽略。因为如果布隆过滤判断失败,也只是多浪费一些时间在数据表中查找,从而退化为读放大场景,并不会产生误读的情况。
布隆过滤的原理简单易懂,它对于 LSM 树存储引擎下所产生的大量 SSTable 的检索很有帮助,是重要的优化查询的手段。
索引数据表
含有索引的数据表有索引组织表和哈希组织表。其实,我们在分布式数据库中最常见的是 Google 的 BigTable 论文所提到的 SSTable(排序字符串表)。
Google 论文中的原始描述为:SSTable 用于 BigTable 内部数据存储。SSTable 文件是一个排序的、不可变的、持久化的键值对结构,其中键值对可以是任意字节的字符串,支持使用指定键来查找值,或通过给定键范围遍历所有的键值对。每个 SSTable 文件包含一系列的块。SSTable 文件中的块索引(这些块索引通常保存在文件尾部区域)用于定位块,这些块索引在 SSTable 文件被打开时加载到内存。在查找时首先从内存中的索引二分查找找到块,然后一次磁盘寻道即可读取到相应的块。另一种方式是将 SSTable 文件完全加载到内存,从而在查找和扫描中就不需要读取磁盘。
这些键值对是按照键进行排序的,而且一旦写入就不可变。数据引擎支持根据特定键查询,或进行范围扫描。同时,索引为稀疏索引,它只定位到数据块。查到块后,需要顺序扫描块内部,从而获取目标数据。
日志型存储
经典日志合并树(LSM 树)
LSM 树的结构
LSM 树包含内存驻留单元和磁盘驻留单元。首先数据会写入内存的一个缓冲中,而后再写到磁盘上的不可变文件中。
内存驻留单元一般称为MemTable(内存表),是一个可变结构,他可以作为数据暂存的缓存,同时也对外提供读取服务。当数据量达到一个阈值后,数据批量写入磁盘。
磁盘驻留单元,也就是数据文件,是在内存缓冲刷盘时生成的。且这些数据文件是不可变的,只能提供读取服务。而相对的,内存表同时提供读写两个服务。
多树结构
LSM 树的结构,一般有双树结构和多树结构两种。
-
数据首先写入当前内存表,当数据量到达阈值后,当前数据表把自身状态转换为刷盘中,并停止接受写入请求。
-
此时会新建另一个内存表来接受写请求。
-
刷盘完成后,由于数据在磁盘上,除了废弃内存表的数据外,还对提交日志进行截取操作。而后将新数据表设置为可以读取状态。
-
在合并操作(磁盘)开始时,将被合并的表设置为合并中状态,此时它们还可以接受读取操作。
-
完成合并后,原表作废,新表开始启用提供读取服务。
LSM树对数据的修改和删除本质上都是增加一条数据。修改操作是比较简明的,插入新数据就好了。删除数据,插入的是墓碑*(比如有从 k0 到 k9 的 9 条数据,在 k3 处设置开始删除点(包含 k3),在 k7 处设置结束删除点(不包含 k7),那么 k3 到 k6 这四条数据就被删除了。此时查询就会查不到 k4 到 k6,即使它们上面没有设置墓碑。)*
合并操作
合并操作会根据一定规则,从磁盘的数据文件中选择若干文件进行合并,而后将新文件写入磁盘,成功后会删除老数据。
在整个合并的过程中,老的数据表依然可以对外提供读取服务,这说明老数据依然在磁盘中。这就要求磁盘要留有一定的额外空间来容纳生成中的新数据表。同时合并操作可以并行执行,但是一般情况下它们操作的数据不会重合,以免引发竞争问题。合并操作既可以将多个数据文件合并成一个,也可以将一个数据文件拆分成多个。
Size-Tiered Compaction
第一层保存的是系统内最小的数据表,它们是刚刚从内存表中刷新出来的。合并过程就是将低层较小的数据表合并为高层较大的数据表的过程。简单实现,但是容易造成容量压力,比如有两个 5GB 的文件需要合并,那么磁盘至少要保留 10GB 的空间来完成这次操作。
Leveled Compaction
该策略是将数据表进行分层,按照编号排成 L0 到 Ln 这样的多层结构。
L0 层是从内存表刷盘产生的数据表,该层数据表中间的 key 是可以相交的;L1 层及以上的数据,将 Size-Tiered Compaction 中原本的大数据表拆开,成为多个 key 互不相交的小数据表,每层都有一个最大数据量阈值,当到达该值时,就出发合并操作。每层的阈值是按照指数排布的,例如 RocksDB 文档中介绍了一种排布:L1 是 300MB、L2 是 3GB、L3 是 30GB、L4 为 300GB。
每次合并时不必再选取一层内所有的数据,并且每层中数据表的 key 区间都是不相交的,重复 key 减少了,所以很大程度上缓解了空间放大的问题。
当然在实际应用中会组合两种策略,比如经典的 RocksDB 会在 L0 合并到 L1 时,使用 Size-Tiered Compaction;而从 L1 开始,则是采用经典的 Leveled Compaction。这其中原因是 L0 的数据表之间肯定会存在相同的 key。
RUM 假说
开始介绍这个假说之前,你要先明确几个“放大”概念。
- 读放大(Read)。它来源于在读取时需要在多个文件中获取数据并解决数据冲突问题,如查询操作中所示的,读取的目标越多,对读取操作的影响越大,而合并操作可以有效缓解读放大问题。
- 写放大(Update)。对于 LSM 树来说,写放大来源于持续的合并操作,特别是 Leveled Compaction,可以造成多层连续进行合并操作,这样会让写放大问题呈几何倍增长。
- 空间放大(Memory)。这是我在说合并的时候提到过的概念,是指相同 key 的数据被放置了多份,这是在合并操作中所产生的。尤其是 Size-Tiered Compaction 会有严重的空间放大问题。