后端这篇只讲一件事:一年只发生两次的夏令时切换,会把 Java 后端里四类极常见的写法——定时排期、时长统计、幂等键、时区配置——在什么条件下悄悄做错,以及怎么用不到十行代码在上线前把它们测出来。
适合谁读:写过「每天 02:30 跑一次」这类排期、给 Agent 工具调用做过去重、或者同一套服务里既按 UTC 又按当地时区出报表的同学。不需要提前了解 tzdb,只需要用过 LocalDateTime。
能收获什么:一张「六种校验写法分别能挡住哪一半问题」的实测矩阵、四条可以直接抄进代码库的判据写法、一份在当前目录用两条命令就能复跑且三次输出逐字节一致的实验。
时间锚点:据网易新闻 2026-11-02 与腾讯新闻 2026-11-01 的报道,美国夏令时在当地时间 2026 年 11 月 1 日凌晨 2 点结束,时钟从 02:00 拨回 01:00,这一天多出 1 小时;按现行联邦规则,夏令时每年 3 月第二个周日开始、11 月第一个周日结束,下一次开始是 2027 年 3 月 14 日;《阳光保护法案》2026 年 7 月已在众议院以 308 票对 117 票通过,但卡在参议院,所以 2026 年 11 月照常调钟。欧洲大部分地区则在 2026 年 10 月 25 日结束夏令时。以上为媒体报道,本文不讨论立法走向。
报道里的立法文本我没法在本机核对,但 JDK 自带一份 tzdb,那才是我代码真正依赖的东西。于是我把这几个日期当输入,在本机做了 6,307,200 次逐分钟判定(12 个时区 × 一年 525,600 分钟),顺带把排期、解析、校验、格式化四类写法全部跑了一遍。结论是:我的 tzdb 实测数据与报道的两个日期完全吻合,而代码里那些「看起来没问题」的写法,在切换日确实会错,且错得静悄悄。
一、实测环境与口径:哪些数字会随环境变
先把口径钉死,否则下面所有表格都没有意义。下表全部是本机实采,不是从任何旧文章复制的。
| java -version | 17.0.18 2026-01-20 LTS(build 17.0.18+8-LTS-264) |
| javac -version | 17.0.18 |
| java.vm.name | Java HotSpot(TM) 64-Bit Server VM |
| os.name / os.version / os.arch | Windows 11 / 10.0 / amd64 |
| file.encoding / native.encoding | GBK / GBK(所以实验程序一律只输出 ASCII,避免解码问题) |
| availableProcessors | 16 |
| ZoneId.getAvailableZoneIds().size() | 604 |
| 随机 seed | 20261102(只用于抽样打印哪一条样本,不影响任何统计数字) |
| 实验材料 | 全部由本程序在当前目录生成,未改动 JDK 安装目录与任何系统配置 |
关于可复现性,需要区分三类数字:
- 规则类(切换时刻、空洞分钟数、重叠分钟数、漏跑槽数、错钟天数):只取决于 JDK 内置 tzdb,与 CPU、内存、负载无关,换机器也一样。
- 构造类(360 次请求压成 240 个键这类):完全由步数和公式决定,是确定性的。
- 计时类:本文刻意为之,一个都没有。程序末尾打印的各阶段耗时只用于说明跑得快,不参与任何结论。
我的复现判定标准写在第十二节,共四条;三次复跑除实时钟那一行外,190 行输出逐字节一致。本机只装了一套 JDK 17.0.18,因此全文不做跨版本对拍,也不声称 JDK 8/11/21 会得到同样的数字。
二、630 万分钟扫描:全年只有 9 个区调钟,切换点并不都在凌晨 2 点
我没有直接相信「美国凌晨 2 点调钟」这个常识,而是用两种互相独立的方法对拍:一种把 ZoneRules.getValidOffsets(LocalDateTime) 在一年里逐分钟问一遍(返回空列表即空洞、返回两个偏移即重叠),另一种从 getTransition() 拿权威切换记录。两种方法对 12 个时区给出完全一致的结论。
| America/New_York | 60 | 60 | 02:00..02:59 | 01:00..01:59 | ±3600s | 2027-03-14T02:00 -05:00>-04:00 |
| America/Chicago | 60 | 60 | 02:00..02:59 | 01:00..01:59 | ±3600s | 2027-03-14T02:00 -06:00>-05:00 |
| America/Denver | 60 | 60 | 02:00..02:59 | 01:00..01:59 | ±3600s | 2027-03-14T02:00 |
| America/Los_Angeles | 60 | 60 | 02:00..02:59 | 01:00..01:59 | ±3600s | 2027-03-14T02:00 |
| America/Havana | 60 | 60 | 00:00..00:59 | 00:00..00:59 | ±3600s | 2027-03-14T00:00 |
| Europe/London | 60 | 60 | 01:00..01:59 | 01:00..01:59 | ±3600s | 2027-03-28T01:00 Z>+01:00 |
| Europe/Berlin | 60 | 60 | 02:00..02:59 | 02:00..02:59 | ±3600s | 2027-03-28T02:00 |
| Australia/Lord_Howe | 30 | 30 | 02:00..02:29 | 01:30..01:59 | ±1800s | 2027-04-04T02:00 +11:00>+10:30 |
| Pacific/Chatham | 60 | 60 | 02:45..03:44 | 02:45..03:44 | ±3600s | 2027-04-04T03:45 |
| America/Phoenix | 0 | 0 | – | – | 0 | nextTransition 返回 null |
| Europe/Moscow | 0 | 0 | – | – | 0 | nextTransition 返回 null |
| Asia/Shanghai | 0 | 0 | – | – | 0 | nextTransition 返回 null |
| 合计 | 510 | 510 | 扫描总次数 6,307,200(12 × 525,600) | |||
三个和直觉不符的点,都是实测出来的:
第一,切换点不在凌晨 2 点的区真实存在。古巴哈瓦那的空洞和重叠都落在 00:00..00:59,也就是说「每天 00:00 跑日结」这条排期在哈瓦那会正好撞在空洞上;查塔姆群岛因为用的是 +12:45 这种 45 分钟制偏移,切换窗口是 02:45..03:44。只按「凌晨 2 点」写特判的代码,在这些区一律失效。
第二,霍厄岛的切换只有 30 分钟。Australia/Lord_Howe 全年空洞 30 分钟、重叠 30 分钟。这条直接否决了「按 1 小时粒度做保护」的写法——它连一个整点都不覆盖。
第三,不调钟的区不是「没有历史」。Asia/Shanghai 的 tzdb 里有 29 条切换记录,最后一条落在 1991 年,之后 nextTransition() 返回 null;Phoenix 的 11 条记录停在 1967 年。这从机器侧印证了报道里「中国 1986 至 1991 年实行夏时制、1992 年起暂停」的说法,但更重要的是:判断一个区未来会不会调钟,要看 nextTransition() 是不是 null,而不是看它有没有历史。
三、查未来调钟不能枚举 getTransitions():它在纽约只走到 2008 年
第二节的第一版代码里,我用的是一种看起来很自然的写法:把 rules.getTransitions() 全量取出来,过滤出落在 2026 年的切换。结果12 个时区全部返回 0 条,而逐分钟扫描明明发现了 510 个空洞分钟。两个数字相互矛盾,说明错在实验台,而不是 JDK 忽然不发夏令时了。
| America/New_York | 177 | 2008 | 0 | 60 分钟 |
| America/Chicago | 177 | 2008 | 0 | 60 分钟 |
| America/Denver | 99 | 2008 | 0 | 60 分钟 |
| America/Los_Angeles | 127 | 2008 | 0 | 60 分钟 |
| America/Havana | 110 | 2014 | 0 | 60 分钟 |
| Europe/London | 161 | 1997 | 0 | 60 分钟 |
| Europe/Berlin | 63 | 1997 | 0 | 60 分钟 |
| Australia/Lord_Howe | 59 | 2009 | 0 | 30 分钟 |
| Pacific/Chatham | 73 | 2009 | 0 | 60 分钟 |
| America/Phoenix | 11 | 1967 | 0 | 0 分钟 |
| Europe/Moscow | 76 | 2014 | 0 | 0 分钟 |
| Asia/Shanghai | 29 | 1991 | 0 | 0 分钟 |
换用 getTransition(LocalDateTime)(对单个本地时间问“你落在哪次切换上”)与 nextTransition(Instant) 后,两个切换当场就出来了:New_York 命中 2026-03-08T02:00 GAP -05:00>-04:00 与 2026-11-01T02:00 OVL -04:00>-05:00,与新闻报道完全一致。
结论不是「getTransitions() 有 bug」,而是它的语义跟我假设的不一样:它给的是已经固定下来的切换表,后面的重复规则不会预先展开。表格最后一列停在 1997、2008、2009、2014 各不相同,完全取决于该地最后一次“非规则化”调整是哪一年。因此:
- 要“未来某天会不会调钟”,用 nextTransition() / previousTransition();
- 要“这个本地时间是不是异常”,用 getValidOffsets(ldt).size();
- 要“今年全年有几个异常小时”,用逐分钟或逐槽扫描,别拿历史表凑答案。
这个坑的实际危害在于:如果你用枚举历史表的方式生成「全年需要特殊处理的时刻清单」,在美国四个主要时区上会得到一张空清单,然后这些漏到线上的切换会在生产环境首次运行到 11 月 1 日时才暴露。
四、把偏移写死成 UTC-5,2024 到 2030 每年稳定错 238 天
另一个常见写法是“美国东部不就是 UTC-5 吗”,直接用 ZoneOffset.ofHours(-5) 或 GMT-5 而不用 ZoneId。我把两种写法对同一个本地时间(每天 09:30)转出的瞬间逐个对比,共跑 7 年。
| 2024(闰) | 366 | 238 | 238 | 238 | 128 |
| 2025 | 365 | 238 | 238 | 238 | 127 |
| 2026 | 365 | 238 | 238 | 238 | 127 |
| 2027 | 365 | 238 | 238 | 238 | 127 |
| 2028(闰) | 366 | 238 | 238 | 238 | 128 |
| 2029 | 365 | 238 | 238 | 238 | 127 |
| 2030 | 365 | 238 | 238 | 238 | 127 |
两个值得记住的观察:错误天数与闰年无关(2024、2028 多出的那一天并不在切换窗口里,所以一直是 238);以及错误率高达 65%(238/365),不是“一年就错两天”那么温和——真正只有两天的是一天 23/25 小时那种口径错(见第六节),而固定偏移是 238 天全部偏 1 小时。
最后一列是另一个角度的实测:把“永久夏令时法案如果生效”简化成“全年写死 -04:00”,那么错的天数变成 127(闰年 128)。也就是说:换制度不会把这类代码变正确,只会把错的日子换一批。真正的修法是用 ZoneId,而不是调那个数字。
在切换当天现场看更直观(New_York,固定 -05:00 对比真实区):
| 11-01 00:30 | [-04:00] | 05:30Z | 04:30Z(差 1 小时) |
| 11-01 01:30 | [-04:00, -05:00] | 06:30Z | 05:30Z(差 1 小时) |
| 11-01 02:30 | [-05:00] | 07:30Z | 07:30Z(对) |
| 11-01 03:30 | [-05:00] | 08:30Z | 08:30Z(对) |
回拨日凌晨 00:30 这个看似“安安全全不存在歧义”的时刻,因为当时还在夏令时,写死 -05:00 同样错 1 小时。重叠小时里只有后半截能“碰巧对”,前半截必错。
五、同一个时间字符串,两代 JDK API 给出差 1 小时的两个瞬间
这是本次实验里我最不愿意看到、但确实跑出来的结果:SimpleDateFormat 和 java.time 对重叠小时内的解析,系统性地差整 1 小时。我把 2026 年纽约全部 60 个空洞分钟和 60 个重叠分钟逐分钟拿去问两个 API。
| 2026-03-08 02:30:00(空洞) | 1772955000000(= 03:30 EDT) | 2026-03-08T07:30:00Z | 一致 |
| 2026-11-01 01:30:00(重叠) | 1793514600000(= 06:30Z,-05:00) | 2026-11-01T05:30:00Z(-04:00) | 差 60 分钟 |
| 2026-11-01 00:30:00(正常) | 1793507400000 | 2026-11-01T04:30:00Z | 一致 |
| 2026-06-15 12:00:00(正常) | 1781539200000 | 2026-06-15T16:00:00Z | 一致 |
把 120 个分钟全部跑完,结论非常干净:
| America/New_York | 60 | 0 | 60(100%) | 2026-11-01 01:00:00 | 60 分钟 |
| America/Havana | 60 | 0 | 60(100%) | 2026-11-01 00:00:00 | 60 分钟 |
| Europe/Berlin | 60 | 0 | 60(100%) | 2026-10-25 02:00:00 | 60 分钟 |
| Australia/Lord_Howe | 30 | 0 | 30(100%) | 2026-04-05 01:30:00 | 30 分钟 |
| Pacific/Chatham | 60 | 0 | 60(100%) | 2026-04-05 02:45:00 | 60 分钟 |
| 空洞分钟(同样 60 个):两个 API 一致 60 / 分歧 0——两者都把不存在的本地时间往后搬 1 小时。 | |||||
String s = "2026-11-01 01:30:00"; // 重叠小时内,合法但歧义
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(TimeZone.getTimeZone("America/New_York"));
long oldMs = sdf.parse(s).getTime(); // 1793514600000 -> 01:30-05:00(标准时)
LocalDateTime ldt = LocalDateTime.parse(s.replace(' ', 'T'));
long newMs = ldt.atZone(ZoneId.of("America/New_York")).toInstant().toEpochMilli(); // 1793511000000 -> 01:30-04:00(夏令时)
System.out.println((newMs – oldMs) / 60000); // -60,整整一小时
System.out.println(ZoneId.of("America/New_York").getRules().getValidOffsets(ldt)); // [-04:00, -05:00]
为了确认这不是谁算错了,我又用 GregorianCalendar 反向验证:同一组年月日时分,DST_OFFSET=0 得到的毫秒数 1793514600000 与 SimpleDateFormat 完全相等,DST_OFFSET=3600000 得到的 1793511000000 与 java.time 完全相等。两套实现各自都是自洽的,只是选了两个都合法的偏移里的不同那一个。
更麻烦的坑在于回归测试:SimpleDateFormat 自己的 format/parse 回环在这 120 个分钟上几乎完美(96 个样本中只有 2 个空洞分钟回环后变成 03:00/03:30,重叠小时全部自洽)。也就是说,只测单一 API 的单元测试根本发现不了这个差异,它只在“旧代码与新代码共存”或“跨服务传字符串”时才会响。典型事故路径:上游用 java.time 产生字符串,下游用旧映射层解析,重叠小时内同一份日志里两个时间戳对不上。
六、排期实测:按 24 小时步进一年漂 238 天,按墙钟排期漏 4 个槽
6.1 两种步进方式:一种保本地时刻,一种保时长
把同一条“每天 00:00”的排期用两种方法跑完整 2026 年:
| instant.plus(Duration.ofHours(24)) | 365 | 127 | 238(65.2%) | 2026-03-09 | 2026-11-01 |
| zonedDateTime.plusDays(1) | 365 | 365 | 0 | – | – |
固定 24 小时步进全年只有 127 次在午夜,另外 238 次全部跑到本地 01:00(直方图实测:{00:00=127, 01:00=238})。这不是 1 小时的随机抖动,而是从 3 月 9 日到 11 月 1 日连续 238 天稳定偏 1 小时:业务上表现为“夜间批次跟上游日切错开 1 小时”,而且只在夏令时期间出现,监控上极难归因。
反过来,plusDays(1) 保住了本地时刻,但时长不再恒定:实测相邻两次触发中有 2 次不是 86,400 秒,分别是 2026-03-08 -> 03-09 = 82,800s(23 小时)与 2026-11-01 -> 11-02 = 90,000s(25 小时)。SLA 计时、计费周期、签名有效期这类“时长敏感”逻辑如果基于墙钟推算,就必须接受这两个数字。
6.2 墙钟排期自己也会丢任务
以 15 分钟一个槽位、全年 35,040 个槽位计数,空洞槽会被静默后搬,重叠槽只会被执行一次:
| America/New_York | 1 分钟 | 525,600 | 525,540 | 60 | 60 |
| America/New_York | 5 分钟 | 105,120 | 105,108 | 12 | 12 |
| America/New_York | 15 分钟 | 35,040 | 35,036 | 4 | 4 |
| America/New_York | 整点 | 8,760 | 8,759 | 1 | 1 |
| Australia/Lord_Howe | 整点 | 8,760 | 8,760 | 0 | 0 |
| Australia/Lord_Howe | 15 分钟 | 35,040 | 35,038 | 2 | 2 |
| Asia/Shanghai | 任意 | – | – | 0 | 0 |
这张表里最有价值的是两个边界:其一,受影响的槽位数与颗粒度成正比,粒度越细,一年里丢的越多(1 分钟粒度下丢 60 个);其二,霍厄岛在整点颗粒度下完全没事(0/0),但 15 分钟粒度下丢 2 个,因为它的切换只跳 30 分钟,不跨过任何整点。如果你的保护策略是“只在整点做特判断”,在这个区会 100% 漏掉。
另一个容易误判的地方:2026-11-01T01:30 这个本地时间在真实世界里发生两次(1793511000000 与 1793514600000 两个瞬间),但按墙钟排期只会得到 365 个不重复瞬间中的 1 个(实测 distinctInstants=365、distinctWallSlots=365、ambiguousDays=1)。“本地时间看起来每天只跑一次”并不等于“只应该跑一次”,需要跟业务确认的是:重叠小时内到底该跑 1 次还是 2 次。
6.3 时长统计口径:两种错误刚好抵消
把“一天多少小时”用本地时间相减与用瞬间相减对拍:
| America/New_York | 2 | 03-08 naive=24/real=23;11-01 naive=24/real=25 | 8,736 | 8,736 | 0 |
| Australia/Lord_Howe | 1 | 10-04 naive=24/real=23(23.5h 被 toHours 截断) | 8,736 | 8,735 | +1 |
全年总秒数实测 31,536,000,与 365×86400 完全相等——因为 3 月少掉的 1 小时在 11 月又还回来了。如果你依赖“全年总时长对不对”做健康检查,夏令时错误在这项校验上是隐形的;同理,23 小时日与 25 小时日在“小时数相减”口径下会完全抵消,尽管它们各自都错了。
另外,“一天 24 小时”也不是恒真:纽约等 9 个区里,每年固定有 1 天 82,800 秒、1 天 90,000 秒,剩下 362 天才是 86,400 秒(实测每区 364 个相邻日对)。
七、六种校验写法只有两种能全覆盖,而“100% 拦住”那个是假象
知道有坑之后,下一个问题是怎么把它拦在代码里。我把六种常见写法在纽约全年 60 个空洞分钟 + 60 个重叠分钟(共 120 个异常分钟)上全量跑了一遍,统计每种写法真正拦下了多少。
| V1 ldt.atZone(zone) 直接转 | 0 | 0 | 0% | 空洞后搬、重叠取较早偏移,全部静默 |
| V2 ZonedDateTime.ofStrict(ldt, offset, zone) | 60 | 0 | 50% | 只在偏移与本地时间不匹配时抛 |
| V3 getValidOffsets(ldt).size() != 1 | 60 | 60 | 100% | 显式分类:0 个是空洞,2 个是重叠 |
| V4 getTransition(ldt) != null | 60 | 60 | 100% | 直接拿到切换对象,可区分 GAP/OVL |
| V5 DateTimeFormatter + ResolverStyle.STRICT(uuuu) | 0 | 0 | 0% | 120 个全部正常解析,不报错 |
| V6 SimpleDateFormat.setLenient(false) | 60 | 0 | 50% | 空洞报 Unparseable date,重叠静默 |
三个反直觉结论:
一、ResolverStyle.STRICT 对时区完全无效。它约束的是字段组合能否解析成日期(比如 2 月 30 日),不约束“这个本地时间在该时区到底存不存在”。实测 120 个异常分钟全部成功解析,空洞分钟 2026-03-08 02:30 直接被解析成 2026-03-08T03:30-04:00。
二、ofStrict 只挡得住一半。空洞全部抛;而重叠小时里两个偏移都合法,只要你传的是其中之一就静默通过(实测拦下 0/60)。重叠本质上不是错误,是一个需模业务表态的歧义,因此任何“抛异常式”校验都天然管不了它。
三、旧 API 反而在空洞上更严格。setLenient(false) 的 SimpleDateFormat 对 60 个空洞分钟全部抛 ParseException,但打印出来的因果链只有一层:ParseException:Unparseable date: "2026-03-08 02:30:00",没有更深原因。
// 推荐的“异常分钟”判定:空洞与重叠分开处理,两者都不静默
ZoneRules rules = ZoneId.of("America/New_York").getRules();
List<ZoneOffset> offs = rules.getValidOffsets(ldt); // 1 正常 / 0 空洞 / 2 重叠
switch (offs.size()) {
case 1: return ldt.atOffset(offs.get(0)).toInstant();
case 0: ZoneOffsetTransition gap = rules.getTransition(ldt);
throw new IllegalStateException("不存在于当地日历: " + ldt
+ " 将搬至 " + ldt.plus(gap.getDuration())); // 显式告知,不自作主张
default: ZoneOffsetTransition ovl = rules.getTransition(ldt); // size()==2
return offs.stream().map(o -> ldt.atOffset(o).toInstant()).collect(Collectors.toList())
.get(policy == AMBIGUITY.FIRST ? 0 : 1); // 把选择写成配置
}
这里要诚实地记一次返工。上面表格的 V5 第一版结果是 120/120 全部抛错,看起来完美拦截。我当时的反应是“STRICT 怎么可能把 atZone 拦不住的东西全拦了”,就往里加了一个已知正常的对照样本 2026-07-01 12:00——结果它照样抛。真正的原因是第一版模式串写的是 yyyy-MM-dd HH:mm:ss VV,而 STRICT 下 yyyy 是 year-of-era,没有 era 就无法解析成日期,它对任何输入都抛。改成 uuuu 后,拦截归零(0/120),才是真实行为。完整因果链打印出来是三层:
DateTimeParseException: Text '2026-07-01 12:00:00 America/New_York' could not be parsed:
Unable to obtain ZonedDateTime from TemporalAccessor:
{YearOfEra=2026, DayOfMonth=1, MonthOfYear=7},ISO,America/New_York resolved to 12:00
<- DateTimeException: Unable to obtain ZonedDateTime from TemporalAccessor: … same fields …
<- DateTimeException: Unable to obtain LocalDate from TemporalAccessor: …
// 只取前两个字符判断会误以为是“时区非法”;最内层才是 year-of-era 缺 era
这条值得单独划一句:任何“检出率 100%”的校验,都必须先拿一个已知正常的样本做阴性对照;只看最外层消息也不行,要把 getCause() 一路追到最底层原因。
八、时区 ID 拼错和 setDefault 都不报错:8 个 ID 静默变 GMT,20/20 个静态格式化器永久冻结
先说 ID。我拿了 18 个“真实 ID + 长相接近的错误 ID”分别交给 TimeZone.getTimeZone(String) 和 ZoneId.of(String)。
| America/New_Yorkk(多一个 k) | GMT | 0 | 抛 DateTimeException | 静默变 GMT |
| america/New_York(小写开头) | GMT | 0 | 抛 | 静默变 GMT |
| America/NY、America/NewYork | GMT | 0 | 抛 | 静默变 GMT |
| Asia/Shangai(少一个 h) | GMT | 0 | 抛 | 静默变 GMT |
| Eastern | GMT | 0 | 抛 | 静默变 GMT |
| EST | EST | -5 | 抛 | 缩写只在旧 API 可用 |
| US/Eastern、EST5EDT | 原样 | -5 | 接受 | 旧别名两边都认 |
| Etc/GMT+5 | Etc/GMT+5 | -5 | 接受(-05:00) | 符号是反的 |
| Etc/GMT-5 | Etc/GMT-5 | +5 | 接受(+05:00) | 符号是反的 |
| GMT+8 | GMT+08:00 | +8 | 接受 | 仅 ID 被规范化 |
| 合计 18 个:旧 API 回读 ID 与输入不一致 9 个(其中硬落到 GMT 的 8 个、仅别名规范化的 1 个);ZoneId.of 抛异常 9 个。 | ||||
这里发生过第三次返工,必须记下来。我本想用“两个 API 解出的偏移是否相等”作为拼写检测手段,第一次跑出的结果是 same=9 diff=9,样本长这样:America/New_York old=-4h new=0h。纽约不可能是 UTC+0——这个物理上不可能的数字说明错在测台:TimeZone.getOffset(long) 返回的是毫秒,而 ZoneOffset.getTotalSeconds() 返回的是秒,我两侧用了同一个 /3600000 除数,把 java.time 一侧全部除成了 0。改成各自单位后实测 same=18 diff=0。
修完之后的结论反而更重要:偏移对拍完全查不出拼错时区,因为旧 API 已经把错误 ID 静默换成 GMT,两边都是 0,对拍自然一致。真正可用的判据只有一条:比较回读 ID。
TimeZone tz = TimeZone.getTimeZone(inputId);
if (!tz.getID().equals(inputId)) { // 唯一可靠判据
throw new IllegalStateException("unresolved zone id: " + inputId + " -> " + tz.getID());
}
// 或者一开始就用 ZoneId.of(inputId),让拼错直接抛异常;启动时跑一次,不要等到线上
接下来说说默认时区。很多团队靠单测里 TimeZone.setDefault(…) 隔离时区依赖,实测它对已创建的对象无效:
| 20 个预先 new 好的 SimpleDateFormat | 0/20 | 20/20 仍与最初输出一致 |
| 20 个预先 Calendar.getInstance() | 0/20(构造时 clone 了当时默认时区) | 同上 |
| 20 个切换后新建的 SimpleDateFormat | 20/20 变 | 20/20 变回 |
| 另一线程内新建的格式化器 | 看到新默认(跨线程可见,因为默认时区是进程级) | – |
| Instant.now() / ZoneId.systemDefault() | 前者不变(本来就是 UTC),后者立即变 LA | – |
所以:默认时区是进程级开关,不是线程级;而任何“在静态字段里初始化一次”的格式化器会永久停留在类加载那一刻的默认时区,setDefault 再改回来也救不回来。实测里那 20 个预建实例在两次切换之后仍输出 2026-11-01 21:30:00 CST,即它们从始至终用的都是最初的 Asia/Shanghai。
九、Agent 工程里的两个真实踩点:360 次请求被压成 240 个幂等键
时区问题到 Agent 工程上并不新鲜,但后果更直接:工具调用的去重键、会话 TTL、“最近 N 分钟”的上下文窗口、喂给模型的当前时间串,全部建在时间上。我拿幂等键做了个最小验证:以 30 秒为间隔产生 360 个请求,时间轴恰好跨过 2026-11-01 的回拨小时,分别用三种方式构键。
| 本地时间串 yyyyMMdd_HHmmss | 360 | 240 | 120 | 120 | 1 |
| epoch 毫秒 | 360 | 360 | 0 | 0 | 未单独测(键本身即瞬间) |
| 本地时间 + 偏移 ID(如 01:30-04:00 / 01:30-05:00) | 360 | 360 | 0 | 0 | 未单独测 |
3 小时真实时长只对应 2 小时本地墙钟,因此重叠半小时里的每个 30 秒槽位都被用了两次,120 个键仍重复。对一个“同一小时内同一工具+同一参数不重跑”的 Agent 工具锁来说,这就是直接的漏执行:两次真实不同的调用在锁看来是同一个。另外字典序只出现 1 次倒置(跳回的那一刻),但一次就足够把“取最大时间戳作为最新状态”的合并逻辑带错。
排序侧实测到的一个具体现象:把回拨前后 3 小时内的瞬间按顺序排出来,本地时间轴不单调,会出现 2026-11-01T01:45-04:00 后面跟 2026-11-01T01:00-05:00;而 01:30 这个本地时间串在 3 小时窗口里出现 2 次。如果你把工具调用 trace 按本地时间串排序做回放,重叠小时内的先后关系是反的。
// 幂等键:把“瞬间”拼进键里,而不是把本地时间串拼进键里
String keyVulnerable = toolName + "@" + LocalDateTime.now(ZoneId.of("America/New_York"))
.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); // 实测 120/360 撞
String keySafe = toolName + "@" + Instant.now().toEpochMilli(); // 实测 0/360 撞
ZonedDateTime z = LocalDateTime.now(ZoneId.of("America/New_York")).atZone(ZoneId.of("America/New_York"));
String keyReadable = toolName + "@" + z.toLocalDateTime() + z.getOffset().getId(); // 可读且不撞,实测 0/360
还有一个组合坑,把第八节和第六节叠起来看就明白了:如果服务里有一个 static final DateTimeFormatter 在类加载时绑定了默认时区(实测 20/20 个预建格式化器不随 setDefault 改变),那么它发出的时间串与另一侧用 java.time 现取现算的串,在重叠小时内会差整 1 小时;同一套日志里两套时间戳、两个幂等键空间,查问题时对不上。这类问题不在任何单测里出现,因为每个 API 自己都是自洽的。
十、适用边界与风险提示
主动说清楚哪些结论只在什么范围内成立,以及哪些我根本没做。
- 环境边界:本文所有数字来自单机单进程、当前目录、只执行 javac/java 的本机 JDK 17.0.18。未做跨版本对拍(本机只有一套 JDK),因此不能声称 JDK 8/11/21 会得到同样的切换年表。
- 数据边界:“下一次切换”只实测到 nextTransition() 返回的一步(最早 2027-03-14)。再往后的日期是 tzdb 按规则推算,不是本文实测;tzdb 本身会随安全更新变化,旧 JDK 可能不认识最新调整。
- “永久夏令时”那一列不是预测:第四节里写死 -04:00 的 127/128 天,只是“把制度变化简化成一个固定偏移”的算术对照,不构成对《阳光保护法案》结果的任何判断。
- 未验证的调度框架:Quartz、Spring @Scheduled、XXL-JOB、Cron4j 各自的时区语义本文一律未测。第六节的两种步进方式是我手写的最小实现,只能证明“这两种语义会给出不同结果”,不能推断某个框架内置行为。
- 未验证的系统层:NTP 跳变、容器里的 TZ 环境变量、/usr/share/zoneinfo 与 JDK 内置 tzdb 不同步、updatetz 类补丁场景,均未实测。
- 未验证的并发边界:第八节只做了“另一个线程里新建的格式化器能看到新默认时区”的单点验证,不构成任何线程安全结论,也没做 setDefault 竞态压测。
- 不要直接引用为线上指标:第九节的 360/240/120 是“用本地时间串做键 + 30 秒固定间隔”这一构造选择的直接推论;真实线上的撞键率取决于到达节奏、窗口长度与键粒度。
- 风险与回退:把 atZone 改成 ofStrict 会把空洞时刻从“静默后搬”变成“抛异常”。对计费、风控、证书类有效期逻辑来说这是行为变更,建议先只加日志不抛错,在灰度环境观察一个完整切换周期(3 月与 11 月各一次)后再收紧。
- 热点引用口径:文中两个外部日期(美国 2026-11-01 结束、2027-03-14 开始)来自网易新闻 2026-11-02 与腾讯新闻 2026-11-01 的报道,并由本机 tzdb 独立复核一遍;欧洲 2026-10-25 同样是本机实测与公开报道相互印证。众议院投票 308 对 117 是报道里的数字,本文未向第一方立法网站核实,不作为自己核实过的数据使用。
AI 辅助说明:实验程序与全部数据均在本机实跑,文中每个数字都能在第十二节的命令下复现得出;未填充任何估算或虚构的实验结果。选题与行文由作者整理,热点部分仅引用公开报道并已标注来源。
十一、完整复现:两条命令与六条判定标准
实验程是一个单文件 TzProbe.java,798 行、95 个输出行,不依赖任何外部包与测试材料:所有时间数据由 java.time 自己生成,所有循环在同一个 JVM 里完成,输出 192 行。它只写当前工作目录,不动 JDK 安装目录与系统配置。
javac -encoding UTF-8 -d out exp/TzProbe.java
java -cp out TzProbe
预期输出里必须出现下面六组数字。它们不依赖 CPU、内存与负载(程序里没有任何计时项参与结论),因此可以当回归断言用。
| ① E1 TOTAL minutes=6307200 gapMin=510 overlapMin=510 且 E1 SELF-ASSERT-OK | 6,307,200 / 510 / 510 | 年份常量是不是还是 2026;时区列表是不是漏了 12 个 |
| ② E1 表里 New_York 的 getTransitionsLastYear | 2008 | 这是 API 语义而非数据错误;如果你把它当成“没有切换”,就重现了本文的第二次返工 |
| ③ E5e 两行 | gap agree=60 disagree=0;overlap agree=0 disagree=60 | 你的默认时区不是 Asia/Shanghai 不影响(程内显式设了时区),但 JDK tzdb 版本不同会变 |
| ④ E8a 六行检出数 | V1 0|0,V2 60|0,V3 60|60,V4 60|60,V5 0|0,V6 60|0 | V5 如果全是“拦住”,先看 STRICT+yyyy 那条对照行是不是也抛了——那就是第三个坑 |
| ⑤ old-vs-new offset agreement | same=18 diff=0 | 若出现 same=9 diff=9 且样本里有 new=0h,说明你在 getTotalSeconds() 上除了 3600000 |
| ⑥ 三次复跑 diff | 除实时钟那一行外逐字节一致(190 行相同) | 任何统计列不一致都意味着测台有跨场景污染,先拆成单场景重跑 |
复现时两个环境细节需要注意。一是输出编码:本机 file.encoding 是 GBK,而工具链往往按 UTF-8 解码,所以程内一律只输出 ASCII(包括时区名与 ISO 偏移串),中文只在本文里写。二是年份相关数字的“新鲜度”:如果把年份改成 2027,第四节的 238 会变(因为 3 月 14 日与 11 月 7 日之间的天数变了),这不是 bug。
十二、结论:三条可记住的判断,一个自检动作
第一条:调钟影响的不是“两天”,而是“大半年”。把偏移写死成 -05:00 在纽约每年错 238 天(占 65.2%);固定 24 小时步进排期每年偏 238 天;而 23 小时日与 25 小时日在“全年总时长”这一项上恰好抵消(实测 31,536,000 与 365×86400 完全相等)——这就是为什么年度健检发现不了它。
第二条:重叠小时内,JDK 两代 API 系规律地差整 1 小时。60 个空洞分钟两边 100% 一致(都后搬),60 个重叠分钟两边 0% 一致。它不会在任何单一 API 的自洽单测里出现,只会在“旧代码与新代码共存”、“跳服务传时间串”时响。
第三条:校验层只有显式问规则才能全覆盖。120 个异常分钟上,getValidOffsets().size() 与 getTransition() != null 是 120/120;ofStrict 与 setLenient(false) 只拦住空洞那一半(60/120);ResolverStyle.STRICT 与 atZone 一样,0/120。
建议今天就做的自检动作(5 分钟):在服务启动时加两行——第一行用 ZoneId.of(configuredZoneId) 解析配置里的时区并打印回读值,第二行用 getValidOffsets() 把下一个切换日前后 48 小时内的异常分钟扫出来打一条 INFO。然后对比你手上的 cron 表达式:如果它们落在打印出来的分钟里,就已经确认了风险。
两个问题想听听你们的做法:第一,你们服务里“按天”的计费、限流或上下文窗口,到底存的是 Instant 还是 LocalDateTime?夏令时切换那天它应该跑两次还是一次,代码里有注释写明吗?第二,你有看过自己那套 JDK 里 getTransitions() 的最后一年吗?如果运维脚本依赖它,今年 11 月 1 日会漏掉哪些任务?
本文的六张实测表(异常分钟分布、错钟天数、两 API 分歧、颗粒度丢槽、六种校验覆盖率、时区 ID 静默清单)适合直接收到代码评审清单里,下次碰到“11 月任务没跑”类工单时不必重新跑一遍实验。
网硕互联帮助中心







评论前必须登录!
注册