分片、Misfire 与时区:调度语义怎样在多节点和日历中保持稳定
结算任务读取哪些订单,由业务窗口的起止值决定。即使任务晚到两小时,读取范围仍可能保持为原来的结算日。多台执行器共同处理这个窗口时,每台机器还需要明确的数据范围,才能检查有没有遗漏或重复。
Cron 保存启动规则,窗口参数保存输入范围,分片计划记录由谁处理。补跑沿用窗口,成员变化调整分配,各自修改对应记录。
计划时刻、日历与业务窗口
一次运行涉及哪些时间
scheduledTime 表达式算出的计划时刻
dispatchTime 调度中心实际发出任务的时刻
startTime 执行器开始处理的时刻
finishTime 执行器结束的时刻
windowStart / windowEnd 本次处理的数据范围
zoneId 定义业务日历的地区时区startTime - scheduledTime 可以反映迟到程度,finishTime - startTime 反映执行耗时。二者混在一个“任务耗时”中,会掩盖排队、网络和计算各自的影响。
业务窗口通常采用左闭右开区间 [windowStart, windowEnd),使相邻窗口在边界上不重复读取。补跑旧窗口时传入原来的窗口参数,不要再次用 now() 计算“昨天”。
数据本身也有不同时间:事件发生时间、系统接收时间和数据库写入时间。按接收时间统计可以及时出结果,按事件时间结算则可能需要等待迟到数据或生成修订版本。这个选择影响读取条件和补跑规则,应与业务约定一起固定。
Cron 有方言
Linux 常见的用户 crontab 使用五个时间字段;Spring 使用六个字段,从秒开始;Quartz 使用六个字段并允许可选年份。系统级 crontab 还可能在命令前包含运行用户,迁移配置时需要分清它属于文件格式还是时间表达式。
| 含义 | Spring 示例 | Quartz 示例 |
|---|---|---|
| 每分钟开始 | 0 * * * * * | 0 * * * * ? |
| 每天 02:30 | 0 30 2 * * * | 0 30 2 * * ? |
| 每周一 09:00 | 0 0 9 * * MON | 0 0 9 ? * MON |
| 每小时的第 0、15、30、45 分钟 | 0 0/15 * * * * | 0 0/15 * * * ? |
Spring 的星期数字使用 0 或 7 表示周日;Quartz 的 1 表示周日。使用 MON、SUN 等名称可减少跨方言误读。L、W、# 等扩展也应交给对应解析器验证,不要只通过字符串字段数判断。Spring 调度与 Cron、Quartz CronTrigger
0/35 放在分钟字段里表示每小时第 0 和第 35 分钟;相邻触发间隔会交替出现 35 分钟和 25 分钟。需要严格每隔 35 分钟运行,应选择基于间隔的触发器,并另外决定 fixed-rate 或 fixed-delay。
一个表达式语法合法,还需要检查未来若干次触发时间。特别是月末、工作日、跨午夜时间段和特殊星期规则,应把实际计算出的时刻与业务预期对照。
地区时区与固定偏移量
Asia/Shanghai、Europe/Berlin 是地区时区;+08:00、UTC 表示固定偏移或固定基准。地区规则可以随夏令时或行政调整发生变化。同一个本地时间转换为时间线上的瞬间时,可能对应一个、零个或两个合法偏移量。JDK ZoneRules
普通时刻:02:30 → 一个 UTC 偏移量 → 一个瞬间
春季跳时:02:30 → 没有合法偏移量 → 这个本地时刻不存在
秋季回拨:02:30 → 两个合法偏移量 → 本地钟表时间出现两次因此,“每天本地 02:30”与“每隔 24 小时”会产生不同计划。一个业务日可能长 23 小时或 25 小时。构造窗口应按业务地区的本地日期分别计算相邻两天的起点,再转换为 Instant,不要直接将前一天起点加 24 小时。
存储与传输通常使用 UTC 瞬间,同时保存业务 ZoneId。展示本地时间时带上地区或偏移量,可以区分回拨日的两个 02:30。地区规则来自运行时的时区数据库,升级 JDK 后应复核重要日历计划。
遇到不存在或重复的本地时刻,Cron 解析器还会应用自己的计算策略。JDK 的时间转换只能给出部分信息,需要继续查看解析器产生的触发点。后面的 Spring 实验会计算回拨日的两个 02:30;迁移到 Quartz 或其他平台时,也应计算同一规则作比较。
Misfire 调整的是已经迟到的触发
Quartz 在触发器错过计划时间且超过相应阈值后,按 misfire 指令调整触发状态。线程池不足、节点停机或调度数据库阻塞都可能造成迟到。JDBCJobStore 的 org.quartz.jobStore.misfireThreshold 配置用于判定可容忍的迟到时间,单位为毫秒;它不是任务执行超时。JDBCJobStore 配置
对于 CronTrigger,常见选择包括:
| 配置 | 调整方式 | 适用考虑 |
|---|---|---|
withMisfireHandlingInstructionDoNothing() | 跳到当前时间之后的下一个合法触发点 | 旧触发已失去意义 |
withMisfireHandlingInstructionFireAndProceed() | 尽快触发一次,再继续 Cron | 需要一次恢复执行 |
withMisfireHandlingInstructionIgnoreMisfires() | 忽略 misfire 处理,让调度器继续按原有时间计算 | 可能形成连续追赶,需评估积压 |
CronTrigger 的默认 SMART 策略按其实现采用立即触发一次的处理。不要把这条结论扩大到所有 Trigger 类型。
SimpleTrigger 还要考虑已触发次数和剩余重复次数。其指令可以选择从现在或下一个时刻继续,并调整剩余次数;“跳过或补一次”无法概括全部行为。SimpleTrigger 与 misfire 指令
Spring CronTrigger 也区分依据上次完成时间的 lenient execution 与依据上次计划时间的 fixed execution。后者会保留需要补上的计划点,前者可能跳过执行期间错过的触发点。具体 API 和重建触发器时的恢复时间应按版本核对。Spring CronTrigger 源码
调度器补发一次回调,并没有自动生成所有缺失业务窗口。停机期间漏了五个结算窗口,要由业务窗口表识别这五项工作,分别处理或按规则合并。
分片计划怎样覆盖完整数据
广播、路由与分片
路由决定任务发给哪台执行器。广播将任务发给多台执行器。分片进一步规定每台执行器处理哪些数据。
三台机器都收到“扫描待结算订单”,如果查询没有分片条件,三台机器仍会扫描同一集合。正确的分片需要同时满足:
- 所有分片的并集覆盖目标数据。
- 在不允许重复处理的读取计划中,每条数据只属于一个分片。
- 重试能够再次定位相同数据范围。
- 运行中的执行者使用同一个计划版本。
业务幂等可以吸收重复提交,但不能自动找回漏掉的数据。统计只看“总处理数”也不够:漏 100 条、重复 100 条时,总数看起来仍然正确。应比较主键集合或使用逐分片覆盖统计。
几种常见分法
| 分片方式 | 查询形式 | 需要注意 |
|---|---|---|
| 主键范围 | id >= lower AND id < upper | 热点和范围大小可能不均 |
| 固定哈希桶 | bucket(id) = bucketNo | 哈希算法、编码和桶数必须固定 |
| 租户或业务分区 | tenant_id = tenant | 大租户可能独占大量时间 |
| 数据库队列领取 | 到期记录加行锁后批量领取 | 每轮要重新扫描未领取和过期工作 |
| 输入文件分块 | 文件身份 + 起止位置 | 文本编码和记录边界不能切错 |
按 id % workerCount 分片容易实现,但 worker 数量变化会改变几乎所有分配。运行中直接把 total 从 3 改为 4,而部分节点仍用旧值,可能同时出现遗漏和重复。
固定分桶将“数据如何分组”和“谁来处理”分开。例如长期固定 12 个逻辑桶,数据按 id % 12 归桶,再把桶分配给 3 台执行器。
数据 → 固定 12 个桶
├── worker 0:桶 0、3、6、9
├── worker 1:桶 1、4、7、10
└── worker 2:桶 2、5、8、11机器变化时转移桶的所有者,数据所属桶保持不变。真实系统可以选择更多逻辑桶,改善负载分配;但桶数越多,元数据、任务领取和检查点管理开销也越大。
成员变化需要计划版本
一个运行窗口建立后,应保存分片范围或桶集合及计划版本。新加入的机器领取尚未开始的分片,或接管已确认失效的租约。不要让每台机器各自按“当前在线机器数”重新计算 total。
已完成桶保持完成。未完成桶转移时,保留原业务窗口和检查点,再提高持有者版本。旧执行者可能仍在运行,因此真正的写入还需要幂等和 fencing 校验。
同一桶内如果存在严格顺序要求,接管者必须从已提交位置继续。对输入做游标扫描时,固定上界和排序键,避免新增数据使运行范围不断扩展。
慢分片也需要单独观察。把单台机器的线程数提高,无法解决某个分片拥有大部分数据的问题;可以细分范围或增加逻辑桶,再受控调整后续计划。
用真实解析器与 SQL 检查调度规则
运行环境
下载分片与时间规则实验。需要 Linux、Bash、Docker Engine、Compose v2 和 unzip。版本为 JDK 17、Maven 3.9.12、Spring Framework 7.0.9、Quartz 2.5.2、PostgreSQL 18.6。
应用容器以 10001:10001 运行,Maven 使用宿主 UID/GID。PostgreSQL 仅绑定 127.0.0.1:18224,固定口令限本地实验使用。
unzip sharding-misfire-timezone-lab.zip
cd sharding-misfire-timezone-lab
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,source=$PWD,target=/work" \
--mount "type=bind,source=$PWD/.m2,target=/m2" \
-w /work maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify
docker compose -p shard20 up -d --wait postgres
docker compose -p shard20 run --rm lab程序使用独立空 schema 创建实验数据,已有表时会拒绝再次初始化。所有输出都来自 API 计算或 SQL 查询,断言不成立时进程非零退出。
验证方言和夏令时
实际 Spring 与 Quartz 解析器均接受各自的六字段表达式,并拒绝直接传入五字段表达式:
springSixFields=true quartzSixFields=true unixFiveFieldsRejected=true程序从 Europe/Berlin 的 JDK 时区规则中取得一次跳时和一次回拨,分别查询本地时间的合法偏移量,再让 Spring 计算回拨日的两个 02:30:
gapValidOffsets=0 overlapValidOffsets=2 springOverlapDistanceSeconds=3600
shortBusinessDayHours=23 longBusinessDayHours=25两个瞬间相差 3600 秒,但本地钟表显示相同时间。如果业务规定“每个结算日只结算一次”,幂等身份应使用业务日期和规则版本;如果业务确实需要两次运行,则要加入偏移量或计划瞬间区分。
验证三种 misfire 调整
实验直接调用 Quartz CronTriggerImpl 和 SimpleTriggerImpl 的 misfire 处理实现。它设置已经过去的下一次触发时间,然后检查指令处理后的状态:
cronDoNothing=future cronFireOnceNow=now simpleRepeatCount=8 timesTriggered=0前两项验证 Cron 的跳过与立即触发一次。第三项初始 repeatCount 为 10、已触发次数为 2,选择 RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT 后,真实实现将 repeatCount 调整为 8,并重置 timesTriggered。
调用 updateAfterMisfire() 会直接更新 Trigger,绕过调度线程发现迟到触发的过程。要观察发现速度,还需要启动完整 Scheduler,记录停机、数据库等待和恢复扫描的耗时。Spring Scheduler 与 Quartz中的持久调度实验可以用于观察进程故障后的恢复。
SQL 正例:固定桶没有遗漏和重复
输入为主键 0 到 1199,共 1200 行。分片表建立 12 个桶,初始分给 3 个所有者:
CREATE TABLE lab_bucket (
bucket integer PRIMARY KEY CHECK (bucket BETWEEN 0 AND 11),
owner integer NOT NULL,
epoch integer NOT NULL
);
INSERT INTO lab_bucket
SELECT i, mod(i, 3), 1
FROM generate_series(0, 11) AS i;读取条件使用固定桶数:
SELECT i.id, b.owner
FROM lab_item AS i
JOIN lab_bucket AS b ON mod(i.id, 12) = b.bucket;查询缺失集合和重复主键,得到:
stableRows=1200 missing=0 duplicates=0lab_bucket.bucket 的唯一约束保证每个桶只出现一次,范围约束限制桶号。完整覆盖仍需要检查 12 个桶是否全部存在;只有唯一约束,没有桶数量检查,仍可能漏建某些桶。PostgreSQL 约束
SQL 负例:一台机器使用不同 total
错误配置让 worker 0 和 worker 2 继续使用 total=3,而 worker 1 使用 total=4:
SELECT i.id, w.worker_index
FROM lab_item AS i
JOIN bad_worker AS w
ON mod(i.id, w.total) = w.worker_index;查询缺失集合与重复主键,得到:
mixedWorkerTotals missing=300 duplicates=200某些余数属于旧规则的 worker 1,却不属于它的新规则,于是无人处理;另一些数据同时满足不同 worker 的条件。每台执行器都可能成功完成自己的查询,覆盖错误要从主键集合中找。
可以独立查询遗漏的主键:
docker compose -p shard20 exec -T postgres \
psql -U shardlab -d shardlab -v ON_ERROR_STOP=1 -c \
"SELECT i.id FROM lab_item i
WHERE NOT EXISTS (SELECT 1 FROM bad_coverage c WHERE c.id=i.id)
ORDER BY i.id LIMIT 10;"看到缺失集合后,应先停止继续扩散错误计划,再补齐遗漏数据、核对重复副作用,而不是单纯增加一次全量重跑。
转移桶而不改变数据分组
实验将 owner=2 的四个桶转给其他所有者,同时增加这些桶的 epoch:
UPDATE lab_bucket
SET owner = mod(bucket, 2),
epoch = epoch + 1
WHERE owner = 2;覆盖仍为 1200 行:
transferredBuckets=4 stableBucketCount=12 reassignedCoverage=1200更新后,12 个桶都有所有者,分片映射保持完整。这条 SQL 只改变映射;在线转移还需要确认旧持有者失效,并在业务写入处校验新的 token。
结束实验用 docker compose -p shard20 down 保留数据。确认放弃本实验数据后,才执行 docker compose -p shard20 down -v 删除本项目卷。
迟到、漏跑与补跑怎样判断
补哪些窗口,必须由业务决定
恢复后常见的业务选择有三种:
| 业务要求 | 处理方式 | 代价 |
|---|---|---|
| 每个窗口都必须产出独立结果 | 枚举缺失窗口,限速逐个补跑 | 恢复期间可能形成积压 |
| 只需要当前最新状态 | 跳过旧快照,生成最新快照 | 旧时刻的中间结果不会补齐 |
| 多个窗口允许合并计算 | 保存合并区间并计算一次 | 结果含义发生变化,需要业务支持 |
这些是业务补跑策略,不是每个调度产品都提供的同名选项。合并计算还要处理统计口径:求和可以合并的窗口,去重计数或阶段状态转换未必能简单合并。
业务窗口表可以记录预期窗口、计划版本和最终结果引用。定期比较“应有窗口”与“已完成窗口”,比只看最近一次任务是否成功更容易发现漏跑。
排查从时间和数据分别开始
| 现象 | 检查位置 | 下一步 |
|---|---|---|
| 完全没有触发记录 | Cron 合法性、有效期、暂停状态、时区 | 计算下一次触发点,再检查调度节点 |
| 有触发记录但开始很晚 | 队列等待、执行器负载、数据库锁 | 区分派发迟到和执行排队 |
| 恢复后连续触发 | misfire 指令、积压计划点 | 限制追赶速度,核对业务窗口 |
| 只有夏令时附近异常 | 地区规则、本地时间偏移 | 同时展示本地时间和 Instant |
| 所有分片成功但总量不对 | 分片计划版本、缺失集合、重复集合 | 按主键范围核对,不只比总数 |
| 扩容后大量重复 | 动态 total、旧节点未停止 | 冻结计划,转移未完成分片并拒绝旧写入 |
应将 JVM 默认时区、容器 TZ、Cron 配置时区和业务窗口时区分别核对。它们可能刚好相同,也可能完全不同。明确传入 ZoneId 比依赖机器默认设置更容易复现。
修改 Cron、业务时区、过滤条件或桶数时,同时保存新的计划版本。旧窗口继续按旧规则恢复,还是迁入新规则,应在变更时决定;同一个业务键不能在重试中悄悄换成另一组输入。
权威资料与规范地址
Cron 触发与时区规则
- Spring 调度与 Cron:https://docs.spring.io/spring-framework/reference/integration/scheduling.html
- Quartz CronTrigger:https://www.quartz-scheduler.org/documentation/quartz-2.5.x/tutorials/tutorial-lesson-06.html
- JDK ZoneRules:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/zone/ZoneRules.html
- Spring CronTrigger 固定版本源码:https://github.com/spring-projects/spring-framework/blob/v7.0.9/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java
