题干与适用场景
请设计一个多租户分布式任务调度器。它支持单次任务和带 IANA 时区的 Cron 任务,保存 2000 万条活跃计划,每天产生 2 亿次执行实例,整点最多有 50 万次触发。严格任务在正常峰值下从计划时间到任务进入持久队列的 p99 不超过 5 秒;不要求精确时刻的任务可在 5 分钟弹性窗口内分散。
系统提供创建、暂停、恢复、更新和取消计划的 API,投递语义为至少一次。调度器只负责在正确时间生成执行实例并把它交给目标队列,不负责容器资源放置、DAG 依赖或任意工作流编排。任务真正完成所需时间不计入 5 秒调度 SLO。
这道题适合中高级后端、平台、基础设施和系统设计岗位。2026 年公开的系统设计面试材料仍把 Job Scheduler 作为独立题目;AWS 和 Kubernetes 的现行文档也直接暴露了弹性时间窗、重复或漏建、错过触发、时区与重试等真实边界。核心考点不是写一个 Cron 表达式解析器,而是把“某个计划时间”可靠地物化成一次可追踪、可重试、可幂等的执行。
面试官考察点
第一个信号是先区分 Schedule、Occurrence 和 Attempt。计划描述未来何时运行;执行实例代表某个确定的计划时间;尝试代表执行实例的一次投递或处理。只用一张带 nextrunat 和 status 的表,会把重试、历史记录、更新与下一次触发混在一起。
第二个信号是没有轻率承诺 exactly-once。调度节点可能在消息已入队、状态尚未落库时崩溃,工作节点也可能完成副作用后丢失确认。Kubernetes 明确提示 CronJob 在某些情况下可能创建两个 Job 或一个都不创建,任务应当幂等;标准队列的可见性租约也不能绝对阻止重复投递。强回答会选择至少一次投递,并用稳定执行实例 ID 把业务效果做成幂等。
第三个信号是能处理整点相关洪峰。日均吞吐只有约 2315 次/秒,但 50 万个严格触发若要在 5 秒内入队,调度数据面要承受约 10 万次/秒。按平均值扩容会在每小时零分失效;把所有任务放进一个按时间排序的全局堆,也会形成单点和热分区。
最后看时间与故障语义是否写成产品契约:Cron 是本地墙上时间还是固定间隔,夏令时不存在或重复的时刻怎么处理,调度器停机十分钟后补跑还是跳过,上一轮没结束时是否允许重叠,更新和取消已经入队的实例有什么边界。
回答前需要澄清的问题
- 5 秒指什么? 本题定义为
enqueuedat - scheduledfor,只测调度与入队,不包含下游排队和执行时间;若要求任务完成,容量模型会完全改变。 - 单次、Cron 与固定频率是否都要支持? 本题支持单次和 Cron。固定每 24 小时与每天当地时间 09:00 的夏令时语义不同,不能用同一种加法替代。
- 允许重复还是允许漏跑? 本题选择至少一次投递,重复由幂等处理;对不可重复的外部副作用,目标必须支持幂等键或接受残余风险。
- 停机期间错过的实例怎么办? 每个计划需要
misfire_policy、最大迟到时间和补跑上限;否则恢复时可能突然生成数百万个过期实例。 - 同一计划可以重叠执行吗? 默认
ALLOW;还支持SKIPIFRUNNING。强制替换正在运行的任务会遇到不可撤销副作用,不作为通用默认项。 - 更新和取消的保证到哪里? 尚未入队的旧版本实例应失效;已经被工作节点领取或完成的实例无法靠删除计划追回,API 必须返回实际状态。
- 目标是什么? 本题把执行实例写入持久队列,由注册处理器消费;不接受任意用户脚本,避免把计算沙箱和资源调度混入题目。
- 多租户需要怎样隔离? 需要计划数量、触发速率、运行并发与补跑速率四类配额,不能让一个整点批任务占满所有分片和队列。
30 秒回答框架
“我会把计划、执行实例和执行尝试分开。计划表保存表达式、IANA 时区、版本与下一触发时间;调度节点按时间桶和计划 ID 哈希分片,只物化一个有限时间窗内的实例。执行实例 ID 由计划 ID、版本和原定时间确定,唯一约束避免故障切换时重复生成。实例、下一触发时间和 outbox 在同一事务提交,再由 relay 至少一次写入持久队列。工作节点用可续租的可见性超时处理,业务端按实例 ID 幂等。严格任务按峰值扩容,可弹性任务用确定性抖动分散;停机补跑、重叠、时区和取消都用显式策略定义。最后通过整点 50 万触发、各崩溃点、夏令时和取消竞态验证调度延迟、漏跑与重复副作用。”
分步骤深入解答
先算数据面,而不是先画组件:
average = 200,000,000 / 86,400 ≈ 2,315 occurrences/s
strict_peak = 500,000 / 5 = 100,000 dispatches/s日均值与严格峰值相差 43 倍以上。假设一条活跃计划和一条执行实例的逻辑记录各约 1 KB,计划元数据约 20 GB,执行实例每天约新增 200 GB;索引、副本、队列消息和历史保留会继续放大。数值用于确定分区与冷热分层,真实容量必须用实际字段和索引压测。
控制面负责校验表达式、鉴权、配额、幂等创建和版本更新。一个简化模型如下:
Schedule(schedule_id, tenant_id, expression, time_zone, version,
next_run_at, state, misfire_policy, max_lateness,
overlap_policy, flexible_window)
Occurrence(occurrence_id, schedule_id, schedule_version,
scheduled_for, available_at, state, attempt_count)
Attempt(attempt_id, occurrence_id, lease_token, started_at,
finished_at, result)
UNIQUE(schedule_id, schedule_version, scheduled_for)创建 API 带调用方幂等键。服务校验 Cron 与时区,并预览未来若干次触发,避免用户保存了语法合法但含义错误的表达式。nextrunat 统一存 UTC,原始表达式和 IANA 时区也要保留;每天当地时间运行的任务应从时区规则计算下一次,不能在上次 UTC 时间上固定加 24 小时。
时区边界必须形成稳定契约。本题规定:春季跳时导致不存在的本地时间就跳过,秋季回拨产生两次相同墙上时间时只运行一次;更新时区只影响新版本的未来实例。AWS Scheduler 公开了相同的跳过与单次处理示例,但这是本文选定的产品语义,不应假装所有调度器都相同。固定频率任务若以后加入,则按经过时长计算,不随夏令时改变。
调度存储建立 (timebucket, shard, nextrunat) 或等价索引,shard = hash(scheduleid) mod N。一组调度节点以短租约拥有多个逻辑分片,扫描有限规划窗口,例如未来几分钟。全局只选一个 leader 会限制扩展与故障恢复;分片租约减少重复扫描,数据库唯一约束和条件更新承担最终正确性。
对每条到期计划,调度节点在同一事务内完成三件事:插入确定性的 Occurrence,按当前计划版本计算并条件更新 nextrunat,写入带 availableat 的 dispatch outbox。若两个节点因租约切换同时处理,唯一约束使同一 (scheduleid, version, scheduled_for) 只留下一个实例;条件更新防止旧节点把下一触发时间倒退。
不能一次预生成无限 Cron 历史。规划窗口太短会把存储抖动直接传给 5 秒 SLO,太长则让更新和取消面对大量旧版本实例。窗口长度应覆盖调度故障转移与队列写入预算,再由指标调整。单次计划完成后进入终态;周期计划只保留下一触发游标,并按保留策略归档历史实例。
接近 availableat 时,dispatch relay 把 outbox 事件写入按租户和优先级分区的持久队列。写队列成功后若 relay 在确认数据库前崩溃,它会再次发送同一 occurrenceid;这是有意接受的重复。直接“先改成已发送,再发消息”会在第二步失败时漏跑,先发消息再改状态则会重复,事务 outbox 把选择明确为可幂等重试的至少一次。
整点 50 万任务不能只靠增加轮询线程。时间桶还要按计划 ID 哈希拆成足够多的逻辑分片。若压测得到一个分片在含事务、索引和队列写入时的安全吞吐为 Q,严格数据面至少需要 ceil(100,000 / Q) 个同时可用分片,再为节点故障留余量。队列侧按租户做加权公平调度和速率配额,避免一个租户占满严格通道。
允许 5 分钟窗口的任务使用确定性抖动:availableat = scheduledfor + hash(occurrence_id) mod 300s。相同实例在重试和故障切换后得到相同时间,便于复现,也能把相关触发摊开。严格任务不移动原定时间,独立预留容量。Amazon Builders' Library 也把对周期性后台任务加入抖动列为减少相关故障的方法。
工作节点从队列领取执行实例后获得有期限的可见性租约,长任务通过心跳续租。租约过期会让消息重新可见,甚至有效期内也不能绝对排除重复,因此状态机不能把“租约存在”当成 exactly-once:
SCHEDULED -> ENQUEUED -> RUNNING -> SUCCEEDED
| |
| +-> RETRY_WAIT -> ENQUEUED
+------> DEAD
Side states: MISSED, CANCELED, SKIPPED_OVERLAP每次领取生成新的 leasetoken 或递增 attempt 代次。完成更新必须匹配当前 token,防止超时的旧工作节点在新尝试完成后把状态改回去。这种 fencing 只保护调度状态,不能撤销旧节点已调用的外部系统。真正的业务幂等要由目标使用 occurrenceid:在同一数据库事务用唯一约束记录效果,或把它作为外部 API 的幂等键。目标不支持幂等且副作用不可查询时,只能承认重复风险。
重试按错误类型处理。明确的参数错误进入 DEAD;网络错误、限流和暂时 5xx 用指数退避与全抖动,并受最大尝试次数、实例截止时间和租户预算约束。队列 DLQ 保存耗尽重试的实例。重放沿用同一 occurrence_id,不能生成新 ID 绕过去重。
控制面故障恢复需要显式的 misfire 策略:SKIP 把超过最大迟到时间的实例标为 MISSED;FIREONCE 只补最近一次;CATCHUP 最多补 K 次,并经过租户补跑速率限制。Kubernetes 的 startingDeadlineSeconds 与错过调度限制说明了同一问题:恢复策略若不设边界,要么漏掉业务需要的任务,要么形成恢复洪峰。
SKIPIFRUNNING 通过查询同一计划是否有当前 RUNNING 实例决定是否标记 SKIPPED_OVERLAP。这项检查与状态转换要原子化,否则两个实例会同时认为没有前序任务。取消或更新会递增计划版本并使未领取的旧版本实例失效;工作节点在副作用前复查版本和取消标记。已开始的任务只能协作取消,已经完成的外部副作用不能被调度器回滚。
可观测性围绕可证明的边界:schedulelag = enqueuedat - scheduled_for 的 p50/p95/p99、各时间桶待处理数、物化延迟、唯一冲突、outbox 积压、队列年龄、重复领取率、重试和 DLQ、MISSED 与重叠跳过数、租户限流量、时钟偏差,以及执行实例各状态停留时间。API 可用率不能替代“该运行的实例是否按时入队”。
验收先在整点同时注入 50 万个严格实例,确认 p99 入队不超过 5 秒且租户公平;再让调度节点分别在插入实例前、事务提交后和队列发送后崩溃,断言没有静默漏跑且同一实例 ID 可去重。还要覆盖工作节点完成后丢确认、租约过期、十分钟控制面停机后的三种 misfire 策略、春秋夏令时、计划更新与取消竞态、长任务重叠和单租户补跑洪峰。
高质量示范回答
“我先把 SLO 定义为原定时间到持久队列接收的延迟,不包含任务执行。每天 2 亿次触发平均约 2315 次/秒,但整点 50 万次要在 5 秒内入队,所以严格通道要按 10 万次/秒设计,可弹性任务则在 5 分钟内用确定性抖动摊开。
数据模型分成计划、执行实例和尝试。计划保存 Cron、IANA 时区、版本和 nextrunat;实例 ID 由计划 ID、版本和 scheduled_for 决定,并有唯一约束。调度节点按时间桶和计划 ID 哈希分片,短租约负责扫描。每次在一个事务里插入实例、推进下一触发时间并写 dispatch outbox,所以节点切换不会静默漏掉实例,重复 relay 也只会发送相同实例 ID。
队列和工作节点采用至少一次语义。工作节点用可续租的可见性超时,完成状态必须匹配当前 lease token;业务处理器则用实例 ID 做数据库唯一键或下游幂等键。这样租约超时最多增加尝试,不会重复业务效果;没有幂等能力的外部目标必须明确残余风险。
我会把错过触发、重叠、时区和取消定义成 API 策略。停机后可选择跳过、只补一次或最多补 K 次;Cron 的不存在时间跳过、重复墙上时间只跑一次;更新递增版本,工作节点在副作用前复查。最后用整点洪峰、每个崩溃窗口、重复消息、夏令时和取消竞态验收,并监控 schedule lag、due backlog、重复领取、missed、DLQ 和租户限流。”
常见错误
- 把计划、执行和尝试放在一行 → 重试会覆盖历史,更新也无法区分旧实例 → 拆成
Schedule、Occurrence和Attempt。 - 按每天平均 2315 次/秒扩容 → 整点 50 万触发会让调度延迟爆炸 → 按严格峰值压测分片,并把可弹性任务确定性分散。
- 一个全局 leader 加内存最小堆 → leader 成为容量与恢复单点,重启还要重建全部未来任务 → 用持久时间索引、逻辑分片和有限规划窗口。
- 先标记已发送再写队列 → 队列写入失败会造成静默漏跑 → 在事务中写 outbox,由 relay 至少一次发送。
- 宣称锁和可见性超时保证 exactly-once → 超时与确认丢失仍会产生重叠尝试 → 稳定实例 ID、fencing token 与业务幂等共同处理。
- 恢复后补跑所有历史实例 → 数百万过期任务会形成第二次故障 → 配置最大迟到时间、补跑上限和租户速率。
- 用 UTC 加 24 小时实现当地每日 Cron → 夏令时后墙上时间会漂移 → 保留 IANA 时区并按当地规则计算下一次。
- 暂停或取消就删除计划行 → 已物化或入队的实例仍可能执行,也失去审计记录 → 版本化失效并在副作用前复查状态。
- 所有租户共享一个 FIFO → 大租户整点批次会阻塞其他租户 → 按租户和优先级公平调度,分别限制触发与运行并发。
- 只看 API 成功率 → 创建计划成功也可能从未按时触发 → 监控 schedule lag、due backlog、missed 和实例状态对账。
追问及应对
追问一:调度节点在队列发送成功后、更新 outbox 前崩溃怎么办?
relay 会重发同一个 occurrence_id。队列消费者和业务目标按该 ID 幂等,outbox 状态更新也使用条件写。选择重复而不是漏跑,因为跨数据库与队列没有单一事务;若使用支持事务消费的同一日志系统,可以减少窗口,但外部副作用仍需幂等。
追问二:如何支持多地域主动—主动?
先给每个计划分配唯一 home region 和递增 generation,只有持有当前 generation 的地域能物化实例;执行队列可按目标地域分发。故障转移先提升 generation,再由新地域从持久游标接管。两个地域同时物化时,全球唯一实例键或汇聚层去重仍是最后边界。若业务不需要跨地域写入,单主控制面加异地接管更简单。
追问三:夏令时回拨时当地 01:30 出现两次,应该跑几次?
没有通用答案,必须成为计划契约。本题选择一次,并用当地日期、墙上时间和时区折叠两个 UTC 候选;金融对账若要求两个真实时间点都执行,就改为两次并产生不同 scheduled_for。API 应预览未来触发,让用户在保存前看到选择。
追问四:上一轮任务执行两小时,下一次每小时到达怎么办?
ALLOW 会并行;SKIPIFRUNNING 原子检查并把新实例记为 SKIPPEDOVERLAP。若业务要求排队等待,应增加 QUEUEONE 语义并限制最多保留一个待运行实例。直接杀死上一轮可能已经产生副作用,只能在处理器支持协作取消时提供。
追问五:调度器停机一天,怎样恢复而不压垮下游?
逐计划应用 SKIP、FIREONCE 或有上限的 CATCHUP,再按租户与目标容量限速。恢复扫描从持久 nextrunat 开始,不能按当前时间直接覆盖游标。监控最老未处理时间和预计清空时长,严格任务与补跑任务使用独立容量。
追问六:怎样证明没有静默漏跑?
离线对账应从计划版本与时间规则独立重算期望实例集合,再与 Occurrence 唯一键集合比较,分类为成功、失败、取消、错过或缺失。在线指标发现延迟,离线对账发现缺口;两者配合才能覆盖“系统没有报错,但实例从未生成”的情况。