TiFlink:使用 TiKV 和 Flink 實現強一致的物化檢視丨TiDB Hackathon 專案分享

PingCAP發表於2021-11-04

編者按:

本文為 TiDB Hackathon 2020 比賽中 TiFlink 專案最新進展的介紹,使用 TiKV 和 Flink 實現了強一致的物化檢視的功能。

作者張茄子,演算法、分散式技術和函數語言程式設計愛好者。個人部落格:https://io-meter.com/

在本年初的 TiDB Hackathon 上,我和一眾隊友嘗試使用 Flink 為 TiDB 新增物化檢視功能,並摘得了 “最佳人氣獎”。可以說,物化檢視在這屆比賽中可謂是一個熱點。單單是結合 Flink 實現相關功能的隊伍就有三四個。必須承認的是,在比賽結束時我們專案的完成度很低,雖然基本思路已經定型,最終呈現的結果卻遠沒達到預期。經過半年多斷斷續續的修補,在今天終於可以釋出一個預覽版本給大家試用。這篇文章就是對我們思路和成果的一個介紹。
相比其他隊伍,我們的主要目標是實現強一致的物化檢視構建。也就是保證查詢時的物化檢視可以達到接近快照隔離(Snapshot Isolation)的隔離級別,而不是一般流處理系統的最終一致性(Eventual Consistency)。關於實現一致性的討論在下文有詳細介紹。

使用簡介

儘管是一個實驗性的專案,我們仍然探索了一些方便實用的特性,包括:

零外部依賴:除了 TiDB 叢集和 Flink 部署環境之外,無需維護任何其他元件(包括 Kafka 叢集和 TiCDC)。這是因為 TiFlink 直接從 TiKV 讀寫資料,不經過任何中間層,為更高吞吐、更低延遲和更易維護創造了可能。

易用的介面:儘管為了實現強一致性 TiFlink 引進了一些新的概念,但是通過特別編寫的TiFlinkApp 介面,使用者可以快速啟動一個任務,也無需手動建立寫入目標表。

批流結合:任務啟動後會先批量消費源表當前已有的資料,隨後自動切換到 CDC 日誌消費。這個過程也會確保檢視的一致性。

關於 TiFlink 實用的詳細資訊,請參考 README。下面是快速啟動一個任務的程式碼片段:

TiFlinkApp.newBuilder()   .setJdbcUrl("jdbc:mysql://root@localhost:4000/test") // Please make sure the user has correct permission   .setQuery(       "select id, "           + "first_name, "           + "last_name, "           + "email, "           + "(select count(*) from posts where author_id = authors.id) as posts "           + "from authors")   // .setColumnNames("a", "b", "c", "d") // Override column names inferred from the query   // .setPrimaryKeys("a") // Specify the primary key columns, defaults to the first column   // .setDefaultDatabase("test") // Default TiDB database to use, defaults to that specified by JDBC URL   .setTargetTable("author_posts") // TiFlink will automatically create the table if not exist   // .setTargetTable("test", "author_posts") // It is possible to sepecify the full table path   .setParallelism(3) // Parallelism of the Flink Job   .setCheckpointInterval(1000) // Checkpoint interval in milliseconds. This interval determines data refresh rate   .setDropOldTable(true) // If TiFlink should drop old target table on start   .setForceNewTable(true) // If to throw an error if the target table already exists   .build()   .start(); // Start the app

物化檢視(流處理系統)的一致性

目前主流的物化檢視(流處理)系統主要使用最終一致性。也就是說盡管最終結果會收斂到一致的狀態,但在處理期間終端使用者仍可能查詢到一些不一致的結果。最終一致性在很多應用中被證明是足夠的,那麼更強的一致性是否真的需要呢?這裡的一致性和 Flink 的 Exact Once 語義又有什麼關係呢?有必要進行一些介紹。

ACID

ACID 是資料庫的一個基本的概念。一般來說,作為 CDC 日誌來源的資料庫已經保證了這四條要求。但是在使用 CDC 資料進行流式處理的時候,其中的某些約束卻有可能被破壞。
最典型的情況是失去 Atomic 特性。這是因為在 CDC 日誌中,一個事務的修改可能覆蓋多條記錄,流處理系統如果以行為單位進行處理,就有可能破壞原子性。也就是說,在結果集上進行查詢的使用者看到的事務是不完整的。
一個典型的案例如下:


Change Log 與事務的原子性

在上述案例中,我們有一個賬戶表,賬戶表之間會有轉賬操作,由於轉賬操作涉及多行修改,因此往往會產生多條記錄。假設我們有如下一條 SQL 定義的物化檢視,計算所有賬戶餘額的總和:

SELECT SUM(balance) FROM ACCOUNTS;

顯然,如果我們只存在表內賬戶之間的轉賬,這個查詢返回的結果應該恆為某一常數。但是由於目前一般的流處理系統不能處理事務的原子性,這條查詢產生的結果卻可能是不斷波動的。實際上,在一個不斷併發修改的源表上,其波動甚至可能是無界的。

儘管在最終一致的模型下,上述查詢的結果在經過一段時間之後將會收斂到正確值,但沒有原子性保證的物化檢視仍然限制的應用場景:假設我想實現一個當上述查詢結果偏差過大時進行報警的工具,我就有可能會接收到很多虛假報警。也就是說此時在資料庫端並沒有任何異常,數值的偏差只是來源於流處理系統內部。
在分散式系統中,還有另一種破壞原子性的情況,就是當一個事務修改產生的副作用分佈在多個不同的節點處。如果在這時不使用 2PC 等方法進行分散式提交,則也會破壞原子性:部分節點(分割槽)上的修改先於其他節點生效,從而出現不一致。

線性一致性

不同於由單機資料庫產生的 CDC 日誌(如 MySQL 的 Binlog),TiDB 這類分散式資料庫產生的日誌會有線性一致性的問題。在我們的場景下,線性一致性的問題可以描述為:從使用者的角度先後執行的一些操作,其產生的副作用(日誌)由於訊息系統傳遞的延遲,以不同的先後順序被流處理系統處理。
假設我們有訂單表(ORDERS)和付款資訊表(PAYMENTS)兩個表,使用者必須先建立訂單才能進行支付,因此下列查詢的結果必然是正數:

WITH order_amount AS (SELECT SUM(amount) AS total FROM ORDERS),WITH payment_amount AS (SELECT SUM(amount) AS total FROM PAYMENTS)SELECT order_amount.total - payment_amount.totalFROM order_amount, payment_amount;

但是由於 ORDERS 表和 PAYMENTS 表在分別儲存在不同的節點上,因此流處理系統消費他們的速度可能是不一致的。也就是說,流處理系統可能已經看到了支付資訊的記錄,但是其對應的訂單資訊還沒到達。因此就可能觀察到上述查詢出現負數的結果。

在流處理系統中,有一個 Watermark 的概念可以用來同步不同表的資料的處理進度,但是它並不能避免上述線性一致性問題。這是因為 Watermark 只要求時間戳小於其的所有記錄都已經到達,不要求時間戳大於其的記錄都沒有到達。也就是說,儘管 ORDERS 表和 PAYMENTS 表現在擁有相同的 Watermark,後者仍然可能會有一些先到的記錄已經生效。
由此可見,單純依靠 Watermark 本身是無法處理線性一致性問題的,必須和源資料庫的時間產生系統和訊息系統配合。

更強一致性的需求

儘管最終一致性在很多場景下是夠用的,但其依然存在很多問題:

  1. 誤導使用者:由於很多使用者並不瞭解一致性相關的知識,或者對其存在一定的誤解,導致其根據尚未收斂的查詢結果做出了決策。這種情況在大部分關係型資料庫都預設較強一致性的情況下是應該避免的。
  2. 可觀測性差:由於最終一致性並沒有收斂時間的保證,再考慮到線性一致性問題的存在,很難對流處理系統的延遲、資料新鮮度、吞吐量等指標進行定義。比如說使用者看到的 JOIN 的結果可能是表 A 當前的快照和表 B 十分鐘前的快照聯接的結果,此時應如何定義查詢結果的延遲度呢?
  3. 限制了部分需求的實現:正如上文所提到的,由於不一致的內部狀態,導致某些告警需求要麼無法實現,要麼需要延遲等待一段時間。否則使用者就不得不接受較高的誤報率。

實際上,更強一致性的缺乏還導致了一些運維操作,特別是 DDL 類的操作難以利用之前計算好的結果。參考關係型資料庫和 NoSQL 資料庫的發展歷史,我們相信目前主流的最終一致性只是受限於技術發展的權宜之計,隨著相關理論和技術研究的進步,更強的一致性將會慢慢成為流處理系統的主流。

技術方案簡介

這裡詳細介紹一下 TiFlink 在技術方案上的考慮,以及如何實現了強一致的物化檢視(StreamSQL)維護。

TiKV 和 Flink

儘管這是一個 TiDB Hackthon 專案,因此必然會選擇 TiDB/TiKV 相關的元件,但是在我看來 TiKV 作為物化檢視系統的中間儲存方案具備很多突出的優勢:

  1. TiKV 是一個比較成熟分散式 KV 儲存,而分散式環境是下一代物化檢視系統必須要支援的場景。利用 TiKV 配套的 Java Client,我們可以方便的對其進行操作。同時 TiDB 本身作為一個 HTAP 系統,正好為物化檢視這個需求提供了一個 Playground。
  2. TiKV提供了基於 Percolator 模型的事務支援和 MVCC,這是 TiFlink 實現強一致流處理的基礎。在下文中可以看到,TiFlink 對 TiKV 的寫入主要是以接連不斷的事務的形式進行的。
  3. TiKV 原生提供了對 CDC 日誌輸出的支援。實際上 TiCDC 元件正是利用這一特性實現的 CDC 日誌匯出功能。在 TiFlink 中,為了實現批流一體並簡化系統流程,我們選擇直接呼叫 TiKV 的 CDC GRPC 介面,因此也放棄了 TiCDC 提供的一些特性。

我們最初的想法本來是直接將計算功能整合進 TiKV,選擇 Flink 則是在比賽過程中進一步思考後得到的結論。選擇 Flink 的主要優勢有:

  1. Flink 是目前市面上最成熟的 Stateful 流處理系統,其對處理任務的表達能力強,支援的語義豐富,特別是支援批流一體的 StreamSQL 實現,是我們可以專心於探索我們比較關注的功能,如強一致性等。
  2. Flink 比較完整的 Watermark,而我們發現其基於 Checkpoint 實現的 Exactly Once Delivery 語義可以很方便地和 TiKV 結合來實現事務處理。實際上,Flink 自己提供的一些支援 Two Phase Commit 的 Sink 就是結合 Checkpoint 來進行提交的。
  3. Flink 的流處理(特別是 StreamSQL)本身就基於物化檢視的理論,在比較新的版本開始提供的 DynamicTable 介面,就是為了方便將外部的 Change Log 引入系統。它已經提供了對 INSERT、DELETE、UPDATE 等多種 CDC 操作的支援。

當然,選擇 TiKV+Flink 這樣的異構架構也會引入一些問題,比如 SQL 語法的不匹配,UDF 無法共享等問題。在 TiFlink 中,我們以 Flink 的 SQL 系統和 UDF 為準,將其作為 TiKV 的一個外掛系統使用,但同時提供了方便的建表功能。
強一致的物化檢視的實現思路

這一部分將介紹 TiFlink 如何在 TiDB/TiKV 的基礎上實現一個比較強的一致性級別:延遲快照隔離(Stale Snapshot Isolation)。在這種隔離級別下,查詢者總是查詢到歷史上一個一致的快照狀態。在傳統的快照隔離中,要求查詢者在 T時間能且只能觀察到 Commit 時間小於 T 的所有事務。而延遲快照隔離只能保證觀察到 T−Δt 之前所有已提交的事務。
在 TiDB 這樣支援事務的分散式資料庫上實現強一致的物化檢視,最簡單的思路就是使用一個接一個的事務來更新檢視。事務在開始時讀取到的是一個一致的快照,而使用分散式事務對物化檢視進行更新,本身也是一個強一致的操作,且具有 ACID 的特性,因此得以保證一致性。

使用連續事務更新物化檢視

為了將 Flink 和這樣的機制結合起來且實現增量維護,我們利用了 TiKV 本身已經提供的一些特性:

  1. TiKV 使用 Time Oracle 為所有的操作分配時間戳,因此雖然是一個分散式系統,其產生的 CDC 日誌中的事務的時間戳實際上是有序的。
  2. TiKV 的節點(Region)可以產生連續不斷的增量日誌(Change Log),這些日誌包含了事務的各種原始資訊幷包含時間戳資訊。
  3. TiKV 的增量日誌會定期產生 Resolved Timestamp,宣告當前 Region 不再會產生時間戳更老的訊息。因此很適合用來做 Watermark。
  4. TiKV 提供了分散式事務,允許我們控制一批修改的可見性。

因此 TiFlink 的基本實現思路就是:

  1. 利用流批一體的特性,以某全域性時間戳對源表進行快照讀取,此時可以獲得所有源表的一個一致性檢視。
  2. 切換到增量日誌消費,利用 Flink 的 DynamicTable 相關介面,實現物化檢視的增量維護和輸出。
  3. 以一定的節奏 Commit 修改,使得所有的修改以原子的事務方式寫入目標表,從而為物化檢視提供一個又一個更新檢視。

以上幾點的關鍵在於協調各個節點一起完成分散式事務,因此有必要介紹一下 TiKV 的分散式事務執行原理。

TiKV 的分散式事務

TiKV 的分散式事務基於著名的 Percolator 模型。Percolator 模型本身要求儲存層的 KV Store 有 MVCC 的支援和單行讀寫的原子性和樂觀鎖(OCC)。在此基礎上它採用以下步驟完成一次事務:

  1. 指定一個事務主鍵(Primary Key)和一個開始時間戳並寫入主鍵。
  2. 其他行在 Prewrite 時以副鍵(Secondary Key)的形式寫入,副鍵會指向主鍵並具有上述開始時間戳。
  3. 在所有節點 Prewrite 完成後,可以提交事務,此時應先 Commit 主鍵,並給定一個 Commit 時間戳。
  4. 主鍵 Commit 成功後事務實際上已經提交成功,但此時為了方便讀取,可以多節點併發地對副鍵進行 Commit 並執行清理工作,之後寫入的行都將變為可見。

上述分散式事務之所以可行,是因為對主鍵的 Commit 是原子的,分佈在不同節點的副鍵是否提交成功完全依賴於主鍵,因此其他的讀取者在讀到 Prewrite 後但還沒 Commit 的行時,會去檢查主鍵是否已 Commit。讀取者也會根據 Commit 時間戳判斷某一行資料是否可見。Cleanup 操作如果中途故障,在之後的讀取者也可以代行。
為了實現快照隔離,Percolator 要求寫入者在寫入時檢查併發的 Prewrite 記錄,保證他們的時間戳符合一定的要求才能提交事務。本質上是要求寫入集重疊的事務不能同時提交。在我們的場景中假設物化檢視只有一個寫入者且事務是連續的,因此無需擔心這點。
在瞭解了 TiKV 的分散式事務原理之後,要考慮的就是如何將其與 Flink 結合起來。在 TiFlink 裡,我們利用 Checkpoint 的機制來實現全域性一致的事務提交。

使用 Flink 進行分散式事務提交

從上面的介紹可以看出,TiKV 的分散式事務提交可以抽象為一次 2PC。Flink 本身有提供實現2PC 的 Sink,然而並不能直接用在我們的場景下。原因是 Percolator 模型在提交時需要有全域性一致的事務開始時間戳和提交時間戳。而且僅僅是在 Sink 端實現 2PC 是不足以實現強一致隔離級別的:我們還需要在 Source 端配合,使得每個事務恰好讀入所需的增量日誌。
幸運的是,Flink 的 2PC 提交機制實際上是由 Checkpoint 驅動的:當 Sink 接收到 Checkpoint 請求時,會完成必要的任務以進行提交。受此啟發,我們可以實現一對 Source 和 Sink,讓他們使用 Checkpoint 的 ID 共享 Transaction 的資訊,並配合 Checkpoint 的過程完成 2PC。而為了使不同節點可以對事務的資訊(時間戳,主鍵)等達成一致,需要引入一個全域性協調器。事務和全域性協調器的介面定義如下:

public interface Transaction {  public enum Status {    NEW,    PREWRITE,    COMMITTED,    ABORTED;  };  long getCheckpointId();  long getStartTs();  default long getCommitTs();  default byte[] getPrimaryKey();  default Status getStatus();}public interface Coordinator extends AutoCloseable, Serializable {  Transaction openTransaction(long checkpointId);  Transaction prewriteTransaction(long checkpointId, long tableId);  Transaction commitTransaction(long checkpointId);  Transaction abortTransaction(long checkpointId);}

使用上述介面,各個 Source 和 Sink 節點可以使用 CheckpointID 開啟事務或獲得事務 ID,協調器會負責分配主鍵並維護事務的狀態。為了方便起見,事務 Commit 時對主鍵的提交操作也放在協調器中執行。協調器的實現有很多方法,目前 TiFlink 使用最簡單的實現:在 JobManager 所在程式中啟動一個 GRPC 服務。基於 TiKV 的 PD(ETCD)或 TiKV 本身實現分散式的協調器也是可能的。


事務與 Checkpoint 的協調執行

上圖展示了在 Flink 中執行分散式事務和 Checkpoint 之間的協調關係。一次事務的具體過程如下:

  1. Source 先從 TiKV 接收到增量日誌,將他們按照時間戳 Cache 起來,等待事務的開始。
  2. 當 Checkpoint 程式開始時,Source 會先接收到訊號。在 Source 端的 Checkpoint 與日誌接收服務執行在不同的執行緒中。
  3. Checkpoint 執行緒先通過全域性協調器獲得當前事務的資訊(或開啟一個新事務),分散式情況下一個 CheckpointID 對應的事務只會開啟一次。
  4. 得到事務的開始時間戳後,Source 節點開始將 Cache 中小於此時間戳的已提交修改 Emit 到下游計算節點進行消費。此時 Source 節點也會 Emit 一些 Watermark。
  5. 當所有 Source 節點完成上述操作後,Checkpoint 在 Source 節點成功完成,此後會向後繼續傳播,根據 Flink 的機制,Checkpoint 在每個節點都會保證其到達之前的所有 Event 都已被消費。
  6. 當 Checkpoint 到達 Sink 時,之前傳播到 Sink 的 Event 都已經被 Prewrite 過了,此時可以開始事務的提交過程。Sink 在內部狀態中持久化事務的資訊,以便於錯誤時恢復,在所有 Sink 節點完成此操作後,會在回撥中呼叫協調器的 Commit 方法從而提交事務。
  7. 提交事務後,Sink 會啟動執行緒進行 Secondary Key 的清理工作,同時開啟一個新的事務。

注意到,在第一個 Checkpoint 開始前,Sink 可能已經開始接收到寫入的資料了,而此時它還沒有事務的資訊。為了解決這一問題,TiFlink在任務開始時會直接啟動一個初始事務,其對應的 CheckpointID 是 0,用於提交最初的一些寫入。這樣的話,在 CheckpointID=1 的 Checkpoint 完成時,實際上提交的是這個 0 事務。事務和 Checkpoint 以這樣的一種錯位的方式協調執行。
下圖展示了包含協調器在內的整個 TiFlink 任務的架構:

TiFlink 的系統架構

基於以上的系統設計,我們就得到了一個在 TiKV 上實現延遲快照隔離的物化檢視。

其他設計考慮

眾所周知,KSQL 是 Flink 之外另一個流行的流處理系統,它直接與 Kafka 訊息佇列系統結合,使用者無需部署兩套處理系統,因此受到一些使用者的青睞。很多使用者也使用 KSQL 實現類似物化檢視這樣的需求。然而在我看來,這種強耦合於訊息佇列的流處理系統並不適合物化檢視的使用場景。
KSQL 可以說是 Log Oriented 資料處理系統的的代表,在這種系統中,資料的本源在於日誌資訊,所有的表都是為了方便查詢而消費日誌資訊從而構建出來的檢視。這種系統具有模型簡單、容易實現、可以長時間儲存日誌記錄等優點。
與之相對是 Table Oriented 資料處理系統,MySQL、TiDB/TiKV 都屬於這一類系統。這一類系統的所有修改操作都作用於表資料結構,雖然期間也會有日誌生成,但往往對錶資料結構和日誌的修改是一起協調進行的。這裡日誌的主要是為持久化和事務服務,往往不會留存太長時間。相比於 Log Oriented 資料處理系統,這類系統對寫入和事務的處理都更為複雜一點,然而卻擁有更強可擴充套件性的要求。
歸根結底,這是因為 Log Oriented 系統中的資料是以日誌的形式儲存,因此在擴充套件時往往需要進行成本較高的 Rehash,也更難實現再平衡。而 Table Oriented 的系統,資料主要以表的形式儲存,因此可以以某些列進行有序排列,從而方便在一致性 Hash 的支援下實現 Range 的切分、合併和再平衡。
個人認為,在批流一體的物化檢視場景下,長時間儲存日誌並無太大的意義(因為總是可以從源表的快照恢復資料)。相反,隨著業務的發展不斷擴充套件資料處理任務和檢視是一件比較重要的事。從這個角度來看 Table Oriented 系統似乎更適合作為物化檢視需求的儲存承載介質。
當然,在實時消費增量 Log 時發生的分割槽合併或分裂是一個比較難處理的問題。TiKV 在這種情況下會丟擲一個 GRPC 錯誤。TiFlink 目前使用的是比較簡單的靜態對映方法處理任務和分割槽之間的關係,在未來可以考慮更為合理的解決方案。

總結

本文介紹了使用 Flink 在 TiKV 上實現強一致的物化檢視的基本原理。以上原理已經基本上在 TiFlink 系統中實現,歡迎各位讀者試用。以上所有的討論都基於 Flink 的最終一致模型的保證,即:流計算的結果只與消費的 Event 和他們在自己流中的順序有關,與他們到達系統的順序以及不同流之間的相對順序無關。

目前的 TiFlink 系統還有很多值得提高的點,如:

  • 支援非 Integer 型主鍵和聯合主鍵
  • 更好的 TiKV Region 到 Flink 任務的對映
  • 更好的 Fault Tolerance 和任務中斷時 TiKV 事務的清理工作
  • 完善的單元測試

如果各位讀者對 TiFlink 感興趣的話,歡迎試用並提出反饋意見,如果能夠貢獻程式碼幫助完善這個系統那就再好不過了。

關於物化檢視系統一致性的思考是我今年最主要的收穫之一。實際上,最初我們並沒有重視這一方面,而是在不斷地交流當中才認識到這是一個有價值且很有挑戰性的問題。通過 TiFlink 的實現,可以說是基本上驗證了上述方法實現延遲快照一致性的可行性。當然,由於個人的能力水平有限,如果存在什麼紕漏,也歡迎各位提出討論。

最後,如果我們假設上述延遲快照一致性的論述是正確的,那麼實現真正的快照隔離的方法也就呼之欲出。不知道各位讀者能否想到呢?

相關文章