本文所有的讨论均在如下版本进行,其他版本可能会有所不同。
Kafka 只能保证单一分区内的顺序消息,无法保证多分区间的顺序消息。具体来说,要在 Kafka 完全实现顺序消息,至少需要保证以下几个条件:
而要满足第 3 点,常用的有 2 种思路:
key hash
的方式写入 broker;生产端发送出来的消息的顺序和消费端接收到消息的顺序是一样的。
一般来说,消息队列都是基于顺序存储结构来存储数据的,不需要 B 树、B+ 树等复杂数据结构,利用文件的顺序读写,性能也很高。所以理想情况下,生产者按顺序发送消息,broker 会按顺序存储消息,消费者再按顺序消费消息,那么天然就实现了我们要的顺序消息了,如下:
但是一般情况下,消息队列为了支持更高的并发和吞吐,大多数都有分区(partition)和消费者组(consumer group)机制,而为了高可用,一般也会有副本(replica)机制,所以情况就复杂得多了,如下面几个例子,就会导致消息失序:
所以到这里,我们可以总结出实现顺序消息,至少需要满足以下 3 点:
第 1、3 点比较简单,Kafka 通过分区和 offset 的方式保证了消息的顺序。每个分区都是一个有序的、不可变的消息序列,每个消息在分区中都有一个唯一的序数标识,称为 offset
。生产者在发送消息到分区时,Kafka 会自动为消息分配一个 offset。消费者在读取消息时,会按照 offset 的顺序来读取,从而保证了消息的顺序。
下面我们主要来谈一谈第 2 点。
send()
方法将消息发送到 Kafka 集群。发送消息时,生产者会将消息发送到对应分区的 leader 副本。再 Kafka 中,我们要实现将消息写入到同一个分区,有 3 种思路:
num.partitions=1
或者创建 topic 的时候指定只有 1 个分区,但这会显著降低 Kafka 的吞吐量。// 如下例子,所有使用"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)中的消费者实例对分区的重新分配。这个过程主要发生在以下几种情况:
#unsubscribe()
或者 #subscribe()
方法。重平衡的过程主要包括以下几个步骤:
重平衡的目的是为了保证消费者组中的消费者能够公平地消费 Topic 的分区。通过重平衡,Kafka 可以在消费者的数量发生变化时,动态地调整消费者对分区的分配,从而实现负载均衡。
然而,当发生重平衡时,分区可能会被重新分配给不同的消费者,这可能会影响消息的消费顺序。
举个例子:
再举个例子:
same-key
的消息应该都发到 第 2 个分区;same-key
的消息可能就被发到第 3 个分区了;当然这个例子不是由重平衡直接引起的,但是这种情况也是有可能导致消息失序的。
上面这些措施,只能减少重平衡带来的问题,并无法根除,如果非要实现严格意义上的顺序消息,要么在消息中加入时间戳等标记,在业务层保证顺序消费,要么就只能采用 单一生产者同步发送 + 单一分区 +单一消费者同步消费
这种模式了。
Kafka 2.3.0 版本引入了一项新功能:静态成员(Static Membership)。这个功能主要是为了减少由于消费者重平衡(rebalance)引起的开销和延迟。在传统的 Kafka 消费者组中,当新的消费者加入或离开消费者组时,会触发重平衡。这个过程可能会导致消息的处理延迟,并且在高吞吐量的场景下可能会对性能造成影响。静态成员功能旨在缓解这些问题。以下是它的一些关键点:
静态成员的工作原理:
静态成员标识:消费者在加入消费者组时可以提供一个静态成员标识(Static Member ID)。这允许 Kafka Broker 识别特定的消费者实例,而不是仅仅依赖于消费者组内的动态分配。
重平衡优化:当使用静态成员功能时,如果一个已知的消费者由于某种原因(如网络问题)短暂断开后重新连接,Kafka 不会立即触发重平衡。相反,Kafka 会等待一个预设的超时期限(session.timeout.ms),在此期间如果消费者重新连接,它将保留原来的分区分配。
减少重平衡次数:这大大减少了由于消费者崩溃和恢复、网络问题或维护操作引起的不必要的重平衡次数。
使用静态成员的优点:
提高稳定性:减少重平衡可以提高消费者组的整体稳定性,尤其是在大型消费者组和高吞吐量的情况下。
减少延迟:由于减少了重平衡的次数,可以减少因重平衡导致的消息处理延迟。
持久的消费者分区分配:这使得消费者在分区分配上更加持久,有助于更好地管理和优化消息的消费。
如何使用:
group.instance.id
。这个 ID 应该是唯一的,并且在消费者重启或重新连接时保持不变。同时,还需要配置 session.timeout.ms
,以决定在触发重平衡之前消费者可以离线多长时间。注意事项:
session.timeout.ms
,以避免消费者由于短暂的网络问题或其他原因的断开而过早触发重平衡。静态成员功能在处理大规模 Kafka 应用时尤其有用,它提供了一种机制来优化消费者组的性能和稳定性。
Kafka 0.11 版本后提供了幂等性生产者,这意味着即使生产者因为某些错误重试发送相同的消息,这些消息也只会被记录一次。这是通过给每一批发送到 Kafka 的消息分配一个序列号实现的,broker 使用这个序列号来删除重复发送的消息。使用幂等性生产者,可以减少重复消息的风险,这意味着即使在网络重试等情况下,消息的顺序也能得到更好的保证。因为重复消息不会被多次记录,所以不会破坏已有消息的顺序。
Pulsar 和 Kafka 一样,都是通过生产端按 Key Hash 的方案将数据写入到同一个分区。
RabbitMQ 在生产时没有生产分区分配的过程。它是通过 Exchange
和 Route Key
机制来实现顺序消息的。Exchange
会根据设置好的 Route Key
将数据路由到不同的 Queue
中存储。此时 Route Key
的作用和 Kafka 的消息的 Key
是一样的。
RocektMQ 支持消息组(MessageGroup)
的概念。在生产端指定消息组,则同一个消息组的消息就会被发送到同一个分区中。此时这个消息组起到的作用和 Kakfa 的消息的 Key 是一样的。
代码仓库: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 是固定的,则所有消息都会写到同一个分区,是可以实现顺序消息的。
代码目录如下:
├─config
│ config.go # 常量定义
├─consumer
│ consumer.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 &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 个消费者:
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)
}
启动生产者生产消息,然后启动消费者,观察控制台,不难看出这种情况下就是顺序消费:
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 的角度来说是无法完全实现顺序消息的,只能通过静态成员功能、避免分区数量变化和减少消费者组成员数量变化等方式来尽可能减少重平衡的发生,进而尽可能维持消息的顺序性。