Питання на співбесіді з MySQL
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
107 питань
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) не працює саме через порядок виконання.
NULL у SQL означає «невідоме значення». Будь-яке порівняння з невідомим дає теж невідоме - NULL, а не true чи false. А WHERE пропускає лише рядки, де умова true.
SELECT * FROM users WHERE deleted_at = NULL; -- завжди порожньо
SELECT * FROM users WHERE deleted_at IS NULL; -- правильно
SELECT * FROM users WHERE deleted_at IS NOT NULL;
Де ще NULL поводиться несподівано:
NULL = NULL- тежNULL. Для «рівні, з урахуванням NULL» єIS NOT DISTINCT FROMу PostgreSQL і оператор<=>у MySQL.COUNT(*)рахує всі рядки,COUNT(column)- лише ті, де значення неNULL.SUM,AVGігноруютьNULL.AVGз трьох значень, одне з якихNULL, ділить на 2.- Арифметика з
NULLдаєNULL:price * NULL. Підставити значення за замовчуванням допомагаєCOALESCE(discount, 0). WHERE status <> 'banned'не поверне рядки зіstatus IS NULL.
Порада: колонки, де значення обов'язкове, позначайте NOT NULL. Тоді питання «а що, як тут NULL» просто не виникає.
Первинний ключ (PRIMARY KEY) однозначно ідентифікує рядок: значення унікальне й не може бути NULL. Під нього база автоматично створює унікальний індекс.
Зовнішній ключ (FOREIGN KEY) гарантує, що значення в колонці посилається на існуючий рядок іншої таблиці.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
total NUMERIC(10, 2) NOT NULL
);
Що дає зовнішній ключ:
- Неможливо створити замовлення для неіснуючого користувача - помилка буде одразу, а не через місяць у звіті.
- Видалення батьківського рядка керується явно:
CASCADE(видалити й залежні),SET NULL,RESTRICT(заборонити, поки є залежні).
Чому інколи без FK: при шардингу чи в дуже навантажених системах цілісність перевіряють у коді. Але для звичайного застосунку обмеження в базі - найнадійніший захист: код може мати баги, кілька сервісів можуть писати в одну базу, а обмеження перевіряється завжди.
Нюанс MySQL: зовнішні ключі підтримує лише InnoDB. У PostgreSQL під FK індекс на колонці, що посилається, не створюється автоматично - його варто додати, інакше JOIN і каскадне видалення будуть повільними.
Обидва об'єднують результати кількох 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.
| Тип | Байтів | Діапазон зі знаком | UNSIGNED |
|---|---|---|---|
TINYINT |
1 | -128..127 | 0..255 |
SMALLINT |
2 | -32 768..32 767 | 0..65 535 |
MEDIUMINT |
3 | ±8,4 млн | 0..16,7 млн |
INT |
4 | ±2,1 млрд | 0..4,29 млрд |
BIGINT |
8 | ±9,2 × 10^18 | 0..1,8 × 10^19 |
UNSIGNED - лише невід'ємні значення, зате вдвічі більший верхній діапазон.
Як обирати:
- Первинні ключі -
BIGINT UNSIGNED.INTзакінчується на 2,1 мільярда (4,29 зUNSIGNED), і в таблицях подій, логів чи лічильників це трапляється раніше, ніж очікують. Змінити тип ключа на великій таблиці - довга й ризикована операція. Laravel$table->id()створює самеBIGINT UNSIGNED. - Зовнішні ключі - того самого типу, що й ключ, на який вони посилаються, включно з
UNSIGNED. Інакше обмеження не створиться. - Прапорці й маленькі перелічення -
TINYINT.BOOLEANу MySQL - це синонімTINYINT(1).
Пастки:
INT(11)- не обмеження розміру. Число в дужках - лише «ширина відображення» для старогоZEROFILL, на діапазон не впливає й застаріло з MySQL 8.0.17.- Віднімання з
UNSIGNED:SELECT a - bдляUNSIGNED-колонок при від'ємному результаті дає помилку «out of range» (а в нестрогому режимі - величезне число). Для арифметики, де можливі від'ємні значення, -CAST(a AS SIGNED) - b. - Гроші не в
INTгривнях, а в копійках (BIGINT) чиDECIMAL. - Переповнення: у строгому режимі (
STRICT_TRANS_TABLES, за замовчуванням) вставка значення поза діапазоном - помилка; без нього значення тихо обрізається до межі.
CHAR(n)- фіксованої довжини: значення доповнюється пробілами доnсимволів при збереженні, а при читанні кінцеві пробіли прибираються.VARCHAR(n)- змінної довжини: зберігається рівно стільки символів, скільки записано, плюс 1-2 байти на довжину.
CREATE TABLE codes (
country CHAR(2), -- завжди 2 символи: 'UA', 'PL'
email VARCHAR(255) -- від 0 до 255 символів
);
Коли CHAR: значення завжди однакової довжини - коди країн і валют, хеші фіксованої довжини, коди статусів. Тоді немає зайвих байтів на довжину.
Коли VARCHAR: усе інше - імена, email, адреси, заголовки.
Нюанси:
n- у символах, а не байтах. Вutf8mb4символ займає до 4 байтів, тожVARCHAR(255)- до 1020 байтів даних. Це важливо для обмежень розміру рядка таблиці й індексу.CHARвutf8mb4у InnoDB займає змінну кількість байтів, тож перевага «фіксованої довжини» для багатобайтових кодувань майже зникає.- Кінцеві пробіли:
CHARвтрачає їх при читанні; дляVARCHARвони зберігаються, але порівняння в collations зPAD SPACEїх ігнорують ('a' = 'a '- істина). Collationsutf8mb4_0900_*у MySQL 8 -NO PAD, і там пробіли значущі. - Обмеження
n:VARCHARдо 65 535 байтів, але всі колонки рядка разом не можуть перевищити 65 535 байтів - довгі тексти виносять уTEXT.
VARCHAR(255) за звичкою - нормально як верхня межа, але для полів з відомим обмеженням (телефон, код) варто ставити реальну довжину: це й документація, і захист від сміття. Валідація довжини все одно потрібна й у застосунку, щоб користувач отримав зрозуміле повідомлення.
Розміри типів TEXT:
TINYTEXT- до 255 байтів;TEXT- до 64 КБ;MEDIUMTEXT- до 16 МБ;LONGTEXT- до 4 ГБ.
Ліміти - у байтах: в utf8mb4 TEXT вміщує від ~16 до 64 тисяч символів залежно від мови.
Відмінності від VARCHAR:
- Ліміт рядка таблиці. Усі колонки рядка разом - не більше 65 535 байтів, і
VARCHARвраховується повністю.TEXTзаймає в цьому ліміті лише 9-12 байтів (вміст зберігається окремо), тож довгі тексти - лишеTEXT. - Індекси. Індекс на
TEXTможливий лише за префіксом (INDEX (body(100))), повний унікальний індекс - ні.VARCHARіндексується цілком (в межах ліміту ключа 3072 байти). - Значення за замовчуванням. Для
TEXT- лише як вираз у дужках (з MySQL 8.0.13);VARCHARприймає звичайний літерал. - Зберігання. InnoDB з форматом рядка
DYNAMICвиносить великі значення на окремі сторінки. Запит, що вибирає такі колонки (SELECT *), читає ці сторінки - звідси повільні списки.
Як обирати:
- Обмежений короткий текст (імена, заголовки, email, URL) -
VARCHARз розумною довжиною. - Довгий текст без жорсткої межі (статті, коментарі, описи, сирий HTML) -
TEXT/MEDIUMTEXT. - Великі бінарні дані (файли) - у сховище (S3), а в базі - шлях;
BLOBлише для невеликих бінарних значень.
Практичні наслідки:
- Не вибирайте
TEXT-колонки в списках:select(['id', 'title'])замістьSELECT *. - Для пошуку по тексту
LIKE '%...%'наTEXT- повний перегляд таблиці. ПотрібенFULLTEXT-індекс або пошуковий рушій. - У Laravel
$table->string()-VARCHAR(255),$table->text()-TEXT,$table->longText()-LONGTEXT.
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.
MySQL зберігає дані через рушії зберігання (storage engines). Один сервер може мати таблиці на різних рушіях. Від MySQL 5.5 рушій за замовчуванням - InnoDB, а MyISAM лишився спадщиною.
Що дає InnoDB:
- Транзакції (ACID).
COMMIT,ROLLBACK, відновлення після збою. У MyISAM транзакцій немає взагалі:DB::transaction()у Laravel на MyISAM-таблиці нічого не відкотить. - Блокування рядків. Два запити, що оновлюють різні рядки однієї таблиці, не заважають один одному. MyISAM блокує всю таблицю на кожен запис - під навантаженням на запис усі запити стають у чергу.
- Узгоджене читання (MVCC). Читачі не чекають на записувачів:
SELECTбачить знімок даних, а не блокується через чужийUPDATE. - Зовнішні ключі. MyISAM синтаксис
FOREIGN KEYприймає, але ігнорує. - Стійкість до збоїв. Після аварійного вимкнення InnoDB відновлюється за журналом повтору (redo log). MyISAM-таблиці після збою часто доводиться «лагодити» (
REPAIR TABLE) з ризиком втратити дані. - Кластерний індекс. Рядки фізично впорядковані за первинним ключем, тож пошук за ним дуже дешевий.
Чим MyISAM колись приваблював і чому це вже неактуально:
- точний
COUNT(*)без умов миттєво (лічильник у метаданих) - InnoDB рахує рядки, бо через MVCC різні транзакції бачать різну кількість; - повнотекстовий пошук - InnoDB має його від 5.6;
- менший розмір на диску - різниця не варта втрати транзакцій.
Як перевірити й перевести:
SELECT table_name, engine FROM information_schema.tables
WHERE table_schema = DATABASE() AND engine <> 'InnoDB';
ALTER TABLE legacy_logs ENGINE = InnoDB;
ALTER ... ENGINE перебудовує таблицю повністю, тож на великих таблицях це варто планувати.
Інші рушії мають вузьке застосування: MEMORY для тимчасових даних (зникають при перезапуску), ARCHIVE для рідко читаних логів, CSV для обміну. Для таблиць застосунку відповідь майже завжди одна - InnoDB.
Питання з реальних технічних співбесід - 107 питань у 5 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії