TiDB 源码阅读系列文章(一)序

背景知识

Go

又称Golang,是Google开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言。

  • 特色
    • 简洁、快速、安全
    • 并行、有趣、开源
    • 内存管理、数组安全、编译迅速
  • 用途
    • Go 语言被设计成一门应用于搭载 Web 服务器,存储集群或类似用途的巨型中央服务器的系统编程语言
    • 对于高性能分布式系统领域而言,Go 语言无疑比大多数其它语言有着更高的开发效率。
    • 它提供了海量并行的支持,这对于游戏服务端的开发而言是再好不过了。

进程(Process),线程(Thread),协程(Coroutine)

进程是一个程序在一个数据集中的一次动态执行过程,可以简单理解为“正在执行的程序”,它是CPU资源分配和调度的独立单位。 进程一般由程序、数据集、进程控制块三部分组成。我们编写的程序用来描述进程要完成哪些功能以及如何完成;数据集则是程序在执行过程中所需要使用的资源;进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。 进程的局限是创建、撤销和切换的开销比较大。

线程是在进程之后发展出来的概念。 线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元,由线程ID、程序计数器、寄存器集合和堆栈共同组成。一个进程可以包含多个线程。 线程的优点是减小了程序并发执行时的开销,提高了操作系统的并发性能缺点是线程没有自己的系统资源,只拥有在运行时必不可少的资源,但同一进程的各线程可以共享进程所拥有的系统资源,如果把进程比作一个车间,那么线程就好比是车间里面的工人。不过对于某些独占性资源存在锁机制,处理不当可能会产生“死锁”。

协程是一种用户态的轻量级线程,又称微线程,英文名Coroutine,协程的调度完全由用户控制。人们通常将协程和子程序(函数)比较着理解。 子程序调用总是一个入口,一次返回,一旦退出即完成了子程序的执行。与传统的系统级线程和进程相比,协程的最大优势在于其"轻量级",可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万的。这也是协程也叫轻量级线程的原因。

Goroutine

Go语言的协程——Goroutine,go中使用Goroutine来实现并发concurrently。Goroutine是与其他函数或方法同时运行的函数或方法。Goroutines可以被认为是轻量级的线程。与线程相比,创建Goroutine的成本很小,它就是一段代码,一个函数入口。以及在堆上为其分配的一个堆栈(初始大小为4K,会随着程序的执行自动增长删除)。因此它非常廉价,Go应用程序可以并发运行数千个Goroutines。

使用方法

在调用函数的时候在前面加 go关键字,就可以为函数创建一个goroutine,一个goroutine必定对应一个函数,可以创建多个goroutine执行同一个函数。

启动单个goroutine
package main

import (  
    "fmt" // fmt包实现了类似C语言printf和scanf的格式化I/O。主要分为向外输出内容和获取输入内容两大部分。
    "time"
)

func hello(){
	fmt.Println("goroutine hello println")
}

func main(){
	go hello() //开启一个groutine
    fmt.Println("main println")
    time.Sleep(time.Second*2)//如果不睡就会出现不输出groutine的内容,直接输出main println
}

在程序启动时,Go程序会默认给main()函数创建一个goroutine, 当main函数的goroutine结束后,所有在main函数中创建的goroutine都会一同结束。

启动多个goroutine
wg sync.WaitGroup //定义全局变量
  
func hello(){
	fmt.Println("goroutine println")
  	wg.Done()//goroutine结束就登记减1
}

func main(){
	for i:=0; i<5; i++{
    	wg.Add(1) //启动一个goroutine就登记加1
  		go hello()
  }
  wg.Wait()//等待所有登记的goroutine都结束
  fmt.Println("main goroutine done")
}

channel

通道可以被认为是Goroutines通信的管道,数据可以从一端发送到另一端,通过通道接收。

“不要通过共享内存来通信,而应该通过通信来共享内存” 这是一句风靡golang社区的经典语

Go语言中,要传递某个数据给另一个goroutine(协程),可以把这个数据封装成一个对象,然后把这个对象的指针传入某个channel中,另外一个goroutine从这个channel中读出这个指针,并处理其指向的内存对象

Go从语言层面保证同一个时间只有一个goroutine能够访问channel里面的数据,为开发者提供了一种优雅简单的工具,所以Go的做法就是使用channel来通信,通过通信来传递内存数据,使得内存数据在不同的goroutine中传递,而不是使用共享内存来通信。

每个通道都有与其相关的类型。该类型是通道允许传输的数据类型。

通道的声明
//声明通道
var 通道名 chan 数据类型
//创建通道:如果通道为nil(就是不存在),就需要先创建通道
通道名 = make(chan 数据类型)

示例:

package main

import "fmt" 

func main() {
    var a chan int
    if a == nil {
        fmt.Println("channel 是 nil 的, 不能使用,需要先创建通道。")
        a = make(chan int)
        fmt.Printf("数据类型是: %T", a)
    }
}

也可以简短的声明:

a := make(chan int) // := 是声明变量并赋值
通道的发送和接收

发送和接收的语法:

data := <- a // read from channel a  
a <- data // write to channel a

在通道上箭头的方向指定数据是发送还是接收。

发送和接收默认是阻塞的

一个通道发送和接收数据,默认是阻塞的。当一个数据被发送到通道时,在发送语句中被阻塞,直到另一个Goroutine从该通道读取数据。相对地,当从通道读取数据时,读取被阻塞,直到一个Goroutine将数据写入该通道。

这些通道的特性是帮助Goroutines有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。

死锁

使用通道时要考虑的一个重要因素是死锁。如果Goroutine在一个通道上发送数据,那么预计其他的Goroutine应该接收数据。如果这种情况不发生,那么程序将在运行时出现死锁。

类似地,如果Goroutine正在等待从通道接收数据,那么另一些Goroutine将会在该通道上写入数据,否则程序将会死锁。

sync

Go语言的sync包提供了常见的并发编程同步原语。

sync.Mutex

sync.Mutex可能是sync包中使用最广泛的原语。它允许在共享资源上互斥访问(不能同时访问):

mutex := &sync.Mutex{}

mutex.Lock()
// Update共享变量 (比如切片,结构体指针等)
mutex.Unlock()
sync.RWMutex

sync.RWMutex是一个读写互斥锁,它提供了我们上面的刚刚看到的sync.MutexLockUnLock方法(因为这两个结构都实现了sync.Locker接口)。但是,它还允许使用RLockRUnlock方法进行并发读取:

mutex := &sync.RWMutex{}

mutex.Lock()
// Update 共享变量
mutex.Unlock()

mutex.RLock()
// Read 共享变量
mutex.RUnlock()

锁定/解锁sync.RWMutex读锁的速度比锁定/解锁sync.Mutex更快,另一方面,在sync.RWMutex上调用Lock()/ Unlock()是最慢的操作。因此,只有在频繁读取和不频繁写入的场景里,才应该使用sync.RWMutex

sync.WaitGroup

sync.WaitGroup也是一个经常会用到的同步原语,它的使用场景是在一个goroutine等待一组goroutine执行完成。

sync.WaitGroup拥有一个内部计数器。当计数器等于0时,则Wait()方法会立即返回。否则它将阻塞执行Wait()方法的goroutine直到计数器等于0时为止。

要增加计数器,我们必须使用Add(int)方法。要减少它,我们可以使用Done()(将计数器减1),也可以传递负数给Add方法把计数器减少指定大小,Done()方法底层就是通过Add(-1)实现的。

在以下示例中,我们将启动八个goroutine,并等待他们完成:

wg := &sync.WaitGroup{}

for i := 0; i < 8; i++ {
  wg.Add(1)
  go func() {
    // Do something
    wg.Done()
  }()
}

wg.Wait()
// 继续往下执行...

每次创建goroutine时,我们都会使用wg.Add(1)来增加wg的内部计数器。我们也可以在for循环之前调用wg.Add(8)

与此同时,每个goroutine完成时,都会使用wg.Done()减少wg的内部计数器。

main goroutine会在八个goroutine都执行wg.Done()将计数器变为0后才能继续执行。

此外,还有sync.Map(一个并发版本的Go语言的map),sync.Pool(是一个并发池,负责安全地保存一组对象),sync.Once(一个简单而强大的原语,可确保一个函数仅执行一次)和sync.Cond(可能是sync包提供的同步原语中最不常用的一个,它用于发出信号(一对一)或广播信号(一对多)到goroutine)。

数据库

单机数据库组件及功能

  • 核心

    • 进程管理器:很多数据库具备一个需要妥善管理的进程/线程池。再者,为了实现纳秒级操作,一些现代数据库使用自己的线程而不是操作系统线程。

    • 网络管理器:网路I/O是个大问题,尤其是对于分布式数据库。所以一些数据库具备自己的网络管理器。

    • 文件系统管理器:磁盘I/O是数据库的首要瓶颈。具备一个文件系统管理器来完美地处理OS文件系统甚至取代OS文件系统,是非常重要的。

    • 内存管理器:为了避免磁盘I/O带来的性能损失,需要大量的内存。但是如果你要处理大容量内存你需要高效的内存管理器,尤其是你有很多查询同时使用内存的时候。

    • 安全管理器:用于对用户的验证和授权。

    • 客户端管理器:用于管理客户端连接。

  • 工具

    • 备份管理器:用于保存和恢复数据。
    • 复原管理器:用于崩溃后重启数据库到一个一致状态。
    • 监控管理器:用于记录数据库活动信息和提供监控数据库的工具。
    • Administration管理器:用于保存元数据(比如表的名称和结构),提供管理数据库、模式、表空间的工具。
  • 查询管理器

    • 查询解析器:用于检查查询是否合法。
    • 查询重写器:用于预优化查询。
    • 查询优化器:用于优化查询。
    • 查询执行器:用于编译和执行查询。
  • 数据管理器

    • 事务管理器:用于处理事务。
    • 缓存管理器:数据被使用之前置于内存,或者数据写入磁盘之前置于内存。
    • 数据访问管理器:访问磁盘中的数据。

SQL基础知识

DDL语句

数据库模式定义语言DDL(Data Definition Language),是用于描述数据库中要存储的现实世界实体的语言。主要由create(添加)、alter(修改)、drop(删除)和 truncate(删除) 四个关键字完成。

DML语句

DML操作是指对数据库中表记录的操作,主要包括表记录的插入(insert),更新(update),删除(delete)和查询(select),是开发人员日常使用最频繁的操作。

事务的基本常识

事务是恢复和 并发控制 的 基本单位 。 事务应该具有4个属性(ACID): 原子性 、一致性、 隔离性 、 持久性

  • 原子性(atomicity)。一个事务是一个不可分割的工作单位,事务中包括的操作要么都做,要么都不做。
  • 一致性(consistency)。事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
  • 隔离性(isolation)。一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
  • 持久性(durability)。持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

基本的后端服务知识

如何启动一个后台进程

服务端程序一般都是运行在Linux系统上面,因此限定于linux操作系统。

后台进程和普通进程的区别

后台进程不需要依附于终端程序;普通进程需要依附于终端程序,当终端程序退出后,也就消亡了。

因为后台进程在启动的过程中,其父进程进行了托管,后台进程的父进程变更为操作系统,也就是进程号为1的线程。而普通的进程的父进程没有进行过托管,其父进程为启动他的终端,而在linux系统中父进程退出的事后,会通知归属于他的所有的子进程全部退出,以避免出现孤儿进程。这就是终端退出,归属于其的普通进程也推出了的原因。

当用户注销(logout)或者网络断开时,终端会收到 HUP(hangup)信号从而关闭其所有子进程。因此,我们的解决办法就有两种途径:要么让进程忽略 HUP 信号,要么让进程运行在新的会话里从而成为不属于此终端的子进程。

让进程在后台运行的方法
在命令后面添加&,然后将整条命令放到脚本里启动

新建一个run.sh文件,然后在其中写入如下内容:
<command> &
然后chmod +x run.sh为其添加可执行权限,之后./run.sh来执行这个脚本,此时进程虽然会输出到当前终端会话,但其实进程已经脱离了当前会话,此时你可以安全的推出会话,进程也不会退出。

nohup

nohup 的用途就是让提交的命令忽略 hangup 信号。

nohup 的使用是十分方便的,只需在要处理的命令前加上 nohup 即可,标准输出和标准错误缺省会被重定向到 nohup.out 文件中。

nohup <command> &

一般我们可在结尾加上"&“来将命令同时放入后台运行,也可用”>filename 2>&1"来更改缺省的重定向文件名。

nohup <command> >filename 2>&1 &
setsid
setsid <command>

setsid方式实为设置启动后的进程的父进程为1号进程来实现的:

[root@pythontab ~]# setsid ping www.baidu.com
[root@pythontab ~]# ps -ef |grep www.baidu.com
root     31094     1  0 07:28 ?        00:00:00 ping www.baidu.com
root     31102 29217  0 07:29 pts/4    00:00:00 grep www.baidu.com

该例中我们的进程 ID(PID)为31094,而它的父 ID(PPID)为1(即为 init 进程 ID),并不是当前终端的进程 ID。

screen

此方式实为开启了一个伪终端,进程是在伪终端中执行的,进程是通过伪终端挂载在1号进程上的

  • 创建screen:screen -dmS screen1

  • 查看screen列表:screen -list:

  • 连接到某个screen:screen -r screen1

连接到screen后,正常执行命令启动进程(无需使用nohop,也不用添加&),之后使用ctrl + a + d从当前screen断开即可

RPC

RPC(Remote Procedure Call)远程过程调用协议,一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。RPC它假定某些协议的存在,例如TPC/UDP等,为通信程序之间携带信息数据。在OSI网络七层模型中,RPC跨越了传输层和应用层,RPC使得开发,包括网络分布式多程序在内的应用程序更加容易。

RPC模式

RPC采用客户端/服务端的模式,通过request-response消息模式实现

RPC的三个过程
  • 通讯协议
  • 寻址
  • 数据序列化
为什么要使用RPC
  • 服务化/微服务
  • 分布式系统架构
  • 服务可重用
  • 系统间交互调用
RPC流程
  • 客户端处理过程中调用client stub,就像调用本地方法一样,传入参数
  • client stub将参数编组为消息,然后通过系统调用向服务端发送消息
  • 客户端本地的操作系统将消息从客户端发送到服务端
  • 服务端将接收到的数据包传递给server stub
  • server stub将接收到的数据解组为参数
  • server stub再调用服务端的过程,过程执行的结果以反方向的相同步骤响应给客户端

TiDB的存储:TiKV

数据的存储模型:Key-Value

TiKV 的选择是 Key-Value 模型,并且提供有序遍历方法(Next方法)。

TiKV 数据存储的两个关键点:

  • 这是一个巨大的 Map(可以类比一下 C++ 的 std::map),也就是存储的是 Key-Value Pairs(键值对)
  • 这个 Map 中的 Key-Value pair 按照 Key 的二进制顺序有序,也就是可以 Seek 到某一个 Key 的位置,然后不断地调用 Next 方法以递增的顺序获取比这个 Key 大的 Key-Value。

本地存储 (RocksDB)

TiKV 没有选择直接向磁盘上写数据,而是把数据保存在 RocksDB 中,具体的数据落地由 RocksDB 负责(这里可以简单的认为 RocksDB 是一个单机的持久化 Key-Value Map)。

这个选择的原因是开发一个单机存储引擎工作量很大,特别是要做一个高性能的单机引擎,需要做各种细致的优化,而 RocksDB 是由 Facebook 开源的一个非常优秀的单机 KV 存储引擎,可以满足 TiKV 对单机引擎的各种要求。

一致性算法:Raft

Raft 是一个一致性算法,它和 Paxos 等价,但是更加易于理解。(关于TiKV对Raft协议实现进行的优化:TiKV 源码解析系列 - Raft 的优化 - 知乎 (zhihu.com)

Raft提供的功能:

  • Leader选举
  • 成员变更
  • 日志复制

Raft

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

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

Region

那么为了实现存储的水平扩展,我们需要将数据分散在多台机器上。

对于一个 KV 系统,将数据分散在多台机器上有两种比较典型的方案:

  • Hash:按照 Key 做 Hash,根据 Hash 值选择对应的存储节点。
  • Range:按照 Key 分 Range,某一段连续的 Key 都保存在一个存储节点上。

TiKV 选择了第二种方式,将整个 Key-Value 空间分成很多段,每一段是一系列连续的 Key,我们将每一段叫做一个 Region,并且我们会尽量保持每个 Region 中保存的数据不超过一定的大小(这个大小可以配置,目前默认是 96mb)。每一个 Region 都可以用 StartKey 到 EndKey 这样一个左闭右开区间来描述。

Region

将数据划分成 Region 后,将会做两件重要的事情

  • 以 Region 为单位,将数据分散在集群中所有的节点上,并且尽量保证每个节点上服务的 Region 数量差不多
    • 实现了存储容量的水平扩展(增加新的结点后,会自动将其他节点上的 Region 调度过来)
    • 实现了负载均衡(不会出现某个节点有很多数据,其他节点上没什么数据的情况)
  • 以 Region 为单位做 Raft 的复制和成员管理,将每一个副本叫做一个 Replica,Replica 之间是通过 Raft 来保持数据的一致。一个 Region 的多个 Replica 会保存在不同的节点上,构成一个 Raft Group。其中一个 Replica 会作为这个 Group 的 Leader,其他的 Replica 作为 Follower。所有的读和写都是通过 Leader 进行,再由 Leader 复制给 Follower。

KeyValue

以 Region 为单位做数据的分散和复制,就有了一个分布式具备一定容灾能力KeyValue 系统,不用再担心数据存不下,或者是磁盘故障丢失数据的问题。

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 是有序的排列),这样当用户通过一个 Key + Version 来获取 Value 的时候,可以将 Key 和 Version 构造出 MVCC 的 Key,也就是 Key-Version。然后可以直接 Seek(Key-Version),定位到第一个大于等于这个 Key-Version 的位置。

分布式 ACID 事务

TiKV 的事务采用的是 Google 在 BigTable 中使用的事务模型:Percolator ,TiKV 根据这篇论文实现,并做了大量的优化。

TiDB的计算

表数据与 Key-Value 的映射关系

  • 为了保证同一个表的数据放在一起,方便查找,TiDB 会为每个表分配一个表 ID,用 TableID 表示。表 ID 是一个整数,在整个集群内唯一。
  • TiDB 会为表中每行数据分配一个行 ID,用 RowID 表示。行 ID 也是一个整数,在表内唯一。对于行 ID,TiDB 做了一个小优化,如果某个表有整数型的主键,TiDB 会使用主键的值当做这一行数据的行 ID。

每行数据按照如下规则编码成 (Key, Value) 键值对:

Key:   tablePrefix{TableID}_recordPrefixSep{RowID}
Value: [col1, col2, col3, col4]

索引数据和 Key-Value 的映射关系

TiDB 同时支持主键和二级索引(包括唯一索引和非唯一索引)。与表数据映射方案类似,TiDB 为表中每个索引分配了一个索引 ID,用 IndexID 表示。

对于主键和唯一索引,需要根据键值快速定位到对应的 RowID,因此,按照如下规则编码成 (Key, Value) 键值对:

Key:   tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue
Value: RowID

对于不需要满足唯一性约束的普通二级索引,一个键值可能对应多行,需要根据键值范围查询对应的 RowID。因此,按照如下规则编码成 (Key, Value) 键值对:

Key:   tablePrefix{TableID}_indexPrefixSep{IndexID}_indexedColumnsValue_{RowID}
Value: null

上述所有编码规则中的 tablePrefixrecordPrefixSepindexPrefixSep 都是字符串常量,用于在 Key 空间内区分其他数据,定义如下:

tablePrefix     = []byte{'t'}
recordPrefixSep = []byte{'r'}
indexPrefixSep  = []byte{'i'}

元信息管理

TiDB 中每个 DatabaseTable 都有元信息,也就是其定义以及各项属性。这些信息也需要持久化,TiDB 将这些信息也存储在了 TiKV 中。

每个 Database/Table 都被分配了一个唯一的 ID,这个 ID 作为唯一标识,并且在编码为 Key-Value 时,这个 ID 都会编码到 Key 中,再加上 m_ 前缀。这样可以构造出一个 Key,Value 中存储的是序列化后的元信息。

SQL 层简介

TiDB 的 SQL 层,即 TiDB Server,负责将 SQL 翻译成 Key-Value 操作,将其转发给共用的分布式 Key-Value 存储层 TiKV,然后组装 TiKV 返回的结果,最终将查询结果返回给客户端。

这一层的节点都是无状态的,节点本身并不存储数据,节点之间完全对等。

SQL 运算

最简单的方案就是通过上一节所述的表数据与 Key-Value 的映射关系方案,将 SQL 查询映射为对 KV 的查询,再通过 KV 接口获取对应的数据,最后执行各种计算。

分布式 SQL 运算

计算应该需要尽量靠近存储节点,以避免大量的 RPC 调用。

SQL 层架构

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

TiDB的调度

PD (Placement Driver) 是 TiDB 集群的管理模块,同时也负责集群数据的实时调度。

调度的需求

第一类:作为一个分布式高可用存储系统,必须满足的需求,包括几种

  • 副本数量不能多也不能少
  • 副本需要根据拓扑结构分布在不同属性的机器上
  • 节点宕机或异常能够自动合理快速地进行容灾

第二类:作为一个良好的分布式系统,需要考虑的地方包括

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

满足第一类需求后,整个系统将具备强大的容灾功能。满足第二类需求后,可以使得系统整体的资源利用率更高且合理,具备良好的扩展性。

调度的基本操作

调度的基本操作指的是为了满足调度的策略。上述调度需求可整理为以下三个操作:

  • 增加一个副本
  • 删除一个副本
  • 将 Leader 角色在一个 Raft Group 的不同副本之间 transfer(迁移)

刚好 Raft 协议通过 AddReplicaRemoveReplicaTransferLeader 这三个命令,可以支撑上述三种基本操作。

信息收集

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

每个 TiKV 节点会定期向 PD 汇报节点的状态信息

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

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

每个 Raft Group 的 Leader 和 PD 之间存在心跳包,用于汇报这个 Region 的状态。

PD 不断的通过这两类心跳消息收集整个集群的信息,再以这些信息作为决策的依据。

调度的策略

  • 一个 Region 的副本数量正确
  • 一个 Raft Group 中的多个副本不在同一个位置
  • 副本在 Store 之间的分布均匀分配
  • Leader 数量在 Store 之间均匀分配
  • 访问热点数量在 Store 之间均匀分配
  • 各个 Store 的存储空间占用大致相等
  • 控制调度速度,避免影响在线服务

调度的实现

PD 不断地通过 Store 或者 Leader 的心跳包收集整个集群信息,并且根据这些信息以及调度策略生成调度操作序列。每次收到 Region Leader 发来的心跳包时,PD 都会检查这个 Region 是否有待进行的操作,然后通过心跳包的回复消息,将需要进行的操作返回给 Region Leader,并在后面的心跳包中监测执行结果。

注意这里的操作只是给 Region Leader 的建议,并不保证一定能得到执行,具体是否会执行以及什么时候执行,由 Region Leader 根据当前自身状态来定。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值