Хабрахабр

Spring Data JPA: что такое хорошо, и что такое плохо

Крошка-сын к отцу пришел
И спросила кроха
— Что такое хорошо
и что такое плохо

Владимир Маяковский

Эта статья о Spring Data JPA, а именно в подводных граблях, встретившихся на моём пути, ну и конечно же немного о производительности.

Примеры, описанные в статье можно запустить в проверочном окружении, доступном по ссылке.

Примечание для тех, кто ещё не переехал на Spring Boot 2

В версиях 1.* основные методы выглядели так: В версиях Spring Data JPA 2.* значительным изменениям подвергся основной интерфейс для работы с репозиториями, а именно CrudRepository, от которого наследуется JpaRepository.

public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids);
}

В новых версиях:

public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids);
}

Итак, приступим.

select t.* from t where t.id in (...)

Уверен, почти все из вас писали или видели что-то вроде Один из наиболее распространённых запросов — это запрос вида "выбери все записи, у которых ключ попадает в переданное множество".

@Query("select ba from BankAccount ba where ba.user.id in :ids")
List<BankAccount> findByUserIds(@Param("ids") List<Long> ids); @Query("select ba from BankAccount ba where ba.user.id in :ids")
List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);

Это рабочие, годные запросы, здесь нет подвоха или проблем с производительность, но есть небольшой, совсем неприметный недостаток.

Прежде чем раскрыть вкладыш, попробуйте подумать самостоятельно

"Ну и?" — скажете вы. Недостаток заключается в использовании слишком "узкого" интерфейса для передачи ключей. Однако, если мы посмотрим на методы корневого интерфейса, принимающие множество значений, то везде увидим Iterable: "Ну список, ну набор, я не вижу здесь проблемы".

А я хочу список. "Ну и что? Чем он хуже?"
Ни чем не хуже, только будьте готовы к появлению на вышестоящем уровне вашего приложения подобного кода:

public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids);
} //или public List<BankAccount> findByUserIds(Set<Long> userIds) { List<Long> ids = new ArrayList<>(userIds); return repository.findByUserIds(ids);
}

Может получиться так, что аргументом метода будет список, а репозиторный метод принимает набор (или наоборот), и перезаворачивать придётся просто для прохода компиляции. Этот код не делает ничего, кроме перезаворачивания коллекций. Разумеется это не станет проблемой на фоне накладных расходов на сам запрос, речь скорее о ненужных телодвижениях.

Поэтому хорошей практикой является использование Iterable:

@Query("select ba from BankAccount ba where ba.user.id in :ids")
List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);

Если речь идёт о методе из *RepositoryCustom, то имеет смысл использовать Collection для упрощения вычисления размера внутри реализации: З.Ы.

public interface BankAccountRepositoryCustom { boolean anyMoneyAvailable(Collection<Long> accountIds);
} public class BankAccountRepositoryImpl
}

Лишний код: неповторяющиеся ключи

В продолжение прошлого раздела хочу обратить внимание на распространённое заблуждение:

@Query("select ba from BankAccount ba where ba.user.id in :ids")
List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);

Другие проявления этого же заблуждения:

Set<Long> ids = new HashSet<>(notUniqueIds);
List<BankAccount> accounts = repository.findByUserIds(ids); List<Long> ids = ts.stream().map(T::id).distinct().collect(toList());
List<BankAccount> accounts = repository.findByUserIds(ids); Set<Long> ids = ts.stream().map(T::id).collect(toSet());
List<BankAccount> accounts = repository.findByUserIds(ids);

На первый взгляд, ничего необычного, верно?

Не торопитесь, подумайте самостоятельно 😉

HQL/JPQL запросы вида select t from t where t.field in ... в конечном итоге превратятся в запрос

select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, …)

Поэтому обеспечивать уникальность ключей не нужно. который всегда вернёт одно и тоже безотносительно наличия повторов в аргументе. Но если вы пытаетесь уменьшить количество ключей исключением повторов, то стоит скорее задуматься о причине их возникновения. Есть один особый случай — "Оракл", где попадание >1000 ключей в in приводит к ошибке. Скорее всего ошибка где-то уровнем выше.

Итого, в хорошем коде используйте Iterable:

@Query("select ba from BankAccount ba where ba.user.id in :ids")
List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);

Самопись

Внимательно посмотрите на этот код и найдите здесь три недостатка и одну возможную ошибку:

@Query("from User u where u.id in :ids")
List<User> findAll(@Param("ids") Iterable<Long> ids);

Подумайте ещё немного

  • всё уже реализовано в SimpleJpaRepository::findAllById
  • холостой запрос при передаче пустого списка (в SimpleJpaRepository::findAllById есть соответствующая проверка)
  • все запросы описанные с использованием @Query проверяются на этапе поднятия контекста, что требует времени (в отличии от SimpleJpaRepository::findAllById)
  • если используется "Оракл", при пустой коллекции ключей мы получим ошибку ORA-00936: missing expression (чего не будет при использовании SimpleJpaRepository::findAllById, см. пункт 2)

Гарри Поттер и составной ключ

Взгляните на два примера и выберите предпочтительный для вас:

Способ номер раз

@Embeddable
public class CompositeKey implements Serializable { Long key1; Long key2;
} @Entity
public class CompositeKeyEntity { @EmbeddedId CompositeKey key;
}

Способ номер два

@Embeddable
public class CompositeKey implements Serializable { Long key1; Long key2;
} @Entity @IdClass(value = CompositeKey.class)
public class CompositeKeyEntity { @Id Long key1; @Id Long key2;
}

Теперь попробуем первый способ и запустим простой тест: На первый взгляд, разницы нет.

//case for @EmbeddedId
@Test
public void findAll() { int size = entityWithCompositeKeyRepository.findAllById(compositeKeys).size(); assertEquals(size, 5);
}

В логе запросов (вы ведёте его, не так ли?) увидим вот это:

select e.key1, e.key2
from CompositeKeyEntity e
where e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ?

Теперь второй пример

//case for @Id @Id
@Test
public void _findAll() { int size = anotherEntityWithCompositeKeyRepository.findAllById(compositeKeys).size(); assertEquals(size, 5);
}

Журнал запросов выглядит иначе:

select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?
select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?
select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?
select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?
select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?

Вот и вся разница: в первом случае всегда получаем 1 запрос, во втором — n запросов.
Причина этого поведения кроется в SimpleJpaRepository::findAllById:

// ...
if (entityInfo.hasCompositeId()) { List<T> results = new ArrayList<>(); for (ID id : ids) { findById(id).ifPresent(results::add); } return results;
}
// ...

Какой из способов лучше — определять вам, исходя из того, насколько важно количество выполняемых запросов.

Лишний CrudRepository::save

Часто в коде встречается такой антипаттерн:

@Transactional
public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account);
}

Как раз этот код выглядит очень логичным: получаем сущность — обновляем — сохраняем. Читатель недоумевает: а где антипаттерн? Осмелюсь утверждать, что лишним здесь является вызов CrudRepository::save. Всё как в лучших домах Петербурга.

Во-первых: метод updateRate транзакционный, следовательно все изменения в управляемой сущности отслеживаются Хибернейтом и превращаются в запрос при выполнении Session::flush, что в данном коде происходит при завершении метода.

Как вы знаете, в основе всех репозиториев лежит SimpleJpaRepository. Во-вторых, присмотримся к методу CrudRepository::save. Вот реализация CrudRepository::save:

@Transactional
public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); }
}

Иными словами, каждое действие пользователя порождает событие, которое ставится в очередь и обрабатывается с учётом прочих событий, находящихся в той же очереди. Здесь есть тонкость, о которой не все помнят: Хибернейт работает с помощью событий. Там содержится довольно разветвлённая, но несложная логика для каждого из состояний сущности-аргумента. В данном случае обращение к EntityManager::merge порождает MergeEvent, по умолчанию обрабатываемый в методе DefaultMergeEventListener::onMerge. е. В нашем случае сущность получена из репозитория внутри транзакционного метода и пребывает в состоянии PERSISTENT (т. по сути управляемая фреймворком):

protected void entityIsPersistent(MergeEvent event, Map copyCache) { LOG.trace("Ignoring persistent instance"); Object entity = event.getEntity(); EventSource source = event.getSession(); EntityPersister persister = source.getEntityPersister(event.getEntityName(), entity); ((MergeContext)copyCache).put(entity, entity, true); this.cascadeOnMerge(source, persister, entity, copyCache); //<---- this.copyValues(persister, entity, entity, source, copyCache); //<---- event.setResult(entity);
}

Послушаем прямую речь Влада Михалче, одного из ключевых разработчиков Хибернейта: Дьявол в мелочах, а именно в методах DefaultMergeEventListener::cascadeOnMerge и DefaultMergeEventListener::copyValues.

If the entity has child associations and the merge operation is also cascaded from parent to child entities, the overhead is even greater because each child entity will propagate a MergeEvent and the cycle continues. In the copyValues method call, the hydrated state is copied again, so a new array is redundantly created, therefore wasting CPU cycles.

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

@Transactional
public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account;
}

Возможно ли это? Конечно, постоянно держать это в голове при разработке и вычитке чужого кода неудобно, поэтому нам хотелось бы внести изменения на уровне каркаса, чтобы метод JpaRepository::save утратил свои вредные свойства.

Да, возможно

//было
@Transactional
public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); }
} //стало
@Transactional
public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else if (!em.contains(entity)) { return em.merge(entity); } return entity;
}

Эти изменения действительно были внесены в декабре 2017 года:
https://jira.spring.io/browse/DATAJPA-931
https://github.com/spring-projects/spring-data-jpa/pull/237

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

@Entity
public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO;
}

Теперь положим, что к счёту привязан его владелец:

@Entity
public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user;
}

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

@Transactional
public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account);
}

Проверка em.contains(entity) вернёт истину, а значит em.merge(entity) не будет вызван. Что произойдёт теперь? е. Если ключ сущности User создаётся на основе последовательности (один из наиболее частых случаев), то он не будет создан вплоть до завершения транзакции (или ручного вызова Session::flush) т. В некоторых случаях это может сломать логику приложения, что и произошло: пользователь будет пребывать в состоянии DETACHED, а его родительская сущность (счёт) — в состоянии PERSISTENT.

02. 03. 2018 DATAJPA-931 breaks merging with RepositoryItemWriter

В этой связи была создана задача Revert optimizations made for existing entities in CrudRepository::save и внесены изменения: Revert DATAJPA-931.

«Слепой» CrudRepository::findById

Продолжаем рассматривать всё ту же модель данных:

@Entity
public class User { @Id Long id; // ...
} @Entity
public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user;
}

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

@Transactional
public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser); //<---- return accountRepository.save(account);
}

С версией 2.* указанный стрелкой антипаттерн не так бросается в глаза — чётче его видно на старых версиях:

@Transactional
public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId)); //<---- return accountRepository.save(account);
}

Если не видите недостаток \"на глаз\", то взгляните на запросы:

select u.id, u.name from user u where u.id = ?
call next value for hibernate_sequence
insert into bank_account (id, /*…*/ user_id) values (/*…*/)

Дальше получаем из базы ключ для новорожденного счёта и вставляем его в таблицу. Первым запросом мы достаём пользователя по ключу. С другой стороны, BankAccount содержит поле "пользователь" и оставить его пустым мы не можем (как порядочные люди мы выставили ограничение в схеме). И единственное, что мы берём от пользователя — это ключ, который у нас и так есть в виде аргумента метода. Опытные разработчики наверняка уже видят способ и рыбку съесть, и на лошадке покататься и пользователя получить, и запрос не делать:

@Transactional
public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId)); //<---- return accountRepository.save(account);
}

Этот код даёт всего два запроса: JpaRepository::getOne возвращает обёртку над ключом, имеющую тот же тип, что и живая "сущность".

call next value for hibernate_sequence
insert into bank_account (id, /*…*/ user_id) values (/*…*/)

Когда создаваемая сущность содержит множество полей с отношением "многие к одному" / "один к одному" этот приём поможет ускорить сохранение и снизить нагрузку на базу.

Исполнение HQL запросов

Доменная модель та же и есть такой запрос: Это отдельная и интересная тема :).

@Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id")
long countUserAccounts(@Param("id") Long id);

Рассмотрим "чистый" HQL:

select count(ba) from BankAccount ba join ba.user user where user.id = :id

При его исполнении будет создан вот такой SQL запрос:

select count(ba.id)
from bank_account ba
inner join user u on ba.user_id = u.id
where u.id = ?

Запрос может быть упрощён (и ускорен): Проблема здесь не сразу бросается в глаза даже умудрённым жизнью и хорошо понимающим SQL разработчикам: inner join по ключу пользователя исключит из выборки счета с отсутствующим user_id (а по-хорошему вставка таковых должна быть запрещена на уровне схемы), а значит присоединять таблицу user вообще не нужно.

select count(ba.id)
from bank_account ba
where ba.user_id = ?

Существует способ легко добиться этого поведения в c помощью HQL:

@Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id")
long countUserAccounts(@Param("id") Long id);

Этот метод создаёт "облегчённый" запрос.

Аннотация Query против метода

Запрос, описанный в предыдущем примере может быть легко переписан: Одна из основных фишек Spring Data — возможность создавать запрос из имени метода, что очень удобно, особенно в сочетании с умным дополнением от IntelliJ IDEA.

//было
@Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id")
long countUserAccounts(@Param("id") Long id); //стало
long countByUserAccount_Id(Long id);

Имя метода прочитал — и уже понятно, что он выбирает и как. Вроде бы и проще, и короче, и читаемее, а главное — не нужно смотреть сам запрос. Итоговый запрос метода, помеченного @Query мы уже видели. Но дьявол и здесь в мелочах. Что же будет во втором случае?

Бабах!

select count(ba.id)
from bank_account ba
left outer join // <--- !!!!!!! user u on ba.user_id = u.id
where u.id = ?

Ведь выше мы уже убедились, что скрипач join не нужен. "Какого лешего!?" — воскликнет разработчик.

Причина прозаична:

Если вы ещё не обновились до версий с исправлением, а присоединение таблицы тормозит запрос здесь и сейчас, то не отчаивайтесь: есть сразу два способа облегчить боль:

  • хороший способ заключается в добавлении optional = false (если схема позволяет):

    @Entity
    public class BankAccount {
    @Id
    Long id; @ManyToOne
    @JoinColumn(name = "user_id", optional = false)
    User user;
    }

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

    @Entity
    public class BankAccount {
    @Id
    Long id; @ManyToOne
    @JoinColumn(name = "user_id")
    User user; @Column(name = "user_id", insertable = false, updatable = false)
    Long userId;
    }

    Теперь запрос-из-метода станет приятнее:

    long countByUserId(Long id);

    даёт

    select
    count(ba.id)
    from
    bank_account ba
    where
    ba.user_id = ?

    чего мы и добивались.

Ограничение выборки

Для своих целей нам нужно ограничивать выборку (например, хотим возвращать Optional из метода *RepositoryCustom):

select ba.*
from bank_account ba
order by ba.rate
limit ?

Теперь ява:

@Override
public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; BankAccount account = em .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .getSingleResult(); return Optional.ofNullable(bankAccount);
}

Указанный код обладает одной неприятной особенностью: в том случае, если запрос вернул пустую выборку будет брошено исключение

Caused by:
javax.persistence.NoResultException: No entity found for query

В проектах, которые я видел, это решалось двумя основными способами:

  • try-catch с вариациями от тупого перехвата исключения и возвращения Optonal.empty() до более продвинутых способов, вроде передачи лямбды с запросом в утилитный метод
  • аспект, в который заворачивались репозиторные методы возвращающие Optional

И очень редко я видел правильное решение:

@Override
public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; return em.unwrap(Session.class) .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .uniqueResultOptional();
}

EntityManager — часть стандарта JPA, в то время как Session принадлежит Хибернейту и является ИМХО более продвинутым средством, о чём часто забывают.

[Иногда] вредное улучшение

Когда нужно достать одно маленькое поле из "толстой" сущности мы поступаем так:

@Query("select a.available from BankAccount a where a.id = :id")
boolean findIfAvailable(@Param("id") Long id);

Иногда это не только не улучшает производительность, но и наоборот — создаёт ненужные запросы на пустом месте. Запрос позволяет достать одно поле типа boolean без загрузки всей сущности (с добавлением в кэш-первого уровня, проверкой изменений по завершению сессии и прочими расходами). Представим код, выполняющий некоторые проверки:

@Override
@Transactional
public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new); // ... return repository.findIfAvailable(id);
}

Этот код делает по меньшей мере 2 запроса, хотя второго можно было бы избежать:

@Override
@Transactional
public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new); // ... return repository.findById(id) // возьмём из наличия .map(BankAccount::isAvailable) .orElseThrow(IllegalStateException::new);
}

к. Вывод простой: не пренебрегайте кэшем первого уровня, в рамках одной транзакции только первый JpaRepository::findById обращается к базе, т. кэш первого уровня включен всегда и привязан к сессии, которая, как правило, привязана к текущей транзакции.

Тесты, на которых можно поиграться (ссылка на репозиторий дана в начале статьи):

  • тест с "узким" интерфейсом: InterfaceNarrowingTest
  • тест для примера с составным ключом: EntityWithCompositeKeyRepositoryTest
  • тест лишнего CrudRepository::save: ModifierTest.java
  • тест "слепого" CrudRepository::findById: ChildServiceImplTest
  • тест лишнего left join: BankAccountControlRepositoryTest

Запускается он с помощью класса BenchmarkRunner. Стоимость лишнего вызова CrudRepository::save можно посчитать с помощью RedundantSaveBenchmark.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть