Senior: питання на співбесіді з теми «Пагінація»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
2 питання
Laravel дає три способи гортати вибірку, і вони по-різному поводяться на великих таблицях.
paginate() - класична пагінація з номерами сторінок:
$posts = Post::latest()->paginate(15);
Виконує два запити: сам вибір і count(*) для загальної кількості сторінок.
simplePaginate() - те саме без підрахунку загальної кількості, лише «далі/назад». Прибирає дорогий count(*) на великій таблиці.
cursorPaginate() - гортання за значенням ключа, без offset:
$posts = Post::latest()->cursorPaginate(15);
Чому offset повільний. limit 15 offset 100000 не перестрибує рядки - база читає й відкидає сто тисяч, перш ніж віддати п'ятнадцять. Що глибша сторінка, то повільніше; на останніх сторінках великого списку це секунди.
Курсорна пагінація натомість запитує «наступні 15 після цього значення»:
where (published_at, id) < (?, ?) order by published_at desc, id desc limit 15
Час не залежить від глибини - за умови, що є індекс на колонках сортування.
Друга перевага - стабільність. З offset новий запис, доданий між переглядами сторінок, зсуває всю вибірку, і один запис читач побачить двічі, а інший пропустить. Курсор до цього стійкий.
Ціна курсора: немає номерів сторінок і переходу «на сторінку 7» - лише вперед і назад. Сортування має бути за унікальною чи доповненою id комбінацією, інакше рядки з однаковим значенням загубляться.
Що обирати: адмінка з номерами сторінок - paginate(); довга стрічка чи API - cursorPaginate(); проміжний варіант, де потрібні лише «далі/назад» - simplePaginate().
paginate() має порахувати, скільки рядків поверне запит. Для простого запиту це select count(*) from ..., але зі складними запитами виникають дві проблеми.
1. GROUP BY чи HAVING. count(*) над запитом з групуванням повертає кількість у кожній групі, а не кількість груп. Laravel це враховує: якщо в запиті є groupBy чи having, він обгортає запит у підзапит:
select count(*) as aggregate from (
select customer_id, sum(total) from orders group by customer_id
) as aggregate_table
Результат правильний, але база виконує весь запит з групуванням лише для підрахунку - на великих даних це повільно.
2. DISTINCT, об'єднання з дублями, UNION - підрахунок або дорогий, або без уважності дає неправильне число.
Варіанти розв'язання:
Не рахувати взагалі - simplePaginate() чи cursorPaginate(). Для більшості стрічок і звітів кнопок «далі/назад» достатньо.
Передати кількість самому - paginate() приймає параметр total:
$total = Cache::remember('reports:customers:count', 300, fn () => DB::table('orders')->distinct()->count('customer_id'));
$customers = DB::table('orders')
->select('customer_id', DB::raw('sum(total) as revenue'))
->groupBy('customer_id')
->orderByDesc('revenue')
->paginate(perPage: 50, total: $total);
Кількість груп можна порахувати простішим запитом (count(distinct customer_id)), закешувати чи взяти з денормалізованого лічильника.
Приблизна кількість: для «приблизно 1,2 млн результатів» - оцінка з EXPLAIN чи статистики таблиці (reltuples у PostgreSQL) замість точного count.
Перенести групування в окрему таблицю: для звітів, які відкривають часто, - матеріалізоване подання чи таблиця агрегатів, що оновлюється за розкладом. Тоді пагінація йде по простій таблиці з індексом.
Обмежити глибину: пошукові системи показують лише перші сотні результатів. Обмеження на кількість сторінок прибирає і дорогий підрахунок, і повільні далекі OFFSET.
Пастки з Eloquent:
paginate()зselectіgroupByвимагає, щоб усі неагреговані колонки були вGROUP BY(PostgreSQL і MySQL зONLY_FULL_GROUP_BY);withCountіhavingразом з пагінацією - перевіряйте згенерований SQL черезtoRawSql();- порядок має бути детермінованим (
orderBy('revenue')->orderBy('customer_id')), інакше записи з однаковим значенням перескакують між сторінками.