Пагинация: offset vs cursor
Зачем пагинация
Проблема: Model::all() — миллион.
Что даёт:
- Меньше данных.
- Быстрее ответ.
- Меньше памяти.
- UI — страницы / бесконечная.
Правило: любая выборка — пагинация.
Offset pagination
Классика:
$posts = Post::paginate(20);
Что даёт:
- Параметр
?page=2. - Метаданные total, per_page.
- Ссылки в Blade.
Правило: paginate — по умолчанию.
Simple paginate
$posts = Post::simplePaginate(20);
Что даёт:
- Без total count.
- Быстрее (нет COUNT).
- Только next/prev.
Правило: simplePaginate — когда total не нужен.
Проблема offset
Что происходит на page=10000:
SELECT * FROM posts LIMIT 20 OFFSET 200000;
БД должна:
- Прочитать 200 000 строк.
- Пропустить их.
- Вернуть 20.
Что даёт: O(N) — медленно.
Правило: offset > 1000 — проблема.
Cursor pagination
Что это: курсор по id.
$posts = Post::cursorPaginate(20);
Что генерирует:
SELECT * FROM posts
WHERE id > 200000
ORDER BY id
LIMIT 20;
Что даёт: O(log N) — быстро.
Правило: cursor — для больших коллекций.
Cursor: параметры
URL:
/posts?cursor=eyJpZCI6MjAwMDAwfQ
Что важно: курсор — base64 от ID.
Правило: нельзя ?page=2 с cursor.
Cursor: сортировка
Post::orderBy('created_at', 'desc')->cursorPaginate(20);
⚠️ Нужна колонка сортировки с индексом.
Правило: уникальная колонка или комбинация.
Cursor: с фильтрами
Post::where('category_id', $id)
->orderBy('id')
->cursorPaginate(20);
Что даёт: фильтр + cursor.
Правило: фильтр должен быть в индексе.
Бесконечная прокрутка
Frontend:
let cursor = null;
async function loadMore() {
const url = cursor ? `/api/posts?cursor=${cursor}` : '/api/posts';
const res = await fetch(url);
const data = await res.json();
render(data.data);
cursor = data.next_cursor;
}
Что даёт: классический infinite scroll.
Правило: cursor — идеально для infinite.
Offset vs Cursor
| offset | cursor | |
|---|---|---|
| Скорость | O(N) | O(log N) |
| Total count | Есть | Нет |
| Страницы в UI | Да | Нет |
| Infinite scroll | Плохо | Отлично |
| Прыгать по страницам | Да | Нет |
| Стабильность | Дрейф | Стабильно |
Правило:
- Админка — offset.
- API — cursor.
- Infinite — cursor.
Дрейф offset
Проблема: на page=2 вставили 5 записей → page=1 показал их дважды.
Что даёт: дубли в пагинации.
Cursor решает.
Правило: публичный API — cursor.
Total count
Проблема: COUNT(*) на большой таблице — медленно.
Что делать:
simplePaginateбез total.- Кэш total на 5 минут.
- Примерная оценка.
Правило: total — кэшировать или не считать.
Кэш total
$total = Cache::remember('posts:total', 300, fn () => Post::count());
Что даёт: COUNT раз в 5 мин.
Правило: total — кэш обязателен.
Limit в API
Правило:
$perPage = min($request->integer('per_page', 20), 100);
Что даёт: клиент не запросит 10 000.
Правило: максимум per_page.
Ключи в JSON
Формат ответа:
{
"data": [...],
"links": {
"first": "...",
"last": "...",
"prev": null,
"next": "..."
},
"meta": {
"current_page": 1,
"total": 1234,
"per_page": 20
}
}
Правило: стандарт Laravel — не ломать.
Ресурсы и пагинация
return PostResource::collection(Post::paginate(20));
Что даёт: paginated коллекция в ресурсе.
Правило: ресурсы работают с paginated.
Cursor + ресурсы
return PostResource::collection(Post::cursorPaginate(20));
Что даёт: cursor-структура в JSON.
Offset + фильтры
Проблема: фильтры раздувают COUNT.
Что делать:
simplePaginate.- Кэш с ключом по фильтру.
- Cursor.
Правило: сложные фильтры — cursor.
Индексы для пагинации
Что нужно:
- Колонка в
orderBy. - Колонка в
where. - Композитный индекс.
Пример:
CREATE INDEX idx_posts_category_id ON posts (category_id, id);
Правило: (filter_column, order_column) — композит.
Cursor по двум колонкам
Post::orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->cursorPaginate(20);
Что даёт: стабильная сортировка.
Правило: если created_at не уникален — + id.
Laravel 11+ cursor
Что нового:
cursorPaginate— основной.Cursorв JSON.next_cursorстабильно.
Правило: использовать нативный cursor.
Пагинация в GraphQL
Если GraphQL:
- Relay cursor-подход.
edges+pageInfo.
Правило: GraphQL — всегда cursor.
Практика
1. Замените paginate на simplePaginate где total не нужен.
2. Добавьте cursorPaginate в 3 API-роута.
3. Настройте per_page максимум.
4. Кэшируйте total.
5. Добавьте композитные индексы.
6. Проверьте дрейф offset.
7. Перейдите на cursor в infinite scroll.
8. Замерьте page=10000 offset vs cursor.
9. Обновите фронт под cursor.
10. Прогоните тесты.
Частые ошибки
1. paginate с COUNT на 10M:
Медленно
Решение: simplePaginate / кэш.
2. page=10000 offset:
Секунды
Решение: cursor.
3. per_page=100000:
OOM
Решение: min(100).
4. Cursor без orderBy:
Ошибка
Решение: явно сортировать.
5. Cursor по не уникальной:
Дрейф
Решение: + id.
6. Нет индекса под фильтр:
Seq Scan
Решение: композит.
7. total без кэша:
COUNT каждый раз
Решение: кэш.
8. Смена схемы в API:
Ломает клиентов
Решение: стандарт Laravel.
Итог
paginate— с total.simplePaginate— без.cursorPaginate— для больших.- Offset — O(N).
- Cursor — O(log N).
- Infinite — cursor.
- Админка — offset.
- Индексы — обязательны.
per_page— максимум.- Total — кэш.
Дальше — кэш на уровне БД: материализованные представления.