ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot集成Redisson实现延迟队列:从入门到排坑实战

Spring Boot集成Redisson实现延迟队列:从入门到排坑实战

延迟任务在业务系统里几乎无处不在:用户下单后三十分钟未支付要自动关闭订单,直播开始前十分钟要提醒预约用户,会员到期前三天要推送续费提示。刚接触这类需求的人往往会直接用定时任务每分钟扫一次表,数据量小的时候没什么感觉,数据量上来之后数据库压力很快成为瓶颈。我也在项目里反复比较过延迟消息的实现方案,最终比较常用的是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底层依赖netty,Spring Boot中间接依赖了netty,两个版本如果不兼容,运行期会出现各种连不上Redis、线程池异常的诡异问题。

我建议优先用官方的redisson-spring-boot-starter,让Maven把Redisson和Spring Boot相关的依赖一起仲裁。下面是一个基准参考:

<properties> <spring.boot.version>2.7.18</spring.boot.version> <redisson.version>3.17.5</redisson.version> </properties> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>${redisson.version}</version> </dependency>

如果你的项目已经升级到 Spring Boot 3.x,Redisson版本最好选 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是如何排期的

看一下生产者端最核心的代码:

RBlockingQueue<OrderTimeoutEvent> queue = redissonClient.getBlockingQueue("order:timeout:queue"); RDelayedQueue<OrderTimeoutEvent> delayedQueue = redissonClient.getDelayedQueue(queue); delayedQueue.offer(event, 30, TimeUnit.SECONDS);

offer(event, 30, TimeUnit.SECONDS)做的事情大致可以拆成三步:

  1. 计算当前时间戳 + 30秒,作为这条消息在ZSet中的score。
  2. 把消息写入那个按score排序的ZSet。
  3. 通知内部的后台transferTask,让它知道这条消息在未来某个时间点需要搬运。

正因为score是绝对时间戳,所以消息如果因为Redis挂了或者应用重启而没有来得及搬运,只要ZSet里的数据还在,后续重新启动后依然可以按原来的时间点判断是否到期。Redisson的这个设计让延迟消息天然具备了一定的持久能力,不会因为进程重启就丢得干干净净。

3.3 消费者的take为什么能阻塞等待

消费者端看起来更简单:

RBlockingQueue<OrderTimeoutEvent> 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 RBlockingQueue<OrderTimeoutEvent> orderTimeoutQueue(RedissonClient redissonClient) { return redissonClient.getBlockingQueue(ORDER_TIMEOUT_QUEUE); } @Bean(destroyMethod = "") public RDelayedQueue<OrderTimeoutEvent> orderDelayedQueue( RedissonClient redissonClient, RBlockingQueue<OrderTimeoutEvent> orderTimeoutQueue) { return redissonClient.getDelayedQueue(orderTimeoutQueue); } }

这里有一个非常反直觉的细节:destroyMethod我写的是空字符串,而不是默认的close或者destroy。原因在后面的排坑章节会详细说,这里先记住一个结论:RDelayedQueue如果执行了destroy(),和这个队列相关的Redis对象会被清理,没消费的任务可能直接消失。Spring容器关闭时不该主动销毁它,让进程自然退出即可。

4.3 投递端:下单后立即提交延迟事件

订单服务在创建订单之后,只需要做一件事:把延迟关闭事件投递到延迟队列里。

@Service public class OrderTimeoutService { @Resource private RDelayedQueue<OrderTimeoutEvent> 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 RBlockingQueue<OrderTimeoutEvent> 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 自测三步法

写完代码之后,可以用下面的步骤快速自测:

  1. 启动Redis和Spring Boot应用,确认Redisson连接日志正常。
  2. 临时把延迟时间改成5秒,调用一次submitTimeoutCloseEvent("NO20240101", 10001L)。
  3. 观察控制台日志,正常情况下5秒后消费者线程会打印处理记录。
[order-timeout-worker-0] INFO 开始处理订单超时事件 orderNo=NO20240101 [order-timeout-worker-0] INFO 订单关闭成功 orderNo=NO20240101

如果看到消息到了但业务上没有关闭,优先检查消费者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结构,作为死信队列,然后再通过定时任务去补偿。这样一来,延迟队列的消费线程不会被失败消息拖垮,同时失败的数据也有迹可循。你把这个结构设计成可重入的,后面排查线上问题会轻松很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进