23 сент. 2025 г.·4 мин. чтения

Стабильная сортировка при пагинации: прекратите перемешивание элементов списков

Узнайте, как обеспечить стабильную сортировку при пагинации: добавлять разрешители равенств, выбирать безопасные ключи сортировки и делать быстрые проверки, чтобы элементы не перемешивались.

Стабильная сортировка при пагинации: прекратите перемешивание элементов списков

Как выглядит «перемешивание страниц списка» в реальных приложениях

Вы открываете список, переходите на страницу 2 и замечаете элемент, который, по вашему мнению, только что был на странице 1. Вы обновляете страницу — и порядок снова меняется. Иногда элемент появляется на обеих страницах. Иногда он пропадает, пока вы не поменяете фильтр.

Это и есть «перемешивание страниц списка». Приложение пагинирует, но порядок сортировки не зафиксирован полностью, поэтому база данных (или API) возвращает записи в чуть другом порядке при повторных запросах.

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

Чаще всего это заметно в админских таблицах, лентах активности, результатах поиска, журналах аудита и каталогах продуктов.

«Стабильно» не означает, что список никогда не меняется. Речь о стабильной сортировке при пагинации: если входные параметры одинаковы (те же фильтры, тот же вариант сортировки, один и тот же снимок данных), вы получаете один и тот же порядок каждый раз.

У стабильного списка три признака:

  • Равенства разрешаются одинаково каждый раз (никакого «случайного» порядка для записей с одинаковым значением сортивочного поля).
  • Границы страниц предсказуемы (элемент либо на странице 1, либо на странице 2, но не на обеих).
  • Обновление не перемешивает элементы, если исходные данные не изменились.

Частая причина — сортировка по полю с дубликатами, например created_at, status или score. Если десять элементов имеют одинаковую метку времени или оценку, база может вернуть эти десять в любом порядке, если вы явно не указали, как разрешать равенства.

Почему пагинация ломается, когда сортировка недетерминирована

Пагинация полагается на простое предположение: выполнить один и тот же запрос дважды — и вы получите одинаковый порядок.

Если порядок не гарантирован, «страница 2» уже не является «страницей 2». Пользователи видят повторы, пропуски или строки, которые как будто прыгают.

Типичная причина — равенства. Запрос сортирует по чему‑то вроде created_at, score или name, и несколько строк имеют одно и то же значение. В таком случае база данных вправе вернуть связанные строки в любом порядке, если вы не добавили явное правило для разрешения равенств. Этот «любой порядок» может меняться между запросами.

Пагинация с OFFSET делает это особенно заметным, потому что смещения считаются по позициям, а не по конкретным строкам. Если позиции меняются, смещения указывают на другую часть списка.

Даже если данные не меняются, равенства могут менять порядок по причинам, которые вы не контролируете: используется другой индекс, план запроса меняется после обновления статистики, или параллельное выполнение возвращает батчи в другой последовательности. Это нормальное поведение, когда вы не просите детерминированный порядок.

Выберите стабильное правило сортировки (основной ключ + разрешитель равенств)

Чтобы остановить перемешивание, нужно правило сортировки, которое никогда не оставляет равенства без решения.

Выберите сортировку, которую ожидают пользователи

Начните с поля, которое соответствует тому, как люди думают.

  • Лента активности: новые сверху.
  • Таблица рекордов: сначала с наивысшим score.
  • Справочник: имена по алфавиту (A→Z).

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

Добавьте разрешитель равенств, который никогда не меняется

Добавьте второй ключ сортировки, который уникален и стабильный. У большинства приложений он уже есть: первичный ключ вроде id. Разрешитель равенств не должен меняться с течением времени и должен быть у каждой строки.

Указывайте направление сортировки для каждого ключа. Если основная сортировка DESC (сначала новые), использование DESC для разрешителя обычно делает поведение интуитивным.

Простое правило, которое можно повторно использовать:

  • Сортируйте сначала по пользовательскому полю.
  • В качестве второго ключа используйте id для разрешения равенств.
  • Явно укажите направление для обоих полей.
  • Применяйте одинаковое правило везде, где возвращаете этот список.

Запишите правило одной фразой, например: «Показывать новые элементы первыми; при совпадении времени — показывать элементы с большим id первыми.» Это предотвращает рассинхронизацию, когда один эндпоинт использует один порядок, а другой — чуть другой.

Пошагово: добавляем разрешитель равенств в запросы

Самый быстрый фикс обычно одинаков: оставьте основную сортировку, затем добавьте уникальный разрешитель равенств.

Начните с точного запроса, который выполняет ваш API. Найдите текущий ORDER BY и спросите: могут ли две строки иметь одинаковые значения для этих полей сортировки? Если да — есть равенство, и база может возвращать связанные строки в разном порядке.

Обычный паттерн для «новые первыми» выглядит так:

SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

Если вы сортируете по неуникальному полю, такому как created_at, price или score, всегда включайте id (или другой уникальный столбец) как последний ключ сортировки.

Ещё один практический момент: проверьте индекс. Для примера выше композитный индекс вроде (status, created_at, id) (направления зависят от СУБД) часто предотвращает медленные сортировки и делает производительность предсказуемой.

Offset vs курсорная пагинация: что меняется для стабильной сортировки

Stop list pages from jumping
Бесплатный аудит для поиска пропущенных разрешителей равенств и нестабильных ORDER BY.

Пагинация с OFFSET — классический подход «page=3»: отсортировать, пропустить первые N строк и взять следующую порцию. Её легко реализовать, но она предполагает, что порядок остаётся стабильным.

Курсорная пагинация — подход «after=item_123»: вместо пропуска строк вы берёте элементы после последнего известного элемента. Она часто работает быстрее и избегает ряда неприятных краёв, но только если порядок сортировки стабильный и уникальный.

Курсорная пагинация явно требует: курсор должен описывать точную позицию в детерминированном порядке. Это обычно означает использование составной сортировки и составного курсора.

Например: сортировка created_at DESC, id DESC, и курсор должен нести оба значения. Если в курсоре хранится только created_at, граница неясна, когда несколько строк имеют одинаковую метку времени.

Практические правила:

  • Всегда включайте уникальный разрешитель равенств в ORDER BY.
  • Делайте курсор соответствующим полному ORDER BY (те же поля, те же направления).
  • Если пользователь меняет фильтры или опцию сортировки — начинайте новый запрос с чистого курсора.

Новые и обновлённые данные: как сохранять согласованность результатов со временем

Даже при идеальном ORDER BY страницы могут казаться нестабильными, если исходный набор данных меняется между запросами. Сортировка детерминирована, но данные движутся.

Новые элементы — классическая проблема для пагинации с OFFSET. Вы загрузили страницу 1 (элементы 1–20), затем приходит новый элемент в начало списка, и ваш следующий запрос для страницы 2 (offset 20) стартует с того, что раньше было элементом 21 — всё смещается. Пользователи видят дубли или пропуски.

Правки могут быть ещё хуже. Если поле сортировки меняется (например, updated_at), существующая строка может прыгнуть между страницами.

Решение начинается с продуктового решения: должна ли лента быть «живой», или необходимо сохранить её согласованной в течение сессии?

Если нужна согласованность, заякорьте результаты к точке снимка. Распространённые подходы:

  • Зафиксировать по времени (показывать только элементы с created_at <= времени первого запроса).
  • Зафиксировать по курсору (показывать элементы ниже верхней границы первого курсора).
  • Избегать сортировки лент по updated_at, если пользователи не ожидают именно этого поведения.
  • Показывать баннер «Новые элементы» и позволять пользователю обновлять вручную.

Пример: лента активности сортируется по updated_at DESC. Пользователь открывает страницу 1, затем кто‑то редактирует старую запись, обновляя updated_at и перемещая её в начало. Когда пользователь загружает страницу 2, он видит запись, которую уже читал, и другую запись отсутствует. Фиксация по времени первого загрузки или переход на created_at убирает такую тряску.

Частые ошибки, которые вызывают прыжки элементов между страницами

Большинство багов «элементы перемещаются» — это ошибки сортировки.

Типичные виновники:

  • Сортировка только по неуникальному полю вроде created_at, status или name без разрешителя равенств.
  • Использование случайного порядка (или изменяющегося score) и попытка обращаться с ним как со стабильным списком.
  • Пагинация в SQL с последующей сортировкой в коде приложения.
  • Разные эндпоинты используют разные настройки сортировки для одного и того же списка.
  • Забыли указать, куда ставить NULL-значения.

Тонкие версии той же проблемы проявляются, когда метка в UI не соответствует реальной сортировке (например, показывают «Последние обновления», но сортируют по «created_at»). Пользователи воспринимают это как перемешивание, потому что список не ведёт себя так, как указано на экране.

Быстрая проверка перед релизом

Make cursor pagination reliable
Мы согласуем поля курсора с детерминированной сортировкой.

Эти баги часто проходят локально, потому что на маленькой dev‑базе всё выглядит нормально, а затем ломается с реальным объёмом и реальными правками.

Короткий чеклист:

  • Завершайте порядок уникальным полем (часто id), чтобы две строки никогда не были полностью равны.
  • Указывайте ASC/DESC для каждого поля в ORDER BY.
  • Применяйте сортировку в запросе к базе до LIMIT/OFFSET (или до фильтра курсора), а не после выборки.
  • Если вы используете курсорную пагинацию, включайте в курсор все поля сортировки.
  • Убедитесь, что у вас есть индекс, соответствующий обычным фильтрам и сортировке.

Быстрый тест реальности: загрузите страницу 1 и страницу 2, затем вставьте новую строку, которая равна по основному полю сортировки (та же секунда в timestamp, тот же score и т. п.). Обновите. Если элементы поменялись местами или появились на обеих страницах, у вас ещё есть неразрешённые равенства.

Пример: исправление перемешивающейся ленты активности

Команда выкатила ленту активности, сгенерированную AI-инструментом. В тестах всё было нормально, но в продакшене пользователи жалуются: «Я видел элемент на странице 2, обновил — и он переместился на страницу 1.» Команда списывает на кэширование, но истинная проблема — сортировка.

Лента сортировалась только по created_at DESC. В продакшене многие строки имели одинаковую метку времени (батчевые вставки, фоновые задания или низкая точность timestamp). Когда несколько элементов имеют одинаковый created_at, база может вернуть их в любом порядке, и границы страниц начинают «шататься».

До:

SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

После (детерминированно):

SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;

Если вы используете курсорную пагинацию, обновите курсор так, чтобы он нес оба значения. Вместо «последний виденный created_at» храните составной курсор (created_at, id) и выбирайте элементы «меньше» этой пары, сохраняя тот же порядок.

Как тестировать и мониторить стабильность в продакшене

Find hidden pagination bugs
Мы сканируем эндпоинты на дублированные, пропущенные или несогласованные правила сортировки.

Самое простое определение успеха: один и тот же запрос, выполненный дважды подряд, возвращает одни и те же ID элементов в одинаковом порядке (если вы не допускаете появления новых элементов между запросами).

Хорошие тесты:

  • Дважды запросить страницу 1 с теми же параметрами и сравнить возвращённые ID в порядке следования.
  • Запросить страницу 2, затем страницу 1 снова и проверить, что страница 1 не изменилась.
  • Утверждать, что каждый ID появляется не более одного раза на страницах 1 и 2.
  • Проверять, что пара (sort_field, tie_breaker) строго монотонна.

Когда пользователи жалуются на перемешивание, логируйте всё, что нужно для воспроизведения: фильтры, limit/offset или значения курсора и все поля сортировки (включая разрешитель равенств).

После изменения порядка или индексов следите за задержкой запросов (особенно p95) и slow query log. Если производительность ухудшилась, чаще всего дело в индексации, а не аргумент в пользу отказа от детерминированной сортировки.

Следующие шаги: сделать порядок единым по всему приложению

После того как вы почините один экран, следующая ошибка часто всплывёт в другом месте, потому что логика списков повторяется во многих местах.

Сделайте быстрый аудит всех мест, где вы возвращаете списки: пользовательские экраны, админки, поиск, экспорты и фоновые задания, которые перебирают данные. Затем введите одну общую политику: у каждого списка есть детерминированный ORDER BY, завершающийся уникальным разрешителем равенств, и каждый эндпоинт её использует.

Если вы наследовали кодовую базу, сгенерированную AI, где запросы копировались и правились для каждого экрана, фокусированный аудит правил сортировки даст быстрый эффект. Команды иногда обращаются с такими проблемами в FixMyMess (fixmymess.ai), когда ленты и админки продолжают перемешиваться в продакшене — обычно это из‑за отсутствия разрешителей равенств и несогласованных правил сортировки между эндпоинтами.