凌晨2点47分,监控群突然连续弹出告警。我打开手机看了一眼,心里“咯噔”一下:订单跑批的批次号变成了20210101,可这会明明是2022年1月1日。电话接着响起来,同事的声音带着点慌:“线上对账文件全乱了,今天的批次和去年的重叠了。”
我当时的反应和大多数人一样:第一怀疑服务器时钟漂移,第二怀疑数据库时区配错,第三怀疑是不是跨年导致某个调度任务重复触发。排查了半个多小时,最后发现“凶手”是一个再常见不过的写法——SimpleDateFormat里用了"YYYY-MM-dd",大写的 Y。Java 的日期格式化,YYYY和yyyy只差一个字母,语义却完全不同。这个坑每年年底和年初都会炸一次,而且炸得又准又狠。
这篇文章不打算讲太多理论,就把这次事故的完整复盘过程写下来:现象、根因、排查链路、修复方案,以及我把YYYY挡在代码库之外的整套防御手段。如果你是写 Java 的,哪怕现在项目里还没有踩到,我也建议你顺手全局搜一下YYYY。大概率,能搜出几条不该出现的东西。
1. 事故现场:凌晨3点的告警,和一批“穿越”的批次号
1.1 报警内容与第一反应
事发时间非常典型:2022年1月1日凌晨2点47分。为什么这么典型,后面细说。当时的现象有三条:
- 订单服务的对账批次号生成了
BATCH-20210101-xxx,而正确批次号应该是BATCH-20220101-xxx。 - 日志里所有
create_time字段都打印成了2021-01-01 00:00:00,但实际业务时间已经是2022-01-01 00:00:00。 - 凌晨的报表任务读取不到数据,因为查询条件里写的日期分区是
2022-01-01,而数据落库时时间戳被格式化成了去年。
第一和第二点摆在一起,特别迷惑人:服务器时间已经正确显示2022年,但应用打印出来的时间却是2021年。用 DBA 跑了一下数据库,SELECT NOW()返回2022-01-01 02:50:00,也是对的。于是我把怀疑重点放到了应用层和 JDBC 参数上。
我当时的排查顺序,说实话是走了弯路的:先检查了 NTP 服务,timedatectl显示时间正确且已同步;再查 MySQL 连接串,serverTimezone参数配的是Asia/Shanghai,也没问题;最后干脆重启了几个服务节点,结果问题依旧。这时候我才意识到,问题大概率不是“时间错了”,而是某个时间在我看不见的地方,被人为地“改写了”。
1.2 最初排查走的弯路,和一条关键线索
重启都解决不了,说明问题在代码里,而不是环境变量或者操作系统层面。为了缩小范围,我做了个粗暴但有效的对比:把所有打印时间的服务列出来,看哪些模块正常、哪些模块异常。结果发现,报表服务、对账服务、导出服务全都中招,而用户前台展示订单创建时间的接口却是正常的。
这个分布很有意思。同样一套服务集群,为什么有的格式化对,有的不对?我立刻意识到问题不在时钟,而在“日期格式化字符串”上。于是不再徒手猜了,直接在代码库里搜格式化相关的调用。
结果很快就出来了:异常服务里用的是一个公共工具类,里面写的是:
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("YYYY-MM-dd HH:mm:ss");而正常服务用的是:
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");一个字母之差,一个服务正常,一个服务炸了一整晚。
多说一句,看到这里,如果你觉得“那我把 Y 改成 y 不就好了”,那这事故复盘才刚开了个头。真正要注意的是,这个大写 Y 到底是哪来的、为什么跨年才会爆、以及怎么保证它以后不再混进代码里。
2. 根因解剖:Y是周基准年,y才是日历年份
2.1 只差一个字母,语义天差地别
Java 的日期格式化模式字母非常多,常见的有:
| 字母 | 含义 | 示例 |
|---|---|---|
y | 日历年份,也就是我们平时说的“年份” | 2022 |
Y | 周基准年(Week-based year) | 按 ISO-8601 周编号计算出的年份 |
M | 月 | 01-12 |
d | 月中的日 | 01-31 |
w | 周基准年的周数 | 01-53 |
W | 月中的周数 | 1-5 |
D | 年中的天数 | 001-366 |
E | 星期 | 周一、Tue |
其中最容易踩坑的就是y和Y。y是日历年份,YYYY-MM-dd和yyyy-MM-dd在绝大多数日子里输出完全一样。但Y用的是“周基准年”,它不跟日历年的1月1日对齐,而是跟 ISO-8601 周编号系统对齐。
为什么 Java 要提供这种模式?因为它不是 Java 自己发明的,而是来自 Unicode 通用区域设置数据(CLDR)。在一些需要按周和财务周做统计的场景里,比如“第53周属于哪一年”“这个周五算不算下个财年”,Y就有用途了。问题是,绝大多数业务系统根本不需要周基准年,大家就是想要个普通的日历年。
所以真相是:YYYY本身没错,错的是你在不需要用它的时候,把它当成了yyyy。
我在复盘时看到代码历史记录,那个公共工具类是两年前从某个开源项目的模板里复制来的,原模板里写的居然是"YYYY-MM-dd HH:mm:ss"。复制的人以为它就是“年-月-日”的标准写法,从此这个雷就埋下了。这大概也是这个 bug 每年高频出现的原因:搜索引擎一搜,一排模板都是大写的 Y,抄起来特别顺手。
2.2 ISO-8601 周编号规则,和那个“幽灵年份”
要搞清楚Y为什么在跨年会翻车,必须先理解 ISO-8601 的周编号规则。这个规则其实很简单,就三条:
- 每周从周一开始,到周日结束。
- 每年的第1周,是包含该年1月4日的那个周。
- 等价地看,哪个年份拥有该周的星期四,这个周就属于哪个年份。
第3条是最好用的判断方式。我排查问题的时候不需要背1月4日的规则,只需要看“这周的星期四在哪一年”。
举一个当时炸雷的实例:2022年1月1日是星期六,它所在的周是2021年12月27日(周一)到2022年1月2日(周日)。这一周的星期四是2021年12月30日,星期四落在2021年,所以按照 ISO 规则,这一整周都属于“2021年的第52周”。那么这一周里所有日期的周基准年都是2021。用yyyy格式化,2022年1月1日输出的年份是2022;用YYYY格式化,输出的年份却是2021。
这就是我遇到的“日期倒退一年”现象。
反过来,另一个经典案例是2018年12月31日。这一天是周一,所在的周是2018年12月31日到2019年1月6日。这一周的星期四是2019年1月3日,所以这一整周被划归“2019年的第1周”。用YYYY格式化2018年12月31日,你会得到2019-12-31。
你品品这个操作:明明是2018年的最后一天,结果打印出来成了明年的今天。日志文件、批次号、报表分区,全部跟着错。
为了理解方便,可以这样打比方:yyyy是“自然年的年龄”,YYYY是“按周数算的年龄”。元旦前夜出生的孩子,按周基准年算,可能已经被记到下一年;元旦后两天出生的孩子,如果那一周还挂在上一年的账上,按周基准年算,他又会被记回上一年。这就像有些人过农历生日和公历生日,会出现在两个年份拉扯的情况。
2.3 危险窗口一览:到底哪些日期会翻车
网上很多文章喜欢举某个具体的日期作为例子,但实际排查时,你需要的是一张“危险日期表”。我把历史上容易踩坑的日期整理了一下:
| 日期 | 星期 | 所在ISO周 | yyyy输出 | YYYY输出 | 是否翻车 |
|---|---|---|---|---|---|
| 2018-12-30 | 周日 | 2018-W52 | 2018 | 2018 | 正常 |
| 2018-12-31 | 周一 | 2019-W01 | 2018 | 2019 | 翻车 |
| 2019-01-01 | 周二 | 2019-W01 | 2019 | 2019 | 正常 |
| 2019-12-30 | 周一 | 2020-W01 | 2019 | 2020 | 翻车 |
| 2019-12-31 | 周二 | 2020-W01 | 2019 | 2020 | 翻车 |
| 2020-01-01 | 周三 | 2020-W01 | 2020 | 2020 | 正常 |
| 2021-12-31 | 周五 | 2021-W52 | 2021 | 2021 | 正常 |
| 2022-01-01 | 周六 | 2021-W52 | 2022 | 2021 | 翻车 |
| 2022-01-02 | 周日 | 2021-W52 | 2022 | 2021 | 翻车 |
| 2024-12-30 | 周一 | 2025-W01 | 2024 | 2025 | 翻车 |
| 2024-12-31 | 周二 | 2025-W01 | 2024 | 2025 | 翻车 |
| 2025-01-01 | 周三 | 2025-W01 | 2025 | 2025 | 正常 |
| 2025-12-31 | 周三 | 2026-W01 | 2025 | 2026 | 翻车 |
看了这个表你会发现一个规律:不是每年12月31日都会翻车,也不是每年1月1日都会翻车。翻车窗口完全取决于“元旦所在的那个 ISO 周”到底被划给了哪一年。
如果元旦落在周一到周四,元旦所在的周通常属于新年第一周,那么上一年的周一、周二这类紧贴元旦的工作日反而会被划到新年里,比如2018-12-31、2019-12-30、2024-12-30。如果元旦落在周五到周日,元旦本身可能还待在上一年的最后一周,于是2022年1月1日、1月2日会被格式化成上一年。
最稳妥的做法是:凡是代码里用了YYYY,就把去年12月26日到今年1月8日这14天全部拉出来测一遍。这个窗口足够覆盖所有可能的错位情况。
3. 全链路排查:从一行格式化代码挖到全线告警
3.1 顺着调用链找到事故源头
告警是从对账服务报出来的,但我没有只修对账服务,而是顺着调用链往外摸了一圈。当时的定位链条是这样的:
- 告警信息:
Duplicate batch no: BATCH-20210101-xxx。 - 顺着批次号生成方法找到
BatchService.generateBatchNo()。 - 发现方法里调用了公共工具类
DateUtils.formatNow()。 - 点进
DateUtils,看到大写的YYYY。 - 全局搜索
YYYY在代码库里出现的次数,结果有37处。这个数字当时让我后背发凉。
这里分享一个实用的排查命令。如果你也要查历史代码,可以用 Git 定位这个错误模式是什么时候引入的:
git log -p -S "YYYY-MM-dd" --format="%h %an %ad %s" --date=short-S参数会找出所有提交中“YYYY-MM-dd”这个字符串出现或消失的变更记录。我就是靠着这个命令,找到了两年前一个叫“优化日期工具类”的提交,然后把当时的提交人和代码上下文调出来,复盘才有了实锤。
另外,搜索时不要只搜"YYYY",要连DateTimeFormatter一起搜:
grep -RIn --include='*.java' -E 'DateTimeFormatter\.ofPattern\("[^"]*Y[^"]*"'因为 Java 8 之后很多人已经不用SimpleDateFormat了,但DateTimeFormatter.ofPattern("YYYY-MM-dd")一样中招。DateTimeFormatter并不会因为你换了新 API 就自动帮你修正语义。
3.2 影响面有多大:日志、分区、流水、导出文件
这次事故里,YYYY的影响比最开始看到的“批次号重复”大得多。我把问题面梳理出来之后,发现几乎所有的日期落盘和日期命名场景都出了问题:
| 影响对象 | 错误表现 | 严重程度 |
|---|---|---|
| 日志和ES索引 | 时间戳被写成去年,按日索引的数据进错分区 | 高 |
| 对账批次号 | 批次号与去年撞号,触发幂等拦截 | 高 |
| 报表导出文件名 | 生成20210101_report.xlsx,覆盖去年文件 | 高 |
| 定时任务目录 | 按日期创建目录,任务跑到了去年的目录下 | 中 |
| 数据库分区表 | 若用日期后缀做分区,数据写进错误分区 | 高 |
| 用户端展示 | 部分接口时间显示成去年 | 中 |
最坑的是,这些问题在12月31日当天屁事没有。因为12月31日的日历年份和周基准年在大部分年份是一致的,直到元旦前后的那几个特定窗口期才会闪爆。我们平时的测试数据、灰度环境、联调环境根本不会在每年的那几个小时里去跑一遍,所以这种问题特别容易带着debuff上线。
还有一个容易被漏掉的点:logback 的日志滚动文件名。logback.xml里的时间戳模式同样支持YYYY:
<fileNamePattern>/logs/app-%d{YYYY-MM-dd}.log</fileNamePattern>只要这里用了大写Y,跨年窗口期产生的日志文件就会落到去年的文件名里,切割、归档、采集全部错乱。这次排查最后,我在 logback 配置里也揪出一处。很多开发只知道 Java 代码里别用YYYY,却没意识到配置文件里的日期格式同样会踩坑。
3.3 修复与验证:不是把Y换成y就完事了
修复的第一步当然是把所有YYYY替换成yyyy。但我强烈建议不要直接上一句sed -i 's/YYYY/yyyy/g',因为你可能误伤真正需要YYYY的场景,比如周报、ISO 周统计功能。哪怕理论上不需要,也要先通过全局搜索结果人工确认一遍。
我当时在 IntelliJ IDEA 里用全局替换,然后逐个 diff 审查,确认没有任何地方是故意用周基准年的,才正式提交。替换后,关键的回归测试长这样:
class DatePatternGuardTest { @Test void 跨年日期不应被格式化成相邻年份() { List<String> boundaryDates = List.of( "2018-12-31", "2019-12-30", "2019-12-31", "2021-12-31", "2022-01-01", "2022-01-02", "2024-12-30", "2024-12-31", "2025-12-31" ); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); for (String dateStr : boundaryDates) { LocalDate date = LocalDate.parse(dateStr); String formatted = date.format(formatter); int realYear = date.getYear(); int formattedYear = Integer.parseInt(formatted.substring(0, 4)); org.junit.jupiter.api.Assertions.assertEquals( realYear, formattedYear, dateStr + " 的年份被格式化为 " + formattedYear + ",而不是 " + realYear ); } } }这个测试跑起来之后,我还顺手检查了 logback 配置里残留的yyyy是否真的救回来了。修复上线后,我让运维把系统时间“伪装”成元旦附近的几个日期模拟跑批,确认批次号从20220101重新生成了,才把告警彻底关闭。
这里有一个提醒:不要以为有DateTimeFormatter就万事大吉。DateTimeFormatter同样接受YYYY作为周基准年模式。换成新 API 的目的是线程安全和更好的解析行为,但格式字符串的模式字母语义没有变。
4. 防御体系:规约、扫描、测试三板斧
4.1 编码阶段能卡住的检查点
事故处理的最后一个阶段,是让YYYY从“靠人记”变成“靠机制拦”。
最原始也最直接的手段,就是在 Code Review 阶段全局搜一下。我后来在工程标准里定了一条规矩:任何改动涉及日期格式化,都在 commit 描述里标注“已检查 yyyy/YYYY”。但这毕竟靠自觉,效果有限。更靠谱的做法是在团队 commit template 里加一个勾选项:
- 已确认所有日期模式使用小写
yyyy,未使用YYYY。
有人会觉得,这不就是让人多一步操作吗?对,操作多了,出错的概率就低了。很多 bug 不是不知道,而是“没想起来”。
另一个很实用的点是 IDE 设置。IntelliJ IDEA 默认对YYYY其实有弱提示,但不够明显。我一般会让团队在 IDE 的 Inspection 配置里把包含YYYY的日期格式化模式调成 Error 级别。虽然它不能保证第一次写代码时就拦住,但至少打开文件就会出现红线提示,效果比事后 review 更好。
4.2 把YYYY挡在CI之外
人不是最可靠的,但流水线是。我们可以直接在 CI 脚本里加一道扫描,发现大写YYYY就直接让构建失败。这是一条很简单但是特别有效的规则:
#!/usr/bin/env bash # 防止 YYYY 混入生产代码 violations=$(grep -RIn --include='*.java' -E '"(YYYY[^"]*)"|DateTimeFormatter\.ofPattern\("[^"]*Y[^"]*"' .) if [ -n "$violations" ]; then echo "发现疑似 YYYY 日期格式,请改为 yyyy:" echo "$violations" exit 1 fi这里要处理误报问题。有些代码可能真的需要周基准年,比如财务系统里按 ISO 周统计的报表。对这种合法场景,我建议做两件事:
- 把合法用法收敛到单独的、显式命名为
weekBasedYearFormatter的常量上。 - 在 CI 脚本里加一个白名单机制,比如允许包含
WeekBasedYear或ISO_WEEK的文件通过。
这样设计的好处是,误报可以被白名单处理,而不是直接关掉整条检查。关掉检查等于回到全靠自律的时代,不出事则已,出事就是大事。
4.3 把跨年窗口写进单元测试
除了 CI 里挡YYYY,我还推荐一种“反向防御”:不管代码里怎么写,只要统一的日期工具类在跨年窗口期输出错误,测试就必须失败。
具体做法是写一个测试,覆盖每年的危险日期段。最简单粗暴的方式是把近十年的跨年日期都列出来:
class YearBoundaryTest { @Test void 所有跨年边界日期都应该保持日历年份一致() { List<LocalDate> dangerZone = List.of( LocalDate.of(2018, 12, 30), LocalDate.of(2018, 12, 31), LocalDate.of(2019, 12, 30), LocalDate.of(2019, 12, 31), LocalDate.of(2021, 12, 31), LocalDate.of(2022, 1, 1), LocalDate.of(2022, 1, 2), LocalDate.of(2024, 12, 30), LocalDate.of(2024, 12, 31), LocalDate.of(2025, 12, 31) ); DateTimeFormatter formatter = DateUtils.DATE_FORMATTER; for (LocalDate date : dangerZone) { String value = date.format(formatter); String realYear = String.valueOf(date.getYear()); org.junit.jupiter.api.Assertions.assertTrue( value.startsWith(realYear), date + " 被格式化为 " + value + ",期望以 " + realYear + " 开头" ); } } }有人可能觉得,这不是专门为YYYY写的测试吗,等彻底禁用了YYYY不就可以删了吗?我的建议是不删。因为虽然现在代码库里禁止了YYYY,但团队会流动、模板可能更新、第三方库可能引入新的格式化方式。保留这个边界测试,可以保证以后无论谁重新引入这种错误,都会在测试阶段炸出来。
4.4 规范与知识沉淀:写进wiki的小事
处理完技术问题后,我建议团队里做一次“模板清理”。这比写一篇 wik 文档更实际。
我当时的做法是这样的:把工程模板里所有SimpleDateFormat和DateTimeFormatter的示例全部改成,统一用yyyy-MM-dd HH:mm:ss。这一步很关键,因为很多人根本不会去读规范文档,但会直接复制项目模板里的代码。模板对了,大多数人就对了。
然后在团队 wiki 里挂了一个极简速查表:
| 格式串 | 真实含义 | 推荐度 |
|---|---|---|
yyyy-MM-dd | 日历年的年月日 | 推荐 |
YYYY-MM-dd | 周基准年的年月日,跨年附近会错位 | 禁止 |
MM/dd/yyyy | 美式日期,有歧义 | 不推荐 |
dd-MMM-yy | 英文缩写月份和两位数年份 | 不推荐 |
yyyy-MM-dd HH:mm:ss | 日期+时间 | 常用 |
新同事入职时,我不要求他背下整张字母表,只要求他记住一条原则:“日期格式字母,小写优先。拿不准的时候,打开文档查,不要凭记忆写。”
我见过太多代码是凭记忆写的了。尤其是日期格式这种没有编译器强约束、平时又很难触发错误场景的东西,一旦写错,容易闷声埋雷。
5. 一次复盘后,我留给自己的排查清单
事故平息之后,我把这次的排查过程沉淀成了一份个人习惯清单,每次遇到时间相关的问题都会先过一遍:
排查时间问题,不要第一时间怀疑服务器时钟。先做对比,看看哪些模块正常、哪些模块异常,缩小范围再去碰环境配置。
看到“格式化”、“时间戳”、“pattern”这类词,优先检查字母大小写。y和Y、M和m、d和D,都是不同的意思。
跨年前后对线上保持敏感。每年12月26日到1月8日期间,如果日志文件名、批次号、报表数据出现“重了”或者“没了”的诡异现象,先想周基准年错位。
做日期相关的单元测试,不要只用今天或任意工作日,要把边界日期显式放进去。连续跑14天跨年窗口,比什么都管用。
无论用SimpleDateFormat还是DateTimeFormatter,日期格式统一由工具类提供,代码里不要到处 new。否则将来需要改一处规范时,全局搜索能搜出一百处散落的格式化代码。
这次事故本身不复杂,复杂的是它在跨年那个特殊时间点,把所有时间戳相关的模块同时带崩。我后来也养成了一个肌肉记忆:写日期格式化时,先低头看一眼键盘上的 Y 是大写还是小写。这听起来很笨,但确实很管用。