Мільйон рядків заради одного
У PostgreSQL рядки таблиці лежать у купі (heap) приблизно в порядку вставки, в InnoDB вони впорядковані за первинним ключем. За email вони не впорядковані ніде. Щоб знайти користувача за email, базі нема на що спертися: вона робить , читає рядок за рядком до самого кінця таблиці, бо такий самий email може трапитися ще раз.
Індекс зберігає значення колонки відсортованими, найчастіше у вигляді . Пошук починається з кореня, на кожному рівні відкидає більшу частину значень і за три-чотири кроки доходить до листка навіть на мільйонах рядків. У листку поруч зі значенням лежить адреса рядка: у PostgreSQL це місце рядка в купі, у вторинному індексі InnoDB це значення первинного ключа, за яким база спускається ще й .
Так само відсортованість допомагає діапазонам (>, BETWEEN), префіксу в LIKE 'olena%', ORDER BY і JOIN за проіндексованою колонкою. Та чи брати індекс, вирішує планувальник за : якщо умова повертає помітну частину таблиці, прочитати її підряд дешевше, ніж стрибати за кожним рядком окремо.
Спуск по дереву замість перебору
Перемикайте «Без індексу», «З індексом», «Складений» і «Покриваючий». Числа це приклад для таблиці на мільйон рядків, а сценарій підготовлений заздалегідь: справжньої бази в браузері немає.
Порядок колонок вирішує все
Індекс (company_id, status, published_at) впорядкований як телефонна книга: спершу за компанією, усередині компанії за статусом, далі за датою. Межі пошуку задають колонки зліва направо, поки в них рівність; перша колонка з діапазоном ще звужує пошук, а все правіше від неї база лише перевіряє. Це правило лівого префікса.
Звідси порядок для складеного індексу: спершу колонки, які порівнюють на рівність, потім та, що стоїть у діапазоні чи в ORDER BY.
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 з мільйоном вакансій.
Коли база навіть не йде в таблицю
Якщо всі колонки, які просить запит, уже лежать в індексі, база відповідає з самого індексу. Такий індекс називають покриваючим. Тому SELECT * часто повільніший за SELECT з потрібними колонками: він змушує сходити в таблицю за кожним рядком.
Вторинний індекс InnoDB тримає ще й первинний ключ, тож SELECT id, email покривається індексом на одну колонку email. У PostgreSQL зайві колонки додають через INCLUDE: вони лежать у листках, але в пошуку не беруть участі. І пастка PostgreSQL: видимість рядка він перевіряє за картою видимості (visibility map). Поки після UPDATE не пройшов VACUUM, Index Only Scan ходить у таблицю, і це видно в рядку Heap Fetches.
type: ref key: users_email_index rows: 1 Extra: Using index
Using index означає, що таблицю не читали. id потрапив у відповідь з індексу, бо InnoDB зберігає первинний ключ у кожному вторинному індексі.
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 її не поверне.
SEARCH users USING COVERING INDEX users_email_index (email=?)
SQLite так і пише в плані: COVERING INDEX. Колонка id INTEGER PRIMARY KEY тут це rowid, і він теж лежить в індексі.
Унікальний індекс як арбітр
Унікальний індекс ще й гарантує, що двох однакових значень у таблиці не буде. На цьому стоїть upsert: вставити рядок, а якщо такий ключ уже є, оновити наявний. Приклад: лічильник переглядів сторінки за день з первинним ключем (page_id, day). Перший запит вставляє рядок, другий натрапляє на ключ і збільшує hits.
Синтаксис у баз різний. Метод upsert() у Laravel збирає потрібний сам: ON DUPLICATE KEY UPDATE для MySQL і ON CONFLICT для PostgreSQL та SQLite.
Query OK, 1 row affected -- перший раз, вставка Query OK, 2 rows affected -- далі, оновлення
Спрацьовує на будь-якому унікальному ключі таблиці, вибрати конкретний не можна. Оновлення рахується як 2 rows affected. Аліас нового рядка AS new зʼявився в MySQL 8.0.19, до того писали VALUES(hits).
INSERT 0 1 -- вставка INSERT 0 1 -- оновлення
Конфлікт називають явно: колонками унікального індексу або ON CONSTRAINT з назвою обмеження. EXCLUDED це рядок, який намагалися вставити.
changes() = 1 і для вставки, і для оновлення
Синтаксис як у PostgreSQL, SQLite знає його з версії 3.24. Тому тести на SQLite і продакшн на PostgreSQL ганяють той самий upsert.
Пʼять полів, на які дивитися першими
EXPLAIN перед запитом показує план: яким шляхом база збирається читати дані. EXPLAIN ANALYZE ще й виконує запит і додає справжні рядки й час, тож UPDATE чи DELETE під ним загортайте в BEGIN і ROLLBACK. Нижче той самий пошук за email без індексу, з індексом і з покриваючим індексом.
SELECT * FROM users WHERE email = 'olena@kvitka.ua';| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ALL | NULL | 995838 | 10.00 | Using where |
SELECT * FROM users WHERE email = 'olena@kvitka.ua';| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ref | users_email_index | 1 | 100.00 | NULL |
SELECT id, email FROM users WHERE email = 'olena@kvitka.ua';| type | key | rows | filtered | Extra |
|---|---|---|---|---|
| ref | users_email_index | 1 | 100.00 | Using 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 на кожен з трьох процесів заради одного знайденого рядка.
Як оголосити індекс у міграції
Назву індексу Laravel складає сам: таблиця, колонки й тип через підкреслення. Саме цю назву ви потім побачите в EXPLAIN у полі key чи в рядку Index Scan using.
Індекси таблиці вакансій
Таблиця vacancies оголошує індекси у своїй міграції. Унікальний (source, source_id) знаходить вакансію джерела під час синхронізації: findBySourceRef шукає рівно за цими двома колонками. Складений (status, published_at) іде в порядку «рівність, потім дата», як велить правило лівого префікса. company_id оголошено через constrained(), і на MySQL під ним був би індекс, а на PostgreSQL, де працює сайт, окремого індексу під цією колонкою немає. У таблиці applications навпаки: ліві частини складених індексів (company_id, status) і (vacancy_id, email) покривають обидва її зовнішні ключі.
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.Кожен індекс оплачується записом
Індекс на кожну колонку з WHERE
Кожен INSERT і DELETE оновлює всі індекси таблиці, а UPDATE ті, що містять змінені колонки. Десяток індексів помітно сповільнює запис і займає місце на диску та в кеші.
Натомість: Індекс під конкретний частий запит. Невикористані шукайте в pg_stat_user_indexes (idx_scan = 0) у PostgreSQL і в sys.schema_unused_indexes у MySQL.
Індекс на колонку з двома значеннями
is_active чи status з парою значень відбирають половину таблиці. Прочитати її підряд дешевше, ніж стрибати за кожним рядком, тож планувальник такий індекс ігнорує.
Натомість: Якщо шукають рідкісне значення, у PostgreSQL допоможе частковий індекс: CREATE INDEX … WHERE is_test. У MySQL часткових індексів немає, там таку колонку ставлять другою в складений.
Функція над колонкою
WHERE lower(email) = ? чи WHERE DATE(created_at) = ? не використають звичайний індекс: в індексі лежать самі значення, а не результат функції.
Натомість: Порівнюйте колонку з діапазоном (created_at >= '2026-10-01' AND created_at < '2026-10-02') або додайте індекс за виразом через rawIndex().
LIKE з відсотком на початку
B-tree допомагає LIKE 'olena%', бо це діапазон за префіксом, і ніколи LIKE '%olena%'. У PostgreSQL навіть префікс працює лише з локаллю C або з індексом на text_pattern_ops.
Натомість: Для пошуку всередині тексту беріть повнотекстовий індекс (fullText() у Laravel), pg_trgm у PostgreSQL або окремий пошуковий рушій.
Дубль лівого префікса
Індекс (company_id) зайвий, якщо вже є (company_id, status): ліва частина складеного працює сама, а другий індекс лише сповільнює запис.
Натомість: Перед новим індексом подивіться наявні: SHOW INDEX FROM vacancies у MySQL, \d vacancies у psql.
- Повний перебір
- Читання всієї таблиці рядок за рядком. У MySQL в EXPLAIN це type = ALL, у PostgreSQL вузол Seq Scan. На маленьких таблицях це нормальний і навіть найшвидший план.
- B-tree
- Збалансоване дерево сторінок: корінь, проміжні вузли й листки з відсортованими значеннями. Усі листки на однаковій глибині, тож пошук будь-якого значення займає однакову кількість кроків. Тип індексу за замовчуванням і в MySQL, і в PostgreSQL.
- Кластерний індекс
- В InnoDB рядки таблиці лежать у листках B-tree первинного ключа, окремої купи немає. Тому вторинний індекс веде не на адресу рядка, а на первинний ключ, і пошук за ним проходить два дерева.
- Селективність
- Яку частку таблиці відбирає умова. email = ? відбирає один рядок з мільйона, і це висока селективність. status = 'active' може відібрати половину, і тоді індекс програє читанню підряд.
Питання зі співбесід
Кожна вставка має покласти нове значення в правильне місце дерева, тобто оновити індекс, а іноді й розділити сторінку. Читання виграє, запис платить. Тому індексів має бути стільки, скільки є частих запитів, а не стільки, скільки колонок.
Майже ні. Дерево впорядковане спершу за 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.