张晋尉,腾讯云消息队列专项支持团队成员,kafka,puslar资深开发者,kafka sdk贡献者,在流式数据处理,消息队列方向有多年实践经验。
Kafka 简介
kafka 是一款已经发布了近10年的分布式消息队列系统,是一款非常成熟的产品,在各大公司或者产品中或多或少都有他的身影,特别是大数据流处理,log 流处理之类的场景,kafka 更是充当着几乎必不可少的角色。
这款消息队列在官方给出的定义中被称为“分布式流式处理平台”,其主要目的是在大数据流处理中承担着存储记录流的一个作用,不过到了现在这个年代,越来越多的业务架构更倾向于将 kafka 当作消息队列来使用,用来取代比较厚重且性能有限的 RabbitMQ。
kafka 这样一个系统为了确保其简洁性和高性能,其实将很多逻辑细节和配置放到了 client 端,所以我们将从客户端的视角出发,从使用者的角度通过生产者和消费者两个方面来介绍 kafka 在实践生产中遇到的一些问题和相应的技术细节。本文是系列文章的第一篇,介绍生产者。
标准 producer API 简介
这里我们先介绍下最经常使用的生产者 API,相信看本文各位已经是 kafka 使用的熟手了,不过为了后续介绍可能会使用的一些术语,我们还是先复习下 kafka 基础概念,这里我们只关注于生产这部分,忽略其他的无关细节。
首先我们画出生产者和 kafka 交互的一张图,这张图用于描述生产者消息数据的流向和 kafka server 为了接受消息需要用到什么组件。
图片如上,现在让我们分别介绍下图上所绘内容以及相应的专业术语
- Client 指的是将会写入消息的多个不同的客户端,这里的客户端是一个抽象化的概念,只要和 kafka server建立了连接,将会写入消息到 kafka 中,无论是否在同一个服务器或者一个进程中,我们都把它称为一个 client。
- Broker 指的是加入到了集群里面的服务器,这是一个物理层面的机器节点。一台机器上部署了 kafka,并且加入到了 kafka 集群,那么这台机器就是 kafka 集群的一个节点。一般情况下,一台机器只会部署一个 kafka 服务。
- Topic 是一个抽象的概念,主要的作用是将用户处理的消息分为不同的类别,同时每个 topic 可以具有 topic纬度的一些配置,比如消息最大大小之类的,topic 下会创建不同数量的 partition 去实际的承载消息,这里值得注意的是 kafka 和 RabbitMQ 不一样的地方,kafka 每创建一个 topic 都会在 broker 上去创建对应数量的 partition,所以 kafka 的 topic 数量是有限的,而且尽量不能太多。
- 而 RabbitMQ 的 topic 是一个路由概念,创建非常大数量的 topic 并不会实际创建承载的队列,而只是在订阅分发的时候执行不同的路由策略。这是很多从 RabiitMQ 切换到 kafka 的用户比较常见的问题。
- Partiotion 是 topic 的分区,用来实际的去承载消息,每个 partition 之间是没有关联的,他们各自有各自的顺序和消息内容,以及记录的 offset。每个生产者的消息都会写入到一个 partition 中去,生产者自己会根据自己的算法去选择 partition 去写入。
- Replicas 是 partition 的副本,是物理和概念两个层面的最小单位,它会将自己绑定到 broker上,每个partition 至少都需要有一个 replicas,它是消息实际写入的地方。当 partition 有多个 replicas 的时候,控制器会决定哪一个 replicas 会是 leader。消息始终会被写入到 leader 中,然后 leader 会同步数据到其他为 follower 状态的 replicas 中,所以如上图所示,client 的消息在选中写入到某个 partition 中后,实际上,client 会去连接 replicas leader 所在的 broker,然后把消息写进去。
现在我们开始讲述下关于生产者 API 的使用和一些在生产的时候需要注意的配置,这里的生产者 API 指的是 kafka 提供的几乎无状态的 API,非常的轻便,同时也可以提供非常不错的性能。不过如果使用这个 API 来进行生产,kafka 只保证最少一次和最多一次语义。
- 最少一次(at-least-once) 也就是说明消息可能重复,如果消息重复,那么消费者需要在消费的时候进行去重。
- 最多一次(at-most-once) 用的比较少,一般是大量不重要的数据处理的时候,容忍丢失数据的情况下,可以提供比较优秀的性能,生产端生产消息并投递后就不再关注是否成功了,也不会进行重试。
- 恰好一次(exactly-once) 这个语义在当前这个生产者api中是不提供的,不过由于大量的流式计算系统都需要保证 exactly once semantics,而且 kafka 也推出了kafka stream 这样的流式处理框架,所以后续新版本的 kafka 提供了事物消息来确保恰好一次语义,我们将在本文后续章节讨论这个问题。
接下来我们通过一个代码片段的实例来看下如何使用生产者 api,同时看一下一些重要的配置,首先让我们创建一个有两个 partition,每个 partition 都有两个 replicas 的 topic
- bin/kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 2 --partitions 2 --topic test
然后让我们写一段 java 生产者片段,代码非常简单,只是配置一些生产者 client 的相关配置,然后调用 producer的 send 方法将需要发送的消息提交到 kafka 的 client 库
- Properties props = new Properties();
-
- props.put("bootstrap.servers", "localhost:9092");
-
- props.put("acks", "all");
-
- props.put("retries",3);
-
- props.put("retry.backoff.ms",2000);
-
- props.put("compression.type","lz4");
-
- props.put("batch.size", 16384);
-
- props.put("linger.ms",200);
-
- props.put("max.request.size",1048576);
-
- props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
-
- props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
-
- props.put("request.timeout.ms", 10000);
-
- props.put("max.block.ms", 30000);
-
- Producer
producer = new KafkaProducer(props); for (int i = 0; i < 1000; i++) { -
- Future
future = producer.send(new ProducerRecord<>("test", UUID.randomUUID().toString())); -
- System.out.println("produce offset:" + future.get().offset());
-
- }
-
- producer.close();
通过以上代码就可以完成消息的生产了,kafka 给我们提供的这个 API 的功能确实非常简单易用,当然这里面实际上包含上比较多的细节,不过被 client 封装了进去,这里我们继续往深处挖掘下,看看隐藏在这段代码里面的可能存在的坑。
首先我们来简单分析下在这段代码里面 client 会做什么,(注:这里我们更倾向于给出一个通用工作流程,所以可能会忽略部分 java客户端独有特性 )。
client 通过代码中给出的 bootstrap.servers 去连接 broker,这里如果第一个broker 连接失败,那么 client 会从左往右重试去连接,直到全部连接失败或者某一个地址成功连接。
当连接成功后,kafka client 会发起 ApiVersions request去kafka server 查询server 端支持各个 api 以及每个 api 最大的支持版本。从而达到 kafka 一个向下兼容的目的。当然由于 ApiVersions 是一个大约在 0.10 版本加入的 api,所以新版client 如果访问 0.9 版本的 kafka server 会引起ArrayIndexOutOfBoundsException 的报错,这个错误 kafka 官方在 0.10 的时候修复了。
接下来 client 会查询将要发送消息的 topic 的元数据信息,向已经连接的 broker 发送 Metadata request,通过这个 api,kafka client 将会拿到集群 broker 的各种信息,包括 ip 和 port,以及 broker 对应的唯一 id,同时 client 也将获取到 topic的相关信息,parition 的 id 和 partition 选择出来的 leader replicas 所在 broker 的id,然后 client 将会建立 leader replicas 所在 broker 的连接,作为实际发送消息的数据链路。
在这里我们有三个细节需要注意
- 第一点,如果 kafka 的 server 配置了 auto.create.topics.enable 为 true,那么如果 client 查询了一个不存在的 topic 元数据,这个 topic 随后会被 kafka server 自动创建。
- 第二点,一般来说,kafka client 处理 Metadata 是一个定期刷新的动作,假如 Metadata 每过 30s 刷新一次,那么在这 30s 中,用户修改了 topic 配置增加了一个 partition,client 是无法感知的,需要等待到client 更新了 Metadata,生产端才会知道这个 topic 多出了一个 partition,才能往新的 partition 写入数据。
- 那么如果在 Metadata 刷新时,由于 client 生产流量持续超过 kafka 配额限制,导致 kafka 限流,使得获取 Metadata 数据一直重试和超时,这种极端情况下,client 可能会非常长一段时间无法感知到 partition 的新增。
- 这种情况在生产实践中也是发生过的,如果大家使用过程中发现了这种生产端迟迟不写入任何消息到新建的 partition 的情况,那么多半可以从这个方向入手。
- 第三点,从 kafka 的建立链接的逻辑来看,kafka 实际上是会建立一条更多的链接的,同时也会直接链接到集群中不同的 broker 上,所以这里如果要申请防火墙策略,那么一定要为每个 broker 都申请好策略,否则可能会出现,能够拿到 Metadata,但无法生产消息的情况。
client 开始根据 message 中的 key 来计算 hash,确定这个 message 会被投递到哪个 partition 中去,然后 client 投递消息到本地的一个队列中,实际连接到partition 的投递者类,将从队列中取出消息,然后 client 会做两个检查之后调用Produce request 去投递消息。
- 如果消息大于 max.request.size 则直接返回 RecordTooLargeException
- 如果消息小于 batch.size 则等待后续消息,直到到消息大小总和大于 batch.size或者超过 linger.ms 规定的时间
- client 将会启动一个异步过程或者同步过程等待 Produce request 的返回,然后将依据配置的重试策略来执行重试或者返回发送失败的错误到业务逻辑中,让业务逻辑进行错误处理。
在这一步中主要涉及到以下几个配置
- acks 为 -1 或者 all,代表所有处于 isr(in-sync) 列表中的 replicas 都写入消息成功后才会返回成功给客户端,同时在 topic 级别也提供了一个min.insync.replicas 配置,如果 isr 中的 replicas 少于这个配置的值,那么写入同样会失败。这是 kafka 所能提供的最强约束了。
- acks 为 0,代表消息只要投递到 client 的 tcp socket 缓冲区后就认为已经发送出去了,client 不再关注是否 kafka 集群是否收到或者写入成功。在这种模式下kafka 只提供 at-most-once 语义,在容忍数据丢失的情况下,是性能最好的模式。
- acks 为 1 代表发送消息到 replicas leader 写入成功就返回成功,不关注其余follower 是否写入成功,如果投递消息后,leader 马上挂掉了,消息是会丢失的。
- 这个模式在大多数时候可以确保消息不丢失,是一个性能和安全性权衡的模式。
- server 将根据 client 提供的 acks 配置值来确定服务端的写入会在什么情况下返回给客户端
- client 如果接收到 produce 写入失败,那么将会重试 retries 配置的次数,每次重试之间间隔 retry.backoff.ms 所定义的时间。重试次数耗尽之后才会返回失败到业务逻辑。
以上就是整个 producer api 在使用过程中的一些细节了,明白了这些细节,在生产时遇到kafka的一些奇怪报错就会有一些思路去定位和处理。当然从代码上来看,代码里面还有一些配置在上面的文章中没有覆盖到,这里我在一起介绍一下
- compression.type 用于配置压缩,kafka 提供不同的压缩模式,包括 none(不压缩),gzip,snappy,lz4,以及 zstd(需要2.1.0以上版本的kafka)。
- 一般来说我们比较推荐 lz4 格式的压缩,在比较轻的 cpu 负载下,可以提供不错的压缩比,和非常高的吞吐量,整体的性能和性价比会优于其他几个压缩方式,所以一般没有强烈的压缩比需求的话,使用 lz4 是比较好的选择。
- key.serializer 和 value.serializer 是序列化器,这个只是在 java 的客户端中特有的,用于决定如何把 key 和 value 的值序列化,这里就不细说了。
- request.timeout.ms 这个配置定义了网络请求超时时间,任何一个 kafka client对于 server 的请求,如果在本参数规定时间内没有收到答复,那么就都会取消请求并认为请求失败,并将逻辑转移到失败处理逻辑,这个约束是比较强的约束。
- max.block.ms 这个配置项定义了 client 内部的一个阻塞时间,比如如果内部的异步队列满了,kafka client 调用 send 会等待这样一个时间,直到超时返回,这个参数需要注意的一点是用户自定义配置的序列化器和分区器中花费的时间不会计入这个参数超时中。
幂等生产者(Idempotent Producer)简介
幂等生产者提供了生产者在单一分区上的恰好一次语义,但是他不能覆盖到生产者对于复数 partition 操作的一致性,这种一致性需要通过后续的事务消息来解决。
现在让我们先看下幂等生产者如何使用,以及一些涉及到的细节。
为什么我们需要使用到幂等生产者,其主要的原因是生产者发送消息到服务端后,如果遇到了网络问题导致连接断开,生产者是无法感知到消息到底是写入成功还是失败,对于 kafka 一般的生产者 api 来说我们会设置 retries 参数,始终去进行重试,这也就是我们所谓至少一次语义,因为我们无法感知是否写入成功,如果写入成功,但是我们没有接收到成功的回复,我们进行重试动作,就会导致消息的重复写入,如果消息消费依赖于消息顺序,这种重试甚至会导致顺序的错乱。
现在通过幂等生产者,kafka 可以在我们进行这样的重试的时候丢弃掉这种重复写入的消息。
现在让我们看看如何使用幂等生产者。(这里让我们来看下代码,代码中让我们忽略掉一些不重要的配置)。
- Properties props = new Properties();
-
- props.put("bootstrap.servers", "localhost:9092");
-
- props.put("enable.idempotence", true);
-
- Producer
producer = new KafkaProducer(props); for (int i = 0; i < 1000; i++) { -
- Future
future = producer.send(new ProducerRecord<>("test", UUID.randomUUID().toString())); -
- System.out.println("produce offset:" + future.get().offset());
-
- }
-
- producer.close();
在用户的使用上,要启动幂等生产者只需要添加设置 enable.idempotence 为 true 就好,让我们继续关注下细节,看看启用幂等生产者后 kafka client 会做什么。首先 client会强制设置一些生产者的配置值。
- acks 会被强制设置为all,如果客户本来使用的是0,1级别的 acks 那么用户需要考虑下被设置为 all 的时候对于自己业务性能的影响,如果用户本来就是设置为all的情况,那么使用幂等生产者是几乎不会有额外代价的。
- retries 必须设置为大于1的数字,一般 librdkafka 和 java kafka client 会把 retries设置为一个非常大的数比如 Integer.MAX_VALUE,基本靠近于无限重试。确保消息一定会成功发送。
- max.inflight.requests.per.connection 必须小于5,其中 java kafka client 如果版本小于1.0.0,会把 max.inflight.requests.per.connection 设置为 1,确保一条数据链路上一次只有一个请求,这会导致一定情况下 tps 有所下降。
- 发送的消息格式必须是 v2 格式。不支持低版本的消息格式。
完成生产者配置之后,client 开始执行生产消息的发送,这里我们省略在上文提到过的生产 api 的逻辑,只关注于启用幂等后多出来的逻辑和步骤
- 生产者 client 向 broker 发起 InitProducerId request 请求一个 PID,后续发送的消息,都会带上这一个 PID 用于标明生产者的身份。
- 每个消息会带上一个单调递增的 Sequence ID。kafka server会记录下同一个PID最后一次提交消息的 Sequence ID,如果当前发送的消息 Sequence ID 小于等于最后一次提交的 ID,那么 server 会认为当前消息已经过期了,并拒绝接受消息。client 收到这样的拒绝请求后就可以感知到之前的消息一定是投递成功了,并停止重试发送,丢弃掉消息。
通过以上的这些步骤,kafka 确保了每个消费者对于单 partition 操作的一个幂等性,这是一个非常实用的功能,特别是在使用消费者 api 的时候本来就已经设置了acks 为 all 的业务,启用生产者幂等几乎没有额外消耗,这也是一个 kafka 推出了比较久的功能了(从 kafka 0.11 开始支持),但是目前看起使用本功能的用户还是比较少。
事务消息(Transactional Messaging)简介
事务消息是目前 kafka 为了确保恰好一次语义所提供的最强约束,他确保了一个生产者如果生产多个相互关联的消息到不同的 partition 上时要么最后同时成功,要么同时失败。同时启用事物消息的前提必须启用幂等生产者,所以单 partition 上的恰好一次语义就由幂等的特性来保证。
不过一般很少有业务会直接使用 kafka 的事物消息,会涉及使用事物消息的业务其实基本上都是通过 kafka stream 进行流处理,而 kafka stream 依赖于事务消息并且对于业务隐藏掉了事务细节,所以这里我们来看看如何直接使用事务消息并继续尝试分析下client 在这期间做了什么,先让我们放出一份代码片段。
- Properties props = new Properties();
-
- props.put("bootstrap.servers", "localhost:9092");
-
- props.put("enable.idempotence", "true");
-
- props.put("transactional.id", "testtrans-1");
-
- KafkaProducer
producer = new KafkaProducer(props); -
- producer.initTransactions(); try{
-
- producer.beginTransaction();
-
- producer.send(record0);
-
- producer.send(record1);
-
- producer.sendOffsetsToTxn(…);
-
- producer.commitTransaction();
-
- } catch( ProducerFencedException e) {
-
- producer.close();
-
- } catch( KafkaException e ) {
-
- producer.abortTransaction();
-
- }
首先代码里面执行 initTransactions 作为第一步,在这个逻辑中 client 将会请求 InitProducerId 并传递事务 id,用来建立一个事务 id 和 PID 一对一的关系。如果有多个生产者加入到同一个事务 id 中,前面加入的生产者都会被后面加入的替代。前面生产者的请求都会被拒绝。
值得注意的是如果 client 短线重新连接,它会在请求 InitProducerId 的时候提交之前使用的 PID 以及 epoch,如果成功随后 server 会返回 epoch+1,同时会拒绝所有 epoch小于当前 epoch 的生产者消息,这是为了解决分布式系统中所谓的僵死问题。
然后接下来的代码调用就和很多事务代码一样了,启动一个事务,写入所有需要写入的信息,最后再 commit,如果失败则回滚,如果成功就会一起提交所有写入,然后做接下来的业务逻辑。一般大部分事务的实现都是一个状态机,这里我们就放上一张图不继续分析下去了。
在看完了事务代码后,我们似乎没有提到 sendOffsetsToTxn 这个函数,这个函数实际上是用于当前事务消息是一个从一个 topic 消费,然后写入到事务消息的时候使用的,消费的 offset 可以通过这个函数提交到协调者,后续在事物提交的时候再一并提交消费者消费掉的 offset。防止事务失败的时候用户还需要手动管理消费者 offset。是一个非常有用的帮助函数。
总结
到此为止,我们从客户端视角出发简单的去分析了 kafka 生产者的一些用法和相应需要注意的坑,由于作者的本篇文章是从工作中遇到的一些问题出发的,所以相应的如果某些地方用得多有人咨询的多,那么可能会写的稍微详细一些,有的地方咨询的人少,遇到的问题也相应比较少,可能就会简略一些。
希望本文能够让各位读者有所收获,能够对 kafka 生产者这部分有更好的了解。感谢各位的阅读,让我们下一篇文章 kafka 消费者再见。