java 如何在Hibernate 6/Sping Boot 3.0中正确配置TIMESTAMP列的时间戳处理?

p1iqtdky  于 2023-02-02  发布在  Java
关注(0)|答案(2)|浏览(200)

我们目前正在将Spring Boot 2应用程序迁移到Spring Boot 3(3.0.2),其中包括迁移到Hibernate 6(6.1.6)。
所有时间戳都被规范化为UTC,并使用TIMESTAMP数据类型(不带时区)的列保存到底层OracleDB。要使Hibernate使用UTC,我们在application.yaml中将jpa.properties.hibernate.jdbc.time_zone配置属性设置为true
迁移到Spring Boot 3和Hibernate 6(引入了更具体的时间戳转换配置)后,时间戳迁移不再按预期工作:
当通过时间戳属性查找实体时,将找不到它们。

    • 简短示例:**

java.time.Instant时间戳为2023-12-13T00:00:00Z的实体保存到数据库会按预期创建新记录。
但是,当尝试使用相同的时间戳检索记录时,数据存储库将返回空结果。因此,保存和查询之间的时间戳转换似乎不同。
我们尝试使用配置属性spring.jpa.properties.hibernate.timezone.default_storage来调整这个行为,但是无论我们设置什么值,行为都保持不变。我们还尝试了jdbc.time_zonetimezone.default_storage配置属性的不同组合,但没有发现对应用程序行为的任何影响。我们最初认为无论出于什么原因,这些属性都不会被应用。但分配无效值会在应用程序启动时引发异常。
在使用H2数据库时,同样的代码就像一个护身符。
我们使用的application.yaml的相关部分如下所示:

spring:
  datasource:
    driverClassName: oracle.jdbc.OracleDriver
    url: jdbc:oracle:thin:@localhost:1521:xe
    username: [dbuser]
    password: [dbpassword]
    type: org.springframework.jdbc.datasource.SimpleDriverDataSource
  jpa:
    show-sql: false
    generate-ddl: true
    hibernate.ddl-auto: none
    task:
    scheduling:
      pool:
        size: 10
    properties:
      hibernate:
        jdbc:
          time_zone: UTC
        timezone:
          default_storage: NORMALIZE_UTC

实体示例:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@Entity
@Table(name ="entity")
public class DemoEntity {

  @Id
  @Column(name = "`id`")
  UUID id;

  @Column(name = "`demo_timestamp`" ,columnDefinition = "TIMESTAMP")
  private Instant timestamp;

  public DemoEntity() {
    this.id = UUID.randomUUID();
  }

}

存储库:

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

@Repository
public interface EntityRepository extends JpaRepository<DemoEntity, UUID>, JpaSpecificationExecutor<DemoEntity> {

  Optional<DemoEntity> findAllByTimestamp(Instant timestamp);
}

显示观察到的行为的服务:

import java.time.Instant;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class EntityService {

  private final EntityRepository repository;

  @Autowired
  public EntityService(EntityRepository repository) {
    this.repository = repository;
  }

  @EventListener(ContextRefreshedEvent.class)
  @Transactional
  public void init() {
    Instant timestamp = Instant.parse("2022-12-31T23:00:00Z");

    Optional<DemoEntity> findResult = repository.findAllByTimestamp(timestamp);

    if(findResult.isPresent()) {
      log.info("Entity was found for timestamp {}", timestamp);
      return;
    }

    log.info("No entity was found for timestamp {}, creating one", timestamp);

    DemoEntity demoEntity = new DemoEntity();
    demoEntity.setTimestamp(timestamp);

    this.repository.save(demoEntity);
  }
}

由于无法查找持久化的时间戳,因此服务不断创建具有正确时间戳的新记录:
1.* * 实体创建正确...**

1.* ...但之后SQL查询未找到...*

2023-01-26T07:20:47.986+01:00  INFO 1274 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2023-01-26T07:20:48.105+01:00 DEBUG 1274 --- [  restartedMain] org.hibernate.SQL                        : select d1_0."id",d1_0."demo_timestamp" from entity d1_0 where d1_0."demo_timestamp"=?
2023-01-26T07:20:48.106+01:00 TRACE 1274 --- [  restartedMain] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [TIMESTAMP_UTC] - [2022-12-31T23:00:00Z]
2023-01-26T07:20:48.130+01:00  INFO 1274 --- [  restartedMain] com.example.demo.EntityService           : No entity was found for timestamp 2022-12-31T23:00:00Z, creating one
2023-01-26T07:20:48.138+01:00 DEBUG 1274 --- [  restartedMain] org.hibernate.SQL                        : select d1_0."id",d1_0."demo_timestamp" from entity d1_0 where d1_0."id"=?
2023-01-26T07:20:48.138+01:00 TRACE 1274 --- [  restartedMain] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BINARY] - [1ccd9b88-4d18-416a-938d-d8c3fb6dac7d]
2023-01-26T07:20:48.150+01:00 DEBUG 1274 --- [  restartedMain] org.hibernate.SQL                        : insert into entity ("demo_timestamp", "id") values (?, ?)
2023-01-26T07:20:48.150+01:00 TRACE 1274 --- [  restartedMain] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [TIMESTAMP_UTC] - [2022-12-31T23:00:00Z]
2023-01-26T07:20:48.150+01:00 TRACE 1274 --- [  restartedMain] org.hibernate.orm.jdbc.bind              : binding parameter [2] as [BINARY] - [1ccd9b88-4d18-416a-938d-d8c3fb6dac7d]

1.* ...导致创建另一个实体*

我们还发现,查找实体时,就好像它们实际上不使用UTC而是使用我们的本地时区CET(即UTC +1)一样,可以得到预期的结果。更具体地说,查找2022-31-12T22:00:00Z的Instant的记录时,返回的实体的时间戳为2022-31-12T23:00:00Z。此外,使用TimeZone.setDefault(TimeZone.getTimeZone("UTC"));-Duser.timezone=UTC设置JVM时区时,一切都正常。
似乎表示为Instants的UTC时间戳在被查询时实际上转换为本地时间,但在写入数据库时得到了正确处理。
我们是否遗漏了配置中的任何内容,或者Spring/Hibernate中是否存在导致这种奇怪行为的bug?
据我所知,Hibernate 6的默认行为应该与版本5提供的行为相匹配,因此根本不需要更改配置来保留以前的行为。

yi0zb3m4

yi0zb3m41#

Hibernate 5使用以下方法执行所有日期/时间相关计算:

@Override
protected X doExtract(ResultSet rs, String name, WrapperOptions options) throws SQLException {
  return options.getJdbcTimeZone() != null ?
   javaTypeDescriptor.wrap( rs.getTimestamp( name, Calendar.getInstance( options.getJdbcTimeZone() ) ), options ) :
   javaTypeDescriptor.wrap( rs.getTimestamp( name ), options );
}

现在,对于您的"即时案例",Hibernate 6使用以下方法:

protected void doBind(
        PreparedStatement st,
        X value,
        int index,
        WrapperOptions wrapperOptions) throws SQLException {
    final OffsetDateTime dateTime = javaType.unwrap( value, OffsetDateTime.class, wrapperOptions );
    // supposed to be supported in JDBC 4.2
    st.setObject( index, dateTime.withOffsetSameInstant( ZoneOffset.UTC ), Types.TIMESTAMP_WITH_TIMEZONE );
}

这看起来很奇怪,因为HBN把时区敏感的数据类型绑定到非时区敏感的列,问题是:表达式st.setObject(index, odt, 2014)的真正含义是什么?DB引擎在选择行时将使用什么实际值?
答:

  • JDBC驱动程序发送"预期"值
  • DB引擎在执行任何比较之前(这就是为什么您的select语句不返回数据的原因)将存储在表和查询参数中的日期/时间数据调整为UTC时区(对于存储的数据,它使用会话时区作为基线-ALTER SESSION SET TIME_ZONE='UTC'解决了这个问题,并将JDK时区设置为UTC)-如果我们看一下执行计划,这一点实际上就很清楚了:

一个二个一个一个
因此,是的,Hibernate 6中有"突破性的变化"(或bug),现在在非时区感知列中存储时区感知数据可能会导致大量问题(错误的结果、缓慢的查询等)

tcomlyy6

tcomlyy62#

好的,听着,在我发表评论之后,我真的希望你能尝试在Spring之外重现这个问题,这样我们就可以确定它的原因。
但无论如何,我现在我已经设法重现它与以下设置:
1.将JVM时区设置为EST,将Oracle服务器时区设置为UTC
1.* 不要设置 * hibernate.jdbc.time_zone,这样Hibernate就不会知道时区之间存在不匹配
1.使用JPA@Column注解手动覆盖Hibernate模式导出生成的默认列类型,从timestamp with time zone导出为timestamp
1.* 不要设置 * hibernate.type.preferred_instant_jdbc_type,也不要使用@JdbcTypeCode(TIMESTAMP),这样Hibernate就不会知道您正在使用本地timestamp作为列类型
1.最后,* 也是最重要的 *,在绑定查询参数时使用setParameter("timestamp", Timestamp.from(instant)),而不是只传递Instant对象
FTR,我使用的测试代码如下:

@DomainModel(annotatedClasses = UTCNormalizedInstantTest.Instantaneous.class)
@SessionFactory
@ServiceRegistry(settings = {@Setting(name = AvailableSettings.TIMEZONE_DEFAULT_STORAGE, value = "NORMALIZE_UTC"),
//      @Setting(name = AvailableSettings.JDBC_TIME_ZONE, value="UTC"),
//      @Setting(name = AvailableSettings.PREFERRED_INSTANT_JDBC_TYPE, value="TIMESTAMP")
})
public class UTCNormalizedInstantTest {

    @Test
    void test(SessionFactoryScope scope) {
        TimeZone.setDefault(TimeZone.getTimeZone("EST"));
        Instant timestamp = Instant.parse("2022-12-31T23:00:00Z");
        UUID id = scope.fromTransaction( s-> {
            Instantaneous z = new Instantaneous();
            z.instant = timestamp;
            s.persist(z);
            return z.id;
        });
        scope.inSession( s-> {
            Instantaneous z = s.find(Instantaneous.class, id);
            assertEquals( timestamp, z.instant );
        });
        scope.inSession( s-> {
            Instant i = s.createQuery("select i.instant from Instantaneous i", Instant.class)
                    .getSingleResult();
            assertEquals( timestamp, i );
            Instantaneous a = s.createQuery("from Instantaneous i where i.instant=:timestamp", Instantaneous.class)
                    .setParameter("timestamp", timestamp )
                    .getSingleResult();
            assertEquals( timestamp, a.instant );
            Instantaneous b = s.createQuery("from Instantaneous i where cast(i.instant as Timestamp)=:timestamp", Instantaneous.class)
                    .setParameter("timestamp", Timestamp.from(timestamp) )
                    .getSingleResult();
            assertEquals( timestamp, b.instant );
        });
    }

    @Entity(name = "Instantaneous")
    public static class Instantaneous {
        @Id
        @GeneratedValue
        UUID id;
//      @JdbcTypeCode(Types.TIMESTAMP)
        @Column(columnDefinition = "timestamp")
        Instant instant;
    }
}

现在,取消注解三个注解行中的 * 任何一个 *,或者删除@Column注解,就可以通过此测试。
因此,解决方案是:

  • 设置hibernate.type.preferred_instant_jdbc_type=TIMESTAMP
  • 注解字段@JdbcTypeCode(TIMESTAMP),或
  • hibernate.jdbc.time_zone设置为服务器时区(您说您尝试过这种方法,但我敢打赌,如果您调试,您会发现它没有坚持)。

请注意,我 * 只能 * 使用setParameter("timestamp", Timestamp.from(instant))最终重现了这个问题。如果使用setParameter("timestamp", instant),它似乎不会发生,这当然更好,更自然。这是有道理的。我想(但不知道)如果调试Spring生成的代码,您会发现它在将Instant传递给setParameter()之前(不正确地)将您的Instant转换为Timestamp
FTR,H5和H6之间的区别在于,默认情况下,H6使用timestamp with time zone来存储Instant,而不是timestamp,因为没有时区的timestamp更像是Java LocalDateTime。让它知道JVM和Oracle时区之间的不匹配。

相关问题