Junior: питання на співбесіді з теми «SQL-запити»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
9 питань
INNER JOINповертає лише ті рядки, для яких знайшлася пара в обох таблицях.LEFT JOINповертає всі рядки лівої таблиці; де пари в правій немає, її колонки заповнюютьсяNULL.
-- Лише користувачі, які мають замовлення
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON o.user_id = u.id;
-- Усі користувачі; без замовлень - з NULL у o.total
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
Типова задача: «користувачі без жодного замовлення» - LEFT JOIN і фільтр на NULL з правого боку:
SELECT u.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
Пастка: умова на праву таблицю в WHERE (WHERE o.status = 'paid') відкидає рядки з NULL і непомітно перетворює LEFT JOIN на INNER JOIN. Якщо треба зберегти всіх користувачів, умову ставлять в ON: LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid'.
Ще є RIGHT JOIN (дзеркальний до LEFT, на практиці рідкісний) і FULL JOIN - усі рядки з обох боків.
Різниця в моменті, коли вони спрацьовують:
WHEREфільтрує рядки до групування. Агрегатів у ньому ще немає.HAVINGфільтрує групи післяGROUP BYі може використовувати агрегати.
SELECT user_id, COUNT(*) AS orders, SUM(total) AS spent
FROM orders
WHERE status = 'paid' -- лише оплачені замовлення
GROUP BY user_id
HAVING SUM(total) > 10000; -- лише ті, хто витратив понад 10 000
Порядок виконання запиту: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Звідси й правило: умову на звичайну колонку ставлять у WHERE, бо так база відкидає зайві рядки раніше й групує менше даних.
Типові помилки:
WHERE COUNT(*) > 5- помилка: на етапіWHEREгруп ще немає.HAVING status = 'paid'безstatusуGROUP BY- помилка і в PostgreSQL, і в MySQL з режимомONLY_FULL_GROUP_BY(він увімкнений за замовчуванням). А навіть там, де такий запит проходить, він фільтрує пізніше, ніж міг би.- Посилання в
WHEREна псевдонім зSELECT(WHERE spent > 100) не працює саме через порядок виконання.
Обидва об'єднують результати кількох SELECT в один набір, один під одним.
UNION- прибирає дублікати: результат містить лише унікальні рядки.UNION ALL- залишає всі рядки як є, з повторами.
SELECT email FROM customers
UNION
SELECT email FROM newsletter_subscribers; -- кожен email один раз
SELECT 'order' AS type, id, created_at FROM orders
UNION ALL
SELECT 'refund', id, created_at FROM refunds -- усі події, повтори неможливі за змістом
ORDER BY created_at DESC;
Чому UNION ALL за замовчуванням краще: щоб прибрати дублікати, UNION змушений сортувати чи хешувати весь об'єднаний результат - на великих наборах це дорого (тимчасова таблиця, можливо, на диску). Якщо дублікатів бути не може (різні таблиці, різні типи подій) або вони потрібні, - UNION ALL.
Правила:
- Кількість колонок у всіх
SELECTоднакова; типи - сумісні. - Імена колонок результату беруться з першого
SELECT. ORDER BYіLIMITв кінці стосуються всього результату. Щоб відсортувати окрему частину, її беруть у дужки з власнимLIMIT.
Типові застосування: стрічка подій з кількох таблиць, пошук по кількох сутностях («знайти серед користувачів і компаній»), заміна складного OR на кілька простих запитів, кожен з яких використовує свій індекс:
SELECT * FROM users WHERE email = ?
UNION
SELECT * FROM users WHERE phone = ?;
У Laravel - ->union($query) і ->unionAll($query).
У запиті з GROUP BY кожна колонка в SELECT має бути або в GROUP BY, або всередині агрегатної функції (COUNT, SUM, MAX...). Інакше незрозуміло, яке значення показати для групи з кількох рядків.
SELECT user_id, status, COUNT(*)
FROM orders
GROUP BY user_id;
-- ERROR 1055: Expression #2 of SELECT list is not in GROUP BY clause
-- and contains nonaggregated column 'orders.status'...
У користувача кілька замовлень з різними статусами - який із них показати?
Режим ONLY_FULL_GROUP_BY (увімкнений за замовчуванням з MySQL 5.7) забороняє такі запити. Старі версії MySQL виконували їх, повертаючи значення з довільного рядка групи - і код роками працював з випадковими даними. Після оновлення MySQL такі запити раптом починають падати.
Як виправити - вирішити, що насправді потрібно:
-- групувати й за статусом
SELECT user_id, status, COUNT(*) FROM orders GROUP BY user_id, status;
-- агрегат: останній статус за датою - вже інша задача (віконна функція)
SELECT user_id, MAX(created_at) AS last_order_at FROM orders GROUP BY user_id;
-- значення справді однакове для групи (валюта клієнта), а MySQL цього не знає
SELECT user_id, ANY_VALUE(currency), COUNT(*) FROM orders GROUP BY user_id;
Функціональна залежність: MySQL дозволяє колонки, однозначно визначені колонками GROUP BY. Якщо групують за первинним ключем users.id, можна вибирати users.name - вона залежить від ключа.
Чого не робити: вимикати режим ('strict' => false у config/database.php Laravel чи прибирати його з sql_mode), щоб «запрацювало». Так помилка ховається, а результат лишається недетермінованим. PostgreSQL такі запити не дозволяє взагалі.
DISTINCT прибирає однакові рядки результату. GROUP BY збирає рядки в групи, щоб порахувати для кожної агрегати.
Без агрегатів вони дають однаковий результат, і MySQL часто виконує їх однаково:
SELECT DISTINCT city FROM customers;
SELECT city FROM customers GROUP BY city;
Різниця з'являється, коли потрібні агрегати:
SELECT city, COUNT(*) AS customers, MAX(created_at) AS last_signup
FROM customers
GROUP BY city;
DISTINCT порахувати нічого не вміє - лише прибрати повтори.
DISTINCT діє на весь рядок, а не на першу колонку:
SELECT DISTINCT city, name FROM customers; -- унікальні пари (місто, ім'я)
Поширена помилка - очікувати «унікальні міста з будь-яким ім'ям».
COUNT(DISTINCT ...) - кількість унікальних значень:
SELECT COUNT(DISTINCT user_id) AS buyers FROM orders WHERE created_at >= '2026-10-01';
Запах у запитах - DISTINCT для «лікування» дублікатів після JOIN:
SELECT DISTINCT u.* FROM users u JOIN orders o ON o.user_id = u.id;
JOIN з таблицею «багато» розмножує рядки, а DISTINCT їх потім прибирає - база спершу робить зайву роботу, а потім ще й сортує результат. Правильно сформулювати, що потрібно: «користувачі, у яких є замовлення»:
SELECT u.* FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
Швидкодія: обидва можуть використати індекс за колонками групування; без нього - тимчасова таблиця. У EXPLAIN це видно як Using temporary.
Upsert - вставити рядок, а якщо він уже є, - оновити. У MySQL «вже є» визначається порушенням унікального ключа (первинного чи UNIQUE).
INSERT INTO product_stats (product_id, views)
VALUES (42, 1)
ON DUPLICATE KEY UPDATE views = views + 1;
Перший виклик вставить рядок, наступні - збільшать лічильник. Атомарно, без гонки «перевірити - вставити».
Посилання на значення, яке намагалися вставити - через псевдонім рядка (MySQL 8.0.19+):
INSERT INTO prices (sku, price, updated_at)
VALUES ('A1', 199.00, NOW()), ('B2', 349.00, NOW()) AS new
ON DUPLICATE KEY UPDATE price = new.price, updated_at = new.updated_at;
Стара функція VALUES(price) в UPDATE-частині працює, але застаріла.
Підводні камені:
- Потрібен унікальний ключ саме на тих колонках, за якими визначається «той самий» рядок. Без нього буде звичайна вставка дубліката.
- Кілька унікальних ключів: якщо рядок конфліктує одразу з двома різними рядками за різними ключами, оновиться лише один - результат несподіваний. Upsert краще робити по таблиці з одним унікальним ключем, крім первинного.
- Автоінкремент «з'їдається»: невдала вставка часто все одно забирає значення
AUTO_INCREMENT- звідси дірки в ID. - Кількість змінених рядків: 1 - вставлено, 2 - оновлено, 0 - значення ті самі.
- Блокування: при конфлікті InnoDB бере блокування на запис індексу - при великій конкуренції можливі deadlock'и, і їх варто повторювати.
У Laravel: Model::upsert($rows, uniqueBy: ['sku'], update: ['price', 'updated_at']) генерує саме цей запит для MySQL (і ON CONFLICT для PostgreSQL).
Докладніше в документації: INSERT ... ON DUPLICATE KEY UPDATE
Обидва вирішують «вставити або замінити», але REPLACE робить це грубо: при конфлікті унікального ключа він видаляє старий рядок і вставляє новий.
REPLACE INTO settings (user_id, theme) VALUES (1, 'dark');
Наслідки, що роблять REPLACE небезпечним:
- Нове значення
AUTO_INCREMENT. Якщо ключ конфлікту - неid, рядок отримає новийid. Посилання на старийidз інших таблиць ламаються. - Каскадні видалення. Зовнішні ключі з
ON DELETE CASCADEвидалять залежні рядки - «оновлення» налаштувань може знищити пов'язані дані. - Тригери
DELETEспрацьовують, хоча логічно це оновлення. - Втрата колонок. Колонки, яких немає в
REPLACE, отримують значення за замовчуванням: усе, що було в старому рядку, зникає. - Два рядки замість одного при конфлікті за кількома ключами:
REPLACEвидалить усі рядки, з якими конфліктує.
INSERT ... ON DUPLICATE KEY UPDATE оновлює наявний рядок на місці: id той самий, інші колонки не змінюються, каскади не спрацьовують, змінюється рівно те, що вказано.
INSERT INTO settings (user_id, theme) VALUES (1, 'dark') AS new
ON DUPLICATE KEY UPDATE theme = new.theme;
Ще варіант - INSERT IGNORE: вставити, якщо немає, і нічого не робити, якщо є. Але IGNORE перетворює на попередження й інші помилки (обрізання значень, неправильні типи), тож ховає баги.
Правило: INSERT ... ON DUPLICATE KEY UPDATE за замовчуванням. REPLACE - лише для таблиць без залежностей, де повна заміна рядка - саме те, що потрібно (кеш-таблиці, денормалізовані знімки).
Таблиця в SQL - це множина рядків без визначеного порядку. Якщо в запиті немає ORDER BY, база повертає рядки в тому порядку, в якому їй зручно: за індексом, яким скористалася, за фізичним розташуванням, за порядком паралельного виконання.
SELECT * FROM products LIMIT 10;
Сьогодні це «перші 10 вставлених», а завтра - після оновлення MySQL, нового індексу, OPTIMIZE TABLE чи іншого плану запиту - інші 10. Код, що на це покладався, ламається без жодних змін.
Ще підступніше - пагінація з неповним ORDER BY:
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 20;
Якщо багато товарів мають однаковий created_at (масовий імпорт), порядок серед них не визначений. Одні товари з'являтимуться на двох сторінках, інші - на жодній.
Правило: ORDER BY має задавати однозначний порядок - додайте унікальну колонку останньою:
SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 20;
Коли порядок справді не важливий: «будь-які 10 записів для перевірки», EXISTS, вибірка для обробки порціями, де кожен рядок однаково підходить.
Швидкодія LIMIT з ORDER BY: якщо є індекс, що віддає рядки в потрібному порядку, MySQL читає лише перші N і зупиняється. Без індексу - сортує всі рядки (Using filesort у EXPLAIN), хоча й оптимізує сортування для невеликого LIMIT.
OFFSET на далеких сторінках все одно читає й відкидає всі попередні рядки. Для глибокої пагінації - keyset: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20.
Представлення (view) - збережений SELECT з іменем, який можна читати як таблицю. Дані не зберігаються: при кожному зверненні MySQL виконує запит, що лежить в основі.
CREATE VIEW active_customers AS
SELECT u.id, u.email, COUNT(o.id) AS orders_count
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.email;
SELECT * FROM active_customers WHERE orders_count > 5;
Як MySQL виконує представлення - два алгоритми:
MERGE |
TEMPTABLE |
|
|---|---|---|
| що робить | підставляє текст представлення в зовнішній запит | спершу виконує представлення в тимчасову таблицю, потім читає її |
| індекси базових таблиць | використовуються | для тимчасової таблиці - ні |
| можна оновлювати через представлення | так | ні |
| коли обирається | простий SELECT |
є GROUP BY, DISTINCT, агрегати, UNION, LIMIT, підзапит у списку колонок |
За замовчуванням (ALGORITHM = UNDEFINED) MySQL сам обирає MERGE, якщо це можливо. Представлення вище містить GROUP BY, тож воно завжди матеріалізується в тимчасову таблицю цілком - навіть якщо зовнішній запит просить один рядок. На великих таблицях це пастка продуктивності.
Оновлювані представлення: просте представлення з однієї таблиці дозволяє INSERT, UPDATE, DELETE. WITH CHECK OPTION забороняє записувати рядки, що не пройдуть умову WHERE представлення.
SQL SECURITY:
DEFINER(за замовчуванням) - запит виконується з правами того, хто створив представлення. Так можна дати користувачу доступ до частини колонок без прав на саму таблицю;INVOKER- з правами того, хто читає.
Якщо користувача-власника видалили, представлення з DEFINER перестає працювати з помилкою про неіснуючого definer - типова проблема після перенесення дампу на інший сервер.
Навіщо представлення: повторно використовувати складний запит, дати стабільний інтерфейс звітам і BI-інструментам, обмежити доступ до колонок.
У Laravel представлення створюють у міграції через DB::statement('CREATE VIEW ...'), а читають звичайною моделлю з protected $table = 'active_customers'. Перевіряйте план через EXPLAIN: рядок DERIVED означає, що представлення матеріалізувалося.