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

Как выглядит «перемешивание страниц списка» в реальных приложениях
Вы открываете список, переходите на страницу 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 курсорная пагинация: что меняется для стабильной сортировки
Пагинация с 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»). Пользователи воспринимают это как перемешивание, потому что список не ведёт себя так, как указано на экране.
Быстрая проверка перед релизом
Эти баги часто проходят локально, потому что на маленькой 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) и выбирайте элементы «меньше» этой пары, сохраняя тот же порядок.
Как тестировать и мониторить стабильность в продакшене
Самое простое определение успеха: один и тот же запрос, выполненный дважды подряд, возвращает одни и те же 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), когда ленты и админки продолжают перемешиваться в продакшене — обычно это из‑за отсутствия разрешителей равенств и несогласованных правил сортировки между эндпоинтами.