Цей документ містить детальні розбори питань із реляційних баз даних (PostgreSQL), SQL-зв'язків (1:1, 1:N, N:M), механік ORM, оптимізації запитів та проєктування схем із p-1_d-3.md.
1. Проблема N+1: Що це таке при роботі з реляційними базами через ORM і якими двома основними способами її вирішують у SQL/ORM?
Проблема N+1 виникає, коли для отримання списку з
-
1 запит для отримання батьківського списку (
$N$ записів). -
$N$ додаткових запитів (по одному для кожного запису) для завантаження зв'язаних даних.
Разом:
Уявімо код:
// 1-й запит до БД:
const orders = await orderRepository.find(); // повернуло 100 замовлень
// Далі в циклі звертаємося до зв'язаного користувача:
for (const order of orders) {
// Ще 100 окремих запитів до БД!
const user = await userRepository.findOne({ where: { id: order.userId } });
console.log(`${order.id} belongs to ${user.name}`);
}У логах бази даних ми побачимо:
SELECT * FROM orders; -- 1 запит (N = 100 рядків)
SELECT * FROM users WHERE id = 'u1'; -- 1-й додатковий
SELECT * FROM users WHERE id = 'u2'; -- 2-й додатковий
...
SELECT * FROM users WHERE id = 'u100'; -- 100-й додатковий| Спосіб | Як працює на рівні SQL | Плюси | Мінуси |
|---|---|---|---|
| 1. JOIN (Single Query / Eager Loading) | SELECT * FROM orders o LEFT JOIN users u ON o.user_id = u.id |
Виконується рівно 1 запит до БД. База оптимізує план виконання. | Cartesian Product: якщо зв'язок 1:N, батьківські дані дублюються для кожного дочірнього рядка. Збільшується мережевий трафік. |
| 2. BATCHING / WHERE IN (Two Queries) | SELECT * FROM orders;SELECT * FROM users WHERE id IN ('u1', 'u2', ...); |
2 запити. Немає дублювання рядків. Ідеально працює для великих об'ємів та 1:N зв'язків. | Потрібно виконати 2 запити замість одного та зіставити дані в пам'яті бекенду. |
Саме спосіб Batching (WHERE IN) використовує Prisma ORM та бібліотека DataLoader у GraphQL:
- Робиться запит за всіма замовленнями.
- Збирається унікальний масив ID користувачів:
['u1', 'u2', ...]. - Виконується один запит
SELECT * FROM users WHERE id IN (...). - Отриманий масив користувачів індексується у пам'яті через
Map(точно як наша функціяbuildIndexMap), і за$O(N + M)$ об'єкти з'єднуються без вкладених циклів!
2. Цілісність даних: Чому ціну в OrderItem зберігають окремо, навіть якщо вона вже є в таблиці Product?
Збереження unitPrice у таблиці OrderItem — це фіксація знімка стану (Snapshot / Point-in-Time Data) на момент здійснення правочину (купівлі). Ціна в Product є поточною вітринною ціною, яка постійно змінюється.
-
Історична незмінність та фінансовий аудит:
- Сьогодні товар коштує 200 грн. Клієнт купив його і заплатив 200 грн.
- Через місяць через інфляцію ціну в таблиці
Productзмінили на 300 грн. - Якщо
OrderItemне має власної колонкиunitPrice, а бере ціну через зв'язокJOIN product ON product.id = order_item.product_id:- Старе замовлення покаже суму 300 грн замість 200 грн.
- Фінансова звітність, податкові чеки та баланс компанії не зійдуться з реально списаними коштами в банку!
-
Персональні ціни, знижки та акції:
- На момент оформлення замовлення могла діяти сезонна знижка (Black Friday -20%) або персональний промокод.
OrderItem.unitPriceфіксує конкретну ціну, за якою товар було продано в дану секунду.
-
Життєвий цикл товару (Видалення та архівація):
- Якщо товар знято з виробництва і видалено/архівовано з таблиці
Product, замовлення не повинно зламатися. Воно повинно містити повну інформацію про куплений товар і його вартість.
- Якщо товар знято з виробництва і видалено/архівовано з таблиці
-
Нормалізація vs Практична денормалізація:
- Формально це порушення третьої нормальної форми (3NF), але семантично:
Product.price— це пропозиція ціни (Offer).OrderItem.unitPrice— це ціна юридичного договору купівлі-продажу (Contract Price). Це дві різні сутності з різним життєвим циклом.
- Формально це порушення третьої нормальної форми (3NF), але семантично:
3. ON DELETE CASCADE vs SET NULL vs RESTRICT: У яких бізнес-сценаріях слід обрати кожен із цих варіантів при налаштуванні Foreign Key?
Ці правила (Foreign Key Constraints) визначають поведінку реляційної бази даних, коли хтось намагається видалити батьківський рядок (Parent Row), на який посилаються дочірні записи (Child Rows).
| Стратегія | Що робить БД при видаленні батька | Бізнес-сценарій | Приклад зв'язку |
|---|---|---|---|
CASCADE |
Автоматично видаляє всі дочірні записи, що посилаються на батька. | Строга композиція (Strict Ownership): Дочірній запис не має сенсу існувати без батьківського. |
Order OrderItem(Видалили замовлення — видалилися всі його позиції). User Profile(Видалили акаунт — стерлися персональні дані профілю). |
SET NULL |
Залишає дочірні записи, але встановлює значення Foreign Key у NULL. |
Опціональний зв'язок (Loose Aggregation): Дочірній запис має цінність сам по собі, зв'язок необов'язковий. |
Category Product(Видалили категорію «Акції» — товари залишилися з categoryId = NULL).User (Courier) Order(Кур'єра звільнили — замовлення залишається, але без призначеного кур'єра). |
RESTRICT / NO ACTION |
Блокує видалення. База викидає помилку Foreign Key Constraint Violation. |
Захист критичних бізнес-даних та аудиту: Заборонено видаляти сутність, якщо є хоч одна транзакція/посилання на неї. |
User Order(Не можна видалити користувача, якщо у нього є реальні замовлення!). Product OrderItem(Не можна видалити товар, якщо його вже купували — замість видалення роблять Soft Delete). |
Warning
Небезпека CASCADE у продакшені:
Якщо налаштувати ON DELETE CASCADE на зв'язку User Orders OrderItems Payments, випадковий запит DELETE FROM users WHERE id = 'u1' миттєво і незворотно зітре всю фінансову історію користувача. Тому для фінансових і транзакційних сутностей завжди використовують RESTRICT у комбінації з Soft Delete (deletedAt TIMESTAMP).
4. Eager Loading vs Lazy Loading: Чому Lazy Loading вважається антипатерном у високонавантажених Node.js бекендах?
- Eager Loading (Жадібне завантаження): Ви явно вказуєте ORM завантажити зв'язки одразу в межах одного або згрупованого запиту (наприклад,
prisma.order.findMany({ include: { items: true } })). - Lazy Loading (Ліниве завантаження): Дані зв'язаної таблиці не завантажуються одразу, а підтягуються неявно у момент першого звернення до властивості (наприклад,
order.items).
У високонавантажених Node.js сервісах Lazy Loading вважається небезпечним антипатерном.
Розробник пише звичайний серіалізатор або цикл:
const orders = await orderRepo.find();
const response = orders.map(order => ({
id: order.id,
user: order.user.name, // ⚠️ Бум! Для кожного рядка неявно полетів новий SQL-запит!
}));У коді це виглядає як просте звернення до поля в пам'яті, а на рівні бази генеруються сотні прихованих мережевих запитів.
- У Java (Hibernate) чи C# (.NET Entity Framework) звернення до властивості
order.getUser()блокує потік до отримання даних. - У JavaScript звернення до поля
order.userє синхронним. Щоб зробити його лінивим, ORM має повертатиPromise(await order.user), або використовувати складні Proxy-обгортки, що порушує передбачуваність роботи Event Loop та ускладнює типізацію TypeScript.
У високонавантаженому середовищі пул з'єднань до PostgreSQL зазвичай обмежений (наприклад, 10–20 з'єднань на інстанс).
- При Eager Loading запит бере з'єднання, швидко вичитує всі дані і відразу повертає з'єднання в пул.
- При Lazy Loading транзакція/з'єднання утримується відкритим на весь час виконання коду, доки розробник почергово звертається до різних зв'язаних властивостей. Пул миттєво блокується, і нові клієнти отримують
Connection Timeout.
При Eager Loading ви бачите у коді точний SQL-план, можете додати потрібні індекси, оптимізувати JOIN чи використати кеш. При Lazy Loading запити розмазані по всьому додатку (іноді виникають навіть усередині UI-шаблонів чи DTO-трансформерів).
Сучасні TypeScript ORM (зокрема Prisma, MikroORM та сучасний режим TypeORM) повністю відмовилися від Lazy Loading або вважають його deprecated. Стандартом є Explicit Eager Loading:
// ✅ Чітко, прозоро, передбачувано за запитами:
const orderWithItems = await prisma.order.findUnique({
where: { id: 'o1' },
include: {
items: true,
user: true,
},
});