黄东旭解析 TiDB 的核心优势
550
2024-03-15
TiDB 大体可以分为三个模块TiDB Server, PD Cluster 和 Storage Cluster。
TiDB Server 的功能类似于Mysql中的Server端,用于 处理客户端的连接、解析和优化SQL语句、生成执行计划 等等。除此之外,因为TiDB底层存储是LSM-Tree而不是B+ Tree,所以还需要将关系型数据转换成KV格式。此外还有GC、DDL执行、缓存和热点小表等功能。
PD Cluster 是 TiDB 相比于传统数据库独有的一个模块,这个模块也是 TiDB 作为分布式系统的核心。主要的功能有元数据的存储、TiDB 集群中微服务的注册和调用、分布式事务/全局ID(TSO)的生成 和 集群的负载均衡。为了实现这些,PD集成了 etcd。
Storage Cluster 是TiDB 的存储模块,为了实现 HTAP(OLAP + OLTP),这部分又被分成两个存储模块——TiKV(行式存储) 和 TiFlash (列式存储)。
TiKV 为了支持OLTP业务,类似传统数据库引擎会有MVCC多版本并发管理、锁机制、算子下堆、事务隔离、ACID等等功能,底层的存储结构使用的是rocksdb实现的LSM-Tree。
TiFlash 是为了支持OLAP业务,TiFlash的数据是基于TiKV异步复制过来的。
上面我们对三个模块进行了梳理,下面我们具体剖开每个模块,看看具体的细节
Protocol Layer 是协议层,用于管理客户端的连接和身份认证
SQL的解析和编译是 Parse 和 Compile 模块处理的,主要的作用是:SQL → AST语法树 → 执行计划(Plan)
Parse 模块会将SQL语句解析成AST语法树,AST语法树是pkg/parser/ast 目录中定义的基于 Node 的一组用于存储SQL语句信息的对象,比如 DMLNode对象中会包含读写字段(FieldList)、表名(TableName)、条件信息(OnCondition)等等。
Compile 模块会将 AST语法树转换成执行计划(SQL Plan),这部分对应的代码在pkg/planner 目录下,Plan 对象会根据 ast.Node 即AST语法树来生成,并在创建Plan的过程中会进行验证、逻辑优化 和 物理优化,也会创建对应的session上下文。
Compile 模块中提供了将Plan对象转为对应存储类型(StoreType)的执行器(Executor),在执行器中包含KV结构数据,即Compile 模块会将解析SQL生成的关系型数据转换成对应的KV结构数据。
经过上面的解析和编译 ,我们来到了执行Executor模块,它更像是一个 TiDB Server 中的调度模块,用于调度 Transaction、KV 和 DistSQL 模块。
每个 Exec 中会包含对应的 从Plan计划中传递过来的 session 上下文 (sessionctx),session中会包含事务管理器(TxnManager)的调用方法,以便在执行过程中管理事务。
事务具体的实现都是封装在 Transaction 模块,对应代码中则是在 pkg/sessiontxn 目录下。
拿到 Executor 后,之后就是具体的执行阶段,针对比较简单的仅 根据主键或者唯一索引的等值查询 都是通过 KV 模块处理的;相对复杂的任务(job)会通过 DistSQL 将其转换成基于单表的子任务(subjob)。
不论是KV还是 DistSQL 最终都会通过调用TiKV Client来获取引擎层的数据。
Executor模块既然负责了执行的调度,那有关 算子下推 的调度也是在此模块做的。具体的代码在pkg/executor/coprocessor.go
算子下推 指的是将一部分函数计算交给引擎层(TiKV和TiFlash),以减轻Server层和引擎层在网络IO上的开销,它将本需要Server层处理的工作交给了引擎层,Server层只需要对引擎层的结果进行聚合即可。由此可见,为了支持算子下推,引擎层也需要支持一部分函数执行。
TiKV可以支持 TopN 和 Limit 的算子下推
TiFlash可以支持 TableScan、Selection、Hash/Stream Aggregation、TopN、Limit、Project、HashJoin、Windows等算子,支持了几乎所有的SQL函数的下推
online DDL 相关模块——start job, workers 和 schema load
DDL 语句的执行原理及最佳实践
TiDB Server集群中所有实例的start job 模块都可以接收DDL语句,并将其放入 TiKV 中的 job queue
在 TiDB Server 的集群中,只有一个 Leader节点,也可以称为 Owner 节点,只有Owner节点的workers 模块会生效,从TiKV中获取持久化的队列( 物理DDL队列general job queue 和 逻辑DDL队列add index job queue),执行job,并在直接结束后记录到历史队列 history queue
schema load 是 TiDB 中表结构schema的缓存,主要用于DDL语句的查询
缓存模块——memBuffer 和 cache table
TiDB Server的缓存主要是存储在 membuffer 模块,会缓存下面三个数据:SQL结果、线程缓、元数据和统计数据,可以通过tidb_mem_quota_query阈值参数限制每条SQL的缓存占用大小,如果超过此阈值,可以通过oom-action参数设置返回ERROR或打印日志等oom动作。
cache table 主要是用于热点小表缓存,此功能主要用于查询频繁、数据量不大、极少修改的场景,因此想使用热点小表的功能需要满足以下几个条件:
表的总数据量不大 (小于64M)
表的读取频繁 (查询频繁、数据量小、极少修改)
不做DDL,热点小表支持DDL操作,进行DDL需要先关闭热点小表缓存
缓存租约参数 tidb_table_cache_lease ,默认5s。① 租约时间内,读操作直接读缓存,无法进行写操作 ② 租约到期时,缓存中的数据过期,写操作不在堵塞,读写操作都直接请求TiKV ③ 数据更新完毕后后,租约继续开启,回到步骤1
GC机制——GC主要是清理 TiKV 中由 MVCC 产生的历史版本。GC操作是仅由TiDB Server集群中 Leader节点 发起,执行GC前,Server会找到一个safe point,小于safe point 时间的数据将会被请求,默认是safe point是当前减去十分钟,可以通过 GC Left Time 设定,即 GC Left Time 默认是10分钟。
TiDB Server集群每10分钟会进行一次GC操作。首先清除 表或者字段被drop 的数据,其次清除被delete的数据,最后清除这些数据相关的 锁信息。
前面说到PD是TiDB分布式集群的大脑,提供的能力主要是服务整体集群的分布式一致性、高可用。PD是基于etcd实现的,主要的功能大体可以分为以下几类:
分布式集群中唯一键的生成:TSO的生成、全局和事务ID的生成
数据存储:存储集群中的服务信息(元数据)、集群中的调度信息
负载均衡策略:集群中的调度规则、标签(label)能力
PD会记录集群中每个实例的元数据信息、TiDB 集群的环境变量信息(如Server中的GC Left time)、集群的调度信息、使用过的TSO、规则信息、资源组等等。具体是通过 leveldb 存储的。
TSO是一个 int64的整数型,主要是由 physical (unix时钟,精确到毫秒) 和 logical (逻辑相对时钟) 组成,TSO的分配都是由PD集群中的 Leader 节点 分配的。TiDB Server 在请求PD 获取 TSO的时候是调用Leader节点,通过异步 callable-future 的方式获取的,避免了PD生成TSO的等待时间。
还需要注意一点的是, PD在分配TSO的时候会对TSO进行落盘,即会产生IO操作,所以在分配过程中会分配 一段时间窗口 的TSO并缓存到内存,在TiDB Server 多次获取的过程中,只需要从内存中获取TSO即可,大大增加了并发性。源码在 pd 的pkg/tso/global_allocator.go
对于负载均衡所需要的实例状态信息,由TiKV节点周期性地通过心跳(heart beat)的方式传递给PD。其中这部分信息包含 store heartbead(TiKV实例的状态信息) ****和 region heartbeat (TiKV中每个Region的状态信息)。
基于这些信息,PD支持读写均衡、region容量分配均衡、热点数据打散、扩容缩容、故障恢复等等功能,这些属于比较常规的负载均衡策略,这里不做详细说明。下面主要说明一下PD提供的 label 标签 功能。
针对大规模的分布式集群业务,服务会部署在不同的IDC机房、不同的子网上,为了提供用户对于跨机房业务的调度配置,TiDB 在 PD 中提供了 label,具体的代码在pkg/schedule/labeler
PD 中定义了ReplicationConfig来读取label相关的配置,比较重要的几个变量有:
max-replicas:每个区域的副本数
location-labels:标签列表,列表中的顺序代表标签的优先级
isolation-level:显式强制隔离副本的label,即在此配置中的label key至少在3个不同的label value中有副本
TiKV的存储数据结构是基于rocksdb 的 LSM-Tree (全称是 Log-Structured Merge-tree),存储的格式是KV键值对,通过分块 + 二分查找的方式找到对应的记录。
LSM-Tree会在内存中分配1个MemTable块和若干个 immutable MemTable,这些块以链表的形式连接在一起;同理磁盘中的 SSTable 也会按照层级串联起来且大小不断递增;需要注意的是 Level 0 的 SSTable 数据是 immutable MemTable 数据的复刻 (rocksdb会尽可能块地将 immutable MemTables 刷盘到 L0的SSTable中)。
内存中的immutable MemTable = 磁盘中的 Level 0 SSTable
当 MemTable 填满时,会刷盘到immutable MemTable,后台进程会将immutable MemTable 的数据刷新到磁盘中Level 0 的 SSTable ,当Level 0 的SSTABLE文件达到4个时,会进行压缩并存储到Level 1,以此类推。
写操作时,直接将写请求记录在内存的MemTable 和磁盘的 WAL 日志即可
读操作时,TiKV在 MemTable 上有提供了一个Block Cache 的缓存,用于缓存最近最常读取的数据,Block Cache 中没有再依次读取MemTable、immutable MemTable、SSTable
还需要注意一点数据写入都是到内存的MemTable,那么如何保证一致性?
答案是:WAL —— Write Ahead Log
既然 LSM-Tree 是KV形式存储,那么一个表中的不同索引要怎么查找?
这里就需要引入列簇Column Families,即每个column对应一组MemTable、immutable MemTable和SSTable。
下面我们从 rocksdb 源码的数据结构,来串一下上面的功能
// [db/column_family.h] // 列簇集合 class ColumnFamilySet { private: friend class ColumnFamilyData; // 默认主键列簇 // 列簇集合 UnorderedMap<std::string, uint32_t> column_families_; UnorderedMap<uint32_t, ColumnFamilyData*> column_family_data_; } // [db/column_family.h] // 单列簇数据 class ColumnFamilyData { private: friend class ColumnFamilySet; // 列信息 uint32_t id_; const std::string name_; // 版本信息 Version* dummy_versions_; // Head of circular doubly-linked list of versions. Version* current_; // == dummy_versions->prev_ MemTable* mem_; // MemTable MemTableList imm_; // immutable MemTable SuperVersion* super_version_; // 所有版本(当前+历史版本) // ColumnFamily 形成双向链表 ColumnFamilyData* next_; ColumnFamilyData* prev_; } // [db/memtable_list.h] // 所有 immutable memtables 的集合的引用,数组存在 MemTableListVersion 中 // 官方注释也说明 MemTableList即imm 会尽快刷新到 L0 的SST中 class MemTableList { const int min_write_buffer_number_to_merge_; MemTableListVersion* current_; // 仍需刷盘的elements数量 int num_flush_not_started_; // 当前内存使用量 size_t current_memory_usage_; } // [db/memtable_list.h] // 保存了 immutable memtables 数组 class MemTableListVersion { // 没有刷盘的 Immutable MemTables std::list<MemTable*> memlist_; // 已经落盘的 MemTables std::list<MemTable*> memlist_history_; } // [db/memtable.h] class MemTable { // MemtableRep 里包含了 SkipListRep 和 HashSkipListRep // 具体可以看 [db/memtablerep.h] const size_t kArenaBlockSize; ConcurrentArena arena_; std::unique_ptr<MemTableRep> table_; std::unique_ptr<MemTableRep> range_del_table_; // flush 相关 bool flush_in_progress_; // started the flush bool flush_completed_; // finished the flush uint64_t file_number_; // filled up after flush is complete std::atomic<FlushStateEnum> flush_state_; }根据上面的数据结构,我们可以得到下面这张图
分布式事务的设计思想沿用了 Google Percolator (Google.Inc. (2010). Large-scale Incremental Processing Using Distributed Transactions and Notifications.) 提出的分布式事务,主要分为两部分
基于MVCC机制的快照读
事务的两阶段提交 prewrite-commit
稍微总结一下 Percolator :
数据结构层面,定义了一种带TSO时间戳的KV格式,其中 key 是行关键字(row),列关键字(column),以及时间戳(timestamp)的组合,value 是任意的 byte 数组
Key: row:string, column:string,timestamp:int64 Value: string架构层面分为3个组件:Client、 TSO 和 Bigtable,其中Client是分布式事务流程的控制者、两阶段提交的协调者,TSO全局唯一且递增的时间戳,Bigtable是持久化的分布式存储
关于Percolator更详细的内容可以看这篇文章,这里不在展开说明——***数据库内核月报. 11(2018). Database · 原理介绍 · Google Percolator 分布式事务实现原理解读
对应到TiDB,首先我们需要知道 TiDB 中的KV格式是如何设计的。TiDB为表的每个索引分配了一个索引ID,用IndexId表示。
对于主键和唯一索引,可以根据键值key快速定位到对应的 RowID;如果是唯一索引,value对应RowID主键的值,如果是主键则是具体的数据:
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue Value: RowID对于不需要满足唯一性约束的普通二级索引,一个键值可能对应多行,需要根据键值范围查询对应的 RowID:
Key: tablePrefix{TableID}_indexPrefixSep{IndexID}_indexedColumnsValue_{RowID} Value: null生成 IndexKey 这部分代码在tidb中的pkg/tablecodec/tablecodec.go
var ( tablePrefix = []byte{t} recordPrefixSep = []byte("_r") indexPrefixSep = []byte("_i") metaPrefix = []byte{m} ) // 生成 index key func GenIndexKey(loc *time.Location, tblInfo *model.TableInfo, idxInfo *model.IndexInfo, phyTblID int64, indexedValues []types.Datum, h kv.Handle, buf []byte) (key []byte, distinct bool, err error) { // 判断 unique if idxInfo.Unique { for _, cv := range indexedValues { if cv.IsNull() { distinct = false break } } } TruncateIndexValues(tblInfo, idxInfo, indexedValues) // 分配定长buf key = GetIndexKeyBuf(buf, RecordRowKeyLen+len(indexedValues)*9+9) // table id, 返回 t[tableID]_i key = appendTableIndexPrefix(key, phyTblID) // index id,返回 t[tableID]_i[indexId] key = codec.EncodeInt(key, idxInfo.ID) // indexedColumnsValue key, err = codec.EncodeKey(loc, key, indexedValues...) if err != nil { return nil, false, err } if !distinct && h != nil { // 二级索引 <版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。