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 — что происходит на уровне самой базы.