本系列学习笔记根据官方课程《TiDB 高级系统管理 [TiDB v5]》整理,感谢官方精心制作的视频课程。相关课程介绍,详见官方课程链接:https://learn.pingcap.com/learner/course/120005
1. 第一章 深入TiDB体系架构
1.4. TiKV
TiKV 是一个分布式事务型的 Key-Value 键值数据库,提供了满足 ACID 约束的分布式事务接口,并且通过 Raft 协议保证了多副本数据一致性以及高可用。TiKV 作为 TiDB 的存储层,为用户写入 TiDB的数据提供了持久化以及读写服务,同时还存储了 TiDB 的统计信息数据。
1.4.1. TiKV 架构和作用
TiKV 的整体架构如图1.11所示,为 TiDB 集群数据库提供了如下功能:
- 数据持久化
通过集成在 TiKV 中的 RocksDB 引擎,为 TiDB 数据库提供数据的持久化。
- 分布式一致性
TiDB 数据库中的数据以 Region 为单位,分布式的存储在多个 TiKV 节点中。通过 Raft 算法来实现分布式环境中多个 TiKV 节点数据(Region)的强一致性。
- MVCC
TiDB 数据库通过 MVCC 实现事务的多版本并发控制。当新写入的数据覆盖旧数据时,旧数据不会被替换掉,而是与新写入的数据同时保留,并以时间戳来区分版本7(Version)。当用户获取数据时,通过KEY 和 Version 构造出 MVCC 的 KEY(KEY_Version),然后通过 RockDB 的 SeekPrefix(KEY_Version)API 即可定位到数据的位置。
- 分布式事务
TiKV 的事务采用的是 Google 在 BigTable 中使用的事务模型:Percolator ,TiKV 根据这篇论文实现,并做了大量的优化。
- Coprocessor(协处理器)
TiKV 通过协处理器 (Coprocessor) 可以为 TiDB Server 分担一部分计算:TiDB Server 会将可以由存储层分担的计算下推到 TiKV 节点。从而节省了网络带宽以及降低了 TiDB Server 实例的负载。计算单元仍然是以 Region 为单位,即 TiKV 的一个 Coprocessor 计算请求中不会计算超过一个 Region的数据。
1.4.2. RocksDB
RocksDB 作为 TiKV 的核心存储引擎,用于存储 Raft 日志以及用户数据。每个 TiKV 实例中有两个 RocksDB 实例,一个用于存储 Raft 日志(通常被称为 raftdb),另一个用于存储用户数据以及 MVCC信息(通常被称为 kvdb)。
RocksDB 针对 Flash 存储(SSD)进行了优化(延迟极小),具有如下特点:
- 是一个高性能的 Key-Value 数据库
- 完善的持久化机制,同时保证性能和安全性
- 良好地支持范围查询
- 为需要存储 TB 级别数据到本地 FLASH 或 RAM 的应用服务器设计
- 针对存储在高速设备的中小键值进行优化,即可存储在 FLASH 或直接存储在内存
- 性能随 CPU 数量线性提升,对多核系统友好
1.4.2.1. RocksDB 中的数据写入
如图1.12所示,用户写入的键值对会先写入磁盘上的 WAL (Write Ahead Log)10,然后再写入内存中的跳表(SkipList11,这部分结构又被称作 “MemTable”)。LSM-tree 引擎由于将用户的随机修改(插入)转化为了对 WAL 文件的顺序写,因此具有比 B 树类存储引擎更高的写吞吐。
【知识补充】
RocksDB 是由 Facebook 基于 LevelDB 开发的一款提供键值存储与读写功能的 LSM-tree 9架构引擎。这里可以简单的认为 RocksDB 是一个单机的持久化 Key-Value Map。WAL:类似于 Oracle 数据库的 Online Redo Log,循环利用。用于系统掉电重启后,将实例恢复至掉电之前的状态。
Skiplist:本质上是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的 key,快速查到它所在的位置(或者对应的 value)。详见RocksDB-skplist。
内存中的数据达到一定阈值后,会刷到磁盘上生成 SST 文件 (Sorted String Table),SST 又分为多层(默认至多 6 层),每一层的数据达到一定阈值后会挑选一部分 SST 合并到下一层,每一层的数据是上一层的 10 倍(因此 90% 的数据存储在最后一层)。
RocksDB 允许用户创建多个 ColumnFamily ,这些 ColumnFamily 各自拥有独立的内存跳表(Skiplist)以及 SST 文件,但是共享同一个 WAL 文件,这样的好处是可以根据应用特点为不同的 ColumnFamily选择不同的配置,但是又没有增加对 WAL 的写次数。
如下,以写入一条记录 (1,”tom”) 为例,简介 TiKV 实例中 RocksDB 的数据写入流程:
- RocksDB 首先,将 (1,”tom”) 写入磁盘上的 WAL(Write Ahead Log,预写日志)文件中。可通过设置参数 sync-log=true,以使 WAL 写入操作绕开操作系统缓存,直接写入磁盘文件中。
- 再将 (1,”tom”) 写入内存中的 MemTable,MemTable 中通过 Skiplist 结构来保证数据的有序性。此时,若系统掉电重启后,MemTable 中的内容会丢失。RocksDB 通过读取 WAL 文件,恢复 MemTable中丢失的内容。
- 当 MemTable 的大小达到 write-buffer-size 时(默认为 128MB),当前的 MemTable 会变成只读状态(即 immutable MemTable);然后,生成一个新的 MemTable 来接收新的写入。
- 只读的 MemTable 会被 RocksDB 的 flush 线程(线程数由max-background-flushes12 控制)刷写到磁盘,成为 Level0 的一个 SST 文件。
- 默认当 immutable MemTable 数量达到 1 个(由参数min-write-buffer-number-to-merge控制)时,即会 flush 到磁盘的 SST 文件中。当 flush 线程忙不过来,导致等待 flush 到磁盘的 immutableMemTable 的数量到达 max-write-buffer-number13 限定的个数(默认为 5)的时候,会触发 RocksDB的 Write Stall14(写入降级)流控机制。
- 当 immutable Memtable 的内容 flush 到 SST 文件后,WAL 文件的内容即可被新的写入操作覆盖(循环利用)。
1.4.2.2. RocksDB 的文件组织
如图1.13所示,RocksDB 在磁盘中的文件组织方式为 “分层组织”。“immutable Memtable” 中的数据会首先被刷新到 Level0。L0 层的 SST 之间的范围可能存在重叠(因为文件顺序是按照生成的顺序排列),因此同一个 KEY 在 L0 中可能存在多个版本。默认,当 Level0 的文件数量(由参数 level0-file-num-compaction-trigger15 控制)达到 4 个时,会合并(按 KEY 进行排序)压缩16到 Level1,此过程称为“Compaction”。当 Level1 的多个 SST 文件达到 256M 时,继续按 KEY 排序、压缩、合并到下一层(即 Level2),以此类推(如图1.13所示)。当文件从 L0 合并到 L1 的时候,会按照一定大小(默认是 8MB)切割为多个文件,同一层的文件中 KEY 的范围互不重叠。所以,L1 及其以后的层每一层的 KEY 都只有一个版本。
![RocksDB磁盘文件组织](vx_images/30613113223819.png =600x)
【知识补充】
max-background-flushes:RocksDB 用于刷写 memtable 的最大后台线程数量。默认值为 [(max-background-jobs + 3) / 4],取整数。
max-write-buffer-number:当 storage.flow-control.enable 的值为 true 时,storage.flow-control.memtables-threshold 会覆盖 max-write-buffer-number 的配置值。
Write Stall(写入降级)是 RocksDB 的一种流控机制,RocksDB 会将新的写入 stall 住,以限制客户端的写入速度。
level0-file-num-compaction-trigger:不同的列簇,该参数默认值不同。rocksdb.defaultcf.level0-file-num-compaction-trigger 默认值为 4;rocksdb.writecf.level0-file-num-compaction-trigger 默认值为 4;rocksdb.lockcf.level0-file-num-compaction-trigger 默认值为 1。
SST 压缩算法:可通过 [rocksdb.defaultcf] 下的 compression-per-level 为每层指定压缩算法。如 compression-per-level = [”no”, ”no”, ”lz4”,”lz4”, ”lz4”, ”zstd”, ”zstd”] 表示 L0、L1 不压缩,L2-L4 采用 lz4 压缩,L5-L6 采用 zstd 压缩。
当查找数据时,因为每个 SST 文件中的 KEY-VALUE 键值对都是按 KEY 排序存储。所以,通过二分查找法即可快速定位到所需的 KEY-VALUE 值。当修改(包括 DELETE、UPDATE)数据时,RocksDB可直接操作 MemTable,将修改操作存入到 MemTable 中。此时,当其他用户需要读取此数据,可直接读取 MemTable 中的数据,而无需关注其在 SST 文件中的位置。
【知识补充】
修改操作:RocksDB 中的修改操作并不是在原值上修改,而是直接写入修改后的新值。
1.4.2.3. RocksDB 中的数据读取
RocksDB 中读取数据的流程如下:
- RocksDB 中的 Block Cache 内存区用于缓存最近常读的数据(即热点数据)。当读取的数据已缓存于 Block Cache 中时,直接从 Block Cache 中读取,称为 “Block Cache 命中”。
- 当读取的数据未缓存于 Block Cache 中(称为 “Block Cache 未命中”)时,则依次检索 MemTable →immutableMemTable → Level0 → Level1 → . . . → LevelN。
- 因上层数据的版本比下层数据新(如 Level2 的数据比 Level3 新),所以 RocksDB 在检索到所需的数据后,就直接返回结果,不会继续向下层检索。如图1.14所示,在检索到 Level2 中的“1:Jack”后,直接返回结果,不会再继续检索 Level3 层的“1:Tom”。
每个 SST 文件都是按 KEY 排好序的 KEY-VALUE 键值对集合,RocksDB 为了加快 SST 文件的数据检索,引入了 Bloom Filter(布隆过滤器)18。当 Blook Filter 确定待检索的 KEY 不在指定的 SST文件时,直接跳过该文件,继续检索下一个 SST 文件。
1.4.2.4. Column Family(列簇)
Column Family(列簇,简称 “CF”)19是 RocksDB 从 3.0 版本开始引入的特性,实际上就是 RocksDB的逻辑分片技术。RocksDB 引入这个特性后,每个键值对都需与唯一一个列簇(Column Family)相关联。如果没有指定 Column Family,键值对将会关联到“default”列簇。举例说明 Column Family 的应用场景。比如,有两张表 students(sid,name) 和 classes(cid,name)。当为 students 表的键值对指定 Column Family01,为 classes 表的键值对指定 Column Family02。此时,Column Family01 相关的 MemTable 及 SST 文件中都是关于 students 表的内容,而 Column Family02相关的 MemTable 及 SST 文件中都是关于 classes 表的内容。从而,为数据存储提供了逻辑分片的方法。
如图1.15所示,不同的 Column Family 共享 WAL,而每个 Column Family 都有自己独立的 MemTable和 SST 文件。TiKV 的 RocksDB 实例 kvdb 中有 4 个 Column Family(列簇):raft、lock、default 和write:
- raft 列簇:用于存储各个 Region 的元信息。仅占极少量空间,用户可以不必关注。
- lock 列簇:用于存储悲观事务的悲观锁以及分布式事务的一阶段 Prewrite 锁。当用户的事务提交之后,lock CF 中对应的数据会很快删除掉,因此大部分情况下 lock CF 中的数据也很少(少于 1GB)。如果 lock CF 中的数据大量增加,说明有大量事务等待提交,系统出现了 bug 或者故障。
- write 列簇:用于存储用户真实写入的数据(长度小于 255 字节)以及 MVCC 信息(该数据所属事务的开始时间以及提交时间)。当用户写入了一行数据时,如果该行数据长度小于 255 字节,那么会被存储 write 列簇中,否则该行数据会被存入到 default 列簇中。由于 TiDB 的非 unique 索引存储的value 为空,unique + 引存储的 value 为主键索引,因此二级索引只会占用 write CF 的空间。
- default 列簇:用于存储长度超过 255 字节的数据。
【知识补充】
- 布隆过滤器(Bloom Filter)是 1970 年由布隆提出的,用于检索一个元素是否包含在一个集合中。其优点是空间效率高、查询时间短,缺点是有一定的误识别率。当布隆过滤器说一个元素不在指定的集合中时,那么它一定不在;当布隆过滤器说一个元素在指定的集合中时,那么它也可能不在集合中。
- Column Family(列簇):关于列簇的详细介绍,请访问https://github.com/johnzeng/rocksdb-doc-cn/blob/master/doc/Column-Families.md
1.4.3. 分布式事务
思考如下场景,在分布式数据库中,student(id,name) 表包含 2 条记录“(1,’xxxx’),(2,’yyyy’)”,分别存储于节点 node1 和 node2 中。在一个事务中,通过“update student set name=’Jack’ where id =1”、“update student set name=’Tom’ where id = 2”分别修改这两条记录。当 node1 节点“id=1”的记录修改完成后,刚要修改 node2 节点“id=2”的记录时,node2 节点故障,无法完成修改。此时,一个事务中就出现了一部分完成了修改,另一部分未完成,破坏了事务的原子性。
那么,来看一下在 TiDB 数据库中,是如何处理分布式事务中此类问题的?TiDB 数据库的分布式事务采用的是 Google 在 BigTable 中使用的 Percolator事务模型。提供乐观事务与悲观事务两种事务模式。TiDB 3.0.8 及以后版本,TiDB 默认采用悲观事务模式。
1.4.3.1. 单机事务的流程
如图1.16所示,以修改”<3,xxx>” 为“<3,Frank>”的单机事务为例,来了解一下事务在 TiKV 中是如何存储的,以及 TiDB 数据库中事务的流程。
-
首先,执行 begin 时,TiDB Server 会从 PD 组件中获取一个事务的开始时间戳,称其为 “start_ts”。示例中 start_ts=100。
-
然后,TiDB 会将需要修改的数据(示例中为 <3,xxx>)读取到 TiDB Server 的内存中,并在内存中完成修改操作(示例中修改为 <3,Frank>)。
-
修改之后,一旦当事务遇到 commit 语句时,说明需要将数据持久化了。此时,事务也就进入到了两阶段(PreWrite 和 Commit)提交。
- 第一阶段,为 PreWrite。在此阶段,TiDB 会将内存中修改完的数据(<3,Frank>)写入到 TiKV节点中的 Default 列簇中,写入的 KEY 包含行 ID 和事务的 start_ts,如“<3_100, Frank>”。同时将锁20信息写入到 TiKV 节点中的 Lock 列簇中,锁信息包含行 ID、事务的 start_ts 和操作类型 W(写入锁),如“<3,(W,pk,3,100„,)>”。在 PreWrite 阶段完成后,当有其他会话要读取或修改该行数据,首先检查 Lock CF,发现该行数据被加了一把锁,说明此刻该行数据正被其他会话修改且未提交。读操作将会根据 MVCC 读取最近已提交的数据版本,而修改操作将等待该锁的释放(被阻塞)。
- 第二阶段,为 Commit。在此阶段,TiDB 会先向 PD 组件申请一个事务的提交时间戳,称其为“commit_ts”。示例中 commit_ts=110。并将提交信息写入 TiKV 节点中的 Write 列簇中,提交信息包含行 ID、事务的 commit_ts 和 start_ts,如“<3_110,100>”。在 Lock 列簇中写入锁的清理信息,包含行 ID、事务的 start_ts 和操作类型 D(清除锁),如“<3,(D,pk,3,100„,)>”。事务结束后,其他用户要读取行 ID=3 的数据时,首先到 Write 列簇中查找该行数据的提交信息。通过“<3_110,100>”可得知该行数据最近一次事务的 commit_ts 为 110,该事务的 start_ts 为 100,于是通过行 ID 和 start_ts(即<3_100>)到 Default 列簇中读取到数据“<3_100, Frank>”。
【知识补充】
在 TiKV 中,当用户写入了一行数据时,如果该行数据长度小于 255 字节,那么会被存储 write 列簇中,否则的话该行数据会被存入到 default 列簇中。为了便于理解,示例中假设写入的数据长度大于 255 字节。
1.4.3.2. 分布式事务的流程
有了单机事务的流程作为基础,下面来看一下 TiKV 中分布式事务的流程。如图1.17所示,这里以一个事务内修改 2 行数据(2 行数据分别存储于 TiKV 的不同节点)为例,介绍一下 TiDB 中的分布式事务。
-
首先,执行 Begin 时,TiDB Server 会从 PD 组件中获取事务的开始时间戳,称其为 “start_ts”。示例中的 start_ts=100。
-
然后,TiDB 会将需要修改的 2 行数据(示例中为 <1, Tom> 和 <2, Andy>)读取到 TiDB Server的内存中,并在内存中执行 2 行数据的修改操作(示例中 <1, Tom> 修改为 <1, Jack>,<2, Andy> 修改为 <2, Candy>)。
-
在内存中修改完成后,一旦遇到 Commit 语句时,说明修改的数据需要持久化。此时,事务也就进入了两阶段(PreWrite、Commit)提交。
- PreWrite 阶段。在此阶段,TiDB 首先将内存中修改完的第 1 行数据写入到 TiKV Node1 节点的Default 列簇中,写入的 KEY 包含行 ID 和事务的 start_ts,如“<1_100, Jack>”;同时,将 pk 主锁信息写入到 TiKV Node1 节点的 Lock 列簇中,锁信息包含行 ID、事务的 start_ts 和操作类型 W(写入锁),如“<1,(W,pk,1,100„,)>”。将修改完的第 2 行数据写入到 TiKV Node2 节点的 Default 列簇中,写入的 KEY 包含行 ID 和事务的 start_ts,如“<2_100, Candy>”;同时,将第 2 行数据的锁信息写入到 TiKV Node2 节点的 Lock 列簇中,锁信息包含行 ID、事务的 start_ts 和操作类型 W(写入锁),如“<2,(W,@1,2,100„,)>”。其中,“@1”表示此锁依附于(指向)行 ID=1 的主锁 pk,即指向 Node1 的锁信息“<1,(W,pk,1,100„,)>”。此刻,若有其他会话要读取或修改这两行数据,首先分别检查 TiKV Node1和 TiKV Node2 节点的 Lock CF,发现数据行被加了锁,说明此刻数据正被修改且未提交。读操作将会根据 MVCC 读取最近已提交的版本,修改操作将被阻塞。
- Commit 阶段。在此阶段,TiDB 会先向 PD 组件获取一个事务的提交时间戳,称为 “commit_ts”,示例中 commit_ts=110。并将各行数据的提交信息分别写入 TiKV Node1 和 TiKV Node2 节点的 WriteCF 中。如示例中的”<1_110, 100>” 和”<2_110, 100>”。在 TiKV Node1 和 TiKV Node2 节点的 LockCF 中分别清理两行数据的锁信息,如示例中的”<1,(D,pk,1,100„,)>” 和”<2,(D,@1,2,100„,)>”。
在两阶段提交过程中,假设 TiKV Node1 节点中的数据已完成提交,TiKV Node2 节点的数据在提交时出现宕机,从而导致第 2 行数据的提交信息和清除锁信息的持久化失败。在 TiKV Node2 恢复正常后,当会话读取 TiKV Node2 中的”<2, Candy>” 数据时,首先检查 Write CF,未发现提交信息;再检查Lock CF,发现了锁信息(指向 Node1 的主锁)。TiDB 通过锁指向,到 TiKV Node1 节点的 Lock CF 中发现该主锁已清除,并且 Write CF 中显示该行数据已提交。从而 TiDB 可判断出,刚才 TiKV Node2 因执行 Commit 失败,而导致丢失了提交信息和锁清除的记录。最后,TiDB 通过 TiKV Node1 的提交信息,找回丢失的锁信息“<2,(D,@1,2,100„,)>”和提交信息“<2_110,100>”,并分别补充到 TiKV Node2节点的 Lock CF 和 Write CF,这一过程称为 “roll-forward(前滚)”。
【注意】
分布式事务模型,在同一个事务中 TiDB 只给修改的第 1 行数据加一把锁(主锁 pk),如示例 Node1 的“<1,(W,pk,1,100„,)>”;后续被修改的行都依附于(指向)第 1 行的主锁,如示例中 Node2 的锁信息“<2,(W,@1,2,100„,)>”,其中“@1”表示依附于第 1 行(ID=1)的主锁,即指向 Node1 的锁信息“<1,(W,pk,1,100„,)>”。
1.4.4. 乐观事务与悲观事务
如章节1.3.3中的事务流程均为 “乐观事务”,即加锁操作在两阶段提交的 PreWrite 阶段进行。因此,在事务在执行 Commit 之前(进入两阶段提交之前),无法感知到其他事务的锁信息。而在悲观事务中,事务在修改数据时就对要修改的数据行进行加锁操作。但是此时的锁信息中不包含事务的 start_ts,当事务进入两阶段提交的 PreWrite 阶段时,才将事务的 start_ts 信息补充到锁信息中。
1.4.5. MVCC
假设存在如下所示的两个事务:
- 事务 1:已执行 Commit,两阶段提交执行完毕。其 start_ts=100,commit_ts=110;
- 事务 2:未执行 Commit,未进入两阶段提交21。其 start_ts=115。
# 事务1 (已提交)
Begin # start_ts = 100
<1,Tom> −> <1,Jack>
<2,Andy> −> <2,Candy>
Commit; # commit_ts = 110
# 事务2 (未提交)
Begin # start_ts = 115
<1,Jack> −> <1,Tim>
<4,Tomy> −> <4, Jerry >
则根据”1.3.3分布式事务” 可知这两个事务在 TiKV 中的存储如图1.18所示。
1.4.5.1. MVCC 下的读写流程
在 TSO=120 时,用户开始读取 ID 为 1、2、4 的数据。参考1.18,其读写流程如下所示:
- 读取 ID=1 的数据。到 Write CF 中检索 ID=1 的数据的提交历史,找到早于且距离 TSO=120 最近一次提交的 commit_ts=110,其提交信息为”<1_110, 100>”。于是,根据“<1_100>”到 Default CF中读取到数据“<1_100,Jack>”。若此时用户要修改 ID=1 的数据,首先检查 Write CF,发现最近提交的 commit_ts=110。然后,检查 Lock CF,发现 ID=1 的数据被加了锁”<1,(W,pk,1,115„,)>”,说明当前该行数据正被 start_ts=115 的事务修改且未提交。于是,用户进入阻塞状态,等待锁的释放。
- 读取 ID=2 的数据。到 Write CF 中检索 ID=2 的数据的提交历史,找到早于且距离 TSO=120 最近一次提交的 commit_ts=110,其提交信息为”<2_110, 100>”。于是,根据”<2_100>” 到 Default CF 中读取到数据“<2_100,Candy>”。若此时用户要修改 ID=2 的数据,首先检查 Write CF,发现最近提交的 commit_ts=110。然后,检查 Lock CF,发现 ID=2 的数据没有加锁信息,于是用户可以修改 ID=2的数据。
- 读取 ID=4 的数据。到 Write CF 中检索 ID=4 的数据的提交历史,找到早于且距离 TSO=120 最近一次提交的 commit_ts=90,其提交信息为“<4_90, 80>”。于是,根据“<4_80>”到 Default CF 中读取到数据“<4_80, Tony>”。若此时用户要修改 ID=4 的数据,首先检查 Write CF,发现最近提交的commit_ts=90。然后,检查 Lock Cf,发现 ID=4 的数据被加了锁”<4,(W,@1,4,115„,)>”,并且该锁指向ID=1 的主锁”<1,(W,pk,1,115„,)>”,说明 ID=4 和 ID=1 的数据当前正被同一个事务修改且未提交。于是,用户进入阻塞状态,等待锁的释放。
【注意】
以上示例以悲观事务为例介绍数据的读取流程。悲观事务模型中,事务在未执行 Commit 之前(即未进入两阶段提交之前),其他会话也可以感知得到锁的存在。如在事务 2 中,未执行 Commit,其他会话也可以感知得到行 ID=1 和 4 的锁。
1.4.6. 基于 Raft 的分布式一致性
首先,如图所示1.19,来看一下 TiKV 中的如下几个名词:
- Store:Store 即指一个 TiKV 实例。
- Region:TiKV 将整个 Key-Value 空间分成很多段,每一段是一系列连续的 Key,将每一段叫做一个 Region,并且会尽量保持每个 Region 中保存的数据不超过一定的大小(默认 96MB)。每个Region 都可以用 [StartKey,EndKey) 这样一个左闭右开区间来描述。由 PD 组件来负责将 Region 均匀的散布在多个 TiKV +点中,并记录 Region 的分布情况。当增删 TiKV 节点后,Region 自动在节点之间调度。
- Replica/Peer:TiKV 以 Region 为单位做 Raft 的复制和成员管理。也就是每个 Region 会在不同的 Store 中保存多个副本(默认 3 副本),TiKV 将每一个副本叫做一个 Replica 或者一个 Peer。Replica之间是通过 Raft 来保持数据的一致。
- Raft Group:一个 Region 的多个 Replica 会保存在不同的 TiKV 实例上,构成一个 Raft Group。
- Leader:Raft Group 中的一个 Replica 会作为这个 Group 的 Leader ,为用户提供数据的读写。Leader 会将对数据的写操作以 Raft Log 的方式同步给 Follower,并且定期向 Follower 发送心跳信息。
- Follower:Raft Group 中的其他 Replica 则作为这个 Group 的 Follower,接收来自 Leader 的Raft Log,完成数据的多副本。当 Follower 长时间(Election Timeout)未收到 Leader 的心跳信息,会将自己转换为 Condidate,重新投票选举 Group 中的 Leader。
1.4.6.1. Raft 协议与 Region
Raft是一个共识算法(consensus algorithm),所谓共识,就是多个节点对某个事情达成一致的看法。Raft 会先选举出 Leader,Leader 完全负责 Replicated Log 的管理。Leader 负责接受所有客户端更新请求,然后复制到 Follower 节点,并在“安全”的时候执行这些请求。如果 Leader 故障,Followes会重新选举出新的 Leader。在 Raft 协议中,一个节点任意时刻都处于 Leader、Follower、Candidate三个角色之一。
Region 与副本之间通过 Raft 协议来维持数据一致性,任何写请求都只能在 Leader 上写入,并且需要写入多数副本后(默认配置为 3 副本,即所有请求必须至少成功写入 2 个副本)才会为客户端返回“写入成功”。
当某个 Region 超过一定大小(默认 144MB)后,TiKV 会将它分裂为两个或者多个,以保证各个Region 的大小大致相等,这样更有利于 PD 进行调度决策。同样,当某个 Region 因为大量的删除而导致其变得更小时,TiKV 会将较小的两个相邻 Region 合并为一个。
当 PD 需要把某个 Region 的一个副本从一个 TiKV 节点调度到另一个节点上时,PD 会先为这个Raft Group 在目标节点上增加一个 Learner 副本23。当这个 Learner 副本的进度大致追上 Leader 副本时,Leader 会将它变更为 Follower,之后再移除操作节点的 Follower 副本。
Leader 副本的调度原理也类似,不过需要在目标节点的 Learner 副本变为 Follower 副本后,再执行一次 Leader Transfer,让该 Follower 主动发起一次选举成为新 Leader,之后新 Leader 负责删除旧Leader 这个副本。
1.4.6.2. Raft 日志复制
当 TiKV 收到客户端的写入请求后,Leader 会做如下工作:
- Propose(接收操作):表示写数据的操作已被 Leader 收到,Leader 开始准备日志的同步;将接收到的写入操作转变成 Raft Log 日志。日志格式如图1.20所示,“4_1, log PUT key=1, name=tom ”表示 4 号 Region,日志序号为 1,操作为 “PUT key=1,name=tom”。
- Append(存储日志): 将 Raft Log 持久化到 TiKV 中名为 raftdb 的 RocksDB 实例中。
- Replicate(复制日志):Leader 将 raftdb 中的 Raft Log 日志复制到 Follower 副本。Follower 接收到 Raft Log,并持久化到其 raftdb 后(即 Append),向 Leader 返回确认消息。
- Committed(日志持久化成功):当 Leader 收到大多数 Follower(默认 3 副本,即必须至少成功写入 2 个副本)都返回 Append 成功后,TiKV 认为该 Raft Log 持久化(Commit)成功。
- Apply(应用日志):当 Raft Log 日志成功写入到 Follower 的 raftdb 后,TiKV 即可从 raftdb 中取出该日志,并将日志转化为 Key-Value,存入名为 kvdb 的 RocksDB 实例中,以完成 Follower 副本的数据同步。
从这里,也可以了解到在 TiKV 节点中存在两个 RocksDB 实例:一个是用于持久化 Raft Log 的raftdb,另一个是用于持久化 KV 数据的 kvdb。
【注意】
Raft Log 日志复制流程中的 Committed,指 Raft Log 的 Committed,表示Leader 的 Raft Log 日志已持久化成功,但其对应的事务还未提交(Commit);而应用程序中的 Committed,指事务的 Committed,表示事务中修改的数据(KV)已持久化成功,即事务已提交(Commit)。注意两处 Committed 之间的区别。
TiKV 为了实现数据的写入,实际上是分层实现的。RocksDB 层提供 Raft Log和 KV 持久化;Raft 层提供多节点的 Region 副本一致性;MVCC 层提供多版本一致性读;Transaction 层提供分布式事务的支持。
1.4.6.3. Leader 选举
在 TiKV 的 Raft 协议中,哪个 Region 做 Leader 是大家投票选举出来的。Leader 会不停的给Follower 发心跳消息,表明自己的存活状态。当 Leader 失效或故障时,Follower 会将自己转变为 Candidate,重新投票选出新的 Leader。Leader 持续工作的这段时间,称为一个“任期(Term)”。因此,任期(Term)以选举(Election)开始,然后就是一段或长或短的稳定工作期(Normal Operation)。
在集群刚创建的时候,TiKV 中是没有 Leader 的,此时的 Region 都是 Follower,每个 Region 都有一个名为 Election Timeout24的计时器。当 Follower 在 Election Timeout 时长内,未收到 Leader 的心跳信息,则 Follower 认为集群中没有 Leader。
【注意】
在集群初始化时,Raft Group 中的多个 Follower 若因为拥有相同的 ElectionTimeout 计时器,而同时将自己选举为 Leader,可能导致选举失败,Follower 需要重新发起新一轮 Leader 选举,直至选出 Leader 为止。为了减少这种情况发生,提高 Leader 选举的效率,TiKV 会在指定范围内为每个 Follower 指定不同的Election Timeout 数值,减少多个 Follower 同时 Candidate 的概率。
如图1.21所示,假设 TiKV Node2 的 Follower 率先超时(Election Timeout),其会将自己转变为Candidate(进入新的 Term=2),然后发起 Leader 选举(先投自己 1 票),并向 TiKV Node1 和 Node3发送选举请求(请投我 1 票,我的 Term=2)。TiKV Node1 与 Node3 接收到请求后,发现新的任期(Term=2)大于自己维持的任期(Term=1)。于是,达成共识,都为 Node2 投票25选举 Node2 的 Region为新的 Leader。
Raft Group 中的 Leader 会定期(Heartbeat Time Interval26)向 Follower 发送心跳信息,以维持任期(Term=2)的关系。如图1.22所示,假设身为 Leader 的 TiKV Node2 宕机后,TiKV Node3 的Follower 率先发现 Leader 心跳超时(Hearbeat Timeout),说明当前任期(Term=2)的 Leader 出现故障。则 TiKV Node3 的 Follower 会将自己转变为 Candidate(进入新的 Term=3),然后发起 Leader选举(先投自己 1 票),并向 TiKV Node1 发送选举请求(请投我 1 票,我的 Term=3)。TiKV Node1接收到请求后,发现新的任期(Term=3)大于自己维持的任期(Term=2)。于是,为 TiKV Node3 投票选举 Node3 的 Region 为新的 Leader。
1.4.7. TiKV 中的数据写入
TiDB Server 负责处理 SQL 语句,将 SQL 语句要修改的数据载入到自己的缓存中,在缓存中进行数据修改。当用户发出 Commit 命令后,开始两阶段提交,将缓存中修改的数据写入到 TiKV 中。PD 在事务开始的时候,为事务提供事务开始的 TSO(start_ts),当用户执行 Commit 时,为事务提供事务提交的 TSO(commit_ts);还为 TiDB Server 提供待修改的数据的位置(在哪个 TiKV 的哪个Region 中)。
如图所示,这里以写入”<key=1, value=Tom>” 为例,介绍一下一次 Raft 的流程。
- Propose:TiKV Node2 的 raftstore pool 线程池接收写请求后,将写请求转化为 Raft Log。
- Append:TiKV Node2 的 raftstore pool 线程池将转化的 Raft Log 持久化到本地名为 raftdb 的RocksDB 实例中。
- Replicate:TiKV Node2 的 raftstore pool 线程池将 Raft Log 日志复制到 TiKV Node1 与 TiKVNode3 节点中。TiKV Node1 与 TiKV Node3 节点的 raftstore pool 线程池接收到 Raft Log 后,将日志分别持久化到 TiKV Node1 和 TiKV Node3 的 raftdb 实例中,并向 TiKV Node2 返回“持久化成功”。
- Committed:当 TiKV Node2 的 raftstore pool 线程池收到 Majority(大多数,即过半数,包含本地 Node)的 Replicate 成功消息后,TiKV Node2 才认为 Raft Log 已持久化(Commit)成功。此刻,若其他会话要读取行 ID=1 的数据,将因该行存在锁(事务未提交),而被阻塞,进入等待状态。
- Apply:TiKV Node2 的 raftstore pool 线程池从 raftdb 实例中读取 Raft Log 日志,将其发送给Apply Pool 线程池;Apply Pool 线程池将”<key=1, value=Tom>” 持久化到名为 kv 的 RocksDB 后,才会向客户端返回数据修改成功(Commit), 即事务提交成功。当前事务释放锁,其他会话可读取到”<1,Tom>”。
【注意】
本章节中,为了描述简单,暂时不考虑 MVCC 及 Transaction 层,只聚焦于 Raft 与 RocksDB 层。
可以看到上面的流程是一个典型的顺序操作,如果 TiKV 完全按照这个流程来执行,性能是完全不够的。TiKV 在此基础上做了进一步的优化,详细内容参考:TiKV功能介绍:Raft优化
1.4.8. TiKV 中的数据读取
线性一致性:TiKV 是一个要保证线性一致性的分布式 KV 系统,所谓线性一致性,一个简单的例子就是在 t1 的时间我们写入了一个值,那么在 t1 之后,我们的读一定能读到这个值,不可能读到 t1 之前的值。
1.4.8.1. Raft Log Read
TiKV 内部可分成多个模块:Raft 模块、RocksDB 模块,两者通过 Raft Log 进行交互。整体架构如图1.24所示,consensus 就是 Raft 模块(对应 raftdb 实例),state machine 就是 RocksDB 模块(对应 kvdb 实例)。
如章节1.3.7中所描述,Client 将“写请求”发送到 Leader 后,Leader 将“写请求”作为一个 Proposal通过 Raft 协议复制到自身以及 Follower 的 Log 中,然后将其 commit 到 raftdb 实例。TiKV 将 raftdb实例中的 Raft Log 应用到 RocksDB 上,由于 Input(即 Raft Log)顺序都一样,可推出各个 TiKV 的状态机(即 kvdb 实例)的状态能达成一致。
可参考“图1.23TiKV 节点数据的写入”中“写请求”的流程,将“读请求”也走一次 Raft log 流程(即 Propose→Append→Replicate→Committed→Apply)。因为在 Raft 模块中,已对读写请求按先后顺序都做了排序(CommitIndex,如图1.25所示),所以等这个“读请求”的 Raft Log 提交之后,在Apply 的时候从状态机(kvdb)里面读取值,我们就一定能够保证这个读取到的值是满足线性一致性要求的。这种需要走一遍 Raft Log 流程的读取方式,称为“Raft Log Read”。因为“读请求”不涉及对状态机(数据)的修改,而每次“读请求”都需要走一遍 Raft Log 流程,增加了 RPC 开销和写 Log 开销,性能较差。
我们知道,任何 Raft 的写入操作都必须经过 Leader,我们可以认为如果当前处理 Read 的 Leader能确定一定是 Leader(即在从 PD 组件获取 Leader 位置至 TiKV Node 中检索到 Leader 这段时间内,Leader 未发生调度、切换),我们直接在这个 Leader 上读取数据,那么读写操作是能满足线性一致性的。
那么,如何确定 TiKV Node 在处理这次 Read 时,Leader 未发生调度或切换呢?在 Raft 论文中,提到两种方法:
- ReadIndex Read
- Lease/Local Read
1.4.8.2. ReadIndex Read
相比于 Raft Log Read,ReadIndex 跳过了 Raft Log,节省了开销,大幅提升读的吞吐。Leader 执行 ReadIndex 流程(如图1.26)如下:
- 将自己 Raft 模块当前的 CommitIndex=1_97 记录到 local 变量ReadIndex中;
- 向 Follower 发起一次 Heartbeat(我是 Leader 吗?),如果大多数节点回复了 Heartbeat Response,那就能确定现在仍然是 Leader
- Leader 等待自己的状态机执行(即应用 RaftLog 到 kvdb),直到ApplyIndex超过了 ReadIndex=1_97。此时,即使 Leader 发生了切换,也不会影响线性一致性(思考一下为什么?)。
- Leader 执行读请求,将结果返回给 Client。
以上就是 Raft 中标准的 ReadIndex 执行流程。可以看到,ReadIndex Read 使用 Heartbeat 的方式来确认自己 Leader 的地位,省去了 Raft Log 的流程,节省了开销。
但是,需要注意一种极端的情况(corner case),即 Leader 刚通过选举成为 Leader,此时该 Leader的 Commit Index 并不能够保证是当前整个系统最新的 Commit Index,所以 Raft 要求当 Leader 选举成功之后,首先提交一个 no-op(是一条需要落盘的 log)的 entry。从而保证之前 term 的 log entry 提交成功。并且通过 no-op,新当选的 Leader 可快速获取系统最新的 CommitIndex,来保证系统迅速进入可读状态。
1.4.8.3. LeaseRead
LeaseRead 与 ReadIndex 类似,但更进一步,不仅省去了 Log,还省去了网络交互,大幅提升了读的吞吐也能显著降低延时。基本思路是 Leader 在发送 Heartbeat 时,会首先记录一个时间点 start,当大部分节点都回复了 Heartbeat Response。那么,就可以认为 Leader 的 Lease(租期)有效期可以到“start + election timeout / clock drift bound”这个时间点。在 Lease(租期)内不会发生 Leader 选举,确保 Leader 不会变,所以可跳过 ReadIndex 的第二步,也就降低了延时。LeaseRead 有效的前提是各个服务器的 CPU clock 的时间是准的,即使有误差,也会在一个非常小的 bound 范围内。如果时钟漂移(clock drift)严重,这套 LeaseRead 机制就会有问题。
TiKV 的 LeaseRead 在实现细节上与 Raft 论文中的 LeaseRead 有些差别。TiKV 未通过 Hearbeat来更新 Lease,而是通过写操作。对于任何的写入操作,都会走一次 Raft Log,所以在 Propose 这次write 请求的时候,记录下当前的时间戳 start,然后等到对应的请求 Apply 之后,就可以续约 Leader的 Lease。但是,如果用户长时间没有写入操作,这时候 Leader 接收到的读取操作因为早就已经没有Lease 了,需要强制走一次 ReadIndex Read。
1.4.8.4. Follower Read
Follower Read 是在 TiDB3.1 版本中引入的新特性,在 Follower Read 功能出现之前,TiDB 采用strong leader 策略将所有的读写操作全部提交到 Region 的 Leader 节点上完成。对于每一个 Region来说,只有 Leader 副本能对外提供服务,Follower 除了时刻同步数据准备着 failover 时投票切换成为Leader 外,无法对 TiDB 的请求提供任何帮助。
当系统中存在读取热点 Region 导致 Leader 资源紧张成为整个系统读取瓶颈时,启用 Follower Read 功能可明显降低 Leader 的负担,并且通过在多个 Follower 之间均衡负载,显著提升系统整体的吞吐能力。
要开启 TiDB 的 Follower Read 功能,将变量 tidb_replica_read 的值设置为 follower 或 leader-and-follower 即可:
set [ session|global ] tidb_replica_read = '<目标值>';
1.4.8.4.1. Follower 强一致读
TiKV Follower 节点处理读取请求如图1.27,首先使用 Raft ReadIndex 协议与 Region 当前的Leader 进行一次交互,来获取当前 Raft Group 最新的 CommitIndex。本地(Follower)Apply 到所获取的 Leader 最新 CommitIndex 后,便可以开始正常的读取请求处理流程。
Follower Read 方案可能会引入两个问题:
- Leader 虽然告诉了 Follower 最新的 CommitIndex,但是 Leader 对这条 Log 的 Apply 是异步进行的。在 Follower 端对这条 Log 的 Apply 可能会比 Leader 端要快。这样就会出现“在 Follower 上能读到这条记录,但是在 Leader 上可能过一会才能读取到”的现象。这一现象虽然不满足线性一致性,但是因为锁28的存在,并不会破坏事务的隔离级别(Snapshot Isolation)。
- Follower Read 的实现方式仍然会有一次到 Leader 请求 CommitIndex 的 RPC,所以目前的Follower Read 实现在降低延迟上不会有太多的效果。但是,对于提升读的吞吐,减轻 Leader 的负担很有帮助。
1.4.8.4.2 Follower 副本选择策略
由于 TiKV 的 Follower Read 不会破坏 TiDB 的 Snapshot Isolation 事务隔离级别,因此 TiDB 选择 Follower 的策略可以采用 Round Robin(轮询)的方式。
对于 Coprocessor 请求,Follower Read 负载均衡策略粒度是连接级别的,对于一个 TiDB 的客户端连接在某个具体的 Region 上会固定使用同一个 Follower,只有在选中的 Follower 发生故障或者因调度策略发生调整的情况下才会进行切换。
而对于非 Coprocessor 请求(点查等),Follower Read 负载均衡策略粒度是事务级别的,对于一个 TiDB 的事务在某个具体的 Region 上会固定使用同一个 Follower,同样在 Follower 发生故障或者因调度策略发生调整的情况下才会进行切换。本章节的内容可参考大神们的文章:
1.4.9. Coprocessor
TiKV 通过协处理器 (Coprocessor) 可以为 TiDB 分担一部分计算:TiDB 会将可以由存储层分担的计算下推。能否下推取决于 TiKV 是否可以支持相关下推。计算单元仍然是以 Region 为单位,即 TiKV的一个 Coprocessor 计算请求中不会计算超过一个 Region 的数据。
TiDB5.4 版本中,已支持下推到 TiKV 的表达式如表1.1所示:
1.5. TiDB 数据库 SQL 执行流程
1.5.1. SQL 语句执行流程概要
1.5.1.1. DML 读语句的流程概要
DML 读语句的流程概要如图1.34所示:
- TiDB Server 实例的协议层模块接收到用户读请求后,首先到 PD 组件申请 TSO(start_ts);
- 申请到 TSO 后,将读请求交由 Parse 模块进行 lex(词法分析)、yacc(语法分析),生成抽象语法树(AST);
- Parse 模块再将抽象语法树交由 Compile 模块进行合法性验证(对象是否存在)、逻辑优化(SQL语句优化)及物理优化(如是否走索引),并生成执行计划;
- Compile 将生成的执行计划交给 Execute 执行;
- Execute 到 TiKV 实例中读取数据,并通过协议层将数据返回给用户。
1.5.1.2. DML 写语句的流程概要
DML 写语句的流程概要如图1.35所示:
因为在修改数据之前,首先要将待修改的数据从 TiKV 读取到内存(memBuffer)中。所以,DML写语句概要流程的前半部分与 DML 读语句基本一致。
-
TiDB Server 实例的协议层接收到用户的 SQL 语句后,首先到 PD 组件申请 TSO(start_ts,事务开始时间戳);然后,依次进行解析(Parse)、编译(Compile)以生成执行计划。将执行计划交由Execute 执行。
-
Execute 到 TiKV 中读取数据,并存入内存(memBuffer)中,开始进行数据修改。
-
当用户执行 Commit 语句时,事务开始进入两阶段(PreWrite、Commit)提交:
- PreWrite 阶段:将内存中的修改及修改的行加一把锁,将修改的内容(KV、锁信息、事务的 start_ts 等)通过 Transaction 模块写入到 TiKV 中;TiKV 通过 Raftstore 将修改的内容以 Raft Log 的方式写入本地 raftdb 中,同时通过 Raft 协议将 Raft Log 复制到其他 TiKV 实例;各个 TiKV 实例的 Apply 摸块以异步方式,将 Raft Log 应用到 kvdb 中进行持久化。
- Commit 阶段:当 Leader 的 Raft Log 成功 Apply 到 kvdb 后,说明修改的数据持久化成功。Transaction 模块向 PD 组件申请 TSO(commit_ts,事务提交时间戳),将提交信息(含行 ID、commit_ts 及 start_ts)持久化到 TiKV 实例中 Write CF 中,并清除 Lock CF 中的锁信息。
-
两阶段提交执行结束后,Transaction 模块通过协议层向用户返回“事务提交成功”。
1.5.1.3. DDL 语句流程概要
TiDB 的 DDL 通过实现 Google F1 的在线异步 schema 变更算法,来完成在分布式场景下的无锁,在线 schema 变更。为了简化设计,TiDB 在同一时刻,只允许一个节点执行 DDL 操作。用户可以把多个 DDL 请求发给任何 TiDB 节点,但是所有的 DDL 请求在 TiDB 内部是由 owner 节点的 worker 串行执行的。
- start job: 每个 TiDB Server 节点都有一个用于接收 DDL 请求的 start job 模块,多个 TiDB Server节点中的 start job 模块可同时并发接收用户的 DDL 请求。
- worker:每个 TiDB Server 节点都有一个用于执行 DDL 操作的 worker 模块,但只有角色为Owner 的 TiDB Server 中的 worker 才有执行 DDL 操作的权利。
- owner:整个集群中只有一个 TiDB Server 节点能当选 owner,owner 角色定期在多个 TiDBServer 节点之间轮换。Owner 节点的产生是用 Etcd 的选举功能从多个 TiDB 节点选举出 Owner 节点。Owner 是有任期的,Owner 会主动维护自己的任期,即续约。当 Owner 节点宕机后,其他节点可以通过 Etcd 感知到并且选举出新的 Owner。
在线 DDL 语句的执行流程概要如图1.36所示:
- 多个 TiDB Server 实例中的 start job 模块可同时接收多个 ddl 请求。将索引相关的 DDL 请求置入 TiKV 实例的 add index queue 队列中,将其它的 ddl 请求置入 job queue 队列中;
- 角色为 Owner 的 TiDB Server 实例中的 workers 模块负责读取 job queue、add index queue 队列,按序执行队列中的 ddl 请求,并将执行完毕的 ddl 存入 history queue 队列中;
- 同一时刻,只有一个 TiDB Server 角色为 Owner,Owner 角色定期在多个 TIDB Server 节点中轮换(重选举)。成为 Owner 的 TiDB Server 节点,首先会通过 schema load 模块来加载 schema 元数据。
1.5.2. SQL 的 Parse 与 Compile
无论是 DML 语句的读写还是 DDL 语句的执行,首先第一步都需要进行解析(Parse)与编译(Compile)。因此,将 DML 语句与 DDL 语句共通的部分(解析与编译)拿出来单独详细介绍。SQL 语句的解析与编译如图1.37所示。
-
TiDB Server 实例的协议层(Protocol Layer)模块监听到客户端发来的 SQL 请求后,首先通过PD Client 模块向 PD 组件异步获取 TSO(start_ts),以标记 SQL 开始执行的时间;
-
申请到 TSO 之后,开始进行 SQL 语句的词法分析(lex)与语法分析(yacc),将 SQL 转化成AST 语法树;
-
Parse 将转化的 AST 语法树发送给 Compile 模块,由 Compile 模块进行进一步的处理:
- Preprocess 预处理:检查 SQL 语句的合法性,如对象名称是否正确、绑定变量等信息。判断 SQL 语句是否为点查(PointGet)34。
- PointGet 点查:如果 SQL 语句为点查(PointGet),则将 AST 直接交由 Executor 模块执行,省掉了后边的优化流程。
- Optimize 优化:如果 SQL 语句不是点查,需要对 AST 进行优化。包括逻辑优化(即 SQL语句优化,如等价改写)、物理优化(结合统计信息、直方图等,选择最优的执行路径),生成物理执行计划。
1.5.3. DML 的执行
1.5.3.1. 读取的执行
SQL 语句经过解析和编译之后,即进入执行阶段。SQL 语句的执行流程如图1.38所示。
-
Executor 执行器接收到 Compile 模块生成的物理执行计划后,需要做两件事情:
- 获取表的元数据(如表结构)。从 Information Schema 中获取表的元数据。因 TiDB 数据库在启动时,已将 Information Schema 载入到 TiDB Server 的内存中。因此,可直接从内存中获取到表的元数据。
- 获取 Region 元数据(即要修改的 KEY 所对应的 Region 及 Region 所在的 TiKV)。首先,到 TiKV Client 模块的 Region Cache 中检索 Region 的元数据。若在 Region Cache 中未检索到Region 元数据或检索到的 Region 元数据过旧,则 TiKV Client 模块会通过 PD Client 模块到 PD组件中获取最新的 Region 元数据,并将其缓存至 TiKV Client 的 Region Cache 中。
-
对象元数据读取完毕后,Exector 即可开始到 TiKV 实例中读数据了。读数据主要包含两种方式:
- 通过 KV 模块读取数据。若 SQL 语句为 PointGet(点查),Compile 模块无需生成执行计划,直接通过 KV 模块、TiKV Client 模块到 TiKV 实例中读取数据。
- 通过 DistSQL 模块读取数据。若 SQL 语句为复杂查询(如表连接或自查询),则 Exector将复杂查询通过 DistSQL 模块转换为对多个单表的简单查询。然后,再通过 TiKV Client 模块到TiKV 实例中读取数据。
-
TiKV 实例接收到读取请求之后,首先会构建一个快照(snapshot),以确保用户只能读取到开始读取之前已提交的数据。如用户 A 在 10:00:00 开始读取数据,用户 B 在 9:00:00 修改数据,用户 C 在11:00:00 修改数据,则用户 A 只能读取到用户 B 的修改,读取不到用户 C 的修改。
-
从 5.0 开始,点查和复杂查询都会进入到 TiKV 的 UnifyRead Pool 线程池。该线程池按优先级执行查询,到 kvdb 中读取数据。
1.5.3.2. 写入的执行
因 TiDB Server 在修改数据之前,需要先将待修改的数据读取到内存(memBuffer)中。因此,修改请求的解析、编译、读取部分与前边介绍的流程基本一致,唯一区别是将数据读取到内存中。这里从已将数据读取到内存中开始介绍修改数据的流程(如图1.39)。
TiDB Server 实例中负责数据写入主要包括 3 个模块,依次为 Transaction、KV、TiKV Client。写入流程如下:
-
TiDB Server 在内存中执行数据修改操作。当用户执行 Commit 语句时,Transaction 模块开始进入两阶段(PreWrite、Commit)提交:
- PreWrite 阶段,执行数据修改和加锁。将内存中的修改及修改的数据行进行加锁。
- Commit 阶段,写入事务提交信息和释放锁。
-
两阶段提交将写入请求35通过 TiKV Client 模块发送到 TiKV 实例进行持久化。TiKV 实例中负责持久化的模块主要有 Scheduler、Raftstore、Apply 模块以及名为 raftdb 和 kvdb 的 RocksDB 实例。
- Scheduler 模块:首先,TiDB Server 的 TiKV Client 模块会将写请求发送给 TiKV 中的Scheduler 模块。Scheduler 模块用于协调并发写入的冲突,并将收到的修改操作发送给 Raftstore模块。当存在并发写入冲突时(如同时写入一个 KEY),Scheduler 通过 latch 来管理写冲突,即拿到 latch 的事务继续进行写入,未拿到 latch 的事务则进入等待。
- Raftstore 模块:Raftstore 模块将 Scheduler 发送来的写请求转换为 Raft Log,持久化到本地 raftdb 实例中,同时将 Raft Log 发送给其他 TiKV Node 的 Raftstore 模块,以同步写操作。
- Apply 模块:负责按序读取 raftdb 中的 Raft Log,异步将 Raft Log 应用到 kvdb 中。至此,数据写入持久化成功。
-
Raft Log 通过 Apply 模块应用到 kvdb 中后,Transaction 模块即可向用户返回“事务提交成功”。
1.5.4. DDL 的执行
TiDB 数据库中与 DDL 执行相关的主要包括 TiDB Server 实例中的 start job、workers、schema load 模块,以及 TiKV 实例中的 job queue、add index queue、history queue 队列。其中,start job 模块负责接收 DDL 请求,并将其存入 TiKV 实例的 job queue 队列中(与加索引相关的 DDL 请求会存入add index queue 队列)。
详细的 DDL 请求执行流程如下:
- TiDB Server 模块接收到 DDL 请求后,将其置入 TiKV 实例的 job queue 队列中。然后,监听job history queue 队列,等待 DDL 执行结果。
- 角色为 Owner 的 worker 模块,从 TiKV 实例的 job queue 中取出首个 DDL 请求,并执行 DDL操作。执行完毕,将其置入 job history queue 队列。
- start job 模块从 job history queue 队列监听到 DDL 执行完毕,向用户返回执行结果。
关于 DDL 语句的源码解析,可参考:《TiDB 源码阅读系列文章(十七)DDL 源码解析》