Каждый backend-разработчик рано или поздно упирается в стену. Приложение отлично работает на ноутбуке. Отлично работает на стенде с тремя одновременными пользователями. Потом вы выкатываете его в продакшен, приходит тысяча человек разом — и база данных ложится. API начинает отдавать 5xx, фронтенд зависает, а где-то в чате кто-то выкладывает скриншот, от которого ваш телефон звонит в два часа ночи.
Проектирование систем — это то, что находится по другую сторону этой стены. Набор шаблонов, компромиссов и инфраструктурных решений, которые отделяют игрушечное приложение от продакшен-системы, способной держать настоящий трафик. Хорошая новость: чтобы понимать основы, не нужно быть старшим инженером по инфраструктуре. Нужно знать примерно восемь понятий, то, как они взаимодействуют, и когда за каждым из них тянуться.
Эта статья охватывает шаблоны проектирования, которые важнее всего практикующему backend-разработчику. Каждый раздел объясняет понятие, показывает практический пример и описывает компромиссы, которые надо взвесить перед внедрением. Это не абстрактные академические конструкции, а инструменты, к которым вы будете обращаться всякий раз, когда строите то, что должно масштабироваться.
Балансировка нагрузки: держим каждый сервер занятым, но не перегруженным
Балансировка нагрузки — самый простой и действенный шаблон, который можно внедрить. Идея проста: вместо того чтобы направлять весь трафик на один сервер, вы распределяете входящие запросы по пулу серверов. Это даёт сразу две вещи: более высокую доступность (если один сервер умирает, остальные продолжают обслуживать) и более высокую пропускную способность (несколько серверов делят работу).
Самые распространённые алгоритмы — круговой перебор, наименьшее число соединений и хеш по IP. Круговой перебор проходит по списку серверов по порядку: просто и предсказуемо, но не учитывает, насколько каждый сервер на самом деле занят. «Наименьшее число соединений» отправляет запрос на сервер с наименьшим количеством активных соединений, что лучше справляется с неравномерной нагрузкой. Хеш по IP выбирает сервер детерминированно по адресу клиента — это важно, когда нужны «липкие» сессии: один и тот же клиент всегда попадает на тот же сервер.
На практике большинство продакшен-схем комбинируют подходы. Балансировщик четвёртого уровня (на уровне TCP) распределяет сырые соединения по пулу обратных прокси или API-шлюзов, которые затем выполняют балансировку седьмого уровня (на уровне HTTP) и маршрутизируют запросы к конкретным экземплярам приложения. Такой многоуровневый подход держит плоскость данных быстрой, а логику маршрутизации — гибкой.
# nginx.conf — простая круговая балансировка по трём серверам приложения
upstream app_cluster {
round-robin;
server app1.internal:3000 max_fails=3 fail_timeout=30s;
server app2.internal:3000 max_fails=3 fail_timeout=30s;
server app3.internal:3000 max_fails=3 fail_timeout=30s;
}
server {
listen 443 ssl;
location / {
proxy_pass http://app_cluster;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}Критическая деталь, которую упускает большинство разработчиков, — проверки состояния. Балансировщик полезен, только если знает, какие серверы живы. Если app2 упал, а балансировщик продолжает слать на него трафик, доля ошибок вырастает примерно на треть. Всегда настраивайте активные проверки: балансировщик периодически опрашивает каждый сервер и автоматически убирает из пула тех, кто не отвечает.
Кеширование: скорость, но какой ценой?
Кеширование — оптимизация производительности с наибольшим рычагом, доступная любому разработчику. Одно попадание в кеш превращает запрос к базе за 200 миллисекунд в чтение из памяти за 2 миллисекунды. Два порядка величины. На миллионах запросов эта разница — разница между счётом за базу в 10 000 долларов в месяц и в 500.
Канонический стек кеширования состоит из трёх уровней с разными свойствами. Кеширование на уровне приложения (Redis, Memcached) хранит результаты дорогих вычислений или запросов к базе. Кеширование в CDN держит статические и полустатические ресурсы на пограничных узлах рядом с пользователями. HTTP-кеширование использует заголовки Cache-Control и ETag, чтобы браузеры и прокси кешировали ответы вообще без участия ваших серверов.
Самое трудное в кешировании — не настройка, а инвалидация, когда исходные данные меняются. Индустрия остановилась на нескольких надёжных шаблонах. «Кеш сбоку» (он же ленивая загрузка) означает, что приложение сначала смотрит в кеш, при промахе идёт в базу и заполняет кеш результатом. Сквозная запись означает, что каждая запись идёт одновременно и в кеш, и в базу. Отложенная запись означает, что записи сначала попадают в кеш и асинхронно сбрасываются в базу.
В информатике есть только две трудные вещи: инвалидация кеша и придумывание имён. Инвалидация труднее, потому что её нельзя просто переименовать в надежде, что она исчезнет.
Для большинства приложений «кеш сбоку» с коротким временем жизни — правильный выбор по умолчанию. Задайте TTL под вашу терпимость к устаревшим данным: 60 секунд для профилей пользователей, 5 минут для списков товаров, 24 часа для справочных данных. Когда нужна более строгая согласованность, берите сквозную запись, но примите более высокую задержку записи. Когда нужна максимальная пропускная способность чтения и допустима итоговая согласованность, берите «кеш сбоку» со щедрым TTL.
import redis.asyncio as aioredis
import json
cache = aioredis.Redis.from_url("redis://cache:6379")
CACHE_TTL = 300 # 5 минут
async def get_user_profile(user_id: str) -> dict:
key = f"profile:{user_id}"
cached = await cache.get(key)
if cached:
return json.loads(cached)
profile = await db.fetch_user(user_id)
if profile:
await cache.setex(key, CACHE_TTL, json.dumps(profile))
return profileПредупреждение: кеширование умеет маскировать проблемы, а не решать их. Если запросы к базе медленные из-за отсутствующего индекса, кеш прячет симптом, но сам запрос остаётся медленным. Кеш очищается, медленный запрос выполняется, пользователь ждёт. Сначала профилируйте и оптимизируйте медленные пути, и только потом кладите кеш поверх уже оптимизированной версии.
Шардирование базы данных: делим работу, чтобы ни одна база не утонула
Шардирование (горизонтальное секционирование) — то, к чему обращаются, когда один экземпляр базы не справляется с объёмом записи или размером набора данных. Идея в том, чтобы разложить данные по нескольким экземплярам, где каждый (шард) держит свою часть. Приложение определяет, к какому шарду обратиться, по ключу шардирования — обычно это хеш идентификатора пользователя, географический регион или диапазон времени.
Ключ шардирования — самое важное решение в любой шардированной системе. Хороший ключ распределяет данные равномерно и соответствует вашим шаблонам запросов. Плохой создаёт «горячие» шарды: несколько шардов держат почти весь трафик, пока остальные простаивают. Например, шардирование по времени создания звучит разумно, пока не окажется, что сегодняшний шард принимает все записи, а шарды прошлых лет — ничего.
Консистентное хеширование решает проблему перебалансировки, которая мучает наивное шардирование. В простой схеме по остатку (shard = hash(key) % N) добавление нового шарда требует перемещения почти всех данных. Консистентное хеширование отображает и ключи, и шарды на хеш-кольцо; при добавлении шарда переезжают только ключи в его непосредственной близости. Это делает масштабирование вверх и вниз куда менее болезненным.
- Шардирование по хешу — распределение через хеш ключа; просто и равномерно, но повторное шардирование дорого.
- Шардирование по диапазону — деление по диапазонам значений (пользователи с ID 1–10000 на шард A, 10001–20000 на шард B); эффективно для диапазонных запросов, но склонно к горячим точкам.
- Шардирование по каталогу — таблица соответствия ключей и шардов; гибко, но добавляет шаг поиска и единую точку отказа, если каталог падает.
- Географическое шардирование — деление по регионам пользователей; отлично для задержки, но неудобно, если пользователи перемещаются или данные должны быть глобальными.
Компромисс шардирования в том, что межшардовые запросы становятся дорогими или невозможными. Если таблица пользователей шардирована по user_id и таблица заказов тоже, то запрос всех заказов за последние 30 дней должен ударить по каждому шарду. Приложения, которым нужна глобальная аналитика или межшардовые соединения, обычно держат вторичную реплику для чтения (или отдельную аналитическую базу), куда данные со всех шардов сводятся асинхронно.
Теорема CAP: выбрать можно только два
Теорема CAP утверждает, что распределённое хранилище не может одновременно давать больше двух из трёх гарантий: согласованность (каждое чтение видит самую последнюю запись), доступность (каждый запрос получает ответ, пусть и не самыми свежими данными) и устойчивость к разделению (система продолжает работать несмотря на сетевые сбои между узлами).
На практике устойчивость к разделению не опциональна. Сети сбоят. Пакеты теряются, соединения отваливаются по тайм-ауту, дата-центры теряют питание. Так что реальный выбор — между CP (согласованность плюс устойчивость) и AP (доступность плюс устойчивость). Система CP вроде etcd или ZooKeeper откажет в обслуживании чтений, если не может гарантировать согласованность между узлами. Система AP вроде Cassandra или DynamoDB обслужит чтение с любого доступного узла, даже если данные на нём устарели.
Это не академическое различие. Проектируя систему, охватывающую несколько дата-центров, вы обязаны решить, что произойдёт, когда связь между ними оборвётся. Продолжаете обслуживать запросы потенциально устаревшими данными (AP)? Или останавливаете обслуживание, пока сеть не восстановится (CP)? Ответ зависит от приложения. Сеть доставки контента должна быть AP: устаревший контент лучше, чем никакого. Система обработки платежей должна быть CP: вы никогда не захотите списать с клиента дважды из-за того, что два разделённых узла приняли один и тот же платёж.
Очереди сообщений: превращаем синхронную боль в асинхронное изящество
Очереди сообщений — это позвоночник асинхронной обработки в распределённых системах. Они позволяют одному сервису отправить сообщение в очередь, не дожидаясь, пока потребитель его обработает. Потребитель забирает сообщение, когда готов, обрабатывает и подтверждает завершение. Это развязывает отправителя и получателя и во времени, и в пространстве: им не нужно работать с одинаковой скоростью или даже одновременно.
Любая нетривиальная backend-система где-то использует очередь. Канонический пример — отправка писем. Когда пользователь регистрируется, вы не хотите, чтобы HTTP-ответ ждал, пока служба доставки отрендерит шаблон, подключится к почтовому провайдеру и доставит сообщение. Вместо этого API кладёт событие send_email в очередь и немедленно возвращает 201 Created. Отдельный обработчик забирает событие, отправляет письмо и помечает задачу выполненной.
Две основные модели — «публикация и подписка» и рабочие очереди. В первой каждое сообщение рассылается всем подписчикам. Это полезно для событийных архитектур, где на одно событие должны отреагировать несколько сервисов: новая регистрация запускает приветственное письмо, обновление CRM и аналитическое событие одновременно. В рабочей очереди каждое сообщение доставляется ровно одному потребителю. Это полезно, когда работу надо распределить по пулу обработчиков: каждая загрузка изображения уходит ровно одному генератору миниатюр.
# docker-compose.yml — минимальная настройка RabbitMQ для локальной разработки
version: "3.8"
services:
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- "5672:5672" # порт AMQP для отправителей и получателей
- "15672:15672" # веб-интерфейс управления
environment:
RABBITMQ_DEFAULT_USER: app
RABBITMQ_DEFAULT_PASS: dev-only-password
volumes:
- rabbitmq_data:/var/lib/rabbitmq
volumes:
rabbitmq_data:Хитрая часть очередей — корректная обработка сбоев. Что произойдёт, если потребитель упадёт на середине обработки? RabbitMQ и Amazon SQS решают это подтверждениями: потребитель должен явно подтвердить, что сообщение обработано успешно. Если он отключился без подтверждения, сообщение возвращается в очередь и уходит другому потребителю. Эта гарантия доставки «хотя бы один раз» означает, что ваши потребители обязаны быть идемпотентными: обработка одного и того же сообщения дважды должна давать тот же результат, что и однократная.
Очереди недоставленных сообщений — ещё один необходимый шаблон. Когда сообщение не удаётся обработать после нескольких попыток (нижележащий сервис недоступен, данные повреждены, бизнес-правило изменилось), оно уходит в очередь недоставленных вместо бесконечных повторов. Дежурный следит за этой очередью, разбирается в первопричине и либо исправляет сообщение и возвращает его в работу, либо отбрасывает, убедившись, что это безопасно.
CDN и ограничение частоты: передняя линия обороны
Сети доставки контента и ограничители частоты служат разным целям, но у них есть общее: это первая линия обороны между вашими пользователями и вашими серверами. CDN держит статические ресурсы и кешированные ответы рядом с пользователями, снижая задержку и разгружая исходные серверы. Ограничитель частоты не даёт одному пользователю или клиенту перегрузить систему запросами.
CDN работают, раскладывая ваш контент по глобальной сети пограничных серверов. Когда пользователь в Токио запрашивает ресурс, CDN отдаёт его с ближайшего узла, а не гонит запрос через полмира к вашему серверу в Вирджинии. Для статики это снижает задержку с 200 миллисекунд до 10. Современные CDN идут дальше: кешируют ответы API, терминируют TLS-соединения и даже исполняют бессерверные функции на границе.
Ограничение частоты защищает систему на нескольких уровнях. Глобальное ограничение задаёт потолок общего числа запросов в секунду для всей системы, защищая от всплесков трафика и DDoS. Ограничение на пользователя гарантирует, что один недобросовестный клиент не отберёт ресурсы у остальных. Ограничение на уровне эндпоинта применяет разные лимиты к разным маршрутам: эндпоинт входа может разрешать 5 запросов в минуту, а поисковый эндпоинт только для чтения — 100.
Алгоритм скользящего окна стал отраслевым стандартом, потому что он точен и экономичен. Вместо сброса счётчиков через фиксированные интервалы (что позволяет всплески на границе) скользящее окно считает запросы за скользящий период. Redis здесь естественный выбор: используйте сортированное множество с временными метками в качестве весов, удаляйте записи за пределами окна и считайте оставшиеся. Расход памяти минимален (несколько байт на запрос), а временная сложность логарифмическая.
// Ограничитель частоты на Redis со скользящим окном (TypeScript)
import { createClient } from "redis";
const redis = createClient({ url: "redis://ratelimit:6379" });
async function checkRateLimit(
key: string,
limit: number,
windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
multi.zRemRangeByScore(key, 0, windowStart);
multi.zAdd(key, { score: now, value: `${now}` });
multi.zCard(key);
multi.expire(key, Math.ceil(windowMs / 1000));
const [, , count] = await multi.exec() as [any, any, number];
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
};
}И CDN, и ограничители частоты разделяют важный операционный принцип: открываться при сбое или закрываться? Если пограничный узел CDN не может достучаться до источника, должен ли он отдать устаревший кеш (открыться) или вернуть ошибку (закрыться)? Если кластер Redis у ограничителя падает, должны ли все запросы проходить (открыться — риск перегрузки) или все отклоняться (закрыться — гарантированный простой)?
Универсального ответа нет, но хорошая установка по умолчанию для большинства систем такова: открываться при сбое на чтении, закрываться на записи. Устаревшая страница товара приемлема. Потерянный заказ — нет. Зафиксируйте это решение явно в регламенте, чтобы дежурный инженер знал, какого поведения ждать, когда инфраструктура даст сбой.
Собираем всё вместе: мышление в компромиссах
Проектирование систем — это не заучивание шаблонов. Это понимание компромиссов и умение определить, какой шаблон подходит к вашим ограничениям. Каждое решение — размен между согласованностью и доступностью, между пропускной способностью чтения и задержкой записи, между операционной сложностью и чистой производительностью. Лучшие инженеры — не те, кто знает больше всего шаблонов; это те, кто, глядя на задачу, отличает фиксированные ограничения от обсуждаемых.
Вот быстрая рамка для новой задачи проектирования. Начните с перечисления нефункциональных требований: ожидаемый объём трафика, целевые задержки, требования к согласованности, бюджет на инфраструктуру и знакомство команды с технологией. Затем пройдитесь по шаблонам из этой статьи и спросите себя, приближает ли каждый из них вас к требованиям или отдаляет.
Если главная забота — задержка, начните с кеширования и CDN: они дают наибольшее улучшение при наименьшей сложности. Если критична доступность, используйте балансировку с проверками состояния, проектируйте сервисы без состояния и выбирайте AP вместо CP там, где бизнес это позволяет. Если нагрузка тяжела по записи, оцените шардирование и очереди заранее — вводить их куда легче до того, как накопятся миллионы строк. Если вы делаете публичный API для сторонних разработчиков, внедряйте ограничение частоты в первый же день: добавить его позже — значит версионировать API или сломать существующих клиентов.
Самый важный навык в проектировании систем — знать, что вам не нужно. Большинству приложений не нужно шардирование. Большинству приложений не нужна очередь сообщений. Преждевременная распределённость — корень всех зол: любая распределённая система привносит режимы отказа, которых у односерверной просто нет. Добавляйте сложность только тогда, когда об этом говорят метрики, а не потому, что шаблон звучит внушительно на собеседовании.
Начинайте с простого. Измеряйте всё. Добавляйте по одному шаблону за раз. Проверяйте улучшение, прежде чем идти дальше. Выживают не системы с самой сложной архитектурой, а те, которые легко понять, легко эксплуатировать и легко изменить, когда появится следующее узкое место.