TiDB - 体系架构
本系列学习笔记根据官方课程《TiDB 高级系统管理 [TiDB v5]》整理,感谢官方精心制作的视频课程。相关课程介绍,详见官方课程链接:https://learn.pingcap.com/learner/course/120005
第一章 深入TiDB体系架构
TiDB 数据库架构概述
TiDB Server
TiDB Server 是 TiDB 数据库的计算节点,主要提供如下功能:
- 处理客户端的连接(MySQL 协议);
- SQL 语句的解析、编译;
- 关系型数据与 KV 的转化;
- SQL 语句的执行
- 在线 DDL 语句执行;
- MVCC(多版本并发控制)垃圾回收;
【注意】
TiDB Server 本身并不存储数据,只是解析 SQL,将实际的数据读取请求转发给底层的存储节点 TiKV(或 TiFlash)。
TiDB 层本身是无状态的,实践中可以启动多个 TiDB 实例,通过负载均衡组件(如 LVS、HAProxy或 F5)对外提供统一的接入地址,客户端的连接可以均匀地分布在多个 TiDB 实例上以达到负载均衡的效果。
TiKV
TiKV 是 TiDB 数据库的存储节点,主要提供如下功能:
- 数据持久化。
TiKV 在内部选择了基于 LSM-Tree 结构的 RocksDB 引擎(是由 Facebook 开源的一个非常优秀的单机 KV 存储引擎)作为数据存储引擎。可以简单的认为 RocksDB 是一个单机的持久化 Key-Value Map。 - 分布式事务支持。
TiKV 的事务采用的是 Google 在 BigTable 中使用的两阶段提交事务模型:Percolator。在每个 TiKV 节点上会单独分配存储锁的空间,称为 CF Lock。 - 副本的强一致性和高可用性。
采用 Multi-Raft 机制实现了多副本的强一致和高可用。TiKV 中存储的 Region 会自动分裂与合并,实现动态扩展,并借助 PD 节点可使 Region 在 TiKV 节点中灵活调度。 - MVCC(多版本并发控制)。
- Coprocessor(协同处理器)。
将 SQL 中的一部分计算利用协同处理器,下推到 TiKV 节点中完成(即算子下推)。充分利用 TiKV 节点的计算资源实现并行计算,从而减轻 TiDB Server 节点的计算压力。TiKV 节点将初步计算后的数据返回给 TiDB Server 节点,减少了网络带宽的占用。
PD(Placement Driver)
PD 是整个 TiDB 集群的控制中枢,是 TiDB 数据库的元信息管理模块。提供如下功能:
- TiKV 的元数据存储(即 Region 分布),方便 TiDB 知道数据位于哪个 TiKV 节点;
- 集群整体拓扑结构的存储;
- 分配全局 ID 和事务 ID。
- 生成全局 TSO 时间戳;
- 收集集群信息进行调度;
- 提供 TiDB Dashboard 服务。
此外,PD 本身也是由至少 3 个节点构成,拥有高可用的能力。建议部署奇数个 PD 节点。
TiFlash
TiFlash 是 TIDB 数据库的列式存储节点,提供如下功能:
- 列式存储,提高分析查询效率;
- 支持强一致性和实时性
- 业务隔离,即 OLTP 与 OLAP 隔离
- 智能选择
TiFlash 节点以 Leaner 角色加入到 Multi-Raft 体系中,从而实现了对 TiKV 数据的准实时更新和强一致性。
TiDB Server
TiDB Server 架构
- Protocol Layer、Parse、Compile
Protocol Layer 提供 MySQL 兼容的协议,处理客户端的连接;将接收到的 SQL 语句交由 Parse 模块进行解析后,并交由 Compile 模块进行编译,并生成执行计划。
- Executor、DistSQL
Executor、DistSQL 负责 SQL 执行计划的执行;对于可直接返回结果的 SQL 语句(点查),由Executor 和 KV 直接执行;而复杂的 SQL 语句由 Executor 交给 DistSQL 来执行。
- Transaction
Transaction 则负责与事务相关的任务。
- PD Client、TiKV Client
PD Client 负责与 PD 实例的交互;TiKV Client 则负责与 TiKV 实例的交互。
- Schema load、Worker、Start job
schema load、worker、start job 则负责在线 ddl。
- GC
GC 负责 MVCC 的垃圾回收
SQL 语句的解析和编译
SQL Parse 解析
如图1.3所示。首先,通过 lex(词法分析)将 SQL 语句解析成多个 token;再根据 token,通过yacc(语法分析)生成抽象语法树(AST),以便于 compile 模块进行优化。
SQL Compile 编译
Compile 接收到 AST 后,如图1.4所示。首先根据元数据进行合法性验证(如对象是否存在),然后进行逻辑优化(即 SQL 语句优化,如等价改写),再结合统计信息进行物理优化(如是否走索引等),最后生成执行计划。
关系型数据与 KV 的转化
聚簇表的 KV 转化
聚簇表(Clustered Table)中,行数据的物理存储顺序与主键的存储顺序一致,访问主键时可直接获取到行记录。表的主键即为 KV 映射中 Key 的一部分。如图1.5所示,以 “编号” 列为主键的聚簇表为例,简单介绍关系性数据与 KV 的转化:
【知识补充】
聚簇表的特点:
1.表中行数据的物理存储顺序与主键的存储顺序一致;
2.通过主键访问行数据时,可以直接获取行数据,即只需 1 次 I/O;
3.创建方法:“CREATE TABLE t(a BIGINT PRIMARY KEY CLUSTERED , b VARCHAR(255);”
4.聚簇表(Clustered Table)在 TiDB 文档中又称聚簇索引表(index-organized tables),在 Oracle 中称为索引组织表(IOT,Index-Organized Tables)。
1.首先,提取聚簇表主键列“编号”为 KEY 值;
2.为了使 KEY 值在 TiDB 数据库中全局唯一,由 TiDB 自动为 KEY 追加“TableID”2前缀;
3.TableID 与主键列共同构成 KEY 值,而表行的其它字段则成为该 KEY 值对应的 VALUE 值。如 KEY 值“T24_r1”对应的 VALUE 值为“Tom,1982-09-28,1390811212,78”。
4.聚簇表的 KV 映射规则:
- Key:tablePrefix{TableID}_recordPrefixSep{Col1}。即假设 Col1 为 Cluster Index,Key 为 TableID 与索引键组合而成。
- Value:[col2,col3,col4]。即 Value 由行数据中除了主键列的其它列组成。
非聚簇表的 KV 转化
非聚簇表(Non-clustered Table)中,行数据的物理存储顺序与主键(主键本质上是唯一索引)的存储顺序无关。在对非聚簇表做 KV 转化时,TiDB 会隐式为每行数据生成 RowID(是一个 Int64 整型)。TableID 与 RowID 组合成 KV 键值对的 KEY 值。具体的 KV 转化流程(如图1.6所示)如下:
1.首先,TiDB 隐式为非聚簇表的每行生成一个 int64 类型的 RowID,并将该 RowID 作为行数据的 KEY 值;若表中含有整型列的主键,则 TiDB 默认将主键列作为每行的 RowID;
2.为了使 KEY 值在 TiDB 数据库中全局唯一,由 TiDB 自动为 KEY 追加“TableID”前缀;
3.TableID与 RowID 共同构成 KEY 值,而表行的所有字段都成为该 KEY 值对应的 VALUE 值。如 KEY 值 ‘’T24_xx1’’ 对应的 VALUE 值为 “1,Tom,1982-09-28,1390811212,78”。
4.非聚簇表的 KV 映射规则:
- Key:tablePrefix{TableID}_recordPrefixSep{_Tidb_RowID}。即 Key 由 TableID 与 RowID 组合而成。
- Value:[col1,col2,col3,col4]。即 Value 为真实的行数据。
【知识补充】
TableID:TableID 在 TiDB 数据库中全局唯一,可理解为 Oracle 的 ObjectID非聚簇表的特点:
1.表中行数据的物理存储顺序与主键(主键本质上是唯一索引)的存储顺序无关;
2.通过主键访问非聚簇表的行数据时,无法直接获取行数据,需要 2 次 I/O(第一次扫主键索引,获取数据行的 RowID;第二次通过 RowID 回表获取行数据);
3.创建方法:CREATE TABLE t(a BIGINT PRIMARY KEY NONCLUSTERED ,b VARCHAR(255));
Region 分裂
Region 是 TiDB 数据存储管理的基本单位。多组连续的“KV 键值对”构成一个 Region,存储在TiKV 实例的 RocksDB 中。
- 每个 Region 默认大小为 96MB。当一个 Region 达到 144MB 时,会自动分裂成两个 Region(如下图所示);
- 每个 Region 按左闭右开的区间划分数据存储范围,如 [a d)、[d g);
SQL 读写相关模块
如图1.8所示,SQL 读写相关模块的协作流程如下:
- Executor 拿到执行计划后,根据 SQL 语句的类型,分别交给不同模块执行;
- 当 SQL 语句为简单的点查(PointGet,根据主键或索引,只查 1 行或 0 行),直接交给 KV 执行;
- 当 SQL 语句为复杂的查询(如多表连结、嵌套查询等),则交给 DistSQL 来执行;DistSQL 模块将多表查询拆分成多个单表查询的操作。
- 若 SQL 语句包含事务,则 Executor 将启动 Transaction 模块。Transaction 模块负责两阶段提交(PreWrite、Commit)和锁管理等。
- Transaction 模块通过 PD Client 向 PD 节点申请全局事务 ID、TSO(全局时间戳)类型的事务开始时间戳 start_ts 和提交时间戳 commit_ts,再调用 KV 模块或 DistSQL 模块。
- 最终的数据读写,通过调用 TiKV Client 模块,来完成与 TiKV 节点的读写交互。
在线 DDL 相关模块
如图1.9所示,在线 DDL 相关模块的协作流程如下:
- start job 模块负责接受 ddl 请求,并将其存入 TiKV 节点的 job queue 队列中;
- workers 模块负责读取 TiKV 节点的 job queue 队列,按序执行队列中的 ddl 操作,并将执行完毕的 ddl 存入 history queue 队列中;
- schema load 模块负责加载 schema 元数据。
- 多个 TiDB Server 中的 start job 可同时接受多个 ddl 请求。但是,只有角色为 Owner 的 TiDBServer 中的 workers 模块可以执行 ddl。
- 同一时刻,只有一个 TiDB Server 角色为 Owner;Owner 角色定期在多个 TIDB Server 节点中轮换(重选举)。
- 成为 Owner 的 TiDB Server 节点,首先会通过 schema load 模块来加载 schema 元数据。
GC 机制与相关模块
TiDB 的事务的实现采用了 MVCC(多版本并发控制)机制,当新写入的数据覆盖旧数据时,旧数据不会被替换掉,而是与新写入的数据同时保留,并以时间戳来区分版本,GC 的任务便是周期性地清理不再需要的旧数据。
GC Leader
TiDB 集群会从众多 TiDB Server 实例中选举一个作为 GC Leader,而 GC 的工作由 GC Leader 中的 GC Worker 模块来处理4。其他 TiDB Server 上的 GC Worker 是不工作的。
【知识补充】
GC Worker 模块:维护 GC 状态并向所有的 TiKV Region leader 发送 GC 命令。
【注意】
选举 GC Leader 的方式为 GC Worker 每分钟 Tick 时,如果发现没有 Leader或 Leader 失效,就把自己写进去,成为 GC Leader。
GC 流程
每一轮 GC 分为以下三个步骤(串行执行):
- Resolve Locks:该阶段会对所有 Region 扫描 Safepoint 5 之前的锁,并清理这些锁。
- Delete Ranges:该阶段快速地删除由于 DROP TABLE/DROP INDEX 等操作产生的整区间(Region)的废弃数据。
- Do GC:该阶段每个 TiKV 节点将会各自扫描该节点上的数据,并对每一个 key 删除其不再需要的旧版本。
【知识补充】
Safepoint:每次 GC 时,首先 TiDB 会计算一个称为 Safepoint 的时间戳,接下来 TiDB 会在保证 Safepoint 之后的快照全部拥有正确数据的前提下,删除更早的过期数据。
【注意】
如果一轮 GC 运行时间太久,上轮 GC 还在前两个阶段,下轮 GC 又开始了,下一轮 GC 会忽略,GC Leader 会报“there’s already a gc job running,skipped”:
默认配置下,GC 每 10 分钟触发一次,每次 GC 会保留最近 10 分钟内的数据(即默认 GC LifeTime 为 10 分钟)。
【注意】
为了使持续时间较长的事务能在超过 GC Life Time 之后仍然可以正常运行,Safepoint 不会大于正在执行中的事务的开始时间 (start_ts)。
GC 实现细节
Resolve Locks(清理锁)
TiDB 的事务是基于 Google Percolator 模型实现的,事务的提交是一个两阶段提交的过程。第一阶段完成时,所有涉及的 key 都会上锁,其中一个锁会被选为 Primary,其余的锁 ( Secondary ) 则会存储一个指向 Primary 的指针;第二阶段会将 Primary 锁所在的 key 加上一个 Write 记录,并去除锁。如果因为某些原因(如发生故障等),这些 Secondary 锁没有完成替换、残留了下来,那么也可以根据锁中的信息找到 Primary,并根据 Primary 是否提交来判断整个事务是否提交。但是,如果 Primary的信息在 GC 中被删除了,而该事务又存在未成功提交的 Secondary 锁,那么就永远无法得知该锁是否可以提交。这样,数据的正确性就无法保证。Resolve Locks 这一步的任务即对 Safepoint 之前的锁进行清理。即如果一个锁对应的 Primary 已经提交,那么该锁也应该被提交;反之,则应该回滚。而如果 Primary 仍然是上锁的状态(没有提交也没有回滚),则应当将该事务视为超时失败而回滚。Resolve Locks 的执行方式是由 GC leader 对所有的 Region 发送请求扫描过期的锁,并对扫到的锁查询 Primary 的状态,再发送请求对其进行提交或回滚。从 3.0 版本开始,Resolve Locks 实现了并行,把所有 Region 分配给各个线程,所有线程并行的向各个 Region 的 Leader 发送请求:
- 并发线程数若 tikv_gc_auto_concurrency = 1,则每个 TiKV 自动一个线程。若 tikv_gc_auto_concurrency = 0,则由 tikv_gc_concurrency 决定总线程数,但每个 tikv 最多一个线程。
- 实际清锁的操作,是调用了 RocksDB 的 Delete ,RocksDB 的内部实现原理是写一个删除标记,需要等 RocksDB 执行 Compaction 回收空间,通常这步骤涉及的数据非常少。
Delete Ranges(删除区间)
在执行 Drop/Truncate Table ,Drop Index 等操作时,会有大量连续的数据被删除。如果对每个key 都进行删除操作、再对每个 key 进行 GC 的话,那么执行效率和空间回收速度都可能非常的低下。事实上,这种时候 TiDB 并不会对每个 key 进行删除操作,而是将这些待删除的区间及删除操作的时间戳记录下来。Delete Ranges 会将这些时间戳在 Safepoint 之前的区间进行快速的物理删除,而普通DML 的多版本不在这个阶段回收。
- TiKV 默认使用 RocksDB 的 UnsafeDestroyRange 接口
- Drop/Truncate Table ,Drop Index 会先把 Ranges 写进 TiDB 系统表(mysql.gc_delete_range),TiDB 的 GC worker 定期查看是否过了 Safepoint,然后拿出这些 Ranges,并发的给 TiKV 去删除 sst文件,并发数和 concurrency 无关,而是直接发给各个 TiKV。删除是直接删除,不需要等 compact 。完成 Delete Ranges 后,会记录在 TiDB 系统表 mysql.gc_delete_range_done,表中的内容过 24 小时后会清除:
mysql> select * from mysql.gc_delete_range_done ;
+−−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+
| job_id | element_id | start_key | end_key | ts |
+−−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+
| 283 | 171 | 7480000000000000ab | 7480000000000000ac | 422048703668289538 |
| 283 | 172 | 7480000000000000ac | 7480000000000000ad | 422048703668289538 |
+−−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−+
2 rows in set
(0.01 sec )
Do GC(进行 GC 清理)
此步操作主要是清理因 DML 操作而产生的过期版本数据。为了保证 Safepoint 之后的任何时间戳都具有一致的快照,这一步删除 Safepoint 之前提交的数据,但是会对每个 key 保留 Safepoint 前的最后一次写入(除非最后一次写入是删除)。
在进行这一步时,TiDB 只需将 Safepoint 发送给 PD,即可结束整轮 GC。TiKV 会每分钟自行检测是否 Safepoint 发生了更新,然后会对当前节点上所有 Region Leader 进行 GC。与此同时,GC Leader 可以继续触发下一轮 GC。详细流程如下:
- 调用 RocksDB 的 Delete 接口,打一个删除标记。
- 每一轮 GC 都会扫所有的 Region,但会根据 sst 上的元信息初步判断是否有较多的历史数据,进而来判断是否可以跳过。如果增量数据比较大,表示要打标记的老版本较多,会大幅增加耗时。
- GC 打完标记后,不会立即释放空间,最终通过 RocksDB Compaction 来真正回收空间。
- 如果这时 TiKV 进程挂掉了,重启后,需要等下一轮 GC 开始继续。
- 并行度。3.0 开始,默认设置 tikv_gc_mode = distributed,无需 TiDB 通过对 TiKV 发送请求的方式来驱动,而是 TiDB 只需在每个 GC 周期发送 safepoint 到 PD 就可以结束整轮 GC,每台 TiKV 会自行去 PD 获取 safepoint 后分布式处理。
- 由于通常 Do GC 比较慢,下一轮 interval 到来时,上一轮 GC 还没有跑完。3.0 开始,如果没有执行完,下一轮 GC 将新的 safepoint 更新到 PD 后,TiKV 每隔 1 分钟到 PD 获取新的 safepoint,获取后会使用新的 safepoint 将剩余的 Region 完成扫描,并尽量回头完成 100% 的 Region (TiKV 会将执行到的 Region 位置在内存中,并按 Region 顺序扫描所有 Region )。
GC 相关的配置项
mysql> select VARIABLE_NAME, VARIABLE_VALUE from mysql.tidb;
+--------------------------+--------------------------------------------------------------------------------------------------------+
| VARIABLE_NAME | VARIABLE_VALUE |
+--------------------------+--------------------------------------------------------------------------------------------------------+
| bootstrapped | True |
| tidb_server_version | 91 |
| system_tz | America/New_York |
| new_collation_enabled | False |
| tikv_gc_leader_uuid | 609230f72ec0002 |
| tikv_gc_leader_desc | host:localhost.localdomain, pid:31283, start at 2022-07-29 15:02:51.839012244 +0800 CST m=+0.337013955 |
| tikv_gc_leader_lease | 20220824-13:18:51 +0800 |
| tikv_gc_enable | true |
| tikv_gc_run_interval | 10m0s |
| tikv_gc_life_time | 10m0s |
| tikv_gc_last_run_time | 20220824-13:16:51 +0800 |
| tikv_gc_safe_point | 20220824-13:06:51 +0800 |
| tikv_gc_auto_concurrency | true |
| tikv_gc_scan_lock_mode | legacy |
| tikv_gc_mode | distributed |
+--------------------------+--------------------------------------------------------------------------------------------------------+
15 rows in set (0.01 sec)
其中,tikv_gc_run_interval,tikv_gc_life_time,tikv_gc_auto_concurrency 这三条记录可以手动配置。其余带有 tikv_gc 前缀的记录为当前运行状态的记录,TiDB 会自动更新这些记录,请勿手动修改。
TiDB Server 的缓存
TiDB Server 缓存组成: SQL 结果、线程缓存、元数据、统计信息。
TiDB Server 缓存相关的参数:
- mem-quota-query
单条 SQL 语句可以占用的最大内存阈值,单位为字节。该值作为系统变量 tidb_mem_quota_query的初始值。超过该值的请求会被 oom-action 定义的行为所处理。 - tidb_mem_quota_query
这个变量用来设置一条查询语句的内存使用阈值。如果一条查询语句执行过程中使用的内存空间超过该阈值,会触发 TiDB 启动配置文件中 OOMAc-tion 项所指定的行为。该变量的初始值由配置项 mem-quota-query 配置。 - oom-action
当 TiDB 中单条 SQL 的内存使用超出 mem-quota-query 限制且不能再利用临时磁盘时的行为。目前合法的选项为”log”、”cancel”(默认值)。设置为”log” 时,仅输出日志。设置为”cancel” 时,取消执行该 SQL 操作,并输出日志。其他详细的配置参数,详见“第六章 TiDB 数据库系统优化”。
PD (Placement Driver)
PD(Placement Driver)架构
Placement Driver (简称 PD) 是 TiDB 集群数据库的总控节点,负责整个集群的调度,负责全局 ID的生成,以及全局时间戳 TSO 的生成等。PD 还保存着整个集群 TiKV 的元信息,负责给 client 提供路由功能。
作为总控节点,PD 集群由多个(通常为 3 个)PD 实例构成,其中的 Leader 实例对外提供服务。PD 通过集成 etcd ,实现 PD 高可用和元数据存储,自动支持 auto failover,无需担心单点故障问题。同时,PD 通过 Raft 协议,保证了多个 PD 实例 etcd 数据的强一致性,不用担心数据丢失的问题。
在架构层面,PD 所有的数据都是通过 TiKV 主动上报获知。同时,PD 对整个 TiKV 集群的调度等操作,也只会在 TiKV 发送 Heartbeat 命令的结果里面返回相关的命令,让 TiKV 自行处理,而不是主动给 TiKV 发送命令。可以认为 PD 是一个无状态的服务。
PD(Placement Driver)主要功能
- 存储集群的元信息(metadata),即某个 Key 存储在哪个 TiKV 实例的哪个 Region;
- 分配全局 ID(如 tableid、indexid 等)和事务 ID
- 生成全局时间戳 TSO,如事务的开始时间与结束时间
- 收集集群信息进行调度,即收集各个 TiKV 实例中 Region 的分布情况,并对其进行调度。
- 提供 label 功能,实现相关的高可用,即通过 label,使 Region 可实现更合理的高可用隔离性。
- 提供 TiDB Dashboard 服务
路由功能
PD 存储了整个集群 TiKV 的元数据(即 Region 的分布),因此可为 TiDB Server 的 Executor 提供Region 的路由功能(如图1.29所示):
1.TiDB Server 实例的 Executor 执行器将执行计划的请求发送给 TiKV Client 模块;
2.TiKV Client 模块通过 PD Client 模块,到 PD Server 中获取 Region 的元数据信息;
3.PD Server 将 Region 的元数据信息,通过 TiDB Server 的 PD Client 模块返回给 TiKV Client 模块;
4.TiKV Client 模块根据接收到的 Region 元数据信息,到 TiKV 实例中获取数据。
为了减少与 PD Server 的网络交互,TiKV Client 将从 PD Server 获取的元数据信息缓存到本地Region Cache 中,以便后续的读取请求可直接从 Region Cache 中获取该 Region 的元数据信息。当TiKV 的 Region 的 Leader 发生切换或分裂,TiKV Client 还是按照 Region Cache 缓冲的元数据进行请求时,会发生二次请求29,这种现象则成为 back off。导致 back off 的主要原因是 Region Cache 的信息过旧;back off 事件越多,读写的延迟就越高。
TSO授时
TiDB 中的时间服务(TSO)由 PD 提供,使用的是中心式的混合逻辑时钟。其使用 64 位(int64)表示一个时间戳,其中低 18 位代表逻辑时钟(Logical Time)部分,剩余部分代表物理时钟(Physical Time)部分,其结构如下图1.30所示。物理时钟是当前 Unix Time 物理时钟的毫秒时间。由于逻辑时钟部分为 18 位,因此理论上,每毫秒内 PD 最多可以分配 218=262144 个时间戳(TSO)。
下面从“校时”、“授时”、“推进”三部分,对 TSO 进行讲解:
校时
当新的 Leader 节点被选出时,其并不知道当前系统的时间已经推进到了哪里,因此首选要对Leader 的时间进行校对。首先,新 Leader 会读取上一个 Leader 保存到 etcd 中的时间,这个时间是上一个 Leader 申请的物理时间(ms)的最大值 Tlast。通过读取 Tlast,便可知道上一个 Leader 分配的时间戳是小于(因为是预分配)Tlast 的。获得 Tlast 后,会将本地物理时间 Tnow 与 Tlast 进行比较,如果 Tnow - Tlast < 1ms,那么当前的物理时间 Tnext = Tlast + 1,否则 Tnext = Tnow。至此,校时完成。
授时
TSO 的授时流程如图1.31所示。
1.TSO 请求者向 TiDB Server 实例的 PD Client 模块发送“TSO 请求”;
2.PD Client 将“TSO 请求”转发至 PD Leader;
3.PD 接收到“TSO 请求”后,因为无法立刻分配 TSO。于是,先为 PD Client 返回一个异步对象tsFuture,表示 “我已经收到请求了,这个 tsFuture 你先拿着,待会儿给你分配 TSO,到时候你需用这个 tsFuture 来领取 TSO”;
4.PD Client 将 PD 分配的 tsFuture 转发给 TSO 请求者,TSO 请求者收到并存储 tsFuture。
5.PD 为 TSO 请求者分配 TSO(TSO 中会携带 tsFuture 信息),PD Client 将分配的 TSO 转交给TSO 请求者;
6.TSO 请求者接收到 PD Client 发送过来的 TSO 后,将其中携带的 tsFuture 信息与自己在第 4 步中已收到的 tsFuture 相比对,以确保是分配给自己的 TSO。
为了保证当前 Leader 宕机后,新 Leader 能校时成功,需要在每次授时之后,都要对 Tlast 进行持久化,保存到 etcd 中。若每次授时之后,都持久化,性能会大大降低。PD 采取的优化策略是预申请一个可分配的“时间窗口 Tx”,默认 Tx = 3s。因此,在授时开始之前,PD Leader 首先将 Tlast = Tnext +Tx 存储到 etcd 中;然后,PD Leader 便可在内存中直接分配 [Tnext , Tnext + Tx)
之内的所有时间戳,避免了频繁写入 etcd 带来的性能问题。但是,如果 Leader crash,会浪费一些时间戳。
TSO 中的物理时钟部分便是校时之后的 Tlast,而逻辑时钟部分便随着请求而原子递增。如果逻辑时钟部分超过了最大值(218=262144),则会睡眠 50ms 来等待物理时间被推进,物理时间被推进后,如果有时间戳可以被分配,则会继续分配时间戳。
由于 TSO 请求是跨网络的,所以为减少网络开销,PD 的 TSO 服务支持批量请求时间戳。客户端可以一次申请 N 个时间戳,减少网络开销。
推进
授时阶段,只能通过逻辑时钟部分自增来分配时间戳,当逻辑时钟部分到达上限(218=262144)后,则无法继续分配,则需要对物理时间进行推进。
PD 会每 50ms 检测当前的时钟,进行时钟推进。首先计算 jetLag = Tnow - Tlast,如果 jetLag >1ms,则说明混合逻辑时钟的物理时钟部分落后于物理时钟,则需要更新混合逻辑时钟的物理时钟部分:Tnext = Tnow。与此同时,为了防止授时阶段由于逻辑时钟达到阈值导致的等待,在推进阶段,当发现当前的逻辑时钟已经大于逻辑时钟的最大值的一半时,也会增加混合逻辑时钟的物理时钟部分。一旦混合逻辑时钟的物理时钟部分增长,则逻辑时钟部分会被重置为 0。
当 Tlast - Tnext <= 1ms 时,说明上次申请的时间窗口已经用完了,需要申请下一个时间窗口。此时,同样将 Tlast = Tnext + Tx 存储到 etcd 中,然后继续在新的时间窗口内进行时间分配。
中心式的解决方案实现简单,但是跨区域的性能损耗大,因此实际部署时,会将 PD 集群部署在同一个区域,避免跨区域的性能损耗;
调度
宏观上看,PD 的调度流程主要包括如下 3 个部分(如图1.32所示):
1.信息收集TiKV 节点周期性地主动向 PD 上报 StoreHeartbeat 和 RegionHeartbeat 两种心跳消息。其中,StoreHeartbeat 包含了 Store 的基本信息,容量,剩余空间,读写流量等数据,RegionHeartbeat 包含了 Region 的范围,副本分布,副本状态,数据量,读写流量等数据。注意,对于 RegionHeartbeat,只有Leader peer 才会上报心跳,Follower peer 是不会上报的。PD 收到 StoreHeartbeat 和 RegionHeartbeat后,首先将其缓存到 cache 中。若 PD 发现 Region 的 epoch 30 有变化,会将这个 Region 的信息也保存到 etcd 中。
2.生成调度
PD 中的调度器(Scheduler31)根据接收到的心跳信息,考虑各种限制和约束后生成待执行的 Operator。生成调度(Operator)时,主要关注如下这些点:
- Balance(负载均衡调度):Region 负载均衡调度主要依赖 balance-leader-scheduler 和 balance-region-scheduler 这两个调度器,balance-leader-scheduler 目的是保持不同 TiKV 节点中 Leader 的均衡,分散读写压力;balance-region-scheduler 目的是保持不同 TiKV 节点中 Peer 的均衡,分散存储压力。
- Hot Region(热点调度):热点调度对应的调度器是 hot-region-scheduler,保持不同 TiKV 节点读写热点 Region 的均衡。热点调度会尝试将热点 Region 打散成多个 Region,使其分布在不同的 TiKV实例中。
- 集群拓扑感知:让 PD 感知不同节点分布的拓扑是为了通过调度使不同 Region 的各个副本尽可能分散,保证高可用和容灾。例如集群有 3 个数据中心,最安全的调度方式就是把 Region 的 3 个 Peer分别放置在不同的数据中心,这样任意一个数据中心故障时,都能继续提供服务。PD 的 replicaChecker .会在后台不断扫描所有 Region,当发现 Region 的分布不是当前的最优化状态时,会生成调度替换 Peer,将 Region 调整至最佳状态。
- 缩容及故障恢复:缩容是指预备将某个 Store(即 TiKV)下线,通过命令将该 Store 标记为 Offline状态。此时,PD 通过 evict-leader-store-id 调度器,将此节点上的 Region 迁移至其他节点。故障恢复是指当有 Store 发生故障且无法恢复时,有 Peer 分布在对应 Store 上的 Region 会产生缺少副本的状况,此时 .需要在其他节点上为这些 Region 补副本。二者处理过程基本一致,由 replicaChecker 检查到 Region 存在异常状态的 Peer,然后生成调度在健康的 Store 创建新的副本替换掉异常的 Peer。
- Region Merge:Region merge 由 mergeChecker 负责,其过程与 replicaChecker 类似,也是在后台遍历,发现连续的小 Region 后发起调度,将相邻的小的 Region 进行合并(Merge)。
1.执行调度生成的 Operator 不会立即开始执行,而是首先会进入一个由 OperatorController 管理的一个等待队列。OperatorController 会根据配置以一定的并发,从等待队列中取出 Operator 进行执行。执行的过程就是依次把每个 Operator Step33 通过 Regionhearbeat Response 下发给对应 Region 的 Leader。让 TiKV 自己去处理,待处理完成之后,通过下一个 Heartbeat 重新上报,PD 就能知道是否调度成功
【补充知识】epoch:在 Region 的 epoch 里面,有 conf_ver 和 version,分别表示这个 Region 不同的版本状态。若一个 Region 发生了 membership changes,即新增或删除了 peer,conf_ver 会加 1,若 Region 发生了 split 或者 merge,则 version 加 1。
Label 与高可用
为了提升 TiDB 集群的高可用性和数据容灾能力,TiDB 推荐让 TiKV 节点尽可能在物理层面上分散,例如让 TiKV 节点分布在不同的机架甚至不同的机房。PD 调度器根据 TiKV 的拓扑信息,会自动在后台通过调度使得 Region 的各个副本尽可能隔离,从而使得数据容灾能力最大化。
而 PD 调度器,默认因无法感知 TiDB 集群的拓扑结构,只能保证一个 Raft Group 中的多个副本(Peer)分布在不同的 TiKV 实例中,而无法保证其分布在不同的机房或机柜中。如图1.33的调度示例中,当机柜 Rack4 故障后,则 Region3 将无法读取;当数据中心 DC1 故障后,则 Region2 将无法读取;只有 Region1,无论单个机柜或单个数据中心故障,都可正常提供服务。
可通过为 TiKV 设置 labels(用于描述集群的拓扑信息,特别是 TiKV 的位置),并将其上报给 PD。以便于 PD 调度器可通过 TiKV 上报的 labels 信息,感知到 TiDB 的拓扑结构,从而隔离各个 Region副本,提高容灾能力。
根据集群拓扑配置 labels
可根据集群拓扑配置 labels,配置流程如下:
1.设置 TiKV 的 labels 标签
TiKV 支持在命令行参数或者配置文件中以键值对的形式绑定一些属性,我们把这些属性叫做标签(label)。TiKV 在启动后,会将自身的标签上报给 PD,因此我们可以使用标签来标识 TiKV 节点的地理位置。
比如集群的拓扑结构分成三层:机房 (zone) -> 机架 (rack) -> 主机 (host),就可以使用这 3 个标签来描述 TiKV 的位置。
使用命令行参数的方式启动一个 TiKV 实例:
tikv-server --labels zone=<zone>,rack=<rack>,host=<host>
使用配置文件的方式:
[server]
labels = "zone=<zone>,rack=<rack>,host=<host>"
1.设置 PD 的 location-labels 配置
根据前面的描述,labels 标签可以是用来描述 TiKV 属性的任意键值对,但 PD 无从得知哪些 labels标签是用来标识地理位置的,而且也无从得知这些标签的层次关系。因此,PD 也需要一些配置来使得PD 可以理解 TiKV 节点的拓扑结构。
PD 上的配置叫做 location-labels,是一个字符串数组。该配置的每一项与 TiKV labels 的 key 是对应的,而且其中每个 key 的顺序代表不同标签的层级关系(从左到右,隔离级别依次递减)。
location-labels 没有默认值,你可以根据具体需求来设置该值,包括 zone、rack、host 等等。同时,location-labels 对标签级别的数量也没有限制,只要其级别与 TiKV 服务器的标签匹配,即可配置成功。
【注意】
- 必须同时配置 PD 的 location-labels 和 TiKV 的 labels 参数,否则 PD 不会根据拓扑结构进行调度。
- 如果你使用 Placement Rules in SQL,只需要配置 TiKV 的 labels 即可。Placement Rules in SQL 目前不兼容 PD location-labels 设置,会忽略该设置。不建议 location-labels 与 Placement Rules in SQL 混用,否则可能产生非预期的结果。
可以根据集群当前状态,来选择不同的配置方式: - 在集群初始化之前,可以通过 PD 的配置文件进行配置:
[replication]
location-labels = ["zone", "rack", "host"] - 在 PD 集群初始化完成后进行配置,则需要使用 pd-ctl 工具进行在线更改:
pd-ctl config set location-labels zone,rack,host
1.设置 PD 的 isolation-level 配置
在配置了 location-labels 的前提下,通过 isolation-level 配置来进一步加强对 TiKV 集群的拓扑隔离要求。假设按照上面的说明通过 location-labels 将集群的拓扑结构分成三层:机房 (zone) -> 机架(rack) -> 主机 (host),并对 isolation-level 作如下配置:
[replication]
isolation-level = "zone"
当 PD 集群初始化完成后,需要使用 pd-ctl 工具进行在线更改:
pd-ctl config set isolation-level host
其中,isolation-level 配置是一个字符串,需要与 location-labels 的其中一个 key 对应。该参数限制 TiKV 拓扑集群的最小且强制隔离级别要求,比如设置 isolation-level=”zone”,则可确保 Raft Group中的多个副本分布在不同的 zone 区域中。
【注意】
isolation-level 默认情况下为空,即不进行强制隔离级别限制,若要对其进行设置,必须先配置 PD 的 location-labels 参数,同时保证 isolation-level 的值一定为 location-labels 中的一个。
使用 TiUP 进行配置(推荐)
如果使用 TiUP 部署集群,可以在初始化配置文件中统一进行 location 相关配置。TiUP 会负责在部署时生成对应的 TiKV 和 PD 配置文件。
下面的例子定义了 zone/host 两层拓扑结构。集群的 TiKV 分布在三个 zone,每个 zone 内有两台主机,其中 z1 每台主机部署两个 TiKV 实例,z2 和 z3 每台主机部署 1 个实例。以下例子中 tikv-n 代表第 n 个 TiKV 节点的 IP 地址。
server_configs:
pd:
replication.location-labels: ["zone", "host"]
tikv_servers:
- host: tikv-1
config:
server.labels:
zone: z1
host: h1
- host: tikv-2
config:
server.labels:
zone: z1
host: h1
- host: tikv-3
config:
server.labels:
zone: z1
host: h2
- host: tikv-4
config:
server.labels:
zone: z1
host: h2
- host: tikv-5
config:
server.labels:
zone: z2
host: h1
- host: tikv-6
config:
server.labels:
zone: z2
host: h2
- host: tikv-7
config:
server.labels:
zone: z3
host: h1
- host: tikv-8
config:
server.labels:
zone: z3
host: h2
TiKV
TiKV 是一个分布式事务型的 Key-Value 键值数据库,提供了满足 ACID 约束的分布式事务接口,并且通过 Raft 协议保证了多副本数据一致性以及高可用。TiKV 作为 TiDB 的存储层,为用户写入 TiDB的数据提供了持久化以及读写服务,同时还存储了 TiDB 的统计信息数据。
TiKV 架构和作用
TiKV 的整体架构如图1.11所示,为 TiDB 集群数据库提供了如下功能:
- 数据持久化
通过集成在 TiKV 中的 RocksDB 引擎,为 TiDB 数据库提供数据的持久化。 - 分布式一致性
TiDB 数据库中的数据以 Region 为单位,分布式的存储在多个 TiKV 节点中。通过 Raft 算法来实现分布式环境中多个 TiKV 节点数据(Region)的强一致性。 - MVCC
TiDB 数据库通过 MVCC 实现事务的多版本并发控制。当新写入的数据覆盖旧数据时,旧数据不会被替换掉,而是与新写入的数据同时保留,并以时间戳来区分版本(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
RocksDB 作为 TiKV 的核心存储引擎,用于存储 Raft 日志以及用户数据。每个 TiKV 实例中有两个 RocksDB 实例,一个用于存储 Raft 日志(通常被称为 raftdb),另一个用于存储用户数据以及 MVCC信息(通常被称为 kvdb)。
RocksDB 针对 Flash 存储(SSD)进行了优化(延迟极小),具有如下特点:
- 是一个高性能的 Key-Value 数据库
- 完善的持久化机制,同时保证性能和安全性
- 良好地支持范围查询
- 为需要存储 TB 级别数据到本地 FLASH 或 RAM 的应用服务器设计
- 针对存储在高速设备的中小键值进行优化,即可存储在 FLASH 或直接存储在内存
- 性能随 CPU 数量线性提升,对多核系统友好
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 的数据写入流程:
1.RocksDB 首先,将 (1,”tom”) 写入磁盘上的 WAL(Write Ahead Log,预写日志)文件中。可通过设置参数 sync-log=true,以使 WAL 写入操作绕开操作系统缓存,直接写入磁盘文件中。
2.再将 (1,”tom”) 写入内存中的 MemTable,MemTable 中通过 Skiplist 结构来保证数据的有序性。此时,若系统掉电重启后,MemTable 中的内容会丢失。RocksDB 通过读取 WAL 文件,恢复 MemTable中丢失的内容。
3.当 MemTable 的大小达到 write-buffer-size 时(默认为 128MB),当前的 MemTable 会变成只读状态(即 immutable MemTable);然后,生成一个新的 MemTable 来接收新的写入。
4.只读的 MemTable 会被 RocksDB 的 flush 线程(线程数由max-background-flushes12 控制)刷写到磁盘,成为 Level0 的一个 SST 文件。
5.默认当 immutable MemTable 数量达到 1 个(由参数min-write-buffer-number-to-merge控制)时,即会 flush 到磁盘的 SST 文件中。当 flush 线程忙不过来,导致等待 flush 到磁盘的 immutableMemTable 的数量到达 max-write-buffer-number13 限定的个数(默认为 5)的时候,会触发 RocksDB的 Write Stall14(写入降级)流控机制。
6.当 immutable Memtable 的内容 flush 到 SST 文件后,WAL 文件的内容即可被新的写入操作覆盖(循环利用)。
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 都只有一个版本。
【知识补充】
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 中的修改操作并不是在原值上修改,而是直接写入修改后的新值。
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 文件。
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.16所示,以修改”❤️,xxx>” 为“❤️,Frank>”的单机事务为例,来了解一下事务在 TiKV 中是如何存储的,以及 TiDB 数据库中事务的流程。
1.首先,执行 begin 时,TiDB Server 会从 PD 组件中获取一个事务的开始时间戳,称其为 “start_ts”。示例中 start_ts=100。
2.然后,TiDB 会将需要修改的数据(示例中为 ❤️,xxx>)读取到 TiDB Server 的内存中,并在内存中完成修改操作(示例中修改为 ❤️,Frank>)。
3.修改之后,一旦当事务遇到 commit 语句时,说明需要将数据持久化了。此时,事务也就进入到了两阶段(PreWrite 和 Commit)提交。
- 第一阶段,为 PreWrite。在此阶段,TiDB 会将内存中修改完的数据(❤️,Frank>)写入到 TiKV节点中的 Default 列簇中,写入的 KEY 包含行 ID 和事务的 start_ts,如“❤️_100, Frank>”。同时将锁20信息写入到 TiKV 节点中的 Lock 列簇中,锁信息包含行 ID、事务的 start_ts 和操作类型 W(写入锁),如“❤️,(W,pk,3,100„,)>”。在 PreWrite 阶段完成后,当有其他会话要读取或修改该行数据,首先检查 Lock CF,发现该行数据被加了一把锁,说明此刻该行数据正被其他会话修改且未提交。读操作将会根据 MVCC 读取最近已提交的数据版本,而修改操作将等待该锁的释放(被阻塞)。
- 第二阶段,为 Commit。在此阶段,TiDB 会先向 PD 组件申请一个事务的提交时间戳,称其为“commit_ts”。示例中 commit_ts=110。并将提交信息写入 TiKV 节点中的 Write 列簇中,提交信息包含行 ID、事务的 commit_ts 和 start_ts,如“❤️_110,100>”。在 Lock 列簇中写入锁的清理信息,包含行 ID、事务的 start_ts 和操作类型 D(清除锁),如“❤️,(D,pk,3,100„,)>”。事务结束后,其他用户要读取行 ID=3 的数据时,首先到 Write 列簇中查找该行数据的提交信息。通过“❤️_110,100>”可得知该行数据最近一次事务的 commit_ts 为 110,该事务的 start_ts 为 100,于是通过行 ID 和 start_ts(即<3_100>)到 Default 列簇中读取到数据“❤️_100, Frank>”。
【知识补充】
在 TiKV 中,当用户写入了一行数据时,如果该行数据长度小于 255 字节,那么会被存储 write 列簇中,否则的话该行数据会被存入到 default 列簇中。为了便于理解,示例中假设写入的数据长度大于 255 字节。
分布式事务的流程
有了单机事务的流程作为基础,下面来看一下 TiKV 中分布式事务的流程。如图1.17所示,这里以一个事务内修改 2 行数据(2 行数据分别存储于 TiKV 的不同节点)为例,介绍一下 TiDB 中的分布式事务。
1.首先,执行 Begin 时,TiDB Server 会从 PD 组件中获取事务的开始时间戳,称其为 “start_ts”。示例中的 start_ts=100。
2.然后,TiDB 会将需要修改的 2 行数据(示例中为 <1, Tom> 和 <2, Andy>)读取到 TiDB Server的内存中,并在内存中执行 2 行数据的修改操作(示例中 <1, Tom> 修改为 <1, Jack>,<2, Andy> 修改为 <2, Candy>)。
3.在内存中修改完成后,一旦遇到 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.3.3中的事务流程均为 “乐观事务”,即加锁操作在两阶段提交的 PreWrite 阶段进行。因此,在事务在执行 Commit 之前(进入两阶段提交之前),无法感知到其他事务的锁信息。而在悲观事务中,事务在修改数据时就对要修改的数据行进行加锁操作。但是此时的锁信息中不包含事务的 start_ts,当事务进入两阶段提交的 PreWrite 阶段时,才将事务的 start_ts 信息补充到锁信息中。
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所示。
MVCC 下的读写流程
在 TSO=120 时,用户开始读取 ID 为 1、2、4 的数据。参考1.18,其读写流程如下所示:
1.读取 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 的事务修改且未提交。于是,用户进入阻塞状态,等待锁的释放。
2.读取 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的数据。
3.读取 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 的锁。
基于 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。
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 这个副本。
Raft 日志复制
当 TiKV 收到客户端的写入请求后,Leader 会做如下工作:
1.Propose(接收操作):表示写数据的操作已被 Leader 收到,Leader 开始准备日志的同步;将接收到的写入操作转变成 Raft Log 日志。日志格式如图1.20所示,“4_1, log PUT key=1, name=tom ”表示 4 号 Region,日志序号为 1,操作为 “PUT key=1,name=tom”。
2.Append(存储日志): 将 Raft Log 持久化到 TiKV 中名为 raftdb 的 RocksDB 实例中。
3.Replicate(复制日志):Leader 将 raftdb 中的 Raft Log 日志复制到 Follower 副本。Follower 接收到 Raft Log,并持久化到其 raftdb 后(即 Append),向 Leader 返回确认消息。
4.Committed(日志持久化成功):当 Leader 收到大多数 Follower(默认 3 副本,即必须至少成功写入 2 个副本)都返回 Append 成功后,TiKV 认为该 Raft Log 持久化(Commit)成功。
5.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 层提供分布式事务的支持。
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。
TiKV 中的数据写入
TiDB Server 负责处理 SQL 语句,将 SQL 语句要修改的数据载入到自己的缓存中,在缓存中进行数据修改。当用户发出 Commit 命令后,开始两阶段提交,将缓存中修改的数据写入到 TiKV 中。PD 在事务开始的时候,为事务提供事务开始的 TSO(start_ts),当用户执行 Commit 时,为事务提供事务提交的 TSO(commit_ts);还为 TiDB Server 提供待修改的数据的位置(在哪个 TiKV 的哪个Region 中)。
如图所示,这里以写入”<key=1, value=Tom>” 为例,介绍一下一次 Raft 的流程。
1.Propose:TiKV Node2 的 raftstore pool 线程池接收写请求后,将写请求转化为 Raft Log。
2.Append:TiKV Node2 的 raftstore pool 线程池将转化的 Raft Log 持久化到本地名为 raftdb 的RocksDB 实例中。
3.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 返回“持久化成功”。
4.Committed:当 TiKV Node2 的 raftstore pool 线程池收到 Majority(大多数,即过半数,包含本地 Node)的 Replicate 成功消息后,TiKV Node2 才认为 Raft Log 已持久化(Commit)成功。此刻,若其他会话要读取行 ID=1 的数据,将因该行存在锁(事务未提交),而被阻塞,进入等待状态。
5.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 中的数据读取
线性一致性:TiKV 是一个要保证线性一致性的分布式 KV 系统,所谓线性一致性,一个简单的例子就是在 t1 的时间我们写入了一个值,那么在 t1 之后,我们的读一定能读到这个值,不可能读到 t1 之前的值。
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
ReadIndex Read
相比于 Raft Log Read,ReadIndex 跳过了 Raft Log,节省了开销,大幅提升读的吞吐。Leader 执行 ReadIndex 流程(如图1.26)如下:
1.将自己 Raft 模块当前的 CommitIndex=1_97 记录到 local 变量ReadIndex中;
2.向 Follower 发起一次 Heartbeat(我是 Leader 吗?),如果大多数节点回复了 Heartbeat Response,那就能确定现在仍然是 Leader
3.Leader 等待自己的状态机执行(即应用 RaftLog 到 kvdb),直到ApplyIndex超过了 ReadIndex=1_97。此时,即使 Leader 发生了切换,也不会影响线性一致性(思考一下为什么?)。
4.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,来保证系统迅速进入可读状态。
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。
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 = '<目标值>';
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 的负担很有帮助。
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.《TiKV 功能介绍 - Lease Read》
2.《TiDB 新特性漫谈:从 Follower Read 说起》
3.《线性一致性和 Raft》
4.《TiKV Follower Read》
Coprocessor
TiKV 通过协处理器 (Coprocessor) 可以为 TiDB 分担一部分计算:TiDB 会将可以由存储层分担的计算下推。能否下推取决于 TiKV 是否可以支持相关下推。计算单元仍然是以 Region 为单位,即 TiKV的一个 Coprocessor 计算请求中不会计算超过一个 Region 的数据。
TiDB5.4 版本中,已支持下推到 TiKV 的表达式如表1.1所示:
TiDB 数据库 SQL 执行流程
SQL 语句执行流程概要
DML 读语句的流程概要
DML 读语句的流程概要如图1.34所示:
1.TiDB Server 实例的协议层模块接收到用户读请求后,首先到 PD 组件申请 TSO(start_ts);
2.申请到 TSO 后,将读请求交由 Parse 模块进行 lex(词法分析)、yacc(语法分析),生成抽象语法树(AST);
3.Parse 模块再将抽象语法树交由 Compile 模块进行合法性验证(对象是否存在)、逻辑优化(SQL语句优化)及物理优化(如是否走索引),并生成执行计划;
4.Compile 将生成的执行计划交给 Execute 执行;
5.Execute 到 TiKV 实例中读取数据,并通过协议层将数据返回给用户。
DML 写语句的流程概要
DML 写语句的流程概要如图1.35所示:
因为在修改数据之前,首先要将待修改的数据从 TiKV 读取到内存(memBuffer)中。所以,DML写语句概要流程的前半部分与 DML 读语句基本一致。
1.TiDB Server 实例的协议层接收到用户的 SQL 语句后,首先到 PD 组件申请 TSO(start_ts,事务开始时间戳);然后,依次进行解析(Parse)、编译(Compile)以生成执行计划。将执行计划交由Execute 执行。
2.Execute 到 TiKV 中读取数据,并存入内存(memBuffer)中,开始进行数据修改。
3.当用户执行 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 中的锁信息。
4.两阶段提交执行结束后,Transaction 模块通过协议层向用户返回“事务提交成功”。
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所示:
1.多个 TiDB Server 实例中的 start job 模块可同时接收多个 ddl 请求。将索引相关的 DDL 请求置入 TiKV 实例的 add index queue 队列中,将其它的 ddl 请求置入 job queue 队列中;
2.角色为 Owner 的 TiDB Server 实例中的 workers 模块负责读取 job queue、add index queue 队列,按序执行队列中的 ddl 请求,并将执行完毕的 ddl 存入 history queue 队列中;
3.同一时刻,只有一个 TiDB Server 角色为 Owner,Owner 角色定期在多个 TIDB Server 节点中轮换(重选举)。成为 Owner 的 TiDB Server 节点,首先会通过 schema load 模块来加载 schema 元数据。
SQL 的 Parse 与 Compile
无论是 DML 语句的读写还是 DDL 语句的执行,首先第一步都需要进行解析(Parse)与编译(Compile)。因此,将 DML 语句与 DDL 语句共通的部分(解析与编译)拿出来单独详细介绍。SQL 语句的解析与编译如图1.37所示。
1.TiDB Server 实例的协议层(Protocol Layer)模块监听到客户端发来的 SQL 请求后,首先通过PD Client 模块向 PD 组件异步获取 TSO(start_ts),以标记 SQL 开始执行的时间;
2.申请到 TSO 之后,开始进行 SQL 语句的词法分析(lex)与语法分析(yacc),将 SQL 转化成AST 语法树;
3.Parse 将转化的 AST 语法树发送给 Compile 模块,由 Compile 模块进行进一步的处理:
- Preprocess 预处理:检查 SQL 语句的合法性,如对象名称是否正确、绑定变量等信息。判断 SQL 语句是否为点查(PointGet)34。
- PointGet 点查:如果 SQL 语句为点查(PointGet),则将 AST 直接交由 Executor 模块执行,省掉了后边的优化流程。
- Optimize 优化:如果 SQL 语句不是点查,需要对 AST 进行优化。包括逻辑优化(即 SQL语句优化,如等价改写)、物理优化(结合统计信息、直方图等,选择最优的执行路径),生成物理执行计划。
DML 的执行
读取的执行
SQL 语句经过解析和编译之后,即进入执行阶段。SQL 语句的执行流程如图1.38所示。
1.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 中。
2.对象元数据读取完毕后,Exector 即可开始到 TiKV 实例中读数据了。读数据主要包含两种方式: - 通过 KV 模块读取数据。若 SQL 语句为 PointGet(点查),Compile 模块无需生成执行计划,直接通过 KV 模块、TiKV Client 模块到 TiKV 实例中读取数据。
- 通过 DistSQL 模块读取数据。若 SQL 语句为复杂查询(如表连接或自查询),则 Exector将复杂查询通过 DistSQL 模块转换为对多个单表的简单查询。然后,再通过 TiKV Client 模块到TiKV 实例中读取数据。
3.TiKV 实例接收到读取请求之后,首先会构建一个快照(snapshot),以确保用户只能读取到开始读取之前已提交的数据。如用户 A 在 10:00:00 开始读取数据,用户 B 在 9:00:00 修改数据,用户 C 在11:00:00 修改数据,则用户 A 只能读取到用户 B 的修改,读取不到用户 C 的修改。
4.从 5.0 开始,点查和复杂查询都会进入到 TiKV 的 UnifyRead Pool 线程池。该线程池按优先级执行查询,到 kvdb 中读取数据。
写入的执行
因 TiDB Server 在修改数据之前,需要先将待修改的数据读取到内存(memBuffer)中。因此,修改请求的解析、编译、读取部分与前边介绍的流程基本一致,唯一区别是将数据读取到内存中。这里从已将数据读取到内存中开始介绍修改数据的流程(如图1.39)。
TiDB Server 实例中负责数据写入主要包括 3 个模块,依次为 Transaction、KV、TiKV Client。写入流程如下:
1.TiDB Server 在内存中执行数据修改操作。当用户执行 Commit 语句时,Transaction 模块开始进入两阶段(PreWrite、Commit)提交:
- PreWrite 阶段,执行数据修改和加锁。将内存中的修改及修改的数据行进行加锁。
- Commit 阶段,写入事务提交信息和释放锁。
2.两阶段提交将写入请求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 中。至此,数据写入持久化成功。
3.Raft Log 通过 Apply 模块应用到 kvdb 中后,Transaction 模块即可向用户返回“事务提交成功”。
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 请求执行流程如下:
1.TiDB Server 模块接收到 DDL 请求后,将其置入 TiKV 实例的 job queue 队列中。然后,监听job history queue 队列,等待 DDL 执行结果。
2.角色为 Owner 的 worker 模块,从 TiKV 实例的 job queue 中取出首个 DDL 请求,并执行 DDL操作。执行完毕,将其置入 job history queue 队列。
3.start job 模块从 job history queue 队列监听到 DDL 执行完毕,向用户返回执行结果。
关于 DDL 语句的源码解析,可参考:《TiDB 源码阅读系列文章(十七)DDL 源码解析》