<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · ПРОДУКТИВНІСТЬ

EXPLAINQuery plans

EXPLAIN показує, яким шляхом база збирається виконати запит, а EXPLAIN ANALYZE виконує його і дописує, що сталося насправді. Між цими двома числами і ховається більшість повільних запитів.

18 хв читання Junior → Senior
01 · ЩО ПОКАЗУЄ ПЛАН

Запит, яким його бачить база

SQL каже, що потрібно отримати, а не як. Як саме, вирішує планувальник: яку таблицю читати першою, яким індексом, яким способом зʼєднувати й сортувати. Результат його роботи це , дерево кроків, де кожен вузол отримує рядки від дочірніх і віддає батьківському.

СЛОВНИКВузол плану. Один крок виконання: сканування таблиці чи індексу, зʼєднання, сортування, агрегат, LIMIT. У PostgreSQL кожен рядок плану зі стрілкою ->, у MySQL кожен рядок формату TREE. Дочірні вузли виконуються першими.

Обирає планувальник за : скільки рядків поверне кожен крок і скільки це коштуватиме. Оцінки беруться зі , яку база збирає про таблиці. EXPLAIN показує план і оцінки, нічого не виконуючи. EXPLAIN ANALYZE виконує запит по-справжньому і поруч з оцінкою пише фактичні рядки й час кожного вузла.

СЛОВНИКОцінка. Скільки рядків, на думку планувальника, поверне вузол і скільки це коштуватиме в умовних одиницях cost. Оцінка не є виміром: її видно і без виконання запиту.
СЛОВНИКСтатистика. Дані про таблицю, з яких планувальник рахує оцінки: кількість рядків, кількість різних значень, найчастіші значення й гістограми. PostgreSQL оновлює її через ANALYZE і autovacuum, MySQL через ANALYZE TABLE і автоматичний перерахунок InnoDB, SQLite лише через ANALYZE.

Тому EXPLAIN ANALYZE над UPDATE чи DELETE справді змінює дані: загортайте його в BEGIN і ROLLBACK. А читати план варто з питанням «де оцінка розійшлася з фактом і де база прочитала набагато більше рядків, ніж віддала».

02 · ДЕРЕВО ПЛАНУ

Як читати 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.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1EXPLAIN ANALYZE
2SELECT c.name, v.title
3FROM vacancies v
4JOIN companies c ON c.id = v.company_id
5WHERE v.status = 'active'
6 AND v.published_at >= '2026-09-25';
-> 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.

1EXPLAIN (ANALYZE, BUFFERS)
2SELECT c.name, v.title
3FROM vacancies v
4JOIN companies c ON c.id = v.company_id
5WHERE v.status = 'active'
6 AND v.published_at >= '2026-09-25';
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.

1EXPLAIN QUERY PLAN
2SELECT c.name, v.title
3FROM vacancies v
4JOIN companies c ON c.id = v.company_id
5WHERE v.status = 'active'
6 AND v.published_at >= '2026-09-25';
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 видає байткод віртуальної машини, а не план.

03 · ОЦІНКА ПРОТИ ФАКТУ

Коли статистика бреше

Планувальник обирає план за оцінками, тому неправильна оцінка дає неправильний план. Найчастіша причина це статистика, зібрана до масової зміни даних: імпорту, міграції, UPDATE на пів таблиці. Autovacuum у PostgreSQL і перерахунок у InnoDB наздоганяють не одразу.

Нижче в PostgreSQL 65 031 вакансію перевели в статус pending, якого в статистиці ще не було. Планувальник чекає один рядок і обирає вкладений цикл з пошуком компанії для кожної вакансії. Після ANALYZE він бачить 65 300 рядків і переходить на Hash Join, і запит стає майже вчетверо швидшим.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1UPDATE vacancies SET status = 'pending'
2WHERE id % 10 = 0 AND status = 'closed'; -- 65 031 рядок
3
4EXPLAIN ANALYZE
5SELECT v.id, v.title, c.name
6FROM vacancies v JOIN companies c ON c.id = v.company_id
7WHERE v.status = 'pending';
8
9ANALYZE vacancies; -- і той самий EXPLAIN ANALYZE ще раз
-- до 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.

1EXPLAIN ANALYZE
2SELECT title, count(*) FROM vacancies
3WHERE status = 'active'
4GROUP BY title ORDER BY count(*) DESC;
5
6-- той самий запит з IGNORE INDEX (vacancies_status_published_at_index)
-> 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.

1CREATE INDEX vacancies_company_id_status_published_at_index
2 ON vacancies (company_id, status, published_at);
3
4EXPLAIN QUERY PLAN SELECT * FROM vacancies
5WHERE status = 'active' AND published_at > '2026-09-25';
6
7ANALYZE; -- і той самий план ще раз
-- до 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.

04 · ЯК ЧИТАТИ ПЛАН

Сім запитів і що про них каже EXPLAIN

Таблиця vacancies на мільйон рядків з індексами на id, company_id і (status, published_at). У MySQL класичний EXPLAIN це таблиця, по рядку на кожну таблицю запиту. У PostgreSQL план одразу дерево, і з ANALYZE та BUFFERS він показує фактичні рядки, час і прочитані сторінки.

за idSELECT * FROM vacancies WHERE id = 42;
typekeyrowsfilteredExtra
constPRIMARY1100.00NULL
за компанієюSELECT * FROM vacancies WHERE company_id = 7;
typekeyrowsfilteredExtra
refvacancies_company_id_index505100.00NULL
діапазонSELECT * FROM vacancies WHERE status = 'active' AND published_at >= '2026-09-25';
typekeyrowsfilteredExtra
rangevacancies_status_published_at_index2058100.00Using index condition
покриваючийSELECT status, published_at FROM vacancies WHERE status = 'active' AND published_at >= '2026-09-25';
typekeyrowsfilteredExtra
rangevacancies_status_published_at_index2058100.00Using where; Using index
сортуванняSELECT * FROM vacancies WHERE company_id = 7 ORDER BY salary_usd_to DESC LIMIT 20;
typekeyrowsfilteredExtra
refvacancies_company_id_index505100.00Using filesort
групуванняSELECT title, count(*) FROM vacancies WHERE status = 'active' GROUP BY title ORDER BY count(*) DESC;
typekeyrowsfilteredExtra
refvacancies_status_published_at_index498257100.00Using temporary; Using filesort
без індексуSELECT * FROM vacancies WHERE title = 'Team Lead';
typekeyrowsfilteredExtra
ALLNULL99651510.00Using where

MySQL 8.0.46, InnoDB, таблиця на 1 000 000 рядків. Вивід знято зі справжньої бази, частину колонок прибрано.

Seq ScanSELECT * 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
Index ScanSELECT * 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
Bitmap Heap ScanSELECT * 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
Index Only ScanSELECT 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 з диска. Час залежить від того, чи прогрітий кеш, а кількість сторінок ні, тож порівнювати плани зручніше за нею.
05 · SQL З ELOQUENT

Як дістати запит, який збудував Laravel

Перш ніж читати план, потрібен сам запит. Eloquent збирає його з методів, і найпростіше подивитися на готовий SQL або зловити всі запити сторінки. Вивід нижче знято з Laravel 13 на MySQL 8.0.

php VacancyQuery.php toRawSql()
1$query = Vacancy::query()
2 ->where('company_id', 7)
3 ->where('status', 'active')
4 ->orderByDesc('published_at')
5 ->limit(20);
6
7$query->toSql();
8// select * from `vacancies` where `company_id` = ? and `status` = ?
9// order by `published_at` desc limit 20
10
11$query->toRawSql();
12// select * from `vacancies` where `company_id` = 7 and `status` = 'active'
13// order by `published_at` desc limit 20
ЩО ПЕРЕВІРЯЄ
toSql() показує запит із плейсхолдерами, toRawSql() підставляє значення з лапками потрібного діалекту, і такий рядок можна вставити просто в EXPLAIN. dumpRawSql() і ddRawSql() роблять те саме з виводом.
КОЛИ БРАТИ
Коли треба зрозуміти, що саме збудував ланцюжок методів, перед тим як шукати причину повільності.
php VacancyQuery.php explain()
1$query->explain()->all();
2// [{ "type": "ref",
3// "key": "vacancies_company_id_index",
4// "rows": 505, "filtered": 49.99,
5// "Extra": "Using where; Using filesort" }]
ЩО ПЕРЕВІРЯЄ
Виконує EXPLAIN перед toSql() з тими самими bindings і повертає колекцію рядків. Це саме EXPLAIN, без ANALYZE: оцінки без фактичних чисел. У PostgreSQL кожен елемент колекції це один рядок плану в полі QUERY PLAN.
КОЛИ БРАТИ
Для швидкої перевірки з tinker чи тесту, чи запит бере індекс. Тут видно, що MySQL обрав індекс на company_id і сортує окремо.
php VacancyQuery.php DB::listen()
1// AppServiceProvider::boot(), лише локально
2if (app()->isLocal()) {
3 DB::listen(function (QueryExecuted $query): void {
4 logger()->debug($query->toRawSql().' -- '.$query->time.' ms');
5 });
6}
ЩО ПЕРЕВІРЯЄ
Викликається після кожного запиту з SQL, bindings і часом у мілісекундах. Так видно всі запити сторінки, зокрема ті, що Eloquent робить сам при доступі до звʼязку.
КОЛИ БРАТИ
Щоб знайти, який із десятків запитів сторінки повільний або чому їх стільки. Повільні можна ловити й окремо через DB::whenQueryingForLongerThan().
php VacancyQuery.php Журнал запитів
1DB::enableQueryLog();
2
3$catalog->page($filter, 1);
4
5$queries = DB::getQueryLog();
6// [['query' => 'select count(*) ...', 'bindings' => [...], 'time' => 45.43], ...]
ЩО ПЕРЕВІРЯЄ
Складає всі запити в масив у памʼяті. Зручно в тесті, щоб перевірити кількість запитів чи отримати SQL для EXPLAIN.
КОЛИ БРАТИ
У тестах і одноразових скриптах. У довгих процесах журнал росте без меж, тому вимикайте його через DB::disableQueryLog().
06 · ЯК У PHP UKRAINE

Каталог вакансій під 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. На справжній базі сайту рядків менше, і план може бути іншим.

php EloquentJobCatalog.php фрагмент, скорочено
1public function page(JobFilter $filter, int $page, int $perPage = 20): CatalogPage
2{
3 $page = max(1, $page);
4
5 $query = $this->query($filter)->with('company:id,name,slug,kind,logo_url');
6
7 match ($filter->sort) {
8 JobSort::Salary => $query->orderByRaw('salary_usd_to DESC NULLS LAST')->orderByDesc('published_at'),
9 default => $query->orderByDesc('published_at')->orderByDesc('id'),← Incremental Sort за id
10 };
11
12 $total = (clone $query)->count();← Index Only Scan
13
14 $models = $query->forPage($page, $perPage)->get();← limit 20 offset 40 на третій сторінці
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 бере перший.
07 · ТИПОВІ ПОМИЛКИ

Де план читають неправильно

✕ 1

Порівнювати cost з мілісекундами

cost це умовні одиниці планувальника, а не час. cost=17331 не означає 17 секунд, і порівнювати його між різними запитами чи різними базами марно.

Натомість: cost порівнюйте лише між планами одного запиту. Для часу дивіться actual time і Execution Time в EXPLAIN ANALYZE.

✕ 2

Читати rows як результат

rows у MySQL EXPLAIN і rows біля cost у PostgreSQL це оцінка, скільки рядків буде прочитано на кроці. Фактична кількість буває інакшою в рази.

Натомість: Беріть EXPLAIN ANALYZE і порівнюйте оцінку з actual rows у кожному вузлі. Велика різниця вказує на статистику, з якої почати.

✕ 3

Забути про loops

У PostgreSQL actual time і rows вказані на одну ітерацію. Внутрішній вузол вкладеного циклу з actual time=0.001 і loops=65031 насправді працював 65 мілісекунд.

Натомість: Множте actual time і rows на loops. У MySQL дерево EXPLAIN ANALYZE так само показує loops.

✕ 4

EXPLAIN ANALYZE над DELETE

EXPLAIN ANALYZE виконує запит. Над UPDATE чи DELETE на продакшні він змінить дані так само, як звичайний запит.

Натомість: BEGIN; EXPLAIN ANALYZE DELETE ...; ROLLBACK; або перевіряйте на копії бази.

✕ 5

План з порожньої бази

На локальній базі з сотнею рядків Seq Scan найшвидший план, і EXPLAIN там нічого не скаже про продакшн з мільйоном рядків.

Натомість: Перевіряйте план на даних реального обсягу: на копії продакшну чи на згенерованій таблиці з таким самим розподілом значень.

08 · СЛОВНИК
Вузол плану
Один крок виконання: сканування таблиці чи індексу, зʼєднання, сортування, агрегат, LIMIT. У PostgreSQL кожен рядок плану зі стрілкою ->, у MySQL кожен рядок формату TREE. Дочірні вузли виконуються першими.
Оцінка
Скільки рядків, на думку планувальника, поверне вузол і скільки це коштуватиме в умовних одиницях cost. Оцінка не є виміром: її видно і без виконання запиту.
Статистика
Дані про таблицю, з яких планувальник рахує оцінки: кількість рядків, кількість різних значень, найчастіші значення й гістограми. PostgreSQL оновлює її через ANALYZE і autovacuum, MySQL через ANALYZE TABLE і автоматичний перерахунок InnoDB, SQLite лише через ANALYZE.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

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 одразу після імпорту.