Go+Redis实现消息队列的三种方式
前言
消息队列是一种很常见的工具,常见的应用场景有:系统解耦、异步消费、削峰填谷。
说到消息队列可能会很快的想到Kafka、RocketMQ这些专业的消息队列,其实如果场景合适,Redis也可以当成消息队列使用。
相比于专业的消息队列,Redis最明显的优势就是我们不需要再单独部署Kafka、RocketMQ服务,而且Redis的运维也相对简单。
当然Redis很明显的劣势是不支持大量消息堆积;并且如果AOF持久化配置为每秒写盘,或者主从切换时(从库未与主库完成同步,就被提成主库)可能存在数据丢失问题(把Redis当作队列来用,真的合适吗?)。
这篇文章主要是使用Go+Redis实现几种消息队列方案,并列举存在的问题。
下面的代码使用到了go-redis客户端。
List
Redis的List是一个天然的队列,它的底层数据结构是双向链表(在列表所有字符串元素长度都小于64字节并且列表保存的元素数量小于512使用压缩列表),并且提供了许多Push和Pop操作,比如我们可以使用lpush+brpop从队伍一头入队,从另外一头阻塞的出队。
发送消息
发送消息只是使用LPush命令把消息内容推入到队列。
type Msg struct {
Topic string // 消息的主题
Body []byte // 消息的Body
}
func (q *ListMQ) SendMsg(ctx context.Context, msg *Msg) error {
return q.client.LPush(ctx, msg.Topic, msg.Body).Err()
}
消费消息
我们定义了一个简单方法,它传入一个主题,和一个消息处理器Handler,这个方法不断地循环并使用BRPop从队列获取消息,然后交给传入的消息处理器处理。
// Handler 返回值代表消息是否消费成功
type Handler func(msg *Msg) error
// Consume 返回值代表消费过程中遇到的无法处理的错误
func (q *ListMQ) Consume(ctx context.Context, topic string, h Handler) error {
for {
// 获取消息
result, err := q.client.BRPop(ctx, 0, topic).Result()
if err != nil {
return err
}
// 处理消息
err = h(&Msg{
Topic: result[0],
Body: []byte(result[1]),
})
if err != nil {
return err
}
}
}
这里会存在一个问题,如果消费者无法正常消费消息,消息会丢失,因为消息一旦被取出就不再存在Redis。
实现ACK机制
其实我们可以每次先取出消息,但不删除消息,直到消费成功后再把消息删除。
我们需要使用lindex从队列取出消息,如果消费成功再使用rpop删除消息。虽然blmove可以实现阻塞的版本,但是需要Redis6.2。
// Consume 返回值代表消费过程中遇到的无法处理的错误
func (q *ACKListMQ) Consume(ctx context.Context, topic string, h Handler) error {
for {
// 获取消息
body, err := q.client.LIndex(ctx, topic, -1).Bytes()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
// 没有消息了,休眠一会
if errors.Is(err, redis.Nil) {
time.Sleep(time.Second)
continue
}
// 处理消息
err = h(&Msg{
Topic: topic,
Body: body,
})
if err != nil {
continue
}
// 如果处理成功,删除消息
if err := q.client.RPop(ctx, topic).Err(); err != nil {
return err
}
}
}
当然这样就会出现一个问题就是没办法同时多个消费者进行消费,这个问题我们在下面解决。
实现多分区
上面我们虽然实现了ACK,但是却造成了一个队列只能被一个消费者消费,其实我们只要多搞几个队列,那么就可以同时被几个消费者进行消费,类似于Kafka的分区。
发送消息
我们给消息多加一个分区字段,然后把消息发送到对应主题的对应分区,其实也就是给主题加一个后缀,比如topic:0、topic:1。
type Msg struct {
Topic string // 消息的主题
Body []byte // 消息的Body
Partition int // 分区号
}
func (q *PartitionACKListMQ) SendMsg(ctx context.Context, msg *Msg) error {
return q.client.LPush(ctx, q.partitionTopic(msg.Topic, msg.Partition), msg.Body).Err()
}
func (q *PartitionACKListMQ) partitionTopic(topic string, partition int) string {
return fmt.Sprintf("%s:%d", topic, partition)
}
消费消息
和上面代码相同,只是主题多加了一个分区号。
// Consume 返回值代表消费过程中遇到的无法处理的错误
func (q *PartitionACKListMQ) Consume(ctx context.Context, topic string, partition int, h Handler) error {
for {
// 获取消息
body, err := q.client.LIndex(ctx, q.partitionTopic(topic, partition), -1).Bytes()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
// 没有消息了,休眠一会
if errors.Is(err, redis.Nil) {
time.Sleep(time.Second)
continue
}
// 处理消息
err = h(&Msg{
Topic: topic,
Body: body,
Partition: partition,
})
if err != nil {
continue
}
// 如果处理成功,删除消息
if err := q.client.RPop(ctx, q.partitionTopic(topic, partition)).Err(); err != nil {
return err
}
}
}
进一步改进
可以实现消费者自动分配分区的功能,消费者手动指定分区不仅很繁琐,同时还很容易出错
存在的问题
- 不支持多个消费者组
适合不需要多个消费者组的场景。
Pub/Sub
Redis的发布订阅功能由publish、subscribe和psubscribe等命令实现。
消费者通过 subscribe订阅某些频道(channels),还可以通过psubscribe订阅某些模式(patterns)的频道;
而生产者则通过publish向某个频道发送消息。
发送消息
直接使用publish命令发送消息到一个频道,这里我们直接做了分区处理。
func (q *PubSubMQ) SendMsg(ctx context.Context, msg *Msg) error {
return q.client.Publish(ctx, q.partitionTopic(msg.Topic, msg.Partition), msg.Body).Err()
}
消费消息
使用subscribe命令订阅某个频道,然后消费消息。
// Consume 返回值代表消费过程中遇到的无法处理的错误
func (q *PubSubMQ) Consume(ctx context.Context, topic string, partition int, h Handler) error {
// 订阅频道
channel := q.client.Subscribe(ctx, q.partitionTopic(topic, partition)).Channel()
for msg := range channel {
// 处理消息
h(&Msg{
Topic: topic,
Body: []byte(msg.Payload),
Partition: partition,
})
}
return errors.New("channel closed")
}
改进点
subscribe和psubscribe都支持一次订阅多个频道或多个模式的频道,所以可以使用一次订阅多个分区,甚至不同主题的队列。
比如下面代码每个消费者可以一次消费多个分区的消息:
// ConsumeMultiPartitions 返回值代表消费过程中遇到的无法处理的错误
func (q *PubSubMQ) ConsumeMultiPartitions(ctx context.Context, topic string, partitions []int, h Handler) error {
// 订阅频道
channels := make([]string, len(partitions))
for i, partition := range partitions {
channels[i] = q.partitionTopic(topic, partition)
}
channel := q.client.Subscribe(ctx, channels...).Channel()
for msg := range channel {
// 处理消息
_, partitionString, _ := strings.Cut(msg.Channel, ":")
partition, _ := strconv.Atoi(partitionString)
h(&Msg{
Topic: topic,
Body: []byte(msg.Payload),
Partition: partition,
})
}
return errors.New("channels closed")
}
存在的问题
看起来
-
消费者只能接收到在它订阅之后的消息,在订阅成功之前的消息都会丢失;也就是说如果生产者先发送消息,消费者再订阅是接收不到的。
-
Pub/Sub不会持久化消息,Redis下线消息将丢失。
-
消息缓冲区达到无法暂存太多消息,只要满足下面其中一个条件Redis就会直接把消费者踢下线:
- 缓冲区达到32MB
- 缓冲区达到8MB并且持续60秒
所以Pub/Sub只适合对消息可靠性没有要求的场景。
Stream
Stream是Redis5.0提供的一个新的数据结构,它支持xadd推送消息,xreadgroup指定消费者组的某个消费者进行消费,xack用于表示一条消息已经成功消费。
发送消息
我们使用xadd往一个stream添加消息。
这里调用翻译成Redis命令就是XADD msg.Topic MAXLEN q.approx q.maxLen * body msg.Body
其中:
-
Stream:表示流的名字
-
ID:表示消息的唯一编号,它由两部分组成
<unix_time_milliseconds>-<sequence_number_in_same_millisecond>,也就是当前时间戳毫秒数加上当前毫秒数的序列号,类似于雪花算法。- 可以像这样
1526919030474-55自己指定时间戳和序列号; - 也可以像这样
1526919030474-*自己指定时间戳和让Redis生成序列号; - 还可以像这样
*让Redis生成时间戳和序列号。我们这里就是使用这种形式。
- 可以像这样
-
Values:表示
消息内容键值对。我们的消息只有body,所以只设置了一个键值对。 -
MaxLen和Approx:指定了MaxLen可以
让Redis在消息数量大于此值时删除旧消息,避免内存溢出;而Approx是配合MaxLen使用的,表示几乎精确的删除消息,也就是不完全精确,由于stream内部是流,所以设置此参数xadd会更加高效。
func (q *StreamMQ) SendMsg(ctx context.Context, msg *Msg) error {
return q.client.XAdd(ctx, &redis.XAddArgs{
Stream: msg.Topic,
MaxLen: q.maxLen,
Approx: q.approx,
ID: "*",
Values: []interface{}{"body", msg.Body},
}).Err()
}
消费消息
首先,我们使用xgroup create命令创建消费者组,当然这里可能会重复创建,重复创建会报错,我们忽略这个错误。其中的start参数表示该消费者组从哪个位置开始消费消息,可以指定为ID或$,其中$表示从最后一条消息开始消费。
然后我们使用xreadgroup命令阻塞的获取消息,其中参数的含义是:
-
Group:消费者组
-
Consumer:消费者组里的消费者
-
Streams:消费的流。
- 这里后面还有一个
">"其实是属于ID参数,表示只接收未投递给其他消费者的消息; - 如果指定ID为数值,则表示只接收大于这个ID的已经被拉取却没有被ACK的消息。
- 所以我们这里先使用
>拉取一次最新消息,再使用0拉取已经投递却没有ACK的消息,保证消息都能够成功消费。
- 这里后面还有一个
-
Count:一次性读取消息的条数,减少网络传输时间。
如果成功消费,我们再使用xack指令提交消费位点,这样这条消息就不会再次被投递了。
// Consume 返回值代表消费过程中遇到的无法处理的错误
// group 消费者组
// consumer 消费者组里的消费者
// batchSize 每次批量获取一批的大小
// start 用于创建消费者组的时候指定起始消费ID,0表示从头开始消费,$表示从最后一条消息开始消费
func (q *StreamMQ) Consume(ctx context.Context, topic, group, consumer, start string, batchSize int, h Handler) error {
err := q.client.XGroupCreateMkStream(ctx, topic, group, start).Err()
if err != nil && err.Error() != errBusyGroup {
return err
}
for {
// 拉取新消息
if err := q.consume(ctx, topic, group, consumer, ">", batchSize, h); err != nil {
return err
}
// 拉取已经投递却未被ACK的消息,保证消息至少被成功消费1次
if err := q.consume(ctx, topic, group, consumer, "0", batchSize, h); err != nil {
return err
}
}
}
func (q *StreamMQ) consume(ctx context.Context, topic, group, consumer, id string, batchSize int, h Handler) error {
// 阻塞的获取消息
result, err := q.client.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: group,
Consumer: consumer,
Streams: []string{topic, id},
Count: int64(batchSize),
}).Result()
if err != nil {
return err
}
// 处理消息
for _, msg := range result[0].Messages {
err := h(&Msg{
ID: msg.ID,
Topic: topic,
Body: []byte(msg.Values["body"].(string)),
Group: group,
Consumer: consumer,
})
if err == nil {
err := q.client.XAck(ctx, topic, group, msg.ID).Err()
if err != nil {
return err
}
}
}
return nil
}
存在的问题
看起来Stream已经实现了一个功能完整的消息队列,但是它也是基于Redis实现的,所以一开始提到的不支持大量消息堆积和可能的消息丢失问题还是存在。
特别是消息都存储在内存中,如果不小心可能会溢出导致Redis宕机。
总结
- 如果能够使用Stream(Redis5.0以上),可以优先考虑它,因为它的功能是最完整的;
- 否则也可以使用list,不过它不支持多个消费者组;
- 如果对消息可靠性没有要求,也可以使用Pub/Sub模式。