← Назад к вопросам
Как реализовать пагинацию?
1.3 Junior🔥 251 комментариев
#Spring Boot и Spring Data#Базы данных и SQL
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Ответ
Пагинация — критическая функция при работе с большими наборами данных. Это не просто UI компонент, а комплексная архитектура. Рассмотрю все аспекты.
1. Offset/Limit пагинация (классический подход)
// REST контроллер
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
// Spring Data Page из коробки
Pageable pageable = PageRequest.of(page, size);
Page<User> users = userService.getUsers(pageable);
return users.map(this::convertToDTO);
}
}
// Service слой
@Service
public class UserService {
@Autowired
private UserRepository repository;
public Page<User> getUsers(Pageable pageable) {
// автоматически добавит LIMIT и OFFSET
return repository.findAll(pageable);
}
}
// Repository
public interface UserRepository extends PagingAndSortingRepository<User, Long> {
// Готовые методы: findAll(Pageable), findAll(Page), etc.
}
Использование:
# Первая страница, 10 элементов
GET /api/users?page=0&size=10
# Вторая страница, 20 элементов
GET /api/users?page=1&size=20
# Ответ
{
"content": [{user1}, {user2}, ...],
"pageable": {
"pageNumber": 0,
"pageSize": 10,
"offset": 0
},
"totalElements": 1000,
"totalPages": 100,
"number": 0,
"size": 10,
"hasNext": true,
"hasPrevious": false,
"first": true,
"last": false
}
2. Cursor-based пагинация (для больших наборов)
Лучше для перформанса на больших данных:
@RestController
public class ProductController {
@GetMapping("/products")
public CursorPage<ProductDTO> getProducts(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") int pageSize
) {
CursorPageable pageable = new CursorPageable(cursor, pageSize);
return productService.getProducts(pageable);
}
}
@Service
public class ProductService {
public CursorPage<Product> getProducts(CursorPageable pageable) {
String cursor = pageable.getCursor();
List<Product> items;
if (cursor == null) {
// Первая страница
items = repository.findFirst(pageable.getPageSize() + 1);
} else {
// Следующая страница, начиная после cursor (ID)
Long cursorId = decodeCursor(cursor);
items = repository.findByIdGreaterThan(
cursorId,
pageable.getPageSize() + 1
);
}
// Проверяем есть ли ещё данные
boolean hasMore = items.size() > pageable.getPageSize();
if (hasMore) {
items = items.subList(0, pageable.getPageSize());
}
// Следующий cursor — ID последнего элемента
String nextCursor = hasMore ?
encodeCursor(items.get(items.size() - 1).getId()) : null;
return new CursorPage<>(items, nextCursor, hasMore);
}
private String encodeCursor(Long id) {
return Base64.getEncoder().encodeToString(id.toString().getBytes());
}
private Long decodeCursor(String cursor) {
String decoded = new String(Base64.getDecoder().decode(cursor));
return Long.parseLong(decoded);
}
}
Использование:
# Первая страница
GET /products?pageSize=20
# Ответ
{
"items": [{product1}, {product2}, ...],
"nextCursor": "ZTU=", # Base64("e5")
"hasMore": true
}
# Следующая страница
GET /products?cursor=ZTU=&pageSize=20
3. Сортировка при пагинации
@GetMapping("/users")
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(defaultValue = "ASC") String direction
) {
Sort.Direction dir = Sort.Direction.fromString(direction);
Sort sort = Sort.by(dir, sortBy != null ? sortBy : "id");
Pageable pageable = PageRequest.of(page, size, sort);
return userService.getUsers(pageable)
.map(this::convertToDTO);
}
Использование:
# Сортировка по имени (возрастание)
GET /users?page=0&size=10&sortBy=name&direction=ASC
# Сортировка по дате (убывание)
GET /users?page=0&size=10&sortBy=createdAt&direction=DESC
4. Фильтрация + пагинация
@GetMapping("/users")
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String status,
@RequestParam(required = false) String department
) {
Pageable pageable = PageRequest.of(page, size);
Page<User> users = userService.findUsers(
status,
department,
pageable
);
return users.map(this::convertToDTO);
}
@Service
public class UserService {
public Page<User> findUsers(
String status,
String department,
Pageable pageable
) {
// Использование Specification для сложных фильтров
Specification<User> spec = Specification
.where(statusEquals(status))
.and(departmentEquals(department));
return repository.findAll(spec, pageable);
}
private Specification<User> statusEquals(String status) {
return status == null ? null : (root, query, cb) ->
cb.equal(root.get("status"), status);
}
private Specification<User> departmentEquals(String dept) {
return dept == null ? null : (root, query, cb) ->
cb.equal(root.get("department"), dept);
}
}
5. Оптимизация query для пагинации
// ❌ Плохо: N+1 query problem
@Query("SELECT u FROM User u WHERE u.department = :dept")
Page<User> findByDepartment(@Param("dept") String dept, Pageable p);
// ✓ Хорошо: Fetch join
@Query("""
SELECT DISTINCT u FROM User u
LEFT JOIN FETCH u.roles
LEFT JOIN FETCH u.permissions
WHERE u.department = :dept
""")
Page<User> findByDepartmentWithRelations(
@Param("dept") String dept,
Pageable p
);
// ✓ Хорошо: Entity graphs
@EntityGraph(attributePaths = {"roles", "permissions"})
@Query("SELECT u FROM User u WHERE u.department = :dept")
Page<User> findByDepartmentWithGraph(
@Param("dept") String dept,
Pageable p
);
6. Кэширование пагинированных результатов
@Service
public class CachedUserService {
@Cacheable(
value = "userPage",
key = "#page + ':' + #size + ':' + #status"
)
public Page<User> getUsersPage(
int page,
int size,
String status
) {
Pageable pageable = PageRequest.of(page, size);
return repository.findByStatus(status, pageable);
}
}
// application.properties
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
7. Frontend пагинация (React пример)
import { useState, useEffect } from 'react';
export function UserList() {
const [users, setUsers] = useState([]);
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
useEffect(() => {
fetchUsers(page);
}, [page]);
const fetchUsers = async (pageNum) => {
const response = await fetch(
`/api/users?page=${pageNum}&size=${pageSize}`
);
const data = await response.json();
setUsers(data.content);
setTotalPages(data.totalPages);
};
return (
<div>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<div className="pagination">
<button
onClick={() => setPage(page - 1)}
disabled={page === 0}
>
Previous
</button>
<span>Page {page + 1} of {totalPages}</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
>
Next
</button>
</div>
</div>
);
}
8. Infinite scroll (вместо пагинации)
// Cursor-based для infinite scroll
@GetMapping("/feed")
public InfiniteScrollResponse<PostDTO> getFeed(
@RequestParam(required = false) String cursor
) {
List<Post> posts = postService.getNextPosts(cursor, 20);
String nextCursor = !posts.isEmpty() ?
String.valueOf(posts.get(posts.size() - 1).getId()) : null;
return new InfiniteScrollResponse<>(
posts.stream().map(this::convertToDTO).toList(),
nextCursor
);
}
9. Лучшие практики
✓ Limit результатов (максимум 1000 элементов)
✓ Выбирать между offset/limit и cursor
✓ Индексировать поля сортировки
✓ Кэшировать популярные страницы
✓ Указывать размер страницы по умолчанию
✓ Валидировать параметры (page >= 0, size <= 1000)
✓ Использовать Pageable Spring Data
✓ Lazy loading для relacionships
✓ Distinct при FETCH JOIN
10. Тесты пагинации
@SpringBootTest
public class UserPaginationTest {
@Autowired
private UserRepository repository;
@Test
void testFirstPage() {
Pageable pageable = PageRequest.of(0, 10);
Page<User> page = repository.findAll(pageable);
assertTrue(page.isFirst());
assertTrue(page.hasNext());
assertEquals(10, page.getSize());
}
@Test
void testWithSort() {
Pageable pageable = PageRequest.of(
0,
10,
Sort.by("name").ascending()
);
Page<User> page = repository.findAll(pageable);
assertEquals("Alice", page.getContent().get(0).getName());
}
}
Сравнение подходов
| Подход | Плюсы | Минусы |
|---|---|---|
| Offset/Limit | Просто, flexible | Медленно на больших offset |
| Cursor | Быстро, consistent | Сложнее, нет "перейти на 10 страницу" |
| Infinite scroll | UX friendly | Сложнее реализовать |
| Keyset | Оптимально | Требует unique ordering |
Рекомендация: Offset/Limit для admin интерфейсов, Cursor для мобильных приложений и infinite scroll.