代码中的时间

在国际化应用中,对日期/时间的处理远比你想象中的更难,特别是当涉及到时区的时候。为什么会这么难?我们该如何解决它?请听我为你一一解析。

几乎所有的系统都离不开“时间”的概念,以至于大多数语言(及其默认库)都定义了日期/时间等类型。

但是,我们日常所说的“时间”这个词实际上包含了多个相似却有微妙差异的概念。如果不能分清它们,会给你的开发工作带来很多烦恼。

基本概念

时区(Timezone)

在应用系统中,对时间的混淆往往和时区有关。这是很多系统从本地化应用发展成全球化应用时的一大障碍。

由于各地日出日落的时刻不同,所以全球一共分为 24 个时区,每个时区横跨 360/24=15 个经度。比如伦敦位于北京的西面,那么当北京的太阳已经升起的时候,伦敦还要再过 8 小时才能迎来黎明。也就是说,伦敦比北京晚 8 小时。而东京位于北京的东面,所以东京的日出比北京早 1 小时。

如果我们想知道当北京是中午 12:00 的时候,东京是什么时间,可以先用 12:00 减去当前时区 +08:00,换算成伦敦时间 04:00,再加上目标时区 +09:00,就得到了东京时间 13:00。

零时区

地球是圆的,北京比伦敦早8小时其实就等于比伦敦晚16小时,到底谁比谁早呢?我们既可以把这个时差表示为+8,也可以表示为-16,到底该怎么写呢?我们要先确定一个标准。

首先,要确定一个零时区。

虽然任何一个地方都可以作为零时区,但拥有世界上第一座航海钟的英国格林威治天文台夺得了这项殊荣,而经过那里的子午线(经线),被称为本初子午线。它左右各7.5度的范围叫做零时区,再往西一个时区就叫做西一区。

当我们自东往西旅行的时候,是在追着太阳走,因此每过一个时区,就要把表拨慢一个小时才能跟当地时间保持一致,我们把这个“拨慢”的动作,记作 -01:00,反之则是 +01:00。如果从伦敦往北京走,需要从西往东走八个时区,因此北京的时区就记作 +08:00。

日界线(国际日期变更线)

在球面上,与它相对的那条子午线,正好是 +12:00 区和 -12:00 区的分界线。这条线很特殊,因为当你自西向东越过它的时候,既是比伦敦早了13小时,从另一个方向来看,也是比伦敦晚了11小时。就像数学中的进位一样,它们的日期应该是不同的。当自西向东越过日界线的时候(迎着朝阳),日期应该减一,反之(追赶夕阳),则应该加一。

假设有一个人于中午 12:00 坐飞机从伦敦出发,自东向西(追着太阳)做环球旅行。以这架飞机的速度,恰好每小时飞过一个时区,于是,每过一个小时,他都要把表拨慢一个小时(-01:00)。换句话说,我们的主人公一直生活在中午 12:00,而太阳也确实一直在他的头顶,他的感知时间和表上的时间是一致的,这样就避免了他顶着大太阳说“现在是午夜”的荒谬感。但是,当他到达日界线的时候,还要做另一件事。由于他是在自东向西穿越日界线,所以他还要把日期加一。他继续向前,等回到伦敦走下飞机的时候,他表上的时间是第二天中午 12:00,而当地时间也恰好是第二天的 12:00,他自己恰好在天上飞了一整天。这样,所有的时间就都对上了。

但是如果我们仔细看时区表,就会发现有些时区被标记为+13:00、+14:00,这是怎么回事呢?还是因为日界线。因为虽然日界线大部分位于海上,但还是会穿过有人生活的陆地。如果把同一个地方的人划分到不同的日期,会带来很多不便,于是日界线在中间拐了几个弯,而这些拐弯的地方,自然就出现了像 +13:00、+14:00 这样的怪异时区。

夏时制

一到夏天,白天就变得很长,特别是高纬度地区会更明显,到了北极或南极,太阳整天都不会落下去,这就是极昼。

为了充分利用大自然的馈赠,有些地方会实行夏时制,也就是说到了夏天,就人为把表拨快一个小时,让人们早点起床、早点睡觉,这样可以节省一些照明的电费。中国曾经短暂实行过几年夏时制,不过后来认为它带来的负面影响超过收益,就取消了。但是世界上仍然有很多地方实施夏时制,当设计全球化应用的时候,必须得考虑它。

时刻(Instant)

也许你意识到了,当伦敦是中午十二点时(太阳正当空),位于伦敦西面的巴黎应该是下午一点(太阳略偏西)。但实际上它们指的是一定是同一个时间。

想象一下,如果我中午十二点从伦敦给巴黎的朋友打一个电话,他接电话时手机上显示的应该是下午一点。但无论是伦敦的中午十二点,还是巴黎的下午一点,都只是同一个客观时间的两种不同表示而已。这个与时区无关的客观时间,我们称之为“时刻”。事实上,在大部分场景下,我们应该关注的都是这个时刻,而其它的时间,全都作为它的衍生物或等价物。用这种客观时刻作为记录时唯一的一种时间,可以避免很多的概念混淆。

GMT —— 格林威治标准时间

自从确定了时区之后,国际上就把格林威治时间记作 GMT+0。对于同一个时刻,可以有 12:00 GMT+00:00、13:00 GMT+01:00 等多种等价的表示形式。

UTC —— 协调世界时

现代科技对时间精度的要求越来越高,GMT 依靠天文观测(地球自转)得来的时间已经远远不能满足现代科技的精度要求。于是人们改用原子钟来实现高精度计时,但是 GMT 已经有了很多历史应用,直接把它替换成原子钟计时会带来一些不兼容问题。所以,人们创建了 UTC 时间,以便在新应用中代替 GMT。

由于 UTC 不再依靠天文观测来获取,所以地球自转一天的时间也不再一定等于 86400 秒。如果地球自转稍微慢了一丢丢呢?那一天的最后一分钟可能就有 61 秒,这叫做闰秒。事实上,由于潮汐作用,地球的自转确实一直在微不可查地减速。所以,如果在某些系统中看到 23:59:60 这样的表示法,请不要急着喊 BUG,先看看当时的新闻上有没有发过闰秒公告。

当然,为了减少不必要的换算,UTC 在设计的时候刻意向 GMT 看齐,在绝大部分场景下,两者没有值得注意的差别。

日历

我们时常提到日期,但实际上并没有一个叫做日期的独立概念。所有的日期,其实都是在某个日历系统中的日期。比如说我们既可以用“1911 年 10 月 10 日”表示发生辛亥革命的日期,也可以用“宣统三年八月十九”表示。这两个都对。所以,当我们要把一个时间显示给用户的时候,其日期部分必须指定一个日历才能正确地格式化。

我们日常使用的默认日历系统,都是指格里高利日历系统,由于采纳它的国家最多,因此也被称为公历。而中国的传统历法叫做农历或阴历。类似的,还有伊斯兰历和佛教历等日历系统。

而年、月、日、星期等,也都是与特定日历系统紧密相关的概念。所以,一旦遇到“下个月”、“第 2 周”这样的概念,先要明白它是指公历系统中的。

一些语言或其默认库中把日期的概念绑死在了公历系统上,比如 Java 的 Date 类,这会导致它在国际化时难以适应不同的日历系统,容易引起混淆。所以 Date 类的一些方法和属性被弃用,并在 Java 8 中引入了一些新的时间/日期类。

时间的表示格式

无论使用哪种时间/日期系统,也无论它们写成什么格式,背后所代表的都是时刻。这点差异非常重要,如果混淆了它们,在设计国际化应用时,就会陷入歧义。

Unix 时间戳(Time stamp)

当 Unix 系统诞生的时候,需要一种数据结构来表示时间,在计算机系统资源非常有限的条件下,系统的设计师选择使用 32 位整数来表示时间,并以 UTC 时间的 1970年1月1日0时0分0秒 作为起点。

随着 Unix 和 Linux 系统的广泛流行,这种表示法的使用范围也越来越广。然而,由于它是 32 位整数,因此它最多只能表示到 2038 年初。不过,在新系统中,已经改用 64 位整数表示时间戳,它可以表示到2900亿年之后,相当于不存在最大时间限制了。但考虑到存在很多遗留系统,这种迁移将是一个巨大的工程。

除了兼容性问题之外,Unix 时间戳在调试、追踪方面也很不友好,你很难一眼看出它是什么时间,所以,在 API 和日志中尽量不要再用这种格式传输或存储时间数据。

RFC2822

在互联网协议中传输的字符串,通常是 RFC2822 格式的。比如 Thu, 10 Dec 2020 13:49:45 GMT。这种形式虽然冗长,但不存在精度限制,所以在一些对存储空间不很敏感、但注重可读性的场合却很合适。

不过,这种格式涉及一点英语,这对非英语国家的人不太友好。因此虽然对开发和调试的影响不大,但在国际化应用中最好不要把它直接显示给最终用户。

ISO8601 / RFC3339

另一种常用的字符串表示法是 ISO8601 格式,比如 2020-12-01T00:49:45.001Z。

ISO8601 包含很多种子格式。事实上,中国使用的日期格式标准就是 ISO8601,但我们日常主要使用其“年-月-日”部分。

从名字就可以看出,它是一个 ISO 标准,几乎所有的现代语言和库都能很好地支持它,不会造成歧义。而且,它只会使用阿拉伯数字和两个字母,以及几个可选的分隔符,对于非英语用户比较友好。

在互联网领域,定义了另一个与 ISO8601 基本兼容的标准 RFC3339,也就是“{年}-{月}-{日}T{时}:{分}:{秒}.{毫秒}{时区}”格式,其中的年要用零补齐为4位,月日时分秒则补齐为2位。毫秒部分是可选的。最后一部分是时区,前面例子中的 Z 其实是零时区 Zulu 的缩写,它也可能是 +08:00 或 -08:00 等。

这两个标准非常相似,但又不完全兼容,在编程语境下常用的 ISO8601,指的是一个像 RFC3339 一样五脏俱全的子版本。也就是前面举例过的 2020-12-01T00:49:45.001Z 这种形式。

人类可读格式(Human-readable)

虽然我们已经有了很多种存储格式,但人类用户的需求是多样的,比如有时候用户只希望看到“月-日”或时间中的其它部分,甚至还有“刚刚、五分钟前、上月”等“人类友好格式”,这些信息显然是不全的,而且很不规范,无法作为存储格式使用。他们存在的意义,就在于供人类阅读。

还有另一种容易混淆的人类可读格式,比如 2020-12-01 00:49:45.001,为什么说它是人类可读格式而不是 ISO8601 呢?问题的关键不在于它少一个 T,而在于它丢了时区信息!这样一来,当我把这个时间给一位伦敦同学看的时候,我们默认都会把它当做本地时间,看似一样,但实际的时刻差了足足八小时,什么事都耽误了!

与时间有关的编程要点

只存储时刻

Unix 时间戳、RFC2822 和 ISO8601 存储的都是时刻,而人类可读格式却非如此,因为它通常会缺少至关重要的时区信息。所以,不要在数据库中存储人类可读格式,而应该存储时刻,否则会丢失信息。只有在把时间显示给人类的时候,才应该临时转换成人类可读格式。

只传输时刻

在 API 中,我们只应该传输时刻。因为 API 的提供者和消费者很可能不在同一个时区,如果传输缺少时区的人类可读格式,就会被解释为各自时区的时间,从而带来歧义。

正确设置服务器时刻

在服务器的内部,存储时刻通常使用 Unix 时间戳,这意味着它是 UTC 时刻。

当你要在服务器上设置时间的时候,通常会输入本地时间,并且由服务器内部换算为时刻后生效。这就要求服务器上必须正确设置了你输入的本地时间所对应的时区,否则换算时就会出错,让服务器所理解的时刻不同于你期望的时刻,从而导致错误。

如果你使用远程登录的方式去管理服务器,可以把当前会话的时区临时设置为你所在的时区,这样你就可以自由输入本地时间了,服务器会自动帮你换算。当然,如果你要以另一个时区的用户身份在服务器上查询,也可以把当前会话的时区设置为该用户的时区,这样你就可以自由使用该用户期望的时间了。

也可以采用另一种方案:把服务器设置为零时区,并且每次会话时不再设置时区。这样可以防止遗忘,但你就要自己把本地时间换算到零时区时间才能在服务器上输入了。比如你要查询北京时间今天 00:0012:00 的日志,当在服务器上做维护的时候就要换算成服务器上(零时区)的时间也就是昨天16:00今天4:00。
这两种方案各有利弊,但无论采用哪种方案,都要记住时区只是表象,真实时刻才是根本。必须确保所有服务器上的真实时刻保持一致,这样才会记录一个唯一的“真相”,以保持数据的一致性。比如,如果服务器设置为零时区,输入的时间时却是你的本地时间,显然会导致错误。

让各个节点的真实时刻保持一致并不容易。不过好在互联网建立之初就设计了一个协议:网络时间协议 NTP。它可以帮助各个网络节点自动同步其真实时刻,在互联网上大部分区域,其同步精度可以达到 1~50 毫秒。

最好让上下游服务器的时区保持一致

无论采用哪种方案,都最好确保上下游服务器之间的时区保持一致,特别是应用服务器与相应的数据库服务器。比如应用服务器和数据库服务器如果分别设置成了本地时区和零时区,并且在应用服务器上发送一条 SQL,以查询 2020-01-01 和 2020-01-02 之间的数据,那么这个时刻到底指的是什么呢?应用服务器以为它在查本地时区的,而数据库服务器以为它要查零时区的,这显然是错误的。

在保存数据的时候,这种问题更严重。如果一个表中某些时间字段是由应用服务器填写的,而另一些字段是由数据库服务器填写的,那么这种时区设置方面的差异就可能带来灾难性的错误。

为了防范这种问题,最简单的办法是让这些服务器的时区保持一致。如果无法保持一致该怎么办呢?这就要涉及接下来的几个要点了。

不要使用“日期”

刚才提到的问题,其表面问题在时区,本质问题却在于“日期”。这两个日期有什么问题呢?问题就在于它没有自带时区信息!所以,应用服务器和数据库服务器之间,将无法就时区达成一致!各类信息丢失问题是很多 BUG 的根源,这里同样如此。

更严重的是,它还丢失了时间信息。

既然我要传的是“日期”,为什么还需要带时间信息呢?很简单,因为没有所谓“日期”!我们日常所说的今天,其实是个时间段,指的是本时区今天 00:00:00 到明天 00:00:00 之间。如果换个时区,今天可能就不是今天了,而是从昨天 16:00:00 到 今天 16:00:00。当你说的今天指的到底是哪一天呢?
所以,虽然和用户交互时,我们会使用日期的概念,但是在真正的程序中,我们应该始终使用时刻,这样才能保持概念一致性。

保存时使用来自应用服务器的时刻

虽然可以让数据库服务器和应用服务器保持一致,但为了简化逻辑,保存数据时,尽量由应用服务器来提供时刻,而不要由数据库服务器提供,这样可以简化时刻的来源,更容易保持一致性。

而对于客户端提供的时间,我们无法信任,因为客户端节点通常不在我们的控制范围内,使用客户端数据会带来数据错误,甚至带来安全漏洞。

所以,对于需要保存的数据,把应用服务器上的时刻作为真相之源通常是最佳选择。

查询时使用来自用户的时刻

查询通常是来自用户视角的,比如当用户在北京查询今天的数据时,他一般是希望查询北京时间今天 00:00:00 到明天 00:00:00 之间的数据,而不会关心服务器在哪里。

所以,如果我们要设计一个查询今天数据的 API,那么就不能把一个日期传给应用服务器,因为客户端和服务端的时区可能不同,服务端就无法准确理解客户端的意图。我们应该传给它两个参数:本时区今天的起始时刻和结束时刻。

使用“闭-开”区间表示时间段

当我们用时间段来表示日期的时候,需要注意区间的右侧应该是开区间,也就是说,查询要今天的数据就要查询今天午夜零点到明天午夜零点之间的数据,但不包含明天午夜的零点。否则即使我们用 11:59:59.999 来查询,仍然可能存在一条今天的数据出现在这个时间点之后。

用 SQL 在查数据库时有一个坑:BETWEEN 是个闭区间,也就是说其结束时间是包含在统计范围内的。所以,我们应该用 今晚零点 >= 时间 AND 时间 < 明晚零点 才能准确查出今天的数据。

强制指定时区

有时候,用户期望使用的时区并不是自己所在的时区,比如当用户到其它时区出差时,可能关心的仍然是自己原来的时区。除了让用户强制修改客户端的时区之外,还可以允许当前用户指定一个时区,在应用服务器上用这个时区进行换算。不过,这种情况下客户端需要对日期选择器进行特殊处理,以便让用户感知的日期与实际使用的日期保持一致。

指定数据库会话的时区

我们经常需要根据年月日周等标准进行统计。这时候只通过指定区间就不容易统计了。我们可以把数据库会话的时区修改为用户期望的时区。比如 alter session set time_zone = ‘+08:00’;。这样一来,我们在 SQL 中使用的函数就能得到正确的年月日周等时区相关的结果了。

总结

时间包含很多相关却又容易混淆的概念。特别是我们的日常用语往往不是很精确,这就留下了不少隐患。仔细区分这些概念,并且在思考的时候刻意使用这些精确的概念,可以避免很多与时间有关的 BUG。

Share

One Comment

发表评论

邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据