在Hibernate中,重新附加分离的对象的正确方法是什么?

213

我有一个情况,需要将已经分离的对象重新附加到Hibernate会话中。但是,如果会话中已经存在相同标识的对象,这将导致错误。

目前,我可以采取以下两种方法之一。

  1. getHibernateTemplate().update(obj)。 只有在Hibernate会话中不存在该对象时,此方法才有效。如果稍后需要使用该对象,将抛出异常并指出给定标识符的对象已经存在于会话中。

  2. getHibernateTemplate().merge(obj)。 只有在Hibernate会话中存在该对象时,此方法才有效。如果使用此方法,在稍后需要将对象放入会话时,将抛出异常。

鉴于这两种情况,我该如何通用地将会话附加到对象上?我不想使用异常来控制问题解决方案的流程,因为肯定有更优雅的解决方案......

17个回答

197

看起来在JPA中没有将过期的分离实体重新附加回去的方法。

merge() 方法会将过期的状态推送到数据库中,并覆盖任何干预更新。

refresh() 不能用于分离的实体。

lock() 不能用于分离的实体, 即使可以,并且它确实能够重新附加实体, 使用参数 'LockMode.NONE' 调用 'lock' 方法 意味着您正在锁定,但不锁定, 这是我见过的最反直觉的API设计。

所以你卡住了。 有一个 detach() 方法,但没有 attach()reattach() 方法。 对象生命周期中的一个显然的步骤对你来说不可用。

根据关于 JPA 的类似问题数量,似乎即使 JPA 声称有一致的模型, 它也绝对不符合大多数程序员的心理模型, 他们被诅咒着浪费了很多时间试图理解如何让 JPA 做最简单的事情, 最终在他们的应用程序中到处都是缓存管理代码。

似乎唯一的方法是放弃您过时的分离实体, 并使用相同的 id 进行查找查询,这将触发 L2 缓存或数据库。

Mik


1
我想知道JPA规范为什么不允许在分离的实体上使用refresh()方法?在查看2.0规范时,我没有看到任何理由;只是说明它不被允许。 - FGreg
18
这绝对不准确。从JPwH中得知:修改过的分离实例可以通过在分离对象上调用update()来重新附加到新的Session(并由这个新的持久化上下文管理)。根据我们的经验,如果你在脑海中将update()方法重命名为reattach(),你可能更容易理解以下代码 - 但是,它被称为updating有很好的理由。更多信息可以在第9.3.2节中找到。 - cwash
持久对象的工作效果非常好,脏标记是基于初始加载和flush()时间值之间的增量设置的。分离的对象需要这个功能,但目前没有。Hibernate实现它的方法是为分离的对象添加一个附加的哈希/ ID。并保留可用的分离对象上次状态的快照,就像对持久对象所做的那样。这样他们可以利用所有现有的代码,并使其适用于分离的对象。这样,正如@mikhailfranco所指出的那样,我们就不会“将旧状态推送到DB并覆盖任何中间更新”。 - tom
3
根据Hibernate javadoc(但不包括JPA),实际上可以在瞬态对象上调用lock(LockMode.NONE),它确实会将实体重新附加到会话中。请参见https://dev59.com/oXNA5IYBdhLWcg3wjOve#3683370。 - seanf
锁定对我没有起作用:java.lang.IllegalArgumentException: 实体不在持久化上下文中在org.hibernate.internal.SessionImpl.lock(SessionImpl.java:3491)处 在org.hibernate.internal.SessionImpl.lock(SessionImpl.java:3482)处 在com.github.vok.framework.DisableTransactionControlEMDelegate.lock(DB.kt)处 - Martin Vysny

41

实体状态

JPA定义了以下实体状态:

新建(瞬态)

一个新创建的对象,尚未与Hibernate的Session(也称为Persistence Context)关联,并且未映射到任何数据库表行,被视为处于新建(瞬态)状态。

要持久化此对象,我们需要显式调用EntityManager#persist方法或使用传递性持久化机制。

持久化(托管)

持久化实体已与数据库表行关联,并由当前运行的Persistence Context管理。对这样的实体所做的任何更改都将被检测并传播到数据库(在Session刷新期间)。

通过Hibernate,我们不再需要执行INSERT/UPDATE/DELETE语句。Hibernate采用事务性写后工作方式,在最后负责的时刻,在当前Session刷新期间同步更改。

游离

一旦当前运行的Persistence Context关闭,所有先前受管理的实体都会变成游离状态。后续更改将不再被跟踪,并且不会发生自动数据库同步。

实体状态转换

可以使用EntityManager接口定义的各种方法来更改实体状态。

为了更好地理解JPA实体状态转换,请考虑以下图表:

JPA实体状态转换

在使用JPA时,要将游离实体重新关联到活动的EntityManager中,可以使用merge操作。

在使用本机Hibernate API时,除了merge之外,还可以使用更新方法将游离实体重新附加到活动的Hibernate Session中,如下图所示:

Hibernate实体状态转换

合并游离实体

合并操作将把游离实体状态(源)复制到托管实体实例(目标)中。

假设我们已经持久化了以下Book实体,并且现在该实体已分离,因为用于持久化实体的EntityManager被关闭了:

Book _book = doInJPA(entityManager -> {
    Book book = new Book()
    .setIsbn("978-9730228236")
    .setTitle("High-Performance Java Persistence")
    .setAuthor("Vlad Mihalcea");
 
    entityManager.persist(book);
 
    return book;
});

当实体处于分离状态时,我们将其修改如下:

_book.setTitle(
    "High-Performance Java Persistence, 2nd edition"
);

现在,我们想要将更改传播到数据库中,所以我们可以调用merge方法:

doInJPA(entityManager -> {
    Book book = entityManager.merge(_book);
 
    LOGGER.info("Merging the Book entity");
 
    assertFalse(book == _book);
});

同时,Hibernate将会执行以下SQL语句:

SELECT
    b.id,
    b.author AS author2_0_,
    b.isbn AS isbn3_0_,
    b.title AS title4_0_
FROM
    book b
WHERE
    b.id = 1
 
-- Merging the Book entity
 
UPDATE
    book
SET
    author = 'Vlad Mihalcea',
    isbn = '978-9730228236',
    title = 'High-Performance Java Persistence, 2nd edition'
WHERE
    id = 1
如果合并的实体在当前EntityManager中没有同等实体,则会从数据库获取新的实体快照。一旦有了托管实体,JPA将把分离的实体状态复制到当前托管的实体上,并在持久化上下文刷新期间,如果脏检查机制发现托管实体已更改,则生成更新。因此,在使用合并时,合并操作后分离的对象实例仍将保持分离状态。

重新附加分离的实体

Hibernate但不支持JPA通过update方法重新附加。Hibernate Session只能为给定数据库行关联一个实体对象。这是因为持久化上下文充当内存缓存(第一级缓存),并且只有一个值(实体)与给定键(实体类型和数据库标识符)相关联。只有在当前Hibernate Session中没有与同一数据库行匹配的其他JVM对象关联时,才能重新附加实体。考虑到我们已经持久化了Book实体,并在Book实体处于分离状态时对其进行修改:
Book _book = doInJPA(entityManager -> {
    Book book = new Book()
    .setIsbn("978-9730228236")
    .setTitle("High-Performance Java Persistence")
    .setAuthor("Vlad Mihalcea");
 
    entityManager.persist(book);
 
    return book;
});
      
_book.setTitle(
    "High-Performance Java Persistence, 2nd edition"
);
我们可以这样重新附加已分离的实体:
doInJPA(entityManager -> {
    Session session = entityManager.unwrap(Session.class);
 
    session.update(_book);
 
    LOGGER.info("Updating the Book entity");
});

而Hibernate将执行以下SQL语句:

-- Updating the Book entity
 
UPDATE
    book
SET
    author = 'Vlad Mihalcea',
    isbn = '978-9730228236',
    title = 'High-Performance Java Persistence, 2nd edition'
WHERE
    id = 1

update 方法需要你将 EntityManager 转换成 Hibernate 的 Session

merge 不同的是,提供的分离实体将重新关联到当前持久化上下文并且会在 flush 期间安排 UPDATE,无论实体是否已修改。

为了防止这种情况发生,你可以使用 @SelectBeforeUpdate Hibernate 注解,它将触发一个 SELECT 语句来获取已加载状态,然后由脏检查机制使用。

@Entity(name = "Book")
@Table(name = "book")
@SelectBeforeUpdate
public class Book {
 
    //Code omitted for brevity
}

注意NonUniqueObjectException异常

update可能会出现的一个问题是,如果持久化上下文已经包含具有相同id和类型的实体引用,则会发生以下示例中的情况:

Book _book = doInJPA(entityManager -> {
    Book book = new Book()
    .setIsbn("978-9730228236")
    .setTitle("High-Performance Java Persistence")
    .setAuthor("Vlad Mihalcea");
 
    Session session = entityManager.unwrap(Session.class);
    session.saveOrUpdate(book);
 
    return book;
});
 
_book.setTitle(
    "High-Performance Java Persistence, 2nd edition"
);
 
try {
    doInJPA(entityManager -> {
        Book book = entityManager.find(
            Book.class,
            _book.getId()
        );
 
        Session session = entityManager.unwrap(Session.class);
        session.saveOrUpdate(_book);
    });
} catch (NonUniqueObjectException e) {
    LOGGER.error(
        "The Persistence Context cannot hold " +
        "two representations of the same entity",
        e
    );
}
现在,当执行上面的测试用例时,Hibernate 会抛出一个 NonUniqueObjectException 异常,因为第二个 EntityManager 已经包含一个与我们传递给 update 的 Book 实体具有相同标识符的实体,而持久性上下文无法容纳两个表示同一实体的实例。
org.hibernate.NonUniqueObjectException:
    A different object with the same identifier value was already associated with the session : [com.vladmihalcea.book.hpjp.hibernate.pc.Book#1]
    at org.hibernate.engine.internal.StatefulPersistenceContext.checkUniqueness(StatefulPersistenceContext.java:651)
    at org.hibernate.event.internal.DefaultSaveOrUpdateEventListener.performUpdate(DefaultSaveOrUpdateEventListener.java:284)
    at org.hibernate.event.internal.DefaultSaveOrUpdateEventListener.entityIsDetached(DefaultSaveOrUpdateEventListener.java:227)
    at org.hibernate.event.internal.DefaultSaveOrUpdateEventListener.performSaveOrUpdate(DefaultSaveOrUpdateEventListener.java:92)
    at org.hibernate.event.internal.DefaultSaveOrUpdateEventListener.onSaveOrUpdate(DefaultSaveOrUpdateEventListener.java:73)
    at org.hibernate.internal.SessionImpl.fireSaveOrUpdate(SessionImpl.java:682)
    at org.hibernate.internal.SessionImpl.saveOrUpdate(SessionImpl.java:674)

结论

如果你使用乐观锁,那么应该优先考虑使用merge方法,因为它可以防止更新丢失。

对于批量更新,update是一个不错的选择,因为它可以避免由merge操作生成的额外SELECT语句,从而减少批量更新的执行时间。


不错的回答。不过我对 @SelectBeforeUpdate 注解有些疑问。这个 select 是什么时候触发的?是在调用 update 之后,就在 flush 之前吗?还是说它其实并不重要(如果 Hibernate 在 flush 之前一次性获取了所有带注解的实体,则可能会很重要)? - Andronicus
@SelectBeforeUpdate 触发在持久化上下文 flush 操作期间的 SELECT。有关更多详细信息,请查看 DefaultFlushEntityEventListener 中的 getDatabaseSnapshot 方法 - Vlad Mihalcea
如果您想同时进行saveOrUpdate并避免NonUniqueObjectException,该怎么办?您希望在实体存在时更新上下文,否则创建一个新实体。您必须手动执行还是有其他方法? - giannis christofakis
合并修复了这个问题。那是最简单的解决方法。 - Vlad Mihalcea
@VladMihalcea 我对Java生态系统了解不多,但我只是使用两次detach而不是merge就能够重新关联,这是怎么可能的? - Vinícius OA

39
所有这些答案都忽略了一个重要的区别。update()用于将您的对象图重新附加到Session上。您传递给它的对象就是被管理的对象。
merge()实际上不是一个(重新)附加API。注意merge()有一个返回值吗?这是因为它返回的是被管理的图,可能不是您传递给它的图。merge()是一个JPA API,其行为受JPA规范的控制。如果您传递给merge()的对象已经被管理(已经与Session相关联),那么Hibernate使用的就是该图;传入的对象与从merge()返回的对象相同。然而,如果您传递给merge()的对象是分离的,则Hibernate会创建一个新的被管理的对象图,并将您的分离图的状态复制到新的被管理的图中。再次强调,所有这些都由JPA规范所规定和控制。
就“确保此实体已被管理或使其被管理”的通用策略而言,这在某种程度上取决于您是否也想考虑尚未插入的数据。假设您想这样做,请使用类似以下的内容:
if ( session.contains( myEntity ) ) {
    // nothing to do... myEntity is already associated with the session
}
else {
    session.saveOrUpdate( myEntity );
}

请注意我使用的是saveOrUpdate()而不是update()。如果您不想在此处处理尚未插入的数据,请改用update()。


3
这是这个问题的正确答案 - 案子结束了! - cwash
3
Session.contains(Object) 检查的是引用。如果会话中已经有另一个表示相同行的实体,并且您传递了一个分离的实例,则会抛出异常。 - djmj
由于Session.contains(Object)是通过引用检查的,如果在会话中有另一个表示相同行的实体,则它将返回false,并更新该实体。 - AxelWass

19

不那么客气的回答: 你可能正在寻找一个扩展的持久化上下文。这是Seam Framework的主要原因之一...如果你在特定于Spring中使用Hibernate方面遇到困难,请查看Seam文档中的这一部分。

比较客气的回答: 这在Hibernate文档中有描述。如果需要更多澄清,请查看Java Persistence with Hibernate第9.3.2节,名为“处理分离的对象”。如果你在使用Hibernate时做的事情超过了CRUD,我强烈建议你购买这本书。


9
来自 http://www.seamframework.org/ 的消息是:“Red Hat 已经停止了 Seam 3 的活跃开发。”“Seam 文档中的这部分内容”链接也已失效。 - badbishop

16

如果您确定实体没有被修改(或者如果您同意任何修改都将丢失),那么您可以使用锁重新将其附加到会话中。

session.lock(entity, LockMode.NONE);

它不会锁定任何内容,但它将从会话缓存中获取实体或(如果在那里找不到)从数据库中读取它。这非常有用,可以防止当您从“旧”的实体(例如来自HttpSession)导航关系时出现LazyInitException。您首先需要“重新附加”实体。

使用get可能也可以工作,除非您获取了继承映射(这将已经在getId()上引发异常)。

entity = session.get(entity.getClass(), entity.getId());

2
我想重新将一个实体与会话关联起来。不幸的是,Session.lock(entity, LockMode.NONE) 失败并抛出异常,说无法重新关联未初始化的瞬态集合。如何克服这个问题? - dma_k
1
事实上,我并不完全正确。使用lock()会重新附加您的实体,但不会重新附加绑定到它的其他实体。因此,如果您执行entity.getOtherEntity().getYetAnotherEntity(),可能会出现LazyInit异常。我知道克服这个问题的唯一方法是使用find。entity = em.find(entity.getClass(), entity.getId(); - John Rizzo
没有 Session.find() 的 API 方法。也许你想要使用 Session.load(Object object, Serializable id) - dma_k

10

我查看了 org.hibernate.Session 的 JavaDoc,并找到了以下内容:

通过调用 save()persist()saveOrUpdate(),可以将瞬态实例变为持久实例。通过调用 delete(),可以将持久实例变为瞬态实例。由 get()load() 方法返回的任何实例都是持久实例。通过调用 update()saveOrUpdate()lock()replicate(),可以将分离实例变为持久实例。也可以通过调用 merge() 将瞬态或分离实例的状态作为新持久实例进行持久化。

因此,update()saveOrUpdate()lock()replicate()merge() 是备选项。

update():如果存在具有相同标识符的持久实例,将抛出异常。

saveOrUpdate():保存或更新。

lock():已弃用。

replicate():持久化给定的分离实例的状态,重用当前标识符的值。

merge():返回具有相同标识符的持久对象。给定实例不会与会话关联。

因此,不应直接使用lock(),而是根据功能需求选择一个或多个选项。


7

我在C#中使用NHibernate这种方式实现了它,但是在Java中应该也可以用相同的方法:

public virtual void Attach()
{
    if (!HibernateSessionManager.Instance.GetSession().Contains(this))
    {
        ISession session = HibernateSessionManager.Instance.GetSession();
        using (ITransaction t = session.BeginTransaction())
        {
            session.Lock(this, NHibernate.LockMode.None);
            t.Commit();
        }
    }
}

每个对象都会调用第一个锁(Lock)方法,因为包含(Contains)方法总是返回false。问题在于NHibernate通过数据库ID和类型比较对象。如果没有重写,包含使用了equals方法进行比较引用。有了这个equals方法,它就可以正常工作而没有任何异常:

public override bool Equals(object obj)
{
    if (this == obj) { 
        return true;
    } 
    if (GetType() != obj.GetType()) {
        return false;
    }
    if (Id != ((BaseObject)obj).Id)
    {
        return false;
    }
    return true;
}

4

Session.contains(Object obj) 检查引用,不会检测已经附加到其上并表示相同行的不同实例。

这里是我针对具有标识属性的实体的通用解决方案。

public static void update(final Session session, final Object entity)
{
    // if the given instance is in session, nothing to do
    if (session.contains(entity))
        return;

    // check if there is already a different attached instance representing the same row
    final ClassMetadata classMetadata = session.getSessionFactory().getClassMetadata(entity.getClass());
    final Serializable identifier = classMetadata.getIdentifier(entity, (SessionImplementor) session);

    final Object sessionEntity = session.load(entity.getClass(), identifier);
    // override changes, last call to update wins
    if (sessionEntity != null)
        session.evict(sessionEntity);
    session.update(entity);
}

这是我喜欢的.NET EntityFramework的少数方面之一,涉及更改实体及其属性的不同附加选项。

3
我想出了一个解决方案来“刷新”一个对象从持久存储,这将考虑到可能已经附加到会话的其他对象。
public void refreshDetached(T entity, Long id)
{
    // Check for any OTHER instances already attached to the session since
    // refresh will not work if there are any.
    T attached = (T) session.load(getPersistentClass(), id);
    if (attached != entity)
    {
        session.evict(attached);
        session.lock(entity, LockMode.NONE);
    }
    session.refresh(entity);
}

2

可能在Eclipselink上表现略有不同。为了重新连接已分离的对象而不获取陈旧数据,我通常会执行以下操作:

Object obj = em.find(obj.getClass(), id);

另外,作为可选步骤(以使缓存失效):

em.refresh(obj)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接