3003 字
约 10 分钟
13
S07-A12 · 任务调度的时区与夏令时:跨时区的时间怎么算
"每天凌晨两点跑结算"——这行配置上线三个月没出事,直到某个地区进入夏令时的那天,任务在 02:00 到 03:00 之间根本没有那个时刻,于是当天少跑了一次;换季那天同一时刻出现两次,于是又跑了两遍。代码一行没改,错的是那句话本身:"凌晨两点"不是时间点,是一种说法。
调度系统里只有两种时间,含义完全不同。这篇拆四件事:语义怎么选、切换日出什么异常、业务日怎么定、配置怎么对齐。
1. 两种时间语义:绝对时刻与当地墙上时间
| 语义 | 例子 | 存储表示 | 夏令时切换日的行为 |
|---|---|---|---|
| 绝对时刻 | 每 24 小时一次、距上次执行满 N 分钟 | UTC 时间戳、单调时钟 | 不受影响,但与当地钟表的对应会漂移一小时 |
| 当地墙上时间 | 每天 02:00、工作日 09:00 | 时区标识 + 本地时间字段 | 该时刻可能不存在,也可能出现两次 |
| 固定偏移 | 每天 02:00+08:00 | 偏移量 | 忽略规则变化,偏移本身不会自动更新 |
// 调度定义:语义必须显式写在配置里,而不是留给读者猜
settlement-daily:
when: "02:00" // 这是墙上时间,依赖 timezone 字段
timezone: "Asia/Shanghai" // 缺这一行,语义就取决于宿主机
settlement-interval:
every: "24h" // 这是绝对时刻,与当地钟表无关
判断清单:
- 任务是否必须发生在某个当地时刻(对账、开门、收盘)?是则用墙上时间,并写明时区。
- 任务是否只是周期性处理积压?是则用绝对时刻,少一整类异常。
- 任务频率是否高于每小时?是则时区几乎不影响,可退化为固定偏移。
- 任务跨多个时区运行?是则必须为每个区域单独定义,不能用一条全局配置。
- 漏跑一次与多跑一次,哪个后果更重?漏跑 Money 错,多跑可能重复入账,二者要求不同的兜底。
- 调度器是否支持时区字段?不支持时,只能把语义降级为"宿主机本地时间",并把宿主机时区写进文档。
- 时区数据库是否需要随系统更新?跨年度的规则变更(某国取消夏令时)只有更新 tzdata 才生效。
一句话判据:"每天几点"是业务语言,"每隔多久"是工程语言。二者不能互换,也不能靠注释弥补。
2. 夏令时切换日的三种异常
| 异常 | 成因 | 表现 | 业务后果 |
|---|---|---|---|
| 跳过 | 当地时钟从 02:00 直接跳到 03:00,02:30 不存在 | 任务不触发 | 当天数据未处理,次日发现缺口 |
| 重复 | 当地时钟从 03:00 退回 02:00,02:30 出现两次 | 任务触发两次 | 重复入账、重复通知、幂等被击穿 |
| 漂移 | 用绝对间隔跑"每天一次" | 执行时间逐日前后移动一小时 | 落在业务高峰或维护窗口内 |
// 判断某个本地时间在该时区是否存在、是否重复(java.time,按 JDK 17 核对)
ZoneId zone = ZoneId.of("America/New_York");
LocalDateTime local = LocalDateTime.of(2026, 3, 8, 2, 30);
ZoneRules rules = zone.getRules();
boolean gap = rules.getTransition(local).isGap(); // true:该时刻不存在
boolean overlap = rules.getTransition(local).isOverlap(); // true:该时刻出现两次
三条应对,按成本递增:
- 避开:把任务时间挪到切换窗口之外(如 04:30),这是最便宜的做法,前提是业务允许。
- 归一:定义"不存在的时刻顺延到下一个合法时刻",并在调度器里实现,同时记录一次显式日志。
- 幂等 + 补偿:接受可能重复触发,用业务键去重;接受可能漏触发,用"数据缺口检测"补跑。
// 避让写法:把任务挪出切换窗口,并用业务日做去重键
settlement-daily:
when: "04:30"
timezone: "America/New_York"
idempotencyKey: "{task}:{biz_day}"
判断清单:
- 该任务所在的时区是否使用夏令时?不使用(如
Asia/Shanghai)时,这一类异常整体消失。 - 触发时刻是否落在切换窗口内?窗口通常在当地 01:00–03:00 之间,按 tzdata 核对。
- 重复触发时,业务是否幂等?不幂等就必须在调度层去重。
- 漏触发时,谁会发现?没有缺口检测,就只能等下游对账报警。
- 调度器与业务代码是否共用同一个时区判断逻辑?两处各写一份,切换日必然分歧。
- 是否有切换日的手工核对清单?一年两次,写在 runbook 里比写进代码更划算。
3. 业务日:自然日、结算日与跨零点营业
财务与结算类任务必须用绝对时刻或明确的业务日定义,绝不能用墙上时间。结算的对象是"一批业务数据",不是"钟表上的某个瞬间"。
| 概念 | 定义 | 边界由谁决定 | 典型坑 |
|---|---|---|---|
| 自然日 | 当地 00:00–24:00 | 时区 | 跨零点营业的数据被切成两天 |
| 业务日 | 例如 04:00 至次日 04:00 | 业务规则 | 规则写在代码里,改一次要发版 |
| 结算日 | 账期,通常滞后于业务日 | 财务口径 | 与业务日混用,对不上账 |
─── 跨时区"同一天"聚合:先统一到业务时区再截断,而不是先截断再转换
SELECT
DATE(CONVERT_TZ(created_at, '+00:00', 'Asia/Shanghai')) AS biz_day,
COUNT(*) AS cnt
FROM orders
GROUP BY biz_day;
─── 反例:直接 DATE(created_at) 会把 UTC 的前一天算进当天
时间存储的三条规则:存 UTC、存时区标识(当"这个时刻属于哪个当地日历"本身是业务信息时)、显示时本地化。混用的代价是累积的:每一次"就这一次先这么存"都会在后来的对账里变成解释不清的差额。
时间戳精度与截断:秒级截断会破坏同一秒内事件的先后顺序,进而破坏基于时间戳的唯一性与排序。用毫秒或微秒,且同一实体保持一致精度;把精度写进字段定义,而不是靠默认值。
判断清单:
- 业务日边界是否可配置?写死在代码里,改口径就要发版。
- 跨零点的数据是否有归属规则?没有规则时,两天的报表都不平。
- 聚合口径是否统一到一个时区?多地报表各按本地截断,全年合计必然对不上。
- 时间戳精度是否够区分同秒事件?不足时唯一索引与游标分页都会出错。
- 存储是否混用了本地时间与 UTC?一处混用,排查成本是全局的。
- 时区标识是否随记录一起存?只存 UTC 时,"这笔单属于哪个当地日历"就永久丢失了。
4. 三层时区配置与对齐方式
时区不一致的排查顺序是固定的:容器 → 应用 → 数据库会话 → 数据本身。四层里任意一层不同,都会表现为"时间差几小时"或"跨天"。
─── 逐层取当前时区,四层输出必须能互相解释
$ date; timedatectl | grep -i zone // 宿主机
$ docker exec APP date // 容器(镜像常默认 UTC)
$ java -XshowSettings:properties -version 2>&1 | grep user.timezone
$ mysql -e "SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();"
| 层 | 时区来源 | 冲突时的表现 |
|---|---|---|
| 容器 | 镜像 TZ、/etc/localtime |
日志时间与宿主机差 8 小时 |
| 应用 | user.timezone、TZ、框架默认 |
序列化出的时间少了偏移 |
| 数据库会话 | time_zone 变量、连接串参数 |
NOW() 与 UTC_TIMESTAMP() 不一致 |
| 数据 | 列类型(TIMESTAMP / DATETIME)与写入值 |
同一列里混着两种语义的值 |
判断清单:
- "服务器时间是 UTC 还是本地时间"是否写进了文档?没写,就等于每个人各自假设一次。
- 容器是否显式设置
TZ?依赖镜像默认值,换基础镜像就会漂移。 - 数据库列用的是
TIMESTAMP还是DATETIME?前者随时区转换,后者不转,混用即事故。 - JDBC 连接串是否显式指定时区参数?不指定时,行为取决于驱动版本与服务端配置。
- 日志时间戳是否带偏移?只带本地时间,跨时区排查要靠猜。
- 定时任务的时区是否在代码里显式声明?默认取 JVM 时区,容器一改就变。
| 度量 | 说明 |
|---|---|
| 时区相关故障数 | 归因为时间解释错误的故障条数 |
| 夏令时切换异常次数 | 跳过与重复各计一次 |
| 跨时区聚合偏差 | 多地报表合计与总账的差额 |
| 时区配置不一致项数 | 上表四层中取值不一致的层数 |
5. 排错:五种"时间不对"
| 现象 | 原因 | 处理 |
|---|---|---|
| 某天地域任务没跑 | 触发时刻落在夏令时跳过的空档 | 挪开窗口,或定义顺延规则并记日志 |
| 同一天重复入账 | 触发时刻在回拨日出现两次 | 调度层按"业务日 + 任务名"去重 |
| 报表跨天错位 | 先按 UTC 截断日期再转时区 | 先转业务时区再截断 |
| 容器内时间比宿主机早 8 小时 | 镜像默认 UTC,宿主机为本地时区 | 显式设置 TZ 与 /etc/localtime |
NOW() 与写入值差 8 小时 |
会话时区与列类型语义不一致 | 统一用 UTC 写入,读出时转换 |
6. 边界:什么情况可以简化
| 场景 | 可否简化 | 理由 |
|---|---|---|
| 业务只在单一固定偏移时区 | 可以,用固定偏移 | 无规则变化,墙上时间等价于固定偏移 |
| 所在地区不使用夏令时 | 可以省掉切换逻辑 | 跳过与重复两类异常不存在 |
| 任务频率高于每小时 | 可以简化为固定偏移 | 一小时内的时区差异不影响业务 |
| 纯内部批处理、无对账要求 | 可以只用绝对间隔 | 漏跑与重复的后果都可人工修正 |
| 跨多时区的对外结算 | 不可简化 | 业务日必须逐区域定义,且需可举证 |
简化的前提是把假设写下来:一句"本系统只服务 UTC+8 且无夏令时的地区",能让后来者少查一周日志。
自测
- "每天 02:00"与"每 24 小时"分别属于哪种时间语义,切换日行为差在哪?
- 夏令时回拨日为什么会导致同一任务执行两次,靠什么兜底?
- 财务结算任务为什么不能用墙上时间?
- 跨时区"同一天"聚合,截断与转换的顺序应该谁先?
- 排查时间差 8 小时,四层时区该按什么顺序核对?
- 哪些条件下可以把时区处理简化为固定偏移?
S07-A12 · 任务调度的时区与夏令时:跨时区的时间怎么算
https://www.clxhxhhr.top/posts/6373/ 评论
0 条
还没有评论,先写一条吧。