1. 程式人生 > >三篇文章瞭解 TiDB 技術內幕-說計算

三篇文章瞭解 TiDB 技術內幕-說計算

關係模型到 Key-Value 模型的對映

在這我們將關係模型簡單理解為 Table 和 SQL 語句,那麼問題變為如何在 KV 結構上儲存 Table 以及如何在 KV 結構上執行 SQL 語句。 假設我們有這樣一個表的定義:

	CREATE TABLE User {
		ID int,
		Name varchar(20),
		Role varchar(20),
		Age int,
		PRIMARY KEY (ID),
		Key idxAge (age)
	};

SQL 和 KV 結構之間存在巨大的區別,那麼如何能夠方便高效地進行對映,就成為一個很重要的問題。一個好的對映方案必須有利於對資料操作的需求。那麼我們先看一下對資料的操作有哪些需求,分別有哪些特點。

對於一個 Table 來說,需要儲存的資料包括三部分:

  1. 表的元資訊
  2. Table 中的 Row
  3. 索引資料

表的元資訊我們暫時不討論,會有專門的章節來介紹。 對於 Row,可以選擇行存或者列存,這兩種各有優缺點。TiDB 面向的首要目標是 OLTP 業務,這類業務需要支援快速地讀取、儲存、修改、刪除一行資料,所以採用行存是比較合適的。

對於 Index,TiDB 不止需要支援 Primary Index,還需要支援 Secondary Index。Index 的作用的輔助查詢,提升查詢效能,以及保證某些 Constraint。查詢的時候有兩種模式,一種是點查,比如通過 Primary Key 或者 Unique Key 的等值條件進行查詢,如 select name from user where id=1;

 ,這種需要通過索引快速定位到某一行資料;另一種是 Range 查詢,如 select name from user where age > 30 and age < 35;,這個時候需要通過idxAge索引查詢 age 在 20 和 30 之間的那些資料。Index 還分為 Unique Index 和 非 Unique Index,這兩種都需要支援。

分析完需要儲存的資料的特點,我們再看看對這些資料的操作需求,主要考慮 Insert/Update/Delete/Select 這四種語句。

對於 Insert 語句,需要將 Row 寫入 KV,並且建立好索引資料。

對於 Update 語句,需要將 Row 更新的同時,更新索引資料(如果有必要)。

對於 Delete 語句,需要在刪除 Row 的同時,將索引也刪除。

上面三個語句處理起來都很簡單。對於 Select 語句,情況會複雜一些。首先我們需要能夠簡單快速地讀取一行資料,所以每個 Row 需要有一個 ID (顯示或隱式的 ID)。其次可能會讀取連續多行資料,比如 Select * from user;。最後還有通過索引讀取資料的需求,對索引的使用可能是點查或者是範圍查詢。

大致的需求已經分析完了,現在讓我們看看手裡有什麼可以用的:一個全域性有序的分散式 Key-Value 引擎。全域性有序這一點重要,可以幫助我們解決不少問題。比如對於快速獲取一行資料,假設我們能夠構造出某一個或者某幾個 Key,定位到這一行,我們就能利用 TiKV 提供的 Seek 方法快速定位到這一行資料所在位置。再比如對於掃描全表的需求,如果能夠對映為一個 Key 的 Range,從 StartKey 掃描到 EndKey,那麼就可以簡單的通過這種方式獲得全表資料。操作 Index 資料也是類似的思路。接下來讓我們看看 TiDB 是如何做的。

TiDB 對每個表分配一個 TableID,每一個索引都會分配一個 IndexID,每一行分配一個 RowID(如果表有整數型的 Primary Key,那麼會用 Primary Key 的值當做 RowID),其中 TableID 在整個叢集內唯一,IndexID/RowID 在表內唯一,這些 ID 都是 int64 型別。 每行資料按照如下規則進行編碼成 Key-Value pair:

	Key: tablePrefix_rowPrefix_tableID_rowID
	Value: [col1, col2, col3, col4]

其中 Key 的 tablePrefix/rowPrefix 都是特定的字串常量,用於在 KV 空間內區分其他資料。 對於 Index 資料,會按照如下規則編碼成 Key-Value pair:

	Key: tablePrefix_idxPrefix_tableID_indexID_indexColumnsValue
	Value: rowID

Index 資料還需要考慮 Unique Index 和非 Unique Index 兩種情況,對於 Unique Index,可以按照上述編碼規則。但是對於非 Unique Index,通過這種編碼並不能構造出唯一的 Key,因為同一個 Index 的 tablePrefix_idxPrefix_tableID_indexID_ 都一樣,可能有多行資料的 ColumnsValue 是一樣的,所以對於非 Unique Index 的編碼做了一點調整:

	Key: tablePrefix_idxPrefix_tableID_indexID_ColumnsValue_rowID
	Value:null

這樣能夠對索引中的每行資料構造出唯一的 Key。 注意上述編碼規則中的 Key 裡面的各種 xxPrefix 都是字串常量,作用都是區分名稱空間,以免不同型別的資料之間相互衝突,定義如下:

	var(
		tablePrefix     = []byte{'t'}
		recordPrefixSep = []byte("_r")
		indexPrefixSep  = []byte("_i")
	)

另外請大家注意,上述方案中,無論是 Row 還是 Index 的 Key 編碼方案,一個 Table 內部所有的 Row 都有相同的字首,一個 Index 的資料也都有相同的字首。這樣具體相同的字首的資料,在 TiKV 的 Key 空間內,是排列在一起。同時只要我們小心地設計字尾部分的編碼方案,保證編碼前和編碼後的比較關係不變,那麼就可以將 Row 或者 Index 資料有序地儲存在 TiKV 中。這種保證編碼前和編碼後的比較關係不變 的方案我們稱為 Memcomparable,對於任何型別的值,兩個物件編碼前的原始型別比較結果,和編碼成 byte 陣列後(注意,TiKV 中的 Key 和 Value 都是原始的 byte 陣列)的比較結果保持一致。具體的編碼方案參見 TiDB 的 codec 包。採用這種編碼後,一個表的所有 Row 資料就會按照 RowID 的順序排列在 TiKV 的 Key 空間中,某一個 Index 的資料也會按照 Index 的 ColumnValue 順序排列在 Key 空間內。

現在我們結合開始提到的需求以及 TiDB 的對映方案來看一下,這個方案是否能滿足需求。首先我們通過這個對映方案,將 Row 和 Index 資料都轉換為 Key-Value 資料,且每一行、每一條索引資料都是有唯一的 Key。其次,這種對映方案對於點查、範圍查詢都很友好,我們可以很容易地構造出某行、某條索引所對應的 Key,或者是某一塊相鄰的行、相鄰的索引值所對應的 Key 範圍。最後,在保證表中的一些 Constraint 的時候,可以通過構造並檢查某個 Key 是否存在來判斷是否能夠滿足相應的 Constraint。

至此我們已經聊完了如何將 Table 對映到 KV 上面,這裡再舉個簡單的例子,便於大家理解,還是以上面的表結構為例。假設表中有 3 行資料:

  1. “TiDB”, “SQL Layer”, 10
  2. “TiKV”, “KV Engine”, 20
  3. “PD”, “Manager”, 30

那麼首先每行資料都會對映為一個 Key-Value pair,注意這個表有一個 Int 型別的 Primary Key,所以 RowID 的值即為這個 Primary Key 的值。假設這個表的 Table ID 為 10,其 Row 的資料為:

	t_r_10_1 --> ["TiDB", "SQL Layer", 10]
	t_r_10_2 --> ["TiKV", "KV Engine", 20]
	t_r_10_3 --> ["PD", "Manager", 30]

除了 Primary Key 之外,這個表還有一個 Index,假設這個 Index 的 ID 為 1,則其資料為:

	t_i_10_1_10_1 --> null
	t_i_10_1_20_2 --> null
	t_i_10_1_30_3 --> null

大家可以結合上面的編碼規則來理解這個例子,希望大家能理解我們為什麼選擇了這個對映方案,這樣做的目的是什麼。

元資訊管理

上節介紹了表中的資料和索引是如何對映為 KV,本節介紹一下元資訊的儲存。Database/Table 都有元資訊,也就是其定義以及各項屬性,這些資訊也需要持久化,我們也將這些資訊儲存在 TiKV 中。每個 Database/Table 都被分配了一個唯一的 ID,這個 ID 作為唯一標識,並且在編碼為 Key-Value 時,這個 ID 都會編碼到 Key 中,再加上 m_ 字首。這樣可以構造出一個 Key,Value 中儲存的是序列化後的元資訊。 除此之外,還有一個專門的 Key-Value 儲存當前 Schema 資訊的版本。TiDB 使用 Google F1 的 Online Schema 變更演算法,有一個後臺執行緒在不斷的檢查 TiKV 上面儲存的 Schema 版本是否發生變化,並且保證在一定時間內一定能夠獲取版本的變化(如果確實發生了變化)。這部分的具體實現參見 TiDB 的非同步 schema 變更實現一文。

SQL on KV 架構

TiDB 的整體架構如下圖所示

image.png

TiKV Cluster 主要作用是作為 KV 引擎儲存資料,上篇文章已經介紹過了細節,這裡不再敷述。本篇文章主要介紹 SQL 層,也就是 TiDB Servers 這一層,這一層的節點都是無狀態的節點,本身並不儲存資料,節點之間完全對等。TiDB Server 這一層最重要的工作是處理使用者請求,執行 SQL 運算邏輯,接下來我們做一些簡單的介紹。

SQL 運算

理解了 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 的很好,原因是顯而易見的:
  1. 在掃描資料的時候,每一行都要通過 KV 操作同 TiKV 中讀取出來,至少有一次 RPC 開銷,如果需要掃描的資料很多,那麼這個開銷會非常大
  2. 並不是所有的行都有用,如果不滿足條件,其實可以不讀取出來
  3. 符合要求的行的值並沒有什麼意義,實際上這裡只需要有幾行資料這個資訊就行

分散式 SQL 運算

如何避免上述缺陷也是顯而易見的,首先我們需要將計算儘量靠近儲存節點,以避免大量的 RPC 呼叫。其次,我們需要將 Filter 也下推到儲存節點進行計算,這樣只需要返回有效的行,避免無意義的網路傳輸。最後,我們可以將聚合函式、GroupBy 也下推到儲存節點,進行預聚合,每個節點只需要返回一個 Count 值即可,再由 tidb-server 將 Count 值 Sum 起來。 這裡有一個數據逐層返回的示意圖:

image

這裡有一篇文章詳細描述了 TiDB 是如何讓 SQL 語句跑的更快,大家可以參考一下。

SQL 層架構

上面幾節簡要介紹了 SQL 層的一些功能,希望大家對 SQL 語句的處理有一個基本的瞭解。實際上 TiDB 的 SQL 層要複雜的多,模組以及層次非常多,下面這個圖列出了重要的模組以及呼叫關係:

image

使用者的 SQL 請求會直接或者通過 Load Balancer 傳送到 tidb-server,tidb-server 會解析 MySQL Protocol Packet,獲取請求內容,然後做語法解析、查詢計劃制定和優化、執行查詢計劃獲取和處理資料。資料全部儲存在 TiKV 叢集中,所以在這個過程中 tidb-server 需要和 tikv-server 互動,獲取資料。最後 tidb-server 需要將查詢結果返回給使用者。

小結

到這裡,我們已經從 SQL 的角度瞭解了資料是如何儲存,如何用於計算。SQL 層更詳細的介紹會在今後的文章中給出,比如優化器的工作原理,分散式執行框架的細節。 下一篇文章我們將會介紹一些關於 PD 的資訊,這部分會比較有意思,裡面的很多東西是在使用 TiDB 過程中看不到,但是對整體叢集又非常重要。主要會涉及到叢集的管理和排程。