Canal介紹與應用

一、背景

早期,阿里巴巴B2B公司因爲存在杭州和美國雙機房部署,存在跨機房同步的業務需求。不過早期的數據庫同步業務,主要是基於trigger的方式獲取增量變更,不過從2010年開始,阿里系公司開始逐步的嘗試基於數據庫的日誌解析,獲取增量變更進行同步,由此衍生出了增量訂閱&消費的業務,從此開啓了一段新紀元。ps. 目前內部使用的同步,已經支持mysql5.x和oracle部分版本的日誌解析

基於日誌增量訂閱&消費支持的業務:

  1. 數據庫鏡像
  2. 數據庫實時備份
  3. 多級索引 (賣家和買家各自分庫索引)
  4. search build
  5. 業務cache刷新
  6. 價格變化等重要業務消息

二、項目介紹

名稱:canal [kə’næl]

譯意: 水道/管道/溝渠

語言: 純java開發

定位: 基於數據庫增量日誌解析,提供增量數據訂閱&消費,目前主要支持了mysql


工作原理

mysql主備複製實現

img
從上層來看,複製分成三步:

  1. master將改變記錄到二進制日誌(binary log)中(這些記錄叫做二進制日誌事件,binary log events,可以通過show binlog events進行查看);
  2. slave將master的binary log events拷貝到它的中繼日誌(relay log);
  3. slave重做中繼日誌中的事件,將改變反映它自己的數據。

canal的工作原理:

img

原理相對比較簡單:

  1. canal模擬mysql slave的交互協議,僞裝自己爲mysql slave,向mysql master發送dump協議
  2. mysql master收到dump請求,開始推送binary log給slave(也就是canal)
  3. canal解析binary log對象(原始爲byte流)

三、架構

img

說明:

  • server代表一個canal運行實例,對應於一個jvm
  • instance對應於一個數據隊列 (1個server對應1..n個instance)

instance模塊:

  • eventParser (數據源接入,模擬slave協議和master進行交互,協議解析)
  • eventSink (Parser和Store鏈接器,進行數據過濾,加工,分發的工作)
  • eventStore (數據存儲)
  • metaManager (增量訂閱&消費信息管理器)

知識科普

mysql的Binlay Log介紹

簡單點說:

  • mysql的binlog是多文件存儲,定位一個LogEvent需要通過binlog filename + binlog position,進行定位

  • mysql的binlog數據格式,按照生成的方式,主要分爲:statement-based、row-based、mixed。

    mysql> show variables like 'binlog_format';
    +---------------+-------+
    | Variable_name | Value |
    +---------------+-------+
    | binlog_format | ROW   |
    +---------------+-------+
    1 row in set (0.00 sec)

目前canal只能支持row模式的增量訂閱(statement只有sql,沒有數據,所以無法獲取原始的變更日誌)


EventParser設計

大致過程:

img

整個parser過程大致可分爲幾步:

  1. Connection獲取上一次解析成功的位置 (如果第一次啓動,則獲取初始指定的位置或者是當前數據庫的binlog位點)
  2. Connection建立鏈接,發送BINLOG_DUMP指令
    // 0. write command number
    // 1. write 4 bytes bin-log position to start at
    // 2. write 2 bytes bin-log flags
    // 3. write 4 bytes server id of the slave
    // 4. write bin-log file name
  3. Mysql開始推送Binaly Log
  4. 接收到的Binaly Log的通過Binlog parser進行協議解析,補充一些特定信息
    // 補充字段名字,字段類型,主鍵信息,unsigned類型處理
  5. 傳遞給EventSink模塊進行數據存儲,是一個阻塞操作,直到存儲成功
  6. 存儲成功後,定時記錄Binaly Log位置

mysql的Binlay Log網絡協議:

img

說明:


EventSink設計

img

說明:

  • 數據過濾:支持通配符的過濾模式,表名,字段內容等
  • 數據路由/分發:解決1:n (1個parser對應多個store的模式)
  • 數據歸併:解決n:1 (多個parser對應1個store)
  • 數據加工:在進入store之前進行額外的處理,比如join

數據1:n業務

爲了合理的利用數據庫資源, 一般常見的業務都是按照schema進行隔離,然後在mysql上層或者dao這一層面上,進行一個數據源路由,屏蔽數據庫物理位置對開發的影響,阿里系主要是通過cobar/tddl來解決數據源路由問題。

所以,一般一個數據庫實例上,會部署多個schema,每個schema會有由1個或者多個業務方關注

數據n:1業務

同樣,當一個業務的數據規模達到一定的量級後,必然會涉及到水平拆分和垂直拆分的問題,針對這些拆分的數據需要處理時,就需要鏈接多個store進行處理,消費的位點就會變成多份,而且數據消費的進度無法得到儘可能有序的保證。

所以,在一定業務場景下,需要將拆分後的增量數據進行歸併處理,比如按照時間戳/全局id進行排序歸併.


EventStore設計

  • \1. 目前僅實現了Memory內存模式,後續計劃增加本地file存儲,mixed混合模式
  • \2. 借鑑了Disruptor的RingBuffer的實現思路

RingBuffer設計:

img

定義了3個cursor

  • Put : Sink模塊進行數據存儲的最後一次寫入位置
  • Get : 數據訂閱獲取的最後一次提取位置
  • Ack : 數據消費成功的最後一次消費位置

借鑑Disruptor的RingBuffer的實現,將RingBuffer拉直來看:
img

實現說明:

  • Put/Get/Ack cursor用於遞增,採用long型存儲
  • buffer的get操作,通過取餘或者與操作。(與操作: cusor & (size - 1) , size需要爲2的指數,效率比較高)

Instance設計

img

instance代表了一個實際運行的數據隊列,包括了EventPaser,EventSink,EventStore等組件。

抽象了CanalInstanceGenerator,主要是考慮配置的管理方式:

  • manager方式: 和你自己的內部web console/manager系統進行對接。(目前主要是公司內部使用)
  • spring方式:基於spring xml + properties進行定義,構建spring配置.

Server設計

img

server代表了一個canal的運行實例,爲了方便組件化使用,特意抽象了Embeded(嵌入式) / Netty(網絡訪問)的兩種實現

  • Embeded : 對latency和可用性都有比較高的要求,自己又能hold住分佈式的相關技術(比如failover)
  • Netty : 基於netty封裝了一層網絡協議,由canal server保證其可用性,採用的pull模型,當然latency會稍微打點折扣,不過這個也視情況而定。(阿里系的notify和metaq,典型的push/pull模型,目前也逐步的在向pull模型靠攏,push在數據量大的時候會有一些問題)

增量訂閱/消費設計

img

具體的協議格式,可參見:CanalProtocol.proto

get/ack/rollback協議介紹:

  • Message getWithoutAck(int batchSize),允許指定batchSize,一次可以獲取多條,每次返回的對象爲Message,包含的內容爲:
    a. batch id 唯一標識
    b. entries 具體的數據對象,對應的數據對象格式:EntryProtocol.proto
  • void rollback(long batchId),顧命思議,回滾上次的get請求,重新獲取數據。基於get獲取的batchId進行提交,避免誤操作
  • void ack(long batchId),顧命思議,確認已經消費成功,通知server刪除數據。基於get獲取的batchId進行提交,避免誤操作

canal的get/ack/rollback協議和常規的jms協議有所不同,允許get/ack異步處理,比如可以連續調用get多次,後續異步按順序提交ack/rollback,項目中稱之爲流式api.

流式api設計的好處:

  • get/ack異步化,減少因ack帶來的網絡延遲和操作成本 (99%的狀態都是處於正常狀態,異常的rollback屬於個別情況,沒必要爲個別的case犧牲整個性能)
  • get獲取數據後,業務消費存在瓶頸或者需要多進程/多線程消費時,可以不停的輪詢get數據,不停的往後發送任務,提高並行化. (作者在實際業務中的一個case:業務數據消費需要跨中美網絡,所以一次操作基本在200ms以上,爲了減少延遲,所以需要實施並行化)

流式api設計:

img

  • 每次get操作都會在meta中產生一個mark,mark標記會遞增,保證運行過程中mark的唯一性
  • 每次的get操作,都會在上一次的mark操作記錄的cursor繼續往後取,如果mark不存在,則在last ack cursor繼續往後取
  • 進行ack時,需要按照mark的順序進行數序ack,不能跳躍ack. ack會刪除當前的mark標記,並將對應的mark位置更新爲last ack cusor
  • 一旦出現異常情況,客戶端可發起rollback情況,重新置位:刪除所有的mark, 清理get請求位置,下次請求會從last ack cursor繼續往後取

數據對象格式:EntryProtocol.proto

Entry
    Header
        logfileName [binlog文件名]
        logfileOffset [binlog position]
        executeTime [發生的變更]
        schemaName 
        tableName
        eventType [insert/update/delete類型]
    entryType   [事務頭BEGIN/事務尾END/數據ROWDATA]
    storeValue  [byte數據,可展開,對應的類型爲RowChange]
RowChange
isDdl       [是否是ddl變更操作,比如create table/drop table]
sql     [具體的ddl sql]
rowDatas    [具體insert/update/delete的變更數據,可爲多條,1個binlog event事件可對應多條變更,比如批處理]
beforeColumns [Column類型的數組]
afterColumns [Column類型的數組]
Column
index
sqlType     [jdbc type]
name        [column name]
isKey       [是否爲主鍵]
updated     [是否發生過變更]
isNull      [值是否爲null]
value       [具體的內容,注意爲文本]

說明:

  • 可以提供數據庫變更前和變更後的字段內容,針對binlog中沒有的name,isKey等信息進行補全
  • 可以提供ddl的變更語句

HA機制設計

canal的ha分爲兩部分,canal server和canal client分別有對應的ha實現

  • canal server: 爲了減少對mysql dump的請求,不同server上的instance要求同一時間只能有一個處於running,其他的處於standby狀態.
  • canal client: 爲了保證有序性,一份instance同一時間只能由一個canal client進行get/ack/rollback操作,否則客戶端接收無法保證有序。

整個HA機制的控制主要是依賴了zookeeper的幾個特性,watcher和EPHEMERAL節點(和session生命週期綁定),可以看下我之前zookeeper的相關文章。

Canal Server:

img
大致步驟:

  1. canal server要啓動某個canal instance時都先向zookeeper進行一次嘗試啓動判斷 (實現:創建EPHEMERAL節點,誰創建成功就允許誰啓動)
  2. 創建zookeeper節點成功後,對應的canal server就啓動對應的canal instance,沒有創建成功的canal instance就會處於standby狀態
  3. 一旦zookeeper發現canal server A創建的節點消失後,立即通知其他的canal server再次進行步驟1的操作,重新選出一個canal server啓動instance.
  4. canal client每次進行connect時,會首先向zookeeper詢問當前是誰啓動了canal instance,然後和其建立鏈接,一旦鏈接不可用,會重新嘗試connect.

Canal Client的方式和canal server方式類似,也是利用zokeeper的搶佔EPHEMERAL節點的方式進行控制.


四、異構系統

數據量大了之後,必然會存在分庫分表,甚至database都要分散到多臺服務器上。誰也不知道下一個業務會是一個怎樣的奇葩,所以必然會導致你要做一些跨服務器join查詢,DBA很可能早就把跨服務器查詢的函數給你

關掉了,求爹爹拜奶奶都不會給你開的,除非你殺一個DBA祭天,不過如果你的業務真的很重要,可能DBA會給你做數據異構,所謂的數據異構,那就是:

將需要join查詢的多表按照某一個維度又聚合在一個DB中讓你去查詢

這裏寫圖片描述

下面的代碼對table的CURD都做了一個基本的判斷,看看是不是能夠智能感知,然後可以根據實際情況進行異構表的更新操作:

package com.datamip.canal;

import java.awt.Event;
import java.net.InetSocketAddress;
import java.util.List;

import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.Column;
import com.alibaba.otter.canal.protocol.CanalEntry.Entry;
import com.alibaba.otter.canal.protocol.CanalEntry.EntryType;
import com.alibaba.otter.canal.protocol.CanalEntry.EventType;
import com.alibaba.otter.canal.protocol.CanalEntry.Header;
import com.alibaba.otter.canal.protocol.CanalEntry.RowChange;
import com.alibaba.otter.canal.protocol.Message;
import com.google.protobuf.InvalidProtocolBufferException;

public class App {

    public static void main(String[] args) throws InterruptedException {

        // 第一步:與canal進行連接
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress("192.168.23.170", 11111),
                "example", "", "");
        connector.connect();

        // 第二步:開啓訂閱
        connector.subscribe();

        // 第三步:循環訂閱
        while (true) {
            try {
                // 每次讀取 1000 條
                Message message = connector.getWithoutAck(1000);

                long batchID = message.getId();

                int size = message.getEntries().size();

                if (batchID == -1 || size == 0) {
                    System.out.println("當前暫時沒有數據");
                    Thread.sleep(1000); // 沒有數據
                } else {
                    System.out.println("-------------------------- 有數據啦 -----------------------");
                    PrintEntry(message.getEntries());
                }

                // position id ack (方便處理下一條)
                connector.ack(batchID);

            } catch (Exception e) {
                // TODO: handle exception

            } finally {
                Thread.sleep(1000);
            }
        }
    }

    // 獲取每條打印的記錄
    @SuppressWarnings("static-access")
    public static void PrintEntry(List<Entry> entrys) {

        for (Entry entry : entrys) {

            // 第一步:拆解entry 實體
            Header header = entry.getHeader();
            EntryType entryType = entry.getEntryType();

            // 第二步: 如果當前是RowData,那就是我需要的數據
            if (entryType == EntryType.ROWDATA) {

                String tableName = header.getTableName();
                String schemaName = header.getSchemaName();

                RowChange rowChange = null;

                try {
                    rowChange = RowChange.parseFrom(entry.getStoreValue());
                } catch (InvalidProtocolBufferException e) {
                    e.printStackTrace();
                }

                EventType eventType = rowChange.getEventType();

                System.out.println(String.format("當前正在操作 %s.%s, Action= %s", schemaName, tableName, eventType));

                // 如果是‘查詢’ 或者 是 ‘DDL’ 操作,那麼sql直接打出來
                if (eventType == EventType.QUERY || rowChange.getIsDdl()) {
                    System.out.println("rowchange sql ----->" + rowChange.getSql());
                    return;
                }

                // 第三步:追蹤到 columns 級別
                rowChange.getRowDatasList().forEach((rowData) -> {

                    // 獲取更新之前的column情況
                    List<Column> beforeColumns = rowData.getBeforeColumnsList();

                    // 獲取更新之後的 column 情況
                    List<Column> afterColumns = rowData.getAfterColumnsList();

                    // 當前執行的是 刪除操作
                    if (eventType == EventType.DELETE) {
                        PrintColumn(beforeColumns);
                    }

                    // 當前執行的是 插入操作
                    if (eventType == eventType.INSERT) {
                        PrintColumn(afterColumns);
                    }

                    // 當前執行的是 更新操作
                    if (eventType == eventType.UPDATE) {
                        PrintColumn(afterColumns);
                    }
                });
            }
        }
    }

    // 每個row上面的每一個column 的更改情況
    public static void PrintColumn(List<Column> columns) {

        columns.forEach((column) -> {

            String columnName = column.getName();
            String columnValue = column.getValue();
            String columnType = column.getMysqlType();
            boolean isUpdated = column.getUpdated(); // 判斷 該字段是否更新

            System.out.println(String.format("columnName=%s, columnValue=%s, columnType=%s, isUpdated=%s", columnName,
                    columnValue, columnType, isUpdated));

        });

    }
}
發佈了94 篇原創文章 · 獲贊 43 · 訪問量 12萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章