ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Redis队列与阻塞队列解析:从List到Stream的选型与避坑

2026/9/30 11:48:25 拓冰建站 浏览量
Redis队列与阻塞队列解析:从List到Stream的选型与避坑 做后台开发的迟早会跟“队列”打交道。Redis普及之后很多人第一个想到的队列实现就是它——一条LPUSH、一条BRPOP三行命令就能让两个服务解耦比上一套RabbitMQ轻太多。但真把Redis队列当成消息中间件用又会踩不少坑消息丢了、重复消费了、阻塞一晚上消费者掉线了、日志打满磁盘了……这篇文章就从“Redis队列”和“阻塞队列”这两个关键词展开聊聊我这几年的实际使用经验以及Redis队列、线程池阻塞队列和专用消息队列之间到底该怎么选。文章适合几类人看刚接触Redis、准备用List做异步任务的开发团队规模不大、不想引入重型MQ但又担心数据可靠性的后端同学以及面试前想系统梳理“Redis能当消息队列用吗”这个问题的人。我不会只贴命令还会把原理、坑点和选型逻辑一起讲清楚。1. 先搞清楚Redis队列能解决什么问题1.1 队列模式的三板斧List命令组合Redis队列大多数情况下就是围绕List数据结构做文章。List本身就是个双向链表底层在3.2版本之后由quicklist实现兼顾了内存压缩和读写性能。利用LPUSH和RPOP这对对称命令就能搭出一个标准的生产者-消费者模型# 生产者往队列右边推任务 RPUSH queue:task task-001 RPUSH queue:task task-002 # 消费者从队列左边取任务 LPOP queue:task # 输出 task-001生产者也可以换成LPUSH消费者用RPOP方向无所谓只要生产和消费的方向相反就行。队列里元素的顺序就是消息的先后顺序LPOP/RPOP都是O(1)操作哪怕队列里有几十万条消息取头取尾也不会变慢。这就是很多人说的“Redis队列三件套”。但同时你也能看出来LPOP是立即返回的。如果队列是空的LPOP直接返回nil消费者如果想持续拉任务就得写一个死循环不断去LPOP。空转一次两次没事空转几百万次就是纯纯浪费CPU。于是就有了阻塞版本命令这是后面第2章的重点。除了ListRedis里其他数据类型也能做“队列的变体”Set可以做不去重的轻量队列ZSet带score天生适合做延迟队列和优先级队列5.0版本引入的Stream则是专门为消息队列场景设计的数据结构。这个演进顺序很重要后面我会展开。1.2 消息队列的本质与Redis的边界先说本质。消息队列解决的核心问题有三个异步解耦、削峰填谷、流量缓冲。订单系统创建订单后不需要同步等短信服务返回把“发送短信”这个动作丢到队列里短信消费者自己慢慢消费这就叫异步解耦。秒杀场景里瞬间涌入10万请求数据库扛不住先把请求丢进队列再由消费者按数据库能承受的速度落库这就叫削峰。Redis队列能解决这些问题吗能但只在特定范围内。它最大的优势是轻量、极低延迟、几乎没有学习成本。一个状态机任务、一个异步通知、一批待处理日志直接用List上就行。我见过不少中小团队在早期用Redis List跑异步任务跑得挺稳直到某天Redis重启队列里的消息全没了才意识到它和专业MQ的本质差别。差别在哪三点没有消息确认机制。消费者LPOP拿到一条消息还没处理完就宕机这条消息就永久消失了。没有消费者组概念。多个消费者同时BRPOP同一个List虽然每条消息只会被一个消费者拿走但你没法精确控制每个消费者的分配策略也没法做“组内广播、组间竞争”这种模式。持久化能力有限。Redis的RDB和AOF主要服务于缓存数据恢复不是为消息系统设计的。AOF即使配置everysec也可能在极端故障下丢最后一秒数据。所以我的建议是把Redis队列定位成“可丢失但代价低的临时任务通道”而不是“核心业务数据的传输管道”。凡是涉及到钱、订单状态、跨系统对账的消息哪怕代码里只用Redis队列跑通验证最终也要迁移到Stream消费者组或者专业MQ上。2. 阻塞队列BRPOP/BLPOP为什么是刚需2.1 BRPOP工作原理与轮询的区别阻塞队列的核心命令是BRPOP和BLPOP。BRPOP是RPOP的阻塞版本BLPOP是LPOP的阻塞版本用法一致。以BRPOP为例# 从 queue:task 取数据如果队列为空服务端挂起连接等待最多等 10 秒 BRPOP queue:task 10 # timeout 为 0 表示永久阻塞直到拿到数据 BRPOP queue:task 0第一次看到这个命令的人可能会疑惑服务端挂起连接那连接会不会断不会。Redis在处理BRPOP时会把这条命令标记为该连接的阻塞队列并在内部的事件循环里挂起不会继续读取这个连接上的其他命令。一旦有新的数据被LPUSH/RPUSH进来Redis会立刻唤醒阻塞中的连接把数据返回给客户端。这个机制和客户端轮询的最大区别在于轮询是客户端主动发请求Redis每次都要解析命令、查一次数据结构就算队列是空的也要走一遍完整流程阻塞模式是服务端被动等待没有数据时几乎不消耗CPU只有数据到达时才触发唤醒。我实测过一个简单的对比单客户端轮询空队列每秒大概会消耗几千次QPS的处理能力换成BRPOP阻塞后Redis上的无效命令直接降为零效果非常明显。阻塞命令还支持多key优先级# 优先从 queue:high 取如果它是空的再从 queue:normal 取 BRPOP queue:high queue:normal 0这对那些希望“高优任务先处理”的场景非常实用。比如运营后台的审核队列和自动任务队列共用一套消费者想让人工审核优先用这一个命令就搞定了不需要自己写复杂的调度逻辑。2.2 阻塞客户端的心跳、超时与主从切换BRPOP看起来简单用起来有几个隐藏的坑要先避开。第一个坑是socket timeout。Redis客户端通常都会设置连接超时但这个超时是“连接建立”的超时不是“命令执行”的超时。如果你用的是Jedis或者LettuceBRPOP的阻塞时间由命令参数控制但底层socket的read timeout如果小于阻塞时间客户端会先感知到读超时。我遇到过的情况是配置了Lettuce默认超时5秒然后执行BRPOP queue:task 0结果5秒后客户端就抛超时异常任务被重新拉起的逻辑反复循环日志里全是错误。解决办法有两种要么把socket timeout设得比阻塞时间大要么在业务里捕获超时后重新发起BRPOP。Leettuce本身对阻塞命令有专用处理路径但依然建议把timeout配置单独加大。第二个坑是主从切换。Redis哨兵或Cluster在故障转移时客户端连接会被断开。对于一个长时间阻塞的BRPOP连接这是一次“无感断线”——客户端不知道服务端也不会有额外的通知。消费者可能就一直卡在重连逻辑里队列里的消息堆积如山。这个场景下消费端必须实现自动重连并且重连后要立即重新发起BRPOP不能等下一个消息触发。第三个坑是“惊群”和饥饿。Redis 6.0之前如果多个客户端同时阻塞在同一个key上新消息到达时可能会被部分客户端抢占出现某些客户端长期拿不到数据的饥饿现象。Redis 6.0之后对阻塞命令做了公平性优化情况好了很多但在高并发场景我仍然不建议挂几十个客户端同时BRPOP同一个key尽量让消费者数量保持在一个合理范围。消费者少消息分发不均的问题不会太明显消费者太多每次唤醒的调度开销反而上升。2.3 可靠性增强BRPOPLPUSH/LMOVE 用法BRPOP只是解决了“空转”问题并没有解决“消息丢失”问题。消费者从队列里取出消息后如果处理过程中宕机这条消息就永远找不回来了。解决思路是引入一个备份队列# 从 queue:task 阻塞弹出同时把消息推入 queue:task:processing BRPOPLPUSH queue:task queue:task:processing 0这条命令是原子操作从源队列弹出消息并立刻推入目标队列。消费者拿到消息后先执行业务处理处理成功后再从queue:task:processing里把这条消息删掉LREM。如果消费者中途崩溃消息还留在processing队列里重启后可以从processing队列恢复并重新处理。这其实就是“至少一次投递”的雏形。Redis 6.2之后官方推荐用LMOVE替代BRPOPLPUSH因为LMOVE可以指定方向。用法上把BRPOPLPUSH的两队列参数放前面LMOVE queue:task queue:task:processing LEFT RIGHT # 加上 BLOCK 参数变成阻塞版本 BLMOVE queue:task queue:task:processing LEFT RIGHT 0我习惯的模式是正常消费BLMOVE从主队列弹到处理队列。业务成功LREM从处理队列删除。业务失败LREM从处理队列删除同步把消息重新RPUSH回主队列。启动时先扫处理队列把还留着的消息重新放回主队列再开始阻塞消费。这套模式在中小规模的异步任务里非常稳定但它仍然不是真正意义上的消息中间件。它的致命弱点是处理队列和主队列都保存在Redis里如果整个Redis挂了两个队列的数据都可能丢。想更进一步需要引入Stream和AOF。3. 从List到Stream可靠消息队列的终局方案3.1 为什么List队列不够用了当你的系统里有多个消费者、需要消息确认、需要历史消息回溯的时候List队列就力不从心了。List只能做到“所有消费者抢同一条消息”做不到“一条消息被多个消费者组分别消费”也做不到“处理失败后重新投递到同一个消费者”。Redis 5.0引入的Stream就是专门补这些短板的。Stream的核心概念可以这么理解它像一个只追加的日志文件每条消息都有唯一ID消费者可以从任意位置读取也可以以消费者组的形式协作消费。常用命令# 生产端添加消息 XADD task-stream * body task-001 # 返回消息ID形如 1750000000000-0 # 创建消费者组 XGROUP CREATE task-stream group-1 0 # 消费者组消费新消息用 表示只读未投递过的消息 XREADGROUP GROUP group-1 consumer-1 COUNT 10 BLOCK 5000 STREAMS task-stream # 消费成功后确认消息 XACK task-stream group-1 1750000000000-0关键在于XACK。消费者组为每个消费者维护一个PELPending Entries List记录的是一批“已投递但未确认”的消息。消费者拿到消息后如果没执行XACK就挂了这条消息会一直留在PEL里。其他消费者可以通过XPENDING查看滞留消息用XCLAIM把滞留消息转移过来重新处理。这样一来消息至少不会因为消费者宕机而立刻消失可靠性比List队列高了一个量级。3.2 延迟队列与优先级队列的实现套路Redis做延迟队列最经典的做法是用ZSetscore存这条消息期望被执行的时间戳。生产端# 60秒后执行的任务 ZADD delay-queue 1750000060 task-001消费端需要有一个后台任务不断检查“当前时刻已经到期”的延迟任务。我用Lua脚本做“取出删除”的原子操作-- KEYS[1]: 延迟队列key -- ARGV[1]: 当前时间戳 -- ARGV[2]: 单次最多取多少个 local tasks redis.call(ZRANGEBYSCORE, KEYS[1], 0, ARGV[1], LIMIT, 0, ARGV[2]) if #tasks 0 then redis.call(ZREM, KEYS[1], unpack(tasks)) end return tasks这段脚本的关键点在于取出和删除是原子的避免多个消费者同时拿到同一条延迟任务。消费端拿到返回的任务列表后再按业务类型分发到真正的处理队列或直接处理。优先级队列也有两种思路。一种是用BRPOP的多key参数天然支持静态优先级另一种是动态优先级用ZSet的score存“优先级权重”而非时间戳选取时按score从高到低取。我个人的建议是如果优先级层次少于4级用BRPOP多key就够如果优先级是动态计算的用ZSet更灵活。不要为了“纯洁的队列模型”强行引入复杂性。3.3 Redis队列在任务调度里的实战案例一个我现在还在维护的系统里Redis队列用于一个内容审核平台的任务调度。用户的图片上传后系统把“图片ID任务元数据”写入Stream队列审核服务通过XREADGROUP批量拉取审核结果写回另一个Stream。整个链路里消息体只放任务ID和相关参数不放大图片本身避免大value阻塞Redis。消费者挂了PEL里的任务还在审核服务重启先从PENDING里恢复未完成任务再继续读新消息。类似地大模型调度平台里的任务队列管理本质上也是这套东西一个任务提交接口把任务元数据入队调度器消费队列里的任务分配给GPU节点执行执行完更新状态失败则按延迟策略重新入队。用Redis ZSet做延迟队列的好处是不需要单独再部署一个调度器组件Redis本来就是现成的代码量很少。不过要提醒一句如果你需要精细的“任务依赖编排”“任务优先级抢占”“失败重试次数限制”Redis队列只能提供基础能力。更复杂的场景需要自研调度模块或在专业MQ之上实现。把队列当存储用把调度逻辑放在业务代码里而不是把调度逻辑塞进Redis命令里这是架构上的一个重要分界线。4. 阻塞队列的另一层线程池里的BlockingQueue选择很多人搜“阻塞队列”其实问的是Java线程池里的BlockingQueue和Redis阻塞队列是两回事但概念上是互通的。这部分我在面试时经常被问到顺便一起讲清楚。4.1 ThreadPoolExecutor四类队列Java的ThreadPoolExecutor构造参数里workQueue决定任务怎么排队。常用的阻塞队列有四类LinkedBlockingQueue无界链表阻塞队列。默认容量是Integer.MAX_VALUE任务永远能塞进去缺点是队列无限膨胀时内存会溢出且maximumPoolSize参数几乎失效。ArrayBlockingQueue有界数组阻塞队列。容量固定任务满了之后触发拒绝策略。SynchronousQueue容量为0的同步队列。线程池不会缓存任务而是直接把任务交给一个空闲线程适合“瞬时任务很多、线程能快速接管”的场景。PriorityBlockingQueue支持优先级的无界阻塞队列。还有一个常用的DelayQueue是不包含在ThreadPoolExecutor默认构造函数里的它本质是一个延迟阻塞队列任务需要实现getDelay方法到期后线程池才会取走。这和Redis ZSet做延迟队列的思路完全一样只是一个在JVM内一个在Redis里。4.2 队列参数与拒绝策略怎么搭我踩过的一个典型坑是用无界LinkedBlockingQueue然后设置corePoolSize4、maximumPoolSize8结果任务高峰时队列里堆了几百万个任务内存飙到溢出。原因很简单——有了无界队列线程池永远不会把线程数扩到maximumPoolSize因为新任务永远有地方放。线程池出于“优先队列缓冲”的考虑会优先让core线程处理任务core线程忙不过来就进队列队列满了才创建新线程。正确做法是对绝大部分业务场景用有界队列ThreadPoolExecutor executor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() );这个配置的含义是核心线程4个任务队列最多1000个队列满了再创建临时线程到8个到8个还有任务进来就触发CallerRunsPolicy——由提交任务的线程自己执行。这种方式比直接抛异常更温和但也意味着提交任务的代码会被阻塞需要评估是否影响主流程。我自己的经验是有界队列是底线容量一般是“核心线程数 * 单个任务平均耗时 / 可接受的最大排队时间”粗略估算。如果你完全无法容忍任务丢弃CallerRunsPolicy是最安全的选择。如果你宁可失败也不愿意拖垮整体系统AbortPolicy加上降级日志更合适。至于“无界队列能不能用”只有任务量可控、任务处理快、内存有余量的内部工具型线程池我才会用线上核心链路绝不推荐。5. 别只盯着Redis消息队列选型实战对比5.1 Redis队列、RabbitMQ、Kafka、RocketMQ怎么选这四者放在一起选型本质上是“轻量够用”和“专业可靠”之间的权衡。我用一张表把关键词拉一下维度Redis队列RabbitMQApache KafkaRocketMQ吞吐量单机数万级受内存限制万到十万级依赖配置百万级天生分布式十万级到百万级依赖部署消息可靠性依赖AOF和副本可能丢高支持ACK、持久化、镜像队列高多副本ISR机制高多副本事务消息消费模型竞态消费Queue模型 Exchange路由消费者组 分区并行消费者组 Tag过滤学习成本极低中概念多高分区/副本/位移管理中高典型场景内部异步、轻量任务、缓存系统传统企业内部服务解耦、灵活路由日志采集、大数据流、埋点数据电商、金融、事务消息、延迟消息延迟消息自己用ZSet造轮子通过死信或延迟插件实现自定义实现复杂原生支持延迟等级这张表能帮你快速避开几个常见误区。第一个误区日志和数据管道业务用Redis队列。日志场景的特点是吞吐量非常大、不要求复杂路由、允许重复。这种场景Kafka几乎是行业标准Redis队列单机吞吐和堆积能力根本扛不住长时间的高水位。第二个误区业务系统一上来就上Kafka。如果你的系统只有几十个消费者、TPS不超过几千Kafka的分区管理、消费者位移存储、服务器配置调优会成为运维负担。RabbitMQ反而更适合处理灵活的消费路由和复杂业务流。第三个误区用RabbitMQ做事务型消息。RabbitMQ也有事务机制但性能牺牲很大如果消息发送后要求强一致的库存扣减、订单状态流转RocketMQ的事务消息设计得更好Kafka通过事务API也能做但复杂度更高。5.2 选型避坑指南结合标题里的热搜词我把能想到的踩坑经验整理一下每一条都是真实发生过的问题。消息重复是必然的不是可选的。不管用Redis还是专业MQ消费者都可能因为网络超时、重试、重启而收到重复消息。生产端给每个消息生成唯一业务ID消费端用Redis的SETNX或者数据库唯一索引做幂等这比任何队列本身的功能都重要。延迟消息一定要先规划。Redis ZSet可以根据score轻松实现延迟队列但你需要自己处理时钟、轮询频率和持久化恢复。RabbitMQ的延迟消息依靠TTL死信交换机或者延时插件灵活性不如RocketMQ原生的延迟等级。如果业务强依赖秒级延迟消息且不想自己写调度RocketMQ是最省心的。队列消息体越大系统越危险。无论哪种队列都建议只传关键ID和轻量参数具体内容让消费者主动查库或查缓存。把上千行的JSON直接塞进消息体吞吐瞬间掉一半还容易触发MQ单条消息大小限制。“Redis缓存治理”和“Redis队列”不要混用。同一个Redis实例既当缓存提供方又当核心任务队列如果缓存失效风暴打满CPU队列消费也跟着遭殃。生产环境最好把队列类的key隔离到单独的Redis实例或至少单独的db。6. 我们踩过的那些坑常见问题与排查思路6.1 消息丢失与重复消费我在线上环境遇到的第一起严重事故就是Redis队列丢消息。当时用的是List LPOP的方式消费者拿到消息后准备写数据库恰好Redis发生主从切换主库上的队列数据还没来得及同步到从库消息就没了。排查到最后发现这个队列承载的是用户上传文件后要执行的转码任务丢一批就要人工补跑非常痛苦。解决方案分三步走第一步Redis开启appendonly yes并配置appendfsync everysec降低进程崩溃丢数据的概率第二步消费端改用Stream消费者组 XACK第三步每个消息体带上业务ID消费成功后记录处理状态重跑时通过状态判断跳过。做完之后同样的故障在测试环境再模拟消息至少能从PEL里恢复出来不会无声消失。重复消费是另一个高频问题。消费者处理完消息后在XACK之前宕机Stream会认为消息没被处理后续重新投递业务逻辑就执行了两次。解决方式不是“换一个不会重复的MQ”而是“让消费者具备幂等能力”。印象最深的案例一个订单通知消费者重复执行导致用户收到两条一模一样的短信。后来在短信任务里加上“相同业务ID在5分钟内只发送一次”的幂等判断问题彻底消失。6.2 阻塞客户端与运维层坑阻塞命令最容易被运维同学忽视。BRPOP一个0超时的命令连接长时间挂起如果客户端没有配置KeepAlive或者负载均衡器主动断开空闲连接就会出现一堆假连接Redis的clients数量只增不减。我遇到过最夸张的一次clients数量显示一千多个实际真正活跃的只有几十个。排查方法是看INFO clients里的connected_clients和blocked_clients两个指标。blocked_clients挂着一堆是正常的但connected_clients如果不断攀升就要检查是不是客户端重连逻辑有问题——每次BRPOP超时后重新发起连接旧连接没有被正确关闭。另外Redis 6.0以后多了io-threads机制它主要优化网络IO不会加速BRPOP这种单key操作。如果因为队列吞吐量不够而想通过IO线程池提性能不如把队列按业务拆成多个key让并发消费分散到不同分片效果立竿见影。6.3 队列膨胀与性能监控队列膨胀是所有队列系统都会遇到的事。我常用的监控指标有两个对于List是LLEN对于Stream是XLEN。可以每分钟采样一次超过阈值就告警。单看队列长度还不够还要监控“消费延迟时间”。举个例子延迟队列里score是任务期望执行时间如果当前时间已经超过score三分钟而任务还没被取出说明调度逻辑或者消费者卡住了。性能层面有个细节值得注意不要在一个消费者里用循环一个个LPOP处理尽量用Stream的XREADGROUP COUNT批量拉取。批量拉取能减少网络往返次数对吞吐的改善比调大线程池数量更明显。我自己实测过单条BRPOP挨个处理大概每秒能消费几千条用XREADGROUP一次拉100条再并发处理同样条件下能到几万条差别主要就在网络IO上。6.4 Key命名、序列化与过期时间Redis队列的几个细节几乎每个新人都会踩一遍。第一是key命名。队列key一定要有清晰规范我建议用queue:{模块}:{业务}比如queue:order:pay_notify。一旦业务复杂起来没有规范的key名运维查问题时根本分不清哪个队列是核心链路、哪个是废弃任务。第二是序列化。Redis队列里存的数据建议统一用JSON字符串或者紧凑的二进制格式。不要直接把Java对象序列化后丢进去一旦Consumer升级、类结构变化旧消息可能反序列化失败。我吃过一次亏消息体里有个字段从Integer改成了Long消费者版本混布期间一批老消息反序列化直接报错任务全部卡在PEL里。第三是过期时间。千万别在队列key上设置EXPIRE。很多人的习惯是所有key都设TTL但队列里的消息是有状态的一旦设置了过期时间Redis过期清理策略可能会在key还没被消费完时直接把整个队列清掉。如果你确实想控制队列长度应该在业务层面做消息过期标记而不是让Redis帮你“清理垃圾”。最后我再分享一个个人习惯给队列预留“非法消息”通道。消费者解析消息失败时不要直接把消息扔掉而是把它推进queue:{业务}:dead保留原始内容。这样出问题时可以通过dead队列反查原因。这个习惯让我在排查线上问题时节省了大量时间。每次看到代码里catch异常后直接日志打印就算完事的消费者我都会建议先想想这条消息如果永远消失了系统真的能自愈吗不能的话就值得多写一行入dead队列的逻辑。