延时队列:Redis、RocketMQ 与时间轮的选型 — editorial cover photo

延时队列:Redis、RocketMQ 与时间轮的选型

订单超时关闭、优惠券到期、预约提醒,延时任务无处不在,但 Redis、RocketMQ、时间轮三种实现方式差异巨大,选型要看场景。

2025年4月16日·返回文章列表
文章大纲

电商系统里有一类需求特别常见但总让人头疼,就是延时任务。下单后 30 分钟不付款自动关单,优惠券到期前提醒,预约的时间到了触发通知。这些任务的特点是「在未来的某个时间点执行」,不是立刻执行。

这就引出了延时队列,一种能「让消息延迟指定时间后被消费」的机制。听起来简单,但实现方式有好几种,各自的适用场景、精度、可靠性、复杂度差别很大。选错了要么精度不够、要么可靠性差、要么杀鸡用牛刀。这篇把主流的三种方案讲清楚。

场景先分类,不同场景要求不同

延时任务按业务要求大致分几类,对队列的要求不一样。

高精度任务,比如「15:00:00 准时开始抢购」「定时发推送」,对执行时间的精度要求高,差几秒都算事故。这类需要队列有较高的时间精度。

低精度任务,比如「下单后 30 分钟不付款关单」,早 10 秒晚 10 秒无所谓。这类对精度要求低,但对可靠性要求高,任务不能丢。

海量任务,比如「给所有过期的会员发提醒」,一次可能几百万条。这类对吞吐量要求高,队列不能成为瓶颈。

先分清你的任务属于哪类,再选方案。最怕的是用高精度方案去做低精度海量任务,成本爆炸。或者用低可靠方案去做关键业务任务,出了事任务丢了。

Redis ZSet,最简单直接的方案

最朴素的延时队列用 Redis 的 ZSet 实现。每个任务存成一个 ZSet 的元素,score 是任务的执行时间戳,value 是任务内容或者任务 ID。一个后台消费者不断轮询,用 ZRANGEBYSCORE 取出 score 小于当前时间的任务,取到了就执行,同时从 ZSet 删除。

这套方案的优点是简单,Redis 大家都会用,几行代码就实现了。缺点也明显。轮询的间隔决定了精度,轮询越频繁精度越高但 Redis 压力越大。多消费者并发取任务时要处理竞争(用 ZPOPMIN 或者 Lua 脚本保证原子)。任务执行失败了怎么办,要有重试和补偿机制。

这套方案适合任务量不大、精度要求不高、能容忍偶尔丢失的场景。比如内部系统的定时提醒、非关键的异步任务。用在电商关单这种关键业务上就有点勉强,因为 Redis 宕了 ZSet 数据可能丢(除非开了持久化,但那又有性能代价)。

实际中常见的一个增强是,任务内容只存任务 ID,真正的任务数据存数据库。消费者拿到任务 ID 后去数据库查详情执行。这样 ZSet 里只有轻量的 ID,内存占用小,任务数据有数据库兜底不丢。

RocketMQ 延时消息,可靠但固定级别

RocketMQ 原生支持延时消息,这是它的一个特性。发消息的时候指定延时级别,消息会在指定时间后才被消费者看到。

RocketMQ 的延时级别是固定的 18 档,从 1 秒到 2 小时(1s 5s 10s 30s 1m 2m ... 2h)。你只能选这 18 档里的一个,不能任意指定时间。这是它的一个限制,但对绝大多数业务场景够用了,因为延时任务的精度要求本来就不高。

它的优点是可靠性高。RocketMQ 是专业的消息队列,消息持久化、消费确认、失败重试这些都有保障。任务不会因为某个节点挂了就丢。而且它是分布式系统,吞吐量大,适合海量延时任务。

缺点是延时级别固定。如果你的业务需要「精确到某个时间点」(比如 15:30:00),RocketMQ 的固定级别满足不了。还有它毕竟是消息队列,引入它意味着引入一整套 MQ 的运维,对团队有一定要求。

蜂控平台用 RabbitMQ 解耦批量验真和报表导出,思路是类似的。把延时或者耗时的操作丢给消息队列异步处理,主链路不阻塞。区别是 RabbitMQ 没有原生的延时消息能力,要做延时得靠死信队列或者延时插件,不如 RocketMQ 开箱即用。

时间轮,高精度高吞吐的方案

PlantUML
正在加载图表…
PlantUML diagram

图:时间轮原理——任务按延时放入对应格子,指针走到即触发,添加和触发都是 O(1)

时间轮是一种更精巧的数据结构,适合高精度、高吞吐的延时任务。Netty、Kafka、ZooKeeper 内部都用了时间轮。

它的原理想象一个时钟的表盘。表盘上有 N 个格子,每个格子代表一个时间间隔(比如 1 秒)。一个指针每过一个间隔走一格。任务根据它的延时时间,被放到对应的格子里。指针走到那个格子时,格子里的任务就到期被执行。

如果延时时间超过一圈(N 个间隔),就用多层时间轮,类似时钟的秒针分针时针。超过一圈的任务先放在外层轮,外层轮转一圈再把任务降级到内层轮。

时间轮的优点是,添加任务和到期触发都是 O(1) 的,非常高效。精度由格子的大小决定,格子越小精度越高。适合海量且需要较高精度的延时任务。

缺点是它是内存数据结构,进程挂了任务就丢了。要做可靠性,得配合持久化(任务入库,启动时恢复)。而且时间轮通常作为应用内的组件,不像 Redis 或 MQ 那样天然分布式,分布式协调要自己实现。

所以时间轮适合放在单个应用实例内做本地延时调度,或者作为某个中间件内部的调度机制。要做跨节点的分布式延时队列,光靠时间轮不够,要配合别的存储。

怎么选,回到三个维度

PlantUML
正在加载图表…
PlantUML diagram

图:三方案选型对比——可靠性、精度、规模三维度权衡,没有银弹各适合不同场景

延时队列的选型,看三个维度。

可靠性要求。任务能不能丢。不能丢的(关单、扣款这类关键业务),用 RocketMQ 这种有持久化保障的。能容忍偶尔丢的(提醒、统计),Redis 就够。

精度要求。需要精确到秒还是分钟级就行。精确到秒的高精度场景,时间轮合适。分钟级的低精度场景,Redis ZSet 或 RocketMQ 都行。

规模要求。任务量大不大。海量任务(百万级),Redis ZSet 轮询会成瓶颈,用 RocketMQ 或者时间轮更合适。任务量小,怎么都行。

延时任务的可靠性容易被低估

很多人用 Redis ZSet 做延时队列,觉得简单够用,但忽略了 Redis 宕机时的任务丢失风险。下单关单这种任务丢了,结果是用户下了单没付款却一直不关单,库存被占着,超卖或者库存泄漏。所以关键业务的延时任务,可靠性是第一位的,宁可方案重一点。Redis 做延时队列,务必开持久化,并且要有任务落库的对账机制,不能只信内存里的 ZSet。

一个常见的组合方案

PlantUML
正在加载图表…
PlantUML diagram

图:电商关单组合方案——RocketMQ 延时消息主路径 + 数据库扫描兜底,既快又不丢

实际系统里,延时队列往往不是单一方案,是组合。

以电商关单为例。下单时,发一条 RocketMQ 延时消息(30 分钟级别),同时把订单的「待支付」状态和「超时时间」写进数据库。延时消息到了被消费,检查订单状态,还是待支付就关单。

如果 RocketMQ 的延时消息因为某种原因没投递(虽然概率很低),还有一个兜底的定时任务,每隔几分钟扫数据库里超时未支付的订单,补偿关单。这样即使消息丢了,定时任务也能兜住。

这套「延时消息加数据库兜底」的组合,是生产环境里比较稳的做法。主路径用消息队列保证实时性和吞吐,兜底用数据库扫描保证可靠性。两者结合,既快又不丢。比单押 Redis ZSet 或者单押消息队列都稳。

延时队列这事没有银弹,Redis 简单但不稳,MQ 可靠但重,时间轮精确但单机。理解了每种方案的边界,在自己的场景里组合使用,才是工程上的正确做法。

Continue Reading

关联文档推荐

查看全部
多级缓存:从浏览器到 DB 的全链路缓存设计 — vibrant cover for technical blog article消息队列:顺序、幂等与可靠投递的工程保证 — editorial cover photoRedis 热点 Key 与大 Key 治理 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。