Проектирование бэкенд-системы с учётом узких мест: от сервисов сокращения URL до электронной коммерции
Подход к проектированию бэкендов на Node.js, основанный на сначала определении требований: когда добавлять балансировщики нагрузки, Redis, реплики, очереди и ограничения скорости, а также сколько каждый из них будет стоить.
Источники: 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
Важна именно последовательность. Каждый шаг предпринимается только тогда, когда предыдущий уже недостаточен, причем каждый следующий шаг увеличивает сложность по сравнению с предыдущим.
Повторяемый процесс для собеседований по проектированию
Тот же принцип применим и к собеседованиям по проектированию систем. Хорошая последовательность выглядит так:
- Уточните функциональные и нефункциональные требования.
- Оцените объем трафика и необходимое хранилище.
- Предложите самую простую архитектуру, соответствующую этим требованиям.
- Определите, в чем она первой даст сбои.
- Специально устраните эти узкие места.
- Рассмотрите сценарии сбоев.
- Обсудите принимаемые компромиссы.
- Обоснуйте каждый добавленный компонент.
Такой подход гораздо убедительнее, чем сразу нарисовать двадцать коробок. Изящная диаграмма без обоснования не является хорошим дизайном.
Собираем всё вместе: небольшой бэкенд для электронной коммерции
Применение этих идей к интернет-магазину позволяет получить разумную начальную архитектуру, подобную этой:
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
То, что делает её обоснованной, — это то, что каждый компонент отвечает на определённый вопрос:
- Redis отвечает за кэширование, временные данные, счётчики ограничения скорости и инфраструктуру очередей.
- PostgreSQL хранит долговечные бизнес-данные и обеспечивает реляционные транзакции для заказов и платежей.
- Очередь обрабатывает медленные, асинхронные задачи.
- Множество экземпляров API обеспечивают горизонтальное масштабирование и резервирование.
- Балансир нагрузки распределяет трафик между этими экземплярами.
Обратите внимание, что Redis выполняет здесь несколько функций. Это эффективно, но это также означает, что сбой в Redis влияет одновременно на кэширование, ограничение частоты запросов и обработку задач, что именно тот тип зависимостей, который должна выявить анализ сбоев.
Шесть вопросов, которые следует задать перед окончательным утверждением дизайна
- Какую проблему это решает? Если вы не можете четко сформулировать это, архитектура еще не готова.
- Какой масштаб требуется? Количество пользователей, частота запросов, объем данных, цели по хранилищу и задержкам.
Подходящая сложность лучше впечатляющей
Самая большая ловушка при изучении проектирования систем — это мнение, что более сложная архитектура обязательно лучше. Это не так; она просто более сложная. Хорошее проектирование направлено на достижение подходящей сложности.
Если одно приложение на Node.js и база данных PostgreSQL соответствуют требованиям, это уже отличная архитектура. По мере роста нагрузки добавляются новые экземпляры. Когда чтение становится узким местом, его оптимизируют. Если помогает кэширование, добавляется Redis. Для задач, которые могут быть выполнены позже, используется очередь. Когда модулю действительно требуется независимое масштабирование или развертывание, стоит рассмотреть его выделение. Архитектура развивается вместе с продуктом.
Основные выводы
- Сначала определяются требования и приблизительные оценки мощности; технологический выбор делается на их основе.
- Начинайте с самой простой рабочей системы и масштабируйте только те компоненты, которые по показателям являются узкими местами.
- Каждое дополнение имеет свою цену: общее состояние для горизонтального масштабирования, аннулирование данных в кэше, задержки у реплик, дубликаты в очередях.
- Явно планируйте ситуации сбоев, предусматривая резервные варианты, таймауты, идемпотентные попытки и ограничения скорости.