TiDB基础学习

特性

newSql技术有如下显著特点

  • 水平扩展能力
  • 分布式强一致性
  • 完整的分布式事务处理哪里与acid特性

传统的关系型数据库历史悠久,如mysql,pgsql,有一些问题,如自身容量限制,随着业务不断增加,容量渐渐成为瓶颈.此时DBA会通过分库分表来解决问题.除此之外RDBMS伸缩性较差,通常集群扩容成本较高,且不满足分布式的事务.

NoSql数据库代表为Hbase,Redis,MongoDB等,这类数据库解决了RDBMS伸缩性的问题,集群扩容变得方便很多,但是由于存储方式为多个KV存储,所以对sql的兼容性就大打折扣,对于NoSql类数据库来说,只能满足部分分布式事务的特点.

TiDB

开源分布式,关系型数据库.tidb是一款定位于在线事务处理,在线分析的融合技术性产品,实现了一键水平扩容,强一致性的多副本数据安全,实时OLAP.同时兼容mysql协议和生态.

TiDB的设计目标是100%的OLTP场景和80%的OLAP场景,更复杂的OLAP分析可以通过TIspark来完成

TiDB对于业务没有任何的入侵性,能优雅的替换传统的数据库中间件,数据库分库分表等sharding方案.同时也让开发人员不用关心数据库scale的细节问题.专注业务开发

newsql

Nosql问题:Nosql不能完全取代RDBMS,单机RDBMS无法满足性能需求

newSql:

无共享存储(MPP架构)是比较常见的架构

基于多副本实现高可用和容灾,分布式查询,数据Sharding机制

通过2PC,paxos/raft等协议实现数据一致性

架构

整体架构

TiDB主要包括三个核心组件:TiDB Server ,PD Server,TiKV Server

此外还有用于解决复杂OLAP需求的TiSpark组件和简化云上部署管理的TiDB Operator组件

PD:整个集群的管理者,对存储元数据进行负载均衡,生成全局唯一的事务id

TiKV :真正的存储数据的,本质上是一个key v存储引擎

TiDB Cluster:负责接收sql请求,通过pd中存储的元数据找到数据存储在哪个kv上,并于TiKV进行交互,将结果返回给用户

TiSpark :解决用户复杂的OLAP查询需求

核心特性

高度兼容Mysql

大多数情况下无需修改代码即可从mysql轻松迁移至TiDB,分库分表后的Mysql集群亦可以通过TiDB根据进行实时迁移

对用户使用的时候,可以透明地从mysql切换到TiDB中.运维使用时也可以将TiDB当做一个从库挂到Mysql主从架构中

TiDBServer

tiDB是无状态的,推荐至少部署俩个实例,前端通过负载均衡组件对外提供服务.当单个实例失效时,会影响正在这个实例上进行的session,从应用角度看会出现单次请求失败的情况,重新连接后即可继续获得服务.单个实例失效后可以重启这个实例或者部署一个新的实例

PD

PD是一个集群,通过raft保持数据的一致性,单个实例失效时,如果这个实例不是raft的leader,那么服务完全不受影响;如果这个实例是raft的leader,会重新选出新的raft leader,自动恢复服务.再选举的过程中无法对外提供服务,大概是3秒.推荐至少部署三个PD实例,单个实例失效后重启这个实例或者添加新的实例

TiKV

TiKV是一个集群,通过Raft协议保持数据一致性(副本数量可配置,默认保存三副本),并通过PD做负载均衡调度,单个节点失效时,会影响这个节点好耍那个存储的所有Region.对于Region中的Leader节点,会中断服务,等待重新选举;对Region中的Follower节点不会影响服务.当某个TiKV节点失效,并且在一段时间内无法恢复(默认30分钟),PD会将其上的数据迁移到其他节点上

存储&计算能力

存储能力

TiKV Server通常是3+的,TiDB没分数据缺省位3副本,这一点于HDFS有些相似,通过Rat协议进行赋值,TiServer上的数据是以Region为单位进行,由PD Server集群机箱统一调度,类似Hbase的region调度

TiKV集群存储的书格式是kv的,再TiDB中,并不是将数据直接存储在HDD/SSD中,而是通过RocksDB实现了TB级别的存储能力,RocksDB和HBASE一样,都是通过LSM树作为存储方案,避免了B+树叶子结点碰撞带来大量的随机读写.从而提升了整体的吞吐量.

计算能力

TiDB本身是无状态的,意味着当计算能力成为瓶颈的时候,可以直接扩容机器,对用户是透明的.理论上TiDB的数量并没有上限限制

TiDB读取历史数据

TiDB实现通过标准SQL读取历史数据功能,无需特殊的client或者driver.当数据被更新删除后,依然可以通过sql接口将更新删除前的数据读取出来

为支持读取历史版本数据,引入了一个新的 system variable:tidb_snapshot,这个变量是session范围有效,可以通过标准的set语句修改其值

其值为文本,能够存储TSO和日期时间.TSO即是全局授时的时间戳,是从PD端获取的.可以一般来说写到秒.

当这个变量被设置是,TiDb会用这个时间戳建立Snapshot(没有开销,只是创建数据结构),随后所有的select 操作都会在这个snapshot上读取数据

TiDB事务是通过PD进行全局授权时,所以存储的数据版本也是PD所授时间戳为版本号.再生成snapshot时,是一tidb_snapshot变量的值作为版本号,如果TiDB server所在的机器和PD Server所在的机器本地时间相差较大,需要以PD的时间为准

当读取历史版本操作结束后,可以结束当前session,或者是通过set语句将tidb_snashot设置为""即可读取最新版本数据

内部原理

存储

Key-Value

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

TiKV

TiKV没有选择直接向磁盘上写数据,而是把数据保存在RocksDB中,具体的数据落地让RocksDB负责

region

为了实现存储水平扩展,我们需要将数据分散在多台机器上,典型的方案,一种是按照key做hash,根据hash值选择对应存储节点,另一种是分range,某一段连续的key都保存在一个存储节点上

TiKV选择了第二种方式,将整个Key-Value空间分成很多段,每一段是连续的Key我们将每一段叫做一个Region,并且我们会劲量保持每个Region中保存的数据不超过一定的大小(这个大小可配置目前是64mb).每个region都可以用startKey到EndKey这样一个左开右闭的区间来描述

  • 以region为单位,将数据分散在集群中的所有节点上,并且尽量保持每个节点上服务的Region数量不多
  • 以Region为单位做Raft的复制和成员管理

数据按照key切分成Region,每个Region的数据只会保存在一个节点上面.我们系统将会有一个组件来负责将Region尽可能均匀散布在集群中所有节点上,这样一方面实现了存储容量的水平扩展(增加新的节点后,会自动将其他节点上的Region调度过来),另一方面也实现了数据的均衡

同时为了保证上层客户端能够访问所需要的数据,我们的系统也会有一个组件记录region在节点上面的分布情况,他就是通过任意一个key就能查询到这个key在哪个region中,以及这个region目前在哪个节点上.

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

以region为单位做数据的分散和复制,就由了一个分布式的具备一定容灾能力的keyValue系统,不用担心数据存不下,或者磁盘故障丢书数据的问题

MVCC

很多数据库都会实现多版本控制(MVCC),TiDB也不例外

注意,对于同一个key的多个版本,我们把版本号较大的放在前面,版本号小的放在后面,这样用户通过key+version来获取value的时候,可以将key和version构造出mvcc的key也就是key-Version.然后可以直接Seek(key-version),定位到一个大于等于整个Key-Version的位置

事务

TiKV的事务采用的是percolator模型,并且做了大量的优化.TiKV的事务采用乐观锁,事务执行的过程中,不会检测谢谢冲突,只有提交的过程中才会做冲突检测,冲突双方比较早完成提交的会写入成功,另一方会尝试重新执行整个事务.当业务的写入冲突不严重的情况下,这种模型性能会很好,但是写入冲突严重就会很差

模型到key-value映射

在这我们将关系模型简单理解为 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. 索引数据

表的元信息我们暂时不讨论,会有专门的章节来介绍。

对于 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 hwere 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

最后还有通过索引读取数据的需求,对索引的使用可能是点查或者是范围查询。

大致的需求已经分析完了,现在让我们看看手里有什么可以用的:一个全局有序的分布式 Key-Value 引擎。全局有序这一点重要,可以帮助我们解决不少问题。比如对于快速获取一行数据,假设我们能够构造出某一个或者某几个 Key,定位到这一行,我们就能利用 TiKV 提供的 Seek 方法快速定位到这一行数据所在位置。再比如对于扫描全表的需求,如果能够映射为一个 Key 的 Range,从 StartKey 扫描到 EndKey,那么就可以简单的通过这种方式获得全表数据。操作 Index 数据也是类似的思路。接下来让我们看看 TiDB 是如何做的。

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 包 。采用这种编码后,一个表的所有 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 变更实现。

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 起来。
这里有一个数据逐层返回的示意图:

实际上 TiDB 的 SQL 层要复杂的多,模块以及层次非常多,下面这个图列出了重要的模块以及调用关系:

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

Last modification:November 17, 2023
如果觉得我的文章对你有用,请随意赞赏