延时队列核心是“精准标记+主动释放”:任务写入时即固化触发时间戳,按时间有序组织,到期由worker拉取、broker自动流转或take()阻塞获取,结合持久化、ntp校时、幂等去重保障不丢不错不重。

延时队列的核心不是“等时间”,而是“精准标记+主动释放”——任务写入时即确定可执行时刻,系统不靠轮询倒计时,而是按时间戳有序组织、到期批量触发。
任务如何被标记为“可执行”?
关键在写入阶段就固化触发时间点,不同实现方式略有差异:
- Redis(ZSET):任务存入有序集合,score = 执行时间戳(毫秒级),值为序列化任务数据;worker 每次只取 score ≤ 当前时间戳的成员
- RabbitMQ(死信+TTL):先发到带 TTL 的临时队列,到期自动转入绑定的死信交换器,再路由至真实消费队列
- RocketMQ(TimerIndexFile):消息写入独立定时存储目录,由专门的 TimerService 扫描索引文件,按时间片投递到底层 consume queue
-
数据库(available_at 字段):任务记录插入时写入计算好的执行时间,调度器查
WHERE available_at 并加行锁防重复
谁来检查并释放到期任务?
这不是被动等待,而是一套有节奏的“拉取-搬运-分发”机制:
- Redis 场景下,通常由一个或多个专用 worker 进程定期执行
ZRANGEBYSCORE delay_zset -inf now_timestamp,取出后用 Lua 脚本原子性地ZREM+LPUSH到待执行队列 - RabbitMQ 中无需额外调度器,TTL 到期由 broker 自动触发死信流转,完全由服务端保障
- 数据库方案常配合 XXL-JOB 或自研轻量调度器,每 1–5 秒执行一次扫描 SQL,避免长轮询空耗资源
- DelayQueue 是纯内存单机结构,依赖调用方主动调用
take()阻塞等待,适合非高可用但低延迟要求的内部模块
变量到期怎么和业务逻辑挂钩?
所谓“变量”,实际是任务携带的上下文参数,它必须在投递时就完成序列化封装,不能留到执行时动态查询:
- 订单超时场景中,“订单ID”“用户ID”“原始创建时间”要作为 payload 一并写入队列,而不是只存个 ID 再现场查库——避免执行时数据已变更或删除
- Laravel 中
delay(now()->addMinutes(30))本质是把 future timestamp 序列化进 job payload,Redis driver 后续按该时间判断是否入队 - RocketMQ 定时消息支持
setDelayTimeLevel()或直接设setDeliverTimeMs(System.currentTimeMillis() + 1800_000),时间信息随消息体持久化 - 所有方案都要求任务类/消息体具备幂等性设计,因网络抖动或重试可能导致同一条任务被执行多次
怎么确保不丢、不错、不重复?
生产环境必须直面三个问题:崩溃丢失、时钟漂移、并发争抢。应对策略需分层设计:
-
持久化:Redis 开启 AOF + RDB 混合模式;RabbitMQ 开启
durable=true+ 消息delivery_mode=2;数据库用事务写入 - 时间校准:禁止使用本地系统时间做判定;统一接入 NTP 服务,Redis worker 和 DB 调度器读取同一台时间服务器
- 去重与幂等:为每个任务生成唯一 trace_id,消费前先查 Redis 缓存或数据库状态表,已处理则跳过;或用乐观锁更新订单状态字段
- 失败兜底:设置最大重试次数 + 死信通道;对关键任务(如关单)增加人工干预入口和监控告警










