返回

Java计算1927年时间戳差值错误?时区变更惹的祸

java

1927 年的时间差之谜:为什么 Java 计算出的时间戳差值不对?

碰到个奇怪的问题!一段 Java 代码解析两个日期字符串,这两个日期就差一秒钟,结果计算出来的 Unix 时间戳 (秒) 却不是差 1,而是一个意料之外的数字,真让人摸不着头脑。

先上代码,看看这段出问题的 Java 程序:

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class TimeDiffTest {
    public static void main(String[] args) throws ParseException {
        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String str3 = "1927-12-31 23:54:07";
        String str4 = "1927-12-31 23:54:08";
        Date sDt3 = sf.parse(str3);
        Date sDt4 = sf.parse(str4);
        long ld3 = sDt3.getTime() / 1000;
        long ld4 = sDt4.getTime() / 1000;
        System.out.println(ld4 - ld3);
    }
}

按理说,str3str4 两个时间只差一秒,计算结果也应该差 1 秒。 可程序一运行,输出的结果却是:353。 怪不怪?

更有趣的是, 我把时间往后拨一秒:

String str3 = "1927-12-31 23:54:08";
String str4 = "1927-12-31 23:54:09";

这下,ld4-ld3 输出的结果就正常了,是 1

1. 深究原因:时区变更惹的祸

问题出在哪儿呢?答案藏在时区 里。程序运行的环境使用了 Asia/Shanghai 时区,问题就和这个时区 1927 年的一次变更有关。

通过查找时区数据库(TZ database,也叫 Olson database),我们能发现上海时区在 1927 年 12 月 31 日午夜发生了一次调整。当时的时间从 LMT(Local Mean Time,地方平时) 向前拨了 5 分 52 秒,变成了 CST (China Standard Time,中国标准时间)。

可以使用这个 joda-time 的库查看具体的时区变化:

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.LocalDateTime;

public class JodaTimeTest {

  public static void main(String[] args)  {

      DateTimeZone shanghai = DateTimeZone.forID("Asia/Shanghai");
      DateTime dateTime = new LocalDateTime(1927,12,31,23,54,0).toDateTime(shanghai);
     System.out.println(dateTime.getMillis()/1000);

       DateTime dateTime2 = dateTime.plusSeconds(10);
        System.out.println(dateTime2.getMillis()/1000);
          System.out.println(dateTime2.getMillis()/1000 -  dateTime.getMillis()/1000 );

  }
}

这段代码的输出, 两个时间戳直接的差异, 就是我们之前遇到的那个 352.
注意:我测试时, 直接求 millis 差值,并不会触发时区变更,所以我转为seconds。

更具体的证据是,你可以在时区数据库变更历史(或者 IANA 的 tzdata)中直接看到记录。 简单来说,当时为了统一时间标准,对时钟做了拨快。

这意味着,1927-12-31 23:54:08 这个时间,实际上在 Java 的 Asia/Shanghai 时区下,根据其历史规则,需要加上一个 352 秒的时区偏移。而 1928 年以后的时间就没有这个偏移了, 因为标准时间已经推行。

2. 解决问题: 修正时间戳计算

问题的原因清楚了,解决办法就相对简单。以下列出几个常见的思路。

2.1. 考虑时区偏移 (TimeZone Offset)

最直接的方法就是把时区偏移明确考虑到计算中。 我们知道 Java 的 Date.getTime() 拿到的是相对于 UTC 时间 1970 年 1 月 1 日 00:00:00 的毫秒数。出现问题的原因,在于 SimpleDateFormatparse 的实现会结合时区规则,产生和后来不同的 TimezoneOffset.

可以用 TimeZone 类来获取特定时间的时区偏移量(单位:毫秒)。然后, 通过这个偏移值对我们的时间戳做修正。

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;

public class TimeDiffFix {
    public static void main(String[] args) throws ParseException {
        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String str3 = "1927-12-31 23:54:07";
        String str4 = "1927-12-31 23:54:08";
        Date sDt3 = sf.parse(str3);
        Date sDt4 = sf.parse(str4);

        // 获取时区
        TimeZone tz = TimeZone.getTimeZone("Asia/Shanghai");

        // 获取指定日期的时区偏移量
        int offset3 = tz.getOffset(sDt3.getTime());
        int offset4 = tz.getOffset(sDt4.getTime());
      
        long ld3 = (sDt3.getTime()  + offset3)/ 1000;
        long ld4 = (sDt4.getTime()  + offset4)/ 1000;
        System.out.println(ld4 - ld3);
    }
}

通过显示的 getOffset, 获取和 SimpleDateFormat 进行 parse 时相同的偏移信息。从而将两个时间拉到同一起跑线计算, 就得到了预期的 1.

2.2. 使用 java.time (Java 8+)

如果你的项目用的是 Java 8 或更高版本,强烈建议你用新的 java.time 包。这个包对日期和时间的处理更加清晰和强大,也更好地处理了时区问题。

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.Duration;

public class JavaTimeFix {
    public static void main(String[] args) {
        LocalDateTime ldt3 = LocalDateTime.parse("1927-12-31T23:54:07");
        LocalDateTime ldt4 = LocalDateTime.parse("1927-12-31T23:54:08");

        ZoneId shanghaiZone = ZoneId.of("Asia/Shanghai");
        ZonedDateTime zdt3 = ldt3.atZone(shanghaiZone);
        ZonedDateTime zdt4 = ldt4.atZone(shanghaiZone);
         
        Duration duration = Duration.between(zdt3, zdt4);
        System.out.println(duration.getSeconds()); // 输出 1
    }
}

java.time 的设计更严谨,会考虑到时区的转变, 通过 ZonedDateTime, 就能还原带时区的时间。

或者,我们可以直接计算 unix timestamp:

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.Instant;

public class JavaTimeFix2 {
    public static void main(String[] args) {
        LocalDateTime ldt3 = LocalDateTime.parse("1927-12-31T23:54:07");
        LocalDateTime ldt4 = LocalDateTime.parse("1927-12-31T23:54:08");

        ZoneId shanghaiZone = ZoneId.of("Asia/Shanghai");
        ZonedDateTime zdt3 = ldt3.atZone(shanghaiZone);
        ZonedDateTime zdt4 = ldt4.atZone(shanghaiZone);
       
        //计算秒数差
         System.out.println(zdt4.toEpochSecond() - zdt3.toEpochSecond());

    }
}

这个例子里,直接用 toEpochSecond ,也可以规避问题。因为这里计算结果基于 zdt3/zdt4 这两个包含了时区信息的 ZonedDateTime

2.3. 使用 UTC

另一个处理这类问题的思路是用 UTC (协调世界时) 来存储和计算时间。UTC 是一个标准时间,不受时区影响。这样能够最大限度保证计算一致性.

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;

public class UtcFix {
    public static void main(String[] args) throws ParseException {
        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
          sf.setTimeZone(TimeZone.getTimeZone("UTC"));// 设置解析器使用 UTC 时区

        String str3 = "1927-12-31 23:54:07";
        String str4 = "1927-12-31 23:54:08";

        // 这两个时间,需要根据上海时区计算得到相对于UTC的偏移量。再手动做转换.
       
        Date sDt3 = sf.parse(str3);
        Date sDt4 = sf.parse(str4);

          TimeZone tz = TimeZone.getTimeZone("Asia/Shanghai");
          int offset3 = tz.getOffset(sDt3.getTime());
          int offset4 = tz.getOffset(sDt4.getTime());
          
         //在 UTC 的基础上反向偏移. 如此得到UTC时间
          long utc3 = (sDt3.getTime() + offset3 );
          long utc4 = (sDt4.getTime() + offset4);
         System.out.println( (utc4 -utc3)/1000);

    }
}

安全建议: 无论选择哪种方案,都应尽量显性处理。尤其遇到历史时区可能有调整的情况时,一定在代码中写清楚如何处理时区问题。这样接手维护代码的人,才能够理解这段逻辑是基于什么规则处理的。
并且,代码一定要有良好的单元测试来覆盖.尤其要重点关注在时区发生变化的边界, 例如上面的跨1927-1928年,程序是不是依旧能够正常工作.
尽量统一存储,避免混用多种存储方式。

3. 小结

出现这种时间戳计算不准的问题,往往是由于我们忽略了时区的历史变化。这次的问题根源就在于上海时区 1927 年的那次变更。要保证计算精确,关键在于搞清楚时间处理所用的时区,并根据需求选用合适的 API 和策略。 Java 8 之后的java.time 库更推荐大家使用,它提供了一个更加强大的解决方案。如果用的是老版本的 Java, 通过理解TimeZone 和 偏移量的逻辑,也可以解决这类时区问题。