Spring Data JPA убирает почти весь рутинный код вокруг работы с базой. Это удобно, пока запросы простые. Когда они усложняются, нужно понимать, что происходит под капотом, иначе появляются странные тормоза. Разберём с нуля.
С ленивой ссылкой заказы приезжают одним запросом, а покупателя Hibernate дозагружает при первом обращении — цикл по трём заказам даёт четыре запроса. С JOIN FETCH заказы приходят вместе с покупателями сразу, и цикл в базу больше не ходит.
Зачем нужен репозиторий
Раньше, чтобы достать данные из базы, под каждую таблицу писали один и тот же скучный код: открыть соединение, составить SQL, пройтись по результату, собрать объекты, закрыть соединение. На десять таблиц — десять почти одинаковых классов, и в каждом легко ошибиться.
Репозиторий — это объект, который отвечает за чтение и запись одной сущности (например, заказа). Идея Spring Data в том, что вам не нужно писать его реализацию: вы объявляете только интерфейс, а код за вас сгенерирует Spring.
public interface OrderRepository extends JpaRepository<Order, UUID> {
}
Здесь Order — класс-сущность (entity), который соответствует строке таблицы, а UUID — тип его первичного ключа. Уже от одной этой строки вы бесплатно получаете готовые методы: save, findById, findAll, deleteById, count и другие. Реализацию Spring создаёт сам при старте приложения.
С методом save связана главная неожиданность Spring Data JPA, и лучше узнать о ней здесь, чем в проде. Внутри транзакции изменение загруженной сущности сохраняется без всякого save. Hibernate помнит, какой сущность была при загрузке, и перед фиксацией сравнивает её с текущим состоянием; нашёл расхождение — сам отправляет UPDATE. Это называется грязной проверкой (dirty checking).
@Transactional
public void rename(UUID id, String name) {
Order order = repo.findById(id).orElseThrow();
order.setCustomerName(name); // всё, UPDATE уйдёт при коммите
} // никакого repo.save(order) не нужно
Отсюда два практических следствия. Первое: лишний save в таком методе ничего не ломает, но и ничего не делает — сущность уже под наблюдением. Второе, куда неприятнее: случайное изменение загруженной сущности тоже сохранится, даже если вы не собирались её менять (подправили поле «для расчёта», передали объект в чужой метод). Сущность, изменять которую не надо, либо не загружают целиком (берут проекцию), либо помечают транзакцию readOnly = true, и тогда Hibernate отслеживание выключает.
Сам save нужен для новых объектов и для сущностей, пришедших извне транзакции (из HTTP-запроса, из кэша). И он не равен INSERT: если у объекта уже проставлен идентификатор, Spring Data считает его существующим и вызывает merge — сначала SELECT, потом UPDATE. Для массовой вставки строк с заранее известными идентификаторами это означает лишний запрос на каждую строку; лечится реализацией Persistable с методом isNew() или вставкой через JdbcTemplate.
JpaRepository — самый «богатый» из готовых интерфейсов. Есть и более простые (CrudRepository, PagingAndSortingRepository), но на практике почти всегда берут именно JpaRepository — он включает в себя возможности остальных.
Запросы из имени метода
Готовых методов вроде findById хватает не всегда — часто нужно искать по другим полям. Раньше под это писали SQL руками. Spring Data умеет хитрее: он читает имя метода и сам составляет запрос.
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByCustomerIdAndStatus(UUID customerId, OrderStatus status);
Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(UUID customerId);
long countByCustomerId(UUID customerId);
boolean existsByOrderNumber(String orderNumber);
}
Spring разбирает имя на части: findBy (ищем), CustomerId и Status (по каким полям), And (оба условия). Параметры метода подставляются в том же порядке.
В именах понимаются ключевые слова: And, Or, Between, LessThan, GreaterThan, Like, In, IsNull, OrderBy<Поле>Asc/Desc, Top3/First3 (число пишут прямо в имени: findTop3ByStatusOrderByCreatedAtDesc), Distinct. Из них собирается довольно сложный поиск.
Никакой магии: это разбор строки. Тот же приём на чистой Java, для одного ключевого слова And:
живой пример
public class QueryMethodDemo {
static String toJpql(String method) {
String conditions = method.substring("findBy".length());
StringBuilder where = new StringBuilder();
for (String part : conditions.split("And")) {
String field = Character.toLowerCase(part.charAt(0)) + part.substring(1);
if (!where.isEmpty()) {
where.append(" AND ");
}
where.append("o.").append(field).append(" = :").append(field);
}
return "SELECT o FROM Order o WHERE " + where;
}
public static void main(String[] args) {
System.out.println(toJpql("findByStatus"));
System.out.println(toJpql("findByCustomerIdAndStatus"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
У приёма есть предел: когда имя метода разрастается до шести-семи слов, читать его уже невозможно. Тут пора переходить к запросу, написанному вручную.
Запрос вручную через @Query
Когда из имени метода запрос не выразить, его пишут явно в аннотации @Query. Внутри — не чистый SQL, а JPQL: похожий язык, но оперирует он не таблицами и колонками, а классами-сущностями и их полями.
@Query("""
SELECT o FROM Order o
WHERE o.customerId = :customerId
AND o.status IN :statuses
ORDER BY o.createdAt DESC
""")
List<Order> findRecent(UUID customerId, Collection<OrderStatus> statuses);
Здесь Order — это имя класса, а не таблицы; o.status — поле объекта. Параметры передаются по имени: :customerId берётся из аргумента customerId.
Если JPQL не хватает (например, нужны специфичные функции конкретной базы), можно написать настоящий SQL — для этого добавляют nativeQuery = true:
@Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '24 hours'",
nativeQuery = true)
List<Order> findRecentNative();
Плата за это — запрос привязывается к конкретной базе и не проверяется при компиляции: опечатку в имени колонки вы увидите только во время выполнения. Две дополнительные оговорки про nativeQuery: имена колонок в результате должны совпадать с тем, что ждёт маппинг сущности (created_at, а не createdAt), иначе Hibernate не соберёт объект; а если такому методу передать Pageable, придётся самому написать запрос подсчёта — countQuery в той же аннотации, потому что вывести его из произвольного SQL Spring Data не может.
Изменение и удаление запросом
@Query умеет не только читать. Массовое обновление или удаление одним запросом пишут так:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :before")
@Transactional
int archiveOlderThan(Instant before, OrderStatus status);
Без @Modifying Spring Data попытается выполнить это как чтение и упадёт. Метод возвращает число затронутых строк и требует транзакции.
Главное здесь — то, чего в запросе не видно. Такой UPDATE идёт мимо кэша первого уровня: Hibernate его не разбирает и не знает, какие из загруженных в текущей сессии сущностей он изменил. Поэтому объект, прочитанный до массового обновления, останется со старым статусом, а если его после этого тронуть, грязная проверка запишет старое значение поверх нового. Флаг flushAutomatically сбрасывает в базу накопленные изменения до запроса, clearAutomatically очищает сессию после него — вместе они убирают рассогласование, ценой того, что все ранее загруженные сущности становятся «отсоединёнными» и их придётся перечитать.
Проекции — когда не нужны все поля
Обычный метод репозитория возвращает целую сущность — со всеми её полями. Но часто для списка на экране нужны два-три поля, а тащить из базы всё остальное — лишняя работа и память.
Проекция — это способ вернуть только нужный набор полей. Самый простой вариант — объявить интерфейс с нужными геттерами:
public interface OrderSummary {
UUID getId();
String getOrderNumber();
BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<OrderSummary> findByCustomerId(UUID customerId);
}
Spring увидит, что метод возвращает OrderSummary, и составит SQL только с тремя колонками вместо всей строки.
Второй вариант — вернуть свой класс (например, record), собрав его прямо в запросе. Так можно взять и поле связанной сущности:
public record OrderCard(UUID id, String customerName, BigDecimal totalAmount) {}
@Query("""
SELECT new com.example.OrderCard(o.id, c.name, o.totalAmount)
FROM Order o JOIN o.customer c
WHERE c.id = :customerId
""")
List<OrderCard> findCards(UUID customerId);
Из базы в обоих случаях приезжает только нужное, а выбор между ними такой: интерфейс-проекция короче, но берёт поля только самой сущности; конструктор-проекция умеет взять поле связанной сущности, но это уже ваш запрос, и @EntityGraph к нему не приложить.
Постраничный вывод: Page и Slice
Список заказов может содержать миллионы строк — целиком его не отдашь. Данные режут на страницы. Чтобы получить одну страницу, методу передают объект Pageable:
Page<Order> page = repo.findByStatus(OrderStatus.PENDING,
PageRequest.of(0, 20, Sort.by("createdAt").descending()));
page.getContent(); // 20 заказов на этой странице
page.getTotalElements(); // сколько всего заказов
page.getTotalPages(); // сколько всего страниц
PageRequest.of(0, 20, ...) означает: страница номер 0 (первая), по 20 элементов, отсортированных по дате убыванию.
Важная деталь: Page выполняет два запроса — один достаёт сами 20 строк, второй считает общее количество. На большой таблице второй запрос (подсчёт всех строк) может быть дорогим.
Один и тот же вызов страницы: у Page в базу уходит второй запрос, который пересчитывает всю выборку, у Slice его нет.
Если общее число знать не обязательно — например, для бесконечной прокрутки, где важно лишь «есть ли ещё», — берут Slice. Он не считает всё и делает один запрос:
Slice<Order> slice = repo.findSliceByStatus(OrderStatus.PENDING, PageRequest.of(0, 20));
slice.hasNext(); // true, если впереди есть ещё страница
Имя метода здесь другое не случайно: в Java нельзя объявить два метода с одинаковым именем и одинаковыми параметрами, но разным типом возврата. Нужны в одном репозитории и Page, и Slice — разведите имена, между find и By можно вставить любое слово.
Правило простое: нужны номера страниц и общее количество — Page; нужна только подгрузка «ещё» — Slice.
У обоих общая слабость, которая проявляется не сразу: постраничный вывод через OFFSET. Запрос двадцатой страницы это LIMIT 20 OFFSET 380, и база честно читает 400 строк, чтобы выбросить первые 380. На странице номер пять тысяч она прочитает сто тысяч строк ради двадцати, и время растёт линейно с номером страницы. Второй изъян логический: если между запросами страниц кто-то вставил запись, границы страниц поедут и одна и та же строка покажется дважды.
Оба лечатся пагинацией по ключу (keyset, её же называют курсорной): вместо номера страницы клиенту отдают значение последнего элемента, и следующий запрос просит «то, что после него».
@Query("""
SELECT o FROM Order o
WHERE o.createdAt < :after
ORDER BY o.createdAt DESC
""")
List<Order> nextPage(Instant after, Pageable limit); // PageRequest.of(0, 20)
База с индексом по created_at встанет сразу на нужное место и прочитает ровно двадцать строк, независимо от того, десятая это страница или десятитысячная. Плата — нельзя прыгнуть на страницу по номеру и нельзя показать «всего 4312 записей»; для бесконечной прокрутки и выгрузок это не нужно, а для таблицы с номерами страниц берут Page и следят, чтобы номера не уходили далеко.
Проблема N+1
Страница со списком из ста заказов открывается восемь секунд, а в логе SQL сто один запрос: один за списком и по одному за покупателем каждого заказа. Это N+1, самая частая и самая коварная проблема при работе с JPA, и растёт она из умолчаний загрузки связей.
У сущностей бывают связи: у заказа есть покупатель, у заказа есть строки. Здесь спрятана ловушка, на которой обжигаются почти все. Ссылка на один объект — @ManyToOne и @OneToOne, например покупатель у заказа — по умолчанию грузится сразу (eager): вытащили заказ, а Hibernate заодно сходил в базу за покупателем, даже если он не нужен. А коллекции — @OneToMany, @ManyToMany, те же строки заказа — наоборот, грузятся лениво (lazy): пока вы их не трогаете, в базу за ними не ходят, а при первом обращении Hibernate тихо делает отдельный запрос.
Поэтому на ссылках fetch = FetchType.LAZY проставляют руками почти всегда, а нужные данные забирают явно — через JOIN FETCH или @EntityGraph.
Из этого правила есть известное исключение, на котором спотыкаются: @OneToOne с необязательной стороны ленивым не становится, сколько ни пиши LAZY. Причина в том, что поле либо содержит прокси, либо null, и чтобы решить, какое из двух, Hibernate обязан сходить в базу — а значит, ленивость бессмысленна. Работает LAZY только на владеющей стороне (там, где лежит внешний ключ) и при optional = false, когда известно, что связанная запись есть всегда. Практический выход: не делать @OneToOne там, где хватает @ManyToOne, либо грузить такую связь явным запросом.
Звучит разумно, но смотрите, что выходит в цикле. Ниже база подменена счётчиком: findAll это один запрос за списком, loadCustomer отдельный запрос за покупателем, а третье поле Order изображает покупателя, который при JOIN FETCH приехал вместе с заказом. Первый прогон тянет покупателя лениво, второй берёт его сразу.
живой пример
import java.util.List;
public class NPlusOneDemo {
record Order(long id, long customerId, String customer) {}
static int queries;
static List<Order> findAll(boolean joinFetch) {
queries++;
return List.of(new Order(1, 7, joinFetch ? "Иванов" : null),
new Order(2, 8, joinFetch ? "Петров" : null),
new Order(3, 9, joinFetch ? "Сидоров" : null));
}
static String loadCustomer(long customerId) {
queries++;
return "покупатель " + customerId;
}
public static void main(String[] args) {
queries = 0;
for (Order order : findAll(false)) {
loadCustomer(order.customerId());
}
System.out.println("ленивая ссылка: запросов " + queries);
queries = 0;
for (Order order : findAll(true)) {
order.customer();
}
System.out.println("JOIN FETCH: запросов " + queries);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда и название: один запрос на список плюс N на связи. На тестовых данных быстро, под нагрузкой база захлёбывается. Лечится двумя приёмами.
JOIN FETCH — подтянуть сразу
Просим Hibernate в одном запросе сразу достать и заказы, и покупателей:
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findWithCustomer(OrderStatus status);
Теперь это один SQL вместо ста одного.
Глубже: @EntityGraph — то же самое, но декларативнорасширенное
Если запрос писать не хочется, можно просто перечислить, что подгрузить вместе:
@EntityGraph(attributePaths = {"customer", "lines"})
List<Order> findByStatus(OrderStatus status);
Результат тот же: связанные данные приезжают одним запросом.
Но подтягивать ссылку и подтягивать коллекцию — разные истории, и вторая с сюрпризами. Покупатель у заказа один, поэтому JOIN FETCH o.customer просто добавляет колонок. А строк у заказа много, и JOIN FETCH o.lines заставит базу вернуть сам заказ столько раз, сколько у него строк — поэтому в таких запросах пишут SELECT DISTINCT или объявляют связь как Set. Две коллекции-List сразу Hibernate не возьмёт вовсе: упадёт с MultipleBagFetchException. А если к запросу с коллекцией добавить Pageable, Hibernate вытащит всё и порежет на страницы уже в памяти — в логе появится предупреждение HHH000104, и его нельзя игнорировать.
Глубже: @BatchSize — третье лекарстворасширенное
У первых двух приёмов общий недостаток: они требуют менять запрос, а запросов может быть много. Третий способ не трогает их вовсе: Hibernate можно попросить дозагружать ленивые связи пачками.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 50)
private Customer customer;
}
Теперь при первом обращении к покупателю первого заказа Hibernate увидит, что рядом лежат ещё 99 заказов с непрогруженными покупателями, и загрузит их одним запросом WHERE id IN (...) по пятьдесят штук. Сто один запрос превращается в три. То же самое включается сразу для всего приложения:
spring.jpa.properties.hibernate.default_batch_fetch_size=50
Это самая дешёвая страховка от N+1: одна строка в настройках, и все случаи, которые вы не заметили и не переписали на JOIN FETCH, дешевеют на порядок. Она не отменяет первые два приёма там, где вы точно знаете, что связь нужна, но убирает катастрофические случаи.
Глубже: конкурентная запись: @Versionрасширенное
Два менеджера открыли карточку заказа, оба поменяли адрес доставки, оба нажали «Сохранить». Побеждает тот, кто сохранил вторым, и его правка молча затирает первую — никто ничего не заметит. Это называют потерянным обновлением, и в веб-приложении оно случается постоянно, потому что между чтением и записью проходят минуты и транзакции там разные.
Лечится это оптимистической блокировкой: в сущность добавляют поле версии, и Hibernate сам следит за ним.
@Entity
public class Order {
@Id private UUID id;
@Version private long version; // Hibernate ведёт его сам
private String address;
}
Теперь каждый UPDATE выглядит как UPDATE orders SET address = ?, version = 6 WHERE id = ? AND version = 5. Если кто-то успел записать раньше, версия в базе уже 6, условие не совпадёт, обновлено будет ноль строк, и Hibernate бросит OptimisticLockException (в Spring это ObjectOptimisticLockingFailureException). Дальше решает приложение: показать пользователю «данные изменились, перечитайте» или повторить операцию на свежих данных.
Слово «оптимистическая» здесь означает «конфликтов мало, проверим по факту»: никаких блокировок в базе не ставится, и параллельная работа не замедляется. Противоположный подход, пессимистическая блокировка, запрашивается явно через @Lock(LockModeType.PESSIMISTIC_WRITE) на методе репозитория и превращается в SELECT ... FOR UPDATE: строка блокируется на время транзакции, и соседи ждут. Её берут там, где конфликты частые и повтор дорог (списание остатка со склада), а @Version — почти везде остальное.
Глубже: Open Session In Viewрасширенное
Ещё одна ловушка, связанная с ленивой загрузкой. По умолчанию Spring Boot держит открытой сессию Hibernate на весь HTTP-запрос — эта настройка называется Open Session In View (spring.jpa.open-in-view=true). На практике вместе с сессией легко застревает и соединение с базой. После коммита сервисной транзакции Hibernate соединение отпускает, но первое же обращение к ленивому полю в контроллере или при сборке JSON берёт его снова — и держит уже до конца запроса: транзакции, на завершении которой соединение положено вернуть, там больше нет.
Один HTTP-запрос при двух значениях флага: видно, в какой момент закрывается сессия и почему при выключенном OSIV ленивое поле падает.
Удобство в том, что ленивые поля можно трогать где угодно: в контроллере, в шаблоне страницы. Проблема — ровно в этом же. Запросы к базе начинают незаметно вылетать уже во время формирования ответа, далеко от того места, где данные действительно нужны. Та самая N+1-проблема расползается по всему приложению, и её трудно заметить, потому что «всё же работает».
На новом проекте настройку выключают сразу:
spring.jpa.open-in-view=false
После этого попытка обратиться к ленивому полю вне транзакции сразу даст ошибку LazyInitializationException. Это не баг, а полезный сигнал: он заставляет заранее достать всё нужное (через JOIN FETCH, @EntityGraph или проекцию) там, где идёт работа с базой, а наружу отдавать уже готовый результат.
На живом проекте флаг так просто не переключают: весь код, который трогал ленивые поля в контроллерах и шаблонах, начнёт падать той же LazyInitializationException. Такие места сначала находят по журналу SQL с ещё включённым open-in-view, переписывают на выборки с JOIN FETCH и проекции, и только потом выключают.
Коротко
- Репозиторий объявляется интерфейсом; реализацию пишет Spring. Базовый выбор —
JpaRepository. - Из имени метода (
findByCustomerIdAndStatus) Spring сам составляет запрос; когда имя слишком длинное — переходят на@Query: там пишут JPQL (по классам и полям) или настоящий SQL сnativeQuery = true. - Проекции возвращают только нужные поля — через интерфейс или через свой
recordв запросе. Pageделает два запроса (данные + подсчёт всего),Slice— один (только «есть ли ещё»).- N+1 — обращение к ленивым связям в цикле плодит по запросу на элемент; лечится
JOIN FETCHили@EntityGraph. - Open Session In View часто выключают (
open-in-view=false): это вскрывает скрытые запросы и заставляет грузить данные осознанно. - Внутри транзакции изменение загруженной сущности сохраняется без
save(грязная проверка), аsaveс заданным идентификатором делаетmergeс лишнимSELECT;readOnly = trueотслеживание выключает. @Modifying-запрос идёт мимо кэша первого уровня: нужныflushAutomaticallyиclearAutomatically;nativeQueryтребует имён колонок как в базе и своегоcountQueryдля пагинации.OFFSETна глубоких страницах читает всё до них: для прокрутки берут пагинацию по ключу.@OneToOneна необязательной стороне ленивым не бывает.- Третье лекарство от N+1 без правки запросов —
@BatchSizeилиhibernate.default_batch_fetch_size; потерянное обновление между транзакциями закрывает@Version.
Что пощупать
Репозиторий, у которого запрос выводится из имени метода, и репозиторий с явным @Query под блокировку лежат рядом в одном интерфейсе стартового сервиса каталога практикума remodov/marketplace-system. Тесты идут на H2, Docker не нужен.
Код: product.
Сделаем сами
Ветка step-02-read-endpoint — метода в репозитории нет, тест красный, условие по ссылке в TASK.md.
Что почитать дальше
@Transactionalглубоко — как транзакция управляет соединением с базой.- Spring Testing —
@DataJpaTestи тестирование репозиториев. - Проблема N+1 в Hibernate — та же беда со стороны Hibernate: как её увидеть в логах и чем ещё лечить.
- ACID и уровни изоляции в PostgreSQL — что происходит на уровне самой базы.