Pagination cursor vs offset на собеседовании системного аналитика

Готовься к собесу аналитика как в Duolingo
10 минут в день — SQL, Python, A/B, метрики. 1700+ вопросов в Telegram
Открыть Карьерник в Telegram

Карьерник — Duolingo для аналитиков: 10 минут в день тренируй SQL, Python, A/B, статистику, метрики и ещё 3 темы собеса. 1500+ вопросов в Telegram-боте. Бесплатно.

Зачем спрашивают на собесе SA

Pagination — must-have для любого list-API. На собесе SA: «как пагинировать ленту», «зачем cursor», «что такое deep pagination problem». Senior — нюансы консистентности, sorting + cursor, GraphQL Connections.

Главная боль без понимания — SA закладывает offset для бесконечной ленты постов, на 100k записей пагинация замедляется в 100×, юзер видит дубли при новых постах.

Offset pagination

GET /posts?limit=20&offset=40

Backend:

SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;

Плюсы:

  • Простота на клиенте: «третья страница» = offset = 40.
  • Можно прыгать на любую страницу.
  • Готовый total: SELECT count(*) FROM posts.

Минусы:

  • Deep pagination проблема. OFFSET 1000000 — БД сканирует и пропускает миллион строк. Медленно.
  • Дубли при insert. Между запросами вставили запись — позиции сдвигаются, юзер видит запись дважды или пропускает.
  • Inconsistent. Если данные меняются — пагинация ненадёжна.

Cursor pagination

Курсор — указатель на «последнюю показанную запись». Следующий запрос — «дай 20 после курсора».

GET /posts?limit=20
→ {data: [...], next_cursor: "eyJpZCI6Mzg1fQ=="}

GET /posts?limit=20&cursor=eyJpZCI6Mzg1fQ==
→ следующие 20

Backend:

SELECT * FROM posts
WHERE created_at < $cursor_created_at
   OR (created_at = $cursor_created_at AND id < $cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Курсор — opaque строка для клиента. На сервере декодируется в (created_at, id) пары.

Плюсы:

  • Стабильная производительность. Index range — O(log N) для поиска курсора + 20 строк.
  • Консистентность при insert/delete — записи между курсорами не смещаются.
  • Подходит для бесконечной прокрутки.

Минусы:

  • Нельзя прыгнуть на «страницу 50».
  • Total count — отдельный запрос (если нужен).
  • Сложнее реализация.

Keyset pagination как разновидность cursor

«Keyset» — частный случай cursor pagination, когда курсор = значения сортировочных колонок.

SELECT * FROM posts
WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

В Postgres работает row-comparison < для tuple. На индексе (created_at, id) — index range scan, очень быстро.

Часто кодируется как opaque token:

import base64, json

def encode_cursor(post):
    return base64.urlsafe_b64encode(
        json.dumps({"id": post.id, "ts": post.created_at.isoformat()}).encode()
    ).decode()

def decode_cursor(token):
    return json.loads(base64.urlsafe_b64decode(token))

Когда что выбирать

Offset:

  • Админка с короткими списками (< 10k записей).
  • Когда «прыжок на страницу N» обязателен.
  • Простой UI с пагинацией страницами.
  • Точный total нужен.

Cursor / keyset:

  • Бесконечная лента (Twitter, TG, Instagram).
  • Большие таблицы (миллионы записей).
  • API для мобильного / SPA.
  • Real-time поток данных.
  • Любой случай с deep pagination.

В современном API-дизайне cursor — стандарт. Twitter API, Stripe API, GraphQL Relay Connections — все cursor-based.

Готовься к собесу аналитика как в Duolingo
10 минут в день — SQL, Python, A/B, метрики. 1700+ вопросов в Telegram
Открыть Карьерник в Telegram

API дизайн

Offset-style:

GET /posts?page=3&per_page=20

{
  "data": [...],
  "meta": {
    "page": 3,
    "per_page": 20,
    "total": 1500,
    "total_pages": 75
  }
}

Cursor-style:

GET /posts?limit=20&after=cursor_xyz

{
  "data": [...],
  "page_info": {
    "has_next_page": true,
    "end_cursor": "cursor_abc"
  }
}

GraphQL Relay Connections — стандартизированный паттерн:

{
  posts(first: 20, after: "cursor_xyz") {
    edges {
      node { id title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Sorting и tiebreaker

Pagination всегда требует детерминированной сортировки. Если два объекта имеют одинаковый ключ — порядок не определён, пагинация ломается.

-- плохо
ORDER BY created_at DESC

-- хорошо
ORDER BY created_at DESC, id DESC

id — tiebreaker. Уникальный, monotonic — даёт стабильность.

В cursor — обязательно включить tiebreaker в курсор:

cursor = {"created_at": "...", "id": 12345}

Иначе при равных timestamps — пропуски или дубли.

Multi-column sort: курсор — все колонки sort'а.

Частые ошибки

Offset для бесконечной ленты. Deep pagination + дубли при insert. Cursor.

Sort без tiebreaker. Нестабильная пагинация. Всегда добавляй уникальный ID в конец sort.

Курсор без TTL. Курсор должен быть валиден разумный срок. Через год — данные могли быть удалены, курсор невалиден.

Возвращать total_count всегда. На больших таблицах COUNT(*) без условий — медленно. Использовать estimate (pg_class.reltuples) или вообще не возвращать.

Декодировать cursor доверчиво. Cursor — input от клиента. Валидируй формат, защити от injection в SQL.

Не учитывать DELETE между запросами. Если запись удалена — её просто нет в следующем page. Это нормально, но клиент должен это понимать.

Возвращать «next: null» только если len(data) < limit. Опаснее: иногда len(data) == limit, но больше нет — нужен явный has_next флажок или дополнительный +1 запрос.

Использовать UUID как cursor с random sort. UUIDv4 случаен — нет естественного порядка. UUIDv7 / ULID имеют timestamp prefix — подходят.

Связанные темы

FAQ

Можно ли совмещать offset и cursor?

Да, в публичном API часто доступны оба варианта: offset для UI с страницами, cursor для бесконечной ленты. Twitter API так и делал.

Что делать с total count при cursor?

Несколько вариантов: не возвращать вовсе, возвращать estimated (быстро через pg_class), или отдельный endpoint /posts/count.

Cursor для full-text search results?

Сложнее — score-based ranking требует tiebreaker. Часто используют (score, id) или (relevance_rank, id).

Что такое seek pagination?

Синоним keyset/cursor pagination. Все три — про «дай записи после такого-то ключа».

Как пагинировать с фильтрами?

Фильтры применяются стабильно — в курсоре сохраняется состояние сортировки + значения сортировочных колонок. Фильтры обычно неизменны при пагинации.

Это официальная информация?

Нет. Статья основана на материалах PostgreSQL Use The Index Luke, GraphQL Cursor Connections Specification и опыте API design.


Тренируйте системный анализ — откройте тренажёр с 1500+ вопросами для собесов.