文章目录
前言
- 可靠性是系统而不是某个独立组件的一个属性,所以,在讨论Kafka的可靠性保证时,需要从系统的整体出发。说到可靠性,那些与Kafka集成的系统与Kafka本身一样重要。正因为可靠性是系统层面的概念,所以它不只是某个个体的事情。Kafka管理员、Linux系统管理员、网络和存储管理员,以及应用程序开发者,所有人必须协同作战才能构建出一个可靠的系统。
- Kafka在数据传递可靠性方面具备很大的灵活性,它可以被应用在很多场景中------从跟踪用户点击动作到处理信用卡支付操作。有些场景对可靠性要求很高,有些则更看重速度和简便性。Kafka是高度可配置的,它的客户端API也提供了高度灵活性,可以满足不同程度的可靠性权衡。
- 不过,灵活性也很容易让人掉入陷阱。有时候,我们的系统看起来是可靠的,但实际上可能不是。
本文将首先讨论各种各样的可靠性以及它们在Kafka中的含义;
然后介绍Kafka的复制功能以及它是如何提高系统可靠性的;
接下来探讨如何配置Kafka的broker和主题来满足不同应用场景的需求;
随后讨论如何在各种可靠性场景中使用生产者和消费者客户端;
可靠性保证
类似于事物数据库保证 ACID 的特性来保证数据的可靠性,Kafka 可以在哪些方面做出可靠性保证呢?
- 保证同一个分区内的数据是有序的。
- 一条消息只有在被写入分区所有的同步副本时才被认为是"已提交"的(但不一定要冲刷到磁盘上)。生产者可以选择接收不同类型的确认,比如确认消息被完全提交,或者确认消息被写入首领副本,或者确认消息被发送到网络上。
- 只要还有一个副本是活动的,已提交的消息就不会丢失。
- 消费者只能读取已提交的消息。
kafka 提供了生产者和消费者端灵活可配置的参数,来保证数据的可靠性,我们在使用 kafka 构建我们的系统时需要结合具体架构来选择合适的配置。
我之前写过关于生产者 API 最佳实践和消费者 API 最佳实践的说明,可以参考下。
复制
分区副本复制与首领副本
- Kafka的复制机制 和分区多副本架构是Kafka可靠性保证的核心。把消息写入多个副本可以保证Kafka在发生崩溃时仍然能够提供消息的持久性。
- Kafka的主题会被分为多个分区,分区是最基本的数据构建块。分区存储在单个磁盘上,Kafka可以保证分区中的事件是有序的。
一个分区可以在线(可用),也可以离线(不可用)。每个分区可以有多个副本,其中有一个副本是首领。
- 所有的事件都会被发送给首领副本 ,通常情况下消费者也直接从首领副本读取事件 。其他副本只需要与首领保持同步,及时从首领那里复制最新的事件。当首领副本不可用时,其中一个同步副本将成为新首领。
同步副本
分区的首领肯定是同步副本,而对跟随者副本来说,则需要满足以下条件才能被认为是同步副本:
- 与ZooKeeper之间有一个活跃的会话,也就是说,它在过去的6秒(可配置)内向ZooKeeper发送过心跳。
- 在过去的10秒(可配置)内从首领那里复制过消息。
- 在过去的10秒内从首领那里复制过最新的消息。仅从首领那里复制消息是不够的,它还必须在每10秒(可配置)内复制一次最新的消息。
同步副本滞后
- 一个稍有滞后的同步副本会导致生产者和消费者变慢,因为在消息被认为已提交之前,客户端会等待所有同步副本确认消息。
- 如果一个副本变成不同步的,那么我们就不再关心它是否已经收到消息。这个时候,虽然不同步副本同样是滞后的,但它不影响性能。然而,更少的同步副本意味着更小的有效复制系数,因此在停机时丢失数据的风险就更大了。
broker配置
broker中有3个配置参数会影响Kafka的消息存储可靠性。与其他配置参数一样,它们既可以配置在broker级别,用于控制所有主题的行为,也可以配置在主题级别,用于控制个别主题的行为。
复制系数
主题级别的配置参数是 replication.factor
。在broker级别,可以通过default.replication.factor
来设置自动创建的主题的复制系数 默认为 3。
更高的复制系数会带来更高的可用性、可靠性和更少的灾难性事故,但是同时也会带来使用资源的增加,是在用硬件换取可用性。
该如何确定一个主题需要几个副本呢?这个时候需要考虑以下因素:
可用性
- 如果一个分区只有一个副本,那么它在broker例行重启期间将不可用。副本越多,可用性就越高。
持久性
- 每个副本都包含了一个分区的所有数据。如果一个分区只有一个副本,那么一旦磁盘损坏,这个分区的所有数据就丢失了。如果有更多的副本,并且这些副本位于不同的存储设备中,那么丢失所有副本的概率就降低了。
吞吐量
- 每增加一个副本都会增加broker内的复制流量。如果以10 MBps的速率向一个分区发送数据,并且只有1个副本,那么不会增加任何的复制流量。如果有2个副本,则会增加10 MBps的复制流量,3个副本会增加20 MBps的复制流量,5个副本会增加40MBps的复制流量。在规划集群大小和容量时,需要把这个考虑在内。
端到端延迟
- 每一条记录必须被复制到所有同步副本之后才能被消费者读取。从理论上讲,副本越多,出现滞后的可能性就越大,因此会降低消费者的读取速度。在实际当中,如果一个broker由于各种原因变慢,那么它就会影响所有的客户端,而不管复制系数是多少。
成本
- 一般来说,出于成本方面的考虑,非关键数据的复制系数应该小于3。数据副本越多,存储和网络成本就越高。因为很多存储系统已经将每个数据块复制了3次,所以有时候可以将Kafka的复制系数设置为2,以此来降低成本。需要注意的是,与复制系数3相比,这样做仍然会降低可用性,但可以由存储设备来提供持久性保证。
不彻底的首领选举
unclean.leader.election.enable
只能在broker级别(实际上是在集群范围内)配置,它的默认值是 false。
前面讲过,当分区的首领不可用时,一个同步副本将被选举为新首领。如果在选举过程中未丢失数据,也就是说所有同步副本都包含了已提交的数据,那么这个选举就是"彻底"的,也就是说选出来的首领是正常的。
但如果在首领不可用时其他副本都是不同步的,该怎么办呢?
这种情况会在以下两种场景中出现:
- 分区有3个副本,其中的两个跟随者副本不可用(比如有两个broker发生崩溃)。这个时候,随着生产者继续向首领写入数据,所有消息都会得到确认并被提交(因为此时首领是唯一的同步副本)。现在,假设首领也不可用了(又一个broker发生崩溃),这个时候,如果之前的一个跟随者重新启动,那么它就会成为分区的唯一不同步副本。
- 分区有3个副本,由于网络问题导致两个跟随者副本复制消息滞后 ,因此即使它们还在复制,但已经不同步了。作为唯一的同步副本,首领会继续接收消息。这个时候,如果首领变为不可用,则只剩下两个不同步的副本可以成为新首领。
对于这两种场景,我们要做出一个两难的选择:
- 如果不允许不同步的副本被提升为新首领 ,那么分区在旧首领(最后一个同步副本)恢复之前是不可用的。有时候这种状态会持续数小时(比如更换内存芯片)。
- 如果允许不同步的副本被提升为新首领 ,那么在这个副本变为不同步之后写入旧首领的数据将全部丢失,消费者读取的数据将会出现不一致。
总的来说,如果允许不同步副本成为首领,那么就要承担丢失数据和消费者读取到不一致的数据的风险。如果不允许它们成为首领,那么就要接受较低的可用性,因为必须等待原先的首领恢复到可用状态。
临时设置
- 在默认情况下,
unclean.leader.election.enable
的值是 false,也就是不允许不同步副本成为首领。这是最安全的选项,因为它可以保证数据不丢失。这也意味着在之前描述的极端不可用场景中,一些分区将一直不可用,直到手动恢复。 - 当遇到这种情况时,管理员可以决定是否允许数据丢失,以便让分区可用,如果可以,就在启动集群之前将其设置为 true,在集群恢复之后不要忘了再将其改回 false。
最少同步副本
min.insync.replicas
参数可以配置在主题级别和broker级别。
如果想确保已提交的数据被写入不止一个副本,就要把最少同步副本设置得大一些。对于一个包含3个副本的主题,如果 min.insync.replicas
被设置为2,那么至少需要有两个同步副本才能向分区写入数据。
如果3个副本都是同步的,那么一切正常进行。即使其中一个副本变为不可用,也不会有什么问题。
但是,如果有两个副本变为不可用,那么broker就会停止接受生产者的请求。尝试发送数据的生产者会收到 NotEnoughReplicasException
异常,不过消费者仍然可以继续读取已有的数据。
实际上,如果使用这样的配置,那么当只剩下一个同步副本时 ,它就变成只读 的了。
这样做是为了避免在发生不彻底的选举时数据的写入和读取出现非预期的行为。要脱离这种只读状态,必须让两个不可用分区中的一个重新变为可用(比如重启broker),并等待它变为同步的。
保持副本同步
前面提到过,不同步副本会降低总体可靠性,所以要尽量避免出现这种情况。一个副本可能在两种情况下变得不同步:要么它与ZooKeeper断开连接,要么它从首领复制消息滞后。对于这两种情况,Kafka提供了两个broker端的配置参数。
zookeeper.session.timeout.ms
是允许broker不向ZooKeeper发送心跳的时间间隔。- 如果超过这个时间不发送心跳,则ZooKeeper会认为broker已经"死亡 ",并将其从集群中移除。
- 一般来说,我们希望将这个值设置得足够大,以避免因垃圾回收停顿或网络条件造成的随机抖动,但又要设置得足够小,以确保及时检测到确实已经发生故障的broker。
- 如果一个副本未能在
replica.lag.time.max.ms
指定的时间内从首领复制数据或赶上首领,那么它将变成不同步副本。 - 这个值也会影响消费者的最大延迟 ------值越大,等待一条消息被写入所有副本并可被消费者读取的时间就越长,最长可达30秒。
持久化到磁盘
- 即使消息还没有被持久化到磁盘上,Kafka也可以向生产者发出确认,这取决于已接收到消息的副本的数量。
- Kafka会在重启之前和关闭日志片段(默认1 GB大小时关闭)时将消息冲刷到磁盘上,或者等到Linux系统页面缓存被填满时冲刷。
- 不过,也可以让broker更频繁地将消息持久化到磁盘上。配置参数 flush.messages 用于控制未同步到磁盘的最大消息数量,flash.ms 用于控制同步频率。
在可靠的系统中使用生产者
即使我们会尽可能地把broker配置得很可靠,但如果没有对生产者进行可靠性方面的配置,则整个系统仍然存在丢失数据的风险。
案例1
- 我们为broker配置了3个副本 ,并禁用了不彻底的首领选举 ,这样应该可以保证已提交的消息不会丢失。不过,我们把生产者发送消息的 acks 设置成了 1。
- 生产者向首领发送了一条消息,虽然其被首领成功写入,但其他同步副本还没有收到这条消息。
- 首领向生产者发送了一个响应,告诉它"消息写入成功",然后发生了崩溃,而此时其他副本还没有复制这条消息。另外两个副本此时仍然被认为是同步的 (我们需要一小段时间才能判断一个副本是否变成了不同步的),并且其中的一个副本会成为新首领 。因为消息还没有被写入这两个副本 ,所以就丢失了,但发送消息的客户端认为消息已经成功写入。
- 从消费者的角度来看,系统仍然是一致的,因为它们看不到丢失的消息(副本没有收到这条消息,不算已提交),但从生产者的角度来看,这条消息丢失了。
案例2
- 我们为broker配置了3个副本,并禁用了不彻底的首领选举。我们接受了之前的教训,把生产者的 acks 设置成了 all。
- 假设现在生产者向Kafka发送了一条消息,此时分区首领刚好发生崩溃,新首领正在选举当中,Kafka会向生产者返回"首领不可用"的响应。在这个时候,如果生产者未能正确处理这个异常,也没有重试发送消息,那么消息也有可能丢失。这不算是broker的可靠性问题,因为broker并没有收到这条消息;这也不是一致性问题,因为消费者也不会读取到这条消息。问题在于,如果生产者未能正确处理异常,就有可能丢失数据。
从上面的两个例子可以看出,开发人员需要注意两件事情:
- 根据可靠性需求配置恰当的 acks。
- 正确配置参数,并在代码里正确处理异常。
关于如何配置生产者可以参考之前的文章 生产者 API 最佳实践。
发送确认
acks=0
- 生产者发送消息忽略 broker 响应,不管是否发送成功。
- 生产延迟底,但是不可靠,生产环境不建议使用。
acks=1
- 生产者发送完消息后需要等到首领副本响应。
- 如果首领被关闭或发生崩溃 ,那么那些已经成功写入并确认但还没有被跟随者复制的消息就丢失了。
- 消息写入首领的速度可能比副本从首领那里复制消息的速度更快,这样会导致分区复制不及时,因为首领在消息被副本复制之前就向生产者发送了确认响应。
acks=all
- 首领在返回确认或错误响应之前,会等待所有同步副本都收到消息。
- 这个配置可以和
min.insync.replicas
参数结合起来,用于控制在返回确认响应前至少要有多少个副本收到消息。 - 这是最安全的选项,因为生产者会一直重试,直到消息提交成功。不过,这种模式下的生产者延迟也最大,因为生产者在发送下一批次消息之前需要等待所有副本都收到当前批次的消息。
配置生产者的重试参数
生产者需要处理的错误包括两个部分:一部分是由生产者自动处理的错误 ,另一部分是需要开发者手动处理的错误 。
生产者自动重试处理
- 生产者可以自动处理可重试的错误。最好的重试方式是使用默认的重试次数(整型最大值或无限),并把
delivery.timeout.ms
配置成我们愿意等待的时长,生产者会在这个时间间隔内一直尝试发送消息。 - 重试可能会导致消息重复,如果把
enable.idempotence
参数设置为 true,那么生产者就会在消息里加入一些额外的信息,broker可以使用这些信息来跳过因重试导致的重复消息。
开发者手动处理
- 不可重试的broker错误,比如消息大小错误、身份验证错误等。
- 在将消息发送给broker之前发生的错误,比如序列化错误。
- 在生产者达到重试次数上限或重试消息占用的内存达到上限时发生的错误。
- 超时。
在可靠的系统中使用消费者
除了保证生产者发送数据的可靠性,同时还需要保证消费者消费数据的可靠性。
只有已经被提交到Kafka的数据,也就是已经被写入所有同步副本的数据,对消费者是可用的。这保证了消费者读取到的数据是一致的。消费者唯一要做的是跟踪哪些消息是已经读取过的,哪些消息是还未读取的,这是消费者在读取消息时不丢失消息的关键。
在从分区读取数据时,消费者会先获取一批消息,检查批次的最后一个偏移量,然后从这个偏移量开始读取下一批消息。这样可以保证消费者总能以正确的顺序获取新数据,不会错过任何消息。
消费者会把读取的每一个分区的偏移量都保存起来,这样在重启或其他消费者接手之后就知道从哪里开始读取了。造成消费者丢失消息最主要的一种情况是它们提交了已读取消息的偏移量却未能全部处理完。在这种情况下,如果其他消费者接手了工作,那么那些没有被处理的消息就会被忽略,永远不会得到处理。这就是为什么我们非常重视何时以及如何提交偏移量。
已提交消息与已提交偏移量说明
已提交消息: 已经被写入所有同步副本并且对消费者可见的消息。
已提交偏移量: 指消费者发送给Kafka的偏移量,用于确认它已接收到的最后一条消息在分区中的位置。
消费者的可靠性配置
为了保证消费者行为的可靠性,需要注意以下4个非常重要的配置参数。
group.id
- 如果两个消费者具有相同的群组ID,并订阅了同一个主题,那么每个消费者将分到主题分区的一个子集,也就是说它们只能读取到所有消息的一个子集(但整个群组可以读取到主题所有的消息)。
- 如果希望一个消费者可以读取主题所有的消息,那么就需要为它设置唯一的group.id。
auto.offset.reset
- 指定了当没有偏移量(比如在消费者首次启动时)或请求的偏移量在broker上不存在时,消费者该作何处理。
- 这个参数有两个值,一个是
earliest
,如果配置了这个值,那么消费者将从分区的开始位置读取数据,即使它没有有效的偏移量。这会导致消费者读取大量的重复数据,但可以保证最少的数据丢失。 - 另一个值是
latest
,如果配置了这个值,那么消费者将从分区的末尾位置读取数据。这样可以减少重复处理消息,但很有可能会错过一些消息。
enable.auto.commit
- 设置消费者自动提交偏移量。
- 当消费逻辑代码不复杂,在消费轮询中处理数据,可以使用自动提交。
- 当应用程序逻辑复杂(比如把消息交给另外一个后台线程去处理),那么就只能使用手动提交了,因为自动提交机制有可能会在还没有处理完消息时就提交偏移量。
- 如果选择使用自动提交,那么可以通过这个参数来控制提交的频率,默认每5秒提交一次。一般来说,频繁提交会增加额外的开销,但也会降低重复处理消息的概率。
手动提交偏移量
如果想要更大的灵活性,选择了手动提交,那么就需要考虑正确性和性能方面的问题。
下边说明在开发可靠消费者应用时需要注意的事项:
总是在处理完消息后提交偏移量
- 如果所有的处理逻辑都是在轮询里进行的,并且不需要维护轮询之间的状态(比如为了聚合数据),那么就很简单。我们可以使用自动提交,在轮询结束时提交偏移量,也可以在轮询里提交偏移量,并选择一个合适的提交频率,在额外的开销和重复消息量之间取得平衡。如果涉及额外线程或有状态处理,那么情况就复杂一些。
提交频率是性能和重复消息数量之间的权衡
- 即使是在最简单的场景中(比如所有的处理逻辑都在轮询里进行,并且不需要维护轮询之间的状态),仍然可以选择在一个轮询里提交多次或多个轮询提交一次。提交偏移量需要额外的开销,这有点儿类似生产者配置了 acks=all,但同一个消费者群组提交的偏移量会被发送给同一个broker,这可能会导致broker超载。提交频率需要在性能需求和重复消息量之间取得平衡。处理一条消息就提交一次偏移量的方式只适用于吞吐量非常低的主题。
在正确的时间点提交正确的偏移量
- 在轮询过程中提交偏移量有一个缺点,就是有可能会意外提交已读取但未处理的消息的偏移量。一定要在处理完消息后再提交偏移量,这点很关键------提交已读取但未处理的消息的偏移量会导致消费者错过消息。
再均衡
- 在设计应用程序时,需要考虑到消费者会发生再均衡并需要处理好它们。
消费者可能需要重试
- 有时候,在调用了轮询方法之后,有些消息需要稍后再处理,有两种模式来解决。
- 第一种模式,在遇到可重试错误时,提交最后一条处理成功的消息的偏移量,然后把还未处理好的消息保存到缓冲区(这样下一个轮询就不会把它们覆盖掉),并调用消费者的 pause() 方法,确保其他的轮询不会返回数据,之后继续处理缓冲区里的消息。