关系模型到Key-Value模型的映射
参考自https://book.tidb.io/session1/chapter3/tidb-computing.html
将关系模型简单理解为 Table 和 SQL 语句,那么问题变为如何在 KV 结构上保存 Table 以及如何在 KV 结构上运行 SQL 语句。
对于一个 Table 来说,需要存储的数据包括三部分:
- 表的元信息
- Table 中的 Row
- 索引数据
操作需求
对于 Insert 语句,需要将 Row 写入 KV,并且建立好索引数据。
对于 Update 语句,需要将 Row 更新的同时,更新索引数据(如果有必要)。
对于 Delete 语句,需要在删除 Row 的同时,将索引也删除。
对于 Select 语句,首先需要能够简单快速地读取一行数据,所以每个 Row 需要有一个 ID (显示或隐式的 ID)。其次可能会读取连续多行数据,比如 Select * from user;
。最后还有通过索引读取数据的需求,对索引的使用可能是点查或者是范围查询。
底层存储
有一个全局有序的分布式 Key-Value 引擎。对于快速获取一行数据,假设我们能够构造出某一个或者某几个 Key,定位到这一行,我们就能利用 KV存储引擎 提供的 Seek 方法快速定位到这一行数据所在位置。再比如对于扫描全表的需求,如果能够映射为一个 Key 的 Range,从 StartKey 扫描到 EndKey,那么就可以简单的通过这种方式获得全表数据。操作 Index 数据也是类似的思路。
TiDB
表数据
要将一行中各列数据映射成一个 (Key, Value) 键值对 ,需要考虑如何构造 Key。
- 为了保证同一个表的数据放在一起,方便查找,TiDB 会为每个表分配一个表 ID,用
TableID
表示。表 ID 是一个整数,在整个集群内唯一。 - TiDB 会为表中每行数据分配一个行 ID,用
RowID
表示。行 ID 也是一个整数,在表内唯一。对于行 ID,TiDB 做了一个小优化,如果某个表有整数型的主键,TiDB 会使用主键的值当做这一行数据的行 ID。
每行数据按照如下规则编码成 (Key, Value) 键值对:
Key: tablePrefix{TableID}_recordPrefixSep{RowID}
Value: [col1, col2, col3, col4]
其中 tablePrefix
和 recordPrefixSep
都是特定的字符串常量,用于在 Key 空间内区分其他数据。
索引数据
TiDB 为表中每个索引分配了一个索引 ID,用 IndexID
表示。
对于主键和唯一索引,我们需要根据键值快速定位到对应的 RowID,因此,按照如下规则编码成 (Key, Value) 键值对:
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue
Value: RowID
对于不需要满足唯一性约束的普通二级索引,一个键值可能对应多行,我们需要根据键值范围查询对应的 RowID。 因此,按照如下规则编码成 (Key, Value) 键值对:
Key: tablePrefix{TableID}_indexPrefixSep{IndexID}_indexedColumnsValue_{RowID}
Value: null
无论是表数据还是索引数据的 Key 编码方案,一个表内所有的行都有相同的 Key 前缀,一个索引的所有数据也都有相同的前缀。这样具有相同的前缀的数据,在 TiKV 的 Key 空间内,是排列在一起的。因此只要小心地设计后缀部分的编码方案,保证编码前和编码后的比较关系不变,就可以将表数据或者索引数据有序地保存在 TiKV 中。
CREATE TABLE User {
ID int,
Name varchar(20),
Role varchar(20),
Age int,
PRIMARY KEY (ID),
KEY idxAge (Age)
};
TiDB SQL 层做完 SQL 解析后,会将 SQL 的执行计划转换为实际对 TiKV API 的调用。
TiDB SQL层
TiDB 的 SQL层,即 tidb-server,跟 Google 的 F1 比较类似,负责将 SQL 翻译成 Key-Value 操作,将其转发给共用的分布式 Key-Value 存储层 TiKV,然后组装 TiKV 返回的结果,最终将查询结果返回给客户端。这一层的节点都是无状态的,节点本身并不存储数据,节点之间完全对等。
将 SQL 查询映射为对 KV 的查询,再通过 KV 接口获取对应的数据,最后执行各种计算。
比如 select count(*) from user where name = "TiDB"
这样一个语句,我们需要读取表中所有的数据,然后检查 name
字段是否是 TiDB
,如果是的话,则返回这一行。具体流程是:
- 构造出 Key Range:一个表中所有的
RowID
都在[0, MaxInt64)
这个范围内,那么我们用0
和MaxInt64
根据行数据的Key
编码规则,就能构造出一个[StartKey, EndKey)
的左闭右开区间 - 扫描 Key Range:根据上面构造出的 Key Range,读取 TiKV 中的数据
- 过滤数据:对于读到的每一行数据,计算
name = "TiDB"
这个表达式,如果为真,则向上返回这一行,否则丢弃这一行数据 - 计算
Count(*)
:对符合要求的每一行,累计到Count(*)
的结果上面
缺点:
- 在扫描数据的时候,每一行都要通过 KV 操作从 TiKV 中读取出来,至少有一次 RPC 开销,如果需要扫描的数据很多,那么这个开销会非常大
- 并不是所有的行都有用,如果不满足条件,其实可以不读取出来
- 符合要求的行的值并没有什么意义,实际上这里只需要有几行数据这个信息就行
解决:需要将计算尽可能靠近存储节点以避免大量的 RPC 调用。
- 将 SQL 中的谓词条件下推到存储节点计算,只返回有效的行,避免无意义的网络传输
- 还可以将聚合函数
Count(*)
也下推到存储节点,预聚合,只返回 count 的结果 - SQL 层再把各个节点的 count 结果累加求和。