Перейти к содержимому
DedicatedPHP Контакт

Как спроектировать PHP API со стабильной пагинацией при изменении данных

Узнайте, как выбрать между offset и cursor, задать детерминированный порядок и обрабатывать вставки, удаления и фильтры, чтобы избежать дубликатов и пропусков.

Схема PHP API, которое продолжает выдачу страниц результатов с помощью курсора и детерминированного порядка

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

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

Почему результаты меняются между страницами

Почему результаты меняются между страницами — guía visual de DedicatedPHP

Предположим, запрос сортирует записи по дате в порядке убывания. Клиент получает первые 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 централизуйте кодирование, проверку и подпись, чтобы не дублировать правила в контроллерах.

Определите гарантии согласованности при параллельных изменениях

При обходе актуальных данных каждая страница запрашивает состояние, доступное в этот момент. Курсор, основанный на неизменяемом порядке сортировки, предотвращает многие смещения, вызванные вставками перед достигнутой точкой. Но он не создаёт снимок данных: после курсора могут появиться новые строки, ещё не просмотренные строки могут быть удалены, а применимые разрешения и фильтры могут измениться. Документируйте это поведение, чтобы клиент не принимал такой обход за завершённый экспорт.

Если продукту нужен набор, ограниченный заданной границей, можно зафиксировать её, например дату или максимальный идентификатор, и добавлять в каждый запрос. Это исключает вставки после заданной границы, если выбранный критерий позволяет это сделать, но не сохраняет удалённые строки и не гарантирует идеальный снимок при изменениях. Другой вариант — транзакционный снимок; сохранение открытой транзакции между запросами обычно имеет операционные последствия и требует ресурсов, поэтому его не следует считать решением по умолчанию.

Контракт может чётко определять ограничения: поддерживаемый порядок сортировки, фильтры, которые должны оставаться неизменными, срок действия, если он есть, поведение при некорректном курсоре и возможность изменения набора при параллельных изменениях. Не обещайте полное отсутствие дубликатов и пропусков, если выбранная стратегия этого не гарантирует.

Проверяйте граничные случаи и документируйте контракт

Проверяйте граничные случаи и документируйте контракт — guía visual de DedicatedPHP

Тесты должны проверять полный обход данных, а не только форму ответа. Подготовьте строки с повторяющимися значениями сортировки и убедитесь, что объединение нескольких страниц даёт ожидаемый порядок без дубликатов. Добавьте случаи, когда размер страницы разделяет группу совпадающих значений, и проверьте сортировку как по возрастанию, так и по убыванию.

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

Для диагностики записывайте метрики длительности запросов, размера страницы и ошибок проверки, не раскрывая содержимое чувствительных курсоров и персональные данные. Если появляются повторы, сначала проверьте полный порядок сортировки и условие продолжения. Если проблема связана со стоимостью запросов, изучите план выполнения и индексы для полей сортировки и условий фильтрации.

Стабильная пагинация зависит не от того, что строка скрыта в Base64, а от детерминированного порядка, согласованных сравнений, контролируемых фильтров и чётких ожиданий относительно параллельных изменений. С учётом этих условий offset и cursor можно выбирать в зависимости от реального сценария обхода, необходимого клиенту.

Хотите применить эти идеи в своем проекте?Давайте обсудим вашу PHP-платформу.
Посмотреть связанные услуги