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

Складені індексиComposite indexes

Складений індекс сортує рядки за кількома колонками одразу, як телефонна книга сортує людей спершу за прізвищем, потім за іменем. Він швидкий для запитів, що йдуть по його колонках зліва направо, і майже марний для решти.

16 хв читання Junior → Senior
01 · ТЕЛЕФОННА КНИГА

Одне дерево на кілька колонок

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

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

Звідси . Усі вакансії компанії 7 лежать в індексі одним шматком, і знайти їх легко. Вакансії компанії 7 зі статусом active теж лежать поруч і вже впорядковані за датою. А всі active вакансії розкидані по двох тисячах компаній, бо статус відсортований лише всередині кожної з них. Що довший префікс колонок зліва фіксує умова, то менший шматок індексу читає база.

СЛОВНИКЛівий префікс. Перші колонки індексу підряд, без пропусків. Індекс (a, b, c) допомагає умовам на a, на a і b, на a, b і c. Умові лише на b чи лише на c він допомагає не більше, ніж телефонна книга пошуку за іменем.

Префікс обривається на першому . Поки колонки порівнюють на рівність, кожна наступна звужує пошук. Колонка з >, < чи BETWEEN звужує його востаннє, а все правіше база лише перевіряє в записах, які вже знайшла. Тому в складеному індексі спершу стоять колонки з рівністю, потім колонка з діапазоном або та, за якою сортують.

СЛОВНИКДіапазон. Умова, яка відбирає проміжок значень: >, <, BETWEEN, LIKE 'abc%'. Після неї наступні колонки індексу вже не звужують пошук, бо в межах проміжку вони не впорядковані.
02 · ЗАПИТ → ІНДЕКС

Вісім запитів до одного індексу

Таблиця vacancies на мільйон рядків: 2 000 компаній, у кожної близько 500 вакансій, три статуси. Індекс один, (company_id, status, published_at). У MySQL дивимося на type, key_len і Extra, у PostgreSQL на вузли плану й Buffers.

INDEX (company_id, status, published_at)
WHERE company_id = 7 працює Ліва колонка. MySQL: type = ref, 505 рядків. PostgreSQL читає той самий індекс через Bitmap Index Scan.
WHERE company_id = 7 AND status = 'active' працює Дві колонки зліва без пропусків: 121 рядок замість 505. key_len у MySQL виріс з 9 до 91 байта, тобто в пошуку беруть участь обидві колонки.
WHERE company_id = 7 AND status = 'active' ORDER BY published_at DESC LIMIT 20 без сортування Записи вже лежать за датою. MySQL читає індекс з кінця (Backward index scan), PostgreSQL робить Index Scan Backward і зупиняється на двадцятому рядку: 23 сторінки замість 501.
WHERE company_id = 7 AND status IN ('active', 'stale') ORDER BY published_at DESC LIMIT 20 частково IN працює як дві рівності: пошук звужується до двох шматків індексу. Але кожен шматок відсортований окремо, тож разом їх сортують: MySQL пише Using filesort, у PostgreSQL зʼявляється вузол Sort.
WHERE company_id = 7 ORDER BY published_at DESC LIMIT 20 частково Пошук за company_id працює, сортування ні: між ними пропущено status, і за датою записи впорядковані лише всередині кожного статусу. MySQL: Using filesort, PostgreSQL: Sort після Bitmap Heap Scan.
WHERE company_id = 7 AND published_at > '2026-06-01' частково Межі пошуку задає лише company_id: key_len 9 і Using index condition. Дату MySQL перевіряє в записах індексу, але читає всі 505 записів компанії, щоб віддати 98.
WHERE company_id > 1990 AND status = 'active' частково Діапазон на першій колонці обриває префікс, і status уже лише перевіряється. MySQL: type = range, key_len 9, оцінка 9 074 записи індексу з filtered 10.00.
WHERE status = 'active' AND published_at > '2026-09-25' майже ні Пропущено першу колонку. Для SELECT * обидві бази читають усю таблицю: type = ALL і Seq Scan. Коли запит просить лише колонки індексу, MySQL вмикає skip scan, але все одно читає 250 310 записів заради 2 058.

MySQL 8.0.46 і PostgreSQL 16, таблиця на 1 000 000 рядків. Числа взято з EXPLAIN і EXPLAIN ANALYZE, на ваших даних вони будуть іншими.

03 · РІВНІСТЬ ПЕРЕД ДІАПАЗОНОМ

Ті самі колонки в іншому порядку

Запит «вакансії компанії 7 з першого червня»: одна колонка з рівністю, одна з діапазоном. Два індекси з тими самими колонками в різному порядку дають ту саму відповідь, 98 рядків, але читають зовсім різну кількість записів.

Коли першою стоїть дата, база знаходить у дереві перше червня і йде праворуч до кінця, перевіряючи company_id у кожному записі: це вакансії всіх компаній з червня. Коли першою стоїть компанія, база одразу стає на пару «компанія 7, перше червня» і читає лише потрібне.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1CREATE INDEX vacancies_published_at_company_id_index
2 ON vacancies (published_at, company_id);
3CREATE INDEX vacancies_company_id_published_at_index
4 ON vacancies (company_id, published_at);
5
6EXPLAIN ANALYZE SELECT id FROM vacancies
7 FORCE INDEX (vacancies_published_at_company_id_index)
8WHERE company_id = 7 AND published_at >= '2026-06-01';
-- (published_at, company_id)
-> Covering index range scan on vacancies using vacancies_published_at_company_id_index
   over ('2026-06-01 00:00:00.000000' <= published_at AND 7 <= company_id)
   (cost=1.21 rows=1) (actual time=0.082..39 rows=167153 loops=1)

-- (company_id, published_at)
-> Covering index range scan on vacancies using vacancies_company_id_published_at_index
   over (company_id = 7 AND '2026-06-01 00:00:00.000000' <= published_at)
   (cost=1.21 rows=1) (actual time=0.0263..0.0514 rows=98 loops=1)

actual rows у вузлі сканування рахує прочитані записи індексу: 167 153 проти 98. Оцінка rows=1 в обох планах однакова й неправдива, тому порядок колонок перевіряють EXPLAIN ANALYZE, а не самим EXPLAIN.

1EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
2SELECT id FROM vacancies
3WHERE company_id = 7 AND published_at >= '2026-06-01';
-- лише (published_at, company_id)
Index Scan using vacancies_published_at_company_id_index on vacancies
    (actual time=0.131..8.848 rows=98 loops=1)
  Index Cond: ((published_at >= '2026-06-01 00:00:00'::timestamp without time zone) AND (company_id = 7))
  Buffers: shared hit=99 read=644

-- лише (company_id, published_at)
Bitmap Heap Scan on vacancies (actual time=0.055..0.164 rows=98 loops=1)
  Heap Blocks: exact=96
  Buffers: shared hit=96 read=3
  ->  Bitmap Index Scan on vacancies_company_id_published_at_index
        Index Cond: ((company_id = 7) AND (published_at >= '2026-06-01 00:00:00'::timestamp without time zone))
        Buffers: shared read=3

Index Cond в обох планах містить обидві колонки, але межі пошуку задають лише рівності зліва й перший діапазон. Різницю видно в Buffers: 743 сторінки проти 3 сторінок індексу й 96 сторінок таблиці.

1EXPLAIN QUERY PLAN SELECT id FROM vacancies
2 INDEXED BY vacancies_published_at_company_id_index
3WHERE company_id = 7 AND published_at >= '2026-06-01';
-- (published_at, company_id)
SEARCH vacancies USING COVERING INDEX vacancies_published_at_company_id_index (published_at>?)
-- (company_id, published_at)
SEARCH vacancies USING COVERING INDEX vacancies_company_id_published_at_index (company_id=? AND published_at>?)

SQLite прямо пише, що стало межами пошуку: у першому індексі лише published_at>?, у другому company_id=? AND published_at>?.

04 · SKIP SCAN

Коли пропущену колонку перестрибують

Skip scan обходить пропущену першу колонку: для кожного її значення база робить окремий пошук за рештою умови, наче до запиту дописали company_id = 1, company_id = 2 і так далі. Вигідно це, коли різних значень першої колонки мало. У нашій таблиці їх 2 000, і виграш скромний.

Підтримка різна. MySQL уміє skip scan з версії 8.0.13, але лише для запитів, яким вистачає колонок індексу. PostgreSQL отримав його у версії 18, у 16 і 17 його немає. SQLite уміє, але тільки після ANALYZE, коли статистика каже, що в першій колонці багато повторів.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1EXPLAIN ANALYZE
2SELECT company_id, status, published_at FROM vacancies
3WHERE status = 'active' AND published_at > '2026-09-25';
-> Filter: ((vacancies.`status` = 'active') and (vacancies.published_at > TIMESTAMP'2026-09-25 00:00:00'))
   (cost=134901 rows=165893) (actual time=0.0962..219 rows=2058 loops=1)
    -> Covering index skip scan on vacancies using vacancies_company_id_status_published_at_index
       over status = 'active'  (cost=134901 rows=497729) (actual time=0.0349..166 rows=250310 loops=1)

-- той самий запит з IGNORE INDEX
-> Table scan on vacancies  (cost=100740 rows=995458) (actual time=0.0436..211 rows=1e+6 loops=1)

У табличному EXPLAIN це Using index for skip scan. Для SELECT * skip scan не вмикається навіть з підказкою /*+ SKIP_SCAN(vacancies) */: план лишається type = ALL.

1EXPLAIN (ANALYZE, BUFFERS)
2SELECT company_id, status, published_at FROM vacancies
3WHERE status = 'active' AND published_at > '2026-09-25';
Gather  (cost=1000.00..17389.70 rows=2167 width=22) (actual time=34.043..37.503 rows=2058 loops=1)
  Workers Planned: 2
  ->  Parallel Seq Scan on vacancies  (cost=0.00..16173.00 rows=903 width=22) (actual time=27.979..28.281 rows=686 loops=3)
        Filter: ((published_at > '2026-09-25 00:00:00'::timestamp without time zone) AND ((status)::text = 'active'::text))
        Rows Removed by Filter: 332647
        Buffers: shared hit=9923

У PostgreSQL 16 skip scan немає, тож індекс без першої колонки не годиться. Документація PostgreSQL 18 описує його так само: база генерує рівність для кожного значення першої колонки, і це вигідно лише тоді, коли цих значень мало.

1EXPLAIN QUERY PLAN SELECT * FROM vacancies
2WHERE status = 'active' AND published_at > '2026-09-25';
-- до ANALYZE
SCAN vacancies
-- після ANALYZE
SEARCH vacancies USING INDEX vacancies_company_id_status_published_at_index (ANY(company_id) AND status=? AND published_at>?)

ANY(company_id) і є skip scan. Без таблиці sqlite_stat1, яку заповнює ANALYZE, SQLite не знає, скільки повторів у company_id, і читає таблицю цілком.

05 · ЗАЙВИЙ ІНДЕКС

Ліва частина вже є індексом

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

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

MySQL 8.0 PostgreSQL 16
1CREATE TABLE vacancies (
2 id bigint unsigned PRIMARY KEY,
3 company_id bigint unsigned NULL,
4 status varchar(20) NOT NULL,
5 CONSTRAINT vacancies_company_id_foreign
6 FOREIGN KEY (company_id) REFERENCES companies (id)
7);
8-- індекси: PRIMARY, vacancies_company_id_foreign
9
10CREATE INDEX vacancies_company_id_status_index
11 ON vacancies (company_id, status);
12-- індекси: PRIMARY, vacancies_company_id_status_index
13
14DROP INDEX vacancies_company_id_status_index ON vacancies;
ERROR 1553 (HY000): Cannot drop index 'vacancies_company_id_status_index':
needed in a foreign key constraint

Автоматичний індекс vacancies_company_id_foreign зник сам. Щоб прибрати складений, спершу додайте інший індекс з company_id зліва в тому самому ALTER TABLE: ADD INDEX (company_id), DROP INDEX vacancies_company_id_status_index.

1CREATE INDEX vacancies_company_id_index
2 ON vacancies (company_id);
3CREATE INDEX vacancies_company_id_status_published_at_index
4 ON vacancies (company_id, status, published_at);
5
6SELECT indexname, indexdef FROM pg_indexes
7WHERE tablename = 'vacancies' ORDER BY indexname;
vacancies_company_id_index                     | ... USING btree (company_id)
vacancies_company_id_status_published_at_index | ... USING btree (company_id, status, published_at)
vacancies_pkey                                 | ... USING btree (id)

PostgreSQL під зовнішній ключ індексу не створює, тож і дублів сам не робить: їх роблять люди. Шукайте пари, де колонки одного індексу є початком іншого, а рахунок звернень дивіться в pg_stat_user_indexes.idx_scan.

06 · СКЛАДЕНІ ІНДЕКСИ В LARAVEL

Як оголосити складений індекс

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

php create_vacancies_table.php Складений
1// Спершу рівність, потім колонка з діапазоном або з ORDER BY.
2$table->index(['company_id', 'status', 'published_at']);
3// vacancies_company_id_status_published_at_index
ЩО ПЕРЕВІРЯЄ
Один індекс на три колонки в заданому порядку. Працює для company_id, для company_id і status, для всіх трьох, і знімає сортування за published_at, коли перші дві порівнюють на рівність.
КОЛИ БРАТИ
Коли частий запит фільтрує за кількома колонками разом або фільтрує й сортує.
php create_vacancies_table.php Унікальний
1// Одна людина зберігає вакансію один раз.
2$table->unique(['user_id', 'vacancy_id']);
3// saved_jobs_user_id_vacancy_id_unique
ЩО ПЕРЕВІРЯЄ
Пара значень не може повторитися. Ліва колонка user_id працює й сама: список збережених вакансій людини окремого індексу не потребує.
КОЛИ БРАТИ
Для природних ключів із двох колонок і для upsert за ними.
php create_vacancies_table.php Видалити зайвий
1Schema::table('vacancies', function (Blueprint $table) {
2 $table->index(['company_id', 'status', 'published_at']);
3 // Ліва частина нового індексу вже покриває company_id.
4 $table->dropIndex(['company_id']); // vacancies_company_id_index
5});
ЩО ПЕРЕВІРЯЄ
dropIndex() з масивом колонок сам будує назву так само, як index(). На MySQL видаляйте старий індекс після того, як новий уже створено: інакше зовнішньому ключу не буде на що спертися.
КОЛИ БРАТИ
Коли новий складений індекс починається з тієї самої колонки, що й старий.
php create_vacancies_table.php Різні напрямки
1// ORDER BY company_id, published_at DESC без окремого сортування.
2$table->rawIndex(
3 'company_id, published_at desc',
4 'vacancies_company_id_published_at_desc_index',
5);
ЩО ПЕРЕВІРЯЄ
index() приймає лише назви колонок, тому напрямок задають через rawIndex(). Індекс за зростанням база вміє читати з кінця, але для змішаних напрямків потрібен саме такий.
КОЛИ БРАТИ
Рідко: коли запит сортує колонки в різних напрямках і без цього в плані зʼявляється Using filesort чи Incremental Sort.
07 · ЯК У PHP UKRAINE

Зріз ринку по днях

Таблиця market_snapshots зберігає по рядку на кожен день, тип і ключ (наприклад, framework і laravel). Унікальний індекс (day, kind, key) не дає записати той самий день двічі. Графік читає ряд одного ключа за проміжок днів: kind і key на рівність, day діапазоном і в ORDER BY. Під це другий індекс (kind, key, day), рівно в порядку «рівність, рівність, діапазон». Межі дат задає whereDate, і на PostgreSQL, де працює сайт, це не заважає: Laravel пише "day"::date, колонка й так типу date, і в EXPLAIN усі три колонки стоять в Index Cond. На MySQL той самий код дав би date(`day`), і межі пошуку задавали б лише kind і key. У тестах на SQLite whereDate стає strftime(...), і план так само показує лише kind=? AND key=?.

php 2026_09_04_000008_create_market_snapshots.php фрагмент, скорочено
1Schema::create('market_snapshots', function (Blueprint $table) {
2 $table->id();
3 $table->date('day');
4 $table->string('kind', 20);
5 $table->string('key', 40);
6 $table->integer('jobs');
7
8 $table->unique(['day', 'kind', 'key']);← один рядок на день, тип і ключ
9 $table->index(['kind', 'key', 'day']);← під series()
10});
src/Hiring/Infrastructure/Persistence/EloquentMarketSnapshots.phpseries(): where kind, where key, whereDate(day) від і до, orderBy day. Колонки йдуть у тому самому порядку, що й в індексі (kind, key, day).
database/migrations/2026_09_04_000004_create_analytics_tables.phpvisits має індекси (occurred_at), (occurred_at, source) і (occurred_at, path). Перший дублює ліву частину двох інших. Обидва складені починаються з occurred_at, а звіт бере її діапазоном, тож source і path у них не звужують пошук, лише перевіряються в записах індексу.
src/Hiring/Infrastructure/Persistence/EloquentApplicationRepository.phpexists() шукає повторний відгук за vacancy_id і email, тобто рівністю за обома колонками індексу (vacancy_id, email).
08 · ТИПОВІ ПОМИЛКИ

Де складений індекс не спрацьовує

✕ 1

Окремий індекс на кожну колонку

Три індекси (company_id), (status), (published_at) не замінюють одного складеного. Зазвичай база бере один з них і решту умов перевіряє в таблиці. PostgreSQL уміє поєднати кілька через BitmapAnd, MySQL через index_merge, але це все одно дорожче за один прохід складеним і не дає порядку для ORDER BY.

Натомість: Один складений індекс під частий запит, з колонками в порядку «рівність, діапазон або сортування».

✕ 2

Діапазон посередині

Індекс (company_id, published_at, status) під WHERE company_id = ? AND status = ? AND published_at > ?: після діапазону на published_at колонка status уже не звужує пошук.

Натомість: (company_id, status, published_at): обидві рівності зліва, діапазон останнім.

✕ 3

Найселективніша колонка першою

Популярна порада ставити першою колонку з найбільшою кількістю різних значень. Для рівностей порядок між собою мало впливає на швидкість пошуку, зате вирішує, які інші запити зможуть використати лівий префікс.

Натомість: Першими ставте колонки, які є в більшості запитів, рівності перед діапазоном, і колонку з ORDER BY останньою.

✕ 4

Розраховувати на skip scan

Індекс (company_id, status) і запит лише за status. MySQL 8.0.13+ і SQLite після ANALYZE інколи перестрибують company_id, PostgreSQL до 18 ні. Навіть коли skip scan спрацьовує, він читає набагато більше, ніж індекс з потрібною колонкою зліва.

Натомість: Якщо запит за status частий, дайте йому власний індекс або індекс, у якому status стоїть першим.

✕ 5

Різні напрямки сортування

ORDER BY company_id, published_at DESC на індексі (company_id, published_at): обидві колонки в одному напрямку база прочитає з будь-якого кінця, а змішані ні. MySQL пише Using filesort, PostgreSQL додає Incremental Sort.

Натомість: Індекс з тим самим змішаним напрямком: (company_id, published_at DESC). У Laravel через rawIndex().

09 · СЛОВНИК
B-tree
Збалансоване дерево сторінок з відсортованими ключами. У складеному індексі ключ це кортеж значень, і порівнюються вони як слова в словнику: спершу перше значення, за рівності друге і так далі.
Лівий префікс
Перші колонки індексу підряд, без пропусків. Індекс (a, b, c) допомагає умовам на a, на a і b, на a, b і c. Умові лише на b чи лише на c він допомагає не більше, ніж телефонна книга пошуку за іменем.
Діапазон
Умова, яка відбирає проміжок значень: >, <, BETWEEN, LIKE 'abc%'. Після неї наступні колонки індексу вже не звужують пошук, бо в межах проміжку вони не впорядковані.
10 · ПЕРЕВІР СЕБЕ

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

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

Перша й третя. Індекс відсортований спершу за company_id, тож і company_id сама, і company_id разом зі status дають лівий префікс. Умова лише на status пропускає першу колонку, і без skip scan база читає всю таблицю. Порядок умов у WHERE значення не має, важливий порядок колонок в індексі.

Рівність має стояти перед діапазоном. З (company_id, published_at) база одразу стає на потрібну компанію й потрібну дату і читає записи вже в порядку сортування. З (published_at, company_id) вона читає всі вакансії всіх компаній після дати й відкидає чужі: на нашій таблиці це 167 153 записи заради 98.

IN це кілька рівностей, тож база читає кілька шматків індексу, і кожен відсортований за датою окремо. Разом вони не впорядковані, тому MySQL робить filesort, а PostgreSQL вузол Sort. Якщо таких запитів багато, допомагає індекс (company_id, published_at): він дає порядок, а статус база перевірить у записах, поки не набере 20 рядків.

InnoDB вимагає індекс під кожен зовнішній ключ. Свій автоматичний індекс він тихо видалив, коли зʼявився складений з тією самою лівою колонкою, і тепер ключ тримається на ньому. Спершу додайте індекс, який його замінить, наприклад (company_id), а старий видаляйте в тому самому ALTER TABLE або після. Перевірити, на якому індексі тримається ключ, можна через SHOW INDEX і SHOW CREATE TABLE.