生产者是线程安全的,跨线程共享单个生成器实例通常比拥有多个实例更快。
生成器包括一个缓冲空间池,其中包含尚未传输到服务器的记录,以及一个后台I/O线程,后者负责将这些记录转换为请求并将它们传输到集群。使用后未能关闭生成器将
泄漏这些资源。
send()方法是异步的。 调用时,它会将记录添加到待处理记录发送的缓冲区中并立即返回。 这允许生产者将各个记录一起批处理以提高效率。
生产者为每个分区维护未发送记录的缓冲区。这些缓冲区的大小由batch.size配置指定。使这个更大可以导致更多的批处理,但需要更多的内存(因为通常每个活动分区
都有一个这样的缓冲区)。
默认情况下,即使缓冲区中有额外的未使用空间,也可以立即发送缓冲区。但是,如果要减少请求数量,可以将linger.ms设置为大于0的值。这将指示生产者在发送请求
之前等待该毫秒数,希望更多记录到达以填满同一批次。这类似于TCP中的Nagle算法。例如,在上面的代码片段中,由于我们将逗留时间设置为1毫秒,因此可能会在单个
请求中发送所有100条记录。但是,如果我们没有填满缓冲区,此设置会为我们的请求增加1毫秒的延迟,等待更多记录到达。请注意,即使在linger.ms = 0的情况下,
及时到达的记录通常也会一起批处理,因此在重负载情况下,无论延迟配置如何,都会发生批处理;但是,将此设置为大于0的值可以在不受最大负载影响的情况下会以少量
延迟为代价,导致更少、更高效的请求。
buffer.memory控制生产者可用于缓冲的总内存量。如果记录的发送速度快于传输到服务器的速度,则此缓冲区空间将耗尽。当缓冲区空间耗尽时,其他发送调用将被阻止。
阻塞时间的阈值由max.block.ms确定,之后它会抛出TimeoutException。
key.serializer和value.serializer指示如何将用户提供的键和值对象及其ProducerRecord转换为字节。您可以将包含的ByteArraySerializer或StringSerializer用于
简单的字符串或字节类型。
从Kafka 0.11开始,KafkaProducer支持另外两种模式:幂等生成器和事务生成器。幂等生成器将Kafka的传递语义从至少一次增强到
恰好一次。特别是生产者重试将不再引入重复。事务生成器允许应用程序以原子方式向多个分区(和主题!)发送消息。
enable.idempotence=true设置幂等生成器(需要注意的是生产者只能保证在单次会话中发送的消息具有幂等性,也就是单个分区,),幂等生成器不必
设置重试次数,因为默认为Intger.value,而且acks为all,如果一个send返回一个错误,即使是无限次重试(如果消息在发送之前在缓
冲区中过期),那么关闭生成器并检查最后生成的消息的内容,以确保它没有重复
Kafka是如何具体实现幂等的呢?
Kafka为此引入了producer id(以下简称PID)和序列号(sequence number)这两个概念。每个新的生产者实例在初始化的时候都会被分配一个PID,这个PID对用户而言是
完全透明的。对于每个PID,消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。生产者每发送一条消息就会将对应的序列号的值加1。
broker端会在内存中为每一对维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应的序列号的值(SN_old)大1
(即SN_new = SN_old + 1)时,broker才会接收它。
如果SN_new< SN_old + 1,那么说明消息被重复写入,broker可以直接将其丢弃。如果SN_new> SN_old + 1,那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息
丢失,这个异常是一个严重的异常。
引入序列号来实现幂等也只是针对每一对而言的,也就是说,Kafka的幂等只能保证单个生产者会话(session)中单分区的幂等。幂等性不能跨多个分区运作,而事务可以
弥补这个缺陷。
事务可以保证对多个分区写入操作的原子性。操作的原子性是指多个操作要么全部成功,要么全部失败,不存在部分成功、部分失败的可能。
要使用事务和相关api,你必须设置transactional.id,设置后将自动启用幂等性,并且事务中的主题复制因子应该设置为3,最小应该为2,此外消费者应该被配置为只读取
提交的消息
transactional.id的目的用于跨单个生产者实例的多个会话启用事务恢复,对于分区应用程序中运行的每个生成器实例,它应该是唯一的
所有新的事务API都是阻塞的,并且会在失败时抛出异常。
transactionalId与PID一一对应,两者之间所不同的是transactionalId由用户显式设置,而PID是由Kafka内部分配的。
另外,为了保证新的生产者启动后具有相同transactionalId的旧生产者能够立即失效,每个生产者通过transactionalId获取PID的同时,还会获取一个单调递增的producer
epoch。如果使用同一个transactionalId开启两个生产者,那么前一个开启的生产者会报错。
从生产者的角度分析,通过事务,Kafka可以保证跨生产者会话的消息幂等发送,以及跨生产者会话的事务恢复。
前者表示具有相同transactionalId的新生产者实例被创建且工作的时候,旧的且拥有相同transactionalId的生产者实例将不再工作。
后者指当某个生产者实例宕机后,新的生产者实例可以保证任何未完成的旧事务要么被提交(Commit),要么被中止(Abort),如此可以使新的生产者实例从一个正常的状
态开始工作。
KafkaProducer提供了5个与事务相关的方法,详细如下:
void initTransactions();
void beginTransaction() throws ProducerFencedException;
void sendOffsetsToTransaction(Map
String consumerGroupId)
throws ProducerFencedException;
void commitTransaction() throws ProducerFencedException;
void abortTransaction() throws ProducerFencedException;
import kafka.common.AuthorizationException;
import kafka.common.KafkaException;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.errors.OutOfOrderSequenceException;
import org.apache.kafka.common.errors.ProducerFencedException;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class Application {
public static void main(String[]args){
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
//设置事务id
props.put("transactional.id", "my-transactional-id");
KafkaProducer producer = new KafkaProducer(props, new StringSerializer(), new StringSerializer());
//
producer.initTransactions();
try {
//开启事务
producer.beginTransaction();
for (int i = 0; i < 100; i++)
producer.send(new ProducerRecord("my-topic", Integer.toString(i), Integer.toString(i)));
//提交事务
producer.commitTransaction();
//如果遭遇致命异常,就关闭生产者
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// We can't recover from these exceptions, so our only option is to close the producer and exit.
producer.close();
//中断事务
//如果某个producer.send()或事务调用在事务期间遇到不可恢复的错误,就会抛出KafkaException
} catch (KafkaException e) {
// For all other exceptions, just abort the transaction and try again.
producer.abortTransaction();
}
producer.close();
}`在这里插入代码片`
}
如示例中所暗示的,每个生产者只能有一个打开的事务。beginTransaction()和commitTransaction()调用之间发送的所有消息都将是单个事务的一部分。当
transactional.id指定后,生产者发送的所有消息都必须是事务的一部分。
事务生成器使用异常来通信错误状态。特别是,不需要为producer.send()指定回调,也不需要在返回的Future调用.get():如果某个producer.send()或事务调用在事务期间
遇到不可恢复的错误,就会抛出KafkaException。
通过在收到KafkaException时调用producer.abortTransaction(),我们可以确保将任何成功的写入标记为已中止,从而保留事务保证。
同一个事务ID,只有保证如下顺序epch小producer执行init-transaction和committransaction,然后epoch较大的procuder才能开始执行init-transaction和commit-transaction,如下顺序:
/**
* 在一个事务内,即有生产消息又有消费消息
*/
public void consumeTransferProduce() {
Properties props1=new Properties();
// 1.构建生产者
KafkaProducer producer = new KafkaProducer(props1, new StringSerializer(), new StringSerializer());
// 2.初始化事务(生成productId),对于一个生产者,只能执行一次初始化事务操作
producer.initTransactions();
// 3.构建消费者和订阅主题
Properties props2=new Properties();
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props2);
consumer.assign(Arrays.asList(new TopicPartition("",2)));
//consumer.subscribe(Arrays.asList("",""));
while (true) {
// 4.开启事务
producer.beginTransaction();
// 5.1 接受消息
ConsumerRecords<String, String> records = consumer.poll(500);
try {
// 5.2 do业务逻辑;
System.out.println("customer Message---");
Map<TopicPartition, OffsetAndMetadata> commits = Maps.newHashMap();
for (ConsumerRecord<String, String> record : records) {
// 5.2.1 读取消息,并处理消息。print the offset,key and value for the consumer records.
System.out.printf("offset = %d, key = %s, value = %s\n",
record.offset(), record.key(), record.value());
// 5.2.2 记录提交的偏移量
commits.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset()));
// 6.生产新的消息。比如外卖订单状态的消息,如果订单成功,则需要发送跟商家结转消息或者派送员的提成消息
producer.send(new ProducerRecord<String, String>("test", "data2"));
}
// 7.提交偏移量
producer.sendOffsetsToTransaction(commits, "group0323");
// 8.事务提交
producer.commitTransaction();
} catch (Exception e) {
// 7.放弃事务
producer.abortTransaction();
}
}
}
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
public class Application {
public static void main(String[]args){
Properties props = new Properties();
props.setProperty("bootstrap.servers", "localhost:9092");
props.setProperty("group.id", "test");
props.setProperty("enable.auto.commit", "false");
props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("foo", "bar"));
final int minBatchSize = 200;
List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
buffer.add(record);
}
if (buffer.size() >= minBatchSize) {
//insertIntoDb(buffer);
consumer.commitSync();
buffer.clear();
}
}
}
}
订阅一组主题之后,当调用poll(持续时间)时,消费者将自动加入组。poll API旨在确保消费者的活力。只要继续调用poll,使用者
就会留在组中,并继续从分配给它的分区接收消息。在幕后,消费者向服务器发送周期性的心跳。如果消费者在会话期间崩溃或无法发
送心跳。超时,消费者将被视为已死,其分区将被重新分配。
基本上,如果您不像配置的最大间隔那样频繁地调用poll,那么消费者将主动离开组,以便另一个消费者可以接管其分区。当发生这种
情况时,您可能会看到一个偏移提交失败(由调用commitSync()时抛出的CommitFailedException指示)。这是一种安全机制,它保证只有
组中的活跃成员才能提交偏移量。因此,要留在组中,必须继续调用poll。
消费者可以使用两个参数来控制轮询的行为
max.poll.interval.ms 给消费者更多的时间处理通过poll返回的一批记录,缺点是,增加这个值可能会延迟组重新平衡,因为消费者
只会在轮询调用中加入重新平衡。
max.poll.records 用此设置来限制从单个调用返回到轮询的总记录。这样可以更容易地预测每个轮询间隔内必须处理的最大值。通过
调优这个值,您可以减少轮询间隔,这将减少组再平衡的影响。
对于消息处理时间不可预测地变化的用例,处理这些情况的推荐方法是将消息处理转移到另一个线程,这允许消费者在处理器仍在工作
时继续调用poll。必须采取一些谨慎的措施,以确保提交偏移量不会超过实际情况。通常,只有在线程完成对记录的处理之后(取决于
需要的交付语义),您才必须禁用自动提交和手动提交已处理的偏移量。还请注意,您将需要暂停分区,以便在线程处理完返回的
记录之前,不会从poll接收新记录。
例子:我们将使用一批记录并在内存中进行批处理。当我们批量处理足够的记录时,我们将把它们插入数据库。如果我们允许偏移量自
动提交,那么在轮询中将这些记录返回给用户后,就会认为它们已经被消费了。然后,我们的进程可能在批量处理记录之后失败,但
是在它们被插入数据库之前就失败了。
为了避免这种情况,我们将只在将相应的记录插入数据库之后手动提交偏移量。这使我们能够精确地控制何时使用记录。这提出了相反
的可能性:进程可能在插入数据库之后的时间间隔内失败,但在提交之前就失败了(尽管这可能只有几毫秒,但也有可能)。在这种情况
下,接收消费的进程将从最后提交的偏移量中消费,并重复插入最后一批数据。以这种方式使用Kafka提供了通常称为“至少一次”的交
付保证,因为每条记录可能只交付一次,但是在失败的情况下可以复制。
使用手动偏移控制的优点是,您可以直接控制记录何时被视为“已使用”。
上面的示例使用commitSync将所有接收到的记录标记为已提交。在某些情况下,您可能希望通过显式指定偏移量来更好地控制哪些记录
已经提交。在下面的例子中,我们在处理完每个分区中的记录后提交偏移量。
while(true) {
ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
for (ConsumerRecord<String, String> record : partitionRecords) {
System.out.println(record.offset() + ": " + record.value());
}
long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));
}
}
如果流程本身具有高可用性,并且在失败时将重新启动,可能使用诸如yarn、Mesos或AWS工具之类的集群管理框架,或者作为流处理框
架的一部分,在这种情况下,kafka不需要检测故障并重新分配分区,因为消费进程将在另一台机器上重新启动。
要使用此模式,您只需调用assign(Collection)和您想要使用的分区的完整列表,而不是使用subscribe订阅主题。
TopicPartition partition0 = new TopicPartition(topic, 0);
TopicPartition partition1 = new TopicPartition(topic, 1);
consumer.assign(Arrays.asList (partition0, partition1));
一旦分配了,就可以像前面的示例一样,在循环中调用poll来使用记录。消费者指定的组仍然用于提交偏移量,但是现在分区集只会随
着要分配的另一个调用而更改。手动分区分配不使用组协调,因此消费者故障不会导致分配的分区被重新平衡。每个消费者独立行动,
即使它与另一个消费者共享一个groupId。为了避免偏移提交冲突,您通常应该确保groupId对于每个消费者实例都是惟一的。
注意,不可能将手动分区分配(即使用assign)与通过主题订阅(即使用subscribe)进行的动态分区分配混合使用。
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++=
在kafka外存储偏移量
消费者应用程序不需要使用Kafka的内置偏移存储,它可以在自己选择的存储中存储偏移量。这方面的主要用例是允许应用程序以原子
存储结果和偏移量的方式将偏移量和消耗的结果存储在同一个系统中。这并不总是可能的,但当它成为可能时,它将使消费完全原子化
,并提供“恰好一次”语义,这比使用Kafka的偏移提交功能得到的默认“至少一次”语义更强。
下面是这类用法的几个例子:
如果消费的结果存储在关系数据库中,那么也可以将偏移量存储在数据库中,从而允许在单个事务中提交结果和偏移量。因此,要么事
务成功,偏移量将根据所使用的内容进行更新,要么不存储结果,也不更新偏移量。
如果结果存储在本地存储中,那么也可以将偏移量存储在本地存储中。例如,可以通过订阅特定分区并同时存储偏移量和索引数据来构
建搜索索引。如果这是以原子的方式完成的,那么即使发生了导致不同步数据丢失的崩溃,剩下的也有可能存储相应的偏移量。这意味
着,在这种情况下,返回的索引过程丢失了最近的更新,它只是从确保没有丢失更新的部分获得索引
每条记录都有自己的偏移量,所以要管理自己的偏移量,你只需要做以下工作:
配置enable.auto.commit = false
使用每个消费记录记录提供的偏移量来保存您的位置。
重启时,使用seek(TopicPartition, long)恢复消费者的位置。
当分区分配也是手工完成时,这种类型的使用是最简单的(这可能在上面描述的搜索索引用例中)。如果分区分配是自动完成的,则需要
特别注意处理分区分配更改的情况。这可以通过在调用subscribe(Collection, ConsumerRebalanceListener)和subscribe(Pattern,
ConsumerRebalanceListener)中提供一个ConsumerRebalanceListener实例来实现。例如,当从消费者中获取分区时,消费者将希望通过
实现ConsumerRebalanceListener.onPartitionsRevoked(Collection)为这些分区提交其偏移量。当将分区分配给消费者时,消费者将
希望查找这些新分区的偏移量,并通过实现ConsumerRebalanceListener.onPartitionsAssigned(Collection)将使用者正确初始化
到该位置。
ConsumerRebalanceListener的另一个常见用途是刷新应用程序为迁移到其他地方的分区维护的缓存。
控制消费者的位置
在大多数用例中,消费者只是从头到尾使用记录,定期提交其位置(自动或手动)。然而Kafka允许消费者手动控制它的位置,在分区中
随意向前或向后移动。这意味着消费者可以重新使用较旧的记录,或者跳过到最近的记录,而不必实际使用中间记录。
在一些情况下,手动控制消费者的位置是有用的。
一种情况是对时间敏感的记录处理,对于远远落后于处理所有记录的消费者来说,这可能是有意义的,因为他们不会试图赶上处理所有
记录的速度,而是直接跳到最近的记录。
另一个用例是维护上一节描述的本地状态的系统。在这样的系统中,消费者将希望在启动时将其位置初始化到本地存储中包含的任何位
置。同样,如果本地状态被破坏(比如磁盘丢失),则可以通过重新消费所有数据并重新创建状态(假设Kafka保留了足够的历史记录)在
新机器上重新创建状态。
Kafka允许使用seek(TopicPartition, long)来指定新位置。还提供了一些特殊的方法,用于查找服务器维护的最早偏移量和
最新偏移量(分别是seektobegin (Collection)和seekToEnd(Collection))。
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
消费流控制
如果一个消费者被分配多个分区来获取数据,它将尝试同时从所有分区中消费数据,有效地为这些分区提供了相同的优先级。然而,在
某些情况下,用户可能希望首先集中精力从分配的分区的某个子集全速获取数据,只有当这些分区没有或只有很少的数据可供使用时才
开始获取其他分区。
其中一种情况是流处理,处理器从两个主题获取数据,并在这两个流上执行连接。当其中一个主题长时间滞后于另一个主题时,处理器
会暂停从前面的主题获取数据,以便让滞后的流跟上。另一个例子是引导消费者启动,其中有很多历史数据需要跟踪,应用程序通常希
望在考虑获取其他主题之前获得一些主题的最新数据。
Kafka支持使用pause(Collection)和resume(Collection)动态控制消费流,以便在未来的轮询(持续时间)调用中分别暂停指定分区上
的消费和恢复指定暂停分区上的消费。
读事务消息
LW 是 Low Watermark 的缩写,俗称“低水位”,代表AR(分区的所有副本)集合中最小的 logStartOffset 值。副本的拉取请求(FetchRequest,它有可能触发新建日志分段而旧的被清
理,进而导致 logStartOffset 的增加)和删除消息请求(DeleteRecordRequest)都有可能促使 LW 的增长。
HW:highwatermark,高水印值,任何一个副本对象的HW值一定不大于其LEO值,而小于或等于HW值的所有消息被认为是“已提交的”或“已备份的”。(更新HW时选择AR中最小的
LEO)
LEO:last end offset,日志末端偏移量,记录了该副本对象底层日志文件中下一条消息的位移值。举一个例子,若LEO=10,那么表示在该副本日志上已经保存了10条消息
,位移范围是[0,9]。
LSO:最后一个稳定的偏移量,其offset小于LSO的所有transactional message的状态都已确定,要不就是committed,要不就是aborted,对未完成的事务而言,LSO 的值等于事务
中第一条消息的位置
事务是在Kafka 0.11.0中引入的,其中应用程序可以原子地写入多个主题和分区。为了实现这一点,应该将从这些分区读取的消费者配
置为只读取提交的数据。这可以通过设置隔离来实现。isolation.level=read_committed。
在消费端有一个参数isolation.level,与事务有着莫大的关联,这个参数的默认值为“read_uncommitted”,意思是说消费端应用可以看到(消费到)未提交的事务,当然对
于已提交的事务也是可见的。
这个参数还可以设置为“read_committed”,表示消费端应用不可以看到尚未提交的事务内的消息。
举个例子,如果生产者开启事务并向某个分区值发送3条消息msg1、msg2和msg3,在执行commitTransaction()或abortTransaction()方法前,设置为“read_committed”的消费
端应用是消费不到这些消息的,不过在KafkaConsumer内部会缓存这些消息,直到生产者执行commitTransaction()方法之后它才能将这些消息推送给消费端应用。反之,
如果生产者执行了abortTransaction()方法,那么KafkaConsumer会将这些缓存的消息丢弃而不推送给消费端应用。
日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息(ControlBatch)。控制消息一共有两种类型:COMMIT和ABORT,分别用来表征
事务已经成功提交或已经被成功中止。
RecordBatch中attributes字段的第6位用来标识当前消息是否是控制消息。如果是控制消息,那么这一位会置为1,否则会置为0,如上图所示。
attributes字段中的第5位用来标识当前消息是否处于事务中,如果是事务中的消息,那么这一位置为1,否则置为0。由于控制消息也处于事务中,所以attributes字段的
第5位和第6位都被置为1。
KafkaConsumer可以通过这个控制消息来判断对应的事务是被提交了还是被中止了,然后结合参数isolation.level配置的隔离级别来决定是否将相应的消息返回给消费端应
用,如上图所示。注意ControlBatch对消费端应用不可见。
对未完成的事务而言,LSO 的值等于事务中第一条消息的位置(firstUnstableOffset,如上图所示),对已完成的事务而言,它的值同 HW 相同, 所以我们可以得出一个结
论:LSO≤HW≤LEO。
read_committed消费者将只读取到LSO并过滤掉任何已中止的事务消息。LSO还影响read_committed消费者的seekToEnd(Collection)和
endoffset (Collection)的行为,其详细信息在每个方法的文档中。最后,对于read_committed消费者,还调整了获取延迟指标,
使之与LSO相关联。
带有事务消息的分区将包括表明事务结果的提交或中止标记(一个批次的消息要么是提交的,要么是中断的)。这些标记没有返回给应用程
序,但是日志中有一个偏移量。因此,从具有事务消息的主题中读取的应用程序将在已消费的偏移量中看到不同标记。这些丢失的消息
将作为事务标记,并在两个隔离级别上为消费者过滤掉它们。此外,使用read_committed消费者的应用程序可能还会看到由于中止的事务
而产生的不同标记,因为这些消息不会由消费者返回,但是会有有效的偏移量。
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
多线程处理
Kafka消费者不是线程安全的。所有网络I/O都发生在执行调用的应用程序的线程中。确保多线程访问正确同步是用户的责任。非同步访
问将导致ConcurrentModificationException。
此规则的唯一例外是wakeup(),它可以安全地从外部线程中断活动操作。在这种情况下,将从阻塞操作的线程中抛出一个唤醒异常。这
可以用于从另一个线程关闭消费者。下面的代码片段显示了典型的模式:
class KafkaConsumerRunner implements Runnable {
private final AtomicBoolean closed = new AtomicBoolean(false);
private final KafkaConsumer consumer;
public KafkaConsumerRunner(KafkaConsumer consumer) {
this.consumer = consumer;
}
public void run() {
try {
consumer.subscribe(Arrays.asList("topic"));
while (!closed.get()) {
ConsumerRecords records = consumer.poll(10000);
// Handle new records
}
} catch (WakeupException e) {
// Ignore exception if closing
if (!closed.get()) throw e;
} finally {
consumer.close();
}
}
// Shutdown hook which can be called from a separate thread
public void shutdown() {
closed.set(true);
consumer.wakeup();
}
}
然后在一个单独的线程中,可以通过设置关闭标志关闭消费者并重新唤醒。
closed.set(true);
consumer.wakeup ();
请注意,虽然可以使用线程中断而不是wakeup()来中止阻塞操作(在这种情况下,会引发InterruptException),但我们不鼓励使用它们
,因为它们可能会导致彻底终止消费者。中断主要支持那些不可能使用wakeup()的情况,例如,当消费者线程由粗心大意的kafka客户端
代码管理时。
我们有意避免为处理实现特定的线程模型。这为实现记录的多线程处理留下了几个选项。
1.一个线程一个消费者
优点:它通常是最快的,因为不需要线程间的协调
它使得基于每个分区的有序处理非常容易实现(每个线程只是按照接收消息的顺序处理消息)
缺点:更多的消费者意味着更多到集群的TCP连接(每个线程一个)。一般来说,Kafka处理连接非常有效,所以这通常是一个小成本。
多个消费者意味着更多的请求被发送到服务器,而更少的数据批处理会导致I/O吞吐量下降。
所有进程的线程总数将受到分区总数的限制。
2.解耦消费和处理
另一种方法是让一个或多个消费者线程执行所有数据消费,并将ConsumerRecords实例传递给一个阻塞队列,该队列由实际处理记录
处理的处理器线程池使用。这个选项也有好处和坏处:
优点:这个选项允许独立地扩展消费者和处理器的数量。这样就可以有一个单一的消费者来提供多个处理器线程,从而避免了分区上的
任何限制。
缺点:保证处理器之间的顺序需要特别小心,因为线程将独立执行较早的数据块,由于线程执行时间的幸运,可能会在稍后的数据块之
后处理较早的数据块。对于没有订购要求的处理,这不是问题。
缺点:手动提交位置变得更加困难,因为它要求所有线程协调以确保该分区的处理完成。
这种方法有许多可能的变体。例如,每个处理器线程都可以有自己的队列,而消费者线程可以使用TopicPartition散列到这些队列中
,以确保有序使用并简化提交。