Головна / Статті

Проєктування бекенд-системи через вузьке місце: від скорочувача URL до електронної комерції

Підхід до проектування бекендів Node.js, зосереджений на вимогах: коли слід додавати балансувальники навантаження, Redis, репліки, черги та обмеження швидкості, та скільки кожен з цих елементів коштує.

Видавець: REACTAPP.TOP13 хв читання3028 слів

Джерела: System Design for Backend Engineers: A Practical Guide With Real Examples

Багато інженерів підходять до проектування системи, створюючи схему: балансувальник навантаження, Redis, Kafka, API-шлюз та десяток мікросервісів. Результат виглядає вражаюче, але нічого не пояснює. Чи то під час співбесіди, чи у реальному продукті, проектування — це практика вибору правильних компонентів для конкретної проблеми та здатність обґрунтувати кожен з них. Цей посібник демонструє цей процес обґрунтування на прикладі двох конкретних систем — скорочувача URL та невеликого бекенду для електронної комерції, щоб ви могли поступово розвивати архітектуру, кожен крок обґрунтовуючи, та називати компроміси, які вводить кожен з них.

Починайте з вимог, а не з технологій

Найпоширенішою початковою помилкою є те, що люди починають з інструментів. Хтось описує проблему, і перші думки — «Redis чи ні? Kafka? Мікросервіси?» Цей порядок є неправильним. Поки ви не знаєте, що має робити система та в яких масштабах, у вас немає підстав для вибору чого-небудь.

Візьмемо скорочувач URL. Перш ніж намалювати хоча б одну схему, потрібно отримати відповіді на такі запитання:

  • Скільки буде користувачів та скільки URL вони створять?
  • Яку швидкість запитів він має обробляти у пік?
  • Як довго залишаються активними посилання та чи можуть вони закінчитися терміном дії?
  • Чи потрібні користувачам аналітики кліків?
  • Яку кількість часу перерв у роботі можна вважати прийнятною?
  • Наскільки швидко має відбуватися перенаправлення?

Кожна відповідь впливає на дизайн. Сервіс, який обробляє такий обсяг трафіку:

1,000 requests/day

майже нічим не схожий на той, який має обробляти такий обсяг:

10 million requests/secon

Перший варіант зручно розміщується на одному невеликому сервері. Другий змушує вже з самого початку приймати рішення щодо кешування, реплікації та географічного розташування.

Почніть з найпростішої працездатної архітектури

Для невеликого скорочувача URL перша версія може бути майже абсолютно простою:

Client
   ↓
Node.js API
   ↓
PostgreSQL

API генерує короткий код та повертає посилання на кшталт:

https://example.com/abc123

Потім він зберігає одне значення мапування в PostgreSQL:

short_code → original_url

Коли браузер запитує шлях, подібний до наведеного нижче, API знаходить оригінальний URL та відповідає перенаправленням:

/abc123

Для помірного обсягу роботи це є повноцінною та правильною системою. У ній немає потреби у звичайних елементах:

Kafka
Redis
Kubernetes
Microservices

Додавання цих елементів лише через їх наявність призводить до зростання операційних витрат, більшої кількості можливих збоїв та необхідності навчання кожного нового члена команди. Спочатку потрібно створити працездатний продукт, оцінити його роботу, а потім масштабувати ті компоненти, які виявляються вузькими місцями.

Масштабування при недостатності одного сервера

Припустимо, що сервіс скорочення посилань стає популярним, і його єдиний процес API перевантажується:

1 API server

Можна продовжувати купувати більш потужні машини, але вертикальне масштабування має свої обмеження, а одна машина є єдиним точкою відмови. Більш поширеним підходом є запуск кількох ідентичних екземплярів за допомогою балансувальника навантаження:

Load Balancer
                      ↓
          ┌───────────┼───────────┐
          ↓           ↓           ↓
       Node API    Node API    Node API
          ↓           ↓           ↓
          └───────────┼───────────┘
                      ↓
                  PostgreSQL

Запити тепер розподіляються між кількома екземплярами додатку. Це є горизонтальним масштабуванням: замість того, щоб покладатися на

1 huge server

ви покладаєтесь на

multiple smaller servers

Горизонтальне масштабування працює лише тоді, коли екземпляри є взаємозамінними, а це ставить питання, яких раніше не існувало:

  • Де зберігається стан сесії, якщо наступний запит користувача може надійти до іншого екземпляра?
  • Чи справді кожен екземпляр є безстановим?
  • Куди потрапляють завантажені файли, оскільки локальний диск більше не є спільним?
  • Як кожен екземпляр підключається до спільної інфраструктури, такої як база даних?

Масштабування одного компонента зазвичай створює вимоги в інших частинах системи; тут стан доводиться переміщувати до спільних сховищ.

Додайте кеш лише тоді, коли у вас справді є проблема з кешуванням

Популярні короткі посилання запитуються знову та знову:

/abc123
/abc123
/abc123
/abc123
/abc123

Немає жодних підстав для того, щоб кожен із цих запитів надсилався до PostgreSQL. Кеш перед базою даних забезпечує швидкий доступ до часто використовуваних даних з пам’яті, переходячи до бази даних у разі її недоступності та зберігаючи результат для подальшого використання:

Client
  ↓
Load Balancer
  ↓
Node.js API
  ↓
Redis
  │
  ├── Cache Hit → Return URL
  │
  └── Cache Miss
          ↓
      PostgreSQL
          ↓
        Redis

Цей підхід із використанням кешу дозволяє зменшити навантаження на базу даних під час значного обсягу операцій зчитування, як-от при перенаправленні посилань. Водночас виникає нове питання: якщо місце призначення короткого посилання змінюється або видаляється, як кешована копія про це дізнається? Тепер необхідна стратегія анулювання даних у кеші – чи то шляхом видалення ключа після кожної запису, чи встановлення терміну дії кешу, або обома способами. Щоб дізнатися більше про ці варіанти, ознайомтесь з нашим посібником про шаблони та проблеми кешування в Redis.

Кожне покращення в цьому посібнику супроводжується подібними витратами. Саме здатність назвати ці витрати відрізняє дизайн від діаграми.

Виправте базу даних перед додаванням нових

У певний момент PostgreSQL може стати вузьким місцем. Перед додаванням нової інфраструктури з’ясуйте, чому він працює повільно. Поширені причини включають:

  • Відсутність індексів у стовпцях, які використовуються для пошуку
  • Неефективні запити або об’єднання даних
  • Шаблони запитів N+1 у додатку
  • Повернення значно більшої кількості рядків чи стовпців, ніж потрібно
  • Погана структура сторінкування, наприклад великі зсуви
  • Неправильно налаштований або вичерпаний пул з’єднань

Досить часто рішенням є одне просте речення:

CREATE INDEX ...

Це дешевше та безпечніше, ніж використання нового компонента. Правило, яке потрібно пам’ятати: спочатку виправте виявлене вузьке місце, перш ніж будувати архітектуру навколо нього.

Репліки для читання у завданнях із високим навантаженням на читання

Багато додатків читають значно більше даних, ніж записують, наприклад:

90% reads
10% writes

У такій ситуації основна база даних може бути перевантажена запитами на читання. Одним із рішень є додавання реплік для читання: основна база приймає всі записи, а репліки беруть на себе частину запитів на читання.

Node.js API
                      ↓
             ┌────────┴────────┐
             ↓                 ↓
        Primary DB        Read Replica
             ↓                 ↑
          Writes            Reads

Витрати через затримку реплікації

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

Write → Primary
          ↓
       Replication
          ↓
      Read Replica

Якщо запит спочатку записує дані, а потім негайно читає їх з репліки, він може бачити застарілі дані протягом короткого часу. Тому додаток має знати, які запити на читання потребують найновішого стану. Поширеним підходом є надсилання запитів типу „прочитати власні записи“, наприклад, завантаження профілю відразу після його збереження, до основної бази, а всі інші запити — до реплік.

Перенесіть повільні операції у фонові завдання

Багато операцій не потребують завершення перед тим, як користувач отримає відповідь:

  • Надсилання електронних листів
  • Створення рахунків-фактур
  • Надсилання сповіщень
  • Складання звітів
  • Звернення до повільних зовнішніх сервісів

Замість того, щоб змушувати користувача чекати, API записує те, що потрібно зробити, додає завдання до черги та негайно відповідає:

Client
  ↓
Node.js API
  ↓
Create Record
  ↓
Queue Job
  ↓
Response

Окремий процес-робітник бере на себе завдання та виконує роботу:

Queue
  ↓
Worker
  ↓
Process Job

На практиці одна черга може живити робітників, які обробляють кілька типів завдань:

API
 ↓
Redis / Queue
 ↓
Worker
 ├── Email
 ├── Invoice
 └── Notification

Це зменшує затримку запитів та надає можливість контролювати кількість повторних спроб та обмеження пропускної здатності. Однак це також створює свої проблеми у проектуванні:

  • Завдання може виконуватися більше разів, тому обробники мають вміти працювати з дублікатами.
  • Агресивні спроби повторного виконання завдань через несправну залежність можуть перетворитися на справжню бурю повторних спроб.
  • Неуспішні завдання потребують місця для зберігання та людини, яка буде ними керувати.
  • Деякі робочі процеси вимагають гарантій послідовності виконання.
  • Обробники повинні бути ідемпотентними.
  • Необхідно стежити за глибиною черги та рівнем невдач.
  • Масштабованість ніколи не є безкоштовною. Якщо ви розробляєте це у Node.js, наш посібник зі створення систем фонових завдань за допомогою BullMQ та Redis розглядає ці проблеми на прикладах коду.

    Проектування з урахуванням можливих невдач

    Цікава робота з проектуванням починається тоді, коли ви перестаєте запитувати лише про те, що відбувається, коли все працює, та починаєте думати про те, що відбувається, коли щось ламається.

    Плавно переходити на альтернативний режим при збої залежності

    Припустимо, Redis стає недоступним. Чи повинен зупинитися весь API? Якщо Redis використовується лише як кеш, ймовірно, ні. API може оминути кеш та читати дані з PostgreSQL — це повільніше, але все одно коректно:

    Request
      ↓
    Redis
      ↓
    FAIL
      ↓
    PostgreSQL
      ↓
    Response
    

    Якщо Redis також підтримує ваш черговий буфер або механізм обмеження частоти запитів, проблема стає набагато серйознішою, оскільки для цих функцій немає простого альтернативного рішення. Дизайн має чітко визначати роль кожного компонента, адже саме ця роль визначає поведінку системи у разі його збою.

    Встановіть таймаут для кожного зовнішнього запиту

    API
     ↓
    Payment Service
     ↓
    ... waiting ...
     ↓
    ... waiting ...
     ↓
    ... waiting ...
    

    Поки він чекає, сервер утримує сокети, пам’ять та ресурси для запитів до залежності, яка може ніколи не відповісти. Під навантаженням такі застряглі запити можуть виснажити всю систему. Добре спроєктована система встановлює термін завершення для кожного запиту:

    API
     ↓
    Payment Service
     ↓
    5 second timeout
     ↓
    Failure
    

    Як тільки закінчується термін, додаток має вирішити, як діяти, і правильний вибір залежить від операції:

    • Повторити виклик
    • Надіслати помилку клієнту
    • Відправити операцію у чергу на пізніше виконання
    • Використати альтернативний варіант
    • Позначити операцію як у процесі виконання та узгодити її пізніше

    Повторні спроби є безпечними лише тоді, коли операції є ідемпотентними

    Після тайм-ауту платіжного виклику здається очевидним повторити спробу. Але тайм-аут не підказує, чи провалився платіж. Можливо, оплата вдалася, а лише відповідь була втрачена. Неконтрольований повторний виклик може призвести до подвійного списання коштів у клієнта.

    Рішенням є ідемпотентність: до операції слід додати стабільний ідентифікатор, щоб отримувальна сторона могла розпізнати повторення.

    payment_id = order_123
    

    Провайдер оплати використовує цей ключ, щоб визначити, чи вже була оброблена заявка, і повертає початковий результат замість того, щоб знову стягувати плату:

    Request
       ↓
    Idempotency Key
       ↓
    Payment Provider
       ↓
    Already processed?
       ├── Yes → Return previous result
       └── No  → Process payment
    

    Додавання повторних спроб потребує одного рядка коду. Щоб зробити їх безпечними, необхідна співпраця всієї системи. Наша стаття на тему ідемпотентних ключів у Node.js POST-кінцевих точках показує, як самостійно реалізувати їх на стороні отримувача.

    Захист системи за допомогою обмеження частоти запитів

    Тепер уявімо, що один клієнт починає надсилати таку кількість трафіку:

    100,000 requests/minute
    

    Якби ми приймали всі ці запити, один користувач міг би погіршити якість послуг для всіх. Обмежувач частоти розташовується перед API та відхиляє зайві запити за допомогою коду HTTP 429:

    Client
      ↓
    Rate Limiter
      ↓
    Allowed?
     ├── Yes → API
     └── No  → 429
    

    При наявності кількох інстанцій API лічильники мають бути спільними, саме тому Redis є популярним сховищем для реалізації дистрибуйованого обмеження частоти запитів: кожна інстанція збільшує значення одних і тих самих ключів.

    Обмеження мають відображати функції кожного ендпоїнта. Ендпоінт входу є мішенню для атак типу brute-force, тому розумним є встановлення суворого обмеження, як це показано.

    Login:
    10 requests/minute
    

    Натомість публічний каталог продуктів може дозволяти значно більше:

    GET /products:
    1000 requests/minute
    

    Встановлюйте обмеження на основі вартості операції та її моделі загроз, а не шляхом застосування єдиного глобального числа.

    Проектування систем — це переважно компроміси

    Рідко існує єдина ідеальна архітектура. Більшість рішень — це вибір між опціями, кожна з яких є ефективною у різних ситуаціях.

    SQL проти NoSQL

    Система реляційних баз даних пропонує потужне моделювання взаємозв’язків, транзакції та ефективні запити. Сховище типу NoSQL може забезпечувати інші характеристики масштабування, гнучкі схеми та переваги для певних типів завдань. Форма ваших даних та способи їх доступу визначають, який варіант підходить.

    Кеш проти бази даних

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

    Моноліт проти мікросервісів

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

    Загалом жоден варіант не є ідеальним. Вирішувати мають ваші вимоги.

    Віддаєте перевагу модульному моноліту перед ранніми мікросервісами

    Цей вибір заслуговує на окремий розділ, адже саме тут надмірна складність проекту коштує найдорожче. Новий продукт не обов’язково має починатися як набір таких сервісів:

    Auth Service
    User Service
    Product Service
    Order Service
    Payment Service
    Notification Service
    

    Кращим варіантом є єдине застосунок, який можна розгорнути:

    Node.js Application
    

    внутрішньо організований у модулі з чіткими межами:

    ├── Auth
    ├── Users
    ├── Products
    ├── Orders
    ├── Payments
    └── Notifications
    

    У вас є одна база коду, одне розгортання та чітке розділення доменів. Якщо пізніше якийсь модуль потребуватиме масштабування або незалежного розгортання, чіткі межі полегшують його вилучення.

    Мікросервіси дійсно вирішують реальні організаційні та проблеми масштабування. Водночас вони створюють проблеми зі збоями мережі між сервісами, розподіленим відстеженням, пошуком сервісів, складнішими процесами розгортання, труднощами з консистентністю даних між межами сервісів, а також потребу в більш ретельному моніторингу та інфраструктурі. Беріть на себе ці витрати лише тоді, коли потрібні їх переваги.

    Вирішуйте кожну проблему на найменшому можливому рівні

    Розгляньмо просту трьохрівневу систему з повільною базою даних:

    Client
     ↓
    API
     ↓
    Database
    

    Існує ієрархія можливих рішень, від найдешевших до найбільш впливових. Якщо певний запит працює повільно, оптимізуйте його:

    Slow query
       ↓
    Optimize query
    

    Якщо пошук сканує всю таблицю, додайте індекс:

    Missing index
       ↓
    Add index
    

    Якщо одні й ті самі дані завантажуються багаторазово, додайте кеш:

    Repeated reads
       ↓
    Add cache
    

    Якщо читання складають більшу частину операцій, додайте репліки:

    Read-heavy workload
       ↓
    Read replicas
    

    Лише при справді великих масштабах вдається до структурних змін:

    Massive scale
       ↓
    Partitioning / sharding / architectural changes
    

    Саме порядок є ключовим. Кожен крок робиться лише тоді, коли попередній вже недостатній, і кожен з них додає більше складності, ніж попередній.

    Повторюваний процес для інтерв’ю з проектуванням

    Той самий підхід застосовується і під час інтерв’ю з проектуванням системи. Надійна послідовність виглядає так:

    1. Уточнити функціональні та нефункціональні вимоги.
    2. Оцінити обсяг трафіку та потреби у зберіганні.
    3. Запропонувати найпростішу архітектуру, яка їх задовольняє.
    4. Визначити, де вона спочатку дасть збій.
    5. Спеціально оптимізувати ці «вузькі місця».
    6. Розглянути сценарії збоїв.
    7. Обговорити компроміси, які ви робите.
    8. Обґрунтувати кожен доданий компонент.

    Цей підхід набагато переконливіший, ніж миттєве створення двадцяти коробок. Відполірована діаграма без обґрунтування — це не гарний дизайн.

    Об’єднання елементів: невеликий бекенд для інтернет-магазину

    Застосування цих ідей до онлайн-магазину дає розумну початкову архітектуру, подібну до цієї:

    Client
                              ↓
                        Load Balancer
                              ↓
                     ┌────────┴────────┐
                     ↓                 ↓
                 Node API           Node API
                     │                 │
                     └────────┬────────┘
                              ↓
                     ┌────────┼────────┐
                     ↓        ↓        ↓
                  Redis   PostgreSQL  Queue
                             │          ↓
                             │        Worker
                             │          ↓
                             │    Email / Invoice
                             │
                             ↓
                       External APIs
                      Payment / Shipp
    

    Те, що робить її обґрунтованою, — це те, що кожен компонент відповідає на певне запитання:

    • Redis відповідає за кешування, тимчасові дані, лімітування частоти запитів та інфраструктуру черг.
    • PostgreSQL зберігає довготривалі бізнес-дані та забезпечує реляційні транзакції для замовлень та оплат.
    • Черга обробляє повільну, асинхронну роботу.
    • Кілька інстанцій API забезпечують горизонтальне масштабування та резервність.
    • Балансувальник навантаження розподіляє трафік між цими інстанціями.
  • Роботи обробляють фонові завдання, такі як електронні листи та рахунки-фактури, незалежно від запитів API.
  • Зовнішні API для оплати та доставки викликаються з таймаутами та ключами ідемпотентності.
  • Зверніть увагу, що Redis виконує тут кілька функцій. Це ефективно, але це також означає, що збій Redis впливає одночасно на кешування, обмеження швидкості та обробку завдань, що саме є типом залежностей, які має виявити аналіз збоїв.

    Шість запитань, які потрібно поставити перед остаточним узгодженням дизайну

    1. Яку проблему це вирішує? Якщо ви не можете чітко це сформулювати, архітектура ще не готова.
    2. Яка масштабованість потрібна? Кількість користувачів, частота запитів, обсяг даних, цілі щодо зберігання та затримки.
  • Що відбувається, коли якась з частин виходить з ладу? База даних, Redis, екземпляр API, мережа, зовнішній постачальник.
  • Що має бути швидким? Читання, запис, пошук чи комунікація в реальному часі.
  • Що може бути асинхронним? Електронні листи, звіти, сповіщення, аналітика.
  • Який компроміс я приймаю? Кожне значуще рішення має мати підставу.
  • Відповідна складність краща за вражаючу

    Найбільша пастка у вивченні проектування систем — це переконання, що складніша архітектура обов’язково є кращою. Це не так; вона просто складніша. Хороше проектування прагне до відповідної складності.

    Якщо один додаток Node.js та база даних PostgreSQL відповідають вимогам, це абсолютно гарна архітектура. Коли обсяг трафіку зростає, додаються нові екземпляри. Коли читання стають вузьким місцем, їх оптимізують. Якщо корисно використовувати кеш, додається Redis. Якщо роботу можна виконати пізніше, створюється черга. Якщо модуль справді потребує незалежного масштабування чи розгортання, варто розглянути можливість його виділення. Архітектура розвивається разом із продуктом.

    Ключові висновки

    • Спочатку визначаються вимоги та приблизні оцінки пропускної здатності; вибір технологій ґрунтується на них.
    • Починайте з найпростішої працездатної системи та масштабуйте лише те, що за даними вимірювань є вузьким місцем.
    • Кожне додавання має свою ціну: спільний стан для горизонтального масштабування, анулювання даних у кеші, затримка у репліках, дублікати в чергах.
    • Чітко плануйте ситуації збою, використовуючи запасні рішення, таймаути, ідемпотентні спроби та обмеження швидкості.
  • Модульний моноліт є оптимальним варіантом за замовчуванням; мікросервіси — це рішення для конкретних потреб, а не вихідна точка.
  • Метою є створення найпростішої системи, яка надійно відповідає сучасним вимогам та може розвиватися разом із їхніми змінами.