Administrator
Administrator
发布于 2022-08-01 / 2018 阅读
0
0

Java、Mysql时区问题(常见的8小时偏差)

问题

很多童鞋在使用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°,分布在从东五区到东九区的五个时区内。为了便于东西间的联系,现在全国都采用东八区的标准时间,也就是“北京时间”,作为全国统一的时刻。

image-1659351155741

谈到时区就不得不提及几个重要的概念。

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主要是用来做LocalDateTimeInstant的转换处理的。因为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实现

上述DateLocalDateTime的转换不难发现,无可避免需要通过ZoneIdInstant。其实很好理解,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连接,这个后面再说。

结果如下:
image-1665208545326

这里解释一下:

  1. system_time_zone: 表示系统时区。MySQL会在启动时检查当前服务器的时区并设置为此值。上述截图的mysql是跑在docker容器里的,容器里linux是UTC时间(一般虚拟机或者物理机,既然在咱们中华国土,一般都是UTC+8了, 或者CST了,所以一般出现8小时问题的mysql多是跑在容器里的):
    image-1665209322900

  2. 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:009999-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区的服务器上:
image-1665209752322
那么会出现这样的情况:

情况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连接时指定时区,常常会有这些写法:

  1. serverTimezone=Asia/Shanghai&useTimezone=true
  2. serverTimezone=GMT%2B8&useTimezone=true
  3. 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;
    }
  1. 如果使用了jackson, 以下代码也可能导致时区问题: @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "Asia/Shanghai"), fastjson估计也有。

结论

  1. 尽可能是用UTC标准,而不是GMT
  2. 坚决不使用时区简称,因为会重名,而是采用完整字符,例如将CTT改为Asia/Shanghai(ZoneId类中有完整的映射关系)
  3. 如果MySQL不考虑服务多时区,那么就改MySQL的默认时区; 如果需要服务多时区,那么程序实例的jdbc连接就要用MySQL的实际时区——即使带来的后果是MySQL存储的时间会区别于程序所在的时区时间,但是既然要服务多时区,MySQL中的时间就该兑现MySQL的时区,而不是某个程序实例的时区。

引用

感谢批评指正!


评论