萬字長文解密資料異構最佳實踐(含完整程式碼實現)!!

冰河團隊發表於2021-07-04

大家好,我是冰河~~

在當今網際網路行業,尤其是現在分散式、微服務開發環境下,為了提高搜尋效率,以及搜尋的精準度,會大量使用Redis、Memcached等NoSQL資料庫,也會使用大量的Solr、Elasticsearch等全文檢索服務和搜尋引擎。那麼,這個時候,就會有一個問題需要我們來思考和解決:那就是資料同步的問題!如何將實時變化的資料庫中的資料同步到Redis/Memcached或者Solr/Elasticsearch中呢?

網際網路背景下的資料同步需求

在當今網際網路行業,尤其是現在分散式、微服務開發環境下,為了提高搜尋效率,以及搜尋的精準度,會大量使用Redis、Memcached等NoSQL資料庫,也會使用大量的Solr、Elasticsearch等全文檢索服務。那麼,這個時候,就會有一個問題需要我們來思考和解決:那就是資料同步的問題!如何將實時變化的資料庫中的資料同步到Redis/Memcached或者Solr/Elasticsearch中呢?

例如,我們在分散式環境下向資料庫中不斷的寫入資料,而我們讀資料可能需要從Redis、Memcached或者Elasticsearch、Solr等服務中讀取。那麼,資料庫與各個服務中資料的實時同步問題,成為了我們亟待解決的問題。

試想,由於業務需要,我們引入了Redis、Memcached或者Elasticsearch、Solr等服務。使得我們的應用程式可能會從不同的服務中讀取資料,如下圖所示。

img

本質上講,無論我們引入了何種服務或者中介軟體,資料最終都是從我們的MySQL資料庫中讀取出來的。那麼,問題來了,如何將MySQL中的資料實時同步到其他的服務或者中介軟體呢?

注意:為了更好的說明問題,後面的內容以MySQL資料庫中的資料同步到Solr索引庫為例進行說明。

資料同步解決方案

1.在業務程式碼中同步

在增加、修改、刪除之後,執行操作Solr索引庫的邏輯程式碼。例如下面的程式碼片段。

public ResponseResult updateStatus(Long[] ids, String status){
    try{
        goodsService.updateStatus(ids, status);
        if("status_success".equals(status)){
            List<TbItem> itemList = goodsService.getItemList(ids, status);
            itemSearchService.importList(itemList);
            return new ResponseResult(true, "修改狀態成功")
        }
    }catch(Exception e){
        return new ResponseResult(false, "修改狀態失敗");
    }
}

優點:

操作簡便。

缺點:

業務耦合度高。

執行效率變低。

2.定時任務同步

在資料庫中執行完增加、修改、刪除操作後,通過定時任務定時的將資料庫的資料同步到Solr索引庫中。

定時任務技術有:SpringTask,Quartz。

哈哈,還有我開源的mykit-delay框架,開源地址為:https://github.com/sunshinelyz/mykit-delay。

這裡執行定時任務時,需要注意的一個技巧是:第一次執行定時任務時,從MySQL資料庫中以時間欄位進行倒序排列查詢相應的資料,並記錄當前查詢資料的時間欄位的最大值,以後每次執行定時任務查詢資料的時候,只要按時間欄位倒序查詢資料表中的時間欄位大於上次記錄的時間值的資料,並且記錄本次任務查詢出的時間欄位的最大值即可,從而不需要再次查詢資料表中的所有資料。

注意:這裡所說的時間欄位指的是標識資料更新的時間欄位,也就是說,使用定時任務同步資料時,為了避免每次執行任務都會進行全表掃描,最好是在資料表中增加一個更新記錄的時間欄位。

優點:

同步Solr索引庫的操作與業務程式碼完全解耦。

缺點:

資料的實時性並不高。

3.通過MQ實現同步

在資料庫中執行完增加、修改、刪除操作後,向MQ中傳送一條訊息,此時,同步程式作為MQ中的消費者,從訊息佇列中獲取訊息,然後執行同步Solr索引庫的邏輯。

我們可以使用下圖來簡單的標識通過MQ實現資料同步的過程。

img

我們可以使用如下程式碼實現這個過程。

public ResponseResult updateStatus(Long[] ids, String status){
    try{
        goodsService.updateStatus(ids, status);
        if("status_success".equals(status)){
            List<TbItem> itemList = goodsService.getItemList(ids, status);
            final String jsonString = JSON.toJSONString(itemList);
            jmsTemplate.send(queueSolr, new MessageCreator(){
                @Override
                public Message createMessage(Session session) throws JMSException{
                    return session.createTextMessage(jsonString);
                }
            });
        }
        return new ResponseResult(true, "修改狀態成功");
    }catch(Exception e){
        return new ResponseResult(false, "修改狀態失敗");
    }
}

優點:

業務程式碼解耦,並且能夠做到準實時。

缺點:

需要在業務程式碼中加入傳送訊息到MQ的程式碼,資料呼叫介面耦合。

4.通過Canal實現實時同步

Canal是阿里巴巴開源的一款資料庫日誌增量解析元件,通過Canal來解析資料庫的日誌資訊,來檢測資料庫中表結構和資料的變化,從而更新Solr索引庫。

使用Canal可以做到業務程式碼完全解耦,API完全解耦,可以做到準實時。

Canal簡介

阿里巴巴MySQL資料庫binlog增量訂閱與消費元件,基於資料庫增量日誌解析,提供增量資料訂閱與消費,目前主要支援了MySQL。

Canal開源地址:https://github.com/alibaba/canal。

Canal工作原理

MySQL主從複製的實現

img

從上圖可以看出,主從複製主要分成三步:

  • Master節點將資料的改變記錄到二進位制日誌(binary log)中(這些記錄叫做二進位制日誌事件,binary log events,可以通過show binlog events進行檢視)。
  • Slave節點將Master節點的二進位制日誌事件(binary log events)拷貝到它的中繼日誌(relay log)。
  • Slave節點重做中繼日誌中的事件將改變反映到自己本身的資料庫中。

Canal內部原理

首先,我們來看下Canal的原理圖,如下所示。

img

原理大致描述如下:

  • Canal 模擬 MySQL slave 的互動協議,偽裝自己為 MySQL Slave ,向 MySQL Master 傳送dump 協議
  • MySQL Master 收到 dump 請求,開始推送 binary log 給 Slave (即 Canal )
  • Canal 解析 binary log 物件(原始為 byte 流)

Canal內部結構

img

說明如下:

  • Server:代表一個Canal執行例項,對應一個JVM程式。
  • Instance:對應一個資料佇列(1個Server對應1個或者多個Instance)。

接下來,我們再來看下Instance下的子模組,如下所示。

img

  • EventParser:資料來源接入,模擬Slave協議和Master節點進行互動,協議解析。
  • EventSink:EventParser和EventStore的聯結器,對資料進行過濾、加工、歸併和分發等處理。
  • EventSore:資料儲存。
  • MetaManager:增量訂閱和消費資訊管理。

Canal環境準備

設定MySQL遠端訪問

grant all privileges on *.* to 'root'@'%' identified by '123456';
flush privileges;

MySQL配置

注意:這裡的MySQL是基於5.7版本進行說明的。

Canal的原理基於MySQL binlog技術,所以,要想使用Canal就要開啟MySQL的binlog寫入功能,建議配置binlog的模式為row。

可以在MySQL命令列輸入如下命令來檢視binlog的模式。

SHOW VARIABLES LIKE 'binlog_format';

執行效果如下所示。

img

可以看到,在MySQL中預設的binlog格式為STATEMENT,這裡我們需要將STATEMENT修改為ROW。修改/etc/my.cnf檔案。

vim /etc/my.cnf

在[mysqld]下面新增如下三項配置。

log-bin=mysql-bin  #開啟MySQL二進位制日誌
binlog_format=ROW #將二進位制日誌的格式設定為ROW
server_id=1 #server_id需要唯一,不能與Canal的slaveId重複

修改完my.cnf檔案後,需要重啟MySQL服務。

service mysqld restart

接下來,我們再次檢視binlog模式。

SHOW VARIABLES LIKE 'binlog_format';

img

可以看到,此時,MySQL的binlog模式已經被設定為ROW了。

MySQL建立使用者授權

Canal的原理是模式自己為MySQL Slave,所以一定要設定MySQL Slave的相關許可權。這裡,需要建立一個主從同步的賬戶,並且賦予這個賬戶相關的許可權。

CREATE USER canal@'localhost' IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'localhost';
FLUSH PRIVILEGES;

img

Canal部署安裝

下載Canal

這裡,我們以Canal 1.1.1版本進行說明,小夥伴們可以到連結 https://github.com/alibaba/canal/releases/tag/canal-1.1.1 下載Canal 1.1.1版本。

img

上傳解壓

將下載好的Canal安裝包,上傳到伺服器,並執行如下命令進行解壓

mkdir -p /usr/local/canal
tar -zxvf canal.deployer-1.1.1.tar.gz -C /usr/local/canal/

解壓後的目錄如下所示。

img

各目錄的說明如下:

  • bin:儲存可執行指令碼。
  • conf:存放配置檔案。
  • lib:存放其他依賴或者第三方庫。
  • logs:存放的是日誌檔案。

修改配置檔案

在Canal的conf目錄下有一個canal.properties檔案,這個檔案中配置的是Canal Server相關的配置,在這個檔案中有如下一行配置。

canal.destinations=example

這裡的example就相當於Canal的一個Instance,可以在這裡配置多個Instance,多個Instance之間以逗號分隔即可。同時,這裡的example也對應著Canal的conf目錄下的一個資料夾。也就是說,Canal中的每個Instance例項都對應著conf目錄下的一個子目錄。

接下來,我們需要修改Canal的conf目錄下的example目錄的一個配置檔案instance.properties。

vim instance.properties

修改如下配置項。

#################################################################
## canal slaveId,注意:不要與MySQL的server_id重複
canal.instance.mysql.slaveId = 1234

#position info,需要改成自己的資料庫資訊
canal.instance.master.address = 127.0.0.1:3306
canal.instance.master.journal.name =
canal.instance.master.position =
canal.instance.master.timestamp =

#canal.instance.standby.address =
#canal.instance.standby.journal.name =
#canal.instance.standby.position =
#canal.instance.standby.timestamp =

#username/password,需要改成自己的資料庫資訊
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal
canal.instance.defaultDatabaseName =canaldb
canal.instance.connectionCharset = UTF-8

#table regex
canal.instance.filter.regex = canaldb\\..*
#################################################################

選項含義:

  • canal.instance.mysql.slaveId : mysql叢集配置中的serverId概念,需要保證和當前mysql叢集中id唯一;
  • canal.instance.master.address: mysql主庫連結地址;
  • canal.instance.dbUsername : mysql資料庫帳號;
  • canal.instance.dbPassword : mysql資料庫密碼;
  • canal.instance.defaultDatabaseName : mysql連結時預設資料庫;
  • canal.instance.connectionCharset : mysql 資料解析編碼;
  • canal.instance.filter.regex : mysql 資料解析關注的表,Perl正規表示式.

啟動Canal

配置完Canal後,就可以啟動Canal了。進入到Canal的bin目錄下,輸入如下命令啟動Canal。

./startup.sh

測試Canal

匯入並修改原始碼

這裡,我們使用Canal的原始碼進行測試,下載Canal的原始碼後,將其匯入到IDEA中。

img

接下來,我們找到example下的SimpleCanalClientTest類進行測試。這個類的原始碼如下所示。

package com.alibaba.otter.canal.example;

import java.net.InetSocketAddress;

import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.common.utils.AddressUtils;

/**
 * 單機模式的測試例子
 * 
 * @author jianghang 2013-4-15 下午04:19:20
 * @version 1.0.4
 */
public class SimpleCanalClientTest extends AbstractCanalClientTest {

    public SimpleCanalClientTest(String destination){
           super(destination);
     }

    public static void main(String args[]) {
        // 根據ip,直接建立連結,無HA的功能
        String destination = "example";
        String ip = AddressUtils.getHostIp();
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress(ip, 11111),
                destination,
                "canal",
                "canal");

        final SimpleCanalClientTest clientTest = new SimpleCanalClientTest(destination);
        clientTest.setConnector(connector);
        clientTest.start();
        Runtime.getRuntime().addShutdownHook(new Thread() {

            public void run() {
                try {
                    logger.info("## stop the canal client");
                    clientTest.stop();
                } catch (Throwable e) {
                    logger.warn("##something goes wrong when stopping canal:", e);
                } finally {
                    logger.info("## canal client is down.");
                }
            }

        });
    }
}

可以看到,這個類中,使用的destination為example。在這個類中,我們只需要將IP地址修改為Canal Server的IP即可。

具體為:將如下一行程式碼。

String ip = AddressUtils.getHostIp();

修改為:

String ip = "192.168.175.100"

由於我們在配置Canal時,沒有指定使用者名稱和密碼,所以,我們還需要將如下程式碼。

CanalConnector connector = CanalConnectors.newSingleConnector(
    new InetSocketAddress(ip, 11111),
    destination,
    "canal",
    "canal");

修改為:

CanalConnector connector = CanalConnectors.newSingleConnector(
    new InetSocketAddress(ip, 11111),
    destination,
    "",
    "");

修改完成後,執行main方法啟動程式。

測試資料變更

接下來,在MySQL中建立一個canaldb資料庫。

create database canaldb;

此時會在IDEA的命令列輸出相關的日誌資訊。

****************************************************
* Batch Id: [7] ,count : [3] , memsize : [149] , Time : 2020-08-05 23:25:35
* Start : [mysql-bin.000007:6180:1540286735000(2020-08-05 23:25:35)] 
* End : [mysql-bin.000007:6356:1540286735000(2020-08-05 23:25:35)] 
****************************************************

接下來,我在canaldb資料庫中建立資料表,並對資料表中的資料進行增刪改查,程式輸出的日誌資訊如下所示。

#在mysql進行資料變更後,這裡會顯示mysql的bin日誌。
****************************************************
* Batch Id: [7] ,count : [3] , memsize : [149] , Time : 2020-08-05 23:25:35
* Start : [mysql-bin.000007:6180:1540286735000(2020-08-05 23:25:35)] 
* End : [mysql-bin.000007:6356:1540286735000(2020-08-05 23:25:35)] 
****************************************************

================> binlog[mysql-bin.000007:6180] , executeTime : 1540286735000(2020-08-05 23:25:35) , gtid : () , delay : 393ms
 BEGIN ----> Thread id: 43
----------------> binlog[mysql-bin.000007:6311] , name[canal,canal_table] , eventType : DELETE , executeTime : 1540286735000(2020-08-05 23:25:35) , gtid : () , delay : 393 ms
id : 8    type=int(10) unsigned
name : 512    type=varchar(255)
----------------
 END ----> transaction id: 249
================> binlog[mysql-bin.000007:6356] , executeTime : 1540286735000(2020-08-05 23:25:35) , gtid : () , delay : 394ms

****************************************************
* Batch Id: [8] ,count : [3] , memsize : [149] , Time : 2020-08-05 23:25:35
* Start : [mysql-bin.000007:6387:1540286869000(2020-08-05 23:25:49)] 
* End : [mysql-bin.000007:6563:1540286869000(2020-08-05 23:25:49)] 
****************************************************

================> binlog[mysql-bin.000007:6387] , executeTime : 1540286869000(2020-08-05 23:25:49) , gtid : () , delay : 976ms
 BEGIN ----> Thread id: 43
----------------> binlog[mysql-bin.000007:6518] , name[canal,canal_table] , eventType : INSERT , executeTime : 1540286869000(2020-08-05 23:25:49) , gtid : () , delay : 976 ms
id : 21    type=int(10) unsigned    update=true
name : aaa    type=varchar(255)    update=true
----------------
 END ----> transaction id: 250
================> binlog[mysql-bin.000007:6563] , executeTime : 1540286869000(2020-08-05 23:25:49) , gtid : () , delay : 977ms

****************************************************
* Batch Id: [9] ,count : [3] , memsize : [161] , Time : 2020-08-05 23:26:22
* Start : [mysql-bin.000007:6594:1540286902000(2020-08-05 23:26:22)] 
* End : [mysql-bin.000007:6782:1540286902000(2020-08-05 23:26:22)] 
****************************************************

================> binlog[mysql-bin.000007:6594] , executeTime : 1540286902000(2020-08-05 23:26:22) , gtid : () , delay : 712ms
 BEGIN ----> Thread id: 43
----------------> binlog[mysql-bin.000007:6725] , name[canal,canal_table] , eventType : UPDATE , executeTime : 1540286902000(2020-08-05 23:26:22) , gtid : () , delay : 712 ms
id : 21    type=int(10) unsigned
name : aaac    type=varchar(255)    update=true
----------------
 END ----> transaction id: 252
================> binlog[mysql-bin.000007:6782] , executeTime : 1540286902000(2020-08-05 23:26:22) , gtid : () , delay : 713ms

資料同步實現

需求

將資料庫資料的變化, 通過canal解析binlog日誌, 實時更新到solr的索引庫中。

具體實現

建立工程

建立Maven工程mykit-canal-demo,並在pom.xml檔案中新增如下配置。

<dependencies>
    <dependency>
        <groupId>com.alibaba.otter</groupId>
        <artifactId>canal.client</artifactId>
        <version>1.0.24</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba.otter</groupId>
        <artifactId>canal.protocol</artifactId>
        <version>1.0.24</version>
    </dependency>
    <dependency>
        <groupId>commons-lang</groupId>
        <artifactId>commons-lang</artifactId>
        <version>2.6</version>
    </dependency>
    <dependency>
        <groupId>org.codehaus.jackson</groupId>
        <artifactId>jackson-mapper-asl</artifactId>
        <version>1.8.9</version>
    </dependency>

    <dependency>
        <groupId>org.apache.solr</groupId>
        <artifactId>solr-solrj</artifactId>
        <version>4.10.3</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.9</version>
        <scope>test</scope>
    </dependency>

</dependencies>

建立log4j配置檔案xml

在工程的src/main/resources目錄下建立log4j.properties檔案,內容如下所示。

log4j.rootCategory=debug, CONSOLE

# CONSOLE is set to be a ConsoleAppender using a PatternLayout.
log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender
log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout
log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} %-6r [%15.15t] %-5p %30.30c %x - %m\n

# LOGFILE is set to be a File appender using a PatternLayout.
# log4j.appender.LOGFILE=org.apache.log4j.FileAppender
# log4j.appender.LOGFILE.File=d:\axis.log
# log4j.appender.LOGFILE.Append=true
# log4j.appender.LOGFILE.layout=org.apache.log4j.PatternLayout
# log4j.appender.LOGFILE.layout.ConversionPattern=%d{ISO8601} %-6r [%15.15t] %-5p %30.30c %x - %m\n

建立實體類

在io.mykit.canal.demo.bean包下建立一個Book實體類,用於測試Canal的資料傳輸,如下所示。

package io.mykit.canal.demo.bean;
import org.apache.solr.client.solrj.beans.Field;
import java.util.Date;
public class Book implements Serializable {
    private static final long serialVersionUID = -6350345408771427834L;{

    @Field("id")
    private Integer id;

    @Field("book_name")
    private String name;

    @Field("book_author")
    private String author;

    @Field("book_publishtime")
    private Date publishtime;

    @Field("book_price")
    private Double price;

    @Field("book_publishgroup")
    private String publishgroup;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public Date getPublishtime() {
        return publishtime;
    }

    public void setPublishtime(Date publishtime) {
        this.publishtime = publishtime;
    }

    public Double getPrice() {
        return price;
    }

    public void setPrice(Double price) {
        this.price = price;
    }

    public String getPublishgroup() {
        return publishgroup;
    }

    public void setPublishgroup(String publishgroup) {
        this.publishgroup = publishgroup;
    }

    @Override
    public String toString() {
        return "Book{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", author='" + author + '\'' +
                ", publishtime=" + publishtime +
                ", price=" + price +
                ", publishgroup='" + publishgroup + '\'' +
                '}';
    }
}

其中,我們在Book實體類中,使用Solr的註解@Field定義了實體類欄位與Solr域之間的關係。

各種工具類的實現

接下來,我們就在io.mykit.canal.demo.utils包下建立各種工具類。

  • BinlogValue

用於儲存binlog分析的每行每列的value值,程式碼如下所示。

package io.mykit.canal.demo.utils;
import java.io.Serializable;
/**
 * 
 * ClassName: BinlogValue <br/> 
 * 
 * binlog分析的每行每列的value值;<br>
 * 新增資料:beforeValue 和 value 均為現有值;<br>
 * 修改資料:beforeValue是修改前的值;value為修改後的值;<br>
 * 刪除資料:beforeValue和value均是刪除前的值; 這個比較特殊主要是為了刪除資料時方便獲取刪除前的值<br>
 */
public class BinlogValue implements Serializable {

    private static final long serialVersionUID = -6350345408773943086L;
    
    private String value;
    private String beforeValue;
    
    /**
     * binlog分析的每行每列的value值;<br>
     * 新增資料: value:為現有值;<br>
     * 修改資料:value為修改後的值;<br>
     * 刪除資料:value是刪除前的值; 這個比較特殊主要是為了刪除資料時方便獲取刪除前的值<br>
     */
    public String getValue() {
        return value;
    }
    public void setValue(String value) {
        this.value = value;
    }
    
    /**
     * binlog分析的每行每列的beforeValue值;<br>
     * 新增資料:beforeValue為現有值;<br>
     * 修改資料:beforeValue是修改前的值;<br>
     * 刪除資料:beforeValue為刪除前的值; <br>
     */
    public String getBeforeValue() {
        return beforeValue;
    }
    public void setBeforeValue(String beforeValue) {
        this.beforeValue = beforeValue;
    }
}
  • CanalDataParser

用於解析資料,程式碼如下所示。

package io.mykit.canal.demo.utils;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.commons.lang.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;

import com.alibaba.otter.canal.protocol.Message;
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.RowChange;
import com.alibaba.otter.canal.protocol.CanalEntry.RowData;
import com.alibaba.otter.canal.protocol.CanalEntry.TransactionBegin;
import com.alibaba.otter.canal.protocol.CanalEntry.TransactionEnd;
import com.google.protobuf.InvalidProtocolBufferException;

/**
 * 解析資料
 */
public class CanalDataParser {
    
    protected static final String DATE_FORMAT   = "yyyy-MM-dd HH:mm:ss";
    protected static final String yyyyMMddHHmmss = "yyyyMMddHHmmss";
    protected static final String yyyyMMdd      = "yyyyMMdd";
    protected static final String SEP           = SystemUtils.LINE_SEPARATOR;
    protected static String  context_format     = null;
    protected static String  row_format         = null;
    protected static String  transaction_format = null;
    protected static String row_log = null;
    
    private static Logger logger = LoggerFactory.getLogger(CanalDataParser.class);
    
    static {
        context_format = SEP + "****************************************************" + SEP;
        context_format += "* Batch Id: [{}] ,count : [{}] , memsize : [{}] , Time : {}" + SEP;
        context_format += "* Start : [{}] " + SEP;
        context_format += "* End : [{}] " + SEP;
        context_format += "****************************************************" + SEP;

        row_format = SEP
                     + "----------------> binlog[{}:{}] , name[{},{}] , eventType : {} , executeTime : {} , delay : {}ms"
                     + SEP;

        transaction_format = SEP + "================> binlog[{}:{}] , executeTime : {} , delay : {}ms" + SEP;

        row_log = "schema[{}], table[{}]";
    }

    public static List<InnerBinlogEntry> convertToInnerBinlogEntry(Message message) {
        List<InnerBinlogEntry> innerBinlogEntryList = new ArrayList<InnerBinlogEntry>();
        
        if(message == null) {
            logger.info("接收到空的 message; 忽略");
            return innerBinlogEntryList;
        }
        
        long batchId = message.getId();
        int size = message.getEntries().size();
        if (batchId == -1 || size == 0) {
            logger.info("接收到空的message[size=" + size + "]; 忽略");
            return innerBinlogEntryList;
        }

        printLog(message, batchId, size);
        List<Entry> entrys = message.getEntries();

        //輸出日誌
        for (Entry entry : entrys) {
            long executeTime = entry.getHeader().getExecuteTime();
            long delayTime = new Date().getTime() - executeTime;
            
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN) {
                    TransactionBegin begin = null;
                    try {
                        begin = TransactionBegin.parseFrom(entry.getStoreValue());
                    } catch (InvalidProtocolBufferException e) {
                        throw new RuntimeException("parse event has an error , data:" + entry.toString(), e);
                    }
                    // 列印事務頭資訊,執行的執行緒id,事務耗時
                    logger.info("BEGIN ----> Thread id: {}",  begin.getThreadId());
                    logger.info(transaction_format, new Object[] {entry.getHeader().getLogfileName(),
                                String.valueOf(entry.getHeader().getLogfileOffset()), String.valueOf(entry.getHeader().getExecuteTime()), String.valueOf(delayTime) });

                } else if (entry.getEntryType() == EntryType.TRANSACTIONEND) {
                    TransactionEnd end = null;
                    try {
                        end = TransactionEnd.parseFrom(entry.getStoreValue());
                    } catch (InvalidProtocolBufferException e) {
                        throw new RuntimeException("parse event has an error , data:" + entry.toString(), e);
                    }
                    // 列印事務提交資訊,事務id
                    logger.info("END ----> transaction id: {}", end.getTransactionId());
                    logger.info(transaction_format,
                        new Object[] {entry.getHeader().getLogfileName(),  String.valueOf(entry.getHeader().getLogfileOffset()),
                                String.valueOf(entry.getHeader().getExecuteTime()), String.valueOf(delayTime) });
                }
                continue;
            }

            //解析結果
            if (entry.getEntryType() == EntryType.ROWDATA) {
                RowChange rowChage = null;
                try {
                    rowChage = RowChange.parseFrom(entry.getStoreValue());
                } catch (Exception e) {
                    throw new RuntimeException("parse event has an error , data:" + entry.toString(), e);
                }

                EventType eventType = rowChage.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()), String.valueOf(delayTime) });

                //組裝資料結果
                if (eventType == EventType.INSERT || eventType == EventType.DELETE || eventType == EventType.UPDATE) {
                    String schemaName = entry.getHeader().getSchemaName();
                    String tableName = entry.getHeader().getTableName();
                    List<Map<String, BinlogValue>> rows = parseEntry(entry);

                    InnerBinlogEntry innerBinlogEntry = new InnerBinlogEntry();
                    innerBinlogEntry.setEntry(entry);
                    innerBinlogEntry.setEventType(eventType);
                    innerBinlogEntry.setSchemaName(schemaName);
                    innerBinlogEntry.setTableName(tableName.toLowerCase());
                    innerBinlogEntry.setRows(rows);

                    innerBinlogEntryList.add(innerBinlogEntry);
                } else {
                    logger.info(" 存在 INSERT INSERT UPDATE 操作之外的SQL [" + eventType.toString() + "]");
                }
                continue;
            }
        }
        return innerBinlogEntryList;
    }

    private static List<Map<String, BinlogValue>> parseEntry(Entry entry) {
        List<Map<String, BinlogValue>> rows = new ArrayList<Map<String, BinlogValue>>();
        try {
            String schemaName = entry.getHeader().getSchemaName();
            String tableName = entry.getHeader().getTableName();
            RowChange rowChage = RowChange.parseFrom(entry.getStoreValue());
            EventType eventType = rowChage.getEventType();

            // 處理每個Entry中的每行資料
            for (RowData rowData : rowChage.getRowDatasList()) {
                StringBuilder rowlog = new StringBuilder("rowlog schema[" + schemaName + "], table[" + tableName + "], event[" + eventType.toString() + "]");
                
                Map<String, BinlogValue> row = new HashMap<String, BinlogValue>();
                List<Column> beforeColumns = rowData.getBeforeColumnsList();
                List<Column> afterColumns = rowData.getAfterColumnsList();
                beforeColumns = rowData.getBeforeColumnsList();
                if (eventType == EventType.DELETE) {//delete
                    for(Column column : beforeColumns) {
                        BinlogValue binlogValue = new BinlogValue();
                        binlogValue.setValue(column.getValue());
                        binlogValue.setBeforeValue(column.getValue());
                        row.put(column.getName(), binlogValue);
                    }
                } else if(eventType == EventType.UPDATE) {//update
                    for(Column column : beforeColumns) {
                        BinlogValue binlogValue = new BinlogValue();
                        binlogValue.setBeforeValue(column.getValue());
                        row.put(column.getName(), binlogValue);
                    }
                    for(Column column : afterColumns) {
                        BinlogValue binlogValue = row.get(column.getName());
                        if(binlogValue == null) {
                            binlogValue = new BinlogValue();
                        }
                        binlogValue.setValue(column.getValue());
                        row.put(column.getName(), binlogValue);
                    }
                } else { // insert
                    for(Column column : afterColumns) {
                        BinlogValue binlogValue = new BinlogValue();
                        binlogValue.setValue(column.getValue());
                        binlogValue.setBeforeValue(column.getValue());
                        row.put(column.getName(), binlogValue);
                    }
                } 
               
                rows.add(row);
                String rowjson = JacksonUtil.obj2str(row);
                
                logger.info("#################################### Data Parse Result ####################################");
                logger.info(rowlog + " , " + rowjson);
                logger.info("#################################### Data Parse Result ####################################");
                logger.info("");
            }
        } catch (InvalidProtocolBufferException e) {
            throw new RuntimeException("parseEntry has an error , data:" + entry.toString(), e);
        }
        return rows;
    }

    private static void printLog(Message message, long batchId, int size) {
        long memsize = 0;
        for (Entry entry : message.getEntries()) {
            memsize += entry.getHeader().getEventLength();
        }

        String startPosition = null;
        String endPosition = null;
        if (!CollectionUtils.isEmpty(message.getEntries())) {
            startPosition = buildPositionForDump(message.getEntries().get(0));
            endPosition = buildPositionForDump(message.getEntries().get(message.getEntries().size() - 1));
        }

        SimpleDateFormat format = new SimpleDateFormat(DATE_FORMAT);
        logger.info(context_format, new Object[] {batchId, size, memsize, format.format(new Date()), startPosition, endPosition });
    }

    private static String buildPositionForDump(Entry entry) {
        long time = entry.getHeader().getExecuteTime();
        Date date = new Date(time);
        SimpleDateFormat format = new SimpleDateFormat(DATE_FORMAT);
        return entry.getHeader().getLogfileName() + ":" + entry.getHeader().getLogfileOffset() + ":" + entry.getHeader().getExecuteTime() + "(" + format.format(date) + ")";
    }
}
  • DateUtils

時間工具類,程式碼如下所示。

package io.mykit.canal.demo.utils;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class DateUtils {
    
    private static final String FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss";
    
    private static SimpleDateFormat sdf = new SimpleDateFormat(FORMAT_PATTERN);
    
    public static Date parseDate(String datetime) throws ParseException{
        if(datetime != null && !"".equals(datetime)){
            return sdf.parse(datetime);
        }
        return null;
    }
    
    
    public static String formatDate(Date datetime) throws ParseException{
        if(datetime != null ){
            return sdf.format(datetime);
        }
        return null;
    }
    
    public static Long formatStringDateToLong(String datetime) throws ParseException{
        if(datetime != null && !"".equals(datetime)){
            Date d =  sdf.parse(datetime);
            return d.getTime();
        }
        return null;
    }
    
    public static Long formatDateToLong(Date datetime) throws ParseException{
        if(datetime != null){
            return datetime.getTime();
        }
        return null;
    }
}
  • InnerBinlogEntry

Binlog實體類,程式碼如下所示。

package io.mykit.canal.demo.utils;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import com.alibaba.otter.canal.protocol.CanalEntry.Entry;
import com.alibaba.otter.canal.protocol.CanalEntry.EventType;

public class InnerBinlogEntry {
    
    /**
     * canal原生的Entry
     */
    private Entry entry;
    
    /**
     * 該Entry歸屬於的表名
     */
    private String tableName;
    
    /**
     * 該Entry歸屬資料庫名
     */
    private String schemaName;
    
    /**
     * 該Entry本次的操作型別,對應canal原生的列舉;EventType.INSERT; EventType.UPDATE; EventType.DELETE;
     */
    private EventType eventType;
    
    private List<Map<String, BinlogValue>> rows = new ArrayList<Map<String, BinlogValue>>();
    
    
    public Entry getEntry() {
        return entry;
    }
    public void setEntry(Entry entry) {
        this.entry = entry;
    }
    public String getTableName() {
        return tableName;
    }
    public void setTableName(String tableName) {
        this.tableName = tableName;
    }
    public EventType getEventType() {
        return eventType;
    }
    public void setEventType(EventType eventType) {
        this.eventType = eventType;
    }
    public String getSchemaName() {
        return schemaName;
    }
    public void setSchemaName(String schemaName) {
        this.schemaName = schemaName;
    }
    public List<Map<String, BinlogValue>> getRows() {
        return rows;
    }
    public void setRows(List<Map<String, BinlogValue>> rows) {
        this.rows = rows;
    }
}
  • JacksonUtil

Json工具類,程式碼如下所示。

package io.mykit.canal.demo.utils;

import java.io.IOException;
import org.codehaus.jackson.JsonGenerationException;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;


public class JacksonUtil {
    private static ObjectMapper mapper = new ObjectMapper();

    public static String obj2str(Object obj) {
        String json = null;
        try {
            json = mapper.writeValueAsString(obj);
        } catch (JsonGenerationException e) {
            e.printStackTrace();
        } catch (JsonMappingException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return json;
    }

    public static <T> T str2obj(String content, Class<T> valueType) {
        try {
            return mapper.readValue(content, valueType);
        } catch (JsonParseException e) {
            e.printStackTrace();
        } catch (JsonMappingException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

同步程式的實現

準備好實體類和工具類後,我們就可以編寫同步程式來實現MySQL資料庫中的資料實時同步到Solr索引庫了,我們在io.mykit.canal.demo.main包中常見MykitCanalDemoSync類,程式碼如下所示。

package io.mykit.canal.demo.main;

import io.mykit.canal.demo.bean.Book;
import io.mykit.canal.demo.utils.BinlogValue;
import io.mykit.canal.demo.utils.CanalDataParser;
import io.mykit.canal.demo.utils.DateUtils;
import io.mykit.canal.demo.utils.InnerBinlogEntry;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import org.apache.solr.client.solrj.SolrServer;
import org.apache.solr.client.solrj.impl.HttpSolrServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetSocketAddress;
import java.text.ParseException;
import java.util.List;
import java.util.Map;

public class SyncDataBootStart {

    private static Logger logger = LoggerFactory.getLogger(SyncDataBootStart.class);

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

        String hostname = "192.168.175.100";
        Integer port = 11111;
        String destination = "example";

        //獲取CanalServer 連線
        CanalConnector canalConnector = CanalConnectors.newSingleConnector(new InetSocketAddress(hostname, port), destination, "", "");

        //連線CanalServer
        canalConnector.connect();

        //訂閱Destination
        canalConnector.subscribe();

        //輪詢拉取資料
        Integer batchSize = 5*1024;
        while (true){
            Message message = canalConnector.getWithoutAck(batchSize);

            long messageId = message.getId();
            int size = message.getEntries().size();

            if(messageId == -1 || size == 0){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }else{
                //進行資料同步
                //1. 解析Message物件
                List<InnerBinlogEntry> innerBinlogEntries = CanalDataParser.convertToInnerBinlogEntry(message);

                //2. 將解析後的資料資訊 同步到Solr的索引庫中.
                syncDataToSolr(innerBinlogEntries);
            }

            //提交確認
            canalConnector.ack(messageId);

        }

    }
    private static void syncDataToSolr(List<InnerBinlogEntry> innerBinlogEntries) throws Exception {
        //獲取solr的連線
        SolrServer solrServer = new HttpSolrServer("http://192.168.175.101:8080/solr");

        //遍歷資料集合 , 根據資料集合中的資料資訊, 來決定執行增加, 修改 , 刪除操作 .
        if(innerBinlogEntries != null){
            for (InnerBinlogEntry innerBinlogEntry : innerBinlogEntries) {

                CanalEntry.EventType eventType = innerBinlogEntry.getEventType();

                //如果是Insert, update , 則需要同步資料到 solr 索引庫
                if(eventType == CanalEntry.EventType.INSERT || eventType == CanalEntry.EventType.UPDATE){
                    List<Map<String, BinlogValue>> rows = innerBinlogEntry.getRows();
                    if(rows != null){
                        for (Map<String, BinlogValue> row : rows) {
                            BinlogValue id = row.get("id");
                            BinlogValue name = row.get("name");
                            BinlogValue author = row.get("author");
                            BinlogValue publishtime = row.get("publishtime");
                            BinlogValue price = row.get("price");
                            BinlogValue publishgroup = row.get("publishgroup");

                            Book book = new Book();
                            book.setId(Integer.parseInt(id.getValue()));
                            book.setName(name.getValue());
                            book.setAuthor(author.getValue());
                            book.setPrice(Double.parseDouble(price.getValue()));
                            book.setPublishgroup(publishgroup.getValue());
                            book.setPublishtime(DateUtils.parseDate(publishtime.getValue()));


                            //匯入資料到solr索引庫
                            solrServer.addBean(book);
                            solrServer.commit();
                        }
                    }

                }else if(eventType == CanalEntry.EventType.DELETE){
                    //如果是Delete操作, 則需要刪除solr索引庫中的資料 .
                    List<Map<String, BinlogValue>> rows = innerBinlogEntry.getRows();
                    if(rows != null){
                        for (Map<String, BinlogValue> row : rows) {
                            BinlogValue id = row.get("id");

                            //根據ID刪除solr的索引庫
                            solrServer.deleteById(id.getValue());
                            solrServer.commit();
                        }
                    }

                }
            }
        }
    }
}

接下來,啟動SyncDataBootStart類的main方法,監聽Canal Server,而Canal Server監聽MySQL binlog的日誌變化,一旦MySQL的binlog日誌發生變化,則SyncDataBootStart會立刻收到變更資訊,並將變更資訊解析成Book物件實時更新到Solr庫中。如果在MySQL資料庫中刪除了資料,則也會實時刪除Solr庫中的資料。

部分參考Canal官方文件:https://github.com/alibaba/canal

好了,今天就到這兒吧,我是冰河,我們下期見~~

相關文章