Когда пользователь открывает список заказов, он хочет видеть только свои, только за прошлый месяц, только отменённые — и желательно отсортированные по дате. Всё это передаётся через query-параметры: часть URL после знака ?.
Разберём, как их правильно называть, как строить фильтры, и как сделать так, чтобы большие списки грузились постранично.
Самое дорогое решение здесь — способ постраничной загрузки: от него зависит, что увидит человек, если список пополнился прямо во время чтения.
Пока читали первую страницу, в начало ленты попал новый заказ — и page=2 с тем же size вернул №224 второй раз: offset считает позиции, а позиции сдвинулись. Cursor запоминает не позицию, а саму запись, поэтому отдаёт ровно следующие двадцать — ценой номеров страниц и общего счётчика.
Как называть параметры
Клиент помнит, что в ответе поле customerId, а в фильтре его надо писать customer_id, и половина запросов уходит с опечаткой в имени параметра, которую сервер молча игнорирует. Чтобы не помнить два стиля, имена query-параметров пишут в camelCase, так же, как поля в JSON.
| Запрос | Годится | Почему |
|---|---|---|
GET /orders?customerId=123&dateFrom=2026-01-01 | да | camelCase — как поля в JSON |
GET /orders?customer_id=123 | нет | snake_case |
GET /orders?CustomerID=123 | нет | PascalCase |
Единая конвенция по всему API избавляет клиентов от путаницы: не нужно помнить, где подчёркивание, а где нет.
Фильтрация
Самый простой способ фильтровать — передать имя поля и значение напрямую:
GET /orders?status=CONFIRMED
GET /orders?customerId=550e8400-e29b-41d4-a716-446655440000
Для диапазонов добавляют суффиксы From и To:
GET /orders?dateFrom=2026-01-01&dateTo=2026-12-31
GET /orders?amountFrom=100&amountTo=500
Интервал включает оба края: от dateFrom включительно до dateTo включительно. Можно передать только одну границу — например, dateFrom без dateTo означает «с этой даты и далее».
С датами тут есть ловушка, на которую напарываются почти все. Клиент шлёт dateTo=2026-12-31, а в базе лежит не дата, а момент времени с часами и минутами (timestamptz). Java превратит присланную строку в 2026-12-31T00:00:00 — то есть в полночь. И условие «до dateTo включительно» отрежет весь последний день: заказ, оформленный 31 декабря в 14:05, в выборку не попадёт. Пользователь видит, что за декабрь чего-то не хватает, и никто не понимает почему.
Лечится это тем, что верхняя граница делается не включительной: берём всё, что случилось от dateFrom включительно и строго раньше следующего дня после dateTo.
// dateTo = 2026-12-31 → берём всё, что раньше 2027-01-01 00:00
var from = dateFrom.atStartOfDay(zone).toInstant();
var to = dateTo.plusDays(1).atStartOfDay(zone).toInstant();
// WHERE created_at >= :from AND created_at < :to
Правило короткое: если в параметре дата, а в колонке момент времени — нижнюю границу сравниваем через >=, верхнюю через <, сдвинув её на сутки вперёд. И в документации честно пишем, что dateTo означает «включая весь этот день».
Два вида пагинации
Когда в базе тысячи записей, отдавать их все за один запрос нельзя. Нужна постраничная загрузка. Есть два подхода, и у каждого своя область применения.
Offset-пагинация — для классического UI со страницами
Клиент говорит: «дай мне страницу номер 3, по 20 штук». Сервер пропускает первые 40 и возвращает следующие 20.
| Запрос | Что вернёт |
|---|---|
GET /orders?page=1&size=20 | первая страница, 20 записей |
GET /orders?page=3&size=50 | третья страница, 50 записей |
Важная деталь: первая страница — это page=1, а не page=0. Нулевая нумерация страниц внутри кода — это детали реализации, которые не должны протекать в публичный контракт.
В Spring Data для этого есть готовая настройка:
spring:
data:
web:
pageable:
one-indexed-parameters: true
Ответ сервера содержит сами данные и информацию о постраничности:
{
"content": [
{ "orderId": "...", "status": "CREATED" }
],
"page": 1,
"size": 20,
"totalElements": 243,
"totalPages": 13
}
totalElements и totalPages позволяют UI нарисовать кнопки «1 2 3 … 13».
Важно: это ваш собственный класс ответа, а не то, что Spring отдаст сам. Если вернуть из контроллера Page<OrderResponse> как есть, наружу поедет внутреннее устройство Spring Data: номер страницы там называется number и считается с нуля, рядом вылезет объект pageable со своими offset и paged, а настройка one-indexed-parameters на ответ вообще не влияет — она правит только входящий параметр. Клиент попросил page=1, а в ответе увидел "number": 0.
Начиная со Spring Boot 3.3 прямую отдачу Page считают нежелательной именно поэтому: форма ответа не контракт, а слепок реализации, и его нельзя менять, не сломав клиентов. Переключатель есть:
spring:
data:
web:
pageable:
serialization-mode: via-dto # по умолчанию direct
С ним Spring завернёт страницу в PagedModel — стабильную оболочку с content и page: { size, number, totalElements, totalPages }. Но проще и честнее собрать свой ответ: тогда имена полей выбираете вы, а не библиотека.
Когда использовать: нужны номера страниц в интерфейсе, пользователь хочет перепрыгнуть на страницу 7, данные меняются редко.
Ограничения: при активной вставке/удалении записей страницы «плывут» — элемент может появиться дважды или пропасть. На очень больших OFFSET в SQL запрос замедляется.
Cursor-пагинация — для лент и бесконечной прокрутки
Вместо номера страницы клиент получает непрозрачный токен (cursor) и передаёт его в следующем запросе: «дай мне 20 записей после этой точки».
| Запрос | Что вернёт |
|---|---|
GET /orders?size=20 | первые 20 записей и курсор на следующую порцию |
GET /orders?size=20&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTAzLTE0VDEwOjIyOjQxWiIsImlkIjoyMjR9 | следующие 20 после записи, на которую указывает курсор |
Клиент не знает, что внутри cursor, и знать не должен: со своей стороны это просто строка, которую вернул сервер. Конструировать cursor самостоятельно не нужно — берёте значение nextCursor из ответа и подставляете в следующий запрос.
{
"content": [...],
"size": 20,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAzLTE0VDA5OjE1OjAyWiIsImlkIjoyMDR9",
"prevCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAzLTE0VDEwOjIyOjQxWiIsImlkIjoyMjR9",
"hasNext": true,
"hasPrev": true
}
Что класть в курсор: одного поля мало
Кажется, что достаточно запомнить идентификатор последней отданной записи — {"id": 224}. Но лента отсортирована по дате, и даты повторяются: два заказа, оформленных в одну и ту же секунду, для базы неразличимы. Сервер говорит «дай всё, что раньше 14 марта 10:22:41» — и либо теряет соседа с той же датой, либо отдаёт его второй раз. Это тот самый дубль, от которого мы и уходили с offset.
Поэтому в курсор кладут пару: значение поля сортировки и идентификатор — {"createdAt": "...", "id": 224}. Идентификатор тут работает как разрешение спора при одинаковых датах. Та же пара должна стоять и в сортировке запроса, иначе порядок опять поплывёт:
WHERE (created_at, id) < (:cursorCreatedAt, :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 20
Правило: сколько полей в ORDER BY, столько же и в курсоре — до последнего, которое гарантированно уникально.
Base64 не защищает
Часто пишут, что курсор «непрозрачный, потому что Base64». Это неправда: Base64 — не шифрование, а способ записи, и любой браузер раскодирует такую строку одной строчкой кода. Если внутри лежит просто {"id": 224}, клиент может подставить чужой идентификатор и пролистать то, что ему не показывали.
Для внутреннего API этого обычно и не нужно бояться — курсор всё равно ограничен теми же правами, что и сам запрос. А для публичного API курсор подписывают (HMAC от содержимого) или шифруют, и при разборе проверяют подпись: подделали — 400. «Непрозрачный» означает «клиент не имеет права на него полагаться», а не «клиент не сможет его прочитать».
Когда использовать: данные часто меняются (лента сообщений, уведомления), нужна бесконечная прокрутка, большие объёмы данных.
Ограничения: нельзя перейти сразу на страницу 7 и нельзя узнать общее количество записей без отдельного запроса.
Потолок размера страницы
Параметр размера приходит от клиента, и это значит, что ?size=100000 придёт обязательно — случайно или намеренно. Сервер обязан его обрезать, а не выполнять.
Три числа, которые должны быть в контракте и в коде:
Значение по умолчанию — сколько отдаём, если параметр не передан. Обычно 20–50: столько влезает на экран, и такой ответ дёшев.
Максимум — жёсткий потолок, выше которого сервер не отдаёт, сколько ни проси. Для обычных списков это 100–200; для служебных выгрузок отдельная ручка со своим потолком. Важно, что при size=1000 сервер не отвечает ошибкой, а отдаёт максимум — и говорит об этом в ответе (в метаданных страницы видно, сколько реально вернулось).
Минимум. size=0 — отдельный случай: в Spring Data он даёт ошибку, а в самописной реализации может превратиться в LIMIT 0 (пустой ответ) или в LIMIT -1 (все строки). Значение меньше единицы приводят к единице.
@GetMapping("/orders")
public Page<OrderRow> list(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) { … }
В Spring Boot потолок задаётся и глобально — spring.data.web.pageable.max-page-size, — и это правильное место: тогда он действует на все ручки, а не на те, где про него вспомнили.
Почему это не мелочь: один запрос с большим размером страницы поднимает в память десятки тысяч объектов, собирает из них JSON на сотни мегабайт и отправляет — то есть один клиент одним запросом может занять всю память сервиса. Это отказ в обслуживании без всякого злого умысла.
Сортировка: только по белому списку
Имя поля, по которому сортируют, приходит от клиента и попадает в запрос к базе. Это единственное место в обычном API, где параметр превращается не в значение, а в часть структуры запроса, — и потому требует явной проверки.
Две опасности. Первая: сортировка по полю, которого нет в контракте. В Spring Data Sort пробрасывается в запрос как есть, и клиент может отсортировать по внутреннему полю (internalComment, costPrice) — а по порядку выдачи иногда можно восстановить значения, которых в ответе нет. Вторая: сортировка по неиндексированному полю на большой таблице — это сортировка всего набора, то есть способ положить базу одним запросом.
Лечение — белый список с сопоставлением «имя в API → поле в модели»:
private static final Map<String, String> SORTABLE = Map.of(
"createdAt", "createdAt",
"total", "totalAmount",
"status", "status");
private Sort toSort(String sortParam) {
var parts = sortParam.split(",", 2);
String field = SORTABLE.get(parts[0]);
if (field == null) throw new BadSortFieldException(parts[0], SORTABLE.keySet());
var direction = parts.length > 1 && "desc".equalsIgnoreCase(parts[1])
? Sort.Direction.DESC : Sort.Direction.ASC;
return Sort.by(direction, field);
}
Заодно это развязывает контракт и модель: переименовали поле в сущности — сопоставление поправили, а клиенты не заметили.
Чем платит offset на глубине
«Запрос замедляется» — слишком мягкая формулировка. LIMIT 20 OFFSET 100000 в PostgreSQL означает: база находит подходящие строки, сортирует их, читает и выбрасывает сто тысяч и только потом отдаёт двадцать. Работа пропорциональна номеру страницы, а не размеру страницы.
Сотая тысяча строк в ленте: сверху смещение, снизу курсор, и смотреть надо не на то, сколько строк отдали, а на то, сколько прочитали.
Отсюда картина, узнаваемая по графикам: первые страницы отдаются за миллисекунды, сотая — за секунду, тысячная — за десять. И это не «много данных», а именно выбрасывание: тот же запрос с условием по последней увиденной строке отдаёт двадцать строк за те же миллисекунды на любой глубине.
Второй эффект — сдвиг страниц. Между запросами первой и второй страницы кто-то добавил запись; при постраничном смещении одна строка уедет с первой страницы на вторую, и пользователь увидит её дважды (или не увидит вовсе). Курсорная пагинация от этого свободна, потому что опирается на значение, а не на номер.
Можно ли оба способа сразу
Можно, и это частое решение — потому что у способов разные потребители.
Курсор отдают публичному API и бесконечной прокрутке в приложении: там никто не прыгает на сотую страницу, зато важно, чтобы пролистывание работало на любой глубине и не дублировало записи.
Смещение оставляют административным таблицам и отчётам: там нужны номера страниц, переход на произвольную страницу и общее число записей — и там объёмы такие, что глубина не страшна.
Как это выглядит в контракте: два разных набора параметров у одной ручки (?cursor=…&limit=… против ?page=…&size=…), причём взаимоисключающих — передали оба, сервер отвечает ошибкой. Или, что чище, разные ручки: /orders с курсором для клиентов и /admin/orders со смещением для панели. Второй вариант лучше тем, что у административной ручки свои права, свои потолки и свой уровень нагрузки.
Чего делать не стоит — отдавать общее число записей вместе с курсорной пагинацией: подсчёт COUNT(*) по большому набору стоит столько же, сколько сама выборка, и обнуляет выигрыш курсора. Если число нужно приблизительно, его берут из статистики базы; если точно — это осознанно дорогая операция.
Цена POST /search
У переноса фильтра в тело есть три потери, и о них стоит знать до того, как переносить.
Не кэшируется. GET с параметрами кэшируется браузером, прокси и сетью доставки; POST — нет, потому что по протоколу считается изменяющим. Значит, одинаковые запросы будут ходить до сервиса каждый раз. Обходной путь — отвечать с явными заголовками кэширования и кэшировать на своей стороне по отпечатку тела, но «бесплатного» кэша не будет.
Не ложится в закладку и в ссылку. Поисковую выдачу нельзя переслать коллеге, нельзя открыть в новой вкладке, нельзя восстановить из истории браузера. Для интерфейса это иногда решающий довод: тогда фильтр держат в адресной строке, а в тело уходит только то, что не влезло.
Плохо видно в журналах и в мониторинге. Путь в журнале один и тот же (POST /orders/search), а параметры — в теле, которое обычно не логируют (и правильно: там могут быть персональные данные). Значит, «какой именно запрос был медленным» становится отдельной задачей: приходится логировать отпечаток фильтра или ключевые поля отдельно.
Отсюда практическое правило: GET с параметрами — пока влезает и читается; POST /search — когда фильтр действительно сложный, и вместе с ним сразу решают, что делать с кэшированием и с журналами.
Пустой результат — это 200
Коллекция, по которой ничего не нашлось, отвечает 200 и пустым массивом. Не 404: ресурс-коллекция существует, он просто пуст сейчас.
404 для коллекции означает «такого списка нет вовсе» — например, GET /customers/999/orders, где покупателя 999 не существует. Вот это различие и полезно соблюдать: пустой список — 200, несуществующий владелец списка — 404. Тогда клиент по коду понимает, показать «ничего не найдено» или «такого клиента нет».
И про метаданные: в пустом ответе они остаются на месте — total: 0, hasNext: false. Клиенту не нужно угадывать структуру по наличию элементов.
Сортировка
Параметр sort принимает имя поля и направление через запятую:
GET /orders?sort=createdAt,desc
GET /orders?sort=totalAmount,asc
Если нужна многоуровневая сортировка, параметр повторяют:
GET /orders?sort=totalAmount,asc&sort=createdAt,desc
Многоуровневую сортировку стоит применять осторожно: составные индексы в базе данных должны соответствовать порядку полей.
Полнотекстовый поиск
Для свободного текстового поиска используют параметр q:
GET /products?q=клавиатура
GET /orders?q=Иванов
Один параметр, никакой магии. Если поиск сложный — смотрите раздел ниже.
q и фильтры решают разные задачи и работают вместе: фильтр отбирает по точному значению поля, q ищет по тексту сразу в нескольких полях и сам решает, что считать совпадением. Запрос GET /orders?q=Иванов&status=PAID сначала сужает выборку по статусу, а текстом ищет уже внутри неё.
Пустой q это не ошибка: параметр без значения означает «текстом не ищем», и сервер отдаёт ту же выборку, что и без него. Отвечать на ?q= четырёхсотым не стоит, иначе очистка поля поиска в интерфейсе превращается в ошибку.
Несколько значений одного фильтра
Чтобы передать массив значений, параметр просто повторяют:
GET /orders?status=CREATED&status=CONFIRMED&status=PAID ✓
GET /orders?status=CREATED,CONFIRMED,PAID ✗
Сразу снимем частое заблуждение: «через запятую придётся разбирать руками» — неправда, по крайней мере в Spring. Метод с параметром @RequestParam List<String> status примет оба варианта: повтор параметра сложится в список сам собой, а строку CREATED,CONFIRMED разрежет по запятым штатный преобразователь типов. Никакого своего кода писать не нужно ни там, ни там.
Причина выбрать повтор другая — запятая живёт внутри значений. Со статусами это не видно, потому что там латиница без знаков препинания. А вот ?q=Иванов, Пётр или фильтр по названию товара «Клавиатура, механическая» сломаются молча: сервер послушно разрежет одно значение на два и вернёт пустой список. Отличить разделитель от запятой в данных нечем — придётся придумывать экранирование, а его надо ещё описать клиентам.
Вторая причина — описание в OpenAPI. Повтор параметра — это explode: true, одно поведение для всех инструментов. Запятая — это explode: false, и генераторы клиентов раскладывают её по-разному.
В OpenAPI это описывается так:
parameters:
- name: status
in: query
schema:
type: array
items:
type: string
style: form
explode: true
Когда GET не хватает: POST /search
У GET-запроса есть физические ограничения. Длинный URL никто не «режет» посередине — запрос просто не проходит целиком: сервер отвечает 414 URI Too Long, и вы даже не узнаете, какой именно фильтр не поместился.
Где проходит граница, зависит от того, кто первым скажет «хватит». В Spring Boot это Tomcat с настройкой server.max-http-request-header-size — по умолчанию 8 КБ, причём не на один URL, а на всю строку запроса вместе с заголовками: авторизация, cookie, трассировка тоже занимают место. У прокси перед приложением свой лимит (у nginx это large_client_header_buffers), и он может быть меньше. Плюс старые браузеры не отправляли адрес длиннее ~2000 символов. Практический вывод: рассчитывать больше чем на пару тысяч символов нельзя.
Отдельно — в query-строке нельзя передать вложенные объекты.
Когда запрос слишком сложный для URL, используют POST /resources/search с JSON-телом:
живой пример
POST /api/v1/orders/search
Content-Type: application/json
{
"statuses": ["CONFIRMED", "PAID", "SHIPPED"],
"dateRange": { "from": "2026-01-01", "to": "2026-12-31" },
"customer": { "regionIds": [1, 5, 12], "segment": "VIP" },
"totalAmount": { "from": 1000, "to": 50000 },
"sort": [
{ "field": "createdAt", "direction": "DESC" },
{ "field": "totalAmount", "direction": "ASC" }
],
"page": 1,
"size": 20
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Когда переходить на POST:
- нужны вложенные объекты в фильтре;
- массив из 10 и более значений;
- комбинации AND/OR;
- запрос нужно сохранять и переиспользовать.
Правила для POST-поиска:
- URL:
/resources/search— не/query, не/find. - Код ответа:
200 OK, ресурс не создаётся. - Формат ответа такой же, как у
GET /resources— тот же пагинированный список.
Частые ошибки
Про page=0, запятую в значениях и разбор cursor сказано выше, здесь один случай, которого выше не было. Бизнес-действие в query. ?action=cancel — это не фильтр, а команда, и фильтром она притворяется зря: GET с побочным эффектом повторит прокси или предзагрузка браузера. Команды идут через отдельный endpoint: POST /orders/{id}/cancel.
Глубже: пределы и значения по умолчанию как часть контрактарасширенное
Всё, что не ограничено, однажды придёт максимальным. Клиент попросит size=100000, пришлёт фильтр с десятью тысячами идентификаторов, строку в мегабайт в поле имени и диапазон дат в десять лет, и не по злому умыслу, а потому что так получилось в цикле. Пределы это часть контракта, а не деталь реализации, и записывают их в спецификацию.
Страница. Размер по умолчанию (20) и максимум (100): значение выше максимума отвечает 400 с указанием предела, а не молча обрезается, иначе клиент считает, что получил всё. В OpenAPI это default и maximum у параметра, и из них генерируется проверка.
Списки в параметрах. Фильтр id=1,2,3 ограничивают числом элементов (сто, редко тысяча), потому что за ним стоит IN (...) и план запроса. Длиннее это уже POST /search с телом или пакетная операция.
Диапазоны. Отчёт за произвольный период ограничивают шириной окна (год, квартал), а запрос без нижней границы получает её по умолчанию; иначе один запрос читает всю историю таблицы.
Строки и тело. У каждого строкового поля maxLength в схеме, и не только для валидации, но и потому, что колонка в базе конечна; у массивов в теле maxItems; у тела запроса предел размера на прокси и в приложении, о чём говорит статья про валидацию на границе; у файлов свой предел, о чём статья про загрузку.
Время. У любой операции есть предельное время ответа, и операция, которая в него не укладывается, становится асинхронной с 202 и ресурсом статуса, а не «просто долгим запросом», который убьёт балансировщик по своему таймауту.
Частота. Лимит запросов и заголовки остатка это тоже пределы контракта, и они записаны рядом с остальными.
Проверяют пределы на границе и отвечают 400 с полем и допустимым значением в теле ошибки, чтобы клиент исправил запрос, а не гадал. И умолчания записывают явно: «сортировка по умолчанию по createdAt убыванию» это обещание, и его смена ломает клиентов не хуже удалённого поля.
Коротко
- Имена параметров держат в одном стиле с полями ответа (camelCase), иначе клиент помнит два словаря и промахивается молча: неизвестный параметр сервер просто игнорирует.
- Дата в параметре и момент времени в колонке не сравниваются в лоб: верхнюю границу берут строго меньше следующих суток, иначе последний день диапазона выпадает из выборки.
- Номера страниц и стабильность ленты это взаимоисключающие требования: смещение даёт прыжок на любую страницу и общее число, курсор даёт отсутствие дублей на любой глубине, и выбирать придётся.
- Смещение платит не скоростью вообще, а работой, пропорциональной глубине: на сотой тысяче база читает сто тысяч двадцать строк и сто тысяч из них выбрасывает.
- В курсор кладут пару «поле сортировки плюс идентификатор», и ровно та же пара стоит в
ORDER BY; Base64 это способ записи, а не защита, поэтому публичный курсор подписывают. - Всё, что от клиента попадает не в значение, а в структуру запроса, проверяют по белому списку: имя поля сортировки уходит в
ORDER BY, размер страницы вLIMIT. - Несколько значений передают повтором параметра, а не запятой: запятая встречается внутри самих данных, и сервер молча разрежет одно значение на два.
POST /searchпокупает сложный фильтр ценой кэша, ссылки на выдачу и видимости в журналах, поэтому на него переходят, когда фильтр действительно не влезает в адрес.- Пределы и умолчания это часть контракта: размер страницы, длина списка в фильтре, ширина диапазона дат и время ответа записаны в спецификацию, а превышение отвечает
400с указанием предела.
Что почитать дальше
- URL и структура ресурсов — как строить пути.
- JSON и формат ответов — структура пагинированного ответа.
- Ошибки и RFC 9457 — что отвечать, когда размер страницы выше предела, а поле сортировки не из белого списка.