☰
SpringBoot集成Redisson延迟队列,彻底告别数据库定时扫表
2026/10/20 4:20:52 网站建设 项目流程

先纠正一个输入习惯:标题里的 Sping 和 redission,其实是 Spring 和 Redisson 的手滑,但这个问题我太熟了。做支付系统的时候,订单超时关闭、预授权释放、优惠券过期提醒,这些需求几乎每期迭代都能碰到。最粗暴的做法是定时任务每分钟扫一遍订单表,数据量小的时候挺开心,订单量一旦上来,数据库就遭罪。后来我把方案换成了 Spring 集成 Redisson 延迟队列,整体代码量少了一个量级,而且 Redis 本身就是现成的基础设施,不用额外引入消息队列。这篇文章就把我的完整落地过程、核心代码和踩过的坑都盘一遍,适合正在选型、或者已经被扫表任务折磨过的同学。

1. 为什么选择 Redisson 延迟队列

1.1 业务场景与需求拆解

先从一个最常见的需求说起:外卖订单 15 分钟未支付自动取消。假设系统里已经有订单表,字段里有create_time、pay_status。最直接的实现是定时任务每分钟跑一次,把“创建时间超过 15 分钟且未支付”的订单捞出来,批量更新状态。订单量小的时候没问题,可一旦到了高峰期,每分钟扫一次全表或者扫一次二级索引,对数据库的磁盘 IO 和缓存命中率都是不小的冲击。而且“每分钟扫一次”意味着单笔订单的最大延迟误差是 1 分钟,用户可能在 15 分 59 秒才收到取消通知,体验并不好。

这类需求本质上是:给每个任务设置一个未来触发时间,时间到了自动执行。任务可以是订单状态变更、活动提醒、缓存过期后的补偿动作,甚至是异步重试。它们有几个共同特征:延迟时间可配置,从几秒到几天;任务量可能非常大,一天几百万条;业务要求分布式部署,不能单机扛;执行动作需要和主流程解耦,最好“只投递、不用管”。

Redisson 延迟队列正好覆盖这些场景。它基于 Redis 实现,Redis 通常已经是微服务架构里的标配中间件,不需要额外引入 RabbitMQ、RocketMQ 那一套,也不需要维护 Topic、Exchange、死信路由这些概念。开发人员只需要关心三件事:投递消息、等到时间、消费消息。

1.2 常见技术方案横向对比

选型的时候我把市面上常见的方案都过了一遍,整理成一张对比表:

方案实现原理优点缺点
数据库定时扫表每隔若干秒执行 SQL 查询过期数据并处理实现简单,依赖少数据库压力大,延迟误差大,任务多时慢查询风险高
JDK DelayQueue / ScheduledExecutorServiceJVM 内存中的延迟队列毫秒级精度进程内有效,不支持分布式,重启丢失
RabbitMQ TTL + 死信队列消息设置 TTL,过期后进入死信队列,由消费者消费消息可靠,异步解耦需要引入并维护 MQ,TTL 设置繁琐,死信场景难排查
Redis Keyspace Notifications监听 key 过期事件代码量少消息易丢失,过期通知不保证精准,不适合生产关键任务
Redisson RDelayedQueueRedis 有序集合 + 队列,由客户端自动转移到期元素Redis 成本低,API 简单,支持分布式,消息不会丢在 zset 里依赖 Redis 稳定性,没有 MQ 级别的 ACK 机制

我在实际项目中最终选了 Redisson,原因很直接:团队里 Redis 运维已经很成熟,应用本身也用了 Redisson 做分布式锁,再加一个延迟队列只是多一个 API 的问题。对比 MQ 方案,RabbitMQ 的 TTL 延迟队列不是不好,而是运维成本和管理成本高;对比定时扫表,Redisson 的延迟队列能把轮询压力从数据库转移到 Redis,而且 Redis 本身擅长处理这种基于 score 的有序集合操作。

1.3 Redisson 延迟队列的运行原理

Redisson 的延迟队列在 3.x 版本里叫RDelayedQueue,它内部不是简单地把消息丢进 List,而是利用 Redis 的 ZSet 按到期时间排序。用一句话解释:投递消息时,消息带着一个未来执行时间戳作为 score 存入 ZSet;Redisson 客户端会有一个后台任务定期检查 ZSet 头部,凡是 score 已经小于等于当前时间的元素,就从 ZSet 里移除并转移到目标队列;消费者从目标队列里take()阻塞获取消息。

这里有个容易误解的点:RDelayedQueue和RQueue是两把钥匙。RDelayedQueue负责“延迟”,它内部持有RQueue作为真正放可消费消息的地方。消费者永远不直接读RDelayedQueue,而是读与它绑定的那个RQueue。看到网上很多同学在“为什么消费者 poll 不到消息”的问题里卡了半天,基本都是把这两个对象用混了。

ZSet 的 score 是客户端本地时钟生成的毫秒时间戳,Redisson 会用一个调度线程每隔一段时间检查一次到期元素。很多资料说它是每 10 毫秒检查一次,实测下来不同版本内部调度周期不一样,但延迟误差通常控制在几十毫秒以内,对订单超时这种秒级需求完全够用。关键点是:即使 Redisson 客户端宕机重启,ZSet 里的数据还在,重启后会继续扫描并转移到期元素,所以消息不会因为应用重启而全部丢失。

2. Spring 集成前的准备

2.1 版本选型与依赖引入

我用的技术栈是 Spring Boot 2.7 + JDK 8 + Redisson 3.17.6,这套组合在线上跑了挺长时间,没有因为版本兼容出问题。现在新项目如果用 Spring Boot 3.x,建议直接把 Redisson 升到 3.20 以上,因为新版本对 Jakarta 命名空间和 Java 17 的支持更完善。选版本的时候最忌讳“随便拿一个最新版”,我建议去 Maven 仓库看 Redisson 对应 Spring Boot 版本的 Release 说明,不同大版本之间 API 有变化,尤其是Config类里useClusterServers等配置方法。

Maven 依赖只需要一个核心包:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.17.6</version> </dependency>

没有用redisson-spring-boot-starter,因为那个自动配置虽然省事,但会自己创建RedissonClient,有时候和 Spring Data Redis 的连接配置互相干扰。我更倾向于由我自己的@Configuration类统一创建RedissonClient,所有连接参数都明明白白写在代码里,排查问题的时候少一个隐式魔法。

2.2 RedissonClient 的两种配置姿势

第一种姿势是 YAML 文件配置。如果你用的是redisson-spring-boot-starter,可以在application.yml里写:

spring: redis: redisson: config: | singleServerConfig: address: redis://127.0.0.1:6379 password: 123456 database: 0

第二种姿势是纯 Java Config。我实际项目中用的是这种,好处是可以在连接池参数、序列化策略上做细粒度控制,而且 IDE 里跳转源码方便:

@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setPassword("123456") .setDatabase(0) .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(16) .setTimeout(3000) .setRetryAttempts(3); config.setCodec(new JsonJacksonCodec()); return Redisson.create(config); } }

这里有几个细节容易踩坑。第一,setAddress必须带redis://前缀,只写127.0.0.1:6379会直接连不上;如果是 SSL 连接,前缀是rediss://。第二,生产环境如果使用 Redis 哨兵或集群模式,不要照抄单机配置,useSentinelServers和useClusterServers的参数完全不一样。第三,destroyMethod = "shutdown"这个属性很重要,Spring 容器关闭时会自动调用shutdown()释放连接,否则会看到一堆 Netty 线程在容器销毁后还挂着。

2.3 序列化策略如何选

Redisson 默认序列化是 Kryo 或者 Fst,不同版本默认值不一样。我在集成时吃过亏:代码里定义的消息体类改了字段名,老队列里还留着用 Kryo 序列化过的二进制数据,消费时直接反序列化失败,抛ClassNotFoundException。后来我把全局 Codec 改成了JsonJacksonCodec,Redis 里存的是一条条 JSON 字符串,出问题还能直接redis-cli看内容,排查成本低很多。

但是用JsonJacksonCodec也要注意泛型擦除问题。比如消息体定义成DelayMessage<OrderDTO>,写入 Redis 时 Jackson 会把类型信息丢得差不多,读出来的时候可能是LinkedHashMap,强转直接报错。我的做法是消息体里不要用太复杂的泛型嵌套,或者干脆为不同业务定义不同的消息类,一个队列对应一个具体类,反序列化时就明确了。序列化这件事看着低级,但延迟队列 90% 的怪问题都出在这层。

3. 核心功能代码实现

3.1 消息体设计

延迟队列里的消息不是简单的字符串,它至少要包含三部分信息:业务标识、执行动作、业务数据。我以一个支付超时订单为例:

public class OrderDelayMessage implements Serializable { private static final long serialVersionUID = 1L; private String orderId; private Long userId; private String orderType; private Long delayMillis; private String uniqueTaskId; public OrderDelayMessage() { } public OrderDelayMessage(String orderId, Long userId, String orderType, Long delayMillis, String uniqueTaskId) { this.orderId = orderId; this.userId = userId; this.orderType = orderType; this.delayMillis = delayMillis; this.uniqueTaskId = uniqueTaskId; } // getter / setter ... }

uniqueTaskId这个字段是为了幂等设计的。比如orderId加orderType拼接成一个全局唯一任务 ID,消费的时候用它做去重。如果不加这个字段,同一笔订单因为重复投递被消费两次,就会出现重复取消订单的问题。消息体里的delayMillis只是一种描述性信息,方便日志排查,真正决定延迟时间的是投递时传的参数。

有些教程喜欢定义一个超级通用的DelayMessage<T>,里面塞Map<String, Object>的 payload。我试过,短期开发是很爽,但时间一长就没人知道 payload 里有哪些字段,消费端写着一坨map.get("xxx"),不读文档根本跑不通。还不如消息类定义得笨一点,每个业务一个专用消息,字段写死,编译器帮你兜底。

3.2 生产者:投递延迟任务

Redisson 的延迟队列入口是getDelayedQueue,它能复用一个RQueue对象作为目标队列。我封装了一个DelayQueuePublisher组件:

@Component public class DelayQueuePublisher { @Resource private RedissonClient redissonClient; public <T> void publish(String queueName, T message, long delay, TimeUnit unit) { RQueue<T> queue = redissonClient.getQueue(queueName); RDelayedQueue<T> delayedQueue = redissonClient.getDelayedQueue(queue); delayedQueue.offer(message, delay, unit); } public <T> boolean cancel(String queueName, T message) { RQueue<T> queue = redissonClient.getQueue(queueName); RDelayedQueue<T> delayedQueue = redissonClient.getDelayedQueue(queue); return delayedQueue.remove(message); } }

投递的时候调用:

String queueName = "delay:order:pay:cancel"; OrderDelayMessage msg = new OrderDelayMessage( orderId, userId, "pay_cancel", 15 * 60 * 1000L, orderId + ":pay_cancel" ); delayQueuePublisher.publish(queueName, msg, 15, TimeUnit.MINUTES);

这里有个编程细节:每次publish都调一次getDelayedQueue,会不会创建很多对象?Redisson 内部对RDelayedQueue和RQueue都有实例缓存,同一个 key 返回的是同一个对象,所以不用担心资源泄漏。但要注意,getDelayedQueue的参数是RQueue,你如果先getQueue再传入,OK;如果直接在getDelayedQueue里传一个字符串,编译是过不去的,API 设计上不允许。

cancel方法非常实用。比如订单在 15 分钟内完成支付,那延迟取消任务就不应该再执行,此时用remove(message)把 ZSet 里的元素移除。remove返回boolean,表示是否真的移除成功;如果返回false,可能任务已经被转移到目标队列,也可能已经消费完了,这种情况下需要在业务侧再判断订单状态。

3.3 消费者:监听与消费

消费者我推荐用一个独立线程从目标队列里阻塞take(),而不是用@Scheduled每分钟poll()。因为take()是阻塞方法,Redis 端对应的是BRPOP,没有消息的时候线程挂在那里等,一旦有消息立刻返回,延迟误差很小;轮询poll()即使设成每 500 毫秒一次,也凭空增加了平均延迟。

一个可运行的最小消费者:

@Component public class OrderDelayConsumer implements SmartLifecycle { private static final Logger log = LoggerFactory.getLogger(OrderDelayConsumer.class); private static final String QUEUE_NAME = "delay:order:pay:cancel"; @Resource private RedissonClient redissonClient; private Thread consumerThread; private volatile boolean running = false; private volatile boolean started = false; @Override public void start() { if (started) { return; } started = true; running = true; consumerThread = new Thread(this::consume, "order-delay-queue-consumer"); consumerThread.setDaemon(false); consumerThread.start(); } private void consume() { RBlockingQueue<OrderDelayMessage> queue = redissonClient.getBlockingQueue(QUEUE_NAME); while (running) { try { OrderDelayMessage msg = queue.take(); processWithIdempotency(msg); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { log.error("consume delay message failed, queueName={}", QUEUE_NAME, e); } } } private void processWithIdempotency(OrderDelayMessage msg) { // 1. 判断订单当前状态,如果已支付则直接返回 // 2. 执行关闭订单操作 // 3. 记录消费日志 } @Override public void stop() { running = false; if (consumerThread != null) { consumerThread.interrupt(); } } @Override public boolean isRunning() { return running; } @Override public boolean isAutoStartup() { return true; } @Override public int getPhase() { return 0; } }

实现SmartLifecycle要比@PostConstruct强不少。@PostConstruct方法在 Spring 容器初始化的早期阶段就会跑,如果那时 Redis 连接还没准备好,可能抛异常;而SmartLifecycle在容器刷新完成后启动,还能控制多个消费者 Bean 的启动顺序。stop()方法里设置running = false后,再interrupt()正在take()的线程,线程会退出阻塞并处理中断标记,整个流程很干净。

多个消费者实例需要注意:所有实例都会启动消费者线程,但同一消息只会被一个实例的BRPOP取走,相当于天然负载均衡。如果某个实例处理得慢,消息会尽量被其他空闲实例拿走,这是 Redis 阻塞队列的特性。不过RBlockingQueue.take()本质上是对同一个 Redis List 的竞争的,多个实例之间不需要手动分片。

3.4 优雅关闭与异常兜底

消费者线程捕获异常时,我习惯把异常消息写入一个死信队列,而不是直接记一条 error 日志就完事。死信队列就是一个普通的RBlockingQueue,只是名字不同:

private void sendToDeadQueue(OrderDelayMessage msg, String reason) { try { RBlockingQueue<OrderDelayMessage> deadQueue = redissonClient.getBlockingQueue("delay:order:dead"); deadQueue.put(msg); } catch (Exception e) { log.error("write dead queue failed, orderId={}", msg.getOrderId(), e); } }

死信队列里的消息可以另外起一个补偿任务定时扫描,或者人工介入处理。这里要克制一点:不要在catch里无脑把同一个消息重新offer回到延迟队列,那样如果业务处理一直失败,消息就会在 Redis 里进入死循环。我的策略是重试次数限制在 3 次以内,超过就进入死信队列。消息体里可以加一个retryCount字段,每次消费失败时自增。

4. 实践中的坑与排查实录

4.1 序列化与类型转换

使用JsonJacksonCodec遇到的第一个坑是LocalDateTime反序列化。Spring Boot 项目普遍习惯用LocalDateTime做时间字段,但 Jackson 默认不会处理 Java 8 时间类型,需要引入jackson-datatype-jsr310并注册JavaTimeModule。Redisson 内部的JsonJacksonCodec默认是否注册这个模块,不同版本行为还不一样。要么消息体里所有时间字段都用Long时间戳,要么自己扩展JsonJacksonCodec。

第二个坑是接口里用了泛型父类。比如消息体继承了一个BaseMessage<T>,消费端强转成子类时偶尔会报cannot be cast to。这通常是 Jackson 泛型信息丢失造成的,解决办法是消费端反序列化时用TypeReference或者在消息体里固定具体类型。最笨的方法反而是最稳的:一个队列一个消息类,不要搞各种继承。

4.2 消息丢失与 Redis 持久化

延迟队列的消息并不是“绝对不丢”,它依赖 Redis 的持久化能力。如果 Redis 只开了 RDB 快照,默认策略可能是 60 秒内 1000 次写入才触发一次快照,那两次快照之间的所有延迟任务,在 Redis 宕机重启后都会丢。生产环境建议至少开启 AOF 持久化,appendfsync至少要设置成everysec,对性能影响可控,安全性比 RDB 高很多。

如果是 Redis Cluster 模式,还需要关注主从切换的数据同步问题。某个分片的主节点挂掉时,从节点如果没有完全同步,切换后 ZSet 里的一部分数据也会丢。关键业务消息不能只依赖延迟队列,我最终的做法是:订单表有一个expire_time字段和task_status字段,延迟队列负责执行,但启动一个低频率兜底任务,把expire_time已到且task_status还处于待处理状态的订单重新投递一次。这种“队列为主、数据库兜底”的双保险,比单纯迷信 Redis 持久化靠谱得多。

4.3 重复消费与幂等设计

Redisson 的RQueue.take()没有 ACK 机制。消息被取走后,如果消费者在处理过程中抛异常导致进程退出,这条消息就永远不会回来了。如果消费者在catch中把消息重新写回队列,那又会造成重复消费。所以幂等不是可选项,是必选项。

最简单的幂等方案是利用数据库唯一索引。订单关闭任务可以根据orderId加一个处理类型作为唯一键,插入成功说明第一次处理,插入失败说明已经处理过。也可以使用 Redis 的setIfAbsent做短时间去重:

String lockKey = "delay:order:done:" + msg.getOrderId(); Boolean first = redissonClient.getBucket(lockKey).trySet("1", Duration.ofHours(1)); if (Boolean.TRUE.equals(first)) { executeCloseOrder(msg); }

但这只能挡住并发场景,如果消费线程在trySet成功之后、executeCloseOrder执行之前崩溃,消息还是丢了。稳妥的做法还是让业务操作本身具备幂等性,比如关闭订单的 SQL 是UPDATE orders SET status = 'CLOSED' WHERE order_id = ? AND status = 'PAID',只更新特定状态,重复执行也不会产生副作用。

4.4 延迟精度与时间校准

ZSet 的 score 是投递那一刻客户端算出来的时间戳,所以多实例部署时,如果各台服务器的系统时钟不同步,会导致同样 15 分钟延迟的消息有的 14 分 50 秒执行,有的 15 分 10 秒执行。必须给生产服务器统一配置 NTP 时间同步,这不只是为了日志对齐,更是为了保证延迟队列的触发时间一致性。

Redisson 内部的调度周期也会影响精度。它需要周期性地扫描 ZSet 头部,看是否有元素到期。如果扫描间隔刚好偏大,少数消息的消费时间会偶尔比预设时间晚几百毫秒。对秒级甚至分钟级业务无所谓,但如果业务要求秒级精准,就最好在消费端记录实际执行时间和预期执行时间,做个差值监控,超过阈值报警。

4.5 测试与验证方案

集成测试不能只测“投进去能不能出来”,要覆盖延迟、取消、重复消费几个维度。我推荐用 Testcontainers 启动一个单机 Redis,再写几个用例:

  • 投递延迟 2 秒的消息,线程睡眠 2 秒后能消费到;
  • 投递后立刻cancel,等待超过延迟时间,确认消费不到;
  • 连续投递 100 条消息,消费者线程全部处理,记录处理数量是否等于 100;
  • 模拟消费者处理异常,确认消息进入死信队列,且不无限制重试。

实际测试时还可以用redis-cli直接看 Redis 里的内部 key。keys *delay*能看到类似redisson_delay_queue_timeout:{queueName}和{queueName}这样的 key。前者是 ZSet,后者是 List。看到一个消息已经在目标 List 里,说明延迟转移已经完成,问题大概率出在消费者线程上;如果消息还在 ZSet 里,说明还没到时间或者调度线程没正常工作。

5. 性能观察与扩展建议

5.1 压测与监控

我在压测环境做过一轮简单测试:单机 Redis、消息体 200 字节左右、50 个并发消费者线程,投递 10 万条延迟 5 秒的消息,峰值吞吐大概在每秒 8000 条左右,消费侧吞吐没有出现明显积压。延迟队列的瓶颈主要在 Redis 的写操作和 ZSet 的扫描,而不是应用线程。如果要提高吞吐,可以多定义几个队列,把不同业务分散到不同 key 上,避免所有任务挤在一个 ZSet 里。

监控上至少要看四个指标:

指标获取方式说明
延迟队列 ZSet 大小Rediszcard命令表示等待执行的任务数量
目标队列 List 长度Redisllen命令表示已经到期但还没被消费的消息数量
消费者线程存活状态Spring Actuator 自定义端点线程挂掉要能立刻感知
执行耗时与失败率日志 + 打点判断业务处理是否健康

如果llen持续上涨,说明生产速度快于消费速度,优先看消费者线程数和业务处理耗时,而不是盲目加 Redis 资源。如果 ZSet 大小异常增长,说明上游投递量很大或者延迟时间设得太长,要检查业务投递逻辑有没有重复投递、参数有没有写反。

5.2 高可用架构下的扩展思考

Redisson 延迟队列对中小规模场景非常合适,但我不会建议把它当成万能方案。如果业务量达到千万级甚至亿级,且对消息可靠性要求极高,我更倾向于把“延迟任务”这个需求单独建模:任务表存数据库,状态字段推进,用 RocketMQ 或其它专业消息队列的定时消息能力触发执行。Redisson 的优势是轻、快、好接入,缺点是它依赖 Redis 的稳定性,Redis 一旦抖动,所有延迟任务都会受到影响。

多系统部署时,消费者实例会水平扩展,但要注意所有实例必须连同一个 Redis 或者同一个集群,不能各连各的。还有一个容易忽略的地方:应用发布时,消费者线程会随着 Spring 容器优雅关闭,如果发布过程比较长,延迟到期的任务会积压在 List 里,新实例起来后会自动消费,不会丢。但如果使用了强制杀进程,没有给stop()方法时间,线程会直接消失,已经取到内存里还没处理完的消息就会丢,这点在发布流程里要考虑进去。

我个人在项目里还有一个习惯:启动时扫描一遍数据库里“本该到期但还没执行”的任务,重新投递到延迟队列。这样即使发布期间 Redis 短暂不可用,业务也能在一定时间后自愈。延迟队列是很好用,但把它当唯一数据源是有风险的,数据库里的状态字段永远是保险绳。

最后再分享一个小技巧:延迟队列的队列名统一用delay:开头,比如delay:order:pay:cancel、delay:promotion:expire。这样 Redis 里的 key 一眼就能区分,监控也方便。至于标题里 Spring 和 Redisson 的拼写错误,代码能跑就行,问题不大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询