黄东旭解析 TiDB 的核心优势
571
2024-02-23
本系列学习笔记根据官方课程《TiDB 高级系统管理 [TiDB v5]》整理,感谢官方精心制作的视频课程。相关课程介绍,详见官方课程链接:https://learn.pingcap.com/learner/course/120005
TiKV 是一个分布式事务型的 Key-Value 键值数据库,提供了满足 ACID 约束的分布式事务接口,并且通过 Raft 协议保证了多副本数据一致性以及高可用。TiKV 作为 TiDB 的存储层,为用户写入 TiDB的数据提供了持久化以及读写服务,同时还存储了 TiDB 的统计信息数据。
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的数据。
RocksDB 作为 TiKV 的核心存储引擎,用于存储 Raft 日志以及用户数据。每个 TiKV 实例中有两个 RocksDB 实例,一个用于存储 Raft 日志(通常被称为 raftdb),另一个用于存储用户数据以及 MVCC信息(通常被称为 kvdb)。
RocksDB 针对 Flash 存储(***)进行了优化(延迟极小),具有如下特点:
是一个高性能的 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:类似于 *** 数据库的 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
思考如下场景,在分布式数据库中,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
第二阶段,为 Commit。在此阶段,TiDB 会先向 PD 组件申请一个事务的提交时间戳,称其为“commit_ts”。示例中 commit_ts=110。并将提交信息写入 TiKV 节点中的 Write 列簇中,提交信息包含行 ID、事务的 commit_ts 和 start_ts,如“
【知识补充】
在 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,如“
Commit 阶段。在此阶段,TiDB 会先向 PD 组件获取一个事务的提交时间戳,称为 “commit_ts”,示例中 commit_ts=110。并将各行数据的提交信息分别写入 TiKV Node1 和 TiKV
在两阶段提交过程中,假设 TiKV Node1 节点中的数据已完成提交,TiKV Node2 节点的数据在提交时出现宕机,从而导致第 2 行数据的提交信息和清除锁信息的持久化失败。在 TiKV Node2 恢复正常后,当会话读取 TiKV Node2 中的”<2, Candy>” 数据时过程称为 “roll-forward(前滚)”。
【注意】
如章节1.3.3中的事务流程均为 “乐观事务”,即加锁操作在两阶段提交的 PreWrite 阶段进行。因此,在事务在执行 Commit 之前(进入两阶段提交之前),无法感知到其他事务的锁信息。而在悲观事务中,事务在修改数据时就对要修改的数据行进行加锁操作。但是此时的锁信息中不包含事务的 start_ts,当事务进入两阶段提交的 PreWrite 阶段时,才将事务的 start_ts 信息补充到锁信息中。
假设存在如下所示的两个事务:
事务 1:已执行 Commit,两阶段提交执行完毕。其 start_ts=100,commit_ts=110;
事务 2:未执行 Commit,未进入两阶段提交21。其 start_ts=115。
# 事务1 (已提交)则根据”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_1
读取 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>”。若此时用户要修改
【注意】
以上示例以悲观事务为例介绍数据的读取流程。悲观事务模型中,事务在未执行 Commit 之前(即未进入两阶段提交之前),其他会话也可以感知得到锁的存在。如在事务 2 中,未执行 Commit,其他会话也可以感知得到行 ID=1 和 4 的锁。
首先,如图所示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 协议与 RegionRaft是一个共识算法(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。
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优化
线性一致性:TiKV 是一个要保证线性一致性的分布式 KV 系统,所谓线性一致性,一个简单的例子就是在 t1 的时间我们写入了一个值,那么在 t1 之后,我们的读一定能读到这个值,不可能读到 t1 之前的值。
1.4.8.1. Raft Log ReadTiKV 内部可分成多个模块: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 流
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。