破解Excel时间偏移5分43秒:LMT历史时区解析与Java/EasyExcel解决

发布于 2025-07-18 | 更新于 2025-07-18

image-20250718083622761

在近期的项目中,我们注意到表格解析服务在处理部分表格里面的时间格式数据时,解析结果与原始数据存在不一致的现象,这显然是不正常的。接下来我们就分析下是什么问题。

问题排查

关键证据

发现解析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
2
3
4
5
6
7
8
9
10
# Zone	NAME		STDOFF	RULES	FORMAT	[UNTIL]
# Beijing time, used throughout China; represented by Shanghai.
#STDOFF 8:05:43.2
Zone Asia/Shanghai 8:05:43 - LMT 1901
8:00 Shang C%sT 1949 May 28
8:00 PRC C%sT
# Xinjiang time, used by many in western China; represented by Ürümqi / Ürümchi
# / Wulumuqi. (Please use Asia/Shanghai if you prefer Beijing time.)
Zone Asia/Urumqi 5:50:20 - LMT 1928
6:00 - %z

上海在1901年之前使用的是 LMT(Local Mean Time 当地平均时间)(8:05:43)

看了是历史的时区问题,旧中国的时区划分比较复杂,这里就不细说了,反正在1901年之前,上海的时区offset 是 +08:05:43

但是我表格里面写的不是1901年的日期啊,直提供了时间,没有日期。

跟踪线索

我们进一步看看,EasyExcel是如何处理的,跟到底层代码,发下有如下逻辑:

com.alibaba.excel.util.DateUtils:

com.alibaba.excel.util.DateUtils

继续往下跟,发现了如下逻辑:

org.apache.poi.ss.usermodel.DateUtil:

org.apache.poi.ss.usermodel.DateUtil

getLocalDateTime(date, use1904windowing) 会返回一个 本地时间字段(比如 1899-12-31T20:00:00

getLocalDateTime

看到这里,问题已经很明显了,表格解析工具把我们的时间转为了1899年的时间,最终时间解析出来的时候,做了时区转换,减去了这个5分43秒。

这个转换逻辑是Java的日期系统处理的:

Java 的日期/时间 API 在“绑定时区”这一步应用了时区数据库里记录的历史偏移规则

  1. 当你拿到一个 LocalDateTime ldt = getLocalDateTime(...) (比如 1899-12-31T20:00:00),它只是一个不带时区的本地时间字段,JVM 本身并不会在这时“扣”任何东西。
  2. 你调用 ldt.atZone(ZoneId.systemDefault()),这只是在给这段本地时间“打上”一个时区标签,并根据该时区在那一天的历史 UTC 偏移,来计算它对应的 ZonedDateTime
  3. 如果你随后把它转成 Instant 或者 java.util.Date(本质上都是 UTC 时间戳),Java 会用 zone rules 把带有 +08:05:43(1899-12-31 中国当地的历史偏移)的本地时间转换成 UTC,从而在数值上“少掉”那 5 分 43 秒。

所以,当调用

1
2
LocalDateTime ldt = getLocalDateTime(...);
ZonedDateTime zdt = ldt.atZone(ZoneId.systemDefault());

ldt时分秒字段并不会被修改,它仍然是 20:00:00

只是 zdt 会带上一个 历史偏移,比如在 Asia/Shanghai 时区,1899-12-31 的 offset 是 +08:05:43(现代是 +08:00)。

如果再把 zdt 转成 Instant(或 toEpochMilli()),Java 会用那个历史 offset 计算,等价于 “把本地时间减去 05:43” 之后再归入 UTC 时间戳。

所以,扣减那几分钟 的时刻其实发生在“ZonedDateTimeInstant”或“内部用历史 zone offset 解释本地时间”这一步。

到这里,真相大白了。

问题总结

是POI的问题吗,看起来也不是。POI 其实是按照设计在做的事情:

  1. Excel 的时间存储机制
    • 无论单元格只显示 “Time”,Excel 内部都是一个以“1899-12-31”为基准、带小数部分的浮点序号。
  2. POI 的转换逻辑
    • DateUtil.getJavaDate(double date, …) 会把这个序号转成 java.util.Date,并且在内部用 TimeZone.getDefault()(你的 JVM 默认时区)来解释“基准日 + 天数”。
  3. 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
2
3
4
5
6
7
public class ApiApplication {

public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
SpringApplication.run(ApiApplication.class, args);
}
}

是谁在这里设置为了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
2
3
4
5
6
7
8
9
10
11
12
13
// 假设你有一个旧的本地时间(1899-12-31T20:00:00)
LocalDateTime oldLdt = LocalDateTime.of(1899, 12, 31, 20, 0, 0);

// 1. 先把它按老上海时区解析,Java 会自动用那天的 +08:05:43
ZonedDateTime oldInShanghai = oldLdt.atZone(ZoneId.of("Asia/Shanghai"));
// oldInShanghai.toInstant() 对应的 epoch 毫秒,相当于 1899-12-31T11:54:17Z

// 2. 再把这个瞬时点,在现代 GMT+8(或者 Asia/Shanghai)的墙上“重画”:
ZonedDateTime modernInGmt8 = oldInShanghai.withZoneSameInstant(ZoneOffset.ofHours(8));
// modernInGmt8.toLocalDateTime() → 1899-12-31T20:05:43

// 3. 如果你只想要看时间部分:
LocalTime fixedTime = modernInGmt8.toLocalTime(); // 20:05:43

要么更省事的办法,让用户把这列的格式改为字符,当然,用户可能不会配合,而是会看看其他软件能不能直接支持。


另外,最近一个同事在排查另一个问题,接口接收json参数,jackson自动格式化时间,没有指定时区,也是导致了拿取到了非期望的时间,导致查询数据时间区间出现了问题,解决方法也是类似:

1
2
3
spring:
jackson:
time-zone: UTC+8

其实,如果系统里面都统一用long类型的时间戳,可以避免很多此类时区的问题。

本文作者: arthinking

本文链接: https://www.itzhai.com/java/resolve-excel-time-offset-5-min-43-sec-lmt-historical-timezone-java-easyexcel.html

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请订阅本站。

×
Java架构杂谈

订阅及时获取网站内容更新。

充电

当前电量:100%

Java架构杂谈

订阅我,及时获取网站内容更新。