深入浅出TiDB整体架构与实践心得

TiDB 是 PingCAP 公司基于 Google Spanner / F1 论文实现的开源分布式 NewSQL 数据库。

TiDB 具备如下 NewSQL 核心特性:

  • SQL支持 (TiDB 是 MySQL 兼容的)
  • 水平线性弹性扩展
  • 分布式事务
  • 跨数据中心数据强一致性保证
  • 故障自恢复的高可用

TiDB 的设计目标是 100% 的 OLTP 场景和 80% 的 OLAP 场景。对业务没有任何侵入性,能优雅的替换传统的数据库中间件、数据库分库分表等 Sharding 方案。同时它也让开发运维人员不用关注数据库 Scale 的细节问题,专注于业务开发,极大的提升研发的生产力。

 

  • TiDB整体架构

  • TiDB的存储,计算,调度是怎么样的

  • TiDB事务和MySQL事务的差异

  • TiDB实践踩坑分享


TiDB整体架构

首先看一下TiDB整体的架构

TiDB集群主要有3个组件组成:

 

  • TiDB Server

TiDB Server 负责接收 SQL 请求,处理 SQL 相关的逻辑,并通过 PD 找到存储计算所需数据的 TiKV 地址,与 TiKV 交互获取数据,最终返回结果。 TiDB Server 是无状态的,其本身并不存储数据,只负责计算,可以无限水平扩展,可以通过负载均衡组件(如LVS、HAProxy 或 F5)对外提供统一的接入地址。笔者这边是通过HAProxy组件实现负载均衡。

  • PD Server

Placement Driver (简称 PD) 是整个集群的管理模块,其主要工作有三个: 一是存储集群的元信息(某个 Key 存储在哪个 TiKV 节点);二是对 TiKV 集群进行调度和负载均衡(如数据的迁移、Raft group leader 的迁移等);三是分配全局唯一且递增的事务 ID。

  • TiKV Server

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

 

TiDB集群具有水平扩展和高可用的特性

 

  • 水平扩展

水平扩展是 TiDB 的一大特点,这里说的水平扩展包括两方面:计算能力和存储能力。TiDB Server 负责处理 SQL 请求,随着业务的增长,可以简单的添加 TiDB Server 节点,提高整体的处理能力,提供更高的吞吐。TiKV 负责存储数据,随着数据量的增长,可以部署更多的 TiKV Server 节点解决数据 Scale 的问题。PD 会在 TiKV 节点之间以 Region 为单位做调度,将部分数据迁移到新加的节点上。所以在业务的早期,可以只部署少量的服务实例(笔者这里部署了 3 个 TiKV, 3 个 PD,2 个 TiDB),随着业务量的增长,按照需求添加 TiKV 或者 TiDB 实例。

 

  • 高可用

高可用是 TiDB 的另一大特点,TiDB/TiKV/PD 这三个组件都能容忍部分实例失效,不影响整个集群的可用性。下面分别说明这三个组件的可用性、单个实例失效后的后果以及如何恢复。

      • TiDB

    TiDB 是无状态的,推荐至少部署两个实例,前端通过负载均衡组件对外提

供服务。当单个实例失效时,会影响正在这个实例上进行的 Session,从

应用的角度看,会出现单次请求失败的情况,重新连接后即可继续获得

服务。单个实例失效后,可以重启这个实例或者部署一个新的实例。

      • PD

    PD 是一个集群,通过 Raft 协议保持数据的一致性,单个实例失效时,

如果这个实例不是 Raft 的 leader,那么服务完全不受影响;

如果这个实例是 Raft 的 leader,会重新选出新的 Raft leader,

自动恢复服务。PD 在选举的过程中无法对外提供服务,

这个时间大约是3秒钟。推荐至少部署三个 PD 实例,单个实例失效后,

重启这个实例或者添加新的实例。

    • TiKV  

            TiKV 是一个集群,通过 Raft 协议保持数据的一致性(副本数量可配置,

        默认保存三副本),并通过 PD 做负载均衡调度。单个节点失效时,

        会影响这个节点上存储的所有 Region。对于 Region 中的 Leader 结点,

        会中断服务,等待重新选举;对于 Region 中的 Follower 节点,

       不会影响服务。当某个 TiKV 节点失效,并且在一段时间内

    (默认 10 分钟)无法恢复,PD 会将其上的数据迁移到其他的 TiKV 节点上。

TiDB是如何处理用户请求的?

 

首先,请求从 Client 端进来后进入语法解析层,然后对语句进行合法性验证和类型推导,接着做查询优化——这里我们分了逻辑优化和物理优化。优化之后会构建一个执行器,最后执行——把数据从 TiKV 取出来进行计算,最后反馈结果。

 


TiDB的存储,计算和调度

 

1.存储

Key-Value

作为保存数据的系统,首先要决定的是数据的存储模型,也就是数据以什么样的形式保存下来。TiKV 的选择是 Key-Value 模型,并且提供有序遍历方法。简单来讲,可以将 TiKV 看做一个巨大的 Map,其中 Key 和 Value 都是原始的 Byte 数组,在这个 Map 中,Key 按照 Byte 数组总的原始二进制比特位比较顺序排列。 总结下来有以下两点:

  • 这是一个巨大的 Map,也就是存储的是 Key-Value pair
  • 这个 Map 中的 Key-Value pair 按照 Key 的二进制顺序有序,也就是我们可以

    查询到某一个 Key 的位置,然后不断的调用 Next 方法以递增的顺序获取比

    这个 Key 大的 Key-Value

 

RocksDB

任何持久化的存储引擎,数据终归要保存在磁盘上,TiKV 也不例外。但是 TiKV 没有选择直接向磁盘上写数据,而是把数据保存在 RocksDB 中,具体的数据落地由 RocksDB 负责。这里可以简单的认为 RocksDB 是一个单机的 Key-Value Map。

Raft

Raft 是一个一致性协议,提供几个重要的功能:

  • Leader选举

  • 成员变更

  • 日志复制

TiKV 利用 Raft 来做数据复制,每个数据变更都会落地为一条 Raft 日志,通过 Raft 的日志复制功能,将数据安全可靠地同步到 Group 的多数节点中。

                           

通过单机的 RocksDB,可以将数据快速地存储在磁盘上;通过 Raft,可以将数据复制到多台机器上,以防单机失效。数据的写入是通过 Raft 这一层的接口写入,而不是直接写 RocksDB。通过实现 Raft,我们拥有了一个分布式的 KV,不用担心某台机器挂掉。

Region

前面提到,将 TiKV 看做一个巨大的有序的 KV Map,那么为了实现存储的水平扩展,我们需要将数据分散在多台机器上。这里提到的数据分散在多台机器上和 Raft 的数据复制不是一个概念,在这里先忘记 Raft,假设所有的数据都只有一个副本,这样更容易理解。

对于一个 KV 系统,将数据分散在多台机器上有两种比较典型的方案:一种是按照 Key 做 Hash,根据 Hash 值选择对应的存储节点;另一种是分 Range,某一段连续的 Key 都保存在一个存储节点上。TiKV 选择了第二种方式,将整个 Key-Value 空间分成很多段,每一段是一系列连续的 Key,我们将每一段叫做一个 Region,并且我们会尽量保持每个 Region 中保存的数据不超过一定的大小(这个大小可以配置,目前默认是 64mb)。每一个 Region 都可以用 StartKey 到 EndKey 这样一个左闭右开区间来描述。

                                                

这里的 Region 还是和 SQL 中的表没什么关系! 将数据划分成 Region 后,会做两件重要的事情:

  • 以 Region 为单位,将数据分散在集群中所有的节点上,并且尽量保证

    每个节点上服务的 Region 数量均衡

  • 以 Region 为单位做 Raft 的复制和成员管理

先看第一点,数据按照 Key 切分成很多 Region,每个 Region 的数据只会保存在一个节点上面。系统会有一个组件来负责将 Region 尽可能均匀的散布在集群中所有的节点上,这样一方面实现了存储容量的水平扩展(增加新的结点后,会自动将其他节点上的 Region 调度过来),另一方面也实现了负载均衡(不会出现某个节点有很多数据,其他节点上没什么数据的情况)。同时为了保证上层客户端能够访问所需要的数据,系统中也会有一个组件记录 Region 在节点上面的分布情况,也就是通过任意一个 Key 就能查询到这个 Key 在哪个 Region 中,以及这个 Region 目前在哪个节点上。至于是哪个组件负责这两项工作,会在后续介绍。

对于第二点,TiKV 是以 Region 为单位做数据的复制,也就是一个 Region 的数据会保存多个副本,将每一个副本叫做一个 Replica。Replica 之间是通过 Raft 来保持数据的一致(终于提到了 Raft),一个 Region 的多个 Replica 会保存在不同的节点上,构成一个 Raft Group。其中一个 Replica 会作为这个 Group 的 Leader,其他的 Replica 作为 Follower。所有的读和写都是通过 Leader 进行,再由 Leader 复制给 Follower。 大家理解了 Region 之后,应该可以理解下面这张图:

以 Region 为单位做数据的分散和复制,就有了一个分布式的具备一定容灾能力的 KeyValue 系统。

MVCC

TiKV 也实现多版本控制(MVCC)。设想这样的场景,两个 Client 同时去修改一个 Key 的 Value,如果没有 MVCC,就需要对数据上锁,在分布式场景下,可能会带来性能以及死锁问题。 TiKV 的 MVCC 实现是通过在 Key 后面添加 Version 来实现,简单来说,没有 MVCC 之前,可以把 TiKV 看做这样的:

  Key1 -> Value  Key2 -> Value  ……  KeyN -> Value

有了 MVCC 之后,TiKV 的 Key 排列是这样的:

  Key1-Version3 -> Value  Key1-Version2 -> Value  Key1-Version1 -> Value  ……  Key2-Version4 -> Value  Key2-Version3 -> Value  Key2-Version2 -> Value  Key2-Version1 -> Value  ……  KeyN-Version2 -> Value  KeyN-Version1 -> Value  ……

这里需要注意,对于同一个 Key 的多个版本,把版本号较大的放在前面,版本号小的放在后面(回忆一下 Key-Value 一节我们介绍过的 Key 是有序的排列),这样当用户通过一个 Key + Version 来获取 Value 的时候,可以将 Key 和 Version 构造出 MVCC 的 Key,也就是 Key-Version。然后可以直接 Seek(Key-Version),定位到第一个大于等于这个 Key-Version 的位置。

 

事务

TiKV 的事务采用的是 Percolator 模型。事务的细节和实现会单独写一篇分享。这里提一点,TiKV 的事务采用乐观锁,事务的执行过程中,不会检测写写冲突,只有在提交过程中,才会做冲突检测,冲突的双方中比较早完成提交的会写入成功,另一方会尝试重新执行整个事务。当业务的写入冲突不严重的情况下,这种模型性能会很好,比如随机更新表中某一行的数据,并且表很大。但是如果业务的写入冲突严重,性能就会很差,举一个极端的例子,就是计数器,多个客户端同时修改少量行,导致冲突严重的,造成大量的无效重试。

 

2.计算

SQL on KV 架构

TiDB 的整体架构如下图:

TiKV Cluster 主要作用是作为 KV 引擎存储数据,TiDB Servers 的节点都是无状态的节点,本身并不存储数据,节点之间完全对等。TiDB Server 这一层最重要的工作是处理用户请求,执行 SQL 运算逻辑。

SQL 运算

理解了 SQL 到 KV 的映射方案之后,可以理解关系数据是如何保存的,接下来要理解如何使用这些数据来满足用户的查询需求,也就是一个查询语句是如何操作底层存储的数据。 能想到的最简单的方案就是通过上一节所述的映射方案,将 SQL 查询映射为对 KV 的查询,再通过 KV 接口获取对应的数据,最后执行各种计算。 比如 Select count(*) from user where name="TiDB"; 这样一个语句,我们需要读取表中所有的数据,然后检查 Name 字段是否是 TiDB,如果是的话,则返回这一行。这样一个操作流程转换为 KV 操作流程:

  • 构造出 Key Range:一个表中所有的 RowID 都在 [0, MaxInt64) 这个范围内,那么我们用 0 和 MaxInt64 根据 Row 的 Key 编码规则,就能构造出一个 [StartKey, EndKey) 的左闭右开区间
  • 扫描 Key Range:根据上面构造出的 Key Range,读取 TiKV 中的数据
  • 过滤数据:对于读到的每一行数据,计算 name="TiDB" 这个表达式,如果为真,则向上返回这一行,否则丢弃这一行数据
  • 计算 Count:对符合要求的每一行,累计到 Count 值上面 这个方案肯定是可以 Work 的,但是并不能 Work 的很好,原因是显而易见的:
  1. 在扫描数据的时候,每一行都要通过 KV 操作同 TiKV 中读取出来,至少有一次 RPC 开销,如果需要扫描的数据很多,那么这个开销会非常大
  2. 并不是所有的行都有用,如果不满足条件,其实可以不读取出来
  3. 符合要求的行的值并没有什么意义,实际上这里只需要有几行数据这个信息就行

分布式 SQL 运算

如何避免上述缺陷也是显而易见的,首先需要将计算尽量靠近存储节点,以避免大量的 RPC 调用。其次,需要将 Filter 也下推到存储节点进行计算,这样只需要返回有效的行,避免无意义的网络传输。最后,我们可以将聚合函数、GroupBy 也下推到存储节点,进行预聚合,每个节点只需要返回一个 Count 值即可,再由 tidb-server 将 Count 值 Sum 起来。 这里有一个数据逐层返回的示意图:

SQL 层架构

TiDB 的 SQL 层要复杂的多,模块以及层次非常多,下面这个图列出了重要的模块以及调用关系:

用户的 SQL 请求会直接或者通过 Load Balancer 发送到 tidb-server,tidb-server 会解析 MySQL Protocol Packet,获取请求内容,然后做语法解析、查询计划制定和优化、执行查询计划获取和处理数据。数据全部存储在 TiKV 集群中,所以在这个过程中 tidb-server 需要和 tikv-server 交互,获取数据。最后 tidb-server 需要将查询结果返回给用户。

 

3.调度

 

TiKV 集群是 TiDB 数据库的分布式 KV 存储引擎,数据以 Region 为单位进行复制和管理,每个 Region 会有多个 Replica(副本),这些 Replica 会分布在不同的 TiKV 节点上,其中 Leader 负责读/写,Follower 负责同步 Leader 发来的 raft log。

需求:

  • 副本数量不能多也不能少
  • 副本需要分布在不同的机器上
  • 新加节点后,可以将其他节点上的副本迁移过来
  • 节点下线后,需要将该节点的数据迁移走

        优化:

  • 维持整个集群的 Leader 分布均匀
  • 维持每个节点的储存容量均匀
  • 维持访问热点分布均匀
  • 控制 Balance 的速度,避免影响在线服务
  • 管理节点状态,包括手动上线/下线节点,以及自动下线失效节点

调度的基本操作

  • 增加一个 Replica
  • 删除一个 Replica
  • 将 Leader 角色在一个 Raft Group 的不同 Replica 之间 transfer

信息收集

调度依赖于整个集群信息的收集,简单来说,我们需要知道每个 TiKV 节点的状态以及每个 Region 的状态。TiKV 集群会向 PD 汇报两类消息:

每个 TiKV 节点会定期向 PD 汇报节点的整体信息

TiKV 节点(Store)与 PD 之间存在心跳包,一方面 PD 通过心跳包检测每个 Store 是否存活,以及是否有新加入的 Store;另一方面,心跳包中也会携带这个Store的状态信息,主要包括:

  • 总磁盘容量
  • 可用磁盘容量
  • 承载的 Region 数量
  • 数据写入速度
  • 发送/接受的 Snapshot 数量(Replica 之间可能会通过 Snapshot 同步数据)
  • 是否过载
  • 标签信息(标签是具备层级关系的一系列 Tag)

每个 Raft Group 的 Leader 会定期向 PD 汇报信息

每个 Raft Group 的 Leader 和 PD 之间存在心跳包,用于汇报这个Region状态,主要包括下面几点信息:

  • Leader 的位置
  • Followers 的位置
  • 掉线 Replica 的个数
  • 数据写入/读取的速度

PD 不断的通过这两类心跳消息收集整个集群的信息,再以这些信息作为决策的依据。除此之外,PD 还可以通过管理接口接受额外的信息,用来做更准确的决策。比如当某个 Store 的心跳包中断的时候,PD 并不能判断这个节点是临时失效还是永久失效,只能经过一段时间的等待(默认是 30 分钟),如果一直没有心跳包,就认为是 Store 已经下线,再决定需要将这个 Store 上面的 Region 都调度走。但是有的时候,是运维人员主动将某台机器下线,这个时候,可以通过 PD 的管理接口通知 PD 该 Store 不可用,PD 就可以马上判断需要将这个 Store 上面的 Region 都调度走。

调度的策略

PD 收集了这些信息后,还需要一些策略来制定具体的调度计划。

一个 Region 的 Replica 数量正确

当 PD 通过某个 Region Leader 的心跳包发现这个 Region 的 Replica 数量不满足要求时,需要通过 Add/Remove Replica 操作调整 Replica 数量。出现这种情况的可能原因是:

  • 某个节点掉线,上面的数据全部丢失,导致一些 Region 的 Replica 数量不足
  • 某个掉线节点又恢复服务,自动接入集群,这样之前已经补足了 Replica 的 Region 的 Replica 数量多过,需要删除某个 Replica
  • 管理员调整了副本策略,修改了max-replicas 的配置

一个 Raft Group 中的多个 Replica 不在同一个位置

注意第二点,『一个 Raft Group 中的多个 Replica 不在同一个位置』,这里用的是『同一个位置』而不是『同一个节点』。在一般情况下,PD 只会保证多个 Replica 不落在一个节点上,以避免单个节点失效导致多个 Replica 丢失。在实际部署中,还可能出现下面这些需求:

  • 多个节点部署在同一台物理机器上
  • TiKV 节点分布在多个机架上,希望单个机架掉电时,也能保证系统可用性
  • TiKV 节点分布在多个 IDC 中,希望单个机房掉电时,也能保证系统可用

副本在 Store 之间的分布均匀分配

前面说过,每个副本中存储的数据容量上限是固定的,所以我们维持每个节点上面,副本数量的均衡,会使得总体的负载更均衡。

Leader 数量在 Store 之间均匀分配

Raft 协议要读取和写入都通过 Leader 进行,所以计算的负载主要在 Leader 上面,PD 会尽可能将 Leader 在节点间分散开。

访问热点数量在 Store 之间均匀分配

每个 Store 以及 Region Leader 在上报信息时携带了当前访问负载的信息,比如 Key 的读取/写入速度。PD 会检测出访问热点,且将其在节点之间分散开。

各个 Store 的存储空间占用大致相等

每个 Store 启动的时候都会指定一个 Capacity 参数,表明这个 Store 的存储空间上限,PD 在做调度的时候,会考虑节点的存储空间剩余量。

控制调度速度,避免影响在线服务

调度操作需要耗费 CPU、内存、磁盘 IO 以及网络带宽,我们需要避免对线上服务造成太大影响。PD 会对当前正在进行的操作数量进行控制,默认的速度控制是比较保守的,如果希望加快调度(比如已经停服务升级,增加新节点,希望尽快调度),那么可以通过 pd-ctl 手动加快调度速度。

支持手动下线节点

当通过 pd-ctl 手动下线节点后,PD 会在一定的速率控制下,将节点上的数据调度走。当调度完成后,就会将这个节点置为下线状态。

调度的实现

PD 不断的通过 Store 或者 Leader 的心跳包收集信息,获得整个集群的详细数据,并且根据这些信息以及调度策略生成调度操作序列,每次收到 Region Leader 发来的心跳包时,PD 都会检查是否有对这个 Region 待进行的操作,通过心跳包的回复消息,将需要进行的操作返回给 Region Leader,并在后面的心跳包中监测执行结果。注意这里的操作只是给 Region Leader 的建议,并不保证一定能得到执行,具体是否会执行以及什么时候执行,由 Region Leader 自己根据当前自身状态来定。


TiDB事务和MySQL事务的差异

 

TiDB事务和MySQL事务对比


在 TiDB 中执行的事务 b,返回影响条数是 1(认为已经修改成功),但是提交后查询,status 却不是事务 b 修改的值,而是事务 a 修改的值。

可见,MySQL 事务和 TiDB 事务存在这样的差异:

MySQL 事务中,可以通过影响条数,作为写入(或修改)是否成功的依据;而在 TiDB 中不是

作为开发者我们需要考虑下面的问题:

1. 同步 RPC 调用中,如果需要严格依赖影响条数以确认返回值,那将如何?

2. 多表操作中,如果需要严格依赖某个主表数据更新结果,作为是否更新(或写入)其他表的判断依据,那又将如何?

 

原因分析&解决方案

MySQL,当更新某条记录时,会先获取该记录对应的行级锁(排他锁),获取成功则进行后续的事务操作,获取失败则阻塞等待。对于 TiDB,使用 Percolator 事务模型:可以理解为乐观锁实现,事务开启、事务中都不会加锁,而是在提交时才加锁。

在事务提交的 PreWrite 阶段,当“锁检查”失败时:如果开启冲突重试,事务提交将会进行重试;如果未开启冲突重试,将会抛出写入冲突异常。可见,对于 MySQL,由于在写入操作时加上了排他锁,变相将并行事务从逻辑上串行化;而对于 TiDB,属于乐观锁模型,在事务提交时才加锁,并使用事务开启时获取的“全局时间戳”作为“锁检查”的依据。所以,在业务层面避免 TiDB 事务差异的本质在于避免锁冲突,即,当前事务执行时,不产生别的事务时间戳(无其他事务并行)。处理方式为事务串行化。

 


 

TiDB实践踩坑分享

  • TiDB对硬盘要求很高,如果生产环境使用建议是SSD硬盘
  • 如果有批量插入大数据的场景,建议修改参数
    set @session.tidb_batch_insert=10000
  • 大批量事务提交,需要修改tidb.yml文件参数
    stmt-count-limit=10000000;
  • TiDB对大小写敏感,并且不能通过配置修改是否敏感,所以在编写SQL中需要特别注意
  • TiDB上层采用HA做负载均衡,HA默认参数配置下会导致系统JDBC频繁创建和销毁连接,导致JDBC连接池timeout。解决方案是修改HA配置timeout时长,建议大于JDBC参数maxWait时长
 
 
 

 
 

未经允许不得转载:大自然的搬运工 » 深入浅出TiDB整体架构与实践心得

赞 (0)

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址