Pagination cursor vs offset на собеседовании системного аналитика
Карьерник — 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=40Backend:
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==
→ следующие 20Backend:
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.
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 DESCid — 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 — подходят.
Связанные темы
- REST API на собесе SA
- HTTP методы и коды на собесе SA
- Идемпотентность API для SA
- OpenAPI и Swagger на собесе SA
- Подготовка к собесу системного аналитика
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+ вопросами для собесов.