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

Владимир Маяковский Эта статья о Spring Data JPA, а именно в подводных граблях, встретившихся на моём пути, ну и конечно же немного о производительности. Примеры, описанные в статье можно запустить в проверочном окружении, доступном по ссылке. В версиях Spring Data JPA 2.* значительным изменениям подвергся основной интерфейс для работы с репозиториями, а именно CrudRepository, от которого наследуется JpaRepository. В версиях 1.* основные методы выглядели так: В новых версиях: Итак, приступим. Один из наиболее распространённых запросов — это запрос вида "выбери все записи, у которых ключ попадает в переданное множество". Уверен, почти все из вас писали или видели что-то вроде Это рабочие, годные запросы, здесь нет подвоха или проблем с производительность, но есть небольшой, совсем неприметный недостаток. Недостаток заключается в использовании слишком "узкого" интерфейса для передачи ключей. "Ну и?" — скажете вы. "Ну список, ну набор, я не вижу здесь проблемы". Однако, если мы посмотрим на методы корневого интерфейса, принимающие множество значений, то везде увидим Iterable: "Ну и что? А я хочу список. Чем он хуже?"
Ни чем не хуже, только будьте готовы к появлению на вышестоящем уровне вашего приложения подобного кода: Этот код не делает ничего, кроме перезаворачивания коллекций. Может получиться так, что аргументом метода будет список, а репозиторный метод принимает набор (или наоборот), и перезаворачивать придётся просто для прохода компиляции. Разумеется это не станет проблемой на фоне накладных расходов на сам запрос, речь скорее о ненужных телодвижениях. Поэтому хорошей практикой является использование Iterable: З.Ы. Если речь идёт о методе из *RepositoryCustom, то имеет смысл использовать Collection для упрощения вычисления размера внутри реализации: В продолжение прошлого раздела хочу обратить внимание на распространённое заблуждение: Другие проявления этого же заблуждения: На первый взгляд, ничего необычного, верно? HQL/JPQL запросы вида select t from t where t.field in ... в конечном итоге превратятся в запрос который всегда вернёт одно и тоже безотносительно наличия повторов в аргументе. Поэтому обеспечивать уникальность ключей не нужно. Есть один особый случай — "Оракл", где попадание >1000 ключей в in приводит к ошибке. Но если вы пытаетесь уменьшить количество ключей исключением повторов, то стоит скорее задуматься о причине их возникновения. Скорее всего ошибка где-то уровнем выше. Итого, в хорошем коде используйте Iterable: Внимательно посмотрите на этот код и найдите здесь три недостатка и одну возможную ошибку: Взгляните на два примера и выберите предпочтительный для вас: Способ номер раз Способ номер два На первый взгляд, разницы нет. Теперь попробуем первый способ и запустим простой тест: В логе запросов (вы ведёте его, не так ли?) увидим вот это: Теперь второй пример Журнал запросов выглядит иначе: Вот и вся разница: в первом случае всегда получаем 1 запрос, во втором — n запросов.
Причина этого поведения кроется в SimpleJpaRepository::findAllById: Какой из способов лучше — определять вам, исходя из того, насколько важно количество выполняемых запросов. Часто в коде встречается такой антипаттерн: Читатель недоумевает: а где антипаттерн? Как раз этот код выглядит очень логичным: получаем сущность — обновляем — сохраняем. Всё как в лучших домах Петербурга. Осмелюсь утверждать, что лишним здесь является вызов CrudRepository::save. Во-первых: метод updateRate транзакционный, следовательно все изменения в управляемой сущности отслеживаются Хибернейтом и превращаются в запрос при выполнении Session::flush, что в данном коде происходит при завершении метода. Во-вторых, присмотримся к методу CrudRepository::save. Как вы знаете, в основе всех репозиториев лежит SimpleJpaRepository. Вот реализация CrudRepository::save: Здесь есть тонкость, о которой не все помнят: Хибернейт работает с помощью событий. Иными словами, каждое действие пользователя порождает событие, которое ставится в очередь и обрабатывается с учётом прочих событий, находящихся в той же очереди. В данном случае обращение к EntityManager::merge порождает MergeEvent, по умолчанию обрабатываемый в методе DefaultMergeEventListener::onMerge. Там содержится довольно разветвлённая, но несложная логика для каждого из состояний сущности-аргумента. В нашем случае сущность получена из репозитория внутри транзакционного метода и пребывает в состоянии PERSISTENT (т. е. по сути управляемая фреймворком): Дьявол в мелочах, а именно в методах DefaultMergeEventListener::cascadeOnMerge и DefaultMergeEventListener::copyValues. Послушаем прямую речь Влада Михалче, одного из ключевых разработчиков Хибернейта: Иными словами делается работа, которую можно не делать. В итоге наш код можно упростить, одновременно улучшив его производительность: Конечно, постоянно держать это в голове при разработке и вычитке чужого кода неудобно, поэтому нам хотелось бы внести изменения на уровне каркаса, чтобы метод JpaRepository::save утратил свои вредные свойства. Возможно ли это? Эти изменения действительно были внесены в декабре 2017 года:
https://jira.spring.io/browse/DATAJPA-931
https://github.com/spring-projects/spring-data-jpa/pull/237 Однако, искушенный читатель наверняка уже почуял неладное. Действительно, указанное изменение ничего не сломает, но только в простом случае, когда отсутствуют дочерние сущности: Теперь положим, что к счёту привязан его владелец: Существует метод, позволяющий открепить пользователя от счёта и передать последний новому пользователю: Что произойдёт теперь? Проверка 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. Продолжаем рассматривать всё ту же модель данных: В приложении есть метод, создающий новый счёт для указанного пользователя: С версией 2.* указанный стрелкой антипаттерн не так бросается в глаза — чётче его видно на старых версиях: Первым запросом мы достаём пользователя по ключу. Дальше получаем из базы ключ для новорожденного счёта и вставляем его в таблицу. И единственное, что мы берём от пользователя — это ключ, который у нас и так есть в виде аргумента метода. С другой стороны, BankAccount содержит поле "пользователь" и оставить его пустым мы не можем (как порядочные люди мы выставили ограничение в схеме). Опытные разработчики наверняка уже видят способ и рыбку съесть, и на лошадке покататься и пользователя получить, и запрос не делать: JpaRepository::getOne возвращает обёртку над ключом, имеющую тот же тип, что и живая "сущность". Этот код даёт всего два запроса: Когда создаваемая сущность содержит множество полей с отношением "многие к одному" / "один к одному" этот приём поможет ускорить сохранение и снизить нагрузку на базу. Это отдельная и интересная тема :). Доменная модель та же и есть такой запрос: Рассмотрим "чистый" HQL: При его исполнении будет создан вот такой SQL запрос: Проблема здесь не сразу бросается в глаза даже умудрённым жизнью и хорошо понимающим SQL разработчикам: inner join по ключу пользователя исключит из выборки счета с отсутствующим user_id (а по-хорошему вставка таковых должна быть запрещена на уровне схемы), а значит присоединять таблицу user вообще не нужно. Запрос может быть упрощён (и ускорен): Существует способ легко добиться этого поведения в c помощью HQL: Этот метод создаёт "облегчённый" запрос. Одна из основных фишек Spring Data — возможность создавать запрос из имени метода, что очень удобно, особенно в сочетании с умным дополнением от IntelliJ IDEA. Запрос, описанный в предыдущем примере может быть легко переписан: Вроде бы и проще, и короче, и читаемее, а главное — не нужно смотреть сам запрос. Имя метода прочитал — и уже понятно, что он выбирает и как. Но дьявол и здесь в мелочах. Итоговый запрос метода, помеченного @Query мы уже видели. Что же будет во втором случае? "Какого лешего!?" — воскликнет разработчик. Ведь выше мы уже убедились, что скрипач join не нужен. Причина прозаична: Если вы ещё не обновились до версий с исправлением, а присоединение таблицы тормозит запрос здесь и сейчас, то не отчаивайтесь: есть сразу два способа облегчить боль: хороший способ заключается в добавлении optional = false (если схема позволяет): костыльный способ заключается в добавлении колонки, имеющий тот же тип, что и ключ сущности User, и использовании его в запросах вместо поля user: Теперь запрос-из-метода станет приятнее: даёт чего мы и добивались. Для своих целей нам нужно ограничивать выборку (например, хотим возвращать Optional из метода *RepositoryCustom): Теперь ява: Указанный код обладает одной неприятной особенностью: в том случае, если запрос вернул пустую выборку будет брошено исключение В проектах, которые я видел, это решалось двумя основными способами: И очень редко я видел правильное решение: EntityManager — часть стандарта JPA, в то время как Session принадлежит Хибернейту и является ИМХО более продвинутым средством, о чём часто забывают. Когда нужно достать одно маленькое поле из "толстой" сущности мы поступаем так: Запрос позволяет достать одно поле типа boolean без загрузки всей сущности (с добавлением в кэш-первого уровня, проверкой изменений по завершению сессии и прочими расходами). Иногда это не только не улучшает производительность, но и наоборот — создаёт ненужные запросы на пустом месте. Представим код, выполняющий некоторые проверки: Этот код делает по меньшей мере 2 запроса, хотя второго можно было бы избежать: Вывод простой: не пренебрегайте кэшем первого уровня, в рамках одной транзакции только первый JpaRepository::findById обращается к базе, т. к. кэш первого уровня включен всегда и привязан к сессии, которая, как правило, привязана к текущей транзакции. Тесты, на которых можно поиграться (ссылка на репозиторий дана в начале статьи): Стоимость лишнего вызова CrudRepository::save можно посчитать с помощью RedundantSaveBenchmark. Запускается он с помощью класса BenchmarkRunner. Разработчик