延时任务的六种实现:从定时扫表到定时消息
订单 30 分钟未支付自动关闭、优惠券到期提醒、支付结果轮询重试——这类需求看着简单,方案却有六七种,差别在于精度、规模和进程重启后任务会不会丢。
本文对比常见的六种实现,并实测了 Redis ZSET 延时队列的触发精度、多消费者下的重复取出和崩溃丢失。
一、先说结论
- 先问三个问题:精度要求(分钟级还是秒级)、任务规模(每天几千还是几百万)、丢失的代价(能不能靠兜底任务补上)。
- 绝大多数业务场景,定时扫表就够了,配合状态条件更新,简单、可靠、容易排查。
- 需要秒级精度且规模大时,用消息中间件的定时消息。RocketMQ 5.x 支持任意时刻的定时消息(默认最长 24 小时),RabbitMQ 用 TTL 加死信队列或延时插件。
- Redis ZSET 延时队列是轻量选择,实测 100ms 轮询下触发延迟 p50 约 55ms、最大约一个轮询间隔;但每轮到期的任务超过单次取出上限时会积压,延迟可以涨到秒级。可靠性要自己保证。
- JVM 内的
DelayQueue和时间轮不适合跨进程场景:任务只存在于内存,重启即丢失,多实例还会重复执行。 - 无论用哪种方案,都要有兜底的扫表任务,因为没有任何一种投递是绝对可靠的。
二、六种方案的定位
| 方案 | 精度 | 规模 | 重启后 | 适合 |
|---|---|---|---|---|
| 定时扫表 | 分钟级(取决于扫描间隔) | 中(受索引与扫描量限制) | 不丢 | 大多数业务的默认选择 |
JVM DelayQueue | 毫秒级 | 小(受内存限制) | 丢失 | 单机内的短时任务 |
时间轮(Netty HashedWheelTimer) | 毫秒级 | 大(单机内) | 丢失 | 单机内海量短时任务,如连接超时 |
| Redis ZSET | 百毫秒级 | 大 | 不丢(Redis 持久化) | 已有 Redis、规模中等、可接受轮询延迟 |
| RabbitMQ TTL + 死信队列 | 秒级 | 大 | 不丢 | 延时时长固定的场景 |
| RocketMQ 定时消息 | 毫秒级 | 大 | 不丢 | 延时时长任意、规模大的场景 |
三、定时扫表:被低估的默认方案
-- 每分钟扫一次,只处理已超时且仍未支付的订单
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 延时队列
把到期时间戳作为 score:
# 生产: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 脚本取出并删除到期任务:
| 轮询间隔 | 每次最多取 | p50 | p90 | 最大 |
|---|---|---|---|---|
| 20ms | 1000 | 12ms | 22ms | 34ms |
| 100ms | 1000 | 55ms | 95ms | 105ms |
| 100ms | 100 | 54ms | 94ms | 110ms |
| 500ms | 1000 | 258ms | 445ms | 505ms |
| 500ms | 100 | 1,705ms | 2,894ms | 3,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 脚本里完成「查出 + 删除」
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# 方案二:用 ZPOPMIN 取出最小 score 的成员,再判断是否到期(未到期则放回)
ZPOPMIN delay:orders 1任务取出后崩溃就丢了。 取出即从 ZSET 删除,处理过程中进程挂掉,这个任务不会再被触发。实测 100 个任务,一个消费者取出 10 个后崩溃,另一个消费者只处理完 90 个,那 10 个再也不会出现。可靠做法是「取出时移入处理中集合(score 为租约到期时间),处理完再删除」,并由补偿任务把租约过期的条目放回队列;同样的场景下 100 个全部完成。这里要求任务处理幂等,因为租约过期时原消费者可能只是慢,而不是崩溃——和 分布式信号量 里的租约机制是同一个思路。
嫌麻烦的话,还是那句话:加一个兜底扫表任务,它能覆盖所有丢失路径。
五、消息中间件的定时消息
5.1 RabbitMQ:TTL + 死信队列
给队列或消息设置 TTL,消息过期后进入死信交换机,消费者订阅死信队列:
业务 → 延时队列(TTL=30min,无消费者)→ 过期 → 死信交换机 → 关单队列 → 消费者两个限制:
- 队列级 TTL 只适合固定延时。每个延时时长要建一个队列(5 分钟、30 分钟、2 小时各一个)。
- 消息级 TTL 有队头阻塞问题:RabbitMQ 只检查队头消息是否过期,前面的消息 TTL 更长时,后面已过期的消息不会被及时投递。
需要任意延时时,用官方的 rabbitmq_delayed_message_exchange 插件,它在交换机层面按时间调度,没有队头阻塞问题。
5.2 RocketMQ:定时消息
RocketMQ 5.x 的定时消息支持任意时刻投递,按毫秒级时间戳指定:
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),适合海量短时任务,典型用途是网络连接的超时检测。同样是内存结构,不跨进程。
用它们做业务延时任务,几乎一定会在某次发布后出现「订单没关」的问题。
七、选型顺序
- 默认用定时扫表:精度分钟级,简单可靠,出问题容易查。
- 需要秒级精度或扫表压力大:用消息中间件的定时消息(已有 RocketMQ 首选它)。
- 只有 Redis、规模中等:用 ZSET 延时队列,并解决原子取出和崩溃丢失。
- 无论选哪个,都要有兜底扫表:频率可以很低(比如每 10 分钟一次),专门捞那些「本该被触发但还没被处理」的数据。
- 任务处理本身必须幂等:条件更新、唯一键,重复触发不能产生第二次副作用。
八、常见误区
- 「定时扫表太土,应该用消息队列」:扫表在精度要求不高时最可靠,且没有额外依赖。
- 「用了延时消息就不会漏」:消息可能丢失、消费失败、被跳过,兜底任务不能省。
- 「Redis ZSET 取出来就算消费成功」:取出后崩溃任务就丢了,需要处理中集合加补偿。
- 「RabbitMQ 消息级 TTL 可以做任意延时」:存在队头阻塞,要用延时插件。
- 「时间轮性能好,用它做订单超时」:内存结构不跨进程,重启即丢。
小结
延时任务的选型是三个维度的权衡:精度、规模、可靠性。定时扫表牺牲精度换可靠和简单,内存方案牺牲可靠换精度,消息中间件的定时消息两者兼顾但引入了依赖。实际系统里最稳的组合是「定时消息或 ZSET 负责准时触发 + 低频扫表负责兜底 + 条件更新保证幂等」——前者管体验,后者管正确性。
消息的投递语义、重复与顺序问题,见 Kafka 不丢、不重与 Exactly Once。
配套实验
- codesphere-labs/distributed/redis-delay-queue:轮询间隔与批量上限造成的触发延迟、两步取删的重复消费、取出后崩溃的丢失与租约回收(验证记录)
参考资料