Связи между сущностями и пагинация
Сущности связаны: у пользователя — заказы, у заказа — товары. 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. Эти две привычки отличают код, который держит нагрузку, от кода, который падает на первой тысяче записей.