如何用redis做一个可靠的消息队列

5,456 阅读6分钟

List

这是大家最常用的队列方式,就是在一个list上用lpush & rpop,如下图所示:

1.png

由于空队列的问题,要引入for循环加上一定的sleep时间,伪代码如下:

for {
  if msg:=redis.rpop();msg!=nil{
    handle(msg)
  }else{
    time.sleep(1000)
  }
}

这种方案可能存在1s处理不及时的风险(虽说在大多场景下基本没有影响)

不过redis有block算子,通过brpop实现阻塞式拉取,可以及时获得数据,伪代码如下:

for {
  # 超时时间为 0,代表无限等待
  if msg:=redis.brpop(0);msg!=nil{
    handle(msg)
  }
}

看起来很完美,解决了不处理不及时的问题,但由于redis client把超时时间设置成0后,redis server在长时间没有接受到消息的情况下,可能会判定该client为无效的链接,从而强制踢下线,所以在消息不是很密集的时候,直接设为0时,还是有一定的风险,建议保留非0的最小等待值(1s即可),伪代码如下:

for {
  if msg:=redis.brpop(1000);msg!=nil{
    handle(msg)
  }
}

既保证了实时性,又避免了链接断开

这样一个最简单的队列就有了,但是要注意这里并不是可靠的队列,主要是消息丢失问题:由于缺失ack机制,消费端在拉到(rpop)消息后宕机(或新版本上线)的话,该消息是很大概率缺失的

其次,我们在看一些消息队列的特性:

  • 支持多组消费者:由于是简单的list,拉出来就消失了,会造成消息只会被某一个消费者消费一次。在一些消费组的场景下,是无法满足的(多个不同业务的消费同一个消费队列)
  • 消息回放:有时候需要回滚消息(线上bug追历史数据、测试等),这时候redis的list也是无法支持该feature的

总结

  • 简单易实现,这也是为什么普遍使用的原因
  • 无ack机制,不可靠
  • 不支持多个消费组
  • 不支持消息回放

Pub/Sub

顾名思义,是redis专门解决分发/订阅问题而产生的命令—— publish & subscribe

# 生产者
127.0.0.1:6379> publish queue 1
(integer) 1

# 消费者 1
127.0.0.1:6379> subscribe queue
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "queue"
3) (integer) 1
1) "message"
2) "queue"
3) "1"

# 消费者 2
127.0.0.1:6379> subscribe queue
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "queue"
3) (integer) 1
1) "message"
2) "queue"
3) "1"

其主要解决了支持多组消费者的问题,如下所示:

2.png

但由于pub/sub本身是没有持久化的能力,只是数据的实时转发,会造成以下几个问题:

  • 消费者下线后,再上线后,由于之前的消息没有存储,老消息都会丢失,只能接收新消息
  • 消费者全下线后,没人消费,消息全都会丢失

所以,这就要求消费者一定要早于生产者先上线,否则会丢失消息

其次,每个订阅关系内部是个buffer,由于buffer是有上限的,一旦超出上限,redis就会强制让消费者下线,造成消息丢失

这里我们看出基于list的队列是pull的模型,而pub/sub是基于push的模型,先推到buffer,然后等消费者来取

总结

  • 支持发布 / 订阅,支持多组消费者
  • 消费者下线会丢失数据
  • 消息堆积会踢掉消费者,造成数据丢失
  • 无ack机制,不可靠

总体来说十分鸡肋,没啥意义,不要考虑该方案

Stream

注意这个feature是在redis 5.0才有的

Stream 通过 xadd 和 xreadgroup来实现消息的生产与消费

# 生产者
127.0.0.1:6379> xadd queue * k1 v1
1636289446933-0
127.0.0.1:6379> xadd queue * k2 v2
1636295029388-0
127.0.0.1:6379> xadd queue * k3 v3
1636291571597-0

# 消费者 1
127.0.0.1:6379> xreadgroup group g1 c1 COUNT 1 streams queue >
1) 1) "queue"
   2) 1) 1) "1636289446933-0"
         2) 1) "k1"
            2) "v1"      
127.0.0.1:6379> xreadgroup group g1 c1 COUNT 1 streams queue >
1) 1) "queue"
   2) 1) 1) "1636295029388-0"
         2) 1) "k2"
            2) "v2"
# 消费者 2
127.0.0.1:6379> xreadgroup group g1 c2 COUNT 1 streams queue >
1) 1) "queue"
   2) 1) 1) "1636291571597-0"
         2) 1) "k3"
            2) "v3"

利用 xack 和 xreadgroup 实现消息的应答与恢复

# 手动 ack
127.0.0.1:6379> xack queue g1 1636289446933-0
(integer) 1

# 查询尚未提交位于 pending 中的消息
127.0.0.1:6379> xreadgroup group g1 c1 COUNT 1 streams queue 0
1) 1) "queue"
   2) 1) 1) "1636295029388-0"
         2) 1) "k2"
            2) "v2"

大多数恢复消费是基于以下的代码实现的

for {
  # 从未ack的消息开始消费
  id:=getLastUnackIDByPending()
  if id=0 {
  # 从未推送的消息开始消费
    id=">"
  }
  msg:=xreadgroup(lastUnAckID)
  handle(msg)
  xack(msg)
}

在消费者不变(数量、唯一id等)的情况下,上述是一个很便捷的方案;但是如果发生变动,就需要引入额外的定时轮询的方案,或者保证消费者一致性的zookeeper方案了,较为复杂,这里不过多展开了,后面会专门开一个stream的专题

与专业的消息队列对比

我们将redis的队列与专业的队列rabbitMQ、kafka对比一下,整体盘点以下两个特性:

  • 消息不丢
  • 消息积压成本低

消息不丢

这是一个整体的话题,需要生产者、消费者、中间件三方配合才能实现

1. 生产者丢失消息的情况

  • 没有成功的发送出去:重试即可
  • 发送出去了,但是回报丢了:这时也只能重试,但可能会导致下游消息重复

所以生产者想要不丢消息就只能重试,这时候消费者就要考虑重试消息的处理,实现幂等性

从这点看,生产者不丢消息与整个中间件无关,完全是业务实现的问题,是否考虑了以上的异常情况

2. 消费者丢失消息的情况

主要是消费者宕机,取出后没有回执

这种场景下,就需要中间件提供一种ack机制,确保哪些消息已经消费掉了,从而保证消息不丢失

这一点redis的stream与kafka、rabbitMQ都是一致的,都有完善的ack机制

3. 中间丢失消息的情况

这其实就是中间件的实现方式了

根据大佬@kaito所说,redis存在两个风险点:

  • aof周期性刷盘,这个过程是异步的,有丢失的风险
  • 主从切换,从库未同步完就被提成主库(存有疑问,没同步完也能提为主库吗🤔️)

而kafka、rabbitMQ则是通过一次写入,多个节点同时ack,才认为写入成功,进一步加强了消息的可靠性

消息积压

  • redis:基于内存,有限的存储空间,积压到一定程度后就会丢弃消息
  • kafka、rabbitMQ:基于硬盘,认为是无限的存储空间

总结

  • 在pub/sub基础上支持ack
  • 支持消息回放
  • 消费者发生变动时,需要引入额外的编码保证消息的可靠性,较为复杂
  • 消息仍有由于组件故障导致丢失的风险

参考

Redis 怎么做消息队列?

官网 Redis Stream 专题