TiDB学习(一)

TiDB基本介绍

组件

TiDB 集群有三大组件构成:TiDB Server、PD Server、TiKV Server(图 1)。

 

其中各个组件的功能如下:

  • TiDB Server,可以理解为 SQL Layer,负责接收 SQL 请求,处理 SQL 解析、SQL 优化等相关逻辑,并通过 PD 与底层 TiKV 交互来获取或变更数据;

  • PD Server,可以视作集群的大脑,负责集群各类元信息的存储以及 TiKV 节点间的数据调度和负载均衡,另外还负责集群全局事务 ID 的分配;

  • TiKV Server,集群的存储层,数据最终以 Key-Value 的形式存储于其中,并通过 PD 在各个 KV 节点调度,以保证各节点负载均衡。TiKV Cluster 本身也可作为分布式的 Key-Value 存储独立使用。

TiDB 核心特性

水平扩展计算与存储

TiDB Server 负责处理 SQL 请求,随着业务的增长,可以简单的添加 TiDB Server 节点,提高整体的计算处理能力。TiKV 负责存储数据,随着数据量的增长,

可以部署更多的 TiKV Server 节点,提高集群整体的存储能力。PD 会在 TiKV 节点之间做调度,将部分数据迁移到新加的节点上,这个过程不需要人为的干预。

 故障自恢复的高可用

TiDB / TiKV / PD 三个组件都能容忍部分实例失效,不影响整个集群的可用性。

  • TiDB Server 节点失效,只会影响该节点上的 session,应用连接失败重试后可通过前端负载均衡中间件将请求发送到其他正常的 TiDB Server 节点。
  • PD 节点失效,若非 Raft leader 节点(PD Cluster 通过 Raft 协议保证自身数据一致性)则无影响,否则会重新选取 leader,期间该无法对外提供服务(约 3 秒钟)。
  • TiKV 节点失效,会影响该节点上的所有 region,若 region 非 Raft leader(TiKV Cluster 也通过 Raft 协议保证节点间的数据一致性),则服务不受影响,否则服务会中断,待重新选举 leader 后恢复。如果 PD 确认了失效的 TiKV 节点已经不能恢复,则会自动将该节点的数据调度至其他正常的 TiKV 节点。

TiDB 其他特性及原理

  • 高度兼容 MySQL 语法和协议;

  • 分布式事务;

  • 跨数据中心数据强一致性保证;

  • 海量数据高并发实时写入与实时查询。 

TiDB演化背景

背景

在大约两年前,我有一次做MySQL分库分表和中间件的经历,那时在中间件里做 sharding,把 16 个节点的 MySQL 扩到 32 节点,差不多要提前一个月做演练,再用一个礼拜来上线。我就在想,能不能有一个数据库可以让我们不再想分库分表这些东西?当时我们也刚刚做完 Codis,觉得分布式是个比较合适的解决方案。另外我一直在关注学术圈关于分布式数据库的最新进展,有看到谷歌在 2013 年发的 Spanner 和 F1 的论文,所以决定干脆就重新开始写一个数据库,从根本上解决 MySQL 扩展性的问题。

 

初始版本

  • 0.2:TiDB 只有一个 SQL 解析器,没有存储引擎
  • 0.5:SQL 层跟存储层基本上做了完全分离,HBASE作为分布式存储引擎
  • 1.0版本:存储引擎改为基于RocksDB的存储

 

TiKV

Key-Value

作为保存数据的系统,首先要决定的是数据的存储模型,也就是数据以什么样的形式保存下来。TiKV 的选择是 Key-Value 模型,并且提供有序遍历方法。简单来讲,可以将 TiKV 看做一个巨大的 Map,其中 Key 和 Value 都是原始的 Byte 数组,在这个 Map 中,Key 按照 Byte 数组总的原始二进制比特位比较顺序排列。

大家这里需要对 TiKV 记住两点:

1. 这是一个巨大的 Map,也就是存储的是 Key-Value pair;

2. 这个 Map 中的 Key-Value pair 按照 Key 的二进制顺序有序,也就是我们可以 Seek 到某一个 Key 的位置,然后不断的调用 Next 方法以递增的顺序获取比这个 Key 大的 Key-Value。

讲了这么多,有人可能会问了,这里讲的存储模型和 SQL 中表是什么关系?在这里有一件重要的事情要说四遍:

这里的存储模型和 SQL 中的 Table 无关!

这里的存储模型和 SQL 中的 Table 无关!

这里的存储模型和 SQL 中的 Table 无关!

这里的存储模型和 SQL 中的 Table 无关!

现在让我们忘记 SQL 中的任何概念,专注于讨论如何实现 TiKV 这样一个高性能高可靠性的巨大的(分布式的) Map。

RocksDB

任何持久化的存储引擎,数据终归要保存在磁盘上,TiKV 也不例外。但是 TiKV 没有选择直接向磁盘上写数据,而是把数据保存在 RocksDB 中,具体的数据落地由 RocksDB 负责。这个选择的原因是开发一个单机存储引擎工作量很大,特别是要做一个高性能的单机引擎,需要做各种细致的优化,而 RocksDB 是一个非常优秀的开源的单机存储引擎,可以满足我们对单机引擎的各种要求,而且还有 Facebook 的团队在做持续的优化,这样我们只投入很少的精力,就能享受到一个十分强大且在不断进步的单机引擎。这里可以简单的认为 RocksDB 是一个单机的 Key-Value Map。

Raft

接下来我们面临一件更难的事情:如何保证单机失效的情况下,数据不丢失,不出错?

简单来说,我们需要想办法把数据复制到多台机器上,这样一台机器挂了,我们还有其他的机器上的副本;

这里采用 Raft 协议。Raft 是一个一致性算法,它和 Paxos 等价,但是更加易于理解。感兴趣的可以参考:[https://raft.github.io/raft.pdf]

Raft 是一个一致性协议,提供几个重要的功能:

1. Leader 选举

2. 成员变更

3. 日志复制

TiKV 利用 Raft 来做数据复制,每个数据变更都会落地为一条 Raft 日志,通过 Raft 的日志复制功能,将数据安全可靠地同步到 Group 的多数节点中。

 

到这里我们总结一下,通过单机的 RocksDB,我们可以将数据快速地存储在磁盘上;通过 Raft,我们可以将数据复制到多台机器上,以防单机失效。数据的写入是通过 Raft 这一层的接口写入,而不是直接写 RocksDB。通过实现 Raft,我们拥有了一个分布式的 KV,现在再也不用担心某台机器挂掉了。

Region

讲到这里,我们可以提到一个非常重要的概念:Region。这个概念是理解后续一系列机制的基础。

前面提到,我们将 TiKV 看做一个巨大的有序的 KV Map,那么为了实现存储的水平扩展,我们需要将数据分散在多台机器上。这里提到的数据分散在多台机器上和 Raft 的数据复制不是一个概念,在这一节我们先忘记 Raft,假设所有的数据都只有一个副本,这样更容易理解。

对于一个 KV 系统,将数据分散在多台机器上有两种比较典型的方案:

  • 一种是按照 Key 做 Hash,根据 Hash 值选择对应的存储节点;
  • 另一种是分 Range,某一段连续的 Key 都保存在一个存储节点上。
    TiKV 选择了第二种方式,将整个 Key-Value 空间分成很多段,每一段是一系列连续的 Key,我们将每一段叫做一个 Region,并且会尽量保持每个 Region 中保存的数据不超过一定的大小(这个大小可以配置,目前默认是 64MB)。每一个 Region 都可以用 StartKey 到 EndKey 这样一个左闭右开区间来描述。

注意,这里的 Region 还是和 SQL 中的表没什么关系! 请各位继续忘记 SQL,只谈 KV。

将数据划分成 Region 后,将会做两件重要的事情:

  • 以 Region 为单位,将数据分散在集群中所有的节点上,并且尽量保证每个节点上服务的 Region 数量差不多

  • 以 Region 为单位做 Raft 的复制和成员管理

这两点非常重要,我们一点一点来说。

先看第一点,数据按照 Key 切分成很多 Region,每个 Region 的数据只会保存在一个节点上面。我们的系统会有一个组件来负责将 Region 尽可能均匀的散布在集群中所有的节点上,这样一方面实现了存储容量的水平扩展(增加新的节点后,会自动将其他节点上的 Region 调度过来),另一方面也实现了负载均衡(不会出现某个节点有很多数据,其他节点上没什么数据的情况)。同时为了保证上层客户端能够访问所需要的数据,我们的系统中也会有一个组件记录 Region 在节点上面的分布情况,也就是通过任意一个 Key 就能查询到这个 Key 在哪个 Region 中,以及这个 Region 目前在哪个节点上。

对于第二点,TiKV 是以 Region 为单位做数据的复制,也就是一个 Region 的数据会保存多个副本,我们将每一个副本叫做一个 Replica。Repica 之间是通过 Raft 来保持数据的一致,一个 Region 的多个 Replica 会保存在不同的节点上,构成一个 Raft Group。其中一个 Replica 会作为这个 Group 的 Leader,其他的 Replica 作为 Follower。所有的读和写都是通过 Leader 进行,再由 Leader 复制给 Follower。

大家理解了 Region 之后,应该可以理解下面这张图:

我们以 Region 为单位做数据的分散和复制,就有了一个分布式的具备一定容灾能力的 KeyValue 系统,不用再担心数据存不下,或者是磁盘故障丢失数据的问题。这已经很 Cool,但是还不够完美,我们需要更多的功能。

MVCC

很多数据库都会实现多版本控制(MVCC),TiKV 也不例外。设想这样的场景,两个 Client 同时去修改一个 Key 的 Value,如果没有 MVCC,就需要对数据上锁,在分布式场景下,可能会带来性能以及死锁问题。

TiKV 的 MVCC 实现是通过在 Key 后面添加 Version 来实现,简单来说,没有 MVCC 之前,可以把 TiKV 看做这样的:

     Key1 -> Value

     Key2 -> Value

     ……

     KeyN -> Value

有了 MVCC 之后,TiKV 的 Key 排列是这样的:

     Key1-Version3 -> Value

     Key1-Version2 -> Value

     Key1-Version1 -> Value

     ……

     Key2-Version4 -> Value

     Key2-Version3 -> Value

     Key2-Version2 -> Value

     Key2-Version1 -> Value

     ……

     KeyN-Version2 -> Value

     KeyN-Version1 -> Value

     ……

注意,对于同一个 Key 的多个版本,我们把版本号较大的放在前面,版本号小的放在后面(回忆一下 Key-Value 一节我们介绍过的 Key 是有序的排列),这样当用户通过一个 Key + Version 来获取 Value 的时候,可以将 Key 和 Version 构造出 MVCC 的 Key,也就是 Key-Version。然后可以直接 Seek(Key-Version),定位到第一个大于等于这个 Key-Version 的位置。

详细原理可以参考:https://www.pingcap.com/blog-cn/mvcc-in-tikv/。

事务

TiKV 的事务采用的是 Percolator 【https://research.google.com/pubs/pub36726.html】模型,并且做了大量的优化。

这里只提一点,TiKV 的事务采用乐观锁,事务的执行过程中,不会检测写冲突,只有在提交过程中,才会做冲突检测,冲突的双方中比较早完成提交的会写入成功,另一方会尝试重新执行整个事务。

当业务的写入冲突不严重的情况下,这种模型性能会很好,比如随机更新表中某一行的数据,并且表很大。但是如果业务的写入冲突严重,性能就会很差,举一个极端的例子,就是计数器,多个客户端同时修改少量行,导致冲突严重的,造成大量的无效重试。

TiDB

sql原理

在这我们将关系模型简单理解为 Table 和 SQL 语句,那么问题变为如何在 KV 结构上保存 Table 以及如何在 KV 结构上运行 SQL 语句。

假设我们有这样一个表的定义:

CREATE TABLE User {

ID int,

Name varchar(20),

Role varchar(20),

Age int,

PRIMARY KEY (ID),

Key idxAge (age)

};

SQL 和 KV 结构之间存在巨大的区别,那么如何能够方便高效地进行映射,就成为一个很重要的问题。一个好的映射方案必须有利于对数据操作的需求。

对于一个 Table 来说,需要存储的数据包括三部分:

1. 表的元信息

2. Table 中的 Row

3. 索引数据

DML操作

对于 Row,可以选择行存或者列存,这两种各有优缺点。TiDB 面向的首要目标是 OLTP 业务,这类业务需要支持快速地读取、保存、修改、删除一行数据,所以采用行存是比较合适的。

对于 Index,TiDB 不只需要支持 Primary Index,还需要支持 Secondary Index。Index 的作用的辅助查询,提升查询性能,以及保证某些 Constraint。

查询的时候有两种模式:

  • 点查,比如通过 Primary Key 或者 Unique Key 的等值条件进行查询,如 select name from user where id=1; ,这种需要通过索引快速定位到某一行数据;
  • Range 查询,如 select name from user where age > 30 and age < 35;,这个时候需要通过 idxAge 索引查询 age 在 20 和 30 之间的那些数据。Index 还分为 Unique Index 和 非 Unique Index,这两种都需要支持。

分析完需要存储的数据的特点,我们再看看对这些数据的操作需求,主要考虑 Insert/Update/Delete/Select 这四种语句。

对于 Insert 语句,需要将 Row 写入 KV,并且建立好索引数据。

对于 Update 语句,需要将 Row 更新的同时,更新索引数据(如果有必要)。

对于 Delete 语句,需要在删除 Row 的同时,将索引也删除。

上面三个语句处理起来都很简单。对于 Select 语句,情况会复杂一些。首先我们需要能够简单快速地读取一行数据,所以每个 Row 需要有一个 ID (显示或隐式的 ID)。其次可能会读取连续多行数据,比如Select * from user;。最后还有通过索引读取数据的需求,对索引的使用可能是点查或者是范围查询。

TiDB 对每个表分配一个 TableID,每一个索引都会分配一个 IndexID,每一行分配一个 RowID(如果表有整数型的 Primary Key,那么会用 Primary Key 的值当做 RowID),其中 TableID 在整个集群内唯一,IndexID/RowID 在表内唯一,这些 ID 都是 int64 类型。

  • 每行数据按照如下规则进行编码成 Key-Value pair:

Key: tablePrefix_rowPrefix_tableID_rowID

Value: [col1, col2, col3, col4]

其中 Key 的 tablePrefix/rowPrefix 都是特定的字符串常量,用于在 KV 空间内区分其他数据。

  • 对于 Index 数据,会按照如下规则编码成 Key-Value pair:

Key: tablePrefix_idxPrefix_tableID_indexID_indexColumnsValue

Value: rowID

Index 数据还需要考虑 Unique Index 和非 Unique Index 两种情况,对于 Unique Index,可以按照上述编码规则。但是对于非 Unique Index,通过这种编码并不能构造出唯一的 Key,因为同一个 Index 的  tablePrefix_idxPrefix_tableID_indexID_  都一样,可能有多行数据的 ColumnsValue  是一样的,所以对于非 Unique Index 的编码做了一点调整:

Key: tablePrefix_idxPrefix_tableID_indexID_ColumnsValue_rowID

Value:null

这样能够对索引中的每行数据构造出唯一的 Key。

注意上述编码规则中的 Key 里面的各种 xxPrefix 都是字符串常量,作用都是区分命名空间,以免不同类型的数据之间相互冲突,定义如下:

var(

tablePrefix     = []byte{'t'}

recordPrefixSep = []byte("_r")

indexPrefixSep  = []byte("_i")

)

另外请大家注意,上述方案中,无论是 Row 还是 Index 的 Key 编码方案,一个 Table 内部所有的 Row 都有相同的前缀,一个 Index 的数据也都有相同的前缀。这样具体相同的前缀的数据,在 TiKV 的 Key 空间内,是排列在一起。同时只要我们小心地设计后缀部分的编码方案,保证编码前和编码后的比较关系不变,那么就可以将 Row 或者 Index 数据有序地保存在 TiKV 中。这种保证编码前和编码后的比较关系不变的方案我们称为 Memcomparable,对于任何类型的值,两个对象编码前的原始类型比较结果,和编码成 byte 数组后(注意,TiKV 中的 Key 和 Value 都是原始的 byte 数组)的比较结果保持一致。具体的编码方案参见 TiDB 的 codec 包 (https://github.com/pingcap/tidb/tree/master/util/codec)。采用这种编码后,一个表的所有 Row 数据就会按照 RowID 的顺序排列在 TiKV 的 Key 空间中,某一个 Index 的数据也会按照 Index 的 ColumnValue 顺序排列在 Key 空间内。

现在我们结合开始提到的需求以及 TiDB 的映射方案来看一下,这个方案是否能满足需求。首先我们通过这个映射方案,将 Row 和 Index 数据都转换为 Key-Value 数据,且每一行、每一条索引数据都是有唯一的 Key。其次,这种映射方案对于点查、范围查询都很友好,我们可以很容易地构造出某行、某条索引所对应的 Key,或者是某一块相邻的行、相邻的索引值所对应的 Key 范围。最后,在保证表中的一些 Constraint 的时候,可以通过构造并检查某个 Key 是否存在来判断是否能够满足相应的 Constraint。

至此我们已经聊完了如何将 Table 映射到 KV 上面,这里再举个简单的例子,便于大家理解,还是以上面的表结构为例。假设表中有 3 行数据:

1, "TiDB", "SQL Layer", 10

2, "TiKV", "KV Engine", 20

3, "PD", "Manager", 30

那么首先每行数据都会映射为一个 Key-Value pair,注意这个表有一个 Int 类型的 Primary Key,所以 RowID 的值即为这个 Primary Key 的值。假设这个表的 Table ID 为 10,其 Row 的数据为:

t_r_10_1  --> ["TiDB", "SQL Layer", 10]

t_r_10_2 --> ["TiKV", "KV Engine", 20]

t_r_10_3 --> ["PD", "Manager", 30]

除了 Primary Key 之外,这个表还有一个 Index,假设这个 Index 的 ID 为 1,则其数据为:

t_i_10_1_10_1 —> null

t_i_10_1_20_2 --> null

t_i_10_1_30_3 --> null

元信息管理

上节介绍了表中的数据和索引是如何映射为 KV,本节介绍一下元信息的存储。Database/Table 都有元信息,也就是其定义以及各项属性,这些信息也需要持久化,我们也将这些信息存储在 TiKV 中。每个 Database/Table 都被分配了一个唯一的 ID,这个 ID 作为唯一标识,并且在编码为 Key-Value 时,这个 ID 都会编码到 Key 中,再加上 m_ 前缀。这样可以构造出一个 Key,对应的 Value 中存储的是序列化后的元信息。

除此之外,还有一个专门的 Key-Value 存储当前 Schema 信息的版本。TiDB 使用 Google F1 的 Online Schema 变更算法,有一个后台线程在不断的检查 TiKV 上面存储的 Schema 版本是否发生变化,并且保证在一定时间内一定能够获取版本的变化(如果确实发生了变化)。这部分的具体实现参见文章:TiDB 的异步 schema 变更实现。

(https://github.com/ngaut/builddatabase/blob/master/f1/schema-change-implement.md) 

SQL on KV 架构

SQL 运算

理解了 SQL 到 KV 的映射方案之后,我们可以理解关系数据是如何保存的,接下来我们要理解如何使用这些数据来满足用户的查询需求,也就是一个查询语句是如何操作底层存储的数据。

能想到的最简单的方案就是通过上一节所述的映射方案,将 SQL 查询映射为对 KV 的查询,再通过 KV 接口获取对应的数据,最后执行各种计算。

比如 Select count(*) from user where name="TiDB"; 这样一个语句,我们需要读取表中所有的数据,然后检查 Name 字段是否是 TiDB,如果是的话,则返回这一行。这样一个操作流程转换为 KV 操作流程:

  • 构造出 Key Range:一个表中所有的 RowID 都在 [0, MaxInt64) 这个范围内,那么我们用 0 和 MaxInt64 根据 Row 的 Key 编码规则,就能构造出一个 [StartKey, EndKey) 的左闭右开区间

  • 扫描 Key Range:根据上面构造出的 Key Range,读取 TiKV 中的数据

  • 过滤数据:对于读到的每一行数据,计算 name="TiDB" 这个表达式,如果为真,则向上返回这一行,否则丢弃这一行数据

  • 计算 Count:对符合要求的每一行,累计到 Count 值上面

整个流程示意图如下:

 

这个方案肯定是可以 Work 的,但是并不能 Work 的很好,原因是显而易见的:

1. 在扫描数据的时候,每一行都要通过 KV 操作同 TiKV 中读取出来,至少有一次 RPC 开销,如果需要扫描的数据很多,那么这个开销会非常大;

2. 并不是所有的行都有用,如果不满足条件,其实可以不读取出来;

3. 符合要求的行的值并没有什么意义,实际上这里只需要有几行数据这个信息就行。

分布式 SQL 运算

如何避免上述缺陷也是显而易见的,首先我们需要将计算尽量靠近存储节点,以避免大量的 RPC 调用。其次,我们需要将 Filter 也下推到存储节点进行计算,这样只需要返回有效的行,避免无意义的网络传输。最后,我们可以将聚合函数、GroupBy 也下推到存储节点,进行预聚合,每个节点只需要返回一个 Count 值即可,再由 tidb-server 将 Count 值 Sum 起来。

这里有一个数据逐层返回的示意图:

 

sql优化

传统数据库的提速方法

传统数据库有很多提速的方法,有两种比较有名的,一个是 MPP,它的架构参见下图。计算数据是分在不同的节点上,并且很可能不在一台机器上,它们通过高速的网络连接,让每个节点都自己去处理数据,处理完数据之后再汇总在一起,最后给用户返回结果。


这个架构最大的特点就是它是一种 share nothing 的架构,也就是说节点之间的计算是相互不知道的,然后他们只执行自己的事情,不需要去交换数据,这是一种架构。
还有一种叫 SMP,这个跟 MPP 对应,它是一种 share everything 的架构。


这种架构一般都是在一个 note 上、一个计算节点上进行,然后它们有多个 CPU 同时计算,它们会去通过总线去共享,比如说内存、IO 这样一些东西,这是一种 share everything 的一个架构。可以看到 MPP 和 SMP 这是两种传统数据库中用来提速的一些方案。我看到 PG(PostgresSQL) 最新的代码,他们已经支持了并行的处理,比如他们可以做并行 scan,他们可以去定义并发度,比如说 scan 一个表,他们利用多核这个特性,能够提速很多。当然这个肯定不是线性的,因为你去做并行,做数据交换,是有 overhead 的,这个 overhead 在你并行度太高的时候是挺大的。

▌TiDB

说完传统数据,我们说一下 TiDB。TiDB 的架构如下图所示。

虚框所标的是 TiDB SQL layer,它的最上层是 protocol layer ,就是解析 MySQL 协议。然后是 SQL layer,它主要负责 SQL 的解析、查询,查询计划的制定以及生成执行器。它会调用底下的接口来获取数据,然后进行 SQL 的运算。接下来这一层,可以看到,分两个接口,一个就是 KV 的 API ,就是我们会把数据映射为 KV,因为我们最底下一层是一个 KV 的 storage engine 。比如说一行数据我们会用 Row ID 加上 Table ID 加上 Database ID 这些来做一个 key,然后把这行里面的数据作为 value ,再扔到 KV 中,就转成一种 key-value 的模式。对于 index 来说,我们也是转成了 KV 的模式,因为我们的 KV 有一个特点,就是可以进行有序的 scan。比如说你要在某些 Column 上建了 index,我们就会把这个 Column 编码成一个 key,然后再加上 index ID、Table ID 之类的东西,也 send  到这个 KV 里面去。就是说我们的上层,你可以认为只通过这个 API 也是能够正确的获取到数据、访问数据的,大概就是这样一个架构。然后这里还有一个 DistSQL API,这个是我们分布式计算框架的对上层提供了一个抽象,后面我会详细介绍这个 API 。最下面一层,就是我们的 TiKV 。为了支持我们分布式 SQL 的 API ,我们给它上面加了更多功能,在这里我们有参考 HBase 的 coprocessor 方案,然后提供一些  EndPoint 的功能,这样对上一层可以提供更丰富的语义。

那么,我们怎么让 SQL 在 TiDB/TiKV 中跑的更快?

第一就是不管是 NewSQL 数据库还是传统数据库,我们肯定要对 optimizer 进行一些优化,在这方面我们做了特别多特别多的事情,包括常量折叠,后面还会做更多的,比如常量传播这些。

除了优化器这块儿,确定一个查询计划之后,怎么去执行它,也是很重要的事情。

  • 利用数据广泛分布的特点,提高整体的并行度。而且我们 TiDB SQL layer 是用 Go 来写的,Go 在多核机器上能够发挥并发优势,它的 Goroutine 调度的开销很小,我们可以建很多 Goroutine,利用现在 CPU 越来越多的这个特性,去提高计算的并行度。还有就是说像传统的,比如 MySQL 、PG 上的东西,它主要还是访问内存、访问硬盘这样的一些开销。但对 TiDB 来说,它很大一部分都耗在网络上了,就是说你发一个请求过去,拿到数据,要走一遍网络,这还是有挺大开销的。所以我们一个很重要的目的就是让整个数据的流程尽可能快起来,尽可能的平滑,把网络这种开销尽可能的搞掉。


 

  • Regions: 存储数据。上图中的 Regions 是 TiKV 的一个 region servers ,我们可以在这里面插入一些代码,让它能够执行我们给它定义的一些任务。
  • TiKV Client: 就是我们经过 SQL Optimizer 之后生成执行计划,而后我们会根据执行计划生成这个执行器。通过它来访问 TiKV,通过 Rpc 发送请求。它还有一个很重要的功能就是能获取数据分布在哪儿。因为一个表会分成很多 KV,这些 KV 是散列在很多很多 TiKV server 上的,它很重要的功能就是干这个事情,就相当于数据路由,是它一个重要的任务。
  • DistSQL API: DistSQL API 是Executor 和TiKV Client两层之间的封装,就是说我们下面除了 TiKV 之外,还可以接其它的存储引擎,只要你满足我们这个接口的定义就可以。
  • Executor 制定执行逻辑就是说它要告诉下面你需要干什么事情。比如你是需要做 count ,还是需要计算 Where ,它理解的是 SQL 逻辑。

SQL 层架构

上面简要介绍了 SQL 层的一些功能,希望大家对 SQL 语句的处理有一个基本的了解。实际上 TiDB 的 SQL 层要复杂的多,模块以及层次非常多,下面这个图列出了重要的模块以及调用关系:

用户的 SQL 请求会直接或者通过 Load Balancer 发送到 tidb-server,tidb-server 会解析 MySQL Protocol Packet,获取请求内容,然后做语法解析、查询计划制定和优化、执行查询计划获取和处理数据。数据全部存储在 TiKV 集群中,所以在这个过程中 tidb-server 需要和 tikv-server 交互,获取数据。最后 tidb-server 需要将查询结果返回给用户。

DDL

DDL in TiDB

TiDB 的 DDL 通过实现 Google F1 的在线异步 schema 变更算法,来完成在分布式场景下的无锁,在线 schema 变更。为了简化设计,TiDB 在同一时刻,只允许一个节点执行 DDL 操作。用户可以把多个 DDL 请求发给任何 TiDB 节点,但是所有的 DDL 请求在 TiDB 内部是由 owner 节点的 worker 串行执行的。

  • worker:每个节点都有一个 worker 用来处理 DDL 操作。
  • owner:整个集群中只有一个节点能当选 owner,每个节点都可能当选这个角色。当选 owner 后的节点 worker 才有处理 DDL 操作的权利。owner 节点的产生是用 Etcd 的选举功能从多个 TiDB 节点选举出 owner 节点。owner 是有任期的,owner 会主动维护自己的任期,即续约。当 owner 节点宕机后,其他节点可以通过 Etcd 感知到并且选举出新的 owner。

这里只是简单概述了 TiDB 的 DDL 设计,下两篇文章详细介绍了 TiDB DDL 的设计实现以及优化,推荐阅读:

  • TiDB 的异步 schema 变更实现

  • TiDB 的异步 schema 变更优化

下图描述了一个 DDL 请求在 TiDB 中的简单处理流程:

 

图 1:TiDB 中 DDL SQL 的处理流程

TiDB 的 DDL 组件相关代码存放在源码目录的 ddl 目录下。

File Introduction
ddl.go 包含 DDL 接口定义和其实现。
ddl_api.go 提供 create , drop , alter , truncate , rename 等操作的 API,供 Executor 调用。主要功能是封装 DDL 操作的 job 然后存入 DDL job queue,等待 job 执行完成后返回。
ddl_worker.go DDL worker 的实现。owner 节点的 worker 从 job queue 中取 job,然后执行,执行完成后将 job 存入 job history queue 中。
syncer.go 负责同步 ddl worker 的 owner 和 follower 间的 schema version 。 每次 DDL 状态变更后 schema version ID 都会加 1。

ddl owner 相关的代码单独放在 owner 目录下,实现了 owner 选举等功能。

另外, ddl job queue 和 history ddl job queue 这两个队列都是持久化到 TiKV 中的。 structure 目录下有 list, hash 等数据结构在 TiKV 上的实现。

PD

TiKV 集群是 TiDB 数据库的分布式 KV 存储引擎,数据以 Region 为单位进行复制和管理,每个 Region 会有多个 Replica(副本),这些 Replica 会分布在不同的 TiKV 节点上,其中 Leader 负责读/写,Follower 负责同步 Leader 发来的 raft log。了解了这些信息后,请思考下面这些问题:

  • 如何保证同一个 Region 的多个 Replica 分布在不同的节点上?更进一步,如果在一台机器上启动多个 TiKV 实例,会有什么问题?

  • TiKV 集群进行跨机房部署用于容灾的时候,如何保证一个机房掉线,不会丢失 Raft Group 的多个 Replica?

  • 添加一个节点进入 TiKV 集群之后,如何将集群中其他节点上的数据搬过来?

  • 当一个节点掉线时,会出现什么问题?整个集群需要做什么事情?如果节点只是短暂掉线(重启服务),那么如何处理?如果节点是长时间掉线(磁盘故障,数据全部丢失),需要如何处理?

  • 假设集群需要每个 Raft Group 有 N 个副本,那么对于单个 Raft Group 来说,Replica 数量可能会不够多(例如节点掉线,失去副本),也可能会 过于多(例如掉线的节点又回复正常,自动加入集群)。那么如何调节 Replica 个数?

  • 读/写都是通过 Leader 进行,如果 Leader 只集中在少量节点上,会对集群有什么影响?

  • 并不是所有的 Region 都被频繁的访问,可能访问热点只在少数几个 Region,这个时候我们需要做什么?

  • 集群在做负载均衡的时候,往往需要搬迁数据,这种数据的迁移会不会占用大量的网络带宽、磁盘 IO 以及 CPU?进而影响在线服务?

这些问题单独拿出可能都能找到简单的解决方案,但是混杂在一起,就不太好解决。有的问题貌似只需要考虑单个 Raft Group 内部的情况,比如根据副本数量是否足够多来决定是否需要添加副本。但是实际上这个副本添加在哪里,是需要考虑全局的信息。整个系统也是在动态变化,Region 分裂、节点加入、节点失效、访问热点变化等情况会不断发生,整个调度系统也需要在动态中不断向最优状态前进,如果没有一个掌握全局信息,可以对全局进行调度,并且可以配置的组件,就很难满足这些需求。因此我们需要一个中心节点,来对系统的整体状况进行把控和调整,所以有了 PD 这个模块。

调度的需求

上面罗列了一大堆问题,我们先进行分类和整理。总体来看,问题有两大类:

作为一个分布式高可用存储系统,必须满足的需求,包括四种

  • 副本数量不能多也不能少

  • 副本需要分布在不同的机器上

  • 新加节点后,可以将其他节点上的副本迁移过来

  • 节点下线后,需要将该节点的数据迁移走

作为一个良好的分布式系统,需要优化的地方,包括:

  • 维持整个集群的 Leader 分布均匀

  • 维持每个节点的储存容量均匀

  • 维持访问热点分布均匀

  • 控制 Balance 的速度,避免影响在线服务

  • 管理节点状态,包括手动上线/下线节点,以及自动下线失效节点

满足第一类需求后,整个系统将具备多副本容错、动态扩容/缩容、容忍节点掉线以及自动错误恢复的功能。满足第二类需求后,可以使得整体系统的负载更加均匀、且可以方便的管理。

为了满足这些需求,首先我们需要收集足够的信息,比如每个节点的状态、每个 Raft Group 的信息、业务访问操作的统计等;其次需要设置一些策略,PD 根据这些信息以及调度的策略,制定出尽量满足前面所述需求的调度计划;最后需要一些基本的操作,来完成调度计划。

调度的基本操作

我们先来介绍最简单的一点,也就是调度的基本操作,也就是为了满足调度的策略,我们有哪些功能可以用。这是整个调度的基础,了解了手里有什么样的锤子,才知道用什么样的姿势去砸钉子。

上述调度需求看似复杂,但是整理下来最终落地的无非是下面三件事:

  • 增加一个 Replica

  • 删除一个 Replica

  • 将 Leader 角色在一个 Raft Group 的不同 Replica 之间 transfer

刚好 Raft 协议能够满足这三种需求,通过 AddReplica、RemoveReplica、TransferLeader 这三个命令,可以支撑上述三种基本操作。

信息收集

调度依赖于整个集群信息的收集,简单来说,我们需要知道每个 TiKV 节点的状态以及每个 Region 的状态。TiKV 集群会向 PD 汇报两类消息:

每个 TiKV 节点会定期向 PD 汇报节点的整体信息

TiKV 节点(Store)与 PD 之间存在心跳包,一方面 PD 通过心跳包检测每个 Store 是否存活,以及是否有新加入的 Store;另一方面,心跳包中也会携带这个 Store 的状态信息,主要包括:

  • 总磁盘容量

  • 可用磁盘容量

  • 承载的 Region 数量

  • 数据写入速度

  • 发送/接受的 Snapshot 数量(Replica 之间可能会通过 Snapshot 同步数据)

  • 是否过载

  • 标签信息(标签是具备层级关系的一系列 Tag)

每个 Raft Group 的 Leader 会定期向 PD 汇报信息

每个 Raft Group 的 Leader 和 PD 之间存在心跳包,用于汇报这个 Region 的状态,主要包括下面几点信息:

  • Leader 的位置

  • Followers 的位置

  • 掉线 Replica 的个数

  • 数据写入/读取的速度

PD 不断的通过这两类心跳消息收集整个集群的信息,再以这些信息作为决策的依据。除此之外,PD 还可以通过管理接口接受额外的信息,用来做更准确的决策。比如当某个 Store 的心跳包中断的时候,PD 并不能判断这个节点是临时失效还是永久失效,只能经过一段时间的等待(默认是 30 分钟),如果一直没有心跳包,就认为是 Store 已经下线,再决定需要将这个 Store 上面的 Region 都调度走。但是有的时候,是运维人员主动将某台机器下线,这个时候,可以通过 PD 的管理接口通知 PD 该 Store 不可用,PD 就可以马上判断需要将这个 Store 上面的 Region 都调度走。

调度的策略

PD 收集了这些信息后,还需要一些策略来制定具体的调度计划。

一个 Region 的 Replica 数量正确

当 PD 通过某个 Region Leader 的心跳包发现这个 Region 的 Replica 数量不满足要求时,需要通过 Add/Remove Replica 操作调整 Replica 数量。出现这种情况的可能原因是:

  • 某个节点掉线,上面的数据全部丢失,导致一些 Region 的 Replica 数量不足

  • 某个掉线节点又恢复服务,自动接入集群,这样之前已经补足了 Replica 的 Region 的 Replica 数量多过,需要删除某个 Replica

  • 管理员调整了副本策略,修改了 max-replicas 的配置

一个 Raft Group 中的多个 Replica 不在同一个位置

注意第二点,『一个 Raft Group 中的多个 Replica 不在同一个位置』,这里用的是『同一个位置』而不是『同一个节点』。在一般情况下,PD 只会保证多个 Replica 不落在一个节点上,以避免单个节点失效导致多个 Replica 丢失。在实际部署中,还可能出现下面这些需求:

  • 多个节点部署在同一台物理机器上

  • TiKV 节点分布在多个机架上,希望单个机架掉电时,也能保证系统可用性

  • TiKV 节点分布在多个 IDC 中,向单个机房掉电时,也能保证系统可用

这些需求本质上都是某一个节点具备共同的位置属性,构成一个最小的容错单元,我们希望这个单元内部不会存在一个 Region 的多个 Replica。这个时候,可以给节点配置 lables 并且通过在 PD 上配置 location-labels 来指名哪些 lable 是位置标识,需要在 Replica 分配的时候尽量保证不会有一个 Region 的多个 Replica 所在结点有相同的位置标识。

副本在 Store 之间的分布均匀分配

前面说过,每个副本中存储的数据容量上限是固定的,所以我们维持每个节点上面,副本数量的均衡,会使得总体的负载更均衡。

Leader 数量在 Store 之间均匀分配

Raft 协议要读取核写入都通过 Leader 进行,所以计算的负载主要在 Leader 上面,PD 会尽可能将 Leader 在节点间分散开。

访问热点数量在 Store 之间均匀分配

每个 Store 以及 Region Leader 在上报信息时携带了当前访问负载的信息,比如 Key 的读取/写入速度。PD 会检测出访问热点,且将其在节点之间分散开。

各个 Store 的存储空间占用大致相等

每个 Store 启动的时候都会指定一个 Capacity 参数,表明这个 Store 的存储空间上限,PD 在做调度的时候,会考虑节点的存储空间剩余量。

控制调度速度,避免影响在线服务

调度操作需要耗费 CPU、内存、磁盘 IO 以及网络带宽,我们需要避免对线上服务造成太大影响。PD 会对当前正在进行的操作数量进行控制,默认的速度控制是比较保守的,如果希望加快调度(比如已经停服务升级,增加新节点,希望尽快调度),那么可以通过 pd-ctl 手动加快调度速度。

支持手动下线节点

当通过 pd-ctl 手动下线节点后,PD 会在一定的速率控制下,将节点上的数据调度走。当调度完成后,就会将这个节点置为下线状态。

调度的实现

了解了上面这些信息后,接下来我们看一下整个调度的流程。

PD 不断的通过 Store 或者 Leader 的心跳包收集信息,获得整个集群的详细数据,并且根据这些信息以及调度策略生成调度操作序列,每次收到 Region Leader 发来的心跳包时,PD 都会检查是否有对这个 Region 待进行的操作,通过心跳包的回复消息,将需要进行的操作返回给 Region Leader,并在后面的心跳包中监测执行结果。注意这里的操作只是给 Region Leader 的建议,并不保证一定能得到执行,具体是否会执行以及什么时候执行,由 Region Leader 自己根据当前自身状态来定。

TiDB监控

TiDB监控

TiDB监控框架

Prometheus负责收集和存储时间序列数据。PushGateway来接收Client Push上来的数据,同一供Prometheus拉去。以及AlertManager来实现报警机制。

重要监控指标

TIDB Server

  • query 处理时间,可以看到延迟和吞吐
  • ddl 过程监控
  • TiKV client 相关的监控
  • PD client 相关的监控

PD Server

  • 命令执行的总次数
  • 某个命令执行失败的总次数
  • 某个命令执行成功的耗时统计
  • 某个命令执行失败的耗时统计
  • 某个命令执行完成并返回结果的耗时统计

TiKV Server

  • GC 监控
  • 执行 KV 命令的总次数
  • Scheduler 执行命令的耗时统计
  • Raft propose 命令的总次数
  • Raft 执行命令的耗时统计
  • Raft 执行命令失败的总次数
  • Raft 处理 ready 状态的总次数

具体可查询:https://pingcap.com/docs-cn/op-guide/dashboard-overview-info/

备份与恢复

数据迁移(将MySQL的数据迁移到TiDB)

TiDB集群部署

 

组件

CPU

内存

本地存储

实例名称

机器IP

TiDB

2核

4 GB

100GB

测试-业务平台-供应链-TiDB-PD01

 

PD

2核

4 GB

100GB

测试-业务平台-供应链-TiDB-PD01

 

TiKV

2核

4 GB

100GB

测试-业务平台-供应链-TiDB-TiKV01~03

 

Monitor

 

 

 

 

 

 

官网部署流程:https://pingcap.com/docs-cn/op-guide/ansible-deployment/

其他工具

Syncer 简介

Syncer是一个数据导入工具,能方便地将MySQL的数据增量导入到TiDB。类似canal

Loader 简介

读取mydumper的输出数据文件,通过MSQL protocol向TiDB/MySQL中导入数据

  • 支持多线程导入
  • 支持表级别的并发导入,分散写入热点
  • 支持对单个大表并发导入,分散写入热点
  • 支持mydumper数据格式
  • 出错重试
  • 断点续导
  • 通过system variable优化TiDB导入数据速度

TiDB-Binlog 简介

TiDB-Binlog用于收集TiDB的Binlog,,并提供实时备份和同步功能的商业工具。

  • 数据同步:同步TiDB集群数据到其他数据库
  • 备份TiDB集群数据,同时可以用于TiDB集群故障时恢复

TiDB-Binlog 架构

Pump:在每个TiDB主机的后台运行,实时记录TiDB产生的Binlog并顺序写入Kafka中。

Drainer:从Kafka中收集Binlog,并按照TiDB中事务的提交顺序转化为指定数据库兼容的SQL语句,最后同步到目的数据库或写到顺序文件。

 

TiDB官方文档:https://pingcap.com/docs-cn/

SQL特点

 

然而TIDB从根本上解决了并实现了分布式数据库,高度兼容MySQL,不需要任何中间件,也无需改变业务开发人员已有的习惯和程序。如果你仅仅关注的是你的业务实现,也不要认为TIDB很神秘,你就把他当做一个单机版的MySQL数据库使用就行了,几乎无需修改代码。

不支持的天条

  1. 存储过程(如果即使支持,也不要使用存储过程,难于移植和扩展)
  2. 视图
  3. 触发器
  4. 自定义函数
  5. 外键约束
  6. 全文索引
  7. 空间索引
  8. 非UTF-8字符集

差异性天条

  1. 表上必须要有唯一索引或者主键
  2. 自增列(auto_increment)只确保唯一,没有顺序性概念;所以在insert的时候不要设置自增列的值
  3. 事务隔离级别采用的是可重复读(TIDB与MySQL和Oracle的可重复读是有区别的,TIDB的可重复读隔离机制个人觉得类似于串行化)
  4. Select …for update 不会给数据枷锁,只是在更新本事务提交时报错而已
  5. 事务大小限制
    1. 单条 KV entry 不超过 6MB
    2. KV entry 的总条数不超过 30w(官网建议值10000,但是要具体到表上的索引,根据索引数量好像是2倍关系的递减)
    3. KV entry 的总大小不超过 100MB
  1. DML语句
    1. 基本MySQL语句都支持,开发中只碰到格式化后的select count(1)报错
  1. DDL语句
    1. Add/Drop primary key 操作目前不支持。
    2. Add Index/Column 操作不支持同时创建多个索引或列。
    3. Drop Column 操作不支持删除的列为主键列或索引列。
    4. Add Column 操作不支持同时将新添加的列设为主键或唯一索引,也不支持将此列设成 auto_increment 属性。
    5. Change/Modify Column 操作目前支持部分语法,细节如下:

在修改类型方面,只支持整数类型之间修改,字符串类型之间修改和 Blob 类型之间的修改,且只能使原类型长度变长。此外,不能改变列的 unsigned/charset/collate 属性。这里的类型分类如下:

具体支持的整型类型有:TinyInt,SmallInt,MediumInt,Int,BigInt。

具体支持的字符串类型有:Char,Varchar,Text,TinyText,MediumText,LongText。

具体支持的 Blob 类型有:Blob,TinyBlob,MediumBlob,LongBlob。

在修改类型定义方面,支持的包括 default value,comment,null,not null 和 OnUpdate,但是不支持从 null 到 not null 的修改。

个人建议

  1. 索引
    1. 业务字段即使多个字段组合构成唯一索引时,必须建成唯一索引
    2. 减少join的使用,尽量在业务层面实现join,如果非要使用关联字段要求要索引
    3. 组合索引,数据区分度高的字段在最前面
  1. 语句
    1. 禁止使用存储过程
    2. 不要使用count(列),尽量使用count(1)
    3. Sum函数注意null
    4. 不得使用级联操作(TIDB也不支持)
    5. 不建议使用truncate
    6. 分批删除使用limit

 

你可能感兴趣的:(数据库)