Hibernate脱管状态下saveOrUpdate与update失效详解
2025-03-12 03:08:52
Hibernate 中 saveOrUpdate()
和 update()
在脱管状态下失效的原因及解决方法
在使用 Hibernate 进行对象持久化时,你可能会遇到在脱管(detached)状态下调用 saveOrUpdate()
或 update()
方法无法更新数据库记录的问题。就像上面的那样,即使调用了这些方法,数据库中的数据也没有变化。 别急,我们来捋一捋。
一、问题产生的原因
Hibernate 对象有四种状态:瞬时态(Transient),持久态(Persistent),脱管态(Detached),删除态(Removed)。问题核心在于对 Hibernate 对象生命周期和状态管理机制理解不够透彻。
-
持久态与 Session 的关联: Hibernate 通过 Session 对象来管理持久态对象。 当一个对象处于持久态时,Hibernate 会跟踪它发生的任何更改。当我们调用
session.get()
或session.load()
等方法时返回的就是持久化对象。 当事务提交(transaction.commit()
) 时, Hibernate 会自动将这些更改同步到数据库(通过flush
操作)。 -
脱管态的产生: 当 Session 关闭 (
session.close()
) 或对象被显式地从 Session 中清除 (session.evict(object)
) 后,持久态对象就会变成脱管态。 脱管对象与数据库记录仍然存在映射关系,但 Hibernate 不再跟踪其变化。 -
saveOrUpdate()
/update()
在不同状态下的行为:- 瞬时态对象:
saveOrUpdate()
会将瞬时态对象转换为持久态,并为其分配ID,在事务提交时会生成INSERT
语句插入一条数据。update()
则会抛出异常。 - 持久态对象:
saveOrUpdate()
和update()
什么都不做。因为Hibernate已经追踪这个对象的更改了。 - 脱管态对象: 这就是问题的关键!
update()
: 会尝试把脱管状态对象直接更新到数据库中。但它会抛出NonUniqueObjectException
异常, 如果此时的Session中已存在一个相同标识符的持久化对象。而当前代码的情形则比较特殊,看起来好像什么都没做(但其实做了两次 select 查询,下文细说)。saveOrUpdate()
: 会进行判断: 如果数据库中已存在一个具有相同标识符的记录, 则将其与当前脱管状态对象进行合并(但有坑, 下文细说);如果数据库不存在, 会将其当成是瞬时对象,在事务提交时进行 insert 操作(执行insert
语句);
- 瞬时态对象:
-
代码分析: 示例代码先通过
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 了。
对原代码saveOrUpdate
和 update
行为的深入解释
让我们再次仔细分析下, 为什么在原代码中直接在同一个Session使用 saveOrUpdate
或者 update
, 没有报错, 但数据也没有更改:
-
saveOrUpdate
的"坑"saveOrUpdate
在遇到脱管对象时,会首先执行一次 select 查询, 查看数据库是否存在对应 ID 的记录(这就是为什么原代码日志中有两条 select 语句的原因!)。- 如果数据库中存在该记录, hibernate 不会直接 拿脱管对象的数据进行更新!
- Hibernate 会加载数据库的最新数据到Session中,然后与当前脱管对象进行比较(根据dirty-checking 机制).
- 由于原代码中第一次
select
出的数据和脱管对象的数据一致(age 都是455),Hibernate 的 dirty-checking 机制认为数据没有变化,所以最终不会生成update
语句。
-
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 的行为。
四、进阶技巧 -- 版本控制(乐观锁)
在并发环境下, 为了防止多个用户同时修改同一条数据导致数据不一致, 可以使用乐观锁。
在你的实体类中添加一个版本属性(通常是Integer
或 Long
类型), 并使用 @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
异常, 表明数据已经被其他事务修改。 可以捕捉这个异常进行处理,比如重新加载最新数据,合并后再更新等.