Skip to content

延时任务的六种实现:从定时扫表到定时消息 ​

订单 30 分钟未支付自动关闭、优惠券到期提醒、支付结果轮询重试——这类需求看着简单,方案却有六七种,差别在于精度、规模和进程重启后任务会不会丢。

本文对比常见的六种实现,并实测了 Redis ZSET 延时队列的触发精度、多消费者下的重复取出和崩溃丢失。

一、先说结论 ​

  • 先问三个问题:精度要求(分钟级还是秒级)、任务规模(每天几千还是几百万)、丢失的代价(能不能靠兜底任务补上)。
  • 绝大多数业务场景,定时扫表就够了,配合状态条件更新,简单、可靠、容易排查。
  • 需要秒级精度且规模大时,用消息中间件的定时消息。RocketMQ 5.x 支持任意时刻的定时消息(默认最长 24 小时),RabbitMQ 用 TTL 加死信队列或延时插件。
  • Redis ZSET 延时队列是轻量选择,实测 100ms 轮询下触发延迟 p50 约 55ms、最大约一个轮询间隔;但每轮到期的任务超过单次取出上限时会积压,延迟可以涨到秒级。可靠性要自己保证。
  • JVM 内的 DelayQueue 和时间轮不适合跨进程场景:任务只存在于内存,重启即丢失,多实例还会重复执行。
  • 无论用哪种方案,都要有兜底的扫表任务,因为没有任何一种投递是绝对可靠的。

二、六种方案的定位 ​

精度:分钟级 → 毫秒级可靠易丢失定时扫表JVM DelayQueueNetty 时间轮Redis ZSETRabbitMQ TTL+DLXRocketMQ 定时消息
图 1 · 纵轴越高表示进程重启后任务不丢;横轴越靠右表示触发时间越准。左下角的方案只适合单机或可容忍丢失的场景
方案精度规模重启后适合
定时扫表分钟级(取决于扫描间隔)中(受索引与扫描量限制)不丢大多数业务的默认选择
JVM DelayQueue毫秒级小(受内存限制)丢失单机内的短时任务
时间轮(Netty HashedWheelTimer)毫秒级大(单机内)丢失单机内海量短时任务,如连接超时
Redis ZSET百毫秒级大不丢(Redis 持久化)已有 Redis、规模中等、可接受轮询延迟
RabbitMQ TTL + 死信队列秒级大不丢延时时长固定的场景
RocketMQ 定时消息毫秒级大不丢延时时长任意、规模大的场景

三、定时扫表:被低估的默认方案 ​

sql
-- 每分钟扫一次,只处理已超时且仍未支付的订单
SELECT id FROM `order`
WHERE status = 'CREATED' AND expire_at < NOW()
ORDER BY expire_at
LIMIT 500;

配合 (status, expire_at) 联合索引,这条查询很快。关键细节:

  • 每批有上限,避免一次捞出几十万条;处理完再捞下一批,直到没有为止。
  • 关闭动作用条件更新:UPDATE ... SET status='CLOSED' WHERE id=? AND status='CREATED',天然幂等,也不怕多个实例同时扫到,见 订单、库存与数据一致性。
  • 多实例要避免重复扫描:用分布式调度(XXL-Job、ShedLock)保证同一时刻只有一个实例在跑,或者按分片键拆分扫描范围。
  • 精度由扫描间隔决定。要求「30 分钟整关闭」时,1 分钟一次的扫描意味着实际关闭时间在 30—31 分钟之间,绝大多数业务可以接受。

它的问题只在规模:待处理数据量很大时,扫描本身会成为负担。但这时候通常也该考虑归档历史数据了,见 千万级大表怎么清理数据。

四、Redis ZSET 延时队列 ​

生产者ZADD 到期时间戳ZSET delay:ordersscore = 到期时间消费者轮询每 100ms 一次处理ZRANGEBYSCORE key -inf now LIMIT 0 100 → ZREM取出与删除要放进 Lua 或用 ZPOPMIN,否则多个消费者会拿到同一个任务实测 1000 个任务、100ms 轮询:p50 55ms,p90 95ms,最大 105ms
图 2 · 到期时间作为 score,消费者轮询取出已到期的成员;实测 100ms 轮询下的触发延迟 p50 约 55ms,最大约为一个轮询间隔

把到期时间戳作为 score:

bash
# 生产:30 分钟后到期
ZADD delay:orders 1758182400000 order-10086

# 消费:取出已到期的任务
ZRANGEBYSCORE delay:orders -inf <now> LIMIT 0 100
ZREM delay:orders order-10086 ...

4.1 实测精度 ​

在 Redis 8.10.1 上实测:1000 个任务,到期时间分布在未来 0.5—2.5 秒内,一个消费者按固定间隔轮询,用 Lua 脚本取出并删除到期任务:

轮询间隔每次最多取p50p90最大
20ms100012ms22ms34ms
100ms100055ms95ms105ms
100ms10054ms94ms110ms
500ms1000258ms445ms505ms
500ms1001,705ms2,894ms3,256ms

到期时间分散时,延迟由轮询间隔决定:平均约半个间隔,最坏约一个间隔。批量上限平时不起作用,100ms 轮询时每轮只到期约 50 个,每次取 100 个和取 1000 个结果一样。它只在每轮到期的任务超过上限时才出问题:500ms 轮询时每轮到期约 250 个,每次只取 100 个,取不完的留到下一轮,积压越滚越大,最大延迟超过 3 秒。

另一种情况是大量任务同一时刻到期(比如整点批量创建的订单)。1000 个任务同时到期、100ms 轮询时,每次取 100 个要取 10 轮,最后一批等了 964ms;每次取 1000 个则一轮取完。

所以调参的顺序是:先按精度要求定轮询间隔,再保证「单次取出上限 ≥ 高峰时一个间隔内到期的任务数」,并监控队列中已到期但未取出的任务数。缩短间隔的代价是空轮询的开销。

4.2 两个必须处理的问题 ​

取出与删除必须原子。 上面的两步写法在多消费者下会让同一个任务被取到多次。实测 4 个消费者抢 1000 个已到期的任务:两步写法、不看 ZREM 的返回值,共处理了 3,612 次,996 个任务被处理了不止一次。只处理 ZREM 返回 1 的成员(谁删掉算谁的),或者用下面方案一的 Lua 脚本,实测都降到 0。常见的写法有两种:

lua
-- 方案一:Lua 脚本里完成「查出 + 删除」
local ready = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
if #ready > 0 then redis.call('ZREM', KEYS[1], unpack(ready)) end
return ready
bash
# 方案二:用 ZPOPMIN 取出最小 score 的成员,再判断是否到期(未到期则放回)
ZPOPMIN delay:orders 1

任务取出后崩溃就丢了。 取出即从 ZSET 删除,处理过程中进程挂掉,这个任务不会再被触发。实测 100 个任务,一个消费者取出 10 个后崩溃,另一个消费者只处理完 90 个,那 10 个再也不会出现。可靠做法是「取出时移入处理中集合(score 为租约到期时间),处理完再删除」,并由补偿任务把租约过期的条目放回队列;同样的场景下 100 个全部完成。这里要求任务处理幂等,因为租约过期时原消费者可能只是慢,而不是崩溃——和 分布式信号量 里的租约机制是同一个思路。

嫌麻烦的话,还是那句话:加一个兜底扫表任务,它能覆盖所有丢失路径。

五、消息中间件的定时消息 ​

5.1 RabbitMQ:TTL + 死信队列 ​

给队列或消息设置 TTL,消息过期后进入死信交换机,消费者订阅死信队列:

text
业务 → 延时队列(TTL=30min,无消费者)→ 过期 → 死信交换机 → 关单队列 → 消费者

两个限制:

  • 队列级 TTL 只适合固定延时。每个延时时长要建一个队列(5 分钟、30 分钟、2 小时各一个)。
  • 消息级 TTL 有队头阻塞问题:RabbitMQ 只检查队头消息是否过期,前面的消息 TTL 更长时,后面已过期的消息不会被及时投递。

需要任意延时时,用官方的 rabbitmq_delayed_message_exchange 插件,它在交换机层面按时间调度,没有队头阻塞问题。

5.2 RocketMQ:定时消息 ​

RocketMQ 5.x 的定时消息支持任意时刻投递,按毫秒级时间戳指定:

java
Message message = provider.newMessageBuilder()
        .setTopic("order-close")                       // Topic 的 MessageType 必须是 Delay
        .setBody(orderId.getBytes(StandardCharsets.UTF_8))
        .setDeliveryTimestamp(System.currentTimeMillis() + Duration.ofMinutes(30).toMillis())
        .build();

官方文档明确的两个限制:默认最长延时 24 小时,且不建议把大量消息设置成同一个投递时刻,否则到点会形成流量尖峰。需要「整点批量触发」的场景,应该把时间打散。

早期版本(4.x)只支持固定的延时等级(1s、5s、10s…2h),需要任意延时时只能自己拆分或多次转发,这是升级到 5.x 的一个实际收益。

5.3 Kafka ​

Kafka 没有内建的定时消息。常见做法是按延时时长建几个固定的 Topic(delay-5m、delay-30m),消费者拉到消息后如果没到期就阻塞或暂停分区消费。这种方案实现起来别扭,如果延时需求很重,选 RocketMQ 或 RabbitMQ 更合适。

六、只在单机内有效的两种 ​

  • DelayQueue:JDK 自带的阻塞队列,按到期时间排序,take() 会阻塞到队头到期。适合单机内的短时任务(比如本地缓存刷新),任务只存在于堆内存,重启就没了,多实例还会各跑一份。
  • 时间轮(Netty HashedWheelTimer):用环形数组加指针推进,添加和取消任务都是 O(1),适合海量短时任务,典型用途是网络连接的超时检测。同样是内存结构,不跨进程。

用它们做业务延时任务,几乎一定会在某次发布后出现「订单没关」的问题。

七、选型顺序 ​

  1. 默认用定时扫表:精度分钟级,简单可靠,出问题容易查。
  2. 需要秒级精度或扫表压力大:用消息中间件的定时消息(已有 RocketMQ 首选它)。
  3. 只有 Redis、规模中等:用 ZSET 延时队列,并解决原子取出和崩溃丢失。
  4. 无论选哪个,都要有兜底扫表:频率可以很低(比如每 10 分钟一次),专门捞那些「本该被触发但还没被处理」的数据。
  5. 任务处理本身必须幂等:条件更新、唯一键,重复触发不能产生第二次副作用。

八、常见误区 ​

  • 「定时扫表太土,应该用消息队列」:扫表在精度要求不高时最可靠,且没有额外依赖。
  • 「用了延时消息就不会漏」:消息可能丢失、消费失败、被跳过,兜底任务不能省。
  • 「Redis ZSET 取出来就算消费成功」:取出后崩溃任务就丢了,需要处理中集合加补偿。
  • 「RabbitMQ 消息级 TTL 可以做任意延时」:存在队头阻塞,要用延时插件。
  • 「时间轮性能好,用它做订单超时」:内存结构不跨进程,重启即丢。

小结 ​

延时任务的选型是三个维度的权衡:精度、规模、可靠性。定时扫表牺牲精度换可靠和简单,内存方案牺牲可靠换精度,消息中间件的定时消息两者兼顾但引入了依赖。实际系统里最稳的组合是「定时消息或 ZSET 负责准时触发 + 低频扫表负责兜底 + 条件更新保证幂等」——前者管体验,后者管正确性。

消息的投递语义、重复与顺序问题,见 Kafka 不丢、不重与 Exactly Once。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权