Tidb

Tidb 架构

Tidb 组件介绍

TiDB 架构图

image-20220723161043866

TiDB Server

TiDB Server 负责接收 SQL 请求,处理 SQL 相关的逻辑,并通过 PD 找到存储计算所需数据的 TiKV 地址, 与 TiKV 交互

获取数据,最终返回结果。 TiDB Server 是无状态的,其本身并不存储数据,只负责计算,可以无限水平扩展, 可以通过

负载均衡组件(如LVS、HAProxy 或 F5)对外提供统一的接入地址。

PD Server

Placement Driver (简称 PD) 是整个集群的管理模块,其主要工作有三个:

  • 一是存储集群的元信息(某个 Key 存储在哪个 TiKV 节点);
  • 二是对 TiKV 集群进行调度和负载均衡(如数据的迁移、Raft group leader 的迁移等);
  • 三是分配全局唯一且递增的事务 ID。

PD 是一个集群,需要部署奇数个节点,一般线上推荐至少部署 3 个节点。

TiKVServer

TiKV Server 负责存储数据,从外部看 TiKV 是一个分布式的提供事务的 Key-Value 存储引擎。存储数据的基本单位是 Region, 每个 Region 负责存储一个 Key Range (从 StartKey 到 EndKey 的左闭右开区间)的数据, 每个 TiKV 节点会负责多个 Region 。TiKV 使用 Raft 协议做复制,保持数据的一致性和容灾。 副本以 Region 为单位进行管理,不同节点上的多个 Region 构成一个 Raft Group,互为副本。 数据在多个 TiKV 之间的负载均衡由 PD 调度,

这里也是以 Region 为单位进行调度。

一、Tidb 核心组件介绍

1. TiDB Server

还是从一个黑盒子讲起,在没有了解之前,我们对 TiDB 的认识就是,我们往里面丢数据,TiDB 负责存储数据。并且由于是分布式的,所以理论上只要存储资源够,多大的数据都能够存下。

1658240512031

我们知道,TiDB 支持 MySQL,或者说兼容大多数 MySQL 的语法。那我们就还是拿一个 Insert 语句来当作切入点,探索数据在 TiDB 中到底是如何存储的。

首先要执行语句,必然要先建立连接。

1658240536106

在 MySQL 中,负责处理客户端连接的是 MySQL Server,在 TiDB 中也有同样的角色 —— TiDB Server,虽角色类似,但两者有着很多的不同

TiDB Server 对外暴露 MySQL 协议,负责 SQL 的解析、优化,并最终生成分布式执行计划,MySQL 的 Server 层也会涉及到 SQL 的解析、优化,但与 MySQL 最大的不同在于,TiDB Server 是无状态的。

而 MySQL Server 由于和底层存储引擎的耦合部署在同一个节点,并且在内存中缓存了页的数据,是有状态的。

这里其实可以简单的把两者理解为,TiDB 是无状态的可横向扩展的服务。而 MySQL 则是在内存中缓存了业务数据、无法横向扩展的单体服务

而由于 TiDB Server 的无状态特性,在生产中可以启动多个实例,并通过负载均衡的策略来对外提供统一服务。

1658240577173

实际情况下,TiDB 的存储节点是单独、分布式部署的,这里只是为了方便理解 TiDB Server 的横向扩展特性,不用纠结,后面会聊到存储

总结下来,TiDB Server 只干一件事:负责解析 SQL,将实际的数据操作转发给存储节点

2.TiKV

我们知道,对于 MySQL,其存储引擎(绝大多数情况)是 InnoDB,其存储采用的数据结构是 B+ 树,最终以 .ibd 文件的形式存储在磁盘上。那 TiDB 呢?

TiDB 的存储是由 TiKV 来负责的,这是一个分布式、支持事务的 KV 存储引擎。说到底,它就是个 KV 存储引擎。

用大白话说,这就是个巨大的、有序的 Map。但说到 KV 存储,很多人可能会联想到 Redis,数据大多数时候是放在内存,就算是 Redis,也会有像 RDB 和 AOF 这样的持久化方式。那 TiKV 作为一个分布式的数据库,也不例外。它采用 RocksDB 引擎来实现持久化,具体的数据落地由其全权负责。

RocksDB 是由 Facebook 开源的、用 C++ 实现的单机 KV 存储引擎。

Tidb 数据存储与调度

1.索引数据

直接拿官网的例子给大家看看,话说 TiDB 中有这样的一张表:

1658240602102

然后表里有三行数据:

1658240621048

这三行数据,每一行都会被映射成为一个键值对:

1658240642814

其中,Key 中的 t10 代表 ID 为 10 的表,r1 代表 RowID 为 1 的行,由于我们建表时制定了主键,所以 RowID 就为主键的值。Value 就是该行除了主键之外的其他字段的值,上图表示的就是主键索引

那如果是非聚簇索引(二级索引)呢?就比如索引 idxAge,建表语句里对 Age 这一列建立了二级索引:

1658240661665

i1 代表 ID 为 1 的索引,即当前这个二级索引,10、20、30 则是索引列 Age 的值,最后的 1、2、3 则是对应的行的主键 ID。从建索引的语句部分可以看出来,idxAge 是个普通的二级索引,不是唯一索引。所以索引中允许存在多个 Age 为 30 的列。

但如果我们是唯一索引呢?

1658240682648

只拿表 ID、索引 ID 和索引值来组成 Key,这样一来如果再次插入 Age 为 30 的数据,TiKV 就会发现该 Key 已经存在了,就能做到唯一键检测。

2.存储细节

知道了列数据是如何映射成 Map 的,我们就可以继续了解存储相关的细节了。

1658240700286

从图中,我们可以看出个问题:如果某个 TiKV 节点挂了,那么该节点上的所有数据是不是都没了?

当然不是的,TiDB 可以是一款金融级高可用的分布式关系型数据库,怎么可能会让这种事发生。

TiKV 在存储数据时,会将同一份数据想办法存储到多个 TiKV 节点上,并且使用 Raft 协议来保证同一份数据在多个 TiKV 节点上的数据一致性。

1658240716982

上图为了方便理解,进行了简化。实际上一个 TiKV 中有存在 2 个 RocksDB。一个用于存储 Raft Log,通常叫 RaftDB,而另一个用于存储用户数据,通常叫 KVDB。

简单来说,就是会选择其中一份数据作为 Leader 对外提供读、写服务,其余的作为 Follower 仅仅只同步 Leader 的数据。当 Leader 挂掉之后,可以自动的进行故障转移,从 Follower 中重新选举新的 Leader 出来。

看到这,是不是觉得跟 Kafka 有那么点神似了。Kafka 中一个 Topic 是逻辑概念,实际上会分成多个 Partition,分散到多个 Broker 上,并且会选举一个 Leader Partition 对外提供服务,当 Leader Partition 出现故障时,会从 Follower Partiiton 中重新再选举一个 Leader 出来。

那么,Kafka 中选举、提供服务的单位是 Partition,TiDB 中的是什么呢?

3.Region

答案是 Region。刚刚讲过,TiKV 可以理解为一个巨大的 Map,而 Map 中某一段连续的 Key 就是一个 Region。不同的 Region 会保存在不同的 TiKV 上。

1658240736105

一个 Region 有多个副本,每个副本也叫 Replica,多个 Replica 组成了一个 Raft Group。按照上面介绍的逻辑,某个 Replica 会被选作 Leader,其余 Replica 作为 Follower。

并且,在数据写入时,TiDB 会尽量保证 Region 不会超过一定的大小,目前这个值是 96M。当然,还是可能会超过这个大小限制。

每个 Region 都可以用 [startKey, endKey) 这样一个左闭右开的区间来表示。

但不可能让它无限增长是吧?所以 TiDB 做了一个最大值的限制,当 Region 的大小超过144M(默认) 后,TiKV 会将其分裂成两个或更多个 Region,以保证数据在各个 Region 中的均匀分布;同理,当某个 Region 由于短时间删除了大量的数据之后,会变的比其他 Region 小很多,TiKV 会将比较小的两个相邻的 Region 合并

大致的存储机制、高可用机制上面已经简单介绍了。

但其实上面还遗留一了比较大的问题。大家可以结合上面的图思考,一条查询语句过来,TiDB Server 解析了之后,它是怎么知道自己要找的数据在哪个 Region 里?这个 Region 又在哪个 TiKV 上?

1658240752698

难道要遍历所有的 TiKV 节点?用脚想想都不可能这么完。刚刚讲到多副本,除了要知道提供读、写服务的 Leader Replica 所在的 TiKV,还需要知道其余的 Follower Replica 都分别在哪个实例等等。

4.PD

这就需要引入 PD 了,有了 PD 「存储相关的细节」那幅图就会变成这样:

1658240768468

PD 是个啥?其全名叫 Placement Driver,用于管理整个集群的元数据,你可以把它当成是整个集群的控制节点也行。PD 集群本身也支持高可用,至少由 3 个节点组成。举个对等的例子应该就好理解了,你可以把 PD 大概理解成 Zookeeper,或者 RocketMQ 里的 NameServer。Zookeeper 不必多说,NameServer 是负责管理整个 RocketMQ 集群的元数据的组件。

担心大家杠,所以特意把大概两个字加粗了。因为 PD 不仅仅负责元数据管理,还担任根据数据分布状态进行合理调度的工作。

这个根据数据状态进行调度,具体是指啥呢?

5.调度

举个例子,假设每个 Raft Group 需要始终保持 3 个副本,那么当某个 Raft Group 的 Replica 由于网络、机器实例等原因不可用了,Replica 数量下降到了 1 个,此时 PD 检测到了就会进行调度,选择适当的机器补充 Replica;Replica 补充完后,掉线的又恢复了就会导致 Raft Group 数量多于预期,此时 PD 会合理的删除掉多余的副本。

一句话概括上面描述的特性:PD 会让任何时候集群内的 Raft Group 副本数量保持预期值

这个可以参考 Kubernetes 里的 Replica Set 概念,我理解是很类似的。

或者,当 TiDB 集群进行存储扩容,向存储集群新增 TiKV 节点时,PD 会将其他 TiKV 节点上的 Region 迁移到新增的节点上来。

或者,Leader Replica 挂了,PD 会从 Raft Group 的 Replica 中选举出一个新的 Leader。

再比如,热点 Region 的情况,并不是所有的 Region 都会被频繁的访问到,PD 就需要对这些热点 Region 进行负载均衡的调度。

总结一下 PD 的调度行为会发现,就 3 个操作:

  1. 增加一个 Replica
  2. 删除一个 Replica
  3. 将 Leader 角色在一个 Raft Group 的不同副本之间迁移

了解完了调度的操作,我们再整体的理解一下调度的需求,这点 TiDB 的官网有很好的总结,我把它们整理成脑图供大家参考:

1658240789891

大多数点都还好,只是可能会对「控制负载均衡的速度」有点问题。因为 TiDB 集群在进行负载均衡时,会进行 Region 的迁移,可以理解为跟 Redis 的 Rehash 比较耗时是类似的问题,可能会影响线上的服务

6.心跳

PD 而要做到调度这些决策,必然需要掌控整个集群的相关数据,比如现在有多少个 TiKV?多少个 Raft Group?每个 Raft Group 的 Leader 在哪里等等,这些其实都是通过心跳机制来收集的。

在 NameServer 中,所有的 RocketMQ Broker 都会将自己注册到 NameServer 中,并且定时发送心跳,Broker 中保存的相关数据也会随心跳一起发送到 NameServer 中,以此来更新集群的元数据。

PD 和 TiKV 也是类似的操作,TiKV 中有两个组件会和 PD 交互,分别是:

  1. Raft Group 的 Leader Replica
  2. TiKV 节点本身

PD 通过心跳来收集数据,更新维护整个集群的元数据,并且在心跳返回时,将对应的「调度指令」返回。

1658240814833

值得注意的是,上图中每个 TiKV 中 Raft 只连了一条线,实际上一个 TiKV 节点上可能会有多个 Raft Group 的 Leader

Store(即 TiKV 节点本身)心跳会带上当前节点存储的相关数据,例如磁盘的使用状况、Region 的数量等等。通过上报的数据,PD 会维护、更新 TiKV 的状态,PD 用 5 种状态来标识 TiKV 的存储,分别是:

  1. Up:这个懂的都懂,不懂的解释了也不懂(手动 doge)
  2. Disconnect:超过 20 秒没有心跳,就会变成该状态
  3. Down:Disconnect 了 max-store-down-time 的值之后,就会变成 Down,默认 30 分钟。此时 PD 会在其他 Up 的 TiKV 上补足 Down 掉的节点上的 Region
  4. Offline:通过 PD Control 进行手动下线操作,该 Store 会变为 Offline 状态。PD 会将该节点上所有的 Region 搬迁到其他 Store 上去。当所有的 Region 迁移完成后,就会变成 Tomstone 状态
  5. Tombstone:表示已经凉透了,可以安全的清理掉了。

其官网的图已经画的很好了,就不再重新画了,以下状态机来源于 TiDB 官网:

1658240945581

Raft Leader 则更多的是上报当前某个 Region 的状态,比如当前 Leader 的位置、Followers Region 的数量、掉线 Follower 的个数、读写速度等,这样 TiDB Server 层在解析的时候才知道对应的 Leader Region 的位置。

Tidb 索引

mysql ACID机制

ACID 相信小伙伴都被面试官问过,我想简单讨论的一点是:如何 持久化数据 才能保证数据写入的 事务性 和 读写性能?

事务性可简单理解为:

  1. 数据必须持久化。
  2. 一次数据的写入返回给用户 写入成功就一定成功,失败就一定失败。 读写性能可简单理解为:一次读 或 一次写 需要的IO次数,因为访问速率:CPU>>内存>>SSD/磁盘。

对于单机存储,最简单的方式当然是:写一条就持久化一条,读一条就遍历一遍所有数据,然后返回。当然没人这么干,在内存中我们都还知道用个HashMap呢。

拿Mysql InnoDB举例子:读性能体现在数据的索引在磁盘上主要用B+树来保证。写性能体现在运用 WAL机制 来避免每次写都去更新B+树上的全量索引和数据内容,而是通过redo log记录下来每次写的增量内容,顺序将redo log写入磁盘。同时在内存中记录下来本次写入应该在B+树上更新的脏页数据,然后在一定条件下触发脏页的刷盘。

image-20220723163702538

redo log是数据增删改的实时增量数据,顺序写入保证了写性能和事务性。磁盘上的B+树是数据增删改的全量数据,通过定时脏页刷盘保证这份数据的完整性。

这里的问题是:虽然通过WAL机制,提高了写入性能,但是仍然避免不了脏页数据定时刷盘的随机IO写入。因为数据在磁盘上是以block为单位存储的(比如4K,16K),假如你更新了Order表一条数据,又更新了Product表一条数据,脏页刷盘时,这两条数据在磁盘上的block位置可能差了十万八千里,就变成了随机IO的写入。

image-20220723163710780

RUM 定理

RUM(Read,Write,Memory)类似分布式领域的CAP定理。如果想得到三者中的两者性能,必须以牺牲第三者的性能为代价。衡量这三者的性能指标有:读放大、写放大、空间放大。

  • 读放大:是指每次查询读取磁盘的次数。如果每次查询需要查询5个page (或block),则读放大是5.
  • 写放大:是指数据写入存储设备的量和数据写入数据库的量之比。如果用户写入数据库的大小是10MB,而实际写入磁盘的大小是30MB,则写放大是3.另外SSD的写入次数是有限的,写放大会减少SSD的寿命。
  • 空间放大:是指数据在存储设备的总量和数据在数据库的总量之比。如果用户往数据库上存10MB,而数据库实际在磁盘上用了100MB去存,则空间放大就是10.

通常,数据结构可以最多优化这其中的两者,如B+树有更少的读放大,LSM Tree有更少的写放大。

下面我们将介绍LSM Tree的结构,它是如何解决 B+树中“全量数据的随机写入” 问题的;在RUM问题上,又该如何优化LSM Tree。

LSM TREE

为什么要有LSM Tree?

LSM Tree(Log-structured merge-tree)起源于1996年的一篇论文:The log-structured merge-tree (LSM-tree)。当时的背景是:为一张数据增长很快的历史数据表设计一种存储结构,使得它能够解决:在内存不足,磁盘随机IO太慢下的严重写入性能问题。

如果我们先后修改两条数据,那么在脏数据块落盘时,不是一条条随机写入,而是以一个脏块批量落盘时,就能解决 B+树中“全量数据的随机写入” 问题了。

image-20220723164125804

所以大佬设计了这么一种结构:

image-20220723164328882

Ck tree是一个有序的树状结构,数据的写入流转从C0 tree 内存开始,不断被合并到磁盘上的更大容量的Ck tree上。这种方式写入是顺序写的,增删改操作都是append。 这样一次I/O可以进行多条数据的写入,从而充分利用每一次写I/O。

merge所做的事情有:当上一棵树容量不足时,1.将上棵树中要删除的数据删除掉,进行GC回收。2.合并数据的最新版本,使得Ck tree不会过度膨胀。

这种初始结构存在的问题有:随着数据量的增大,merge费劲,没有好的索引结构,读放大严重。现代的LSM Tree结构如下:

image-20220723164638828

MemTable:是LSM Tree在内存中还未刷盘的写入数据,这里面的数据可以修改。是一个有序的树状结构,如RocksDB中的跳表。

SSTable(Sorted String Table):是一个键是有序的,存储字符串形式键值对的磁盘文件。当SSTable太大时,可以将其划分为多个block;我们需要通过 下图中的Index 记录下来每个block的起始位置,那么就可以构建每个SSTable的稀疏索引。这样在读SSTable前,通过Index结构就知道要读取的数据块磁盘位置了。

image-20220723165202126

LSM Tree读数据

读数据 包括点读和范围读,我们分开讨论。假设LSM Tree中的key为[0-99],

点读:是指准确地取出一条数据,如get(23),则其示意图为:

image-20220723171020626

按照图中标示的顺序,会先读取内存,在从Level0依次往高层读取,直到找到key=23的数据。

范围读:是指读取一个范围内的数据,如读取[23-40]内的所有数据( select * from t where key >= 23 && key <=40),

则需要做的是在每一层SSTable找到这个范围内的第一个block,然后遍历block块的数据,直到不符合范围条件。

image-20220723171205445

ReadOnly MemTable:如RocksDB,在MemTable 和 Level0数据之间还有一个内存的ReadOnly MemTable结构。它是LSM Tree在内存中还未刷盘的写入数据,这里面的数据不可以修改。是当MemTable到达一定量需要刷到SSTable时,由MemTable完整copy下来的。这样可避免从MemTable直接刷到SSTable时的并发竞争问题。

LSM Tree写数据

在LSM Tree写入数据时,我们把ReadOnly MemTable画上,示意图如下:

image-20220723171341922

当有写请求时,数据会先写入MemTable,同时写入 WAL Log;当MemTable空间不足时,触发ReadOnly MemTable copy,同时写入WAL Log;然后刷新到磁盘Level0的SSTable中。当低层SSTable空间不足时,不断通过Compaction和高层SSTable进行Merge。

LSM Tree优化策略

点读的优化

尽管我们可以通过SSTable的内存block稀疏索引结构简单判断key是否可能存在于block中,但如上get(23),如果level1读不到,仍需要往下读level2,level3。(因为数据是按照增删改的时间顺序一层层往下落盘的,如果一个key不存在低level中,可能存在于更早的高level中)。这样的点读IO次数较多,读放大严重。

布隆过滤器 对于精确查询,hash的时间复杂度为 O(1),那么可以通过布隆过滤器来优化。我们只需要查询时先过一遍布隆过滤器,就能排除掉一定不存在的key了,避免不必要的IO查询。

布隆过滤器:是通过hash算法来判断一个key是否存在于某个集合中,布隆过滤器通常是一个bit数组,用针对一个key多次hash算法确定的多个bit值来表示key是否存在。多个bit值全为1,则表示key可能存在,否则不存在。好处是多个bit值通常比直接存储一个存在的key占用空间小,省内存。

范围读的优化

Hash的不足就是无法支持范围查询索引,优化方式有: 并行查询 因为读数据不存在竞争问题,可以并行读每层的数据,缩短整体查询时间,但是这种方式实际并没有缩短IO次数。

前缀布隆过滤器 前缀布隆过滤器是以key的前缀来Hash构建布隆过滤器,而不是key本身的值。这样可以起到过滤 like Monica* 这样的查询条件作用。RocksDB有做此优化。

TiDB与MySQL索引区别

数据写入方面

  • LSMtree 拆分数据为多个小数组 顺序写入 近似o(1)。
  • B+Tree则是将数据拆分为固定大小的Block或Page, 一般是4KB大小,和磁盘一个扇区的大小对应 写入o(logN) 随着insert的操作,为了维护B+树结构,节点会不断的分裂和合并。操作磁盘的随机读写概率会变大,故导致性能降低

数据更新和删除方面

  • 数据的更新和删除方面,B+Tree可以做到原地更新和删除,这种方式对数据库事务支持更加友好,因为一个key只会出现一个Page页里面
  • LSM-Tree只能追加写,并且在L0层key的range会重叠,所以对事务支持较弱,只能在Segment Compaction的时候进行真正地更新和删除

Tidb的读取优势

RocksDB 将存储在磁盘上的文件都按照一定大小切分成 block(默认是 64KB),读取 block 时先去内存中的 BlockCache 中查看该块数据是否存在,存在的话则可以直接从内存中读取而不必访问磁盘。

Tidb 索引类型

支持

  • 主键索引
  • 唯一索引
  • 普通索引

不支持

  • FULLTEXT
  • HASH
  • SPATIAL 索引
  • 降序索引

与MySQL不同处

  1. AUTO_INCREMENT
    自增编号是系统批量分配给每台 TiDB 服务器的值(默认 3 万个值)
    每个 TiDB 节点在分配 ID 时,都申请一段 ID 作为缓存,用完之后再去取下一段,而不是每次分配都向存储节点申请
    值的顺序不具有全局单调性
  2. DDL
    不能在单条 ALTER TABLE 语句中完成多个操作。例如,不能在单个语句中添加多个列或索引,否则,可能会输出 Unsupported multi schema change 的错误
    不支持修改分区表上的列类型
  3. 分区表
    分区表支持 Hash、Range 和 Add/Drop/Truncate/Coalesce。其他分区操作将被忽略,可能会报 Warning: Unsupported partition type, treat as normal table 错误。不支持以下分区表语法:
    PARTITION BY LIST
    PARTITION BY KEY
    SUBPARTITION
    {CHECK|EXCHANGE|TRUNCATE|OPTIMIZE|REPAIR|IMPORT|DISCARD|REBUILD|REORGANIZE} PARTITION

  4. TiDB 当前不支持 gap locking(间隙锁)

MyISAM 和 innoDB的区别

  • InnoDB支持事务, MyISAM不支持InnoDB支持外键,而MylSAM不支持。对一个包含外键的InnoDB表转为MYISAM会失败;
  • InnoDB是聚集索引,数据文件是和索引绑在一起,必须要有主键,通过主键索引效率高。InnoDB不保存 表的具体行数,执行select count(*) from table时需要全表扫描。
  • Innodb不支持全文索引,而MyISAM支持全文索引,查询效率上MyISAM要高;

Tidb 常见问题解决

TiDB线上报 “pd server timeout”

业务测在代码中加上相关超时重试的处理,将 TiDB 机器上 min_free_kbytes 从默认的 66M 调成 512M,留更多内存给内核用。

当请求分配内存时,如果有足够的内存,则分配成功。如果没有,则要阻塞,释放内存,再来分配内存。原子请求不能被阻塞,如果分配不到,则失败。内核为了尽量避免原子请求失败,预留了一个页池框,在内存不足时使用。这个页池框的大小就是min_free_kbytes。

TIDB 生产环境发生 OOM问题。

处理方案1:将OLAP频繁的SQL业务放到一个单独的TiDB集群,与现有的一套TiDB集群进行隔离,防止一个业务的OLAP SQL 影响到其他的业务;

处理方案2:oom-action 配置为 cancel,kill 使用内存超过阈值的 SQL,单条SQL使用的内存阈值调整配置文件;

tidb 某些 sql 会出现索引失效

对于发现失效的SQL,使用HINT这种方式强制指定索引解决。

刘小恺(Kyle) wechat
如有疑问可联系博主