MySQL: Загальний
20 питань · ~15 хв · Версія v3.0
Увійдіть, щоб продовжити
Запити, JOIN, індекси, нормалізація, транзакції.
- За спробу
- 20
- У пулі
- 100
- Проходжень
- 37
- Середній бал
- 91%
- Пройшли на 70%+
- 100%
Питання для підготовки
107 питаньENUM - колонка, що приймає одне значення з фіксованого списку. SET - кілька значень з фіксованого списку одночасно.
CREATE TABLE orders (
status ENUM('new', 'paid', 'shipped', 'cancelled') NOT NULL DEFAULT 'new',
flags SET('gift', 'urgent', 'fragile')
);
Всередині ENUM зберігається як номер значення в списку (1-2 байти) - компактно й з перевіркою допустимих значень.
Підводні камені ENUM:
- Сортування за номером, а не за абеткою.
ORDER BY statusупорядкує в порядку оголошення (new, paid, shipped, cancelled). Інколи це зручно, частіше - несподівано. - Числа в контексті чисел.
WHERE status = 1порівнює з номером, а не з рядком'1'. ДляENUM('0', '1', '2')це джерело плутанини. - Зміна списку -
ALTER TABLE. Додати значення в кінець у MySQL 8 можна миттєво (лише метадані). Додати в середину, перейменувати чи видалити - перебудова таблиці. - Нестрогий режим: недопустиме значення перетворюється на порожній рядок
''з номером 0 замість помилки. У строгому режимі (за замовчуванням у MySQL 8) - помилка. - Перенесення між СУБД: це нестандартний тип MySQL.
SET - ще специфічніший: значення зберігаються як бітова маска (до 64 елементів), шукати за ними незручно (FIND_IN_SET), а індекси майже не допомагають. Майже завжди краще окрема таблиця зв'язку.
Альтернативи ENUM:
VARCHAR+CHECK(MySQL 8.0.16+):status VARCHAR(20) CHECK (status IN ('new', 'paid', ...))- зміна переліку не чіпає дані.- Таблиця-довідник із зовнішнім ключем - коли значення змінюються без деплою чи мають атрибути (назва, колір, порядок).
- PHP enum у застосунку + рядок у базі - логіка значень живе в коді, а база зберігає просте значення.
$table->enum() у Laravel-міграціях на MySQL створює саме нативний ENUM.
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 обиратиме повне сканування, і висновки будуть хибними.
Застосунок не повинен ходити в базу під root. Якщо через SQL-ін'єкцію чи вкрадений .env хтось отримає доступ, права користувача визначають, скільки шкоди він зробить.
Користувач для застосунку:
CREATE USER 'app'@'10.0.0.%' IDENTIFIED BY 'довгий-випадковий-пароль';
GRANT SELECT, INSERT, UPDATE, DELETE ON app.* TO 'app'@'10.0.0.%';
Частина @'host' - невід'ємна частина облікового запису. 'app'@'10.0.0.%' - підключення лише з внутрішньої мережі; 'app'@'%' - звідусіль. 'app'@'localhost' і 'app'@'%' - два різні облікові записи з різними паролями й правами, що часто плутає.
Окремий користувач для міграцій:
CREATE USER 'migrator'@'10.0.0.%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX, REFERENCES
ON app.* TO 'migrator'@'10.0.0.%';
Застосунок працює з обмеженими правами, а при деплої міграції запускаються з окремими обліковими даними. Тоді ін'єкція не зможе виконати DROP TABLE.
Інші облікові записи за призначенням:
backup-SELECT,SHOW VIEW,TRIGGER,LOCK TABLES,EVENT,PROCESS(для дампів);readonlyдля аналітики й BI - лишеSELECT, бажано на репліці;- моніторинг -
PROCESS,REPLICATION CLIENTіSELECTнаperformance_schema.
Чого не давати застосунку:
ALL PRIVILEGESчи права на*.*;SUPERта адміністративні динамічні привілеї;FILE- дає змогу читати й писати файли на сервері (LOAD DATA INFILE,SELECT ... INTO OUTFILE);GRANT OPTION.
Перевірити права:
SHOW GRANTS FOR 'app'@'10.0.0.%';
Що змінилося в MySQL 8:
GRANTбільше не створює користувача неявно - спершуCREATE USER;- зміна пароля -
ALTER USER 'app'@'10.0.0.%' IDENTIFIED BY '...'; - плагін автентифікації за замовчуванням -
caching_sha2_password; - для груп прав є ролі (
CREATE ROLE).
Пароль застосунку зберігається в .env (DB_USERNAME, DB_PASSWORD), а не в коді чи репозиторії, і змінюється, якщо .env міг потрапити в чужі руки.
Прочитати - ще не значить знати
20 питань, по одному на екран, ~15 хв. Після завершення - розбір кожної помилки з посиланням на питання.