三篇文章瞭解 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 來說,需要儲存的資料包括三部分:
- 表的元資訊
- Table 中的 Row
- 索引資料
表的元資訊我們暫時不討論,會有專門的章節來介紹。 對於 Row,可以選擇行存或者列存,這兩種各有優缺點。TiDB 面向的首要目標是 OLTP 業務,這類業務需要支援快速地讀取、儲存、修改、刪除一行資料,所以採用行存是比較合適的。
對於 Index,TiDB 不止需要支援 Primary Index,還需要支援 Secondary Index。Index 的作用的輔助查詢,提升查詢效能,以及保證某些 Constraint。查詢的時候有兩種模式,一種是點查,比如通過 Primary Key 或者 Unique Key 的等值條件進行查詢,如 select name from user where id=1;
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 行資料:
- “TiDB”, “SQL Layer”, 10
- “TiKV”, “KV Engine”, 20
- “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 的整體架構如下圖所示
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 的很好,原因是顯而易見的:
- 在掃描資料的時候,每一行都要通過 KV 操作同 TiKV 中讀取出來,至少有一次 RPC 開銷,如果需要掃描的資料很多,那麼這個開銷會非常大
- 並不是所有的行都有用,如果不滿足條件,其實可以不讀取出來
- 符合要求的行的值並沒有什麼意義,實際上這裡只需要有幾行資料這個資訊就行
分散式 SQL 運算
如何避免上述缺陷也是顯而易見的,首先我們需要將計算儘量靠近儲存節點,以避免大量的 RPC 呼叫。其次,我們需要將 Filter 也下推到儲存節點進行計算,這樣只需要返回有效的行,避免無意義的網路傳輸。最後,我們可以將聚合函式、GroupBy 也下推到儲存節點,進行預聚合,每個節點只需要返回一個 Count 值即可,再由 tidb-server 將 Count 值 Sum 起來。 這裡有一個數據逐層返回的示意圖:
這裡有一篇文章詳細描述了 TiDB 是如何讓 SQL 語句跑的更快,大家可以參考一下。
SQL 層架構
上面幾節簡要介紹了 SQL 層的一些功能,希望大家對 SQL 語句的處理有一個基本的瞭解。實際上 TiDB 的 SQL 層要複雜的多,模組以及層次非常多,下面這個圖列出了重要的模組以及呼叫關係:
使用者的 SQL 請求會直接或者通過 Load Balancer 傳送到 tidb-server,tidb-server 會解析 MySQL Protocol Packet,獲取請求內容,然後做語法解析、查詢計劃制定和優化、執行查詢計劃獲取和處理資料。資料全部儲存在 TiKV 叢集中,所以在這個過程中 tidb-server 需要和 tikv-server 互動,獲取資料。最後 tidb-server 需要將查詢結果返回給使用者。
小結
到這裡,我們已經從 SQL 的角度瞭解了資料是如何儲存,如何用於計算。SQL 層更詳細的介紹會在今後的文章中給出,比如優化器的工作原理,分散式執行框架的細節。 下一篇文章我們將會介紹一些關於 PD 的資訊,這部分會比較有意思,裡面的很多東西是在使用 TiDB 過程中看不到,但是對整體叢集又非常重要。主要會涉及到叢集的管理和排程。