版本说明
本文所有的讨论均在如下版本进行,其他版本可能会有所不同。
- Kafka: 3.6.0
- Pulsar: 2.9.0
- RabbitMQ 3.7.8
- RocketMQ 5.0
- Go1.21
- github.com/segmentio/kafka-go v0.4.45
结论先行
Kafka 只能保证单一分区内的顺序消息,无法保证多分区间的顺序消息。具体来说,要在 Kafka 完全实现顺序消息,至少需要保证以下几个条件:
- 同一生产者生产消息;
- 同步发送消息到 Kafka broker;
- 所有消息发布到同一个分区;
- 同一消费者同步按照顺序消费消息。
而要满足第 3 点,常用的有 2 种思路:
- 固定消息的 key,生产端采用
key hash
的方式写入 broker; - 自定义分区策略,要保证顺序的消息都写入到指定的分区。
消息队列中的顺序消息如何实现
顺序消息定义
生产端发送出来的消息的顺序和消费端接收到消息的顺序是一样的。
消息存储结构
一般来说,消息队列都是基于顺序存储结构 来存储数据的,不需要 B 树、B+ 树等复杂数据结构,利用文件的顺序读写,性能也很高。所以理想情况下,生产者按顺序发送消息,broker 会按顺序存储消息,消费者再按顺序消费消息,那么天然就实现了我们要的顺序消息了,如下:
基本条件
但是一般情况下,消息队列为了支持更高的并发和吞吐,大多数都有分区(partition)和消费者组(consumer group)机制,而为了高可用,一般也会有副本(replica)机制,所以情况就复杂得多了,如下面几个例子,就会导致消息失序:
- 多个生产者同时发送消息,那么到达 broker 的时间也是不确定的,所以 broker 就无法保证落盘的顺序性了;
- 单个生产者,但是采用异步发送,因为异步线程是并发执行的,由 CPU 进行调度,且有可能会因为发送失败而重试,所以也无法保证消息可以按照顺序到达 broker,同理,消费者异步处理消息,也无法保证顺序性;
- 一个 topic 有多个分区,那么即使是同一个生产者,由于分区策略,消息可能会被分发到多个分区中,消费者也就无法保证顺序性了。
所以到这里,我们可以总结出实现顺序消息,至少需要满足以下 3 点:
- 单一生产者同步发送;
- 单一分区;
- 单一消费者同步消费;
第 1、3 点比较简单,Kafka 通过分区和 offset 的方式保证了消息的顺序。每个分区都是一个有序的、不可变的消息序列,每个消息在分区中都有一个唯一的序数标识,称为 offset
。生产者在发送消息到分区时,Kafka 会自动为消息分配一个 offset。消费者在读取消息时,会按照 offset 的顺序来读取,从而保证了消息的顺序。
下面我们主要来谈一谈第 2 点。
Kafka 顺序消息的实现
写入消息的过程
- 配置生产者:首先,你需要配置 Kafka 生产者。这包括指定 Kafka 集群的地址和端口,以及其他相关配置项,如消息序列化器、分区策略等。
- 创建生产者实例:在应用程序中,你需要创建一个 Kafka 生产者的实例。这个实例将用于与 Kafka 集群进行通信。
- 序列化消息:在将消息发送到 Kafka 集群之前,你需要将消息进行序列化。Kafka 使用字节数组来表示消息的内容,因此你需要将消息对象序列化为字节数组。这通常涉及将消息对象转换为 JSON、Avro、Protobuf 等格式。
- 选择分区:Kafka 的主题(topic)被分为多个分区(partition),每个分区都是有序且持久化的消息日志。当你发送消息时,你可以选择将消息发送到特定的分区,或者让 Kafka 根据分区策略自动选择分区。
- 发送消息 :一旦消息被序列化并选择了目标分区,你可以使用 Kafka 生产者的
send()
方法将消息发送到 Kafka 集群。发送消息时,生产者会将消息发送到对应分区的 leader 副本。 - 异步发送:Kafka 生产者通常使用异步方式发送消息,这样可以提高吞吐量。生产者将消息添加到一个发送缓冲区(send buffer)中,并在后台线程中批量发送消息到 Kafka 集群。
- 消息持久化:一旦消息被发送到 Kafka 集群的 leader 副本,它将被持久化并复制到其他副本,以确保数据的高可靠性和冗余性。只有当消息被成功写入到指定数量的副本后,生产者才会收到确认(acknowledgement)。
- 错误处理和重试:如果发送消息时发生错误,生产者可以根据配置进行错误处理和重试。你可以设置重试次数、重试间隔等参数来控制重试行为。
实现单一分区
再 Kafka 中,我们要实现将消息写入到同一个分区,有 3 种思路:
- 配置
num.partitions=1
或者创建 topic 的时候指定只有 1 个分区,但这会显著降低 Kafka 的吞吐量。 - 固定消息的 key ,然后采用 key hash 的分区策略,这样就可以让所有消息都被分到同一个分区中。
- 实现并指定自定义分区策略,可以根据业务需求,将需要顺序消费的消息都分到固定一个分区中。
java
// 如下例子,所有使用"same-key"作为key的消息都会被发送到同一个Partition
ProducerRecord<String, String> record = new ProducerRecord<String, String>("topic", "same-key", "message");
producer.send(record);
重平衡带来的问题
如果采用上述的第 2 种思路:固定消息 key,依靠 key hash 分区策略,实现单一分区 。在我们只有 1 个消费者的情况下是没有问题的,但是如果我们使用的是消费者组,那么,在发生重平衡操作的时候,就可能会有问题了。
Kafka 的重平衡(Rebalance)是指 Kafka 消费者组(Consumer Group)中的消费者实例对分区的重新分配。这个过程主要发生在以下几种情况:
- 消费者组中新的消费者加入。
- 消费者组中的消费者离开或者挂掉。
- 订阅的 Topic 的分区数发生变化。
- 消费者调用了
#unsubscribe()
或者#subscribe()
方法。
重平衡的过程主要包括以下几个步骤:
- Revoke:首先,Kafka 会撤销消费者组中所有消费者当前持有的分区。
- Assignment:然后,Kafka 会重新计算分区的分配情况,然后将分区分配给消费者。
- Resume:最后,消费者会开始消费新分配到的分区。
重平衡的目的是为了保证消费者组中的消费者能够公平地消费 Topic 的分区。通过重平衡,Kafka 可以在消费者的数量发生变化时,动态地调整消费者对分区的分配,从而实现负载均衡。
然而,当发生重平衡时,分区可能会被重新分配给不同的消费者,这可能会影响消息的消费顺序。
举个例子:
- 假设消费者 A 正在消费分区 P 的消息,它已经消费了消息 1,消息 2,正在处理消息 3。
- 此时,发生了重平衡,分区 P 被重新分配给了消费者 B。
- 消费者 B 开始消费分区 P,它会从上一次提交的偏移量(offset)开始消费。假设消费者 A 在处理消息 3 时发生了故障,没有提交偏移量,那么消费者 B 会从消息 3 开始消费。
- 这样,消息 3 可能会被消费两次,而且如果消费者 B 处理消息 3 的速度快于消费者A,那么消息 3 可能会在消息 2 之后被处理,这就打破了消息的顺序性。
再举个例子:
- topic-A 本来只有 3 个分区,按照 key hash,key 为
same-key
的消息应该都发到 第 2 个分区; - 但是后来 topic-A 变成了 4 个分区,按照 key hash,key 为
same-key
的消息可能就被发到第 3 个分区了; - 这就无法做到单一分区,可能会导致消息失序。
当然这个例子不是由重平衡直接引起的,但是这种情况也是有可能导致消息失序的。
缓解重平衡的问题
- 避免动态改变分区数:在需要严格保持消息顺序的场景下,应避免动态地改变分区数。这意味着在设计 Kafka 主题时,应提前规划好所需的分区数,以避免日后需要进行更改。
- 使用单个分区:对于严格顺序要求的场景,可以考虑使用单分区主题。虽然这会限制吞吐量和并发性,但可以保证消息的全局顺序。
- 使用其他策略保持顺序:在某些情况下,可以通过在应用层实现逻辑来保持顺序,比如在消息中包含顺序号或时间戳,并在消费时根据这些信息重建正确的顺序。
- 使用静态成员功能:它允许消费者在断开和重新连接时保持其消费者组内的身份,这可以减少因短暂的网络问题或消费者重启导致的不必要的重平衡。
上面这些措施,只能减少重平衡带来的问题,并无法根除,如果非要实现严格意义上的顺序消息,要么在消息中加入时间戳等标记,在业务层保证顺序消费,要么就只能采用 单一生产者同步发送 + 单一分区 +单一消费者同步消费
这种模式了。
静态成员功能
Kafka 2.3.0 版本引入了一项新功能:静态成员(Static Membership)。这个功能主要是为了减少由于消费者重平衡(rebalance)引起的开销和延迟。在传统的 Kafka 消费者组中,当新的消费者加入或离开消费者组时,会触发重平衡。这个过程可能会导致消息的处理延迟,并且在高吞吐量的场景下可能会对性能造成影响。静态成员功能旨在缓解这些问题。以下是它的一些关键点:
静态成员的工作原理:
-
静态成员标识:消费者在加入消费者组时可以提供一个静态成员标识(Static Member ID)。这允许 Kafka Broker 识别特定的消费者实例,而不是仅仅依赖于消费者组内的动态分配。
-
重平衡优化:当使用静态成员功能时,如果一个已知的消费者由于某种原因(如网络问题)短暂断开后重新连接,Kafka 不会立即触发重平衡。相反,Kafka 会等待一个预设的超时期限(session.timeout.ms),在此期间如果消费者重新连接,它将保留原来的分区分配。
-
减少重平衡次数:这大大减少了由于消费者崩溃和恢复、网络问题或维护操作引起的不必要的重平衡次数。
使用静态成员的优点:
-
提高稳定性:减少重平衡可以提高消费者组的整体稳定性,尤其是在大型消费者组和高吞吐量的情况下。
-
减少延迟:由于减少了重平衡的次数,可以减少因重平衡导致的消息处理延迟。
-
持久的消费者分区分配:这使得消费者在分区分配上更加持久,有助于更好地管理和优化消息的消费。
如何使用:
- 要使用静态成员功能,需要在 Kafka 消费者的配置中设置
group.instance.id
。这个 ID 应该是唯一的,并且在消费者重启或重新连接时保持不变。同时,还需要配置session.timeout.ms
,以决定在触发重平衡之前消费者可以离线多长时间。
注意事项:
- 虽然静态成员功能可以减少重平衡的发生,但它不会完全消除重平衡。在消费者组成员的长期变化(如新消费者的加入或永久离开)时,仍然会发生重平衡。
- 需要合理设置
session.timeout.ms
,以避免消费者由于短暂的网络问题或其他原因的断开而过早触发重平衡。
静态成员功能在处理大规模 Kafka 应用时尤其有用,它提供了一种机制来优化消费者组的性能和稳定性。
幂等性
Kafka 0.11 版本后提供了幂等性生产者,这意味着即使生产者因为某些错误重试发送相同的消息,这些消息也只会被记录一次。这是通过给每一批发送到 Kafka 的消息分配一个序列号实现的,broker 使用这个序列号来删除重复发送的消息。使用幂等性生产者,可以减少重复消息的风险,这意味着即使在网络重试等情况下,消息的顺序也能得到更好的保证。因为重复消息不会被多次记录,所以不会破坏已有消息的顺序。
其他常见消息队列顺序消息的实现
Pulsar
Pulsar 和 Kafka 一样,都是通过生产端按 Key Hash 的方案将数据写入到同一个分区。
RabbitMQ
RabbitMQ 在生产时没有生产分区分配的过程。它是通过 Exchange
和 Route Key
机制来实现顺序消息的。Exchange
会根据设置好的 Route Key
将数据路由到不同的 Queue
中存储。此时 Route Key
的作用和 Kafka 的消息的 Key
是一样的。
RocketMQ
RocektMQ 支持消息组(MessageGroup)
的概念。在生产端指定消息组,则同一个消息组的消息就会被发送到同一个分区中。此时这个消息组起到的作用和 Kakfa 的消息的 Key 是一样的。
实战 Kafka 实现顺序消息
下面我们来写一写实战用例,更加直观地感受一下 Kafka 顺序消息的实现细节。
首先我们在集群上创建一个 topic ordered-msg-topic
,分区为 3
个,运行以下命令:
sh
/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 是固定的,则所有消息都会写到同一个分区,是可以实现顺序消息的。
代码目录如下:
sh
├─config
│ config.go # 常量定义
├─consumer
│ consumer.go # 消费者
└─producer
producer.go # 生产者
首先我们先定义一些常量:
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
中写入数据:
go
package main
import (
"context"
"fmt"
"time"
"kafka-go-examples/orderedmsg/config"
"github.com/segmentio/kafka-go"
)
func NewProducer() *kafka.Writer {
return &kafka.Writer{
Addr: config.Addr,
Topic: config.Topic,
Balancer: &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 个消费者:
go
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 := &Consumer{
Id: id,
Reader: kafka.NewReader(kafka.ReaderConfig{
Brokers: config.Brokers,
GroupID: config.GroupId,
Topic: config.Topic,
Dialer: &kafka.Dialer{
ClientID: id,
},
}),
}
return c
}
// Read 读取消息,intervalMs 用来控制消费者的消费速度
func (c *Consumer) Read(intervalMs int) {
fmt.Printf("%s start read\n", c.Id)
for {
msg, err := c.ReadMessage(context.Background())
if err != nil {
fmt.Printf("%s read msg err: %v\n", c.Id, err)
return
}
// 模拟消费速度
time.Sleep(time.Millisecond * time.Duration(intervalMs))
fmt.Printf("%s read msg: %s, time: %s\n", c.Id, string(msg.Value), time.Now().Format("03-04-05"))
}
}
func main() {
c1 := NewConsumer("consumer-1")
c1.Read(500)
}
启动生产者生产消息,然后启动消费者,观察控制台,不难看出这种情况下就是顺序消费:
tex
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,清楚掉之前的数据:
sh
/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --delete --topic ordered-msg-topic
sh
/opt/kafka-3.6.0/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ordered-msg-topic --partitions 3 --replication-factor 1
下面我们来采用消费者组的形式消费消息,在这期间,我们不断往消费者组中新增消费者,使其发生重平衡,我们来观察下消息的消费情况。
修改消费者端的 main():
go
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 {}
}
先启动生产者重新生产数据,然后再启动消费者消费数据,观察控制台:
bash
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 的角度来说是无法完全实现顺序消息的,只能通过静态成员功能、避免分区数量变化和减少消费者组成员数量变化等方式来尽可能减少重平衡的发生,进而尽可能维持消息的顺序性。