返回

Hibernate脱管状态下saveOrUpdate与update失效详解

java

Hibernate 中 saveOrUpdate()update() 在脱管状态下失效的原因及解决方法

在使用 Hibernate 进行对象持久化时,你可能会遇到在脱管(detached)状态下调用 saveOrUpdate()update() 方法无法更新数据库记录的问题。就像上面的那样,即使调用了这些方法,数据库中的数据也没有变化。 别急,我们来捋一捋。

一、问题产生的原因

Hibernate 对象有四种状态:瞬时态(Transient),持久态(Persistent),脱管态(Detached),删除态(Removed)。问题核心在于对 Hibernate 对象生命周期和状态管理机制理解不够透彻。

  1. 持久态与 Session 的关联: Hibernate 通过 Session 对象来管理持久态对象。 当一个对象处于持久态时,Hibernate 会跟踪它发生的任何更改。当我们调用 session.get()session.load() 等方法时返回的就是持久化对象。 当事务提交(transaction.commit()) 时, Hibernate 会自动将这些更改同步到数据库(通过flush操作)。

  2. 脱管态的产生: 当 Session 关闭 (session.close()) 或对象被显式地从 Session 中清除 (session.evict(object)) 后,持久态对象就会变成脱管态。 脱管对象与数据库记录仍然存在映射关系,但 Hibernate 不再跟踪其变化。

  3. saveOrUpdate()/update() 在不同状态下的行为:

    • 瞬时态对象: saveOrUpdate() 会将瞬时态对象转换为持久态,并为其分配ID,在事务提交时会生成 INSERT 语句插入一条数据。update()则会抛出异常。
    • 持久态对象: saveOrUpdate()update() 什么都不做。因为Hibernate已经追踪这个对象的更改了。
    • 脱管态对象: 这就是问题的关键!
      • update(): 会尝试把脱管状态对象直接更新到数据库中。但它会抛出 NonUniqueObjectException异常, 如果此时的Session中已存在一个相同标识符的持久化对象。而当前代码的情形则比较特殊,看起来好像什么都没做(但其实做了两次 select 查询,下文细说)。
      • saveOrUpdate(): 会进行判断: 如果数据库中已存在一个具有相同标识符的记录, 则将其与当前脱管状态对象进行合并(但有坑, 下文细说);如果数据库不存在, 会将其当成是瞬时对象,在事务提交时进行 insert 操作(执行insert语句);
  4. 代码分析: 示例代码先通过 session.get() 获取了持久态对象 existingAlien。然后通过 session.evict(existingAlien) 将其转变为脱管态。 之后,虽然修改了 existingAlien 的属性,但是由于 existingAlien 已经处于脱管态,Hibernate 不会跟踪这些更改。 然后,直接在同一个Session里session.saveOrUpdate(existingAlien), 由于 saveOrUpdate()自身的"缺陷",导致无法直接更新. 如果用 update(), 按道理会抛异常,但示例代码这个情况下则是什么都不做。

二、解决方案

解决这个问题的关键在于,需要重新将脱管对象与 Session 关联起来,使其重新进入持久态,Hibernate 才能跟踪并同步其更改。

下面提供几种可行的解决方案:

1. 使用 merge() 方法 (推荐)

merge() 方法可以将脱管对象的状态复制到一个新的持久态对象上。这个新的持久态对象是 Session 追踪的,因此后续的修改会被同步到数据库。

package com.ankit.hibn8;

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.Configuration;

public class App {
    public static void main(String[] args) {

        Configuration config = new Configuration().configure().addAnnotatedClass(com.ankit.hibn8.AlienDO.class);

        SessionFactory sessFactory = config.buildSessionFactory();

        Session session = sessFactory.openSession();

        Transaction transaction = session.beginTransaction(); //避免多次transaction变量名冲突,改为局部变量
        try {
            AlienDO existingAlien = session.get(AlienDO.class, 384);

            if (existingAlien != null) {
                session.evict(existingAlien);

                existingAlien.setAlienAge(455);
                existingAlien.setAlienName("Annkit");

                System.out.println("Before merge: " + existingAlien);

                // 使用 merge() 方法将脱管对象的状态复制到新的持久态对象上
                existingAlien = (AlienDO) session.merge(existingAlien);

                System.out.println("After merge: " + existingAlien);

                transaction.commit();
            } else {
                System.out.println("Alien with ID 384 does not exist.");
                transaction.rollback();
            }
        } catch (Exception e) {
            transaction.rollback();
            e.printStackTrace();
        } finally {
            session.close();
        }
    }
}

原理: merge() 方法会先检查 Session 缓存中是否已存在相同 ID 的持久态对象。

  • 如果存在, 直接将脱管对象的所有属性值拷贝过去。
  • 如果不存在,它会从数据库加载一个与脱管对象相同 ID 的记录 (如果没有对应记录, 会抛出异常),并创建一个新的持久态对象,将脱管对象的属性值复制到新的持久态对象上,并返回这个新的持久态对象。

2. 重新获取对象 (简单粗暴, 但可能不适用所有场景)

最简单的方法是在修改对象之前,重新从数据库加载该对象。 这种方法直接绕过了脱管状态。

package com.ankit.hibn8;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.Configuration;

public class App {
    public static void main(String[] args) {
        Configuration config = new Configuration().configure().addAnnotatedClass(com.ankit.hibn8.AlienDO.class);
        SessionFactory sessFactory = config.buildSessionFactory();
        Session session = sessFactory.openSession();
        Transaction transaction = session.beginTransaction();
		try {
           //直接注释掉 evict
            //AlienDO existingAlien = session.get(AlienDO.class, 384);
            //if (existingAlien != null) {
            	//session.evict(existingAlien);
              AlienDO existingAlien = session.get(AlienDO.class, 384); //重新获取

                existingAlien.setAlienAge(455);
                existingAlien.setAlienName("Annkit");
                System.out.println("Before update: " + existingAlien);
                //此时, 对象是持久态, 不需要再进行 saveOrUpdate, update 等操作.
                System.out.println("After update: " + existingAlien);
                transaction.commit();
            //}
//            else {
//                System.out.println("Alien with ID 384 does not exist.");
//                transaction.rollback();
//            }

        } catch (Exception e) {
            transaction.rollback();
            e.printStackTrace();
        } finally {
            session.close();
        }
    }
}

原理: 因为每次修改都是在刚从数据库中加载出来的持久态对象上进行的,Hibernate 会自动跟踪更改并在事务提交时同步到数据库。这种方法的缺点是, 如果要先获取对象再evict, 之后又要进行修改, 则不太适用, 需要用到 merge()方法.

3. LockMode (特定情况下适用)

使用 Session.lock() 方法结合 LockMode,可以尝试重新关联脱管对象。LockMode.READ不会强制进行版本检查, 仅仅把对象放入Session的缓存. 注意:LockMode.READ 依赖于数据库的事务隔离级别来保证数据的一致性, 在高并发场景下,不一定靠谱。LockMode.OPTIMISTIC 是乐观锁, LockMode.PESSIMISTIC_WRITE是悲观锁, 可以按需选择。


package com.ankit.hibn8;
import org.hibernate.LockMode;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.Configuration;
public class App {
 @SuppressWarnings("deprecation")
 public static void main(String[] args) {

  Configuration config = new Configuration().configure().addAnnotatedClass(com.ankit.hibn8.AlienDO.class);

  SessionFactory sessFactory = config.buildSessionFactory();

  Session session = sessFactory.openSession();

        Transaction transaction = session.beginTransaction();
        try {
            AlienDO existingAlien = session.get(AlienDO.class, 384);

            if (existingAlien != null) {
                session.evict(existingAlien);
                
                existingAlien.setAlienAge(455);
                existingAlien.setAlienName("Annkit");

                System.out.println("Before lock: " + existingAlien);

                session.lock(existingAlien, LockMode.READ); //重新关联对象,使用读取锁

                System.out.println("After lock: " + existingAlien);

                transaction.commit(); // 在事务提交时,Hibernate 会检查对象是否被修改,并更新数据库
            } else {
                System.out.println("Alien with ID 384 does not exist.");
                transaction.rollback();
            }
        } catch (Exception e) {
            transaction.rollback();
            e.printStackTrace();
        } finally {
            session.close();
        }
 }
}

原理: session.lock() 方法可以将一个脱管对象重新关联到 Session。LockMode.READ 告诉 Hibernate 使用数据库的读取锁机制。 因为我们在 lock 之后, 再次进行 commit 操作,此时 hibernate 会执行 update 操作.

4. 打开一个新的 Session (适用于不同业务逻辑单元)

如果你的操作需要在不同的业务逻辑单元中进行,可以考虑打开一个新的 Session 来处理脱管对象的更新。

package com.ankit.hibn8;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.Configuration;
public class App {
    public static void main(String[] args) {

        Configuration config = new Configuration().configure().addAnnotatedClass(com.ankit.hibn8.AlienDO.class);

        SessionFactory sessFactory = config.buildSessionFactory();
   // 第一个 Session,用于获取对象并将其置于脱管状态
        Session session1 = sessFactory.openSession();
        Transaction transaction1 = session1.beginTransaction();
        AlienDO existingAlien = null;
        try {
            existingAlien = session1.get(AlienDO.class, 384);
             if (existingAlien != null) {
                session1.evict(existingAlien); // 使对象脱管
             }
            transaction1.commit();
        } catch (Exception e) {
             transaction1.rollback();
            e.printStackTrace();
        } finally {
             session1.close();
        }

        //模拟其他业务的处理
        if(existingAlien != null){
        	existingAlien.setAlienAge(455);
            existingAlien.setAlienName("Annkit");
        }

        // 第二个 Session,用于更新脱管对象
        Session session2 = sessFactory.openSession();
        Transaction transaction2 = session2.beginTransaction();

        try {
             if (existingAlien != null) {
                    System.out.println("Before saveOrUpdate: " + existingAlien);
                    //session2.saveOrUpdate(existingAlien);  //重新关联到新的 Session. saveOrUpdate, update, merge 等方法均可. 按需使用
                   //  或者
                   session2.update(existingAlien); // 或者使用 update() 方法. 但注意: 如果新的 session 已经存在一个相同ID对象时,会报错.
                    System.out.println("After saveOrUpdate: " + existingAlien);
                    transaction2.commit(); // 提交事务,更新数据库
              }
        } catch (Exception e) {
           transaction2.rollback();
            e.printStackTrace();
        } finally {
            session2.close();
        }
    }
}

原理: 一个全新的Session, 自然可以将对象重新关联到新的Session,使之变成持久化对象, 然后就可以顺利进行 update 了。

对原代码saveOrUpdateupdate 行为的深入解释

让我们再次仔细分析下, 为什么在原代码中直接在同一个Session使用 saveOrUpdate 或者 update, 没有报错, 但数据也没有更改:

  1. saveOrUpdate 的"坑"

    • saveOrUpdate 在遇到脱管对象时,会首先执行一次 select 查询, 查看数据库是否存在对应 ID 的记录(这就是为什么原代码日志中有两条 select 语句的原因!)。
    • 如果数据库中存在该记录, hibernate 不会直接 拿脱管对象的数据进行更新!
    • Hibernate 会加载数据库的最新数据到Session中,然后与当前脱管对象进行比较(根据dirty-checking 机制).
    • 由于原代码中第一次select出的数据和脱管对象的数据一致(age 都是455),Hibernate 的 dirty-checking 机制认为数据没有变化,所以最终不会生成 update 语句。
  2. update "看似没反应" 的背后

    • update 针对脱管对象, 一般会抛 NonUniqueObjectException 异常, 这是因为 Session 缓存里已经有相同 ID 的对象。
    • 原代码这个情况,比较特殊: update前进行了select, 数据库里加载出了 age=455的数据; 之后evict, 紧接着, 执行 update, 按道理应该报错, 但是, 此时缓存中数据由于 evict 被清除了, 而且此时Session中并没有新的对象有相同的 ID, 所以, update不会报错。 但由于update针对脱管对象,本身就不应该被直接调用, 所以,数据也不会有任何更改! (可以加一些断点,debug查看)

三、安全建议

  • 避免长时间持有 Session: 长时间持有的 Session 可能会导致内存泄漏和性能问题。 尽量缩短 Session 的生命周期,在不需要时及时关闭。
  • 谨慎使用 evict() evict() 只是将对象从 Session 缓存中移除, 并不代表你可以随意更改脱管对象. 如果要修改,需要重新关联。
  • 了解事务隔离级别: Hibernate 的行为受到数据库事务隔离级别的影响。确保你理解你正在使用的数据库的隔离级别, 以及它们如何影响 Hibernate 的行为。

四、进阶技巧 -- 版本控制(乐观锁)

在并发环境下, 为了防止多个用户同时修改同一条数据导致数据不一致, 可以使用乐观锁。
在你的实体类中添加一个版本属性(通常是IntegerLong 类型), 并使用 @Version 注解标注。

//在AlienDO.java 加上:

@Version
@Column(name = "version")
private Integer version;

public Integer getVersion() {
	return version;
}
public void setVersion(Integer version) {
	this.version = version;
}

Hibernate 会在更新时自动检查版本号。如果版本号不匹配, 会抛出 StaleObjectStateException 异常, 表明数据已经被其他事务修改。 可以捕捉这个异常进行处理,比如重新加载最新数据,合并后再更新等.