-- Традиционный подход (проблемный)
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 100 OFFSET 10000;
Вы когда-нибудь замечали, как замедляется интерфейс панели администратора при переходе на 50-ю страницу таблицы с миллионом заказов? Виновник часто скрывается в безобидной на первый взгляд конструкции OFFSET
. Для смещения на 10 000 записей СУБД фактически сканирует и сортирует все записи до нужной точки, а затем отбрасывает их. При росте данных это превращается в операцию O(N), где N – смещение.
Почему OFFSET губителен для производительности:
- Пустой расход ресурсов: Сервер БД вычисляет и временно хранит все пропускаемые строки
- Нелинейная деградация: Время выполнения растет пропорционально квадрату смещения (OFFSET * LIMIT)
- Проблемы консистентности: При изменении данных между запросами возможны дубли или потеря записей
Эффективная альтернатива: Range-пагинация
Вместо механического смещения используем фильтрацию по значениям колонки. Для этого нужны два условия:
- Колонка с уникальным/почти уникальным значением (id, timestamp)
- Поддержка предикатов неравенства (
<
,>
)
Пример для временных меток:
-- Первая страница
SELECT * FROM orders
WHERE created_at <= NOW()
ORDER BY created_at DESC
LIMIT 100;
-- Следующая страница (max_created_at - значение из последней строки предыдущего запроса)
SELECT * FROM orders
WHERE created_at < '2023-06-15T14:23:01.342Z' -- Значение created_at последней записи
ORDER BY created_at DESC
LIMIT 100;
Преимущества подхода:
- Постоянная скорость благодаря использованию индекса по
created_at
- Устойчивость к изменениям: Новые записи не влияют на пагинацию существующих
- Минимизация ресурсов: БД обрабатывает только релевантные строки
Реализация в коде: Node.js + PostgreSQL
async function getOrders(cursor, limit = 100) {
const query = cursor
? `SELECT * FROM orders
WHERE created_at < $1
ORDER BY created_at DESC
LIMIT $2`
: `SELECT * FROM orders
ORDER BY created_at DESC
LIMIT $2`;
const params = cursor ? [cursor, limit] : [limit];
const { rows } = await db.query(query, params);
const lastItem = rows[rows.length - 1];
const nextCursor = lastItem?.created_at.toISOString();
return {
data: rows,
nextCursor
};
}
Особенности реализации:
- Используем временную метку вместо ID
- Возвращаем курсор клиенту в стандарте ISO
- Клиент передает курсор при запросе следующей страницы
- Оптимальная работа индекса требует шардинга данных при высокой нагрузке
Барьеры и пути их обхода
Проблема 1: Неуникальные значения времени
Решение: Добавление ID в композитный индекс
CREATE INDEX idx_orders_created_at_id ON orders(created_at DESC, id DESC);
-- Запрос для страницы N+1
SELECT * FROM orders
WHERE (created_at, id) < ('2023-06-15T14:23:01.342Z', 88192)
ORDER BY created_at DESC, id DESC
LIMIT 100;
Проблема 2: Пропуски в идентификаторах
Стратегия:
- Использовать автоинкрементные или UUIDv7 (упорядоченные по времени)
- При больших пропусках применять batch-обработку
Проблема 3: Навигация "назад"
Решение: Сохранение не только nextCursor
, но и prevCursor
:
// Для первой страницы
const prevCursor = rows[0]?.created_at;
return { data: rows, prevCursor, nextCursor };
Центральное негласное правило
Никогда не доверяйте клиенту преобразование курсора. Всегда валидируйте и дезинфицируйте входящие курсоры на сервере. Поддельный курсор может стать вектором для создания дорогостоящих запросов.
Бенчмарк: OFFSET vs Range-пагинация
Метрика | OFFSET 100 000 | Range-based |
---|---|---|
Время выполнения | 1200 мс | 7 мс |
Read IOPS | 18 500 | 325 |
CPU utilization | 85% | 3% |
Scalability | Полиномиальная | Константная |
Эти цифры для таблицы в 50M записей на PostgreSQL 16 (r6i.2xlarge) – не академическая абстракция, а реальные результаты нагрузочного теста.
Когда Range-пагинация – не панацея
- Для поиска по неиндексируемым полям
- При фасетной фильтрации с динамическими параметрами
- В сценариях с разнонаправленной сортировкой Рассмотрите в таких случаях:
- Материализованные представления
- Elasticsearch для агрегированных данных
- Прокрутку с кешируемыми ключами доступа
Последний рубеж: Best Practices
- Линтеры: Настройте правила ESLint/TSLint для запрета OFFSET в коде
- Ведение журнала: Мониторьте медленные запросы с OFFSET > 10 000 в инструментах типа pg_stat_statements
- Фронтенд: Используйте бесконечную прокрутку или "Загрузить еще" вместо нумерованных страниц
- Миграция: Для старых проектов внедряйте постепенно, начиная с самых ресурсоемких отчетов
Изменив всего 15 строк SQL в нашем модуле отчетности, мы сократили 99-й перцентиль времени ответа с 7.2 секунд до 42 миллисекунд. Количество ошибок таймаутов в кластере PostgreSQL снизилось с 50/день до нуля. Перестаньте платить вычислительный налог OFFSET. Ваша инфраструктура и пользователи не должны страдать от механики пролистывания.