本系列文章主要面向 TiKV 社群開發者,重點介紹 TiKV 的系統架構,原始碼結構,流程解析。目的是使得開發者閱讀之後,能對 TiKV 專案有一個初步瞭解,更好的參與進入 TiKV 的開發中。
TiKV 是一個分散式的 KV 系統,它採用 Raft 協議保證資料的強一致性,同時使用 MVCC + 2PC 的方式實現了分散式事務的支援。
本文為本系列文章第三節。
介紹
Placement Driver (後續以 PD 簡稱) 是 TiDB 裡面全域性中心總控節點,它負責整個叢集的排程,負責全域性 ID 的生成,以及全域性時間戳 TSO 的生成等。PD 還儲存著整個叢集 TiKV 的元資訊,負責給 client 提供路由功能。
作為中心總控節點,PD 通過整合 etcd ,自動的支援 auto failover,無需擔心單點故障問題。同時,PD 也通過 etcd 的 raft,保證了資料的強一致性,不用擔心資料丟失的問題。
在架構上面,PD 所有的資料都是通過 TiKV 主動上報獲知的。同時,PD 對整個 TiKV 叢集的排程等操作,也只會在 TiKV 傳送 heartbeat 命令的結果裡面返回相關的命令,讓 TiKV 自行去處理,而不是主動去給 TiKV 發命令。這樣設計上面就非常簡單,我們完全可以認為 PD 是一個無狀態的服務(當然,PD 仍然會將一些資訊持久化到 etcd),所有的操作都是被動觸發,即使 PD 掛掉,新選出的 PD leader 也能立刻對外服務,無需考慮任何之前的中間狀態。
初始化
PD 整合了 etcd,所以通常,我們需要啟動至少三個副本,才能保證資料的安全。現階段 PD 有叢集啟動方式,initial-cluster
的靜態方式以及 join
的動態方式。
在繼續之前,我們需要了解下 etcd 的埠,在 etcd 裡面,預設要監聽 2379 和 2380 兩個埠。2379 主要是 etcd 用來處理外部請求用的,而 2380 則是 etcd peer 之間相互通訊用的。
假設現在我們有三個 pd,分別為 pd1,pd2,pd3,分別在 host1,host2,host3 上面。
對於靜態初始化,我們直接在三個 PD 啟動的時候,給 initial-cluster
設定 pd1=http://host1:2380,pd2=http://host2:2380,pd3=http://host3:2380
。
對於動態初始化,我們先啟動 pd1,然後啟動 pd2,加入到 pd1 的叢集裡面,join
設定為 http://host1:2379
。然後啟動 pd3,加入到 pd1,pd2 形成的叢集裡面, join
設定為 http://host1:2379
。
可以看到,靜態初始化和動態初始化完全走的是兩個埠,而且這兩個是互斥的,也就是我們只能使用一種方式來初始化叢集。etcd 本身只支援 initial-cluster
的方式,但為了方便,PD 同時也提供了 join
的方式。
join
主要是用了 etcd 自身提供的 member 相關 API,包括 add member,list member 等,所以我們使用 2379 埠,因為需要將命令發到 etcd 去執行。而 initial-cluster
則是 etcd 自身的初始化方式,所以使用的 2380 埠。
相比於 initial-cluster
,join
需要考慮非常多的 case(在 server/join.go
prepareJoinCluster
函式裡面有詳細的解釋),但 join
的使用非常自然,後續我們會考慮去掉 initial-cluster
的初始化方案。
選舉
當 PD 啟動之後,我們就需要選出一個 leader 對外提供服務。雖然 etcd 自身也有 raft leader,但我們還是覺得使用自己的 leader,也就是 PD 的 leader 跟 etcd 自己的 leader 是不一樣的。
當 PD 啟動之後,Leader 的選舉如下:
-
檢查當前叢集是不是有 leader,如果有 leader,就 watch 這個 leader,只要發現 leader 掉了,就重新開始 1。
-
如果沒有 leader,開始 campaign,建立一個 Lessor,並且通過 etcd 的事務機制寫入相關資訊,如下:
// Create a lessor. ctx, cancel := context.WithTimeout(s.client.Ctx(), requestTimeout) leaseResp, err := lessor.Grant(ctx, s.cfg.LeaderLease) cancel() // The leader key must not exist, so the CreateRevision is 0. resp, err := s.txn(). If(clientv3.Compare(clientv3.CreateRevision(leaderKey), "=", 0)). Then(clientv3.OpPut(leaderKey, s.leaderValue, clientv3.WithLease(clientv3.LeaseID(leaseResp.ID)))). Commit()
如果 leader key 的 CreateRevision 為 0,表明其他 PD 還沒有寫入,那麼我就可以將我自己的 leader 相關資訊寫入,同時會帶上一個 Lease。如果事務執行失敗,表明其他的 PD 已經成為了 leader,那麼就重新回到 1。
-
成為 leader 之後,我們對定期進行保活處理:
// Make the leader keepalived. ch, err := lessor.KeepAlive(s.client.Ctx(), clientv3.LeaseID(leaseResp.ID)) if err != nil { return errors.Trace(err) }
當 PD 崩潰,原先寫入的 leader key 會因為 lease 到期而自動刪除,這樣其他的 PD 就能 watch 到,重新開始選舉。
-
初始化 raft cluster,主要是從 etcd 裡面重新載入叢集的元資訊。拿到最新的 TSO 資訊:
// Try to create raft cluster. err = s.createRaftCluster() if err != nil { return errors.Trace(err) } log.Debug("sync timestamp for tso") if err = s.syncTimestamp(); err != nil { return errors.Trace(err) }
-
所有做完之後,開始定期更新 TSO,監聽 lessor 是否過期,以及外面是否主動退出:
for { select { case _, ok := <-ch: if !ok { log.Info("keep alive channel is closed") return nil } case <-tsTicker.C: if err = s.updateTimestamp(); err != nil { return errors.Trace(err) } case <-s.client.Ctx().Done(): return errors.New("server closed") } }
TSO
前面我們說到了 TSO,TSO 是一個全域性的時間戳,它是 TiDB 實現分散式事務的基石。所以對於 PD 來說,我們首先要保證它能快速大量的為事務分配 TSO,同時也需要保證分配的 TSO 一定是單調遞增的,不可能出現回退的情況。
TSO 是一個 int64 的整形,它由 physical time + logical time 兩個部分組成。Physical time 是當前 unix time 的毫秒時間,而 logical time 則是一個最大 1 << 18
的計數器。也就是說 1ms,PD 最多可以分配 262144 個 TSO,這個能滿足絕大多數情況了。
對於 TSO 的儲存於分配,PD 會做如下處理:
-
當 PD 成為 leader 之後,會從 etcd 上面獲取上一次儲存的時間,如果發現本地的時間比這個大,則會繼續等待直到當前的時間大於這個值:
last, err := s.loadTimestamp() if err != nil { return errors.Trace(err) } var now time.Time for { now = time.Now() if wait := last.Sub(now) + updateTimestampGuard; wait > 0 { log.Warnf("wait %v to guarantee valid generated timestamp", wait) time.Sleep(wait) continue } break }
-
當 PD 能分配 TSO 之後,首先會向 etcd 申請一個最大的時間,譬如,假設當前時間是 t1,每次最多能申請 3s 的時間視窗,PD 會向 etcd 儲存 t1 + 3s 的時間值,然後 PD 就能在記憶體裡面直接使用這一段時間視窗.噹噹前的時間 t2 大於 t1 + 3s 之後,PD 就會在向 etcd 繼續更新為 t2 + 3s:
if now.Sub(s.lastSavedTime) >= 0 { last := s.lastSavedTime save := now.Add(s.cfg.TsoSaveInterval.Duration) if err := s.saveTimestamp(save); err != nil { return errors.Trace(err) } }
這麼處理的好處在於,即使 PD 當掉,新啟動的 PD 也會從上一次儲存的最大的時間之後開始分配 TSO,也就是 1 處理的情況。
-
因為 PD 在記憶體裡面儲存了一個可分配的時間視窗,所以外面請求 TSO 的時候,PD 能直接在記憶體裡面計算 TSO 並返回。
resp := pdpb.Timestamp{} for i := 0; i < maxRetryCount; i++ { current, ok := s.ts.Load().(*atomicObject) if !ok { log.Errorf("we haven`t synced timestamp ok, wait and retry, retry count %d", i) time.Sleep(200 * time.Millisecond) continue } resp.Physical = current.physical.UnixNano() / int64(time.Millisecond) resp.Logical = atomic.AddInt64(¤t.logical, int64(count)) if resp.Logical >= maxLogical { log.Errorf("logical part outside of max logical interval %v, please check ntp time, retry count %d", resp, i) time.Sleep(updateTimestampStep) continue } return resp, nil }
因為是在記憶體裡面計算的,所以效能很高,我們自己內部測試每秒能分配百萬級別的 TSO。
-
如果 client 每次事務都向 PD 來請求一次 TSO,每次 RPC 的開銷也是非常大的,所以 client 會批量的向 PD 獲取 TSO。client 會首先收集一批事務的 TSO 請求,譬如 n 個,然後直接向 PD 傳送命令,引數就是 n,PD 收到命令之後,會生成 n 個 TSO 返回給客戶端。
心跳
在最開始我們說過,PD 所有關於叢集的資料都是由 TiKV 主動心跳上報的,PD 對 TiKV 的排程也是在心跳的時候完成的。通常 PD 會處理兩種心跳,一個是 TiKV 自身 store 的心跳,而另一個則是 store 裡面 region 的 leader peer 上報的心跳。
對於 store 的心跳,PD 在 handleStoreHeartbeat
函式裡面處理,主要就是將心跳裡面當前的 store 的一些狀態快取到 cache 裡面。store 的狀態包括該 store 有多少個 region,有多少個 region 的 leader peer 在該 store 上面等,這些資訊都會用於後續的排程。
對於 region 的心跳,PD 在 handleRegionHeartbeat
裡面處理。這裡需要注意,只有 leader peer 才會去上報所屬 region 的資訊,follower peer 是不會上報的。收到 region 的心跳之後,首先 PD 也會將其放入 cache 裡面,如果 PD 發現 region 的 epoch 有變化,就會將這個 region 的資訊也儲存到 etcd 裡面。然後,PD 會對這個 region 進行具體的排程,譬如發現 peer 數目不夠,新增新的 peer,或者有一個 peer 已經壞了,刪除這個 peer 等,詳細的排程實現,我們會在後續討論。
這裡再說一下 region 的 epoch,在 region 的 epoch 裡面,有 conf_ver
和 version
,分別表示這個 region 不同的版本狀態。如果一個 region 發生了 membership changes,也就是新增或者刪除了 peer,conf_ver
會加 1,如果 region 發生了 split
或者 merge
,則 version
加 1。
無論是 PD 還是在 TiKV,我們都是通過 epoch 來判斷 region 是否發生了變化,從而拒絕掉一些危險的操作。譬如 region 已經發生了分裂,version
變成了 2,那麼如果這時候有一個寫請求帶上的 version
是 1, 我們就會認為這個請求是 stale,會直接拒絕掉。因為 version
變化表明 region 的範圍已經發生了變化,很有可能這個 stale 的請求需要操作的 key 是在之前的 region range 裡面而沒在新的 range 裡面。
Split / Merge
前面我們說了,PD 會在 region 的 heartbeat 裡面對 region 進行排程,然後直接在 heartbeat 的返回值裡面帶上相關的排程資訊,讓 TiKV 自己去處理,TiKV 處理完成之後,通過下一個 heartbeat 重新上報,PD 就能知道是否排程成功了。
對於 membership changes,比較容易,因為我們有最大副本數的配置,假設三個,那麼當 region 的心跳上來,發現只有兩個 peer,那麼就 add peer,如果有四個 peer,就 remove peer。而對於 region 的 split / merge,則情況稍微要複雜一點,但也比較簡單。注意,現階段,我們只支援 split,merge 處於開發階段,沒對外發布,所以這裡僅僅以 split 舉例:
-
在 TiKV 裡面,leader peer 會定期檢查 region 所佔用的空間是否超過某一個閥值,假設我們設定 region 的 size 為 64MB,如果一個 region 超過了 96MB, 就需要分裂。
-
Leader peer 會首先向 PD 傳送一個請求分裂的命令,PD 在
handleAskSplit
裡面處理,因為我們是一個 region 分裂成兩個,對於這兩個新分裂的 region,一個會繼承之前 region 的所有的元資訊,而另一個相關的資訊,譬如 region ID,新的 peer ID,則需要 PD 生成,並將其返回給 leader。 -
Leader peer 寫入一個 split raft log,在 apply 的時候執行,這樣 region 就分裂成了兩個。
-
分裂成功之後,TiKV 告訴 PD,PD 就在
handleReportSplit
裡面處理,更新 cache 相關的資訊,並持久化到 etcd。
路由
因為 PD 儲存了所有 TiKV 的叢集資訊,自然對 client 提供了路由的功能。假設 client 要對 key
寫入一個值。
-
client 先從 PD 獲取
key
屬於哪一個 region,PD 將這個 region 相關的元資訊返回。 -
client 自己 cache,這樣就不需要每次都從 PD 獲取。然後直接給 region 的 leader peer 傳送命令。
-
有可能 region 的 leader 已經漂移到其他 peer,TiKV 會返回
NotLeader
錯誤,並帶上新的 leader 的地址,client 在 cache 裡面更新,並重新向新的 leader 傳送請求。 -
也有可能 region 的 version 已經變化,譬如 split 了,這時候,
key
可能已經落入了新的 region 上面,client 會收到StaleCommand
的錯誤,於是重新從 PD 獲取,進入狀態 1。
小結
PD 作為 TiDB 叢集的中心排程模組,在設計上面,我們儘量保證無狀態,方便擴充套件。本篇文章主要介紹了 PD 是如何跟 TiKV,TiDB 協作互動的。後面,我們會詳細地介紹核心排程功能,也就是 PD 是如何控制整個叢集的。