我对SQL DATE数据类型的行为与java.sql.Date
的行为感到有些困惑。以以下语句为例:
select cast(? as date) -- in most databases
select cast(? as date) from dual -- in Oracle
让我们使用 Java 准备并执行语句
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setDate(1, new java.sql.Date(0)); // GMT 1970-01-01 00:00:00
ResultSet rs = stmt.executeQuery();
rs.next();
// I live in Zurich, which is CET, not GMT. So the following prints -3600000,
// which is CET 1970-01-01 00:00:00
// ... or GMT 1969-12-31 23:00:00
System.out.println(rs.getDate(1).getTime());
换句话说,我绑定到语句的 GMT 时间戳将成为我返回的 CET 时间戳。时区是在哪一步添加的,为什么?
注意:
我观察到这些数据库中的任何一个都是如此:
DB2, Derby, H2, HSQLDB, Ingres, MySQL, Oracle, Postgres, SQL Server, Sybase ASE, Sybase SQL Anywhere
- 我观察到这对于SQLite来说是错误的(它实际上没有真正的
DATE
数据类型( - 当使用
java.sql.Timestamp
而不是java.sql.Date
时,所有这些都无关紧要 - 这是一个类似的问题,但是没有回答这个问题:java.util.Date vs java.sql.Date
JDBC 规范没有定义有关时区的任何细节。尽管如此,我们大多数人都知道必须处理 JDBC 时区差异的痛苦;看看所有的StackOverflow问题!
最终,日期/时间数据库类型的时区处理归结为数据库服务器、JDBC 驱动程序以及介于两者之间的所有内容。您甚至会受到 JDBC 驱动程序错误的摆布;PostgreSQL 修复了 8.3 版中的一个错误,其中
传递日历对象的 Statement.getTime、.getDate 和 .getTimestamp 方法以错误的方向旋转时区。
当您使用 new Date(0)
创建新日期时(假设您使用的是 Oracle JavaSE java.sql.Date
,您的日期已创建
使用给定的毫秒时间值。如果给定的毫秒值包含时间信息,则驱动程序会将时间部分设置为默认时区(运行应用程序的 Java 虚拟机的时区(中对应于零 GMT 的时间。
因此,new Date(0)
应该使用GMT。
当您调用 ResultSet.getDate(int)
时,您正在执行 JDBC 实现。JDBC 规范没有规定 JDBC 实现应如何处理时区详细信息;因此,您可以受制于实施。查看Oracle 11g oracle.sql.DATE
JavaDoc,Oracle DB似乎不存储时区信息,因此它执行自己的转换以将日期转换为java.sql.Date
。我没有使用 Oracle DB 的经验,但我猜 JDBC 实现正在使用服务器和本地 JVM 的时区设置来执行从 oracle.sql.DATE
到 java.sql.Date
的转换。
您提到多个RDBMS实现可以正确处理时区,SQLite除外。让我们看看当您将日期值发送到 JDBC 驱动程序以及从 JDBC 驱动程序获取日期值时,H2 和 SQLite 是如何工作的。
H2 JDBC 驱动程序PrepStmt.setDate(int, Date)
使用 ValueDate.get(Date)
,它调用执行时区转换的DateTimeUtils.dateValueFromDate(long)
。
使用此 SQLite JDBC 驱动程序,PrepStmt.setDate(int, Date)
调用PrepStmt.setObject(int, Object)
,并且不执行任何时区转换。
H2 JDBC 驱动程序JdbcResultSet.getDate(int)
返回get(columnIndex).getDate()
。 get(int)
返回指定列的 H2 Value
。由于列类型是DATE
,H2 使用 ValueDate
。 ValueDate.getDate()
调用DateTimeUtils.convertDateValueToDate(long)
,最终在时区转换后创建一个java.sql.Date
。
使用此SQLite JDBC驱动程序,RS.getDate(int)
代码要简单得多;它只是使用存储在数据库中的long
日期值返回java.sql.Date
。
因此,我们看到 H2 JDBC 驱动程序在处理带有日期的时区转换方面很聪明,而 SQLite JDBC 驱动程序则不然(并不是说这个决定不聪明,它可能非常适合 SQLite 设计决策(。如果你追寻你提到的其他RDBMS JDBC驱动程序的来源,你可能会发现大多数都以与H2类似的方式接近日期和时间区。
虽然JDBC规范没有详细说明时区处理,但RDBMS和JDBC实现设计人员考虑时区并正确处理时区是有道理的;特别是如果他们希望他们的产品在全球舞台上销售。这些设计师非常聪明,即使没有具体的规范,他们中的大多数人也能做到这一点,我并不感到惊讶。
我在SQL Server博客中使用时区数据Microsoft SQL Server 2008中找到了这个,该博客解释了时区如何使事情复杂化:
时区是一个复杂的领域,每个应用程序都需要解决如何处理时区数据的问题,以使程序更加用户友好。
不幸的是,目前没有时区名称和值的国际标准权威机构。 每个系统都需要使用自己选择的系统,在有国际标准之前,尝试让SQL Server提供一个是不可行的,最终会导致比它解决的问题更多的问题。
转换的是 jdbc 驱动程序。 它需要将 Date 对象转换为 db/wire 格式可接受的格式,当该格式不包含时区时,在解释日期时倾向于默认为本地计算机的时区设置。 因此,鉴于您指定的驱动程序列表,最有可能的情况是将日期设置为 GMT 1970-1-1 00:00:00,但将其设置为语句时的解释日期为 CET 1970-1-1 1:00:00。 由于日期只是日期部分,因此您会将 1970-1-1(没有时区(发送到服务器并回显给您。 当驱动程序获取日期,并且您将其作为日期访问时,它会看到 1970-1-1 并使用本地时区再次解释该时区,即 CET 1970-1-1 00:00:00 或 GMT 1969-12-31 23:00:00。 因此,与原始日期相比,您"损失"了一个小时。
java.util.Date 和 Oracle/MySQL Date 对象都只是时间点的表示,与位置无关。这意味着它很可能在内部存储为自"纪元"(GMT 1970-01-01 00:00:00(以来的毫/纳秒数。
当您从结果集中读取时,对"rs.getDate(("的调用会告诉结果集获取包含时间点的内部数据,并将其转换为 java Date 对象。此日期对象是在本地计算机上创建的,因此 Java 将选择您的本地时区,即苏黎世的 CET。
你看到的差异是表示的差异,而不是时间的差异。
类java.sql.Date
对应于不存储时间或时区信息的 SQL DATE。完成此操作的方式是通过"规范化"日期,就像javadoc所说的那样:
为了符合 SQL DATE 的定义,必须通过将与实例关联的特定时区中的小时、分钟、秒和毫秒设置为零来"规范化"由 java.sql.Date 实例包装的毫秒值。
这意味着,当您在 UTC+1 中工作并要求数据库提供 DATE 时,合规的实现完全按照您观察到的方式执行:返回一个 java.sql.Date,其毫秒值对应于 00:00:00 UTC+1 的相关日期,与数据最初到达数据库的方式无关。
如果不是您想要的,数据库驱动程序可能允许通过选项更改此行为。
另一方面,当您将java.sql.Date
传递给数据库时,驱动程序将使用默认时区将日期和时间部分与毫秒值分开。如果您使用 0
并且使用的是 UTC+X,则 X>=0 的日期为 1970-01-01,X<0 的日期为 1969-12-31。
旁注:奇怪的是,Date(long)
构造函数的文档与实现不同。javadoc 是这样说的:
如果给定的毫秒值包含时间信息,则驱动程序会将时间部分设置为默认时区(运行应用程序的 Java 虚拟机的时区(中对应于零 GMT 的时间。
然而,在OpenJDK中实际实现的是这样的:
public Date(long date) {
// If the millisecond date value contains time info, mask it out.
super(date);
}
显然,这种"掩盖"没有实施。同样,因为指定的行为没有很好地指定,例如 1970-01-01 00:00:00 GMT-6
JDBC 4.2 和 java.time
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setObject(1, LocalDate.EPOCH); // The epoch year LocalDate, '1970-01-01'.
ResultSet rs = stmt.executeQuery();
rs.next();
// It doesnt matter where you live or your JVM’s time zone setting
// since a LocalDate doesn’t have or use time zone
System.out.println(rs.getObject(1, LocalDate.class));
我没有测试,但除非您的 JDBC 驱动程序中存在错误,否则所有时区的输出都是:
1970年01月01
日
LocalDate
是一个没有一天中时间和时区的日期。因此,没有毫秒值可以获取,也没有一天中的时间可以混淆。 LocalDate
是现代Java日期和时间APIjava.time的一部分。JDBC 4.2 指定您可以通过我在代码段中使用的方法setObject
和getObject
将 java.time 对象(包括LocalDate
(传输到 SQL 数据库或从 SQL 数据库传输 java.time 对象(因此既不是setDate
也不是getDate
(。
我认为现在几乎所有人都在使用 JDBC 4.2 驱动程序。
您的代码中出了什么问题?
java.sql.Date
是一个黑客,试图(不是很成功(将java.util.Date
伪装成没有一天中时间的日期。我建议您不要使用该类。
从文档中:
为了符合 SQL
DATE
的定义,毫秒值 由java.sql.Date
实例包装必须通过设置 "规范化" 小时、分钟、秒和毫秒到零 与实例关联的特定时区。
"关联"可能含糊不清。我相信这意味着毫秒值必须表示 JVM 默认时区(我想是欧洲/苏黎世(的一天的开始。因此,当您创建等于 GMT 1970-01-01 00:00:00 的new java.sql.Date(0)
时,实际上是您创建了一个不正确的Date
对象,因为此时此时间是您所在时区的 01:00:00。JDBC 忽略了一天中的时间(我们可能预料到会有错误消息,但没有得到任何错误消息(。因此,SQL 获取并返回的日期为 1970-01-01。JDBC 将其正确转换回包含 CET 1970-01-01 00:00:00 的Date
。或毫秒值 -3 600 000。是的,这令人困惑。正如我所说,不要使用该类。
如果您处于负 GMT 偏移量(例如美洲的大多数时区(,new java.sql.Date(0)
将被解释为 1969-12-31,因此这将是您传递到并从 SQL 返回的日期。
更糟糕的是,想想当JVM的时区设置被更改时会发生什么。程序的任何部分和在同一 JVM 中运行的任何其他程序都可以随时执行此操作。这通常会导致在更改之前创建的所有对象java.sql.Date
无效,并且有时可能会将其日期更改一天(在极少数情况下会更改两天(。
链接
- Oracle 教程:日期时间解释如何使用 java.time。
- JSR-000221 JDBC™ API 规范 4.2 维护版本 2
java.sql.Date
文档
有一个接受Calendar
的getDate
重载,使用它可以解决你的问题吗?或者,您可以使用 Calendar
对象来转换信息。
也就是说,假设检索是问题所在。
Calendar gmt = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setDate(1, new Date(0)); // I assume this is always GMT
ResultSet rs = stmt.executeQuery();
rs.next();
//This will output 0 as expected
System.out.println(rs.getDate(1, gmt).getTime());
或者,假设存储是问题所在。
Calendar gmt = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
PreparedStatement stmt = connection.prepareStatement(sql);
gmt.setTimeInMillis(0) ;
stmt.setDate(1, gmt.getTime()); //getTime returns a Date object
ResultSet rs = stmt.executeQuery();
rs.next();
//This will output 0 as expected
System.out.println(rs.getDate(1).getTime());
我没有这方面的测试环境,所以你必须找出哪个是正确的假设。另外,我相信getTime
返回当前区域设置,否则您将不得不查找如何进行快速时区转换。