面试考察点

  • 是否明确任务规模、延迟精度和最长延迟时间。
  • 能否比较数据库扫描、消息延迟和时间轮。
  • 是否设计任务状态、重复执行、取消与故障恢复。

核心答案

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

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

状态模型

任务包含唯一 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。

总结

延时任务系统是持久化状态机加可恢复调度器,核心指标是准时率、积压、重复率和最终执行结果。

机制全景图

下面把「延时任务系统应该如何设计?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["创建任务与执行时间"]
    A --> B["持久化并进入时间索引"]
    B --> C["调度器领取到期任务"]
    C --> D["工作者幂等执行"]
    D --> E["重试/取消/对账收口"]

完整链路:从输入到结果

沿着「创建任务与执行时间 → 持久化并进入时间索引 → 调度器领取到期任务 → 工作者幂等执行 → 重试/取消/对账收口」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 创建任务与执行时间

任务记录至少包含业务 ID、执行时间、状态、重试次数和幂等键,创建与业务事务需避免双写丢失。

2. 持久化并进入时间索引

短延迟可用时间轮,长延迟可用数据库扫描、Redis ZSet 或分层消息,精度和容量不同。

3. 调度器领取到期任务

调度器通过分片租约或 SKIP LOCKED 领取,必须避免多实例重复占有且允许租约超时恢复。

4. 工作者幂等执行

执行端默认至少一次,外部副作用由业务幂等键裁决;处理时间不能阻塞调度扫描。

5. 重试/取消/对账收口

失败按错误类型退避重试,取消与执行并发通过状态机条件更新决定唯一结果,周期对账修复遗漏。

源码与实现定位

入口 阅读重点
task 状态表+due_at 索引 持久时间索引
租约 owner/lease_until 重复领取恢复

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

UPDATE task SET owner=?,lease_until=? WHERE id=? AND status='READY' AND due_at<=NOW();

在领取、执行、提交、ack 各点崩溃,核对至少一次与幂等。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「到期到执行延迟」为主基线,记录值应满足「记录活动/稳态基线」;同时保存 到期到执行延迟、待执行/重试积压,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「task 状态表+due_at 索引」确认请求确实进入「持久时间索引」对应的实现,再沿「租约 owner/lease_until」观察「重复领取恢复」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「业务提交与任务创建双写不一致」,并把单一变量逐级放大,直到「到期到执行延迟」越过「超过容量或 SLO」。随后再分别验证「任务执行超时后租约过期导致并发执行」和「重试所有错误造成永久失败风暴」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「业务与任务同事务/Outbox」,确认它能控制影响范围;第二轮应用「租约超时可接管」,验证核心链路恢复;最后落实「失败分类退避+死信」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「到期到执行延迟」回到「记录活动/稳态基线」、「端到端 P99」回到「小于预算」、「状态差异」回到「0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
到期到执行延迟 记录活动/稳态基线 超过容量或 SLO 触发降级
端到端 P99 小于预算 突破预算 停止扩量
状态差异 0 任意非零 补偿并对账

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:订单超时关闭任务漏执行

服务先创建订单事务,提交后再写延迟队列,进程崩溃留下永不关闭的订单。将任务记录与订单放同一事务并由调度器扫描,或用 Outbox 投递延迟消息后才消除窗口。

失败模式 首要证据 第一处置动作
业务提交与任务创建双写不一致 到期到执行延迟 业务与任务同事务/Outbox
任务执行超时后租约过期导致并发执行 待执行/重试积压 租约超时可接管
重试所有错误造成永久失败风暴 重复执行率 失败分类退避+死信

发布与回滚检查点

  • 发布前:确认「task 状态表+due_at 索引」对应实现和上述配置在目标版本仍然有效,并保存「到期到执行延迟」基线。
  • 灰度中:同时观察 到期到执行延迟、待执行/重试积压、重复执行率;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「业务与任务同事务/Outbox」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「业务提交与任务创建双写不一致」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
数据库时间索引 任务量中等且要求可靠 事务与查询直观 高频扫描和热点需治理
Redis ZSet/时间轮 短延迟、高吞吐 调度效率高 持久性、故障恢复需补充
延迟消息/分层 Topic 已有 MQ 且延迟档位固定 削峰、消费体系成熟 精度、回溯和取消较复杂

选型至少带上 峰值流量、数据规模、依赖容量、一致性目标和故障预算,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

延迟任务不是定时器 API 问题,而是持久状态、至少一次执行、幂等和对账的完整系统;精度越高,成本和故障复杂度越大。

工程落地遵循:先定义 SLO 与不变量,再通过限流、隔离、异步和补偿保护核心链路。回答时直接引用「task 状态表+due_at 索引」、配置实验和事故数据,比复述固定模板更有说服力。