在近期的项目中,我们注意到表格解析服务在处理部分表格里面的时间格式数据时,解析结果与原始数据存在不一致的现象,这显然是不正常的。接下来我们就分析下是什么问题。
问题排查
关键证据
发现解析Excel的时候,某些Excel里面的列是Time格式,数据只有时间,但是解析出来之后的时间不是源表格的时间:
A列 解析前 | A列解析后 |
---|---|
20:25:39 | 20:31:22 |
22:28:24 | 22:31:04 |
22:25:29 | 22:31:00 |
经过排查发现,bug反馈的截图给错了指引,给到的是A列的解析结果与B列对比,上面表格A列解析后实际上是另一列的解析后数据,导致发现不了规律。
经过重现问题,得到如下的解析前后对比:
A列 解析前 | A列解析后 |
---|---|
20:25:39 | 20:19:56 |
22:25:29 | 22:19:46 |
22:28:24 | 22:22:41 |
可以发现,时间稳定的查了5分43秒。这很容易让人联想到是系统时区问题,但是哪有5分43秒的差别,时区不都是隔着一个小时的吗?
我们到Time Zone Database网站上下载下时区数据库来看看有没有蛛丝马迹。结果搜到了如下内容:
1 | # Zone NAME STDOFF RULES FORMAT [UNTIL] |
上海在1901年之前使用的是 LMT(Local Mean Time 当地平均时间)(8:05:43)
看了是历史的时区问题,旧中国的时区划分比较复杂,这里就不细说了,反正在1901年之前,上海的时区offset 是 +08:05:43
。
但是我表格里面写的不是1901年的日期啊,直提供了时间,没有日期。
跟踪线索
我们进一步看看,EasyExcel是如何处理的,跟到底层代码,发下有如下逻辑:
com.alibaba.excel.util.DateUtils:
继续往下跟,发现了如下逻辑:
org.apache.poi.ss.usermodel.DateUtil:
getLocalDateTime(date, use1904windowing)
会返回一个 本地时间字段(比如 1899-12-31T20:00:00
)
看到这里,问题已经很明显了,表格解析工具把我们的时间转为了1899年的时间,最终时间解析出来的时候,做了时区转换,减去了这个5分43秒。
这个转换逻辑是Java的日期系统处理的:
Java 的日期/时间 API 在“绑定时区”这一步应用了时区数据库里记录的历史偏移规则。
- 当你拿到一个
LocalDateTime ldt = getLocalDateTime(...)
(比如1899-12-31T20:00:00
),它只是一个不带时区的本地时间字段,JVM 本身并不会在这时“扣”任何东西。 - 你调用
ldt.atZone(ZoneId.systemDefault())
,这只是在给这段本地时间“打上”一个时区标签,并根据该时区在那一天的历史 UTC 偏移,来计算它对应的ZonedDateTime
。 - 如果你随后把它转成
Instant
或者java.util.Date
(本质上都是 UTC 时间戳),Java 会用 zone rules 把带有+08:05:43
(1899-12-31 中国当地的历史偏移)的本地时间转换成 UTC,从而在数值上“少掉”那 5 分 43 秒。
所以,当调用
1 | LocalDateTime ldt = getLocalDateTime(...); |
ldt
的时分秒字段并不会被修改,它仍然是 20:00:00
。
只是 zdt
会带上一个 历史偏移,比如在 Asia/Shanghai 时区,1899-12-31 的 offset 是 +08:05:43
(现代是 +08:00
)。
如果再把 zdt
转成 Instant
(或 toEpochMilli()
),Java 会用那个历史 offset 计算,等价于 “把本地时间减去 05:43” 之后再归入 UTC 时间戳。
所以,扣减那几分钟 的时刻其实发生在“ZonedDateTime
→ Instant
”或“内部用历史 zone offset 解释本地时间”这一步。
到这里,真相大白了。
问题总结
是POI的问题吗,看起来也不是。POI 其实是按照设计在做的事情:
- Excel 的时间存储机制:
- 无论单元格只显示 “Time”,Excel 内部都是一个以“1899-12-31”为基准、带小数部分的浮点序号。
- POI 的转换逻辑:
DateUtil.getJavaDate(double date, …)
会把这个序号转成java.util.Date
,并且在内部用TimeZone.getDefault()
(你的 JVM 默认时区)来解释“基准日 + 天数”。
- Java 时区历史数据:
TimeZone.getDefault()
拿到的Asia/Shanghai
时区规则里,1899-12-31 这一天相对于 UTC 有 +5m43s 的历史偏移。- 因此,POI 在“加上天数”后,会在计算 UTC 时间戳时自动“扣掉”这 5 分 43 秒。
三者结合,就造就了你看到的“所有纯时间都被往前推了 5m43s”现象。
如何避免这段历史偏移
如果是自己写代码,如果你只是想要时间,不要把纯时间当带日期的 LocalDateTime 去 atZone,如果你只想要“20:00:00”,直接用 LocalTime
或者自己算秒数构造,不要做 atZone(systemDefault())
。
如果一定要用带日期的 ZonedDateTime
,可以显式用 ZoneOffset.UTC
(或其他固定偏移)来替代 systemDefault()
,这样就不会再应用旧时区规则。
这样就能彻底规避那段 5 分 43 秒的历史偏差。
当然,如果使用EasyExcel,并没有参数可以控制时区,要么调整全局时区,但是这会影响整个服务,要么自定义转换器去实现,这需要定义好解析的格式,在对应字段中指定使用转换器。如果默认时区设置未GMT+8不影响服务其他功能,那么直接设置就可以了:
1 | TimeZone.setDefault(TimeZone.getTimeZone("UTC+8")); |
这行代码可以加到Application类的main方法里面,我们打开这文件,把这行代码复制进去…等等,这里似乎发现了点什么:
1 | public class ApiApplication { |
是谁在这里设置为了Asia/Shanghai
时区,原来JVM之所以会拿老上海的时区,都是英文这里设置了…
现在,把这个时区替换成UTC+8,重启启动,正常了。
为何用 “GMT+8” 而不选 “Asia/Shanghai”
Asia/Shanghai
含有历史上 1899–1901 年的平太阳时偏移(08:05:43),会导致 Excel 转换时扣分钟。GMT+8
(或UTC+08:00
)是纯偏移,从不带任何历史数据,最安全地映射“本地时”而不引入老时区差异。
当然,如果你有一个历史上老上海的时间点,用的是Asia/Shanghai 8:05:43 - LMT 1901
这个时区,name你可以选用Asia/Shanghai
,能够转为当前的时间体系:
1 | // 假设你有一个旧的本地时间(1899-12-31T20:00:00) |
要么更省事的办法,让用户把这列的格式改为字符,当然,用户可能不会配合,而是会看看其他软件能不能直接支持。
另外,最近一个同事在排查另一个问题,接口接收json参数,jackson自动格式化时间,没有指定时区,也是导致了拿取到了非期望的时间,导致查询数据时间区间出现了问题,解决方法也是类似:
1 | spring: |
其实,如果系统里面都统一用long类型的时间戳,可以避免很多此类时区的问题。