Найти или вставить на основе уникального ключа с помощью Hibernate

Я пытаюсь написать метод, который будет возвращать объект 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]

Кто-нибудь знает, что вызывает это исключение? Поддерживает ли спящий режим лучший способ добиться этого?

Позвольте мне также предварительно объяснить, почему я сначала вставляю, а затем выбираю, если и когда это не удается. Это должно работать в распределенной среде, поэтому я не могу синхронизировать проверку, чтобы увидеть, существует ли уже запись и вставка. Самый простой способ сделать это — позволить базе данных обрабатывать эту синхронизацию, проверяя нарушение ограничения при каждой вставке.


person Mike Deck    schedule 16.02.2011    source источник
comment
Что вы подразумеваете под распределенной средой? Используете ли вы RDBMS на основе сетки?   -  person vbence    schedule 27.04.2011
comment
@vbence под распределенным Я имею в виду, что у меня есть несколько клиентов на разных машинах, выполняющих этот код в одной централизованной базе данных. Два отдельных клиента могут попытаться вставить одну и ту же запись одновременно, в этом случае первый должен выиграть и сохраниться, а другой клиент должен просто вернуть то, что уже было сохранено.   -  person Mike Deck    schedule 27.04.2011
comment
вы действительно не видите последствий неправильного разбиения данных?   -  person ThomasRS    schedule 02.05.2011
comment
@ Томас, я не уверен, что вы подразумеваете под правильным разбиением данных.   -  person Mike Deck    schedule 02.05.2011
comment
Рассматривали ли вы предварительную обработку ваших пакетных данных, чтобы не было конфликтов? Или просто сначала обработать конфликтующие части в одном потоке?   -  person ThomasRS    schedule 02.05.2011


Ответы (9)


У меня было аналогичное требование к пакетной обработке, когда процессы выполнялись на нескольких JVM. Подход, который я выбрал для этого, был следующим. Это очень похоже на предложение Джталборна. Однако, как указал vbence, если вы используете транзакцию NESTED, когда вы получаете исключение нарушения ограничения, ваш сеанс становится недействительным. Вместо этого я использую REQUIRES_NEW, который приостанавливает текущую транзакцию и создает новую, независимую транзакцию. Если новая транзакция откатится, это не повлияет на исходную транзакцию.

Я использую TransactionTemplate Spring, но я уверен, что вы можете легко перевести его, если не хотите зависимости от 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;
}
person Lawrence McAlpin    schedule 28.04.2011
comment
Я удивлен, что это работает. У меня сложилось впечатление, что спящий режим не поддерживает вложенные транзакции. - person Mike Deck; 29.04.2011
comment
Спящий режим делегирует основному диспетчеру транзакций. Spring обеспечивает поддержку вложенных и приостановленных транзакций. Сервер EJB также будет поддерживать это через JTA. Есть также некоторые автономные поставщики JTA, такие как Atomikos, которые являются вариантом, если вы хотите избежать как сервера приложений EJB, так и Spring. - person Lawrence McAlpin; 29.04.2011
comment
@LawrenceMcAlpin Хорошая работа! Однако в моем случае на шаге 4, когда found равно null, findUnique() возвращает null, даже если запись действительно существует в базе данных (поскольку она создана другим потоком). Мне пришлось реализовать еще один TransactionTemplate для чтения записи. Вы понимаете, почему? - person sp00m; 09.10.2014
comment
Майк Дек: Мне действительно интересно, как это может сработать для вас. Как заметил @sp00m, запись, созданная в другой транзакции, не может быть видна в другой параллельной транзакции (как только будут созданы их снимки на момент времени). См. обсуждение ниже ответа Влада Михалчеаса. - person bgraves; 06.11.2017

Вам нужно использовать UPSERT или MERGE для достижения этой цели.

Однако 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.

person Vlad Mihalcea    schedule 03.11.2017
comment
В jOOQ, в зависимости от поддержки вашей базы данных (например, PostgreSQL), вы можете добавить returning() к оператору INSERT, и вы получите эту запись в одном операторе. - person Lukas Eder; 03.11.2017
comment
@LukasEder Круто! Я не знал о предложении RETURNING в PostgreSQL. - person Vlad Mihalcea; 03.11.2017
comment
Что, если есть две параллельные транзакции, которые пытаются создать одну и ту же запись, а затем (все еще внутри транзакции) делают что-то с вновь созданной записью? Нет проблем внутри последующей транзакции, но в транзакции, которая игнорирует повторяющийся ключ, я думаю, что запись, созданная в другой транзакции, не видна/недоступна для выбора. Я ошибся? Как с этим справиться? - person bgraves; 03.11.2017
comment
Согласен, никаких конфликтов, но внутри второго Tx запись, которая была создана в первом Tx, не видна/недоступна для выбора и, следовательно, не может использоваться внутри второго Tx. - person bgraves; 04.11.2017
comment
Вставка предшествует выбору. Если строка заблокирована Tx1, Tx2 будет ждать. Когда Tx1 зафиксируется, Tx2 возобновится. Таким образом, запись становится видимой для Tx2 благодаря возможности сериализации на уровне строк. - person Vlad Mihalcea; 04.11.2017
comment
Привет @VladMihalcea, я создал небольшой репозиторий . По крайней мере, при использовании mysql 5.5.x запись, созданная в Tx1, не видна в Tx2, чтобы ее можно было выбрать (в Tx2). Или я что-то не так делаю? - person bgraves; 06.11.2017
comment
Я сделал что-то ужасно неправильное, готовясь объяснить это... ;-) - person bgraves; 15.03.2019

На ум приходят два решения:

Вот для чего нужны TABLE LOCK

Hibernate не поддерживает блокировки таблиц, но это тот случай, когда они пригодятся. К счастью, вы можете использовать собственный SQL через Session.createSQLQuery(). Например (в 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 использует блокировку на уровне строк. Мы не можем использовать его напрямую, потому что мы не можем заблокировать несуществующие строки. Но мы можем создать фиктивную таблицу с одной записью, сопоставить ее с 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 собирается использовать его), и, конечно, это работает, только если все ваши клиенты имеют одно и то же соглашение (им нужно блокировать один и тот же фиктивный объект).

person vbence    schedule 27.04.2011
comment
Это хороший совет, и он отвечает на вопрос так, как я его изначально сформулировал, но технически все еще не решит мою проблему. Я обновил вопрос, чтобы прояснить основную проблему, с которой я столкнулся. По сути, мне нужно иметь возможность импортировать несколько записей в рамках одной транзакции, поэтому ожидание, пока вся транзакция не будет зафиксирована для снятия блокировки, в первую очередь лишает цели использования нескольких клиентских процессов. Я начинаю думать, что мне нужно будет использовать необработанный JDBC, чтобы сделать это, если я действительно хочу остаться с этой архитектурой. - person Mike Deck; 28.04.2011
comment
Вы можете создавать временные таблицы на основе CREATE TABLE оригинала. Клиент может иметь право собственности и спокойно выполнять свою работу. Затем другой процесс или хранимая процедура может скопировать записи в оперативную БД. В качестве альтернативы (если вы работаете со многими таблицами) можно также создавать временные базы данных. - person vbence; 28.04.2011
comment
Вместо использования явных блокировок таблиц, которые зависели бы от собственного синтаксиса и функций SQL, аналогичного поведения можно было бы добиться, установив уровень изоляции транзакций JDBC на TRANSACTION_SERIALIZABLE. Преимущество будет заключаться в том, что таблицы для блокировки будут определяться самой БД, что позволяет избежать явного указания таблиц для блокировки. Для этого используйте Session.doWork и connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE) - person Christian K.; 05.12.2014

документация Hibernate по транзакциям and exceptions указывает, что все HibernateExceptions неисправимы и что текущая транзакция должна быть отменена, как только она будет обнаружена. Это объясняет, почему приведенный выше код не работает. В конечном счете, вы никогда не должны перехватывать HibernateException без выхода из транзакции и закрытия сеанса.

Казалось бы, единственный реальный способ добиться этого — управлять закрытием старого сеанса и повторным открытием нового внутри самого метода. Основываясь на том, что я обнаружил, реализация метода findOrCreate, который может участвовать в существующей транзакции и является безопасным в распределенной среде, кажется невозможной с использованием Hibernate.

person Mike Deck    schedule 16.02.2011

Решение на самом деле очень простое. Сначала выполните выбор, используя значение вашего имени. Если результат найден, верните его. Если нет, создайте новый. В случае сбоя создания (с исключением) это происходит потому, что другой клиент добавил это же самое значение между вашим оператором select и вашим оператором вставки. Тогда логично, что у вас есть исключение. Поймай его, откатай свою транзакцию и снова запусти тот же код. Поскольку строка уже существует, оператор select найдет ее, и вы вернете свой объект.

Вы можете увидеть здесь объяснение стратегий для оптимистичной и пессимистической блокировки с помощью спящего режима здесь: http://docs.jboss.org/hibernate/core/3.3/reference/en/html/transactions.html

person Nicolas Bousquet    schedule 28.04.2011

несколько человек упомянули различные части общей стратегии. предполагая, что вы обычно ожидаете найти существующий объект чаще, чем вы создаете новый объект:

  • поиск существующего объекта по имени. если найдут, верни
  • start nested (separate) transaction
    • try to insert new object
    • совершить вложенную транзакцию
  • поймать любой сбой из вложенной транзакции, если что-либо, кроме нарушения ограничения, повторно бросить
  • в противном случае найдите существующий объект по имени и верните его

просто чтобы уточнить, как указано в другом ответе, «вложенная» транзакция на самом деле является отдельной транзакцией (многие базы данных даже не поддерживают настоящие вложенные транзакции).

person jtahlborn    schedule 28.04.2011
comment
По-прежнему возможно, что между первыми двумя шагами будет вставлена ​​новая запись, поэтому проблема остается в основном такой же, как и в исходном сообщении. Если вы получаете постоянное исключение нарушения, ваша сессия становится недействительной. - person vbence; 28.04.2011
comment
@vbence - да, я это учёл. когда вы получаете нарушение ограничения, вы проглатываете это и загружаете новый объект во внешний сеанс. это единственный способ сделать это в распределенной среде. Я сделал это, используя спящий режим, поэтому я знаю, что это работает. - person jtahlborn; 28.04.2011
comment
@vbence — вы используете вложенную транзакцию/сессию, которая не влияет на внешнюю. - person jtahlborn; 28.04.2011
comment
Судя по вашей блок-схеме, это было не очевидно. - person vbence; 29.04.2011

Что ж, вот один из способов сделать это, но он подходит не для всех ситуаций.

  • В Foo удалите атрибут "unique = true" на name. Добавьте метку времени, которая обновляется при каждой вставке.
  • В findOrCreate() не утруждайте себя проверкой существования сущности с данным именем — просто каждый раз вставляйте новую.
  • При поиске экземпляров Foo по name может быть 0 или более экземпляров с заданным именем, поэтому вы просто выбираете самый новый.

Преимущество этого метода в том, что он не требует блокировки, поэтому все должно работать довольно быстро. Недостатком является то, что ваша база данных будет завалена устаревшими записями, поэтому вам, возможно, придется что-то делать в другом месте, чтобы с ними справиться. Кроме того, если другие таблицы ссылаются на Foo по id, то это испортит эти отношения.

person Mike Baranczak    schedule 16.02.2011
comment
Это хорошая идея, но идентификатор этой таблицы используется в качестве внешнего ключа в нескольких других таблицах. - person Mike Deck; 17.02.2011

Возможно, вам следует изменить свою стратегию: сначала найти пользователя с именем и только в том случае, если пользователь не существует, создать его.

person Iogui    schedule 16.02.2011
comment
В конце вопроса я объяснил, почему выбрал именно эту стратегию. Выполнение проверки перед вставкой не работает в распределенной среде, где вы не можете синхронизировать две операции. - person Mike Deck; 17.02.2011
comment
Возможно, вам не нужно синхронизироваться. Если вы сначала сделаете поиск, вы создадите, только если пользователь не существует, и если за это время кто-то создаст его с этим именем, база данных обработает это для вас, и когда вы сохраните, вы получите исключение, поэтому вы просто делаете что-то об этом. - person Iogui; 17.02.2011
comment
@logui, когда вы говорите, что получите исключение, так что вы просто что-то с этим делаете, что вы предлагаете мне делать? В этот момент мне нужно будет запустить еще один выбор, чтобы получить эту запись из базы данных, за исключением того, что после того, как исключение было выброшено, сеанс мертв, и мне нужно откатить транзакцию. В этом сценарии я возвращаюсь к исходной точке, решая ту же самую проблему, о которой я спрашивал в первую очередь. - person Mike Deck; 18.03.2011

Я бы попробовал следующую стратегию:

А. Начать основную транзакцию (в момент времени 1)
B. Начать подтранзакцию (во время 2)

Теперь любой объект, созданный после времени 1, не будет виден в основной транзакции. Итак, когда вы делаете

С. Создайте новый объект состояния гонки, зафиксируйте подтранзакцию
D. Обработайте конфликт, запустив новую подтранзакцию (в момент времени 3) и получив объект из запроса (подтранзакция из точки B теперь выходит за рамки).

верните только первичный ключ объекта, а затем используйте EntityManager.getReference(..) для получения объекта, который вы будете использовать в основной транзакции. В качестве альтернативы начните основную транзакцию после D; мне не совсем ясно, сколько условий гонки у вас будет в вашей основной транзакции, но вышеизложенное должно учитывать n раз BCD в «большой» транзакции.

Обратите внимание, что вы можете захотеть использовать многопоточность (один поток на ЦП), и тогда вы, вероятно, сможете значительно уменьшить эту проблему, используя общий статический кеш для таких конфликтов, и пункт 2 можно оставить «оптимистичным», т.е. .find(..) сначала.

Изменить: для новой транзакции вам нужен вызов метода интерфейса EJB, аннотированный типом транзакции REQUIRES_NEW.

Изменить: Дважды проверьте, что getReference(..) работает так, как я думаю.

person ThomasRS    schedule 02.05.2011