Skip to content

Latest commit

 

History

History
159 lines (117 loc) · 14.5 KB

File metadata and controls

159 lines (117 loc) · 14.5 KB

🎯 Project 1 Day 3 Answers: Відповіді на питання для самоперевірки та співбесіди

Цей документ містить детальні розбори питань із реляційних баз даних (PostgreSQL), SQL-зв'язків (1:1, 1:N, N:M), механік ORM, оптимізації запитів та проєктування схем із p-1_d-3.md.


1. Проблема N+1: Що це таке при роботі з реляційними базами через ORM і якими двома основними способами її вирішують у SQL/ORM?

📌 Коротке резюме

Проблема N+1 виникає, коли для отримання списку з $N$ сутностей та їхніх зв'язаних даних ORM виконує:

  • 1 запит для отримання батьківського списку ($N$ записів).
  • $N$ додаткових запитів (по одному для кожного запису) для завантаження зв'язаних даних.

Разом: $1 + N$ запитів до БД. При 100 замовленнях виконується 101 запит, що призводить до високої затримки (latency), блокування пулу з'єднань та перевантаження бази.


🔍 Як це виглядає на практиці

Уявімо код:

// 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-й додатковий

🛠️ Два основних способи вирішення проблеми N+1

Спосіб Як працює на рівні 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 запити замість одного та зіставити дані в пам'яті бекенду.

Як спосіб №2 пов'язаний із нашим алгоритмом з Завдання 1:

Саме спосіб Batching (WHERE IN) використовує Prisma ORM та бібліотека DataLoader у GraphQL:

  1. Робиться запит за всіма замовленнями.
  2. Збирається унікальний масив ID користувачів: ['u1', 'u2', ...].
  3. Виконується один запит SELECT * FROM users WHERE id IN (...).
  4. Отриманий масив користувачів індексується у пам'яті через Map (точно як наша функція buildIndexMap), і за $O(N + M)$ об'єкти з'єднуються без вкладених циклів!

2. Цілісність даних: Чому ціну в OrderItem зберігають окремо, навіть якщо вона вже є в таблиці Product?

📌 Коротке резюме

Збереження unitPrice у таблиці OrderItem — це фіксація знімка стану (Snapshot / Point-in-Time Data) на момент здійснення правочину (купівлі). Ціна в Product є поточною вітринною ціною, яка постійно змінюється.


🔍 Чому не можна покладатися лише на Product.price:

  1. Історична незмінність та фінансовий аудит:

    • Сьогодні товар коштує 200 грн. Клієнт купив його і заплатив 200 грн.
    • Через місяць через інфляцію ціну в таблиці Product змінили на 300 грн.
    • Якщо OrderItem не має власної колонки unitPrice, а бере ціну через зв'язок JOIN product ON product.id = order_item.product_id:
      • Старе замовлення покаже суму 300 грн замість 200 грн.
      • Фінансова звітність, податкові чеки та баланс компанії не зійдуться з реально списаними коштами в банку!
  2. Персональні ціни, знижки та акції:

    • На момент оформлення замовлення могла діяти сезонна знижка (Black Friday -20%) або персональний промокод.
    • OrderItem.unitPrice фіксує конкретну ціну, за якою товар було продано в дану секунду.
  3. Життєвий цикл товару (Видалення та архівація):

    • Якщо товар знято з виробництва і видалено/архівовано з таблиці Product, замовлення не повинно зламатися. Воно повинно містити повну інформацію про куплений товар і його вартість.
  4. Нормалізація vs Практична денормалізація:

    • Формально це порушення третьої нормальної форми (3NF), але семантично:
      • Product.price — це пропозиція ціни (Offer).
      • OrderItem.unitPrice — це ціна юридичного договору купівлі-продажу (Contract Price). Це дві різні сутності з різним життєвим циклом.

3. ON DELETE CASCADE vs SET NULL vs RESTRICT: У яких бізнес-сценаріях слід обрати кожен із цих варіантів при налаштуванні Foreign Key?

📌 Коротке резюме

Ці правила (Foreign Key Constraints) визначають поведінку реляційної бази даних, коли хтось намагається видалити батьківський рядок (Parent Row), на який посилаються дочірні записи (Child Rows).


🔍 Порівняльна таблиця та бізнес-сценарії

Стратегія Що робить БД при видаленні батька Бізнес-сценарій Приклад зв'язку
CASCADE Автоматично видаляє всі дочірні записи, що посилаються на батька. Строга композиція (Strict Ownership): Дочірній запис не має сенсу існувати без батьківського. Order $\to$ OrderItem
(Видалили замовлення — видалилися всі його позиції).
User $\to$ Profile
(Видалили акаунт — стерлися персональні дані профілю).
SET NULL Залишає дочірні записи, але встановлює значення Foreign Key у NULL. Опціональний зв'язок (Loose Aggregation): Дочірній запис має цінність сам по собі, зв'язок необов'язковий. Category $\to$ Product
(Видалили категорію «Акції» — товари залишилися з categoryId = NULL).
User (Courier) $\to$ Order
(Кур'єра звільнили — замовлення залишається, але без призначеного кур'єра).
RESTRICT / NO ACTION Блокує видалення. База викидає помилку Foreign Key Constraint Violation. Захист критичних бізнес-даних та аудиту: Заборонено видаляти сутність, якщо є хоч одна транзакція/посилання на неї. User $\to$ Order
(Не можна видалити користувача, якщо у нього є реальні замовлення!).
Product $\to$ OrderItem
(Не можна видалити товар, якщо його вже купували — замість видалення роблять Soft Delete).

Warning

Небезпека CASCADE у продакшені: Якщо налаштувати ON DELETE CASCADE на зв'язку User $\to$ Orders $\to$ OrderItems $\to$ 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 вважається небезпечним антипатерном.


🔍 Чому Lazy Loading — антипатерн у Node.js:

1. Невидима та неконтрольована проблема N+1

Розробник пише звичайний серіалізатор або цикл:

const orders = await orderRepo.find();
const response = orders.map(order => ({
  id: order.id,
  user: order.user.name, // ⚠️ Бум! Для кожного рядка неявно полетів новий SQL-запит!
}));

У коді це виглядає як просте звернення до поля в пам'яті, а на рівні бази генеруються сотні прихованих мережевих запитів.

2. Асинхронна природа JavaScript (Event Loop)

  • У Java (Hibernate) чи C# (.NET Entity Framework) звернення до властивості order.getUser() блокує потік до отримання даних.
  • У JavaScript звернення до поля order.user є синхронним. Щоб зробити його лінивим, ORM має повертати Promise (await order.user), або використовувати складні Proxy-обгортки, що порушує передбачуваність роботи Event Loop та ускладнює типізацію TypeScript.

3. Виснаження пулу з'єднань (Connection Pool Starvation)

У високонавантаженому середовищі пул з'єднань до PostgreSQL зазвичай обмежений (наприклад, 10–20 з'єднань на інстанс).

  • При Eager Loading запит бере з'єднання, швидко вичитує всі дані і відразу повертає з'єднання в пул.
  • При Lazy Loading транзакція/з'єднання утримується відкритим на весь час виконання коду, доки розробник почергово звертається до різних зв'язаних властивостей. Пул миттєво блокується, і нові клієнти отримують Connection Timeout.

4. Неможливість оптимізації та профілювання

При 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,
  },
});