当前位置:首页 > 问答 > 正文

Redis消息订阅和列表怎么结合用,消息传递那块儿实现细节还有点复杂

Redis本身提供了两种主要的消息传递机制:基于Pub/Sub的发布订阅模式和基于List的队列模式,它们各有优缺点,而在实际应用中,为了满足更复杂、更可靠的需求,我们常常需要将两者结合起来使用,你提到的“实现细节有点复杂”,正是源于这种结合需要解决Pub/Sub模式的核心缺陷:消息的不可持久化和无状态性

我们明确一下各自的问题

想象一下Pub/Sub就像是一个广场上的大喇叭广播(来源:常见的网络通信比喻),一个发布者(Publisher)对着喇叭喊一句话(发布消息),所有当时正在广场上竖着耳朵听(订阅)的人(Subscriber)都能听到,这看起来效率很高,对吧?但问题也很明显:

  1. 如果你迟到了:如果你在广播结束后才来到广场,那么你永远错过了那条消息,Pub/Sub不保存历史消息,它只负责转发“当下”的消息。
  2. 如果网络闪断:假设你正在听广播,突然一阵风沙迷了眼睛(网络临时中断),等你揉完眼睛,广播已经结束了,消息同样丢失了。

这对于要求消息必达的业务场景(如订单处理、支付通知)是致命的。

而Redis的List则像一个传送带或者一个邮箱(来源:队列的标准比喻),生产者(Producer)将消息像一个一个包裹一样放入列表的尾部(RPUSH),消费者(Consumer)从列表的头部(LPOP)取出消息进行处理,这个模式的优点是:

  1. 消息持久化:只要Redis不崩溃,消息就会一直待在列表里,直到被取走。
  2. 支持多个消费者:虽然一个消息只能被一个消费者取走,但你可以启动多个消费者 worker 同时从同一个列表中争抢消息,实现负载均衡。

但它的缺点是不够“实时”,消费者需要不停地去问传送带:“有我的新包裹吗?”(这就是所谓的“轮询”),即使使用BLPOP这样的阻塞命令,它本质上也是一种等待式的拉取,不如Pub/Sub的推送模式来得直接和高效。

如何结合两者,取长补短呢?

结合的核心思想是:用Pub/Sub的高效通知来触发对List的可靠消费,我们不再让消费者傻傻地轮询List,而是让它去订阅一个频道,当有消息需要处理时,才通过频道通知消费者“有活儿干了”,消费者再去List中安全地取出消息。

Redis消息订阅和列表怎么结合用,消息传递那块儿实现细节还有点复杂

下面是一个典型的实现方案和细节:

架构角色

  1. 生产者:它的工作稍微复杂一点,它不再只做一件事,当有一条新消息需要发送时,它需要完成两个步骤:
    • 步骤一:将消息持久化到一个指定的任务List中(例如使用RPUSH my_task_list “{...}”),这一步确保了消息不会因为通知丢失而彻底消失。
    • 步骤二:向一个特定的通知Channel发布一条简单的通知消息(例如使用PUBLISH my_notify_channel “1”),这条通知消息的内容通常很简单,甚至只是一个数字”1“或者”new_task“,它的目的仅仅是作为一个信号灯,告诉消费者“列表里有新东西了”。
  2. 消费者:它的工作流程也变成了两部分:
    • 步骤一:启动时,首先订阅那个通知Channel(SUBSCRIBE my_notify_channel),然后它就进入等待状态,就像挂机一样,资源消耗很低。
    • 步骤二:一旦从Channel收到“有新任务”的通知,它就会从等待状态被唤醒,它转向那个任务List,使用LPOP(或BLPOP)命令取出一个实际的任务消息进行处理。

为什么说细节复杂?关键点与挑战

这个看似简单的流程,在实现时有几个容易出错的“坑”,这也是复杂性的来源:

Redis消息订阅和列表怎么结合用,消息传递那块儿实现细节还有点复杂

  1. 通知丢失与重复处理:这是最核心的问题,考虑一种情况:生产者完成了步骤一(消息入List),但在执行步骤二(发布通知)之前,Redis意外重启了,消息已经存在于List中,但没有任何消费者收到通知,这条消息就会永远躺在List里无人问津,成为“死信”,为了解决这个问题,消费者不能完全依赖通知,一个健壮的消费者在启动时,除了订阅频道,还应该先检查一下任务List是否已经有积压的未处理消息,也就是说,消费者在订阅前应该先清空当前List中的消息,然后再开始监听通知,这防止了因通知丢失导致的消息堆积。

  2. 惊群效应:如果有多个消费者同时订阅了同一个通知Channel,当一条通知发出时,所有消费者都会被唤醒,然后一窝蜂地去List里抢任务,但通常List里只有一个新任务,这就意味着只有一个消费者能抢到(通过LPOP的原子性保证),其他消费者都会扑空,然后白忙活一场,造成资源浪费,为了缓解这一点,通知消息的内容可以稍微丰富一点,比如包含List中的大致任务数量,这样消费者可以根据任务量来决定是否全部投入工作,或者采用更复杂的机制。

  3. 消息的原子性:生产者的两个操作(入List和发通知)不是原子性的,我们无法在一个Redis事务中同时完成对List和Channel的操作(因为事务不支持Pub/Sub),这是该模式的一个固有弱点,我们才需要上面第1点提到的“启动时检查List”作为补偿机制来保证可靠性。

  4. 消费者崩溃处理:如果消费者在收到通知后、从List取出消息前崩溃,或者取出消息后处理失败,怎么办?使用LPOP会导致消息一旦取出就从List中永久删除,对于要求严格不丢消息的场景,可能需要更复杂的机制,例如使用LRANGE查看消息而不弹出,处理成功后再用LREM删除,但这又会带来重复处理的问题,有时可以引入另一个“处理中列表”来跟踪状态。

总结一下

将Redis的Pub/Sub和List结合,本质上是构建了一个 “带通知机制的可靠队列” ,Pub/Sub负责高效的、广播式的唤醒信号,解决了轮询带来的延迟和资源消耗;List则作为可靠的消息存储后端,解决了Pub/Sub消息易失的问题,实现上的复杂性主要来自于如何弥补这两个独立操作(存列表和发通知)之间缝隙,需要通过启动时扫描、异常处理、谨慎选择命令等细节设计来确保消息最终不会被漏掉,这是一种在简单与可靠之间取得的非常经典的平衡。