TiDB Online DDL 在 TiCDC 中的應用丨TiDB 工具分享

PingCAP發表於2022-03-03

引言
TiCDC 作為 TiDB 的資料同步元件,負責直接從 TiKV 感知資料變更同步到下游。其中比較核心的問題是資料解析正確性問題,具體而言就是如何使用正確的 schema 解析 TiKV 傳遞過來的 Key-Value 資料,從而還原成正確的 SQL 或者其他下游支援的形式。本文主要通過對 TiDB Online DDL 機制原理和實現的分析,引出對當前 TiCDC 資料解析實現的討論。

背景和問題
資料同步元件是資料庫生態中不可或缺的生態工具,比較知名的開源單機資料庫 MySQL 就將資料同步作為 Server 能力的一部分,並基於 MySQL binlog 實現非同步/半同步/同步的主從複製。由於 MySQL 悲觀事務模型和表後設資料鎖的存在,我們總是可以認為 MySQL binlog 中存在因果關係的 data 和 schema 符合時間先後順序的,即:

New data commitTs > New schema commitTs

但是對於 TiDB 這種儲存計算分離的架構而言,schema 的變更在儲存層持久化,服務層節點作為多快取節點,總是存在一個 schema 狀態不一致的時間段。為了保證資料一致性和實現線上 DDL 變更,現有的分散式資料庫大都採用或者借鑑了 Online, Asynchronous Schema Change in F1 機制。所以我們要回答的問題變成了,在 TiDB Online DDL 機制下,TiCDC 如何正確處理 data 和 schema 的對應關係,存在因果關係的 data 和 schema 是否仍然滿足:

New data commitTs > New schema commitTs

為了回答這個問題,我們首先需要先闡述原始的 F1 Online Schema Change 機制的核心原理,然後描述當前 TiDB Online DDL 實現,最後我們討論在當前 TiCDC 實現下,data 和 schema 的處理關係和可能出現的不同的異常場景。

F1 Online Schema Change 機制
F1 Online Schema Change 機制要解決的核心問題是,在單儲存多快取節點的架構下,如何實現滿足資料一致性的 Online Schema 變更,如圖 1 所示:

1.jpg

圖 1: 單儲存多快取節點的架構下的 schema 變更
這裡我們定義資料不一致問題為資料多餘(orphan data anomaly)和資料缺失(integrity anomaly),Schema 變更結束後出現資料多餘和資料缺失我們就認為資料不一致了。這類系統的 schema 變更問題特點可以總結成以下 3 點:

一份 schema 儲存,多份 schema 快取

部分 new schema 和 old schema 無法共存

直接從 old schema 變更到 new schema 時,總是存在一個時間區間兩者同時存在

特點 1 和特點 3 是系統架構導致的,比較容易理解。特點 2 的一個典型例子是 add index,載入了 new schema 的服務層節點插入資料時會同時插入索引,而載入了 old schema 的服務層節點執行刪除操作只會刪除資料,導致出現了沒有指向的索引, 出現資料多餘。

Schema 變更問題的特點 2 和特點 3 看起來是互相矛盾的死結,new schema 和 old schema 無法共存,但又必然共存。而 F1 Online Schema 機制提供的解決方案也很巧妙,改變不了結果就改變條件。所以該論文的解決思路上主要有 2 點,如圖 2 所示:

2.jpg

圖 2: F1 Online DDL 解決方案

  1. 引入共存的中間 schema 狀態,比如 S1->S2’->S2, S1 和 S2’ 可以共存,S2’ 和 S2 可以共存;
  2. 引入確定的隔離時間區間,保證無法共存的 schema 不會同時出現;

具體來講:

引入共存的中間 schema 狀態
因為直接從 schema S1 變更到 schema S2 會導致資料不一致的問題,所以引入了 delete-only 和 write-only 中間狀態,從 S1 -> S2 過程變成 S1 -> S2+delete-only -> S2+write-only -> S2 過程,同時使用 lease 機制保證同時最多有 2 個狀態共存。這時只需要證明每相臨的兩個狀態都是可以共存的,保證資料一致性,就能推匯出 S1 到 S2 變更過程中資料是一致的。

引入確定的隔離時間區間
定義 schema lease,超過 lease 時長後節點需要重新載入 schema,載入時超過 lease 之後沒法獲取 new schema 的節點直接下線,不提供服務。所以可以明確定義 2 倍 lease 時間之後,所有節點都會更新到下一個的 schema。

引入共存的中間狀態
我們需要引入什麼樣的中間狀態呢?那要看我們需要解決什麼問題。這裡我們仍然使用 add index 這個 DDL 作為例子,其他 DDL 細節可以查閱 Online, Asynchronous Schema Change in F1 。

Delete-only 狀態
我們可以看到 old schema 是無法看到索引資訊的,所以會導致出現刪除資料,遺留沒有指向的索引這種資料多餘的異常場景,所以我們要引入的第一個中間狀態是 delete-only 狀態,賦予 schema 刪除索引的能力。在 delete-only 狀態下,schema 只能在 delete 操作的時候對索引進行刪除,在 insert/select 操作的時候無法操作索引,如圖 3 所示:

3.jpg

圖 3: 引入 delete-only 中間狀態
原始論文對於 delete-only 的定義如下:
4.jpg

假設我們已經引入了明確的隔離時間區間(下一個小節會細講),能保證同一時刻最多隻出現 2 個 schema 狀態。所以當我們引入 delete-only 狀態之後,需要考慮的場景就變成:

old schema + new schema(delete-only)

new schema(delete-only) + new schema

對於場景 1,所有的服務層節點要麼處於 old schema 狀態,要麼處於 new schema(delete-only) 狀態。由於 index 只能在 delete 的時候被操作,所以根本沒有 index 生成,就不會出現前面說的遺留沒有指向的索引問題,也不會有資料缺失問題,此時資料是一致的。我們可以說 old schema 和 new schema(delete-only) 是可以共存的。

對於場景 2,所有的服務層節點要麼處於 new schema(delete-only) 狀態,要麼處於 new schema 狀態。處於 new schema 狀態的節點可以正常插入刪除資料和索引,處於 new schema( delete-only) 狀態的節點只能插入資料,但是可以刪除資料和索引,此時存在部分資料缺少索引問題,資料是不一致的。

引入 delete-only 狀態之後,已經解決了之前提到的索引多餘的問題,但是可以發現,處於 new schema( delete-only) 狀態的節點只能插入資料,導致新插入的資料和存量歷史資料都缺少索引資訊,仍然存在資料缺失的資料不一致問題。

Write-only 狀態
在場景 2 中我們可以看到,對於 add index 這種場景,處於 new schema( delete-only) 狀態節點插入的資料和存量資料都存在索引缺失的問題。而存量資料本身數量是確定且有限的,總可以在有限的時間內根據資料生成索引,但是 new insert 的資料卻可能隨時間不斷增加。為了解決這個資料缺失的問題,我們還需要引入第二個中間狀態 write-only 狀態,賦予 schema insert/delete 索引的能力。處於 write-only 狀態的節點可以 insert/delete/update 索引,但是 select 無法看到索引,如圖 4 所示:
5.jpg
圖 4: 引入 write-only 狀態
原始論文中對於 write-only 狀態的定義如下:

6.jpg
引入 write-only 狀態之後,上述的場景 2 被切分成了場景 2‘ 和場景 3:

2’: new schema(delete-only) + new schema(write-only)

3: new schema(write-only) + new schema

對於場景 2‘,所有的服務層節點要麼處於 new schema(delete-only) 狀態,要麼處於 new schema(write-only) 。處於 new schema(delete-only) 狀態的服務層節點只能插入資料,但是可以刪除資料和索引,處於 new schema(write-only) 可以正常插入和刪除資料和索引。此時仍然存在索引缺失的問題,但是由於 delete-only 和 write-only 狀態下,索引對於使用者都是不可見的,所以在使用者的視角上,只存在完整的資料,不存在任何索引,所以內部的索引缺失對使用者而言還是滿足資料一致性的。

對於場景 3,所有的服務層節點要麼處於 new schema(write-only) 狀態,要麼處於 new schema。此時 new insert 的資料都能正常維護索引,而存量歷史資料仍然存在缺失索引的問題。但是存量歷史資料是確定且有限的,我們只需要在所有節點過渡到 write-only 之後,進行歷史資料索引補全,再過渡到 new schema 狀態,就可以保證資料和索引都是完整的。此時處於 write-only 狀態的節點只能看到完整的資料,而 new schema 狀態的節點能看到完整的資料和索引,所以對於使用者而言資料都是一致的。

小節總結
通過上面對 delete-only 和 write-only 這兩個中間狀態的表述,我們可以看到,在 F1 Online DDL 流程中,原來的單步 schema 變更被兩個中間狀態分隔開了。每兩個狀態之間都是可以共存的,每次狀態變更都能保證資料一致性,全流程的資料變更也能保證資料一致性。

7.jpg
引入確定的隔離時間區間
為了保證同一時刻最多隻能存在 2 種狀態,需要約定服務層節點載入 schema 的行為:

所有的服務層節點在 lease 之後都需要重新載入 schema;

如果在 lease 時間內無法獲取 new schema,則下線拒絕服務;

通過對服務層節點載入行為的約定,我們可以得到一個確定的時間邊界,在 2*lease 的時間週期之後,所有正常工作的服務層節點都能從 schema state1 過渡到 schema state2, 如圖 5 所示:
8.jpg
圖 5: 最多 2*lease 時長後所有的節點都能過渡到下一個狀態
中間狀態可見性
要正確理解原始論文的中間狀態,需要正確理解中間狀態的可見性問題。前面小節為了方便我們一直使用 add index 作為例子,然後表述 delete-only 和 write-only 狀態下索引對於使用者 select 是不可見的,但是 write-only 狀態下,delete/insert 都是可以操作索引的。如果 DDL 換成 add column,那節點處於 write-only 狀態時,使用者 insert 顯式指定新增列可以執行成功嗎?答案是不能。

總得來說,中間狀態的 delete/insert 可見性是內部可見性,具體而言是服務層節點對儲存層節點的可見性,而不是使用者可見性。對於 add column 這個 DDL,服務層節點在 delete-only 和 write-only 狀態下就能看到 new column,但是操作受到不同的限制。對使用者而言,只有到 new schema 狀態下才能看到 new column,才能顯式操作 new column,如圖 6 所示:

9.jpg
圖 6: 中間狀態可見性
為了清晰表述可見性,我們舉個例子,如圖 7 所示。原始的表列資訊為 , DDL 操作之後表列資訊為 <c1,c2>。
10.jpg
11.jpg
圖 7: 中間狀態過渡
小圖 (1) 中,服務層節點已經過渡到了場景 1,部分節點處於 old schema 狀態,部分節點處於 new schema(delete-only) 狀態。此時 c2 對使用者是不可見的,不管是 insert<c1,c2> 還是 delete<c1,c2> 的顯式指定 c2 都是失敗的。但是儲存層如果存在 [1,xxx] 這樣的資料是可以順利刪除的,只能插入 [7] 這樣的缺失 c2 的行資料。

小圖 (2) 中,服務層節點已經過渡到了場景 2,部分節點處於 new schema(delete-only) 狀態,部分節點處於 new schema(write-only) 狀態,此時 c2 對使用者仍是不可見的,不管是 insert<c1,c2> 還是 delete<c1,c2> 的顯式指定 c2 都是失敗的。但是處於 write-only 狀態的節點,insert [9] 在內部會被預設值填充成 [9,0] 插入儲存層。處於 delete-only 狀態的節點,delete [9] 會被轉成 delete [9,0]。

小圖 (3) 中,服務層所有節點都過渡到 write-only 之後,c2 對使用者仍是不可見的。此時開始進行資料填充,將歷史資料中缺失 c2 的行進行填充(實現時可能只是在表的列資訊中打上一個標記,取決於具體的實現)。

小圖 (4) 中,開始過渡到場景 3,部分節點處於 new schema(write-only) 狀態,部分節點處於 new schema 狀態。處於 new schema(write-only) 狀態的節點,c2 對使用者仍是不可見的。處於 new schema 狀態的節點,c2 對使用者可見。此時連線在不同服務層節點上的使用者,可以看到不同的的 select 結果,不過底層的資料是完整且一致的。

總結
上面我們通過 3 個小節對 F1 online Schema 機制進行了簡要描述。原來單步 schema 變更被拆解成了多箇中間變更流程,從而保證資料一致性的前提下實現了線上 DDL 變更。

12.jpg
對於 add index 或者 add column DDL 是上述的狀態變更,對於 drop index 或者 drop column 則是完全相反的過程。比如 drop column 在 write-only 階段及之後對使用者都不可見了,內部可以正確 insert/delete,可見性和之前的論述完全一樣。

TiDB Online DDL 實現
TiDB Online DDL 是基於 F1 Online Schema 實現的,整體流程如圖 8 所示:

13.jpg
圖 8 TiDB Online DDL 流程
簡單描述如下:

TiDB Server 節點收到 DDL 變更時,將 DDL SQL 包裝成 DDL job 提交到 TIKV job queue 中持久化;

TiDB Server 節點選舉出 Owner 角色,從 TiKV job queue 中獲取 DDL job,負責具體執行 DDL 的多階段變更;

DDL 的每個中間狀態(delete-only/write-only/write-reorg)都是一次事務提交,持久化到 TiKV job queue 中;

Schema 變更成功之後,DDL job state 會變更成 done/sync,表示 new schema 正式被使用者看到,其他 job state 比如 cancelled/rollback done 等表示 schema 變更失敗;

Schema state 的變更過程中使用了 etcd 的訂閱通知機制,加快 server 層各節點間 schema state 同步,縮短 2*lease 的變更時間。

DDL job 處於 done/sync 狀態之後,表示該 DDL 變更已經結束,移動到 job history queue 中;

詳細的 TiDB 處理流程可以參見: schema-change-implement.md 和 TiDB ddl.html

TiCDC 中 Data 和 Schema 處理關係
前面我們分別描述了 TiDB Online DDL 機制的原理和實現,現在我們可以回到最一開始我們提出的問題:在 TiDB Online DDL 機制下,是否還能滿足:

New data commitTs > New schema commitTs

答案是否定的。在前面 F1 Online Schema 機制的描述中,我們可以看到在 add column DDL 的場景下,當服務層節點處於 write-only 狀態時,節點已經能夠插入 new column data 了,但是此時 new column 還沒有處於使用者可見的狀態,也就是出現了 New data commitTs < New schema commitTs,或者說上述結論變成了:

New data commitTs > New schema(write-only) commitTs

但是由於在 delete-only + write-only 過渡狀態下,TiCDC 直接使用 New schema(write-only) 作為解析的 schema,可能導致 delete-only 節點 insert 的資料無法找到對應的 column 元資訊或者元資訊型別不匹配,導致資料丟失。所以為了保證資料正確解析,可能需要根據不同的 DDL 型別和具體的 TiDB 內部實現,在內部維護複雜的 schema 策略。

在當前 TiCDC 實現中,選擇了比較簡單的 schema 策略,直接忽略了各個中間狀態,只使用變更完成之後的 schema 狀態。為了更好表述在 TIDB Online DDL 機制下,當前 TiCDC 需要處理的不同場景,我們使用象限圖進行進一步歸類描述。
14.jpg
Old schema New schema
Old schema data 1 2
New schema data 3 4
1 對應 old schema 狀態

此時 old schema data 和 old schema 是對應的

4 對應 new schema public 及之後

此時 new schema data 和 new schema 是對應的;

3 對應 write-only ~ public 之間資料

此時 TiCDC 使用 old schema 解析資料,但是處於 write-only 狀態的 TiDB 節點已經可以基於 new schema insert/update/delete 部分資料,所以 TiCDC 會收到 new schema data。不同 DDL 處理效果不同,我們選取 3 個常見有代表性的 DDL 舉例。

add column: 狀態變更 absent -> delete-only -> write-only -> write-reorg -> public。由於 new schema data 是 TiDB 節點在 write-only 狀態下填充的預設值,所以使用 old schema 解析後會被直接丟棄,下游執行 new schema DDL 的時候會再次填充預設值。對於動態生成的資料型別,比如 auto_increment 和 current timestamp,可能會導致上下游資料不一致。
change column:有損狀態變更 absent -> delete-only -> write-only -> write-reorg -> public, 比如 int 轉 double,編碼方式不同需要資料重做。在 TiDB 實現中,有損 modify column 會生成不可見 new column,中間狀態下會同時變更新舊 column。對於 TiCDC 而言,只會處理 old column 下發,然後在下游執行 change column,這個和 TiDB 的處理邏輯保持一致。
drop column:狀態變更 absent-> write-only -> delete-only -> delete-reorg -> public。write-only 狀態下新插入的資料已經沒有了對應的 column,TiCDC 會填充預設值然後下發到下游,下游執行 drop column 之後會丟棄掉該列。使用者可能看到預期外的預設值,但是資料能滿足最終一致性。
2 對應直接從 old schema -> new schema

說明這類 schema 變更下,old schema 和 new schema 是可以共存的,不需要中間狀態,比如 truncate table DDL。TiDB 執行 truncate table 成功後,服務層節點可能還沒有載入 new schema,還可以往表中插入資料,這些資料會被 TiCDC 直接根據 tableid 過濾掉,最終上下游都是沒有這個表存在的,滿足最終一致性。

總結
TiCDC 作為 TiDB 的資料同步元件,資料解析正確性問題是保證上下游資料一致性的核心問題。為了能充分理解 TiCDC 處理 data 和 schema 過程中遇到的各種異常場景,本文首先從 F1 Online Schema Change 原理出發,詳細描述在 schema 變更各個階段的資料行為,然後簡單描述了當前 TiDB Online DDL 的實現。最後引出在當前 TiCDC 實現下在 data 和 schema 處理關係上的討論。

相關文章