我们最近遇到了一个问题,是时区导致的,测试代码往数据库里写了一个字符串时间,比如 2021-12-08 22:04:05 ,然后业务查出来的却是 2021-12-08 14:04:05,这个字段的定义是datetime;问题出在哪里了呢?
谁谁转换时间的时候带上了时区?注意此时测试主机、MySQL、模块都是CST时区即北京时间。
测试认为,我写入的啥时间字符串就应该返回啥字符串,就不应该存在时区转换问题。
很容易想到,直接原因肯定就是第一个访问数据库的模块与数据库时区不一致,造成数据库中DateTime在取出来的时候发生了转化,当然这是猜测的。
当时另一个猜测是,第一个访问数据库的模块不是UTC时区,它在把DateTime转Timestamp时出了错。
到底是哪个原因呢?
请求流程是这样 请求代码–>Python模块A–>Java模块B–>MySQL,同时会原路返回
核实到模块A收到模块B的回复中是个时间戳,这个时间戳T1转换成时间字符串并不是期望的时间,而是减了8h(错误的直接原因);手动转换T1,在UTC时区,这个模块A的转换本身是没错的【这里说的什么?】;模块A将时间戳不带时区信息地转换成了时间字符串。
模块B中对entity的定义中,该字段为java.util.Date,那它返回给A的Json,是怎么变成时间戳的呢?我注意到模块B提供的Rest HTTP API是通过一个叫Jersey的包(它居然还是在javax包下),那这个框架是怎么把对象序列化到Json的呢?通过日志看到,模块B打出了请求内容到请求返回的所有字段,依此按图索骥查得为Jackson的ObjectMapper,这就好办了。
于是本地自己测试ObjectMapper,追踪到com.fasterxml.jackson.databind.ser.std.DateSerializer#_timestamp
方法
/** protected long _timestamp(Date value) { * return value == null ? 0L : value.getTime(); * } *
* 时间戳是没有时区概念的,它表示的就是UTC的1970年那个时间点距今的秒数 * 同样System.currentMillions * 同样Date.getTtime-->getTimeImpl ** private final long getTimeImpl() { * if (cdate != null && !cdate.isNormalized()) { * normalize(); * } * return fastTime; * } *
* isNormalized 表示是否正常的时间,如果调用了什么addHour,addYear,就不是normalized的了 * 所以结论就是,Spring 等使用的反序列化工具是不考虑时区的
就是返回了Date#getTime方法,注意:时间戳是没有时区概念的
所以结论就是,Spring 等使用的反序列化工具是不考虑时区的
String dateStr = "2021-12-08 16:00:00";
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = format.parse(dateStr);
System.out.println(date.getTime());
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
SimpleDateFormat format2 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
date = format2.parse(dateStr);
System.out.println(date.getTime());
*/*
输出为
1638979200000
1638950400000
*/
注意TimeZone的修改必须在定义DateFormat之前;必须重新定义衣蛾DateFormat,详情见DateFormat的构造方法初始化日历的时候用了默认时区
可见时间戳与字符串时间之间相互转化是依赖时区的,Date实例本身是没有时区的
Linux下时区判断可以用date
看到,那date是依赖什么来判断时区的呢?答:TZ
环境变量,这是POSIX标准指定的debugging-java-program-for-changing-timezone-configuration-file-on-ubuntu当没TZ
环境变量时,则GNU系统库会使用文件 /etc/localtime
(它其实是一个链接文件,指向/usr/share/zoneinfo
中的一个文件)
所有的时区信息定义在/usr/share/zoneinfo
,可以进行测试 TZ='Asia/Shanghai' date
一些系统上可能需要 TZ=':Asia/Shanghai' date
how-can-i-have-date-output-the-time-from-a-different-timezone
Ubuntu与Debian系定义了个更友好的文件:/etc/timezone
Java也更愿意使用文件/etc/timezone
,所以一般对单个应用程序,使用TZ
环境变量即可
改变系统时区sudo timedatectl set-timezone UTC
ConnectJ主要有两个:5.1和8.0
8.0有篇文章描述了时间:Preserving Time Instants 保留时间片刻
TIMESTAMP is the only MySQL data type designed to store instants. To preserve time instants, the server applies time zone conversions in incoming or outgoing time values when needed. Incoming values are converted by server from the connection session’s time zone to Coordinated Universal Time (UTC) for storage, and outgoing values are converted from UTC to the session time zone. Starting from MySQL 8.0.19, you can also specify a time zone offset when storing TIMESTAMP values (see The DATE, DATETIME, and TIMESTAMP Types for details), in which case the TIMESTAMP values are converted to the UTC from the specified offset instead of the session time zone. But, once stored, the original offset information is no longer preserved.
TIMESTAMP是唯一的MySQL用来存储的时刻的数据类型。为了存储时刻,在需要的时候,MySQL server在存储、读取时间时进行了时区转换。存储时,服务器把进来的数据从session的时区转换到了UTC,取出的时候,则将UTC时间转化成session的时区。从MySQL 8.0.19版本开始,你可以在存储TIMESTAMP值时指定一个时区,这时进行时区转换时就使用了指定的时区而不是session的时区。但是一旦数据被存储,就不会再保存原始的时区信息。
StackOverFlow sql.Date和util.Date
MySQL文档中又有一句:
Therefore, do not pass instant date-time types (java.util.Calendar, java.util.Date, java.time.OffsetDateTime, java.sql.Timestamp) to non-instant date-time types (for example, java.sql.DATE, java.time.LocalDate, java.time.LocalTime, java.time.OffsetTime) or vice versa, when working with the server.
啊,因此不要把即时时间传给非即时时间;啥叫即时时间啊?
似乎这个东西应该不叫即时时间,应该叫时刻。同一时刻,是和时区不相关的,比如不管美国现在几点,都是和我在同一时刻。而non-instant time就不行,它依赖时区,比如现在2021-12-9 18:00:00 不同时区的此LocalDateTime是不同的时刻。
引用一个Stack Overflow回答whats-the-difference-between-instant-and-localdatetime;推荐这篇回答
So Local… means “not zoned, no offset”. Local表示没有时区,没有偏移。
弄懂了instant概念,再回到connectJ,讲述当时区不同时的处理:
可以看大MySQL本身就能很好得处理时区转换,根本不需要模块去操心。
看到了吗,TIMESTAMP随着时区自己在变,而DATETIME永远不会变,就像生日一样。
python模块丢失了时区信息;因为数据库与模块B时区配置相同,没有暴露出datetime问题
写入数据过程:测试模块向数据库datetime字段写入字符串时间
读取数据过程:MySQL datetime --> java.util.date(东八区时间) --> 转换成时间戳,东八区-8h --> 时间戳转换成字符串格式丢失时区
数据库设计时,分清到底是需要instant还是non-instant,instant就MySQL使用TIMSTAMP类型,否则DateTime,
编写代码时,datetime本身是没有时区信息的,所以在模块中处理时也不应当涉及时区,不应该与java.util.Date匹配使用,而应该是LocalDateTime。
而且如今已经Java8年代,要与时俱进:https://stackoverflow.com/a/61198758
datetime: java.time.LocaleDateTime 曾经:java.sql.Date
timestamp: java.time.Instant 曾经:java.util.Date
可见我们在瞎使用。