Skip to main content

Kafka如何做到消息的有序性?

作者:程序员马丁

在线博客:https://open8gu.com

note

大话面试,技术同学面试必备的八股文小册,以精彩回答应对深度问题,助力你在面试中拿个offer。

回答话术​

1. 基础知识​

Kafka 只保证 Topic 的分区内消息有序,不保证整个 Topic 中的消息有序,除非只有一个分区。

2. Kafka 是如何做到分区内消息有序的?​

Kafka 的一个 Topic 是由多个 Partition 组成,一个 Topic 下的不同 Partition 包含的消息是不同的,Partition 中的每个消息都会有个特定的偏移量 offset,Kafka 利用 offset 来保证分区内的顺序性。

下图中有三个 Partition ,每个 Partition 的 offset 都是从 0 开始,生产者每次投递消息时,offset 按照顺序追加。

image.png

3. Kafka 消费模型​

正常情况下,Kafka 一个分区同一个消费组只能有一个消费者。

消费者在消费 Kafka 消息时,需要维护一个当前消费的 offset 值,以及一个已提交的 offset 值。

当前消费的 offset 值表示消费者正在消费的消息的位置,已提交的 offset 值表示消费者已经确认消费过的消息的位置。消费者在消费完一条消息后,需要提交 offset 来更新已提交的 offset 值。

提交 offset 的目的是为了记录消费进度,以便在消费者发生故障或重启时,默认情况下能够从上次消费的位置继续消费,也可选从最开始的位置开始消费。

流程图如下所示:

image.png

问题详解​

1. 订单逻辑代码示例​

在实际业务中,要做到生产者发消息时消息的有序性,生产者发消息的时候应该指定 Key 进行投递到 Partition,相同的 Key 会发送到一个 Partition 中。

利用 Kafka Partition 内是有序的,从而保证消息的顺序性。

例如在订单的场景中,我们需要保证用户每个订单在进行支付/退款等操作是顺序的,就可以根据订单 ID 当作 Key 进行投递,相同的 Key 每次都会到同一个 Partition 中,从而做到同个订单的每个操作都是顺序的。

生产者 Demo 代码(基于 Spring Kafka)如下所示:

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
</dependencies>

订单下单、取消、付款以及退款等逻辑代码:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class OrderController {

private static final String TOPIC_ORDER = "topic_order";

@Autowired
private KafkaTemplate<String, String> kafkaTemplate;

/**
* 下单
*/
@GetMapping("/create")
public void create(String orderId) {
//send 方法参数, 参数1: topic 参数2: key 参数3: 内容
kafkaTemplate.send(TOPIC_ORDER, orderId, "创建订单:" + orderId);
}

/**
* 取消
*/
@GetMapping("/cancel")
public void cancel(String orderId) {
kafkaTemplate.send(TOPIC_ORDER, orderId, "取消订单:" + orderId);
}


/**
* 付款
*/
@GetMapping("/pay")
public void pay(String orderId) {
kafkaTemplate.send(TOPIC_ORDER, orderId, "订单:" + orderId + " 付款");
}


/**
* 退款
*/
@GetMapping("/refund")
public void refund(String orderId) {
kafkaTemplate.send(TOPIC_ORDER, orderId, "订单:" + orderId + "退款");
}
}

Kafka 执行逻辑示意图:

image.png

因为 Kafka 一个分区同一个消费组只能有一个消费者,所以消费者正常消费即可做到顺序性。

消费者 Demo 代码如下所示:


import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;

@Component
public class OrderConsumer {

/**
* 这边是手动 Ack
*
* @param consumer
* @param acknowledgment
*/
@KafkaListener(topics = "topic_order", groupId = "group1")
public void group1(ConsumerRecord<String, String> consumer, Acknowledgment acknowledgment) {
System.out.println("消费者 topic名称:" + consumer.topic() +
", key:" + consumer.key() +
", 消息内容:" + consumer.value() +
", 分区:" + consumer.partition() +
", 所属分区的偏移量" + consumer.offset());
acknowledgment.acknowledge();
}
}

Kafka offset 提交示意图:

image.png

2. 扩展知识​

2.1. offset 存在哪里?​

offset 的实际存储位置都是在 Kafka 的一个内置 Topic 中:__consumer_offsets。这个 Topic 有 50 个(可配置)分区,每个分区存储一部分消费组的 offset 信息。

Kafka Broker 会根据消费组 ID 和 Topic 名称来计算出一个哈希值,并将其映射到 __consumer_offsets 主题的某个分区上。

备注:0.9.0 版本之前 offset 存在 ZooKeeper 中。

2.2. 为什么单个分区不能并行消费?​

正常情况,一个分区只接受同消费组的一个消费者,当消费者数量大于分区数量时,会导致消费者闲置。

如下图所示:

image.png

那么为什么会这样呢?

我们举个例子,假设 Consumer0、Consumer1、Consumer2 共同消费 Partition0,当 Consumer0 提交 offset 为 12,Consumer2 提交 offset 为 8,Consumer3 从 Partition0 中获取的 offset 是 8,这就会导致出现重复消费。

如果要解决这个问题,就要引入更复杂的算法去解决这个问题。这还是正常消费的情况,如果有 Consumer 出现消费失败,还会出现更复杂的情况,所以一般不这么用。

image.png