我们已经学习了如何在保证 Kafka 可靠性的前提下生产数据,现在来看看如何在同样的前 提下读取数据。
我们知道,只有那些被提交到 Kafka 的数据(也就是那些已经被写入所有同步副本的数据)对消费者是可用的,这意味着消费者得到的消息已经具备了一致性

消费者与偏移量

消费者唯一要做的是跟踪哪些消息是已经读取过的,哪些是还没有读取过的这是在读取 消息时不丢失消息的关键

在从分区读取数据时,消费者会获取一批事件,检查这批事件里最大的偏移量,然后从这个偏移量开始读取另外一批事件。这样可以保证消费者总能以正确的顺序获取新数据,不会错过任何事件。

为什么要提交偏移量

如果一个消费者退出,另一个消费者需要知道从什么地方开始继续处理,它需要知道前一个消费者在退出前处理的最后一个偏移量是多少。所谓的“另一个”消费者,也可能就是它自己重启之后重新回来工作。这也就是为什么消费者要“提交”它们的偏移量。
它们把当前读取的偏移量保存起来,在退出之后,同一个群组里的其他消费者就可以接手它们的工作
如 果消费者提交了偏移量却未能处理完消息,那么就有可能造成消息丢失,这也是消费者丢失消息的主要原因。在这种情况下,如果其他消费者接手了工作,那些没有被处理完的消息就 会被忽略,永远得不到处理。这就是为什么我们非常重视偏移量提交的时间点和提交的方式。

已提交消息与已提交偏移量

要注意,此处的已提交消息偏移量与之前讨论过的已提交消息是不一样的,它是指已经被写入所有同步副本并且对消费者可见的消息,而已提交偏移量是指消费者发送给 Kafka 的偏移量,用于确认它已经收到并处理好的消息位置

消费者的可靠性配置

为了保证消费者行为的可靠性,需要注意以下 4 个非常重要的配置参数

  1. group.id。这个参数之前文章已经详细解释过了,如果两个消费者具有相同的 group.id,并且订阅了同一个主题,那么每个消费者会分到主题分区的一个子集也就是说它们读到的消息彼此之间不会重复(整个群组会读取主题所有的消息)。如果你希望单个消费者可以看到主题的所有消息,那么需要为它们设置不同的唯一的 group.id。
  2. auto.offset.reset。这个参数指定了在没有偏移量可提交时(比如消费者第 1 次 启动时)或者请求的偏移量在 broker 上不存在时,消费者会做些什么。这个参数有两种配置。
  • 一种是 earliest,如果选择了这种配置,消费者会从分区的开始位置读取数据,不管偏移量是否有效,这样会导致消费者读取大量的重复数据,但可以保证最少的数据丢失
  • 一种是 latest,如果选择了这种配置,消费者会从分区的末尾开始读取数据,这样可以减少重复处理消息,但很有可能会错过一些消息
  1. enable.auto.commit。这是一个非常重要的配置参数,你可以让消费者基于任务调度自动提交偏移量,也可以在代码里手动提交偏移量。自动提交的一个最大好处是,在实现消费者逻辑时可以少考虑一些问题。如果你在消费者轮询操作里处理所有的数据,那么自动提交可以保证只提交已经处理过的偏移量。自动提交的主要缺点是,无法控制重复处理消息(比如消费者在自动提交偏移量之前停止处理消息),而且如果把消息交给另外一个后台线程去处理,自动提交机制可能会在消息还没有处理完毕就提交偏移量
  2. auto.commit.interval.ms 与第 3 个参数有直接的联系。如果选择了自动提交偏移量,可以通过该参数配置提交的频度,默认值是每 5 秒钟提交一次。一般来说,频繁提交会增加额外的开销,但也会降低重复处理消息的概率

显式提交偏移量

如果选择了自动提交偏移量,就不需要关心显式提交的问题。不过如果希望能够更多地控制偏移量提交的时间点,那么就要仔细想想该如何提交偏移量了——要么是为了减少重复处理消息,要么是因为把消息处理逻辑放在了轮询之外
这里我们不再重复说明这个机制以及如何使用相关的 API,详情看这里Kafka系列——详解为何以及如何提交消费者偏移量。
相反,我们会着重说明几个在开发具有可靠性的消费者应用程序时需要注意的事项。我们先从简单的开始,再逐步深入。

总是在处理完事件后再提交偏移量

如果所有的处理都是在轮询里完成,并且不需要在轮询之间维护状态(比如为了实现聚合操作),那么可以使用自动提交,或者在轮询结束时进行手动提交

提交频度是性能和重复消息数量之间的权衡

即使是在最简单的场景里,比如所有的处理都在轮询里完成,并且不需要在轮询之间维护状态,仍然可以在一个循环里多次提交偏移量(甚至可以在每处理完一个事件之后), 或者多个循环里只提交一次(与生产者的 acks=all 配置有点类似),这完全取决于你在性能和重复处理消息之间作出的权衡。

确保对提交的偏移量心里有数

在轮询过程中提交偏移量有一个不好的地方,就是提交的偏移量有可能是读取到的最新偏移量,而不是处理过的最新偏移量。要记住,在处理完消息后再提交偏移量是非常关键的——否则会导致消费者错过消息

再均衡

在设计应用程序时要注意处理消费者的再均衡问题。在Kafka系列——详解为何以及如何提交消费者偏移量列举了几个例子,一般要在分区被撤销之前提交偏移量,并在分配到新分区时清理之前的状态

消费者可能需要重试

有时候,在进行轮询之后,有些消息不会被完全处理,希望稍后再来处理。
例如,假设要把 Kafka 的数据写到数据库里,不过那个时候数据库不可用,于是你想稍后重试。要注意,你提交的是偏移量,而不是对消息的“确认”,这个与传统的发布和订阅消息系统不 太一样。如果记录 #30 处理失败,但记录 #31 处理成功,那么你不应该提交 #31,否则会 导致 #31 以内的偏移量都被提交,包括 #30 在内,而这可能不是你想看到的结果。不过可以采用以下两种模式来解决这个问题。

第一种模式
在遇到可重试错误时,提交最后一个处理成功的偏移量,然后把还没有处理好的消息保存到缓冲区(这样下一个轮询就不会把它们覆盖掉),调用消费者的 pause() 方法来确保其他的轮询不会返回数据(不需要担心在重试时缓冲区溢出),在保持轮询的同时尝试重新处理(保持轮询很重要:Kafka系列——详解创建Kafka消费者及相关配置
如果重试成功,或者重试次数达到上限并决定放弃,那么把错误记录下来并丢弃消息,然后调用 resume() 方法让消费者继续从轮询里获取新数据

第二种模式:
在遇到可重试错误时,把错误写入一个独立的主题,然后继续
一个独立的消费者群组负责从该主题上读取错误消息并进行重试,或者使用其中的一个消费者同时从该主题上读取错误消息并进行重试,不过在重试时需要暂停该主题。这种模式有点像其他消息系统里的 dead-letter-queue。

消费者可能需要维护状态

有时候你希望在多个轮询之间维护状态
例如,你想计算消息的移动平均数,希望在首次轮询之后计算平均数,然后在后续的轮询中更新这个结果。如果进程重启,你不仅需要从上一个偏移量开始处理数据,还要恢复移动平均数。
有一种办法是在提交偏移量的同时把最近计算的平均数写到一个“结果”主题上。消费者线程在重新启动之后,它就可以拿到最近的平均数并接着计算。不过这并不能完全地解决问题,因为 Kafka 并没有提供事务支持。消费者有可能在写入平均数之后来不及提交偏移量就崩溃了,或者反过来也一样。
这是一个很复杂的问题,你不应该尝试自己去解决这个问题,建议尝试一下 KafkaStreams 这个类库,它为聚合、连接、时间窗和其他复杂的分析提供了高级的 DSL API。

长时间处理

有时候处理数据需要很长时间:你可能会从发生阻塞的外部系统获取信息,或者把数据 写到外部系统,或者进行一个非常复杂的计算。
要记住,暂停轮询的时间不能超过几秒钟即使不想获取更多的数据,也要保持轮询,这样客户端才能往 broker 发送心跳
在这种情况下,一种常见的做法是使用一个线程池来处理数据,因为使用多个线程可以进 行并行处理,从而加快处理速度。在把数据移交给线程池去处理之后,你就可以暂停消费者,然后保持轮询,但不获取新数据,直到工作线程处理完成。
在工作线程处理完成之后,可以让消费者继续获取新数据。因为消费者一直保持轮询,心跳会正常发送,就不会发生再均衡

仅一次传递

有些应用程序不仅仅需要**“至少一次”(at-least-once)语义(意味着没有数据丢失),还需要“仅一次”(exactly-once)语义**。
尽管 Kafka 现在还不能完全支持仅一次语义,消费者还是有一些办法可以保证 Kafka 里的每个消息只被写到外部系统一次(但不会处理向 Kafka 写入数据时可能出现的重复数据)

实现仅一次处理最简单且最常用的办法是把结果写到一个支持唯一键的系统里,比如键值存储引擎、关系型数据库、ElasticSearch 或其他数据存储引擎。
在这种情况下,要么消息本身包含一个唯一键(通常都是这样),要么使用主题、分区和偏移量的组合来创建唯一键——它们的组合可以唯一标识一个 Kafka 记录。如果你把消息和一个唯一键写入系统, 然后碰巧又读到一个相同的消息,只要把原先的键值覆盖掉即可。数据存储引擎会覆盖已 经存在的键值对,就像没有出现过重复数据一样。这个模式被叫作幂等性写入,它是一种 很常见也很有用的模式。

如果写入消息的系统支持事务,那么就可以使用另一种方法。最简单的是使用关系型数据 库,不过 HDFS 里有一些被重新定义过的原子操作也经常用来达到相同的目的。我们把消息和偏移量放在同一个事务里,这样它们就能保持同步。在消费者启动时,它会获取最近 处理过的消息偏移量,然后调用 seek() 方法从该偏移量位置继续读取数据