TiDB Server 負責接受使用者端的連線,執行 SQL 解析和優化,最終生成分散式執行計劃,對外暴露 MySQL 協定的連線 endpoint,處理 SQL 相關的邏輯,並通過 PD 找到儲存計算所需資料的 TiKV 地址,與 TiKV 互動獲取資料,最終返回結果,TiDB 層本身是無狀態的,實踐中可以啟動多個 TiDB 範例,通過負載均衡元件(如 LVS、HAProxy 或 F5)對外提供統一的接入地址,使用者端的連線可以均勻地分攤在多個 TiDB 範例上以達到負載均衡的效果。TiDB Server 本身並不儲存資料,只是解析 SQL,將實際的資料讀取請求轉發給底層的儲存節點 TiKV(或 TiFlash)。
整個 TiDB 叢集的元資訊管理模組,負責儲存每個 TiKV 節點實時的資料分佈情況和叢集的整體拓撲結構,提供 TiDB Dashboard 管控介面,併為分散式事務分配事務 ID。PD 不僅儲存元資訊,同時還會根據 TiKV 節點實時上報的資料分佈狀態,下發資料排程命令給具體的 TiKV 節點,可以說是整個叢集的「大腦」。此外,PD 本身也是由至少 3 個節點構成,擁有高可用的能力。建議部署奇數個 PD 節點。其主要工作有三個:一是儲存叢集的元資訊(某個 Key 儲存在哪個 TiKV 節點);二是對 TiKV 叢集進行排程和負載均衡(如資料的遷移、Raft group leader 的遷移等);三是分配全域性唯一且遞增的事務 ID。
show table regions:檢視節點
- TiKV Server:負責儲存資料,從外部看 TiKV 是一個分散式的提供事務的 Key-Value 儲存引擎。儲存資料的基本單位是 Region,每個 Region 負責儲存一個 Key Range(從 StartKey 到 EndKey 的左閉右開區間)的資料,每個 TiKV 節點會負責多個 Region。TiKV 的 API 在 KV 鍵值對層面提供對分散式事務的原生支援,預設提供了 SI (Snapshot Isolation) 的隔離級別,這也是 TiDB 在 SQL 層面支援分散式事務的核心。TiDB 的 SQL 層做完 SQL 解析後,會將 SQL 的執行計劃轉換為對 TiKV API 的實際呼叫。所以,資料都儲存在 TiKV 中。另外,TiKV 中的資料都會自動維護多副本(預設為三副本),天然支援高可用和自動故障轉移。
- TiFlash:TiFlash 是一類特殊的儲存節點。和普通 TiKV 節點不一樣的是,在 TiFlash 內部,資料是以列式的形式進行儲存,主要的功能是為分析型的場景加速。
TiDB 中資料到 (Key, Value) 鍵值對的對映方案
背景:在關係型資料庫中,一個表可能有很多列。要將一行中各列資料對映成一個 (Key, Value) 鍵值對,需要考慮如何構造 Key。首先,OLTP 場景下有大量針對單行或者多行的增、刪、改、查等操作,要求資料庫具備快速讀取一行資料的能力。因此,對應的 Key 最好有一個唯一 ID(顯示或隱式的 ID),以方便快速定位。其次,很多 OLAP 型查詢需要進行全表掃描。如果能夠將一個表中所有行的 Key 編碼到一個區間內,就可以通過範圍查詢高效完成全表掃描的任務。3
基於上述考慮,TiDB 中的表資料與 Key-Value 的對映關係作了如下設計:
TableID
表示。表 ID 是一個整數,在整個叢集內唯一。RowID
表示。行 ID 也是一個整數,在表內唯一。對於行 ID,TiDB 做了一個小優化,如果某個表有整數型的主鍵,TiDB 會使用主鍵的值當做這一行資料的行 ID。每行資料按照如下規則編碼成 (Key, Value) 鍵值對:
Key: tablePrefix{TableID}_recordPrefixSep{RowID}
Value: [col1, col2, col3, col4]
其中 tablePrefix
和 recordPrefixSep
都是特定的字串常數,用於在 Key 空間內區分其他資料。其具體值在後面的小結中給出。
TiDB 同時支援主鍵和二級索引(包括唯一索引和非唯一索引)。與表資料對映方案類似,TiDB 為表中每個索引分配了一個索引 ID,用 IndexID
表示。
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue
Value: RowID
對於不需要滿足唯一性約束的普通二級索引,一個鍵值可能對應多行,需要根據鍵值範圍查詢對應的 RowID。因此,按照如下規則編碼成 (Key, Value) 鍵值對:
Key: tablePrefix{TableID}_indexPrefixSep{IndexID}indexedColumnsValue
Value: null
上述所有編碼規則中的 tablePrefix
、recordPrefixSep
和 indexPrefixSep
都是字串常數,用於在 Key 空間內區分其他資料,定義如下:
tablePrefix = []byte{'t'}
recordPrefixSep = []byte{'r'}
indexPrefixSep = []byte{'i'}
另外請注意,上述方案中,無論是表資料還是索引資料的 Key 編碼方案,一個表內所有的行都有相同的 Key 字首,一個索引的所有資料也都有相同的字首。這樣具有相同的字首的資料,在 TiKV 的 Key 空間內,是排列在一起的。因此只要小心地設計字尾部分的編碼方案,保證編碼前和編碼後的比較關係不變,就可以將表資料或者索引資料有序地儲存在 TiKV 中。採用這種編碼後,一個表的所有行資料會按照
RowID
順序地排列在 TiKV 的 Key 空間中,某一個索引的資料也會按照索引資料的具體的值(編碼方案中的indexedColumnsValue
)順序地排列在 Key 空間內。
最後通過一個簡單的例子,來理解 TiDB 的 Key-Value 對映關係。假設 TiDB 中有如下這個表:
CREATE TABLE User (
ID int,
Name varchar(20),
Role varchar(20),
Age int,
PRIMARY KEY (ID),
KEY idxAge (Age)
);
假設該表中有 3 行資料:
1, "TiDB", "SQL Layer", 10
2, "TiKV", "KV Engine", 20
3, "PD", "Manager", 30
首先每行資料都會對映為一個 (Key, Value) 鍵值對,同時該表有一個 int
型別的主鍵,所以 RowID
的值即為該主鍵的值。假設該表的 TableID
為 10,則其儲存在 TiKV 上的表資料為:
t10_r1 --> ["TiDB", "SQL Layer", 10]
t10_r2 --> ["TiKV", "KV Engine", 20]
t10_r3 --> ["PD", "Manager", 30]
除了主鍵外,該表還有一個非唯一的普通二級索引 idxAge
,假設這個索引的 IndexID
為 1,則其儲存在 TiKV 上的索引資料為:
t10_i1_10_1 --> null
t10_i1_20_2 --> null
t10_i1_30_3 --> null
TiDB 中每個
Database
和Table
都有元資訊,也就是其定義以及各項屬性。這些資訊也需要持久化,TiDB 將這些資訊也儲存在了 TiKV 中。每個
Database
/Table
都被分配了一個唯一的 ID,這個 ID 作為唯一標識,並且在編碼為 Key-Value 時,這個 ID 都會編碼到 Key 中,再加上m_
字首。這樣可以構造出一個 Key,Value 中儲存的是序列化後的元資訊。除此之外,TiDB 還用一個專門的 (Key, Value) 鍵值對儲存當前所有表結構資訊的最新版本號。這個鍵值對是全域性的,每次 DDL 操作的狀態改變時其版本號都會加 1。目前,TiDB 把這個鍵值對持久化儲存在 PD Server 中,其 Key 是 "/tidb/ddl/global_schema_version",Value 是型別為 int64 的版本號值。TiDB 採用 Online Schema 變更演演算法,有一個後臺執行緒在不斷地檢查 PD Server 中儲存的表結構資訊的版本號是否發生變化,並且保證在一定時間內一定能夠獲取版本的變化。
TiDB 的 SQL 層,即 TiDB Server,負責將 SQL 翻譯成 Key-Value 操作,將其轉發給共用的分散式 Key-Value 儲存層 TiKV,然後組裝 TiKV 返回的結果,最終將查詢結果返回給使用者端。
這一層的節點都是無狀態的,節點本身並不儲存資料,節點之間完全對等。
最簡單的方案就是通過上一節所述的表資料與 Key-Value 的對映關係方案,將 SQL 查詢對映為對 KV 的查詢,再通過 KV 介面獲取對應的資料,最後執行各種計算。
比如
select count(*) from user where name = "TiDB"
這樣一個 SQL 語句,它需要讀取表中所有的資料,然後檢查name
欄位是否是TiDB
,如果是的話,則返回這一行。具體流程如下:
RowID
都在 [0, MaxInt64)
這個範圍內,使用 0
和 MaxInt64
根據行資料的 Key
編碼規則,就能構造出一個 [StartKey, EndKey)
的左閉右開區間。name = "TiDB"
這個表示式,如果為真,則向上返回這一行,否則丟棄這一行資料。Count(*)
:對符合要求的每一行,累計到 Count(*)
的結果上面。整個流程示意圖如下:
這個方案是直觀且可行的,但是在分散式資料庫的場景下有一些顯而易見的問題:
- 在掃描資料的時候,每一行都要通過 KV 操作從 TiKV 中讀取出來,至少有一次 RPC 開銷,如果需要掃描的資料很多,那麼這個開銷會非常大。
- 並不是所有的行都滿足過濾條件
name = "TiDB"
,如果不滿足條件,其實可以不讀取出來。- 此查詢只要求返回符合要求行的數量,不要求返回這些行的值。
為了解決上述問題,計算應該需要儘量靠近儲存節點,以避免大量的 RPC 呼叫。首先,SQL 中的謂詞條件
name = "TiDB"
應被下推到儲存節點進行計算,這樣只需要返回有效的行,避免無意義的網路傳輸。然後,聚合函數Count(*)
也可以被下推到儲存節點,進行預聚合,每個節點只需要返回一個Count(*)
的結果即可,再由 SQL 層將各個節點返回的Count(*)
的結果累加求和。
以下是資料逐層返回的示意圖:
通過上面的例子,希望大家對 SQL 語句的處理有一個基本的瞭解。實際上 TiDB 的 SQL 層要複雜得多,模組以及層次非常多,下圖列出了重要的模組以及呼叫關係:
使用者的 SQL 請求會直接或者通過
Load Balancer
傳送到 TiDB Server,TiDB Server 會解析MySQL Protocol Packet
,獲取請求內容,對 SQL 進行語法解析和語意分析,制定和優化查詢計劃,執行查詢計劃並獲取和處理資料。資料全部儲存在 TiKV 叢集中,所以在這個過程中 TiDB Server 需要和 TiKV 互動,獲取資料。最後 TiDB Server 需要將查詢結果返回給使用者。
AUTO_INCREMENT
是用於自動填充預設列值的列屬性。當 INSERT
語句沒有指定 AUTO_INCREMENT
列的具體值時,系統會自動地為該列分配一個值。
出於效能原因,自增編號是系統批次分配給每臺 TiDB 伺服器的值(預設 3 萬個值),因此自增編號能保證唯一性,但分配給 INSERT
語句的值僅在單臺 TiDB 伺服器上具有單調性。
TiDB 實現 AUTO_INCREMENT
隱式分配的原理是,對於每一個自增列,都使用一個全域性可見的鍵值對用於記錄當前已分配的最大 ID。由於分散式環境下的節點通訊存在一定開銷,為了避免寫請求放大的問題,每個 TiDB 節點在分配 ID 時,都申請一段 ID 作為快取,用完之後再去取下一段,而不是每次分配都向儲存節點申請。例如,對於以下新建的表:
CREATE TABLE t(id int UNIQUE KEY AUTO_INCREMENT, c int);
假設叢集中有兩個 TiDB 範例 A 和 B,如果向 A 和 B 分別對 t
執行一條插入語句:
INSERT INTO t (c) VALUES (1)
範例 A 可能會快取 [1,30000]
的自增 ID,而範例 B 則可能快取 [30001,60000]
的自增 ID。各自範例快取的 ID 將隨著執行將來的插入語句被作為預設值,順序地填充到 AUTO_INCREMENT
列中。
警告
在叢集中有多個 TiDB 範例時,如果表結構中有自增 ID,建議不要混用顯式插入和隱式分配(即自增列的預設值和自定義值),否則可能會破壞隱式分配值的唯一性。
例如在上述範例中,依次執行如下操作:
id
設定為 2
的語句 INSERT INTO t VALUES (2, 1)
,並執行成功。INSERT
語句 INSERT INTO t (c) (1)
,這條語句中沒有指定 id
的值,所以會由 A 分配。當前 A 快取了 [1, 30000]
這段 ID,可能會分配 2
為自增 ID 的值,並把本地計數器加 1
。而此時資料庫中已經存在 id
為 2
的資料,最終返回 Duplicated Error
錯誤。TiDB 保證 AUTO_INCREMENT
自增值在單臺伺服器上單調遞增。以下範例在一臺伺服器上生成連續的 AUTO_INCREMENT
自增值 1
-3
:
CREATE TABLE t (a int PRIMARY KEY AUTO_INCREMENT, b timestamp NOT NULL DEFAULT NOW());
INSERT INTO t (a) VALUES (NULL), (NULL), (NULL);
SELECT * FROM t;
Query OK, 0 rows affected (0.11 sec)
Query OK, 3 rows affected (0.02 sec)
Records: 3 Duplicates: 0 Warnings: 0
+---+---------------------+
| a | b |
+---+---------------------+
| 1 | 2020-09-09 20:38:22 |
| 2 | 2020-09-09 20:38:22 |
| 3 | 2020-09-09 20:38:22 |
+---+---------------------+
3 rows in set (0.00 sec)
TiDB 能保證自增值的單調性,但並不能保證其連續性。參考以下範例:
CREATE TABLE t (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, a VARCHAR(10), cnt INT NOT NULL DEFAULT 1, UNIQUE KEY (a));
INSERT INTO t (a) VALUES ('A'), ('B');
SELECT * FROM t;
INSERT INTO t (a) VALUES ('A'), ('C') ON DUPLICATE KEY UPDATE cnt = cnt + 1;
SELECT * FROM t;
Query OK, 0 rows affected (0.00 sec)
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
+----+------+-----+
| id | a | cnt |
+----+------+-----+
| 1 | A | 1 |
| 2 | B | 1 |
+----+------+-----+
2 rows in set (0.00 sec)
Query OK, 3 rows affected (0.00 sec)
Records: 2 Duplicates: 1 Warnings: 0
+----+------+-----+
| id | a | cnt |
+----+------+-----+
| 1 | A | 2 |
| 2 | B | 1 |
| 4 | C | 1 |
+----+------+-----+
3 rows in set (0.00 sec)
在以上範例 INSERT INTO t (a) VALUES ('A'), ('C') ON DUPLICATE KEY UPDATE cnt = cnt + 1;
語句中,自增值 3
被分配為 A
鍵對應的 id
值,但實際上 3
並未作為 id
值插入進表中。這是因為該 INSERT
語句包含一個重複鍵 A
,使得自增序列不連續,出現了間隙。該行為儘管與 MySQL 不同,但仍是合法的。MySQL 在其他情況下也會出現自增序列不連續的情況,例如事務被中止和回滾時。
如果在另一臺伺服器上執行插入操作,那麼 AUTO_INCREMENT
值的順序可能會劇烈跳躍,這是由於每臺伺服器都有各自快取的 AUTO_INCREMENT
自增值。
CREATE TABLE t (a INT PRIMARY KEY AUTO_INCREMENT, b TIMESTAMP NOT NULL DEFAULT NOW());
INSERT INTO t (a) VALUES (NULL), (NULL), (NULL);
INSERT INTO t (a) VALUES (NULL);
SELECT * FROM t;
Query OK, 1 row affected (0.03 sec)
+---------+---------------------+
| a | b |
+---------+---------------------+
| 1 | 2020-09-09 20:38:22 |
| 2 | 2020-09-09 20:38:22 |
| 3 | 2020-09-09 20:38:22 |
| 2000001 | 2020-09-09 20:43:43 |
+---------+---------------------+
4 rows in set (0.00 sec)
以下範例在最先的一臺伺服器上執行一個插入 INSERT
操作,生成 AUTO_INCREMENT
值 4
。因為這臺伺服器上仍有剩餘的 AUTO_INCREMENT
快取值可用於分配。在該範例中,值的順序不具有全域性單調性:
INSERT INTO t (a) VALUES (NULL);
Query OK, 1 row affected (0.01 sec)
SELECT * FROM t ORDER BY b;
+---------+---------------------+
| a | b |
+---------+---------------------+
| 1 | 2020-09-09 20:38:22 |
| 2 | 2020-09-09 20:38:22 |
| 3 | 2020-09-09 20:38:22 |
| 2000001 | 2020-09-09 20:43:43 |
| 4 | 2020-09-09 20:44:43 |
+---------+---------------------+
5 rows in set (0.00 sec)
AUTO_INCREMENT
快取不會持久化,重啟會導致快取值失效。以下範例中,最先的一臺伺服器重啟後,向該伺服器執行一條插入操作:
INSERT INTO t (a) VALUES (NULL);
Query OK, 1 row affected (0.01 sec)
SELECT * FROM t ORDER BY b;
+---------+---------------------+
| a | b |
+---------+---------------------+
| 1 | 2020-09-09 20:38:22 |
| 2 | 2020-09-09 20:38:22 |
| 3 | 2020-09-09 20:38:22 |
| 2000001 | 2020-09-09 20:43:43 |
| 4 | 2020-09-09 20:44:43 |
| 2030001 | 2020-09-09 20:54:11 |
+---------+---------------------+
6 rows in set (0.00 sec)
TiDB 伺服器頻繁重啟可能導致 AUTO_INCREMENT
快取值被快速消耗。在以上範例中,最先的一臺伺服器本來有可用的快取值 [5-3000]
。但重啟後,這些值便丟失了,無法進行重新分配。
使用者不應指望 AUTO_INCREMENT
值保持連續。在以下範例中,一臺 TiDB 伺服器的快取值為 [2000001-2030000]
。當手動插入值 2029998
時,TiDB 取用了一個新快取區間的值:
INSERT INTO t (a) VALUES (2029998);
Query OK, 1 row affected (0.01 sec)
INSERT INTO t (a) VALUES (NULL);
Query OK, 1 row affected (0.01 sec)
INSERT INTO t (a) VALUES (NULL);
Query OK, 1 row affected (0.00 sec)
INSERT INTO t (a) VALUES (NULL);
Query OK, 1 row affected (0.02 sec)
INSERT INTO t (a) VALUES (NULL);
Query OK, 1 row affected (0.01 sec)
SELECT * FROM t ORDER BY b;
+---------+---------------------+
| a | b |
+---------+---------------------+
| 1 | 2020-09-09 20:38:22 |
| 2 | 2020-09-09 20:38:22 |
| 3 | 2020-09-09 20:38:22 |
| 2000001 | 2020-09-09 20:43:43 |
| 4 | 2020-09-09 20:44:43 |
| 2030001 | 2020-09-09 20:54:11 |
| 2029998 | 2020-09-09 21:08:11 |
| 2029999 | 2020-09-09 21:08:11 |
| 2030000 | 2020-09-09 21:08:11 |
| 2060001 | 2020-09-09 21:08:11 |
| 2060002 | 2020-09-09 21:08:11 |
+---------+---------------------+
11 rows in set (0.00 sec)
以上範例插入 2030000
後,下一個值為 2060001
,即順序出現跳躍。這是因為另一臺 TiDB 伺服器獲取了中間快取區間 [2030001-2060000]
。當部署有多臺 TiDB 伺服器時,AUTO_INCREMENT
值的順序會出現跳躍,因為對快取值的請求是交叉出現的。
TiDB 自增 ID 的快取大小在早期版本中是對使用者透明的。從 v3.1.2、v3.0.14 和 v4.0.rc-2 版本開始,TiDB 引入了 AUTO_ID_CACHE
表選項來允許使用者自主設定自增 ID 分配快取的大小。例如:
CREATE TABLE t(a int AUTO_INCREMENT key) AUTO_ID_CACHE 100;
Query OK, 0 rows affected (0.02 sec)
INSERT INTO t VALUES();
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0
SELECT * FROM t;
+---+
| a |
+---+
| 1 |
+---+
1 row in set (0.01 sec)
此時如果將該列的自增快取無效化,重新進行隱式分配:
DELETE FROM t;
Query OK, 1 row affected (0.01 sec)
RENAME TABLE t to t1;
Query OK, 0 rows affected (0.01 sec)
INSERT INTO t1 VALUES()
Query OK, 1 row affected (0.00 sec)
SELECT * FROM t;
+-----+
| a |
+-----+
| 101 |
+-----+
1 row in set (0.00 sec)
可以看到再一次分配的值為 101
,說明該表的自增 ID 分配快取的大小為 100
。
此外如果在批次插入的 INSERT
語句中所需連續 ID 長度超過 AUTO_ID_CACHE
的長度時,TiDB 會適當調大快取以便能夠保證該語句的正常插入。
從 v3.0.9 和 v4.0.rc-1 開始,和 MySQL 的行為類似,自增列隱式分配的值遵循 session 變數 @@auto_increment_increment
和 @@auto_increment_offset
的控制,其中自增列隱式分配的值 (ID) 將滿足式子 (ID - auto_increment_offset) % auto_increment_increment == 0
。
從 v6.4.0 開始,TiDB 實現了中心化分配自增 ID 的服務,可以支援 TiDB 範例不快取資料,而是每次請求都存取中心化服務獲取 ID。
當前中心化分配服務內建在 TiDB 程序,類似於 DDL Owner 的工作模式。有一個 TiDB 範例將充當「主」的角色提供 ID 分配服務,而其它的 TiDB 範例將充當「備」角色。當「主」節點發生故障時,會自動進行「主備切換」,從而保證中心化服務的高可用。
MySQL 相容模式的使用方式是,建表時將 AUTO_ID_CACHE
設定為 1
:
CREATE TABLE t(a int AUTO_INCREMENT key) AUTO_ID_CACHE 1;
注意:
在 TiDB 各個版本中,
AUTO_ID_CACHE
設定為1
都表明 TiDB 不再快取 ID,但是不同版本的實現方式不一樣:
- 對於 TiDB v6.4.0 之前的版本,由於每次分配 ID 都需要通過一個 TiKV 事務完成
AUTO_INCREMENT
值的持久化修改,因此設定AUTO_ID_CACHE
為1
會出現效能下降。- 對於 v6.4.0 及以上版本,由於引入了中心化的分配服務,
AUTO_INCREMENT
值的修改只是在 TiDB 服務程序中的一個記憶體操作,相較於之前版本更快。- 將
AUTO_ID_CACHE
設定為1
表示 TiDB 使用預設的快取大小30000
。
使用 MySQL 相容模式後,能保證 ID 唯一、單調遞增,行為幾乎跟 MySQL 完全一致。即使跨 TiDB 範例存取,ID 也不會出現回退。只有當中心化服務的「主」 TiDB 範例異常崩潰時,才有可能造成少量 ID 不連續。這是因為主備切換時,「備」 節點需要丟棄一部分之前的「主」 節點可能已經分配的 ID,以保證 ID 不出現重複。
目前在 TiDB 中使用 AUTO_INCREMENT
有以下限制:
FLOAT
或 DOUBLE
的列上。DEFAULT
同時指定在同一列上。ALTER TABLE
來新增 AUTO_INCREMENT
屬性。ALTER TABLE
來移除 AUTO_INCREMENT
屬性。但從 TiDB 2.1.18 和 3.0.4 版本開始,TiDB 通過 session 變數 @@tidb_allow_remove_auto_inc
控制是否允許通過 ALTER TABLE MODIFY
或 ALTER TABLE CHANGE
來移除列的 AUTO_INCREMENT
屬性,預設是不允許移除。ALTER TABLE
需要 FORCE
選項來將 AUTO_INCREMENT
設定為較小的值。AUTO_INCREMENT
設定為小於 MAX(<auto_increment_column>)
的值會導致重複鍵,因為預先存在的值不會被跳過。PD (Placement Driver) 是 TiDB 叢集的管理模組,同時也負責叢集資料的實時排程。
TiKV 叢集是 TiDB 資料庫的分散式 KV 儲存引擎,資料以 Region 為單位進行復制和管理,每個 Region 會有多個副本 (Replica),這些副本會分佈在不同的 TiKV 節點上,其中 Leader 負責讀/寫,Follower 負責同步 Leader 發來的 Raft log。
需要考慮以下場景:
以上問題和場景如果多個同時出現,就不太容易解決,因為需要考慮全域性資訊。同時整個系統也是在動態變化的,因此需要一箇中心節點,來對系統的整體狀況進行把控和調整,所以有了 PD 這個模組。
對以上的問題和場景進行分類和整理,可歸為以下兩類:
第一類:作為一個分散式高可用儲存系統,必須滿足的需求,包括幾種
第二類:作為一個良好的分散式系統,需要考慮的地方包括
滿足第一類需求後,整個系統將具備強大的容災功能。滿足第二類需求後,可以使得系統整體的資源利用率更高且合理,具備良好的擴充套件性。
為了滿足這些需求,首先需要收集足夠的資訊,比如每個節點的狀態、每個 Raft Group 的資訊、業務存取操作的統計等;其次需要設定一些策略,PD 根據這些資訊以及排程的策略,制定出儘量滿足前面所述需求的排程計劃;最後需要一些基本的操作,來完成排程計劃。
排程的基本操作指的是為了滿足排程的策略。上述排程需求可整理為以下三個操作:
剛好 Raft 協定通過 AddReplica
、RemoveReplica
、TransferLeader
這三個命令,可以支撐上述三種基本操作。
排程依賴於整個叢集資訊的收集,簡單來說,排程需要知道每個 TiKV 節點的狀態以及每個 Region 的狀態。TiKV 叢集會向 PD 彙報兩類訊息,TiKV 節點資訊和 Region 資訊:
每個 TiKV 節點會定期向 PD 彙報節點的狀態資訊
TiKV 節點 (Store) 與 PD 之間存在心跳包,一方面 PD 通過心跳包檢測每個 Store 是否存活,以及是否有新加入的 Store;另一方面,心跳包中也會攜帶這個 Store 的狀態資訊,主要包括:
通過使用
pd-ctl
可以檢視到 TiKV Store 的狀態資訊。TiKV Store 的狀態具體分為 Up,Disconnect,Offline,Down,Tombstone。各狀態的關係如下:
max-store-down-time
指定的時間後,該 Store 會變為 Down 狀態。max-store-down-time
指定的時間,預設 30 分鐘。超過該時間後,對應的 Store 會變為 Down,並且開始在存活的 Store 上補足各個 Region 的副本。leader_count
和 region_count
(在 PD Control 中獲取) 均顯示為 0 後,該 Store 會由 Offline 狀態變為 Tombstone 狀態。在 Offline 狀態下,禁止關閉該 Store 服務以及其所在的物理伺服器。下線過程中,如果叢集裡不存在滿足搬遷條件的其它目標 Store(例如沒有足夠的 Store 能夠繼續滿足叢集的副本數量要求),該 Store 將一直處於 Offline 狀態。remove-tombstone
介面安全地清理該狀態的 TiKV。每個 Raft Group 的 Leader 會定期向 PD 彙報 Region 的狀態資訊
每個 Raft Group 的 Leader 和 PD 之間存在心跳包,用於彙報這個 Region 的狀態,主要包括下面幾點資訊:
PD 不斷的通過這兩類心跳訊息收集整個叢集的資訊,再以這些資訊作為決策的依據。
除此之外,PD 還可以通過擴充套件的介面接受額外的資訊,用來做更準確的決策。比如當某個 Store 的心跳包中斷的時候,PD 並不能判斷這個節點是臨時失效還是永久失效,只能經過一段時間的等待(預設是 30 分鐘),如果一直沒有心跳包,就認為該 Store 已經下線,再決定需要將這個 Store 上面的 Region 都排程走。
但是有的時候,是運維人員主動將某臺機器下線,這個時候,可以通過 PD 的管理介面通知 PD 該 Store 不可用,PD 就可以馬上判斷需要將這個 Store 上面的 Region 都排程走。
PD 收集了這些資訊後,還需要一些策略來制定具體的排程計劃。
一個 Region 的副本數量正確
當 PD 通過某個 Region Leader 的心跳包發現這個 Region 的副本數量不滿足要求時,需要通過 Add/Remove Replica 操作調整副本數量。出現這種情況的可能原因是:
一個 Raft Group 中的多個副本不在同一個位置
注意這裡用的是『同一個位置』而不是『同一個節點』。在一般情況下,PD 只會保證多個副本不落在一個節點上,以避免單個節點失效導致多個副本丟失。在實際部署中,還可能出現下面這些需求:
這些需求本質上都是某一個節點具備共同的位置屬性,構成一個最小的『容錯單元』,希望這個單元內部不會存在一個 Region 的多個副本。這個時候,可以給節點設定 labels 並且通過在 PD 上設定 location-labels 來指名哪些 label 是位置標識,需要在副本分配的時候儘量保證一個 Region 的多個副本不會分佈在具有相同的位置標識的節點上。
副本在 Store 之間的分佈均勻分配
由於每個 Region 的副本中儲存的資料容量上限是固定的,通過維持每個節點上面副本數量的均衡,使得各節點間承載的資料更均衡。
Leader 數量在 Store 之間均勻分配
Raft 協定要求讀取和寫入都通過 Leader 進行,所以計算的負載主要在 Leader 上面,PD 會盡可能將 Leader 在節點間分散開。
存取熱點數量在 Store 之間均勻分配
每個 Store 以及 Region Leader 在上報資訊時攜帶了當前存取負載的資訊,比如 Key 的讀取/寫入速度。PD 會檢測出存取熱點,且將其在節點之間分散開。
各個 Store 的儲存空間佔用大致相等
每個 Store 啟動的時候都會指定一個
Capacity
引數,表明這個 Store 的儲存空間上限,PD 在做排程的時候,會考慮節點的儲存空間剩餘量。
控制排程速度,避免影響線上服務
排程操作需要耗費 CPU、記憶體、磁碟 IO 以及網路頻寬,需要避免對線上服務造成太大影響。PD 會對當前正在進行的運算元量進行控制,預設的速度控制是比較保守的,如果希望加快排程(比如停服務升級或者增加新節點,希望儘快排程),那麼可以通過調節 PD 引數動態加快排程速度。
PD 不斷地通過 Store 或者 Leader 的心跳包收集整個叢集資訊,並且根據這些資訊以及排程策略生成排程操作序列。每次收到 Region Leader 發來的心跳包時,PD 都會檢查這個 Region 是否有待進行的操作,然後通過心跳包的回覆訊息,將需要進行的操作返回給 Region Leader,並在後面的心跳包中監測執行結果。
注意這裡的操作只是給 Region Leader 的建議,並不保證一定能得到執行,具體是否會執行以及什麼時候執行,由 Region Leader 根據當前自身狀態來定。
TiKV 的一些設計思想和關鍵概念。
作為儲存資料的系統,首先要決定的是資料的儲存模型,也就是資料以什麼樣的形式儲存下來。TiKV 的選擇是 Key-Value 模型,並且提供有序遍歷方法。
TiKV 資料儲存的兩個關鍵點:
注意, TiKV 的 KV 儲存模型和 SQL 中的 Table 無關。
任何持久化的儲存引擎,資料終歸要儲存在磁碟上,TiKV 也不例外。但是 TiKV 沒有選擇直接向磁碟上寫資料,而是把資料儲存在 RocksDB 中,具體的資料落地由 RocksDB 負責。這個選擇的原因是開發一個單機儲存引擎工作量很大,特別是要做一個高效能的單機引擎,需要做各種細緻的優化,而 RocksDB 是由 Facebook 開源的一個非常優秀的單機 KV 儲存引擎,可以滿足 TiKV 對單機引擎的各種要求。這裡可以簡單的認為 RocksDB 是一個單機的持久化 Key-Value Map。
傳統B+Tree 資料結構
wal log 預寫紀錄檔 不經過系統快取,直接寫入磁碟
查詢 分配key, bloom
LSM樹和B+Tree 最大的不同在於資料更新的方式,在B+Tree 中,資料的更新是直接在原資料所在的位置進行修改,而LSM樹中,資料的更新是通過追加紀錄檔形式完成的。這種追加方式使得LSM樹可以順序寫,避免了頻繁的隨機寫,從而提高了寫的效能。
在LSM樹中,資料被儲存在不同的層次中,每個層次對應一組SSTable檔案。當MemTable中的資料達到一定的大小時,會被刷寫(flush)到磁碟上,生成一個新的SSTable檔案。由於SSTable檔案是不可變的,因此所有的更新都被追加到新的SSTable檔案中,而不是在原有的檔案中進行修改。
這種追加式的更新方式會導致資料冗餘的問題,即某個Key在不同的SSTable檔案中可能存在多個版本。這些版本中,只有最新的版本是有效的,其他的版本都是冗餘的。為了解決這個問題,需要定期進行SSTable的合併(Compaction)操作,將不同的SSTable檔案中相同Key的資料進行合併,並將舊版本的資料刪除,從而減少冗餘資料的儲存空間。
LSM樹壓縮策略需要圍繞三個問題進行考量(讀放大、寫放大、空間放大):
- 讀放大(Read Amplification)是指在讀取資料時,需要讀取的資料量大於實際的資料量。在LSM樹中,需要先在MemTable中檢視是否存在該key,如果不存在,則需要繼續在SSTable中查詢,直到找到為止。如果資料被分散在多個SSTable中,則需要遍歷所有的SSTable,這就導致了讀放大。如果資料分佈比較均勻,則讀放大不會很嚴重,但如果資料分佈不均,則可能需要遍歷大量的SSTable才能找到目標資料。
- 寫放大(Write Amplification)是指在寫入資料時,實際寫入的資料量大於真正的資料量。在LSM樹中,寫入資料時可能會觸發Compact操作,這會導致一些SSTable中的冗餘資料被清理回收,但同時也會產生新的SSTable,因此實際寫入的資料量可能遠大於該key的資料量。
- 空間放大(Space Amplification)是指資料實際佔用的磁碟空間比資料的真正大小更多。在LSM樹中,由於資料的更新是以紀錄檔形式進行的,因此同一個key可能在多個SSTable中都存在,而只有最新的那條記錄是有效的,之前的記錄都可以被清理回收。這就導致了空間的浪費,也就是空間放大。為了減少空間浪費,LSM樹需要定期進行Compact操作,將多個SSTable中相同的key進行合併,去除冗餘資料,減少磁碟空間的佔用。
TiKV如何做到高並行讀寫
TIDB如何保證多副本一致性和高可用
http://thesecretlivesofdata.com/raft/
接下來 TiKV 的實現面臨一件更難的事情:如何保證單機失效的情況下,資料不丟失,不出錯?
簡單來說,需要想辦法把資料複製到多臺機器上,這樣一臺機器無法服務了,其他的機器上的副本還能提供服務;複雜來說,還需要這個資料複製方案是可靠和高效的,並且能處理副本失效的情況。TiKV 選擇了 Raft 演演算法。Raft 是一個一致性協定,本文只會對 Raft 做一個簡要的介紹,細節問題可以參考它的論文。Raft 提供幾個重要的功能:
Leader(主副本)選舉
成員變更(如新增副本、刪除副本、轉移 Leader 等操作)
紀錄檔複製
TiKV 利用 Raft 來做資料複製,每個資料變更都會落地為一條 Raft 紀錄檔,通過 Raft 的紀錄檔複製功能,將資料安全可靠地同步到複製組的每一個節點中。不過在實際寫入中,根據 Raft 的協定,只需要同步複製到多數節點,即可安全地認為資料寫入成功。
總結一下,通過單機的 RocksDB,TiKV 可以將資料快速地儲存在磁碟上;通過 Raft,將資料複製到多臺機器上,以防單機失效。資料的寫入是通過 Raft 這一層的介面寫入,而不是直接寫 RocksDB。通過實現 Raft,TiKV 變成了一個分散式的 Key-Value 儲存,少數幾臺機器宕機也能通過原生的 Raft 協定自動把副本補全,可以做到對業務無感知。
首先,假設所有的資料都只有一個副本。前面提到,TiKV 可以看做是一個巨大的有序的 KV Map,那麼為了實現儲存的水平擴充套件,資料將被分散在多臺機器上。對於一個 KV 系統,將資料分散在多臺機器上有兩種比較典型的方案:
TiKV 選擇了第二種方式,將整個 Key-Value 空間分成很多段,每一段是一系列連續的 Key,將每一段叫做一個 Region,可以用 [StartKey,EndKey) 這樣一個左閉右開區間來描述。每個 Region 中儲存的資料量預設維持在 96 MiB 左右(可以通過設定修改)。
注意,這裡的 Region 還是和 SQL 中的表沒什麼關係。 這裡的討論依然不涉及 SQL,只和 KV 有關。
將資料劃分成 Region 後,TiKV 將會做兩件重要的事情:
- 以 Region 為單位,將資料分散在叢集中所有的節點上,並且儘量保證每個節點上服務的 Region 數量差不多。
- 以 Region 為單位做 Raft 的複製和成員管理。
這兩點非常重要:
- 先看第一點,資料按照 Key 切分成很多 Region,每個 Region 的資料只會儲存在一個節點上面(暫不考慮多副本)。TiDB 系統會有一個元件 (PD) 來負責將 Region 儘可能均勻的散佈在叢集中所有的節點上,這樣一方面實現了儲存容量的水平擴充套件(增加新的節點後,會自動將其他節點上的 Region 排程過來),另一方面也實現了負載均衡(不會出現某個節點有很多資料,其他節點上沒什麼資料的情況)。同時為了保證上層使用者端能夠存取所需要的資料,系統中也會有一個元件 (PD) 記錄 Region 在節點上面的分佈情況,也就是通過任意一個 Key 就能查詢到這個 Key 在哪個 Region 中,以及這個 Region 目前在哪個節點上(即 Key 的位置路由資訊)。
- 對於第二點,TiKV 是以 Region 為單位做資料的複製,也就是一個 Region 的資料會儲存多個副本,TiKV 將每一個副本叫做一個 Replica。Replica 之間是通過 Raft 來保持資料的一致,一個 Region 的多個 Replica 會儲存在不同的節點上,構成一個 Raft Group。其中一個 Replica 會作為這個 Group 的 Leader,其他的 Replica 作為 Follower。預設情況下,所有的讀和寫都是通過 Leader 進行,讀操作在 Leader 上即可完成,而寫操作再由 Leader 複製給 Follower。
大家理解了 Region 之後,應該可以理解下面這張圖:
以 Region 為單位做資料的分散和複製,TiKV 就成為了一個分散式的具備一定容災能力的 KeyValue 系統,不用再擔心資料存不下,或者是磁碟故障丟失資料的問題。
很多資料庫都會實現多版本並行控制 (MVCC),TiKV 也不例外。設想這樣的場景:兩個使用者端同時去修改一個 Key 的 Value,如果沒有資料的多版本控制,就需要對資料上鎖,在分散式場景下,可能會帶來效能以及死鎖問題。
TiKV 的 MVCC 實現是通過在 Key 後面新增版本號來實現,簡單來說,沒有 MVCC 之前,可以把 TiKV 看做這樣的:
Key1 -> Value
Key2 -> Value
……
KeyN -> Value
有了 MVCC 之後,TiKV 的 Key 排列是這樣的:
Key1_Version3 -> Value
Key1_Version2 -> Value
Key1_Version1 -> Value
……
Key2_Version4 -> Value
Key2_Version3 -> Value
Key2_Version2 -> Value
Key2_Version1 -> Value
……
KeyN_Version2 -> Value
KeyN_Version1 -> Value
……
注意,對於同一個 Key 的多個版本,版本號較大的會被放在前面,版本號小的會被放在後面(見 Key-Value 一節,Key 是有序的排列),這樣當用戶通過一個 Key + Version 來獲取 Value 的時候,可以通過 Key 和 Version 構造出 MVCC 的 Key,也就是 Key_Version。然後可以直接通過 RocksDB 的 SeekPrefix(Key_Version) API,定位到第一個大於等於這個 Key_Version 的位置。
TiKV 的事務採用的是 Google 在 BigTable 中使用的事務模型:Percolator,TiKV 根據這篇論文實現,並做了大量的優化。詳細介紹參見事務概覽。
運算元是為返回查詢結果而執行的特定步驟。真正執行掃表(讀盤或者讀 TiKV Block Cache)操作的運算元有如下幾類:
TiDB 會匯聚 TiKV/TiFlash 上掃描的資料或者計算結果,這種「資料匯聚」運算元目前有如下幾類:
RowID
精確地讀取 TiKV 上的資料。Build 端是 IndexFullScan
或 IndexRangeScan
型別的運算元,Probe 端是 TableRowIDScan
型別的運算元。IndexLookupReader
類似,可以看做是它的擴充套件,可以同時讀取多個索引的資料,有多個 Build 端,一個 Probe 端。執行過程也很類似,先彙總所有 Build 端 TiKV 掃描上來的 RowID,再去 Probe 端上根據這些 RowID 精確地讀取 TiKV 上的資料。Build 端是 IndexFullScan
或 IndexRangeScan
型別的運算元,Probe 端是 TableRowIDScan
型別的運算元。運算元的結構是樹狀的,但在查詢執行過程中,並不嚴格要求子節點任務在父節點之前完成。TiDB 支援同一查詢內的並行處理,即子節點「流入」父節點。父節點、子節點和同級節點可能並行執行查詢的一部分。
在以上範例中,├─IndexRangeScan_8(Build)
運算元為 a(a)
索引所匹配的行查詢內部 RowID。└─TableRowIDScan_9(Probe)
運算元隨後從表中檢索這些行。
Build 總是先於 Probe 執行,並且 Build 總是出現在 Probe 前面。即如果一個運算元有多個子節點,子節點 ID 後面有 Build 關鍵字的運算元總是先於有 Probe 關鍵字的運算元執行。TiDB 在展現執行計劃的時候,Build 端總是第一個出現,接著才是 Probe 端。
在 WHERE
/HAVING
/ON
條件中,TiDB 優化器會分析主鍵或索引鍵的查詢返回。如數位、日期型別的比較符,如大於、小於、等於以及大於等於、小於等於,字元型別的 LIKE
符號等。
若要使用索引,條件必須是 "Sargable" (Search ARGument ABLE) 的。例如條件 YEAR(date_column) < 1992
不能使用索引,但 date_column < '1992-01-01
就可以使用索引。
推薦使用同一型別的資料以及同一型別的字串和排序規則進行比較,以避免引入額外的 cast
操作而導致不能利用索引。
可以在範圍查詢條件中使用 AND
(求交集)和 OR
(求並集)進行組合。對於多維組合索引,可以對多個列使用條件。例如對組合索引 (a, b, c)
:
a
為等值查詢時,可以繼續求 b
的查詢範圍。b
也為等值查詢時,可以繼續求 c
的查詢範圍。a
為非等值查詢,則只能求 a
的範圍。目前 TiDB 的計算任務分為兩種不同的 task:cop task 和 root task。Cop task 是指使用 TiKV 中的 Coprocessor 執行的計算任務,root task 是指在 TiDB 中執行的計算任務。
SQL 優化的目標之一是將計算儘可能地下推到 TiKV 中執行。TiKV 中的 Coprocessor 能支援大部分 SQL 內建函數(包括聚合函數和標量函數)、SQL LIMIT
操作、索引掃描和表掃描。
operator info
結果EXPLAIN
返回結果中 operator info
列可顯示諸如條件下推等資訊。本文以上範例中,operator info
結果各欄位解釋如下:
range: [1,1]
表示查詢的 WHERE
字句 (a = 1
) 被下推到了 TiKV,對應的 task 為 cop[tikv]
。keep order:false
表示該查詢的語意不需要 TiKV 按順序返回結果。如果查詢指定了排序(例如 SELECT * FROM t WHERE a = 1 ORDER BY id
),該欄位的返回結果為 keep order:true
。stats:pseudo
表示 estRows
顯示的預估數可能不準確。TiDB 定期在後臺更新統計資訊。也可以通過執行 ANALYZE TABLE t
來手動更新統計資訊。EXPLAIN
執行後,不同運算元返回不同的資訊。你可以使用 Optimizer Hints 來控制優化器的行為,以此控制物理運算元的選擇。例如 /*+ HASH_JOIN(t1, t2) */
表示優化器將使用 Hash Join 演演算法。詳細內容見 Optimizer Hints。
關鍵模組
邏輯優化
- 列剪裁
- 分割區剪裁
- 聚合消除
- Max/Min 優化
- 投影消除
- 外連線消除
- 子查詢關聯
- 運算元下推
- 外連線轉內連線
- 謂詞下推
- 連線順序調整
Max/Min 優化有序
Max/Min 優化無序
外連線轉內連線 & 謂詞下推
連線順序調整
物理優化
統計資訊
CREATE TABLE `books` (
`id` bigint(20) AUTO_RANDOM NOT NULL,
`title` varchar(100) NOT NULL,
`type` enum('Magazine', 'Novel', 'Life', 'Arts', 'Comics', 'Education & Reference', 'Humanities & Social Sciences', 'Science & Technology', 'Kids', 'Sports') NOT NULL,
`published_at` datetime NOT NULL,
`stock` int(11) DEFAULT '0',
`price` decimal(15,2) DEFAULT '0.0',
PRIMARY KEY (`id`) CLUSTERED
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
SELECT * FROM t where c1 = 10 and c2 = 100 and c3 > 10
,那麼可以考慮建立組合索引 Index cidx (c1, c2, c3)
,這樣可以用查詢條件構造出一個索引字首進行 Scan。建立索引的目的是為了加速查詢,所以請確保索引能在一些查詢中被用上。如果一個索引不會被任何查詢語句用到,那這個索引是沒有意義的,請刪除這個索引。
使用組合索引時,需要滿足最左字首原則。
例如假設在列 title, published_at
上新建一個組合索引索引:
CREATE INDEX title_published_at_idx ON books (title, published_at);
下面這個查詢依然能用上這個組合索引:
SELECT * FROM books WHERE title = 'database';
但下面這個查詢由於未指定組合索引中最左邊第一列的條件,所以無法使用組合索引:
SELECT * FROM books WHERE published_at = '2018-08-18 21:42:08';
在查詢條件中使用索引列作為條件時,不要在索引列上做計算,函數,或者型別轉換的操作,會導致優化器無法使用該索引。
例如假設在時間型別的列 published_at
上新建一個索引:
CREATE INDEX published_at_idx ON books (published_at);
但下面查詢是無法使用 published_at
上的索引的:
SELECT * FROM books WHERE YEAR(published_at)=2022;
可以改寫成下面查詢,避免在索引列上做函數計算後,即可使用 published_at
上的索引:
SELECT * FROM books WHERE published_at >= '2022-01-01' AND published_at < '2023-01-01';
也可以使用表示式索引,例如對查詢條件中的 YEAR(published_at)
建立一個表示式索引:
CREATE INDEX published_year_idx ON books ((YEAR(published_at)));
然後通過 SELECT * FROM books WHERE YEAR(published_at)=2022;
查詢就能使用 published_year_idx
索引來加速查詢了。
注意
表示式索引目前是 TiDB 的實驗特性,需要在 TiDB 組態檔中開啟表示式索引特性,詳情可以參考表示式索引檔案。
儘量使用覆蓋索引,即索引列包含查詢列,避免總是 SELECT *
查詢所有列的語句。
例如下面查詢只需掃描索引 title_published_at_idx
資料即可獲取查詢列的資料:
SELECT title, published_at FROM books WHERE title = 'database';
但下面查詢語句雖然能用上組合索引 (title, published_at)
,但會多一個回表查詢非索引列資料的額外開銷,回表查詢是指根據索引資料中儲存的參照(一般是主鍵資訊),到表中查詢相應行的資料。
SELECT * FROM books WHERE title = 'database';
查詢條件使用 !=
,NOT IN
時,無法使用索引。例如下面查詢無法使用任何索引:
SELECT * FROM books WHERE title != 'database';
使用 LIKE
時如果條件是以萬用字元 %
開頭,也無法使用索引。例如下面查詢無法使用任何索引:
SELECT * FROM books WHERE title LIKE '%database';
當查詢條件有多個索引可供使用,但你知道用哪一個索引是最優的時,推薦使用優化器 Hint 來強制優化器使用這個索引,這樣可以避免優化器因為統計資訊不準或其他問題時,選錯索引。
例如下面查詢中,假設在列 id
和列 title
上都各自有索引 id_idx
和 title_idx
,你知道 id_idx
的過濾性更好,就可以在 SQL 中使用 USE INDEX
Hint 來強制優化器使用 id_idx
索引。
SELECT * FROM t USE INDEX(id_idx) WHERE id = 1 and title = 'database';
查詢條件使用 IN
表示式時,後面匹配的條件數量建議不要超過 300 個,否則執行效率會較差。