一、时间标准(Time Standard) vs 时区(Time Zone)
A time standard is a specification for measuring time: either the rate at which time passes; or points in time; or both.
https://en.wikipedia.org/wiki/Time_standard
从维基百科上摘取的一段定义,大意是时间标准是度量时间的一种规范,既可指时间流逝的速率亦或指时间点。我们常用的UTC(Coordinated Universal Time)就是现在国际通用的时间标准。UTC的定义由两部分决定:原子时(TAI - International Atomic Time)和世界时(UT1 - Universal TIme)。原子时可以非常稳定和精确地定义1秒时间(铯-133原子震动9,192,631,770次),由于它很稳定,我们用它来衡量时间流逝的速率,也就是时钟上秒针转动的速率;世界时是基于地球自转的一种时间计量方式,通过它我们可以衡量地球一天有多长。但由于地球自转因潮汐力等客观因素,导致自转速度在不断变慢,所以人为引入闰秒(leap second)来弥补世界时和地球真实自转速度之间的误差。
追溯下历史,UT最早在1884年被创立,当时选用的Greenwich Mean Time(GMT)作为世界时间标准,把经过经度为0的伦敦格林威治这个地方作为我们现在熟知的本初子午线的原始起点。
之后,UTC最早在1960被引入,最终在多次完善后,从1972年开始替代GMT,自此GMT不再是一个时间标准,而仅仅只是代表一个时区(零时区)。
我们接着说时区:

A time zone is a region of the globe that observes a uniform standard time for legal, commercial and social purposes.
https://en.wikipedia.org/wiki/Time_zone
简而言之,为了满足法律、商业和社交需求,我们用时区将全球划分成不同地区并遵循统一的时间表达方法。刚才说到的GMT是零时区的一个缩写(也叫Z - Zulu Time Zone),还有CST(China Standard Time)也是一个时区的缩写,全球各个国家、地区或城市根据时区的划分使用特定的缩写。举个例子,比如我们知道在伦敦的这个时间点 2020-04-27 07:00:00,我们想知道中国是几点,由于中国在CST时区,伦敦在Z时区,CST时区和Z时区相差8个小时,此时在CST时区,当地时间表示为2020-04-27 15:00:00 CST,UTC时间表示为2020-04-27 15:00:00 UTC+08:00,或者,2020-04-27 02:00:00 UTC-05:00代表了EST(Eastern Standard Time)时区的当地时间。
我们可以发现,UTC时间里带上了和零时区的正负时间偏移量。值得注意的是,这个偏移量的最小单位并不是小时,而是分钟,比如IST(India Standard Time),它的偏移量是UTC+05:30,也就是5个半小时,再如NPT(Nepal Time),它的偏移量是UTC+05:45,5个小时45分钟。通过这个链接:https://www.timeanddate.com/time/zones/,可以找到所有时区的缩写和对应的偏移量(注:不同地区可能共用一个时区缩写,比如BST,在亚洲代表Bangladesh Standard Time, UTC+06:00;在大洋洲代表Bougainville Standard Time, UTC+11:00;在欧洲代表British Summer Time, UTC+01:00)。
综上,我们现在可以理解清楚UTC和GMT之间的区别了。但由于历史原因,GMT有一段时间的作用和UTC一样,只是现在UTC替代了GMT,但很多站点上可能还会保留使用2020-04-27 15:00:00 GMT+08:00的形式来表达国际标准时间,虽然不是什么大是大非的问题,但结合GMT现存的特定意义,仅有部分欧洲和非洲地区在使用,所以还是更推荐使用当地时区缩写的形式来表达用户当地时间。
绝大多数高级开发语言都对时区展示有专门的封装,下面以Java为例,看下如何来处理时区问题。
// 这里的传的“UTC”是一个特例,UTC并不是时区代号而是个时间标准。TimeZone utc = SimpleTimeZone.getTimeZone("UTC");// 一个z打印对应TimeZone对象的缩略名,四个z打印全名String pattern = "yyyy-MM-dd HH:mm:ss z (zzzz)";DateFormat dfUTC = new SimpleDateFormat(pattern, Locale.ENGLISH);dfUTC.setTimeZone(utc);// 以 欧洲-奥地利-维也纳 为例String zoneId = "Europe/Vienna";// 需要注意的是:这里可以传缩略名,但仅仅只是为了兼容JDK1.1的版本// 在实际使用中,需要传国家和城市这样的全名// 通过TimeZone.getAvailableIDs()可以获取到所有支持的idTimeZone tz = SimpleTimeZone.getTimeZone(zoneId);// 这里的XXX可以以+/-08:00的形式输出偏移量,或者用大写的Z以+/-0800的形式输出pattern = "yyyy-MM-dd HH:mm:ss z (zzzz) 'UTC'XXX";DateFormat dfLocal = new SimpleDateFormat(pattern, Locale.ENGLISH);dfLocal.setTimeZone(tz);Calendar cd = Calendar.getInstance(tz);// 维也纳在2010年9月1日,当地使用夏令时cd.set(2010, 8, 1, 10, 0, 0);Date dt = new Date(cd.getTimeInMillis());System.out.print("local time:\t");System.out.println(dfLocal.format(dt));System.out.print("UTC time:\t");System.out.println(dfUTC.format(dt));// 单独获取时区展示名的缩写String abbr = tz.getDisplayName(tz.inDaylightTime(dt), 0, Locale.ENGLISH);System.out.print("abbr.(DST):\t");System.out.println(abbr);// 维也纳在2010年11月26日,当地使用标准时cd.set(2010, 10, 26, 10, 0, 0);dt = new Date(cd.getTimeInMillis());System.out.print("local time:\t");System.out.println(dfLocal.format(dt));System.out.print("UTC time:\t");System.out.println(dfUTC.format(dt));// 单独获取时区展示名的缩写abbr = tz.getDisplayName(tz.inDaylightTime(dt), 0, Locale.ENGLISH);System.out.print("abbr:\t");System.out.println(abbr);
执行上面的代码,可以得到下面的输出:
// 注意下,这里的month是从1开始的,而Calendar里是从0开始的。ZonedDateTime zdt =ZonedDateTime.of(2010, 9, 1, 10, 0, 0, 0, ZoneId.of("Europe/Vienna")).withZoneSameInstant(ZoneId.of("Asia/Shanghai"));System.out.println(zdt.format(DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH));
// 输出结果为:2010-09-01 16:00:00 CST (China Standard Time) UTC+08:00
另外,上面ZoneId构造的时候,类似"Asia/Shanghai"可以替换成"+08:00",但这样输出的结果里是没有CST和China Standard Time,且都被+08:00替换。
// Asia/Shanghai替换成+08:00后的输出结果为:2010-09-01 16:00:00 +08:00 (+08:00) UTC+08:00
从上一部分的描述中,我们可以发现,各个地区的UTC的偏离量,还有DST的规则等等是没有规律的,是需要日常维护的,但上面获取到的Vienna的DST时间区间是从哪里来的呢?
翻看JDK源码,我们可以发现,SimpleTimeZone.getTimeZone()方法的实现最终可以追溯到ZoneInfoFile这个类。
static {String oldmapping = AccessController.doPrivileged(new GetPropertyAction("sun.timezone.ids.oldmapping", "false")).toLowerCase(Locale.ROOT);USE_OLDMAPPING = (oldmapping.equals("yes") || oldmapping.equals("true"));AccessController.doPrivileged(new PrivilegedAction<Object>() {public Object run() {try {String libDir = System.getProperty("java.home") + File.separator + "lib";try (DataInputStream dis = new DataInputStream(new BufferedInputStream(new FileInputStream(new File(libDir, "tzdb.dat"))))) {load(dis);}} catch (Exception x) {throw new Error(x);}return null;}});}
可以看到,ZoneInfoFile在静态构造块中,会去JRE的根目录加载一个叫tzdb.dat的文件。追根溯源,这个Tzdb来自于IANA提供的TimeZone数据库,https://iana.org/time-zones,目前维护着最新最全的全球时区相关基础数据。我们可以通过getVersion方法来查看下系统加载的这个数据库的版本。
String version = ZoneInfoFile.getVersion();System.out.println(String.format("TZDB[%s]", version));
下面这个截图是从我本机输出的结果,可以看出是2015年的版本。

为了能够应用最新的数据,我们可以替换tzdb.bat这个文件。但从IANA上下载的并不是这种格式的,我们需要用到这个工具:https://github.com/akashche/tzdbgen,它可以帮助我们生成最新的tzdb.bat文件。(根据使用开发语言的不同,下载下来的数据需要再编译成对应语言的文件,可以在IANA上找到相对应的编译工具。)除此之外,我们也可以自己实现一个ZoneRulesProvider来定制自己的时区使用规则。
一个确切时间的完整表述都应该有对应的时区,如果没有明确设置,那么要么默认是设备当地时区,要么是Z时区。
在本地化展示用户端当地时间的时候,尽量让用户端来处理本地化输出格式,因为客户端对时间处理的方法一般默认用的是系统设置的当地时区。
如果需要准确输出某个时间所在的时区名字,在后端的数据存储和交换过程中,也需要保存时区ID(比如Asia/Shanghai)。
仅通过offset推算的新时间,是无法准确获取新时间所在的时区名的。比如通过UTC-5,既可以对应EST(Eastern Standard Time),也可以对应ECT(Ecuador Time)。所以如果需要准确输出时区缩略名或者完整的名字,还需要时区ID。




