前言
國際化的業務場景中經常會遇到時區轉換的問題,處理不好就會産生各種各樣的問題。下面就産生時區問題的原因做個總結。時區的概念這裡就不解釋了
認識時區

因為時區的存在,同一時刻不同地區的時間是不一樣的。Java中,這個“同一時刻”可以用java.util.Date#getTime表示,即距離"時間紀元"(1970年1月1日0時0分0秒)的毫秒數。如果在不同時區的國家部署多台伺服器,伺服器時區取當地行政時區(未指定情況下,Java預設使用系統時區)那麼,同時在不同伺服器上Date date = new Date(),那麼date.getTime()的值是一樣的
String format = "yyyy-MM-dd HH:mm:ss S";
//同一時刻不同時區的時間展示
Date date = new Date(1527405234768L);
SimpleDateFormat sdf1 = new SimpleDateFormat(format);
sdf1.setTimeZone(TimeZone.getTimeZone("GMT+8"));
System.out.println(sdf1.format(date));
SimpleDateFormat sdf2 = new SimpleDateFormat(format);
sdf2.setTimeZone(TimeZone.getTimeZone("GMT+7"));
System.out.println(sdf2.format(date));
//不同時區時間換算成同一時刻
String s1 = "2018-05-27 15:13:54 768";
String s2 = "2018-05-27 14:13:54 768";
SimpleDateFormat sdf3 = new SimpleDateFormat(format);
sdf3.setTimeZone(TimeZone.getTimeZone("GMT+8"));
System.out.println(sdf3.parse(s1).getTime());
SimpleDateFormat sdf4 = new SimpleDateFormat(format);
sdf4.setTimeZone(TimeZone.getTimeZone("GMT+7"));
System.out.println(sdf4.parse(s2).getTime());
運作結果:
2018-05-27 15:13:54 768
2018-05-27 14:13:54 768
1527405234768
1527405234768
時間的存在形式
JVM中同一時間可以用毫秒數long、字元串字面量+時區、java.util.Date對象來表示,3者可以等價轉換。當一台伺服器向不同時區的其他伺服器傳輸時間時,可以使用以上任一方式傳輸:
- 毫秒數long:其他伺服器根據自己的時區進行反序列化。時間可以正确處理
- 字元串字面量+時區:如果隻傳輸字元串字面量str1,丢失時區資訊timeZone1,那麼其他伺服器接收後就會以為字面量str1是基于自己的時區timeZone2的,反序列化後就得到了錯誤的時間。比如用json序列化一個Date對象進行傳輸而沒有指定時區的場景
- Date對象:需要序列化成long,或者字元串字面量+時區
時區轉換的場景
使用者Browser <=> Web Server
Web Server => Browser
- 若二者時區相同(已将server伺服器設定為當地時區),那麼可以在server端将時間格式化為字元串字面量直接傳輸到Browser端展示。其中,若server端時間為其他時區的字元串字面量時,需轉為目前時區;若為Date對象,直接format,Java預設取目前系統時區;若為毫秒long,轉為Date再格式化
- 若時區不同,server端将時間轉換成毫秒數long或者字面量+時區,傳輸到Browser,由browser端js基于browser的時區進行轉換處理,最終展示。
Browser => Web Server
如使用者通過前端時間控件選擇的時間,需要轉化為毫秒數long或者字面量+時區,傳輸到server端。
Server-n <=>Server-k
取決于時間的序列化和反序列方式,如HSF用hession将Date對象序列化為毫秒數、json将時間序列化為字元串(需要指定時區)
Server-n <=> DB-MySQL
時間序列化和反序列化
MySQL中時間可以用bigint(存毫秒數long),DateTime,TimeStamp表示。 使用bigint不存在時區問題,這裡不寫了。DateTime和TimeStamp詳細差別參考
官方文檔。那麼這裡server和mysql的時間序列化方式是什麼呢,可以看下MySQL的驅動包源碼,下面貼下幾個源碼片段。代碼有點多慢慢看,直接說結論吧:使用不帶時區的時間字元串
public Timestamp getTimestamp(int columnIndex) throws SQLException {
checkRowPos();
checkColumnBounds(columnIndex);
return getDateOrTimestampValueFromRow(columnIndex, this.defaultTimestampValueFactory);
}
private T getDateOrTimestampValueFromRow(int columnIndex, ValueFactory vf) throws SQLException {
Field f = this.columnDefinition.getFields()[columnIndex - 1];
// return YEAR values as Dates if necessary
if (f.getMysqlTypeId() == MysqlaConstants.FIELD_TYPE_YEAR && this.yearIsDateType) {
return getNonStringValueFromRow(columnIndex, new YearToDateValueFactory<>(vf));
}
return getNonStringValueFromRow(columnIndex, new YearToDateValueFactory<>(vf));
}
/**
* Get a non-string value from a row. All requests to obtain non-string values should use this method. This method implements the "indirect" conversion of
* values that are returned as strings from the server. This is an expensive conversion which first requires interpreting the value as a string in it's
* given character set and converting it to an ASCII string which can then be parsed as a numeric/date value.
*/
private T getNonStringValueFromRow(int columnIndex, ValueFactory vf) throws SQLException {
Field f = this.columnDefinition.getFields()[columnIndex - 1];
// interpret the string as necessary to create the a value of the requested type
String encoding = f.getEncoding();
StringConverterstringConverter = new StringConverter<>(encoding, vf);
stringConverter.setEventSink(this.eventSink);
stringConverter.setEmptyStringsConvertToZero(this.emptyStringsConvertToZero.getValue());
return this.thisRow.getValue(columnIndex - 1, stringConverter);
}
其中StringConverter中的createFromBytes方法部分代碼如下
} else if (s.length() == MysqlTextValueDecoder.DATE_BUF_LEN && s.charAt(4) == '-' && s.charAt(7) == '-') {
return stringInterpreter.decodeDate(bytes, 0, bytes.length, vf);
} else if (s.length() >= MysqlTextValueDecoder.TIME_STR_LEN_MIN && s.length() <= MysqlTextValueDecoder.TIME_STR_LEN_MAX && s.charAt(2) == ':'
&& s.charAt(5) == ':') {
return stringInterpreter.decodeTime(bytes, 0, bytes.length, vf);
} else if (s.length() >= MysqlTextValueDecoder.TIMESTAMP_NOFRAC_STR_LEN
&& (s.length() <= MysqlTextValueDecoder.TIMESTAMP_STR_LEN_MAX || s.length() == MysqlTextValueDecoder.TIMESTAMP_STR_LEN_WITH_NANOS)
&& s.charAt(4) == '-' && s.charAt(7) == '-' && s.charAt(10) == ' ' && s.charAt(13) == ':' && s.charAt(16) == ':') {
return stringInterpreter.decodeTimestamp(bytes, 0, bytes.length, vf);
}
MySQL時間類型
- DateTime類型字段,MySQL存儲時不存時區資訊,并且怎麼存就怎麼取,不做任何處理和轉換。是以時區timeZone1的server1插入MySQL一條記錄後,時區timeZone2的server2讀取出來的時間就不對了。這裡隻能将所有的server的時區設定為一樣的,或者在資料庫表中添加一個字段存儲時區資訊
- TimeStamp類型字段,這個比較特殊。當server建立connection時,可以在資料庫URL中手動指定時區資訊,即不同時區的server連接配接MySQL時,指定connection時區使用自己所在時區。當MySQL處理不同的connection時,就有了時間字元串和送出請求的時區,然後轉換為UTC時間進行存儲。從MySQL中讀取時也是基于connection的時區設定進行轉換。但是如果不指定connection時區,那麼MySQL就将存儲的UTC時間,按MySQL伺服器所在時區進行轉換和展示或者傳輸,此時若MySQL伺服器和server的時區不一緻,就會出現時區問題
由上可知,産生時區問題的根本原因在于不同時區的機器對時間進行序列化和反序列化時,Date對象或者毫秒數long與字元串之間的轉換,丢失了時區資訊,最終導緻問題