時區的坑,別再踩了!

打碼日記發表於2022-03-19

原創:打碼日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。

簡介

最近在使用date命令時,發現表示東8區(中國時區)要使用GMT-8,但在Java中卻需要使用GMT+8,如下:

$ TZ='GMT-8' date -d@1647658144 +'%F %T %:z'
2022-03-19 10:49:04 +08:00

# 如果用GMT+8,反而慢了16小時
$ TZ='GMT+8' date -d@1647658144 +'%F %T %:z'
2022-03-18 18:49:04 -08:00

而在Java中,如下:

DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX");
String dateStr = dtf.format(Instant.ofEpochSecond(1647658144).atZone(ZoneId.of("GMT+8")));
System.out.println(dateStr);
//輸出2022-03-19 10:49:04 +08:00

這就讓人有點迷糊了,經過一段時間搜尋,發現在時區表達形式上還有不少知識點呢!

時區的偏移量表示法

眾所周知,為了方便各地區本地時間之間的轉換,人們將全球劃分為了24個時區,以格林尼治天文臺(GMT)為零時區,往東西兩個方向分別有12個時區,所以自然有了以GMT為字首的時區表示法,如下:

GMT+8表示東8區,中國就是使用這個時區,而GMT-8表示西8區,如果格林尼治天文臺的本地時間是2022-03-19的0點,那麼GMT+8地區的本地時間就是2022-03-19的8點,而GMT-8的本地時間就是往前8小時,即2022-03-18的16點。

注意,上面的各地區本地時間的表述雖然不同,但它們實際是同一個時刻(絕對時間),要理解本地時間與絕對時間的區別。

GMT+8正是Java中支援的時區表示法,那為啥Linux中卻是GMT-8呢?實際上Linux中的GMT-8也可以寫成Etc/GMT-8,這才是它的標準名稱,如下:

$ TZ='Etc/GMT-8' date -d@1647658144 -Is
2022-03-19T10:49:04+08:00
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX");
String dateStr = dtf.format(Instant.ofEpochSecond(1647658144).atZone(ZoneId.of("Etc/GMT-8")));
System.out.println(dateStr);
//輸出2022-03-19 10:49:04 +08:00

可以發現用Etc/GMT-8的話,Linux與Java的輸出都是一樣的了,是的,Etc/GMT-8也是一種類似GMT+8的時區表示機制,只不過它的+-號是反的。

Ok,雖然上面的差異弄清楚了,但時區的表示形式還沒有介紹完,接著往下看...

除了GMT+8表示方式外,我們還經常會看到UTC+8這樣的表示方式,這是UTC時區表示法。

即生GMT何生UTC?這是由於GMT是以格林尼治天文臺為時間基準,但地球不是完美球體且自轉速度在變慢,所以地球自轉速度並不均勻,這導致以格林尼治天文臺為時間基準是不準的。

為了更準確度量時間,科學家們發明了UTC時間,以銫原子躍遷次數來度量時間,比GMT時間更準確,為了保證GMT的準確性,每隔幾年GMT時間會做一次調整,以與UTC時間對齊。

因此,既然有了更準確的UTC,那麼就有了以UTC為字首的時區表示法,如中國時區可使用UTC+8

各時區偏移量表示法一覽表,如下:

偏移量表示法 描述
GMT+8 相對GMT多8個小時
Etc/GMT-8 同GMT+8,+-號相反
UTC+8 同GMT+8
GMT+08:00 精確到分鐘級別
GMT+08:00:00 精確到秒級別
GMT+0800 精確到分鐘級別,省略冒號
GMT+080000 精確到秒級別,省略冒號
+08:00 精確到分鐘級別,省略字首
+08:00:00 精確到秒級別,省略字首
+0800 精確到分鐘級別,省略字首與冒號
+080000 精確到秒級別,省略字首與冒號
Z 表示零時區,等同於GMT、UTC、GMT+0、UTC+0

時區的區域表示法

除了用偏移量來表示時區,為了方便,人們還按區域/城市的方式來定義時區,如Asia/ShanghaiAsia/Hong_Kong都表示東8區,具體有哪些城市命名的時區,可以在時區資料庫中檢視。

另外,為了簡化區域時區表示法,又定義了一套時區縮寫,如CST是中國時區China Standard Time的縮寫,可以在時區縮寫中檢視各種縮寫定義。

注意,一般都不建議使用時區縮寫,因為時區縮寫的命名經常會重複,比如CST是Central Standard Time(北美中部標準時間UTC -6)、China Standard Time(中國標準時間UTC +8)、Cuba Standard Time(古巴標準時間UTC -5)。

由於不同軟體對CST的解釋可能不同,導致會出現時間相差13或14個小時的情況,這在Java搭配MySQL時經常出現,我還專門寫了一篇文章mysql的timestamp會存在時區問題?,對於一定要使用時區縮寫的場景,可以使用香港時區縮寫HKT,它不重複且和上海處於同一個時區。

區域表示法 描述
Asia/Shanghai 上海時區,即東8區
CST 時區縮寫,慎用

Java中表示時區

在Java中和時區相關的類有TimeZone、ZoneId,其中TimeZone是老的時區類,而ZoneId是新的時區類,它有ZoneOffset和ZoneRegion兩個子類,分別代表偏移量表示法和區域表示法。

那它們都支援上述的哪些時區寫法呢?寫個Demo驗證一下,如下:

public static void main(String[] args) {
	printZoneId("+08:00");
	printZoneId("+0800");
	printZoneId("GMT+8");
	printZoneId("Etc/GMT-8");
	printZoneId("UTC+8");
	printZoneId("Asia/Shanghai");
	printZoneId("CST");
	printZoneId("Z");
}

public static void printZoneId(String zone){
	ZoneId zoneId;
	if(!ZoneId.SHORT_IDS.containsKey(zone)){
		zoneId = ZoneId.of(zone);
	}else{
		zoneId = ZoneId.of(ZoneId.SHORT_IDS.get(zone));
	}
	TimeZone timeZone = TimeZone.getTimeZone(zone);
	ZoneOffset zoneOffset = zoneId.getRules().getOffset(Instant.now());
	DateTimeFormatter dtf = DateTimeFormatter.ofPattern("xxx ZZZ O OOOO");
	System.out.printf("%-14s -> %-28s -> class:%s -> TimeZone.offset:%d \n", zone, dtf.format(zoneOffset),
			zoneId.getClass().getSimpleName(), timeZone.getRawOffset());
}

輸出如下:

+08:00         -> +08:00 +0800 GMT+8 GMT+08:00 -> class:ZoneOffset -> TimeZone.offset:0 
+0800          -> +08:00 +0800 GMT+8 GMT+08:00 -> class:ZoneOffset -> TimeZone.offset:0 
GMT+8          -> +08:00 +0800 GMT+8 GMT+08:00 -> class:ZoneRegion -> TimeZone.offset:28800000 
Etc/GMT-8      -> +08:00 +0800 GMT+8 GMT+08:00 -> class:ZoneRegion -> TimeZone.offset:28800000 
UTC+8          -> +08:00 +0800 GMT+8 GMT+08:00 -> class:ZoneRegion -> TimeZone.offset:0 
Asia/Shanghai  -> +08:00 +0800 GMT+8 GMT+08:00 -> class:ZoneRegion -> TimeZone.offset:28800000 
CST            -> -05:00 -0500 GMT-5 GMT-05:00 -> class:ZoneRegion -> TimeZone.offset:-21600000 
Z              -> +00:00 +0000 GMT GMT         -> class:ZoneOffset -> TimeZone.offset:0 
時區寫法 ZoneId TimeZone
+08:00 支援 不支援
+0800 支援 不支援
GMT+8 支援 支援
Etc/GMT-8 支援 支援
UTC+8 支援 不支援
Asia/Shanghai 支援 支援
CST 支援,代表北美西部時間,非中國標準時間 支援,代表北美西部時間,非中國標準時間
Z 支援 支援

偏移量表示法與區域表示法區別

雖然偏移量表示法與區域表示法都可以表示時區,但由於夏令時的存在,它們並不完全等同。

夏令時(Daylight Saving Time: DST),也叫 夏時制,是指為了節約能源,在天亮的早的夏季,人為將時間調快一小時,以充分利用光照資源,節約照明用電。

而中國在 1986 年至 1991 年也實行過夏令時,在1986~1991的每年從四月中旬第一個星期日的凌晨2時整(北京時間),將時鐘撥快一小時,即將錶針由2時撥至3時,夏令時開始;到九月中旬第一個星期日的凌晨2時整(北京夏令時),再將時鐘撥回一小時,即將錶針由2時撥至1時,夏令時結束。從1986年到1991年的六個年度,除1986年因是實行夏時制的第一年,從5月4日開始到9月14日結束外,其它年份均按規定的時段施行。

故會有下面看起來有點奇怪的現象:

DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV");
Instant instant = Instant.ofEpochSecond(515527200);
System.out.println(dtf.format(instant.atZone(ZoneId.of("Asia/Shanghai"))));
//輸出1986-05-04 03:00:00 Asia/Shanghai
System.out.println(dtf.format(instant.atZone(ZoneId.of("GMT+8"))));
//輸出1986-05-04 02:00:00 GMT+08:00

為什麼Asia/Shanghai輸出為3點,而GMT+8輸出為2點呢?原因是1986-05-04 02:00:00這個時間點中國正開始實行夏令時,時鐘撥快了1小時。

GMT+8為什麼輸出為2點呢?因為中國、馬來西亞、菲律賓、新加坡的時區都是GMT+8,只有中國在實行夏令時,而在GMT+8中沒法感知到區域資訊,那java只能以沒有實行夏令時的方法來計算本地時間了。

夏令時導致的奇怪現象

正是由於夏令時的存在,導致程式可能出現詭異的現象甚至bug,如下:

  1. 由於夏令時會將2點改成3點,導致2點沒了,所以date命令報錯了
$ TZ='Asia/Shanghai' date -d 1986-05-04T02:00:00 +%s
date: invalid date ‘1986-05-04T02:00:00’

$ TZ='Asia/Shanghai' date -d 1986-05-04T03:00:00 +%s
515527200
  1. 時間解析後再格式化輸出,發現不一樣了
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV");
ZonedDateTime time1 = ZonedDateTime.parse("1986-05-04 02:00:00 Asia/Shanghai", dtf);
System.out.println(time1.format(dtf));
//輸出1986-05-04 03:00:00 Asia/Shanghai
  1. 時間加1小時,發現加了2小時或根本沒變
public static void main(String[] args) {
	DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV");
	//加1小時剛好夏令時開始
	ZonedDateTime time1 = ZonedDateTime.parse("1986-05-04 01:00:00 Asia/Shanghai", dtf);
	printZonedDateTime(time1);
	printZonedDateTime(time1.plusHours(1));
	
	//加1小時剛好夏令時結束
	ZonedDateTime time2 = ZonedDateTime.parse("1986-09-14 01:00:00 Asia/Shanghai", dtf);
	printZonedDateTime(time2);   			
	printZonedDateTime(time2.plusHours(1));   
}

private static void printZonedDateTime(ZonedDateTime time){
	DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV");
	System.out.println(time.format(dtf));
}

輸出如下:

1986-05-04 01:00:00 Asia/Shanghai
1986-05-04 03:00:00 Asia/Shanghai  //加1小時,結果看起來加了2個小時
1986-09-14 01:00:00 Asia/Shanghai
1986-09-14 01:00:00 Asia/Shanghai  //加1小時,結果時間看起來沒變

為啥會這樣呢?原因是本地時間雖然看起來沒變,但Asia/Shanghai這個代表的時區卻發生了變化。

我們可以將上面printZonedDateTime中時間格式由yyyy-MM-dd HH:mm:ss VV修改為yyyy-MM-dd HH:mm:ss VV xxx再執行,發現輸出如下:

1986-05-04 01:00:00 Asia/Shanghai +08:00
1986-05-04 03:00:00 Asia/Shanghai +09:00
1986-09-14 01:00:00 Asia/Shanghai +09:00
1986-09-14 01:00:00 Asia/Shanghai +08:00

如上,夏令時導致Asia/Shanghai這個時區不一定是東8區了,也可能是東9區,故Java中,想將ZoneRegion轉換為ZoneOffset,需要傳遞一個instant時刻引數,如下:

//輸出+08:00
Instant instant = Instant.now();
System.out.println(ZoneId.of("Asia/Shanghai").getRules().getOffset(instant));
//輸出+09:00,在1986-05-04 02:00:00 +08:00處於夏令時,增加了1小時
Instant instant = Instant.ofEpochSecond(515527200);
System.out.println(ZoneId.of("Asia/Shanghai").getRules().getOffset(instant));

夏令時真是一種自欺欺人的做法,還好中國從1991年後就沒再實行了!

相關文章