架構師必備:巧用Canal實現非同步、解耦的架構

Java烘焙師 發表於 2021-11-27
架構師

本文介紹如何應用Canal實現非同步、解耦的架構,後續有空再寫文章分析Canal原理和原始碼。

Canal簡介

Canal是用來獲取資料庫變更的中介軟體。
偽裝自己為MySQL從庫,拉取主庫binlog並解析、處理。處理結果可傳送給MQ,方便其他服務獲取資料庫變更訊息,這一點非常有用。下面介紹一些典型用途。

架構師必備:巧用Canal實現非同步、解耦的架構

其中,Canal+MQ作為一個整體,從外界看來就是一個資料管道服務服務,如下圖。
架構師必備:巧用Canal實現非同步、解耦的架構

Canal典型用途

異構資料(如ES、HBase、不同路由key的DB)

通過Canal自帶的adapter,同步異構資料至ES、HBase,而不用自行實現繁瑣的資料轉換、同步操作。這裡的adapter就是典型的介面卡模式,把資料轉成相應格式,並寫入異構的儲存系統。

架構師必備:巧用Canal實現非同步、解耦的架構

當然,也可以同步資料至DB,甚至構建一份按不同欄位分片路由的資料庫。
比如:下單時按使用者id分庫分表訂單記錄,然後藉助Canal資料通道,構建一份按商家id分庫分表的訂單記錄,用於B端業務(如商家查詢自己接到哪些訂單)。

架構師必備:巧用Canal實現非同步、解耦的架構

快取重新整理

快取重新整理的常規做法是,先更新DB,再刪除快取,再延遲刪除(即cache-aside pattern+延遲雙刪),這種多步操作可能失敗,而且實現相對複雜。藉助Canal重新整理快取,使主服務、主流程無需關心快取更新等一致性問題,保證最終一致性。
架構師必備:巧用Canal實現非同步、解耦的架構

價格變化等重要業務訊息

下游服務可立即感知價格變化。
常規做法是,先修改價格,再發出訊息,此處的難點是要保證訊息一定傳送成功,以及如果傳送不成功時如何處理。藉助Canal,不用在業務層面擔心訊息丟失的問題。

資料庫遷移

  • 多機房資料同步
  • 拆庫
    雖然可以自己在程式碼中實現雙寫邏輯,然後對歷史資料做處理,但是歷史資料也可能被更新,需要不斷迭代對比、更新,總之很複雜。

實時對賬

常規做法是定時任務跑對賬邏輯,時效性低,不能及時發現不一致問題。藉助Canal,可實時觸發對賬邏輯。
大致流程如下:

  • 接收資料變更訊息
  • 寫入hbase作為流水記錄
  • 一段視窗時間過後,觸發比較與對端資料做比較

Canal客戶端demo程式碼分析

以下示例是客戶端連線Canal的例子,修改自官方github示例,樓主做了一些優化,並且在關鍵程式碼行中加入了註釋。如果Canal把資料變更訊息傳送至MQ,寫法有所不同,不同之處只是一個是訂閱Canal,一個是訂閱MQ,但是解析和處理邏輯基本相同。

`

public void process() {
    // 每批次處理的條數
    int batchSize = 1024;
    while (running) {
        try {
            // 連上Canal服務
            connector.connect();
            // 訂閱資料(比如某個表)
            connector.subscribe("table_xxx");
            while (running) {
                // 批量獲取資料變更記錄
                Message message = connector.getWithoutAck(batchSize);
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    // 非預期情況,需做異常處理
                } else {
                    // 列印資料變更明細
                    printEntry(message.getEntries());
                }

                if (batchId != -1) {
                    // 使用batchId做ack操作:表明該批次處理完成,更新Canal側消費進度
                    connector.ack(batchId);
                }
            }
        } catch (Throwable e) {
            logger.error("process error!", e);
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e1) {
                // ignore
            }

            // 處理失敗, 回滾進度
            connector.rollback();
        } finally {
            // 斷開連線
            connector.disconnect();
        }
    }
}

private void printEntry(List<Entry> entrys) {
    for (Entry entry : entrys) {
        long executeTime = entry.getHeader().getExecuteTime();
        long delayTime = new Date().getTime() - executeTime;
        Date date = new Date(entry.getHeader().getExecuteTime());
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

        // 只關心資料變更的型別
        if (entry.getEntryType() == EntryType.ROWDATA) {
            RowChange rowChange = null;
            try {
                // 解析資料變更物件
                rowChange = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("parse event has an error , data:" + entry.toString(), e);
            }

            EventType eventType = rowChange.getEventType();

            logger.info(row_format,
                new Object[] { entry.getHeader().getLogfileName(),
                        String.valueOf(entry.getHeader().getLogfileOffset()), entry.getHeader().getSchemaName(),
                        entry.getHeader().getTableName(), eventType,
                        String.valueOf(entry.getHeader().getExecuteTime()), simpleDateFormat.format(date),
                        entry.getHeader().getGtid(), String.valueOf(delayTime) });

            // 不關心查詢,和DDL變更
            if (eventType == EventType.QUERY || rowChange.getIsDdl()) {
                logger.info("ddl : " + rowChange.getIsDdl() + " ,  sql ----> " + rowChange.getSql() + SEP);
                continue;
            }

            for (RowData rowData : rowChange.getRowDatasList()) {
                if (eventType == EventType.DELETE) {
                    // 資料變更型別為 刪除 時,列印變化前的列值
                    printColumn(rowData.getBeforeColumnsList());
                } else if (eventType == EventType.INSERT) {
                    // 資料變更型別為 插入 時,列印變化後的列值
                    printColumn(rowData.getAfterColumnsList());
                } else {
                    // 資料變更型別為 其他(即更新) 時,列印變化前後的列值
                    printColumn(rowData.getBeforeColumnsList());
                    printColumn(rowData.getAfterColumnsList());
                }
            }
        }
    }
}

// 列印列值
private void printColumn(List<Column> columns) {
    for (Column column : columns) {
        StringBuilder builder = new StringBuilder();
        try {
            if (StringUtils.containsIgnoreCase(column.getMysqlType(), "BLOB")
                || StringUtils.containsIgnoreCase(column.getMysqlType(), "BINARY")) {
                // get value bytes
                builder.append(column.getName() + " : "
                               + new String(column.getValue().getBytes("ISO-8859-1"), "UTF-8"));
            } else {
                builder.append(column.getName() + " : " + column.getValue());
            }
        } catch (UnsupportedEncodingException e) {
        }
        builder.append("    type=" + column.getMysqlType());
        if (column.getUpdated()) {
            builder.append("    update=" + column.getUpdated());
        }
        builder.append(SEP);
        logger.info(builder.toString());
    }
}

`