Питання на співбесіді: Індекси й оптимізація запитів
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
21 питань
EXPLAIN показує план виконання запиту, не виконуючи його. Кожен рядок виводу - одна таблиця в запиті, у порядку, в якому MySQL їх читає.
EXPLAIN SELECT * FROM orders WHERE user_id = 7 AND status = 'paid';
Найважливіші колонки:
type- спосіб доступу. Від найкращого до найгіршого:const/system- щонайбільше один рядок за первинним чи унікальним ключем;eq_ref- у з'єднанні один рядок цієї таблиці на кожен рядок попередньої (за унікальним ключем);ref- пошук за неунікальним індексом з рівністю;range- діапазон індексу (BETWEEN,>,IN,LIKE 'abc%');index- повне сканування індексу (дешевше за таблицю, але все одно все);ALL- повне сканування таблиці. На великій таблиці - головний сигнал проблеми.
possible_keys- індекси, які теоретично підходять;key- обраний.NULLуkey- індекс не використано.key_len- скільки байтів індексу використано. Для складеного індексу показує, скільки колонок з нього реально задіяно.rows- оцінка кількості рядків, які доведеться прочитати (за статистикою, не точна).filtered- який відсоток прочитаних рядків пройде решту умов.rows × filtered / 100- скільки рядків піде далі.Extra- додаткові подробиці:Using index- запит обслуговано лише з індексу (покривний індекс), чудово;Using where- умову перевірено після читання рядка;Using index condition- частину умови перевірено ще в індексі (Index Condition Pushdown);Using filesort- потрібне окреме сортування;Using temporary- потрібна тимчасова таблиця (частоGROUP BYчиDISTINCT).
Що шукати першим:
type = ALLна великій таблиці;- великі
rowsпри маленькому результаті - індекс неселективний чи відсутній; Using filesort/Using temporaryна запитах, що мають бути швидкими.
Обмеження: EXPLAIN показує план і оцінки, а не фактичний час. Щоб побачити, скільки рядків прочитано насправді й де витрачено час, потрібен EXPLAIN ANALYZE.
У Laravel план зручно отримати прямо з будівника: User::where('email', $email)->explain()->dd();.
Складений індекс (a, b, c) - це B-дерево, впорядковане спершу за a, всередині однакових a - за b, всередині однакових (a, b) - за c. Як телефонний довідник: прізвище, потім ім'я.
Правило лівого префікса: індекс можна використати для пошуку лише за початковими колонками:
CREATE INDEX orders_idx ON orders (user_id, status, created_at);
-- індекс працює
WHERE user_id = 7
WHERE user_id = 7 AND status = 'paid'
WHERE user_id = 7 AND status = 'paid' AND created_at > '2026-01-01'
WHERE status = 'paid' AND user_id = 7 -- порядок в умові не важливий
-- індекс для пошуку не працює
WHERE status = 'paid' -- пропущено user_id
WHERE created_at > '2026-01-01'
Знайти всіх Іванів у довіднику, впорядкованому за прізвищем, можна лише переглянувши його повністю.
Діапазон «обриває» індекс. Колонки після першої з діапазоном (>, <, BETWEEN, LIKE 'abc%') для пошуку вже не використовуються:
WHERE user_id = 7 AND created_at > '2026-01-01' AND status = 'paid'
-- індекс (user_id, created_at, status): пошук за user_id + діапазон created_at,
-- status перевіряється для кожного запису діапазону
-- індекс (user_id, status, created_at): пошук за всіма трьома - краще
Звідси практичне правило порядку колонок: спершу колонки з рівністю, потім колонка з діапазоном чи сортуванням.
Що ще дає порядок колонок:
ORDER BYза колонками, що йдуть одразу після колонок з рівністю, не потребує сортування:WHERE user_id = 7 ORDER BY created_atз індексом(user_id, created_at);- окремий індекс
(user_id)не потрібен, якщо є(user_id, status)- лівий префікс його заміняє. Це зайвий індекс, що лише сповільнює запис.
Skip scan (MySQL 8.0.13+) частково обходить правило: якщо в першої колонки мало різних значень, а запит покривається індексом, MySQL може «перестрибувати» по значеннях першої колонки. У EXPLAIN видно Using index for skip scan. Розраховувати на це при проєктуванні не варто.
У Laravel: $table->index(['user_id', 'status', 'created_at']); - порядок у масиві і є порядком колонок в індексі.
Індекс допомагає лише тоді, коли умова записана так, що MySQL може шукати безпосередньо за значенням колонки. Найчастіші причини, чому це не так:
1. Функція чи вираз над колонкою.
-- індекс на created_at не використовується
WHERE YEAR(created_at) = 2026
WHERE DATE(created_at) = '2026-10-04'
WHERE price * 1.2 > 100
-- переписати на діапазон по «голій» колонці
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
WHERE price > 100 / 1.2
Якщо без функції ніяк, у MySQL 8 є функціональні індекси: CREATE INDEX ... ((LOWER(email))).
2. Неявне приведення типів. Рядкова колонка порівнюється з числом:
-- phone VARCHAR(20), індекс є
WHERE phone = 380501234567 -- кожен рядок перетворюється на число, індекс не працює
WHERE phone = '380501234567' -- індекс працює
Навпаки (числова колонка, рядкова константа) - не страшно: перетворюється константа. Те саме при з'єднаннях: колонки різних типів чи різних кодувань у JOIN можуть позбавити індексу.
3. LIKE з відсотком на початку.
WHERE name LIKE 'Олек%' -- діапазон індексу, працює
WHERE name LIKE '%сандр%' -- немає початку, з якого шукати
Для пошуку всередині тексту - FULLTEXT-індекс чи зовнішній пошуковий рушій.
4. Порушене правило лівого префікса складеного індексу: умова не містить першої колонки.
5. OR між різними колонками. WHERE email = ? OR phone = ? може взагалі не використати індекс або використати злиття індексів. Інколи краще UNION двох запитів.
6. Оптимізатор вирішив, що повне сканування дешевше. Якщо умова підходить під значну частину таблиці (наприклад, status = 'active' для 80% рядків), читати таблицю послідовно швидше, ніж робити тисячі переходів з індексу в кластерний індекс. Це не помилка, а правильне рішення.
7. Застаріла статистика. Після масового завантаження оцінки можуть бути хибними - допомагає ANALYZE TABLE.
Як перевіряти: завжди через EXPLAIN на реалістичному обсязі даних. На таблиці зі ста рядків MySQL обиратиме повне сканування, і висновки будуть хибними.
В InnoDB первинний ключ - не просто унікальний ідентифікатор, а порядок фізичного зберігання рядків (кластерний індекс) і частина кожного вторинного індексу. Тому вибір ключа впливає на продуктивність усієї таблиці.
Вимоги до хорошого первинного ключа:
- короткий - він копіюється в кожен вторинний індекс;
- незмінний - зміна ключа означає переміщення рядка в дереві й оновлення всіх індексів;
- монотонно зростаючий - нові рядки дописуються в кінець дерева, сторінки заповнюються щільно;
- завжди заданий (
NOT NULL).
Варіант за замовчуванням - автоінкремент:
$table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
8 байтів, зростає, нічого не коштує. BIGINT замість INT - щоб не впертися в ~4,29 млрд (пропуски в нумерації з'їдають запас швидше, ніж здається).
UUID - коли справді потрібен (генерація id на клієнті, злиття даних з різних баз, неперебірні публічні ідентифікатори):
- UUIDv4 (випадковий) - найгірший варіант для InnoDB. Кожна вставка йде у випадкову сторінку: розщеплення сторінок, фрагментація, сторінки заповнені наполовину, буферний пул використовується неефективно;
- UUIDv7 / ULID (впорядковані за часом) - вставляються майже послідовно, проблеми v4 зникають. Laravel
HasUuidsгенерує саме впорядковані UUID; - зберігати компактно -
BINARY(16), а неCHAR(36).
Компроміс: внутрішній BIGINT-ключ для зв'язків плюс окрема унікальна колонка uuid для зовнішнього світу (URL, API).
Природні ключі (email, номер телефону, код ІПН) - погана ідея: вони змінюються, бувають довгими, а зміна каскадом зачіпає всі зовнішні ключі. Унікальний індекс на них - так; первинний ключ - ні.
Складений первинний ключ доречний у таблицях зв'язку «багато-до-багатьох»:
Schema::create('role_user', function (Blueprint $table) {
$table->foreignId('user_id')->constrained();
$table->foreignId('role_id')->constrained();
$table->primary(['user_id', 'role_id']);
});
Рядки одного користувача лежать поруч, а пошук «ролі користувача» читає сусідні записи.
Таблиця без первинного ключа отримає прихований внутрішній ключ, недоступний у запитах. До того ж Group Replication (InnoDB Cluster) та інструменти онлайн-змін схеми вимагають явного первинного ключа, а керовані хмарні MySQL часто вмикають sql_require_primary_key.
Префіксний індекс індексує не всю рядкову колонку, а лише перші N символів:
CREATE INDEX urls_path_idx ON urls (path(50));
Навіщо:
- ліміт довжини ключа - для формату рядка
DYNAMICце 3072 байти.VARCHAR(1000)вutf8mb4важить до 4000 байтів і повністю в індекс не поміститься; TEXTіBLOBбез префікса індексувати взагалі не можна;- розмір індексу - менший індекс краще вміщується в буферний пул.
Як обрати довжину префікса. Префікс має бути досить довгим, щоб розрізняти значення майже так само добре, як повна колонка:
SELECT COUNT(DISTINCT path) / COUNT(*) AS full_selectivity,
COUNT(DISTINCT LEFT(path, 20)) / COUNT(*) AS p20,
COUNT(DISTINCT LEFT(path, 50)) / COUNT(*) AS p50,
COUNT(DISTINCT LEFT(path, 100)) / COUNT(*) AS p100
FROM urls;
Беруть найменшу довжину, селективність якої близька до повної. Для URL з однаковим початком (https://example.com/blog/...) префікс має бути довшим, ніж здається.
Обмеження префіксних індексів:
- не можуть бути покривними - повне значення все одно читається з таблиці;
- не допомагають
ORDER BYіGROUP BYза колонкою; - унікальний префіксний індекс перевіряє унікальність лише префікса: два різні URL з однаковими першими 50 символами не вставляться.
Альтернатива для пошуку за точним збігом довгого рядка - індекс на хеші:
ALTER TABLE urls
ADD COLUMN path_hash BINARY(16) AS (UNHEX(MD5(path))) STORED,
ADD UNIQUE INDEX (path_hash);
SELECT * FROM urls WHERE path_hash = UNHEX(MD5(?)) AND path = ?;
Індекс компактний (16 байтів), працює й для унікальності повних значень.
Історична довідка про Laravel. Schema::defaultStringLength(191) з'явився через старий ліміт 767 байтів (формат COMPACT): 191 × 4 = 764. На сучасних MySQL цей ліміт - 3072 байти, і VARCHAR(255) з індексом працює без хитрощів.
EXPLAIN показує план і оцінки оптимізатора. Запит не виконується.
EXPLAIN ANALYZE (MySQL 8.0.18+) виконує запит і показує план разом з фактичними вимірами: скільки рядків пройшло через кожен вузол, скільки часу він зайняв, скільки разів виконувався. Вивід завжди у форматі TREE.
EXPLAIN ANALYZE
SELECT u.name, COUNT(*) FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2026-09-01'
GROUP BY u.id;
-> Table scan on <temporary> (actual time=45.2..45.9 rows=812 loops=1)
-> Aggregate using temporary table (actual time=45.1..45.1 rows=812 loops=1)
-> Nested loop inner join (cost=2410 rows=5230) (actual time=0.09..38.7 rows=5104 loops=1)
-> Index range scan on o using orders_created_at_idx ...
(cost=580 rows=5230) (actual time=0.05..9.1 rows=5104 loops=1)
-> Single-row index lookup on u using PRIMARY (id=o.user_id)
(cost=0.25 rows=1) (actual time=0.005..0.005 rows=1 loops=5104)
Як читати:
- дерево читається зсередини назовні: найглибші вузли виконуються першими й передають рядки батьківським;
cost,rowsу перших дужках - оцінки оптимізатора;actual time=A..B- мілісекунди до першого рядка і до останнього, на одне виконання;rowsу других дужках - фактична кількість рядків на одне виконання;loops- скільки разів вузол виконувався. Повний час вузла ≈B × loops. У прикладі пошук користувача виконано 5104 рази.
На що дивитися:
- розбіжність оцінки й факту (
rows=10в оцінці,rows=200000насправді) - оптимізатор помилився через статистику й міг обрати поганий план. Лікування -ANALYZE TABLE, гістограми, переписаний запит; - вузол, де зростає час - різниця між часом вузла і його дочірніх показує, де він сам витрачає час;
- великі
loopsу вкладеному циклі - тисячі пошуків там, де міг би бути один прохід з'єднання.
EXPLAIN FORMAT=TREE (без ANALYZE) показує те саме дерево лише з оцінками - він інформативніший за табличний формат, бо видно порядок і тип з'єднань. Формат за замовчуванням можна змінити змінною explain_format.
Обережно: EXPLAIN ANALYZE справді виконує запит. Крім SELECT, він підтримує багатотабличні UPDATE і DELETE - і теж виконує їх по-справжньому, тож на продакшені аналізувати модифікації варто лише в транзакції з відкотом або на копії. Однотабличний UPDATE для аналізу зручно переписати на SELECT з тією самою умовою.
Покривний індекс містить усі колонки, які потрібні запиту, - і в умові, і в SELECT, і в сортуванні. Тоді MySQL читає лише індекс і не йде в саму таблицю. У EXPLAIN це видно як Using index в колонці Extra.
Чому це так вигідно в InnoDB. Звичайний пошук за вторинним індексом - це два проходи по деревах:
- у вторинному індексі знаходимо значення первинного ключа;
- у кластерному індексі за первинним ключем знаходимо рядок.
Якщо запит повертає тисячу рядків, другий крок - це тисяча окремих пошуків у різних місцях таблиці. Покривний індекс прибирає його повністю.
Вторинний індекс неявно містить первинний ключ - саме ним він посилається на рядок:
CREATE INDEX orders_user_idx ON orders (user_id); -- фактично (user_id, id)
SELECT id FROM orders WHERE user_id = 7; -- Using index: id уже в індексі
SELECT id, total FROM orders WHERE user_id = 7; -- потрібен рядок: total немає в індексі
Як зробити запит покривним:
CREATE INDEX orders_user_status_total_idx ON orders (user_id, status, total);
SELECT status, SUM(total) FROM orders WHERE user_id = 7 GROUP BY status;
-- усе в індексі: умова, групування, агрегат
Типові застосування:
- лічильники й агрегати за умовою (
COUNT(*) WHERE ...); - пагінація з відкладеним з'єднанням (deferred join): спершу знайти id через покривний індекс, потім підтягнути рядки:
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 20 OFFSET 10000) AS page
ON page.id = o.id;
Глибоке зміщення проходиться по компактному індексу, а повні рядки читаються лише для 20 записів.
- перевірки існування (
EXISTS) іwhereInза зовнішнім ключем.
Ціна: кожна колонка в індексі збільшує його розмір і сповільнює запис. Додавати «хвіст» колонок у кожен індекс заради покриття - погана ідея. Робити покривним варто запит, який виконується дуже часто й читає багато рядків.
Пастка Eloquent: ->get() за замовчуванням вибирає *, що майже ніколи не покривається індексом. Для гарячих запитів варто явно обмежувати колонки: ->select(['id', 'status']) чи ->pluck('id').
Відмінність від PostgreSQL: там вторинний індекс не містить первинного ключа, а index-only scan залежить ще й від карти видимості (visibility map).
Докладніше в документації: Формат виводу EXPLAIN: Using index
Index Condition Pushdown (ICP) - оптимізація, при якій частина умови WHERE перевіряється ще на рівні індексу, до того як читати повний рядок з таблиці.
Без ICP рушій зберігання знаходить записи індексу за тією частиною умови, яку можна використати для пошуку, читає кожен відповідний рядок з таблиці й передає серверу, а той уже перевіряє решту умови.
З ICP сервер передає рушію і ті частини умови, які стосуються колонок індексу, але не можуть бути використані для пошуку. Рушій перевіряє їх на записі індексу й читає рядок лише якщо умова виконується.
Приклад:
CREATE INDEX people_idx ON people (zipcode, lastname, firstname);
SELECT * FROM people
WHERE zipcode = '01001'
AND lastname LIKE '%енко%'
AND address LIKE '%Хрещатик%';
zipcode = '01001'- пошук в індексі;lastname LIKE '%енко%'- для пошуку непридатна (відсоток на початку), алеlastnameє в індексі. З ICP її перевіряють на записах індексу, і рядки з невідповідним прізвищем не читаються з таблиці;addressв індексі немає - перевіряється вже після читання рядка.
Якщо під zipcode 10 000 записів, а прізвище підходить у 200, ICP заощаджує 9 800 переходів у кластерний індекс.
В EXPLAIN це видно як Using index condition в Extra.
Коли ICP застосовується:
- для типів доступу
range,ref,eq_ref,ref_or_null; - для InnoDB - лише для вторинних індексів (для кластерного індексу рядок уже прочитано разом із записом);
- умова не може містити підзапитів і збережених функцій.
Практичне значення:
- складений індекс корисний навіть тоді, коли не всі його колонки використовуються для пошуку: «хвостові» колонки працюють як фільтр;
- типовий випадок - діапазон у середині індексу:
(user_id, created_at, status)приWHERE user_id = ? AND created_at > ? AND status = ?-statusпісля діапазону не бере участі в пошуку, але завдяки ICP фільтрується в індексі; - проте ICP - не заміна правильного порядку колонок: пошук за всіма колонками все одно ефективніший за фільтрацію.
ICP увімкнено за замовчуванням (optimizer_switch: index_condition_pushdown=on). Вимикати його доводиться хіба що для діагностики.
MySQL отримує відсортований результат двома способами:
- читає індекс по порядку - рядки вже йдуть відсортованими, а з
LIMITможна зупинитися після N рядків; - filesort - читає всі відповідні рядки й сортує їх окремо. Попри назву, це не обов'язково диск: сортування йде в пам'яті (
sort_buffer_size), і лише якщо дані не поміщаються - у тимчасових файлах.
Типовий запит стрічки:
SELECT * FROM posts
WHERE author_id = 5
ORDER BY published_at DESC
LIMIT 20;
- з індексом
(author_id): знаходимо всі 50 000 постів автора, сортуємо їх усі (Using filesort), віддаємо 20; - з індексом
(author_id, published_at): йдемо по індексу в потрібному порядку, читаємо 20 записів і зупиняємося. Різниця може бути в тисячі разів.
Коли індекс може дати порядок:
- колонки
ORDER BY- це продовження колонок з рівністю вWHERE:WHERE a = ? ORDER BY b, cз індексом(a, b, c); - або
ORDER BYза лівим префіксом індексу безWHEREна інших колонках; - напрямки сортування узгоджені: або всі однакові (індекс читається вперед чи назад), або збігаються з напрямками в індексі (
DESC-індекси).
Коли filesort неминучий:
WHERE a > 10 ORDER BY b -- діапазон по a, сортування по b
WHERE a IN (1, 2) ORDER BY b -- кілька значень a - кілька відсортованих шматків
ORDER BY a, b DESC -- різні напрямки при індексі (a, b) з однаковими
ORDER BY LOWER(name) -- вираз
ORDER BY t1.a, t2.b -- колонки з різних таблиць
Як зробити filesort дешевшим, якщо його не уникнути:
- вибирати менше колонок: MySQL сортує кортежі з потрібними колонками, і
SELECT *зTEXT-полями роздуває буфер; LIMITз filesort використовує пріоритетну чергу - зберігає лише N найкращих рядків, а не сортує все;- для великих сортувань - збільшити
sort_buffer_sizeдля сесії, а не глобально (він виділяється на кожне з'єднання).
Діагностика: Using filesort в EXPLAIN і лічильники Sort_merge_passes (злиття тимчасових файлів - сортування не вмістилося в пам'ять) та Sort_scan / Sort_range у SHOW GLOBAL STATUS.
Пагінація через OFFSET навіть з індексом читає й відкидає всі пропущені рядки. Для глибоких сторінок краще курсорна пагінація (WHERE published_at < ? ORDER BY published_at DESC LIMIT 20, у Laravel - cursorPaginate()).
Підказки індексів дають оптимізатору вказівки, які індекси розглядати:
SELECT * FROM orders USE INDEX (orders_user_idx) WHERE user_id = 7 AND status = 'paid';
SELECT * FROM orders FORCE INDEX (orders_created_idx) WHERE created_at > '2026-09-01';
SELECT * FROM orders IGNORE INDEX (orders_status_idx) WHERE status = 'paid';
USE INDEX- розглядати лише вказані індекси (але повне сканування лишається можливим);FORCE INDEX- якUSE INDEX, але повне сканування вважається дуже дорогим: індекс буде використано, якщо це взагалі можливо;IGNORE INDEX- не розглядати вказані індекси.
Можна уточнити призначення: FOR JOIN, FOR ORDER BY, FOR GROUP BY.
У Laravel для цього є методи будівника:
Order::query()->forceIndex('orders_created_idx')->where('created_at', '>', $date)->get();
Order::query()->useIndex('orders_user_idx')->...;
Order::query()->ignoreIndex('orders_status_idx')->...;
Чому це крайній засіб:
- підказка заморожує рішення, ухвалене на сьогоднішніх даних. Через рік розподіл зміниться, а запит і далі змушений використовувати індекс, який уже невигідний;
- перейменування чи видалення індексу ламає запит: на неіснуючий індекс у підказці MySQL поверне помилку;
- підказка маскує справжню причину: застарілу статистику, неселективний індекс, невдало записану умову;
- запит прив'язується до MySQL - на PostgreSQL чи SQLite (у тестах) такий синтаксис не працює. Laravel-методи на інших драйверах ігноруються або генерують свої варіанти, тож поведінка розходиться.
Що спробувати перед підказкою:
ANALYZE TABLE- оновити статистику;- гістограма на колонці з нерівномірним розподілом;
- кращий складений індекс, під який запит природно підходить;
- переписати умову (прибрати функцію з колонки, розбити
ORнаUNION); - прибрати зайві схожі індекси, між якими оптимізатор «вагається».
Майбутнє синтаксису. Документація MySQL 8.4 попереджає, що USE INDEX, FORCE INDEX і IGNORE INDEX планують оголосити застарілими на користь оптимізаторних підказок у коментарях: /*+ INDEX(orders orders_created_idx) */ і /*+ NO_INDEX(...) */, плюс точніші JOIN_INDEX, ORDER_INDEX, GROUP_INDEX. Новий код краще писати одразу з ними.
Коли підказка виправдана: відомий патологічний запит, де оптимізатор стабільно помиляється, і всі інші варіанти перевірено. Тоді варто лишити коментар, чому вона тут, - і переглядати після оновлень MySQL.
Видаляти індекси страшно: якщо він таки був потрібен якомусь рідкісному, але важливому запиту, після DROP INDEX цей запит перейде на повне сканування. А відновлення індексу на великій таблиці займає хвилини чи години.
Невидимий індекс (MySQL 8.0+) продовжує існувати й оновлюватися при кожному записі, але оптимізатор його не бачить:
ALTER TABLE orders ALTER INDEX orders_status_idx INVISIBLE;
-- спостерігаємо день-тиждень: чи не з'явилися повільні запити
ALTER TABLE orders ALTER INDEX orders_status_idx VISIBLE; -- миттєвий відкат
-- або, якщо все спокійно
ALTER TABLE orders DROP INDEX orders_status_idx;
Зміна видимості - операція з метаданими, вона виконується миттєво. Повернення індексу теж миттєве, бо його дані весь час підтримувалися в актуальному стані.
Як знайти кандидатів на видалення:
-- індекси, які не використовувалися з моменту запуску сервера
SELECT * FROM sys.schema_unused_indexes;
-- індекси, що дублюють інші (лівий префікс іншого індексу)
SELECT * FROM sys.schema_redundant_indexes;
Статистика використання береться з Performance Schema й скидається при перезапуску сервера. Якщо сервер перезапускали вчора, «невикористаний» індекс міг бути потрібен щомісячному звіту. Тому період спостереження має охоплювати всі регулярні задачі.
Перевірити запит з невидимими індексами в поточній сесії:
SET SESSION optimizer_switch = 'use_invisible_indexes=on';
EXPLAIN SELECT ...; -- чи обрав би оптимізатор цей індекс
Так само можна підготувати новий індекс: створити його невидимим, перевірити плани в сесії й лише потім відкрити для всіх.
Обмеження:
- первинний ключ не може бути невидимим, як і унікальний індекс, що неявно виконує роль первинного ключа;
- невидимий індекс продовжує сповільнювати запис і займати місце. Це інструмент перевірки, а не постійний стан;
- унікальний невидимий індекс і далі перевіряє унікальність;
- підказка
FORCE INDEXна невидимий індекс поверне помилку - корисний спосіб помітити, що десь у коді на нього посилаються.
Чому взагалі видаляти індекси: кожен індекс додає вартість кожному INSERT, UPDATE і DELETE, займає буферний пул і місце на диску. Зайві індекси - тихий податок на запис.
Доступ range читає один чи кілька відрізків індексу: BETWEEN, >, <, LIKE 'abc%', IN (...), OR за однією колонкою. Список IN (1, 5, 9) - це три «відрізки» з одного значення.
Як оптимізатор оцінює кількість рядків. Для кожного відрізка він може зробити index dive - спуститися в B-дерево до початку й кінця відрізка й точно оцінити, скільки записів між ними. Точно, але для довгого списку дорого: тисяча значень у IN - тисяча спусків ще до виконання запиту.
eq_range_index_dive_limit (за замовчуванням 200): якщо в рівностях більше значень, MySQL замість спусків використовує середню статистику індексу (скільки рядків припадає на одне значення). Це швидко, але неточно для нерівномірних даних - звідси раптова зміна плану, коли список у whereIn переростає 200 елементів.
Ліміт пам'яті оптимізатора діапазонів range_optimizer_max_mem_size (8 МБ за замовчуванням). Дуже довгі IN чи складні OR можуть його перевищити - тоді MySQL відмовляється від діапазонного доступу, видає попередження й може обрати повне сканування.
Складені індекси й діапазони:
-- індекс (status, created_at)
WHERE status IN ('new', 'paid') AND created_at > '2026-09-01'
-- два відрізки: ('new', > дата) і ('paid', > дата), обидві колонки працюють
WHERE status > 'a' AND created_at > '2026-09-01'
-- діапазон по першій колонці - created_at для пошуку вже не використовується
IN у першій колонці поводиться як кілька рівностей, тому наступні колонки індексу лишаються корисними. Діапазон - ні.
Кортежі в IN теж оптимізуються діапазоном:
SELECT * FROM prices WHERE (product_id, currency) IN ((1, 'UAH'), (2, 'USD'));
Практичні поради для Laravel:
whereInз десятками тисяч id - запит стає величезним, розбір і оптимізація дорогі, легко впертися вmax_allowed_packet. Краще обробляти частинами (chunkById,lazyById) або вставити id у тимчасову таблицю й з'єднати;- Eloquent при
with()генеруєwhereInза всіма ключами батьківських моделей: жадібне завантаження для 50 000 моделей - цеINз 50 000 значень. Ще одна причина обробляти великі вибірки частинами; whereIntegerInRaw()уникає зв'язування тисяч параметрів для цілих чисел.
Діагностика: в EXPLAIN - type = range і rows; у виводі оптимізатора (optimizer trace) видно, чи робилися index dives і чому обрано план.
Зазвичай MySQL використовує один індекс на таблицю в запиті. Index merge - виняток: кілька діапазонних сканувань різних індексів однієї таблиці, результати яких об'єднуються.
Три варіанти (видно в Extra при type = index_merge):
Using union(a_idx, b_idx)- дляOR:
SELECT * FROM users WHERE email = 'a@b.ua' OR phone = '380501234567';
-- окремо шукає за індексом email, окремо за phone, об'єднує id
Using intersect(a_idx, b_idx)- дляANDза колонками з різних індексів: перетин множин первинних ключів;Using sort_union(...)- як union, але для діапазонів: id треба спершу відсортувати.
Для OR між різними колонками злиття - добрий результат: без нього був би повний перебір таблиці. Альтернатива, яку інколи варто написати явно, - UNION двох запитів, кожен з яких використовує свій індекс:
SELECT * FROM users WHERE email = ?
UNION
SELECT * FROM users WHERE phone = ?;
А ось intersect - майже завжди сигнал проблеми:
-- окремі індекси (user_id) і (status)
SELECT * FROM orders WHERE user_id = 7 AND status = 'paid';
-- Using intersect(orders_user_idx, orders_status_idx)
MySQL читає всі замовлення користувача, всі оплачені замовлення (а їх можуть бути мільйони) і перетинає. Один складений індекс (user_id, status) знайшов би потрібні рядки одним проходом. Окремі індекси на кожну колонку «про всяк випадок» - типова помилка, що й призводить до злиття.
Обмеження index merge:
- не працює з повнотекстовими індексами;
- складні вкладені
AND/ORможуть не розпізнатися - оптимізатор не завжди переписує умову в зручну форму; - оцінка вартості буває хибною, і злиття обирається там, де одного індексу вистачило б.
Керування:
-- вимкнути для запиту
SELECT /*+ NO_INDEX_MERGE(orders) */ * FROM orders WHERE ...;
-- глобально окремі алгоритми через optimizer_switch:
-- index_merge, index_merge_union, index_merge_intersection, index_merge_sort_union
Практичний висновок: побачивши index_merge у плані частого запиту, перше питання - чи не потрібен тут складений індекс. Для intersect відповідь майже завжди «так».
LIKE '%слово%' не використовує індекс і читає всю таблицю. FULLTEXT-індекс розбиває текст на слова й будує інвертований індекс «слово → рядки».
ALTER TABLE posts ADD FULLTEXT INDEX ft_posts (title, body);
SELECT id, title, MATCH(title, body) AGAINST ('черги laravel') AS score
FROM posts
WHERE MATCH(title, body) AGAINST ('черги laravel')
ORDER BY score DESC;
Колонки в MATCH мають точно збігатися з колонками індексу.
Режими пошуку:
| Режим | Що робить |
|---|---|
IN NATURAL LANGUAGE MODE (за замовчуванням) |
ранжує рядки за релевантністю |
IN BOOLEAN MODE |
оператори: +обов'язкове -виключене "точна фраза" префікс* |
WITH QUERY EXPANSION |
другий прохід зі словами з найкращих результатів, часто шумний |
WHERE MATCH(title, body) AGAINST ('+laravel -symfony черг*' IN BOOLEAN MODE)
Чому щось «не знаходиться»:
- мінімальна довжина слова: для InnoDB
innodb_ft_min_token_size= 3 - слова з двох літер («ІТ», «UI») не індексуються. Зміна потребує перезапуску сервера й перебудови індексів; - стоп-слова: InnoDB має короткий вбудований англійський список; власний задають через
innodb_ft_server_stopword_table; - поріг 50% (слово в половині рядків вважається шумом) стосується лише MyISAM у природному режимі - через нього на маленьких тестових таблицях MyISAM пошук «нічого не знаходить».
Кирилиця й морфологія. Стандартний парсер розбиває текст за пробілами й розділовими знаками, тож кирилиця індексується, але без морфології: «черга», «черги», «чергу» - різні слова. Допомагає пошук за префіксом у булевому режимі (черг*), але відмінки з чергуванням літер він не покриває.
Парсер ngram (WITH PARSER ngram) розбиває текст на послідовності з N символів (ngram_token_size, за замовчуванням 2). Створений для китайської, японської й корейської, де немає пробілів; для української дає частковий збіг, але індекс великий і результати менш релевантні.
У Laravel:
$table->fullText(['title', 'body']); // міграція
Post::whereFullText(['title', 'body'], 'черги laravel')->get();
Post::whereFullText(['title', 'body'], '+laravel черг*', ['mode' => 'boolean'])->get();
Коли FULLTEXT достатньо: простий пошук по сайту, адмінка, невеликі обсяги. Коли потрібні морфологія, стійкість до опечаток, фасети й підсвічування - окремий рушій (Meilisearch, Typesense, Elasticsearch) через Laravel Scout.
До MySQL 8.0 синтаксис INDEX (a DESC) приймався, але ігнорувався - індекс завжди будувався за зростанням. З 8.0 спадні індекси справжні: значення фізично впорядковані за спаданням.
Коли це потрібно - сортування в різних напрямках:
SELECT * FROM products
WHERE category_id = 3
ORDER BY rating DESC, price ASC
LIMIT 20;
- індекс
(category_id, rating, price): читаючи вперед, отримуємоrating ASC, price ASC; назад -rating DESC, price DESC. Потрібної комбінаціїDESC, ASCнемає - будеUsing filesort; - індекс
(category_id, rating DESC, price ASC)віддає рядки рівно в потрібному порядку, запит читає 20 записів і зупиняється.
Коли спадний індекс НЕ потрібен. Сортування в одному напрямку будь-яким індексом обслуговується і вперед, і назад:
-- індекс (user_id, created_at)
WHERE user_id = 7 ORDER BY created_at DESC LIMIT 20
-- EXPLAIN: Extra = Backward index scan; Using index condition...
Зворотне сканування працює, але в InnoDB трохи повільніше за пряме: сторінки зв'язані у двонапрямлений список, проте всередині сторінки записи оптимізовані для руху вперед. На гарячих запитах «останні N записів» спадний індекс (user_id, created_at DESC) дає невеликий, але вимірний виграш - і прибирає Backward index scan з плану.
Інші ситуації:
MIN()/MAX()за колонкою індексу оптимізуються в обох напрямках;GROUP BYзі спадними частинами індексу підтримується;- спадні індекси підтримує лише InnoDB, і не для
FULLTEXT,SPATIALчи хеш-індексів.
У Laravel-міграції напрям колонки в індексі задається через сирий вираз:
$table->rawIndex('category_id, rating DESC, price', 'products_category_rating_price_idx');
Практичне правило: проєктувати індекс під найчастіше сортування. Якщо інтерфейс дозволяє сортувати таблицю за будь-якою колонкою в будь-якому напрямку, індекс під кожну комбінацію не створюють - обмежують варіанти сортування або приймають filesort на рідкісних комбінаціях.
PostgreSQL має спадні індекси давно, плюс NULLS FIRST/LAST. У MySQL NULL завжди вважається найменшим значенням, і окремого керування його позицією в індексі немає.