Эффективная пагинация в веб-приложениях: курсоры вместо OFFSET

Примечание: В статье используются Node.js и PostgreSQL для бекенда и React для фронтенда, но принципы универсальны для любых стеков.

Один из самых недооценённых аспектов разработки веб-приложений — правильная реализация пагинации. Казалось бы, что может быть проще? Добавим OFFSET и LIMIT в запрос — и готово. Но почему тогда при 500,000+ записей страница 999 загружается 10 секунд? И почему данные могут "прыгать" при добавлении новых элементов? Ответы кроются в подходе к пагинации.

Ограничения классической пагинации

Традиционный OFFSET/LIMIT подход до сих пор встречается в 90% учебников:

sql
-- Очевидное решение
SELECT * FROM orders ORDER BY id DESC OFFSET 900 LIMIT 10;

Проблемы этого подхода:

  1. Линейная деградация производительности:
    База данных фактически сканирует N записей до нужной позиции. Для OFFSET 100,000 будет прочитано 100,000 строк.

  2. Консистентность данных:
    При добавлении новых элементов между запросами записи могут дублироваться или пропадать между страницами.

  3. Ресурсоёмкость:
    Большие смещения создают нагрузку на сервер БД даже при индексации.

Курсорная пагинация: принцип работы

Вместо отсчёта количества пропущенных записей курсорная пагинация использует референсную точку для навигации. Представьте, что вы читаете книгу и ставите закладку вместо подсчёта страниц.

Основная концепция:

  • Клиент передаёт курсор (обычно уникальный идентификатор последнего показанного элемента)
  • Сервер выбирает данные, начиная со следующего за этим курсором элемента
  • Структура ответа содержит:
    • Данные на текущей странице
    • Курсоры для следующей/предыдущей страниц

Реализация на бекенде (Node.js + PostgreSQL)

Рассмотрим реализацию для списка заказов. Создадим миграцию:

sql
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);

Эндпоинт обработки запроса:

javascript
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' });
  }
});

Важные детали:

  1. Используем составной курсор id_created_at вместо только ID
  2. Выбираем на одну запись больше (limit + 1) для проверки наличия следующей страницы
  3. Сортируем по основному предикату (created_at) и дополнительному (id) для соблюдения порядка

Реализация на фронтенде (React)

Клиентская часть обработки пагинации с курсорами:

jsx
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>
  );
}

Расширенные сценарии

Совместная работа с фильтрацией и сортировкой

Основная сложность курсорной пагинации — поддержка произвольной сортировки. Решение:

  1. Для каждого типа сортировки создаётся свой курсорный механизм
  2. Курсор должен включать все поля, участвующие в сортировке
  3. Динамическая генерация условий WHERE

Пример для сортировки по сумме заказа:

javascript
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 для нативной нумерации страниц.

Заключение

Курсорная пагинация — не просто техоптимизация, а метод рефакторинга унаследованных систем, который даёт:

  • Предсказуемую производительность независимо от глубины выборки
  • Устойчивость к изменениям данных во время просмотра
  • Эффективный просмотр больших объёмов информации
  • Естественный паттерн для бесконечного скролла

Переход на этот подход требует переосмысления архитектуры подсистемы выборки данных, но цена такого перехода окупается при первых же обращениях к реальным данным продуктивного окружения. На небольших наборах данных разница может быть незаметна, но профессиональная разработка всегда включает расчёт на масштабирование.