返回

一个由服务器时区引发的 Bug

太阳照常升起,在每个需要挤公交车上班的日子里,即使窗外早已大雨如注。想来只有在周末,太阳会陪着我一起起床,所谓睡觉睡到自然醒,在雨天里保持晴天的心情,相当大的程度上,是因为今天不必上班。因此,一周里的心情晴雨表,简直就是活生生的天气预报,可惜我并不能预测我的心情,因为 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:

{
 "DateCreated":"\/Date(1528687303302)\/",
 "UserName":"Payne Qin"
}

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

class Foo
{
  [JsonConverter(typeof(IsoDateTimeConverter))]
  public DateTime IsoDateTime { get; set; }
  [JsonConverter(typeof(JavaScriptDateTimeConverter))]
  public DateTime JSDateTime { get; set; }
  public string UserName { get; set; }
}

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

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

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

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

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

{
  "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 类中,我们一起来看下面的代码:

//当前时区:中国夏令时
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 一贯主张简单的传统,有多传统呢,大概只需要两行代码,是的,你没有听错,只需要两行代码:

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,向大家展示了和时区相关的操作。 我承认,这篇文章相当地细碎,可能因此牵扯了太多的概念,我一直在犹豫要不要发到博客上来。其实,有太多的时候,越来越发觉自己写不出来一篇好的文章,大概我需要去读更多的书,或者去解决更多的问题,可坚持写博客的一个重要原因,无非是我觉得我需要花点时间区整理这些东西,因为别人没有去关注的一个问题,而我去尝试关注或者解决了,这就是我的收获啊,总而言之,在这篇相当细碎的文章背后,我收获的可能并不比这篇文章里写出来的少,原谅我这些唠叨的碎碎念吧,这篇文章就是这样啦,谢谢大家!

Built with Hugo v0.110.0
Theme Stack designed by Jimmy
已创作 264 篇文章,共计 997310 字