解析 TiDB 在線數據同步工具 Syncer
TiDB 是一個完全分散式的關係型資料庫,從誕生的第一天起,我們就想讓它來兼容 MySQL 語法,希望讓原有的 MySQL 用戶 (不管是單機的 MySQL,還是多機的 MySQL Sharding)都可以在基本不修改代碼的情況下,除了可以保留原有的 SQL 和 ACID 事務之外,還可以享受到分散式帶來的高並發,高吞吐和 MPP 的高性能。
對於用戶來說,簡單易用是他們試用的最基本要求,得益於社區和 PingCAP 小夥伴們的努力,我們提供基於 Binary 和 基於 Kubernetes 的兩種不同的一鍵部署方案來讓用戶可以在幾分鐘就可以部署起來一個分散式的 TiDB 集群,從而快速地進行體驗。 當然,對於用戶來說,最好的體驗方式就是從原有的 MySQL 資料庫同步一份數據鏡像到 TiDB 來進行對於對比測試,不僅簡單直觀,而且也足夠有說服力。實際上,我們已經提供了一整套的工具來輔助用戶在線做數據同步,具體的可以參考我們之前的一篇文章:TiDB 作為 MySQL Slave 實現實時數據同步, 這裡就不再展開了。後來有很多社區的朋友特別想了解其中關鍵的 Syncer 組件的技術實現細節,於是就有了這篇文章。
首先我們看下 Syncer 的整體架構圖, 對於 Syncer 的作用和定位有一個直觀的印象。
從整體的架構可以看到,Syncer 主要是通過把自己註冊為一個 MySQL Slave 的方式,和 MySQL Master 進行通信,然後不斷讀取 MySQL Binlog,進行 Binlog Event 解析,規則過濾和數據同步。從工程的複雜度上來看,相對來說還是非常簡單的,相對麻煩的地方主要是 Binlog Event 解析和各種異常處理,也是容易掉坑的地方。
為了完整地解釋 Syncer 的在線同步實現,我們需要有一些額外的內容需要了解。
MySQL Replication
我們先看看 MySQL 原生的 Replication 複製方案,其實原理上也很簡單:
MySQL Master 將數據變化記錄到 Binlog (Binary Log),
MySQL Slave 的 I/O Thread 將 MySQL Master 的 Binlog 同步到本地保存為 Relay Log
MySQL Slave 的 SQL Thread 讀取本地的 Relay Log,將數據變化同步到自身
MySQL Binlog
MySQL 的 Binlog 分為幾種不同的類型,我們先來大概了解下,也看看具體的優缺點。
Row MySQL Master 將詳細記錄表的每一行數據變化的明細記錄到 Binlog。 優點:完整地記錄了行數據的變化信息,完全不依賴於存儲過程,函數和觸發器等等,不會出現因為一些依賴上下文信息而導致的主從數據不一致的問題。 缺點:所有的增刪改查操作都會完整地記錄在 Binlog 中,會消耗更大的存儲空間。
Statement MySQL Master 將每一條修改數據的 SQL 都會記錄到 Binlog。 優點:相比 Row 模式,Statement 模式不需要記錄每行數據變化,所以節省存儲量和 IO,提高性能。 缺點:一些依賴於上下文信息的功能,比如 auto increment id,user define function, on update current_timestamp/now 等可能導致的數據不一致問題。
Mixed MySQL Master 相當於 Row 和 Statement 模式的融合。 優點:根據 SQL 語句,自動選擇 Row 和 Statement 模式,在數據一致性,性能和存儲空間方面可以做到很好的平衡。 缺點:兩種不同的模式混合在一起,解析處理起來會相對比較麻煩。
MySQL Binlog Event
了解了 MySQL Replication 和 MySQL Binlog 模式之後,終於進入到了最複雜的 MySQL Binlog Event 協議解析階段了。
在解析 MySQL Binlog Eevent 之前,我們首先看下 MySQL Slave 在協議上是怎麼和 MySQL Master 進行交互的。
Binlog dump
首先,我們需要偽造一個 Slave,向 MySQL Master 註冊,這樣 Master 才會發送 Binlog Event。註冊很簡單,就是向 Master 發送 COM_REGISTER_SLAVE 命令,帶上 Slave 相關信息。這裡需要注意,因為在 MySQL 的 replication topology 中,都需要使用一個唯一的 server id 來區別標示不同的 Server 實例,所以這裡我們偽造的 slave 也需要一個唯一的 server id。
Binlog Event
對於一個 Binlog Event 來說,它分為三個部分,header,post-header 以及 payload。 MySQL 的 Binlog Event 有很多版本,我們只關心 v4 版本的,也就是從 MySQL 5.1.x 之後支持的版本,太老的版本應該基本上沒什麼人用了。
Binlog Event 的 header 格式如下:
header 的長度固定為 19,event type 用來標識這個 event 的類型,event size 則是該 event 包括 header 的整體長度,而 log pos 則是下一個 event 所在的位置。
這個 header 對於所有的 event 都是通用的,接下來我們看看具體的 event。
FORMAT_DESCRIPTION_EVENT
在 v4 版本的 Binlog 文件中,第一個 event 就是 FORMAT_DESCRIPTION_EVENT,格式為:
我們需要關注的就是 event type header length 這個欄位,它保存了不同 event 的 post-header 長度,通常我們都不需要關注這個值,但是在解析後面非常重要的ROWS_EVENT 的時候,就需要它來判斷 TableID 的長度了, 這個後續在說明。
ROTATE_EVENT
而 Binlog 文件的結尾,通常(只要 Master 不當機)就是 ROTATE_EVENT,格式如下:
它裡面其實就是標明下一個 event 所在的 binlog filename 和 position。這裡需要注意,當 Slave 發送 Binlog dump 之後,Master 首先會發送一個 ROTATE_EVENT,用來告知 Slave下一個 event 所在位置,然後才跟著 FORMAT_DESCRIPTION_EVENT。
其實我們可以看到,Binlog Event 的格式很簡單,文檔都有著詳細的說明。通常來說,我們僅僅需要關注幾種特定類型的 event,所以只需要寫出這幾種 event 的解析代碼就可以了,剩下的完全可以跳過。
TABLE_MAP_EVENT
上面我們提到 Syncer 使用 Row 模式的 Binlog,關於增刪改的操作,對應於最核心的ROWS_EVENT ,它記錄了每一行數據的變化情況。而如何解析相關的數據,是非常複雜的。在詳細說明 ROWS_EVENT 之前,我們先來看看 TABLE_MAP_EVENT,該 event 記錄的是某個 table 一些相關信息,格式如下:
table id 需要根據 post_header_len 來判斷位元組長度,而 post_header_len 就是存放到 FORMAT_DESCRIPTION_EVENT 裡面的。這裡需要注意,雖然我們可以用 table id 來代表一個特定的 table,但是因為 Alter Table 或者 Rotate Binlog Event 等原因,Master 會改變某個 table 的 table id,所以我們在外部不能使用這個 table id 來索引某個 table。
TABLE_MAP_EVENT 最需要關注的就是裡面的 column meta 信息,後續我們解析 ROWS_EVENT 的時候會根據這個來處理不同數據類型的數據。column def 則定義了每個列的類型。
ROWS_EVENT
ROWS_EVENT 包含了 insert,update 以及 delete 三種 event,並且有 v0,v1 以及 v2 三個版本。 ROWS_EVENT 的格式很複雜,如下:
ROWS_EVENT 的 table id 跟 TABLE_MAP_EVENT 一樣,雖然 table id 可能變化,但是 ROWS_EVENT 和 TABLE_MAP_EVENT 的 table id 是能保證一致的,所以我們也是通過這個來找到對應的 TABLE_MAP_EVENT。 為了節省空間,ROWS_EVENT 裡面對於各列狀態都是採用 bitmap 的方式來處理的。
首先我們需要得到 columns present bitmap 的數據,這個值用來表示當前列的一些狀態,如果沒有設置,也就是某列對應的 bit 為 0,表明該 ROWS_EVENT 裡面沒有該列的數據,外部直接使用 null 代替就成了。
然後就是 null bitmap,這個用來表明一行實際的數據裡面有哪些列是 null 的,這裡最坑爹的是 null bitmap 的計算方式並不是 (num of columns+7)/8,也就是 MySQL 計算 bitmap 最通用的方式,而是通過 columns present bitmap 的 bits set 個數來計算的,這個坑真的很大。為什麼要這麼設計呢,可能最主要的原因就在於 MySQL 5.6 之後 Binlog Row Image 的格式增加了 minimal 和 noblob,尤其是 minimal,update 的時候只會記錄相應更改欄位的數據,比如我一行有 16 列,那麼用 2 個 byte 就能搞定 null bitmap 了,但是如果這時候只有第一列更新了數據,其實我們只需要使用 1 個 byte 就能記錄了,因為後面的鐵定全為 0,就不需要額外空間存放了。bits set 其實也很好理解,就是一個 byte 按照二進位展示的時候 1 的個數,譬如 1 的 bits set 就是1,而 3 的 bits set 就是 2,而 255 的 bits set 就是 8 了。
得到了 present bitmap 以及 null bitmap 之後,我們就能實際解析這行對應的列數據了,對於每一列,首先判斷是否 present bitmap 標記了,如果為 0,則跳過用 null 表示,然後在看是否在 null bitmap 裡面標記了,如果為 1,表明值為 null,最後我們就開始解析真正有數據的列了。
但是,因為我們得到的是一行數據的二進位流,我們怎麼知道一列數據如何解析?這裡,就要靠 TABLE_MAP_EVENT 裡面的 column def 以及 meta 了。 column def 定義了該列的數據類型,對於一些特定的類型,譬如 MYSQL_TYPE_LONG, MYSQL_TYPE_TINY 等,長度都是固定的,所以我們可以直接讀取對應的長度數據得到實際的值。但是對於一些類型,則沒有這麼簡單了。這時候就需要通過 meta 來輔助計算了。
譬如對於 MYSQL_TYPE_BLOB 類型,meta 為 1 表明是 tiny blob,第一個位元組就是 blob 的長度,2 表明的是 short blob,前兩個位元組為 blob 的長度等,而對於 MYSQL_TYPE_VARCHAR 類型,meta 則存儲的是 string 長度。當然這裡面還有最複雜的 MYSQL_TYPE_NEWDECIMAL, MYSQL_TYPE_TIME2 等類型,關於不同類型的 column 解析還是比較複雜的,可以單獨開一章專門來介紹,因為篇幅關係這裡就不展開介紹了,具體的可以參考官方文檔。
搞定了這些,我們終於可以完整的解析一個 ROWS_EVENT 了:)
XID_EVENT 在事務提交時,不管是 Statement 還是 Row 模式的 Binlog,都會在末尾添加一個 XID_EVENT 事件代表事務的結束,裡面包含事務的 ID 信息。
QUERY_EVENT QUERY_EVENT 主要用於記錄具體執行的 SQL 語句,MySQL 所有的 DDL 操作都記錄在這個 event 裡面。
Syncer
介紹完了 MySQL Replication 和 MySQL Binlog Event 之後,理解 Syncer 就變的比較容易了,上面已經介紹過基本的架構和功能了,在 Syncer 中, 解析和同步 MySQL Binlog,我們使用的是我們首席架構師唐劉的 go-mysql 作為核心 lib,這個 lib 已經在 github 和 bilibili 線上使用了,所以是非常安全可靠的。所以這部分我們就跳過介紹了,感興趣的話,可以看下 github 開源的代碼。這裡面主要介紹幾個核心問題:
MySQL Binlog 模式的選擇
在 Syncer 的設計中,首先考慮的是可靠性問題,即使 Syncer 異常退出也可以直接重啟起來,也不會對線上數據一致性產生影響。為了實現這個目標,我們必須處理數據同步的可重入問題。 對於 Mixed 模式來說,一個 insert 操作,在 Binlog 中記錄的是 insert SQL,如果 Syncer 異常退出的話,因為 Savepoint 還沒有來得及更新,會導致重啟之後繼續之前的 insert SQL,就會導致主鍵衝突問題,當然可以對 SQL 進行改寫,將 insert 改成 replace,但是這裡面就涉及到了 SQL 的解析和轉換問題,處理起來就有點麻煩了。另外一點就是,最新版本的 MySQL 5.7 已經把 Row 模式作為默認的 Binlog 格式了。所以,在 Syncer 的實現中,我們很自然地選擇 Row 模式作為 Binlog 的數據同步模式。
Savepoint 的選取
對於 Syncer 本身來說,我們更多的是考慮讓它儘可能的簡單和高效,所以每次 Syncer 重啟都要儘可能從上次同步的 Binlog Pos 的地方做類似斷點續傳的同步。如何選取 Savepoint 就是一個需要考慮的問題了。 對於一個 DML 操作來說(以 Insert SQL 操作舉例來看),基本的 Binlog Event 大概是下面的樣子:
我們從 MySQL Binlog Event 中可以看到,每個 Event 都可以獲取下一個 Event 開始的 MySQL Binlog Pos 位置,所以只要獲取這個 Pos 信息保存下來就可以了。但是我們需要考慮的是,TABLE_MAP_EVENT 這個 event 是不能被 save 的,因為對於 WRITE_ROWS_EVENT 來說,沒有 TABLE_MAP_EVENT 基本上沒有辦法進行數據解析,所以為什麼很多人抱怨 MySQL Binlog 協議不靈活,主要原因就在這裡,因為不管是 TABLE_MAP_EVENT 還是 WRITE_ROWS_EVENT 裡面都沒有 Schema 相關的信息的,這個信息只能在某個地方保留起來,比如 MySQL Slave,也就是 MySQL Binlog 是沒有辦法自解析的。
當然,對於 DDL 操作就比較簡單了,DDL 本身就是一個 QUERY_EVENT。
所以,Syncer 處於性能和安全性的考慮,我們會定期和遇到 DDL 的時候進行 Save。大家可能也注意到了,Savepoint 目前是存儲在本地的,也就是存在一定程度的單點問題,暫時還在我們的 TODO 裡面。
斷點數據同步
在上面我們已經拋出過這個問題了,對於 Row 模式的 MySQL Binlog 來說,實現這點相對來說也是比較容易的。舉例來說,對於一個包含 3 行 insert row 的 Txn 來說,event 大概是這樣的:
所以在 Syncer 裡面做的事情就比較容易了,就是把每個 WRITE_ROWS_EVENT 結合 TABLE_MAP_EVENT,去生成一個 replace into 的 SQL,為什麼這裡不用 insert 呢?主要是 replace into 是可重入的,重複執行多次,也不會對數據一致性產生破壞。 另外一個比較麻煩的問題就是 DDL 的操作,TiDB 的 DDL 實現是完全無阻塞的,所以根據 TiDB Lease 的大小不同,會執行比較長的時間,所以 DDL 操作是一個代價很高的操作,在 Syncer 的處理中通過獲取 DDL 返回的標準 MySQL 錯誤來判斷 DDL 是否需要重複執行。
當然,在數據同步的過程中,我們也做了很多其他的工作,包括並發 sync 支持,MySQL 網路重連,基於 DB/Table 的規則定製等等,感興趣的可以直接看我們 tidb-tools/syncer 的開源實現,這裡就不展開介紹了。
歡迎對 Syncer 這個小項目感興趣的小夥伴們在 Github 上面和我們討論交流,當然更歡迎各種 PR:)
推薦閱讀:
※太閣技術秀:一起聊聊cassandra
※PingCAP佈道Percona Live 2017 展示TiDB強悍性能
※十分鐘成為 TiDB Contributor 系列 | 添加內建函數
※TiDB 增加 MySQL 內建函數
TAG:分布式数据库 |