本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 batch-4/ 目录(examples/、evidence/ 等子目录);业务数据均为合成数据。

第 19 篇处理的是单次到期任务:一个确定的 UTC 时刻,到了就领取执行。日历调度多了一层翻译:用户说的是“柏林时间每个工作日 07:00”“每月最后一天”“每月 31 日”,系统要把这些本地时间规则翻译成 UTC 时刻,还要回答几个翻译中会遇到的问题:本地时间不存在怎么办,出现两次怎么办,2 月没有 31 日怎么办,调度器停机三天后恢复要不要补跑。

本篇实现一个 RRULE 子集的展开器和一个基于 SQLite 的调度器,用真实的 IANA 时区规则(本机 tzdata 2026c)验证夏令时跳变、半小时跳变、整天跳过、重复触发和补跑。

1. 需求与不变量

  • R1 规则以“本地名义时间 + IANA 时区名”保存,执行时刻按需展开,不预先存储 UTC 列表。
  • R2 跳变日的处理有明确策略,并在实例上留下注记,方便事后解释。
  • R3 同一个实例 (schedule_id, 名义本地时间) 的副作用至多发生一次;执行尝试可以重复。
  • R4 调度器停机后的补跑行为由策略决定,每个错过的实例都有一个终态:done、coalesced、skipped 或 expired,不留悬而未决的 pending。
  • R5 多个调度器实例并发时,同一实例只被一个领取。

幂等键用名义本地时间而不是 UTC 时刻,原因在第 4 节的萨摩亚案例中会看到:不同的名义时间可能映射到同一个 UTC 时刻,同一个名义时间在时区规则更新后也可能映射到不同的 UTC 时刻。名义时间才是用户意图的身份。

规模假设:每个规则每次展开至多数百个实例;调度器每 10 分钟一次 tick。精度要求是分钟级,不处理秒级定时。

2. 标准怎么规定

RFC 5545(iCalendar)对两类边界给出了不同的规则,容易混淆:

  • §3.3.5 讨论的是一个显式写出的本地时间值。如果它落在夏令时开始的空档里,“使用空档之前的 UTC 偏移来解释”,例子是纽约 2007-03-11 02:30 被解释为 03:30 EDT;如果它出现两次,指第一次出现。
  • §3.3.10 讨论的是由重复规则生成的实例。“无效日期(例如 2 月 30 日)或不存在的本地时间”生成的实例必须被忽略,并且不计入重复集合。

也就是说,同一个 02:30,写在 DTSTART 里会被平移,作为 RRULE 生成的实例则会被丢弃,并且不消耗 COUNT。本实现的默认值遵循 §3.3.10(gap="skip"),同时提供 gap="shift",用于产品上“宁可晚一小时也要执行”的需求。

“不计数”会直接改变产品行为。一个“共 10 次的每日 02:30 提醒”在遵循 §3.3.10 时,最后一次会比日历上数出来的第 10 天晚一天;按 shift 处理则仍然是第 10 天,但其中一次是在 03:30 收到的。两种结果都可以接受,前提是用户在创建时就被告知,而后端在两个地方(展开与计数)使用同一套规则。如果展开时平移、计数时又按“忽略”处理,就会出现第 11 次提醒,这类错误只在跨越跳变日的规则上出现,很难在日常测试中被发现。

RFC 7529 定义了 SKIP=OMIT/BACKWARD/FORWARD,用于处理无效日期,但规定它只在 RSCALE(非公历日历)存在时使用,OMIT 是默认值并与 RFC 5545 的行为一致。本实现借用这三个取值名称作为公历下的产品选项,不声称实现了 RFC 7529。

Python 的 zoneinfo(PEP 615)与 fold 属性(PEP 495)提供了实现所需的全部原语:fold=0 对应歧义时间的较早一次,落在空档中的时间在 fold=0 时使用跳变前的规则。这正好与 RFC 5545 §3.3.5 的解释一致。PEP 495 还说明,构造函数和 replace() 不检查生成的时间是否有效,所以“这个本地时间存不存在”必须自己判断。

3. 接口契约

操作 输入 结果 拒绝条件
create_schedule 时区名、名义起始时间、规则、gap/overlap/skip 策略、补跑策略、max_lateness 规则 ID 未知时区名;UNTIL 早于起始时间;不支持的 FREQ
preview(id, n) 规则 ID、条数 未来 n 个实例:名义时间、UTC 时刻、注记 —
update_schedule 新规则 删除尚未执行的未来实例,从当前时刻重新展开 同创建
执行回调 幂等键(规则 ID 与名义时间拼接)、计划时刻、实际时刻 副作用 回调方必须按键去重

preview 把注记直接返回给用户界面。用户在创建“每天 02:30”时就能看到“3 月 8 日不执行”或“3 月 8 日改在 03:30 执行”,比事后解释更容易被接受。

修改规则只影响未来。已经执行的实例是历史事实,不随规则变化改写;已经展开但尚未执行的实例要删除后重新展开,否则旧规则的时刻会残留在表里继续触发。tzdata 更新是同一种情况,只不过修改来自时区规则而不是用户:一个国家宣布取消夏令时之后,已经按旧规则展开的未来实例会落在错误的时刻上,需要对受影响时区的规则批量执行一次重新展开。

4. 关键实现

判断存在与歧义只用两个往返:

1
2
3
4
5
6
7
8
def _exists(local: datetime, tz: ZoneInfo) -> bool:
rt = local.replace(tzinfo=tz).astimezone(timezone.utc).astimezone(tz).replace(tzinfo=None)
return rt == local

def _ambiguous(local: datetime, tz: ZoneInfo) -> bool:
a = local.replace(tzinfo=tz, fold=0).utcoffset()
b = local.replace(tzinfo=tz, fold=1).utcoffset()
return a != b and _exists(local, tz)

一个不存在的本地时间转到 UTC 再转回来,得到的是另一个墙钟时间;空档内两种 fold 的偏移不同,但往返后不等于原值,所以要排除。

展开时,被忽略的实例不计数:

1
2
3
4
5
6
7
8
9
10
inst, rnotes = resolve(nominal, s)
if inst is None:
continue # 被忽略的实例不计入 COUNT
n += 1
if s.count and n > s.count:
break
...
if inst in out:
out[inst].notes.append(f"merged_with_{nominal.isoformat()}")
continue

两个名义时间映射到同一 UTC 时刻时合并为一个实例,并留下注记。

调度器用 runs(sid, nominal) 作为主键。plan() 把游标之后的实例插入表中(INSERT OR IGNORE,可重复执行),tick() 先处理补跑策略,再用条件更新领取:

1
2
3
cur = self.c.execute("UPDATE runs SET state='running', owner=?, attempts=attempts+1 "
"WHERE sid=? AND nominal=? AND state='pending'", (self.name, sid, nominal))
return cur.rowcount == 1

副作用写入 effects(key PRIMARY KEY),键为 sid|nominal。执行尝试可能因为崩溃重复,副作用不会重复。

---
title: 图 E02-1 单个实例的状态机(终态:done / coalesced / skipped / expired)
---
stateDiagram-v2
    [*] --> pending: plan() 展开插入(幂等)
    pending --> running: 条件更新领取(rowcount=1)
    pending --> expired: 迟到 > max_lateness
    pending --> coalesced: latest_only 且之后还有更新的到期实例
    pending --> skipped: skip 且迟到 > 5 分钟
    running --> done: 副作用(按键去重)后标记
    running --> pending: 崩溃恢复(演示用:按实例名;真实系统应按租约超时)
    done --> [*]

5. 实验

运行入口 examples/e02_calendar/:python3 -m unittest test_calsched -v,14 个用例通过;python3 dump.py 把边界规则的展开结果写入 evidence/E02/expansions.txt。

5.1 正常:每天 09:00 跨夏令时开始

纽约规则“每天 09:00”,从 2026-03-05 开始。展开得到的 UTC 小时依次为 14、14、14、13、13、13,转回本地时间都是 09:00。负向对照把它存成“首个 UTC 时刻 + 固定 24 小时”,转回本地时间是 9、9、9、10、10、10:夏令时开始之后,任务每天晚一小时执行。这种错误在测试环境里不容易暴露:只要测试时间段不跨越跳变,两种做法的结果完全相同。测试用例因此要把固定日期写死在跳变附近,而不是取“当前时间往后若干天”。

5.2 边界:空档、重复、半小时跳变

规则 默认 skip shift
纽约每天 02:30,COUNT=4,从 3 月 6 日起 6、7、9、10 日;8 日被忽略且不计数 6、7、8、9 日;8 日落在 03:30 EDT(07:30Z)
纽约每天 01:30,跨 11 月 1 日回拨 11 月 1 日只执行一次,05:30Z(EDT,第一次出现) 同左;overlap="second" 时为 06:30Z
豪勋爵岛每天 02:15,跨 2026-10-04 3、5 日;4 日被忽略 4 日落在本地 02:45

豪勋爵岛(Australia/Lord_Howe)的夏令时只调整 30 分钟,02:00 直接跳到 02:30。按“空档一小时”编写的特殊逻辑在这里会出错;依赖时区库的往返判断则不受跳变长度影响。

5.3 边界:整天不存在

萨摩亚(Pacific/Apia)在 2011 年底从日期变更线一侧调到另一侧,本地日期 2011-12-30 整天不存在。规则“每天 09:00,从 12 月 29 日起”:

  • 默认 skip:29、31、1、2、3 日(30 日被忽略,COUNT=5 向后延伸一天)。测试中从 28 日起的规则得到 28、29、31、1、2 日。
  • shift:30 日 09:00 用跳变前的偏移(−10:00)换算为 19:00Z,转回本地正好是 31 日 09:00(+14:00),与 31 日的实例重合。展开器把两者合并为一个实例,注记 merged_with_2011-12-31T09:00:00。

如果幂等键用的是 UTC 时刻,shift 策略下两个实例会抢同一个键,其中一个被当作重复丢弃,注记也无处可写;用名义时间作键,两个意图都能被追踪,合并是一个显式、可解释的决定。

5.4 边界:每月 31 日

柏林规则“每月 31 日 08:00”,COUNT=6,OMIT(默认)得到 1、3、5、7、8、10 月;BACKWARD 得到 1/31、2/28、3/31、4/30……;BYMONTHDAY=-1(每月最后一天)得到 31、28、31、30……。“每月 31 日”和“每月最后一天”是两种不同的需求,界面上应当让用户明确选择,而不是由后端猜测。

每周规则“周一、三、五 07:00,UNTIL 2026-04-01 07:00”跨越柏林 3 月 29 日的夏令时开始:3 月 23、25、27 日为 06:00Z,3 月 30 日和 4 月 1 日为 05:00Z;UNTIL 含端点,4 月 1 日 07:00 本身被包含。

5.5 失败:停机后的补跑

调度器在 10 月 20 日 09:01(本地)正常执行后停机,10 月 24 日 12:00Z 恢复,max_lateness 为 2 天:

策略 20 日 21 日 22 日 23 日 24 日
all done expired expired done done
latest_only done expired expired coalesced done
skip done expired expired skipped skipped

每个实例都落在终态。实验过程中发现过一个缺陷:初版 skip 策略在有多个到期实例时,只把较早的标为 skipped,最新的一个留在 pending,要等下一次 tick 才处理。修正后测试断言不再出现 pending。

Kubernetes CronJob 的同类机制可以作为对照:startingDeadlineSeconds 超过后该次执行被跳过,未设置时没有截止;错过超过 100 次时不启动 Job 并记录错误,文档说明这不代表 CronJob 停止运行;设置了截止时间时,只统计截止窗口内的错过次数。CronJob 文档对夏令时没有任何描述,没有指定 timeZone 时按 kube-controller-manager 的本地时区解释。

expired 与 skipped 都意味着用户的某个意图没有被执行,它们不能只停留在数据库状态里。可行的处理是:expired 产生一条告警或工单,由人决定是否手工补跑;skipped 只计数,并在用户界面的执行历史中显示为“因调度器停机未执行”。区别在于前者对应的是“本该补跑却来不及”,后者是策略主动放弃。两者混在一个计数里,就无法判断停机造成了多大的业务影响。

5.6 规则修改

规则在 10 月 20 日 09:00 执行之后、21 日实例已展开为 pending 的状态下改为每天 10:00。replan() 删除尚未执行的未来实例,并从修改时刻重新展开。之后的 tick 只在 21 日和 22 日的 10:00 执行,20 日 09:00 的 done 记录保持不变。如果省略这一步,21 日会在 09:00 和 10:00 各执行一次,两个名义时间不同,幂等键也不同,去重拦不住。

5.7 失败:执行后崩溃、双实例竞争

副作用写入后、标记 done 之前注入崩溃。恢复后再次 tick,effects 表只有 1 行,runs.attempts=2:两次尝试,一次副作用。两个调度器实例用独立的 SQLite 连接在同一时刻 tick,屏障同步启动,合计只执行 1 次。

6. 补跑策略按任务语义选择

三种补跑策略没有优劣之分,取决于每个实例代表什么:

  • 每个实例对应一份独立结果,例如按日结算、按日生成对账文件,缺一天就少一份产物。这类任务用 all,max_lateness 设得足够长,甚至不设上限;过期的实例要进入人工处理队列,而不是静默丢弃。
  • 每个实例只是“刷新到最新”,例如重建缓存、生成当前快照报表。补跑三次与补跑一次的结果相同,用 latest_only,被合并的实例记为 coalesced,便于统计。
  • 实例的价值依赖准点,例如“早上 9 点提醒开会”。下午才送达的提醒没有意义,甚至会误导,用 skip。

max_lateness 与这三种策略是两个独立的维度:前者决定“多晚算太晚”,后者决定“没太晚的那些怎么处理”。Kubernetes CronJob 把类似的两个维度拆成 startingDeadlineSeconds 与 concurrencyPolicy,后者处理的是上一次还没结束时新一次是否启动,本文没有实现这一点。

调度器自身需要三类观测:实际执行时刻减去计划时刻的迟到分布;各终态的计数,尤其是 expired 和 skipped,它们代表用户意图没有被满足;以及展开注记的计数,例如本月有多少实例因空档被忽略。第三类在平时为零,只在跳变日前后出现,适合在跳变前一周用 preview 主动检查,而不是等跳变当天才看。

7. 方案比较

做法 夏令时 时区规则更新 补跑 问题
预先存 UTC 列表 生成时正确 不会跟随 需另行实现 生成之后的 tzdata 变化不会反映
首次 UTC + 固定间隔 错一小时 不会跟随 简单 4.1 的负向对照
系统 cron(本机时区) 各实现不同 跟随系统 实现相关 cronie 对空档内的固定时刻任务立即补跑、回拨时不重复执行,只作用于固定时刻或粒度大于一小时的任务;Debian 的 Vixie cron 对含通配符的任务直接按新时间执行;两者都把超过 3 小时的变化视为时钟校正
本地规则 + 按需展开(本文) 按策略 下次展开时跟随 显式策略 展开器要处理全部边界

时区数据本身会变化。IANA 时区数据库在 2026-09-29 发布了 2026e,本机安装的是 2026c。本文用到的时区在这两个版本之间没有影响实验的改动,但这一点只能通过对照版本说明确认;部署时需要一个更新 tzdata 并重新展开未来实例的流程。

8. 限制

  • RRULE 只实现了 DAILY/WEEKLY/MONTHLY、INTERVAL、BYDAY(WEEKLY)、BYMONTHDAY、COUNT、UNTIL;没有 HOURLY、BYSETPOS、EXDATE/RDATE 和 RSCALE。按小时重复的规则在回拨日的行为需要另行定义。
  • DTSTART 自身落在空档的情况(RFC 5545 规定 DTSTART 总是第一个实例)没有专门测试。
  • 崩溃恢复按实例名回收 running,只用于演示;真实系统应使用领取租约,并处理“领取者没有死、只是慢”的情况,参见第 19、26 篇。
  • 多实例竞争只覆盖 SQLite 单机写锁,不覆盖跨节点数据库。
  • 调度器的 tick 间隔是 10 分钟,实例的实际执行时刻最多比计划晚一个间隔;需要更准时的场景应由到期索引驱动唤醒,而不是轮询。这部分与第 19 篇的单次到期任务共用同一套机制,本篇没有重复实现。
  • tzdata 版本以本机 2026c 为准,没有验证 2026d、2026e 对其他时区的影响。

9. 来源

核对日期 2026-10-18。

  • RFC 5545 §3.3.5(不存在与重复的本地时间)、§3.3.10(无效日期与不存在时间的实例必须忽略且不计数;DTSTART 总是第一个实例)。https://www.rfc-editor.org/rfc/rfc5545
  • RFC 7529 §4.1:SKIP 仅与 RSCALE 一起使用,OMIT 为默认。
  • PEP 495(fold)、PEP 615(zoneinfo);Python zoneinfo 文档:fold=0 使用跳变前偏移。
  • IANA Time Zone Database:最新版本 2026e(2026-09-29)。https://www.iana.org/time-zones
  • Kubernetes 文档 CronJob:timeZone、startingDeadlineSeconds、100 次错过的行为。https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
  • cronie cron(8)(man7.org)与 Debian bookworm cron(8):夏令时与时钟变化处理。

本批导航