本文轉自:https://aleiwu.com/post/vimur.cn/
目錄
一、前言
這篇文章我最初是發表於公司技術部公衆號, 原題”實時數據管道探索”, 公開後就搬運到了自己的博客上, 基本上算是對自己2017年上半年工作的一些總結. 對於其中提到的 Kafka, Debezium, Otter, Canal 等項目, 其實都踩了不少坑. 下面的內容是一個概覽的方案分享, 各個環節的坑與一些細節可能會在後續的文章中進行一些討論
二、起源
在進行架構轉型與分庫分表之前,我們一直採用非常典型的單體應用架構:主服務是一個 Java WebApp,使用 Nginx 並選擇 Session Sticky 分發策略做負載均衡和會話保持;背後是一個 MySQL 主實例,接了若干 Slave 做讀寫分離。在整個轉型開始之前,我們就知道這會是一塊難啃的硬骨頭:我們要在全線業務飛速地擴張迭代的同時完成架構轉型,因爲這是實實在在的”給高速行駛的汽車換輪胎”。
爲了最大限度地減少服務拆分與分庫分表給業務帶來的影響(不影響業務開發也是架構轉型的前提),我們採用了一種溫和的漸進式拆分方案:
- 對於每塊需要拆分的領域,首先拆分出子服務,並將所有該領域的數據庫操作封裝爲 RPC 接口;
- 將其它所有服務中對該領域數據表的操作替換爲 RPC 調用;
- 拆分該領域的數據表,使用數據同步保證舊庫中的表與新表數據一致;
- 將該子服務中的數據庫操作逐步遷移到新表,分批上線;
- 全部遷移完成後,切斷同步,該服務拆分結束。
這種方案能夠做到平滑遷移,但其中卻有幾個棘手的問題:
- 舊錶新表的數據一致性如何保證?
- 如何支持異構遷移?(由於舊錶的設計往往非常範式化,因此拆分後的新表會增加很多來自其它表的冗餘列)
- 如何保證數據同步的實時性?(往往會先遷移讀操作到新表,這時就要求舊錶的寫操作必須準實時地同步到新表)
典型的解決方案有兩種:
雙寫(dual write): 即所有寫入操作同時寫入舊錶和新表,這種方式可以完全控制應用代碼如何寫數據庫,聽上去簡單明瞭。但它會引入複雜的分佈式一致性問題:要保證新舊庫中兩張表數據一致,雙寫操作就必須在一個分佈式事務中完成,而分佈式事務的代價太高了。
數據變更抓取(change data capture, CDC): 通過數據源的事務日誌抓取數據源變更,這能解決一致性問題(只要下游能保證變更應用到新庫上)。它的問題在於各種數據源的變更抓取沒有統一的協議,如 MySQL 用 Binlog,PostgreSQL 用 Logical decoding 機制,MongoDB 裏則是 oplog。
最終我們選擇使用數據變更抓取實現數據同步與遷移,一是因爲數據一致性的優先級更高,二是因爲開源社區的多種組件能夠幫助我們解決沒有統一協議帶來的 CDC 模塊開發困難的問題。在明確要解決的問題和解決方向後,我們就可以着手設計整套架構了。
三、架構設計
只有一個 CDC 模塊當然是不夠的,因爲下游的消費者不可能隨時就位等待 CDC 模塊的推送。因此我們還需要引入一個變更分發平臺,它的作用是:
- 提供變更數據的堆積能力;
- 支持多個下游消費者按不同速度消費;
- 解耦 CDC 模塊與消費者;
另外,我們還需要確定一套統一的數據格式,讓整個架構中的所有組件能夠高效而安全地通信。
現在我們可以正式介紹 Vimur [ˈviːmər] 了,它是一套實時數據管道,設計目標是通過 CDC 模塊抓取業務數據源變更,並以統一的格式發佈到變更分發平臺,所有消費者通過客戶端庫接入變更分發平臺獲取實時數據變更。
我們先看一看這套模型要如何才解決上面的三個問題:
- 一致性:數據變更分發給下游應用後,下游應用可以不斷重試保證變更成功應用到目標數據源——這個過程要真正實現一致性還要滿足兩個前提,一是從數據變更抓取模塊投遞到下游應用並消費這個過程不能丟數據,也就是要保證至少一次交付;二是下游應用的消費必須是冪等的。
- 異構遷移:異構包含多種含義:表的 Schema 不同、表的物理結構不同(單表到分片表)、數據庫不同(如 MySQL -> EleasticSearch) ,後兩者只要下游消費端實現對應的寫入接口就能解決;而 Schema 不同,尤其是當新庫的表聚合了多張舊庫的表信息時,就要用反查源數據庫或 Stream Join 等手段實現。
- 實時性:只要保證各模塊的數據傳輸與寫入的效率,該模型便能保證實時性。
可以看到,這套模型本身對各個組件是有一些要求的,我們下面的設計選型也會參照這些要求。
四、開源方案對比
在設計階段,我們調研對比了多個開源解決方案:
- databus: Linkedin 的分佈式數據變更抓取系統;
- Yelp’s data pipeline: Yelp 的數據管道;
- Otter: 阿里開源的分佈式數據庫同步系統;
- Debezium: Redhat 開源的數據變更抓取組件;
這些解決方案關注的重點各有不同,但基本思想是一致的:使用變更抓取模塊實時訂閱數據庫變更,並分發到一箇中間存儲供下游應用消費。下面是四個解決方案的對比矩陣:
方案 | 變更抓取 | 分發平臺 | 消息格式 | 額外特性 |
---|---|---|---|---|
databus | DatabusEventProducer, 支持 Oracle 和 MySQL 的變更抓取 | DatabusRelay, 基於 Netty 的中間件, 內部是一個 RingBuffer 存儲變更消息 | Apache Avro | 有 BootstrapService 組件存儲歷史變更用以支持全量 |
Yelp’s data pipeline | MySQL Streamer, 基於 binlog 抓取變更 | Apache Kafka | Apache Avro | Schematizer, 作爲消息的 Avro Schema 註冊中心的同時提供了 Schema 文檔 |
Otter | Canal, 阿里的另一個開源項目, 基於 binlog | work node 內存中的 ring buffer | protobuf | 提供了一個完善的 admin ui |
Debezium | 提供 MySQL, MongoDB, PostgreSQL 三種 Connector | Apache Kafka | Apache Avro / json | Snapshot mode 支持全量導入數據表 |
(Linkedin databus 的架構圖)
Linkedin databus 的論文有很強的指導性,但它的 MySQL 變更抓取模塊很不成熟,官方支持的是 Oracle,MySQL 只是使用另一個開源組件 OpenReplicator 做了一個 demo。另一個不利因素 databus 使用了自己實現的一個 Relay 作爲變更分發平臺,相比於使用開源消息隊列的方案,這對維護和外部集成都不友好。
(otter 的架構圖)
Otter 和 Canal 在國內相當知名,Canal 還支持了阿里雲 DRDS 的二級索引構建和小表同步,工程穩定性上有保障。但 Otter 本身無法很好地支持多表聚合到新表,開源版本也不支持同步到分片表當中,能夠採取的一個折衷方案是直接將 Canal 訂閱的變更寫入消息隊列,自己寫下游程序實現聚合同步等邏輯。該方案也是我們的候選方案。
Yelp’s data pipeline 是一個大而全的解決方案。它使用 Mysql-Streamer(一個通過 binlog 實現的 MySQL CDC 模塊)將所有的數據庫變更寫入 Kafka,並提供了 Schematizer 這樣的 Schema 註冊中心和定製化的 Python 客戶端庫解決通信問題。遺憾的是該方案是 Python 構建的,與我們的 Java 技術棧相性不佳。
最後是 Debezium , 不同於上面的解決方案,它只專注於 CDC,它的亮點有:
- 支持 MySQL、MongoDB、PostgreSQL 三種數據源的變更抓取,並且社區正在開發 Oracle 與 Cassandra 支持;
- Snapshot Mode 可以將表中的現有數據全部導入 Kafka,並且全量數據與增量數據形式一致,可以統一處理;
- 利用了 Kafka 的 Log Compaction 特性,變更數據可以實現”不過期”永久保存;
- 利用了 Kafka Connect,自動擁有高可用與開箱即用的調度接口;
- 社區活躍:Debezium 很年輕,面世不到1年,但它的 Gitter上每天都有百餘條技術討論,並且有兩位 Redhat 全職工程師進行維護;
最終我們選擇了 Debezium + Kafka 作爲整套架構的基礎組件,並以 Apache Avro 作爲統一數據格式,下面我們將結合各個模塊的目標與設計闡釋選型動機。
五、CDC 模塊
變更數據抓取通常需要針對不同數據源訂製實現,而針對特定數據源,實現方式一般有兩種:
- 基於自增列或上次修改時間做增量查詢;
- 利用數據源本身的事務日誌或 Slave 同步等機制實時訂閱變更;
第一種方式實現簡單,以 SQL 爲例: 相信大家都寫過類似的 SQL, 每次查詢時,查詢 [last_query_time, now)
區間內的增量數據,lastmodified 列也可以用自增主鍵來替代。這種方式的缺點是實時性差,對數據庫帶來了額外壓力,並且侵入了表設計 —— 所有要實現變更抓取的表都必須有用於增量查詢的列並且在該列上構建索引。另外,這種方式無法感知物理刪除(Delete), 刪除邏輯只能用一個 delete
列作爲 flag 來實現。
第二種方式實現起來相對困難,但它很好地解決了第一種方式的問題,因此前文提到的開源方案也都採用了這種方式。下面我們着重分析在 MySQL 中如何實現基於事務日誌的實時變更抓取。
MySQL 的事務日誌稱爲 binlog,常見的 MySQL 主從同步就是使用 Binlog 實現的:
我們把 Slave 替換成 CDC 模塊,CDC 模塊模擬 MySQL Slave 的交互協議,便能收到 Master 的 binlog 推送:
CDC 模塊解析 binlog,產生特定格式的變更消息,也就完成了一次變更抓取。但這還不夠,CDC 模塊本身也可能掛掉,那麼恢復之後如何保證不丟數據又是一個問題。這個問題的解決方案也是要針對不同數據源進行設計的,就 MySQL 而言,通常會持久化已經消費的 binlog 位點或 Gtid(MySQL 5.6之後引入)來標記上次消費位置。其中更好的選擇是 Gtid,因爲該位點對於一套 MySQL 體系(主從或多主)是全局的,而 binlog 位點是單機的,無法支持主備或多主架構。
那爲什麼最後選擇了 Debezium 呢?
MySQL CDC 模塊的一個挑戰是如何在 binlog 變更事件中加入表的 Schema 信息(如標記哪些字段爲主鍵,哪些字段可爲 null)。Debezium 在這點上處理得很漂亮,它在內存中維護了數據庫每張表的 Schema,並且全部寫入一個 backup 的 Kafka Topic 中,每當 binlog 中出現 DDL 語句,便應用這條 DDL 來更新 Schema。而在節點宕機,Debezium 實例被調度到另一個節點上後,又會通過 backup topic 恢復 Schema 信息,並從上次消費位點繼續解析 Binlog。
在我們的場景下,另一個挑戰是,我們數據庫已經有大量的現存數據,數據遷移時的現存數據要如何處理。這時,Debezium 獨特的 Snapshot 功能就能幫上忙,它可以實現將現有數據作爲一次”插入變更”捕捉到 Kafka 中,因此只要編寫一次客戶端就能一併處理全量數據與後續的增量數據。
六、變更分發平臺
變更分發平臺可以有很多種形式,本質上它只是一個存儲變更的中間件,那麼如何進行選型呢?首先由於變更數據數據量級大,且操作時沒有事務需求,所以先排除了關係型數據庫, 剩下的 NoSQL 如 Cassandra,mq 如 Kafka、RabbitMQ 都可以勝任。其區別在於,消費端到分發平臺拉取變更時,假如是 NoSQL 的實現,那麼就能很容易地實現條件過濾等操作(比如某個客戶端只對特定字段爲 true 的消息感興趣); 但 NoSQL 的實現往往會在吞吐量和一致性上輸給 mq。這裏就是一個設計抉擇的問題,最終我們選擇了 mq,主要考慮的點是:消費端往往是無狀態應用,很容易進行水平擴展,因此假如有條件過濾這樣的需求,我們更希望把這樣的計算壓力放在消費端上。
而在 mq 裏,Kafka 則顯得具有壓倒性優勢。Kafka 本身就有大數據的基因,通常被認爲是目前吞吐量最大的消息隊列,同時,使用 Kafka 有一項很適合該場景的特性:Log Compaction。Kafka 默認的過期清理策略(log.cleanup.policy
)是delete
,也就是刪除過期消息,配置爲compact
則可以啓用 Log Compaction 特性,這時 Kafka 不再刪除過期消息,而是對所有過期消息進行”摺疊” —— 對於 key 相同的所有消息會,保留最新的一條。
舉個例子,我們對一張表執行下面這樣的操作: 對應的在 mq 中的流總共會產生 4 條變更消息,而最下面兩條分別是 id:1
id:2
下的最新記錄,在它們之前的兩條 INSERT 引起的變更就會被 Kafka 刪除,最終我們在 Kafka 中看到的就是兩行記錄的最新狀態,而一個持續訂閱該流的消費者則能收到全部4條記錄。
這種行爲有一個有趣的名字,流表二相性(Stream Table Durability):Topic 中有無盡的變更消息不斷被寫入,這是流的特質;而 Topic 某一時刻的狀態,恰恰是該時刻對應的數據表的一個快照(參見上面的例子),每條新消息的到來相當於一次 Upsert,這又是表的特性。落到實踐中來講,Log Compaction 對於我們的場景有一個重要應用:全量數據遷移與數據補償,我們可以直接編寫針對每條變更數據的處理程序,就能兼顧全量遷移與之後的增量同步兩個過程;而在數據異常時,我們可以重新回放整個 Kafka Topic —— 該 Topic 就是對應表的快照,針對上面的例子,我們回放時只會讀到最新的兩條消息,不需要讀全部四條消息也能保證數據正確。
關於 Kafka 作爲變更分發平臺,最後要說的就是消費順序的問題。大家都知道 Kafka 只能保證單個 Partition 內消息有序,而對於整個 Topic,消息是無序的。一般的認知是,數據變更的消費爲了邏輯的正確性,必須按序消費。按着這個邏輯,我們的 Topic 只能有單個 Partition,這就大大犧牲了 Kafka 的擴展性與吞吐量。其實這裏有一個誤區,對於數據庫變更抓取,我們只要保證 同一行記錄的變更有序 就足夠了。還是上面的例子,我們只需要保證對id:2
這行的 insert
消息先於 update
消息,該行數據最後就是正確的。而實現”同一行記錄變更有序”就簡單多了,Kafka Producer 對帶 key 的消息默認使用 key 的 hash 決定分片,因此只要用數據行的主鍵作爲消息的 key,所有該行的變更都會落到同一個 Parition 上,自然也就有序了。這有一個要求就是 CDC 模塊必須解析出變更數據的主鍵 —— 而這點 Debezium 已經幫助我們解決了。
七、統一數據格式
數據格式的選擇同樣十分重要。首先想到的當然是 json
, 目前最常見的消息格式,不僅易讀,開發也都對它十分熟悉。但 json
本身有一個很大的不足,那就是契約性太弱,它的結構可以隨意更改:試想假如有一個接口返回 String
,註釋上說這是個json
,那我們該怎麼編寫對應的調用代碼呢?是不是需要翻接口文檔,提前獲知這段 json
的 schema,然後才能開始編寫代碼,並且這段代碼隨時可能會因爲這段 json
的格式改變而 break。
在規模不大的系統中,這個問題並不顯著。但假如在一個擁有上千種數據格式的數據管道上工作,這個問題就會很麻煩,首先當你訂閱一個變更 topic 時,你完全處於懵逼狀態——不知道這個 topic 會給你什麼,當你經過文檔的洗禮與不斷地調試終於寫完了客戶端代碼,它又隨時會因爲 topic 中的消息格式變更而掛掉。
參考 Yelp 和 Linkedin 的選擇,我們決定使用 Apache Avro 作爲統一的數據格式。Avro 依賴模式 Schema 來實現數據結構定義,而 Schema 通常使用 json 格式進行定義,一個典型的 Schema 如下: 這裏要介紹一點背景知識,Avro 的一個重要特性就是支持 Schema 演化,它定義了一系列的演化規則,只要符合該規則,使用不同的 Schema 也能夠正常通信。也就是說,使用 Avro 作爲數據格式進行通信的雙方是有自由更迭 Schema 的空間的。
在我們的場景中,數據庫表的 Schema 變更會引起對應的變更數據 Schema 變更,而每次進行數據庫表 Schema 變更就更新下游消費端顯然是不可能的。所以這時候 Avro 的 Schema 演化機制就很重要了。我們做出約定,同一個 Topic 上傳輸的消息,其 Avro Schema 的變化必須符合演化規則,這麼一來,消費者一旦開始正常消費之後就不會因爲消息的 Schema 變化而掛掉。
八、應用總結
上圖展現了以變更分發平臺(Kafka) 爲中心的系統拓撲。其中有一些上面沒有涉及的點:我們使用 Kafka 的 MirrorMaker解決了跨數據中心問題,使用 Kafka Connect 集羣運行 Debezium 任務實現了高可用與調度能力。
我們再看看 Vimur 是如何解決數據遷移與同步問題的,下圖展示了一次典型的數據同步過程:
下圖是一次典型的數據遷移過程,數據遷移通常伴隨着服務拆分與分庫分表:
這裏其實同步任務的編寫是頗有講究的,因爲我們一般需要冗餘很多新的列到新表上,所以單個流中的數據是不夠的,這時有兩種方案:
- 反查數據庫:邏輯簡單,只要查詢所需要的冗餘列即可,但所有相關的列變動都要執行一次反查會對源庫造成額外壓力;
- Stream Join:Stream Join 通常需要額外存儲的支持,無論用什麼框架實現,最終效果是把反查壓力放到了框架依賴的額外存儲上;
這兩種方案見仁見智,Stream Join 邏輯雖然更復雜,但框架本身如 Flink、Kafka Stream 都提供了 DSL 簡化編寫。最終的選型實際上取決於需不需要把反查的壓力分散出去。
Vimur 的另一個深度應用是解決跨庫查詢,分庫分表後數據表 JOIN 操作將很難實現,通常我們都會查詢多個數據庫,然後在代碼中進行 JOIN。這種辦法雖然麻煩,但卻不是不採取的妥協策略(框架來做跨庫 JOIN ,可行但有害,因爲有很多性能陷阱必須手動編碼去避免)。然而有些場景這種辦法也很難解決,比如多表 INNER JOIN 後的分頁。這時我們採取的解決方案就是利用 Vimur 的變更數據,將需要 JOIN 的表聚合到搜索引擎或 NoSQL 中,以文檔的形式提供查詢。
除了上面的應用外,Vimur 還被我們應用於搜索索引的實時構建、業務事件通知等場景,並計劃服務於緩存刷新、響應式架構等場景。回顧當初的探索歷程,很多選擇可能不是最好的,但一定是反覆實踐後我們認爲最適合我們的。假如你也面臨複雜數據層中的數據同步、數據遷移、緩存刷新、二級索引構建等問題,不妨嘗試一下基於 CDC 的實時數據管道方案。