Найти или вставить на основе уникального ключа с Hibernate - PullRequest
18 голосов
/ 17 февраля 2011

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

Проблема в том, что когда искомое имя существует, возникает исключение org.hibernate.AssertionFailure при вызове uniqueResult (). Полная трассировка стека ниже:

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 поддерживает лучший способ сделать это?

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

Ответы [ 9 ]

11 голосов
/ 28 апреля 2011

У меня было такое же требование пакетной обработки, когда процессы выполнялись на нескольких JVM. Подход, который я выбрал для этого, был следующим. Это очень похоже на предложение Джталборна. Однако, как указал 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;
}
8 голосов
/ 28 апреля 2011

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

Вот для чего предназначены СТОЛОВЫЕ ЗАМКИ

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

5 голосов
/ 03 ноября 2017

Это отличный вопрос, поэтому я решил написать статью, чтобы объяснить ее более подробно .

Как я объяснил в этой бесплатной главе моей книги , Высокопроизводительная стойкость Java, вам нужно использовать 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.

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

enter image description here

Код доступен на GitHub .

2 голосов
/ 28 апреля 2011

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

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

2 голосов
/ 28 апреля 2011

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

  • поиск существующего объекта по имени. если найдено, вернуть
  • начать вложенную (отдельную) транзакцию
    • попробуйте вставить новый объект
    • фиксация вложенной транзакции
  • поймать любой сбой из вложенной транзакции, если что-либо кроме нарушения ограничения, повторно выбросить
  • в противном случае искать существующий объект по имени и возвращать его

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

2 голосов
/ 17 февраля 2011

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

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

1 голос
/ 17 февраля 2011

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

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

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

0 голосов
/ 02 мая 2011

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

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

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

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

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

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

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

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

0 голосов
/ 17 февраля 2011

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

...