目录
- Kafka生产者——向Kafka写入数据
- Kafka消费者——从Kafka读取数据
- Kafka消费者相关概念
- 创建Kafka消费者
- 订阅主题
- 轮询
- 配置消费者
- fetch.min.bytes
- fetch.max.wait.ms
- max.poll.records
- max.partition.fetch.bytes
- session.timeout.ms和heartbeat.interval.ms
- max.poll.interval.ms
- default.api.timeout.ms
- request.timeout.ms
- auto.offset.reset
- enable.auto.commit
- partition.assignment.strategy
- client.id
- client.rack
- group.instance.id
- receive.buffer.bytes和send.buffer.bytes
- offsets.retention.minutes
- 提交和偏移量
- 从特定偏移量位置读取记录
- 如何退出
- 深入Kafka
- 可靠的数据传递
- 精确一次性语义
Kafka生产者——向Kafka写入数据
不管是把Kafka作为消息队列、消息总线还是数据存储平台,总是需要一个可以往Kafka写入数据的生产者、一个可以从Kafka读取数据的消费者,或者一个兼具两种角色的应用程序。
生产者概览
一个应用程序会在很多情况下向Kafka写入消息:记录用户的活动(用于审计和分析)、记录指标、记录日志、记录从智能家电收集到的信息、与其他应用程序进行异步通信、缓冲即将写入数据库的数据,等等。不同的应用场景直接影响如何使用和配置生产者API。尽管生产者API使用起来很简单,但消息的发送过程还是有点儿复杂。下图展示了向Kafka发送消息的主要步骤:
先从创建一个ProducerRecord对象开始,其中需要包含目标主题和要发送的内容。另外,还可以指定键、分区、时间戳或标头。在发送ProducerRecord对象时,生产者需要先把键和值对象序列化成字节数组,这样才能在网络上传输。
接下来,如果没有显式地指定分区,那么数据将被传给分区器。分区器通常会基于ProducerRecord对象的键选择一个分区。选好分区以后,生产者就知道该往哪个主题和分区发送这条消息了。紧接着,该消息会被添加到一个消息批次里,这个批次里的所有消息都将被发送给同一个主题和分区。有一个独立的线程负责把这些消息批次发送给目标broker。
broker在收到这些消息时会返回一个响应。如果消息写入成功,就返回一个RecordMetaData对象,其中包含了主题和分区信息,以及消息在分区中的偏移量。如果消息写入失败,则会返回一个错误。生产者在收到错误之后会尝试重新发送消息,重试几次之后如果还是失败,则会放弃重试,并返回错误信息。
创建Kafka生产者
要向Kafka写入消息,首先需要创建一个生产者对象,并设置一些属性。Kafka生产者有3个必须设置的属性。
bootstrap.servers
broker的地址。可以由多个host:port组成,生产者用它们来建立初始的Kafka集群连接。它不需要包含所有的broker地址,因为生产者在建立初始连接之后可以从给定的broker那里找到其他broker的信息。不过还是建议至少提供两个broker地址,因为一旦其中一个停机,则生产者仍然可以连接到集群。
key.serializer
一个类名,用来序列化消息的键。broker希望接收到的消息的键和值都是字节数组。生产者可以把任意Java对象作为键和值发送给broker,但它需要知道如何把这些Java对象转换成字节数组。key.serializer必须被设置为一个实现了org.apache.kafka.common.serialization.Serializer接口的类,生产者会用这个类把键序列化成字节数组。Kafka客户端默认提供了ByteArraySerializer、StringSerializer和IntegerSerializer等,如果你只使用常见的几种Java对象类型,就没有必要实现自己的序列化器。需要注意的是,必须设置key.serializer这个属性,尽管你可能只需要将值发送给Kafka。如果只需要发送值,则可以将Void作为键的类型,然后将这个属性设置为VoidSerializer。
value.serializer
一个类名,用来序列化消息的值。与设置key.serializer属性一样,需要将value.serializer设置成可以序列化消息值对象的类。
发送消息到Kafka
同步发送消息
同步发送消息很简单,当Kafka返回错误或重试次数达到上限时,生产者可以捕获到异常。这里需要考虑性能问题。根据Kafka集群繁忙程度的不同,broker可能需要2毫秒或更长的时间来响应请求。如果采用同步发送方式,那么发送线程在这段时间内就只能等待,什么也不做,甚至都不发送其他消息,这将导致糟糕的性能。因此,同步发送方式通常不会被用在生产环境中。
KafkaProducer一般会出现两种错误。一种是可重试错误,这种错误可以通过重发消息来解决。例如,对于连接错误,只要再次建立连接就可以解决。对于“not leader for partition”(非分区首领)错误,只要重新为分区选举首领就可以解决,此时元数据也会被刷新。可以通过配置启用KafkaProducer的自动重试机制。如果在多次重试后仍无法解决问题,则应用程序会收到重试异常。另一种错误则无法通过重试解决,比如“Message size too large”(消息太大)。对于这种错误,KafkaProducer不会进行任何重试,而会立即抛出异常。
异步发送消息
假设一条消息在应用程序和Kafka集群之间往返需要10毫秒。如果在发送完每条消息后都需要等待响应,那么发送100条消息将需要1秒。如果只发送消息但不需要等待响应,那么发送100条消息所需要的时间就会少很多。大多数时候,并不需要等待响应——尽管Kafka会把消息的目标主题、分区信息和偏移量返回给客户端,但对客户端应用程序来说可能不是必需的。不过,当消息发送失败,需要抛出异常、记录错误日志或者把消息写入“错误消息”文件以便日后分析诊断时,就需要用到这些信息了。为了能够在异步发送消息时处理异常情况,生产者提供了回调机制。
为了使用回调,需要一个实现了org.apache.kafka.clients.producer.Callback接口的类,这个接口只有一个onCompletion方法。
回调的执行将在生产者主线程中进行,如果有两条消息被发送给同一个分区,则这可以保证它们的回调是按照发送的顺序执行的。这就要求回调的执行要快,避免生产者出现延迟或影响其他消息的发送。不建议在回调中执行阻塞操作,阻塞操作应该被放在其他线程中执行。
生产者配置
生产者还有很多其他的可配置的参数,Kafka文档中都有说明。它们大部分有合理的默认值,没有必要进行修改。不过有几个参数在内存使用、性能和可靠性方面对生产者影响比较大,接下来将详细介绍它们。
client.id
client.id是客户端标识符,它的值可以是任意字符串,broker用它来识别从客户端发送过来的消息。client.id可以被用在日志、指标和配额中。选择一个好的客户端标识符可以让故障诊断变得更容易些,这就好比“我们看到很多来自IP地址104.27.155.134的身份验证失败了”要比“好像订单验证服务的身份验证失败了,你能不能让Laura看看”更容易诊断问题。
acks
acks指定了生产者在多少个分区副本收到消息的情况下才会认为消息写入成功。在默认情况下,Kafka会在首领副本收到消息后向客户端回应消息写入成功(Kafka 3.0预计会改变这个默认行为)。这个参数对写入消息的持久性有重大影响,对于不同的场景,使用默认值可能不是最好的选择。
- acks=0:如果acks=0,则生产者不会等待任何来自broker的响应。也就是说,如果broker因为某些问题没有收到消息,那么生产者便无从得知,消息也就丢失了。不过,因为生产者不需要等待broker返回响应,所以它们能够以网络可支持的最大速度发送消息,从而达到很高的吞吐量。
- acks=1:如果acks=1,那么只要集群的首领副本收到消息,生产者就会收到消息成功写入的响应。如果消息无法到达首领副本(比如首领副本发生崩溃,新首领还未选举出来),那么生产者会收到一个错误响应。为了避免数据丢失,生产者会尝试重发消息。不过,在首领副本发生崩溃的情况下,如果消息还没有被复制到新的首领副本,则消息还是有可能丢失。
- acks=all:如果acks=all,那么只有当所有副本全部收到消息时,生产者才会收到消息成功写入的响应。这种模式是最安全的,它可以保证不止一个broker收到消息,就算有个别broker发生崩溃,整个集群仍然可以运行。不过,它的延迟比acks=1高,因为生产者需要等待不止一个broker确认收到消息。
你会发现,为acks设置的值越小,生产者发送消息的速度就越快。也就是说,我们通过牺牲可靠性来换取较低的生产者延迟。不过,端到端延迟是指从消息生成到可供消费者读取的时间,这对3种配置来说都是一样的。这是因为为了保持一致性,在消息被写入所有同步副本之前,Kafka不允许消费者读取它们。因此,如果你关心的是端到端延迟,而不是生产者延迟,那么就不需要在可靠性和低延迟之间做权衡了:你可以选择最可靠的配置,但仍然可以获得相同的端到端延迟。1
消息传递时间
有几个参数可用来控制开发人员最感兴趣的生产者行为:在调用send()方法后多长时间可以知道消息发送成功与否。这也是等待Kafka返回成功响应或放弃重试并承认发送失败的时间。多年来,这些配置参数和相应的行为经历了多次变化。这里将介绍在Kafka 2.1中引入的最新实现。从Kafka 2.1开始,我们将ProduceRecord的发送时间分成如下两个时间间隔,它们是被分开处理的:
- 异步调用send()所花费的时间。在此期间,调用send()的线程将被阻塞。
- 从异步调用send()返回到触发回调(不管是成功还是失败)的时间,也就是从ProduceRecord被放到批次中直到Kafka成功响应、出现不可恢复异常或发送超时的时间。
下图展示了生产者的内部数据流以及不同的配置参数如何相互影响:
max.block.ms
这个参数用于控制在调用send()或通过partitionsFor()显式地请求元数据时生产者可以发生阻塞的时间。当生产者的发送缓冲区被填满或元数据不可用时,这些方法就可能发生阻塞。当达到max.block.ms配置的时间时,就会抛出一个超时异常。
delivery.timeout.ms
这个参数用于控制从消息准备好发送(send()方法成功返回并将消息放入批次中)到broker响应或客户端放弃发送(包括重试)所花费的时间。这个时间应该大于linger.ms和request.timeout.ms。如果配置的时间不满足这一点,则会抛出异常。通常,成功发送消息的速度要比delivery.timeout.ms快得多。
如果生产者在重试时超出了delivery.timeout.ms,那么将执行回调,并会将broker之前返回的错误传给它。如果消息批次还没有发送完毕就超出了delivery.timeout.ms,那么也将执行回调,并会将超时异常传给它。
可以将这个参数配置成你愿意等待的最长时间,通常是几分钟,并使用默认的重试次数(几乎无限制)。基于这样的配置,只要生产者还有时间(或者在发送成功之前),它都会持续重试。这是一种合理的重试方式。我们的重试策略通常是:“在broker发生崩溃的情况下,首领选举通常需要30秒才能完成,因此为了以防万一,我们会持续重试120秒。”为了避免烦琐地配置重试次数和重试时间间隔,只需将delivery.timeout.ms设置为120。
request.timeout.ms
这个参数用于控制生产者在发送消息时等待服务器响应的时间。需要注意的是,这是指生产者在放弃之前等待每个请求的时间,不包括重试、发送之前所花费的时间等。如果设置的值已触及,但服务器没有响应,那么生产者将重试发送,或者执行回调,并传给它一个TimeoutException。
retries 和retry.backoff.ms
当生产者收到来自服务器的错误消息时,这个错误有可能是暂时的(例如,一个分区没有首领)。在这种情况下,retries参数可用于控制生产者在放弃发送并向客户端宣告失败之前可以重试多少次。在默认情况下,重试时间间隔是100毫秒,但可以通过retry.backoff.ms参数来控制重试时间间隔。
并不建议在当前版本的Kafka中使用这些参数。相反,你可以测试一下broker在发生崩溃之后需要多长时间恢复(也就是直到所有分区都有了首领副本),并设置合理的delivery.timeout.ms,让重试时间大于Kafka集群从崩溃中恢复的时间,以免生产者过早放弃重试。
生产者并不会重试所有的错误。有些错误不是暂时的,生产者就不会进行重试(例如,“消息太大”错误)。通常,对于可重试的错误,生产者会自动进行重试,所以不需要在应用程序中处理重试逻辑。你要做的是集中精力处理不可重试的错误或者当重试次数达到上限时的情况。
如果想完全禁用重试,那么唯一可行的方法是将retries设置为0。
linger.ms
这个参数指定了生产者在发送消息批次之前等待更多消息加入批次的时间。生产者会在批次被填满或等待时间达到linger.ms时把消息批次发送出去。在默认情况下,只要有可用的发送者线程,生产者都会直接把批次发送出去,就算批次中只有一条消息。把linger.ms设置成比0大的数,可以让生产者在将批次发送给服务器之前等待一会儿,以使更多的消息加入批次中。虽然这样会增加一点儿延迟,但也极大地提升了吞吐量。这是因为一次性发送的消息越多,每条消息的开销就越小,如果启用了压缩,则计算量也更少了。
buffer.memory
这个参数用来设置生产者要发送给服务器的消息的内存缓冲区大小。如果应用程序调用send()方法的速度超过生产者将消息发送给服务器的速度,那么生产者的缓冲空间可能会被耗尽,后续的send()方法调用会等待内存空间被释放,如果在max.block.ms之后还没有可用空间,就抛出异常。需要注意的是,这个异常与其他异常不一样,它是send()方法而不是Future对象抛出来的。
compression.type
在默认情况下,生产者发送的消息是未经压缩的。这个参数可以被设置为snappy、gzip、lz4或zstd,这指定了消息被发送给broker之前使用哪一种压缩算法。snappy压缩算法由谷歌发明,虽然占用较少的CPU时间,但能提供较好的性能和相当可观的压缩比。如果同时有性能和网络带宽方面的考虑,那么可以使用这种算法。gzip压缩算法通常会占用较多的CPU时间,但提供了更高的压缩比。如果网络带宽比较有限,则可以使用这种算法。使用压缩可以降低网络传输和存储开销,而这些往往是向Kafka发送消息的瓶颈所在。
batch.size
当有多条消息被发送给同一个分区时,生产者会把它们放在同一个批次里。这个参数指定了一个批次可以使用的内存大小。需要注意的是,该参数是按照字节数而不是消息条数来计算的。当批次被填满时,批次里所有的消息都将被发送出去。但是生产者并不一定都会等到批次被填满时才将其发送出去。那些未填满的批次,甚至只包含一条消息的批次也有可能被发送出去。所以,就算把批次大小设置得很大,也不会导致延迟,只是会占用更多的内存而已。但如果把批次大小设置得太小,则会增加一些额外的开销,因为生产者需要更频繁地发送消息。
max.in.flight.requests.per.connection
这个参数指定了生产者在收到服务器响应之前可以发送多少个消息批次。它的值越大,占用的内存就越多,不过吞吐量也会得到提升。Apache wiki页面上的实验数据表明,在单数据中心环境中,该参数被设置为2时可以获得最佳的吞吐量,但使用默认值5也可以获得差不多的性能。
顺序保证
Kafka可以保证同一个分区中的消息是有序的。也就是说,如果生产者按照一定的顺序发送消息,那么broker会按照这个顺序把它们写入分区,消费者也会按照同样的顺序读取它们。在某些情况下,顺序是非常重要的。例如,向一个账户中存入100元再取出来与先从账户中取钱再存回去是截然不同的!不过,有些场景对顺序不是很敏感。
假设我们把retries设置为非零的整数,并把max.in.flight.requests.per.connection设置为比1大的数。如果第一个批次写入失败,第二个批次写入成功,那么broker会重试写入第一个批次,等到第一个批次也写入成功,两个批次的顺序就反过来了。
我们希望至少有2个正在处理中的请求(出于性能方面的考虑),并且可以进行多次重试(出于可靠性方面的考虑),这个时候,最好的解决方案是将enable.idempotence设置为true。这样就可以在最多有5个正在处理中的请求的情况下保证顺序,并且可以保证重试不会引入重复消息。
max.request.size
这个参数用于控制生产者发送的请求的大小。它限制了可发送的单条最大消息的大小和单个请求的消息总量的大小。假设这个参数的值为1 MB,那么可发送的单条最大消息就是1 MB,或者生产者最多可以在单个请求里发送一条包含1024个大小为1 KB的消息。另外,broker对可接收的最大消息也有限制(message.max.bytes),其两边的配置最好是匹配的,以免生产者发送的消息被broker拒绝。
receive.buffer.bytes和send.buffer.bytes
这两个参数分别指定了TCP socket接收和发送数据包的缓冲区大小。如果它们被设为–1,就使用操作系统默认值。如果生产者或消费者与broker位于不同的数据中心,则可以适当加大它们的值,因为跨数据中心网络的延迟一般都比较高,而带宽又比较低
enable.idempotence
从0.11版本开始,Kafka支持精确一次性(exactly once)语义。幂等生产者是它的一个简单且重要的组成部分。假设为了最大限度地提升可靠性,你将生产者的acks设置为all,并将delivery.timeout.ms设置为一个比较大的数,允许进行尽可能多的重试。这些配置可以确保每条消息被写入Kafka至少一次。但在某些情况下,消息有可能被写入Kafka不止一次。假设一个broker收到了生产者发送的消息,然后消息被写入本地磁盘并成功复制给了其他broker。此时,这个broker还没有向生产者发送响应就发生了崩溃。而生产者将一直等待,直到达到request.timeout.ms,然后进行重试。重试发送的消息将被发送给新的首领,而这个首领已经有这条消息的副本,因为之前写入的消息已经被成功复制给它了。现在,你就有了一条重复的消息。
为了避免这种情况,可以将enable.idempotence设置为true。当幂等生产者被启用时,生产者将给发送的每一条消息都加上一个序列号。如果broker收到具有相同序列号的消息,那么它就会拒绝第二个副本,而生产者则会收到DuplicateSequenceException,这个异常对生产者来说是无害的。
如果要启用幂等性,那么max.in.flight.requests.per.connection应小于或等于5、retries应大于0,并且acks被设置为all。如果设置了不恰当的值,则会抛出ConfigException异常。
序列化器
我们已经知道如何使用默认的字符串序列化器,除此之外,Kafka还提供了整型和字节数组等序列化器,但它们并不能覆盖大多数应用场景。毕竟,我们还需要序列化更多通用的记录类型。
如果要发送给Kafka的对象不是简单的字符串或整型,则既可以用通用的序列化框架(比如Avro、Thrift或Protobuf)来创建消息,也可以使用自定义序列化器。建议使用已有的序列化器和反序列化器,比如JSON、Avro、Thrift或Protobuf。
分区
ProducerRecord对象包含了主题名称、记录的键和值。Kafka消息就是一个个的键–值对,ProducerRecord对象可以只包含主题名称和值,键默认情况下是null。不过,大多数应用程序还是会用键来发送消息。键有两种用途:一是作为消息的附加信息与消息保存在一起,二是用来确定消息应该被写入主题的哪个分区(键在压缩主题中也扮演了重要角色)。具有相同键的消息将被写入同一个分区。如果一个进程只从主题的某些分区读取数据,那么具有相同键的所有记录都会被这个进程读取。要创建一个包含键和值的记录,只需像下面这样创建一个ProducerRecord即可:ProducerRecord<String, String> record = new ProducerRecord<>("CustomerCountry", "Laboratory Equipment", "USA");
。
如果键为null,并且使用了默认的分区器,那么记录将被随机发送给主题的分区。分区器使用轮询调度(round-robin)算法将消息均衡地分布到各个分区中。从Kafka 2.4开始,在处理键为null的记录时,默认分区器使用的轮询调度算法具备了黏性。也就是说,在切换到下一个分区之前,它会将同一个批次的消息全部写入当前分区。这样就可以使用更少的请求发送相同数量的消息,既降低了延迟,又减少了broker占用CPU的时间。
如果键不为空且使用了默认的分区器,那么Kafka会对键进行哈希(使用Kafka自己的哈希算法,即使升级Java版本,哈希值也不会发生变化),然后根据哈希值把消息映射到特定的分区。这里的关键在于同一个键总是被映射到同一个分区,所以在进行映射时,会用到主题所有的分区,而不只是可用的分区。这也意味着,如果在写入数据时目标分区不可用,那么就会出错。不过这种情况很少发生。
自定义分区策略
我们已经讨论了默认分区器的特点,它也是最为常用的分区器。除了哈希分区,有时也需要使用不一样的分区策略。假设你是B2B供应商,你有一个大客户,它是手持设备Banana的制造商。你的日常交易中有10%以上的交易与这个客户有关。如果使用默认的哈希分区算法,那么与Banana相关的记录就会和其他客户的记录一起被分配给相同的分区,导致这个分区比其他分区大很多。服务器可能会出现存储空间不足、请求处理缓慢等问题。因此,需要给Banana分配单独的分区,然后使用哈希分区算法将其他记录分配给其他分区。下面是一个自定义分区器的例子:
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.record.InvalidRecordException;
import org.apache.kafka.common.utils.Utils;
public class BananaPartitioner implements Partitioner {
public void configure(Map<String, ?> configs) {} ➊
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes,
Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if ((keyBytes == null) || (!(key instanceOf String))) ➋
throw new InvalidRecordException("We expect all messages "+
"to have customer name as key");
if (((String) key).equals("Banana"))
return numPartitions - 1; // Banana的记录总是被分配到最后一个分区
// 其他记录被哈希到其他分区
return Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1);
}
public void close() {}
}
Partitioner接口包含了configure、partition和close这3个方法。这里只实现partition方法。不能在partition方法里硬编码客户的名字,而应该通过configure方法传进来。只接受字符串作为键,如果不是字符串,就抛出异常。
标头
除了键和值,记录还可以包含标头。可以在不改变记录键–值对的情况下向标头中添加一些有关记录的元数据。标头指明了记录数据的来源,可以在不解析消息体的情况下根据标头信息来路由或跟踪消息(消息有可能被加密,而路由器没有访问加密数据的权限)。标头由一系列有序的键–值对组成。键是字符串,值可以是任意被序列化的对象,就像消息里的值一样。
拦截器
有时候,你希望在不修改代码的情况下改变Kafka客户端的行为。这或许是因为你想给公司所有的应用程序都加上同样的行为,或许是因为无法访问应用程序的原始代码。Kafka的ProducerInterceptor拦截器包含两个关键方法:
- ProducerRecord<K, V> onSend(ProducerRecord<K, V> record):这个方法会在记录被发送给Kafka之前,甚至是在记录被序列化之前调用。如果覆盖了这个方法,那么你就可以捕获到有关记录的信息,甚至可以修改它。只需确保这个方法返回一个有效的ProducerRecord对象。这个方法返回的记录将被序列化并发送给Kafka。
- void onAcknowledgement(RecordMetadata metadata, Exception exception):这个方法会在收到Kafka的确认响应时调用。如果覆盖了这个方法,则不可以修改Kafka返回的响应,但可以捕获到有关响应的信息。
配额和节流
Kafka可以限制生产消息和消费消息的速率,这是通过配额机制来实现的。Kafka提供了3种配额类型:生产、消费和请求。生产配额和消费配额限制了客户端发送和接收数据的速率(以字节 / 秒为单位)。请求配额限制了broker用于处理客户端请求的时间百分比。
可以为所有客户端(使用默认配额)、特定客户端、特定用户,或特定客户端及特定用户设置配额。特定用户的配额只在集群配置了安全特性并对客户端进行了身份验证后才有效。
Kafka消费者——从Kafka读取数据
Kafka消费者相关概念
要想知道如何从Kafka读取消息,需要先了解消费者和消费者群组的概念。接下来将解释这些概念。
消费者和消费者群组
Kafka消费者从属于消费者群组。一个群组里的消费者订阅的是同一个主题,每个消费者负责读取这个主题的部分消息。如图所示:
如果向群组里添加更多的消费者,以致超过了主题的分区数量,那么就会有一部分消费者处于空闲状态,不会接收到任何消息。
向群组里添加消费者是横向扩展数据处理能力的主要方式。Kafka消费者经常需要执行一些高延迟的操作,比如把数据写到数据库或用数据做一些比较耗时的计算。在这些情况下,单个消费者无法跟上数据生成的速度,因此可以增加更多的消费者来分担负载,让每个消费者只处理部分分区的消息,这是横向扩展消费者的主要方式。于是,我们可以为主题创建大量的分区,当负载急剧增长时,可以加入更多的消费者。不过需要注意的是,不要让消费者的数量超过主题分区的数量,因为多余的消费者只会被闲置。
除了通过增加消费者数量来横向伸缩单个应用程序,我们还经常遇到多个应用程序从同一个主题读取数据的情况。实际上,Kafka的一个主要设计目标是让Kafka主题里的数据能够满足企业各种应用场景的需求。在这些应用场景中,我们希望每一个应用程序都能获取到所有的消息,而不只是其中的一部分。只要保证每个应用程序都有自己的消费者群组就可以让它们获取到所有的消息。不同于传统的消息系统,横向伸缩消费者和消费者群组并不会导致Kafka性能下降。
在之前的例子中,如果新增一个只包含一个消费者的群组G2,那么这个消费者将接收到主题T1的所有消息,与群组G1之间互不影响。群组G2可以增加更多的消费者,每个消费者会读取若干个分区,就像群组G1里的消费者那样。作为整体来说,群组G2还是会收到所有消息,不管有没有其他群组存在,如图所示:
总的来说,就是为每一个需要获取主题全部消息的应用程序创建一个消费者群组,然后向群组里添加更多的消费者来扩展读取能力和处理能力,让群组里的每个消费者只处理一部分消息。一个消费者最多只能消费一个分区,也是靠这个限制保证了单个分区消费的有序性。
消费者群组和分区再均衡
消费者群组里的消费者共享主题分区的所有权。当一个新消费者加入群组时,它将开始读取一部分原本由其他消费者读取的消息。当一个消费者被关闭或发生崩溃时,它将离开群组,原本由它读取的分区将由群组里的其他消费者读取。主题发生变化(比如管理员添加了新分区)会导致分区重分配。
分区的所有权从一个消费者转移到另一个消费者的行为称为再均衡。再均衡非常重要,它为消费者群组带来了高可用性和伸缩性(你可以放心地添加或移除消费者)。不过,在正常情况下,我们并不希望发生再均衡。
主动再均衡
在进行主动再均衡期间,所有消费者都会停止读取消息,放弃分区所有权,重新加入消费者群组,并获得重新分配到的分区。这样会导致整个消费者群组在一个很短的时间窗口内不可用。这个时间窗口的长短取决于消费者群组的大小和几个配置参数。
协作再均衡
协作再均衡(也称为增量再均衡)通常是指将一个消费者的部分分区重新分配给另一个消费者,其他消费者则继续读取没有被重新分配的分区。这种再均衡包含两个或多个阶段。
在第一个阶段,消费者群组首领会通知所有消费者,它们将失去部分分区的所有权,然后消费者会停止读取这些分区,并放弃对它们的所有权。在第二个阶段,消费者群组首领会将这些没有所有权的分区分配给其他消费者。虽然这种增量再均衡可能需要进行几次迭代,直到达到稳定状态,但它避免了主动再均衡中出现的“停止世界”停顿。这对大型消费者群组来说尤为重要,因为它们的再均衡可能需要很长时间。
消费者会向被指定为群组协调器的broker(不同消费者群组的协调器可能不同)发送心跳,以此来保持群组成员关系和对分区的所有权关系。心跳是由消费者的一个后台线程发送的,只要消费者能够以正常的时间间隔发送心跳,它就会被认为还“活着”。
如果消费者在足够长的一段时间内没有发送心跳,那么它的会话就将超时,群组协调器会认为它已经“死亡”,进而触发再均衡。如果一个消费者发生崩溃并停止读取消息,那么群组协调器就会在几秒内收不到心跳,它会认为消费者已经“死亡”,进而触发再均衡。在这几秒时间里,“死掉”的消费者不会读取分区里的消息。在关闭消费者后,协调器会立即触发一次再均衡,尽量降低处理延迟。
分配分区过程
当一个消费者想要加入消费者群组时,它会向群组协调器发送JoinGroup请求。第一个加入群组的消费者将成为群组首领。首领从群组协调器那里获取群组的成员列表(列表中包含了所有最近发送过心跳的消费者,它们被认为还“活着”),并负责为每一个消费者分配分区。它使用实现了PartitionAssignor接口的类来决定哪些分区应该被分配给哪个消费者。
Kafka内置了一些分区分配策略,后文将深入介绍它们。分区分配完毕之后,首领会把分区分配信息发送给群组协调器,群组协调器再把这些信息发送给所有的消费者。每个消费者只能看到自己的分配信息,只有首领会持有所有消费者及其分区所有权的信息。每次再均衡都会经历这个过程。
群组固定成员
在默认情况下,消费者的群组成员身份标识是临时的。当一个消费者离开群组时,分配给它的分区所有权将被撤销;当该消费者重新加入时,将通过再均衡协议为其分配一个新的成员ID和新分区。
可以给消费者分配一个唯一的group.instance.id,让它成为群组的固定成员。通常,当消费者第一次以固定成员身份加入群组时,群组协调器会按照分区分配策略给它分配一部分分区。当这个消费者被关闭时,它不会自动离开群组——它仍然是群组的成员,直到会话超时。当这个消费者重新加入群组时,它会继续持有之前的身份,并分配到之前所持有的分区。群组协调器缓存了每个成员的分区分配信息,只需要将缓存中的信息发送给重新加入的固定成员,不需要进行再均衡。如果两个消费者使用相同的group.instance.id加入同一个群组,则第二个消费者会收到错误,告诉它具有相同ID的消费者已存在。
创建Kafka消费者
在读取消息之前,需要先创建一个KafkaConsumer对象。创建KafkaConsumer对象与创建KafkaProducer对象非常相似——把想要传给消费者的属性放在Properties对象里。本章后续部分将深入介绍所有的配置属性。为简单起见,这里只提供3个必要的属性:bootstrap.servers、key.deserializer和value.deserializer。
第一个属性bootstrap.servers指定了连接Kafka集群的字符串。它的作用与KafkaProducer中的bootstrap.servers一样。另外两个属性key.deserializer和value.deserializer与生产者的key.serializer和value.serializer类似,只不过它们不是使用指定类把Java对象转成字节数组,而是把字节数组转成Java对象。
group.id指定了一个消费者属于哪一个消费者群组。也可以创建不属于任何一个群组的消费者,只是这种做法不太常见。要求必须指定group.id进行消费!
订阅主题
如果你的Kafka集群包含了大量分区(比如30 000个或更多),则需注意,主题的过滤是在客户端完成的。当你使用正则表达式而不是指定列表订阅主题时,消费者将定期向broker请求所有已订阅的主题及分区。然后,客户端会用这个列表来检查是否有新增的主题,如果有,就订阅它们。如果主题很多,消费者也很多,那么通过正则表达式订阅主题就会给broker、客户端和网络带来很大的开销。在某些情况下,主题元数据使用的带宽会超过用于发送数据的带宽。另外,为了能够使用正则表达式订阅主题,需要授予客户端获取集群全部主题元数据的权限,即全面描述整个集群的权限。
轮询
消费者API最核心的东西是通过一个简单的轮询向服务器请求数据。消费者实际上是一个长时间运行的应用程序,它通过持续轮询来向Kafka请求数据。稍后我们将介绍如何退出循环,并关闭消费者。
消费者必须持续对Kafka进行轮询,否则会被认为已经“死亡”,它所消费的分区将被移交给群组里其他的消费者。传给poll()的参数是一个超时时间间隔,用于控制poll()的阻塞时间(当消费者缓冲区里没有可用数据时会发生阻塞)。如果这个参数被设置为0或者有可用的数据,那么poll()就会立即返回,否则它会等待指定的毫秒数。poll()方法会返回一个记录列表。列表中的每一条记录都包含了主题和分区的信息、记录在分区里的偏移量,以及记录的键–值对。我们一般会遍历这个列表,逐条处理记录。
轮询不只是获取数据那么简单。在第一次调用消费者的poll()方法时,它需要找到GroupCoordinator,加入群组,并接收分配给它的分区。如果触发了再均衡,则整个再均衡过程也会在轮询里进行,包括执行相关的回调。所以,消费者或回调里可能出现的错误最后都会转化成poll()方法抛出的异常。需要注意的是,如果超过max.poll.interval.ms没有调用poll(),则消费者将被认为已经“死亡”,并被逐出消费者群组。因此,要避免在轮询循环中做任何可能导致不可预知的阻塞的操作。
线程安全
我们既不能在同一个线程中运行多个同属一个群组的消费者,也不能保证多个线程能够安全地共享一个消费者。按照规则,一个消费者使用一个线程。如果要在应用程序的同一个消费者群组里运行多个消费者,则需要让每个消费者运行在自己的线程中。最好是把消费者的逻辑封装在自己的对象里,然后用Java的ExecutorService启动多个线程,让每个消费者运行在自己的线程中。Confluent博客上的教程展示了具体该怎么做。
配置消费者
Kafka的文档中列出了所有与消费者相关的配置属性。大部分属性有合理的默认值,一般不需要修改它们。不过,有一些属性与消费者的性能和可用性有很大关系,接下来将介绍这些比较重要的属性。
fetch.min.bytes
这个属性指定了消费者从服务器获取记录的最小字节数,默认是1字节。broker在收到消费者的获取数据请求时,如果可用数据量小于fetch.min.bytes指定的大小,那么它就会等到有足够可用数据时才将数据返回。这样可以降低消费者和broker的负载,因为它们在主题流量不是很大的时候(或者一天里的低流量时段)不需要来来回回地传输消息。如果消费者在没有太多可用数据时CPU使用率很高,或者在有很多消费者时为了降低broker的负载,那么可以把这个属性的值设置得比默认值大。但需要注意的是,在低吞吐量的情况下,加大这个值会增加延迟。
fetch.max.wait.ms
这个属性指定了Kafka返回的数据的最大字节数(默认为50 MB)。消费者会将服务器返回的数据放在内存中,所以这个属性被用于限制消费者用来存放数据的内存大小。需要注意的是,记录是分批发送给客户端的,如果broker要发送的批次超过了这个属性指定的大小,那么这个限制将被忽略。这样可以保证消费者能够继续处理消息。值得注意的是,broker端也有一个与之对应的配置属性,Kafka管理员可以用它来限制最大获取数量。broker端的这个配置属性可能很有用,因为请求的数据量越大,需要从磁盘读取的数据量就越大,通过网络发送数据的时间就越长,这可能会导致资源争用并增加broker的负载。
max.poll.records
这个属性用于控制单次调用poll()方法返回的记录条数。可以用它来控制应用程序在进行每一次轮询循环时需要处理的记录条数(不是记录的大小)。
max.partition.fetch.bytes
这个属性指定了服务器从每个分区里返回给消费者的最大字节数(默认值是1 MB)。当KafkaConsumer.poll()方法返回ConsumerRecords时,从每个分区里返回的记录最多不超过max.partition.fetch.bytes指定的字节。需要注意的是,使用这个属性来控制消费者的内存使用量会让事情变得复杂,因为你无法控制broker返回的响应里包含多少个分区的数据。因此,对于这种情况,建议用fetch.max.bytes替代,除非有特殊的需求,比如要求从每个分区读取差不多的数据量。
session.timeout.ms和heartbeat.interval.ms
session.timeout.ms指定了消费者可以在多长时间内不与服务器发生交互而仍然被认为还“活着”,默认是10秒。如果消费者没有在session.timeout.ms指定的时间内发送心跳给群组协调器,则会被认为已“死亡”,协调器就会触发再均衡,把分区分配给群组里的其他消费者。session.timeout.ms与heartbeat.interval.ms紧密相关。heartbeat.interval.ms指定了消费者向协调器发送心跳的频率,session.timeout.ms指定了消费者可以多久不发送心跳。因此,我们一般会同时设置这两个属性,heartbeat.interval.ms必须比session.timeout.ms小,通常前者是后者的1/3。如果session.timeout.ms是3秒,那么heartbeat.interval.ms就应该是1秒。把session.timeout.ms设置得比默认值小,可以更快地检测到崩溃,并从崩溃中恢复,但也会导致不必要的再均衡。把session.timeout.ms设置得比默认值大,可以减少意外的再均衡,但需要更长的时间才能检测到崩溃。
max.poll.interval.ms
这个属性指定了消费者在被认为已经“死亡”之前可以在多长时间内不发起轮询。前面提到过,心跳和会话超时是Kafka检测已“死亡”的消费者并撤销其分区的主要机制。我们也提到了心跳是通过后台线程发送的,而后台线程有可能在消费者主线程发生死锁的情况下继续发送心跳,但这个消费者并没有在读取分区里的数据。要想知道消费者是否还在处理消息,最简单的方法是检查它是否还在请求数据。但是,请求之间的时间间隔是很难预测的,它不仅取决于可用的数据量、消费者处理数据的方式,有时还取决于其他服务的延迟。在需要耗费时间来处理每个记录的应用程序中,可以通过max.poll.records来限制返回的数据量,从而限制应用程序在再次调用poll()之前的等待时长。但是,即使设置了max.poll.records,调用poll()的时间间隔仍然很难预测。于是,设置max.poll.interval.ms就成了一种保险措施。它必须被设置得足够大,让正常的消费者尽量不触及这个阈值,但又要足够小,避免有问题的消费者给应用程序造成严重影响。这个属性的默认值为5分钟。当这个阈值被触及时,后台线程将向broker发送一个“离开群组”的请求,让broker知道这个消费者已经“死亡”,必须进行群组再均衡,然后停止发送心跳。
default.api.timeout.ms
如果在调用消费者API时没有显式地指定超时时间,那么消费者就会在调用其他API时使用这个属性指定的值。默认值是1分钟,因为它比请求超时时间的默认值大,所以可以将重试时间包含在内。poll()方法是一个例外,因为它需要显式地指定超时时间。
request.timeout.ms
这个属性指定了消费者在收到broker响应之前可以等待的最长时间。如果broker在指定时间内没有做出响应,那么客户端就会关闭连接并尝试重连。它的默认值是30秒。不建议把它设置得比默认值小。在放弃请求之前要给broker留有足够长的时间来处理其他请求,因为向已经过载的broker发送请求几乎没有什么好处,况且断开并重连只会造成更大的开销。
auto.offset.reset
这个属性指定了消费者在读取一个没有偏移量或偏移量无效(因消费者长时间不在线,偏移量对应的记录已经过期并被删除)的分区时该做何处理。它的默认值是latest,意思是说,如果没有有效的偏移量,那么消费者将从最新的记录(在消费者启动之后写入Kafka的记录)开始读取。另一个值是earliest,意思是说,如果没有有效的偏移量,那么消费者将从起始位置开始读取记录。如果将auto.offset.reset设置为none,并试图用一个无效的偏移量来读取记录,则消费者将抛出异常。
enable.auto.commit
这个属性指定了消费者是否自动提交偏移量,默认值是true。你可以把它设置为false,选择自己控制何时提交偏移量,以尽量避免出现数据重复和丢失。如果它被设置为true,那么还有另外一个属性auto.commit.interval.ms可以用来控制偏移量的提交频率。
partition.assignment.strategy
我们知道,分区会被分配给群组里的消费者。PartitionAssignor根据给定的消费者和它们订阅的主题来决定哪些分区应该被分配给哪个消费者。Kafka提供了几种默认的分配策略:
区间(range)
这个策略会把每一个主题的若干个连续分区分配给消费者。假设消费者C1和消费者C2同时订阅了主题T1和主题T2,并且每个主题有3个分区。那么消费者C1有可能会被分配到这两个主题的分区0和分区1,消费者C2则会被分配到这两个主题的分区2。因为每个主题拥有奇数个分区,并且都遵循一样的分配策略,所以第一个消费者会分配到比第二个消费者更多的分区。只要使用了这个策略,并且分区数量无法被消费者数量整除,就会出现这种情况。
轮询(roundRobin)
这个策略会把所有被订阅的主题的所有分区按顺序逐个分配给消费者。如果使用轮询策略为消费者C1和消费者C2分配分区,那么消费者C1将分配到主题T1的分区0和分区2以及主题T2的分区1,消费者C2将分配到主题T1的分区1以及主题T2的分区0和分区2。一般来说,如果所有消费者都订阅了相同的主题(这种情况很常见),那么轮询策略会给所有消费者都分配相同数量(或最多就差一个)的分区。
黏性(sticky)
设计黏性分区分配器的目的有两个:一是尽可能均衡地分配分区,二是在进行再均衡时尽可能多地保留原先的分区所有权关系,减少将分区从一个消费者转移给另一个消费者所带来的开销。如果所有消费者都订阅了相同的主题,那么黏性分配器初始的分配比例将与轮询分配器一样均衡。后续的重新分配将同样保持均衡,但减少了需要移动的分区的数量。如果同一个群组里的消费者订阅了不同的主题,那么黏性分配器的分配比例将比轮询分配器更加均衡。
协作黏性(cooperative sticky)
这个分配策略与黏性分配器一样,只是它支持协作(增量式)再均衡,在进行再均衡时消费者可以继续从没有被重新分配的分区读取消息。
client.id
这个属性可以是任意字符串,broker用它来标识从客户端发送过来的请求,比如获取请求。它通常被用在日志、指标和配额中。
client.rack
在默认情况下,消费者会从每个分区的首领副本那里获取消息。但是,如果集群跨越了多个数据中心或多个云区域,那么让消费者从位于同一区域的副本那里获取消息就会具有性能和成本方面的优势。要从最近的副本获取消息,需要设置client.rack这个参数,用于标识客户端所在的区域。然后,可以将broker的replica.selector.class参数值改为org.apache.kafka.common.replica.RackAwareReplicaSelector。
group.instance.id
这个属性可以是任意具有唯一性的字符串,被用于消费者群组的固定名称。
receive.buffer.bytes和send.buffer.bytes
这两个属性分别指定了socket在读写数据时用到的TCP缓冲区大小。如果它们被设置为–1,就使用操作系统的默认值。如果生产者或消费者与broker位于不同的数据中心,则可以适当加大它们的值,因为跨数据中心网络的延迟一般都比较高,而带宽又比较低。
offsets.retention.minutes
这是broker端的一个配置属性,需要注意的是,它也会影响消费者的行为。只要消费者群组里有活跃的成员(也就是说,有成员通过发送心跳来保持其身份),群组提交的每一个分区的最后一个偏移量就会被Kafka保留下来,在进行重分配或重启之后就可以获取到这些偏移量。但是,如果一个消费者群组失去了所有成员,则Kafka只会按照这个属性指定的时间(默认为7天)保留偏移量。一旦偏移量被删除,即使消费者群组又“活”了过来,它也会像一个全新的群组一样,没有了过去的消费记忆。需要注意的是,这个行为在不同的版本中经历了几次变化,如果你使用的Kafka版本小于2.1.0,那么请仔细查阅相关文档以了解其对应的行为。
提交和偏移量
每次调用poll()方法,它总是会返回还没有被消费者读取过的记录,这意味着我们有办法来追踪哪些记录是被群组里的哪个消费者读取过的。之前提到过,Kafka不像其他JMS队列系统那样需要收到来自消费者的确认,这是Kafka的一个独特之处。相反,消费者可以用Kafka来追踪已读取的消息在分区中的位置(偏移量)。
我们把更新分区当前读取位置的操作叫作偏移量提交。与传统的消息队列不同,Kafka不会提交每一条记录。相反,消费者会将已成功处理的最后一条消息提交给Kafka,并假定该消息之前的每一条消息都已成功处理。
那么消费者是如何提交偏移量的呢?消费者会向一个叫作 __consumer_offset的主题发送消息,消息里包含每个分区的偏移量。如果消费者一直处于运行状态,那么偏移量就没有什么实际作用。但是,如果消费者发生崩溃或有新的消费者加入群组,则会触发再均衡。再均衡完成之后,每个消费者可能会被分配新的分区,而不是之前读取的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的位置继续读取消息。
如果最后一次提交的偏移量小于客户端处理的最后一条消息的偏移量,那么处于两个偏移量之间的消息就会被重复处理,如图所示:
如果最后一次提交的偏移量大于客户端处理的最后一条消息的偏移量,那么处于两个偏移量之间的消息就会丢失,如图所示:
所以,如何管理偏移量对客户端应用程序有很大的影响。KafkaConsumerAPI提供了多种提交偏移量的方式。
自动提交
最简单的提交方式是让消费者自动提交偏移量。如果enable.auto.commit被设置为true,那么每过5秒,消费者就会自动提交poll()返回的最大偏移量。提交时间间隔通过auto.commit.interval.ms来设定,默认是5秒。与消费者中的其他处理过程一样,自动提交也是在轮询循环中进行的。消费者会在每次轮询时检查是否该提交偏移量了,如果是,就会提交最后一次轮询返回的偏移量。
假设我们使用默认的5秒提交时间间隔,并且消费者在最后一次提交偏移量之后3秒会发生崩溃。再均衡完成之后,接管分区的消费者将从最后一次提交的偏移量的位置开始读取消息。这个偏移量实际上落后了3秒,所以在这3秒内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量,缩小可能导致重复消息的时间窗口,但无法完全避免。虽然自动提交很方便,但是没有为避免开发者重复处理消息留有余地。
提交当前偏移量
把enable.auto.commit设置为false,让应用程序自己决定何时提交偏移量。使用commitSync()提交偏移量是最简单可靠的方式。这个API会提交poll()返回的最新偏移量,提交成功后马上返回,如果由于某些原因提交失败就抛出异常。
需要注意的是,commitSync()将会提交poll()返回的最新偏移量,所以,如果你在处理完所有记录之前就调用了commitSync(),那么一旦应用程序发生崩溃,就会有丢失消息的风险(消息已被提交但未被处理)。如果应用程序在处理记录时发生崩溃,但commitSync()还没有被调用,那么从最近批次的开始位置到发生再均衡时的所有消息都将被再次处理——这或许比丢失消息更好,或许更坏。
交失败,就把异常记录到错误日志里。
手动提交有一个缺点,在broker对请求做出回应之前,应用程序会一直阻塞,这样会限制应用程序的吞吐量。可以通过降低提交频率来提升吞吐量,但如果发生了再均衡,则会增加潜在的消息重复。这个时候可以使用异步提交API。只管发送请求,无须等待broker做出响应。
在提交成功或碰到无法恢复的错误之前,commitSync()会一直重试,但commitAsync()不会,这是commitAsync()的一个缺点。之所以不进行重试,是因为commitAsync()在收到服务器端的响应时,可能已经有一个更大的偏移量提交成功。假设我们发出一个提交偏移量2000的请求,这个时候出现了短暂的通信问题,服务器收不到请求,自然也不会做出响应。与此同时,我们处理了另外一批消息,并成功提交了偏移量3000。如果此时commitAsync()重新尝试提交偏移量2000,则有可能在偏移量3000之后提交成功。这个时候如果发生再均衡,就会导致消息重复。
可以用一个单调递增的消费者序列号变量来维护异步提交的顺序。每次调用commitAsync()后增加序列号,并在回调中更新序列号变量。在准备好进行重试时,先检查回调的序列号与序列号变量是否相等。如果相等,就说明没有新的提交,可以安全地进行重试。如果序列号变量比较大,则说明已经有新的提交了,此时应该停止重试。
提交特定的偏移量
消费者API允许在调用commitSync()和commitAsync()时传给它们想要提交的分区和偏移量。假设你正在处理一个消息批次,刚处理好来自主题“customers”的分区3的消息,它的偏移量是5000,那么就可以调用commitSync()来提交这个分区的偏移量5001。需要注意的是,因为一个消费者可能不止读取一个分区,你需要跟踪所有分区的偏移量,所以通过这种方式提交偏移量会让代码变得复杂。
从特定偏移量位置读取记录
如果你想从分区的起始位置读取所有的消息,或者直接跳到分区的末尾读取新消息,那么Kafka API分别提供了两个方法:seekToBeginning(Collection tp) 和seekToEnd(Collection tp)。Kafka还提供了用于查找特定偏移量的API。这个API有很多用途,比如,对时间敏感的应用程序在处理速度滞后的情况下可以向前跳过几条消息,或者如果消费者写入的文件丢失了,则它可以重置偏移量,回到某个位置进行数据恢复。
如何退出
如果你确定马上要关闭消费者(即使消费者还在等待一个poll()返回),那么可以在另一个线程中调用consumer.wakeup()。如果轮询循环运行在主线程中,那么可以在ShutdownHook里调用这个方法。需要注意的是,consumer.wakeup()是消费者唯一一个可以在其他线程中安全调用的方法。调用consumer.wakeup()会导致poll()抛出WakeupException,如果调用consumer.wakeup()时线程没有在轮询,那么异常将在下一次调用poll()时抛出。不一定要处理WakeupException,但在退出线程之前必须调用consumer.close()。消费者在被关闭时会提交还没有提交的偏移量,并向消费者协调器发送消息,告知自己正在离开群组。协调器会立即触发再均衡,被关闭的消费者所拥有的分区将被重新分配给群组里其他的消费者,不需要等待会话超时。
深入Kafka
集群的成员关系
Kafka使用ZooKeeper维护集群的成员信息。每个broker都有一个唯一的标识符,这个标识符既可以在配置文件中指定,也可以自动生成。broker在启动时通过创建ZooKeeper 临时节点把自己的ID注册到ZooKeeper中。broker、控制器和其他的一些生态系统工具会订阅ZooKeeper的 /brokers/ids路径(也就是broker在ZooKeeper上的注册路径),当有broker加入或退出集群时,它们可以收到通知。
如果你试图启动另一个具有相同ID的broker,则会收到一个错误——新broker会尝试进行注册,但不会成功,因为ZooKeeper中已经有一个相同的节点。
当broker与ZooKeeper断开连接(通常会在关闭broker时发生,但在发生网络分区或长时间垃圾回收停顿时也会发生)时,它在启动时创建的临时节点会自动从ZooKeeper上移除。监听broker节点路径的Kafka组件会被告知这个broker已被移除。
broker对应的ZooKeeper节点会在broker被关闭之后消失,但它的ID会继续存在于其他数据结构中。例如,每个主题的副本集(参见6.3节)中就可能包含这个ID。在完全关闭一个broker后,如果使用相同的ID启动另一个全新的broker,则它会立即加入集群,并获得与之前相同的分区和主题。
控制器
控制器其实也是一个broker,只不过除了提供一般的broker功能之外,它还负责选举分区首领。集群中第一个启动的broker会通过在ZooKeeper中创建一个名为 /controller的临时节点让自己成为控制器。其他broker在启动时也会尝试创建这个节点,但它们会收到“节点已存在”异常,并“意识”到控制器节点已存在,也就是说集群中已经有一个控制器了。其他broker会在控制器节点上创建ZooKeeper watch,这样就可以收到这个节点的变更通知了。我们通过这种方式来确保集群中只有一个控制器。
如果控制器被关闭或者与ZooKeeper断开连接,那么这个临时节点就会消失。控制器使用的ZooKeeper客户端没有在zookeeper.session.timeout.ms指定的时间内向ZooKeeper发送心跳是导致连接断开的原因之一。当临时节点消失时,集群中的其他broker将收到控制器节点已消失的通知,并尝试让自己成为新的控制器。第一个在ZooKeeper中成功创建控制器节点的broker会成为新的控制器,其他节点则会收到“节点已存在”异常,并会在新的控制器节点上再次创建ZooKeeper watch。每个新选出的控制器都会通过ZooKeeper条件递增操作获得一个数值更大的epoch。其他broker也会知道当前控制器的epoch,如果收到由控制器发出的包含较小epoch的消息,就会忽略它们。
这一点很重要,因为控制器会因长时间垃圾回收停顿与ZooKeeper断开连接——在停顿期间,新控制器将被选举出来。当旧控制器在停顿之后恢复时,它并不知道已经选出了新的控制器,并会继续发送消息——在这种情况下,旧控制器会被认为是一个“僵尸控制器”。消息里的epoch可以用来忽略来自旧控制器的消息,这是防御“僵尸”的一种方式。
总的来说,Kafka会使用ZooKeeper的临时节点来选举控制器,并会在broker加入或退出集群时通知控制器。控制器负责在broker加入或退出集群时进行首领选举。控制器会使用epoch来避免“脑裂”。所谓的“脑裂”,就是指两个broker同时认为自己是集群当前的控制器。
新控制器KRaft
2019年,Kafka社区启动了一个雄心勃勃的项目:使用基于Raft的控制器替换基于ZooKeeper的控制器。新控制器叫作KRaft,其预览版包含在Kafka 2.8中。于2021年9月发布的Kafka 3.0包含了它的第一个生产版本,Kafka集群既可以使用基于ZooKeeper的传统控制器,也可以使用KRaft。
新控制器背后的核心设计思想是:Kafka本身有一个基于日志的架构,其中用户会将状态的变化表示成一个事件流。开发社区对这种表示非常熟悉——多个消费者可以通过重放事件快速赶上最新的状态。日志保留了事件之间的顺序,并能确保消费者始终沿着单个时间轴移动。新控制器架构为Kafka的元数据管理带来了同样的好处。
在新架构中,控制器节点形成了一个Raft仲裁,管理着元数据事件日志。这个日志中包含了集群元数据的每一个变更。原先保存在ZooKeeper中的所有东西(比如主题、分区、ISR、配置等)都将被保存在这个日志中。
因为使用了Raft算法,所以控制器节点可以在不依赖外部系统的情况下选举首领。首领节点被称为主控制器,负责处理所有来自broker的RPC调用。跟随者控制器会从主控制器那里复制数据,并会作为主控制器的热备。因为控制器会跟踪最新的状态,所以当发生控制器故障转移时(在此期间,所有的状态都将被转移给新控制器),很快就可以完成状态的重新加载。
其他broker将通过新的MetadataFetchAPI从主控制器获取更新,而不是让主控制器将更新发送给它们。与获取请求类似,broker会跟踪它们已经获取到的最新的元数据偏移量,并只向主控制器请求较新的元数据。broker会将元数据持久化到磁盘上,这可以实现快速启动,即使有数百万个分区。broker会将自己注册到控制器仲裁上,在管理员将其注销之前一直保持注册状态,即使被关闭并离线,仍然是注册状态。那些在线但没有保持最新元数据的broker将被隔离,不能处理来自客户端的请求。这样可以防止客户端向已经过时很久但还不知道自己已经不是首领的非首领节点发送消息。
复制
复制是Kafka架构核心的一部分。Kafka经常被描述成“一个分布式、分区、可复制的提交日志服务”。复制之所以这么重要,是因为它可以在个别节点失效时仍能保证Kafka的可用性和持久性。前面介绍过,Kafka的数据保存在主题中,每个主题被分成若干个分区,每个分区可以有多个副本。副本保存在broker上,每个broker可以保存成百上千个主题和分区的副本。副本有以下两种类型:
- 首领副本:每个分区都有一个首领副本。为了保证一致性,所有生产者请求和消费者请求都会经过这个副本。客户端可以从首领副本或跟随者副本读取数据。
- 跟随者副本:首领以外的副本都是跟随者副本。如果没有特别指定,则跟随者副本将不处理来自客户端的请求,它们的主要任务是从首领那里复制消息,保持与首领一致的状态。如果首领发生崩溃,那么其中的一个跟随者就会被提拔为新首领。
为了与首领保持同步,跟随者需要向首领发送Fetch请求,这与消费者为了读取消息而发送的请求是一样的。作为响应,首领会将消息返回给跟随者。Fetch请求消息里包含了跟随者想要获取的消息的偏移量,这些偏移量总是有序的。这样,首领就可以知道一个副本是否已经获取了最近一条消息之前的所有消息。通过检查每个副本请求的最后一个偏移量,首领就可以知道每个副本的滞后程度。如果副本没有在30秒内发送请求,或者即使发送了请求但与最新消息的间隔超过了30秒,那么它将被认为是不同步的。如果一个副本未能跟上首领,那么一旦首领发生故障,它将不能再成为新首领——毕竟,它并未拥有所有的消息。
允许跟随者可以多久不活跃或允许跟随者在多久之后成为不同步副本是通过replica.lag.time.max.ms参数来配置的。这个时间直接影响首领选举期间的客户端行为和数据保留机制。
处理请求
broker的大部分工作是处理客户端、分区副本和控制器发送给分区首领的请求。Kafka提供了一种二进制协议(基于TCP),指定了请求消息的格式以及broker如何对请求做出响应——既包括成功处理请求,也包括在处理请求过程中出现错误。
客户端总是发起连接并发送请求,而broker负责处理这些请求并做出响应。broker会按照请求到达的顺序来处理它们,这种顺序既能保证让Kafka具备消息队列的特性,又能保证保存的消息是有序的。所有的请求消息都有标头,其中包含了如下信息:
- 请求类型
- 请求版本(broker可以处理来自不同版本客户端的请求,并根据不同的版本做出不同的响应)
- 关联ID——一个用于唯一标识请求消息的数字,同时也会出现在响应消息和错误日志里(可用于诊断问题)
- 客户端ID——用于标识发送请求的客户端应用程序
broker会在它监听的每一个端口上运行一个接收器线程,这个线程会创建一个连接,并把它交给处理器线程处理。处理器线程(也叫网络线程)的数量是可配置的。网络线程负责从客户端获取请求,把它们放进请求队列,然后从响应队列取出响应,把它们发送给客户端。有时候,服务器端需要延迟对客户端做出响应,例如,消费者要求只在有可用数据时接收响应,或者发出DeleteTopic请求的客户端要求在开始删除主题之后才接收响应。延迟的响应会被放在炼狱(临时内存)中,直到它们可以被发送给客户端。
请求消息被放入请求队列后,IO 线程(也叫请求处理线程)会负责处理它们。下面是几种最常见的请求类型:
- 生产请求:生产者发送的请求,其中包含了客户端要写入broker的消息。
- 获取请求:消费者和跟随者副本发送的请求,用于从broker读取消息。
- 管理请求:管理客户端发送的请求,用于执行元数据操作,比如创建和删除主题。
生产请求和获取请求都必须发送给分区的首领。如果broker收到一个针对某个分区的写入请求,而这个分区的首领在另一个broker上,那么发送请求的客户端将收到“非分区首领”错误响应。如果针对某个分区的读取请求被发送到一个不包含这个分区首领的broker上,那么也会收到同样的错误。Kafka客户端负责把生产请求和获取请求发送到包含分区首领的broker上。
那么客户端怎么知道该向哪里发送请求呢?客户端使用了另一种请求类型,也就是元数据请求,请求中包含了客户端感兴趣的主题清单。这种请求的响应消息里指明了这些主题所包含的分区、每个分区都有哪些副本,以及哪个副本是首领。元数据请求可以被发送给任意一个broker,因为所有broker都缓存了这些元数据信息。
一般情况下,客户端会把这些信息缓存起来,并直接向目标broker发送生产请求和获取请求。它们需要时不时地通过发送元数据请求来刷新缓存(刷新的时间间隔可以通过metadata.max.age.ms参数来配置),以便知道元数据是否发生了变化,比如,在新broker加入集群时,部分副本会被移动到新broker上。另外,如果客户端收到“非分区首领”错误,那么它会在重新发送请求之前刷新元数据,因为这个错误说明客户端正在使用过期的元数据。
生产请求
在介绍如何配置生产者时提到过acks这个配置参数,该参数指定了需要有多少个broker确认才能认为一个消息写入是成功的。生产者“写入成功”的判定条件根据配置的不同而不同。如果acks=1,那么只要首领确认收到消息就算写入成功;如果acks=all,则需要所有同步副本确认收到消息才算写入成功;如果acks=0,则生产者只管发送消息,完全不需要等待broker返回响应。
首先,包含某个分区首领的broker在收到生产请求时会对请求做一些验证。
- 发送数据的用户是否有主题写入权限?
- 请求中指定的acks是否有效(只允许出现0、1或all)?
- 如果acks=all,那么是否有足够多的同步副本保证消息已经被安全写入?(可以将broker配置成如果同步副本的数量达不到指定的值就拒绝处理新消息)
然后,消息将被写入本地磁盘。在Linux系统中,消息会被写入文件系统缓存,但不能保证何时会被冲刷到磁盘上。Kafka不会一直等待数据被持久化到磁盘上,它主要通过复制功能来保证消息的持久性。
一旦消息被写入分区的首领,broker就会检查acks配置参数——如果acks是0或1,那么broker就会立即返回响应;如果acks是all,则请求将被保存在一个叫作炼狱的缓冲区中,直到首领确认跟随者副本复制了消息,才将响应返回给客户端。
获取请求
broker处理获取请求的方式与处理生产请求的方式很相似。客户端发送请求,希望broker返回指定主题、分区和特定偏移量位置的消息,就好像在说:“请把主题Test的分区0中偏移量从53开始的消息以及主题Test的分区3中偏移量从64开始的消息发给我。”客户端还可以指定一个分区最多可以返回多少数据。这个限制非常重要,因为客户端需要为broker返回的数据分配足够的内存。如果没有这个限制,并且broker返回了大量的数据,则可能会耗尽客户端的内存。
之前说过,请求需要发送给指定的分区首领,所以客户端需要通过查询元数据来确保请求被路由到正确的节点上。首领在收到请求时会先检查请求是否有效,比如,指定的偏移量在分区中是否存在?如果客户端请求的数据已被删除,或者请求的偏移量不存在,则broker会返回错误。
如果请求的偏移量存在,那么broker将按照客户端指定的数量上限从分区中读取消息,再把消息返回给客户端。Kafka使用零复制技术向客户端发送消息,也就是说,Kafka会直接把消息从文件(或者更确切地说是Linux文件系统缓存)里发送到网络通道,不需要经过任何中间缓冲区。 这是Kafka与其他大部分数据库系统不一样的地方,其他数据库在将数据发送给客户端之前会先把它们保存在本地缓存中。这项技术避免了字节复制,也不需要管理内存缓冲区,从而能够获得更好的性能。
除了可以设置broker返回数据的上限,客户端也可以设置broker返回数据的下限。如果把下限设置为10 KB,就好像是在告诉broker“等到有10 KB数据时再把它们返回给我”。在主题消息流量不是很大的情况下,这样可以减少CPU和网络开销。具体操作如下:客户端发送一个请求,broker在等到有足够数据时才把它们返回给客户端,然后客户端再发起另一个请求,而不是让客户端每隔几毫秒就发送一次请求,可能每次都只能拿到很少的数据,甚至没有数据(参见下图)。对比这两种情况,虽然它们最终读取的数据总量是一样的,但前者的来回传送次数更少,开销也更小。
当然,我们不会一直让客户端等待broker累积数据。客户端可以在等待了一段时间之后就开始处理可用的数据,而不是一直等待下去。所以,客户端可以定义一个超时时间,告诉broker“如果你无法在x毫秒内累积足够多的数据,就把当前这些数据返回给我”。
有意思的是,并不是所有保存在分区首领上的数据都可以被客户端读取。大部分客户端只能读取已经被写入所有同步副本 [ 跟随者副本除外(尽管它们也是消费者),否则复制功能将无法正常工作 ] 的消息。分区首领知道哪些消息已经被复制到哪些副本上,所以消息在还没有被写入所有同步副本之前是不会被发送给消费者的——尝试获取这些消息的请求会得到空响应,而不是错误。
之所以这样,是因为还没有被足够多分区副本复制的消息被认为是“不安全”的——如果首领发生崩溃,另一个副本成为新首领,那么这些消息就丢失了。如果允许客户端读取只存在于首领中的消息,则可能会出现不一致的行为。试想,某个消费者先读取了一条消息,然后首领发生了崩溃,因为其他broker不包含这条消息,所以该消息就丢失了,其他消费者也就不可能读取到该消息,这样它们的行为与读取到该消息的消费者就会不一致。所以,我们会等到所有同步副本都复制了消息,才允许消费者读取它们。这也意味着,如果broker间的消息复制因为某些原因变慢,那么消息到达消费者的时间也会变长(因为会先等待消息复制完毕)。最大延迟时间可以通过参数replica.lag.time.max.ms来配置,它指定了分区副本在复制消息时最多出现多长的延迟仍然被认为是同步的。
在某些情况下,消费者需要读取大量的分区。在每个发送给broker的请求中都包含整个分区清单并让broker返回所有的元数据的做法是非常低效的,因为分区及其元数据其实很少会发生变化。所以,为了最小化这种开销,Kafka提供了请求会话缓存。消费者可以尝试创建一个会话,会话中保存了它们正在读取的分区及其元数据信息。在创建了会话之后,消费者将不需要在每个请求中都指定分区,而是使用增量式请求。broker只会在分区及其元数据发生变化时才将它们包含在响应中。不过,会话缓存的空间是有限的,Kafka会优先保存需要处理大量分区的跟随者副本和消费者的会话,所以,在某些情况下,broker可能不会创建会话,甚至会将会话清理掉。对于这两种情况,broker将向客户端返回错误,客户端可以重新发起包含所有分区元数据的请求。
其他请求
broker之间也使用了同样的通信协议。这些请求发生在Kafka内部,客户端不应该使用它们。例如,当一个新首领被选举出来,控制器会将LeaderAndIsr请求发送给新首领(这样它就可以开始接收来自客户端的请求)和跟随者(这样它们就知道要开始跟随新首领)。
Kafka的协议在持续演化——随着Kafka社区不断给客户端增加新功能,协议也要随之不断演进。例如,在过去,Kafka消费者使用ZooKeeper来跟踪偏移量,消费者会在启动时检查保存在ZooKeeper上的偏移量,然后从这个位置开始处理消息。因为各种原因,社区决定不再使用ZooKeeper来保存偏移量,而是把偏移量保存在特定的Kafka主题上。为此,贡献者们不得不在协议里新增了几种请求类型:OffsetCommitRequest、OffsetFetchRequest和ListOffsetsRequest。现在,应用程序在提交偏移量时不会再把偏移量写入ZooKeeper,而是会向Kafka发送OffsetCommitRequest请求。
过去,主题的创建需要通过命令行工具来完成,命令行工具会直接更新ZooKeeper中的主题列表。后来,Kafka社区新增了CreateTopicRequest和其他用于管理元数据的请求类型。Java应用程序通过调用Kafka的AdminClient来执行元数据操作。因为这些操作现在已经成为Kafka协议的一部分,所以那些使用不支持ZooKeeper的编程语言开发的Kafka客户端可以通过直接向Kafka发送请求来创建主题。
物理存储
Kafka的基本存储单元是分区。分区既无法在多个broker间再细分,也无法在同一个broker的多个磁盘间再细分。所以,分区的大小受单个挂载点可用空间的限制。(一个挂载点可以是单个磁盘或多个磁盘。如果配置了JBOD,就是单个磁盘;如果配置了RAID,就是多个磁盘)
在配置Kafka时,管理员会指定一个用于保存分区数据的目录列表,也就是log.dirs参数(不要把它与存放错误日志的目录混淆了,日志目录是配置在log4j.properties文件中的)。这个参数一般会包含Kafka将要使用的每一个挂载点的目录。
下面来看看Kafka是如何使用这些目录存储数据的。我们首先会介绍数据是如何被分配给broker以及broker目录的。然后会介绍broker是如何管理文件的,特别是如何根据策略保留数据。接下来会深入介绍文件和索引格式。最后会介绍日志压实及其工作原理。日志压实是Kafka的一个高级特性,有了这个特性,我们可以将Kafka作为长期的数据存储系统。
分层存储
从2018年年底开始,Kafka社区启动了一个雄心勃勃的项目,为Kafka增加分层存储能力,并计划在3.0中发布。这个项目的动机很简单:之所以使用Kafka存储海量数据,要么是因为吞吐量高,要么是因为需要长时间保留数据,但以下几点不容忽视:
- 一个分区可以存储的数据量是有限的。因此,分区数量不仅由产品需求驱动,也受物理磁盘大小的限制。
- 磁盘和集群大小的选择取决于存储需求。但是,如果将延迟和吞吐量作为主要考虑因素,那么集群的规模通常比实际需要的要大,从而增加了成本。
- 在broker间移动分区(当扩展或缩小集群时)所需要的时间是由分区大小决定的。大分区会降低集群的弹性。如今,我们可以充分利用灵活的云部署,所以在进行架构设计时会偏向于追求更大的弹性。
在分层存储架构中,Kafka集群配置了两个存储层:本地存储层和远程存储层。本地存储层和当前的Kafka存储层一样,使用broker的本地磁盘存储日志片段,远程存储层则使用HDFS、S3等专用存储系统存储日志片段。
Kafka用户可以单独为每一层配置保留策略。由于本地存储的成本通常远高于远程存储,因此本地存储的数据保留时间通常是几小时,甚至更短,而远程存储的保留时间则比较长,可以是几天,甚至几个月。
本地存储的延迟明显低于远程存储。对延迟敏感的应用程序通常从本地存储的分区尾部读取数据,因此可以受益于现有的Kafka存储机制,比如可以有效地利用页面缓存。在进行数据回填或故障恢复时,应用程序需要用到旧数据,所以需要从远程存储读取。
分层存储架构让Kafka集群的存储扩展可以独立于内存和CPU,因此可以将Kafka作为一种长期的存储解决方案。这既减少了存储在broker上的数据量,也减少了在进行故障恢复和再均衡时需要复制的数据量。远程存储中的日志片段不需要恢复到broker上,当然,如果有必要也可以进行按需恢复。因为不是所有的数据都存储在broker上,所以要延长集群数据保留时间就不再需要扩展集群存储或添加新节点。与此同时,延长系统总体数据保留时间也无须像其他系统那样使用单独的数据管道将数据从Kafka复制到外部存储。
分区的分配
在创建主题时,Kafka首先要决定如何在broker间分配分区。假设你有6个broker,打算创建一个包含10个分区的主题,并且复制系数为3,那么总共会有30个分区副本,它们将被分配给6个broker。在进行分区分配时,要达到以下这些目标:
- 在broker间平均分布分区副本。对我们的例子来说,就是要保证每个broker可以分到5个副本。
- 确保每个分区的副本分布在不同的broker上。假设分区0的首领在broker 2上,那么可以把跟随者副本分别放在broker 3和broker 4上,但不能放在broker 2上,也不能将两个都放在broker 3上。
- 如果为broker指定了机架信息(Kafka 0.10.0及之后的版本才支持),那么尽可能把每个分区的副本分配给不同机架上的broker。这样做是为了保证一个机架的不可用不会导致整体分区不可用。
为了实现这些目标,先随机选择一个broker(假设是4),然后使用轮询的方式给每个broker分配分区首领。于是,分区0的首领在broker 4上,分区1的首领在broker 5上,分区2的首领在broker 0上(因为只有6个broker),以此类推。接下来,从分区首领开始,依次分配跟随者副本。如果分区0的首领在broker 4上,那么它的第一个跟随者副本就在broker 5上,第二个跟随者副本就在broker 0上。如果分区1的首领在broker 5上,那么它的第一个跟随者副本就在broker 0上,第二个跟随者副本在broker 1上。
如果配置了机架信息,那么就不是按照数字顺序而是按照机架交替的方式来选择broker了。假设broker 0和broker 1被放置在一个机架上,broker 2和broker 3被放置在另一个机架上。我们不是按照从0到3的顺序来选择broker,而是按照0、2、1、3的顺序来选择,以保证相邻的broker总是位于不同的机架上。于是,如果分区0的首领在broker 2上,那么第一个跟随者副本就在broker 1上,以保证它们位于不同的机架上。因为如果第一个机架离线,则还有其他幸存的副本,所以分区仍然可用。这对所有副本来说都是一样的,因此在机架离线时仍然能够保证可用性。
为分区和副本选好合适的broker之后,接下来要决定新分区应该被放在哪个目录。我们会为每个分区分配目录,规则很简单:计算每个目录里的分区数量,新分区总是会被放在分区数量最少的那个目录。也就是说,如果添加了一个新磁盘,那么所有新分区都会被放到这个磁盘。这是因为在达到均衡分配的状态之前,新磁盘的分区数量总是最少的。
文件管理
数据保留是Kafka的一个重要概念。Kafka不会一直保留数据,也不会一直等到消息被所有消费者读取了之后才将其删除。相反,Kafka管理员会为每个主题配置数据保留期限,主题的数据要么在达到指定的时间之后被清除,要么在达到指定的数量之后被清除。
在一个大文件中查找和删除消息既费时又很容易出错,所以我们会把分区分成若干个片段。在默认情况下,每个片段包含1 GB或一周的数据,以较小的那个为准。在broker向分区写入数据时,如果触及任意一个上限,就关闭当前文件,并打开一个新文件。
当前正在写入数据的片段叫作活动片段。活动片段永远不会被删除,所以,如果你配置的保留时间是1天,但片段里包含了5天的数据,那么这些数据就会被保留5天,因为在片段被关闭之前,这些数据是不会被删除的。如果你要保留数据一周,并且每天使用一个新片段,那么每天就会有一个新片段被创建,同时最旧的一个片段会被删除,因此这个分区在大部分时间里会有7个片段。broker会为分区的每一个打开的日志片段分配一个文件句柄,哪怕是非活动片段。这样就会打开很多文件句柄,因此必须根据实际情况对操作系统做一些调优。
文件格式
每个日志片段被保存在一个单独的数据文件中,文件中包含了消息和偏移量。保存在磁盘上的数据格式与生产者发送给服务器的消息格式以及服务器发送给消费者的消息格式是一样的。因为磁盘存储和网络传输采用了相同的格式,所以Kafka可以使用零复制技术向消费者发送消息,并避免对生产者压缩过的消息进行解压和再压缩。如果修改了消息格式,那么网络传输和磁盘保存的消息格式也需要修改,并且broker需要知道如何处理因升级导致文件中包含了两种格式的消息。
从Kafka 0.11和v2版消息格式开始,生产者都是以批次的方式发送消息。如果每次只发送一条消息,那么使用批次反而会增加开销。但如果每次发送两条或更多的消息,那么使用批次就可以节约空间,减少网络带宽和磁盘的使用。这也是为什么配置了linger.ms=10,Kafka会表现得更好的一个原因——要求的延迟越小,消息被放在同一个批次里发送的可能性就越高。因为Kafka会为每个分区创建一个单独的批次,所以写入的分区越少,生产者的效率就越高。需要注意的是,生产者可以在同一个生产请求中包含多个批次。如果生产者端使用了压缩(推荐这么做),那么更大的批次将意味着不管是通过网络传输还是磁盘保存都能获得更好的压缩比。
消息批次的标头包括以下信息:
- 一个魔数,表示消息格式的版本。
- 批次第一条消息的偏移量以及与最后一条消息的偏移量的差值——即使之后在压实批次时可能会删除一些消息,这些也会一直保留下来。在生产者创建并发送批次时,第一条消息的偏移量会被设置为0,第一个持久化这个批次的broker(分区的首领)会用实际偏移量替换它。
- 批次第一条消息的时间戳和最大时间戳。如果设置的时间戳类型是追加时间而不是创建时间,那么可以由broker来设置时间戳。
- 批次大小,以字节为单位。
- 收到批次的broker首领的epoch(用于在首领选举后截短消息,KIP-101和KIP-279详细说明了用法)。
- 用于验证批次完整性的校验和。
- 表示不同属性的16位(bit):压缩类型、时间戳类型(时间戳可以在客户端或broker端设置),以及批次是作为事务的一部分还是作为控制批次。
- 生产者ID、生产者epoch和批次里的第一个序列——这些都用于实现精确一次性保证。
- 当然,还有组成批次的消息集合。
正如你所看到的,批次标头包含了很多信息。记录本身也有系统标头(不要与用户设置的标头相混淆)。每条记录包含以下信息:
- 记录大小,以字节为单位。
- 属性——目前没有记录级别的属性,所以这个标头没有被用到。
- 这条记录的偏移量与批次第一条消息的偏移量之间的差值。
- 这条记录的时间戳与批次第一条消息的时间戳之间的差值,以毫秒为单位。
- 有效载荷:键、值和用户设置的标头。
需要注意的是,每条记录的开销其实很小,而且大多数系统信息是批次级别的。标头中只保存批次第一条消息的偏移量和时间戳,每条记录中只保存差值,这极大地减少了每条记录的开销,从而让传输更大的批次变得更加高效。
索引
消费者可以从Kafka任意可用的偏移量位置开始读取消息。假设消费者希望从偏移量100开始读取1 MB消息,那么broker就必须立即定位到偏移量100(可能是在分区的任意一个片段里),然后从这个位置开始读取消息。为了帮助broker更快定位到指定的偏移量,Kafka为每个分区维护了一个索引。该索引将偏移量与片段文件以及偏移量在文件中的位置做了映射。
类似地,Kafka还有第二个索引,该索引将时间戳与消息偏移量做了映射。在按时间戳搜索消息时会用到这个索引。这种搜索方式在Kafka Streams中使用广泛,在一些故障转移场景中也很有用。
索引也会被分成片段,所以,在删除消息时也可以删除相应的索引。Kafka没有为索引维护校验和。如果索引损坏,那么Kafka将通过重新读取消息并记录偏移量和位置来再次生成索引。如果有必要,管理员也可以删除索引,这样做绝对安全(尽管可能需要较长的恢复时间),因为Kafka会自动重新生成索引。
压实
一般情况下,Kafka会根据设置的时间来保留数据,把超过时效的旧数据删除。但是,请试想一种场景,假设你用Kafka来保存客户的收货地址,那么保存客户的最新地址比保存客户上周甚至去年的地址更有意义,这样你就不用保留客户的旧地址了。另外一种场景是应用程序使用Kafka来保存它的当前状态,每次状态发生变化,就将新状态写入Kafka。当应用程序从故障中恢复时,它会从Kafka读取之前保存的消息,以便恢复到最近的状态。应用程序只关心发生崩溃前的那个状态,并不关心在运行过程中发生的所有状态变化。
Kafka通过改变主题的保留策略来满足这些应用场景。如果保留策略是delete,那么早于保留时间的旧事件将被删除;如果保留策略是compact(压实),那么只为每个键保留最新的值。很显然,只有当应用程序生成的事件里包含了键–值对时,设置compact才有意义。如果主题中包含了null键,那么这个策略就会失效。
主题的数据保留策略也可以被设置成delete.and.compact,也就是以上两种策略的组合。超过保留时间的消息将被删除,即使它们的键对应的值是最新的。组合策略可以防止压实主题变得太大,同时也可以满足业务需要在一段时间后删除数据的要求。
压实的工作原理
每个日志片段可以分为以下两个部分:
如果启用了压实功能(通过配置log.cleaner.enabled参数来开启),那么broker在启动时会创建一个压实管理器线程和一些压实工作线程来执行压实任务。这些线程会选择浑浊率(浑浊的消息占分区总体消息的比例)最高的分区来压实。
为了压实分区,压实线程会读取分区的浑浊部分,并在内存中创建一个map。map的每个元素都包含消息键的哈希值(16字节)和上一条具有相同键的消息的偏移量(8字节)。也就是说,每个map的元素只占用24字节的内存。假设要压实一个1 GB的日志片段,每条消息大小为1 KB,总共有100万条消息,那么只需要用24 MB的map就可以压实这个片段。(如果有重复的键,则可以重用哈希项,从而使用更少的内存。)这个效率是非常高的。
Kafka管理员可以配置压实线程在执行压实时可以为map分配多少内存。每个线程都会创建自己的map,但这个参数指的是所有线程可使用的内存总大小。如果你为map分配了1 GB内存,并使用了5个压实线程,那么每个线程将可以使用200 MB内存。Kafka不要求这个map可以放下整个分区的浑浊部分,但至少要能够放下一个片段的浑浊部分,否则Kafka会报错。管理员要么为map分配更多的内存,要么减少压实线程数量。如果有几个片段都可以被放进map,那么Kafka将从最旧的片段开始压实,其他片段则继续保持浑浊,等待下一轮压实。
创建好map后,压实线程会开始从干净的片段读取消息,它会先读取最旧的消息,把它们的内容与map中的内容进行比对。对于每一条消息,它会检查消息的键是否存在于map中,如果不存在,则说明这条消息的值是最新的,就把它复制到替换片段上。如果键已存在,就忽略这条消息,因为后面会有一条更新的包含相同键的消息。在复制完所有消息后,将替换片段与原始片段进行交换,然后开始压实下一个片段。完成整个压实过程后,每一个键对应一条消息,这些消息的值都是最新的。
被删除的事件
要彻底把一个键从系统中删除,应用程序必须发送包含这个键且值为null的消息。压实线程在发现这条消息时,会先进行常规的压实操作,只保留值为null的消息。这条消息(被称为墓碑消息)会根据配置的参数保留一段时间。在此期间,消费者可以读取到这条消息,并且发现它的值已经被置空。消费者在将Kafka数据复制到关系数据库时,如果它看到这条墓碑消息,就知道应该要把相关的用户信息从数据库中删除。在超过保留期限之后,清理线程会移除墓碑消息,它们的键也将从Kafka分区中消失。这里的关键是要让消费者有足够的时间看到墓碑消息,因为如果消费者离线几小时,那么可能就错过了墓碑消息,也就不会去删除数据库中的相关数据了。
值得一提的是,Kafka的管理客户端提供了一个deleteRecords方法。这个方法可用于删除指定偏移量之前的所有记录,但它使用的是一种完全不同的机制。当这个方法被调用时,Kafka会将低水位标记(分区的第一个偏移量)移动到指定的偏移量。这样可以防止消费者读取低水位标记之前的记录,保证这些记录在被清理线程删除之前都是不可访问的。这个方法可用于删除设置了保留策略的主题和压实主题。
何时会压实主题
就像delete策略不会删除当前的活动片段一样,compact策略也不会压实当前的活动片段,只有旧片段里的消息才会被压实。
在默认情况下,Kafka会在主题中有50%的数据包含脏记录的情况下进行压实。这样做的目的是避免压实太过频繁(因为压实会影响主题的读写性能),同时也能避免存在太多脏记录(因为它们会占用磁盘空间)。让主题的50%的磁盘空间包含脏记录,然后压实一次,这看起来是个合理的折中,当然,管理员也可以调整这个百分比。此外,管理员可以通过两个配置参数来控制压实时间:
- min.compaction.lag.ms用于确保消息被写入之后最短需要经过多长时间才可以被压实。
- max.compaction.lag.ms用于确保消息从写入到可以被压实最长可以是多长时间。当业务要求在一定时间内进行压实时可以使用这个参数,例如,GDPR要求在收到删除请求之后30天内删除某些信息。
可靠的数据传递
Kafka在数据传递可靠性方面具备很大的灵活性,它可以被应用在很多场景中——从跟踪用户点击动作到处理信用卡支付操作。有些场景对可靠性要求很高,有些则更看重速度和简便性。Kafka是高度可配置的,它的客户端API也提供了高度灵活性,可以满足不同程度的可靠性权衡。
可靠性保证
ACID大概是大家最熟悉的一个例子,它是关系数据库普遍支持的标准可靠性保证。ACID指的是原子性、一致性、隔离性和持久性。如果一个数据库厂商说他们的数据库遵循ACID规范,那么就是在说他们的数据库可以保证事务行为具有事务性。那么Kafka可以在哪些方面做出保证呢?
- Kafka可以保证分区中的消息是有序的。如果使用同一个生产者向同一个分区中写入消息,并且消息B在消息A之后写入,那么Kafka可以保证消息B的偏移量比消息A的偏移量大,而且消费者会先读取消息A再读取消息B。
- 一条消息只有在被写入分区所有的同步副本时才被认为是“已提交”的(但不一定要冲刷到磁盘上)。生产者可以选择接收不同类型的确认,比如确认消息被完全提交,或者确认消息被写入首领副本,或者确认消息被发送到网络上。
- 只要还有一个副本是活动的,已提交的消息就不会丢失。
- 消费者只能读取已提交的消息。
复制
Kafka的复制机制和分区多副本架构是Kafka可靠性保证的核心。把消息写入多个副本可以保证Kafka在发生崩溃时仍然能够提供消息的持久性。
broker配置
broker中有3个配置参数会影响Kafka的消息存储可靠性。与其他配置参数一样,它们既可以配置在broker级别,用于控制所有主题的行为,也可以配置在主题级别,用于控制个别主题的行为。主题级别的可靠性配置让Kafka集群中可以同时存在可靠的主题和不可靠的主题。例如,在银行系统中,管理员可能会把整个集群设置为可靠的,但一些用于保存客户投诉信息的主题可以例外,因为这些主题的消息如果发生丢失,问题也不大。
复制系数
主题级别的配置参数是 replication.factor。在broker级别,可以通过default.replication.factor 来设置自动创建的主题的复制系数。
如果复制系数是N,那么在N–1个broker失效的情况下,客户端仍然能够从主题读取数据或向主题写入数据。所以,更高的复制系数会带来更高的可用性、可靠性和更少的灾难性事故。另外,复制系数N需要至少N个broker,也就是说我们会有N个数据副本,并且它们会占用N倍的磁盘空间。基本上,我们是在用硬件换取可用性。那么该如何确定一个主题需要几个副本呢?这个时候需要考虑以下因素:
- 可用性:如果一个分区只有一个副本,那么它在broker例行重启期间将不可用。副本越多,可用性就越高。
- 持久性:每个副本都包含了一个分区的所有数据。如果一个分区只有一个副本,那么一旦磁盘损坏,这个分区的所有数据就丢失了。如果有更多的副本,并且这些副本位于不同的存储设备中,那么丢失所有副本的概率就降低了。
- 吞吐量:每增加一个副本都会增加broker内的复制流量。如果以10 MBps的速率向一个分区发送数据,并且只有1个副本,那么不会增加任何的复制流量。如果有2个副本,则会增加10 MBps的复制流量,3个副本会增加20 MBps的复制流量,5个副本会增加40 MBps的复制流量。在规划集群大小和容量时,需要把这个考虑在内。
- 端到段延迟:每一条记录必须被复制到所有同步副本之后才能被消费者读取。从理论上讲,副本越多,出现滞后的可能性就越大,因此会降低消费者的读取速度。在实际当中,如果一个broker由于各种原因变慢,那么它就会影响所有的客户端,而不管复制系数是多少。
- 成本:一般来说,出于成本方面的考虑,非关键数据的复制系数应该小于3。数据副本越多,存储和网络成本就越高。因为很多存储系统已经将每个数据块复制了3次,所以有时候可以将Kafka的复制系数设置为2,以此来降低成本。需要注意的是,与复制系数3相比,这样做仍然会降低可用性,但可以由存储设备来提供持久性保证。
副本的位置分布也很重要。Kafka可以确保分区的每个副本被放在不同的broker上。但是,在某些情况下,这样仍然不够安全。如果一个分区的所有副本所在的broker位于同一个机架上,那么一旦机架的交换机发生故障,不管设置了多大的复制系数,这个分区都不可用。为了避免机架级别的故障,建议把broker分布在多个不同的机架上,并通过 broker.rack 参数配置每个broker所在的机架的名字。如果配置了机架名字,那么Kafka就会保证分区的副本被分布在多个机架上,从而获得更高的可用性。如果是在云端运行Kafka,则可以将可用区域视为机架。
不彻底的首领选举
unclean.leader.election.enable 只能在broker级别(实际上是在集群范围内)配置,它的默认值是 false。
前面讲过,当分区的首领不可用时,一个同步副本将被选举为新首领。如果在选举过程中未丢失数据,也就是说所有同步副本都包含了已提交的数据,那么这个选举就是“彻底”的。但如果在首领不可用时其他副本都是不同步的,该怎么办呢?这种情况会在以下两种场景中出现:
- 分区有3个副本,其中的两个跟随者副本不可用(比如有两个broker发生崩溃)。这个时候,随着生产者继续向首领写入数据,所有消息都会得到确认并被提交(因为此时首领是唯一的同步副本)。现在,假设首领也不可用了(又一个broker发生崩溃),这个时候,如果之前的一个跟随者重新启动,那么它就会成为分区的唯一不同步副本。
- 分区有3个副本,由于网络问题导致两个跟随者副本复制消息滞后,因此即使它们还在复制,但已经不同步了。作为唯一的同步副本,首领会继续接收消息。这个时候,如果首领变为不可用,则只剩下两个不同步的副本可以成为新首领。
对于这两种场景,我们要做出一个两难的选择:如果不允许不同步的副本被提升为新首领,那么分区在旧首领(最后一个同步副本)恢复之前是不可用的。有时候这种状态会持续数小时(比如更换内存芯片)。如果允许不同步的副本被提升为新首领,那么在这个副本变为不同步之后写入旧首领的数据将全部丢失,消费者读取的数据将会出现不一致。
在默认情况下,unclean.leader.election.enable 的值是 false,也就是不允许不同步副本成为首领。这是最安全的选项,因为它可以保证数据不丢失。这也意味着在之前描述的极端不可用场景中,一些分区将一直不可用,直到手动恢复。当遇到这种情况时,管理员可以决定是否允许数据丢失,以便让分区可用,如果可以,就在启动集群之前将其设置为 true,在集群恢复之后不要忘了再将其改回 false。这样也是最合理的,如果可用性和数据可靠性非要二选一的话,必须优先保障数据的可靠性。
最少同步副本
min.insync.replicas 参数可以配置在主题级别和broker级别。 如前所述,尽管为一个主题配置了3个副本,还是会出现只剩下一个同步副本的情况。如果这个同步副本变为不可用,则必须在可用性和一致性之间做出选择,而这是一个两难的选择。根据Kafka对可靠性保证的定义,一条消息只有在被写入所有同步副本之后才被认为是已提交的,但如果这里的“所有”只包含一个同步副本,那么当这个副本变为不可用时,数据就有可能丢失。
如果想确保已提交的数据被写入不止一个副本,就要把最少同步副本设置得大一些。对于一个包含3个副本的主题,如果 min.insync.replicas 被设置为2,那么至少需要有两个同步副本才能向分区写入数据。
保持副本同步
前面提到过,不同步副本会降低总体可靠性,所以要尽量避免出现这种情况。一个副本可能在两种情况下变得不同步:要么它与ZooKeeper断开连接,要么它从首领复制消息滞后。对于这两种情况,Kafka提供了两个broker端的配置参数。
zookeeper.session.timeout.ms 是允许broker不向ZooKeeper发送心跳的时间间隔。如果超过这个时间不发送心跳,则ZooKeeper会认为broker已经“死亡”,并将其从集群中移除。在Kafka 2.5.0中,这个参数的默认值从6秒增加到了18秒,以提高Kafka集群在云端的稳定性,因为云环境的网络延迟更加多变。一般来说,我们希望将这个值设置得足够大,以避免因垃圾回收停顿或网络条件造成的随机抖动,但又要设置得足够小,以确保及时检测到确实已经发生故障的broker。
如果一个副本未能在 replica.lag.time.max.ms 指定的时间内从首领复制数据或赶上首领,那么它将变成不同步副本。在Kafka 2.5.0中,这个参数的默认值从10秒增加到了30秒,以提高集群的弹性,并避免不必要的抖动。需要注意的是,这个值也会影响消费者的最大延迟——值越大,等待一条消息被写入所有副本并可被消费者读取的时间就越长,最长可达30秒。
持久化到磁盘
之前提到过,即使消息还没有被持久化到磁盘上,Kafka也可以向生产者发出确认,这取决于已接收到消息的副本的数量。Kafka会在重启之前和关闭日志片段(默认1 GB大小时关闭)时将消息冲刷到磁盘上,或者等到Linux系统页面缓存被填满时冲刷。其背后的想法是,拥有3台放置在不同机架或可用区域的机器,并在每台机器上放置一份数据副本比只将消息写入首领的磁盘更加安全,因为两个不同的机架或可用区域同时发生故障的可能性非常小。不过,也可以让broker更频繁地将消息持久化到磁盘上。配置参数 flush.messages 用于控制未同步到磁盘的最大消息数量,flash.ms 用于控制同步频率。 在配置这些参数之前,最好先了解一下 fsync 是如何影响Kafka的吞吐量的以及如何尽量避开它的缺点。
在可靠的系统中使用生产者
即使我们会尽可能地把broker配置得很可靠,但如果没有对生产者进行可靠性方面的配置,则整个系统仍然存在丢失数据的风险。请看下面的两个例子:
- 我们为broker配置了3个副本,并禁用了不彻底的首领选举,这样应该可以保证已提交的消息不会丢失。不过,我们把生产者发送消息的 acks 设置成了 1。生产者向首领发送了一条消息,虽然其被首领成功写入,但其他同步副本还没有收到这条消息。首领向生产者发送了一个响应,告诉它“消息写入成功”,然后发生了崩溃,而此时其他副本还没有复制这条消息。另外两个副本此时仍然被认为是同步的(我们需要一小段时间才能判断一个副本是否变成了不同步的),并且其中的一个副本会成为新首领。因为消息还没有被写入这两个副本,所以就丢失了,但发送消息的客户端认为消息已经成功写入。从消费者的角度来看,系统仍然是一致的,因为它们看不到丢失的消息(副本没有收到这条消息,不算已提交),但从生产者的角度来看,这条消息丢失了。
- 我们为broker配置了3个副本,并禁用了不彻底的首领选举。我们接受了之前的教训,把生产者的 acks 设置成了 all。假设现在生产者向Kafka发送了一条消息,此时分区首领刚好发生崩溃,新首领正在选举当中,Kafka会向生产者返回“首领不可用”的响应。在这个时候,如果生产者未能正确处理这个异常,也没有重试发送消息,那么消息也有可能丢失。这不算是broker的可靠性问题,因为broker并没有收到这条消息;这也不是一致性问题,因为消费者也不会读取到这条消息。问题在于,如果生产者未能正确处理异常,就有可能丢失数据。
从上面的两个例子可以看出,开发人员需要注意两件事情。
- 根据可靠性需求配置恰当的 acks。
- 正确配置参数,并在代码里正确处理异常。
发送确认
生产者可以选择以下3种确认模式:
- acks=0:如果生产者能够通过网络把消息发送出去,那么就认为消息已成功写入Kafka。不过,在这种情况下仍然有可能出现错误,比如发送的消息对象无法被序列化或者网卡发生故障。如果此时分区离线、正在进行首领选举或整个集群长时间不可用,则并不会收到任何错误。在 acks=0 模式下,生产延迟是很低的(这就是为什么很多基准测试是基于这种模式的),但它对端到端延迟并不会带来任何改进(在消息被所有可用副本复制之前,消费者是看不到它们的)。
- acks=1:首领在收到消息并把它写入分区数据文件(不一定要冲刷到磁盘上)时会返回确认或错误响应。在这种模式下,如果首领被关闭或发生崩溃,那么那些已经成功写入并确认但还没有被跟随者复制的消息就丢失了。另外,消息写入首领的速度可能比副本从首领那里复制消息的速度更快,这样会导致分区复制不及时,因为首领在消息被副本复制之前就向生产者发送了确认响应。
- acks=all:首领在返回确认或错误响应之前,会等待所有同步副本都收到消息。这个配置可以和min.insync.replicas 参数结合起来,用于控制在返回确认响应前至少要有多少个副本收到消息。 这是最安全的选项,因为生产者会一直重试,直到消息提交成功。不过,这种模式下的生产者延迟也最大,因为生产者在发送下一批次消息之前需要等待所有副本都收到当前批次的消息。
配置生产者的重试参数
生产者需要处理的错误包括两个部分:一部分是由生产者自动处理的错误,另一部分是需要开发者手动处理的错误。生产者可以自动处理可重试的错误。当生产者向broker发送消息时,broker可以返回一个成功响应或者错误响应。错误响应可以分为两种,一种是在重试之后可以解决的,另一种是无法通过重试解决的。如果broker返回 LEADER_NOT_AVAILABLE 错误,那么生产者可以尝试重新发送消息——或许新首领被选举出来了,那么第二次尝试发送就会成功。也就是说,LEADER_NOT_AVAILABLE 是一个可重试错误。如果broker返回 INVALID_CONFIG 错误,那么即使重试发送消息也无法解决这个问题,所以这样的重试是没有意义的,这是不可重试错误。
一般来说,如果你的目标是不丢失消息,那么就让生产者在遇到可重试错误时保持重试。正如之前所建议的那样,最好的重试方式是使用默认的重试次数(整型最大值或无限),并把 delivery.timeout.ms 配置成我们愿意等待的时长,生产者会在这个时间间隔内一直尝试发送消息。
重试发送消息存在一定的风险,因为如果两条消息都成功写入,则会导致消息重复。通过重试和小心翼翼地处理异常,可以保证每一条消息都会被保存至少一次,但不能保证只保存一次。如果把 enable.idempotence 参数设置为 true,那么生产者就会在消息里加入一些额外的信息,broker可以使用这些信息来跳过因重试导致的重复消息。
额外的错误处理
使用生产者内置的重试机制可以在不造成消息丢失的情况下轻松地处理大部分错误,但开发人员仍然需要处理以下这些其他类型的错误:
- 不可重试的broker错误,比如消息大小错误、身份验证错误等。
- 在将消息发送给broker之前发生的错误,比如序列化错误。
- 在生产者达到重试次数上限或重试消息占用的内存达到上限时发生的错误。
- 超时
如果错误处理只是为了重试发送消息,那么最好还是使用生产者内置的重试机制。
在可靠的系统中使用消费者
只有已经被提交到Kafka的数据,也就是已经被写入所有同步副本的数据,对消费者是可用的,这保证了消费者读取到的数据是一致的。 消费者唯一要做的是跟踪哪些消息是已经读取过的,哪些消息是还未读取的,这是消费者在读取消息时不丢失消息的关键。
在从分区读取数据时,消费者会先获取一批消息,检查批次的最后一个偏移量,然后从这个偏移量开始读取下一批消息。这样可以保证消费者总能以正确的顺序获取新数据,不会错过任何消息。
如果一个消费者退出,那么另一个消费者需要知道从什么地方开始继续处理,以及前一个消费者在退出处理前的最后一个偏移量是多少。所谓的“另一个”消费者,也可能是原来的消费者重启之后重新上线。不过这个不重要,因为总归会有一个消费者继续从这个分区读取数据,重要的是它需要知道该从哪里开始读取。这也就是为什么消费者要“提交”偏移量。消费者会把读取的每一个分区的偏移量都保存起来,这样在重启或其他消费者接手之后就知道从哪里开始读取了。造成消费者丢失消息最主要的一种情况是它们提交了已读取消息的偏移量却未能全部处理完。在这种情况下,如果其他消费者接手了工作,那么那些没有被处理的消息就会被忽略,永远不会得到处理。这就是为什么我们非常重视何时以及如何提交偏移量。
需要注意的是,与之前讨论的“已提交的消息”不同,这里已提交的消息是指已经被写入所有同步副本并且对消费者可见的消息,而已提交的偏移量是指消费者发送给Kafka的偏移量,用于确认它已接收到的最后一条消息在分区中的位置。
消费者的可靠性配置
为了保证消费者行为的可靠性,需要注意以下4个非常重要的配置参数:
- 第一个是 group.id,这个参数已经详细介绍过了。如果两个消费者具有相同的群组ID,并订阅了同一个主题,那么每个消费者将分到主题分区的一个子集,也就是说它们只能读取到所有消息的一个子集(但整个群组可以读取到主题所有的消息)。如果你希望一个消费者可以读取主题所有的消息,那么就需要为它设置唯一的group.id。
- 第二个是 auto.offset.reset,这个参数指定了当没有偏移量(比如在消费者首次启动时)或请求的偏移量在broker上不存在时消费者该作何处理。这个参数有两个值,一个是 earliest,如果配置了这个值,那么消费者将从分区的开始位置读取数据,即使它没有有效的偏移量。这会导致消费者读取大量的重复数据,但可以保证最少的数据丢失。另一个值是 latest,如果配置了这个值,那么消费者将从分区的末尾位置读取数据。这样可以减少重复处理消息,但很有可能会错过一些消息。(其实还有一个是none)
- 第三个是 enable.auto.commit,你可以决定让消费者自动提交偏移量,也可以在代码里手动提交偏移量。自动提交的一个最大好处是可以少操心一些事情。如果是在消费者的消息轮询里处理数据,那么自动提交可以确保不会意外提交未处理的偏移量。自动提交的主要缺点是我们无法控制应用程序可能重复处理的消息的数量,比如消费者在还没有触发自动提交之前处理了一些消息,然后被关闭。如果应用程序的处理逻辑比较复杂(比如把消息交给另外一个后台线程去处理),那么就只能使用手动提交了,因为自动提交机制有可能会在还没有处理完消息时就提交偏移量。
- 第四个配置参数 auto.commit.interval.ms 与第三个参数有直接的联系。如果选择使用自动提交,那么可以通过这个参数来控制提交的频率,默认每5秒提交一次。一般来说,频繁提交会增加额外的开销,但也会降低重复处理消息的概率。
手动提交偏移量
如果想要更大的灵活性,选择了手动提交,那么就需要考虑正确性和性能方面的问题。
总是在处理完消息后提交偏移量
如果所有的处理逻辑都是在轮询里进行的,并且不需要维护轮询之间的状态(比如为了聚合数据),那么就很简单。我们可以使用自动提交,在轮询结束时提交偏移量,也可以在轮询里提交偏移量,并选择一个合适的提交频率,在额外的开销和重复消息量之间取得平衡。如果涉及额外线程或有状态处理,那么情况就复杂一些。
提交频率是性能和重复消息数量之间的权衡
即使是在最简单的场景中(比如所有的处理逻辑都在轮询里进行,并且不需要维护轮询之间的状态),仍然可以选择在一个轮询里提交多次或多个轮询提交一次。提交偏移量需要额外的开销,这有点儿类似生产者配置了 acks=all,但同一个消费者群组提交的偏移量会被发送给同一个broker,这可能会导致broker超载。提交频率需要在性能需求和重复消息量之间取得平衡。处理一条消息就提交一次偏移量的方式只适用于吞吐量非常低的主题。
在正确的时间点提交正确的偏移量
在轮询过程中提交偏移量有一个缺点,就是有可能会意外提交已读取但未处理的消息的偏移量。一定要在处理完消息后再提交偏移量,这点很关键——提交已读取但未处理的消息的偏移量会导致消费者错过消息。
再均衡
在设计应用程序时,需要考虑到消费者会发生再均衡并需要处理好它们。之前展示过几个示例,主要是关于如何在分区被撤销之前提交偏移量,或者在应用程序被分配到新分区并清理状态时提交偏移量。
消费者可能需要重试
有时候,在调用了轮询方法之后,有些消息需要稍后再处理。假设我们要把Kafka的数据写到数据库,但此时数据库不可用,那么就需要稍后再重试。需要注意的是,消费者提交偏移量并不是对单条消息的“确认”,这与传统的发布和订阅消息系统不一样。也就是说,如果记录 #30处理失败,但记录 #31处理成功,那么就不应该提交记录 #31的偏移量——如果提交了,就表示 #31以内的记录都已处理完毕,包括记录 #30在内,但这可能不是我们想要的结果。不过,可以采用以下两种模式来解决这个问题:
- 第一种模式,在遇到可重试错误时,提交最后一条处理成功的消息的偏移量,然后把还未处理好的消息保存到缓冲区(这样下一个轮询就不会把它们覆盖掉),并调用消费者的 pause() 方法,确保其他的轮询不会返回数据,之后继续处理缓冲区里的消息。
- 第二种模式,在遇到可重试错误时,把消息写到另一个重试主题,并继续处理其他消息。另一个消费者群组负责处理重试主题中的消息,或者让一个消费者同时订阅主主题和重试主题。这种模式有点儿像其他消息系统中的死信队列。
消费者可能需要维护状态
在一些应用程序中,需要维护多个轮询之间的状态。如果想计算移动平均数,就需要在每次轮询之后更新结果。如果应用程序重启,则不仅需要从上一个偏移量位置开始处理消息,还需要恢复之前保存的移动平均数。一种办法是在提交偏移量的同时把算好的移动平均数写到一个“结果”主题中。当一个线程重新启动时,它就可以获取到之前算好的移动平均数,并从上一次提交的偏移量位置开始读取数据。一般来说,这是一个比较复杂的问题,建议尝试使用其他框架,比如Kafka Streams或Flink,它们为聚合、连接、时间窗和其他复杂的分析操作提供了高级的DSL API。
验证系统可靠性
经过了所有这些流程(确认可靠性需求、配置broker、配置客户端和正确使用API),现在可以把所有东西都部署到生产环境中,然后高枕无忧,自信不会丢失任何消息了,对吗?建议还是先对系统可靠性做3个层面的验证:验证配置、验证应用程序以及在生产环境中监控可靠性。下面来看看每一步都需要验证什么以及如何验证。
验证配置
可以抛开应用程序逻辑,单独验证broker和客户端的配置。之所以建议这么做,主要是因为以下两个原因:有助于验证配置是否能够满足需求;有助于了解系统的预期行为。
Kafka提供了两个重要的配置验证工具:org.apache.kafka.tools 包下面的VerifiableProducer 类和 VerifiableConsumer 类。可以在命令行中运行这两个工具,或把它们嵌入自动化测试框架中。 VerifiableProducer 会生成一系列消息,消息里包含了一个从1到指定数字的数字。可以使用与真实的生产者相同的配置参数来配置 VerifiableProducer,比如配置相同的 acks、retries、delivery.timeout.ms 和消息生成速率。在运行过程中,VerifiableProducer 会根据收到的确认响应将每条消息发送成功或失败的结果打印出来。反过来,VerifiableConsumer 会读取由 VerifiableProducer 生成的消息,并按照读取顺序把它们打印出来。它也会把与偏移量和再均衡相关的信息打印出来。
验证应用程序
在确定broker和客户端的配置可以满足需求之后,接下来要验证应用程序是否能够提供我们想要的保证。应用程序的验证包括检查错误处理逻辑、偏移量提交的方式、再均衡监听器以及其他使用了Kafka客户端的地方。应用程序的逻辑千变万化,关于如何测试它们,我们也只能提供这些指导。建议将应用程序集成测试作为开发流程的一部分,并针对故障场景做一些测试。
现今有很多优秀的网络故障和磁盘故障注入工具,这里就不一一推荐了。Kafka项目本身也提供了一个故障注入框架Trogdor。我们对每一种测试场景都有期望的行为(也就是在开发应用程序时所期望看到的行为),然后运行测试看看实际会发生什么。例如,在测试“滚动重启消费者”这一场景时,我们期望在进行再均衡时出现短暂的停顿,然后继续读取消息,并且重复消息的条数不超过1000条。测试结果会告诉我们应用程序提交偏移量和处理再均衡的方式是否与预期一样。
在生产环境中监控可靠性
Kafka的Java客户端提供了一些JMX指标,可用于监控客户端的状态和事件。对生产者来说,最重要的两个可靠性指标是消息的错误率和重试率(聚合过的)。如果这两个指标上升,则说明系统出问题了。除此之外,还要监控生产者日志,注意那些被设为WARN 级别的错误日志,以及包含“Got error produce response with correlation id 5689 on topic-partition [topic-1,3], retrying (two attempts left). Error: …”的日志。
在消费者端,最重要的指标是消费者滞后指标。这个指标告诉我们消费者正在处理的消息与最新提交到分区的消息偏移量之间有多少差距。理想情况下,这个指标的值是0,也就是说消费者读取的是最新的消息。
精确一次性语义
我们主要关注至少一次性传递——保证Kafka不会丢失已确认写入的消息。但仍然可能出现重复消息。在一些简单的系统中,生产者生成的消息会被各种应用程序读取,即使出现了重复消息也很容易处理。大多数真实的应用程序有唯一标识符,可用于对消息进行去重处理。但是,在处理聚合事件的流式处理应用程序中,事情就没那么简单了。在检查读取消息、计算平均值并生成结果的应用程序时,我们通常无法检测出平均值是不正确的,因为有可能在计算平均值时把一个事件处理了两次。对于这种情况,需要提供更强的保证,这种保证就是精确一次性处理语义。
Kafka的精确一次性语义由两个关键特性组成:幂等生产者(避免因重试导致的消息重复)和事务语义(保证流式处理应用程序中的精确一次性处理)。
幂等生产者
如果为生产者配置了至少一次性语义而不是幂等性语义,则意味着在发生了不确定性事件的情况下,生产者将重试发送消息,这样可以保证消息至少有一次是送达的,但可能会因为重试而出现重复。Kafka的幂等生产者可以自动检测并解决消息重复问题。
幂等生产者的工作原理
如果启用了幂等生产者,那么每条消息都将包含生产者ID(PID)和序列号。我们将它们与目标主题和分区组合在一起,用于唯一标识一条消息。broker会用这些唯一标识符跟踪写入每个分区的最后5条消息。为了减少每个分区需要跟踪的序列号数量,生产者需要将max.inflight.requests设置成5或更小的值(默认值是5)。
如果broker收到之前已经收到过的消息,那么它将拒绝这条消息,并返回错误。生产者会记录这个错误,并反映在指标当中,但不抛出异常,也不触发告警。在生产者客户端,错误将被添加到record-error-rate指标当中。在broker端,错误是ErrorsPerSec指标的一部分(RequestMetrics类型)。
如果broker收到一个非常大的序列号该怎么办?如果broker期望消息2后面跟着消息3,但收到了消息27,那么这个时候该怎么办?在这种情况下,broker将返回“乱序”错误。如果使用了不带事务的幂等生产者,则这个错误可能会被忽略。
虽然生产者在遇到“乱序”异常后将继续正常运行,但这个错误通常说明生产者和broker之间出现了消息丢失——如果broker在收到消息2之后直接收到消息27,那么说明从消息3到消息26一定发生了什么。如果你在日志中看到这样的错误,那么最好重新检查一下生产者和主题的配置,确保为生产者配置了高可靠性参数,并检查是否发生了不彻底的首领选举。
- 每次生成新消息时,首领都会用最后5个序列号更新内存中的生产者状态。每次从首领复制新消息时,跟随者副本都会更新自己的内存。当跟随者成为新首领时,它的内存中已经有了最新的序列号,并且可以继续验证新生成的消息,不会有任何问题或延迟。
- 但是,如果旧首领又“活”过来了,会发生什么呢?在重启之后,内存中没有旧首领的生产者状态。为了能够恢复状态,每次在关闭或创建日志片段时broker都会将生产者状态快照保存到文件中。broker在启动时会从快照文件中读取最新状态,然后通过复制当前首领来更新生产者状态。当它准备好再次成为首领时,内存中已经有了最新的序列号。
- 如果broker发生崩溃,但没有更新最后一个快照,会发生什么呢?生产者ID和序列号也是Kafka消息格式的一部分。在进行故障恢复时,我们将通过读取旧快照和分区最新日志片段里的消息来恢复生产者状态。等故障恢复完成,一个新的快照就保存好了。
- 如果分区里没有消息,会发生什么呢?假设某个主题的数据保留时间是两小时,但在过去的两小时内没有新消息到达——如果broker发生崩溃,则没有消息可以用来恢复状态。幸运的是,没有消息也就意味着没有重复消息。我们可以立即开始接收新消息(同时将状态缺失的警告信息记录下来),并创建生产者状态。
幂等生产者的局限性
幂等生产者只能防止由生产者内部重试逻辑引起的消息重复。对于使用同一条消息调用两次producer.send()就会导致消息重复的情况,即使使用幂等生产者也无法避免。这是因为生产者无法知道这两条消息实际上是一样的。通常建议使用生产者内置的重试机制,而不是在应用程序中捕获异常并自行进行重试。使用幂等生产者是在进行重试时避免消息重复的最简单的方法。
应用程序有多个实例或一个实例有多个生产者的情况非常常见。如果两个生产者尝试发送同样的消息,则幂等生产者将无法检测到消息重复。这在一些应用程序中非常常见,例如,从数据源(一个文件目录)获取数据,并将其生成到Kafka中。如果碰巧应用程序有两个实例在读取相同的文件并将记录生成到Kafka,那么我们将会收到多个同样的消息副本。
幂等生产者只能防止因生产者自身的重试机制而导致的消息重复,不管这种重试是由生产者、网络还是broker错误所导致。
如何使用幂等生产者
幂等生产者使用起来非常简单,只需在生产者配置中加入enable.idempotence=true。如果生产者已经配置了acks=all,那么在性能上就不会有任何差异。在启用了幂等生产者之后,会发生下面这些变化:
- 为了获取生产者ID,生产者在启动时会调用一个额外的API。
- 每个消息批次里的第一条消息都将包含生产者ID和序列号(批次里其他消息的序列号基于第一条消息的序列号递增)。这些新字段给每个消息批次增加了96位(生产者ID是长整型,序列号是整型),这对大多数工作负载来说几乎算不上是额外的开销。
- broker将会验证来自每一个生产者实例的序列号,并保证没有重复消息。
- 每个分区的消息顺序都将得到保证,即使max.in.flight.requests.per.connection被设置为大于1的值(5是默认值,这也是幂等生产者可以支持的最大值)。
事务
保证Streams应用程序的正确性,Kafka中加入了事务机制。为了让流式处理应用程序生成正确的结果,要保证每个输入的消息都被精确处理一次,即使是在发生故障的情况下。Kafka的事务机制可以保证流式处理应用程序生成准确的结果,这样开发人员就可以在对准确性要求较高的场景中使用流式处理了。 Kafka的事务机制是专门为流式处理应用程序而添加的。因此,它非常适用于流式处理应用程序的基础模式,即“消费–处理–生产”。事务可以保证流式处理的精确一次性语义——在更新完应用程序内部状态并将结果成功写入输出主题之后,对每个输入消息的处理就算完成了。
事务是底层机制的名字。精确一次性语义或精确一次性保证是流式处理应用程序的行为。Streams使用事务来实现精确一次性保证。其他流式处理框架,比如Spark Streaming或Flink,则使用不同的机制来为用户提供精确一次性保证。
事务的应用场景
一些流式处理应用程序对准确性要求较高,特别是如果处理过程包含了聚合或连接操作,那么事务对它们来说就会非常有用。如果流式处理应用程序只进行简单的转换和过滤,那么就不需要更新内部状态,即使出现了重复消息,也可以很容易地将它们过滤掉。但是,如果流式处理应用程序对几条消息进行了聚合,一些输入消息被统计了不止一次,那么就很难知道结果是不是错误的。如果不重新处理输入消息,则不可能修正结果。
金融行业的应用程序就是典型的复杂流式处理的例子,在这些应用程序中,精确一次性被用于保证精确的聚合结果。不过,因为可以非常容易地在Streams应用程序中启用精确一次性保证,所以已经有非常多的应用场景(如聊天机器人)启用了这个特性。
事务是如何保证精确一次性的
继续以流式处理应用程序为例。它会从一个主题读取数据,对数据进行处理,再将结果写入另一个主题。精确一次处理意味着消费、处理和生产都是原子操作,要么提交偏移量和生成结果这两个操作都成功,要么都不成功。我们要确保不会出现只有部分操作执行成功的情况(提交了偏移量但没有生成结果,反之亦然)。
为了支持这种行为,Kafka事务引入了原子多分区写入的概念。我们知道,提交偏移量和生成结果都涉及向分区写入数据,结果会被写入输出主题,偏移量会被写入consumer_offsets主题。如果可以打开一个事务,向这两个主题写入消息,如果两个写入操作都成功就提交事务,如果不成功就中止,并进行重试,那么就会实现我们所追求的精确一次性语义。
为了启用事务和执行原子多分区写入,我们使用了事务性生产者。事务性生产者实际上就是一个配置了transactional.id并用initTransactions()方法初始化的Kafka生产者。与producer.id(由broker自动生成)不同,transactional.id是一个生产者配置参数,在生产者重启之后仍然存在。实际上,transactional.id主要用于在重启之后识别同一个生产者。 broker维护了transactional.id和producer.id之间的映射关系,如果对一个已有的transactional.id再次调用initTransactions()方法,则生产者将分配到与之前一样的producer.id,而不是一个新的随机数。
防止“僵尸”应用程序实例重复生成结果需要一种“僵尸”隔离机制,或者防止“僵尸”实例将结果写入输出流。通常可以使用epoch来隔离“僵尸”。在调用initTransaction()方法初始化事务性生产者时,Kafka会增加与transactional.id相关的epoch。带有相同transactional.id但epoch较小的发送请求、提交请求和中止请求将被拒绝,并返回FencedProducer错误。旧生产者将无法写入输出流,并被强制close(),以防止“僵尸”引入重复记录。Kafka 2.5及以上版本支持将消费者群组元数据添加到事务元数据中。这些元数据也被用于隔离“僵尸”,在对“僵尸”实例进行隔离的同时允许带有不同事务ID的生产者写入相同的分区。
在很大程度上,事务是一个生产者特性。创建事务性生产者、开始事务、将记录写入多个分区、生成偏移量并提交或中止事务,这些都是由生产者完成的。然而,这些还不够。以事务方式写入的记录,即使是最终被中止的部分,也会像其他记录一样被写入分区。消费者也需要配置正确的隔离级别,否则将无法获得我们想要的精确一次性保证。
我们通过设置isolation.level参数来控制消费者如何读取以事务方式写入的消息。如果设置为read_committed,那么调用consumer.poll()将返回属于已成功提交的事务或以非事务方式写入的消息,它不会返回属于已中止或执行中的事务的消息。默认的隔离级别是read_uncommitted,它将返回所有记录,包括属于执行中或已中止的事务的记录。配置成read_committed并不能保证应用程序可以读取到特定事务的所有消息。也可以只订阅属于某个事务的部分主题,这样就可以只读取部分消息。此外,应用程序无法知道事务何时开始或结束,或者哪些消息是哪个事务的一部分。
为了保证按顺序读取消息,read_committed隔离级别将不返回在事务开始之后(这个位置也被叫作最后稳定偏移量,last stable offset,LSO)生成的消息。这些消息将被保留,直到事务被生产者提交或终止,或者事务超时(通过transaction.timeout.ms参数指定,默认为15分钟)并被broker终止。长时间使事务处于打开状态会导致消费者延迟,从而导致更高的端到端延迟。
事务不能解决哪些问题
之前讲过,在Kafka中加入事务是为了提供多分区原子写入(不是读取),并隔离流式处理应用程序中的“僵尸”生产者。在“消费–处理–生产”流式处理任务中,事务为我们提供了精确一次性保证。在其他场景中,事务要么不起作用,要么需要做额外的工作才能获得我们想要的结果。
人们对事务存在两个误解,他们假设精确一次性保证也适用于向Kafka写入消息之外的动作,并认为消费者总是能够读取整个事务和获取事务的边界信息。下面是Kafka事务无法实现精确一次性保证的几种场景:
- 在流式处理中执行外部操作:假设流式处理应用程序在处理数据时需要向用户发送电子邮件。在应用程序中启用精确一次性语义并不能保证邮件只发送一次,这个保证只适用于将记录写入Kafka。使用序列号去重,或者使用标记中止或取消事务在Kafka中是有效的,但它不会撤销已发送的电子邮件。在流式处理应用程序中执行的任何带有外部效果的操作都是如此:调用REST API、写入文件,等等。
- 从Kafka中读取数据并写入数据库:在这种情况下,应用程序会将数据写入外部数据库,而不是Kafka。这里没有生产者参与,我们用数据库驱动器(如JDBC)将记录写入数据库,消费者会将偏移量提交给Kafka。没有任何一种机制允许将外部数据库写入操作与向Kafka提交偏移量的操作放在同一个事务中。不过,我们可以在数据库中维护偏移量,并在一个事务中将数据和偏移量一起提交到数据库——这将依赖于数据库的事务保证机制而不是Kafka的事务保证机制。
- 从一个数据库读取数据写入Kafka,再从Kafka将数据写入另一个数据库
- 将数据从一个集群复制到另一个集群
- 发布和订阅模式:对于发布和订阅模式,Kafka提供的保证与JMS事务类似,消费者需要配置成read_committed隔离级别,以保证未提交的事务对消费者是不可见的。JMS broker对所有消费者隐藏了未提交的事务。
如何使用事务
事务既是一个broker特性,也是Kafka协议的一部分,所以有多种客户端支持事务。使用事务的最常见也最推荐的方式是在Streams中启用精确一次性保证。无须直接管理事务,Streams会自动提供我们需要的保证。事务最初就是为这个场景而设计的,所以在Streams中启用事务是最简单也最有可能符合我们预期的方式。要在Streams应用程序中启用精确一次性保证,只需要将processing.guarantee设置为exactly_once或exactly_once_beta。
exactly_once_beta在处理发生崩溃或因执行中的事务而被挂起的应用程序时会有一些不同。它是在Kafka 2.5中被引入broker的,并在Kafka 2.6中被引入Streams。它的主要好处是可以用一个事务性生产者处理多个分区,因此可以创建出更加可伸缩的Streams应用程序。可以在Kafka改进提案“KIP-447: Producer scalability for exactly once semantics”中看到有关它的更多信息。
事务ID和隔离
在Kafka 2.5之前,隔离“僵尸”的唯一方法是将事务ID与分区形成固定映射,这样可以保证每个分区总是对应相同的事务ID。假设有一个事务ID为A的生产者从主题T读取消息并断开了连接,事务ID为B的新生产者取代了它,后来,生产者A变成了“僵尸”,但它不会被隔离,因为它的事务ID与生产者B的事务ID不一样。我们希望生产者A总是被新生产者A取代,新生产者A有更高的epoch,这样“僵尸”A就会被隔离。在旧版本中,事务ID是随机分配给线程的,不保证始终使用相同的事务ID写入相同的分区。
Kafka 2.5中引入了除事务ID之外的第二种基于消费者群组元数据的隔离方法(KIP-447)。我们会调用生产者的偏移量提交方法,并将消费者群组元数据(而不只是消费者群组ID)作为参数传给它。
事务的工作原理
Kafka事务的基本算法受到了Chandy-Lamport快照的启发,它会将一种被称为“标记”(marker)的消息发送到通信通道中,并根据标记的到达情况来确定一致性状态。Kafka事务根据标记消息来判断跨多个分区的事务是否被提交或被中止——当生产者要提交一个事务时,它会发送“提交”消息给事务协调器,事务协调器会将提交标记写入所有涉及这个事务的分区。如果生产者在向部分分区写入提交消息后发生崩溃,该怎么办?Kafka事务使用两阶段提交和事务日志来解决这个问题。总的来说,这个算法会执行如下步骤:
- 记录正在执行中的事务,包括所涉及的分区。
- 记录提交或中止事务的意图——一旦被记录下来,到最后要么被提交,要么被中止。
- 将所有事务标记写入所有分区。
- 记录事务的完成情况。
要实现这个算法,Kafka需要一个事务日志。这里使用了一个叫作 __transaction_state的内部主题。
事务的性能
事务给生产者带来了一些额外的开销。事务ID注册在生产者生命周期中只会发生一次。分区事务注册最多会在每个分区加入每个事务时发生一次,然后每个事务会发送一个提交请求,并向每个分区写入一个额外的提交标记。事务初始化和事务提交请求都是同步的,在它们成功、失败或超时之前不会发送其他数据,这进一步增加了开销。
需要注意的是,生产者在事务方面的开销与事务包含的消息数量无关。因此,一个事务包含的消息越多,相对开销就越小,同步调用次数也就越少,从而提高了总体吞吐量。
在消费者方面,读取提交标记会增加一些开销。事务对消费者的性能影响主要是在read_committed隔离级别下的消费者无法读取未提交事务所包含的记录。提交事务的时间间隔越长,消费者在读取到消息之前需要等待的时间就越长,端到端延迟也就越高。但是,消费者不需要缓冲未提交事务所包含的消息,因为broker不会将它们返回给消费者。由于消费者在读取事务时不需要做额外的工作,因此吞吐量不受影响。
所以好多网上的说法是错误的,ack不影响端到端的延迟。 ↩︎