TiDB的核心原理深入研究
TIDB要解決問題:
1. 相容mysql
2. 分散式儲存,擴充套件能力強。
3. 支援OLTP
4. 可以直接利用TiSpark做實時分析。
5. 穩定的高可用性
6. 無阻塞線上DDL
TIDB特點
1. 水平擴充套件:包括計算能力和儲存能力。TiDB Server 負責處理 SQL 請求,隨著業務的增長,可以簡單的新增 TiDB Server 節點,提高整體的處理能力,提供更高的吞吐。TiKV 負責儲存資料,隨著資料量的增長,可以部署更多的 TiKV Server 節點解決資料 Scale 的問題。PD 會在 TiKV 節點之間以 Region 為單位做排程,將部分資料遷移到新加的節點上。
2. 高可用:TiDB/TiKV/PD 這三個元件都能容忍部分例項失效,不影響整個叢集的可用性
TiDB 叢集主要分為三個元件:
TiDB Server
TiDB Server 負責接收 SQL 請求,處理 SQL 相關的邏輯,並通過 PD 找到儲存計算所需資料的 TiKV 地址,與 TiKV 互動獲取資料,最終返回結果。 TiDB Server 是無狀態的,其本身並不儲存資料,只負責計算,可以無限水平擴充套件,可以通過負載均衡元件(如LVS、HAProxy 或 F5)對外提供統一的接入地址。
PD Server
Placement Driver (簡稱 PD) 是整個叢集的管理模組,其主要工作有三個: 一是儲存叢集的元資訊(某個 Key 儲存在哪個 TiKV 節點);二是對 TiKV 叢集進行排程和負載均衡(如資料的遷移、Raft group leader 的遷移等);三是分配全域性唯一且遞增的事務 ID。
PD 是一個叢集,需要部署奇數個節點,一般線上推薦至少部署 3 個節點。
TiKV Server
TiKV Server 負責儲存資料,從外部看 TiKV 是一個分散式的提供事務的 Key-Value 儲存引擎。儲存資料的基本單位是 Region,每個 Region 負責儲存一個 Key Range (從 StartKey 到 EndKey 的左閉右開區間)的資料,每個 TiKV 節點會負責多個 Region 。TiKV 使用 Raft 協議做複製,保持資料的一致性和容災。副本以 Region 為單位進行管理,不同節點上的多個 Region 構成一個 Raft Group,互為副本。資料在多個 TiKV 之間的負載均衡由 PD 排程,這裡也是以 Region 為單位進行排程。
如何儲存?
- 能否支援跨資料中心的容災?
- 寫入速度是否夠快?
- 資料儲存下來後,是否方便讀取?
- 儲存的資料如何修改?如何支援併發的修改?
- 如何原子地修改多條記錄?
這些問題每一項都非常難,但是要做一個優秀的資料儲存系統,必須要解決上述的每一個難題。 為了解決資料儲存問題,我們開發了 TiKV 這個專案。接下來我向大家介紹一下 TiKV 的一些設計思想和基本概念。
Key-Value
作為儲存資料的系統,首先要決定的是資料的儲存模型,也就是資料以什麼樣的形式儲存下來。TiKV 的選擇是 Key-Value 模型,並且提供有序遍歷方法。簡單來講,可以將 TiKV 看做一個巨大的 Map,其中 Key 和 Value 都是原始的 Byte 陣列,在這個 Map 中,Key 按照 Byte 陣列總的原始二進位制位元位比較順序排列。 大家這裡需要對 TiKV 記住兩點:
- 這是一個巨大的 Map,也就是儲存的是 Key-Value pair
- 這個 Map 中的 Key-Value pair 按照 Key 的二進位制順序有序,也就是我們可以 Seek 到某一個 Key 的位置,然後不斷的呼叫 Next 方法以遞增的順序獲取比這個 Key 大的 Key-Value
講了這麼多,有人可能會問了,這裡講的儲存模型和 SQL 中表是什麼關係?在這裡有一件重要的事情要說四遍:
這裡的儲存模型和 SQL 中的 Table 無關! 這裡的儲存模型和 SQL 中的 Table 無關! 這裡的儲存模型和 SQL 中的 Table 無關! 這裡的儲存模型和 SQL 中的 Table 無關!
現在讓我們忘記 SQL 中的任何概念,專注於討論如何實現 TiKV 這樣一個高效能高可靠性的巨大的(分散式的) Map。
RocksDB
任何持久化的儲存引擎,資料終歸要儲存在磁碟上,TiKV 也不例外。但是 TiKV 沒有選擇直接向磁碟上寫資料,而是把資料儲存在 RocksDB 中,具體的資料落地由 RocksDB 負責。這個選擇的原因是開發一個單機儲存引擎工作量很大,特別是要做一個高效能的單機引擎,需要做各種細緻的優化,而 RocksDB 是一個非常優秀的開源的單機儲存引擎,可以滿足我們對單機引擎的各種要求,而且還有 Facebook 的團隊在做持續的優化,這樣我們只投入很少的精力,就能享受到一個十分強大且在不斷進步的單機引擎。當然,我們也為 RocksDB 貢獻了一些程式碼,希望這個專案能越做越好。這裡可以簡單的認為 RocksDB 是一個單機的 Key-Value Map。
Raft
好了,萬里長征第一步已經邁出去了,我們已經為資料找到一個高效可靠的本地儲存方案。俗話說,萬事開頭難,然後中間難,最後結尾難。接下來我們面臨一件更難的事情:如何保證單機失效的情況下,資料不丟失,不出錯?簡單來說,我們需要想辦法把資料複製到多臺機器上,這樣一臺機器掛了,我們還有其他的機器上的副本;複雜來說,我們還需要這個複製方案是可靠、高效並且能處理副本失效的情況。聽上去比較難,但是好在我們有 Raft 協議。Raft 是一個一致性演算法,它和 Paxos 等價,但是更加易於理解。這裡是 Raft 的論文,感興趣的可以看一下。本文只會對 Raft 做一個簡要的介紹,細節問題可以參考論文。另外提一點,Raft 論文只是一個基本方案,嚴格按照論文實現,效能會很差,我們對 Raft 協議的實現做了大量的優化,具體的優化細節可參考我司首席架構師 tangliu 同學的這篇文章。
Raft 是一個一致性協議,提供幾個重要的功能:
- Leader 選舉
- 成員變更
- 日誌複製
TiKV 利用 Raft 來做資料複製,每個資料變更都會落地為一條 Raft 日誌,通過 Raft 的日誌複製功能,將資料安全可靠地同步到 Group 的多數節點中。
到這裡我們總結一下,通過單機的 RocksDB,我們可以將資料快速地儲存在磁碟上;通過 Raft,我們可以將資料複製到多臺機器上,以防單機失效。資料的寫入是通過 Raft 這一層的介面寫入,而不是直接寫 RocksDB。通過實現 Raft,我們擁有了一個分散式的 KV,現在再也不用擔心某臺機器掛掉了。
Region
對於一個 KV 系統,將資料分散在多臺機器上有兩種比較典型的方案:一種是按照 Key 做 Hash,根據 Hash 值選擇對應的儲存節點;另一種是分 Range,某一段連續的 Key 都儲存在一個儲存節點上。TiKV 選擇了第二種方式,將整個 Key-Value 空間分成很多段,每一段是一系列連續的 Key,我們將每一段叫做一個 Region,並且我們會盡量保持每個 Region 中儲存的資料不超過一定的大小(這個大小可以配置,目前預設是 64mb)。每一個 Region 都可以用 StartKey 到 EndKey 這樣一個左閉右開區間來描述。
- 以 Region 為單位,將資料分散在叢集中所有的節點上,並且儘量保證每個節點上服務的 Region 數量差不多
- 以 Region 為單位做 Raft 的複製和成員管理
先看第一點,資料按照 Key 切分成很多 Region,每個 Region 的資料只會儲存在一個節點上面。我們的系統會有一個元件來負責將 Region 儘可能均勻的散佈在叢集中所有的節點上,這樣一方面實現了儲存容量的水平擴充套件(增加新的結點後,會自動將其他節點上的 Region 排程過來),另一方面也實現了負載均衡(不會出現某個節點有很多資料,其他節點上沒什麼資料的情況)。同時為了保證上層客戶端能夠訪問所需要的資料,我們的系統中也會有一個元件記錄 Region 在節點上面的分佈情況,也就是通過任意一個 Key 就能查詢到這個 Key 在哪個 Region 中,以及這個 Region 目前在哪個節點上。
對於第二點,TiKV 是以 Region 為單位做資料的複製,也就是一個 Region 的資料會儲存多個副本,我們將每一個副本叫做一個 Replica。Replica 之間是通過 Raft 來保持資料的一致(終於提到了 Raft),一個 Region 的多個 Replica 會儲存在不同的節點上,構成一個 Raft Group。其中一個 Replica 會作為這個 Group 的 Leader,其他的 Replica 作為 Follower。所有的讀和寫都是通過 Leader 進行,再由 Leader 複製給 Follower。 大家理解了 Region 之後,應該可以理解下面這張圖:
我們以 Region 為單位做資料的分散和複製,就有了一個分散式的具備一定容災能力的 KeyValue 系統,不用再擔心資料存不下,或者是磁碟故障丟失資料的問題。這已經很 Cool,但是還不夠完美,我們需要更多的功能。
- 如何計算?
理解了 SQL 到 KV 的對映方案之後,我們可以理解關係資料是如何儲存的,接下來我們要理解如何使用這些資料來滿足使用者的查詢需求,也就是一個查詢語句是如何操作底層儲存的資料。 能想到的最簡單的方案就是通過上一節所述的對映方案,將 SQL 查詢對映為對 KV 的查詢,再通過 KV 介面獲取對應的資料,最後執行各種計算。 比如 Select count(*) from user where name="TiDB";
這樣一個語句,我們需要讀取表中所有的資料,然後檢查 Name
欄位是否是 TiDB
,如果是的話,則返回這一行。這樣一個操作流程轉換為 KV 操作流程:
- 構造出 Key Range:一個表中所有的 RowID 都在
[0, MaxInt64)
這個範圍內,那麼我們用 0 和 MaxInt64 根據 Row 的 Key 編碼規則,就能構造出一個[StartKey, EndKey)
的左閉右開區間 - 掃描 Key Range:根據上面構造出的 Key Range,讀取 TiKV 中的資料
- 過濾資料:對於讀到的每一行資料,計算
name="TiDB"
這個表示式,如果為真,則向上返回這一行,否則丟棄這一行資料 - 計算 Count:對符合要求的每一行,累計到 Count 值上面 這個方案肯定是可以 Work 的,但是並不能 Work 的很好,原因是顯而易見的:
- 在掃描資料的時候,每一行都要通過 KV 操作同 TiKV 中讀取出來,至少有一次 RPC 開銷,如果需要掃描的資料很多,那麼這個開銷會非常大
- 並不是所有的行都有用,如果不滿足條件,其實可以不讀取出來
- 符合要求的行的值並沒有什麼意義,實際上這裡只需要有幾行資料這個資訊就行
如何避免上述缺陷也是顯而易見的,首先我們需要將計算儘量靠近儲存節點,以避免大量的 RPC 呼叫。其次,我們需要將 Filter 也下推到儲存節點進行計算,這樣只需要返回有效的行,避免無意義的網路傳輸。最後,我們可以將聚合函式、GroupBy 也下推到儲存節點,進行預聚合,每個節點只需要返回一個 Count 值即可,再由 tidb-server 將 Count 值 Sum 起來。 這裡有一個數據逐層返回的示意圖:
下面這個圖列出了重要的模組以及呼叫關係:
使用者的 SQL 請求會直接或者通過 Load Balancer 傳送到 tidb-server,tidb-server 會解析 MySQL Protocol Packet,獲取請求內容,然後做語法解析、查詢計劃制定和優化、執行查詢計劃獲取和處理資料。資料全部儲存在 TiKV 叢集中,所以在這個過程中 tidb-server 需要和 tikv-server 互動,獲取資料。最後 tidb-server 需要將查詢結果返回給使用者。
如何排程?
- 如何保證同一個 Region 的多個 Replica 分佈在不同的節點上?更進一步,如果在一臺機器上啟動多個 TiKV 例項,會有什麼問題?
- TiKV 叢集進行跨機房部署用於容災的時候,如何保證一個機房掉線,不會丟失 Raft Group 的多個 Replica?
- 新增一個節點進入 TiKV 叢集之後,如何將叢集中其他節點上的資料搬過來?
- 當一個節點掉線時,會出現什麼問題?整個叢集需要做什麼事情?如果節點只是短暫掉線(重啟服務),那麼如何處理?如果節點是長時間掉線(磁碟故障,資料全部丟失),需要如何處理?
- 假設叢集需要每個 Raft Group 有 N 個副本,那麼對於單個 Raft Group 來說,Replica 數量可能會不夠多(例如節點掉線,失去副本),也可能會 過於多(例如掉線的節點又回覆正常,自動加入叢集)。那麼如何調節 Replica 個數?
- 讀/寫都是通過 Leader 進行,如果 Leader 只集中在少量節點上,會對叢集有什麼影響?
- 並不是所有的 Region 都被頻繁的訪問,可能訪問熱點只在少數幾個 Region,這個時候我們需要做什麼?
- 叢集在做負載均衡的時候,往往需要搬遷資料,這種資料的遷移會不會佔用大量的網路頻寬、磁碟 IO 以及 CPU?進而影響線上服務?
這些問題單獨拿出可能都能找到簡單的解決方案,但是混雜在一起,就不太好解決。有的問題貌似只需要考慮單個 Raft Group 內部的情況,比如根據副本數量是否足夠多來決定是否需要新增副本。但是實際上這個副本新增在哪裡,是需要考慮全域性的資訊。整個系統也是在動態變化,Region 分裂、節點加入、節點失效、訪問熱點變化等情況會不斷髮生,整個排程系統也需要在動態中不斷向最優狀態前進,如果沒有一個掌握全域性資訊,可以對全域性進行排程,並且可以配置的元件,就很難滿足這些需求。因此我們需要一箇中心節點,來對系統的整體狀況進行把控和調整,所以有了 PD 這個模組。
問題有兩大類:
作為一個分散式高可用儲存系統,必須滿足的需求,包括四種:
- 副本數量不能多也不能少
- 副本需要分佈在不同的機器上
- 新加節點後,可以將其他節點上的副本遷移過來
- 節點下線後,需要將該節點的資料遷移走
作為一個良好的分散式系統,需要優化的地方,包括:
- 維持整個叢集的 Leader 分佈均勻
- 維持每個節點的儲存容量均勻
- 維持訪問熱點分佈均勻
- 控制 Balance 的速度,避免影響線上服務
- 管理節點狀態,包括手動上線/下線節點,以及自動下線失效節點
滿足第一類需求後,整個系統將具備多副本容錯、動態擴容/縮容、容忍節點掉線以及自動錯誤恢復的功能。滿足第二類需求後,可以使得整體系統的負載更加均勻、且可以方便的管理。
為了滿足這些需求,首先我們需要收集足夠的資訊,比如每個節點的狀態、每個 Raft Group 的資訊、業務訪問操作的統計等;其次需要設定一些策略,PD 根據這些資訊以及排程的策略,制定出儘量滿足前面所述需求的排程計劃;最後需要一些基本的操作,來完成排程計劃。
我們先來介紹最簡單的一點,也就是排程的基本操作,也就是為了滿足排程的策略,我們有哪些功能可以用。這是整個排程的基礎,瞭解了手裡有什麼樣的錘子,才知道用什麼樣的姿勢去砸釘子。
上述排程需求看似複雜,但是整理下來最終落地的無非是下面三件事:
- 增加一個 Replica
- 刪除一個 Replica
- 將 Leader 角色在一個 Raft Group 的不同 Replica 之間 transfer
剛好 Raft 協議能夠滿足這三種需求,通過 AddReplica、RemoveReplica、TransferLeader 這三個命令,可以支撐上述三種基本操作。
排程依賴於整個叢集資訊的收集,簡單來說,我們需要知道每個 TiKV 節點的狀態以及每個 Region 的狀態。TiKV 叢集會向 PD 彙報兩類訊息:
每個 TiKV 節點會定期向 PD 彙報節點的整體資訊
TiKV 節點(Store)與 PD 之間存在心跳包,一方面 PD 通過心跳包檢測每個 Store 是否存活,以及是否有新加入的 Store;另一方面,心跳包中也會攜帶這個 Store 的狀態資訊,主要包括:
- 總磁碟容量
- 可用磁碟容量
- 承載的 Region 數量
- 資料寫入速度
- 傳送/接受的 Snapshot 數量(Replica 之間可能會通過 Snapshot 同步資料)
- 是否過載
- 標籤資訊(標籤是具備層級關係的一系列 Tag)
每個 Raft Group 的 Leader 會定期向 PD 彙報資訊
每個 Raft Group 的 Leader 和 PD 之間存在心跳包,用於彙報這個 Region 的狀態,主要包括下面幾點資訊:
- Leader 的位置
- Followers 的位置
- 掉線 Replica 的個數
- 資料寫入/讀取的速度
PD 不斷的通過這兩類心跳訊息收集整個叢集的資訊,再以這些資訊作為決策的依據。除此之外,PD 還可以通過管理介面接受額外的資訊,用來做更準確的決策。比如當某個 Store 的心跳包中斷的時候,PD 並不能判斷這個節點是臨時失效還是永久失效,只能經過一段時間的等待(預設是 30 分鐘),如果一直沒有心跳包,就認為是 Store 已經下線,再決定需要將這個 Store 上面的 Region 都排程走。但是有的時候,是運維人員主動將某臺機器下線,這個時候,可以通過 PD 的管理介面通知 PD 該 Store 不可用,PD 就可以馬上判斷需要將這個 Store 上面的 Region 都排程走。
PD 收集了這些資訊後,還需要一些策略來制定具體的排程計劃。
一個 Region 的 Replica 數量正確
當 PD 通過某個 Region Leader 的心跳包發現這個 Region 的 Replica 數量不滿足要求時,需要通過 Add/Remove Replica 操作調整 Replica 數量。出現這種情況的可能原因是:
- 某個節點掉線,上面的資料全部丟失,導致一些 Region 的 Replica 數量不足
- 某個掉線節點又恢復服務,自動接入叢集,這樣之前已經補足了 Replica 的 Region 的 Replica 數量多過,需要刪除某個 Replica
- 管理員調整了副本策略,修改了 max-replicas 的配置
一個 Raft Group 中的多個 Replica 不在同一個位置
注意第二點,『一個 Raft Group 中的多個 Replica 不在同一個位置』,這裡用的是『同一個位置』而不是『同一個節點』。在一般情況下,PD 只會保證多個 Replica 不落在一個節點上,以避免單個節點失效導致多個 Replica 丟失。在實際部署中,還可能出現下面這些需求:
- 多個節點部署在同一臺物理機器上
- TiKV 節點分佈在多個機架上,希望單個機架掉電時,也能保證系統可用性
- TiKV 節點分佈在多個 IDC 中,希望單個機房掉電時,也能保證系統可用
這些需求本質上都是某一個節點具備共同的位置屬性,構成一個最小的容錯單元,我們希望這個單元內部不會存在一個 Region 的多個 Replica。這個時候,可以給節點配置 lables 並且通過在 PD 上配置 location-labels 來指明哪些 lable 是位置標識,需要在 Replica 分配的時候儘量保證不會有一個 Region 的多個 Replica 所在結點有相同的位置標識。
副本在 Store 之間的分佈均勻分配
前面說過,每個副本中儲存的資料容量上限是固定的,所以我們維持每個節點上面,副本數量的均衡,會使得總體的負載更均衡。
Leader 數量在 Store 之間均勻分配
Raft 協議要讀取和寫入都通過 Leader 進行,所以計算的負載主要在 Leader 上面,PD 會盡可能將 Leader 在節點間分散開。
訪問熱點數量在 Store 之間均勻分配
每個 Store 以及 Region Leader 在上報資訊時攜帶了當前訪問負載的資訊,比如 Key 的讀取/寫入速度。PD 會檢測出訪問熱點,且將其在節點之間分散開。
各個 Store 的儲存空間佔用大致相等
每個 Store 啟動的時候都會指定一個 Capacity 引數,表明這個 Store 的儲存空間上限,PD 在做排程的時候,會考慮節點的儲存空間剩餘量。
控制排程速度,避免影響線上服務
排程操作需要耗費 CPU、記憶體、磁碟 IO 以及網路頻寬,我們需要避免對線上服務造成太大影響。PD 會對當前正在進行的運算元量進行控制,預設的速度控制是比較保守的,如果希望加快排程(比如已經停服務升級,增加新節點,希望儘快排程),那麼可以通過 pd-ctl 手動加快排程速度。
當通過 pd-ctl 手動下線節點後,PD 會在一定的速率控制下,將節點上的資料排程走。當排程完成後,就會將這個節點置為下線狀態。
瞭解了上面這些資訊後,接下來我們看一下整個排程的流程。
PD 不斷的通過 Store 或者 Leader 的心跳包收集資訊,獲得整個叢集的詳細資料,並且根據這些資訊以及排程策略生成排程操作序列,每次收到 Region Leader 發來的心跳包時,PD 都會檢查是否有對這個 Region 待進行的操作,通過心跳包的回覆訊息,將需要進行的操作返回給 Region Leader,並在後面的心跳包中監測執行結果。注意這裡的操作只是給 Region Leader 的建議,並不保證一定能得到執行,具體是否會執行以及什麼時候執行,由 Region Leader 自己根據當前自身狀態來定。