【Kafka从入门到成神系列 四】Kafka 消息丢失及 TCP 管理

805 阅读8分钟

🍂博主正在努力完成2022计划中:以梦为马,扬帆起航,2022追梦人

一、Kafka 无消息丢失配置

Kafka 在什么情况下才能保证不丢失消息呢?

Kafka 只对 "已提交" 的消息(committed message)做有限度的持久化保证。

  • 已提交的消息:当 Kafka 的 Broker 接收到一条消息并写入到日志文件后,告知生产者这条消息已经成功提交,此时,这条消息被称为:已提交消息
  • 有限度的持久化保证:如果你的 Kafka 集群的 Broker 全部挂掉,消息不保证不会丢失。

一般来说,对于消息丢失,主要存在三方面:生产者端、消费者端、Broker 端

1. 生产者端

Kafka Producer 异步发送消息,如果我们调用 producer.send(msg) 这个 API,会立即返回,但我们不能认为消息发送已成功完成。

这个发送方式有个有趣的名字,叫 “fire and forget",简称:发射后不管。因此如果我们的消息就算发送失败,我们也无法得知。

导致消息没有发送成功的因素:网络抖动,Broker 根本没有接受到该消息、消息本身不合格被 Broker 拒收等

一般解决方法:Producer 永远要使用带有回调通知的 API,不要使用 producer.send(msg),而是要使用 producer.send(msg, callback)。回调函数会告诉我们该消息是否真的发送成功并且会返回给我们失败原因。

2. 消费者端

Comsumer 丢失数据主要体现在 Consumer 端要消费的消息不见了。Consumer 程序中有个 “位移” 的概念,表示当前这个 Consumer 消费分区的位置。

image-20220312144028464

比如我们的 Consumer A 的位移就是 9,而我们的 Consumer B 的位移就是 11。

这里的 "位移" 就像我们看书的书签一样,标记我们当前读了多少页,下次翻书的时候还从这个位置开始读取。

如果我们先更新我们的书签的位置,再去看书,这样万一我们中途有事,就会导致一部分的书籍没有看到。也就相当于丢失了消息。

我们的 Kafka 也是如此,我们要:维持先消费消息(读书),再更新位移(书签)的顺序

当然,这种最大的缺点在于,我们可能重复性的读取某一页,既出现数据重复性消费问题,这个我们后面可以在进行探讨,这里我先提一个方法:保证其幂等性

我们消费消息的时候,可能为了速度,采取下面的消费结构:

image-20220312151908763

我们的 Consumer 拉取消息后,异步开启多个线程执行我们的业务逻辑,然后 Consumer 向我们的 Broker 自动提交位移。但如果我们的线程 1 执行失败,线程 1 这部分数据会丢失。

解决方案:**当我们采用多个线程执行我们的消费消息时,Comsuner 程序不要开启自动提交位移,而是要应用程序手动提交位移。**这里有个需要警惕的点:多线程消费及其容易出现消息被消费多次的情况。

3. Broker 端

Broker 端的缺失之前写过一篇:Kafka 副本同步机制

image-20220312152514552

  • 蓝色:已落磁盘的数据
  • 黄色:无任何数据

当我们的副本进行第二次同步时,我们的副本B重启了机器。

等到 副本B 重启成功后,副本B 会执行日志截断操作(根据高水位的数值进行截断),将 LEO 值调整为之前的高水位值,也就是 1。位移值为 1 的那条消息被副本 B 从磁盘中删除,此时副本 B 的底层磁盘文件中只保存有 1 条消息,即位移值为 0 的那条消息。

当执行完截断日志的操作后,副本B开始从副本A拉取消息,进行正常的消息同步。这时候副本A重启了,我们会让我们的副本B成为 Leader。

当副本A重启成功时,会自动向 Leader 看齐,此时,当 A 回来后,需要执行相同的日志截断操作,即将高水位调整为与 B 相同的值,也就是 1。

这样操作之后,位移值为 1 的那条消息就从这两个副本中被永远地抹掉了,这就是这张图要展示的数据丢失场景。

4. 解决方法

  • 不要使用 producer.send(msg),而要使用 producer.send(mag, callback)。一定要使用带有回调通知的 send 方法。
  • 设置 acks = -1。acks 是 Producer 的一个参数,代表了对 "已提交" 消息的定义。acks 一共有三种表现形式,分别为:1、0、-1,分别代表:需要得到 Leader 成功收到数据并确认、不等待 Broker 的同步、需要得到所有 Follower 的确认。
  • 设置 retries 为一个较大的值。当出现网络抖动时,可以自动重试消息的发送
  • 设置 unclean.leader.election.enable = false,不允许落后 Leader 太多的 Follower 副本参与选举。
  • 设置 replication.factor >= 3。将消息多保存几份。
  • 设置 min.insync.replicas > 1。控制的是消息至少要被写入到多少个副本才算是 “已提交”。
  • 确保 replication.factor = min.insync.replicas + 1
  • 确保消息消费完再提交,Consumer 端有个参数 enable.auto.commit。最好把它设置成 false,并采取手动提交位移的方式。

二、生产者的TCP管理

1. 为何采用TCP

从社区的角度而言,人们能够利用 TCP 本身提供的一些高级功能,比如:多路复用和同时轮询多个连接的能力。

所谓的多路复用,既 multiplexing request将两个或多个数据流合并到底层单一物理连接的过程。

另外,社区发现目前已知的 HTTP 库在很多编程语言都略显简陋。

2. Kafka 生产者程序

Kafka 的 Java 生产者 API 主要的对象是 KafkaProducer。我们创建一个生产者的步骤一般如下:

  • 构建生产者对手所需的参数对象
  • 利用参数对象,创建 KafkaProducer 对象实例
  • 使用 KafkaProducer 的 send 方法发送消息
  • 调用 KafkaProducer 的 close 方法关闭释放资源
Properties props = new Properties ();
props.put(“参数 1”, “参数 1 的值”);
props.put(“参数 2”, “参数 2 的值”);
……
try (Producer<String, String> producer = new KafkaProducer<>(props)) {
            producer.send(new ProducerRecord<String, String>(……), callback);
	……
}

3. 何时创建 TCP 连接

根据上述的代码中,我们可能创建 TCP 连接的地方就两处:Producer producer = new KafkaProducer(props)producer.send(msg, callback)

真实情况是,我们的 Kafka 在创建实例化时,生产者应用会在后台创建并启动一个名为 Sender 的线程,该 Sender 线程开始运行时首先会创建与 Broker 的连接。

但是这里有个小疑问,你想,如果我们在实例化的时候创建了 TCP 链接,那么我们的生产者在发送消息的时候,怎么知道该 TCP 连接对应的哪个 Broker 呢?

我们知道,在我们创建实例的时候,需要指定 bootstrap-servers,也就是目标集群的服务器地址。当我们的 Producer 启动时,会与这些 Broker 发起连接,如果你指定了 1000 个 Broker,那么将会先创建与这 1000 个 Broker 的 TCP 连接。

当然,实际情况中,我们完全没必要把所有的集群都写进去。当我们的 Producer 连接到任意一台 Broker,都能读取其 metadata 获取其全部 Broker 的信息。

那么是否我们创建 KafkaProducer 是一个线程安全的行为?

社区给出的文档中,认为 KafkaProducer 是线程安全的。我们在源码中,KafkaProducer 实例创建的线程和 Sender 创建的线程共享的可变数据只有 RecordAccumulator 类,故维护 RecordAccumulator 的线程安全,也就实现了 KafkaProducer 的线程安全。

RecordAccumulator 是缓存待发送消息的地方,KafkaProducer把消息放进来,当消息满了的时候,通知sender来把消息发出去,释放空间。RecordAccumulator就相当于货运站的仓储,货物不断的往里放,每装满一箱就会通知发货者来取货运走。

image-20220312235308347

RecordAccumulator 类中,主要的数据结构为 private final ConcurrentMap<TopicPartition, Deque<ProducerBatch>> batches。TopicPartition 是 Kafka 用来表示主题分区的 Java 对象,本身是不可变的。而 Deque<ProducerBatch> 这个队列是否是线程安全的也就决定了 KafkaProducer 是不是线程安全的。我们观察 RecordAccumulator 类的源码可以看出来,对于队列的操作,全部使用如下的操作,也就是加 synchronized 锁保证线程安全性。

for (Map.Entry<TopicPartition, Deque<ProducerBatch>> entry : this.batches.entrySet()) {
    TopicPartition part = entry.getKey();
    Deque<ProducerBatch> deque = entry.getValue();
    Node leader = cluster.leaderFor(part);
    synchronized (deque) {
        // 执行
    }
}

当然,对 RecordAccumulator 比较感兴趣的可以看一下这篇文章:RecordAccumulator源码分析

到这一步,我们可以准确的说 KafkaProducer 是线程安全的。但《Java 并发编程实践》的布赖恩·格茨(Brian Goetz)大神,说过:在对象构造器中启动线程会造成 this 指针的逃逸

当然,我们的 TCP 连接也可能在其他两个地方创建:一个是更新元数据后,一个是消息发送时

  • 当 Producer 更新了集群的元数据信息之后,如果发现与某些 Broker 当前没有连接,那么它就会创建一个 TCP 连接
  • 当要发送消息时,Producer 发现尚不存在与目标 Broker 的连接,也会创建一个

4. 何时关闭 TCP 连接

两种关闭方式:一种是用户主动关闭,一种是 Kafka 自动关闭

  • 第一种:广义上的主动关闭。甚至调用 kill -9 主动杀死 Producer 应用。推荐 producer.close()
  • 第二种:Kafka 帮你关闭。Producer 端的参数 connections.max.idle.ms 规定了在这段时间如果没有任何请求流过某个 TCP 连接,那么Kafka 会将该 TCP 连接关闭。

往期推荐:

美团面试官让我聊聊kafka的副本同步机制,我忍不住哭了

随机取样已死,蓄水池抽样称王

以梦为马,扬帆起航,双非人的2021,万字逐梦旅