前言
时间处理是所有程序员都必须面对的难题,如果你遇过以下问题:
- 时间显示时总是差八个小时时差
- 存储在数据库的时间看起来没问题,就是程序读出来的时候差了八个小时时差
- 浏览器传过到后台的时间,差了八个小时时差
八个小时时差这个事,好像总是跟程序员过不去,而程序员总是糊里糊涂的把时差补上去完事,到底真正原因是什么却不知晓。
时间的本质
假如你身在中国,需要和一个在英国的同事进行一个电话会议。首先约个通话时间吧。早上9时吧。算一下不行,英国GMT+0时区,他们那边是午夜1时,还没上班呢。那约下午2时吧,等于他们的上午9时,可以了。
时间点,是一个绝对概念,好比约个电话会议:不管你在哪里,一个时间点,就是同一个时刻,电话会议若是错过了就是错过了。北京时间14时,和英国时间09时,就是同一个时间点。(这里不会考虑相对论中对时空的理解可能带来的问题)
一个时间点所衍生的,是不同观察者对这个时间点的解析。同一个时间点,我在我的时区是14时,你在你的时区是09时,其他人在不同时区是05时,具体体现的时间取决于你在那个时区,但都是相同的时间点。所以时间点是绝对的概念,时间是相对的概念,取决于你当时所在的时区。
时间的表达方式
运行结构
一个时间点,在计算机中不同语言或环境会用不同的表达方法。一个特殊时间点叫Epoch (t=0),代表着一个特殊时间点(在Wikipedia的解析:The “epoch” serves as a reference point from which time is measured)在Unix中,Epoch就是 1970-01-01 00:00:00.000 GMT。Epoch标志着0在哪里,作为其他时间点的参考点:比如说如果 t=86400秒, 代表的就是1970-01-02 00:00:00.000 GMT 这个时间 (或者在不同时区的观察者而言,这个点是 1970-01-02 08:00:00 GMT+8)
在Java中,时间点会用一个long
表示,表示自Epoch后过了多少毫秒的意思。大家比较熟悉的是:System.currentTimeMillis()
就是当前时间点。按这样算,一个long
最大值可以到9223372036854775807
,换算一下,可以表达2亿多年后的时间了。
用long
表达时间点,两个相同的时间点就是两个long
值的相等,实现起来非常简单。
在此强调一下,Java中的java.util.Date
仅仅是一个long
的具体形象化的表现,大家可以把Date
对象等同与一个long
来看待,所以Date
的构造函数,用一个long
就可以了,而Date
对象并没有包含时区概念。
当我们需要把一个Date
打印出来看看的时候(或者toString()
的时候),Java会默认通过本机操作系统的时区来打印(或者变成时间字符串)。
// 用默认时区格式化时间
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date d = new Date(86400000L);
System.out.println("Date (Default TZ): " + d);
System.out.println("Formatted (Default TZ): " + df.format(d));
// 设置全局时区
TimeZone.setDefault(TimeZone.getTimeZone("GMT"));
// 修改格式化所用的时区
df.setTimeZone(TimeZone.getTimeZone("GMT"));
System.out.println("Date (in GMT): " + d);
System.out.println("Formatted (in GMT): " + df.format(d));
输出如下:
Date (Default TZ): Fri Jan 02 08:00:00 HKT 1970
Formatted (Default TZ): 1970-01-02 08:00:00
Date (in GMT): Fri Jan 02 00:00:00 GMT 1970
Formatted (in GMT): 1970-01-02 00:00:00
大家编程的时候,不要随便更改系统默认(全局)时区,因为这个配置是全局的,万一默认时区在一个地方改了,其他地方并不知道默认时区被改了,影响可大了。
上面说了基础Java对时间的表达方式,当然也有其他比较完善的工具包如JodaTime,让可以对时间进行更细致的定义和操作,这里不继续展开了。
重点是:观察者,观察者,观察者。是观察者去理解时间点,而不是时间点本身包括了时区信息(针对Date对象而言)。
存储方式
数据库存储结构
我们看看MySQL数据库如何存储时间类型的:
https://dev.mysql.com/doc/internals/en/date-and-time-data-type-representation.html
在内部格式中,除了TIMESTAMP的描述有关时区以外 (seconds UTC since the epoch),都看不到有时区的存储结构的描述。所以MySQL中的时间类型的存储,是没有【时区】存储在数据库中的。
假如你存下了时间 2018-01-01 10:23:45
到数据库中,这时间在理解上是有歧义的:到底你这个时间是英国时间,还是中国时间?单凭存储的这个时间值不能下定论,因为这个时间不能代表一个时间点。
这问题通常需要通过系统设计者的定义来解决。设计师会说明数据库存储的时间是什么时区,而且会用统一时区。比较普遍的做法是使用GMT/UTC时区(0时区),这个做法适合多时区的应用。而另一个做法是存储本地时区的,但这个定义可能不太适合需要支持多时区的应用。按上面例子:2018-01-01 10:23:45
这个时间就能明确下来了, 它代表着 2018-01-01 10:23:45 GMT
这个时间点。
如果你的数据结构文档中,没有定义时间存储的时区,大概你需要先定义一下了。
JDBC的影响
几乎所有Java框架存储结构,最终都会转化成JDBC的调用,而JDBC在时间存储的接口有:ResultSet.getTimestamp()
, PreparedStatement.setTimestamp()
等。我们知道数据库没有在时间字段中存储时区,JDBC驱动怎么知道从数据库读出来的时间 2018-01-01 10:23:45
是什么时间点呢?
在MySQL的JDBC驱动里,有个关于时区的配置有十几个,看到有点头晕:
https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-configuration-properties.html
简单点说,加一个连接参数 serverTimezone=UTC
或者你想用数据库存储时间的时区,就可以避免数据库操作系统时区配置的差异导致问题。
传递方式
跨系统的时间传递,也可能因为定义不清楚,导致传来的时间存在解析歧义。如果你接收一个JSON格式:
{
"username": "hello",
"loginTime: "2018-04-07 10:24:14"
}
从内容上看,loginTime
字段存在理解歧义,因为这里的时间是什么时区的,没有明确定义。所以在交互协议层的API定义中,如果出现这种情况,必须说明这个字符串有没有隐含什么时区,是不是默认中国时间?
消灭歧义的唯一方式,都需要通过准确定义来解决,没有其他办法。
在线格式定义好以后,发送方和接收方双方面就可以做相应的工作。发送的时候,按格式输出;接收的时候,按格式解读。
如果使用框架自带的格式化/序列化功能,如Jackson,Date
类型会变成一个long
,而long
本身代表Epoch后多少微秒,时间点不存在歧义。
倘若你自己处理时间格式,就需要理解到底序列化的过程中,有没有失真。如果Date
转成 “yyyy-MM-dd HH:mm:ss”,内容就有所失真了,因为这样的格式,不足以代表一个时间点,因为存在理解歧义,如果真的需要定义这个格式,必须配合文档说明这个字符串写的时间的是什么时区,才能消除理解歧义。而且你在序列化过程中不做精准控制(如没配置好默认时区,或SimpleDateFormat
没有配置时区),有可能你设计的格式,和真实的输出格式,因为操作系统的配置差异而不同,尤其是生产系统通常是UTC时区的,当你按照开发时的桌面操作系统来验收,结果生产系统出来的是另外一个时间,到时候才排查问题就不是太理想了。
总结
我们需要从不同层面的时间定义上理解透彻,才能彻底解决这类问题,而不是随便调整过来,看上去还可以,过几天连自己做过什么都不太记得了。这样的调整对自身学习毫无价值。