Spring Boot集成Redisson实现延迟队列:从入门到排坑实战
发布时间:2026/10/20 13:20:20 锦皓数字建站

延迟任务在业务系统里几乎无处不在用户下单后三十分钟未支付要自动关闭订单直播开始前十分钟要提醒预约用户会员到期前三天要推送续费提示。刚接触这类需求的人往往会直接用定时任务每分钟扫一次表数据量小的时候没什么感觉数据量上来之后数据库压力很快成为瓶颈。我也在项目里反复比较过延迟消息的实现方案最终比较常用的是Spring Boot集成Redisson用它的RDelayedQueue解决这一类延迟投递问题。Redisson是Redis官方推荐的Java客户端把分布式锁、分布式对象、队列这些能力都补齐了RDelayedQueue就是其中专门用于延迟任务的组件。这篇文章不聊太深的理论重点写清楚怎么集成、怎么用、有哪些坑给正在选型和落地的同学一个可以直接抄作业的参考。1. 为什么选择Redisson延迟队列而不是其他定时方案1.1 延迟任务的核心问题延迟任务的本质其实只有一句话把一条消息的执行时间定在未来的某个时刻到期之后由下游逻辑去处理。听起来简单落地的时候你会发现两个问题。第一个问题是“到期”怎么判断。如果放在关系型数据库里通常需要给任务表加一个 expire_time 字段然后定时任务反复扫描expire_time now()的数据。这个方案本身没什么问题问题出在“反复扫描”上。表小的时候还好表一旦到了百万行甚至更多每隔几十秒全表范围扫一遍索引和IO的压力会非常明显。第二个问题是“投递”怎么感知。延迟任务到期之后最好能立刻触发业务逻辑而不是等待下一次轮询。很多团队用轮询时间粒度只能做成分钟级甚至半小时级实时性不够。这也是延迟队列存在的根本价值把“任务存储”和“到期触发”解耦生产者在未来时间点之前不用反复关心执行消费者也只需要在自己的通道上等待消息到来。哪种方案能把这两件事做得轻量、可靠、成本低哪种就更容易被业务接受。1.2 常见实现方案对比我在项目里见过各种各样的延迟实现这里列一个横向对比方便你按自己的场景选型。实现方案额外组件延迟精度可靠性适用场景数据库定时轮询无分钟级中数据量小、低频任务手写Redis ZSet无秒级高轻量灵活需要自己维护逻辑Redis keyspace过期通知无秒级低对丢消息不敏感的提示类场景RabbitMQ TTL死信队列RabbitMQ秒级高需要复杂路由、消息确认的项目RocketMQ 延迟消息RocketMQ固定18个延迟级别高大规模消息、要求严格投递语义Redisson RDelayedQueueRedis秒级高Spring技术栈、希望轻量集成这里重点说两个容易被忽略的问题。Redis keyspace过期通知表面上很省事给key设置了TTL过期瞬间事件就来了。但过期事件本质是“key被删除时触发”Redis在很多配置下并不保证事件不丢而且消费者如果刚好在通知发出之后才订阅消息已经过去了。用它做优惠券过期提醒这种允许丢的提示可以做订单超时关闭这种强一致业务就不能只依赖它。手写ZSet方案也成立把 score 设为到期时间戳然后一个后台任务轮询ZRANGEBYSCORE key -inf now取出到期任务处理。核心技术不算难但你要自己处理并发消费、轮询空转、任务恢复这些问题。写一遍会花不少时间而且每个项目都要重写。Redisson等于把这些细节都封装好了这是它最大的优势。1.3 我最终选择Redisson的三个理由选型不是看谁功能多而是看谁更贴合当前项目的约束。第一没有引入新的中间件。项目本来就是Redis重度用户缓存、分布式锁都已经在用Redis延迟队列继续用Redis不会增加运维负担。RabbitMQ、RocketMQ再轻量也需要单独部署、监控、保证可用性。第二API设计非常简单。Redisson把底层ZSet和BlockingQueue封装成了RDelayedQueue生产者只用offer(msg, delay, timeUnit)消费者直接take()阻塞等待代码风格很接近Java标准库的DelayQueue。第三和Spring Boot的整合成熟。官方有starter也可以自己定义Bean。Redisson自带分布式能力多个服务实例可以消费同一条延迟队列不需要额外开发分布式协调逻辑。当然它也有短板比如延迟精度只在秒级左右极端场景下可能丢事件。但每个方案都有取舍对大多数业务系统来说Redisson延迟队列是性价比非常高的一条路。2. Spring Boot集成Redisson依赖、配置与连接验证2.1 版本匹配别随意外加高度Spring Boot集成Redisson最常见的问题是版本冲突尤其是netty相关依赖。Redisson底层依赖nettySpring Boot中间接依赖了netty两个版本如果不兼容运行期会出现各种连不上Redis、线程池异常的诡异问题。我建议优先用官方的redisson-spring-boot-starter让Maven把Redisson和Spring Boot相关的依赖一起仲裁。下面是一个基准参考properties spring.boot.version2.7.18/spring.boot.version redisson.version3.17.5/redisson.version /properties dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version${redisson.version}/version /dependency如果你的项目已经升级到 Spring Boot 3.xRedisson版本最好选 3.22 以上因为新版本对Jakarta命名空间的支持更完整。不要抱着“最新版一定最好”的想法版本号越新和项目里其他依赖的校验矩阵就越大反而容易翻车。核心原则是Spring Boot 2.x配Redisson 3.17左右Spring Boot 3.x配Redisson 3.22具体版本以官方兼容列表为准。2.2 最省事的编程式配置用Spring Boot最稳的方式其实是手写一个配置类直接生成RedissonClient Bean。这样能保证依赖版本完全由你掌控不会被starter的自动配置干扰太多。Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setDatabase(0) .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(16); return Redisson.create(config); } }这里有两个细节需要说明。destroyMethod shutdown非常重要。Spring容器关闭时会调用RedissonClient的shutdown方法让底层的线程池和连接池平滑释放。不写这个属性应用重启时可能出现连接没有完全释放Redis侧报“连接数到达上限”的情况。连接池参数要看业务量来配。单机小项目保持默认就够一些在线业务如果并发量高建议单独压测后再调整setConnectionPoolSize。我见过有人把连接池配到100结果Redis服务端连接数远超预期白白增加内存开销。连接池不是越大越好够用且留缓冲即可。2.3 如果你坚持用官方starter的配置文件方式不是所有场景都推荐编程式配置有些项目希望配置文件集中管理Redis地址方便在测试环境来回切换。这时候可以用redisson-spring-boot-starter自带的自动配置属性。在application.yml中加入spring: redis: redisson: file: classpath:redisson.yaml然后在resources/redisson.yaml中定义Redisson参数singleServerConfig: address: redis://127.0.0.1:6379 database: 0 connectionPoolSize: 16 connectionMinimumIdleSize: 4用这种方式配置之后Spring容器里会自动出现RedissonClient直接注入使用即可。它和我们手动写Bean的作用是等效的但需要注意它和Spring Boot自带的spring.data.redis.*配置是两套体系不要混在一起改避免出现“我改了连接密码为什么没生效”的困惑。2.4 验证Redisson连接配置完成后我习惯先写一个简单的接口或者启动日志来确认连接没问题避免代码写到一半才发现Redis地址配错了。最简单的验证方式是在启动类里临时打印几个指标Component public class RedissonCheckRunner implements ApplicationRunner { Resource private RedissonClient redissonClient; Override public void run(ApplicationArguments args) { redissonClient.getKeys().getKeysCount(); redissonClient.getBucket(health-check).set(ok); System.out.println(redisson connected and redis health check ok); } }这段逻辑本身没业务价值但能在第一时间暴露连接配置问题。生产环境不建议为了测试写这些代码本地开发调试时很实用。3. RDelayedQueue底层原理拆开看它内部做了什么3.1 一段最简代码说清三个对象Redisson的延迟队列实现非常有意思表面上你只需要关心两个对象RBlockingQueue和RDelayedQueue。但底层它其实用了三个结构配合工作。RBlockingQueue真正给消费者取消息的队列消费者从这个队列take()阻塞等待。RScoredSortedSet用来存储尚未到达执行时间的消息score保存的是绝对到期时间戳。RDelayedQueue连接上面两个结构并启动一个后台搬运任务。生产者把消息放入延迟队列时消息不会立刻出现在消费者可见的队列里而是先进入一个按执行时间排序的集合。等到执行时间到达由一个内部任务把集合里的消息搬运到RBlockingQueue。消费者最终看到的只是“到期的消息”。这里最反直觉的地方在于不要只定义一个RBlockingQueue然后傻等你必须在某个位置让RDelayedQueue一直存活否则到期搬运动作不会发生。3.2 生产者的offer是如何排期的看一下生产者端最核心的代码RBlockingQueueOrderTimeoutEvent queue redissonClient.getBlockingQueue(order:timeout:queue); RDelayedQueueOrderTimeoutEvent delayedQueue redissonClient.getDelayedQueue(queue); delayedQueue.offer(event, 30, TimeUnit.SECONDS);offer(event, 30, TimeUnit.SECONDS)做的事情大致可以拆成三步计算当前时间戳 30秒作为这条消息在ZSet中的score。把消息写入那个按score排序的ZSet。通知内部的后台transferTask让它知道这条消息在未来某个时间点需要搬运。正因为score是绝对时间戳所以消息如果因为Redis挂了或者应用重启而没有来得及搬运只要ZSet里的数据还在后续重新启动后依然可以按原来的时间点判断是否到期。Redisson的这个设计让延迟消息天然具备了一定的持久能力不会因为进程重启就丢得干干净净。3.3 消费者的take为什么能阻塞等待消费者端看起来更简单RBlockingQueueOrderTimeoutEvent queue redissonClient.getBlockingQueue(order:timeout:queue); OrderTimeoutEvent event queue.take();queue.take()是阻塞方法没有到期消息时线程会一直挂在那里等Redisson内部把到期消息搬运过来之后这个方法才会返回。它内部走的是Redis的阻塞队列语义多个消费者同时take同一个队列的时候Redisson会保证一条消息只被一个消费者拿到天然具备消费互斥能力。需要注意的是消费者线程启动后不要用普通for循环死转poll()去拉这会白白消耗CPU。用take()阻塞等待才是队列的正确姿势。如果你的场景里希望最多等待N秒就去做别的事可以用poll(timeout, timeUnit)。3.4 数据可靠性的边界Redisson延迟队列并不是万能保险箱它的可靠性其实依赖于Redis的持久化策略。如果Redis开了AOF并且执行appendfsync always消息的可靠性会很高但代价是性能下降。实际项目里大多数用的是默认的everysec极端情况下Redis宕机可能会丢最近1秒内的延迟数据。如果你的业务对延迟消息绝对敏感比如一条都不能丢那么需要设计补偿机制把未处理的任务从数据库等地方恢复出来。另一个容易踩的坑出现在主从或者哨兵模式下。Redis主从切换本身是异步复制假如主节点还没把ZSet数据同步给从节点就宕机切换后可能出现延迟消息丢失甚至排序错乱。Redisson本身不负责解决这个问题它只提供数据结构。真正要做的是把Redis的持久化策略和备份策略配置好并在业务层面对偶尔丢失的任务做兜底。4. 手写一个订单超时关闭延迟队列从设计到自测4.1 场景与消息体定义下面用一个最常见的业务场景走通全流程订单提交后如果30秒内未支付自动关闭订单并给用户发送一条站内消息。这里要传递的核心事件是一个“订单已待支付状态超时”的业务事件它只需要包含订单号和用户ID这几个必要字段。消息体不要做得太重尽量只放业务主键处理的时候再通过主键去查询最新的业务数据。这样可以避免同一订单在排队期间状态已经变化消费者拿到一个过期的完整快照。public class OrderTimeoutEvent implements Serializable { private static final long serialVersionUID 1L; private String orderNo; private Long userId; public OrderTimeoutEvent() { } public String getOrderNo() { return orderNo; } public void setOrderNo(String orderNo) { this.orderNo orderNo; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId userId; } }4.2 把队列和延迟队列注册成Spring Bean我强烈建议把队列对象注册成Spring Bean而不是在每次发送和消费时都重新调用RedissonClient去 get。这样做的好处有两点一是保证RDelayedQueue在整个应用生命周期里只创建一次后台搬运任务不会重复启动二是在消费端可以通过Spring依赖注入直接使用代码更干净。Configuration public class OrderDelayQueueConfig { public static final String ORDER_TIMEOUT_QUEUE order:timeout:queue; Bean(destroyMethod ) public RBlockingQueueOrderTimeoutEvent orderTimeoutQueue(RedissonClient redissonClient) { return redissonClient.getBlockingQueue(ORDER_TIMEOUT_QUEUE); } Bean(destroyMethod ) public RDelayedQueueOrderTimeoutEvent orderDelayedQueue( RedissonClient redissonClient, RBlockingQueueOrderTimeoutEvent orderTimeoutQueue) { return redissonClient.getDelayedQueue(orderTimeoutQueue); } }这里有一个非常反直觉的细节destroyMethod我写的是空字符串而不是默认的close或者destroy。原因在后面的排坑章节会详细说这里先记住一个结论RDelayedQueue如果执行了destroy()和这个队列相关的Redis对象会被清理没消费的任务可能直接消失。Spring容器关闭时不该主动销毁它让进程自然退出即可。4.3 投递端下单后立即提交延迟事件订单服务在创建订单之后只需要做一件事把延迟关闭事件投递到延迟队列里。Service public class OrderTimeoutService { Resource private RDelayedQueueOrderTimeoutEvent orderDelayedQueue; public void submitTimeoutCloseEvent(String orderNo, Long userId) { OrderTimeoutEvent event new OrderTimeoutEvent(); event.setOrderNo(orderNo); event.setUserId(userId); orderDelayedQueue.offer(event, 30, TimeUnit.SECONDS); } }调用时机要把握好。我通常是在订单状态成功落库后调用如果先发延迟消息再落库消费者可能在订单还没写入数据库的时候就开始处理查不到订单反而产生误判。4.4 消费端启动时拉起消费线程消费端可以借助Spring Boot的ApplicationRunner在应用启动完成之后拉起消费线程。线程数不必和业务并发量上限对齐根据实际处理耗时来定即可。Component public class OrderTimeoutConsumer implements ApplicationRunner { Resource private RBlockingQueueOrderTimeoutEvent orderTimeoutQueue; private final AtomicBoolean running new AtomicBoolean(true); Override public void run(ApplicationArguments args) { for (int i 0; i 2; i) { Thread worker new Thread(this::consume, order-timeout-worker- i); worker.setDaemon(true); worker.start(); } } private void consume() { while (running.get()) { try { OrderTimeoutEvent event orderTimeoutQueue.take(); handle(event); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { // 业务处理失败不能直接吞掉必须记录日志并做补偿 } } } private void handle(OrderTimeoutEvent event) { // 1. 查询订单真实状态 // 2. 如果状态仍是待支付则尝试关闭订单 // 3. 不管是否成功关闭都要记录日志 } }这里一个重要设计点是幂等处理。因为消费者拿到消息之后如果业务逻辑执行到一半抛异常消息已经从队列里弹出来了没有再次投递给自己的机制。所以处理逻辑里一定要用“状态转换”来天然防重比如UPDATE order SET status CLOSED WHERE order_no ? AND status WAIT_PAY。受影响行数等于1说明这次操作真正生效等于0说明之前已经关过了。4.5 自测三步法写完代码之后可以用下面的步骤快速自测启动Redis和Spring Boot应用确认Redisson连接日志正常。临时把延迟时间改成5秒调用一次submitTimeoutCloseEvent(NO20240101, 10001L)。观察控制台日志正常情况下5秒后消费者线程会打印处理记录。[order-timeout-worker-0] INFO 开始处理订单超时事件 orderNoNO20240101 [order-timeout-worker-0] INFO 订单关闭成功 orderNoNO20240101如果看到消息到了但业务上没有关闭优先检查消费者handle方法里查询订单状态的条件和SQL。延迟队列本身是通的问题多半出在业务状态判断上。4.6 序列化一个容易被忽略的细节延迟队列的消息最终会存储到Redis所以序列化方式非常关键。Redisson默认有一套序列化机制但如果你对性能或者跨语言有要求建议显式配置。Config config new Config(); config.setCodec(new Kryo5Codec());使用Kryo5Codec后消息类的字段标记尽量稳定。因为Kryo序列化对字段顺序和类结构比较敏感如果线上正在跑而代码里随意调整了字段顺序或类型老消息可能反序列化失败。Jackson类codec也有类似问题要求消息体有无参构造方法和getter/setter。一个比较保险的习惯是消息体只放最稳定、最少量的字段并且把版本控制当成一件正经事来做。新增字段时可以给类加一个serialVersionUID升级标识避免老数据反序列化时直接ClassNotFound。5. 延迟队列排坑实录重启、丢失、重复与精度5.1 消息一直消费不到怎么回事我见过最多的问题就是生产端明明把消息塞进去了消费端也启动了但take一直拿不到消息。发生这个情况十有八九是你在消费端只定义并使用了RBlockingQueue没有让对应的RDelayedQueue存活。你要记住到期搬运的动作是靠RDelayedQueue内部的transferTask执行的。如果应用里从来没有创建过getDelayedQueue(queue)ZSet里的消息永远不会被搬到BlockingQueue里消费者当然拿不到。解决方式也很简单把RDelayedQueue也用Spring Bean管理起来并且确保它和消费者的RBlockingQueue使用同一个队列名。5.2 重启后任务消失其实可能没有消失延迟消息存储在Redis的ZSet里只要Redis没有丢失数据任务理论上不会因为应用重启而消失。问题往往出在“重新初始化”上。应用启动后需要重新创建RDelayedQueue它的后台任务才会重新开始搬运到期消息。如果你的应用只是启动了消费者线程却没有初始化对应的RDelayedQueue那么看起来就像任务丢了。按前文说的方案把RDelayedQueue也注册成Spring Bean这个问题就能避免。另外不要把delayedQueue.offer(event, 30, TimeUnit.SECONDS)的计时间隔理解成“从应用启动到执行”相对30秒。ZSet里的score是绝对时间戳应用宕机过程中如果时间已经过去重启后会立刻搬移到BlockingQueue表现得像“消息延迟了”。如果业务不想要这种补执行需要在消费者处根据消息内容再次判断当前状态。5.3 重复消费到底要靠什么解决Redisson的RBlockingQueue在正常模式下一条消息只被一个消费者拿到一次。但“只拿到一次”不等于“只处理一次”。如果你的消费者在处理完业务之后又有一次重试或者手动回归重复执行就可能发生。所以最终兜底方案永远是业务层面的幂等。下单关闭这种操作一定要用数据库的唯一约束或者状态字段做一致性保证不要依赖队列自身的“只消费一次”语义。队列只负责投递业务负责状态推进。把这两个职责分开系统才不会在异常场景下乱掉。5.4 延迟精度达不到业务要求Redisson延迟队列的精度受内部轮询任务间隔影响实际体验下来秒级没问题毫秒级会比较吃力。如果业务要求的延迟时间是500毫秒以内或者需要非常精确的触发时间我的建议是别硬用这个方案。可以考虑另外两种替代一种是用定时调度框架比如把时间点存入任务表用Quartz等组件精确到秒触发另一种是如果能接受单独中间件选择RocketMQ延迟消息它支持固定延迟级别。方案没有优劣只有匹配不匹配。5.5 destroy()会丢数据前面提过destroyMethod要设为空字符串这里把原因说透。Redisson的RDelayedQueue对象由多个Redis结构共同组成当你调用destroy()时它不仅会停止当前的搬运任务还会把关联的队列和ZSet结构删除。如果应用关闭时Spring容器默认执行了destroy方法那些还没到期的延迟消息就会被清掉。虽然正常的生产环境更看重平滑重启但这个坑一旦踩中就是线上丢消息级别的故障。我建议把延迟队列相关Bean的destroyMethod显式设置为空并另外在代码层面通过日志打印当前待处理任务数方便运维在服务下线前评估影响。5.6 大流量下的性能优化建议延迟队列本身不是高性能瓶颈真正容易出问题的是消费者处理的效率。一个慢消费者会把后面的消息全堵住。我遇到过的最典型场景是消费者线程只有1个而每次handle逻辑都要写库并调用外部接口最慢的时候一条消息处理了800毫秒30秒内积压了好几千条延迟消息。后来把消费者线程数提高到4个并把外部调用改成异步化积压才消下去。线程数也不是无脑调大需要同时考虑数据库连接池和下游接口的承受能力。比较稳妥的做法是消费者线程只负责从队列取出消息然后扔给业务线程池去处理队列消费者保持少量长线程业务线程池按需伸缩。如果消息处理特别重还可以考虑按订单号做哈希保证同一订单的消息总是落到同一个工作线程避免并发写一个订单产生锁竞争。6. 从单机到多实例延迟队列的扩展边界6.1 多实例消费同一个队列Redisson延迟队列天然支持多个应用实例同时消费同一个RBlockingQueue原因在于消费者竞争发生在Redis服务端每个实例的take操作本质上都是在抢同一个队列里的元素。Redisson底层通过Redis的阻塞弹出命令保证元素被一个消费者拿走之后不会再次被其他消费者拿到。所以你不需要额外引入负载均衡组件多个应用实例直接跑同一套消费者代码即可横向扩容。这里要注意的是每个实例都应该把RDelayedQueue正常初始化否则某个实例只消费不搬运会把“搬运到期消息”的职责全部压在少数实例上。一般推荐所有实例都创建RDelayedQueue后台搬运任务多做几次没有副作用反而提升了到期消息搬移的及时性。6.2 配置主从与持久化的注意点单机Redis可以完成延迟队列的开发和基本验证但线上环境我建议至少使用主从加哨兵的部署模式。主从模式下需要特别关注Redis的持久化配置。如果你配置成RDB save 900 1这种策略意味着数据变更至少几百秒才落盘一次延迟消息一旦在这个窗口期丢失就很难恢复。生产环境建议开启AOF按everysec配置既能兼顾性能和可靠性。对支付、订单这类强业务场景甚至可以针对保存延迟队列的DB单独配置更严格的持久化策略。另一个容易被忽略的问题是不要在同一个Redis实例里混放太多不同业务的延迟队列。万一某个队列出现消息积压Redis内存持续上涨其他不相关的业务也会受影响。按业务域拆分Redis实例或者使用独立DB是降低爆炸半径的好习惯。6.3 何时该换成RocketMQ这类消息中间件Redisson延迟队列虽然好用但终究是Redis之上的“轻量级封装”不适合承接所有延迟业务。如果你的系统已经引入了RocketMQ或RabbitMQ消息量又比较大我建议直接使用消息中间件自带的延迟能力。RocketMQ的延迟消息优势在于分级延迟和投递语义上有更完整的方案而且消息一旦进了Broker就不会因为Redis内存淘汰策略或者数据持久化配置不当而消失。另外如果业务需要按指定时间点精确触发而不是相对延迟比如“每天凌晨0点执行一次”那也不适合用延迟队列。延迟队列更适合统一格式的“N秒后执行”而不是“某个具体时刻执行”。把这两类需求分开选型上会清晰很多。6.4 监控与告警线上使用延迟队列之后一定要补上监控。最基本的两个指标是队列里待消费的消息数以及延迟队列里尚未到期的任务数。待消费的消息数可以通过RBlockingQueue.size()拿到如果这个值长时间增长说明消费者处理不过来需要扩容。尚未到期的任务数可以从对应ZSet结构读取如果积压了几十万条说明上游投递有问题或者业务量异常。我习惯了在监控面板上同时展示这三个指标队列积压量、消费耗时、消费失败数。队列积压量让你知道系统是否健康消费耗时让你知道下游有没有变慢消费失败数让你知道业务逻辑有没有出bug。三者结合才能形成完整的可观测性。最后再分享一个我在实际业务里也用得很顺的小技巧当消费处理失败时不要只记录日志就丢掉。我会先把失败的消息写入Redis的一个List结构作为死信队列然后再通过定时任务去补偿。这样一来延迟队列的消费线程不会被失败消息拖垮同时失败的数据也有迹可循。你把这个结构设计成可重入的后面排查线上问题会轻松很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。