从hibernate并发更新报错问题说起

问题描述

最近在生产环境中碰到一个并发更新的错误,具体报错信息为:

javax.persistence.OptimisticLockException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1;

看报错信息判断是并发更新失败引起的,而业务表里刚好有个version字段,通过javax.persistence.Version进行注解,该注解用于数据库乐观锁版本控制。

但问题在于线上虽然发布了新版本,但是并没有对报错的相关代码有任何变更。最后经过一系列验证发现,该报错是由hibernatete包升级引起的。

具体来说,就是项目使用了Quarkus框架,并在本次发布时将其由2.13.3版本升级到了2.15.3版本。相应的,quarkus-spring-data-jpa也做了同样的升级。而quarkus-spring-data-jpa 中引用了hibernate-core的包,且在升级过程中由5.6.15版本降低到了5.6.14版本。

需要注意的是,并不是Quarkus版本越高,依赖的其他三方包的版本就越高。Quarkus的稳定版本是一直在持续维护的,也就是说,有可能低版本的quarkus在维护后比高版本的quarkus使用的三方包的版本要高。

查询官方release note可以看到,hibernate 5.6.15版本修复了一个bug,描述如下:
在这里插入图片描述
这个bug就是说将属性设置为当前的属性时会导致不必要的更新。继续进入HHH-16049报告页可以看到,该bug可能是在解决HHH-15634问题时引入的:
在这里插入图片描述
在github上该bug描述如下:
在这里插入图片描述

在Quarkus中,除了2.14.1-2.16.1引用了hibernate 5.6.14之外,其他的版本都没有影响。

解决办法也很简单,那就是再引入hibernate 5.6.15版本,这样就可以保证系统优先使用Hibernate 5.6.15了,该版本不存在此bug。

JPA和Spring Data JPA

JPA(Java Persistence API)即Java持久化API,是一套基于ORM的思想的规范。换句话说,JPA只有定义没有实现,其内部只有一系列接口和抽象类。

JPA是由具体的db访问框架来实现的,如Hibernate,EclipseLink,OpenJPA等。

那Spring Data JPA又是什么东西呢?

Spring Data JPA

Spring Data是Spring的一个子项目,主要用来简化数据库的访问,Spring Data JPA是Spring Data的一个子模块。

Spring Data JPA是在JPA的基础上进一步封装的,简化了JPA的使用。引入Spring Data JPA之前和之后,使用方式分别是这样的:

在这里插入图片描述
Spring Data JPA 主要由spring-data-commons和spring-data-jpa两个包组成,其接口结构如下所示:
在这里插入图片描述

查询方式

Spring Data Jpa提供了几种查询方式:

  • 基于方法名称命名规则查询
  • 基于@Query 注解查询

除此之外,还能自定义查询。

方法名称命名规则查询

规则:findBy(关键字)+属性名称(属性名称的首字母大写)+查询条件(首字母大写)

关键字方法命令查询条件
AndfindByNameAndPwdwhere name = ? and pwd = ?
OrfindByNameOrSexwhere name = ? or sex = ?
Is, EqualfindById, findByIdEqualswhere id = ?
BetweenfindByIdBetweenwhere id between ? and ?
LessThanfindByIdLessThanwhere id < ?
LessThanEqualfindByIdLessThanEqualswhere id <= ?
GreaterThanfindByIdGreaterThanwhere id > ?
EndingWithfindByNameEndingWithwhere name like ‘%?’
ContainingfindByNameContaingwhere name like ‘%?%’
OrderByfindByIdOrderByXDescwhere id = ? order by x desc
NotfindByIdNotwhere id <> ?
InfindByIdInwhere id in (?)
TruefindByStatusTruewhere status = true
IgnoreCasefindByNameIgnoreCasewhere UPPER(name) = UPPER(?)

基于@Query注解的查询

@Query注解的使用非常简单,只需在声明的方法上面标注该注解,同时提供一个SQL查询语句即可,如下所示

public interface UserDao extends Repository<AccountInfo, Long> {

    @Query("select a from AccountInfo a where a.accountId = ?1")
    public AccountInfo findByAccountId(Long accountId);

    @Query("select a from AccountInfo a where a.balance > ?1")
    public Page<AccountInfo> findByBalanceGreaterThan(Integer balance, Pageable pageable);
}

当然,除了使用位置编号以外,还可以在SQL语句中通过: 变量名的方式来指定参数,如下所示:

@Query("from AccountInfo a where a.accountId = :id")
public AccountInfo findByAccountId(@Param("id") Long accountId);
 
@Query("from AccountInfo a where a.balance > :balance")
public Page<AccountInfo> findByBalanceGreaterThan(@Param("balance") Integer balance, Pageable pageable);

如果是update操作,除了@Query之外,还需要加上@Modifying注解。

自定义查询

某些特殊查询无法通过Spring Data Jpa自动生成的代理对象来实现,可以自定义实现。

为了让jpa的规则命名接口和自定义接口区分开,可以定义一个特殊接口,专门用来声明自定义方法。

如声明接口AccountRepositoryCustom,然后在AccountRepositoryCustomImpl类中实现自定义方法,并让AccountRepository接口扩展JpaRepositoryAccountRepositoryCustom

这样就能通过AccountRepository来调用jpa中按规则命名的方法和AccountRepositoryCustomImpl中的自定义方法了。

Hibernate自动更新

Hibernate中是有缓存的,当从数据库中查询到持久化对象后,该对象就被加载到缓存中。

Hibernate会跟踪该对象的状态,当对象属性发生变更时,hibernate会将该属性标记为dirty的。
即使调用save操作,该对象并不会立即更新到数据库中,而是在缓存中记录更新操作。只有当flush方法被调用时,缓存中的数据才会同步到数据库。

当flush方法被调用时,hibernate准备将缓存中的数据同步到数据库,但只有缓存中的数据真正发生变更时,这个同步才会实际发生。
如果flush刷新时,缓存中的数据与一开始加载到缓存中的数据是一致的,更新不会实际执行。

Hibernate通过dirtyCheck机制来判断缓存数据是否有变更。本文开头描述的Hibernate 5.6.14版本的bug就是这个机制有问题,没有准确判断出是否发生了实际的变更。dirtyCheck的方法签名为:protected void dirtyCheck(FlushEntityEvent event) throws HibernateException

flush模式

虽然jpa中提供了flush方法,但在业务代码中通常不会主动调用该方法,flush方法一般是由系统自动调用的。

需要注意的是,JPA和hibernete本身的flush模式是不同的,JPA只有两种模式,即AUTOCOMMIT,如下所示:
在这里插入图片描述

Hibernate支持的flush模式更多,一共有5种:
AUTOCOMMIT外,还有NEVEL, MANAUL, ALWAYS

FlushModelType.AUTO

AUTO模式是JPA和Hibernate都支持的,且是JPA的默认刷新模式。在这种模式下,有两种情况会触发flush方法:

  • 事务提交之前
  • 当查询语句涉及到当前事务中有更新的实体表

第一种情况是强制的,不多解释;第二种情况可能稍微有点复杂,它取决于hibernate如何判断查询语句影响哪些表。

对于每一个JPQL或者条件查询,Hibernate都会生成一个SQL语句,通过Sql语句可以知道查询使用的数据表。

如果hibernate判断出当前的持久化上下文中包含查询表对应的实体对象,且该实体对象发生了改变,也就是dirty的,这时候hibernate就会调用flush刷新缓存。

下面看一个例子:

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
 
ChessPlayer player = new ChessPlayer();
player.setFirstName("Thorben");
player.setLastName("Janssen");
// 持久化一个player实体,理论上应该插入到数据库,但实际放到了缓存中,不会立即刷新到数据库
em.persist(player);
 
 // 该查询不涉及缓存中的player对象,故还是不会刷新缓存到数据库
em.createQuery("SELECT t from ChessTournament t").getResultList();
 
 // 该查询涉及缓存中的player对象,因此会触发缓存刷新,将player插入到数据库
Query q = em.createQuery("SELECT p FROM ChessPlayer p WHERE p.firstName = :firstName");
q.setParameter("firstName", "Magnus");
q.getResultList();
 
em.getTransaction().commit();
em.close();

从输出日志中可以看出缓存的刷新时间:

11:56:14,076 DEBUG SQL:144 - select nextval ('player_seq')
11:56:14,085 DEBUG SQL:144 - select nextval ('player_seq')
11:56:14,188 DEBUG SQL:144 - select chesstourn0_.id as id1_2_, chesstourn0_.endDate as enddate2_2_, chesstourn0_.name as name3_2_, chesstourn0_.startDate as startdat4_2_, chesstourn0_.version as version5_2_ from ChessTournament chesstourn0_
11:56:14,213 DEBUG SQL:144 - insert into ChessPlayer (birthDate, firstName, lastName, version, id) values (?, ?, ?, ?, ?)
11:56:14,219 DEBUG SQL:144 - select chessplaye0_.id as id1_1_, chessplaye0_.birthDate as birthdat2_1_, chessplaye0_.firstName as firstnam3_1_, chessplaye0_.lastName as lastname4_1_, chessplaye0_.version as version5_1_ from ChessPlayer chessplaye0_ where chessplaye0_.firstName=?

参考资料

[1]. https://blog.csdn.net/cd546566850/article/details/107180272/
[2]. https://blog.csdn.net/jakekong/article/details/105339593
[3]. https://www.cnblogs.com/wangshaoyun/p/16925905.html
[4]. https://thorben-janssen.com/flushmode-in-jpa-and-hibernate

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值