Примечание: В статье используются Node.js и PostgreSQL для бекенда и React для фронтенда, но принципы универсальны для любых стеков.
Один из самых недооценённых аспектов разработки веб-приложений — правильная реализация пагинации. Казалось бы, что может быть проще? Добавим OFFSET и LIMIT в запрос — и готово. Но почему тогда при 500,000+ записей страница 999 загружается 10 секунд? И почему данные могут "прыгать" при добавлении новых элементов? Ответы кроются в подходе к пагинации.
Ограничения классической пагинации
Традиционный OFFSET/LIMIT подход до сих пор встречается в 90% учебников:
-- Очевидное решение
SELECT * FROM orders ORDER BY id DESC OFFSET 900 LIMIT 10;
Проблемы этого подхода:
-
Линейная деградация производительности:
База данных фактически сканирует N записей до нужной позиции. Для OFFSET 100,000 будет прочитано 100,000 строк. -
Консистентность данных:
При добавлении новых элементов между запросами записи могут дублироваться или пропадать между страницами. -
Ресурсоёмкость:
Большие смещения создают нагрузку на сервер БД даже при индексации.
Курсорная пагинация: принцип работы
Вместо отсчёта количества пропущенных записей курсорная пагинация использует референсную точку для навигации. Представьте, что вы читаете книгу и ставите закладку вместо подсчёта страниц.
Основная концепция:
- Клиент передаёт курсор (обычно уникальный идентификатор последнего показанного элемента)
- Сервер выбирает данные, начиная со следующего за этим курсором элемента
- Структура ответа содержит:
- Данные на текущей странице
- Курсоры для следующей/предыдущей страниц
Реализация на бекенде (Node.js + PostgreSQL)
Рассмотрим реализацию для списка заказов. Создадим миграцию:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
total_amount DECIMAL(10,2) NOT NULL,
customer_id UUID NOT NULL,
status VARCHAR(20) NOT NULL
);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC, id);
Эндпоинт обработки запроса:
app.get('/orders', async (req, res) => {
const after = req.query.after || '';
const limit = parseInt(req.query.limit ?? '10');
try {
let query = {
text: `
SELECT * FROM orders
WHERE
($1 = '' OR id > $1 AND created_at = $2)
OR created_at < $2
ORDER BY created_at DESC, id
LIMIT $3
`,
values: [after.split('_')[0], after.split('_')[1] || new Date().toISOString(), limit + 1],
};
const result = await pool.query(query);
const orders = result.rows.slice(0, limit);
const nextCursor = orders.length >= limit
? `${orders[orders.length-1].id}_${orders[orders.length-1].created_at.toISOString()}`
: null;
res.json({ orders, nextCursor });
} catch (err) {
res.status(500).json({ error: 'Internal server error' });
}
});
Важные детали:
- Используем составной курсор
id_created_at
вместо только ID - Выбираем на одну запись больше (
limit + 1
) для проверки наличия следующей страницы - Сортируем по основному предикату (created_at) и дополнительному (id) для соблюдения порядка
Реализация на фронтенде (React)
Клиентская часть обработки пагинации с курсорами:
function OrdersList() {
const [orders, setOrders] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadInitial = async () => {
setLoading(true);
try {
const res = await fetch('/orders');
const data = await res.json();
setOrders(data.orders);
setNextCursor(data.nextCursor);
} catch (err) {
setError('Failed to load orders');
} finally {
setLoading(false);
}
};
const loadMore = async () => {
if (!nextCursor || loading) return;
setLoading(true);
try {
const res = await fetch(`/orders?after=${encodeURIComponent(nextCursor)}`);
const data = await res.json();
setOrders(prev => [...prev, ...data.orders]);
setNextCursor(data.nextCursor);
} catch (err) {
setError('Failed to load more orders');
} finally {
setLoading(false);
}
};
return (
<div>
{orders.map(order => (
<OrderItem key={`${order.id}_${order.created_at}`} {...order} />
))}
{loading && <LoadingSpinner />}
{error && <ErrorMessage message={error} />}
{nextCursor && !loading && (
<button onClick={loadMore}>Load More</button>
)}
</div>
);
}
Расширенные сценарии
Совместная работа с фильтрацией и сортировкой
Основная сложность курсорной пагинации — поддержка произвольной сортировки. Решение:
- Для каждого типа сортировки создаётся свой курсорный механизм
- Курсор должен включать все поля, участвующие в сортировке
- Динамическая генерация условий WHERE
Пример для сортировки по сумме заказа:
function buildOrderQuery(sortField, cursor, limit) {
const cursorParts = cursor ? cursor.split('_') : [];
return {
text: `
SELECT * FROM orders
WHERE
(${cursorParts[0] || null} IS NULL OR
(${sortField} $1 ${cursorParts[1]} AND id != $2))
ORDER BY ${sortField} ${sortDirection}, id
LIMIT $3
`,
values: [/* динамические параметры */]
};
}
Обратная совместимость API
Для существующих клиентов поддерживайте /items?page=10 и /items?after=cursor параллельно через динамические роуты.
Кеширование на уровне базы данных
Используйте материализованные представления для часто пагинируемых данных с редкими изменениями.
Когда курсорная пагинация не подходит
- Данные с высоким уровнем вставок во время пагинации
- Требуется доступ к произвольной странице (например "страница 42")
- Простейшие случаи с <10,000 записей
В таких случаях можно использовать гибридный подход: курсоры для основной навигации + ограниченный OFFSET для нативной нумерации страниц.
Заключение
Курсорная пагинация — не просто техоптимизация, а метод рефакторинга унаследованных систем, который даёт:
- Предсказуемую производительность независимо от глубины выборки
- Устойчивость к изменениям данных во время просмотра
- Эффективный просмотр больших объёмов информации
- Естественный паттерн для бесконечного скролла
Переход на этот подход требует переосмысления архитектуры подсистемы выборки данных, но цена такого перехода окупается при первых же обращениях к реальным данным продуктивного окружения. На небольших наборах данных разница может быть незаметна, но профессиональная разработка всегда включает расчёт на масштабирование.