先说结论

小规模低精度任务可用数据库按执行时间建立索引并分批抢占;已有消息系统且延迟级别受支持时可用延迟消息;海量近时任务可用分层时间轮降低调度成本。无论调度器如何实现,执行通常至少一次,业务处理必须幂等。

任务表或事件日志是可靠事实源,内存时间轮只是加速索引,进程重启后必须能重新加载未完成任务。

状态模型

任务包含唯一 ID、业务键、计划时间、状态、重试次数和载荷引用。通过条件更新从待执行抢占到处理中,设置租约防止执行器宕机永久占用;成功、失败、取消只允许合法状态迁移。

最小状态机

PENDING --到期抢占--> RUNNING --成功--> SUCCEEDED
    |                    |
    |                    +--租约超时--> PENDING/RETRY
    +--取消-------------> CANCELED
RUNNING --失败且可重试--> RETRY_WAIT --到期--> PENDING

状态更新要带版本或条件,例如 UPDATE task SET status='RUNNING', lease_until=? WHERE id=? AND status='PENDING',返回影响行数为 1 才算抢占成功。多个调度器同时扫描时,只有一个能拿到租约。

数据库扫描实现

status, execute_at, id 建联合索引,按小批次查询到期任务并使用 FOR UPDATE SKIP LOCKED 或条件更新抢占。扫描周期、批量大小和预取窗口决定准时率与数据库压力;不要每秒全表扫描。

SELECT id
FROM delayed_task
WHERE status = 'PENDING' AND execute_at <= NOW()
ORDER BY execute_at, id
LIMIT 100;

扫描到任务后还需原子抢占,不能把 SELECT 结果直接当作所有权。大规模任务可按时间桶和逻辑分片分散扫描。

时间轮与分层存储

时间轮适合大量近时任务:槽位保存未来一段时间内的任务,tick 到达时投递执行;很远的任务仍放持久化存储,接近时间窗口才加载。时间轮重启后必须从数据库或日志恢复,不能把内存结构当事实源。

延迟消息队列省掉自建调度,但要确认最小延迟粒度、最大延迟、重试、顺序和消息丢失语义。第三方“定时器”仍需持久化任务和结果,否则平台故障会让任务静默消失。

分片与调度

按任务 ID 或时间桶分片,多节点只负责各自范围。近时任务进入内存结构,远期任务保留在持久层并周期加载;批量拉取要有预取窗口和背压,避免瞬时到期压垮下游。

容易踩坑的地方

依赖单机定时器会在重启后丢失状态。把任务取出队列就确认会在执行器崩溃时丢任务;执行后再确认则可能重复,因此幂等和租约不可缺少。

重试与退避

失败区分业务不可重试、暂时依赖失败和结果未知。重试次数、下次执行时间和错误摘要持久化;使用指数退避加随机抖动,设置最大延迟和死信。任务执行可能超时但实际副作用已完成,重试必须靠幂等键或状态查询确认。

取消与修改

取消先把持久化状态改为 CANCELED,执行器在真正调用副作用前再次检查;已进入 RUNNING 的任务只能协作取消。修改执行时间要用版本条件,避免旧调度器把任务重新排回原时间。对已发出的支付、短信和物流动作不能承诺强制撤销,应提供补偿。

准时率与容量指标

监控计划时间到实际开始的延迟分布、任务积压、RUNNING 租约超时、重试率、死信数、执行耗时和下游拒绝。任务数量高不一定异常,关键是到期任务的 max/P99 延迟和业务过期率。执行器扩容前先确认下游是否有容量,否则只是把队列压力转移。

常见问题

追问:如何取消一个即将执行的任务?

持久层把状态原子改为取消,执行前再次校验状态;若已开始执行,只能按业务能力协作取消或执行补偿,不能保证强行中断外部副作用。

追问:调度器宕机会丢任务吗?

持久化状态和租约设计正确时,任务会在租约超时后被其他节点重新抢占;仅保存在内存时间轮中的任务会丢失,必须有恢复来源。

追问:延时任务如何保证只执行一次?

分布式系统通常只能保证至少一次尝试。通过业务唯一键、数据库唯一约束、幂等状态机和副作用查询实现效果上的只生效一次。

追问:为什么任务到了时间却没执行?

排查时钟偏移、扫描间隔、分片热点、数据库锁等待、调度器存活、队列积压和下游限流;不能只看任务表的 execute_at。