Ответ с пагинацией может быть корректным в момент выполнения запроса и при этом приводить к несогласованному обходу данных. Если приложение запрашивает страницу, набор данных меняется, а затем запрашивается следующая страница, некоторые записи могут повториться, а другие останутся незамеченными. Это распространённая проблема для списков событий, заказов и записей, которые постоянно пополняются.
Пагинация по курсору в PHP API помогает контролировать такое смещение, но сама по себе не гарантирует неизменный снимок данных. Важно определить, что означает продвижение по списку, какие изменения могут произойти во время обхода и какой контракт требуется клиенту.
Почему результаты меняются между страницами

Предположим, запрос сортирует записи по дате в порядке убывания. Клиент получает первые 20 записей. До того как он запросит следующие, добавляются три новые записи с недавними датами. Если во втором запросе используется OFFSET 20, он начинает с позиции 21 в текущем наборе, а не с позиции 21, которая была при первом запросе. Некоторые записи с первой страницы могут появиться снова.
Возможны и пропуски. Если запись перед смещением удалена, последующие записи сдвинутся на одну позицию, и строка, которую ожидал получить клиент, может оказаться позади. Порядок также не обязательно будет стабильным, если у нескольких строк совпадает дата: без дополнительного критерия база данных не обязана каждый раз возвращать такие записи в одном и том же порядке.
Следует различать две цели: избежать скачков, вызванных изменением позиций, и предоставить точный снимок всего набора. Грамотно определённая пагинация по курсору помогает достичь первой цели. Для второй нужна отдельная стратегия согласованности, которая может быть более затратной и зависеть от базы данных.
Offset или cursor: выбирайте с учётом сценария чтения
Пагинация со смещением, обычно реализуемая с помощью LIMIT и OFFSET, проста и позволяет сразу перейти к известной странице. Она подходит для небольших или относительно статичных наборов, интерфейсов с частыми переходами между страницами и случаев, когда несогласованность во время навигации допустима. На больших наборах значительные смещения могут заставить базу данных просматривать или отбрасывать много строк; фактическая стоимость зависит от СУБД, индексов и запроса.
Пагинация по курсору возвращает ссылку на точку, с которой нужно продолжить, например последнее значение сортировки и уникальный ключ. Следующий запрос ищет записи до или после этой точки, а не пропускает заданное количество строк. Такой подход подходит для последовательного обхода, лент и списков, в которые часто добавляются записи. Однако он не обеспечивает естественного перехода к произвольной странице: клиенту нужно последовательно пройти страницы или использовать другую стратегию.
Необязательно применять один подход во всём API. В административном запросе с нумерованными страницами можно использовать offset, а в ленте событий — cursor. Интерфейс должен отражать гарантии сервера, а не обещать произвольную навигацию и абсолютную стабильность с помощью одного механизма.
Задайте полный порядок до создания курсора
Курсор указывает на позицию только при детерминированном порядке сортировки. Сортировки только по created_at недостаточно, если даты у двух записей совпадают. Добавьте уникальный неизменяемый столбец в качестве дополнительного критерия, например id:
ORDER BY created_at DESC, id DESCТак каждая строка занимает определённую позицию в порядке сортировки. Курсор продолжения должен содержать оба значения. Для того же порядка по убыванию следующий запрос ищет пары, меньшие последней выданной пары:
WHERE created_at < :cursor_date
OR (created_at = :cursor_date AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_sizeДля сортировки по возрастанию сравнения меняются на противоположные. При нескольких критериях условия должны соответствовать полному лексикографическому порядку: сначала сравнивается первое поле, а при равенстве — следующее. Если направления сортировки смешаны — например, дата по убыванию, а идентификатор по возрастанию, — каждое сравнение должно соответствовать направлению своего столбца. Недостаточно просто одновременно инвертировать все операторы.
Для значений сортировки также нужны стабильные правила. Если столбец может содержать NULL, определите порядок таких значений и учитывайте это различие в условии продолжения. Предпочтительно использовать критерии, которые не меняются во время обхода: если изменить дату, определяющую позицию, строка может переместиться с одной стороны курсора на другую.
Сделайте курсор непрозрачным, проверяемым и привязанным к запросу
Курсор можно сериализовать из значений сортировки и закодировать, например, с помощью Base64URL. Непрозрачность означает, что клиенту не нужно интерпретировать курсор или создавать его самостоятельно, а не то, что Base64 обеспечивает защиту. Если изменение значений может повлиять на область запроса, проверяйте формат и подписывайте содержимое с помощью HMAC или применяйте эквивалентный механизм контроля целостности. Не включайте в курсор секреты и ненужные персональные данные.
Перед обращением к базе данных проверяйте типы, ожидаемые поля, версию формата и ограничения размера. Для значений используйте SQL-параметры. Имена столбцов и направления сортировки нельзя напрямую принимать из курсора или запроса: они должны выбираться из списка разрешённых значений на сервере.
Курсор с датой и идентификатором не должен случайно использоваться с другими фильтрами, если это приводит к неверному продолжению. Можно включить каноническое представление значимых фильтров, направление сортировки и, при необходимости, размер страницы, а затем подписать их вместе с точкой продолжения. Если они не совпадают с текущим запросом, верните понятную ошибку, а не продолжайте незаметно с другими параметрами. В PHP централизуйте кодирование, проверку и подпись, чтобы не дублировать правила в контроллерах.
Определите гарантии согласованности при параллельных изменениях
При обходе актуальных данных каждая страница запрашивает состояние, доступное в этот момент. Курсор, основанный на неизменяемом порядке сортировки, предотвращает многие смещения, вызванные вставками перед достигнутой точкой. Но он не создаёт снимок данных: после курсора могут появиться новые строки, ещё не просмотренные строки могут быть удалены, а применимые разрешения и фильтры могут измениться. Документируйте это поведение, чтобы клиент не принимал такой обход за завершённый экспорт.
Если продукту нужен набор, ограниченный заданной границей, можно зафиксировать её, например дату или максимальный идентификатор, и добавлять в каждый запрос. Это исключает вставки после заданной границы, если выбранный критерий позволяет это сделать, но не сохраняет удалённые строки и не гарантирует идеальный снимок при изменениях. Другой вариант — транзакционный снимок; сохранение открытой транзакции между запросами обычно имеет операционные последствия и требует ресурсов, поэтому его не следует считать решением по умолчанию.
Контракт может чётко определять ограничения: поддерживаемый порядок сортировки, фильтры, которые должны оставаться неизменными, срок действия, если он есть, поведение при некорректном курсоре и возможность изменения набора при параллельных изменениях. Не обещайте полное отсутствие дубликатов и пропусков, если выбранная стратегия этого не гарантирует.
Проверяйте граничные случаи и документируйте контракт

Тесты должны проверять полный обход данных, а не только форму ответа. Подготовьте строки с повторяющимися значениями сортировки и убедитесь, что объединение нескольких страниц даёт ожидаемый порядок без дубликатов. Добавьте случаи, когда размер страницы разделяет группу совпадающих значений, и проверьте сортировку как по возрастанию, так и по убыванию.
- Вставляйте записи до и после курсора между двумя запросами и проверяйте согласованное поведение.
- Удалите ещё не просмотренную запись и измените столбец сортировки, если модель это допускает; задокументируйте последствия.
- Меняйте фильтр, порядок сортировки или направление обхода и проверяйте, что несовместимый курсор отклоняется.
- Отправляйте некорректные, изменённые и слишком большие курсоры, а также курсоры со значениями неверных типов.
- Проверяйте ограничения размера страницы и случай отсутствия результатов, в том числе отсутствие следующей страницы.
Для диагностики записывайте метрики длительности запросов, размера страницы и ошибок проверки, не раскрывая содержимое чувствительных курсоров и персональные данные. Если появляются повторы, сначала проверьте полный порядок сортировки и условие продолжения. Если проблема связана со стоимостью запросов, изучите план выполнения и индексы для полей сортировки и условий фильтрации.
Стабильная пагинация зависит не от того, что строка скрыта в Base64, а от детерминированного порядка, согласованных сравнений, контролируемых фильтров и чётких ожиданий относительно параллельных изменений. С учётом этих условий offset и cursor можно выбирать в зависимости от реального сценария обхода, необходимого клиенту.



