基于MapReduce的常驻Kafka Consumer程序的实现

这篇文章是对近期工作的一个总结,虽然主要利用了一些开源系统和比较成熟的机制,但在业务实践过程中还是遇到了一些坑。写下来一是为了自我总结和梳理,另外也希望能够给别人带来一点点的启发。

前言

KafkaConsumer一般作为实时数据流处理当中的订阅端,其主要工作是实时的从Kafka中订阅数据,进行一些简单的ETL工作(不是必须),将数据存储到其它存储系统上(一般是HDFS、HBase、Mysql等)。除此之外,我们的KafkaConsumer还要求具有以下特性:

  1. 数据延迟是必须在秒级别
  2. 要保证数据的Exactly Once Semantics,即不丢不重。
  3. 需要尽量做到优雅退出。

第一个问题比较容易做到,数据延迟在秒级,就要求我们的KafkaConsumer必须是一个7*24小时的常驻MapReduce程序,同时由于我们只需要处理订阅和转储,所以该MapReduce程序是一个 mapper only 的。所以后文我会重点介绍如何保证第二个特性,即数据的不丢不重。

如何保证数据的不丢

这里为了保证数据的不丢我们主要利用了Kafka本身的数据可回溯性和我们自己在Consumer程序中实现的checkpoint机制。

CheckPoint机制的实现

Kafka本身按照Topic划分数据流,我们的一个KafkaConsumer程序只会订阅一个Topic的数据。同时Kafka的一个Topic下划分为多个partition,每个partition是一个实际的数据管道,我们的KafkaConsumer作为mapper only的,每个mapper会去实时的订阅一个partition的数据,所以mapper的个数是和partition的个数保持一样的。如何实现一个Kafka的InputFormat,这块网上已经有很多文章,我就不具体介绍了。下面介绍如何进行checkpoint

由于每个mapper对应一个Kafka的partition,所以每个mapper需要单独记录自己的进度。我们在mysql当中创建这样一张表:

CREATE TABLE `kafka_consumer_progress` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`kafka_partition_id` int(11) NOT NULL,
`current_offset` bigint(11) NOT NULL,
) ENGINE=InnoDB AUTO_INCREMENT=3419273 DEFAULT CHARSET=utf8mb4

其中id作为自增主键,kafka_partition_id记录数据在kafka中对应的partition,current_offset记录最后写入的数据在该partition下对应的offset。每个mapper在将一批(注意不是每条都写,因为往mysql中记录checkpoint也是一个很重的操作)数据写入kudu成功之后,需要在mysql中更新对应的进度。同时,mapper在失败重启的时候也都需要先从msyql当中恢复进度。代码示例如下:

// 1. 重启时需要先恢复进度
currentOffset = metaClient.recoverOffset(partitionId);    
while(true) {
   //  2. 从kafka中订阅数据
   Response response = consumer.fetch(currentOffset);
   // 3. 将数据写入kudu
   foreach(Data data : response.getMessage()) {
        kuduWriter.write(data);
   }
   // 4. 更新进度,记录checkpoint
   currentOffset = response.nextOffset();
   metaClient.updateProgress(partitionId, currentOffset);
}

如何保证数据的不重

进行到这一步我们很容易发现,即使在mapper刚刚写入kudu,还没来得及更新checkpoint时挂掉,我们也能从上一个checkpoint恢复进度,这样保证了数据的at least once 语义。但是如果该批次的数据恰好写成功了而没来得及更新checkpoint,就会导致数据的重复写入。如何保证数据的不重复,这里我们根据具体的业务场景,分为两块来看。

  1. 对于幂等的操作,采用主键去重。
  2. 对于非幂等的操作,每条数据单独记录更新的offset。

幂等操作

针对我们的业务类型来说,数据分为Event和Profile两种类型,其中Event类型的数据我们只支持插入这一种语义。所以Event类型的数据操作是幂等的。对于幂等操作重复执行不受影响,但是我们需要保证每条数据具有唯一ID。这里我们使用kafka的partition id + offset作为kudu表的主键,所有Event数据的写入都相当于对主键数据的replace。所以,Event数据的重复写入相当于在kudu层做了去重。

非幂等操作

Profile类型的数据,是我们业务实体,每条数据具有自己的唯一ID。Profile类型的数据操作支持profile_set、profile_update、profile_increase、profile_set_once等,所以Profile类型的操作属于非幂等的。对于这种类型的数据我们在对应的profile kudu表当中增加了offset字段,记录这条数据最后被更新的数据来源于kafka的哪一个offset。这里可能会问,为什么不需要记录partition id呢,这是因为我们的Profile数据在导入Kafka的时候已经是按照Profile的id进行的hash取模,所以相同id的Profile数据只会出现在相同的partition内。
对Profile进行更新时,我们会先从kudu当中读取是否已有对应id的Profile数据存在,如果有的话,会比较kudu当中Profile数据的offset和Kafka中的profile数据的offset,只有当kudu当中的offset小于Kafka中的offset的时候,才会对该条Profile数据进行更新,从而完成了对非幂等的Profile操作的去重。

如何做到常驻的MapReduce程序的优雅退出。

这里有同学可能会问两个问题:

  1. 你们的KafkaConsumer不是7*24小时常驻的吗,为什么还需要退出?
  2. 上面两步不是保证了数据的不丢不重吗,为什么还需要优雅退出?

对于问题1,主要有两种场景,一种是我们的程序在升级的时候,肯定是需要主动退出并重启的;二是由于各种异常(比如kudu挂掉等),我们需要让KafkaConsumer退出,从而发出报警并进行人工干预。
对于问题2,我们的场景是:需要在KafkaConsumer当中对数据来源进行一些统计工作,同时需要将统计信息记录到mysql当中,主要用处是当数据异常时,可以进行方便的debug工作。所以也只是做到尽量优雅退出,及时这部分数据丢失了,也不会影响我们程序的正常工作。

解决方案一 —— 捕获kill信号

最开始我的想法是当作业失败,hadoop需要将mapper任务kill掉时,我们捕获到相应的信号,然后进行主动退出。但是调研之后发现,hadoop会先尝试使用SIGTERM杀死mapper进程,然后等待一段时间(默认5000毫秒),如果进程还没有退出时,会使用SIGTERM杀死进程。我们虽然能够捕获到SIGTERM信号,但是5000毫秒对于我们来说往往来不及做剩下的clean up工作。而这个超时配置又是yarn全局的,我们没法为KafkaConsumer单独修改,所以这个方案被抛弃了。

解决方案二 —— 使用Zookeeper进行同步

方案一走不通,我们必须使用自己的方法进行mapper间的信息同步。我们想到了Zookeeper(以下简称ZK): 在KafkaConsumer启动时由本地进程创建对应的ZK节点,同时每个mapper都会作为一个Watcher观察这个节点。当KafkaConsumer需要主动退出时,本地进程会将对应的ZK节点删除,同时所有mapper观察到对应的ZK节点变化之后,会进行最后的clean up并优雅退出。这样就解决了我们在升级的时候需要主动退出KafkaConsumer的问题。但是还有一个问题也是我最开始没有想到的,当一个mapper执行出现异常时,需要异常退出,这时hadoop会标记整个Job为失败,并将其它正在运行的mapper也kill掉。这就要求单个mapper异常时,我们也需要使用ZK进行优雅退出。方案类似:当mapper异常退出时,会尝试将对应的ZK节点删除。代码示例如下:

private void doExit(Context context) {  
    // 进行最后的clean up工作
    this.cleanup();  
    if (!Terminator.isShutdownBegan()) {
        logger.error("kafka consumer mapper run failed.");
        // 当mapper因为异常退出时,会将相应的zk节点删除,以通知其它mapper进行安全退出    
        // 探测ZK节点是否存在
        boolean pathExists = true;
        try {
            pathExists = this.zookeeperClient.checkExists(zkNode);
            if (pathExists) {
                this.zookeeperClient.deletePath(this.zkNode);
                pathExists = false;
             }
          } catch (Exception e) {
              logger.error("delete path {} failed.", zkNode, e);
              try {
                  pathExists = this.zookeeperClient.checkExists(zkNode);
              } catch (Exception e1) {
                  logger.error("check zk path result failed.");
              }
          } 
          if (!pathExists) { 
              logger.info("kafka consumer mapper exit gracefully.");
          } else {
              logger.warn("kafka consumer mapper exit failed."); 
              System.exit(1);
          }
      } else {
          logger.info("mark shut down phase done.");    
          Terminator.markShutdownPhaseDone();
      }
}

通过方案二,我们能够解决90%的异常退出问题,但还不能完全解决问题。比如mapper爆内存的时候,hadoop还是会将mapper杀掉,这是我们还是无法进行干预,所以这里的优雅退出只能做到尽量准,如果大家有什么好的方法,也可以告诉我。

你可能感兴趣的:(基于MapReduce的常驻Kafka Consumer程序的实现)