【问题标题】:Find or insert based on unique key with Hibernate使用 Hibernate 基于唯一键查找或插入
【发布时间】:2011-06-28 17:05:49
【问题描述】:

我正在尝试编写一个方法,该方法将基于唯一但非主键返回一个 Hibernate 对象。如果实体已经存在于数据库中,我想返回它,但如果它不存在,我想创建一个新实例并在返回之前保存它。

更新:让我澄清一下,我正在为其编写的应用程序基本上是输入文件的批处理器。系统需要逐行读取文件并将记录插入到数据库中。文件格式基本上是我们模式中几个表的非规范化视图,所以我要做的是解析父记录,或者将其插入数据库,以便我可以获得一个新的合成键,或者如果它已经存在,则选择它。然后我可以在具有外键的其他表中添加额外的关联记录回到该记录。

这变得棘手的原因是每个文件都需要完全导入或根本不导入,即对给定文件所做的所有插入和更新都应该是一个事务的一部分。如果只有一个进程在执行所有导入,这很容易,但如果可能的话,我想将其拆分到多台服务器上。由于这些限制,我需要能够留在一个事务中,但要处理已经存在记录的异常。

父记录的映射类如下所示:

@Entity
public class Foo {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private int id;
    @Column(unique = true)
    private String name;
    ...
}

我最初编写此方法的尝试如下:

public Foo findOrCreate(String name) {
    Foo foo = new Foo();
    foo.setName(name);
    try {
        session.save(foo)
    } catch(ConstraintViolationException e) {
        foo = session.createCriteria(Foo.class).add(eq("name", name)).uniqueResult();
    }
    return foo;
}

问题是当我要查找的名称存在时,调用 uniqueResult() 会引发 org.hibernate.AssertionFailure 异常。完整的堆栈跟踪如下:

org.hibernate.AssertionFailure: null id in com.searchdex.linktracer.domain.LinkingPage entry (don't flush the Session after an exception occurs)
    at org.hibernate.event.def.DefaultFlushEntityEventListener.checkId(DefaultFlushEntityEventListener.java:82) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.event.def.DefaultFlushEntityEventListener.getValues(DefaultFlushEntityEventListener.java:190) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.event.def.DefaultFlushEntityEventListener.onFlushEntity(DefaultFlushEntityEventListener.java:147) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.event.def.AbstractFlushingEventListener.flushEntities(AbstractFlushingEventListener.java:219) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.event.def.AbstractFlushingEventListener.flushEverythingToExecutions(AbstractFlushingEventListener.java:99) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.event.def.DefaultAutoFlushEventListener.onAutoFlush(DefaultAutoFlushEventListener.java:58) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.impl.SessionImpl.autoFlushIfRequired(SessionImpl.java:1185) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.impl.SessionImpl.list(SessionImpl.java:1709) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.impl.CriteriaImpl.list(CriteriaImpl.java:347) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]
    at org.hibernate.impl.CriteriaImpl.uniqueResult(CriteriaImpl.java:369) [hibernate-core-3.6.0.Final.jar:3.6.0.Final]

有谁知道是什么导致这个异常被抛出? hibernate 是否支持更好的方法来实现这一点?

让我先解释一下为什么我要先插入,然后再选择是否以及何时失败。这需要在分布式环境中工作,因此我无法在检查中同步以查看记录是否已经存在和插入。最简单的方法是让数据库通过检查每次插入时是否违反约束来处理这种同步。

【问题讨论】:

  • “分布式环境”是什么意思?您是否使用基于网格的 RDBMS?
  • @vbence 分布式 我的意思是我有多个客户端在不同的机器上针对单个集中式数据库运行此代码。两个不同的客户端可以尝试同时插入相同的记录,在这种情况下,“第一个”客户端应该获胜并被持久化,而另一个客户端应该只返回已经持久化的内容。
  • 您真的没有看到不正确分解数据的后果吗?
  • @Thomas,我不确定您所说的“正确分解数据”是什么意思。
  • 您是否考虑过对批处理数据进行预处理,以免发生冲突?还是简单地先在一个线程中处理冲突的部分?

标签: java hibernate unique-constraint upsert unique-key


【解决方案1】:

我有类似的批处理需求,进程在多个 JVM 上运行。我为此采取的方法如下。这很像 jtahlborn 的建议。但是,正如 vbence 指出的那样,如果您使用 NESTED 事务,当您获得约束违反异常时,您的会话将失效。相反,我使用 REQUIRES_NEW,它暂停当前事务并创建一个新的独立事务。如果新事务回滚,不会影响原来的事务。

我正在使用 Spring 的 TransactionTemplate,但如果您不想依赖 Spring,我相信您可以轻松翻译它。

public T findOrCreate(final T t) throws InvalidRecordException {
   // 1) look for the record
   T found = findUnique(t);
   if (found != null)
     return found;
   // 2) if not found, start a new, independent transaction
   TransactionTemplate tt = new TransactionTemplate((PlatformTransactionManager)
                                            transactionManager);
   tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
   try {
     found = (T)tt.execute(new TransactionCallback<T>() {
        try {
            // 3) store the record in this new transaction
            return store(t);
        } catch (ConstraintViolationException e) {
            // another thread or process created this already, possibly
            // between 1) and 2)
            status.setRollbackOnly();
            return null;
        }
     });
     // 4) if we failed to create the record in the second transaction, found will
     // still be null; however, this would happy only if another process
     // created the record. let's see what they made for us!
     if (found == null)
        found = findUnique(t);
   } catch (...) {
     // handle exceptions
   }
   return found;
}

【讨论】:

  • 我很惊讶这个作品。我的印象是hibernate不支持嵌套事务。
  • Hibernate 委托给底层事务管理器。 Spring 提供对嵌套和暂停事务的支持。 EJB 服务器也将通过 JTA 支持它。如果您想避免同时使用 EJB 应用服务器和 Spring,也可以选择一些独立的 JTA 提供程序,例如 Atomikos。
  • @LawrenceMcAlpin 干得好!但就我而言,在第 4 步中,当 foundnull 时,findUnique() 将返回 null,即使该记录实际上存在于数据库中(因为由另一个线程创建)。我必须实现另一个TransactionTemplate 才能读取记录。你明白为什么吗?
  • Mike Deck:我真的很想知道这对你有什么用。正如@sp00m 所观察到的,在另一个事务中创建的记录在另一个并发事务中不可见(一旦创建了它们的时间点快照)。请参阅 Vlad Mihalceas 回答下面的讨论。
【解决方案2】:

您需要使用UPSERTMERGE 来实现此目标。

但是,Hibernate 不支持这种结构,因此您需要改用jOOQ

private PostDetailsRecord upsertPostDetails(
        DSLContext sql, Long id, String owner, Timestamp timestamp) {
    sql
    .insertInto(POST_DETAILS)
    .columns(POST_DETAILS.ID, POST_DETAILS.CREATED_BY, POST_DETAILS.CREATED_ON)
    .values(id, owner, timestamp)
    .onDuplicateKeyIgnore()
    .execute();

    return sql.selectFrom(POST_DETAILS)
    .where(field(POST_DETAILS.ID).eq(id))
    .fetchOne();
}

在 PostgreSQL 上调用这个方法:

PostDetailsRecord postDetailsRecord = upsertPostDetails(
    sql, 
    1L, 
    "Alice",
    Timestamp.from(LocalDateTime.now().toInstant(ZoneOffset.UTC))
);

产生以下 SQL 语句:

INSERT INTO "post_details" ("id", "created_by", "created_on") 
VALUES (1, 'Alice',  CAST('2016-08-11 12:56:01.831' AS timestamp))
ON CONFLICT  DO NOTHING;
    
SELECT "public"."post_details"."id",
       "public"."post_details"."created_by",
       "public"."post_details"."created_on",
       "public"."post_details"."updated_by",
       "public"."post_details"."updated_on"
FROM "public"."post_details"
WHERE "public"."post_details"."id" = 1

在 Oracle 和 SQL Server 上,jOOQ 将使用 MERGE,而在 MySQL 上它将使用 ON DUPLICATE KEY

插入、更新或删除记录时采用的行级锁定机制确保了并发机制,您可以在下图中查看:

代码在GitHub 上可用。

【讨论】:

  • 在 jOOQ 中,根据您的数据库支持(例如 PostgreSQL),您可以将 returning() 附加到 INSERT 语句中,它会在一条语句中为您获取该记录。
  • @LukasEder 酷!我不知道 PostgreSQL 中的 RETURNING 子句。
  • 如果有两个并发事务都尝试创建相同的记录,然后(仍在事务内部)对新创建的记录做一些事情怎么办?在后续事务中没有问题,但在忽略重复键的事务中,我认为在另一个事务中创建的记录不可见/不可选。我错了吗?这个应该怎么处理?
  • 没有冲突,我同意,但是 inside 第二个 Tx 在第一个 Tx 中创建的条目不可见/不可选,因此不能在 inside 第二个 Tx。
  • 插入在选择之前。如果行被 Tx1 锁定,Tx2 将等待。当 Tx1 提交时,Tx2 将恢复。因此,由于行级可串行化,记录对 Tx2 可见。
【解决方案3】:

想到两个解决方案:

这就是 TABLE LOCKS 的用途

Hibernate 不支持表锁,但这是它们派上用场的情况。幸运的是,您可以通过Session.createSQLQuery() 使用本机 SQL。例如(在 MySQL 上):

// no access to the table for any other clients
session.createSQLQuery("LOCK TABLES foo WRITE").executeUpdate();

// safe zone
Foo foo = session.createCriteria(Foo.class).add(eq("name", name)).uniqueResult();
if (foo == null) {
    foo = new Foo();
    foo.setName(name)
    session.save(foo);
}

// releasing locks
session.createSQLQuery("UNLOCK TABLES").executeUpdate();

这样当一个会话(客户端连接)获得锁时,所有其他连接都会被阻塞,直到操作结束并释放锁。其他连接的读取操作也被阻止,所以不用说只在原子操作的情况下使用。

Hibernate 的锁呢?

Hibernate 使用行级锁定。我们不能直接使用它,因为我们不能锁定不存在的行。但是我们可以创建一个带有单个记录的 dummy 表,将其映射到 ORM,然后在该对象上使用SELECT ... FOR UPDATE 样式锁来同步我们的客户端。基本上,我们只需要确保没有其他客户端(运行相同的软件,具有相同的约定)在我们工作时会执行任何冲突的操作。

// begin transaction
Transaction transaction = session.beginTransaction();

// blocks until any other client holds the lock
session.load("dummy", 1, LockOptions.UPGRADE);

// virtual safe zone
Foo foo = session.createCriteria(Foo.class).add(eq("name", name)).uniqueResult();
if (foo == null) {
    foo = new Foo();
    foo.setName(name)
    session.save(foo);
}

// ends transaction (releasing locks)
transaction.commit();

您的数据库必须知道SELECT ... FOR UPDATE 语法(Hibernate 是使用它的 goig),当然这只有在您的所有客户端都具有相同约定时才有效(他们需要锁定相同的虚拟实体)。

【讨论】:

  • 这是一个很好的建议,并按照我最初的措辞回答了问题,但从技术上讲仍然无法解决我的问题。我已经更新了这个问题,以明确我面临的根本问题。基本上我需要能够在单个事务中导入多条记录,因此等到整个事务被提交后才释放锁,这违背了首先使用多个客户端进程的目的。我开始认为如果我真的想继续使用这种架构,我需要使用原始 JDBC 来执行此操作。
  • 您可以根据原始的CREATE TABLE创建临时表。客户可以拥有所有权并从容地完成工作。然后,其他进程或存储过程可以将记录复制到实时数据库。或者(如果您使用许多表)也可以创建临时数据库。
  • 不使用依赖于本地 SQL 语法和特性的显式表锁,将 JDBC 事务隔离级别设置为 TRANSACTION_SERIALIZABLE 可以实现类似的行为。优点是要锁定的表将由数据库本身确定,从而避免显式指定要锁定的表。为此,请使用 Session.doWork 和 connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE)
【解决方案4】:

Hibernate documentation on transactions and exceptions 声明所有 HibernateExceptions 都是不可恢复的,并且一旦遇到当前事务就必须回滚。这就解释了为什么上面的代码不起作用。最终,您不应该在不退出事务并关闭会话的情况下捕获 HibernateException。

似乎实现这一点的唯一真正方法是管理关闭旧会话并在方法本身内重新打开新会话。根据我的发现,使用 Hibernate 实现可以参与现有事务并且在分布式环境中安全的 findOrCreate 方法似乎是不可能的。

【讨论】:

    【解决方案5】:

    解决方案其实很简单。首先使用您的名称值执行选择。如果找到结果,则返回该结果。如果没有,请创建一个新的。如果创建失败(有例外),这是因为另一个客户端在您的 select 和 insert 语句之间添加了这个相同的值。那么你有一个例外是合乎逻辑的。抓住它,回滚您的事务并再次运行相同的代码。因为该行已经存在,所以 select 语句会找到它并返回您的对象。

    您可以在此处查看使用 hibernate 进行乐观和悲观锁定的策略说明:http://docs.jboss.org/hibernate/core/3.3/reference/en/html/transactions.html

    【讨论】:

      【解决方案6】:

      一些人提到了整体战略的不同部分。假设您通常希望找到现有对象的频率高于创建新对象的频率:

      • 按名称搜索现有对象。如果找到,返回
      • 开始嵌套(单独)事务
        • 尝试插入新对象
        • 提交嵌套事务
      • 从嵌套事务中捕获任何失败,如果没有违反约束,则重新抛出
      • 否则按名称搜索现有对象并将其返回

      只是为了澄清,正如另一个答案中所指出的,“嵌套”事务实际上是一个单独的事务(许多数据库甚至不支持真正的嵌套事务)。

      【讨论】:

      • 前两步之间还是有可能插入了一条新记录,所以问题和原帖基本一样。如果你得到一个持续的违规异常,你的会话就会失效。
      • @vbence - 是的,我解释了这一点。当您遇到约束违规时,您会吞下它并在外部会话中加载新对象。这是在分布式环境中执行此操作的唯一方法。我已经使用休眠完成了这件事,所以我知道它有效。
      • @vbence - 您正在使用嵌套事务/会话,这不会影响外部的。
      • 根据你的流程图看不太明显。
      【解决方案7】:

      嗯,这是一种方法 - 但它并不适用于所有情况。

      • 在 Foo 中,删除 name 上的“unique = true”属性。添加在每次插入时都会更新的时间戳。
      • findOrCreate() 中,不必检查具有给定名称的实体是否已存在 - 每次只需插入一个新实体即可。
      • 通过name 查找 Foo 实例时,可能有 0 个或多个具有给定名称的实例,因此您只需选择最新的。

      这个方法的好处是它不需要任何锁定,所以一切都应该运行得很快。缺点是您的数据库将充满过时的记录,因此您可能必须在其他地方做一些事情来处理它们。此外,如果其他表通过其id 引用 Foo,那么这将搞砸这些关系。

      【讨论】:

      • 这是个好主意,但是这个表的id在其他几个表中被用作外键。
      【解决方案8】:

      也许你应该改变你的策略: 首先找到具有该名称的用户,并且仅当该用户不存在时才创建它。

      【讨论】:

      • 我解释了为什么我选择了我在问题末尾所做的策略。在无法跨两个操作同步的分布式环境中,在插入之前进行检查不起作用。
      • 也许你不必同步。如果您先进行搜索,您将在用户不存在时创建,如果此时有人使用此名称创建一个,数据库将为您处理此问题,当您保存时,您将得到一个异常,所以你只要做点什么就行了。
      • @logui,当你说“你会得到一个例外,所以你只是做点什么”,你建议我做什么?那时我将需要运行另一个选择以从数据库中检索该条目,除非在引发异常后会话已死并且我需要回滚事务。在这种情况下,我又回到了原点,解决了我一开始问的完全相同的问题。
      【解决方案9】:

      我会尝试以下策略:

      A。开始一个主事务(在时间 1)
      B。开始一个子事务(在时间 2)

      现在,在时间 1 之后创建的任何对象都不会在主事务中可见。所以当你这样做时

      C。创建新的竞争条件对象,提交子事务
      D。通过启动一个新的子事务(在时间 3)并从查询中获取对象(来自 B 点的子事务现在超出范围)来处理冲突。

      仅返回对象主键,然后使用 EntityManager.getReference(..) 获取您将在主事务中使用的对象。或者,在 D 之后启动主事务;我并不完全清楚你的主要交易中有多少竞争条件,但以上应该允许在“大”交易中进行 n 次 B-C-D。

      请注意,您可能希望进行多线程处理(每个 CPU 一个线程),然后您可以通过使用共享静态缓存来解决此类冲突,从而大大减少此问题 - 并且第 2 点可以保持“乐观”,即先不做 .find(..)。

      编辑:对于一个新事务,您需要一个 EJB 接口方法调用,并用事务类型 REQUIRES_NEW 注释。

      编辑:仔细检查 getReference(..) 是否像我认为的那样工作。

      【讨论】:

        猜你喜欢
        • 2016-04-27
        • 1970-01-01
        • 2016-06-07
        • 2023-02-10
        • 1970-01-01
        • 1970-01-01
        • 2017-03-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多