阅读 74

中间件系列之Kafka-5-可靠消息传输

在之前文章的介绍中,我们已经了解到,Kafka提供了非常灵活的配置和API来支持不同的用户场景。但也因为这样,如果使用时不加以留意可能会导致问题。对于消息队列,一个永恒的问题就是如何保证消息的可靠性,这篇文章将在之前的基础上,对Kafka的可靠性做一个总结。

1.Kafka可靠性保证

Kafka中的可靠性保证有如下四点:

  • 对于一个分区来说,它的消息是有序的。如果一个生产者向一个分区先写入消息A,然后写入消息B,那么消费者会先读取消息A再读取消息B。
  • 当消息写入所有in-sync状态的副本后,消息才会认为已提交(committed)。这里的写入有可能只是写入到文件系统的缓存,不一定刷新到磁盘。生产者可以等待不同时机的确认,比如等待分区主副本写入即返回,后者等待所有in-sync状态副本写入才返回。
  • 一旦消息已提交,那么只要有一个副本存活,数据不会丢失。
  • 消费者只能读取到已提交的消息。

有了这些基础保证,我们就可以构建一个可靠的系统,但究竟我们的系统需要多大程度的可靠性呢?可靠性与系统可用性、吞吐量、延迟和硬件价格息息相关,得此失彼。故此,在实际生产中,我们往往需要做权衡。

Kafka的复制机制是保证可靠性的基础。

每一个Kafka的主题都会分成几个分区,每个分区都单独存放在磁盘上。Kafka保证分区内的消息顺序。每个分区可以有若干个相同的副本,其中一个为主副本。生产者发送消息到主副本,消费者从主副本中读取消息。其他副本需要保持与主副本同步,如果主副本不可用,那么这些同步中的副本其中之一会成为新的主副本。

一个副本被认为是in-sync(也就是及时同步)状态,当且仅当它为主副本,或者是保持如下行为的其他副本:

  • 与Zookeeper的会话仍然活跃,即心跳间隔不超过6秒(时间可以配置)。
  • 向主副本发起拉取数据请求间隔不超过10秒(时间可以配置)。
  • 向主副本拉取的数据应当是10秒内产生的消息,也就是说不能拉取太老的消息。

如果一个副本不满足以上要求,那么它会变成out-of-sync状态直到它重新满足以上要求。

稍微有落后但仍然是in-sync状态的副本会拖慢生产者和消费者,因为它们需要等待所有in-sync状态的副本写入该消息。一旦某个副本变成out-of-sync状态,我们不需要等待它写入消息,这时候该副本不会造成任何性能影响,但同时由于in-sync状态副本减少,系统的冗余度也减少,数据丢失的可能性增大。

下面将对Kafka的可靠性配置做进一步的说明和总结。

2.broker配置

broker有3个配置参数影响kafka消息存储的可靠性:

复制因子

broker级别参数default.replication.factor,topic级别参数replication.factor。默认值为3。

如果设置复制因子为N,那么我们能够容忍N-1个副本发生故障。因此更高的复制因子意味着更高的可用性以及数据可靠性;但另一方面,N个副本意味着需要存储N份相同的数据,需要N倍的存储空间。因此我们需要基于业务特点来决定复制因子。

除此之外,副本的分布同样也会影响可用性。默认情况下,Kafka会确保分区的每个副本分布在不同的Broker上,但是如果这些Broker在同一个机架上,一旦机架的交换机发生故障,分区就会不可用。所以建议把Broker分布在不同的机架上,可以使用broker.rack参数配置Broker所在机架的名称。

同步副本列表

In-sync replica(ISR)称之为同步副本,ISR中的副本都是与Leader进行同步的副本,所以不在该列表的follower会被认为与Leader是不同步的。那么,ISR中存在的是什么副本呢?

1.Leader副本总是存在于ISR中。

2.follower副本是否在ISR中,取决于该follower副本是否与Leader副本保持了“同步”。

Kafka的broker端有一个参数replica.lag.time.max.ms, 该参数表示follower副本滞后与Leader副本的最长时间间隔,默认是10秒。这就意味着,只要follower副本落后于leader副本的时间间隔不超过10秒,就可以认为该follower副本与leader副本是同步的。

可以看出ISR是一个动态的,所以即便是为分区配置了3个副本,还是会出现同步副本列表中只有一个副本的情况(其他副本由于不能够与leader及时保持同步,被移出ISR列表)。如果这个同步副本变为不可用,我们必须在可用性一致性之间作出选择。

根据Kafka 对可靠性保证的定义,消息只有在被写入到所有同步副本之后才被认为是已提交的。但如果这里的“所有副本”只包含一个同步副本,那么在这个副本变为不可用时,数据就会丢失。如果要确保已提交的数据被写入不止一个副本,就需要把最小同步副本数量设置为大一点的值。对于一个包含3 个副本的主题分区,如果min.insync.replicas=2 ,那么至少要存在两个同步副本才能向分区写入数据。

如果进行了上面的配置,此时必须要保证ISR中至少存在两个副本,如果ISR中的副本个数小于2,那么Broker就会停止接受生产者的请求。尝试发送数据的生产者会收到NotEnoughReplicasException异常,消费者仍然可以继续读取已有的数据。

禁用unclean选举

选择一个同步副本列表中的分区作为leader 分区的过程称为clean leader election。注意,这里要与在非同步副本中选一个分区作为leader分区的过程区分开,在非同步副本中选一个分区作为leader的过程称之为unclean leader election。由于ISR是动态调整的,所以会存在ISR列表为空的情况,通常来说,非同步副本落后 Leader 太多,因此,如果选择这些副本作为新 Leader,就可能出现数据的丢失。

在 Kafka 中,选举这种副本的过程可以通过Broker 端参数 unclean.leader.election.enable控制是否允许 Unclean 领导者选举。开启 Unclean 领导者选举可能会造成数据丢失,但好处是,它使得分区 Leader 副本一直存在,不至于停止对外提供服务,因此提升了高可用性。反之,禁止 Unclean Leader 选举的好处在于维护了数据的一致性,避免了消息丢失,但牺牲了高可用性。

设置unclean.leader.election.enable为true,会允许落后副本成为新的主副本,这也是Kafka的默认设置。对于数据可靠性以及一致性非常重要的场景,用户可以设置为false。

3. 可靠的生产者

即使我们使用以上方式来配置broker以保证数据不丢失,但如果使用生产者的方式不对,任然会有丢失的风险:

  • 生产者使用acks=1的确认方式,那么消息发送到主副本并且写入成功后,主副本返回结果,这时候生产者认为消息已经提交成功了。如果这时候主副本返回结果后就出现故障,并且没有把该消息同步到in-sync副本(in-sync副本允许有短暂的消息落后),那么in-sync副本会选举出新的主副本,而新的主副本并没有包含该消息。这样便出现生产者“认为”消息提交成功,但消息丢失的情况。
  • 生产者使用acks=all的配置。假如在写入消息时,主副本发生故障(并进行新主副本选举),那么broker会返回主副本不可用的异常。如果我们程序没有处理这个错误并进行重试,那么消息将会丢失。这样的数据丢失并不是由于broker本身可靠性导致的,因为broker就没有成功收到这个消息。

因此对于可靠性非常重要的系统来说,在使用生产者写入消息时需要注意如下两点:

  • 使用正确的acks参数来满足业务的可靠性需要;
  • 正确处理写入消息的异常信息。

消息确认

生产者可以选择三种acks取值:

  • acks=0:这种方式意味着,一旦生产者将消息通过网络传输成功,那么就认为消息已经写入到Kafka。因为生产者在传输成功就进行后续处理了,没有关注Kafka的处理结果。这种方式性能最高,但可能会导致消息丢失。
  • acks=1:这种方式意味着,生产者传输消息到主副本,并等待主副本写入磁盘(注意,可能只是写入到文件系统缓存,不一定刷新到磁盘),如果写入完成则返回成功,否则返回失败。主副本发生故障时,那些没有及时同步到其他副本的消息会丢失。
  • acks=all:这种方式意味着,生产者传输消息到主副本,并且在所有in-sync副本写入成功后主副本返回确认信息,否则返回错误。这个参数同时配合broker的min.insync.replica参数使用,能够实现“一旦返回成功,意味着最少写入N个副本”的语义,其中N为min.insync.replica的取值。如果生产者接收到错误,那么需要进行重试以防止消息丢失。可靠性最高。

配置生产者的重试机制

在使用生产者发送消息的时候,我们将会碰到两类错误:

  • 可恢复错误:对于这种错误,生产者内部会进行重试。比如说broker返回主副本不可用异常,当生产者收到此异常后会进行重试。
  • 不可恢复错误:对于这种错误,即便生产者进行重试也不会成功,因此需要应用本身进行处理。比如说broker返回配置非法异常,当生产者收到此异常后进行重试也于事无补,需要应用自身进行处理。

Producer 的参数retries ,对应Producer 的自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了 retries > 0 的 Producer 能够自动重试消息发送,避免消息丢失。

另外,生产者重试也可能会导致消息重复。假如消息发送到broker并且所有in-sync副本都写入成功,但在返回结果时网络发生故障,这时候生产者由于没收到回复认为消息没有发送成功,然后进行重试,这样便导致消息重复。异常处理和重试能够保证消息“最少一次”的语义,但无法保证“有且仅有一次”的语义。应用本身如果需要实现“有且仅有一次”的语义,可以在消息中加入全局唯一标识符,这样在消费消息时可以进行去重。或者,应用生产的就是幂等的消息。

其他的错误处理

还有一些错误是需要应用进行处理的:

  • 不可恢复异常(比如消息大小非法、权限认证失败);
  • 消息发送到broker之前发生的异常(比如序列化异常);
  • 生产者达到重试上限的异常,或者由于消息重试导致消息堆积最终内存溢出的异常。

对于这些异常,我们可以记录日志,持久化到数据库或者简单的抛弃。

4. 可靠的消费者

之前说到,当消息变成已提交状态(也就是写入到所有in-sync副本)后,它才能被消费端读取。这保证了消费者读取到的数据始终是一致的,为了达到高可靠,消费者需要保证在消费消息时不丢失数据。

在处理分区消息时,消费者一般的处理流程为:拉取批量消息,处理完成后提交位移,然后再拉取下一批消息。提交位移保证了当前消费者发生故障或重启时,其他消费者可以接着上一次的消息位移来进行处理。消费端丢失消息的一个主要原因就是:消费者拉取消息后还没处理完就提交位移,一旦在消息处理过程中发生故障,新的消费者会从已提交的位移接着处理,导致发生故障时的消息丢失。

重要的可靠性配置

如果希望设计一个高可靠的消费者,那么消费者中有4个重要的属性需要慎重考虑。

  • group.id:如果有多个消费者拥有相同的group.id并且订阅相同的主题,那么每个消费者会负责消费一部分的消息。如果消费组内存在多个消费者,那么一个消费者发生故障那么其他消费者可以接替其工作,保证高可用。

  • auto.offset.reset,这个属性在如下场景中起作用:当消费者读取消息,Kafka中没有提交的位移(比如消费者所属的消费组第一次启动)或者希望读取的位移不合法(比如消费组曾经长时间下线导致位移落后)时,消费者如何处理?这个参数有两种配置。一种是earliest:消费者会从分区的开始位置读取数据,不管偏移量是否有效,这样会导致消费者读取大量的重复数据,但可以保证最少的数据丢失。一种是latest(默认),如果选择了这种配置, 消费者会从分区的末尾开始读取数据,这样可以减少重复处理消息,但很有可能会错过一些消息。

  • enable.auto.commit,这个属性需要慎重考虑,你希望消费者定期自动提交位移,还是应用手动提交位移?自动提交位移可以让应用在处理消息时不用实现提交位移的逻辑,并且如果我们是在poll循环中使用相同的线程处理消息,那么自动提交位移可以保证在消息处理完成后才提交位移。如果我们在poll循环中使用另外的线程处理消息,那么自动提交位移可能会导致提交还没完成处理的消息位移。

  • auto.commit.interval.ms,它与第三个属性有关。如果选择了自动提交位移,那么这个属性控制提交位移的时间间隔。默认值是5秒,通常来说降低间隔可以降低消息重复处理的可能性。

手动提交位移

如果我们选择手动提交位移,下面来根据不同场景来讨论如何实现更可靠的消费者。

处理完消息后立即提交

如果在poll循环中进行消息处理,并且处理完后提交位移,那么提交位移的实现方式非常简单。对于这种场景,可以考虑使用自动提交而不是手动提交。

在处理消息过程中多次提交

消费者拉取批量消息后处理消息时,在处理过程中可以使用手动提交位移方式来多次更新位移。这种方式可以使得消息重复处理可能性降低。

重平衡

在设计应用时,我们需要记得正确处理重平衡。当重平衡发生时,消费者当前处理的分区可能会被回收,我们需要记得在回收前提交位移。

消费者的重试

在某些场景下,消费者拉取消息后进行处理时会遇到一些问题,可能希望这些消息可以延迟处理。比如,对于从Kafka拉取消息然后持久化到数据库的应用来说,如果某个时刻数据库不可用,我们可能希望延后重试。延后重试的策略可以分成如下两大类:

第一种处理方式是,我们提交已经处理成功的位移,然后将处理失败的消息存储到一个缓冲区,并不断进行重试处理这些消息。另外,在处理这些消息时可能poll循环仍然在继续,我们可以使用pause()方法来使得poll不会返回新的数据,这样使得重试更容易。

第二种处理方式是,我们把处理失败的消息写入到另外的主题,然后继续处理当前的消息。对于失败消息的主题,我们既可以使用同一个消费组进行处理,也可以使用不同的消费组进行处理。

消息处理时间长

某些应用拉取消息回来后处理消息时间比较长(比如依赖于一个阻塞服务或者进行复杂的计算),而某些版本的消费者如果长时间不poll消息会导致会话超时,因此使用这些版本的应用需要不断的拉取消息来发送心跳包到broker。一种常见的处理方式是,我们使用多线程来处理消息,然后当前线程调用pause()来使得既可以调用poll()而且消费者不会拉取新的消息;当消息处理完成后,再调用resume()来使得消费者恢复正常拉取逻辑。

有且仅有一次的语义

Kafka不支持有且仅有一次的语义,但可以支持至少一次的语义。因此对于需要实现有且仅有一次语义的应用来说,我们需要自己额外处理。

一种常见的处理方式为,我们使用支持唯一键的外部系统(比如关系型数据库、Elasticsearch等)来进行结果去重。我们可以自己实现唯一键并且在消息中加入此属性,也可以根据消息的主题、分区以及位移信息来生成唯一键。

文章分类
后端
文章标签