Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Keyset‑пагинация вместо OFFSET/LIMIT: как ускорить списки в 5–20 раз и снизить нагрузку на базу

Разработка и технологии4 января 2026 г.
OFFSET/LIMIT удобны, но дорого обходятся: глубокие страницы тормозят, база делает лишнюю работу, а пользователи видят «дрожащие» списки. Разбираем, как перейти на keyset/seek‑пагинацию с курсорами, какие индексы нужны, как работать с фильтрами и «назад», и как безопасно кодировать курсор.
Keyset‑пагинация вместо OFFSET/LIMIT: как ускорить списки в 5–20 раз и снизить нагрузку на базу

  • Содержание
    • Зачем менять OFFSET/LIMIT
    • Что такое keyset/seek‑пагинация
    • Простой пример: лента заказов по дате
    • Кодирование и подпись курсора
    • Фильтры и индексы: как не потерять скорость
    • Как сделать кнопку «Назад» и обратную пагинацию
    • Как понять, есть ли следующая страница
    • Счётчик страниц и «сколько всего»
    • Стабильность выдачи и «прыгающие» записи
    • Пример API-ответа
    • Чек‑лист индексов и частые ошибки
    • Миграция с OFFSET/LIMIT без боли
    • Итоги

Зачем менять OFFSET/LIMIT

OFFSET/LIMIT — простой способ разбить список на страницы. Но у него есть три дорогие проблемы:

  • Глубокие страницы дороже в разы. Чтобы показать страницу 200 с лимитом 50, базе нужно отсортировать и пропустить 9 950 строк, которые никто не увидит. Это лишние чтения диска и CPU.
  • Нестабильная выдача. Пока пользователь листает, в таблицу приходят новые записи. OFFSET считает «количество пропущенных строк», а не «точку во времени», из‑за чего элементы дублируются или пропадают.
  • Плохая предсказуемость нагрузки. Чем популярнее раздел и длиннее списки, тем чаще покупаем «лишние ядра» под сортировки и сканирования.

Keyset‑пагинация решает все три проблемы: не пропускает записи зря, стабилизирует порядок и почти всегда работает по индексу.

Что такое keyset/seek‑пагинация

Идея простая: вместо «номер страницы» мы запоминаем «курсором» последнюю виденную запись и при следующем запросе просим «записи строго новее/старее неё». Это называется seek‑сканированием по индексу.

Ключевые правила:

  • Должен быть детерминированный порядок сортировки и «уникальная развязка» на случай равенства. Чаще всего: created_at DESC, id DESC.
  • Курсор содержит значения полей сортировки последнего элемента страницы.
  • Следующая страница берётся условием вида «(created_at, id) < (cursor_created_at, cursor_id)» при сортировке DESC.

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

Пусть у нас таблица orders с колонками id (PK), created_at, amount, status, tenant_id.

Создадим индекс под сортировку:

-- PostgreSQL
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_id_desc
  ON orders (created_at DESC, id DESC);

Первая страница (без курсора):

-- Возьмём 50 последних заказов клиента
SELECT id, created_at, amount, status
FROM orders
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2; -- $2 = 50

Следующая страница (есть курсор с последним элементом из предыдущей):

-- Курсор содержит cursor_created_at, cursor_id
SELECT id, created_at, amount, status
FROM orders
WHERE tenant_id = $1
  AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT $2;

Для MySQL эквивалент условия:

-- MySQL
WHERE tenant_id = ?
  AND (
    created_at < ? OR (created_at = ? AND id < ?)
  )
ORDER BY created_at DESC, id DESC
LIMIT ?;

Почему это быстро: база идёт по индексу ровно до лимита и на этом останавливается. Нет «пропуска тысяч строк», как при OFFSET.

Кодирование и подпись курсора

Не стоит отдавать курсор «как есть» с внутренними идентификаторами — его можно подделать или подобрать. Решение: кодируем полезную нагрузку и подписываем её HMAC‑подписью.

Пример на TypeScript (Node.js), токены в формате base64url.payload.signature:

import crypto from 'crypto';

const SECRET = process.env.CURSOR_SECRET || 'change_me_strong_secret';

type Dir = 'next' | 'prev';
interface CursorPayload {
  createdAt: string; // ISO‑строка
  id: number;
  dir: Dir;
}

function sign(payload: string): string {
  return crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
}

export function encodeCursor(p: CursorPayload): string {
  const json = JSON.stringify(p);
  const b64 = Buffer.from(json, 'utf8').toString('base64url');
  const sig = sign(json);
  return `${b64}.${sig}`;
}

export function decodeCursor(token: string): CursorPayload {
  const [b64, sig] = token.split('.');
  if (!b64 || !sig) throw new Error('Некорректный курсор');
  const json = Buffer.from(b64, 'base64url').toString('utf8');
  const expected = sign(json);
  const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) throw new Error('Неверная подпись курсора');
  const obj = JSON.parse(json);
  if (typeof obj.id !== 'number' || typeof obj.createdAt !== 'string') {
    throw new Error('Неверные поля в курсоре');
  }
  if (obj.dir !== 'next' && obj.dir !== 'prev') obj.dir = 'next';
  return obj as CursorPayload;
}

С серверной стороны вы всегда можете менять «состав» курсора (например, добавлять фильтры), не ломая клиентов — достаточно поддерживать расшифровку старого формата на время миграции.

Фильтры и индексы: как не потерять скорость

Keyset‑пагинация быстрая, когда условие фильтра поддержано индексом, и порядок сортировки совпадает с порядком в индексе.

Рекомендации:

  • Фильтр + сортировка в одном составном индексе. Пример: для status = 'paid' и сортировки по дате
    • индекс: (status, created_at DESC, id DESC)
    • запрос: WHERE status = 'paid' AND (created_at, id) < (...)
  • Частичные индексы. Если у вас 80% запросов по status = 'paid', сделайте частичный индекс:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_paid_created_id
  ON orders (created_at DESC, id DESC)
  WHERE status = 'paid';
  • Избегайте сортировок по вычисляемым выражениям без материализации. Если нужно «по популярности», заведите колонку popularity и обновляйте её фоном — тогда индекс станет возможен.
  • Осторожно с NULL. Если created_at может быть NULL, решите стратегию: либо исключайте их частичным индексом (WHERE created_at IS NOT NULL), либо сортируйте с NULLS LAST в запросе.

Как сделать кнопку «Назад» и обратную пагинацию

Для «предыдущей страницы» используйте обратное сравнение и перевёрнутый порядок, затем разверните результат в приложении.

-- Предыдущая страница относительно курсора (последний элемент текущей страницы)
SELECT id, created_at, amount, status
FROM orders
WHERE tenant_id = $1
  AND (created_at, id) > ($3, $4)     -- меняем знак
ORDER BY created_at ASC, id ASC       -- инвертируем порядок
LIMIT $2;                             -- берём N записей

На уровне кода:

  • Достаём записи в ASC, чтобы сканировать вперёд по индексу.
  • Разворачиваем массив на клиенте перед показом, чтобы пользователю по‑прежнему казалось, что он листает «вниз».

Храните стек курсоров у клиента (или в сессии), чтобы «назад» работало без пересчётов.

Как понять, есть ли следующая страница

Классический приём: запрашивайте на 1 запись больше лимита.

  • Если пришло N + 1 — есть следующая страница, отдаём первые N и формируем next_cursor по последнему из N.
  • Если пришло ≤ N — следующей страницы нет.

Это избавляет от отдельного «count(*)» запроса и споров с оптимизатором.

Счётчик страниц и «сколько всего»

Абсолютный count(*) при больших объёмах данных может стоить миллисекунды на маленьких таблицах и секунды на больших. В лентах и каталогах он редко обязателен.

Практичные варианты:

  • Показывать «Есть ещё» вместо точного числа страниц.
  • Если жизненно нужен «примерный счётчик», используйте статистику таблицы (PostgreSQL):
-- Грубая оценка общего числа строк в таблице (не по фильтру!)
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';
  • Для важных бизнес‑фильтров заведите отдельные счётчики (агрегирующую таблицу), обновляемые триггером или фоновым процессом. Тогда запрос становится мгновенным.

Стабильность выдачи и «прыгающие» записи

Если между запросами появляются новые строки, keyset‑пагинация не ломается: курсор «смотрит назад» относительно стабильно отсортированного множества.

Что учесть:

  • Выберите порядок по «монотонной» колонке. created_at обычно достаточно стабилен. Для абсолютной стабильности добавьте id как последний ключ сортировки.
  • Обновления записей. Если обновления меняют поле сортировки (например, updated_at), записи могут «переезжать». Лучше сортировать по created_at, а updated_at использовать только для фильтра «изменено после».
  • Повторяемость в транзакции. Если нужна строго одинаковая выборка на протяжении нескольких запросов (редко для лент), используйте уровень изоляции REPEATABLE READ в PostgreSQL и фиксируйте снапшот на время сессии. Обычно это не требуется.

Пример API-ответа

{
  "items": [
    {"id": 5012, "created_at": "2024-12-09T10:11:22Z", "amount": 1490, "status": "paid"},
    {"id": 5009, "created_at": "2024-12-09T10:10:07Z", "amount": 990,  "status": "paid"}
  ],
  "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTEyLTA5VDEwOjEwOjA3WiIsImlkIjo1MDA5LCJkaXIiOiJuZXh0In0.yS2yQ2...",
  "has_next": true
}

Эндпоинт может выглядеть так: GET /api/orders?limit=50&after=... Возвращайте next_cursor и, при необходимости, prev_cursor.

Чек‑лист индексов и частые ошибки

  • Составной индекс с полями сортировки в нужном порядке, плюс «сильные» фильтры в начале.
  • Включайте id в конец сортировки для уникальности порядка.
  • Для популярного фиксированного фильтра (status, tenant_id) используйте частичные индексы.
  • Не сортируйте по выражениям без материализации колонки — заведите колонку и обновляйте её фоном.
  • Следите за NULL: либо исключайте их частичным индексом, либо определитесь с NULLS LAST/NULLS FIRST в запросе.
  • В PostgreSQL DESC в индексе соответствует ORDER BY ... DESC в запросе; «NULLS LAST» — это свойство запроса, не индекса. Если много NULL, подумайте о частичном индексе WHERE created_at IS NOT NULL.
  • Не делайте OFFSET одновременно с keyset — это снова приведёт к пропускам.

Миграция с OFFSET/LIMIT без боли

Пошаговый план:

  1. Зафиксируйте канонический порядок для списка (например, created_at DESC, id DESC). Пройдитесь по всем местам, где список показывается, чтобы везде был один и тот же порядок.
  2. Добавьте индексы под популярные фильтры и порядок сортировки.
  3. Реализуйте эндпоинт с курсором параллельно со старым. Отдавайте оба метода в течение переходного периода.
  4. На клиенте используйте новый эндпоинт для «бесконечного скролла», старый оставьте для пагинации «номер страниц» — или добавьте маппинг по ключевым точкам (например, «перейти к дате»).
  5. В логах сравните p95/p99 задержки, число логических чтений по БД и нагрузку на CPU. В реальных проектах улучшение в 5–20 раз — норма.
  6. После стабилизации уберите OFFSET‑вариант либо оставьте как резервный на случай редких сценариев (например, экспорта).

Бонус: если у вас мультиарендность (tenant_id), обязательно включайте её в начало индекса — это резко уменьшает рабочий набор ключей на один запрос.

Итоги

Keyset/seek‑пагинация — простой приём, который дает непропорционально большой выигрыш:

  • Быстрее на 5–20 раз по p95 за счёт сканирования по индексу без «пропусков».
  • Стабильная выдача без дубликатов и «прыжков» при новых записях.
  • Меньше расходов на БД: меньше сортировок, меньше чтений, меньше буферов.

Внедряется поэтапно: зафиксируйте порядок, добавьте индексы, введите курсоры с подписью и аккуратно мигрируйте клиентов. В результате списки грузятся быстрее, пользователи дольше остаются в продукте, а счёт за базу предсказуемо снижается.


PostgreSQLпроизводительностьпагинацияиндексы