时间是一个大家既熟悉又陌生的概念,大家每天都在用,但对于时间大家又有很多不了解的内容,比如闰秒、时区、夏令时等等。最近在做全球支付的项目,在项目中也遇到一些关于时间的问题,比如时间的保存、展示、查询等等。自己抽空查了很多资料,在此把我了解的和大家分享下,也希望对大家有帮助。
如果你觉得时间很简单,可以先看一下这个文章《你确信你了解时间吗?》,本文稍后也会说下这篇文章中提的事情。时间的处理是很复杂的,里面有很多坑,下面简单罗列几项:
- 闰年的规则:规则很复杂,不过好在有规则
- 时区的规则:一段时间内规则明确,如果考虑历史就呵呵了
- 夏令时的规则:跟各个地区的当地政策有关,既然是政策就不能公式化了
- 闰秒的规则:是随机加的,会临时通知的,我们都不知道哪天会加下一个闰秒
时间的定义
首先时间是什么呢,你会发现没有一个合适的定义,维基百科提到一个争议较小的定义为“时间是时钟量测的物理量”。那么时间是如何测量的呢,很早之前很多时间单位都是用天文概念定义的,包括日、月、年。由于年是使用地球绕太阳转的周期定义,月采用月球绕地球公转周期定义,日采用地球自转周期来定义,但这几个周期都是相对独立的,各自之间的换算都不是整数,例如一年并不都是365天,回归年(太阳年)=365.242199174日,所以就有了闰年的概念,有些年份的2月会多1天,为29天。目前使用的闰年规则为:
+ 4的倍数是闰年。
+ 100的倍数不是闰年。
+ 400的倍数是闰年
2000年千年虫的问题其中一个就是因为这个闰年问题,因为2000年是闰年,但很多程序没有考虑到这个场景(另一个问题是采用2位数值来保存年而引起了处理错误)。月的概念在这就不说了,因为它已经偏离最初的定义太多了,中国的农历应该是比较接近自然定义的,但那个太复杂了,-_- |
。 |
秒的定义和闰秒
下面再说下秒的定义,最开始秒的定义是太阳日(或地球自转)的1/86400,但随着近代测量越来越精确,发现这种天文秒的精度太低了,而且地球自转的周期本身的也是波动的,近代观测结果是逐渐变慢的,这样就无法提供一个准确的秒了。在1967年第13届国际计量大会上通过一项决议,重新对秒进行了定义,定义为铯-133原子基态两个超精细能级间跃迁辐射9,192,631,770周所持续的时间,也就是我们现在说的原子时。原子时的定义是近代的一个重大进步,目前原子钟的精度可以达到每2000万年才误差1秒,此精度还在不断提高中。你可能觉得时间精度和我们生活关系不大,这你就错了,我们每天赖以使用的GPS核心原理就是原子时,我们的手机是根据天空中的多个卫星发送的位置(卫星的位置)和时间信号,然后跟自己的时间通过复杂的公式,来计算出自己的当前位置。随着原子钟的时间精度提高,定位的精度从石英钟时代的14米减小到了现在的2.9米。
秒被重新定义为原子时后(目前我们所用的时间UTC也采用原子时),由于原子钟的精度很高,比原来使用的天文秒要精确很多。闰秒就是为了校正原子秒和天文秒的误差而存在的。因为天文秒是根据地球自转来确定的,但是地球自转的周期不是固定的,它本身是有波动的,这样就到这一天不再是86400秒了,可能会有些误差,现在规定如果误差达到0.9秒就需要进行调整,会在协调世界时(UTC,在时刻上和格林威治时间GMT尽量一致)的6月30日、或12月31日的23:59:59秒后再加上1秒,也就是23:59:60。目前只有增加闰秒,是因为地球自转在减慢,由于地球自转的波动是随机的,也就导致闰秒是随机公布的,谁也不知道下次是什么时候。加入随机的闰秒后,一分钟可能是61秒或60秒。因此在UTC系统的时间尺度中,秒和比秒小的单位(毫秒、微妙等)其长度是固定的,但是对于分钟和比分大的单位(小时、天、周等)的长度都是可变的。闰秒给大家带来了很多困扰,例如日本,由于其时区为UTC+09:00,所以闰秒调整会出现在上午9点,这个时候让你调表是个麻烦事,所以日本强烈建议取消闰秒。闰秒带来的另一个问题是计算机使用的时间,这里需要说一下:大家都知道时间是在计算机中是表示为一个long整数的,但是闰秒是不计算在现在的时间戳内的,也就是long整数中是没有这1秒的,这样在闰秒后操作系统时间会出现倒流,这个原因曾导致过程序崩溃,对此不同系统处理各异,Linux会让后续时间变慢以适应时间倒流,Windows则直接忽略。闰秒的另一个问题就是导致时长精度问题,如果你直接拿两个时间戳相减得到的不是真正的秒数,还需要加上这段时间发生的闰秒数。
下面是历史上所有闰秒统计:
实行年份 |
6.30 23:59:60 |
12.31 23:59:60 |
实行年份 |
6.30 23:59:60 |
12.31 23:59:60 |
1972年 |
+1秒 |
+1秒 |
1989年 |
|
+1秒 |
1973年 |
|
+1秒 |
1990年 |
|
+1秒 |
1974年 |
|
+1秒 |
1992年 |
+1秒 |
|
1975年 |
|
+1秒 |
1993年 |
+1秒 |
|
1976年 |
|
+1秒 |
1994年 |
+1秒 |
|
1977年 |
|
+1秒 |
1995年 |
|
+1秒 |
1978年 |
|
+1秒 |
1997年 |
+1秒 |
|
1979年 |
|
+1秒 |
1998年 |
|
+1秒 |
1981年 |
+1秒 |
|
2005年 |
|
+1秒 |
1982年 |
+1秒 |
|
2008年 |
|
+1秒 |
1983年 |
+1秒 |
|
2012年 |
+1秒 |
|
1985年 |
+1秒 |
|
2015年 |
+1秒 |
|
1987年 |
|
+1秒 |
2016年 |
|
+1秒 |
时区和夏令时
时区和夏令时这两个概念更是个政治概念了。你可以看下世界时区的划分,发现很乱,这里面很多政治因素在里面,我们需要知道的就是各个时区是相对协调世界时UTC做了调整,例如我国是UTC+08:00。在处理时区时要指定它的地区名称,而不是国家(一个国家可能跨多个时区,例如美国),也不是位置,虽然时区和位置有关,但不是线性的。说完了时区还要再说下夏令时,夏令时又叫日光节约时间(Daylight saving time,或Summer time),是为了节约能源让大家在夏天早起1小时(也就是把时钟拨快1小时),各个地区对于夏令时有不同的规定,而且不同年份的开始日期还可能不同(跟政府政策有关)。我国在1986-1991年实行夏令时,后来因为中国地域辽阔且修改事件效果不好且影响大家生活,后来作废了。
程序处理应注意的内容
好了,到这时间的概念大家应该都清楚了。下面再说下程序中时间处理中应该注意的。
- 数据库中存储的时间、操作系统的时间都是一个long整数,它是指格林威治时间1970年01月01日00时00分00秒起至现在的总秒数(或是总毫秒数);
- 只有在展示时才将此时间戳换算成本地时间,换算时需要考虑时区和夏令时。当然了如果你直接将本地时间字符串存到数据库中,那很多转换工作需要你自己做了,需要考虑清楚。在多时区处理时一般将UTC时间存入数据库,便于后续处理。
- 时间包括两个属性,一个是时间戳,即相对于UTC的某个时点的偏移量;另一个是时区,即事件发生是所在的时区信息,这个信息可以根据你的业务场景分析是否需要
- 在java中如果你输出显示时间,默认是本地时间,和系统设置有关,如果要输出某个时区的时间需要自己指定时区;另外这个本地时区时间会考虑夏令时,当然了因为夏令时和政府政策有关,java不可能实时获知这些信息,如果你想自己控制,则需要建立一个自己专用的夏令时起止时间表,自己进行换算(我们海外系统就是自己保存的夏令时信息),这时请不要再使用Asia/Shanghai这种,请使用UTC+08:00这种,会避免程序自身夏令时的处理影响。
下面给大家展示一个测试程序,通过这个能看出java程序的处理方式。值得一提的是上面酷壳提到的这个时间现在并不特殊,反而另一个时间是特殊的(1900-12-31 23:54:16),我不是说那篇文章的不对,而是很可能是近期政府对历史的时间定义有调整。
package com.zyh.mytest;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class TimeTest {
private static final String TIME_FORMAT_STR = "yyyy-MM-dd HH:mm:ss";
private SimpleDateFormat utc8Sdf;
private SimpleDateFormat utcSdf;
private SimpleDateFormat shanghaiSdf;
@Before
public void setUp() {
utc8Sdf = new SimpleDateFormat(TIME_FORMAT_STR);
utc8Sdf.setTimeZone(TimeZone.getTimeZone("GMT+08:00"));
utcSdf = new SimpleDateFormat(TIME_FORMAT_STR);
utcSdf.setTimeZone(TimeZone.getTimeZone("GMT+00:00"));
// 输出全部时区
// for (String s : TimeZone.getAvailableIDs()){
// System.out.println(s);
// }
shanghaiSdf = new SimpleDateFormat(TIME_FORMAT_STR);
shanghaiSdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
}
/**
* 测试时区的时差
*
* @throws ParseException
*/
@Test
public void testTimeZone() throws ParseException {
Date utc8Dt = utc8Sdf.parse("2017-10-01 13:04:56");
Assert.assertEquals(1506834296000L, utc8Dt.getTime());
Date utcDt = utcSdf.parse("2017-10-01 13:04:56");
Assert.assertEquals(1506863096000L, utcDt.getTime());
Assert.assertEquals((long) (8 * 60 * 60 * 1000), utcDt.getTime() - utc8Dt.getTime());// 8H
}
/**
* 测试夏令时
*
* @throws ParseException
*/
@Test
public void testDst() throws ParseException {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd HH:mm:ss");
sdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
Date shanghaiDt = shanghaiSdf.parse("1988-07-02 12:00:00");
Assert.assertEquals(583815600000L, shanghaiDt.getTime());
Date utcDt = utcSdf.parse("1988-07-02 12:00:00");
Assert.assertEquals(583848000000L, utcDt.getTime());
Assert.assertEquals((long) (9 * 60 * 60 * 1000), utcDt.getTime() - shanghaiDt.getTime());// 9H
}
/**
* 测试闰秒
*
* @throws ParseException
*/
@Test
public void testLeapSeconds() throws ParseException {
Date utcDt1 = utcSdf.parse("2008-12-31 23:59:59");
Assert.assertEquals(1230767999000L, utcDt1.getTime());
Date utcDt2 = utcSdf.parse("2008-12-31 23:59:60");
Assert.assertEquals(1230768000000L, utcDt2.getTime());// +1s
Date utcDt3 = utcSdf.parse("2009-01-01 00:00:00");
Assert.assertEquals(1230768000000L, utcDt3.getTime());// =23:59:60
}
/**
* 测试本地时间调整1,没有异常
*
* @throws ParseException
*/
@Test // 本地时间,时间调整
public void testLocalTime() throws ParseException {
Date shanghaiDt = shanghaiSdf.parse("1927-12-31 23:54:07");
Assert.assertEquals(-1325491553000L, shanghaiDt.getTime());
Date shanghaiDt2 = shanghaiSdf.parse("1927-12-31 23:54:08");
Assert.assertEquals(-1325491552000L, shanghaiDt2.getTime());
Assert.assertEquals((long) (1 * 1000), shanghaiDt2.getTime() - shanghaiDt.getTime());// 1S
}
/**
* 测试本地时间调整2
*
* @throws ParseException
*/
@Test
public void test6() throws ParseException {
Date shanghaiDt = shanghaiSdf.parse("1900-01-01 12:00:00");
Date utcDt = utcSdf.parse("1900-01-01 12:00:00");
Assert.assertEquals((long) ((8 * 60 * 60 + 5 * 60 + 43) * 1000), utcDt.getTime() - shanghaiDt.getTime());// 8:05:43
Date shanghaiDt2 = shanghaiSdf.parse("1901-01-01 12:00:00");
Date utcDt2 = utcSdf.parse("1901-01-01 12:00:00");
Assert.assertEquals((long) (8 * 60 * 60 * 1000), utcDt2.getTime() - shanghaiDt2.getTime());// 8H
Date shanghaiDt3 = shanghaiSdf.parse("1900-12-31 23:54:16");
Date utcDt3 = utcSdf.parse("1900-12-31 23:54:16");
Assert.assertEquals((long) ((8 * 60 * 60 + 5 * 60 + 43) * 1000), utcDt3.getTime() - shanghaiDt3.getTime());// 8:05:43
Date shanghaiDt4 = shanghaiSdf.parse("1900-12-31 23:54:17");
Date utcDt4 = utcSdf.parse("1900-12-31 23:54:17");
Assert.assertEquals((long) (8 * 60 * 60 * 1000), utcDt4.getTime() - shanghaiDt4.getTime());// 8H
Assert.assertEquals((long) (( 5 * 60 + 44)* 1000), shanghaiDt4.getTime() - shanghaiDt3.getTime());// 00:05:43
}
}
参考资料
- How Does an Atomic Clock Work?
- Working with time zones in Ruby on Rails
- 你确信你了解时间吗?
- 时间频率和铯原子钟
- 关于闰秒
- 元旦我国第27次闰秒将来临:多一秒世界有何不同
- 关于Oracle Timezone的一点总结
- ISO8601 Dates in Ruby