← назад к разделу

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

приложение база цикл: order.getCustomer() ленивая ссылкаSELECT … FROM orders 3 заказа, покупатели не тронуты SELECT … FROM customer WHERE id = 7 … WHERE id = 8 … WHERE id = 9 4 запроса на 3 заказа — это N+1 JOIN FETCHSELECT … FROM orders JOIN FETCH o.customer 3 заказа вместе с покупателями покупатель уже в памятицикл в базу не ходит 1 запрос на 3 заказа

С ленивой ссылкой заказы приезжают одним запросом, а покупателя 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 SELECT 20 строк COUNT(*) всей выборки Slice SELECT 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 берёт его снова — и держит уже до конца запроса: транзакции, на завершении которой соединение положено вернуть, там больше нет.

open-in-view=true транзакция в сервисе сессия ещё жива ленивое поле грузится open-in-view=false транзакция в сервисе сессия закрыта ленивое поле падает

Один 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.

Что почитать дальше