Kafka之Consumer Rebalance

Kafka版本

  1. kafka版本1.1.1,可能絕大部分也適用於kafka 0.10.x及以上版本。

rebalance

  1. ConsumerGroup(消費組)裏的Consumer(消費者)共同讀取topic(主題)partition(分區),一個新的Consumer(消費者)加入ConsumerGroup(消費組)時,讀取的是原本由其他Consumer(消費者)讀取的消息。當一個Consumer(消費者)被關閉或發生奔潰時,它就離開ConsumerGroup(消費組),原本由它讀取的分區將有ConsumerGroup(消費組)的其他Consumer(消費者)來讀取。在topic發生變化時(比如添加了新的分區),會發生Partition重分配,Partition的所有權從一個Consumer(消費者)轉移到另一個Consumer(消費者)的行爲被稱爲rebalance(再均衡)rebalance(再均衡)本質上是一種協議,規定了ConsumerGroup(消費組)中所有Consumer(消費者)如何達成一致來消費topic(主題)下的partition(分區)
  2. rebalance(再均衡)ConsumerGroup(消費組)帶來了高可用性和伸縮性(可以安全的添加或移除消費者),在rebalance(再均衡)期間,Consumer(消費者)無法讀取消息,造成整個Consumer(消費者)一段時間的不可用
  3. Consumer(消費者)通過向GroupCoordinator(羣組協調器)(不同的ConsumerGroup(消費組)可以有不同的)發送心跳來維持它們與羣組的從屬關係以及它們對分區的所有權關係。Consumer(消費者)會在輪詢消息或者提交偏移量時發送心跳(kafka0.10.1之前的版本),在kafka0.10.1版本里,心跳線程是獨立的
  4. 分配分區的過程
    • Consumer(消費者)加入ConsumerGroup(消費組)時,會向GroupCoordinator(羣組協調器)發送一個JoinGroup請求,第一個加入羣組的Consumer(消費者)將會成爲羣主,羣主從GroupCoordinator(羣組協調器)獲得ConsumerGroup(消費組)的成員列表(此列表包含所有最新正常發送心跳的活躍的Consumer(消費者)),並負責給每一個Consumer(消費者)分配分區(PartitionAssignor的實現類來決定哪個分區被分配給哪個Consumer(消費者))
    • 羣主把分配情況列表發送給GroupCoordinator(羣組協調器)GroupCoordinator(羣組協調器)再把這些信息發送給ConsumerGroup(消費組)裏所有的Consumer(消費者)。每個Consumer(消費者)只能看到自己的分配信息,只有羣主知道ConsumerGroup(消費組)裏所有消費者的分配信息。
  5. rebalance(再均衡)觸發條件
    • ConsumerGroup(消費組)裏的Consumer(消費者)發生變更(主動加入、主動離開、崩潰),崩潰不一定就是指 consumer進程"掛掉"、 consumer進程所在的機器宕機、長時間GC、網絡延遲,當 consumer無法在指定的時間內完成消息的處理,那麼coordinator就認爲該 consumer已經崩潰,從而引發新一輪 rebalance
    • 訂閱topic(主題)的數量發生變更(比如使用正則表達式的方式訂閱),當匹配正則表達式的新topic被創建時則會觸發 rebalance。
    • 訂閱topic(主題)partition(分區)數量發生變更,比如使用命令行腳本增加了訂閱 topic 的分區數

rebalance策略

  1. 分配策略:決定訂閱topic的每個分區會被分配給哪個consumer。默認提供了 3 種分配策略,分別是 range 策略、round-robin策略和 sticky策略,可以通過partition.assignment.strategy參數指定。kafka1.1.x默認使用range策略
  2. range策略:將單個 topic 的所有分區按照順序排列,然後把這些分區劃分成固定大小的分區段並依次分配給每個 consumer。假設有ConsumerA和ConsumerB分別處理三個分區的數據,當ConsumerC加入時,觸發rebalance後,ConsumerA、ConsumerB、ConsumerC每個都處理2個分區的數據。
  3. round-robin 策略:把所有 topic 的所有分區順序擺開,然後輪詢式地分配給各個 consumer
  4. sticky策略(0.11.0.0後引入):有效地避免了上述兩種策略完全無視歷史分配方案的缺陷。採用了"有黏性"的策略對所有 consumer 實例進行分配,可以規避極端情況下的數據傾斜並且在兩次 rebalance間最大限度地維持了之前的分配方案

rebalance generation

  1. rebalance generation 用於標識某次 rebalance,在 consumer中它是一個整數,通常從 0開始
  2. consumer generation主要是防止無效 offset提交。比如上一屆的 consumer成員由於某些原因延遲提交了 offset(已經被踢出group),但 rebalance 之後該 group 產生了新一屆的 group成員,而延遲的offset提交攜帶的是舊的 generation信息,因此這次提交會被 consumer group拒絕,使用 consumer時經常碰到的 ILLEGAL_GENERATION異常就是這個原因導致的
  3. 每個 group進行 rebalance之後, generation號都會加 1,表示 group進入了 一個新的版本

rebalance協議

  1. rebalance本質是一組協議
    • JoinGroup: consumer請求加入組
    • SyncGroup:group leader把分配方案同步更新到組內所有成員
    • Heartbeat :consumer定期向coordinator發送心跳錶示自己存活
    • LeaveGroup:consumer主動通知coordinator該consumer即將離組
    • DescribeGroup:查看組的所有信息,包括成員信息、協議信息、分配方案以及訂閱信息。該請求類型主要供管理員使用 。 coordinator不使用該請求執行 rebalance
  2. 在 rebalance過程中, coordinator主要處理 consumer發過來的JoinGroupSyncGroup請求
  3. 當 consumer主動離組時會發送LeaveGroup請求給 coordinator
  4. 在成功 rebalance之後,組內所有 consumer都需要定期地向 coordinator發送Heartbeat請求。每個 consumer 也是根據Heartbeat請求的響應中是否包含 REBALANCE_IN_PROGRESS 來判斷當前group是否開啓了新一輪 rebalance

rebalance流程

  1. 第一步確認coordinator所在的broker,並建立socket連接

    • 計算Math.abs(groupId.hashcode)%offset.topic.num.partitions(默認50),假設是10

    • 查找__consumer_offsets分區10的leader副本所在的broker,該broker即爲這個consumer group的coordinator

  2. 第二步執行rebalance,rebalance分爲兩步

    • 加入組

      • 組內所有consumer向coordinator發送JoinGroup請求
      • coordinator選擇一個consumer擔任組的leader,並把所有成員信息以及訂閱信息發送給leader
    • 同步更新方案

      • leader根據rebalance的分配策略爲 group中所有成員制定分配方案,決定每個 consumer都負責哪些 topic 的哪些分區。

      • 分配完成後,leader通過SyncGroup請求將分配方案發送給coordinator。組內所有的consumer成員也會發送SyncGroup給coordinator

      • coordinator把每個consumer的方案抽取出來作爲SyncGroup請求的response返回給各自的consumer

  3. 爲什麼consumer group的分配方案在consumer端執行?

    • 這樣做可以有更好的靈活性。比如同一個機架上的分區數據被分配給相同機架上的 consumer減少網絡傳輸的開銷。即使以後分區策略發生了變更,也只需要重啓 consumer 應用即可,不必重啓 Kafka服務器
  4. 加入組流程

    image-20191209225910650

  5. 同步分配方案

    image-20191209225959292

  6. kafka爲consumer group定義了5種狀態

    • Empty:表示group下沒有任何激活的consumer,但可能包含offset信息。
      • 每個group創建時處於Empty狀態
      • 所有consumer都離開group時處於Empty狀態
      • 由於可能包含offset信息,所以此狀態下的group可以響應 OffsetFetch請求,即返回 clients端對應的位移信息
    • PreparingRebalance:表示group 正在準備進行 group rebalance。此狀態表示group已經接收到部分JoinGroup的請求,同時在等待其他成員發送JoinGroup請求,知道所有成員都成功加入組或者超時(Kafka 0.10.1.0之後超時時間由consumer端參數max.poll.interval.ms指定)
      • 該狀態下的 group 依然可能保存有offset信息,因此 clients 依然可以發起 OffsetFetch 請求去獲取offset,甚 至還可以發起OffsetCommit請求去提交位移
    • AwaitingSync:所有成員都已經加入組並等待 leader consumer 發送分區分配方案。同樣地,此時依然可以獲取位移,但若提交位移, coordinator 將會拋出REBALANCE_IN_PROGRESS異常來表明該 group 正在進行 rebalance
    • Stable:表明 group 開始正常消費 。 此時 group 必須響應 clients 發送過來的任何請求,比如位移提交請求、位移獲取請求、心跳請求等
    • Dead: 表明 group 已經徹底廢棄, group 內沒有任何激活consumer並且 group 的所有元數據信息都己被刪除。處於此狀態的 group 不會響應任何請求。嚴格來說, coordinator會返回UNKNOWN_MEMBER_ID異常

rebalance監聽器

  1. 如果要實現將offset存儲在外部存儲中,需要使用rebalance。使用 rebalance 監聽器的前提是必須使用 consumer group。如果使用的是獨立 consumer或是直接手動分配分區,那麼 rebalance監聽器是無效的

  2. rebalance 監聽器最常見的用法就是手動提交位移到第三方存儲以及在 rebalance 前後執行一些必要的審計操作

  3. 自動提交位移是不需要在 rebalance監聽器中再提交位移的,consumer 每次 rebalance 時會檢查用戶是否啓用了自動提交位移,如果是,它會幫用戶執行提交

  4. 鑑於 consumer 通常都要求 rebalance 在很短的時間內完成,用戶千萬不要在 rebalance 監聽器 的兩個方法中放入執行時間很長的邏輯,特別是一些長時間阻塞方法

  5. 代碼案例

    public class ConsumerOffsetSaveDB {
        private final static Logger logger = LoggerFactory.getLogger("kafka-consumer");
        @Test
        public void testConsumerOffsetSaveDB() {
            Properties props = new Properties();
            props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-master:9092,kafka-slave1:9093,kafka-slave2:9094");
            props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10);
            props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
            props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
            props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
            props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
            props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
    
            props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
            String groupId = "test_group_offset_db11";
            props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
    
            KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
            String topic = "testTopic";
            Map<TopicPartition, OffsetAndMetadata> offsetAndMetadataMap = new HashMap<>();
            consumer.subscribe(Collections.singletonList(topic), new SaveOffsetsRebalance(consumer, offsetAndMetadataMap, groupId));
            consumer.poll(0);
    
            OffsetService offsetService = new OffsetService();
    
            for (TopicPartition partition : consumer.assignment()) {
                // 從數據庫獲取當前分區的偏移量
                Offset offset = offsetService.getOffset(groupId, partition.topic(), partition.partition());
                if (offset != null && offset.getOffset() != null) {
                    consumer.seek(partition, offset.getOffset());
                } else {
                    logger.info("初始時庫沒有值");
                }
            }
    
            try {
                while (true) {
                    ConsumerRecords<String, String> records = consumer.poll(1000);
                    for (ConsumerRecord<String, String> record : records) {
                        //模擬異常
                       /* if (record.value().equals("hello world 10")) {
                            throw new RuntimeException(String.format("hello world 10處理異常,offset:%s,partition:%s",record.offset(),record.partition()));
                        }*/
                        /**
                         *1. 消息處理(要考慮去重,如果消息成功,但是存儲偏移量失敗或者宕機,此時數據庫存儲的消息偏移量不是最新的)
                         *2. 如果消息處理是數據庫,最好將消息處理與存儲offset放在一個事務當中
                         */
                        logger.info("key:{},value:{},offset:{}", record.key(), record.value(), record.offset());
                        offsetAndMetadataMap.put(
                                new TopicPartition(record.topic(), record.partition()),
                                new OffsetAndMetadata(record.offset() + 1, "")
                        );
                        // 2.存儲偏移量到DB,這裏採用單次更新,當然也可以批量
                        offsetService.insertOffset(groupId, record.topic(), record.partition(), record.offset() + 1);
                    }
    
                }
            } catch (WakeupException e) {
                // 不處理異常
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
            } finally {
                consumer.close();
            }
        }
    }
    
  6. rebalance代碼

    public class SaveOffsetsRebalance implements ConsumerRebalanceListener {
    
        private Logger logger = LoggerFactory.getLogger(SaveOffsetsRebalance.class);
    
        private Consumer consumer;
        private Map<TopicPartition, OffsetAndMetadata> map;
        private String groupId;
        OffsetService offsetService = new OffsetService();
    
        public SaveOffsetsRebalance(Consumer consumer, Map<TopicPartition, OffsetAndMetadata> map, String groupId) {
            this.consumer = consumer;
            this.map = map;
            this.groupId = groupId;
        }
    
        /**
         * 此方法在rebalance操作之前調用,用於我們提交消費者偏移,
         * 這裏不提交消費偏移,因爲consumer中每處理一次消息就保存一次偏移,
         * consumer去做重複消費處理
         */
        @Override
        public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
            int size = partitions.size();
            logger.info("rebalance 之前觸發.....{}", size);
            Iterator<TopicPartition> iterator = partitions.iterator();
            while (iterator.hasNext()) {
                TopicPartition topicPartition = iterator.next();
                long position = consumer.position(topicPartition);
                OffsetAndMetadata offsetAndMetadata = map.get(topicPartition);
                if (offsetAndMetadata != null) {
                    long offset = offsetAndMetadata.offset();
                    logger.info("position:{},offset:{}", position, offset);
                } else {
                    logger.info("position:{},offset:{}", position, null);
                }
            }
        }
    
        /**
         * 此方法在rebalance操作之後調用,用於我們拉取新的分配區的偏移量。
         */
        @Override
        public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
            logger.info("rebalance 之後觸發.....");
            for (TopicPartition partition : partitions) {
                //從數據庫獲取當前分區的偏移量
                Offset offset = offsetService.getOffset(this.groupId, partition.topic(), partition.partition());
                if (offset != null && offset.getOffset() != null) {
                    consumer.seek(partition, offset.getOffset());
                }
            }
        }
    }
    
  7. 數據庫sql

    DROP TABLE IF EXISTS `t_offset`;
    CREATE TABLE `t_offset` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
      `group_id` varchar(50) CHARACTER SET latin1 NOT NULL,
      `topic` varchar(50) CHARACTER SET latin1 NOT NULL COMMENT 'topic',
      `partition` int(11) NOT NULL,
      `offset` bigint(20) DEFAULT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `unique_gtp` (`group_id`,`topic`,`partition`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='偏移量保存';
    
  8. 添加依賴

    compile group: 'commons-dbutils', name: 'commons-dbutils', version: '1.7'
    compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.30'
    compile group: 'org.aeonbits.owner', name: 'owner', version: '1.0.9'
    
  9. 數據庫工具

    public class JdbcUtils {
        public static Connection getConnection(String driverClass, String url, String username, String password) throws SQLException, ClassNotFoundException {
            Class.forName(driverClass);
            return DriverManager.getConnection(url, username, password);
        }
    }
    
    @Sources({"classpath:config.properties"})
    public interface MysqlJdbcConfig extends Reloadable {
        @Key("config.mysql.url")
        public String url();
    
        @Key("config.mysql.username")
        public String username();
    
        @Key("config.mysql.password")
        public String password();
    
        @Key("config.mysql.driverClass")
        public String driverClass();
    
        public final static class ServerConfigInner {
            public final static MysqlJdbcConfig config = ConfigFactory.create(MysqlJdbcConfig.class);
        }
    
        public static final MysqlJdbcConfig instance = ServerConfigInner.config;
    
    }
    
  10. 配置config.properties

    config.mysql.url=jdbc:mysql://jannal.mac.com:3306/test
    config.mysql.username=root
    config.mysql.password=root
    config.mysql.driverClass=com.mysql.jdbc.Driver
    
  11. 偏移量處理類

    public class Offset {
        private Long id;
    
        private String groupId;
    
        private String topic;
    
        private String partition;
    
        private Long offset;
    		...省略getter setter toString..
    }
    
    public class OffsetService {
        
        public Offset getOffset(String groupId, String topic, int partition) {
            QueryRunner queryRunner = new QueryRunner();
            Offset offset = null;
            Connection connection = null;
            try {
                connection = JdbcUtils.getConnection(MysqlJdbcConfig.instance.driverClass(), //
                        MysqlJdbcConfig.instance.url(),//
                        MysqlJdbcConfig.instance.username(),//
                        MysqlJdbcConfig.instance.password());//
                String sql = "select `topic`,`partition`,`offset` from `t_offset` where `group_id`=? and  `topic` = ? and  `partition` = ?";
                offset = queryRunner.query(connection, sql, new BeanHandler<Offset>(Offset.class), new Object[]{groupId, topic, partition});
            } catch (Exception e) {
                throw new RuntimeException(e.getMessage(), e);
            } finally {
                if (connection != null) {
                    try {
                        connection.close();
                    } catch (SQLException e) {
                        e.printStackTrace();
                    }
                }
            }
            return offset;
        }
    
    
        public void insertOffset(String groupId, String topic, int partition, long offset) {
            QueryRunner queryRunner = new QueryRunner();
            Connection connection = null;
            try {
                connection = JdbcUtils.getConnection(MysqlJdbcConfig.instance.driverClass(), //
                        MysqlJdbcConfig.instance.url(),//
                        MysqlJdbcConfig.instance.username(),//
                        MysqlJdbcConfig.instance.password());//
                String sql = " insert into `t_offset`( `group_id`,`topic`, `partition`,`offset`) values ( ?,?, ?,?) on duplicate key update `offset` =VALUES(offset);";
                Object[] params = {groupId, topic, partition, offset};
                connection.setAutoCommit(false);
                queryRunner.update(connection, sql, params);
                connection.commit();
            } catch (Exception e) {
                try {
                    if (connection != null) {
                        connection.rollback();
                    }
                } catch (SQLException e1) {
                    throw new RuntimeException(e.getMessage(), e);
                }
                throw new RuntimeException(e.getMessage(), e);
            } finally {
                if (connection != null) {
                    try {
                        connection.close();
                    } catch (SQLException e) {
                        e.printStackTrace();
                    }
                }
            }
    
        }
    
    
        public void batchInsertOffset(Object[][] params) {
            QueryRunner queryRunner = new QueryRunner();
            Connection connection = null;
            try {
                connection = JdbcUtils.getConnection(MysqlJdbcConfig.instance.driverClass(), //
                        MysqlJdbcConfig.instance.url(),//
                        MysqlJdbcConfig.instance.username(),//
                        MysqlJdbcConfig.instance.password());//
                String sql = " insert into `t_offset`(`group_id`, `topic`, `partition`,`offset`) values (?, ?, ?,?) on duplicate key update `offset`=VALUES(offset);";
                connection.setAutoCommit(false);
                queryRunner.batch(connection, sql, params);
                connection.commit();
            } catch (Exception e) {
                try {
                    if (connection != null) {
                        connection.rollback();
                    }
                } catch (SQLException e1) {
                    throw new RuntimeException(e.getMessage(), e);
                }
                throw new RuntimeException(e.getMessage(), e);
            } finally {
                if (connection != null) {
                    try {
                        connection.close();
                    } catch (SQLException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
    
    
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章