Middle: питання на співбесіді з теми «Типи й обмеження»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
8 питань
FLOAT і DOUBLE зберігають числа у двійковій формі з плаваючою комою. Більшість десяткових дробів так точно не записати: 0.1 + 0.2 дає 0.30000000000000004. На мільйонах операцій копійки «губляться», а сума в звіті не сходиться з сумою платежів.
Два правильні варіанти:
NUMERIC/DECIMAL- точний десятковий тип:
amount NUMERIC(12, 2) NOT NULL
- Ціле число в найменших одиницях - копійках чи центах (
BIGINT). Так роблять Stripe та більшість платіжних систем: 1 999 копійок замість 19.99 грн.
Що ще важливо:
- Валюта поруч із сумою.
100без валюти нічого не означає. Окрема колонкаcurrency CHAR(3)(ISO 4217). - У PHP теж не
float: суму тримають якintу копійках або як рядок і рахують черезbcmathчи бібліотеки на кшталтbrick/money. - Округлення - явне й в одному місці. Розподіл 100 грн на три частини має дати 33.34 + 33.33 + 33.33, а не загубити копійку.
- Тип
moneyу PostgreSQL не радять: його формат залежить від налаштувань локалі сервера.
Правило: зберігати момент часу в UTC (або в типі, що зберігає саме момент), а в часовий пояс користувача перетворювати лише під час показу.
PostgreSQL:
timestamptz(timestamp with time zone) зберігає момент часу. Під час запису значення переводиться в UTC, під час читання - у пояс сесії (TimeZone). Назва вводить в оману: сам пояс не зберігається.timestamp(без поясу) - просто дата й час «на годиннику», без прив'язки до поясу. Годиться для «зустріч о 10:00 за місцевим часом», але не для моментів подій.
MySQL:
TIMESTAMPперетворюється в UTC за поясом сесії й назад, але має межу 2038 року.DATETIMEзберігає значення як є, без перетворень. Застосунок сам відповідає за те, щоб писати в UTC.
Типові проблеми:
- Сервер БД, PHP і застосунок мають різні пояси, і час «з'їжджає» на кілька годин. Явно налаштований пояс на кожному шарі прибирає цей клас багів.
- Перехід на літній час: «щодня о 02:30» за київським часом раз на рік не існує, а раз на рік буває двічі.
- Порівняння
DATE(created_at) = '2026-10-04'у UTC і в місцевому часі дає різні множини рядків.
Для майбутніх подій (запис до лікаря через пів року) інколи зберігають місцевий час і назву поясу окремо: правила поясів можуть змінитися до того моменту.
Історично utf8 у MySQL - це utf8mb3: до 3 байтів на символ. Справжній UTF-8 використовує до 4 байтів, і 4-байтові символи (емодзі, частина ієрогліфів, математичні символи) в utf8mb3 не вміщуються.
Що відбувається на практиці: користувач пише коментар з емодзі, і вставка падає з Incorrect string value: '\xF0\x9F\x98\x80', а в нестрогому режимі - текст обрізається на першому емодзі без помилки.
utf8mb4 - повний UTF-8. У MySQL 8 це кодування за замовчуванням (з collation utf8mb4_0900_ai_ci), а utf8mb3 застаріло.
Переведення наявних таблиць:
ALTER DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
ALTER TABLE comments CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CONVERT TO перекодовує дані всіх текстових колонок - це перебудова таблиці: на великих таблицях довго, з блокуванням запису. Для продакшену - через gh-ost чи pt-online-schema-change.
Підводні камені переходу:
- Ліміт довжини ключа індексу. У старих версіях і форматах рядка (
COMPACT) ключ обмежений 767 байтами:VARCHAR(255)вutf8mb4- це 1020 байтів, і індекс не створюється. ЗвідсиSchema::defaultStringLength(191)у старих Laravel-проєктах. З форматомDYNAMIC(за замовчуванням у MySQL 8) ліміт - 3072 байти, і проблеми немає. - Зміна collation змінює порівняння: нечутливість до регістру й діакритики (
ai_ci) може зробити «різні» значення однаковими - і унікальний індекс не створиться, поки дублікати не прибрати. - Розмір: тексти, що містять 4-байтові символи, займуть більше; для кирилиці розмір не зміниться (2 байти на символ).
- З'єднання теж має бути
utf8mb4:charsetу конфігурації підключення ('charset' => 'utf8mb4'у Laravel) - інакше дані перекодуються вutf8mb3ще по дорозі.
Правило для нових проєктів: лише utf8mb4 скрізь - сервер, база, таблиці, з'єднання.
CHECK - умова, яку має задовольняти кожен рядок. База перевіряє її при кожній вставці й оновленні.
CREATE TABLE products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
price DECIMAL(10, 2) NOT NULL,
discount_price DECIMAL(10, 2),
stock INT NOT NULL,
CONSTRAINT price_positive CHECK (price > 0),
CONSTRAINT stock_not_negative CHECK (stock >= 0),
CONSTRAINT discount_below_price CHECK (discount_price IS NULL OR discount_price < price)
);
INSERT INTO products (price, stock) VALUES (-5, 10);
-- ERROR 3819: Check constraint 'price_positive' is violated.
Важлива історія версій: до MySQL 8.0.16 синтаксис CHECK приймався, але ігнорувався - обмеження не перевірялися зовсім. Код, написаний для старих версій з «перевірками», насправді нічого не перевіряв. З 8.0.16 обмеження працюють.
Що вміє CHECK:
- умови на значення однієї колонки і на кілька колонок того самого рядка;
NOT ENFORCED- оголосити, але не перевіряти (для поступового впровадження).
Обмеження:
- Лише в межах рядка: не можна посилатися на інші рядки чи таблиці («сума залишків не більше ліміту складу») і використовувати недетерміновані функції (
NOW(),RAND()) чи підзапити. - Колонки з
AUTO_INCREMENTуCHECKне беруть участі. - Додавання до наявної таблиці перевіряє всі рядки: якщо хоч один порушує умову,
ALTER TABLEне виконається - спершу виправити дані.
Навіщо, якщо є валідація в застосунку: застосунок - не єдине джерело змін (консоль, міграції, імпорт, інший сервіс, баг у коді). CHECK - остання лінія захисту цілісності, а від'ємний залишок на складі - це завжди баг, хай би звідки він прийшов.
У Laravel-міграціях окремого методу для CHECK немає - додають сирим SQL через DB::statement('ALTER TABLE ... ADD CONSTRAINT ... CHECK (...)').
Згенерована колонка обчислюється з інших колонок того самого рядка за виразом.
CREATE TABLE order_items (
price DECIMAL(10, 2) NOT NULL,
quantity INT NOT NULL,
total DECIMAL(12, 2) AS (price * quantity) STORED
);
ALTER TABLE users
ADD COLUMN email_domain VARCHAR(255) AS (SUBSTRING_INDEX(email, '@', -1)) VIRTUAL,
ADD INDEX users_email_domain_idx (email_domain);
Два види:
VIRTUAL(за замовчуванням у MySQL) - не зберігається, обчислюється при читанні.STORED- обчислюється при записі й зберігається, як звичайна колонка.
Головна особливість MySQL - індекс на віртуальній колонці. InnoDB дозволяє вторинний індекс на VIRTUAL-колонці: значення обчислюються й зберігаються в індексі, але не в самій таблиці. Так індексують те, що інакше не проіндексувати:
Поле JSON:
ALTER TABLE products
ADD COLUMN brand VARCHAR(100) AS (attributes ->> '$.brand') VIRTUAL,
ADD INDEX products_brand_idx (brand);
SELECT * FROM products WHERE brand = 'Bosch'; -- використовує індекс
Вираз для пошуку без урахування регістру, дату з часу, нормалізоване значення.
Обмеження:
- Вираз - детермінований: без
NOW(),RAND(), змінних, підзапитів і посилань на інші таблиці. - Не можна записати значення напряму.
- Первинний ключ - лише на
STORED. - Зміна виразу
STORED-колонки - перебудова таблиці.
Функціональні індекси (MySQL 8.0.13+) - коротший запис того самого: індекс за виразом без явної колонки.
CREATE INDEX users_lower_email_idx ON users ((LOWER(email)));
Під капотом MySQL створює приховану віртуальну колонку. Щоб індекс спрацював, вираз у запиті має збігатися з виразом в індексі.
У Laravel-міграціях: $table->string('brand')->virtualAs("attributes->>'$.brand'") і ->storedAs(...).
sql_mode визначає, наскільки суворо MySQL поводиться з некоректними даними. Ключовий прапорець - STRICT_TRANS_TABLES (строгий режим), увімкнений за замовчуванням з MySQL 5.7.
Без строгого режиму MySQL «виправляє» дані мовчки:
-- name VARCHAR(10), age TINYINT UNSIGNED, created_at DATE
INSERT INTO users (name, age, created_at) VALUES ('Олександра Іваненко', 300, '2026-02-30');
-- вставлено з попередженнями:
-- name = 'Олександра', age = 255, created_at = '0000-00-00'
- рядок обрізано до довжини колонки;
- число поза діапазоном замінено на межу;
- неіснуюча дата - на нульову;
- значення не того типу - на 0 чи порожній рядок.
Застосунок отримує «успіх», а в базі - зіпсовані дані, які виявляють через місяці.
Зі строгим режимом ті самі вставки - помилки, і застосунок дізнається про проблему одразу.
Інші важливі прапорці режиму за замовчуванням у MySQL 8:
ONLY_FULL_GROUP_BY- заборона неоднозначнихGROUP BY;NO_ZERO_DATE,NO_ZERO_IN_DATE- заборона «нульових» дат0000-00-00;ERROR_FOR_DIVISION_BY_ZERO- ділення на нуль при записі - помилка, а неNULL;NO_ENGINE_SUBSTITUTION- помилка замість тихої заміни рушія таблиці.
Де режим вимикають (і не варто):
'strict' => falseуconfig/database.phpLaravel - встановлює м'який режим для з'єднання застосунку. Інколи так «лікують» помилки старого коду при оновленні MySQL - і повертають тихе псування даних.- Старі дампи з
0000-00-00не імпортуються в строгому режимі - їх треба виправити, а не вимикати режим глобально.
Як перевірити:
SELECT @@GLOBAL.sql_mode, @@SESSION.sql_mode;
SHOW WARNINGS; -- після операції, якщо режим м'який
Правило: строгий режим скрізь, а некоректні значення - відхиляти валідацією в застосунку з зрозумілим повідомленням, а не покладатися на те, що база їх «підправить».
Автоматичні мітки часу - для TIMESTAMP і DATETIME:
CREATE TABLE posts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
DEFAULT CURRENT_TIMESTAMP- значення при вставці, якщо колонку не передали.ON UPDATE CURRENT_TIMESTAMP- автоматичне оновлення при будь-якій зміні рядка.- Дробові секунди - з точністю:
DATETIME(6) ... DEFAULT CURRENT_TIMESTAMP(6).
Нюанси ON UPDATE:
- Спрацьовує, лише якщо значення якоїсь колонки справді змінилося.
UPDATE posts SET title = titleмітку не оновить. - Спрацьовує й на технічні зміни (лічильник переглядів, службовий прапорець) - «оновлено» перестає означати «змінено автором». Тоді краще керувати міткою в застосунку.
Значення за замовчуванням виразом (MySQL 8.0.13+) - вираз у дужках:
CREATE TABLE api_tokens (
id BINARY(16) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1)) PRIMARY KEY,
expires_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL 30 DAY),
settings JSON NOT NULL DEFAULT (JSON_OBJECT()),
notes TEXT DEFAULT ('')
);
До 8.0.13 дозволялися лише літерали й CURRENT_TIMESTAMP; TEXT, BLOB і JSON не могли мати значення за замовчуванням узагалі.
Обмеження виразів: без підзапитів, змінних, збережених функцій і посилань на AUTO_INCREMENT-колонку.
Laravel і мітки часу: Eloquent сам заповнює created_at / updated_at у застосунку (з часовим поясом застосунку) і не покладається на ON UPDATE. Але масові оновлення через Query Builder (DB::table()->update()) Eloquent не бачить - там updated_at не зміниться, якщо в базі немає ON UPDATE. $table->timestamp('updated_at')->useCurrentOnUpdate() додає його в міграції.
Часові пояси: CURRENT_TIMESTAMP обчислюється в поясі сесії MySQL. Якщо він не збігається з поясом застосунку, мітки, поставлені базою й застосунком, розійдуться на кілька годин.
Докладніше в документації: Ініціалізація TIMESTAMP і DATETIME
Тригер - SQL-код, який MySQL виконує автоматично до чи після INSERT, UPDATE або DELETE для кожного рядка.
DELIMITER //
CREATE TRIGGER orders_audit
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
IF NOT (OLD.status <=> NEW.status) THEN
INSERT INTO order_status_log (order_id, old_status, new_status, changed_at)
VALUES (NEW.id, OLD.status, NEW.status, NOW());
END IF;
END //
DELIMITER ;
OLD- рядок до зміни (уUPDATEіDELETE),NEW- після (уINSERTіUPDATE);- у тригері
BEFOREможна змінити значення:SET NEW.slug = LOWER(NEW.slug); SIGNAL SQLSTATE '45000'уBEFORE-тригері скасовує операцію з помилкою.
Обмеження MySQL, які варто знати:
- лише рядкові тригери (
FOR EACH ROW) - тригерів рівня оператора, як у PostgreSQL, немає. МасовийUPDATEмільйона рядків - мільйон викликів; - не можна змінювати ту саму таблицю, на яку спрацював тригер (і будь-яку таблицю, яку вже читає оператор, що його викликав) - помилка
Can't update table ... in stored function/trigger; - тригери не спрацьовують на дії зовнішніх ключів:
ON DELETE CASCADEвидалить дочірні рядки без їхніх тригерів; - кілька тригерів на одну подію дозволені, порядок задають
FOLLOWS/PRECEDES; - реплікація: з рядковим форматом бінарного журналу (за замовчуванням) на репліці тригери не виконуються - туди приходять уже готові зміни рядків, включно з тими, що зробив тригер на джерелі. Зі
STATEMENT-форматом тригери виконуються на репліці знову.
Тригер чи подія Eloquent:
| Тригер | Подія моделі Eloquent | |
|---|---|---|
спрацьовує при масовому update(), сирому SQL, зміні з іншого сервісу |
так | ні |
| видно в коді застосунку | ні | так |
| тестування | на справжній базі | звичайними тестами |
| може надіслати лист, поставити завдання в чергу | ні | так |
Підводні камені: невидима логіка (розробник не знає, що UPDATE змінює ще одну таблицю), складність налагодження, додаткові блокування в довгих транзакціях. Тригери мають бути задокументовані, мати зрозумілі назви й створюватися в міграціях (DB::unprepared()), а тести - запускатися на MySQL, а не SQLite.
Правило: у тригерах - інваріанти й аудит, які мають працювати незалежно від того, хто пише в базу; побічні ефекти - у застосунку.