写点什么

以 TiDB 热点问题来谈 Region 的调度流程

  • 2022 年 7 月 11 日
  • 本文字数:4587 字

    阅读完需:约 15 分钟

作者: alexshen 原文来源:https://tidb.net/blog/83e1810e

什么是热点问题

说这个话题之前我们先回顾一下 TiDB 的主要结构和概念。


TiDB 的核心架构分为 TiDB、TiKV、PD 三个部分,其中 TiKV 是一个分布式数据存储引擎用来存储真实的数据,在 TiKV 中又对存储区域进行了一系列的逻辑划分也就是 Region,它是被 PD 调度的最小单元。熟悉 TiDB 的读者对这个结构应该了然于胸。


正是由于这种设计,TiDB 在碰到短时间内的大流量时就会碰到数据热点问题,大量的数据被写入到同一个 Region Leader 导致某一部分 TiKV 节点资源消耗特别高,而其他节点又处于空闲状态,这种情况明显是违背了分布式系统的设计初衷。TiDB 为了避免这种情况的发生,官方已经给出了成熟的解决方案,比如提前切分好 Region 或者是对 row_id 进行打散等。下图是我们对热点问题处理前后进行测试的结果:





如何处理热点不是我们本文讨论的重点,TiDB 本身是可以对 Region 进行分割和调度的,使其尽可能均匀地分布在所有 TiKV 节点,所以一定程度上来说它有热点自愈的特性,也就是说经过一段时间的调度后能够让 Region 处于一个均衡的状态,这个过程一般称为预热阶段。接下来我们重点看看它是如何进行自动调度的,这涉及到 Region 的两个操作: 分裂和打散。


这里要注意,预热需要花费时间(具体看调度器的运行情况,可以修改配置参数优化),对于持续高并发写入的场景依然需要提前做好 Region 划分,避免出现性能问题。

Region 的结构

打开 PD 的源码结构,我们大致能看到 PD 包含以下几大核心模块:


  • API

  • 核心 Server

  • 集群管理

  • 调度器

  • TSO 管理器

  • Mock

  • 监控指标收集

  • Dashboard

  • 其他一些工具包等



在 PD 的源码中我们可以找到 Region 的定义,为了使大家看的更清晰一些,我拿 API 模块使用的 Region 定义来说明:


// server/api/region.go// RegionInfo records detail region info for api usage.type RegionInfo struct {  ID          uint64              `json:"id"`  StartKey    string              `json:"start_key"`  EndKey      string              `json:"end_key"`  RegionEpoch *metapb.RegionEpoch `json:"epoch,omitempty"`  Peers       []*metapb.Peer      `json:"peers,omitempty"`
Leader *metapb.Peer `json:"leader,omitempty"` DownPeers []*pdpb.PeerStats `json:"down_peers,omitempty"` PendingPeers []*metapb.Peer `json:"pending_peers,omitempty"` WrittenBytes uint64 `json:"written_bytes"` ReadBytes uint64 `json:"read_bytes"` WrittenKeys uint64 `json:"written_keys"` ReadKeys uint64 `json:"read_keys"` ApproximateSize int64 `json:"approximate_size"` ApproximateKeys int64 `json:"approximate_keys"`
ReplicationStatus *ReplicationStatus `json:"replication_status,omitempty"`}
复制代码


重点看一下如下几个字段:


  • StartKeyEndKey定义了这个 Region 的存储范围,它是一个左闭右开的区间[StartKey, EndKey)。

  • RegionEpoch定义了 Region 的变更版本,用来做安全性校验

  • Peers是这个 Region 的 Raft Group 成员,里面包含了三种类型的 Peer:Leader、Follower 和 Learner。

  • Leader即表示这个 Region 的 Leader Peer 是谁。

  • PendingPeersDownPeers是两种不同状态的 Peer,和 Raft 选举有关。


我们可以通过 pd-ctl 命令行工具查看 Region 信息:


» region 40{  "id": 40,  "start_key": "7480000000000000FF2500000000000000F8",  "end_key": "7480000000000000FF2700000000000000F8",  "epoch": {    "conf_ver": 5,    "version": 19  },  "peers": [    {      "id": 41,      "store_id": 1    },    {      "id": 63,      "store_id": 4    },    {      "id": 80,      "store_id": 5    }  ],  "leader": {    "id": 80,    "store_id": 5  },  "written_bytes": 0,  "read_bytes": 0,  "written_keys": 0,  "read_keys": 0,  "approximate_size": 1,  "approximate_keys": 0}
复制代码


PD 只负责存储 Region 的元数据信息,它并不负责实际的 Region 操作,而且 PD 也不会主动地发起对 Region 的操作。PD 所有关于 Region 的数据都由 TiKV 主动上报,TiKV 会对 PD 维持一个心跳,Leader Peer 发起心跳请求的时候就会带上自己的信息,PD 收到请求会更新 Region 元数据信息,同时根据上报的信息进行调度,这一块后面再详细说。


TiDB 启动的时候并不是提前划分好 Region 范围的,而是用一个默认 Region 覆盖所有范围的 key,当这个 Region 的大小超过设定的阈值时就会触发 Region 分裂,这个过程也是在 TiKV 中发生的。TiKV 会把需要切分的 key range 上报给 PD,PD 对这个 Region 元信息重新计算,再把分裂操作发回给 TiKV 去执行。


这个特性 TiKV 本身就是具有的,并不会说因为热点问题才出现,本文就不做深究。

PD 中的调度器

PD 里面包含多种类型的调度器,与本文主题相关的调度器主要是以下几类:


  • balance-leader-scheduler ,侧重于平衡计算,用来维持所有 TiKV 节点中 Leader Peer 的平衡,可以避免 Leader 分布不均匀的情况

  • balance-region-scheduler ,侧重于平衡存储,用来维持所有 TiKV 节点中 Peer 的平衡,可以避免数据存储不均匀的情况

  • hot-region-scheduler ,侧重于平衡网络,用来维持所有 TiKV 节点流量均衡,避免出现热点情况


每一种调度器都是可以独立启停的,我们可以使用 pd-ctl 工具来控制他们,也可以根据实际情况调整参数值优化执行效率,


比如我们查看 PD 中所有的调度器:


» scheduler show[  "balance-hot-region-scheduler",  "balance-leader-scheduler",  "balance-region-scheduler",  "label-scheduler"]
复制代码


查看调度器的参数:


» scheduler config balance-hot-region-scheduler{  "min-hot-byte-rate": 100,  "min-hot-key-rate": 10,  "max-zombie-rounds": 3,  "max-peer-number": 1000,  "byte-rate-rank-step-ratio": 0.05,  "key-rate-rank-step-ratio": 0.05,  "count-rank-step-ratio": 0.01,  "great-dec-ratio": 0.95,  "minor-dec-ratio": 0.99,  "src-tolerance-ratio": 1.05,  "dst-tolerance-ratio": 1.05}
复制代码


这些调度器会在 PD 的后台任务中持续运行,根据 PD 收集到的数据生成一个执行计划,前面我们提到过,PD 不会主动发起请求,那么如何把这个执行计划下发到 TiKV 中呢?


事实上,PD 是在处理 TiKV 的心跳时把执行计划返回给 TiKV 去执行的,所以这中间其实是有个时间差。那这个时间间隔到底是多少呢,我们从源码中一探究竟:


// server/schedulers/base_scheduler.goconst (  exponentialGrowth intervalGrowthType = iota  linearGrowth  zeroGrowth)
// intervalGrow calculates the next interval of balance.func intervalGrow(x time.Duration, maxInterval time.Duration, typ intervalGrowthType) time.Duration { switch typ { case exponentialGrowth: return typeutil.MinDuration(time.Duration(float64(x)*ScheduleIntervalFactor), maxInterval) case linearGrowth: return typeutil.MinDuration(x+MinSlowScheduleInterval, maxInterval) case zeroGrowth: return x default: log.Fatal("type error", errs.ZapError(errs.ErrInternalGrowth)) } return 0}
复制代码


从以上代码可以看出,PD 提供了 3 中类型的调度频率,分别是指数增长、线性增长和不增长。对于指数增长,默认的指数因子由ScheduleIntervalFactor定义默认是 1.3,对于线性增长,增长步长由MinSlowScheduleInterval定义默认是 3 秒。除此之外,每一种调度器都定义了最小和最大的 ScheduleInterval,不管使用哪一种调度频率都不能超过最大值,以 balance-hot-region-scheduler 为例:


// server/schedulers/hot_region.goconst (  // HotRegionName is balance hot region scheduler name.  HotRegionName = "balance-hot-region-scheduler"  // HotRegionType is balance hot region scheduler type.  HotRegionType = "hot-region"  // HotReadRegionType is hot read region scheduler type.  HotReadRegionType = "hot-read-region"  // HotWriteRegionType is hot write region scheduler type.  HotWriteRegionType = "hot-write-region"
minHotScheduleInterval = time.Second maxHotScheduleInterval = 20 * time.Second)
复制代码

调度器的执行流程

先用一张图看看调度器的组成结构:



这里面的各个角色我不重复去介绍,大家可以参考 PingCAP 的一篇文章说的非常详细:



MySQL at Scale. No more manual sharding


TiKV 功能介绍 - PD Scheduler | PingCAP

在前面的文章里面,我们介绍了 PD 一些常用功能,以及它是如何跟 TiKV 进行交互的,这里,我们重点来介绍一下 PD 是如何调度 TiKV 的。


有了这个结构之后,对多种调度器进行操作甚至扩展就变得非常容易了。


我大致把调度器的执行流程分为 3 个阶段:


  • 注册阶段

  • 创建阶段

  • 运行阶段

  • 整个过程我总结为下面一个流程图:



第一阶段相对比较独立,主要发生在生个 PD 服务启动过程中,PD 启动的时候不仅会注册相关的调度器,还会启动一个 Cluster 对象,里面是对整个 PD 集群的封装,上一张图中的Coordinator和它里面的对象也是在这时候被创建和启动。


调度器注册实质是保存了一个创建调度器对象的 function,当收到创建请求的时候就来执行这个 function 得到调度器对象。接着,调度器会被封装成一个ScheduleController对象,它被用来控制调度器的执行,这个对象里保存了调度器下一次被执行的间隔时间以及一些上下文参数。ScheduleController对象会被加入到 Coordinator 的调度器列表中,然后开启一个后台任务和定时器来执行最终的调度,也就是调度器的Schedule()方法,这个方法返回的是一组Operator,表示需要对 Region 执行一系列操作,这其中就可能包含对 Region 的打散操作。这些操作会被AddWaitingOperator()方法加入到OperatorController的等待队列中,等待下一次心跳到来后被下发到 TiKV 节点去执行。


这里要注意的是,调度器执行失败会进行重试,这个重试次数是由Coordinator设定的,默认是 10 次:


// server/cluster/coordinator.goconst (  maxScheduleRetries        = 10)
func (s *scheduleController) Schedule() []*operator.Operator { for i := 0; i < maxScheduleRetries; i++ { // If we have schedule, reset interval to the minimal interval. if op := s.Scheduler.Schedule(s.cluster); op != nil { s.nextInterval = s.Scheduler.GetMinInterval() return op } } s.nextInterval = s.Scheduler.GetNextInterval(s.nextInterval) return nil}
复制代码

总结

介绍到这里,大家应该对 PD 的调度器运行机制有一个大致的印象了,不过本文介绍的只是抽象层面的调度器,并没有涉及到某一种具体的调度器执行逻辑,因为 TiDB 的工程量代码量实在太大,这个过程的任何一个细节点单独拿出来都可以写一篇专题文章。


我们会在后续持续输出 TiDB 底层原理技术的系列文章,欢迎大家关注,一起学习交流。如果本文有存在错误的地方,欢迎指出。


发布于: 刚刚阅读数: 2
用户头像

TiDB 社区官网:https://tidb.net/ 2021.12.15 加入

TiDB 社区干货传送门是由 TiDB 社区中布道师组委会自发组织的 TiDB 社区优质内容对外宣布的栏目,旨在加深 TiDBer 之间的交流和学习。一起构建有爱、互助、共创共建的 TiDB 社区 https://tidb.net/

评论

发布
暂无评论
以TiDB热点问题来谈Region的调度流程_实践案例_TiDB 社区干货传送门_InfoQ写作社区