以TiDB熱點問題來談Region的調度流程

什麼是熱點問題

說這個話題以前咱們先回顧一下TiDB的主要結構和概念。shell

TiDB的核心架構分爲TiDB、TiKV、PD三個部分,其中TiKV是一個分佈式數據存儲引擎用來存儲真實的數據,在TiKV中又對存儲區域進行了一系列的邏輯劃分也就是Region,它是被PD調度的最小單元。熟悉TiDB的讀者對這個結構應該瞭然於胸。json

正是因爲這種設計,TiDB在碰到短期內的大流量時就會碰到數據熱點問題,大量的數據被寫入到同一個Region Leader致使某一部分TiKV節點資源消耗特別高,而其餘節點又處於空閒狀態,這種狀況明顯是違背了分佈式系統的設計初衷。TiDB爲了不這種狀況的發生,官方已經給出了成熟的解決方案,好比提早切分好Region或者是對row_id進行打散等。下圖是咱們對熱點問題處理先後進行測試的結果:api



如何處理熱點不是咱們本文討論的重點,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"`
}

重點看一下以下幾個字段:app

  • 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.go
const (
	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.go
const (
	// 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的一篇文章說的很是詳細:
https://pingcap.com/blog-cn/pd-scheduler/

有了這個結構以後,對多種調度器進行操做甚至擴展就變得很是容易了。
我大體把調度器的執行流程分爲3個階段:

  • 註冊階段
  • 建立階段
  • 運行階段
    整個過程我總結爲下面一個流程圖:

第一階段相對比較獨立,主要發生在生個PD服務啓動過程當中,PD啓動的時候不只會註冊相關的調度器,還會啓動一個Cluster對象,裏面是對整個PD集羣的封裝,上一張圖中的Coordinator和它裏面的對象也是在這時候被建立和啓動。
調度器註冊實質是保存了一個建立調度器對象的function,當收到建立請求的時候就來執行這個function獲得調度器對象。接着,調度器會被封裝成一個ScheduleController對象,它被用來控制調度器的執行,這個對象裏保存了調度器下一次被執行的間隔時間以及一些上下文參數。ScheduleController對象會被加入到Coordinator的調度器列表中,而後開啓一個後臺任務和定時器來執行最終的調度,也就是調度器的Schedule()方法,這個方法返回的是一組Operator,表示須要對Region執行一系列操做,這其中就可能包含對Region的打散操做。這些操做會被AddWaitingOperator()方法加入到OperatorController的等待隊列中,等待下一次心跳到來後被下發到TiKV節點去執行。

這裏要注意的是,調度器執行失敗會進行重試,這個重試次數是由Coordinator設定的,默認是10次:

// server/cluster/coordinator.go
const (
	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底層原理技術的系列文章,歡迎你們關注,一塊兒學習交流。若是本文有存在錯誤的地方,歡迎指出。

相關文章
相關標籤/搜索