前言
国际化的业务场景中经常会遇到时区转换的问题,处理不好就会产生各种各样的问题。下面就产生时区问题的原因做个总结。时区的概念这里就不解释了
认识时区
因为时区的存在,同一时刻不同地区的时间是不一样的。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);
}
privateT 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.
*/
privateT 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与字符串之间的转换,丢失了时区信息,最终导致问题