本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
这是一道非常高频的消息队列方向的面试题。
但很遗憾,单纯依靠Kafka本身不能保证消息的全局有序性,只能保证Partition内有序。
如下图所示:就Topic1而言,Kafka只能保证消息从Producer1中发送到Broker1的Partition1上,Consumer1再从Partition1上进行拉取消息并消费,整个过程的有序性。
当然,保证Partition内有序消费也是有前提的,必须在Kafka Producer端和Consumer端同时进行约束才行。
Partition内有序消费——Producer端
我们先来讲讲Kafka Producer端的底层实现原理。
Producer中有一个Sender线程,它负责从消息累加器(打包发送消息的载体)中读取消息,封装成Request对象并缓存至InFlightRequests区域中,然后再将其发送到Kafka的Broker上。
如下图所示:
btw:InFlightRequests区域,存放的是已经发出去、但还没有收到响应的Request对象,默认值为5。
一旦超出这个阈值,Producer就不会再往这个Broker节点发送请求了。
该机制的设计初衷为,防止消息发送密集而导致Broker节点的负载过高。
接下来,我们需要关注这两个Producer端参数,max.in.flight.requests.per.connection和retries。
max.in.flight.requests.per.connection,该参数作用于InFlightRequests缓存区域,用来设置Producer在收到Broker响应之前,可以发送几个批次的消息,默认值为5。
如果将该参数值调高,那么能够容忍没有响应的消息批次就更多了,虽然会消耗一些内存,但可以提升消息发送吞吐量。
retries, 该参数用来设置当Producer发送消息失败时,可以进行重试的次数,默认值为0,也就是不进行重试。
每次重试的间隔期可以通过retry.backoff.ms参数进行设置,默认是100ms。
由此可见,我们是不需要在生产者代码中写消息失败重试逻辑的,只需要设置这个参数即可。
想象这样一种场景:
如果我们将retries参数值设置为1,在max.in.flight.requests.per.connection的参数值大于1的情况下,Request1批次的消息发送失败,Request2批次的消息发送成功,此时Request1批次的消息进行重试发送也成功了,那两者的顺序就反过来了。
因此,生产者端的max.in.flight.requests.per.connection和retries这两个参数必须得有一个等于0,才能保证Partition内的有序性。
Partition内有序消费——Consumer端
我们先来看一下消费者端参数,max.poll.records。
max.poll.record,该参数用于控制在Consumer端单次调用poll()方法能够返回的记录数量,默认为500。
接下来我们看下消费者端进行消息处理的代码。
import org.apache.kafka.clients.consumer.ConsumerConfig;
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.Collections;
import java.util.Properties;
public class KafkaConsumerExample {
public static void main(String[] args) {
// Kafka消费者配置
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); // Kafka服务器地址
props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group"); // 消费者组ID
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
// 创建Kafka消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
// 消费者订阅的主题
consumer.subscribe(Collections.singletonList("test-topic"));
try {
// 持续轮询主题中的消息
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// 处理消息
System.out.printf("Offset = %d, Key = %s, Value = %s%n", record.offset(), record.key(), record.value());
}
}
} finally {
// 关闭消费者
consumer.close();
}
}
}
如代码所示,我们需要在“处理消息”的代码块中保持单线程运行,这样就可以做到有序消费了,因为线程池是无法保证有序性的。
业务有序性
其实,就常见的业务系统而言,它们真正需要的是业务逻辑上的有序性,并不是严格意义上的全局有序。
举个例子,就电商订单中心而言,需要的是对于同一笔订单多次操作的有序性,不能出现退款操作比下单成功操作后发先至的情况。
但对于ID为12345的订单和ID为12346的订单,从系统的业务逻辑上讲,它们之间是不存在顺序关系的。
既然如此,Kafka只要保证同一笔订单操作的有序性即可满足系统要求,我们可以通过Kafka的分区器 + Partition内有序性进行实现。
如上文所述,Kafka Producer中有一个Sender线程,除此之外还有一个主线程。
主线程负责消息创建,然后会依次经过拦截器、序列化器和分区器,并将消息缓存在消息累加器中。
如下图所示:
随后,Sender线程再从消息累加器中获取批次消息,完成后续消息发送逻辑,上文中已经讲过,在此不多做赘述。
如下图所示:
其中,分区器决定将消息发送至哪个分区。
- 如果消息键值为 null,且分区器为默认的话,会使用轮询(Round Robin)算法决定分区归属;
- 如果消息键值不为空,且分区器为默认的话,会对键进行散列(Hash)的方式来决定分区归属。
代码如下所示:
// Producer指定Key确保消息进入同一分区
ProducerRecord<String, String> record =
new ProducerRecord<>("Topic1", "12345", "order_event");
producer.send(record);
因此,如果我们将消息键值设置为订单ID,那这笔订单的所有操作全部会发送到一个Partition中,再利用Partition内有序性 + 生产者端的max.in.flight.requests.per.connection和retries这两个参数必须有一个等于0 + 消费者端的单线程处理方式,最终解决了订单业务的有序性问题。
再谈全局有序性
有的同学会说,如果我在该Topic下只创建一个Partition,那Partition内有序不就等于全局有序了吗?
确实如此,还记得消息队列的三大应用场景吗,异步、消峰和解耦。
如果我们项目中的应用场景,是对Kafka的吞吐量要求不高的异步、解耦场景,并且需要保证全局有序性的话,完全可以用这种简单有效的实现方式。