利用Canal投递MySQL Binlog到Kafka

Update:
Canal与Camus的结合使用,见https://www.jianshu.com/p/4c4213385368

Canal是阿里开源的一个比较有名的Java中间件,主要作用是接入数据库(MySQL)的binlog日志,实现数据的增量订阅、解析与消费,即CDC(Change Data Capture)。近期我们计划将数据仓库由基于Sqoop的离线按天入库方式改为近实时入库,Canal自然是非常符合需求的。

Canal的模块设计精妙,但代码质量低,阅读起来比较困难。在其GitHub Wiki中详细叙述了其设计思路,值得学习,这里不再赘述,参见:https://github.com/alibaba/canal/wiki/Introduction

在最新的Canal 1.1.x版本中,其新增了对消息队列的原生支持,通过不算复杂的配置可以直接将binlog投递到Kafka或者RocketMQ,无需再自己写producer程序(源码中有现成的CanalKafkaProducer和CanalRocketMQProducer类)。

我们使用目前的稳定版本1.1.2小试一下。

Canal最简单原理示意

前置工作

  • 保证MySQL的binlog-format=ROW
  • 为canal用户配置MySQL slave的权限
CREATE USER canal IDENTIFIED BY 'canal';  
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

canal.properties设置

顺便还可以复习一下Kafka producer的一些配置参数含义。

# 默认值tcp,这里改为投递到Kafka
canal.serverMode = kafka
# Kafka bootstrap.servers,可以不用写上全部的brokers
canal.mq.servers = 10.10.99.132:9092,10.10.99.133:9092,10.10.99.134:9092,10.10.99.135:9092
# 投递失败的重试次数,默认0,改为2
canal.mq.retries = 2
# Kafka batch.size,即producer一个微批次的大小,默认16K,这里加倍
canal.mq.batchSize = 32768
# Kafka max.request.size,即一个请求的最大大小,默认1M,这里也加倍
canal.mq.maxRequestSize = 2097152
# Kafka linger.ms,即sender线程在检查微批次是否就绪时的超时,默认0ms,这里改为200ms
# 满足batch.size和linger.ms其中之一,就会发送消息
canal.mq.lingerMs = 200
# Kafka buffer.memory,缓存大小,默认32M
canal.mq.bufferMemory = 33554432
# 获取binlog数据的批次大小,默认50
canal.mq.canalBatchSize = 50
# 获取binlog数据的超时时间,默认200ms
canal.mq.canalGetTimeout = 200
# 是否将binlog转为JSON格式。如果为false,就是原生Protobuf格式
canal.mq.flatMessage = true
# 压缩类型,官方文档没有描述
canal.mq.compressionType = none
# Kafka acks,默认all,表示分区leader会等所有follower同步完才给producer发送ack
# 0表示不等待ack,1表示leader写入完毕之后直接ack
canal.mq.acks = all
# Kafka消息投递是否使用事务
# 主要针对flatMessage的异步发送和动态多topic消息投递进行事务控制来保持和Canal binlog位置的一致性
# flatMessage模式下建议开启
canal.mq.transaction = true

instance.properties设置

# 需要接入binlog的表名,支持正则,但这里手动指定了每张表,注意转义
canal.instance.filter.regex=mall\\.address,mall\\.orders,mall\\.order_product,mall\\.product,mall\\.mall_category,mall\\.mall_comment,mall\\.mall_goods_category,mall\\.mall_goods_info,mall\\.mall_goods_wish,mall\\.mall_new_tags_v2,mall\\.mall_topic,mall\\.mall_topic_goods,mall\\.mall_user_cart_info
# 黑名单
canal.instance.filter.black.regex=
# 消息队列对应topic名
canal.mq.topic=binlog_mall_1
# 发送到哪一个分区,由于下面用hash做分区,因此不设
#canal.mq.partition=0
# 根据正则表达式做动态topic,目前采用单topic,因此也不设
#canal.mq.dynamicTopic=mall\\..*
# 10个分区
canal.mq.partitionsNum=10
# 各个表的主键,依照主键来做hash分区
canal.mq.partitionHash=mall.address:address_id,mall.orders:order_id,mall.order_product:order_product_id,mall.product:product_id,mall.mall_category:category_id,mall.mall_comment:comment_id,mall.mall_goods_category:goods_category_id,mall.mall_goods_info:goods_id,mall.mall_goods_wish:id,mall.mall_new_tags_v2:tags_id,mall.mall_topic:topic_id,mall.mall_topic_goods:id,mall.mall_user_cart_info:id

上面的配置相当灵活,dynamicTopic选项可以控制单topic还是多topic,partitionHash选项可以控制单partition还是多partition。

但是binlog是有序的,必须保证它进入到消息队列之后仍然有序。参照以上的配置,有以下几个方法:

  • 单topic单partition:可以严格保证与binlog相同的顺序,但效率比较低,TPS只有2~3K。
  • 多topic单partition:由于是按照表划分topic,因此可以保证表级别的有序性,但是每个表的热度不一样,对于热点表仍然会有性能问题。
  • 单/多topic多partition:按照给定的hash方法来划分partition,性能无疑是最好的。但必须要多加小心,每个表的hash依据都必须是其主键或者主键组。只有保证每表每主键binlog的顺序性,才能准确恢复变动数据。

经过权衡,我们采用单topic多partition的方式来处理。还可以参考:https://github.com/alibaba/canal/wiki/Canal-Kafka-RocketMQ-QuickStart

Kafka版本兼容性

通过阅读Canal工程中的pom文件,得知它集成的Kafka版本为1.1.1,而我们的集群中,之前为了兼容一些老旧业务,采用的Kafka版本为0.8.2。起初我们做试验时,消息能够正常发送,但topic中始终没有任何消息。

这是因为在0.10.2版本之前,Kafka只对客户端版本有向前兼容性,亦即高版本broker能够处理低版本client的请求,但低版本broker不能处理高版本client的请求。0.10.2版本提出了双向兼容性(bidirectional compatibility)改进,低版本broker与高版本client也能兼容了,但仍然对过时的0.8.x版本没有支持。

鉴于1.1.1版本producer发送的消息不能被0.8.2版本的broker解析,后来我们索性将Kafka broker全部升级到了1.0.1(对应CDH Kafka版本为3.1.1,是目前最新的),兼容性问题就解决了。

另外Kafka自带有命令行工具kafka-broker-api-versions.sh来检测broker支持的API版本,这里不表。

Canal 1.1.2源码中的一处小bug

一切配置好后,运行bin/startup.sh启动Canal,观察canal.log,发现疯狂报空指针异常,如下图所示。

大量NPE

通过仔细观察,发现对于类型为UPDATE的消息没有问题,但一旦触发INSERT就跪掉了。

继续追根溯源,找到com.alibaba.otter.canal.protocol.FlatMessage类中,有一个messagePartition()方法,显然是做hash分区用的,其前半段源码如下,已经改正确了:

    public static FlatMessage[] messagePartition(FlatMessage flatMessage, Integer partitionsNum,
                                                 Map<String, String> pkHashConfig) {
        if (partitionsNum == null) {
            partitionsNum = 1;
        }
        FlatMessage[] partitionMessages = new FlatMessage[partitionsNum];
        String pk = pkHashConfig.get(flatMessage.getDatabase() + "." + flatMessage.getTable());
        if (pk == null || flatMessage.getIsDdl()) {
            partitionMessages[0] = flatMessage;
        } else {
            if (flatMessage.getData() != null) {
                int idx = 0;
                for (Map<String, String> row : flatMessage.getData()) {
                    String value = null;
                    if (flatMessage.getOld() != null) {
                        // [!]
                        Map<String, String> o = flatMessage.getOld().get(idx);
                        // String value;
                        // 如果old中有pk值说明主键有修改, 以旧的主键值hash为准
                        if (o != null && o.containsKey(pk)) {
                            value = o.get(pk);
                        }
                    }
                    if (value == null) {
                        value = row.get(pk);
                    }
                    if (value == null) {
                        value = "";
                    }
                    int hash = value.hashCode();
                    int pkHash = Math.abs(hash) % partitionsNum;
................

注意上面代码中打[!]标记的地方,原有的代码根本没有对flatMessage.getOld()的结果做空校验,而INSERT操作恰好又没有变动之前的记录信息,自然就会产生NPE了。对于当前一个稳定版本release而言,代码中出现低级错误实属不该。

修正这个bug之后,将canal.protocol模块重新打成jar包,替换掉原有deployer包中的同名文件,问题解决。

附上Canal内部Kafka producer类的实现源码

从中可以看出,producer还没有启用事务性,也就是说上面的canal.mq.transactions配置项其实是无效的。

public class CanalKafkaProducer implements CanalMQProducer {
    private static final Logger       logger = LoggerFactory.getLogger(CanalKafkaProducer.class);
    private Producer<String, Message> producer;
    private Producer<String, String>  producer2;                                                 // 用于扁平message的数据投递
    private MQProperties              kafkaProperties;

    @Override
    public void init(MQProperties kafkaProperties) {
        this.kafkaProperties = kafkaProperties;
        Properties properties = new Properties();
        properties.put("bootstrap.servers", kafkaProperties.getServers());
        properties.put("acks", kafkaProperties.getAcks());
        properties.put("compression.type", kafkaProperties.getCompressionType());
        properties.put("retries", kafkaProperties.getRetries());
        properties.put("batch.size", kafkaProperties.getBatchSize());
        properties.put("linger.ms", kafkaProperties.getLingerMs());
        properties.put("max.request.size", kafkaProperties.getMaxRequestSize());
        properties.put("buffer.memory", kafkaProperties.getBufferMemory());
        properties.put("key.serializer", StringSerializer.class.getName());
        if (!kafkaProperties.getFlatMessage()) {
            properties.put("value.serializer", MessageSerializer.class.getName());
            producer = new KafkaProducer<String, Message>(properties);
        } else {
            properties.put("value.serializer", StringSerializer.class.getName());
            producer2 = new KafkaProducer<String, String>(properties);
        }

        // producer.initTransactions();
    }

    @Override
    public void stop() {
        try {
            logger.info("## stop the kafka producer");
            if (producer != null) {
                producer.close();
            }
            if (producer2 != null) {
                producer2.close();
            }
        } catch (Throwable e) {
            logger.warn("##something goes wrong when stopping kafka producer:", e);
        } finally {
            logger.info("## kafka producer is down.");
        }
    }

    @Override
    public void send(MQProperties.CanalDestination canalDestination, Message message, Callback callback) {

        // producer.beginTransaction();
        if (!kafkaProperties.getFlatMessage()) {
            try {
                ProducerRecord<String, Message> record;
                if (canalDestination.getPartition() != null) {
                    record = new ProducerRecord<String, Message>(canalDestination.getTopic(),
                        canalDestination.getPartition(),
                        null,
                        message);
                } else {
                    record = new ProducerRecord<String, Message>(canalDestination.getTopic(), 0, null, message);
                }

                producer.send(record).get();

                if (logger.isDebugEnabled()) {
                    logger.debug("Send  message to kafka topic: [{}], packet: {}",
                        canalDestination.getTopic(),
                        message.toString());
                }
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
                // producer.abortTransaction();
                callback.rollback();
                return;
            }
        } else {
            // 发送扁平数据json
            List<FlatMessage> flatMessages = FlatMessage.messageConverter(message);
            if (flatMessages != null) {
                for (FlatMessage flatMessage : flatMessages) {
                    if (canalDestination.getPartition() != null) {
                        try {
                            ProducerRecord<String, String> record = new ProducerRecord<String, String>(
                                canalDestination.getTopic(),
                                canalDestination.getPartition(),
                                null,
                                JSON.toJSONString(flatMessage, SerializerFeature.WriteMapNullValue));
                            producer2.send(record).get();
                        } catch (Exception e) {
                            logger.error(e.getMessage(), e);
                            // producer.abortTransaction();
                            callback.rollback();
                            return;
                        }
                    } else {
                        if (canalDestination.getPartitionHash() != null
                            && !canalDestination.getPartitionHash().isEmpty()) {
                            FlatMessage[] partitionFlatMessage = FlatMessage.messagePartition(flatMessage,
                                canalDestination.getPartitionsNum(),
                                canalDestination.getPartitionHash());
                            int length = partitionFlatMessage.length;
                            for (int i = 0; i < length; i++) {
                                FlatMessage flatMessagePart = partitionFlatMessage[i];
                                if (flatMessagePart != null) {
                                    try {
                                        ProducerRecord<String, String> record = new ProducerRecord<String, String>(
                                            canalDestination.getTopic(),
                                            i,
                                            null,
                                            JSON.toJSONString(flatMessagePart, SerializerFeature.WriteMapNullValue));
                                        producer2.send(record).get();
                                    } catch (Exception e) {
                                        logger.error(e.getMessage(), e);
                                        // producer.abortTransaction();
                                        callback.rollback();
                                        return;
                                    }
                                }
                            }
                        } else {
                            try {
                                ProducerRecord<String, String> record = new ProducerRecord<String, String>(
                                    canalDestination.getTopic(),
                                    0,
                                    null,
                                    JSON.toJSONString(flatMessage, SerializerFeature.WriteMapNullValue));
                                producer2.send(record).get();
                            } catch (Exception e) {
                                logger.error(e.getMessage(), e);
                                // producer.abortTransaction();
                                callback.rollback();
                                return;
                            }
                        }
                    }
                    if (logger.isDebugEnabled()) {
                        logger.debug("Send flat message to kafka topic: [{}], packet: {}",
                            canalDestination.getTopic(),
                            JSON.toJSONString(flatMessage, SerializerFeature.WriteMapNullValue));
                    }
                }
            }
        }

        // producer.commitTransaction();
        callback.commit();
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容