kafka读书笔记

771 阅读20分钟

本文为《深入理解kafka:核心设计与实践原理》读书笔记
文中内容基于kafka 2.0.0

基本概念

  1. offset
    是消息在分区中的唯一标识,kafka通过它来保证消息在分区内的顺序性,但offset并不跨分区,即kafka只保证分区内有序,但不保证主题有序
  2. 副本
    leader副本负责处理分区读写请求,follower副本只负责从leader副本取消息同步,副本处于不同的broker中

    如果分配的副本数大于机器数,我记得会报错

  3. HW
    hight watermark,高水位,标识一个特定的消息偏移量offset,消费者只能消费HW之前的消息
  4. LEO
    log end offset,标识当前日志文件中下一条待写入消息的offset,即当前日志分区中最后一个offset+1
    分区偏移量
  5. ISR
    In-Sync Replicas,与leader保持一定程度同步的follower副本(包括leader),ISR集合中的每个副本都会维护自身的LEO,LE0>=HW

生产者

  1. 线程安全
    kafkaproducer是线程安全的,可以在多个线程中共享单个kafkaproducer示例,也可以将kafkaproducer示例进行池化来供其他线程调用(kafkaConsumer是非线程安全的)
  2. 发送消息方式
    发后即忘(fire-and-forget),同步(sync)及异步(async)
  • 同步发送
    同步发送的方式可靠性高,消息依次发送,性能低,要么消息被发送成功,要么发送异常,如果发生异常,则可以捕获并进行相应处理
    //send返回异步future,future.get()阻塞获取结果
    producer.send(record,record).get();
    
  • 异步发送
    producer.send(record, new Callback() {
        @Override
        public void onCompletion(RecordMetadata metadata, Exception exception) {
            if (exception == null) {
                //handle error
            }
        });
    

    对同一个分区,回调函数的调用可以保证分区有序,即r1,r2依次发送到同一个分区,则对应的回调顺序也必然是callback1,callback2

  • 发后即忘
    producer.send(record,record);
    
  1. 消息发送流程
    在send方法发往broker的过程中,有可能需要经过拦截器(interceptor),序列化器(serializer)和分区器(partitioner)的一系列作用之后才能被真正发往broker

    在拦截链中,如果某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行,不会断掉,遗失消息

生产者客户端整体架构

  • 生产消息的线程和发送消息的线程为分开的两个线程
  • RecordAccumulator主要用来缓存消息以便Sender线程可以批量发送,减少网络传输的资源消耗

如果生产者生产消息的速度超过了发送到服务器的速度,那么会导致生产者空间不足(RecordAccumulator),send方法阻塞或抛出异常

  • InFlightRequest:缓存已经发送出去但还没有收到响应的请求

max.in.flight.requests.per.connection:每个连接最多可以缓存的请求数,设置为1则可以让客户端按顺序发送请求,注意这里客户端依然可以连接多个node,只不过每个node只能有一个连接缓存, 一般而言,在需要保证消息顺序的场合把max.in.flight.requests.per.connection设置为1,而不是把ack设置为0

消费者

  1. 消费者与分区
    一个分区只能被一个消费者组中的一个消费者消费,若消费者个数大于分区个数,则会有消费者分配不到分区

    可以通过自定义分区分配策略使一个分区可以分配给多个消费者消费

  2. 主题订阅
    • subscribe()方法订阅的主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区分配策略来自动分配各个消费者和分区间的关系,实现消费负载均衡及故障转移
    • assign()方法订阅分区时,是不具备消费者自动均衡功能的

    subscribe方法有ConsumeRebalanceListener参数,而assign没有

  3. 重复消费
    异步提交重试,可能会先成功提交了后面的offset,但前面的offset失败后重试才成功,有覆盖掉之前的提交,则有重复消费的问题,可以通过设置递增序号来维护异步提交的顺序,每次提交都自增该值,重试时判断提交对应的序号即可
  4. 消费者再均衡:分区对应的消费者变更(增减消费者)
    consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
            @Override
             //在均衡开始之前和消费者停止读取消息之后调用
            public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                consumer.commitSync(currentOffsets); //store in db
            }
            @Override
            //重新分配分区之后和消费者开始消费之前调用
            public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                //read offset from db
            }
        });
    
  5. 多线程消费
    kafkaproducer线程安全,kafkaConsumer是非线程安全的,一个线程只能有一个消费者(每个消费者对应一个线程,每次调用消费者共用方法时会先判断)
    1. 为每个线程实例化一个kafkaConsumer对象
      一个消费者线程消费一个或多个分区的消息,所有消费者线程同属于一个消费者组,最大线程数(并发数)=分区数
    public static void main(String[] args) {
        Properties props = initConfig();
        int consumerThreadNum = 4;
        for (int i = 0; i < consumerThreadNum; i++) {
            new KafkaConsumerThread(props, topic).start();
        }
    }
    
    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        public KafkaConsumerThread(Properties props, String topic) {
            this.kafkaConsumer = new KafkaConsumer<>(props);
            this.kafkaConsumer.subscribe(Arrays.asList(topic));
        }
    
        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records =
                            kafkaConsumer.poll(Duration.ofMillis(100));
                    for (ConsumerRecord<String, String> record : records) {
                        //process record.
                    }
                }
            } catch (Exception e) {
            } finally {
                kafkaConsumer.close();
            }
        }
    }
    
    1. 多个消费线程同时消费一个分区
      可以通过assign(),seek()等方法实现,突破消费者线程个数<=分区数的限制,但位移提交和顺序控制复杂
    2. 异步处理消息
      offset提交管理是个问题
    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumerThread consumerThread = new KafkaConsumerThread(props, topic,
                Runtime.getRuntime().availableProcessors());
        consumerThread.start();
    }
    
    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;
    
        public KafkaConsumerThread(Properties props, String topic, int threadNumber) {
            kafkaConsumer = new KafkaConsumer<>(props);
            kafkaConsumer.subscribe(Collections.singletonList(topic));
            this.threadNumber = threadNumber;
            executorService = new ThreadPoolExecutor(threadNumber, threadNumber,
                    0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1000),
                    new ThreadPoolExecutor.CallerRunsPolicy());
        }
    
        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records =
                            kafkaConsumer.poll(Duration.ofMillis(100));
                    if (!records.isEmpty()) {
                        executorService.submit(new RecordsHandler(records));
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
    
    public static class RecordsHandler extends Thread {
        public final ConsumerRecords<String, String> records;
        public RecordsHandler(ConsumerRecords<String, String> records) {
            this.records = records;
        }
        @Override
        public void run() {
            //处理records.
            for (ConsumerRecord<String, String> record : records) {
                System.out.println(record.value());
            }
        }
    }
    

主题与分区

  1. 分区副本分布
    同一个分区中的多个副本必须分布在不同的broker中,以提供有效的数据冗余
  2. 副本分区分配
    目的是在多个主题的情况下尽可能均匀分布分区副本,同一个broker节点只有分区的一个副本
    replica_assignment : 可以手动指定副本具体分配在哪个broker
    under-replicated-partitions : 可以找出所有包含失效副本的分区
    unavailable-partitions : 可以查看主题中没有leader副本的分区,这些分区已经处于离线状态(没有对外服务的leader)
    
  3. 目前kafka只支持增加分区数,不支持减少分区数
    1. 增加分区会导致后续的消息分配到的分区改变,影响顺序消费
    2. 为什么不支持减少分区:代码复杂的增大,得不偿失(删除的分区消息如何处理,时间顺序,复制时暂停消费等)

    可以减少副本数

副本分区分配

核心目标:尽可能将不同topic的不同分区副本分散分配

关键点:

  1. 从0分区开始分配
  2. 每个分区起始brokerid不同:起始分区分配的brokerid随机,往后每个分区起始brokerid+1
  3. 不同分区的副本间隔不同:起始第一个分区的副本分配间隔随机,往后每个分区的副本分配间隔+1(每个分区内副本间隔不变)

每个副本分配也并不是简单的固定间隔,否则会出现所有副本都分配在同一个broker上,有一个replicaIndex()方法来做分配,但大致上可以理解为按间隔取模分配broker

优先副本选举

kafka集群中的一个broker最多只有分区的一个副本,当leader副本节点宕机时,需要选举新leader,为应付leader选举后的负载失衡问题(大量leader集中在同一个broker),引入优先副本概念

优先副本

AR集合列表中第一个副本

Topic:topic-partitions Partition:0  leader:1    Replica:1,2,0  Isr:1,0,2

以上分区0的优先副本为1,理想情况下就是leader副本.(broker下线,分区重分配等都会导致leader不是优先副本) kafka确保所有主题的优先副本在集群中均衡分布,即可保证集群分区负载均衡

即使集群中分区分配均衡,leader分配均衡,也不能确保整个集群负载均衡,因为每个topic/partition的数据量,TPS都不相同,依然会导致失衡问题

优选副本选举

优先副本选举:通过一定的方式促使优先副本选举为leader副本,以达到集群负载均衡

即原来的leader不是AR集合的第一个,那么就重新选举,更改leader为AR集合的第一个broker

  • 分区自动平衡
    auto.leader.rebalance.enable:默认开启(true),kafka控制器会轮询所有broker节点,计算每个broker节点的分区不平衡率(broker不平衡率=非优先副本的leader个数/分区总数),若该值超过leader.imbalance.per.broker.percentage(默认10%),则会自动执行优先副本选举动作以求分区平衡

  • 手动分区平衡
    kafka-perferred-replica-election.sh脚本可以手动触发leader平衡,还可以指定部分分区进行优先副本选举

    建议手动触发,而非自动触发,避免在关键时期自动平衡导致性能下降

分区重分配

  1. broker失效后,kafka不会迁移失效节点的分区副本
  2. 新增broker节点后,原先的topic 分区不会分配到新broker中,而新topic分区会分布到所有broker中,新旧topic在broker中分配不均衡

解决上述问题,引入分区重分配,kafka-reassign-partition.sh可以执行分区重分配,可以在集群扩容,broker下线的场景下对分区进行迁移

其实就是通过kafka控制器为每个分区添加副本,新副本从leader中复制消息,然后删除旧副本
分区重分配可能导致分区分配不均衡(多个leader副本都在同一个broker上)此时可执行优选副本选举

日志存储

日志
每个分区副本都有一个对应的log日志文件夹

日志索引

有偏移量索引和时间戳索引,都是稀疏索引,使用二分查找

  • 偏移量查找方式
  1. 通过跳跃表ConcurrentSkipListMap定位到对应日志分段logSegment(每个日志分段的baseOffset作为key)
  2. 再在对应的索引文件中找到索引项(稀疏索引,不一定有该索引,找比目标值小的最大的索引),得到日志位置
  3. 最后到日志文件位置中顺序查找即可
  • 时间戳查找方式
  1. 查找不小于targetTimeStamp的largestTimeStamp(最后一条消息的时间)的日志分段
  2. 二分查找时间戳索引文件,找到小于targetTimeStamp的最近索引,得到offset
  3. 在索引文件中查找offset,得到文件位置
  4. 从日志分段位置开始查找大于targetTimeStamp的消息

日志清理

可以通过log.cleanup.policy来设置日志清理策略(可以设置同时支持两种策略)

  1. delete-日志删除(log retention):按照一定的保留策略直接删除不符合条件的日志分段
  2. compact-日志压缩(log compaction):针对每个key的消息进行整合,对相同key的消息只保留最后一个值

消息压缩:compress message 使用gzip等压缩方式压缩
日志压缩:compact massage,日志清理合并

日志删除

日志管理器中起一个专门的日志删除任务来周期性检测和删除不符合保留条件的日志分段文件,log.retention.check.interval.ms设置周期时间,默认300000(5分钟),主要有三种日志分段保留策略:基于时间的保留策略,基于日志大小的保留策略,基于日志起始偏移量的保留策略

删除日志分段文件步骤

  1. 先删除日志分段对应的跳跃表数据,以保证没有线程对这些日志分段进行读写操作
  2. 将要删除的日志分段中的所有文件(包括索引)添加".delete"后缀
  3. 最后让延期任务删除有".delete"后缀的文件

日志压缩

有相同key的不同value值,只保留最后一个版本,消息的key值不应为null.会生成新的日志分段文件,该过程类似于redis中的RDB持久化模式

日志压缩

如下,分段日志文件中的清理检查点

清理检查点
为了避免当前活跃的日志分段activeSegment称为热点文件,activeSegment不会参与log compaction的执行

其中skimpyOffsetMap<md5(key),offset>用于构建key与offset的对应map,用于找到要删除的删除具体消息

不同的key,但md5可能相同,有可能误删除消息,导致消息丢失

日志分段压缩
取[0,firstUncleanableOffset]范围的日志分段,按顺序取size之和小于log.segment.bytes(默认1G)分为一组,压缩合并出一个新的日志分段文件,以减少小文件

kafka性能优秀的原因

  1. 日志文件追加消息,顺序读写,速度快于内存随机读写.操作系统可以预读(提前将一个比较大的磁盘读入内存)和后写(合并写操作)
    IO速度
  2. 使用页缓存:页缓存即磁盘缓存,把对磁盘的访问变为对内存的访问
    磁盘IO流程
  3. 零拷贝(zero-copy):直接将数据从磁盘复制到网卡设备中,不经由应用程序之手,可以大大减少内核和用户模之间的上下文切换
  • 非零拷贝

    非零拷贝

  • 零拷贝

    零拷贝

深入服务端

控制器

集群中一个broker选为控制器controller,负责管理整个集群中所有分区和副本的状态.集群中任意时刻只有一个controller

职责:

  1. 监听分区相关变化:分区重分配,ISR集合变更,优先副本选举
  2. 监听主题相关变换:主题增减,主题删除
  3. 监听broker相关变化:处理broker增减
  4. 更新集群元数据信息
  5. 如果开启优先副本均衡auto.leader.rebalance.enable,则启动定时任务维护分区优先副本均衡

控制器对主题,分区,集群状态的监听都是基于zookeeper回调

controller选举

通过在zookeeper中创建/controller临时节点,内容为{version:1,brokerid:0,timestamp:xxxxx} 只有创建/controller节点成功的才能成为控制器,每个broker都会在内存中保存当前控制器的brokerid的值

/controller_epoch:zookeeper中用于记录控制器变更次数的持久化节点,控制器每次变更都+1.控制器中保留该值,每个和控制交互的请求都需要带上该值,只有该值对应上才表示请求正常, 以此保证控制器的唯一性

每个broker都会监听/controller节点,以参加controller选举和更新本地controller信息

分区leader选举

分区leade由controller选出

分区leader选举时机:创建主题,增加分区,原leader副本下线

选举策略:选择AR集合中按顺序第一个存活的副本,且该副本在ISR中

不是ISR集合的第一个副本 unclean.leader.election.enable:设为true,则可以选择不在ISR中副本,只在AR集合的副本为leader

深入客户端

分区分配策略

通过partition.assignment.strategy设置消费者与订阅主题之间的分区分配策略,默认为RangeAssignor,还有RoundRobinAssignor和StickyAssignor

RangeAssignor

按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区跨度进行平均分配,其中消费者按字典序排序 每个主题分配的计算逻辑:n=分区数/消费者数量,m=分区数%消费者数量
则前m个消费者分配n+1个分区,后面的消费者分配n个分区

eg:两个消费者C0,C1,2个主题3个分区,分配结果

C0:t0p0,t0p1, t1p0,t1p1
C1:t0p2,t1p2

注意,是先计算出C0要消费两个t0主题,所以分配分区时是一次性分配连续的两个分区

可以看到分区分配并不均匀,消费者字典序排前的在多个主题的情况下容易多分配分区,极端情况下,有n个主题,那么排前的消费者就多消费n个分区,可能会有过载

RoundRobinAssignor

将消费者组内的所有消费者及订阅的所有主题分区按照字典序排序,轮询逐个分配分区

C0:t0p0,t0p2,t1p1
C1:t0p1,t1p0,t1p2

一个主题分配完,再分配下一个主题,一个个分区去分配,即先分配t0,分完了再分配t1

同一个消费者组内的消费者可以订阅不同的分区(消费者可以分开启动,只要groupid相同就是同一个消费者),此时就可能出现分区消费不均衡的问题

C0订阅t0,C1订阅t0,t1,C2订阅t0,t1,t2,每个主题的分区数不同,分区数分别为1,2,3,订阅结果如下

C0:t0p0
C1:t1p0
C2:t1p1,t2p0,t2p1,t2p2

StickyAssignor

Sticky:粘性

核心目标:

  1. 分区均匀分配
  2. 分区分配尽量与上次分配的保存相同:支持消费者变动后的重新分配,减少不必要的分区移动

C0订阅t0,C1订阅t0,t1,C2订阅t0,t1,t2

# RoundRobinAssignor分配
C0:t0p0
C1:t1p0
C2:t1p1,t2p0,t2p1,t2p2

# StickyAssignor分配,更均衡一点
C0:t0p0
C1:t1p0,t1p1    #C1没有订阅C2
C2:t2p0,t2p1,t2p2

当C0脱离了消费者组

# RoundRobinAssignor全部重新计算,重新分配,t1p1和t1p0移动了
C1:t0p0,t1p1 
C2:t1p0,t2p0,t2p1,t2p2 

# StickyAssignor重分配,只添加了t0p0,其他未动
C1:t1p0,t1p1,t0p0
C2:t2p0,t2p1,t2p2

消费者协调器

旧版zookeeper中与kafka有关的节点

旧版zookeeper中与kafka有关的节点

旧版是指1.x的版本?

问题:

  1. 羊群效应(Herd Effect):一个zookeeper节点变化,大量watcher通知被发送到客户端,导致在通知期间的其他操作延迟,可能死锁
  2. 脑裂问题(split brain):消费者再均衡时,每个消费者都与zookeeper进行通信以判断消费者broker变化情况,可能导致同一时刻各个消费者获取的状态不一致

新版有消费者再均衡管理

触发消费者再均衡的操作:

  1. 新消费者加入
  2. 消费者下线:宕机,GC延迟,网络延迟未发生心跳等
  3. 消费者主动退出消费者组,取消主题订阅
  4. 消费组内订阅的主题发送变化(新增,删除主题),主题分区数发生变化
  5. 消费者所对应的GroupCoorinator节点发送变更

事务

kafka0.11.00开始引入了幂等和事务,以此实现exactly once

幂等

生产者开启幂等enable.idempotece=true

需要配套设置: retries>0,acks=-1(all),max.inflight.requests.per.connection<=5

原理: PID(producerid) + 序列号(sequence number)

PID:每个新的生产者实例对应一个
序列号:每个PID发送到每一个分区都有对应序列号,生产者中<PID,分区>对应序列号从0递增.

broker端也有个<PID,分区>,只有收到生产者发送来的序列号=broker自己维护的<PID,分区>对应的序列号+1时,broker才会接受它

生产者序列号>broker序列号+1:中间有数据缺失,消息丢失,抛OutOfOrderSequenceException
生产者序列号<broker序列号+1:重复消费,丢弃消息

<PID,分区>只针对单个分区而言,即kafka只保证单个生产者会话session中单分区幂等

事务

事务可以保证对多个分区写入操作的原子性,即kafka中的事务可以使应用程序将生产消息,消费消息和提交消费位移当做原子操作来处理

要求:

  1. 需提供唯一的transactionid
properties.put("transaction.id","transactionid");
  1. 生产者开启幂等
properties.put("enable.idempotece",true);

一个transactionid对应一个PID,每个生产者获得transactionid时,都会获得一个递增的producer epoch,以保证同一个transaction只有一个生产者?不是只保证<transaction,PID>唯一,但生产者可以有多个,PID不同,<transaction,PID>也不同?

可靠性探究

副本失效

定义: 当ISR集合中的一个follower副本滞后leader副本的时间超过replica.lag.time.max.ms(broker级别),则判定副本失效

kafka副本管理器会启动一个副本过期检测的定时任务,检查当期时间与副本lastCaughtUpTimeMs差值是否大于参数replica.lag.time.max.ms指定的值

注意,follower副本是在拉取到leader副本LEO(LogEndOffset)之前的所有日志时,才更新副本的lastCaughtUpTimeMs,所以有时候拉取过慢,拉取的不是LEO之前的所有日志,即使一直在不停拉取,但还是不会更新lastCaughtUpTimeMs

失效副本判定

副本失效原因:

  1. follower副本进程卡住,在一段时间内无法向leader副本发起同步请求,如频繁full gc
  2. follower副本进程同步过慢,在一段时间内无法追赶上leader副本,比如I/O开销过大
  3. 新增副本/在赶上leader副本之前都是出于失效状态

follower赶上leader的判定标准:follower LEO不小于Leader的HW, 注意,不是replica.lag.time.max.ms为判定标准

ISR伸缩

ISR缩减

isr-expiratio周期任务:按replica.lag.time.max.ms/2周期检测每个分区是否需要缩减其ISR集合,变更后将ISR 集合记录到zookeeper和isrChangeSet缓存中

isr-change-propagation周期性检查isrChangeSet,有变更则更新到zookeeper中(和isr-expiratio更新目录不一样),通过zookeeper的watch回调更新集群各个broker节点的元数据

ISR增加

follower赶上leader的判定标准:follower LEO不小于Leader的HW, follower加入ISR集合,同样更新ISR集合记录到zookeeper和isrChangeSet缓存中,isr-change-propagation同样周期性检查

为什么要用两个周期任务监控ISR集合变更?
是否因为isr-change-propagation被缩减和增加共享,并且ISR集合变化时,要符合一定的时间条件才更新zookeeper,出发回调(避免过多回调),如果时间为满足,待下一周期触发,所以要周期任务
时间条件: 1. 上一次ISR集合发生变化时距离现在超过5s 2. 上一次写入zookeeper时间距离现在超过60s

其他

kafka为什么不支持主写从读?

  1. 延迟低,数据一致性高,即主写从读, 缺点:延时,数据一致性
  2. leader副本负载均衡

kafka负载不均衡的情况

  1. broker端分配的分区不均,个别broker分配过多partition -- 分区分配算法致力解决的问题
  2. 生产者对部分leader写入消息不均,如热点数据 -- 业务问题
  3. 消费者消费数据不均 -- 业务问题
  4. leader副本切换不均,主从副本切换导致部分leader副本扎堆在某个broker -- 优先副本方案致力解决的问题

kafka可靠性

  1. 副本数量
  2. ack=-1:ISR同步,min.insync.replicasISR最小副本数设置>1
  3. 客户端设置retries>0,retry.backoff.ms重试间隔 ,针对可重试异常,如NetworkException

    retries>0max.in.flight.requests.per.connection>1, 则重试会和顺序性冲突

  4. unclean.leader.election.enable=false 禁止非ISR副本成为leader
  5. 消费端enable.auto.commit=false

参考链接

  1. 深入理解Kafka:核心设计与实践原理