本文介绍: 本文详细介绍如何实现 Kafka顺序消息,同时给出了消息队列顺序消息的通用实现思路,并简单介绍了 RabbitMQ、RocketMQ 和 Pulsar顺序消息方面的实现思路,文章最后还给出了实战案例

版本说明

本文所有的讨论均在如下版本进行,其他版本可能会有所不同

结论先行

Kafka 只能保证单一分区内的顺序消息,无法保证多分区间的顺序消息。具体来说,要在 Kafka 完全实现顺序消息,至少需要保证以下几个条件

  1. 同一生产者生产消息;
  2. 同步发送消息到 Kafka broker
  3. 所有消息发布同一个分区
  4. 同一消费者同步按照顺序消费消息。

而要满足第 3 点,常用的有 2 种思路:

  1. 固定消息的 key,生产端采用 key hash方式写入 broker
  2. 自定义分区策略,要保证顺序的消息都写入指定的分区。

消息队列中的顺序消息如何实现

顺序消息定义

生产端发送出来的消息的顺序和消费端接收到消息的顺序是一样的。

消息存储结构

一般来说,消息队列都是基于顺序存储结构存储数据的,不需要 B 树、B+ 树等复杂数据结构利用文件的顺序读写性能也很高。所以理想情况下,生产者按顺序发送消息,broker 会按顺序存储消息,消费者再按顺序消费消息,那么天然就实现我们要的顺序消息了,如下:

Kafka 消息存储结构

基本条件

但是一般情况下,消息队列为了支持更高的并发和吞吐,大多数都有分区(partition)和消费者组(consumer group机制,而为了高可用,一般也会有副本replica机制,所以情况就复杂得多了,如下面几个例子,就会导致消息失序:

  1. 多个生产者同时发送消息,那么到达 broker时间也是不确定的,所以 broker 就无法保证落盘的顺序性了;
  2. 单个生产者,但是采用异步发送,因为异步线程并发执行的,由 CPU 进行调度,且有可能会因为发送失败重试,所以也无法保证消息可以按照顺序到达 broker,同理,消费者异步处理消息,也无法保证顺序性;
  3. 一个 topic多个分区,那么即使是同一个生产者,由于分区策略,消息可能会被分发多个分区中,消费者也就无法保证顺序性了。

所以到这里我们可以总结实现顺序消息,至少需要满足以下 3 点:

  1. 单一生产者同步发送;
  2. 单一分区;
  3. 单一消费者同步消费;

第 1、3 点比较简单,Kafka 通过分区和 offset方式保证了消息的顺序。每个分区都是一个有序的、不可变的消息序列每个消息在分区中都有一个唯一的序数标识称为 offset。生产者在发送消息到分区时,Kafka 会自动为消息分配一个 offset消费者读取消息时,会按照 offset 的顺序来读取,从而保证了消息的顺序。

下面我们主要来谈一谈第 2 点。

Kafka 顺序消息的实现

写入消息的过程

  1. 配置生产者:首先,你需要配置 Kafka 生产者。这包括指定 Kafka 集群地址端口,以及其他相关配置项,如消息序列化器、分区策略等。
  2. 创建生产者实例:在应用程序中,你需要创建一个 Kafka 生产者的实例这个实例用于与 Kafka 集群进行通信
  3. 序列化消息:在将消息发送到 Kafka 集群之前,你需要将消息进行序列化。Kafka 使用字节数组表示消息的内容,因此你需要将消息对象序列化字节数组。这通常涉及将消息对象转换为 JSON、Avro、Protobuf格式
  4. 选择分区:Kafka 的主题topic)被分为多个分区(partition),每个分区都是有序且持久化的消息日志。当你发送消息时,你可以选择将消息发送到特定的分区,或者让 Kafka 根据分区策略自动选择分区。
  5. 发送消息:一旦消息被序列化选择目标分区,你可以使用 Kafka 生产者的 send()法将消息发送到 Kafka 集群。发送消息时,生产者会将消息发送到对应分区的 leader 副本。
  6. 异步发送:Kafka 生产者通常使用异步方式发送消息,这样可以提高吞吐量。生产者将消息添加一个发送缓冲区send buffer)中,并在后台线程中批量发送消息到 Kafka 集群
  7. 消息持久:一旦消息被发送到 Kafka 集群的 leader 副本,它将被持久化并复制到其他副本,以确保数据的高可靠性冗余性。只有当消息被成功写入指定数量的副本后,生产者才会收到确认acknowledgement)。
  8. 错误处理重试:如果发送消息时发生错误,生产者可以根据配置进行错误处理重试。你可以设置重试次数重试间隔参数控制重试行为

Kafka 生产者组件图 -《Kafka 权威指南第2版》

实现单一分区

再 Kafka 中,我们要实现将消息写入同一个分区,有 3 种思路:

// 如下例子,所有使用"same-key"作为key的消息都会被发送到同一个Partition
ProducerRecord<String, String&gt; record = new ProducerRecord<String, String&gt;("topic", "same-key", "message");
producer.send(record);

平衡带来的问题

如果采用上述的第 2 种思路:固定消息 key,依靠 key hash 分区策略,实现单一分区。在我们只有 1 个消费者的情况下是没有问题的,但是如果我们使用的是消费者组,那么,在发生平衡操作时候,就可能会有问题了。

Kafka 的重平衡(Rebalance)是指 Kafka 消费者组(Consumer Group)中的消费者实例对分区的重新分配这个过程主要发生在以下几种情况:

  1. 消费者组中新的消费者加入
  2. 消费者组中的消费者离开或者挂掉。
  3. 订阅的 Topic 的分区数发生变化。
  4. 消费者调用#unsubscribe() 或者 #subscribe() 方法

重平衡的过程主要包括以下几个步骤

  1. Revoke:首先,Kafka 会撤销消费者组中所有消费者当前持有的分区。
  2. Assignment然后,Kafka 会重新计算分区的分配情况,然后将分区分配给消费者。
  3. Resume最后,消费者会开始消费新分配到的分区。

重平衡的目的是为了保证消费者组中的消费者能够公平地消费 Topic 的分区。通过重平衡,Kafka 可以在消费者的数量发生变化时,动态地调整消费者对分区的分配,从而实现负载均衡

然而,当发生重平衡时,分区可能会被重新分配不同的消费者,这可能影响消息的消费顺序。

举个例子

  1. 假设消费者 A 正在消费分区 P 的消息,它已经消费了消息 1,消息 2,正在处理消息 3。
  2. 此时,发生了重平衡,分区 P 被重新分配给了消费者 B。
  3. 消费者 B 开始消费分区 P,它会从上一次提交偏移量offset)开始消费。假设消费者 A 在处理消息 3 时发生了故障没有提交偏移量,那么消费者 B 会从消息 3 开始消费。
  4. 这样,消息 3 可能会被消费两次,而且如果消费者 B 处理消息 3 的速度快于消费者A,那么消息 3 可能会在消息 2 之后被处理,这就打破了消息的顺序性。

Kafka 重平衡导致消息失序

再举个例子

  1. topic-A 本来只有 3 个分区,按照 key hash,key 为 same-key 的消息应该都发到 第 2 个分区;
  2. 但是后来 topic-A 变成了 4 个分区,按照 key hash,key 为 same-key 的消息可能就被发到第 3 个分区了;
  3. 这就无法做到单一分区,可能会导致消息失序。

当然这个例子不是由重平衡直接引起的,但是这种情况也是有可能导致消息失序的。

缓解重平衡的问题

上面这些措施,只能减少重平衡带来的问题,并无法根除,如果非要实现严格意义上的顺序消息,要么在消息中加入时间戳等标记,在业务层保证顺序消费,要么就只能采用 单一生产者同步发送 + 单一分区 +单一消费者同步消费 这种模式了。

静态成员功能

Kafka 2.3.0 版本引入了一项新功能静态成员(Static Membership)。这个功能主要是为了减少由于消费者重平衡(rebalance)引起的开销和延迟。在传统的 Kafka 消费者组中,当新的消费者加入或离开消费者组时,会触发重平衡。这个过程可能会导致消息的处理延迟,并且在高吞吐量场景下可能会对性能造成影响静态成员功能旨在缓解这些问题。以下是它的一些关键点:

静态成员的工作原理

  1. 静态成员标识:消费者在加入消费者组时可以提供一个静态成员标识(Static Member ID)。这允许 Kafka Broker 识别特定的消费者实例,而不是仅仅依赖于消费者组内的动态分配

  2. 重平衡优化:当使用静态成员功能时,如果一个已知的消费者由于某种原因(如网络问题)短暂断开后重新连接,Kafka 不会立即触发重平衡。相反,Kafka 会等待一个预设的超时期限(session.timeout.ms),在此期间如果消费者重新连接,它将保留原来的分区分配。

  3. 减少重平衡次数:这大大减少了由于消费者崩溃恢复网络问题或维护操作引起的不必要的重平衡次数

使用静态成员的优点:

  1. 提高稳定:减少重平衡可以提高消费者组的整体稳定性,尤其是在大型消费者组和高吞吐量的情况下。

  2. 减少延迟:由于减少了重平衡的次数,可以减少因重平衡导致的消息处理延迟

  3. 持久的消费者分区分配:这使得消费者在分区分配上更加持久,有助于更好管理优化消息的消费。

如何使用:

注意事项

  • 虽然静态成员功能可以减少重平衡的发生,但它不会完全消除重平衡。在消费者组成员的长期变化(如新消费者的加入或永久离开)时,仍然会发生重平衡。
  • 需要合理设置 session.timeout.ms,以避免消费者由于短暂的网络问题或其他原因断开而过早触发重平衡。

静态成员功能在处理大规模 Kafka 应用时尤其有用,它提供了一种机制优化消费者组的性能稳定性。

幂等性

Kafka 0.11 版本后提供了幂等性生产者,这意味着即使生产者因为某些错误重试发送相同的消息,这些消息也只会被记录一次。这是通过给每一批发送到 Kafka 的消息分配一个序列号实现的,broker 使用这个序列号删除重复发送的消息。使用幂等性生产者,可以减少重复消息的风险,这意味着即使在网络重试等情况下,消息的顺序也能得到更好的保证。因为重复消息不会被多次记录,所以不会破坏已有消息的顺序。

其他常见消息队列顺序消息的实现

Pulsar

Pulsar 和 Kafka 一样,都是通过生产端按 Key Hash方案将数据写入到同一个分区。

RabbitMQ

RabbitMQ 在生产时没有生产分区分配的过程。它是通过 ExchangeRoute Key 机制来实现顺序消息的。Exchange 会根据设置好的 Route Key 将数据路由不同Queue 中存储。此时 Route Key作用和 Kafka 的消息的 Key 是一样的。

RocketMQ

RocektMQ 支持消息组(MessageGroup概念。在生产端指定消息组,则同一个消息组的消息就会被发送到同一个分区中。此时这个消息组起到的作用和 Kakfa 的消息的 Key 是一样的。

实战 Kafka 实现顺序消息

代码仓库https://github.com/hedon954/kafka-go-examples/tree/master/orderedmsg

下面我们来写一写实战用例,更加直观地感受一下 Kafka 顺序消息的实现细节

首先我们在集群上创建一个 topic ordered-msg-topic,分区为 3 个,运行以下命令

/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ordered-msg-topic --partitions 3 --replication-factor 1

搭建 Kafka 集群可以看这两篇:Kafka集群搭建(Zookeeper)Kafka集群搭建(KRaft)

单生产者单消费者

正常情况下,使用单一生产者同步发送和单一消费者同步发送,只要我们保证 key 是固定的,则所有消息都会写到同一个分区,是可以实现顺序消息的。

代码目录如下:

├─configconfig.go		# 常量定义
├─consumerconsumer.go		# 消费者
└─producer
        producer.go		# 生产者

首先我们先定义一些常量

import "github.com/segmentio/kafka-go"

var (
	Topic      = "ordered-msg-topic"
	Brokers    = []string{"kafka1.com:9092", "kafka2.com:9092", "kafka3.com:9092"}
	Addr       = kafka.TCP(Brokers...)
	GroupId    = "ordered-msg-group"
	MessageKey = []byte("message-key")
)

我们先实现生产者端,主要是不断往 ordered-msg-topic 中写入数据:

package main

import (
	"context"
	"fmt"
	"time"

	"kafka-go-examples/orderedmsg/config"

	"github.com/segmentio/kafka-go"
)

func NewProducer() *kafka.Writer {
	return &amp;kafka.Writer{
		Addr:     config.Addr,
		Topic:    config.Topic,
		Balancer: &amp;kafka.Hash{}, // 哈希分区
	}
}

func NewMessages(count int) []kafka.Message {
	res := make([]kafka.Message, count)
	for i := 0; i < count; i++ {
		res[i] = kafka.Message{
			Key:   config.MessageKey,
			Value: []byte(fmt.Sprintf("msg-%d", i+1)),
		}
	}
	return res
}

func main() {
	producer := NewProducer()
	messages := NewMessages(100)
	if err := producer.WriteMessages(context.Background(), messages...); err != nil {
		panic(err)
	}
	_ = producer.Close()
}

我们再来实现消费者,目前我们就启动 1 个消费者:

package main

import (
	"context"
	"fmt"
	"time"

	"kafka-go-examples/orderedmsg/config"

	"github.com/segmentio/kafka-go"
)

type Consumer struct {
	Id string
	*kafka.Reader
}

// NewConsumer 创建一个消费者,它属于 config.GroupId 这个消费者组
func NewConsumer(id string) *Consumer {
	c := &amp;Consumer{
		Id: id,
		Reader: kafka.NewReader(kafka.ReaderConfig{
			Brokers: config.Brokers,
			GroupID: config.GroupId,
			Topic:   config.Topic,
			Dialer: &amp;kafka.Dialer{
				ClientID: id,
			},
		}),
	}
	return c
}

// Read 读取消息,intervalMs 用来控制消费者的消费速度
func (c *Consumer) Read(intervalMs int) {
	fmt.Printf("%s start readn", c.Id)
	for {
		msg, err := c.ReadMessage(context.Background())
		if err != nil {
			fmt.Printf("%s read msg err: %vn", c.Id, err)
			return
		}
		// 模拟消费速度
		time.Sleep(time.Millisecond * time.Duration(intervalMs))
		fmt.Printf("%s read msg: %s, time: %sn", c.Id, string(msg.Value), time.Now().Format("03-04-05"))
	}
}

func main() {
	c1 := NewConsumer("consumer-1")
	c1.Read(500)
}

启动生产者生产消息,然后启动消费者,观察控制台,不难看出这种情况下就是顺序消费:

consumer-1 read msg: msg-10, time: 04:29:10
consumer-1 read msg: msg-11, time: 04:29:11
consumer-1 read msg: msg-12, time: 04:29:12
consumer-1 read msg: msg-13, time: 04:29:13
consumer-1 read msg: msg-14, time: 04:29:14
consumer-1 read msg: msg-15, time: 04:29:15
consumer-1 read msg: msg-16, time: 04:29:16

重平衡带来的问题

我们先重建 topic,清楚掉之前的数据:

/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --delete --topic ordered-msg-topic
/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ordered-msg-topic --partitions 3 --replication-factor 1

下面我们来采用消费者组的形式消费消息,在这期间,我们不断往消费者组中新增消费者,使其发生重平衡,我们来观察下消息的消费情况。

修改消费者端的 main():

func main() {
	// 先启动 c1
	c1 := NewConsumer("consumer-1")
	go func() {
		c1.Read(500)
	}()

	// 5 秒后启动 c2
	time.Sleep(5 * time.Second)
	go func() {
		c2 := NewConsumer("consumer-2")
		c2.Read(300)
	}()

	// 再 10 秒后启动 c3 和 c4
	time.Sleep(10 * time.Second)
	go func() {
		c3 := NewConsumer("consumer-3")
		c3.Read(100)
	}()
	go func() {
		c4 := NewConsumer("consumer-4")
		c4.Read(100)
	}()

	select {}
}

先启动生产者重新生产数据,然后再启动消费者消费数据,观察控制台

consumer-1 start read
consumer-1 read msg: msg-1, time: 04:44:28
consumer-1 read msg: msg-2, time: 04:44:28
consumer-1 read msg: msg-3, time: 04:44:29		# consumer-1 按顺序消费
consumer-2 start read						  # consumer-2 进来
consumer-1 read msg: msg-4, time: 04:44:30
consumer-1 read msg: msg-5, time: 04:44:30
consumer-1 read msg: msg-6, time: 04:44:31      # 这里相差了 6s,就是在进行重平衡
consumer-2 read msg: msg-7, time: 04:44:37      # 重平衡后发现原来的分区给 consumer-2 消费了
consumer-1 read msg: msg-7, time: 04:44:37	    # 这里发生了重复消费
consumer-2 read msg: msg-8, time: 04:44:37
consumer-2 read msg: msg-9, time: 04:44:37
consumer-2 read msg: msg-10, time: 04:44:38
consumer-2 read msg: msg-11, time: 04:44:38
consumer-2 read msg: msg-12, time: 04:44:38
consumer-2 read msg: msg-13, time: 04:44:39
consumer-2 read msg: msg-14, time: 04:44:39
consumer-2 read msg: msg-15, time: 04:44:39      # consumer-2 按顺序消息
consumer-4 start read						   # consumer-3 和 consumer-4 进来
consumer-3 start read
consumer-2 read msg: msg-16, time: 04:44:40	   
consumer-4 read msg: msg-17, time: 04:44:46      # 这里发生重平衡
consumer-4 read msg: msg-18, time: 04:44:46      # 重平衡后由 consumer-4 负责该分区
consumer-2 read msg: msg-17, time: 04:44:46      # 这里由于 2 的速度比 4 慢很多,所以就乱序了,还重复消费
consumer-4 read msg: msg-19, time: 04:44:46
consumer-4 read msg: msg-20, time: 04:44:46
# ...

总结

当我们采用消费者组的时候,由于重平衡机制的存在,单纯从 Kafka 的角度来说是无法完全实现顺序消息的,只能通过静态成员功能、避免分区数量变化和减少消费者组成员数量变化等方式来尽可能减少重平衡的发生,进而尽可能维持消息的顺序性。

参考

原文地址:https://blog.csdn.net/Hedon954/article/details/134620228

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任

如若转载,请注明出处:http://www.7code.cn/show_35210.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注