太阳照常升起,在每个需要挤公交车上班的日子里,即使窗外早已大雨如注。想来只有在周末,太阳会陪着我一起起床,所谓睡觉睡到自然醒,在雨天里保持晴天的心情,相当大的程度上,是因为今天不必上班。因此,一周里的心情晴雨表,简直就是活生生的天气预报,可惜我并不能预测我的心情,因为Bug会在某一瞬间发动突然袭击。一周前测试同事小J得到用户的反馈,我们某一笔订单突然无法从系统中查到,可就在数分钟前用户创建了这笔订单。前端同事小Q立刻追踪了这个问题,发现查询交易的接口调用正常,而后端同事小L确认数据库中是有这条交易记录的。于是,为了解决这样一个诡异的问题,几乎花费了大家大半天的时间。而最后的问题根源,居然充满了无厘头的意味,如本文主题所言,这是一个由服务器时区引发的Bug。在这篇文章中,我想和大家聊一聊,关于时区以及日期/时间格式化的相关问题,希望大家会喜欢这个话题,就如同我希望大家会喜欢我一样。
  可能大家都不会意识到时区会成为一个问题,因为对大多数中国人而言,我们唯一的时间概念就是北京时间。我们不得不承认,互联网在弱化了空间地域性的同时,无形中疏远了人与人之间的距离,尤其当我们处在一个分布式架构的时代,云的存在让我们的Service分布在无数个服务器节点上去,我们甚至意识不到它们的存在。比如我们在阿里云上选购主机的时候,阿里云会让我们去选择主机所在的地域,因为选择离自己更近的地域,意味着可以更快的访问速度。再比如像亚马逊这样的云计算服务商,会在国内(宁夏·中卫)部署自己的资源,这显然是为了服务国内用户。那么,我们不得不去思考一个问题,假如我们要同时服务国内、外的用户,那么这些Service 可能会被同时部署到国内和国外的服务器上面。因此,我们就可能会遇到国内、外服务器时区不一致的问题,通常我们会以服务器时间为准并将其储到数据库中。此时,因为时区不一致,难免会产生本文中遇到的这个问题。

时区为什么会不同?

  既然时区是本文里的“罪魁祸首”,那么我们就会不由得思考这样一个问题,即为社么时区会不同。我们知道,地球是自西向东自转的,因此东边会比西边先看到太阳。相应地,东边的时间会比西边的早。这意味着时间并不是一个绝对的概念,即东边的时间与西边的事件存在时差。现实中的时差不单单要以小时计,而且还要以分和秒计,这给人们带来了不便和困扰。因此,1884年在华盛顿召开的国际子午线会议上,规定将全球划分为24个时区(东、西各十二个时区),其中以英国格林尼治天文台旧址作为零时区,每个时区横跨经度15度,时间恰好为1小时,而东、西第12时区各跨经度7.5度,以东、西经180度为界。每个时区内时间,统一以该时区的中央经线的时间为主,相邻的两个时区间总是相差一个小时,这就是时区的由来,时区的出现解决了人们换算时间的问题。

世界时区分布
世界时区分布

  事实上,时区的划分并不是一个严谨的事情,因为常常会出现一种情况,一个国家或者一个省份同时跨着2个或者更多的时区。以中国为例,中国幅员辽阔,差不多横跨5个时区,理论上在国内应该有5个时间,但为了使用起来方便,我们统一使用的是北京时间,即东八区时间。什么叫做东八区呢?即东半球第八个时区,其中央经度为东经120度。时区的计算非常简单,当你往西走时,每经过一个时区,时间会慢一个小时;当你往东走时,每经过一个时区,时间会快一个小时。例如,日本的东京位于东九区,因此,北京时间2018年6月9日8点整,对应的东京时间应该是2018年6月9日9点。这样,我们就会遇到一个非常有趣的问题,如果一个人到世界各地去旅行,它就需要不停地去将手表拨快或者拨慢,即使我们现在有了智能手机,它一样会提供不同时区的时间选择,假如我们偷懒选择了网络时间,那么它将永远和当地时间保持一致,因为我十分地确信,东京的运营商绝对不会选择使用北京时间。

数据库里如何存储时间

  截至到目前为止,我们可以搞清楚的一件事情是,在不同的地域使用的时间是不同的,因为我们所使用的时间,本质上都是相对于格林尼治时间的相对时间,即使这些时间会因为地域存在差异,可从整个宇宙的角度来看,时间分明又是在绝对地流逝着,它对我们每一个人而言都是客观而公正的,当你发现时间越来越不够用的时候,你需要思考时间到底被浪费到什么地方去。我无意像霍金先生一样,去追溯时间的起源以及它的未来,在这篇文章里,我更关心的是,数据库里究竟是怎么样存储时间的,因为最根本的问题是,用户作为查询条件的时间,服务器上存储记录的时间,这两个时间的上下文发生了混乱。人类更喜欢在工作中不停地切换上下文,尤其是在面对无休止的会议、需求分析、Review等等诸如此类的中断的时候,你是否会想到频繁地切换上下文,本质上是需要付出代价的呢?

Time is All
Time is All

  回到这个问题本身,我们现在来看看数据库中是如何存储时间的,这里我们选择三种最为常见的数据库来分析,它们分别是MySQL、Oracle 和 SQL Server。

MySQL

  对MySQL来说,它支持YEAR、DATE、TIME、DATETIME和TIMPSTAMP共5种数据类型。其中,

  • YEAR类型占1个字节,取值范围为1901~2155,可以采用4位字符串或者4位数字赋值,不建议使用2位数字或者2为字符串赋值,因为容易混淆0和‘0’。
  • DATE类型占4个字节,取值范围为1000-01-01 ~ 9999-12-31,采用YYYY-MM-DD的格式赋值,不建议使用@或.这样的分隔符,不建议将年份表示为YY,理由同上。
  • TIME类型占3个字节,取值范围为-838:59:59 ~ 838:59:59,采用HH:MM:SS的格式赋值,不建议使用HH:MM或者SS的简写格式,以及混合使用D的格式。
  • DATETIME类型占8个字节,取值范围为1000-01-01 00:00:00 ~ 9999-12-31 23:59:59,标准格式为YYYY-MM-DD HH:MM:SS,规则同上
  • TIMESTAMP类型占4个字节,取值范围为19700101080001 ~ 20380119111407,系统可以使用CURRENT_TIMESTAMP或者自动输入当前的TIMESTAMP,需要注意的是,该数值与时区有关。

Oracle

  对Oracle来说,它支持DATE、TIMPSTAMP和INTERVAL共3种数据类型。其中,

  • DATE类型占7个字节,是一种表示日期/时间的数据类型,本身包含世纪、世纪中的哪一天、月份、月份中的哪一天、小时、分钟和秒7个属性,例如2005-12-05 12:30:43对应的表示是120、105、12、5、12、31、44,我们注意到这里世纪和世纪中的年份,都被相应地增加了100,而分钟数和秒数分别增加了1,这里增加的100是为了区分公元前和公元后,一般在写入该类型的数据时,最好能显式地指定日期或者时间的格式。
  • TIMPSTAMP类型,同DATE类型类似,不同的是,TIMESTAMP类型可以支持秒分量的小数位数以及时区。秒分量的小数部分最多可以支持9位,当秒分量的小数部分为0时,它和DATE类型在功能上完全一致。
  • INTERVAL类型,顾名思义,这是一个表示时间间隔的数据类型,同.NET中的TimeSpan类型相似,它可以用来存储一个时间间隔,比如8个小时或者是30天,两个DATE或者TIMESTAMP相减可以得到INTERVAL,而DATE或者TIMESTAMP增加一个INTERVAL就可以得到相应的DATE或者TIMESTAMP。

SQL Server

  对SQL Server来说,它支持Date、Time、DateTime和DateTime2共4种数据类型。其中,

  • Date类型仅存储日期,不存储时间,需要3个字节的存储空间,默认格式为yyyy-MM-dd(PS:为什么没有一个标准来统一这些占位符的大小写),取值范围为0001-01-01 ~ 9999-12-31,可以采用字符串、GetDate()、SysDateTime()三种方式赋值。
  • Time类型仅存储时间,不存储日期,需要7个字节的存储空间,默认格式为hh:mm:ss.nnnnnnn,可以注意到默认的秒分量小数部分为7位,建议使用字符串或者SysDateTime()这两种方式赋值,不建议使用GetDate(),因为该方法返回值为DateTime类型,其时间部分的精度没有Time类型的经度高。
  • DateTime/DateTime2,这个命名好COM+啊,其中DateTime类型存储日期和时间,需要8个字节的固定存储空间,相对应地,DateTime2的存储空间则是不固定的,因为它可以指定秒分量的小数位。DateTime的默认格式为yyyy-MM-dd hh:mm:ss.xxx,取值范围为1753-01-01 00:00:00.000 ~ 9999-1-3123:59:59.997,精确度为3.33毫秒,相应地,DateTime2的秒分量小数位默认可达到7位。通过GetDate()和GetUTCDate()两个函数,可以为DateTime类型赋值;通过SysDateTime()和SysUTCDateTime()函数,可以为DateTime2类型赋值。通常来说,DateTime2相比DateTime,具有更好的性能表现。
      此时此刻,我们不得不面对这样一个现实,那就是:不同的数据库中对日期/时间的存储处理是不同的。看起来这像是一个显而易见的结论,因为这就像SQL这门语言一样,即使我们有着相同的标准,可最终我们面对的还是各种“方言”版本的SQL,甚至连这些难以统一的内置函数,都会成为某次面试中的题目。我们注意到,这些和日期/事件相关的数据类型,在对时区的支持上差异明显,MySQL中的DATESTAMP是标准的UNIX时间戳,存储的是自1970-01-01至今经过的秒数,这个数据的存取都是相对简单的,因为MySQL内部帮你做了大量的转换的工作,
      可它的缺点是什么呢?由于4个字节长度的限制,它最多到2038年,可现在都2018年了啊!DATETIME类型的数据范围好像可以解决这个问题,遗憾的是它没有办法包含时区信息,这就尴尬了啊!或许有人会想到能不能用int类型来存储日期,这理论上是没有问题啊,可你愿意每次存取都要做一遍转换吗?这意味着我们需要一种同时支持日期、时间和时区的表示方法,所以,下面我们来说一说DateTime相关的格式化,这里特指UTC时间、GMT时间、本地时间和Unix时间。

繁杂的日期格式

  我对日期/时间的格式化的厌恶,最早来自为Excel编写读写库,人们发明了各种各样的样式,虽然常用的无非那么多种,可对于一个编写Excel读写库的人来说,你不得不去在读写过程中面对各种各样的格式,或者是从字符串变为DateTime类型,或者是从DateTime变为字符串。我本人非常喜欢OADate这种方式,因为它真正地做到了样式与数据分离,我们大多数时候面对的时间,它到底是一种什么数据类型,为什么你在Excel里输入日期/时间字符串会被当作是日期/时间,而通过快捷键插入的系统时间同样会被当作是日期/时间,有没有一种统一的可以描述时间的方式呢?这里需要介绍UTC时间、GMT时间、本地时间和Unix时间4个概念。

UTC时间

  UTC时间,即Coordinate Universal Time。它是一种通用的时间表示方法,UTC是根据原子钟来计算时间,它是经过平均太阳时、地轴运动综合修正计算后的一个结果,使用秒作为计量单位,由于原子钟计量的时间精度非常高,因此,可以认为UTC一个世界标准时间。

GMT时间

  GMT时间,即Greenwhich Mean Time。如果大家对这个名词不熟悉,那么我相信,对于格林威治天文台,大家一定非常熟悉啦! 十七世纪,为了满足英国海上霸权的扩张,格林威治皇家天文台开始对天文进行观测。历史上每一次霸权主义的扩张,其初衷必然是非正义的,可伴随着这个过程中而产生的文明,可谓是是泽被后世,前文中提到的时区划分,就是以格林尼治天文台旧址作为零时区,所以GMT时间和UTC时间等价,前者提出较早,基于天文观测;后者提出较晚,基于现代物理。

本地时间

  本地时间,即LocalTime。从定以上来讲,本地时间=UTC时间+时差,其中东半球时区记为正,西半球时区记为负。以东八区为例,UTC+0800,即为本地时间,这就是我们所熟悉的北京时间。因为同时存在UTC和GMT两种标准,所以我们在某些场合下会看到GMT+0800,这两者表示的实际上是同一个时间,都以秒作为单位。

Unix时间

  Unix时间,又称Unix时间戳,顾名思义,这是一种在Unix及类Unix操作系统中表示时间的方法。Unix时间戳其实就是UTC 时间在计算机领域的一个应用,我们所看到的计算时间,其实都是从1970-01-01 00:00:00开始,截止到此时此刻的总秒数,这个方案被Unix及类Unix操作系统继承下来,甚至影响到了大量非Unix操作系统,这个方案后来被称为POSIX标准,因为该时间又被称为POSIX时间。或许有朋友会感到疑惑,计算机是会关机和断电的啊,那么这个时间不就会丢失吗?事实上计算机内部有一个称为RCT的硬件模块,该模块内部独立供电,所以可以准确记录下这个时间。一个有趣的事情是,计算机内部使用32位整型来表示时间,而32位整型最大能表示为2147483647秒,我们做个简单计算:2147483647/365/24/60/60,就可以知道这个数值为68.1年,这意味着计算机内部能表示最大年份为1970+68=2038。想想看今天已经是2018年啦,难道在我们有生之年会有幸见到这个Bug吗?这对整个数字时代来说算不算一次世界末日呢?哈哈,实际上我们有了64位以后这个问题就可以解决了,至于64位出现类似问题,这个只能交给时间来解决啦,因为那时你和我都早已不复存在。

ISO8601

  OK,现在我们来一起看一个实际的格式化问题,我们在调用后端提供的API接口时,前端同事使用日期格式是:2018-06-05T03:03:57.000Z,而后端同事使用的日期格式是:2018-03-16T19:14:22.077+0800。这两种不同的日期格式到底是什么呢?和我们这篇文章中提到的内容又有什么关联呢?因为博主曾经在写一个小工具的时候,遇到无法解析这种格式日期的问题,所以对这两种日期格式可谓是记忆犹新。这两种日期格式实际源于一个国际标准ISO8601
  根据该格式的定义,当日期和时间组合使用时,需要在时间面前增加一个大写字母T,而Z表示时区,且默认表示0时区,因此字母Z可以省略,以我国为例,我国是东八区,所以正确的写法是+08:00,由此可以得知,第二种写法实际上就是一个表示东八区时间的表示方法,虽然这个写法是错误的。第一种写法有什么问题呢?它表示的是0时区的时间,因此对中国用户而言,他们需要在这个时间上增加8个小时的时差,可如果这个时间是经过时区修正后的时间会怎么办呢?时间对每一个人都很重要,可看到它的稀奇古怪的表示方法,难免会让人感到风中凌乱啊……
  我们知道,Json.Net是.NET中一个非常流行的JSON解析和生成库,而我们在对一个实体进行序列化的时候,如果实体中属性的数据类型为DateTime,那么在序列化的时候就会出现一个非常有趣的现象。假如我们在数据库中有一个字段dateCreated,那么通过这个库转换出来的结果可能会是”/Date(1269582661683+0800)/“这样的结果,例如下面这段JSON:

1
2
3
4
{
"DateCreated":"\/Date(1528687303302)\/",
"UserName":"Payne Qin"
}

出现这个结果的原因,是因为我们使用微软提供的JavaScriptSerializer,而这个序列化器遵循的实际上是Unix时间标准,换句话说,这里展示的这个数值是1970-01-01 00:00:00至今的毫秒数, 这一点我们通过一个简单的计算就可以得到验证。Json.Net中默认使用ISO8601风格的序列化器,我们一起来看下面的例子,这里我们定义一个简单的数据结构,按照惯例,这个数据结构用Foo类表示:

1
2
3
4
5
6
7
8
class Foo
{
[JsonConverter(typeof(IsoDateTimeConverter))]
public DateTime IsoDateTime { get; set; }
[JsonConverter(typeof(JavaScriptDateTimeConverter))]
public DateTime JSDateTime { get; set; }
public string UserName { get; set; }
}

此时,我们可以注意到序列化后的结果如下:

1
2
3
4
5
{
"IsoDateTime":"2018-06-11T11:35:45.898768+08:00",
"JSDateTime":new Date(1528688145898),
"UserName":"Payne Qin"
}

为了将这两种统一起来,建议通过JsonSerializerSettings,因为我们可以定制日期的样式:

1
2
3
4
var settings = new JsonSerializerSettings();
settings.DateFormatHandling = DateFormatHandling.IsoDateFormat;
settings.DateFormatString = "yyyy-MM-ddTHH:mm:ss.fffzzz";
var json = JsonConvert.SerializeObject(entity,settings);

此时,可以注意到结果为:

1
2
3
4
5
{
"IsoDateTime":"2018-06-11T11:45:46.981+08:00",
"JSDateTime":"2018-06-11T11:45:46.981+08:00",
"UserName":"Payne Qin"
}

JavaScriptDateTimeConverter和IsoDateTimeConverter,均是DateTimeConverter的子类,因此我们可以定义更多的转换器,毕竟喜欢折腾的人类永远不会满足,除了本文中介绍到的时间表示方法以外,我们还有CST和DST等不同的表示方法,对了,关于格式化参数fff/zzz等请参考这里,你就会知道人类是多么的无聊啊。

不同语言中对时区的处理

  好了,这篇文章基本上通篇都在讲时间,我们最初的问题是,服务器上的时区和当前时区不一致,导致在查询的时候时间无法对应起来。现在,我们应该可以达到一个共识,不管什么时候,我们都应该使用UTC时间或者GMT时间,而在拿到这样一个时间后,如果有必要请转换为本地时间,而当相关流程结束以后,最好将这个时间转换为UTC时间或者是GTM时间。现在,我们来看看不同的语言中是如何处理时区问题的,按照博主对语言的熟悉程度,博主选择了C#和Python两门语言来说明问题。

CSharp

  C#中关于日期/时间的API都集中在DateTime类中,而关于时区的API则集中在TimeZone和TimeZoneInfo类中,我们一起来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//当前时区:中国夏令时
var timezone = TimeZone.CurrentTimeZone;
//获取所有时区
var timezones = TimeZoneInfo.GetSystemTimeZones();
//获取时区ID:北京时间+08:00/China Standard Time
var timezoneId = TimeZoneInfo.GetSystemTimeZones()[102].Id;
//当前系统时区:(UTC+08:00) 北京,重庆,香港特别行政区,乌鲁木齐
var currentTimeZone = TimeZoneInfo.Local;
//本地时间转换为UTC时间:2018/6/11 5:13:49
var dtUTC2 = TimeZoneInfo.ConvertTimeToUtc(DateTime.Now);
//将本地时间转换为指定时区的UTC时间:2018/6/11 5:13:49
var dt = DateTime.SpecifyKind(DateTime.Now, DateTimeKind.Local);
var dtUTC1 = TimeZoneInfo.ConvertTimeToUtc(dt, TimeZoneInfo.Local);
//将指定时间从指定时区转换至目标时区的时间:2018/6/11 1:13:49
var dtUTC3 = TimeZoneInfo.ConvertTime(dt, TimeZoneInfo.Local, TimeZoneInfo.GetSystemTimeZones()[30]);
//当前UTC时间:2018/6/11 5:13:49
var dtUTC = DateTime.UtcNow;
//当前Unix时间:1528694152
var startTime = TimeZone.CurrentTimeZone.ToLocalTime(new System.DateTime(1970, 1, 1));
var dtUnix = (int)(DateTime.Now - startTime).TotalSeconds;

Python

  Python中针对时区的处理,发扬了Python一贯主张简单的传统,有多传统呢,大概只需要两行代码,是的,你没有听错,只需要两行代码:

1
2
tz = pytz.timezone('Asia/Shanghai')
dt = datetime.datetime.now(tz)

  简单来说,在Python中我们只需要给定时区,即可将本地时间转化为指定时区对应的UTC时间,这里我们使用的是Python中的pytz这个库,如果你打开这个库的安装目录,就会发现其实它有大量时区相关的数据组成,如果我们直接调用pytz.timezone()就可以获得所有的时区信息。博主有一个Python脚本运行在TravisCI的服务器上,而TravisCI来自一家法国的技术公司,因此在不指定时区的情况下,会默认使用TravisCI服务器上的时间,这并不是我想要的结果,所以,我们需要pytz来解决这个问题,至于这里为什么我们使用的是上海而不是北京,这是因为中国横跨5个时区,在国内大家习惯使用北京时间,而在国外这些时区数据没有做及时更新,所以这算是一个关于时区的历史遗留问题吧!

本文小结

  本文从实际生活中一个案例入手,首先,向大家解释了为什么我们需要时区,以及为什么地球上不同地域拥有不同的时间。接下来,我们以MySQL、Oracle和SQL Server三种数据库为例,了解了在数据库中是如何存储时间的,可以注意到大多数数据库都使用了时间戳来存储时间。由此,我们引出了UTC时间、GMT时间、本地时间以及Unix时间,并讲述了它们之间的区别。其中,UTC和GMT可以看作是等价的时间表示方法,两者仅仅是计量工具不同,在历史上提出的先后顺序不同,并且GMT时间是以UTC时间为基准的。而Unix时间是计算中表示时间的方法,其含义是自1970-01-01 00:00:00至今经过的总秒数,在此基础上我们引出了为什么32位计算机下能表示的最大年份是2037。在文章的最后,博主选择了最熟悉的C#和Python,向大家展示了和时区相关的操作。
  我承认,这篇文章相当地细碎,可能因此牵扯了太多的概念,我一直在犹豫要不要发到博客上来。其实,有太多的时候,越来越发觉自己写不出来一篇好的文章,大概我需要去读更多的书,或者去解决更多的问题,可坚持写博客的一个重要原因,无非是我觉得我需要花点时间区整理这些东西,因为别人没有去关注的一个问题,而我去尝试关注或者解决了,这就是我的收获啊,总而言之,在这篇相当细碎的文章背后,我收获的可能并不比这篇文章里写出来的少,原谅我这些唠叨的碎碎念吧,这篇文章就是这样啦,谢谢大家!