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

в 10:04, , рубрики: java, spring data, spring data jpa

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

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

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

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

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

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

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 {
  @Override
  public boolean anyMoneyAvailable(Collection<Long> accountIds) {
    if (ids.isEmpty()) return false;
    //...
  }
}

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

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

@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, что в данном коде происходит при завершении метода.

Во-вторых, присмотримся к методу CrudRepository::save. Как вы знаете, в основе всех репозиториев лежит SimpleJpaRepository. Вот реализация 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. Послушаем прямую речь Влада Михалче, одного из ключевых разработчиков Хибернейта:

In the copyValues method call, the hydrated state is copied again, so a new array is redundantly created, therefore wasting CPU cycles. 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.

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

@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. В некоторых случаях это может сломать логику приложения, что и произошло:

03.02.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

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

Автор: tsypanov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js