Одне дерево на кілька колонок
Складений індекс (company_id, status, published_at) це одне , у якому ключ складається з трьох значень. Записи відсортовані за company_id, серед однакових company_id за status, а серед однакових пар ще й за published_at. Окремого дерева на кожну колонку немає.
Звідси . Усі вакансії компанії 7 лежать в індексі одним шматком, і знайти їх легко. Вакансії компанії 7 зі статусом active теж лежать поруч і вже впорядковані за датою. А всі active вакансії розкидані по двох тисячах компаній, бо статус відсортований лише всередині кожної з них. Що довший префікс колонок зліва фіксує умова, то менший шматок індексу читає база.
Префікс обривається на першому . Поки колонки порівнюють на рівність, кожна наступна звужує пошук. Колонка з >, < чи BETWEEN звужує його востаннє, а все правіше база лише перевіряє в записах, які вже знайшла. Тому в складеному індексі спершу стоять колонки з рівністю, потім колонка з діапазоном або та, за якою сортують.
Вісім запитів до одного індексу
Таблиця vacancies на мільйон рядків: 2 000 компаній, у кожної близько 500 вакансій, три статуси. Індекс один, (company_id, status, published_at). У MySQL дивимося на type, key_len і Extra, у PostgreSQL на вузли плану й Buffers.
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, на ваших даних вони будуть іншими.
Ті самі колонки в іншому порядку
Запит «вакансії компанії 7 з першого червня»: одна колонка з рівністю, одна з діапазоном. Два індекси з тими самими колонками в різному порядку дають ту саму відповідь, 98 рядків, але читають зовсім різну кількість записів.
Коли першою стоїть дата, база знаходить у дереві перше червня і йде праворуч до кінця, перевіряючи company_id у кожному записі: це вакансії всіх компаній з червня. Коли першою стоїть компанія, база одразу стає на пару «компанія 7, перше червня» і читає лише потрібне.
-- (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.
-- лише (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 сторінок таблиці.
-- (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>?.
Коли пропущену колонку перестрибують
Skip scan обходить пропущену першу колонку: для кожного її значення база робить окремий пошук за рештою умови, наче до запиту дописали company_id = 1, company_id = 2 і так далі. Вигідно це, коли різних значень першої колонки мало. У нашій таблиці їх 2 000, і виграш скромний.
Підтримка різна. MySQL уміє skip scan з версії 8.0.13, але лише для запитів, яким вистачає колонок індексу. PostgreSQL отримав його у версії 18, у 16 і 17 його немає. SQLite уміє, але тільки після ANALYZE, коли статистика каже, що в першій колонці багато повторів.
-> 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.
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 описує його так само: база генерує рівність для кожного значення першої колонки, і це вигідно лише тоді, коли цих значень мало.
-- до 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, і читає таблицю цілком.
Ліва частина вже є індексом
Індекс (company_id) нічого не додає, якщо поруч є (company_id, status, published_at): запит за company_id обслужить ліва частина складеного. Зайвий індекс лише сповільнює кожен INSERT, UPDATE і DELETE і займає памʼять.
MySQL тут поводиться по-своєму. Під зовнішній ключ InnoDB сам створює індекс, а коли зʼявляється інший індекс з тією самою лівою колонкою, тихо видаляє свій. Після цього зовнішній ключ тримається на складеному, і просто видалити його вже не вийде.
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.
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.
Як оголосити складений індекс
Порядок колонок у масиві це порядок колонок в індексі. Назву Laravel складає з таблиці, колонок і типу, і за тим самим масивом колонок знаходить індекс, щоб його видалити.
Зріз ринку по днях
Таблиця 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=?.
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).Де складений індекс не спрацьовує
Окремий індекс на кожну колонку
Три індекси (company_id), (status), (published_at) не замінюють одного складеного. Зазвичай база бере один з них і решту умов перевіряє в таблиці. PostgreSQL уміє поєднати кілька через BitmapAnd, MySQL через index_merge, але це все одно дорожче за один прохід складеним і не дає порядку для ORDER BY.
Натомість: Один складений індекс під частий запит, з колонками в порядку «рівність, діапазон або сортування».
Діапазон посередині
Індекс (company_id, published_at, status) під WHERE company_id = ? AND status = ? AND published_at > ?: після діапазону на published_at колонка status уже не звужує пошук.
Натомість: (company_id, status, published_at): обидві рівності зліва, діапазон останнім.
Найселективніша колонка першою
Популярна порада ставити першою колонку з найбільшою кількістю різних значень. Для рівностей порядок між собою мало впливає на швидкість пошуку, зате вирішує, які інші запити зможуть використати лівий префікс.
Натомість: Першими ставте колонки, які є в більшості запитів, рівності перед діапазоном, і колонку з ORDER BY останньою.
Розраховувати на skip scan
Індекс (company_id, status) і запит лише за status. MySQL 8.0.13+ і SQLite після ANALYZE інколи перестрибують company_id, PostgreSQL до 18 ні. Навіть коли skip scan спрацьовує, він читає набагато більше, ніж індекс з потрібною колонкою зліва.
Натомість: Якщо запит за status частий, дайте йому власний індекс або індекс, у якому status стоїть першим.
Різні напрямки сортування
ORDER BY company_id, published_at DESC на індексі (company_id, published_at): обидві колонки в одному напрямку база прочитає з будь-якого кінця, а змішані ні. MySQL пише Using filesort, PostgreSQL додає Incremental Sort.
Натомість: Індекс з тим самим змішаним напрямком: (company_id, published_at DESC). У Laravel через rawIndex().
- B-tree
- Збалансоване дерево сторінок з відсортованими ключами. У складеному індексі ключ це кортеж значень, і порівнюються вони як слова в словнику: спершу перше значення, за рівності друге і так далі.
- Лівий префікс
- Перші колонки індексу підряд, без пропусків. Індекс (a, b, c) допомагає умовам на a, на a і b, на a, b і c. Умові лише на b чи лише на c він допомагає не більше, ніж телефонна книга пошуку за іменем.
- Діапазон
- Умова, яка відбирає проміжок значень: >, <, BETWEEN, LIKE 'abc%'. Після неї наступні колонки індексу вже не звужують пошук, бо в межах проміжку вони не впорядковані.
Питання зі співбесід
Перша й третя. Індекс відсортований спершу за 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.