Связи между сущностями и пагинация

Сущности связаны: у пользователя — заказы, у заказа — товары. JPA выражает это аннотациями связей. А большие списки отдают порциями через пагинацию.
Суть: @ManyToOne и @OneToMany описывают связи между таблицами. Ленивая загрузка экономит запросы, но порождает проблему N+1. Pageable/Page отдают данные страницами.

Реальные данные связаны. Один пользователь делает много заказов — это связь «один ко многим». Каждый заказ принадлежит одному пользователю — «многие к одному». JPA умеет отображать такие связи на внешние ключи в базе.

Связи

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)   // много заказов -> один пользователь
    @JoinColumn(name = "user_id")
    private User user;
}

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "user")        // один пользователь -> много заказов
    private List<Order> orders = new ArrayList<>();
}

@ManyToOne — владелец связи, он держит внешний ключ (user_id). @OneToMany с mappedBy — обратная сторона, она лишь зеркалит связь.

Ленивая загрузка и проблема N+1

FetchType.LAZY означает: связанные данные подгружаются только при обращении, не сразу. Это экономит запросы, но таит ловушку — проблему N+1: если в цикле по 100 заказам обратиться к order.getUser(), Hibernate выполнит 1 запрос на заказы плюс 100 запросов на пользователей. Решение — JOIN FETCH в JPQL или @EntityGraph.

  Проблема N+1 (плохо):
   SELECT * FROM orders            -> 1 запрос (100 заказов)
   для каждого заказа:
   SELECT * FROM users WHERE id=?  -> +100 запросов
   ИТОГО: 101 запрос

  Решение JOIN FETCH (хорошо):
   SELECT o, u FROM Order o JOIN FETCH o.user
   ИТОГО: 1 запрос

Пагинация

Отдавать 100 000 записей одним ответом нельзя — это убьёт и сервер, и клиента. Spring Data принимает Pageable и возвращает Page с данными и метаинформацией:

Page<User> findByAgeGreaterThan(int age, Pageable pageable);

// вызов: страница 0, по 20 записей, сортировка по имени
Pageable p = PageRequest.of(0, 20, Sort.by("name"));
Page<User> page = repo.findByAgeGreaterThan(18, p);
page.getTotalElements(); // всего записей
page.getContent();       // 20 записей этой страницы

Смоделируем пагинацию над коллекцией:

# Пагинация: отдаём данные страницами, как Page в Spring Data
all_users = [{"id": i, "name": "User" + str(i)} for i in range(1, 58)]

def page(data, page_number, size):
    start = page_number * size
    end = start + size
    content = data[start:end]
    total = len(data)
    total_pages = (total + size - 1) // size
    return {
        "content": content,
        "page": page_number,
        "size": size,
        "totalElements": total,
        "totalPages": total_pages,
        "last": page_number >= total_pages - 1,
    }

p = page(all_users, page_number=0, size=20)
print("Записей на странице:", len(p["content"]))
print("Всего:", p["totalElements"], "страниц:", p["totalPages"])
print("Последняя страница?", page(all_users, 2, 20)["last"])

Нажмите «Попробуй сам ▶»: данные нарезаются на страницы с метаданными — ровно то, что отдаёт Page.

Частые ошибки

  • EAGER по умолчанию для коллекций. Жадная загрузка тянет лишнее; для @OneToMany предпочитайте LAZY.
  • Проблема N+1. Обращение к связи в цикле плодит запросы — используйте JOIN FETCH или @EntityGraph.
  • Отдавать всё без пагинации. findAll() на большой таблице может уронить приложение.

Best practices

  • Связи делайте LAZY, подгружайте нужное явно через JOIN FETCH.
  • Списки всегда отдавайте через Pageable/Page.
  • Следите за числом SQL-запросов (включите логирование SQL) — ловите N+1 рано.

Итог: связи (@ManyToOne/@OneToMany) отображают внешние ключи, LAZY экономит запросы ценой риска N+1, а пагинация (Pageable/Page) делает выдачу больших наборов безопасной.

Закрепим главное

Связи между сущностями отображают внешние ключи базы на ссылки между объектами, и важно держать в голове, какая сторона владеет связью. Владелец — это сторона с @ManyToOne и колонкой внешнего ключа; обратная сторона с @OneToMany(mappedBy=...) лишь зеркалит связь и не создаёт отдельной колонки. Путаница здесь приводит к лишним таблицам и неожиданным запросам.

Две практические истины про производительность стоит усвоить сразу. Первая — проблема N+1: ленивая загрузка экономит запросы по умолчанию, но обращение к связи в цикле незаметно превращает один запрос в сотню. Лечится это явным JOIN FETCH или @EntityGraph, а ловится включённым логированием SQL. Вторая — пагинация: на больших таблицах findAll() способен уронить и сервер, и клиента, поэтому списки всегда отдавайте порциями через Pageable и Page. Эти две привычки отличают код, который держит нагрузку, от кода, который падает на первой тысяче записей.

Проверьте себя
1. Что такое проблема N+1 в JPA?
AОшибка компиляции при N+1 сущностях
BЛишние запросы: 1 на список + по одному на каждую связанную сущность при ленивой загрузке в цикле
CПереполнение первичного ключа
DДублирование строк в таблице
2. Как Spring Data безопасно отдавать большой список записей клиенту?
AВернуть весь список через findAll()
BПринимать Pageable и возвращать Page с порцией данных и метаинформацией
CОграничить таблицу 100 строками в базе
DОтключить сортировку