Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Пагинация и фильтрация в JSON API

Пагинация и фильтрация JSON API: offset, cursor и дизайн параметров

Как спроектировать списки в API: offset против cursor-пагинации, envelope со счётчиками, фильтрация и сортировка параметрами, лимиты и ошибки валидации.

Прямой ответ

Для списков в JSON API базовый контракт: envelope ({"items": [...], "next_cursor": "...", "total": 1234} — данные отдельно от навигации), лимит страницы (?limit=50 с серверным максимумом — limit=1000000 без крышки — DoS одним запросом), и выбор стратегии: offset (?offset=100&limit=50 — прост, умеет «перейти к странице 7», но деградирует на больших смещениях — Postgres читает и выбрасывает offset строк — планы запросов — и «плывёт» при вставках: записи дублируются/пропускаются между страницами) против cursor (?cursor=eyJpZCI6MTIzfQ&limit=50 — курсор = закодированная позиция (id/created_at) — WHERE (created_at, id) > (:ts, :id) ORDER BY created_at, id LIMIT 50 — стабильность при изменениях данных и индексная эффективность на любой глубине; цена — нет «страницы 7»). Правило выбора: админки с редкими данными и нуждой в нумерации — offset ок; ленты/фиды/большие таблицы с живой записью — cursor (как у Stripe/Slack/GitHub — ссылки в шапке). Фильтрация — типизированными параметрами (?status=active&created_after=2026-01-01&tag=in,prod — CSV для множественных), а не произвольным query-языком; сортировка — ?sort=-created_at,name (минус = desc) с whitelist-ом полей (sort по произвольному столбцу — и инъекция, и отсутствие индекса — «сортировка» как вектор нагрузки); всё — через Pydantic-валидацию с 422 на мусор и описанием в OpenAPI.

Offset и cursor: механика и грабли

Offset-механика: SELECT ... ORDER BY id LIMIT :limit OFFSET :offset; грабли: (1) производительность — OFFSET 100000 — БД извлекает 100050 строк и выбрасывает 100000 (p95 глубокой страницы растёт линейно; ключевой оптимум — keyset: WHERE id > :last_id — индексный range-scan любой глубины); (2) нестабильность — новая запись «сдвигает» окно (элемент на границе страниц дублируется на следующей странице или теряется — для «ленты заказов» заметно); (3) total-счётчик — отдельный COUNT(*) на больших таблицах дорог (keystore-подходы: кеш счётчика, приблизительный count из статистики, «total не отдаём» для бесконечных лент — честное решение). Cursor-механика: курсор — не магия, а последняя виденная сортировочная позиция, закодированная (base64 JSON {"created_at": "...", "id": 42} — клиенту непрозрачна, серверу понятна; секретность не требуется, но и осмысленность клиенту — ни к чему: менять формат курсора сервер вправе); обязательные условия — детерминированная полная сортировка (created_at + уникальный tiebreaker id — иначе записи с одинаковым created_at теряются/дублируются на границе — классический баг cursor-пагинации), индекс под (created_at, id), > сравнение по составному ключу (WHERE (created_at, id) > (:c, :i) — row-comparison — один индексный проход). Выбор по продукту: UI «страницы 1..N» — offset (+keyset внутри при глубине); «ещё»/бесконечная лента/экспорт — cursor; экспорт больших объёмов — cursor обязателен (стабильность на часы выгрузки).

Фильтрация, сортировка, лимиты

Фильтры — контракт по каждому полю: тип (enum/string/date/число), оператор (равенство по умолчанию; _gte/_lte/_in суффиксы или явные параметры created_after — выбрать один стиль и держать), поведение на пустой результат (пустой список + 200, не 404 — коды); реализация — композиция условий в SQLAlchemy (динамические where по присутствию параметра — запросы), каждый фильтр — с индексом или осознанно без (фильтр по неиндексированному столбцу на большой таблице — seq scan на каждый запрос — индексы); произвольные пользовательские query-DSL (?filter[orders][status]=... — JSON:API-стиль) — оправданы при десятках фильтруемых сущностей, иначе переусложнение. Сортировка — whitelist (Pydantic-валидатор «поле из списка») + соответствие индексов (sort=-created_at — индекс на created_at DESC или B-tree в обе стороны; sort по неиндексированному — filesort — лимитировать или индексировать). Лимиты — limit ≤ 100 серверно (клиентский default 20–50), rate limit на списковые (защита), «тяжёлые» поля — field selection (?fields=id,name — по желанию) или отдельные представления (список — краткий, деталь — полная — стандарт REST). Ошибки — 422 с указанием параметра (невалидный cursor — «cursor expired/invalid» — 400; курсор от другой выборки — не падать, а 400 — клиенты шлют устаревшие).

Практический сценарий: список заказов от offset к cursor

Исходник: админка заказов, GET /orders?offset=&limit=; жалобы: «дальше 50-й страницы тормозит», «при обновлении страницы заказы дублируются». Разбор: EXPLAIN — OFFSET 5000 — seq-подобный план, 400 мс; параллельная вставка заказа — дубль на границе страниц подтверждён. Решение: курсор по (created_at DESC, id DESC) — Pydantic-модель Cursor(created_at: datetime, id: int) + base64url; запрос — row-comparison с индексом (created_at, id); envelope {items, next_cursor, has_more} (total убран — для «ленты заказов» не использовался в UI — минус один COUNT на запрос); backward-совместимость (версионирование): offset-параметры принимаются полгода с deprecation-заголовком, UI переведён на cursor сразу; фильтр status= остался offset-совместимым (фильтр не ломает курсор — сортировка та же). Итог: p95 любой глубины — 8 мс (было 400+), дубли исчезли (детерминированная сортировка с tiebreaker — проверено тестом «вставка на границе страниц» — тесты API); экспорт CSV на 500k строк через тот же cursor — стабильные 11 минут без единого пропуска (проверено сверкой COUNT с БД). Урок в ADR: «cursor для всех живых лент, offset — только админ-таблицы с нумерацией страниц».

FAQ

Что отдавать в total, если COUNT дорог? Варианты по убыванию честности: точный COUNT с кешем на минуты (админка потерпит), приблизительный из pg_class.reltuples/EXPLAIN-оценки («~1.2M» — UI с «примерно»), либо has_more без total (ленты/бесконечный скролл — total не нужен продуктово) — выбирать по UI, не тащить COUNT по инерции.

Cursor должен быть зашифрован? Нет: курсор — позиция, не секрет (подделка курсора даёт доступ к тем же данным с той же авторизацией — права проверяются на запросе, не на курсоре); base64 достаточно; HMAC-подпись — если хочется запретить «рукописные» курсоры (не обязательно); важнее — версионировать формат (префикс v1: внутри — смена схемы сортировки не ломает старые клиенты в 400-апокалипсис).

Как пагинировать JOIN-выдачу с дублями? Проблема: один заказ с 10 позициями — LIMIT 50 по JOIN-строкам отрежет «4.5 заказа»; решения: пагинировать по основной сущности (subquery/window: выбрать 50 id заказов — подтянуть позиции — два запроса или lateral join), либо DISTINCT ON/агрегация до пагинации; правило — LIMIT применяется к сущности списка, не к сырым JOIN-строкам.

Offset совсем запрещать? Нет: offset проще для клиентов и достаточен при малых объёмах и редкой записи (справочники, админ-таблицы на тысячи строк, экспорт не нужен); запрещать стоит «offset по умолчанию везде» — дефолт-выбор без анализа глубины/живости данных; гибриды (offset UI + keyset внутри при offset>N) — законная инженерия.

Источники: практики пагинации Stripe/Slack/GitHub API, документация JSON:API — ссылки в шапке.