问题
很多童鞋在使用MySQL的过程中, 会遇到时间字段会有8小时“误差”问题, debug发现,明明入库的时候,值是正确的,但是一旦插入数据库就提早了8小时。
今天,我打算重新审视一下“时区”这个概念,以及从使用层面重新审视下java,以及MySQL是怎么处理时区这个问题。这篇文章将从以下几个部分展开:
- 时区相关概念和意义
- Java中的时区处理
- MySQL设置时区
时区(TimeZone)相关概念和意义
时区的概念不必赘述,高中地理就学过。沿着赤道将地球分成24等分,每一份经度为15度,称作一个时区, 将地球分为24个时区。
中国科学院地理科学与资源研究所是这么阐述“如何划分地球时区”这个问题的:
地球表面的每个地点都有自己的子午线,太阳经过当地子午线的时刻就是正午。同一条子午线上的不同地点,时间都是一样的。这种各个地点根据太阳和本地子午线所定出的时间,叫做地方时。
经度不同的地点,时间都不相同。经度相差1°,地方时相差4分钟。经度相差15°,地方时就相差l小时。地方时是依据太阳高度作为标准得到的,是自然时。它有许多优点,但也有不少问题。特别是容易引起混乱,给交通、邮电等事业带来不便。为解决这个问题,1884年世界各国根据协议,沿着赤道把地球分成24等份,每份占经度15°,称作一个时区,这样全球就共划分为24个时区。其中,本初子午线所在的一区叫中区或零时区,包括西经7°30′至东经7°30′的范围,全区的时刻都以处于该区中心的0°经线的地方时为准。按照这个划法,中区以东和以西各划出12个时区,把无数个地方时统一规划为24个,就大大简化了地方时。但是,世界各地时区的划分,并不严格按照经线定界,还要考虑行政界线等具体情况加以调整。这就产生了各国具体的标准时。
中国面积广大,东西横跨经度64°,分布在从东五区到东九区的五个时区内。为了便于东西间的联系,现在全国都采用东八区的标准时间,也就是“北京时间”,作为全国统一的时刻。
谈到时区就不得不提及几个重要的概念。
GMT - 格林尼治平均时间(Greenwich Mean Time,GMT)
是指位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时,因为本初子午线被定义为通过那里的经线。
自1924年2月5日开始,格林尼治天文台负责每隔一小时向全世界发放调时信息。
格林尼治标准时间的正午是指当平太阳横穿格林尼治子午线时(也就是在格林尼治上空最高点时)的时间。由于地球每天的自转是有些不规则的,而且正在缓慢减速,因此格林尼治平时基于天文观测本身的缺陷,已经逐步被原子钟报时的协调世界时(UTC)所取代。
UTC - 协调世界时(Coordinated Universal Time)
是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林威治标准时间。
协调世界时是世界上调节时钟和时间的主要时间标准,它与0度经线的平太阳时相差不超过1秒,并不遵守夏令时。协调世界时是最接近格林威治标准时间(GMT)的几个替代时间系统之一。对于大多数用途来说,UTC时间被认为能与GMT时间互换,但GMT时间已不再被科学界所确定。
CST - 中国标准时间: China Standard Time (utc+8)
之前提到的24个时区中,很多时区都有自己的简称。CST就是众多时区简称中的一个,或者说多个,为什么说多个呢,因为既然中国标准时间(China Standard Time), 可以缩写为CST, 自然也有其他撞名的了。所以,为了避免歧义,一般用CTT代替。 但是依然不建议使用时区简称来表示时区
Java中的时区处理
Java中涉及时区的主要有以下几个类:
Class | From Version |
---|---|
java.util.TimeZone; | 1.1 |
java.time.ZoneId; | 1.8 |
java.time.ZoneOffset extends ZoneId; | 1.8 |
TimeZone基本被1.8的两个新类给替代了,平时不要用了。
ZoneId
ZoneId主要是用来做LocalDateTime
和Instant
的转换处理的。因为LocalDateTime使用了系统时区直接生成时间,对象信息内不包含时区信息,而Instant需要包含时区信息,所以在转换时需要指定时区。
Instant
表示时间线上的一点(时间戳),LocalDateTime
也表示时间点。他们两者不同之处就是Instant
的时间点是带上了时区的,Instant
放到任意时区都是代表的同一个时刻,而LocalDateTime
只表示服务器所在的时区的那个时间点。ZoneId
代表时区ID,所以:ZonedDateTime = LocalDateTime + ZoneId
java常见的时间操作:
// 获取系统默认时区
ZoneId = ZoneId.systemDefault();
// 获取所有可用的时区ID
Set<String> availableZoneIds = ZoneId.getAvailableZoneIds();
// 1. Date 转 LocalDateTime. 通过ZoneId可以将date转为不同时区的本地时间
// ----- 方法一
Date date = new Date();
LocalDateTime localDateTime1 = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
// ----- 方法二
LocalDateTime localDateTime2 = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
// 2. LocalDateTime 转 Date
LocalDateTime now = LocalDateTime.now();
Date date = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); // 同样也需要借助ZoneId实现
上述
Date
和LocalDateTime
的转换不难发现,无可避免需要通过ZoneId
和Instant
。其实很好理解,Date
是一个绝对时间,代表一个绝对的历史时刻,无论在哪个时区,都是那个时刻,但是要说是几号几点几分,那得看在哪个时区,所以,转为LocalDateTime时,就得明确是哪个时区(ZoneId
),最后借助Instant
完成转换。
ZoneOffset
见名知意, ZoneOffset 是对时区偏移量的一个调整: 距离格林威治/UTC的时区偏移量,例如+02:00。
Instant utcNow = Instant.now();
LocalDateTime utcNowLocalDateTime = utcNow.atZone(ZoneId.systemDefault()).toLocalDateTime();
System.out.println("当前时区下的当前时间:" + utcNowLocalDateTime);
OffsetDateTime utc8Offset = utcNow.atOffset(ZoneOffset.ofHours(+8));
System.out.println("当前时区偏移+8后所在时区的当前时间(OffsetDateTime):" + utc8Offset);
System.out.println("当前时区偏移+8后所在时区的当前时间(Instant):" + utc8Offset.toInstant());
Instant utc8Now = utc8Offset.toInstant();
LocalDateTime utc8NowLocalDateTime = utc8Now.atZone(ZoneId.systemDefault()).toLocalDateTime();
System.out.println("当前时区下的当前时间:" +utc8NowLocalDateTime); // 虽然偏移了+8,但是仍旧是转为当前时区的本地时间。无论偏移多少,代表的时刻都是一样的,只是在不同时区下表现的时间不同而已
// 输出如下:
// 当前时区下的当前时间:2022-10-08T13:47:07.896
// 当前时区偏移+8后所在时区的当前时间(OffsetDateTime):2022-10-08T13:47:07.896+08:00
// 当前时区偏移+8后所在时区的当前时间(Instant):2022-10-08T05:47:07.896Z
// 当前时区下的当前时间:2022-10-08T13:47:07.896
MySQL时区
MySQL作为一个数据库,无可避免需要存储时间数据,比如,存储一个2022-10-08 13:50:44
这样的时间,那这个时间代表的是哪个时区的时间呢?是北京的10月8号13点50分?还是纽约的,或者巴黎的10月8号13点50分?这是数据库要明确的。
配置time_zone和system_time_zone
通过show variables like '%time_zone%';
命令,查看MySQL的时区。
要通过cli或者navicat等工具,而不能通过jdbc连接,这个后面再说。
结果如下:
这里解释一下:
system_time_zone
: 表示系统时区。MySQL会在启动时检查当前服务器的时区并设置为此值。上述截图的mysql是跑在docker容器里的,容器里linux是UTC时间(一般虚拟机或者物理机,既然在咱们中华国土,一般都是UTC+8了, 或者CST了,所以一般出现8小时问题的mysql多是跑在容器里的):
time_zone
的值将作为MySQL设置连接会话的默认时区。上述截图中值为SYSTEM
, 表示使用系统时区,也就是第一个变量值——UTC
。 因此确定了,所有与mysql连接的会话下,mysql存储的时间就是UTC时间。也就是说,通过JDBC连接的应用程序,存储在mysql的时间数据都明确了就是UTC的时间,也就是明确了上面2022-10-08 13:50:44
这个时间是哪个时区的问题——2022-10-08 13:50:44
是0时区的时间。
MySQL的时间字段区别
字段类型 | 范围 | 存取方式 | 时区 |
---|---|---|---|
date | 1000-01-01 to 9999-12-31 |
字符串存储 | 时区无关 |
datetime | 1000-01-01 00:00:00 到 9999-12-31 23:59:59 |
字符串存储 | 时区无关 |
timestamp | 1970-01-01 00:00:01 UTC to 2038-01-19 03:14:07 UTC |
MySQL会将timestamp值转换为UTC时间,底层以时间戳进行存储,取的时候转换为取时的时区时间。因此同样的值,不同时区会话查询的值可能不同——这也是优点,完美自适配多时区支持。 | 存储时区 |
问题: 应用程序所在服务器的时区,和MySQLtime_zone
不一致时
既然上述MySQL采用的UTC时间,那么与此MySQL建立JDBC连接的应用程序,也得跑在UTC时区的服务器上吧,否则时间显示就有问题。比如,程序跑在咱们东8区的服务器上:
那么会出现这样的情况:
情况1
create_time
字段默认值为CURRENT_TIMESTAMP
, 结果明明是14:20:00
(程序服务器时间)创建的记录,但是这个字段因为是mysql默认生成的,结果是采用UTC时区的CURRENT_TIMESTAMP
, 值变成了06:20:00
了—— 很显然啊,北京时间14:20:00
时, UTC(0时区)就是06:20:00
,没一点问题。
情况2
由于数据库的时区是UTC, 因此, mysql now()
函数也将是UTC的时间,若 程序中存在时间字段与 now()
比较、或select now()等, 也将会产生非预期的结果。
解决办法
上述问题: 应用程序所在服务器的时区,和MySQLtime_zone
不一致时。 产生的类似于8小时时差问题,如何解决?有两个办法!
办法1: 修改mysql时区
修改MySQL的数据库时区,又分为命令行修改(mysql重启失效)和mysql配置文件修改(永久生效)
命令行修改时区
set global time_zone = '+8:00';
mysql配置文件修改
修改my.ini配置文件:
[mysqld]
// 设置默认时区
default-time_zone='+8:00'
办法2: jdbc连接时指定时区
spring:
datasource:
url: jdbc:mysql://xxx.xxx.xxx.xxx:3306/dbname?characterEncoding=utf8&useSSL=false&zeroDateTimeBehavior=convertToNull&tinyInt1isBit=false&serverTimezone=UTC&useTimezone=true
上述jdbc连接中指定了serverTimezone=UTC&useTimezone=true
, 其含义如下:
serverTimezone
: 告诉jdbc,数据库采用的时区是什么。
useTimezone
: 若为true, 则写入数据库时,jdbc会自动将程序中的时间(程序所在服务器的时区),转换为数据库的时区对应的时间。这里,jdbc就认为数据库的时区是(serverTimezone
指定的)UTC。读取时,亦然。因此会发现数据库里存储的时间依然是8小时前的,但是程序能正常读写。因为jdbc已经知道了数据库采用的时区,因此会将其转换为服务器时区所对应的时间。
上述两种办法显然各有优缺点。
办法 | 优点 | 缺点 |
---|---|---|
修改mysql时区 | 一劳永逸 | 当数据需要被不同时区的应用访问和修改时, 应用需要意识到mysql的默认时区已经变了,并做好处理 |
jdbc连接时指定时区 | 数据存储时区不受程序影响 | 虽然程序读写没问题,但是数据库里存的是UTC时间,DBA需要牢记这点,换句通俗的话说,就是DBA操作数据库时需要牢记自己的时间值是在UTC 0时区下的。而且SQL脚本移交也需要注意这点 |
更多
其实明确一点后,mysql时区问题就好理解和处理了:
mysql的时区配置time_zone
代表的含义是 —— mysql中存储的时间是在哪个时区下的。这个含义非常明确。而jdbc中的serverTimeZone含义是告诉jdbc,mysql 的时区是什么,方便jdbc后续自动转换程序时间和数据库时间
个人认为我们使用jdbc指定时区时存在一个很大的误区:
比如很多时候MySQL时区是UTC时,当我们选择不改MySQL时区,而采用jdbc连接时指定时区,常常会有这些写法:
serverTimezone=Asia/Shanghai&useTimezone=true
serverTimezone=GMT%2B8&useTimezone=true
serverTimezone=CTT&useTimezone=true
这样写的确看似解决了问题:程序时间是多少,入库后就是多少。数据库里是多少,查出来就是多少。
但实则导致了数据库时间数据和数据库时区的含义不一致问题: 库里的时间数据其实是东8区的,但是mysql的时区设置的依然是UTC的。如果这个mysql脱离了程序独立出来,那么其中的时间值都是错的。
而数据库本就应该可以脱离程序而自洽的不是吗?
另外,上述这么写并没有完全解决问题。经过测试,对于程序中可能存在的时间字段默认值CURRENT_TIMESTAMP
, 依然存在8小时问题!
8小时差问题只会是JDBC程序和MySQL时区不一致导致的吗?
答案是否定的。还有个坑:应用中使用的fastjson或jackson,也可能导致时区不一致而时间不同的问题。
比如,jackson是可以配置:
spring:
jackson:
time-zone: GMT+8 // 表示jackson
同样java代码里也可以配置:
@Bean
public MappingJackson2HttpMessageConverter jackson2HttpMessageConverter() {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
ObjectMapper mapper = new ObjectMapper();
mapper.setTimeZone(TimeZone.getDefault()); // 时区设置
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.setDateFormat(new SimpleDateFormat(YYYY_MM_DD_HH_MM_SS));
converter.setObjectMapper(mapper);
return converter;
}
- 如果使用了jackson, 以下代码也可能导致时区问题:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "Asia/Shanghai")
, fastjson估计也有。
结论
- 尽可能是用UTC标准,而不是GMT
- 坚决不使用时区简称,因为会重名,而是采用完整字符,例如将
CTT
改为Asia/Shanghai
(ZoneId类中有完整的映射关系) - 如果MySQL不考虑服务多时区,那么就改MySQL的默认时区; 如果需要服务多时区,那么程序实例的jdbc连接就要用MySQL的实际时区——即使带来的后果是MySQL存储的时间会区别于程序所在的时区时间,但是既然要服务多时区,MySQL中的时间就该兑现MySQL的时区,而不是某个程序实例的时区。
引用
- MySQL 5.7 Reference Manual#Time Zone Variables
- spring配置文件中数据库配置serverTimeZone设置的作用
- Mysql JDBC里的useTimezone参数是做什么用的
- Spring连接Mysql获取数据时间快8小时或慢8小时时区问题
- MySQL的时区应该如何配置
感谢批评指正!