Apache Kafka-17-数据传输可靠性保证
数据可靠性
对于系统来说,可靠的数据传递不能成为马后炮。
与性能一样,在系统的设计之初就应该考虑可靠性问题,而不能在事后才来考虑。
而且,可靠性是系统的一个属性,而不是一个独立的组件,所以在讨论Kafka的可靠性保证时,还是要从系统的整体出发。
说到可靠性,那些与Kafka集成的系统与Kafka本身一样重要。正因为可靠性是系统层面的概念,所以它不只是某个个体的事情。Kafka管理员、Linux系统管理员、网络和存储管理员以及应用程序开发者,所有人必须协同作战,才能构建一个可靠的系统。
Kafka在数据传递可靠性方面具备很大的灵活性。
我们知道,Kafka可以被用在很多场景里,从跟踪用户点击动作到处理信用卡支付操作。有些场景要求很高的可靠性,而有些则更看重速度和简便性。Kafka被设计成高度可配置的,而且它的客户端API可以满足不同程度的可靠性需求。
不过,灵活性有时候也很容易让人掉人陷阱。有时候,你的系统看起来是可靠的,但实际上有可能不是。
主要内容
本章先讨论各种各样的可靠性及其在Kafka场景中的含义。
然后介绍Kafka的复制功能,以及它是如何提高系统可靠性的。
随后探讨如何配置Kafka的broker和主题来满足不同的使用场景需求,也会涉及生产者和消费者以及如何在各种可靠性场景里使用它们。
最后介绍如何验证系统的可靠性,因为系统的可靠性涉及方方面面,一些前提条件必须先得到满足。
可靠性保证
在讨论可靠性时,我们一般会使用保证这个词,它是指确保系统在各种不同的环境下能够发生一致的行为。
ACID
ACID大概是大家最熟悉的一个例子,它是关系型数据库普遍支持的标准可靠性保证。
ACID指的是原子性、一致性、隔离性和持久性。如果一个供应商说他们的数据库遵循ACID规范,其实就是在说他们的数据库支持与事务相关的行为。
有了这些保证,我们才能相信关系型数据库的事务特性可以确保应用程序的安全。
我们知道系统承诺可以做到些什么,也知道在不同条件下它们会发生怎样的行为。我们了解这些保证机制,并基于这些保证机制开发安全的应用程序。
所以,了解系统的保证机制对于构建可靠的应用程序来说至关重要,这也是能够在不同条件下解释系统行为的前提。
kafka 的保证
那么Kafka可以在哪些方面作出保证呢?
(1)Kafka可以保证分区消息的顺序。
如果使用同一个生产者往同一个分区写入消息,而且消息B在消息A之后写入,那么Kafka可以保证消息B的偏移量比消息A的偏移量大,而且消费者会先读取消息A再读取消息B。
(2)只有当消息被写入分区的所有同步副本时(但不一定要写入磁盘),它才被认为是“已提交”的。生产者可以选择接收不同类型的确认,比如在消息被完全提交时的确认,或者在消息被写入首领副本时的确认,或者在消息被发送到网络时的确认。
(3)只要还有一个副本是活跃的,那么已经提交的消息就不会丢失。
(4)消费者只能读取已经提交的消息。
这些基本的保证机制可以用来构建可靠的系统,但仅仅依赖它们是无法保证系统完全可靠的。
构建一个可靠的系统需要作出一些权衡,Kafka管理员和开发者可以在配置参数上作出权衡,从而得到他们想要达到的可靠性。
这种权衡一般是指消息存储的可靠性和一致性的重要程度与可用性、高吞吐量、低延迟和硬件成本的重要程度之间的权衡。
下面将介绍Kafka的复制机制,并探讨Kafka是如何实现可靠性的,最后介绍一些重要的配置参数。
复制
Kafka的复制机制和分区的多副本架构是Kafka可靠性保证的核心。
把消息写入多个副本可以使Kafka在发生崩溃时仍能保证消息的持久性。
我们已经在第5章深入解释了Kafka的复制机制,现在重新回顾一下主要内容。
复制机制
Kafka的主题被分为多个分区,分区是基本的数据块。分区存储在单个磁盘上,Kafka可以保证分区里的事件是有序的,分区可以在线(可用),也可以离线(不可用)。每个分区可以有多个副本,其中一个副本是首领。所有的事件都直接发送给首领副本,或者直接从首领副本读取事件。其他副本只需要与首领保持同步,并及时复制最新的事件。当首领副本不可用时,其中一个同步副本将成为新首领。
分区首领是同步副本,而对于跟随者副本来说,它需要满足以下条件才能被认为是同步的。与Zookeeper之间有一个活跃的会话,也就是说,它在过去的6s(可配置)内向Zookeeper发送过心跳。
在过去的10s内(可配置)从首领那里获取过消息。
在过去的10s内从首领那里获取过最新的消息。光从首领那里获取消息是不够的,它还必须是几乎零延迟的。
如果跟随者副本不能满足以上任何一点,比如与Zookeeper断开连接,或者不再获取新消息,或者获取消息滞后了10s以上,那么它就被认为是不同步的。一个不同步的副本通过与Zookeeper重新建立连接,并从首领那里获取最新消息,可以重新变成同步的。
这个过程在网络出现临时间题并很快得到修复的情况下会很快完成,但如果broker发生崩溃就需要较长的时间。
非同步副本
如果一个或多个副本在同步和非同步状态之间快速切换,说明集群内部出现、了问题,通常是Java不恰当的垃圾回收配置导致的。
不恰当的垃圾回收配置会造成几秒钟的停顿,从而让broker与Zookeeper之间断开连接,最后变成不同步的,进而发生状态切换。
一个滞后的同步副本会导致生产者和消费者变慢,因为在消息被认为已提交之前,客户端会等待所有同步副本接收消息
。而如果一个副本不再同步了,我们就不再关心它是否已经收到消息。
虽然非同步副本同样滞后,但它并不会对性能产生任何影响。
但是,更少的同步副本意味着更低的有效复制系数,在发生宕机时丢失数据的风险更大。
我们将在下一节讲解在实际项目中这将意味着什么。
broker 配置
broker 有3个配置参数会影响Kafka消息存储的可靠性。
与其他配置参数一样,它们可以应用在broker级别,用于控制所有主题的行为,也可以应用在主题级别,用于控制个别主题的行为。
在主题级别控制可靠性,意味着Kafka集群可以同时拥有可靠的主题和非可靠的主题。
例如,在银行里,管理员可能把整个集群设置为可靠的,但把其中的一个主题设置为非可靠的,用于保存来自客户的投诉,因为这些消息是允许丢失的。
让我们来逐个介绍这些配置参数,看看它们如何影响消息存储的可靠性,以及Kafka在哪些方面作出了权衡。
复制系数
主题级别的配置参数是 replication.factor
,而在broker级别则可以通过 default.replication.factor
来配置自动创建的主题。
在这本书里,我们假设主题的复制系数都是3,也就是说每个分区总共会被3个不同的broker复制3次。
这样的假设是合理的,因为Kafka的默认复制系数就是3——不过用户可以修改它。即使是在主题创建之后,也可以通过新增或移除副本来改变复制系数。
如果复制系数为N,那么在N-1个broker失效的情况下,仍然能够从主题读取数据或向主题写入数据。
所以,更高的复制系数会带来更高的可用性、可靠性和更少的故障。
另一方面,复制系数N需要至少N个broker,而且会有N个数据副本,也就是说它们会占用N倍的磁盘空间。我们一般会在可用性和存储硬件之间作出权衡。
那么该如何确定一个主题需要几个副本呢?这要看主题的重要程度,以及你愿意付出多少成本来换取可用性。有时候这与你的偏执程度也有点关系。
如果因broker重启导致的主题不可用是可接受的(这在集群里是很正常的行为),那么把复制系数设为1就可以了。
在作出这个权衡的时候,要确保这样不会对你的组织和用户造成影响,因为你在节省了硬件成本的同时也降低了可用性。
复制系数为2意味着可以容忍1个broker发生失效,看起来已经足够了。不过要记住,有时候1个broker发生失效会导致集群不稳定(通常是旧版的Kafka),迫使你重启另一个broker—集群控制器。也就是说,如果将复制系数设为2,就有可能因为重启等问题导致集群不可用。所以这是一个两难的选择。
基于以上几点原因,我们建议在要求可用性的场景里把复制系数设为3.在大多数情况下,这已经足够安全了——不过我们也见过有些银行使用5个副本,以防不测。
副本的分布也很重要。
默认情况下,Kafka会确保分区的每个副本被放在不同的broker上。不过,有时候这样仍然不够安全。如果这些broker处于同一个机架上,一旦机架的交换机发生故障,分区就会不可用,这时候把复制系数设为多少都不管用。为了避免机架级别的故障,我们建议把broker分布在多个不同的机架上,并使用 broker.rack
参数来为每个broker配置所在机架的名字。如果配置了机架名字,Kafka会保证分区的副本被分布在多个机架上,从而获得更高的可用性。
我们已经在第5章介绍了如何在broker和机架上分布副本,如果你对此感兴趣,可以参考第5章的内容。
不完全的首领选举
unclean.leader.election
只能在 broker 级别进行配置,其默认值是 true。
我们之前提到过,当分区首领不可用时,一个同步副本会被选为新首领。
如果在选举过程中没有丢失数据,也就是说提交的数据同时存在于所有的同步副本上,那么这个选举就是“完全”的。
但如果在首领不可用时其他副本都是不同步的,我们该怎么办呢?
这种情况会在以下两种场景里出现。
(1)分区有3个副本,其中的两个跟随者副本不可用(比如有两个broker发生崩溃)。这个时候,如果生产者继续往首领写入数据,所有消息都会得到确认并被提交(因为此时首领是唯一的同步副本)。现在我们假设首领也不可用了(又一个broker发生崩渍),这个时候,如果之前的一个跟随者重新启动,它就成为了分区的唯一不同步副本。
(2)分区有3个副本,因为网络问题导致两个跟随者副本复制消息滞后,所以尽管它们还在复制消息,但已经不同步了。首领作为唯一的同步副本继续接收消息。这个时候,如果首领变为不可用,另外两个副本就再也无法变成同步的了。
对于这两种场景,我们要作出一个两难的选择。
(1)如果不同步的副本不能被提升为新首领,那么分区在旧首领(最后一个同步副本)恢复之前是不可用的。有时候这种状态会持续数小时(比如更换内存芯片)。
(2)如果不同步的副本可以被提升为新首领,那么在这个副本变为不同步之后写入旧首领的消息会全部丢失,导致数据不一致。
为什么会这样呢?
假设在副本0和副本1不可用时,偏移量100-200的消息被写入副本2(首领)。现在副本2变为不可用的,而副本0变为可用的。副本0只包含偏移量0100的消息,不包含偏移量100200的消息。如果我们允许副本0成为新首领,生产者就可以继续写入数据,消费者可以继续读取数据。于是,新首领就有了偏移量100-200的新消息。
这样,部分消费者会读取到偏移量100-200的旧消息,部分消费者会读取到偏移量100~200的新消息,还有部分消费者读取的是二者的混合。这样会导致非常不好的结果,比如生成不准确的报表。另外,副本2可能会重新变为可用、并成为新首领的跟随者。
这个时候,它会把比当前首领旧的消息全部删除,而这些消息对于所有消费者来说都是不可用的。
简而言之,如果我们允许不同步的副本成为首领,那么就要承担丢失数据和出现数据不一致的风险。
如果不允许它们成为首领,那么就要接受较低的可用性,因为我们必须等待原先的首领恢复到可用状态。
如果把 unclean.leader.election.enable
设置为 true, 就是允许不同步的副本成为首领(也就是“不完全的选举),那么我们将面临丢失消息的风险。
如果把这个参数设为false,就要等待原先的首领重新上线,从而降低了可用性。
我们经常看到一些对数据质量和数据一致性要求较高的系统会禁用这种不完全的首领选举(把这个参数设为false)。
银行系统是这方面最好的例子,大部分银行系统宁愿选择在几分钟甚至几个小时内不处理信用卡支付事务,也不会冒险处理错误的消息,不过在对可用性要求较高的系统里,比如实时点击流分析系统,一般会启用不完全的首领选举。
最少同步副本
在主题级别和broker级别上,这个参数都叫 min.insync.replicas
。
我们知道,尽管为一个主题配置了3个副本,还是会出现只有一个同步副本的情况。
如果这个同步副本变为不可用,我们必须在可用性和一致性之间作出选择—这是一个两难的选择。
根据Kafka对可靠性保证的定义,消息只有在被写入到所有同步副本之后才被认为是已提交的。但如果这里的“所有副本”只包含一个同步副本,那么在这个副本变为不可用时,数据就会丢失。
如果要确保已提交的数据被写入不止一个副本,就需要把最少同步副本数量设置为大一点的值,对于一个包含3个副本的主题,如果 min.insync.replicas
被设为2,那么至少要存在两个同步副本才能向分区写入数据。
如果3个副本都是同步的,或者其中一个副本变为不可用,都不会有什么问题。
不过,如果有两个副本变为不可用,那么broker就会停止接受生产者的请求。尝试发送数据的生产者会收到NotEnoughReplicasException异常。
消费者仍然可以继续读取已有的数据。实际上,如果使用这样的配置,那么当只剩下一个同步副本时,它就变成只读了,这是为了避免在发生不完全选举时数据的写入和读取出现非预期的行为。
为了从只读状态中恢复,必须让两个不可用分区中的一个重新变为可用的(比如重启broker),并等待它变为同步的。
在可靠的系统里使用生产者
即使我们尽可能把broker配置得很可靠,但如果没有对生产者进行可靠性方面的配置,整个系统仍然有可能出现突发性的数据丢失。
例子
请看以下两个例子。
例子 1
为broker配置了3个副本,并且禁用了不完全首领选举,这样应该可以保证万无一失。
我们把生产者发送消息的acks设为1(只要首领接收到消息就可以认为消息写入成功)。生产者发送一个消息给首领,首领成功写入,但跟随者副本还没有接收到这个消息。
首领向生产者发送了一个响应,告诉它“消息写入成功”,然后它崩溃了,而此时消息还没有被其他副本复制过去。另外两个副本此时仍然被认为是同步的(毕竟判定一个副本不同步需要一小段时间),而且其中的一个副本成了新的首领。因为消息还没有被写入这个副本,所以就丢失了,但发送消息的客户端却认为消息已成功写入。
因为消费者看不到丢失的消息,所以此时的系统仍然是一致的(因为副本没有收到这个消息,所以消息不算已提交),但从生产者角度来看,它丢失了一个消息。
例子 2
为broker配置了3个副本,并且禁用了不完全首领选举。
我们接受了之前的教训,把生产者的acks设为all。
假设现在往Kafka发送消息,分区的首领刚好崩溃,新的首领正在选举当中,Kafka会向生产者返回“首领不可用”的响应。在这个时候,如果生产者没能正确处理这个错误,也没有重试发送消息直到发送成功,那么消息也有可能丢失。
这算不上是broker的可靠性问题,因为broker并没有收到这个消息。这也不是一致性问题,因为消费者并没有读到这个消息。问题在于如果生产者没能正确处理这些错误,弄丢消息的是它们自己。
那么,我们该如何避免这些悲剧性的后果呢?
从上面两个例子可以看出,每个使用Kafka的开发人员都要注意两件事情。
(1)根据可靠性需求配置恰当的acks值。
(2)在参数配置和代码里正确处理错误。
第3章已经深入讨论了生产者的几种模式,现在回顾几个要点。
发送确认
生产者可以选择以下3种不同的确认模式。
acks=0
acks=0 意味着如果生产者能够通过网络把消息发送出去,那么就认为消息已成功写入Kafka。
在这种情况下还是有可能发生错误,比如发送的对象无法被序列化或者网卡发生故障,但如果是分区离线或整个集群长时间不可用,那就不会收到任何错误。
即使是在发生完全首领选举的情况下,这种模式仍然会丢失消息,因为在新首领选举过程中它并不知道首领已经不可用了。
在acks=0模式下的运行速度是非常快的(这就是为什么很多基准测试都是基于这个模式),你可以得到惊人的吞吐量和带宽利用率,不过如果选择了这种模式,一定会丢失一些消息。
acks=1
acks=1意味着首领在收到消息并把它写入到分区数据文件(不一定同步到磁盘上)时会返回确认或错误响应。
在这个模式下,如果发生正常的首领选举,生产者会在选举时就会收到 LeaderNotAvailableExceptton。如何生产者可以正确处理这个错误,它会重试发送消息,最终消息会安全到达新的首领那里。
不过在这个模式下仍然有可能丢失数据,比如消息已经成功写入首领,但在消息被复制到跟随者副本之前首领发生崩清。
acks=all
acks=all 意味着首领在返回确认或错误响应之前,会等待所有同步副本都收到消息。
如果和 min.insync.replicas
参数结合起来,就可以决定在返回确认前至少有多少个副本能够收到消息。
这是最保险的做法―生产者会一直重试直到消息被成功提交。
不过这也是最慢的做法,生产者在继续发送其他消息之前需要等待所有副本都收到当前的消息。
可以通过使用异步模式和更大的批次来加快速度,但这样做通常会降低吞吐量。
配置生产者的重试参数
生产者需要处理的错误包括两部分:一部分是生产者可以自动处理的错误,还有一部分是需要开发者手动处理的错误。
如果broker返回的错误可以通过重试来解决,那么生产者会自动处理这些错误。
生产者向broker发送消息时,broker可以返回一个成功响应码或者一个错误响应码。
错误响应码可以分为两种,一种是在重试之后可以解决的,还有一种是无法通过重试解决的。
例如,如果broker返回的是 LEADER_NOT_AVAILABLE 错误,生产者可以尝试重新发送消息。也许在这个时候一个新的首领被选举出来了,那么这次发送就会成功。也就是说,LEADER_NOT_AVAILABLE 是一个可重试错误。
另一方面,如果broker返回的是INVALID_CONFIG错误,即使通过重试也无法改变配置选项,所以这样的重试是没有意义的。这种错误是不可重试错误。
一般情况下,如果你的目标是不丢失任何消息,那么最好让生产者在遇到可重试错误时能够保持重试。
为什么要这样?
因为像首领选举或网络连接这类问题都可以在几秒钟之内得到解决,如果让生产者保持重试,你就不需要额外去处理这些问题了。
经常会有人问:“为生产者配置多少重试次数比较好?”这个要看你在生产者放弃重试并抛出异常之后想做些什么,如果你想抓住异常并再多重试几次,那么就可以把重试次数设置得多一点,让生产者继续重试;如果你想直接丢弃消息,多次重试造成的延迟已经失去发送消息的意义;如果你想把消息保存到某个地方然后回过头来再继续处理,那就可以停止重试。
Kafka的跨数据中心复制工具(MirrorMaker,我们将在第8章介绍)默认会进行无限制的重试(例如retrtes=MAX_INT)。
作为一个具有高可靠性的复制工具,它决不会丢失消息。
要注意,重试发送一个已经失败的消息会带来一些风险,如果两个消息都写入成功,会导致消息重复。
例如,生产者因为网络问题没有收到broker的确认,但实际上消息已经写入成功,生产者会认为网络出现了临时故障,就重试发送该消息(因为它不知道消息已经写入成功)。
在这种情况下,broker会收到两个相同的消息。
重试和恰当的错误处理可以保证每个消息“至少被保存一次”,但当前的Kafka版本(0.10.0)无法保证每个消息“只被保存一次”。
现实中的很多应用程序在消息里加入唯一标识符,用于检测重复消息,消费者在读取消息时可以对它们进行清理。
还要一些应用程序可以做到消息的“幂等”,也就是说,即使出现了重复消息,也不会对处理结果的正确性造成负面影响。
例如,消息“这个账号里有110美元”就是幂等的,因为即使多次发送这样的消息,产生的结果都是一样的。
不过消息“往这个账号里增加10美元”就不是幂等的。
额外的错误处理
使用生产者内置的重试机制可以在不造成消息丢失的情况下轻松地处理大部分错误,不过对于开发人员来说,仍然需要处理其他类型的错误,包括:
不可重试的broker错误,例如消息大小错误、认证错误等;
在消息发送之前发生的错误,例如序列化错误:
在生产者达到重试次数上限时或者在消息占用的内存达到上限时发生的错误。
我们在第3章讨论了如何为同步发送消息和异步发送消息编写错误处理器。
这些错误处理器的代码逻辑与具体的应用程序及其目标有关。丢弃“不合法的消息”?把错误记录下来?把这些消息保存在本地磁盘上?回调另一个应用程序?具体使用哪一种逻辑要根据具体的架构来决定。
只要记住,如果错误处理只是为了重试发送消息,那么最好还是使用生产者内置的重试机制。
在可靠的系统里使用消费者
我们已经学习了如何在保证Kafka可靠性的前提下生产数据,现在来看看如何在同样的前提下读取数据。
在本章的开始部分可以看到,只有那些被提交到Kafka的数据,也就是那些已经被写人所有同步副本的数据,对消费者是可用的,这意味着消费者得到的消息已经具备了一致性。
消费者唯一要做的是跟踪哪些消息是已经读取过的,哪些是还没有读取过的。
这是在读取消息时不丢失消息的关键。
在从分区读取数据时,消费者会获取一批事件,检查这批事件里最大的偏移量,然后从这个偏移量开始读取另外一批事件。这样可以保证消费者总能以正确的顺序获取新数据,不会错过任何事件。
如果一个消费者退出,另一个消费者需要知道从什么地方开始继续处理,它需要知道前一个消费者在退出前处理的最后一个偏移量是多少。所谓的“另一个”消费者,也可能就是它自己重启之后重新回来工作。
这也就是为什么消费者要“提交”它们的偏移量。
它们把当前读取的偏移量保存起来,在退出之后,同一个群组里的其他消费者就可以接手它们的工作。
如果消费者提交了偏移量却未能处理完消息,那么就有可能造成消息丢失,这也是消费者丢失消息的主要原因。
在这种情况下,如果其他消费者接手了工作,那些没有被处理完的消息就会被忽略,永远得不到处理。这就是为什么我们非常重视偏移量提交的时间点和提交的方式。
已提交消息与已提交偏移量
要注意,此处的已提交消息与之前讨论过的已提交消息是不一样的,它是指已经被写人所有同步副本井且对消费者可见的消息,而已提交偏移量是指消费者发送给Kafka的偏移量,用于确认它已经收到并处理好的消息位置,
我们在第4章已经详细介绍了消费者API的使用,还介绍了多种提交偏移量的方式。
下面会介绍一些关键的注意事项,如果要了解消费者API的使用细节,请参考第4章。
消费者的可靠性配置
为了保证消费者行为的可靠性,需要注意以下4个非常重要的配置参数。
第1个是 group.id
。这个参数在第4章已经详细解释过了,如果两个消费者具有相同的group.id,并且订阅了同一个主题,那么每个消费者会分到主题分区的一个子集,也就是说它们只能读到所有消息的一个子集(不过群组会读取主题所有的消息)。如果你希望消费者可以看到主题的所有消息,那么需要为它们设置唯一的group.id。
第2个是 auto.offset.reset
。这个参数指定了在没有偏移量可提交时(比如消费者第1次启动时)或者请求的偏移量在broker上不存在时(第4章已经解释过这种场景),消费者会做些什么。这个参数有两种配置。一种是earLiest,如果选择了这种配置,消费者会从分区的开始位置读取数据,不管偏移量是否有效,这样会导致消费者读取大量的重复数据,但可以保证最少的数据丢失。一种是latest,如果选择了这种配置,消费者会从分区的末尾开始读取数据,这样可以减少重复处理消息,但很有可能会错过一些消息。
第3个是 enable.auto.commit
。这是一个非常重要的配置参数,你可以让消费者基于任务调度自动提交偏移量,也可以在代码里手动提交偏移量。自动提交的一个最大好处是,在实现消费者逻辑时可以少考虑一些问题。如果你在消费者轮询操作里处理所有的数据,那么自动提交可以保证只提交已经处理过的偏移量(如果忘了消费者轮询是什么,请回顾一下第4章的内容),自动提交的主要缺点是,无法控制重复处理消息(比如消费者在自动提交偏移量之前停止处理消息),而且如果把消息交给另外一个后台线程去处理,自动提交机制可能会在消息还没有处理完毕就提交偏移量。
第4个配置参数 auto.commit.interval.ms
与第3个参数有直接的联系。如果选择了自动提交偏移量,可以通过该参数配置提交的频度,默认值是每5秒钟提交一次。一般来说,频繁提交会增加额外的开销,但也会降低重复处理消息的概率。
显式提交偏移量
如果选择了自动提交偏移量,就不需要关心显式提交的问题。
不过如果希望能够更多地控制偏移量提交的时间点,那么就要仔细想想该如何提交偏移量了——要么是为了减少重复处理消息,要么是因为把消息处理逻辑放在了轮询之外。
这里我们不再重复说明这个机制以及如何使用相关的API,因为第4章里已经有很详细的介绍。相反,我们会着重说明几个在开发具有可靠性的消费者应用程序时需要注意的事项。
我们先从简单的开始,再逐步深入。
(1)1.总是在处理完事件后再提交偏移量
如果所有的处理都是在轮询里完成,并且不需要在轮询之间维护状态(比如为了实现聚合操作),那么可以使用自动提交,或者在轮询结束时进行手动提交。
(2)2.提交频度是性能和重复消息数量之间的权衡
即使是在最简单的场景里,比如所有的处理都在轮询里完成,并且不需要在轮询之间维护状态,你仍然可以在一个循环里多次提交偏移量(甚至可以在每处理完一个事件之后),或者多个循环里只提交一次(与生产者的acks=all配置有点类似),这完全取决于你在性能和重复处理消息之间作出的权衡。
(3)3.确保对提交的偏移量心里有数
在轮询过程中提交偏移量有一个不好的地方,就是提交的偏移量有可能是读取到的最新偏移量,而不是处理过的最新偏移量。要记住,在处理完消息后再提交偏移量是非常关键的——否则会导致消费者错过消息。我们已经在第4章给出了示例。
(4)4.再均衡
在设计应用程序时要注意处理消费者的再均衡问题。我们在第4章举了几个例子,一般要在分区被撤销之前提交偏移量,并在分配到新分区时清理之前的状态。
(5)5.消费者可能需要重试
有时候,在进行轮询之后,有些消息不会被完全处理,你想稍后再来处理。
例如,假设要把Kafka的数据写到数据库里,不过那个时候数据库不可用,于是你想稍后重试。要注意,你提交的是偏移量,而不是对消息的“确认”,这个与传统的发布和订阅消息系统不太一样。如果记录#30处理失败,但记录#31处理成功,那么你不应该提交#31,否则会导致#31以内的偏移量都被提交,包括#30在内,而这可能不是你想看到的结果。不过可以采用以下两种模式来解决这个问题。
第一种模式,在遇到可重试错误时,提交最后一个处理成功的偏移量,然后把还没有处理好的消息保存到缓冲区里(这样下一个轮询就不会把它们覆盖掉),调用消费者的pause()方法来确保其他的轮询不会返回数据(不需要担心在重试时缓冲区溢出),在保持轮询的同时尝试重新处理(关于为什么不能停止轮询,请参考第4章)。如果重试成功,或者重试次数达到上限并决定放弃,那么把错误记录下来并丢弃消息,然后调用resune()方法让消费者继续从轮询里获取新数据。
第二种模式,在遇到可重试错误时,把错误写入一个独立的主题,然后继续。一个独立的消费者群组负责从该主题上读取错误消息,并进行重试,或者使用其中的一个消费者同时从该主题上读取错误消息并进行重试,不过在重试时需要暂停该主题。这种模式有点像其他消息系统里的dead-letter-queue。
(6)6.消费者可能需要维护状态
有时候你希望在多个轮询之间维护状态,例如,你想计算消息的移动平均数,希望在首次轮询之后计算平均数,然后在后续的轮询中更新这个结果。
如果进程重启,你不仅需要从上一个偏移量开始处理数据,还要恢复移动平均数。
有一种办法是在提交偏移量的同时把最近计算的平均数写到一个“结果”主题上。
消费者线程在重新启动之后,它就可以拿到最近的平均数并接着计算。不过这并不能完全地解决问题,因为Kafka并没有提供事务支持。消费者有可能在写入平均数之后来不及提交偏移量就崩溃了,或者反过来也一样。.
这是一个很复杂的问题,你不应该尝试自己去解决这个问题,建议尝试一下KafkaStreams这个类库,它为聚合、连接、时间窗和其他复杂的分析提供了高级的DSLAPI。
(7)7.长时间处理
有时候处理数据需要很长时间:你可能会从发生阻塞的外部系统获取信息,或者把数据写到外部系统,或者进行一个非常复杂的计算。
要记住,暂停轮询的时间不能超过几秒钟,即使不想获取更多的数据,也要保持轮询,这样客户端才能往broker发送心跳。
在这种情况下,一种常见的做法是使用一个线程池来处理数据,因为使用多个线程可以进行并行处理,从而加快处理速度。
在把数据移交给线程池去处理之后,你就可以暂停消费者,然后保持轮询,但不获取新数据,直到工作线程处理完成。在工作线程处理完成之后,可以让消费者继续获取新数据。
因为消费者一直保持轮询,心跳会正常发送,就不会发生再均衡。
(8)8.仅一次传递
有些应用程序不仅仅需要“至少一次”(at-least-once)语义(意味着没有数据丢失),还需要“仅一次”(exactly-once)语义。尽管Kafka现在还不能完全支持仅一次语义,消费者还是有一些办法可以保证Kafka里的每个消息只被写到外部系统一次(但不会处理向Kafka写人数据时可能出现的重复数据)。
实现仅一次处理最简单且最常用的办法是把结果写到一个支持唯一键的系统里,比如键值存储引擎、关系型数据库、ElasticSearch或其他数据存储引擎。在这种情况下,要么消息本身包含一个唯一键(通常都是这样),要么使用主题、分区和偏移量的组合来创建唯一键——它们的组合可以唯一标识一个Kafka记录。如果你把消息和一个唯一键写人系统,然后碰巧又读到一个相同的消息,只要把原先的键值覆盖掉即可。数据存储引擎会覆盖已经存在的键值对,就像没有出现过重复数据一样。这个模式被叫作幂等性写入,它是一种很常见也很有用的模式。
如果写入消息的系统支持事务,那么就可以使用另一种方法。最简单的是使用关系型数据库,不过HDFS里有一些被重新定义过的原子操作也经常用来达到相同的目的。
我们把消息和偏移量放在同一个事务里,这样它们就能保持同步。在消费者启动时,它会获取最近处理过的消息偏移量,然后调用seek()方法从该偏移量位置继续读取数据。
我们在第4章已经介绍了一个相关的例子。
验证系统可靠性
你经过了所有的流程,从确认可靠性需求,到配置broker,再到配置客户端,并小心谨慎地使用API…现在可以把所有东西都放到生产环境里去运行,然后高枕无忧,自信不会丢失任何消息了,对吗?
你当然可以这么做,不过建议还是先对系统可靠性做一些验证。
我们建议做3个层面的验证——配置验证、应用程序验证以及生产环境的应用程序监控。
让我们来看看每一步都要做些什么以及该怎么做。
配置验证
从应用程序里可以很容易对broker和客户端配置进行验证,我们之所以建议这么做,有以下两方面的原因。
(1)验证配置是否满足你的需求。
(2)帮助你理解系统的行为,了解系统的真正行为是什么,了解你对Kafka基本准则的理解是否存在偏差,然后加以改进,同时了解这些准则是如何被应用到各种场景里的。
这一章的内容偏重理论,所以要确保你能够理解这些理论是如何运用于实际当中的。
Kafka提供了两个重要的工具用于验证配置:org.apache.kafka.tools包里的VerifiableProducer和VerifiableConsurer这两个类。我们可以从命令行运行这两个类,或者把它们嵌入到自动化测试框架里。
其思想是,VerifiableProducer生成一系列消息,这些消息包含从1到你指定的某个数字。你可以使用与生产者相同的方式来配置VerifiableProducer,比如配置相同的acks、重试次数和消息生成速度。在运行VerifiableProducer时,它会把每个消息是否成功发送到broker的结果打印出来。
VerifiableConsuner执行的是另一个检查——它读取事件(由VerifiableProducer生成)并按顺序打印出这些事件。它也会打印出已提交的偏移量和再均衡的相关信息。
测试场景
你可以考虑运行以下一些测试。
首领选举:如果我停掉首领会发生什么事情?生产者和消费者重新恢复正常状态需要多长时间?
控制器选举:重启控制器后系统需要多少时间来恢复状态?
依次重启:可以依次重启broker而不丢失任何数据吗?
不完全首领选举测试:如果依次停止所有副本(确保每个副本都变为不同步的),然后启动一个不同步的broker会发生什么?要怎样恢复正常?这样做是可接受的吗?
然后你从中选择一个场景,启动VerifiabLeProducer和VerifiableConsuner并开始测试这个场景,例如,停掉正在接收消息的分区首领。如果期望在一个短暂的暂停之后状态恢复正常并且没有任何数据丢失,那么只要确保生产者生成的数据个数与消费者读取的数据个数是匹配的就可以了。
Kafka的代码库里包含了大量测试用例。
它们大部分都遵循相同的准则——使用 Verifiable Producer 和 VertfiableConsumer 来确保迭代的版本可以正常工作。
应用程序验证
在确定broker和客户端的配置可以满足你的需求之后,接下来要验证应用程序是否能够保证达到你的期望。
应用程序的验证包括检查自定义的错误处理代码、偏移量提交的方式、再均衡监听器以及其他使用了Kafka客户端的地方。
因为应用程序是你自己的,关于如何测试应用程序的逻辑,我们无法提供更多的指导,但愿你的开发流程里已经包含了集成测试。
故障条件
不管如何验证你的应用程序,我们都建议基于如下的故障条件做一些测试:
客户端从服务器断开连接(系统管理员可以帮忙模拟网络故障);
首领选举;
依次重启broker;
依次重启消费者:
依次重启生产者。
你对每一个测试场景都会有期望的行为,也就是在开发应用程序时所期望看到的行为,然后运行测试看看真实的结果是否符合预期。
例如,在测试“依次重启消费者”这一场景时,你期望看到“在短暂的再均衡之后出现的重复消息个数不超过1000个”。
测试结果会告诉我们应用程序提交偏移量的方式和处理再均衡的方式是否与预期的一样。
在生产环境监控可靠性
测试应用程序是很重要的,不过它无法代替生产环境的持续监控,这些监控是为了确保数据按照期望的方式流动。
我们将会在第9章详细介绍如何监控Kafka集群,不过除了监控集群的健康状况之外,监控客户端和数据流也是很重要的。
首先,Kafka的Java客户端包含了JMX度量指标,这些指标可以用于监控客户端的状态和事件,对于生产者来说,最重要的两个可靠性指标是消息的error-rate和retry-rate(聚合过的)。如果这两个指标上升,说明系统出现了问题。
如果你看到消息剩余的重试次数为0,说明生产者已经没有多余的重试机会。
就像我们在6.4节所讨论的那样,你也许可以增加重试次数,或者把造成这个错误的问题先解决掉,
对于消费者来说,最重要的指标是consumer-lag,该指标表明了消费者的处理速度与最近提交到分区里的偏移量之间还有多少差距。
理想情况下,该指标总是为0,消费者总能读到最新的消息,不过在实际当中,因为poll()方法会返回很多消息,消费者在获取更多数据之前需要花一些时间来处理它们,所以该指标会有些波动。关键是要确保消费者最终会赶上去,而不是越落越远。因为该指标会正常波动,所以在告警系统里配置该指标有一定难度。Burrow是LinkedIn公司开发的一个consumer-lag检测工具,它可以让这件事情变得容易一些。
监控数据流是为了确保所有生成的数据会被及时地读取(你的需求决定了“及时”的具体含义)。为了确保数据能够被及时读取,你需要知道数据是什么时候生成的。0.10.0版本的Kafka在消息里增加了时间戳,表明了消息的生成时间。如果你使用的是更早版本的客户端,我们建议自己在消息里加人时间戮、应用程序的名字和机器名,这样有助于将来诊断问题。
为了确保所有消息能够在合理的时间内被读取,应用程序需要记录生成消息的数量(一般用每秒多少个消息来表示),而消费者需要记录已读取消息的数量(也用每秒多少个消息来表示)以及消息生成时间(生成消息的时间)到当前时间(读取消息的时间)之间的时间差。
然后,你需要使用工具来比较生产者和消费者记录的消息数量(为了确保没有丢失消息),确保这两者之间的时间差不会超出我们允许的范围。
为了做到更好的监控,我们可以增加一个“监控消费者”,这个消费者订阅一个特别的主题,它只进行消息的计数操作,并把数值与生成的消息数量进行对比,这样我们就可以在没有消费者的情况下仍然能够准确地监控生产者。
这种端到端的监控系统实现起来很耗费时间,具有一定挑战性。据我们所知,目前还没有开源的实现。
Confluent提供了一个商业的实现版本,它是ConfluentControlCenter的一部分,
总结
正如我们在本章开头所说的,可靠性并不只是Kafka单方面的事情。
我们应该从整个系统层面来考虑可靠性问题,包括应用程序的架构、生产者和消费者API的使用方式、生产者和消费者的配置、主题的配置以及broker的配置。
系统的可靠性需要在许多方面作出权衡,比如复杂性、性能、可用性和磁盘空间的使用。
掌握Kafka的各种配置和常用模式,对使用场景的需求做到心中有数,你就可以在应用程序和Kafka的可靠性程度以及各种权衡之间作出更好的选择。
参考资料
《kafka 权威指南》