Kafka的“0233”你都掌握了吗?

244 阅读17分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

说到kafka,如果你不是数据处理的从业人员,你可能会首先想到才华横溢的奥地利小说家弗兰茨卡夫卡以及他的代表作《变形记》,很多人都是卡夫卡的拥趸。其实我第一次听说这个名字的时候也是这么想的,虽然我不是卡夫卡的粉丝。

无独有偶kafka的架构师jay kreps也是其中之一,由于jay kreps非常喜欢franz kafka,并且觉得kafka这个名字很酷,因此取了个和消息传递系统完全不相干的名称kafka,该名字并没有特别的含义。

kafka的诞生,是为了解决linkedin的数据管道问题,起初linkedin采用了ActiveMQ来进行数据交换,大约是在2010年前后,那时的ActiveMQ还远远无法满足linkedin对数据传递系统的要求,经常由于各种缺陷而导致消息阻塞或者服务无法正常访问,为了能够解决这个问题,linkedin决定研发自己的消息传递系统,当时linkedin的首席架构师jay kreps便开始组织团队进行消息传递系统的研发;

在很多的流处理框架的介绍中,都会说kafka是一个可靠的数据源,并且推荐使用Kafka当作数据源来进行使用。这是因为与其他消息引擎系统相比,kafka提供了可靠的数据保存及备份机制。并且通过consumer offset位移这一概念,可以让消费者在因某些原因宕机而重启后,可以轻易得回到宕机前的位置。

后来linkedin将kafka捐赠给了Apache基金会,kafka发展进入了快车道。作为一个优秀的分布式消息系统,Kafka 已经被许多企业采用并成为其大数据架构中不可或缺的一部分。事实上成为了大数据处理消息中间件的首选,也成为了大家工作中不可或缺的一个组件。

既然kafka这么重要,那么kafka的很多特性都需要大家掌握,本文给大家讲一讲kafka的“0233”,这对于kafka的使用者来说还是必须要掌握下的。

正文

上面预热了很久,也讲了kafka的诞生史后,下面进入正题。首先来说下kafka的“0233”到底是什么。 其实这是由4个知识点共同组成的:

  • 0:代表着kafka 的zero byte copy的特性
  • 2:代表着kafka producer实现EOS(exactly once semantics)的两个配置
  • 3:代表着kafka consumer消费数据的三种模式
  • 3:代表着kafka rebalance时的三种partition平衡策略

下面就根据这四点来简单说一说kafka的特性,本文的目的并非详细解读这四个特性,否则本文的字数可能要超过五位数。本文的目的是提纲挈领,帮助他们了解和入门者几个特性,对于大家深入了解kafka的用法以及高级特性很有帮助,如果需要进一步深入了解,网上的资料很多,大家可以按需学习。

kafka的zero byte copy

kafka读写数据的方式

首先,有一定操作系统或者硬盘知识的小伙伴肯定知道,硬盘尤其是机械硬盘,顺序读的性能都很高,而随机读的性能却相当的差劲;而固态硬盘虽然可以解决随机读性能的问题,但是随之而来带来的就是高昂的价格以及有限的寿命(数据修改以及覆盖就是耗费寿命的表现)。这对于大数据处理的场景来说显然是成本高昂的。

普通磁盘的顺序访问速度跟SSD顺序访问速度差不多一致,远超随机访问的速度,甚至能达到内存随机访问的速度(这里举的例子是指SAS磁盘),随机读写相对于顺序读写主要时间花费在磁盘寻道上,并且顺序读写会预读信息,所以速度自然就差异很大了。

而kafka在考虑到上述的问题后,选择了顺序写数据,且数据写入后就不能修改,使用追加数据的方式来写数据,这样在保证高性能的前提下也降低了数据写入的复杂性(禁止修改大大减少了数据同步以及并发控制的成本和难度)。而且在读的时候默认支持顺序读取,顺序读取也保证了kafka在机械硬盘上的读取效率也是相当优秀的。如果要唯一定位一条消息,使用<topic, partition, offset>三元组即可。

kafka的zero byte copy的实现

下面再来看看kafka的zero byte copy的实现,直接拿普通的transfer动作来进行比较:

可以看到普通的transfer的过程:硬盘 -> 内核buffer -> 用户buffer ->内核socket缓冲区 -> TCP 协议栈

而zero byte copy的过程:硬盘 -> 内核 -> TCP协议栈

从上面可以明显看出,zero byte copy上下文仅仅切换了两次,而普通的transfer则切换了四次,差别的两次是用户态与内核态的两次交互,减少了数据拷贝问题带来的性能损耗。从总体来看,这两次交互绝大多数场景下是没意义的,尤其对于kafka来说。所以使用了zero byte copy后kafka的读写效率提升很大。

kafka producer实现EOS的两个模式

Kafka的EOS主要体现在3个方面:

  • 幂等producer:保证发送单个分区的消息只会发送一次,不会出现重复消息
  • 事务(transaction):保证原子性地写入到多个分区,即写入到多个分区的消息要么全部成功,要么全部回滚
  • 流处理EOS:流处理本质上可看成是“读取-处理-写入”的管道。此EOS保证整个过程的操作是原子性。注意,这只适用于Kafka Streams

上面3种EOS语义有着不同的应用范围,幂等producr只能保证单分区上无重复消息;事务可以保证多分区写入消息的完整性。

而流处理EOS保证的是端到端(E2E)消息处理的EOS,由于作者没有使用过kafka stream,只是知道在kafka stream的配置中直接设置EOS语义即可:processing.guarantee=exactly_once,所以此章节不会展开讲解这个内容,感兴趣的可以去自学下。

以下针对前两种模式展开讲解下:

幂等producer

启用幂等producer:在producer程序中设置属性enable.idempotence=true,但不要设置transactional.id。注意是不要设置,而不是设置成空字符串或"null"

而开启这种机制的开销相当低,它只是在每批消息中添加了几个额外字段:

  • PID,在Producer初始化时分配,作为每个Producer会话的唯一标识;
  • 序列号(sequence number),Producer发送的每条消息(更准确地说是每一个消息批次,即ProducerBatch)都会带有此序列号,从0开始单调递增。Broker根据它来判断写入的消息是否处理过。

这个功能如何工作的呢?它的工作方式如下:发送到Kafka的每批消息将包含一个序列号,该序列号用于重复数据的删除。序列号将被持久化存储topic中,因此即使leader replica失败,接管的任何其他broker也将能感知到消息是否重复。

事务(transaction)

启用事务支持:在producer程序中设置属性transcational.id为一个指定字符串(你可以认为这是你的事务名称,故最好起个有意义的名字),同时设置enable.idempotence=true。该API允许producer发送批量消息到多个partition。该功能同样支持在同一个事务中提交消费者offsets,因此真正意义上实现了端到端的exactly-once delivery语义。开启后样例代码如下:

producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(data1);
    producer.send(data2);
    producer.commitTransaction();
} catch(ProducerFencedException e) {
    producer.close();
} catch(KafkaException e) {
    producer.abortTransaction();
}

值得注意的是,某个Kafka topic partition内部的消息可能是事务完整提交后的消息,也可能是事务执行过程中的部分消息。 而从consumer的角度来看,有两种策略去读取事务写入的消息,通过"isolation.level"的进行配置来正确使用事务API:

  • read_committed:可以同时读取事务执行过程中的部分写入数据和已经完整提交的事务写入数据;
  • read_uncommitted:完全不等待事务提交,按照offsets order去读取消息,也就是兼容0.11.x版本前Kafka的语义;

kafka consumer消费数据的三种模式

kafka的消费数据的模式总共有3种:At-most-once(最多一次),At-least-once(最少一次),Exactly-once(精确一次)。为什么会有这3种模式?是因为客户端处理消息和提交反馈(commit offset)这两个动作不是原子性的。下面是具体的模式描述:

  • At-most-once:客户端收到消息后,在处理消息前自动提交,这样kafka就认为consumer已经消费过了,offset增加。如果offset提交成功而消息处理失败,就造成了丢数据的情况,但是这种场景可以避免重复消费数据。
  • At-least-once:客户端收到消息后,先处理消息,处理成功后再提交offset。这样就可能出现消息处理完了,在提交反馈前,网络中断或者程序挂了,那么kafka认为这个消息还没有被consumer消费,产生消息重复消费。
  • Exactly-once:保证消息处理和提交反馈在同一个事务中,即有原子性。数据不多不少,正好被处理一次。

下面结合具体的配置和代码来说明下如何开启和使用这三种模式:

At-most-once

思路:

  • 设置enable.auto.commit为true
  • 设置 auto.commit.interval.ms为一个较小的时间间隔
  • client不要调用commitSync(),kafka在特定的时间间隔内自动提交offset

代码样例:

    public void atMostOnce(){
        Properties props = new Properties();
        props.put("bootstrap.servers""localhost:9092");
        props.put("group.id""at-most-once");
        props.put("enable.auto.commit""true");
        props.put("auto.commit.interval.ms""1000");
        props.put("key.deserializer""org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer""org.apache.kafka.common.serialization.StringDeserializer");
        KafkaConsumer<StringString> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList("test-topic"));
        while (true) {
            ConsumerRecords<StringString> records = consumer.poll(100);
            for (ConsumerRecord<StringString> record : records) {
                process(record);
            }
        }
    }

At-least-once

思路:

  • 设置enable.auto.commit为false
  • client调用commitSync(),手动同步offset

代码样例:

    public void atLeastOnce(){
        Properties props = new Properties();
        props.put("bootstrap.servers""localhost:9092");
        props.put("group.id""at-least-once");
        props.put("enable.auto.commit""false"); //取消自动提交offset
        props.put("key.deserializer""org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer""org.apache.kafka.common.serialization.StringDeserializer");
        KafkaConsumer<StringString> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList("test-topic"));
        while (true) {
            ConsumerRecords<StringString> records = consumer.poll(100);
            for (ConsumerRecord<StringString> record : records)
                process(record);
                consumer.commitAsync(); //手动提交offset
        }
    }

Exactly-once

这种方式是最复杂的。如果要实现这种方式,必须自己控制消息的offset,自己记录一下当前的offset,对消息的处理和offset的移动必须保持在同一个事务中。例如在同一个事务中,消息处理成功后同时更新此时的消息的偏移到第三方存储,如redis或者rds。 思路:

  • 设置enable.auto.commit为false
  • 保存ConsumerRecord中的offset到数据库
  • 将数据处理以及offset提交放在一个事务中处理,如果使用springboot可以使用Transactional注解,其他情况需要自己实现事务管理机制。
  • 当partition分区发生变化的时候需要rebalance时需要注册ConsumerRebalanceListener接口,捕捉这些事件,对偏移量进行处理。

这个逻辑有点多,就不提供例子了,大家如果需要可以在网上找一下,应该有很多。

从上面可以看出,实现Exactly-once的成本还是很高的,所以除非必要,否则还是别使用这种模式。如果通过某种方式实现或者保证了Consumer的幂等性(如数据入库,通过标识数据唯一性的key做主键来对数据进行自动去重),那么大多数情况下可以使用At-least-once来代替Exactly-once,建议优先使用这种方式。

kafka rebalance时的三种partition平衡策略

什么是rebalance

下面先看一个例子,某Consumer Group下有2个consumer实例,它订阅了一个具有10个partition的 Topic。正常情况下,kafka会为每个Consumer平均的分配5个分区。这就是kafka的balance机制。

但是某些某些特定情况下,这种balance机制会被打破,而kafka需要一个操作来重新达到balance状态。这个过程就是Rebalance。

Rebalance 的触发条件有3个:

  • Consumer Group成员个数发生变化。例如有新的consumer实例加入该消费组或者离开组。
  • 订阅的Topic个数发生变化。
  • 订阅Topic的分区数发生变化。

而kafka rebalance的策略有如下三种:

  • RangeAssignor
  • RoundRobinAssignor
  • StickyAssignor

用户可以通过partition.assignment.strategy参数进行配置分区策略, 默认使用的策略是org.apache.kafka.clients.consumer.RangeAssignor, 其它选项还有org.apache.kafka.clients.consumer.RoundRobinAssignor和org.apache.kafka.clients.consumer.StickyAssignor (0.11版本之后)这两种。

下面举几个例子简单说说这三种策略的差别吧:

RangeAssignor

就是根据每个topic的partition数量按照consumer的数量完全均分。是默认的规则,下面来看看这个规则的效果:

2个Topic;每个Topic有4个分区;2个消费者C0,C1;所订阅的分区标识为 t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3 分配结果:

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

看似分配的很和谐很均匀。 假如上面的每个Topic改为3个Partition,订阅的分区标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2 分配结果:

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

明显的看到这样的分配并不均匀,平均只存在于topic的粒度下,而跨topic则无法做到均衡。如果将类似的情形扩大,有可能会出现部分消费者过载,而部分消费者又会闲置的情况。

RoundRobinAssignor

吸取了上面RangeAssignor的教训,RoundRobinAssignor将消费组内的所有消费者以及消费者所订阅的所有topic的partition按照字典顺序排序,然后通过轮询的方式逐个将分区以此分配给每个消费者,说白了也就是取消了topic粒度的均衡,而是改成了全局所有partition的均衡,能完美解决RangeAssignor存在的问题。

回到上面的例子,假如上面的每个Topic改为3个Partition,订阅的分区标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2

RoundRobinAssignor的分配结果:

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

虽然解决了RangeAssignor存在的问题,但也有新的问题:

对于多topic且consumer订阅的topic不同时的情况分析:

3个topic:t0,t1,t2。 t0有2个partition,t1有3个partition,t2有4个partition Consumer中有3个Consumer,C0,C1,C2; C0订阅了 t0, C1订阅了t0和t1, C2订阅了t0,t1,t2

RoundRobinAssignor分配方案:

C0:t0p0,
C1:t0p1,t1p0,t1p2
C2:t1p1,t2p0,t2p1,t2p2,t2p3

很明显受制于topic订阅的不同,partition的数量无法在多个consumer中间完全均衡,同样造成了部分消费者过载,而部分消费者又会闲置的情况

StickyAssignor

经历了上述两种情况后,kafka最后祭出了大杀器StickyAssignor,完美的解决了问题。

StickyAssignor的原则就是它保证分配尽可能平衡,为了达到这个目的,必须遵守下面两个规则:

  • 分配给Consumer的topic partitions数量最多相差1个;或者每个拥有比其他Consumer少2倍以上的topic partitions的Consumer无法将任何这些topic partitions转移给它
  • 当发生重新分配时,它会保留尽可能多的现有分配。当topic partitions从一个使用者移动到另一个Consumer时,这有助于节省一些开销处理

上面的规则不是很形象,下面用一个例子来对比下StickyAssignor和RoundRobinAssignor,帮助大家理解StickyAssignor的运行机制:

3个Consumer:C0,C1,C2,均订阅了4个Topic:t0,t1,t2,t3, 每个Topic 有2个Partition 分区个数:t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1 这8个分区 分配结果:

C0:t0p0、t1p1、t3p0
C1:t0p1、t2p0、t3p1
C2:t1p0、t2p1

似乎跟前面的分配没有很大区别,分配的很和谐很完美。但是假如有一个Consumer 脱离了消费组,那么消费组就会执行在平衡操作,消费分区重新分配。如果采用RoundRobinAssignor策略,分配结果如下:

C0: t0p0,t1p0,t2p0,t3p0
C2:t0p1,t1p1,t2p1,t3p1

RoundRobinAssignor策略 会按照消费者C0,C1重新轮询分配。

而如果采用StickyAssignor策略,StickyAssignor策略分配结果:

C0:t0p0,t1p1,t3p0,t2p0
C2:t1p0,t2p1,t0p1,t3p1

可以看出StickyAssignor策略分配结果 保留了第一次的分配结果然后将脱离消费组的C1 在分配给剩余的两个消费者,最终都保持了平衡,展示了上面所说的第二点规则。

再来分析下下面这个例子:

3个topic:t0,t1,t2。 t0有1个partition,t1有2个partition,t2有3个partition t0p0、t1p0、t1p1、t2p0、t2p1、t2p2,Consumer中有3个Consumer,C0,C1,C2; 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
C2:t2p0,t2p1,t2p2

这样很明显比上面的分配结果要均匀了。此时如果C0脱离了消费组在来对比两种消费结果。

RoundRobinAssignor方式:

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

StickyAssignor策略,那么分配结果

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

结果上看StickyAssignor策略比另外两者分配策略而言显得更加的优异,更合适复杂的业务场景,所以生产上推荐使用 StickyAssignor策略。

总结

在kafka出现的很长一段时间内,kafka由于迅猛的发展已经事实上成为了消息中间件的首选,大家在技术选型的时候基本上都会优先考虑kafka。这是kafka的辉煌,但也引来了很多挑战者的挑战。

2018年InfoWorld最佳开源数据平台奖公布,连续两年入选的 Kafka 这次意外失手,pulsar取而代之。当大家用惊艳的目光来审视这个后来者的同时,也发现了kafka在光环下原本存在的短板。

从当前了来看,pulsar有三点比kafka做得好:

  • pulsar支持多租户,有资产和命名空间的概念,这就使得资源隔离以及权限管理成为了可能
  • kafka采用文件存储,而pulsar采用Apache BookKeeper存储,kafka的文件存储在rebalance时会带来性能损失,而pulsar的BookKeeper则不需要类似的操作
  • pulsar的broker是无状态的,数据存储在BookKeeper中,服务和存储是分离的。所以两者可以随意进行集群的调整,而不会像kafka一样绑定在一起,服务和存储需要同时调整

虽然pulsar的出现对kafka的一些问题和痛点进行了优化,但是这不代表着pulsar将会全面替代kafka成为新的龙头。pulsar看上去很美,而且已经有了实践去验证,明天肯定会很光明。但就像kafka一样,刚刚推出的时候,也是一片惊呼,性能碾压,不过时至今日也没有一统江山,每种工具还是都有自己更适合的场景的。所以pulsar肯定也不会一只独秀。但是有了pulsar这个强劲的对手后,很期待kafka接下来的发展。

对于程序员来说,新的技术意味着多一个选择,这对于技术发展来说绝对是一件好事。但是也没有必要喜新厌旧,毕竟最合适的才是最好的,够用就好。技术发展趋势需要去跟随,但也不能被技术发展趋势牵着鼻子走!