Запит, яким його бачить база
SQL каже, що потрібно отримати, а не як. Як саме, вирішує планувальник: яку таблицю читати першою, яким індексом, яким способом зʼєднувати й сортувати. Результат його роботи це , дерево кроків, де кожен вузол отримує рядки від дочірніх і віддає батьківському.
Обирає планувальник за : скільки рядків поверне кожен крок і скільки це коштуватиме. Оцінки беруться зі , яку база збирає про таблиці. EXPLAIN показує план і оцінки, нічого не виконуючи. EXPLAIN ANALYZE виконує запит по-справжньому і поруч з оцінкою пише фактичні рядки й час кожного вузла.
Тому EXPLAIN ANALYZE над UPDATE чи DELETE справді змінює дані: загортайте його в BEGIN і ROLLBACK. А читати план варто з питанням «де оцінка розійшлася з фактом і де база прочитала набагато більше рядків, ніж віддала».
Як читати JOIN у плані
Назви активних вакансій з назвою компанії за останні дні: JOIN двох таблиць. План читають знизу вгору й зсередини назовні: найглибші вузли виконуються першими й передають рядки батькам. У MySQL такий вигляд має формат TREE (з версії 8.0.16) і EXPLAIN ANALYZE (з 8.0.18). Що означає кожне поле, розібрано нижче, у розділі «Як читати план».
Бази вибрали різні способи зʼєднання. MySQL пройшов по 2 058 вакансіях і для кожної знайшов компанію за первинним ключем: це Nested loop, і loops=2058 у внутрішньому вузлі означає 2 058 пошуків. PostgreSQL побудував хеш-таблицю з усіх 2 000 компаній і пройшов по вакансіях один раз: Hash Join.
-> Nested loop inner join (cost=1647 rows=2058) (actual time=0.388..6.63 rows=2058 loops=1)
-> Filter: (v.company_id is not null) (cost=926 rows=2058) (actual time=0.33..4.41 rows=2058 loops=1)
-> Index range scan on v using vacancies_status_published_at_index
over (status = 'active' AND '2026-09-25 00:00:00.000000' <= published_at)
(cost=926 rows=2058) (actual time=0.329..4.24 rows=2058 loops=1)
-> Single-row index lookup on c using PRIMARY (id=v.company_id)
(cost=0.25 rows=1) (actual time=858e-6..887e-6 rows=1 loops=2058) Без ANALYZE той самий запит у EXPLAIN FORMAT=TREE дає дерево лише з оцінками. У табличному EXPLAIN друга таблиця має type = eq_ref: один рядок companies на кожен рядок vacancies.
Hash Join (cost=116.92..5416.28 rows=2195 width=23) (actual time=0.616..1.337 rows=2058 loops=1)
Hash Cond: (v.company_id = c.id)
Buffers: shared hit=107
-> Bitmap Heap Scan on vacancies v (cost=58.92..5352.51 rows=2195 width=19) (actual time=0.122..0.431 rows=2058 loops=1)
Recheck Cond: (((status)::text = 'active'::text) AND (published_at >= '2026-09-25 00:00:00'::timestamp without time zone))
Heap Blocks: exact=83
-> Bitmap Index Scan on vacancies_status_published_at_index (actual time=0.109..0.110 rows=2058 loops=1)
-> Hash (cost=33.00..33.00 rows=2000 width=20) (actual time=0.486..0.486 rows=2000 loops=1)
Buckets: 2048 Batches: 1 Memory Usage: 126kB
-> Seq Scan on companies c (cost=0.00..33.00 rows=2000 width=20) (actual time=0.008..0.197 rows=2000 loops=1)
Execution Time: 1.442 ms Seq Scan по companies тут не проблема: 2 000 рядків на 13 сторінках прочитати цілком дешевше, ніж шукати кожну компанію в індексі. Batches: 1 означає, що хеш-таблиця вмістилася в work_mem.
QUERY PLAN |--SEARCH v USING INDEX vacancies_status_published_at_index (status=? AND published_at>?) `--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
SQLite зʼєднує таблиці лише вкладеними циклами, тому план короткий: зовнішня таблиця зверху, внутрішня під нею. EXPLAIN без QUERY PLAN у SQLite видає байткод віртуальної машини, а не план.
Коли статистика бреше
Планувальник обирає план за оцінками, тому неправильна оцінка дає неправильний план. Найчастіша причина це статистика, зібрана до масової зміни даних: імпорту, міграції, UPDATE на пів таблиці. Autovacuum у PostgreSQL і перерахунок у InnoDB наздоганяють не одразу.
Нижче в PostgreSQL 65 031 вакансію перевели в статус pending, якого в статистиці ще не було. Планувальник чекає один рядок і обирає вкладений цикл з пошуком компанії для кожної вакансії. Після ANALYZE він бачить 65 300 рядків і переходить на Hash Join, і запит стає майже вчетверо швидшим.
-- до ANALYZE
Nested Loop (cost=0.71..16.20 rows=1 width=31) (actual time=0.033..85.582 rows=65031 loops=1)
-> Index Scan using vacancies_status_published_at_index on vacancies v (cost=0.43..7.90 rows=1 width=27) (actual time=0.021..13.177 rows=65031 loops=1)
Index Cond: ((status)::text = 'pending'::text)
-> Index Scan using companies_pkey on companies c (cost=0.28..8.29 rows=1 width=20) (actual time=0.001..0.001 rows=1 loops=65031)
Execution Time: 88.028 ms
-- після ANALYZE
Hash Join (cost=1640.50..13193.53 rows=65300 width=31) (actual time=3.160..21.107 rows=65031 loops=1)
-> Bitmap Heap Scan on vacancies v (cost=1582.50..12963.75 rows=65300 width=27) (actual time=2.703..8.962 rows=65031 loops=1)
-> Hash (actual time=0.446..0.448 rows=2000 loops=1)
Execution Time: 23.452 ms rows=1 проти rows=65031 у першому вузлі і є діагноз. Коли статистику востаннє збирали і скільки рядків змінилося відтоді, видно в pg_stat_user_tables: last_analyze, last_autoanalyze, n_mod_since_analyze.
-> Sort: `count(*)` DESC (actual time=683..683 rows=6 loops=1)
-> Table scan on <temporary> (actual time=683..683 rows=6 loops=1)
-> Aggregate using temporary table (actual time=683..683 rows=6 loops=1)
-> Index lookup on vacancies using vacancies_status_published_at_index (status='active')
(cost=53407 rows=498257) (actual time=0.13..584 rows=250310 loops=1)
-- IGNORE INDEX
-> Filter: (vacancies.`status` = 'active') (cost=100845 rows=498258) (actual time=0.0306..310 rows=250310 loops=1)
-> Table scan on vacancies (cost=100845 rows=996515) (actual time=0.0279..222 rows=1e+6 loops=1) Оцінка вдвічі більша за факт, а сам вибір індексу програв: 250 310 окремих походів у кластерний індекс за 584 мс проти читання всієї таблиці підряд за 310 мс. Статистику MySQL оновлює ANALYZE TABLE vacancies, а для колонок без індексу є гістограми: ANALYZE TABLE ... UPDATE HISTOGRAM ON salary_usd_to.
-- до ANALYZE SCAN vacancies -- після ANALYZE SEARCH vacancies USING INDEX vacancies_company_id_status_published_at_index (ANY(company_id) AND status=? AND published_at>?)
SQLite сам статистику не збирає. Без ANALYZE таблиці sqlite_stat1 немає, і планувальник не знає, що в company_id лише 2 000 різних значень, тому не пробує skip scan.
Сім запитів і що про них каже EXPLAIN
Таблиця vacancies на мільйон рядків з індексами на id, company_id і (status, published_at). У MySQL класичний EXPLAIN це таблиця, по рядку на кожну таблицю запиту. У PostgreSQL план одразу дерево, і з ANALYZE та BUFFERS він показує фактичні рядки, час і прочитані сторінки.
SELECT * FROM vacancies WHERE id = 42;| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| const | PRIMARY | 1 | 100.00 | NULL |
SELECT * FROM vacancies WHERE company_id = 7;| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ref | vacancies_company_id_index | 505 | 100.00 | NULL |
SELECT * FROM vacancies WHERE status = 'active' AND published_at >= '2026-09-25';| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| range | vacancies_status_published_at_index | 2058 | 100.00 | Using index condition |
SELECT status, published_at FROM vacancies WHERE status = 'active' AND published_at >= '2026-09-25';| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| range | vacancies_status_published_at_index | 2058 | 100.00 | Using where; Using index |
SELECT * FROM vacancies WHERE company_id = 7 ORDER BY salary_usd_to DESC LIMIT 20;| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ref | vacancies_company_id_index | 505 | 100.00 | Using filesort |
SELECT title, count(*) FROM vacancies WHERE status = 'active' GROUP BY title ORDER BY count(*) DESC;| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ref | vacancies_status_published_at_index | 498257 | 100.00 | Using temporary; Using filesort |
SELECT * FROM vacancies WHERE title = 'Team Lead';| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ALL | NULL | 996515 | 10.00 | Using where |
MySQL 8.0.46, InnoDB, таблиця на 1 000 000 рядків. Вивід знято зі справжньої бази, частину колонок прибрано.
SELECT * FROM vacancies WHERE title = 'Team Lead' AND salary_usd_to > 6900;Gather (cost=1000.00..17331.80 rows=1588 width=45) (actual time=2.279..49.547 rows=1638 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=9923
-> Parallel Seq Scan on vacancies (cost=0.00..16173.00 rows=662 width=45) (actual time=0.066..38.756 rows=546 loops=3)
Filter: ((salary_usd_to > 6900) AND ((title)::text = 'Team Lead'::text))
Rows Removed by Filter: 332787
Buffers: shared hit=9923
Execution Time: 49.698 ms SELECT * FROM vacancies WHERE id = 42;Index Scan using vacancies_pkey on vacancies (cost=0.42..8.44 rows=1 width=45) (actual time=0.019..0.020 rows=1 loops=1) Index Cond: (id = 42) Buffers: shared hit=7 Execution Time: 0.038 ms
SELECT * FROM vacancies WHERE company_id = 7;Bitmap Heap Scan on vacancies (cost=8.29..1638.87 rows=499 width=45) (actual time=0.166..0.752 rows=505 loops=1)
Recheck Cond: (company_id = 7)
Heap Blocks: exact=495
Buffers: shared hit=495 read=4
-> Bitmap Index Scan on vacancies_company_id_index (cost=0.00..8.17 rows=499 width=0) (actual time=0.104..0.104 rows=505 loops=1)
Index Cond: (company_id = 7)
Buffers: shared read=4
Execution Time: 0.789 ms SELECT status, published_at FROM vacancies WHERE status = 'active' AND published_at >= '2026-09-25';Index Only Scan using vacancies_status_published_at_index on vacancies (cost=0.42..80.33 rows=2195 width=14) (actual time=0.024..0.372 rows=2058 loops=1) Index Cond: ((status = 'active'::text) AND (published_at >= '2026-09-25 00:00:00'::timestamp without time zone)) Heap Fetches: 0 Buffers: shared hit=5 read=7 Execution Time: 0.483 ms
PostgreSQL 16, таблиця на 1 000 000 рядків. Вивід знято зі справжньої бази, рядки Planning прибрано.
- type
- Як база дістає рядки, від найкращого до найгіршого: const (рівно один рядок за первинним чи унікальним ключем), eq_ref (один рядок на кожен рядок попередньої таблиці в JOIN), ref (рівність за неунікальним індексом), range (діапазон по індексу), index (увесь індекс підряд), ALL (уся таблиця).
- key
- Індекс, який обрав оптимізатор. NULL означає, що жодного. Поруч у possible_keys кандидати, а key_len показує, скільки байтів ключа взято в пошук, тобто скільки колонок складеного індексу спрацювало.
- rows
- Оцінка, скільки рядків доведеться прочитати на цьому кроці. Це не результат запиту і не факт: 498 257 у групуванні це оцінка, а прочитано 250 310.
- filtered
- Яка частка прочитаних рядків, за оцінкою, пройде решту умов WHERE. rows × filtered / 100 це очікувана кількість рядків, яку крок передасть далі.
- Extra
- Using index: відповідь з самого індексу. Using index condition: частину умови перевірено в індексі до походу в таблицю. Using where: умову перевіряють уже на рядках. Using filesort: окреме сортування. Using temporary: тимчасова таблиця, найчастіше під GROUP BY чи DISTINCT.
- Seq Scan
- Читання всієї таблиці. Тут 9 923 сторінки заради 1 638 рядків: Rows Removed by Filter показує, скільки прочитаного викинула умова, в кожному з трьох процесів.
- Index Scan
- Спуск по індексу і похід у таблицю за кожним знайденим рядком. Найкращий, коли рядків мало або потрібен порядок індексу.
- Bitmap Heap Scan
- Середній випадок: Bitmap Index Scan спершу збирає з індексу адреси всіх рядків, а потім таблицю читають сторінка за сторінкою по порядку. 505 рядків компанії лежать на 495 різних сторінках, і бітмапа не дає читати жодну двічі.
- Index Only Scan
- Усі потрібні колонки є в індексі, тож у таблицю база не йде. Heap Fetches: 0 це підтверджує: після масових UPDATE, поки не пройшов VACUUM, тут будуть походи в таблицю.
- cost
- Два числа в умовних одиницях: до першого рядка і до останнього. Порівнюйте cost між планами одного запиту, а не з мілісекундами.
- rows і actual
- rows біля cost це оцінка, rows біля actual факт на одну ітерацію. actual time теж на одну ітерацію, тому множте його на loops. Велика різниця між оцінкою і фактом підказує застарілу статистику.
- Buffers
- Сторінки по 8 КБ, які вузол прочитав: hit з памʼяті, read з диска. Час залежить від того, чи прогрітий кеш, а кількість сторінок ні, тож порівнювати плани зручніше за нею.
Як дістати запит, який збудував Laravel
Перш ніж читати план, потрібен сам запит. Eloquent збирає його з методів, і найпростіше подивитися на готовий SQL або зловити всі запити сторінки. Вивід нижче знято з Laravel 13 на MySQL 8.0.
Каталог вакансій під EXPLAIN
Сторінка каталогу робить два запити до vacancies: кількість видимих вакансій і саму сторінку, найсвіжіші першими. Ми зняли toRawSql() з EloquentJobCatalog і прогнали обидва запити на PostgreSQL 16 на лабораторній таблиці з мільйона рядків з тими самими індексами, що в міграції: vacancies_published_at_index і vacancies_status_published_at_index. Лічильник бере Index Only Scan по (status, published_at) і не ходить у таблицю. Сторінка читає індекс на published_at з кінця, відкидає невидимі вакансії фільтром, а id DESC досортовує лише в межах однакових дат: Incremental Sort з Presorted Key: published_at. На справжній базі сайту рядків менше, і план може бути іншим.
src/Hiring/Infrastructure/Persistence/JobModel.phpСкоуп visible() дописує status in ('active', 'stale'), а глобальний скоуп HidesTestData ще й is_test = false. Обидві умови є в toRawSql(), і без нього їх легко не помітити, читаючи лише код каталогу.database/migrations/2026_09_02_000002_create_vacancies_table.phpІндекси, з яких обирає планувальник: published_at окремо і (status, published_at). Для IN ('active', 'stale') з ORDER BY published_at другий не дає готового порядку, тож для сторінки PostgreSQL бере перший.Де план читають неправильно
Порівнювати cost з мілісекундами
cost це умовні одиниці планувальника, а не час. cost=17331 не означає 17 секунд, і порівнювати його між різними запитами чи різними базами марно.
Натомість: cost порівнюйте лише між планами одного запиту. Для часу дивіться actual time і Execution Time в EXPLAIN ANALYZE.
Читати rows як результат
rows у MySQL EXPLAIN і rows біля cost у PostgreSQL це оцінка, скільки рядків буде прочитано на кроці. Фактична кількість буває інакшою в рази.
Натомість: Беріть EXPLAIN ANALYZE і порівнюйте оцінку з actual rows у кожному вузлі. Велика різниця вказує на статистику, з якої почати.
Забути про loops
У PostgreSQL actual time і rows вказані на одну ітерацію. Внутрішній вузол вкладеного циклу з actual time=0.001 і loops=65031 насправді працював 65 мілісекунд.
Натомість: Множте actual time і rows на loops. У MySQL дерево EXPLAIN ANALYZE так само показує loops.
EXPLAIN ANALYZE над DELETE
EXPLAIN ANALYZE виконує запит. Над UPDATE чи DELETE на продакшні він змінить дані так само, як звичайний запит.
Натомість: BEGIN; EXPLAIN ANALYZE DELETE ...; ROLLBACK; або перевіряйте на копії бази.
План з порожньої бази
На локальній базі з сотнею рядків Seq Scan найшвидший план, і EXPLAIN там нічого не скаже про продакшн з мільйоном рядків.
Натомість: Перевіряйте план на даних реального обсягу: на копії продакшну чи на згенерованій таблиці з таким самим розподілом значень.
- Вузол плану
- Один крок виконання: сканування таблиці чи індексу, зʼєднання, сортування, агрегат, LIMIT. У PostgreSQL кожен рядок плану зі стрілкою ->, у MySQL кожен рядок формату TREE. Дочірні вузли виконуються першими.
- Оцінка
- Скільки рядків, на думку планувальника, поверне вузол і скільки це коштуватиме в умовних одиницях cost. Оцінка не є виміром: її видно і без виконання запиту.
- Статистика
- Дані про таблицю, з яких планувальник рахує оцінки: кількість рядків, кількість різних значень, найчастіші значення й гістограми. PostgreSQL оновлює її через ANALYZE і autovacuum, MySQL через ANALYZE TABLE і автоматичний перерахунок InnoDB, SQLite лише через ANALYZE.
Питання зі співбесід
EXPLAIN лише будує план і показує оцінки, нічого не виконуючи. EXPLAIN ANALYZE виконує запит по-справжньому і додає до кожного вузла фактичні рядки й час. Тому другий точніший, але над UPDATE чи DELETE він змінює дані, і його загортають у транзакцію з ROLLBACK.
База читає всю таблицю без індексу, оцінка майже мільйон рядків. Перевірте, чи є індекс на колонки з WHERE, чи не загорнута колонка у функцію, чи збігаються типи значення й колонки. Буває, що індекс є, але умова відбирає велику частку таблиці, і повне читання справді дешевше: тоді винна сама умова, а не індекс.
Index Scan ходить у таблицю за кожним рядком у порядку індексу, і одну сторінку може читати багато разів. Bitmap Index Scan спершу збирає адреси всіх рядків у бітмапу, а Bitmap Heap Scan читає сторінки таблиці по порядку й кожну один раз. Планувальник обирає його, коли рядків забагато для Index Scan, але замало для Seq Scan. Порядок індексу при цьому губиться, тож для ORDER BY потрібне окреме сортування.
Планувальник недооцінив кількість рядків і обрав вкладений цикл, вигідний для одного рядка. На 65 тисячах рядків це 65 тисяч пошуків по індексу замість одного Hash Join. Причина майже завжди в статистиці після масової зміни даних. Виконайте ANALYZE на таблиці (ANALYZE TABLE у MySQL), перевірте last_autoanalyze і n_mod_since_analyze у pg_stat_user_tables, а для таблиць з різкими змінами налаштуйте autovacuum_analyze_scale_factor або запускайте ANALYZE одразу після імпорту.