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

balahoho發表於2021-08-13

什麼是熱點問題

說這個話題之前我們先回顧一下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.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底層原理技術的系列文章,歡迎大家關注,一起學習交流。如果本文有存在錯誤的地方,歡迎指出。

相關文章