1. 为什么Redis队列能火这么多年
做后端开发的,几乎没人能绕开Redis。我最早接触Redis队列是在一个秒杀项目里,那时候库存扣减用数据库行锁,QPS一上来直接把MySQL打崩了,后来改成Redis队列串行化处理,同样的机器配置,吞吐直接翻了几倍。说实话,Redis队列这个方案,技术含量并不算高,但它在很多场景下就是最好用的那个,没有之一。
聊Redis队列之前,先理清一个概念:队列本质上就是一个先进先出的数据结构。你往队尾塞任务,从队头取任务,谁先来谁先被处理。这个逻辑听上去简单,但放到分布式系统里,"谁先来"和"谁先被处理"之间隔着网络延迟、线程调度、进程崩溃、消息丢失等一系列问题。Redis队列能成为经典,不是因为它解决了所有问题,而是它在一个合理范围内把这些问题处理得足够好,而且足够简单。
这篇文章我打算从四个维度讲透Redis队列和阻塞队列:
- Redis原生列表的队列实现,以及BRPOP/BLPOP这类阻塞命令的底层原理
- 为什么说Redis队列是"轻量消息队列",和Kafka、RabbitMQ、RocketMQ这些专业MQ到底差在哪
- 生产环境里我用Redis队列踩过的坑:消息丢失、重复消费、队列积压、消费者组混乱
- 什么时候该换RabbitMQ/Kafka/RocketMQ,什么时候继续用Redis,给一个可操作的选型判断框架
如果你是刚接触后端开发,这部分内容能帮你建立起对队列的整体认知;如果你已经用Redis做队列很久了,那后面那些坑和排查思路,应该能帮你解决一些实际困扰。
2. 先看Redis队列的底层实现和阻塞机制
2.1 列表(List)就是最简单的队列
Redis的List数据结构,底层是双向链表(快速列表quicklist),天生支持在头部和尾部操作元素。用四个命令就能拼出一个完整队列:
# 生产者:从右侧推入任务 LPUSH queue:task task_001 LPUSH queue:task task_002 # 消费者:从左侧弹出任务 RPOP queue:task这就是最原始的队列模型。LPUSH往左边塞,RPOP从右边取,先进去的任务先从右边出来,先进先出。
但直接这样用有个很明显的问题:当队列里没任务时,消费者会不停地空转调用RPOP,产生大量无效的Redis请求,白白消耗CPU和网络带宽。你想象一下,一个消费者线程每100毫秒拉一次,100个消费者就是每秒1000次空请求,虽然Redis单机扛得住,但这毫无意义。
2.2 BRPOP和BLPOP:阻塞式弹出的价值
Redis提供了阻塞版本的弹出命令,这就是阻塞队列的起点:
# 阻塞弹出,最多等待30秒 BRPOP queue:task 30 # 阻塞弹出,从左侧弹(配合RPUSH使用) BLPOP queue:task 30BRPOP和RPOP的区别在于:如果队列为空,BRPOP会阻塞住当前连接,直到有新元素推入或者超时。整个等待过程不消耗CPU,Redis底层是用epoll事件驱动的,连接挂起时CPU占用接近零。
这个设计解决了一个核心问题:消费者不用轮询了,来了任务立刻被唤醒,没任务就安静地挂着。从业务视角看,任务从生产到被消费的延迟也大幅降低,因为消息一旦入队,阻塞中的消费者会被立即唤醒,几乎是毫秒级响应。
我自己在项目里测过一组数据:用RPOP轮询,任务平均消费延迟在50~200毫秒(取决于轮询间隔);换成BRPOP后,平均延迟降到1~5毫秒。后面这个数字在异步任务、即时通知这些场景里非常重要。
注意:BRPOP的阻塞是作用于单个Redis连接的。如果你用连接池,消费者从连接池拿连接时,可能拿到一个没有被阻塞的连接,这是连接池模式下常见的一个坑,后面会详细说。
2.3 阻塞队列的可靠性瓶颈:消息丢失
Redis列表队列最大的痛点是:消息从队列里弹出来之后,如果消费者处理失败,消息就丢了。
# 示例:处理失败后消息直接消失 BRPOP queue:task 30 # 拿到task_001,处理失败,task_001再也没机会被重试Kafka有消费者位移提交机制,消费失败可以重新拉取;RabbitMQ有ack确认机制,没确认的消息会重新入队;但原生Redis List真的没有这个能力。它就是一个数据结构,不是一个完整的消息中间件,它不关心你处理成功没有,弹出去就完事。
业界通常用"备份队列"的方式来缓解这个问题:
# 步骤一:原子性地弹出任务并备份到处理中队列 # Lua脚本实现:RPOPLPUSH queue:task queue:task-processing # 步骤二:处理成功后,从processing队列里删除 LREM queue:task-processing 1 task_001 # 步骤三:处理失败,把任务重新丢回主队列 LPUSH queue:task task_001RPOPLPUSH这个命令是原子操作,它把元素从主队列弹出来,同时推入另一个备份队列。这样即使消费者处理到一半崩溃了,任务还在processing队列里躺着,恢复后可以重新处理。不过这个方案需要额外的清理逻辑,复杂度上去了,但没有比这更好的轻量办法。
2.4 延迟队列和优先级队列的变体实现
如果业务需要延迟执行任务(比如订单超时未支付自动关闭,这种场景很多),可以用Redis ZSet(有序集合)来实现延迟队列:
# 添加延迟任务:score是执行时间戳 ZADD queue:delay 1720000000 order_12345 # 消费者循环取出到期的任务 ZRANGEBYSCORE queue:delay 0 now_time LIMIT 0 10 # 取出后从ZSet中移除,再丢入真正的处理队列优先级队列可以用多个List实现,每个优先级一个List,消费者优先从高优先级队列取任务:
BRPOP queue:high queue:normal 0BRPOP支持传入多个key,Redis会从左到右依次阻塞检查。这个语法其实已经内置了简单的优先级逻辑,不用自己额外设计。
3. Redis队列和Kafka/RabbitMQ/RocketMQ的本质差别
3.1 三类消息队列的定位区分
热词里出现了"kafka、rabbitmq、rocketmq消息队列选型实战对比与避坑指南",说明大家在选型这件事上确实容易纠结。先给出我的结论:
| 维度 | Redis List | RabbitMQ | Kafka | RocketMQ |
|---|---|---|---|---|
| 定位 | 数据结构 | 消息中间件 | 分布式流平台 | 消息中间件 |
| 吞吐量 | 单机约10万级QPS | 单机万级~十万级 | 百万级 | 十万级+ |
| 消息确认 | 无 | ACK机制完善 | 位移提交 | 确认机制完善 |
| 持久化 | RDB/AOF | 磁盘 + 内存 | 磁盘顺序写 | 磁盘顺序写 |
| 消费模式 | 竞争消费 | 竞争+广播 | 分区消费组 | 竞争+广播+事务 |
| 死信队列 | 无 | 有 | 无(需自建) | 有 |
| 消息顺序性 | 单List有序 | 单队列有序 | 分区内有序 | 队列内有序 |
| 延迟消息 | 需自研 | 插件支持 | 需自研 | 内置支持 |
| 学习成本 | 低 | 中 | 中高 | 中 |
| 运维成本 | 极低 | 中 | 高 | 中 |
一句话总结:Redis List是"用数据结构的思路解决消息问题",RabbitMQ/RocketMQ是"专业的消息中间件",Kafka是"高吞吐日志流平台"。它们不在一个维度上,没有绝对的谁替代谁,只看场景合不合适。
3.2 数据安全性和消息不丢失的差距
这是最重要的差距,没有之一。用Redis队列,Redis宕机丢消息是正常现象。虽然Redis有RDB快照和AOF日志,但AOF默认是everysec策略,最多可能丢一秒的数据。专业MQ在设计之初就把"不丢消息"作为核心目标:
- RabbitMQ:消息写入队列后,消费者必须显式发送ack,否则消息不会删除,而且队列和消息都支持持久化到磁盘
- Kafka:消息按分区顺序写入磁盘日志,副本机制保证多个broker上有冗余,leader挂了自动选新leader
- RocketMQ:同步刷盘 + 主从复制,事务消息还支持本地事务和消息事务的两阶段提交
在订单、支付、交易流水这种场景里,丢一条消息可能就是线上事故。Redis队列在这个层面的安全性,和商业级消息队列完全不是一个量级。
3.3 消费失败后的重试机制差异
举一个实际场景:用户下单后,系统要发短信通知。用Redis队列做:
# 消费短信任务 task = BRPOP queue:sms 30 # task是字符串,假设是{"userId": 123, "mobile": "138...", "content": "..."} # 消费者开始调用短信服务商API # 调用失败,task已经弹出,不存在了如果没有RPOPLPUSH备份队列,这条短信就永远发不出去了。而用RabbitMQ:
- 消费者处理失败可以抛出异常,消息会重新回到队列
- 可以配置重试次数上限,超过上限自动进入死信队列
- 运维可以在死信队列里查看、补发、修复
这个差距在企业级系统里很致命,因为消息丢失不可怕,可怕的是你不知道丢了。Redis队列丢了消息,你连一条日志都找不到,RabbitMQ的死信队列至少让你知道"有这么多消息失败了"。
4. 生产环境用Redis队列的实操经验与避坑指南
4.1 连接池与BRPOP的"假阻塞"坑
前面提过,连接池模式下BRPOP可能失效。原因是:连接池里的连接是复用的,如果你某个连接已经在BRPOP阻塞中,连接池再次分配这个连接给其他线程时,那个线程会被迫接管这个阻塞中的连接,行为变得不可预期。
我的建议是:如果消费任务量不大,可以单独建一个直连消费者,不走连接池:
# Python示例,仅供参考 import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) while True: # 这个连接是专用连接,不会被连接池复用 task = r.brpop('queue:task', timeout=30) if task: item = task[1] # 处理业务逻辑 process(item)如果消费并发高,那就要保证每个消费者线程持有独立连接,并且从连接池获取连接后不做长时间阻塞操作。更稳妥的方案是结合多路复用或者使用Redis Stream做消费者组。
4.2 消息重复消费问题的根源
Redis队列天然可能重复消费:消费者A从队列弹出消息,还没来得及处理,进程崩溃了,消息其实已经从List里弹出并丢失。如果你用RPOPLPUSH备份队列,恢复后重新入队,那又可能造成重复——原来的消费者可能其实处理了一半,备份里的消息再被消费一次。
没有完美的解决方案,只能从业务侧做幂等。幂等的意思非常直白:同样的请求处理一万遍,结果和只处理一遍完全一样。实现幂等的常见方法:
- 数据库唯一约束:消费时按业务ID插入记录,插入成功才继续,失败说明重复,直接跳过
- Redis记录已处理ID:用SETNX记录消息ID,已存在的直接忽略
- 状态机校验:处理前查一下业务单据当前状态,已经处理过就不再处理
这些方案不是Redis队列特有的,任何消息队列都会遇到重复消费问题,只是RabbitMQ和Kafka有消息确认机制,重复消费的概率相对可控。Redis这层的责任完全落到开发者头上,必须自己兜底。
4.3 队列积压的监控和处理策略
队列积压在Redis侧表现为:LPUSH成功但BRPOP迟迟没有消费,List长度持续增长。这时候怎么看?用Redis自带命令:
# 查看队列长度 LLEN queue:task生产环境建议做监控脚本,定时采集队列长度,超过阈值就报警。我们当时设定的是:队列长度连续5分钟超过1万,触发告警,运维介入排查。
积压的常见原因和对应策略:
- 消费者挂了:检查消费者进程,重启或扩容
- 消费者处理太慢:单条消息处理耗时是否过高?有没有慢SQL、外部API调用超时?
- 生产速度激增:流量高峰,需要消费者扩容
- 死循环消息:某条消息一直在失败重试,每次消费都异常但是不入死信队列,要排查消息内容和处理逻辑
另外建议给消息带一个时间戳字段,消费时如果发现消息在队列里待的时间超过阈值,可以单独走补偿逻辑。这比盲扫队列更有效。
4.4 Redis Stream:官方推荐的"队列替代方案"
Redis 5.0引入的Stream,从功能上就是为了解决List队列的不足:
# 生产者:添加消息 XADD queue:stream * field1 value1 field2 value2 # 消费者:创建消费组 XGROUP CREATE queue:stream group1 0 # 消费者组内读取 XREADGROUP GROUP group1 consumer1 COUNT 10 BLOCK 3000 STREAMS queue:stream > # 手动确认消息 XACK queue:stream group1 message_idStream的核心改进在于:
- 消息有唯一ID,消费者组管理每个消费者的消费进度
- XACK确认机制,未确认的消息可以重新投递
- 支持消费者组模式,消息不会重复被组内消费者消费
- 消息持久化在Redis里,宕机后可以恢复
说实话,如果你必须在Redis生态里做"更可靠"的队列,Stream是比List更合理的选择。不过它也有自己的问题:消息积压时Redis内存压力大,消费进度管理和RabbitMQ/Kafka相比还是偏弱,对大规模场景并不友好。
4.5 消费者代码常见Bug实录
我见过不止一次同事踩这样的坑:
# 错误示例:BRPOP超时时间为0,但没处理None的情况 task = r.brpop('queue:task', timeout=0) # 0表示无限期阻塞 if task is None: continue # 这句永远不会执行 process(task[1])BRPOP的timeout如果设为0,就是无限阻塞,任务不到永远不会返回。如果你习惯性地写if task is None分支,这个分支永远不会跑,但控制流看起来一切正常,逻辑也没有报错,排查起来非常隐蔽。
另一个常见坑:消费者里用了慢SQL和外部API调用,单条消息处理时间超过BRPOP超时时间。这时超时后连接会自动关闭,处理中的任务可能还在执行,但实际上已经丢了。处理耗时长的消息应该先取出再加处理锁,处理完手动删除,而不是依赖BRPOP的超时来释放连接。
5. 线程池阻塞队列选择与Java实现细节
5.1 线程池的阻塞队列怎么选
热词里有"线程池的阻塞队列选择",这个话题和Redis阻塞队列的关系很微妙。Java的ThreadPoolExecutor构造函数里,workQueue参数决定了任务排队的方式。常见的选择:
| 队列类 | 特性 | 适用场景 |
|---|---|---|
| LinkedBlockingQueue | 无界队列(默认Integer.MAX_VALUE) | 任务量平稳,不适合高并发突发 |
| ArrayBlockingQueue | 有界队列,容量固定 | 需要控制积压量,适合限流 |
| SynchronousQueue | 不存储任务,直接交给线程 | 线程数能动态增长,适合CPU密集型 |
| PriorityBlockingQueue | 按优先级取任务 | 需要任务优先级场景 |
你可能会问,这和Redis队列有什么关系?关系其实很大——JVM层面的阻塞队列是"进程内队列",Redis队列是"跨进程队列"。如果服务是单机单进程,用LinkedBlockingQueue就够了;但服务一旦拆成多个实例部署,JVM队列就无法共享,这时候才需要Redis队列这类中间态存储。
5.2 用Java消费Redis队列的完整示例
一个最简单的可运行示例,使用Spring Boot + Redis的BRPOP消费:
@Component public class RedisQueueConsumer { private static final Logger log = LoggerFactory.getLogger(RedisQueueConsumer.class); @Autowired private StringRedisTemplate redisTemplate; // 建议在应用启动后开启消费线程 @PostConstruct public void startConsume() { ExecutorService executor = Executors.newSingleThreadExecutor(); executor.submit(() -> { while (!Thread.currentThread().isInterrupted()) { try { // 阻塞弹出,最多等待5秒 String task = redisTemplate.opsForList().rightPop("queue:task", 5, TimeUnit.SECONDS); if (task == null) { continue; } // 处理业务 handleTask(task); } catch (Exception e) { log.error("消费Redis队列异常", e); // 避免异常导致循环退出 try { Thread.sleep(1000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } }); } private void handleTask(String task) { // 业务逻辑:解析JSON、调用服务、更新数据库等 log.info("处理任务: {}", task); } }注意几个细节:
rightPop(key, timeout, unit)是阻塞版本,timeout不能太长或太短,实际生产建议30秒到60秒- 整个消费循环必须包try-catch,否则一次异常可能导致整个消费线程退出
- 消费线程建议独立配置,不要和其他业务线程混用线程池
- 如果任务量特别大,可以多开几个消费者线程,但要注意消费线程数过多会增加Redis连接压力
5.3 消费端的高可用设计
我之前提到过用RPOPLPUSH做备份队列,在Java里对应方法是:
// 原子弹出主队列任务,推入备份队列 String task = redisTemplate.opsForList().rightPopAndLeftPush("queue:task", "queue:task-processing"); try { handleTask(task); // 处理成功,从备份队列删除 redisTemplate.opsForList().remove("queue:task-processing", 1, task); } catch (Exception e) { // 处理失败,从备份队列重新放回主队列 redisTemplate.opsForList().leftPush("queue:task", task); }rightPopAndLeftPush在Redis里对应RPOPLPUSH命令,是原子操作,杜绝了"弹出成功但备份失败"的中间状态。这一步是Redis队列可靠性方案的基石,虽然不能和RabbitMQ比,但至少让消息在程序正常异常的情况下不会直接消失。
6. 消息队列选型实战对比与避坑指南
6.1 什么时候该用Redis队列
根据我的经验,以下场景Redis队列是合适的:
- 任务量不大且不重要的异步处理:比如发短信、发通知、清理临时文件,失败掉一两条影响不大
- 内部系统的简单异步解耦:比如订单创建后触发一系列非核心动作,这些动作用Redis队列串行化执行即可
- 低延迟场景:Redis BRPOP的唤醒延迟是毫秒级,比Kafka的批量拉取延迟低很多
- 团队技术栈简单,不想引入额外的消息中间件:如果团队只有Redis,加一个Kafka集群的运维成本可能比Redis队列带来的便利成本还高
注意这里的关键前提是"业务对消息可靠性要求不高"。如果一条消息丢了会造成资损、需要追溯、需要补偿,那就别用Redis List了。
6.2 什么时候必须上专业MQ
反向场景,以下条件只要命中两条以上,就应该认真考虑RabbitMQ/Kafka/RocketMQ:
- 消息不能丢:支付回调、订单状态变更、库存扣减
- 需要消息确认机制:消费者处理失败要能重试,重试到一定次数还能进死信
- 消息量级大:单日千万级以上,消费者要水平扩展
- 需要消费组模式:多个实例竞争消费,同一个消息不能被重复处理
- 需要消息顺序性保证:同一个订单ID的消息必须按生产顺序被消费
- 需要持久化到磁盘:Redis宕机后不能丢失
具体到三者选谁:
- RabbitMQ:适合中小规模系统,路由灵活(direct/topic/fanout),消息可靠性和管理界面都很友好,Java/PHP/Python生态都很成熟
- Kafka:适合大数据量日志收集、流式计算、埋点数据,吞吐极高,但对消息路由的灵活性支持较差,更适合"流水线"式的数据管道
- RocketMQ:适合大规模业务消息,顺序消息、事务消息、延迟消息都有原生支持,金融互联网场景经常选它
6.3 双写Redis和MQ的混合方案
我实际生产里见过不少系统是"双写"方案——重要的消息走RabbitMQ/Kafka,非关键的消息走Redis队列。这样既享受了专业MQ的可靠性,又保留了Redis的轻量高效。
但双写方案有个坑要提前警戒:不要在同一个事务里同步写Redis和MQ。因为Redis和MQ是两个独立存储,事务无法跨系统保证原子性。正确的做法是:
- 业务先落数据库,状态标记为"待发送"
- 异步任务扫描"待发送"记录,写入MQ或Redis队列
- 消费端处理后更新状态为"已发送"
这个模式的本质是"本地消息表",可靠性比双写在同一个事务里高很多。如果消息丢失了,扫描任务可以补偿重发。
6.4 别被"分布式锁"和"队列"混为一谈
热词里有"redis分布式锁",这跟队列是完全两码事,但经常被人混淆。分布式锁解决的是"多个实例互斥执行同一任务",队列解决的是"任务按顺序逐个执行"。虽然场景相关,但机制完全不同。用Redis做分布式锁(SETNX + 过期时间)本身也是高并发下的一个经典话题,但它不是队列,别在队列文章里套用分布式锁的思路去解决顺序问题。
7. 常见问题与排查技巧实录
7.1 队列长度快速增长怎么办
第一步先确认消费者是否在运行。很多"队列积压"最后发现是消费者进程挂了,Redis里面堆了一堆消息。用命令查一下:
INFO clients # 查看connected_clients数量,如果消费者在跑,连接数应该正常如果连接数正常,继续查消费速度。可以在消费端打日志,统计每分钟处理的消息量。如果处理速度跟不上生产速度,要么加消费者,要么优化单条消息的处理耗时。
另一个隐蔽原因:消费者在某种异常下陷入了死循环,反复消费同一条消息失败又重试,其他消息一直堆在队列里。排查方式是看队列里最早的消息ID和最近的消息ID,如果最老的长时间不消费,说明有可能卡在死循环。
7.2 消息丢失后如何定位
Redis List模式消息丢了很难定位。因为弹出就没了,没有日志、没有记录。如果你上了RPOPLPUSH备份队列,还可以排查processing队列里有哪些消息一直没有被删除,这些就是处理失败的。
如果连备份队列都没有加,那就从源头查:检查生产者是不是真的把消息写进去了?消费者弹出之后到处理成功之间有没有异常分支没有处理?这类问题只能靠加日志和加补偿任务来做善后。
7.3 Redis主从切换对队列的影响
热词里有"docker安装redis主从"和"redis主从",主从架构下Redis队列有一些额外风险。主从切换的瞬间,如果有消息写入原主节点但还没同步到新主节点,这些消息就丢了。Redis的异步复制机制决定了这种丢失概率是客观存在的。
如果你用了Redis Stream的消费者组,还有另一个坑:消费者组的进度(last_delivered_id)存在Redis里,主从切换后如果从节点落后,消费进度可能回退,导致消息重复消费。
解决思路:如果你的业务对消息可靠性要求高,Redis队列和主从架构都扛不住,得上专业MQ。如果你仍然坚持用Redis,至少给Redis配置AOF持久化,并且使用wait命令同步写让关键消息确认落盘之后再返回。
7.4 Redis Desktop Manager和可视化工具的实用价值
热词里出现了很多次"redis desktop manager", "another redis desktop manager", "redis可视化工具"。这些工具对排查看队列确实方便——打开工具直接看list长度和内容,比命令行直观很多。我推荐AnyDesk之外的另一个轻量工具:Redis Insight(官方出品),可以看到Stream的消费组状态,排查消费者挂在哪个位置。
不过可视化工具只适合开发调试,生产环境监控建议还是走脚本,定时采集队列长度和消费延迟,配合告警系统。可视化工具打开生产Redis的瞬间,如果key特别多,会有一定的性能开销,这个要注意。
8. 实际项目案例:订单超时关闭功能
讲一个我实际做过并且有代表性的场景:订单创建后30分钟未支付,需要自动关闭。这个需求天然适合延迟队列,但很多团队消息量不大,不上专业MQ。我当时用Redis ZSet做了一个延迟队列,整体思路记录一下供参考。
核心设计:
# 订单创建时,把订单ID写入ZSet,score为"创建时间 + 30分钟" # 即期望的执行时间戳 ZADD order:delay <expire_timestamp> order_123 # 后台定时任务(每10秒扫一次) ZRANGEBYSCORE order:delay 0 now LIMIT 0 100 # 把到期的订单ID取出来,批量处理关闭 # 处理完从ZSet移除 ZREM order:delay order_123实现细节:
- 每个订单一个ZSet key,还是所有订单共用一个ZSet key?答案是共用一个,key就是order:delay,member是订单ID。ZSet天然按score排序,score就是到期时间戳,方便取最紧急的
- 定时任务每10秒扫一次,批量取最多100个到期订单,处理完再取下一批。不要一次取太多,避免单次任务占用处理线程过久
- 如果订单在队列期间被支付了,支付回调里要记得ZREM掉这条记录,避免重复关闭
- 如果订单在队列期间被手动延期(比如超时时间改了),需要重新ZADD,ZSet的member相同会直接更新score
这个方案在订单量日均50万时也能平稳跑,Redis ZSet的有序性让查询到期任务非常高效,每次只取score范围内的数据,性能开销很小。
一个容易忽视的坑:ZRANGEBYSCORE取出到期订单后,再执行处理逻辑,中间可能有新订单到期,但没关系,因为下一次扫描会处理。但如果你在业务上要求"超时后立即关闭,不能有秒级延迟",那就要把扫描间隔调小,或者用Redisson的延迟队列组件(底层也是Redis)实现更精确的调度。
9. 关于Redis队列的个人实操体会
聊了这么多,最后说点自己的真实感受。
Redis队列最吸引我的地方不是性能多好,而是它让一个团队在没有专业MQ的情况下也能实现基本的异步化和解耦。小团队、中小系统,引入Kafka集群意味着要维护ZooKeeper(虽然现在不需要ZK了但运维复杂度依然在),要处理分区、副本、监控一堆事情。Redis队列配合一段健壮的消费代码,往往两周就能上线运行,出了问题的排查路径也直接明了——一个LLEN命令就能看清局面。
但Redis队列也有它天生的天花板。消息可靠性、消费确认、重试机制这三个短板在业务规模变大、消息价值变高之后,会越来越致命。我的建议是:在项目初期就评估消息可靠性需求,用Redis队列先跑起来没问题,但要在设计上留好换MQ的接口——比如消费端逻辑放在独立的service里、队列key集中管理、消息体明确约定格式,这样哪天需要切换,改造范围可控。
如果你拿不准该用Redis队列还是RabbitMQ,给你一个最朴素的判断标准:如果这条消息在队列里躺着的30分钟里,系统宕机重启后你会焦虑消息会不会丢,那就直接上专业MQ。如果不会焦虑,只是希望异步执行一下,Redis队列足够。
最后再分享一个我在实际项目里养成的小习惯:给每条打入Redis队列的消息都带一个唯一的requestId和创建时间戳,消费端处理前先记录日志,处理完再记录一条成功日志。这个看起来微不足道的习惯,在我排查线上问题时帮我省了非常多的时间——哪条消息处理了、哪条没处理、哪条处理得比预期慢,一眼就能从日志里拉出来。消息队列这个东西,底层机制再花哨,最可靠的排查手段永远是清晰完整的日志。