Рубрики
Без рубрики

Как оптимизировать однонаправленные коллекции с помощью JPA и гибернации

Автор оригинала: Vlad Mihalcea.

Режим гибернации поддерживает три типа отображения данных : основные (например, строка, int), Встраиваемый и Сущность . Чаще всего строка базы данных сопоставляется с Сущностью , каждый столбец базы данных связан с атрибутом basic . Встраиваемые типы чаще встречаются при объединении нескольких сопоставлений полей в повторно используемую группу ( Встраиваемые объединяются в структуру сопоставления Сущности//владельца).

Оба базовых типа и Встраиваемые могут быть связаны с Сущностью через @ElementCollection в отношениях “одна сущность-много-не-сущностей”//.

Хотя мы собираемся объяснить эти оптимизации с помощью коллекции @ElementCollection, те же правила применимы к любым однонаправленным ассоциациям @OneToMany или как к однонаправленным, так и к двунаправленным ассоциациям @ManyToMany.

Для предстоящих тестовых случаев мы будем использовать следующую модель сущности:

Патч содержит коллекцию Изменяемых встраиваемых объектов.

@ElementCollection
@CollectionTable(
    name="patch_change",
    joinColumns=@JoinColumn(name="patch_id")
)
private List changes = new ArrayList<>();

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

Во-первых, нам нужно добавить некоторые тестовые данные:

doInTransaction(session -> {
    Patch patch = new Patch();
    patch.getChanges().add(
        new Change("README.txt", "0a1,5...")
    );
    patch.getChanges().add(
        new Change("web.xml", "17c17...")
    );
    session.persist(patch);
});

Добавление нового элемента

Давайте посмотрим, что произойдет, когда мы добавим новое Изменение в существующий Патч :

doInTransaction(session -> {
    Patch patch = (Patch) session.get(Patch.class, 1L);
    patch.getChanges().add(
        new Change("web.xml", "1d17...")
    );
});

Этот тест генерирует следующие SQL выходные данные:

DELETE FROM patch_change 
WHERE  patch_id = 1

INSERT INTO patch_change (patch_id, diff, path)
VALUES (1, '0a1,5...', 'README.txt') 

INSERT INTO patch_change(patch_id, diff, path)
VALUES (1, '17c17...', 'web.xml') 

INSERT INTO patch_change(patch_id, diff, path)
VALUES (1, '1d17...', 'web.xml') 

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

Удаление элемента

Удаление элемента ничем не отличается:

doInTransaction(session -> {
    Patch patch = (Patch) session.get(Patch.class, 1L);
    patch.getChanges().remove(0);
});

Этот тестовый случай генерирует эти SQL инструкции:

DELETE FROM patch_change 
WHERE  patch_id = 1

INSERT INTO patch_change(patch_id, diff, path)
VALUES (1, '17c17...', 'web.xml') 

Все строки таблицы были удалены, а оставшиеся записи в памяти были сброшены в базу данных.

В Вики-книге Сохраняемость Java четко документируется это поведение:

Спецификация JPA 2.0 не предоставляет способа определения идентификатора в встраиваемом. Однако для удаления или обновления элемента сопоставления коллекции элементов обычно требуется некоторый уникальный ключ. В противном случае при каждом обновлении поставщику JPA потребуется удалять все данные из таблицы сбора для Сущности, а затем вставлять значения обратно. Таким образом, поставщик JPA, скорее всего, будет предполагать, что комбинация всех полей в встраиваемом уникальна в сочетании с внешним ключом (столбцами JOINT). Это, однако, может быть неэффективным или просто невыполнимым, если встраиваемый объект большой или сложный.

Некоторые поставщики JPA могут разрешить указывать идентификатор в встраиваемом, чтобы решить эту проблему. Примечание.В этом случае идентификатор должен быть уникальным только для коллекции, а не для таблицы, так как включен внешний ключ. Некоторые также могут разрешить использовать для этого уникальную опцию в таблице сбора. В противном случае, если ваше встраиваемое является сложным, вы можете рассмотреть возможность создания его сущности и вместо этого использовать OneToMany.

Для оптимизации коллекции элементов поведение нам нужно применять те же методы, которые работают для один ко многим ассоциаций. Набор элементов подобен однонаправленному соотношению “один ко многим”, и мы уже знаем, что idbag работает лучше, чем однонаправленный мешок .

Поскольку Встраиваемый не может содержать идентификатор, мы можем, по крайней мере, добавить столбец порядка, чтобы каждая строка могла быть однозначно идентифицирована. Давайте посмотрим, что произойдет, когда мы добавим @OrderColumn в нашу коллекцию элементов:

@ElementCollection
@CollectionTable(
    name="patch_change",
    joinColumns=@JoinColumn(name="patch_id")
)
@OrderColumn(name = "index_id")
private List changes = new ArrayList<>();

Удаление объекта не приводит к улучшению результатов предыдущего теста:

DELETE FROM patch_change 
WHERE  patch_id = 1

INSERT INTO patch_change(patch_id, diff, path)
VALUES (1, '17c17...', 'web.xml') 

Это связано с тем, что abstractpersistentколлекция проверяет наличие столбцов, допускающих обнуление, при предотвращении повторного создания коллекции:

@Override
public boolean needsRecreate(CollectionPersister persister) {
    if (persister.getElementType() instanceof ComponentType) {
        ComponentType componentType = 
            (ComponentType) persister.getElementType();
        return !componentType.hasNotNullProperty();
    }
    return false;
}

Теперь мы добавим ограничения NOT NULL и повторим наши тесты:

@Column(name = "path", nullable = false)
private String path;

@Column(name = "diff", nullable = false)
private String diff;

Добавление нового упорядоченного элемента

Добавление элемента в конец списка создает следующую инструкцию:

INSERT INTO patch_change(patch_id, index_id, diff, path)
VALUES (1, 2, '1d17...', 'web.xml') 

Столбец index_id используется для сохранения порядка сбора данных в памяти. Добавление в конец коллекции не влияет на порядок существующих элементов, следовательно, только один ВСТАВИТЬ требуется заявление.

Добавление нового первого элемента

Если мы добавим новый элемент в начале списка:

doInTransaction(session -> {
    Patch patch = (Patch) session.get(Patch.class, 1L);
    patch.getChanges().add(0, 
        new Change("web.xml", "1d17...")
    );
});

Генерирует следующие SQL выходные данные:

UPDATE patch_change
SET    diff = '1d17...',
       path = 'web.xml'
WHERE  patch_id = 1
       AND index_id = 0 

UPDATE patch_change
SET    diff = '0a1,5...',
       path = 'README.txt'
WHERE  patch_id = 1
       AND index_id = 1

INSERT INTO patch_change (patch_id, index_id, diff, path)
VALUES (1, 2, '17c17...', 'web.xml') 

Существующие записи базы данных обновляются, чтобы отразить новую структуру данных в памяти. Поскольку вновь добавленный элемент добавляется в начале списка, он вызовет обновление первой строки таблицы. Все ВСТАВИТЬ заявления выдаются в конце списка, и все существующие элементы обновляются в соответствии с новым порядком списка.

Это поведение объясняется в столбце @OrderColumn Документация по сохраняемости Java:

Поставщик сохраняемости поддерживает непрерывный (не разреженный) порядок значений столбца order при обновлении ассоциации или коллекции элементов. Значение столбца порядка для первого элемента равно 0.

Удаление упорядоченного элемента

Если мы удалим последнюю запись:

doInTransaction(session -> {
    Patch patch = (Patch) session.get(Patch.class, 1L);
    patch.getChanges().remove(patch.getChanges().size() - 1);
});

Выдается только одно заявление об УДАЛЕНИИ:

DELETE FROM patch_change
WHERE  patch_id = 1
       AND index_id = 1 

Удаление первой записи элемента

Если мы удалим первый элемент, будут выполнены следующие инструкции:

DELETE FROM patch_change
WHERE  patch_id = 1
       AND index_id = 1 

UPDATE patch_change
SET    diff = '17c17...',
       path = 'web.xml'
WHERE  patch_id = 1
       AND index_id = 0 

Hibernate удаляет все лишние строки, а затем обновляет оставшиеся.

Удаление из середины

Если мы удалим элемент из середины списка:

doInTransaction(session -> {
    Patch patch = (Patch) session.get(Patch.class, 1L);
    patch.getChanges().add(new Change("web.xml", "1d17..."));
    patch.getChanges().add(new Change("server.xml", "3a5..."));
});

doInTransaction(session -> {
    Patch patch = (Patch) session.get(Patch.class, 1L);
    patch.getChanges().remove(1);
});

Выполняются следующие инструкции:

DELETE FROM patch_change
WHERE  patch_id = 1
       AND index_id = 3

UPDATE patch_change
SET    diff = '1d17...',
       path = 'web.xml'
WHERE  patch_id = 1
       AND index_id = 1 

UPDATE patch_change
SET    diff = '3a5...',
       path = 'server.xml'
WHERE  patch_id = 1
       AND index_id = 2 

Заказанный Коллекция элементов обновляется следующим образом:

  • Размер таблицы базы данных корректируется, операторы DELETE удаляют дополнительные строки, расположенные в конце таблицы. Если коллекция в памяти больше, чем ее аналог в базе данных, то все операторы INSERT будут выполняться в конце списка
  • Все элементы, расположенные перед записью добавления/удаления, остаются нетронутыми
  • Остальные элементы, расположенные после добавления/удаления, обновляются в соответствии с новым состоянием коллекции в памяти

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

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