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

ІндексиB-tree · EXPLAIN

Індекс це впорядкована копія кількох колонок з адресами рядків. Замість того щоб перебирати мільйон рядків, база спускається деревом і робить кілька кроків.

17 хв читання Junior → Senior
01 · ЧОМУ БЕЗ ІНДЕКСУ ПОВІЛЬНО

Мільйон рядків заради одного

У PostgreSQL рядки таблиці лежать у купі (heap) приблизно в порядку вставки, в InnoDB вони впорядковані за первинним ключем. За email вони не впорядковані ніде. Щоб знайти користувача за email, базі нема на що спертися: вона робить , читає рядок за рядком до самого кінця таблиці, бо такий самий email може трапитися ще раз.

СЛОВНИКПовний перебір. Читання всієї таблиці рядок за рядком. У MySQL в EXPLAIN це type = ALL, у PostgreSQL вузол Seq Scan. На маленьких таблицях це нормальний і навіть найшвидший план.

Індекс зберігає значення колонки відсортованими, найчастіше у вигляді . Пошук починається з кореня, на кожному рівні відкидає більшу частину значень і за три-чотири кроки доходить до листка навіть на мільйонах рядків. У листку поруч зі значенням лежить адреса рядка: у PostgreSQL це місце рядка в купі, у вторинному індексі InnoDB це значення первинного ключа, за яким база спускається ще й .

СЛОВНИКB-tree. Збалансоване дерево сторінок: корінь, проміжні вузли й листки з відсортованими значеннями. Усі листки на однаковій глибині, тож пошук будь-якого значення займає однакову кількість кроків. Тип індексу за замовчуванням і в MySQL, і в PostgreSQL.
СЛОВНИККластерний індекс. В InnoDB рядки таблиці лежать у листках B-tree первинного ключа, окремої купи немає. Тому вторинний індекс веде не на адресу рядка, а на первинний ключ, і пошук за ним проходить два дерева.

Так само відсортованість допомагає діапазонам (>, BETWEEN), префіксу в LIKE 'olena%', ORDER BY і JOIN за проіндексованою колонкою. Та чи брати індекс, вирішує планувальник за : якщо умова повертає помітну частину таблиці, прочитати її підряд дешевше, ніж стрибати за кожним рядком окремо.

СЛОВНИКСелективність. Яку частку таблиці відбирає умова. email = ? відбирає один рядок з мільйона, і це висока селективність. status = 'active' може відібрати половину, і тоді індекс програє читанню підряд.
02 · B-TREE І ЛІЧИЛЬНИК

Спуск по дереву замість перебору

SELECT * FROM users WHERE email = 'olena@kvitka.ua';
usersбез індексу⚷ idemail1admin@kvitka.ua2taras@dnipro.dev3iryna@lviv.pay4max@odesa.cloud5anna@kvitka.ua6olena@kvitka.ua7petro@dnipro.dev+ ще 999 993 рядків
ПЕРЕВІРЕНО 1 000 000 рядків приклад: таблиця на 1 000 000 рядків
ПОВІЛЬНО Перебрано всю таблицю, щоб знайти один рядок: такий email міг трапитися ще раз. MySQL показує type = ALL, PostgreSQL Seq Scan.
SELECT * FROM users WHERE email = 'olena@kvitka.ua';
kd · hp · ta…cd…gh…jk…op…st…zusers⚷ idemailname6olena@kvitka.uaОлена+ ще 999 999 рядків
ПЕРЕВІРЕНО 4 кроки приклад: таблиця на 1 000 000 рядків
ШВИДКО Три сторінки дерева й один рядок таблиці. MySQL показує type = ref і key = users_email_index, PostgreSQL Index Scan using users_email_index.
SELECT * FROM people WHERE last_name = 'Шевченко';SELECT * FROM people WHERE first_name = 'Тарас';
ПЕРЕВІРЕНО 4 кроки приклад: таблиця на 1 000 000 рядків
ШВИДКО Прізвище перше в індексі: знаходимо одразу, як у телефонній книзі.
SELECT email FROM users WHERE email = 'olena@kvitka.ua';
kd · hp · ta…cd…gh…jolena@ ✓p…st…z
ПЕРЕВІРЕНО 3 сторінки індексу приклад: таблиця на 1 000 000 рядків
ШВИДКО Email уже лежить у листку індексу, тож у таблицю база не йде. MySQL пише Using index, PostgreSQL Index Only Scan.

Перемикайте «Без індексу», «З індексом», «Складений» і «Покриваючий». Числа це приклад для таблиці на мільйон рядків, а сценарій підготовлений заздалегідь: справжньої бази в браузері немає.

03 · СКЛАДЕНИЙ ІНДЕКС

Порядок колонок вирішує все

Індекс (company_id, status, published_at) впорядкований як телефонна книга: спершу за компанією, усередині компанії за статусом, далі за датою. Межі пошуку задають колонки зліва направо, поки в них рівність; перша колонка з діапазоном ще звужує пошук, а все правіше від неї база лише перевіряє. Це правило лівого префікса.

Звідси порядок для складеного індексу: спершу колонки, які порівнюють на рівність, потім та, що стоїть у діапазоні чи в ORDER BY.

INDEX (company_id, status, published_at)
WHERE company_id = 7 працює Перша колонка: пошук одразу. MySQL показує type = ref.
WHERE company_id = 7 AND status = 'active' працює Дві колонки зліва направо, без пропусків.
WHERE company_id = 7 AND status = 'active' ORDER BY published_at DESC LIMIT 20 без сортування Рядки вже лежать у потрібному порядку. MySQL пише Backward index scan, PostgreSQL Index Scan Backward, і окремого сортування немає.
WHERE company_id = 7 ORDER BY published_at DESC частково Пошук за company_id працює, а сортування ні: між ними пропущено status. MySQL пише Using filesort.
WHERE company_id = 7 AND published_at > … частково Межі задає лише company_id. Дату база перевіряє в самому індексі (MySQL: Using index condition), але читає всі записи компанії.
WHERE status = 'active' майже ні Пропущено першу колонку: як шукати в книзі лише за іменем. MySQL 8.0.13+ і PostgreSQL 18 інколи рятують це через skip scan, коли в першій колонці мало різних значень, але на це не варто розраховувати.

Кожен рядок перевірено через EXPLAIN на MySQL 8.0 і PostgreSQL 16 з мільйоном вакансій.

04 · ПОКРИВАЮЧИЙ ІНДЕКС

Коли база навіть не йде в таблицю

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

Вторинний індекс InnoDB тримає ще й первинний ключ, тож SELECT id, email покривається індексом на одну колонку email. У PostgreSQL зайві колонки додають через INCLUDE: вони лежать у листках, але в пошуку не беруть участі. І пастка PostgreSQL: видимість рядка він перевіряє за картою видимості (visibility map). Поки після UPDATE не пройшов VACUUM, Index Only Scan ходить у таблицю, і це видно в рядку Heap Fetches.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1CREATE INDEX users_email_index ON users (email);
2
3EXPLAIN SELECT id, email FROM users
4WHERE email = 'olena@kvitka.ua';
type: ref   key: users_email_index   rows: 1
Extra: Using index

Using index означає, що таблицю не читали. id потрапив у відповідь з індексу, бо InnoDB зберігає первинний ключ у кожному вторинному індексі.

1CREATE INDEX vacancies_company_id_include
2 ON vacancies (company_id) INCLUDE (status);
3
4EXPLAIN ANALYZE SELECT status FROM vacancies
5WHERE company_id = 7;
Index Only Scan using vacancies_company_id_include on vacancies
  Index Cond: (company_id = 7)
  Heap Fetches: 0
-- після UPDATE рядків цієї компанії, до VACUUM:
  Heap Fetches: 400

Heap Fetches рахує походи в таблицю. UPDATE знімає з сторінки позначку «усе видиме» в карті видимості, і покриття не дає виграшу, поки VACUUM або autovacuum її не поверне.

1CREATE INDEX users_email_index ON users (email);
2
3EXPLAIN QUERY PLAN SELECT id, email FROM users
4WHERE email = 'olena@kvitka.ua';
SEARCH users USING COVERING INDEX users_email_index (email=?)

SQLite так і пише в плані: COVERING INDEX. Колонка id INTEGER PRIMARY KEY тут це rowid, і він теж лежить в індексі.

05 · УНІКАЛЬНИЙ ІНДЕКС

Унікальний індекс як арбітр

Унікальний індекс ще й гарантує, що двох однакових значень у таблиці не буде. На цьому стоїть upsert: вставити рядок, а якщо такий ключ уже є, оновити наявний. Приклад: лічильник переглядів сторінки за день з первинним ключем (page_id, day). Перший запит вставляє рядок, другий натрапляє на ключ і збільшує hits.

Синтаксис у баз різний. Метод upsert() у Laravel збирає потрібний сам: ON DUPLICATE KEY UPDATE для MySQL і ON CONFLICT для PostgreSQL та SQLite.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1INSERT INTO page_views (page_id, day, hits)
2VALUES (42, '2026-10-05', 1) AS new
3ON DUPLICATE KEY UPDATE hits = page_views.hits + new.hits;
Query OK, 1 row affected    -- перший раз, вставка
Query OK, 2 rows affected   -- далі, оновлення

Спрацьовує на будь-якому унікальному ключі таблиці, вибрати конкретний не можна. Оновлення рахується як 2 rows affected. Аліас нового рядка AS new зʼявився в MySQL 8.0.19, до того писали VALUES(hits).

1INSERT INTO page_views (page_id, day, hits)
2VALUES (42, '2026-10-05', 1)
3ON CONFLICT (page_id, day)
4DO UPDATE SET hits = page_views.hits + EXCLUDED.hits;
INSERT 0 1    -- вставка
INSERT 0 1    -- оновлення

Конфлікт називають явно: колонками унікального індексу або ON CONSTRAINT з назвою обмеження. EXCLUDED це рядок, який намагалися вставити.

1INSERT INTO page_views (page_id, day, hits)
2VALUES (42, '2026-10-05', 1)
3ON CONFLICT (page_id, day)
4DO UPDATE SET hits = hits + excluded.hits;
changes() = 1 і для вставки, і для оновлення

Синтаксис як у PostgreSQL, SQLite знає його з версії 3.24. Тому тести на SQLite і продакшн на PostgreSQL ганяють той самий upsert.

06 · ЯК ПРОЧИТАТИ ЦЕ В EXPLAIN

Пʼять полів, на які дивитися першими

EXPLAIN перед запитом показує план: яким шляхом база збирається читати дані. EXPLAIN ANALYZE ще й виконує запит і додає справжні рядки й час, тож UPDATE чи DELETE під ним загортайте в BEGIN і ROLLBACK. Нижче той самий пошук за email без індексу, з індексом і з покриваючим індексом.

без індексуSELECT * FROM users WHERE email = 'olena@kvitka.ua';
typekeyrowsfilteredExtra
ALLNULL99583810.00Using where
з індексомSELECT * FROM users WHERE email = 'olena@kvitka.ua';
typekeyrowsfilteredExtra
refusers_email_index1100.00NULL
покриваючийSELECT id, email FROM users WHERE email = 'olena@kvitka.ua';
typekeyrowsfilteredExtra
refusers_email_index1100.00Using index

MySQL 8.0.46, InnoDB, таблиця на 1 000 000 рядків. Вивід знято зі справжньої бази, частину колонок прибрано. З MySQL 8.0.18 є й EXPLAIN ANALYZE з фактичним часом у вигляді дерева.

без індексуSELECT * FROM users WHERE email = 'olena@kvitka.ua';
Gather  (cost=1000.00..15544.43 rows=1 width=41) (actual time=34.430..40.367 rows=1 loops=1)
  Workers Planned: 2
  ->  Parallel Seq Scan on users  (cost=0.00..14544.33 rows=1 width=41) (actual time=28.857..28.857 rows=0 loops=3)
        Filter: ((email)::text = 'olena@kvitka.ua'::text)
        Rows Removed by Filter: 333333
Execution Time: 40.389 ms
з індексомSELECT * FROM users WHERE email = 'olena@kvitka.ua';
Index Scan using users_email_index on users  (cost=0.42..8.44 rows=1 width=41) (actual time=0.040..0.041 rows=1 loops=1)
  Index Cond: ((email)::text = 'olena@kvitka.ua'::text)
Execution Time: 0.059 ms
покриваючийSELECT email FROM users WHERE email = 'olena@kvitka.ua';
Index Only Scan using users_email_index on users  (cost=0.42..8.44 rows=1 width=22) (actual time=0.017..0.017 rows=1 loops=1)
  Index Cond: (email = 'olena@kvitka.ua'::text)
  Heap Fetches: 0
Execution Time: 0.027 ms

PostgreSQL 16, таблиця на 1 000 000 рядків. Вивід знято зі справжньої бази, рядки Planning Time прибрано.

type
Як база дістає рядки. ALL це перебір таблиці, index перебір усього індексу, а range, ref, eq_ref і const пошук по індексу, від ширшого до точнішого.
key
Індекс, який вибрав оптимізатор. NULL означає, що жодного. Поруч у possible_keys видно кандидатів.
rows
Оцінка, скільки рядків доведеться прочитати, а не скільки повернеться. 995 838 на мільйонній таблиці це майже вся вона.
filtered
Яку частку прочитаних рядків, за оцінкою, пропустить решта умови WHERE, у відсотках.
Extra
Using index: відповідь з самого індексу. Using filesort: окремий крок сортування. Using index condition: частину умови перевірено в індексі.
Seq Scan
Перебір таблиці. Parallel означає, що його ділять кілька процесів, але читають вони все одно все.
Index Scan
Пошук по індексу з походом у таблицю за кожним рядком. Index Only Scan обходиться без таблиці, а Bitmap Heap Scan спершу збирає адреси з індексу й потім читає сторінки таблиці по порядку.
cost
Оцінка планувальника в умовних одиницях: до першого рядка і до останнього. Порівнюйте її між планами одного запиту, а не з мілісекундами.
rows
Біля cost це оцінка, біля actual скільки рядків вийшло насправді. Велика різниця між ними підказує, що статистика застаріла і час виконати ANALYZE.
Rows Removed by Filter
Скільки прочитаних рядків відкинула умова. Тут 333 333 на кожен з трьох процесів заради одного знайденого рядка.
07 · ІНДЕКСИ В МІГРАЦІЯХ LARAVEL

Як оголосити індекс у міграції

Назву індексу Laravel складає сам: таблиця, колонки й тип через підкреслення. Саме цю назву ви потім побачите в EXPLAIN у полі key чи в рядку Index Scan using.

php create_vacancies_table.php Одна колонка
1Schema::create('users', function (Blueprint $table) {
2 $table->id();
3 $table->string('email')->unique(); // users_email_unique
4 $table->timestampTz('last_login_at')->index(); // users_last_login_at_index
5});
ЩО ПЕРЕВІРЯЄ
index() і unique() одразу після колонки створюють індекс на неї саму. unique() ще й не пустить другий такий самий email.
КОЛИ БРАТИ
Для колонок, за якими шукають точно або діапазоном: email, slug, дата.
php create_vacancies_table.php Складений
1// Порядок у масиві це порядок колонок в індексі.
2$table->index(['status', 'published_at']); // vacancies_status_published_at_index
ЩО ПЕРЕВІРЯЄ
Один індекс на кілька колонок у заданому порядку. Ліва частина (status) працює і сама, правіша (published_at) без лівої ні.
КОЛИ БРАТИ
Коли запит фільтрує за кількома колонками разом: спершу колонка з рівністю, потім та, що в діапазоні або в ORDER BY.
php create_vacancies_table.php Унікальний складений
1$table->unique(['source', 'source_id']); // vacancies_source_source_id_unique
ЩО ПЕРЕВІРЯЄ
Пара значень не може повторитися: друга вакансія з тим самим джерелом і id у джерелі не запишеться. На такому ключі тримається upsert.
КОЛИ БРАТИ
Для природних ключів: email, slug, пара «джерело й id у ньому».
php create_vacancies_table.php Зовнішній ключ
1$table->foreignId('company_id')->nullable()
2 ->constrained()->nullOnDelete();
3
4// PostgreSQL індекс під зовнішній ключ не створює.
5$table->index('company_id'); // vacancies_company_id_index
ЩО ПЕРЕВІРЯЄ
constrained() додає лише обмеження FOREIGN KEY. InnoDB вимагає індекс під зовнішній ключ і створює його сам, якщо відповідного немає. PostgreSQL індекс на колонці, яка посилається, не створює.
КОЛИ БРАТИ
Явний індекс потрібен у PostgreSQL, коли за колонкою шукають або коли з батьківської таблиці видаляють рядки: інакше кожне видалення компанії переглядає всі вакансії.
php create_vacancies_table.php Вираз і без блокування
1// Для WHERE lower(email) = ?. Подвійні дужки потрібні MySQL.
2$table->rawIndex('(lower(email))', 'users_email_lower_index');
3
4// PostgreSQL: CREATE INDEX CONCURRENTLY, запис не блокується.
5// У класі міграції: public $withinTransaction = false;
6$table->index('published_at')->online();
ЩО ПЕРЕВІРЯЄ
rawIndex() бере SQL-вираз і обовʼязкову назву. online() у PostgreSQL дописує CONCURRENTLY. Але CONCURRENTLY не працює всередині транзакції, а міграції на PostgreSQL Laravel загортає в транзакцію, тому таку міграцію виносять окремо з $withinTransaction = false.
КОЛИ БРАТИ
Вираз: коли в WHERE функція над колонкою. online(): коли індекс додають до великої таблиці на живому сайті. Для MySQL є ->lock('none').
08 · ЯК У PHP UKRAINE

Індекси таблиці вакансій

Таблиця vacancies оголошує індекси у своїй міграції. Унікальний (source, source_id) знаходить вакансію джерела під час синхронізації: findBySourceRef шукає рівно за цими двома колонками. Складений (status, published_at) іде в порядку «рівність, потім дата», як велить правило лівого префікса. company_id оголошено через constrained(), і на MySQL під ним був би індекс, а на PostgreSQL, де працює сайт, окремого індексу під цією колонкою немає. У таблиці applications навпаки: ліві частини складених індексів (company_id, status) і (vacancy_id, email) покривають обидва її зовнішні ключі.

php 2026_09_02_000002_create_vacancies_table.php фрагмент, скорочено
1Schema::create('vacancies', function (Blueprint $table) {
2 $table->id();
3 $table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();← без індексу в PostgreSQL
4
5 $table->string('source', 20);
6 $table->string('source_id', 64);
7 $table->string('fingerprint', 500)->nullable()->index();← пошук дублів з інших джерел
8
9 $table->timestampTz('published_at')->index();← vacancies_published_at_index
10 $table->string('status', 20)->default('active');
11
12 $table->unique(['source', 'source_id']);← vacancies_source_source_id_unique
13 $table->index(['status', 'published_at']);← vacancies_status_published_at_index
14});
src/Hiring/Infrastructure/Persistence/EloquentJobRepository.phpfindBySourceRef: where source і where source_id, рівно колонки унікального індексу й у тому самому порядку.
database/migrations/2026_09_04_000001_add_employer_cabinet.phpapplications має індекси (company_id, status) і (vacancy_id, email). Їхні ліві колонки працюють і як індекси під зовнішні ключі, тож окремі на company_id і vacancy_id не потрібні.
src/Hiring/Infrastructure/Persistence/EloquentApplicationRepository.phpexists() шукає повторний відгук за vacancy_id і email, тобто за обома колонками індексу. forCompany() бере ліву колонку company_id, а за created_at сортує окремим кроком.
src/Hiring/Infrastructure/Catalog/EloquentJobCatalog.phpКаталог показує видимі вакансії, найсвіжіші першими: status IN ('active', 'stale') і ORDER BY published_at DESC, id DESC.
09 · КОЛИ ІНДЕКС ШКОДИТЬ

Кожен індекс оплачується записом

✕ 1

Індекс на кожну колонку з WHERE

Кожен INSERT і DELETE оновлює всі індекси таблиці, а UPDATE ті, що містять змінені колонки. Десяток індексів помітно сповільнює запис і займає місце на диску та в кеші.

Натомість: Індекс під конкретний частий запит. Невикористані шукайте в pg_stat_user_indexes (idx_scan = 0) у PostgreSQL і в sys.schema_unused_indexes у MySQL.

✕ 2

Індекс на колонку з двома значеннями

is_active чи status з парою значень відбирають половину таблиці. Прочитати її підряд дешевше, ніж стрибати за кожним рядком, тож планувальник такий індекс ігнорує.

Натомість: Якщо шукають рідкісне значення, у PostgreSQL допоможе частковий індекс: CREATE INDEX … WHERE is_test. У MySQL часткових індексів немає, там таку колонку ставлять другою в складений.

✕ 3

Функція над колонкою

WHERE lower(email) = ? чи WHERE DATE(created_at) = ? не використають звичайний індекс: в індексі лежать самі значення, а не результат функції.

Натомість: Порівнюйте колонку з діапазоном (created_at >= '2026-10-01' AND created_at < '2026-10-02') або додайте індекс за виразом через rawIndex().

✕ 4

LIKE з відсотком на початку

B-tree допомагає LIKE 'olena%', бо це діапазон за префіксом, і ніколи LIKE '%olena%'. У PostgreSQL навіть префікс працює лише з локаллю C або з індексом на text_pattern_ops.

Натомість: Для пошуку всередині тексту беріть повнотекстовий індекс (fullText() у Laravel), pg_trgm у PostgreSQL або окремий пошуковий рушій.

✕ 5

Дубль лівого префікса

Індекс (company_id) зайвий, якщо вже є (company_id, status): ліва частина складеного працює сама, а другий індекс лише сповільнює запис.

Натомість: Перед новим індексом подивіться наявні: SHOW INDEX FROM vacancies у MySQL, \d vacancies у psql.

10 · СЛОВНИК
Повний перебір
Читання всієї таблиці рядок за рядком. У MySQL в EXPLAIN це type = ALL, у PostgreSQL вузол Seq Scan. На маленьких таблицях це нормальний і навіть найшвидший план.
B-tree
Збалансоване дерево сторінок: корінь, проміжні вузли й листки з відсортованими значеннями. Усі листки на однаковій глибині, тож пошук будь-якого значення займає однакову кількість кроків. Тип індексу за замовчуванням і в MySQL, і в PostgreSQL.
Кластерний індекс
В InnoDB рядки таблиці лежать у листках B-tree первинного ключа, окремої купи немає. Тому вторинний індекс веде не на адресу рядка, а на первинний ключ, і пошук за ним проходить два дерева.
Селективність
Яку частку таблиці відбирає умова. email = ? відбирає один рядок з мільйона, і це висока селективність. status = 'active' може відібрати половину, і тоді індекс програє читанню підряд.
11 · ПЕРЕВІР СЕБЕ

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

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

Кожна вставка має покласти нове значення в правильне місце дерева, тобто оновити індекс, а іноді й розділити сторінку. Читання виграє, запис платить. Тому індексів має бути стільки, скільки є частих запитів, а не стільки, скільки колонок.

Майже ні. Дерево впорядковане спершу за company_id, а status всередині кожної компанії, тож без першої колонки нема звідки почати пошук. MySQL 8.0.13+ і PostgreSQL 18 інколи застосовують skip scan, коли різних company_id мало, але надійне рішення це окремий індекс на status або інший порядок колонок, якщо запитів за самим status більше.

Найчастіше одна з чотирьох причин. Умова повертає велику частку таблиці, і читати підряд дешевше. Колонка загорнута у функцію чи в неї порівнюють значення іншого типу. LIKE починається з відсотка. Або статистика застаріла після масового завантаження, і її оновлює ANALYZE у PostgreSQL чи ANALYZE TABLE у MySQL.

Подивіться на Heap Fetches. Видимість рядка зберігається в таблиці, а не в індексі, тож Index Only Scan пропускає таблицю лише для сторінок, позначених у карті видимості як «усе видиме». Після масових UPDATE чи INSERT позначки злітають, і кожен рядок знову читається з таблиці. Допомагає VACUUM, а на гарячих таблицях агресивніші налаштування autovacuum.