Главная / Статьи

Проектирование бэкенд-системы с учётом узких мест: от сервисов сокращения 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 обращается к внешнему сервису оплаты без настроенного таймаута:

    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. Для задач, которые могут быть выполнены позже, используется очередь. Когда модулю действительно требуется независимое масштабирование или развертывание, стоит рассмотреть его выделение. Архитектура развивается вместе с продуктом.

    Основные выводы

    • Сначала определяются требования и приблизительные оценки мощности; технологический выбор делается на их основе.
    • Начинайте с самой простой рабочей системы и масштабируйте только те компоненты, которые по показателям являются узкими местами.
    • Каждое дополнение имеет свою цену: общее состояние для горизонтального масштабирования, аннулирование данных в кэше, задержки у реплик, дубликаты в очередях.
    • Явно планируйте ситуации сбоев, предусматривая резервные варианты, таймауты, идемпотентные попытки и ограничения скорости.
  • Модульный монолит является оптимальным стандартным решением; микросервисы — это ответ на конкретные потребности, а не отправная точка.
  • Цель заключается в создании самой простой системы, которая надежно соответствует современным требованиям и может развиваться по мере их изменения.