引言
本文简单介绍 TiDB 的存储原理,感谢 PingCAP 与阿里云的活动可以白嫖到云 TiDB 集群做测试。
文章主要内容参考官方文档,加入部分个人理解。最后也感谢社区的各位大佬,有问必答,对新人非常友好。
介绍
分布式数据库
分布式数据库也是数据库,而数据库最核心的功能包括:
-
存储数据 -
读取数据
数据的写入与读取依赖数据的存储模型即数据结构,数据模型根据数据存储的位置分别:
-
内存中的数据结构 -
磁盘中的数据结构
单机数据库主要存在以下问题:
-
无法横向扩展(Scale Out),只能纵向扩展(Scale Up),存在性能上限 -
单点风险,因此通常搭建主从集群用于高可用
分布式数据库的难点或者说关键技术主要包括:
-
多副本一致性及高可用 -
分布式事务
下面介绍国产分布式数据库中典型产品 TiDB。
TiDB 架构
TiDB 分布式数据库最初的设计受到 Google 内部开发的知名分布式数据库 Spanner 和 F1 的启发,将整体的架构拆分为三大模块,模块之间相互通信,组成完整的 TiDB 系统。大的架构如下:

这三个大模块相互通信,每个模块都是分布式的架构,在 TiDB 中,对应的这几个模块叫做:

其中:
-
TiDB(tidb-server),无状态 SQL 层,本身不存储数据,仅用于解析SQL后将实例的数据读取请求发送给底层存储层; -
TiKV(tikv-server),分布式 KV 存储,用于存储实际数据,其中 TiFlash 是列式存储,用于加速分析型场景; -
PD(Placement Deiver),用于管理整个 TiDB 集群的元数据,并根据 TiKV 节点实时上报的数据分布状态,下发数据调度命令给具体的 TiKV 节点。
原理
RocksDB
TiKV 使用 RocksDB 作为底层存储引擎,RocksDB 是一种 KV 存储引擎,因此可以将 RocksDB 理解为一个单机的持久化 Key-Value Map。
RocksDB 是由 Facebook 基于 LevelDB 开发的一款提供键值存储与读写功能的 LSM-tree 架构引擎。
可见,RocksDB 底层基于 LSM-tree 实现。LSM Tree(Log Structured Merge Tree,日志结构合并树)是一种数据存储的模型,而不是某一种具体的树类型的数据结构。
LSM 树的核心思想是顺序 IO 远快于随机 IO,因此适用于写多读少的业务场景。
RocksDB 中写入操作的原理见下图。

其中:
-
写入时首先写 WAL(Write Ahead Log)日志文件,方便 crash recovery 的时候可以根据日志恢复。WAL 位于 kv 节点的 db 目录(.log 后缀就是 WAL 文件); -
将请求写入到内存中的跳表 SkipList 即 Memtable 中,并返回写入成功信息给客户端。当 Memtable 写满后,变成 immutable 的 Memtable,并切换到新的 Memtable 提供写入; -
RocksDB 在后台会通过一个 flush 线程将这个 Memtable flush 到磁盘,生成一个 Sorted String Table(SST) 文件,放在 Level 0 层,并将对应的 WAL 日志安全删除。L0 层上包含的文件,是由内存中的 Memtable dump 到磁盘上生成的,单个文件内部按 key 有序,文件之间无序,而 L1~L6 层上的文件都是按照 key 有序; -
当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 Compaction 策略将其放到 Level 1 层,以此类推。每一层的数据是上一层的 10 倍(因此 90% 的数据存储在最后一层)。
RocksDB 中读取操作的原理见下图。

其中:
-
TiKV 默认将系统总内存大小的 45% 用于 BlockCache,作用类似于 MySQL 的 buffer pool,同样也是按照 LRU 算法淘汰低频访问的数据; -
读取操作时,如果在 Memtable 中没有找到数据,将在读取 block 时先去内存中的 BlockCache 中查看该块数据是否存在,存在的话则可以直接从内存中读取而不必访问磁盘,从而提高读性能。不过数据写入时不会写 BlockCache。
可见,RocksDB 中写入操作由于是顺序写入,因此写入性能高。而读取操作中如果目标数据在最底层 level N 的 SSTable 中,需要读取和查找所有的 SSTable,存在读放大现象,因此适用于写多读少的业务场景。
实际上每个 TIKV 中使用两个 RocksDB 实例,分别用于存储 data 数据与存储 Raft 日志,通常分别称为 kvdb 与 raftdb。
其中 Raft 的作用是什么呢?
Raft
Raft 是一个一致性协议,主要提供以下功能:
-
Leader(主副本)选举 -
成员变更(如添加副本、删除副本、转移 Leader 等操作) -
日志复制
TiKV 利用 Raft 来做数据复制,每个数据变更都会落地为一条 Raft 日志,通过 Raft 的日志复制功能,将数据安全可靠地同步到复制组的每一个节点中。不过在实际写入中,根据 Raft 的协议,只需要同步复制到多数节点,即可安全地认为数据写入成功。
如下图所示,通过 Raft,TiKV 将单机 RocksDB 的数据复制到多台机器,从而实现分布式 Key-Value 存储。

其中:
-
TiKV 中数据写入通过 Raft 层接口实现,而不是直接写入 RocksDB; -
RocksDB Raft 保存分布式事务需要同步的数据(如 DML),通过 log apply 等方式将数据同步到其他 TiKV 节点; -
TiKV 中数据写入对应两种写入操作,包括写入 Raft log 与 KV pair 写入数据库,分别由 raftstore 进程与 apply worker 进程后台写入。
Region
TiKV 可以看做一个巨大的有序的 KV Map,为实现存储的水平扩展,需要将数据分散在多台机器上。
对于一个 KV 系统,将数据分散在多台机器上有两种比较典型的方案:
-
Hash:按照 Key 做 Hash,根据 Hash 值选择对应的存储节点 -
Range:按照 Key 分 Range,某一段连续的 Key 都保存在一个存储节点上
TiKV 中选择了第二种,将整个 Key-Value 空间分成很多段,将每一段叫做一个 Region,主要包括以下特征:
-
Key-Value Map 要求按照 Key 的二进制顺序有序,因此可以实现 -
尽量保持每个 Region 中保存的数据不超过一定的大小,目前在 TiKV 中默认是 96M,支持配置; -
每一个 Region 都可以用 [StartKey,EndKey) 这样一个左闭右开区间来描述。
将数据划分成 Region 后,TiKV 将会做两件重要的事情:
-
以 Region 为单位,将数据分散在集群中所有的节点上,每个 Region 的数据只会保存在一个节点上面。用于实现存储容量的水平扩展与负载均衡; -
以 Region 为单位做 Raft 的复制和成员管理。每个 Region 的数据会保存多个副本,分散在多个节点。TiKV 将每一个副本叫做一个 Replica。
Replica 之间是通过 Raft 来保持数据的一致性,一个 Region 的多个 Replica 会保存在不同的节点上,构成一个 Raft Group。其中一个 Replica 会作为这个 Group 的 Leader,其他的 Replica 作为 Follower。其中:
-
所有的读和写都是通过 Leader 进行,再由 Leader 复制给 Follower; -
Leader 会自动地被 PD 组件均匀调度在不同的物理节点上,以均分读写压力。
如下图所示是一个四节点三副本的 TiKV 集群。

不过这样会导致另一个问题,即热点 region。
原因是在 TiDB 中新建一个表后,默认会单独切分出 1 个 Region 来存储这个表的数据,这个默认行为由配置文件中的 split-table 控制。当这个 Region 中的数据超过默认 Region 大小限制后,这个 Region 会开始分裂成 2 个 Region,即动态分片功能。
因此,如果在新建的表上发生大批量写入,则会造成热点,因为开始只有一个 Region,所有的写请求都发生在该 Region 所在的那台 TiKV 上。
为解决上述场景中的热点问题,TiDB 引入了预切分 Region 的功能,即可以根据指定的参数,预先为某个表切分出多个 Region,并打散到各个 TiKV 上去。
可见,TiDB 同时支持动态分片与预分片。
因此,TiKV 由 Region 组成,每个表对应多个 Region,而一个 Region 只会对应一个表,每一个 Region 里是一组有序的数据库记录。
Key-Value
TiKV 根据 Key 将数据打散到不同的 Region,那么 Key 是什么呢?
TiKV 可以看作一个 KV Map,那么 Key 与 Value 分别是什么呢?
为回答这两个问题,需要介绍表数据与 Key-Value 的映射关系。
TiDB 高度兼容 MySQL 协议,而 MySQL 是一种行存的关系型数据库,表由多行组成,行由多列组成。
因此,TiDB 中就需要将一行中各列数据映射成一个 (Key, Value) 键值对,进而需要考虑如何构造 Key。
OLTP 场景下有大量针对单行或者多行的增、删、改、查等操作,要求数据库具备快速读取一行数据的能力。因此要求 Key 对应单行数据。
首先考虑下是否可以类似 MySQL,使用自增主键作为 Key?
假设 Key 是递增主键,那么写入的数据将主要写入最后一个 Region 中,当业务写入量较大时将导致热点 Region。而 Region 又是 PD 调度的最小单位,因此无法通过 PD 调度来解决该问题,进而导致该 Region 所在的 TiKV 的能力决定了这个表甚至集群的写入能力。
可见,尽管 MySQL 的 InnoDB 存储引擎中建议使用自增主键,可以提升写入性能(随机写变顺序写)和降低数据页的碎片率。但是在 TiDB 中并不适用。
实际上,TiDB 中 Key 的实现相对复杂,原因是 Key 既要保证对应单行数据,又要适合将数据打散到不同的 Region。
讨论规则编码之前,声明以下约定:
-
为了保证同一个表的数据放在一起,方便查找,TiDB 会为每个表分配一个表 ID,用 TableID
表示。表 ID 是一个整数,在整个集群内唯一; -
TiDB 会为表中每行数据分配一个行 ID,用 RowID
表示。行 ID 也是一个整数,在表内唯一。对于行 ID,TiDB 做了一个小优化,如果某个表有整数型的主键,TiDB 会使用主键的值当做这一行数据的行 ID,否则会使用隐式自增主键; -
TiDB 同时支持主键和二级索引(包括唯一索引和非唯一索引),TiDB 为表中每个索引分配了一个索引 ID,用 IndexID
表示; -
表数据与索引数据的 Key 编码方案中一个表内所有的行都有相同的 Key 前缀,一个索引的所有数据也都有相同的前缀。这样具有相同的前缀的数据,在 TiKV 的 Key 空间内,是排列在一起的。
具体构造 Key 的规则编码与保存的数据有关:
-
表数据,行 ID 唯一对应表中一行数据。其中 tablePrefix
和recordPrefixSep
都是特定的字符串常量,用于在 Key 空间内区分其他数据;
Key: tablePrefix{TableID}_recordPrefixSep{RowID}
Value: [col1, col2, col3, col4]
-
主键和唯一索引,索引列唯一对应表中一行数据,因此 Key 中包括索引列值;
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_{indexedColumnsValue}
Value: RowID
-
非唯一普通二级索引,一个键值可能对应多行,因此需要根据键值范围查询对应的 RowID。
Key: tablePrefix{TableID}_indexPrefixSep{IndexID}_{indexedColumnsValue}_{RowID}
Value: null
其中:
-
TiDB 的非唯一索引存储的 value 为空,唯一索引存储的 value 为主键索引; -
非唯一索引的 value 为空,因此使用索引时直接从 Key 中提取 RowID 回表以获取整行数据。
总结
TiDB 中数据的存储对应多个概念,从上到下的关系如下所示。
概念 | 层次 | 备注 |
---|---|---|
Table | TiDB | 将表数据映射为 KV pair |
Region | TiKV | 逻辑概念 |
sst | RocksDB | 物理概念 |
因此,TiDB 的任何表都会转换成 KV 存储在 TiKV 中,TiKV 的任何数据都会通过 RocksDB 以 sst 文件的形式最终存储在磁盘上。
可见 TiKV 的 Region 与 sst 文件没有绝对的关系,其中 Region 是逻辑概念,sst 文件是物理概念。
测试
topology
查看 topology.yaml 文件,显示 TiDB 集群中包括 3 个 PD、2 个 TiDB、3 个 TiKV 等组件。
[root@iZbp1a9afi1r8bh90qhepoZ ~]# cat /root/topology.yaml
global:
user: "tidb"
ssh_port: 22
deploy_dir: "/tidb-deploy"
data_dir: "/data1"
server_configs:
tidb:
new_collations_enabled_on_first_bootstrap: true
log.file.max-days: 15
log.slow-threshold: 1000
mem-quota-query: 5368709120
performance.txn-total-size-limit: 3221225472
pd:
schedule.enable-cross-table-merge: true
replication.enable-placement-rules: true
log.file.max-days: 15
tikv:
log.file.max-days: 15
pd_servers:
- host: 192.168.141.132
- host: 192.168.141.135
- host: 192.168.141.133
tidb_servers:
- host: 192.168.141.128
- host: 192.168.141.129
tikv_servers:
- host: 192.168.141.131
- host: 192.168.141.130
- host: 192.168.141.134
monitoring_servers:
- host: 192.168.141.127
grafana_servers:
- host: 192.168.141.127
username: admin
password:
alertmanager_servers:
- host: 192.168.141.127
从工作台中也可以看到资源架构图。

从 Grafana 中可以看到集群的组件及状态。

tiup
TiUP 是 TiDB 4.0 版本引入的集群运维工具,TiUP cluster 是 TiUP 提供的使用 Golang 编写的集群管理组件,通过 TiUP cluster 组件就可以进行日常的运维工作,包括部署、启动、关闭、销毁、弹性扩缩容、升级 TiDB 集群,以及管理 TiDB 集群参数。
执行tiup cluster list
命令查看集群状态,显示有一个集群。

执行tiup cluster display tidb-prod
命令查看集群中每个组件的运行状态。

其中:
-
集群名称为 tidb-prod -
版本为 6.5.0
ControlServer
通过【阿里云-计算巢-服务实例管理】中的 ControlServer 可以连接到 TiDB,ControlServer 可以理解为中控机。不过为了节省资源,还部署了监控等服务。
访问 ControlServer,查看进程,显示其中部署 monitoring_servers、grafana_servers、alertmanager_servers,与 topology.yaml 文件内容一致。

ControlServer 和 PD 没关系,PD 用于 TiDB 内部调度,用户没有感知。应用连数据库要连 TiDB server 或者前端的代理,代理上有外网 ip,不连 PD。
连接到数据库以后,使用 sysbench 自动生成压测数据,用于后续测试。
TiKV
查看 TiKV 的数据目录 /data1/tikv-20160,其中 db 用于保存数据文件,raft-engine 用于保存 Raft 日志。

分别查看 db 与 raft-engine 目录。
db 目录中文件如下所示,主要包括 .sst 格式的文件与 .log 格式的文件,其中 .log 格式文件就是 WAL 日志。

raft-engine 目录中文件如下所示,主要包括 .raftlog 格式的文件,表示用于数据复制的 Raft 日志。

Table
查看表结构,其中主键索引显示 clustered_index,表明与 MySQL 相同,索引是聚簇索引。
MySQL [sbtest]> show create table test1 G
*************************** 1. row ***************************
Table: test1
Create Table: CREATE TABLE `test1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`k` int(11) NOT NULL DEFAULT '0',
`c` char(120) NOT NULL DEFAULT '',
`pad` char(60) NOT NULL DEFAULT '',
PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */,
KEY `k_1` (`k`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin AUTO_INCREMENT=5039862
1 row in set (0.00 sec)
查看索引,发现了一个神奇的现象,显示 Index_type=BTREE,MySQL 中每个索引都是一颗 B+ 树,难道 TiDB 也是如此吗?

当然上面讲到,TiDB 基于 ROCKSDB 实现,而 ROCKSDB 基于 LSM-tree 实现,因此这里显示的 BTREE 应该只是做兼容性的,实际上 ROCKSDB 是 LSM-tree。
TIKV_REGION_STATUS
TIKV_REGION_STATUS
表通过 PD 的 API 展示 TiKV Region 的基本信息,比如 Region ID、开始和结束键值以及读写流量。
执行SELECT * FROM information_schema.tikv_region_status where region_id=188 G
命令查看指定 Region 的基本信息。

其中:
-
APPROXIMATE_SIZE 等于 95 M,WRITTEN_BYTES 与 READ_BYTES 等于 0; -
两条记录仅 INDEX_ID 字段不同,分别对应表数据与索引数据,可见两种数据均保存在 Region 中。
IS_INDEX:Region 数据是否是索引,0 代表不是索引,1 代表是索引。如果当前 Region 同时包含表数据和索引数据,会有多行记录,IS_INDEX 分别是 0 和 1。
生成测试数据属于高并发写入场景,查看监控 huge region 显示产生热点,即短时间内大量数据会持续写入到同一个 Region 上。

原因是新建的表一开始只有一个 Region,所有的写请求都发生在该 Region 所在的那台 TiKV 上。
结论
TiDB 集群主要由三部分组成,TiDB、TiKV 与 PD。
TiKV 使用 RocksDB 作为底层存储引擎,而 RocksDB 基于 LSM-tree 实现。然后通过 Raft 一致性协议将单机 RocksDB 的数据复制到多台机器,从而实现分布式 Key-Value 存储。
TiKV 由 Region 组成,并以 Region 为复制与调度的单位,从而实现存储容量的水平扩展与负载均衡。
数据可以分为两部分,包括内存与磁盘,内存部分由 Memtable 和 Immutable Memtable 组成,磁盘部分由多层的 SSTable 组成。
数据写入时首先顺序写入磁盘中的 WAL,然后顺序写入内存中的 Memtable,接下来返回写入成功信息给客户端。最后由后台线程完成 flush 与 Compaction 操作,分别进行数据刷盘与数据合并。
存储从上到下可以分为 TiDB 中的表、TiKV 中的 KV pair、RocksDB 中的 sst 文件,其中由 TiDB 内部完成表数据与 Key-Value 的映射。
因此 TiDB 的任何表都会转换成 KV 存储在 TiKV 中,TiKV 的任何数据都会通过 RocksDB 以 sst 文件的形式最终存储在磁盘上。
待办
-
RocksDB 原理
参考教程
-
第一章 TiDB 整体架构
https://book.tidb.io/session1/chapter1/tidb-architecture.html
-
三篇文章了解 TiDB 技术内幕 – 说存储
https://cn.pingcap.com/blog/tidb-internal-1
-
TiKV RocksDB读写原理整理
https://tidb.net/book/tidb-monthly/2023/2023-02/feature-indepth/tikv-rocksdb
-
TiDB索引
https://blog.csdn.net/weixin_44265650/article/details/124512567
原文始发于微信公众号(丹柿小院):TiDB 存储原理简介
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/178574.html