一个日报任务在 10 秒到期,工作者 A 取走后停止响应;16 秒工作者 B 重领并完成。A 恰好又恢复,拿着旧结果写入数据库。如果系统只检查“任务 ID 相同”,最后保存的可能是 A 的过期数据。延时队列解决“什么时候可领”,租约和栅栏解决“谁的完成确认还有效”,两者不是同一个问题。

每个周期是独立的一次执行

教学任务 report 的周期实例键是 (task_id,slot),其中 slot 来自固定时区下的计划触发时间,不由工作者的当前时钟临时生成。同一键至多保留一个有效结果;过期工作者可以消耗计算资源,不能覆盖已被后继租约提交的结果。任务执行并不保证恰好一次;若副作用发生在邮件、账务等外部系统,仍需要那些系统支持幂等键或结果核对。误触发、超时和未执行都必须可查询。

教学 SLO:未来 30 天 99% 已到期实例在调度时间后 10 秒内被领取(只对调度器可用且下游有容量的实例,测量计划时间与数据库租约时间);99.9% 到期实例在 5 分钟内达到成功或明确的死信状态。禁止用“已经领到”替代“业务动作完成”。跨时区夏令时规则、需要人工签核的任务及任意第三方 HTTP 的恰好一次副作用不在本题内。实际预算需把租约续期失败和限流降级算作独立错误类别。

教学规模为 100 万个周期任务、平均每小时触发一次:平均 1,000,000/3600≈278 次/秒;假设任务分布不均导致整点峰值为 10 倍,则 2778 次/秒。每个实例记录假定 0.5 KiB、保留 14 天、双副本,约 278×86400×14×0.5 KiB×2≈321 GiB(不含执行日志和索引)。若整点聚集系数变成 100 倍,瞬时需求为 27,778 次/秒;固定扫描速率若只有 3000 次/秒,积压约 (27778-3000)×1 秒≈24778 项/秒,10 秒领取 SLO 首先失效。要把 due_at 分桶并设限流,不能靠无限增大租约消除延迟。

POST /schedules 带周期、时区和任务类型,返回 schedule_id;调度者把每次触发转换成 jobs(task_id,slot,due_at,state,owner,epoch,lease_until,result),主键 (task_id,slot) 拒绝重复展开。POST /jobs/{task}/{slot}/claim 原子领取到期的 READY 或租约超期的 RUNNING 项,返回递增 epoch;POST /jobs/{task}/{slot}/complete 必须携带 owner、epoch 和结果摘要,且仅在当前租约内提交,超期返回冲突。恢复时新租约 epoch+1,客户端读结果时须附任务实例版本;任务 payload 不放在扫描索引里以免重复读取大对象。

flowchart LR
    S[周期定义] --> X[按 slot 展开]
    X --> D[(到期索引 / 唯一键 / 租约)]
    D --> W[领取任务的工作者]
    W --> O[本地结果或幂等外部动作]
    W --> D
    D --> R[超期扫描与死信核查]

计划选择“数据库条件更新 + 租约版本”作为低规模基线:在一个事务内条件领取/提交。SQLite 事务文档 说明同时只有一个写事务,并给出了 BEGIN IMMEDIATE 的语义;本文以单进程连接同一磁盘 SQLite 文件,观察条件更新行数,不证明跨节点并发或时钟同步。若扩成大量短任务,可用消息系统按 due_at 分桶并批量转发,但队列的消费确认并不能代替持久的实例状态和结果栅栏;还要解决周期实例展开的重复与宕机后的重扫。

sequenceDiagram
    participant D as SQLite 实例表
    participant A as worker-a
    participant B as worker-b
    A->>D: t=10 领取 slot=0,epoch=1,有效至 15
    A--xD: t=11 工作中失联
    B->>D: t=14 尝试领取,被拒绝
    B->>D: t=15 重领,epoch=2,有效至 20
    A->>D: t=16 提交旧 epoch=1 的结果
    D-->>A: 0 行更新,过期提交无效
    B->>D: t=16 提交 epoch=2 的结果
    D-->>B: 1 行更新,结果 output
    B->>D: t=19 请求 job:20(到期 20)
    D-->>B: 未到期,拒绝领取

仅以 status=RUNNING 判断旧持有者是否能写会失败:被重派后状态仍然是 RUNNING。真正的完成谓词须检查当前 owner、epoch 和租约有效期,并用更新行数作确认。若 A 曾在外部系统写入成功却未写完成状态,B 依然会执行一次;对外请求应使用 (task,slot) 幂等键,若无法去重,就进入人工核查而不是宣称“恰好执行一次”。延时任务可共享同一 due_at 索引,但不需要周期展开;周期任务要定义 DST 重复和缺失小时的领域政策后才能上线。

失败注入与恢复门槛

python3 -B examples/system-design/labs/30/scheduler.py 在磁盘 SQLite 中创建 job:10、job:20 两个实例。t=9 未到期不能领,t=10 取得 epoch 1;t=14 不能抢占,t=15 到期重领取得 epoch 2。t=16 旧提交更新 0 行,新提交更新 1 行,再次提交更新 0 行,结果表仅 job:10=output。job:20 在 t=19 拒绝、t=20 允许领取。漏触发 [20,30,40,50] 合并为 [50] 只是明确的 misfire 策略算例,不是持久周期展开器。命令、版本、原始输出、退出码见 examples/system-design/evidence/30/RUN.md;没有真实多进程失联、节点时钟或外部副作用测试。

恢复扫描只重派 lease_until 已过且未完成的实例;连续失败按 (task,slot) 计数进入死信,不能无限期重试把整点积压放大。若数据库不可写,停止领取和成功确认;恢复后按 slot 对账,比较已生成实例和应有触发时间,必要时补生成,不能简单从当前时间开始丢弃历史间隙。面试追问:工作者时钟走快会不会抢租约?A 在外部发送成功、B 再执行如何防双发?DST 一小时重复时 slot 怎样编码?没有数据库时钟、下游幂等及明确时区政策,栅栏只能保证本地结果不被旧持有者覆盖。

[PATTERN] 延时决定可领取时点;唯一 slot 决定实例身份;版本化租约决定谁能确认结果;外部副作用另行对账。

实验源码 examples/system-design/labs/30/scheduler.py,证据 examples/system-design/evidence/30/RUN.md。系列导读;本篇是自拟工程扩展题。

实验附件:权威实验源码;运行记录;版本与源码哈希;本地原始结果。