Питання на співбесіді: Продуктивність запитів
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
16 питань
EXPLAIN показує, як база збирається виконати запит: у якому порядку читати таблиці, чи використовувати індекси, як з'єднувати. Сам запит при цьому не виконується.
EXPLAIN SELECT * FROM orders WHERE user_id = 5;
Index Scan using orders_user_id_idx on orders (cost=0.43..8.45 rows=3 width=64)
Index Cond: (user_id = 5)
Що шукати новачку:
Seq Scan(PostgreSQL) /type: ALL(MySQL) на великій таблиці - повний перегляд таблиці. Часто ознака відсутнього індексу.Index Scan/Index Only Scan(PostgreSQL),type: ref,range(MySQL) - використано індекс.rows- скільки рядків база очікує. Якщо оцінка сильно відрізняється від реальності, план може бути поганим.Sortз великою кількістю рядків чиUsing filesortу MySQL - сортування, яке не взяли з індексу.
cost - умовні одиниці вартості, не мілісекунди. Перше число - вартість до першого рядка, друге - до останнього.
План читають знизу вгору й зсередини назовні: вкладені вузли виконуються першими й передають рядки батьківському.
Наступний крок - EXPLAIN ANALYZE, який виконує запит і показує реальний час і кількість рядків.
SELECT * повертає всі колонки, навіть ті, що коду не потрібні. На маленьких таблицях різниці немає, але проблеми з'являються з ростом:
- Зайві дані через мережу й у пам'ять. Колонка
body TEXTчиpayload JSONна сотні кілобайтів, вибрана для списку із 50 заголовків, множить обсяг у рази. - Не працює покривний індекс. Якщо запиту потрібні лише
idіstatus, які є в індексі, база могла б не звертатися до таблиці. З*мусить. - Крихкість. Додали колонку - і запит раптом повертає більше даних; у
INSERT ... SELECT *чиUNIONзміна схеми ламає запит. - Читабельність. З коду не видно, які дані реально використовуються.
-- Для списку статей
SELECT id, title, published_at FROM posts ORDER BY published_at DESC LIMIT 20;
У Laravel Eloquent за замовчуванням робить select *. Для важких таблиць варто явно обмежувати колонки: Post::select(['id', 'title', 'published_at']), а для зв'язків - with('author:id,name').
Коли * нормальний: разові запити в консолі, EXISTS (SELECT * ...) (там колонки не читаються взагалі), і COUNT(*), який з вибором колонок узагалі не пов'язаний.
У PostgreSQL немає збереженого лічильника рядків таблиці. Через MVCC різні транзакції одночасно бачать різну кількість рядків, тож SELECT count(*) FROM orders мусить переглянути рядки (або індекс) і перевірити видимість кожного. На сотнях мільйонів рядків це секунди чи хвилини.
Що можна зробити:
1. Приблизна кількість зі статистики - миттєво:
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE oid = 'public.orders'::regclass;
Значення оновлює ANALYZE/autovacuum. Для лічильника «близько 2,4 млн записів» на сторінці адмінки - цілком достатньо.
2. Оцінка для запиту з умовою - з плану:
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
-- rows=183000 у плані - оцінка планувальника
3. Точна кількість швидше: index-only scan по невеликому індексу, якщо visibility map свіжа (після вакууму), - помітно швидше за читання всієї таблиці.
4. Лічильник, що ведеться окремо: таблиця-лічильник, оновлювана тригером чи застосунком, або кеш на кілька хвилин. Точно й миттєво, але з ціною на кожен запис (і точкою конкуренції, якщо всі пишуть в один рядок лічильника).
Практичні висновки:
- Пагінація з «сторінка 1 з 48 213» - дорога на великих таблицях: кожна сторінка - ще й
count(*). LaravelsimplePaginate()чиcursorPaginate()обходяться без підрахунку. - «Чи є хоч один рядок» - не
count(*) > 0, аEXISTS (SELECT 1 ... ): зупиняється на першому знайденому. count(column)не швидший заcount(*)- він ще й перевіряєNULL.
ANALYZE збирає статистику про дані таблиці: кількість рядків, частку NULL, кількість унікальних значень, найчастіші значення, гістограму розподілу. Планувальник використовує її, щоб оцінити, скільки рядків поверне кожна частина запиту, - і обрати план: індекс чи повний перегляд, який вид JOIN, у якому порядку з'єднувати таблиці.
ANALYZE orders; -- одна таблиця
ANALYZE orders (status, user_id); -- окремі колонки
ANALYZE; -- уся база
ANALYZE читає не всю таблицю, а вибірку рядків, тож виконується швидко навіть на великих таблицях.
Чому після імпорту все повільно: статистика стала застарілою. Таблиця, у якій учора було 1000 рядків, сьогодні має 10 мільйонів, а планувальник досі думає, що їх тисяча, - і обирає план, оптимальний для маленької таблиці (наприклад, Nested Loop там, де потрібен Hash Join).
Autovacuum запускає ANALYZE автоматично, коли змінилася помітна частка таблиці, але не миттєво. Тому після масових операцій - імпорту, видалення великої частини даних, відновлення з дампу, pg_upgrade - ANALYZE варто запустити вручну одразу.
Як помітити проблему: у EXPLAIN ANALYZE оцінка rows сильно розходиться з actual rows. Коли статистика застаріла - розходження величезне; ANALYZE усе виправляє.
Коли статистики недостатньо навіть свіжої:
- Перекошений розподіл значень - збільшити деталізацію:
ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000. - Корельовані колонки - розширена статистика (
CREATE STATISTICS).
Час останнього аналізу видно в pg_stat_user_tables (last_analyze, last_autoanalyze).
EXPLAIN лише оцінює план. EXPLAIN ANALYZE виконує запит і показує поруч з оцінками реальність: фактичний час кожного вузла і скільки рядків він насправді повернув.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20;
Limit (actual time=812.4..812.5 rows=20 loops=1)
-> Sort (actual time=812.4..812.4 rows=20 loops=1)
Sort Method: top-N heapsort
-> Seq Scan on orders (rows=1200 ... actual rows=950000 loops=1)
Filter: (status = 'pending')
Rows Removed by Filter: 4050000
Buffers: shared read=73000
На що дивитися:
- Оцінка проти факту:
rows=1200очікувалося,actual rows=950000вийшло. Велика розбіжність означає застарілу статистику (ANALYZE orders) або корельовані колонки - і планувальник обирає поганий план. Rows Removed by Filter- база прочитала мільйони рядків, щоб відкинути більшість. Підказка до індексу.loops- скільки разів виконувався вузол. Час у вузлі вказано за один прохід: 0.5 мс × 20 000 loops у Nested Loop - це 10 секунд.BUFFERS- скільки сторінок прочитано з кешу (hit) і з диска (read).
Обережно: EXPLAIN ANALYZE справді виконує запит. Для UPDATE/DELETE загортайте його в транзакцію з ROLLBACK.
MySQL має EXPLAIN ANALYZE з версії 8.0.18 - вивід у вигляді дерева з фактичним часом.
LIMIT 20 OFFSET 100000 не «перескакує» на потрібне місце. База мусить знайти й відкинути перші 100 000 рядків і лише потім віддати 20. Чим далі сторінка, тим довший запит - навіть з індексом.
Друга проблема - зсув: якщо між запитами сторінок з'явилися нові записи, частина рядків повториться на наступній сторінці, а частина пропаде.
Keyset (cursor) пагінація - замість номера сторінки запам'ятовують значення останнього рядка й продовжують від нього:
-- Перша сторінка
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Наступна: від останнього показаного рядка
SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ('2026-10-01 12:00:00', 4821)
ORDER BY created_at DESC, id DESC
LIMIT 20;
З індексом (created_at, id) кожна сторінка - кілька кроків по індексу, хоч перша, хоч мільйонна. id додано для однозначності, коли created_at однаковий.
Обмеження keyset: немає переходу на довільну сторінку «47» і загальної кількості сторінок. Для стрічок, нескінченного скролу, API й експорту це не проблема.
У Laravel: cursorPaginate() реалізує саме keyset, paginate() - OFFSET плюс окремий COUNT(*), simplePaginate() - OFFSET без підрахунку.
PostgreSQL має три алгоритми з'єднання таблиць, і планувальник обирає один для кожного JOIN за оцінками кількості рядків.
Nested Loop - для кожного рядка зовнішньої таблиці шукати відповідні рядки у внутрішній.
- Чудовий, коли зовнішніх рядків мало, а у внутрішній таблиці є індекс на ключі з'єднання: 20 замовлень → 20 пошуків по індексу користувачів.
- Катастрофічний, коли зовнішніх рядків багато: мільйон × пошук = мільйон звернень.
Hash Join - побудувати хеш-таблицю з меншого набору, потім пройти більший і шукати збіги в хеш-таблиці.
- Для великих наборів без потрібного порядку - типовий вибір для аналітики й звітів.
- Лише для умов рівності (
=). - Хеш-таблиця будується в пам'яті (
work_mem×hash_mem_multiplier); якщо не вміщується - ділиться на частини на диску, і з'єднання сповільнюється.
Merge Join - обидва набори впорядковані за ключем з'єднання, і їх «зшивають» за один прохід, як дві відсортовані колоди.
- Ефективний, коли обидва набори вже відсортовані (наприклад, читаються з індексів) або результат усе одно потрібен відсортованим.
- Якщо сортування треба робити окремо, часто програє Hash Join.
Як читати в EXPLAIN ANALYZE:
Nested Loop (actual rows=950000 loops=1)
-> Seq Scan on orders (actual rows=950000)
-> Index Scan using users_pkey on users (actual rows=1 loops=950000)
loops=950000 - сигнал: планувальник очікував кілька рядків зовні, а отримав майже мільйон. Майже завжди причина - помилкова оцінка кількості рядків (застаріла статистика, корельовані умови), а не сам алгоритм.
Виправляють не алгоритм, а оцінки: ANALYZE, розширена статистика, переписаний запит. Для перевірки гіпотези в сесії можна вимкнути алгоритм (SET enable_nestloop = off), але на проді так не залишають.
work_mem - скільки пам'яті може використати одна операція запиту: сортування (ORDER BY, DISTINCT, Merge Join), хеш-таблиця (Hash Join, GROUP BY через хеш). За замовчуванням - 4 МБ.
Якщо даних більше, операція переходить на тимчасові файли на диску - на порядки повільніше.
Як побачити в плані:
Sort Method: external merge Disk: 285440kB -- сортування на диску
Sort Method: quicksort Memory: 3120kB -- у пам'яті
Hash ... Batches: 16 Memory Usage: 4096kB -- хеш поділено на частини через нестачу пам'яті
Ще - лог тимчасових файлів (log_temp_files = 0) і temp_files/temp_bytes у pg_stat_database.
Чому не поставити 1 ГБ - пастка множення. Ліміт діє на кожну операцію, а не на з'єднання:
- один запит може мати кілька сортувань і хешів одночасно;
- паралельний запит множить на кількість воркерів;
- хеш-операції можуть брати
work_mem × hash_mem_multiplier(за замовчуванням ×2); - і все це - на кожне з сотень з'єднань.
200 з'єднань × 3 операції × 256 МБ = 150 ГБ потенційно - сервер піде в OOM під навантаженням.
Як налаштовувати:
- Глобально - помірне значення (16-64 МБ залежно від пам'яті й кількості з'єднань).
- Точково більше - для важких звітів і аналітики:
SET LOCAL work_mem = '512MB'; -- лише в поточній транзакції
ALTER ROLE reporting SET work_mem = '256MB';
- Для обслуговування (
CREATE INDEX,VACUUM) - окремий параметрmaintenance_work_mem.
Але спершу - сам запит: часто сортування великого набору не потрібне взагалі - вистачить індексу, що віддає рядки в потрібному порядку, чи фільтра, що зменшує набір до сортування.
shared_buffers - власний кеш сторінок даних PostgreSQL у спільній пам'яті. Кожне читання спершу шукає сторінку тут; якщо її немає - читає з файлу.
Подвійне кешування. PostgreSQL читає файли через звичайну файлову систему, тож сторінки, яких немає в shared_buffers, часто вже лежать у кеші ОС (page cache). Тому:
shared_buffersне має забирати всю пам'ять - решту ефективно використовує ОС;- типова рекомендація документації - близько 25% оперативної пам'яті на виділеному сервері бази (більше дає менший виграш, бо дублюється з кешем ОС);
effective_cache_size- підказка планувальнику, скільки кешу всього (shared_buffers + кеш ОС), зазвичай 50-75% пам'яті. Пам'ять він не виділяє, лише робить індексні плани «дешевшими» в оцінках.
# сервер з 32 ГБ, лише під PostgreSQL
shared_buffers = 8GB
effective_cache_size = 24GB
Значення за замовчуванням - лише 128 МБ - розраховане на запуск будь-де, а не на продуктивність. На сервері для продакшену його майже завжди треба змінити (і перезапустити PostgreSQL).
Як оцінити, чи вистачає кешу:
SELECT round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS cache_hit_percent
FROM pg_stat_database
WHERE datname = current_database();
Для OLTP-навантаження зазвичай очікують 99%+. Але blks_read рахує читання повз shared_buffers, а ці сторінки могли прийти з кешу ОС - низький відсоток не завжди означає читання з диска. Точніше - EXPLAIN (ANALYZE, BUFFERS) для конкретних запитів і метрики вводу-виводу ОС.
Ще пов'язані параметри: huge_pages (для великих shared_buffers зменшує накладні витрати пам'яті), wal_buffers. У керованих базах (RDS, Cloud SQL) розумні значення вже виставлені під розмір інстанса.
Найповільніший спосіб - окремий INSERT на кожен рядок в autocommit: кожен рядок - окрема транзакція з записом у WAL і синхронізацією з диском.
Від швидкого до найшвидшого:
1. Одна транзакція і масові INSERT:
BEGIN;
INSERT INTO events (user_id, type, created_at) VALUES
(1, 'login', now()), (2, 'login', now()), /* ...тисяча рядків... */;
-- ще порції
COMMIT;
Порції по 500-5000 рядків в одному INSERT - у десятки разів швидше за поодинокі. У Laravel - DB::table('events')->insert($batch).
2. COPY - спеціальний протокол масового завантаження, найшвидший спосіб:
COPY events (user_id, type, created_at) FROM '/data/events.csv' WITH (FORMAT csv, HEADER true);
COPY ... FROM читає файл на сервері бази; з клієнта - \copy у psql чи Pdo\Pgsql::copyFromFile() / copyFromArray() у PHP 8.4+ (раніше - методи pgsqlCopyFrom* на самому PDO).
3. Для величезних одноразових завантажень:
- Створити індекси й зовнішні ключі після завантаження - побудувати індекс один раз швидше, ніж оновлювати його мільйон разів.
- Збільшити
maintenance_work_memдля швидшої побудови індексів. UNLOGGED-таблиця для проміжних даних - без запису в WAL, але не переживає збій і не реплікується.- Тимчасово збільшити
max_wal_size, щоб рідше робилися контрольні точки.
Після завантаження - обов'язково ANALYZE, інакше планувальник працюватиме зі статистикою порожньої таблиці. А для таблиць, куди більше не писатимуть, - VACUUM (FREEZE, ANALYZE), щоб пізніше не було масового «заморожування».
Обережно на проді: масове завантаження генерує багато WAL - репліки відстають, а сервер з архівуванням WAL заповнює сховище. Великі імпорти ділять на порції з паузами й стежать за затримкою реплікації.
Звичайне подання (view) - збережений запит: щоразу при зверненні він виконується заново.
Матеріалізоване подання зберігає результат запиту як таблицю. Читати його так само швидко, як таблицю, - але дані оновлюються лише за командою.
CREATE MATERIALIZED VIEW sales_by_month AS
SELECT date_trunc('month', created_at) AS month,
product_id,
sum(total) AS revenue,
count(*) AS orders
FROM orders
WHERE status = 'paid'
GROUP BY 1, 2;
CREATE UNIQUE INDEX sales_by_month_key ON sales_by_month (month, product_id);
Оновлення:
REFRESH MATERIALIZED VIEW sales_by_month; -- блокує читання на весь час оновлення
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_by_month; -- читання не блокується
CONCURRENTLY будує новий результат поруч, порівнює зі старим і застосовує різницю. Вимагає унікального індексу на поданні й працює довше, зате дашборди, що читають подання, не зависають.
Коли доречно:
- Важкі агрегати для дашбордів і звітів, де допустимі дані «станом на кілька хвилин чи годин тому».
- Денормалізовані дані для пошуку чи API, які дорого збирати з багатьох таблиць на кожен запит.
Обмеження:
- Немає автоматичного оновлення - лише
REFRESHза розкладом (cron, планувальник Laravel) чи за подією. - Оновлюється цілком: навіть при зміні одного рядка запит перераховується повністю. Для дуже великих агрегатів - інкрементальні підходи (окрема таблиця з оновленням за тригером чи пакетними задачами, розширення на кшталт
pg_ivm). - Займає місце, як звичайна таблиця, і потребує індексів під свої запити.
Альтернатива на рівні застосунку - кеш результату запиту (Redis). Матеріалізоване подання краще, коли результат великий, його фільтрують і з'єднують з іншими таблицями прямо в SQL.
Повільний запит - не лише той, що виконується 5 секунд один раз. Часто більше шкодить запит на 20 мс, який виконується мільйон разів на день. Тому дивляться на сумарний час.
PostgreSQL - pg_stat_statements. Розширення збирає статистику по кожному нормалізованому запиту (параметри замінено на $1): кількість викликів, сумарний і середній час, прочитані блоки.
-- shared_preload_libraries = 'pg_stat_statements' у postgresql.conf, потім:
CREATE EXTENSION pg_stat_statements;
SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Доповнення:
log_min_duration_statement = 500- записувати в лог усі запити довші за 500 мс, з параметрами.auto_explain- автоматично логувати плани повільних запитів. Без цього план на проді, де дані й статистика інші, ніж локально, часто не відтворити.pg_stat_activity- що виконується просто зараз, хто кого чекає.
MySQL: slow query log (long_query_time), performance_schema і sys.statement_analysis - аналог pg_stat_statements.
На рівні застосунку APM (Sentry Performance, New Relic, Telescope локально) пов'язує запит з місцем у коді, яке його породило. Часто проблема - не сам запит, а N+1: сто швидких запитів там, де мав бути один.
Порядок дій: знайти топ за сумарним часом → EXPLAIN (ANALYZE, BUFFERS) на реальних параметрах → індекс, переписаний запит чи кеш → порівняти статистику після змін.
Кожне з'єднання з PostgreSQL - це окремий процес сервера з власною пам'яттю. Сотні з'єднань споживають багато ресурсів, а встановлення нового з'єднання помітно повільне. Тим часом PHP-FPM з 200 воркерами на кількох серверах легко відкриває тисячу з'єднань, більшість з яких простоює.
PgBouncer стоїть між застосунком і базою: тисячі клієнтських з'єднань він обслуговує невеликим пулом серверних.
Режими:
- Session pooling - серверне з'єднання видається клієнту на всю сесію. Безпечно, але виграш малий.
- Transaction pooling - з'єднання видається лише на час транзакції (або окремого запиту поза транзакцією). Головна економія - і головні сюрпризи.
- Statement pooling - на окремий запит; транзакції з кількох запитів неможливі.
Що ламається в transaction pooling: усе, що прив'язане до сесії, бо наступний запит може піти іншим серверним з'єднанням:
SET(часовий пояс,search_path,statement_timeout) безLOCAL- налаштування «перетече» до іншого клієнта.- Сесійні advisory-блокування,
LISTEN/NOTIFY, тимчасові таблиці. - Підготовлені запити (prepared statements) - PgBouncer підтримує їх на рівні протоколу лише з версії 1.21 і з налаштуванням
max_prepared_statements. До того PDO з емуляцією вимкненою отримував помилки на кшталтprepared statement does not exist.
Альтернативи й доповнення: керовані пули (RDS Proxy, Supavisor у Supabase), довгоживучі процеси (Octane), що тримають сталі з'єднання, і просто обмеження кількості PHP-воркерів під реальну ємність бази.
За замовчуванням один запит виконується одним процесом на одному ядрі. Паралельний запит ділить роботу між кількома процесами-воркерами: кожен обробляє частину таблиці, а головний процес збирає результати.
Finalize Aggregate
-> Gather (Workers Planned: 2, Workers Launched: 2)
-> Partial Aggregate
-> Parallel Seq Scan on orders
Що вміє працювати паралельно: послідовне й індексне сканування, Hash Join і Nested Loop, агрегати, сортування з об'єднанням (Gather Merge), створення B-tree індексів.
Ключові налаштування:
max_parallel_workers_per_gather(за замовчуванням 2) - воркерів на один вузол запиту;max_parallel_workersіmax_worker_processes- загальні ліміти на сервер;min_parallel_table_scan_size- менші таблиці не розпаралелюються;parallel_setup_cost,parallel_tuple_cost- вартість запуску й передачі рядків, з якою планувальник порівнює виграш.
Коли паралельність допомагає: аналітичні запити, що читають і агрегують мільйони рядків, - звіти, дашборди, пакетні обчислення.
Коли не допомагає чи шкодить:
- OLTP-запити (знайти користувача за ID) - запуск воркерів коштує більше, ніж сам запит; планувальник їх і не розпаралелить.
- Високе паралельне навантаження: якщо сервер і так зайнятий сотнями запитів, воркери конкурують з ними за ядра. Сумарна пропускна здатність може впасти.
- Запити, що змінюють дані (
UPDATE,DELETE), майже не розпаралелюються, аINSERT ... SELECT- лише частково. - Функції, позначені
PARALLEL UNSAFE(за замовчуванням для власних функцій), забороняють паралельний план.
Як діагностувати: Workers Planned проти Workers Launched у EXPLAIN ANALYZE - якщо запущено менше запланованого, на сервері забракло вільних воркерів.
Підготовлений запит (prepared statement) розбирається один раз, а виконується багато разів з різними параметрами. PDO і Laravel використовують їх для прив'язки параметрів.
PREPARE find_orders (text) AS SELECT * FROM orders WHERE status = $1;
EXECUTE find_orders('pending');
Для виконання PostgreSQL може використати два види плану:
- Custom plan - будується для конкретних значень параметрів. Точний, але планування на кожне виконання коштує час.
- Generic plan - один план для будь-яких значень, без урахування конкретних. Будується раз і перевикористовується.
Як PostgreSQL обирає (plan_cache_mode = auto): перші п'ять виконань - custom-плани. Потім він порівнює їхню середню вартість з вартістю generic-плану і, якщо generic не набагато гірший, переходить на нього.
Де це ламається - перекошені дані:
-- status = 'pending' - 0.1% рядків → ідеальний Index Scan
-- status = 'completed' - 95% рядків → потрібен Seq Scan
Generic-план не знає, яке значення прийде, і обирає «середній» варіант. Запит, що спершу виконувався за мілісекунди, після п'ятого разу раптом стає повільним для частини значень. Класична загадка «запит повільний лише в застосунку, а в psql - швидкий» (у psql ви виконуєте його з конкретним значенням - custom план).
Що робити:
SET plan_cache_mode = force_custom_plan; -- для сесії чи ролі
Або точково - для конкретних запитів з перекошеними даними.
Як діагностувати: EXPLAIN EXECUTE find_orders('completed') після шостого виконання показує, чи план уже generic (параметри в ньому виглядають як $1); auto_explain у логах продакшену.
Нюанс PHP: PDO з PDO::ATTR_EMULATE_PREPARES = true підставляє параметри сам і надсилає готовий текст - тоді на сервері підготовлених запитів немає взагалі, і проблеми generic-планів теж. Laravel за замовчуванням для PostgreSQL використовує справжні підготовлені запити.