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

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

Зачем нужен репозиторий

Раньше, чтобы достать данные из базы, под каждую таблицу писали один и тот же скучный код: открыть соединение, составить SQL, пройтись по результату, собрать объекты, закрыть соединение. На десять таблиц — десять почти одинаковых классов, и в каждом легко ошибиться.

Репозиторий — это объект, который отвечает за чтение и запись одной сущности (например, заказа). Идея Spring Data в том, что вам не нужно писать его реализацию: вы объявляете только интерфейс, а код за вас сгенерирует Spring.

public interface OrderRepository extends JpaRepository<Order, UUID> {
}

Здесь Order — класс-сущность (entity), который соответствует строке таблицы, а UUID — тип его первичного ключа. Уже от одной этой строки вы бесплатно получаете готовые методы: save, findById, findAll, deleteById, count и другие. Реализацию Spring создаёт сам при старте приложения.

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, Top<N> (первые N), Distinct. Из них собирается довольно сложный поиск.

У приёма есть предел: когда имя метода разрастается до шести-семи слов, читать его уже невозможно. Тут пора переходить к запросу, написанному вручную.

Запрос вручную через @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();

Плата за это — запрос привязывается к конкретной базе и не проверяется при компиляции: опечатку в имени колонки вы увидите только во время выполнения.

Проекции — когда не нужны все поля

Обычный метод репозитория возвращает целую сущность — со всеми её полями. Но часто для списка на экране нужны два-три поля, а тащить из базы всё остальное — лишняя работа и память.

Проекция — это способ вернуть только нужный набор полей. Самый простой вариант — объявить интерфейс с нужными геттерами:

public interface OrderSummary {
    UUID getId();
    String getCustomerName();
    BigDecimal getTotalAmount();
}

public interface OrderRepository extends JpaRepository<Order, UUID> {
    List<OrderSummary> findByCustomerId(UUID customerId);
}

Spring увидит, что метод возвращает OrderSummary, и составит SQL только с тремя колонками вместо всей строки.

Второй вариант — вернуть свой класс (например, record), собрав его прямо в запросе:

public record OrderSummary(UUID id, String customerName, BigDecimal totalAmount) {}

@Query("""
    SELECT new com.example.OrderSummary(o.id, c.name, o.totalAmount)
    FROM Order o JOIN o.customer c
    WHERE c.id = :customerId
""")
List<OrderSummary> findSummaries(UUID customerId);

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

Постраничный вывод: 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 строк, второй считает общее количество. На большой таблице второй запрос (подсчёт всех строк) может быть дорогим.

Если общее число знать не обязательно — например, для бесконечной прокрутки, где важно лишь «есть ли ещё», — берут Slice. Он не считает всё и делает один запрос:

Slice<Order> slice = repo.findByStatus(OrderStatus.PENDING, PageRequest.of(0, 20));
slice.hasNext();  // true, если впереди есть ещё страница

Правило простое: нужны номера страниц и общее количество — Page; нужна только подгрузка «ещё» — Slice.

Проблема N+1

Это самая частая и самая коварная проблема при работе с JPA. Сначала про устройство.

У сущностей бывают связи: у заказа есть покупатель, у заказа есть строки. По умолчанию связанные данные грузятся лениво (lazy): пока вы их не трогаете, в базу за ними не ходят. Как только обращаетесь к полю — Hibernate тихо делает отдельный запрос.

Звучит разумно, но смотрите, что выходит в цикле:

List<Order> orders = repo.findAll();                      // 1 запрос: достали заказы
for (Order order : orders) {
    System.out.println(order.getCustomer().getName());    // +1 запрос на КАЖДЫЙ заказ
}

Сто заказов — это один запрос на список плюс сто запросов на покупателей. Отсюда название: N+1. На странице всё работает, на тестовых данных быстро, а в реальной нагрузке база захлёбывается. Лечится двумя приёмами.

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);

Результат тот же — связанные данные приезжают одним запросом, цикл больше не плодит обращения к базе.

Open Session In View

Ещё одна ловушка, связанная с ленивой загрузкой. По умолчанию Spring Boot держит соединение с базой открытым на весь HTTP-запрос — эта настройка называется Open Session In View (spring.jpa.open-in-view=true).

Удобство в том, что ленивые поля можно трогать где угодно: в контроллере, в шаблоне страницы. Проблема — ровно в этом же. Запросы к базе начинают незаметно вылетать уже во время формирования ответа, далеко от того места, где данные действительно нужны. Та самая N+1-проблема расползается по всему приложению, и её трудно заметить, потому что «всё же работает».

Распространённая рекомендация — выключить эту настройку:

spring.jpa.open-in-view=false

После этого попытка обратиться к ленивому полю вне транзакции сразу даст ошибку LazyInitializationException. Это не баг, а полезный сигнал: он заставляет заранее достать всё нужное (через JOIN FETCH, @EntityGraph или проекцию) там, где идёт работа с базой, а наружу отдавать уже готовый результат.

Коротко

  • Репозиторий объявляется интерфейсом; реализацию пишет Spring. Базовый выбор — JpaRepository.
  • Из имени метода (findByCustomerIdAndStatus) Spring сам составляет запрос; когда имя слишком длинное — переходят на @Query.
  • В @Query пишут JPQL (по классам и полям) или настоящий SQL с nativeQuery = true.
  • Проекции возвращают только нужные поля — через интерфейс или через свой record в запросе.
  • Page делает два запроса (данные + подсчёт всего), Slice — один (только «есть ли ещё»).
  • N+1 — обращение к ленивым связям в цикле плодит по запросу на элемент; лечится JOIN FETCH или @EntityGraph.
  • Open Session In View часто выключают (open-in-view=false): это вскрывает скрытые запросы и заставляет грузить данные осознанно.

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

  • @Transactional глубоко — как транзакция управляет соединением с базой.
  • Spring Testing — @DataJpaTest и тестирование репозиториев.
  • ACID и уровни изоляции в PostgreSQL — что происходит на уровне самой базы.