Проектирование архитектуры веб-приложения
Архитектура веб-приложения — это набор принятых решений, которые сложно или дорого изменить позже. Выбор базы данных, способ организации сервисов, стратегия масштабирования — каждое из этих решений закладывает границы того, что можно будет построить через два года без переписывания.
Хорошее архитектурное решение — не то, которое использует самые современные технологии. Это то, которое учитывает реальные ограничения: размер команды, ожидаемые нагрузки, бюджет на эксплуатацию и скорость изменений продукта.
С чего начинается проектирование
Прежде чем выбирать технологии, нужно ответить на структурные вопросы:
Какой характер нагрузки? Read-heavy (новостной портал, справочник) — одна стратегия кеширования. Write-heavy (биржа, система мониторинга) — другая. Mixed (e-commerce) — третья.
Какова допустимая задержка? Для торговой платформы 100ms — катастрофа. Для CMS — приемлемо.
Есть ли пики? Если трафик равномерный — проще. Если раз в году Black Friday даёт 100x нагрузки — нужен autoscaling или буферизация через очереди.
Где границы транзакционности? Можно ли разделить базу данных или всё завязано на ACID?
Слои типичного веб-приложения
[Клиент]
↓ HTTPS
[CDN / Edge Cache]
↓ Cache Miss
[Load Balancer]
↓
[Приложение — N инстансов]
├── [Кэш — Redis/Memcached]
├── [Очередь — RabbitMQ/Kafka]
└── [База данных — Primary + Replica]
↓
[Object Storage — S3]
Каждый слой решает одну задачу. CDN — статика и кэш на краю. Load Balancer — распределение и терминирование TLS. Приложение — бизнес-логика. Redis — горячие данные и сессии. Очередь — асинхронные задачи, которые нельзя выполнить в рамках HTTP-запроса.
Монолит против микросервисов
Стандартный вопрос, на который слишком часто дают стандартный неправильный ответ.
Монолит — правильный выбор для большинства новых проектов с командой до 15–20 человек. Причины:
- Одна транзакция на несколько агрегатов без saga-паттернов
- Простой деплой и наблюдаемость (один процесс — один лог)
- Рефакторинг без сетевых контрактов
- Нет проблемы согласованности при распределённых данных
Переход к микросервисам оправдан, когда команды работают над независимыми доменами, деплои начинают мешать друг другу, и конкретные сервисы требуют разного масштабирования (например, сервис обработки изображений vs CRUD API).
Монолит с чёткими границами модулей:
src/
├── modules/
│ ├── catalog/ # продукты, категории, поиск
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ ├── orders/ # заказы, корзина, checkout
│ ├── users/ # аутентификация, профили
│ └── notifications/ # email, push, sms
└── shared/
├── events/ # доменные события (для будущей декомпозиции)
└── infrastructure/ # HTTP клиент, логгер
Такая структура позволяет извлечь модуль в сервис, когда это станет необходимым — границы уже проведены.
Выбор базы данных
PostgreSQL подходит для 90% задач. Релационная модель, JSONB для гибких данных, полнотекстовый поиск, партицирование, репликация — всё из коробки. Начинать с PostgreSQL и менять при конкретных проблемах — правильная стратегия.
Дополнительные хранилища по назначению:
| Задача | Инструмент |
|---|---|
| Сессии, кэш, rate limiting | Redis |
| Полнотекстовый поиск с фасетами | Elasticsearch / OpenSearch |
| Аналитика и OLAP | ClickHouse |
| Граф-данные | Neo4j / PostgreSQL с recursive CTE |
| Очереди сообщений | Redis Streams, RabbitMQ, Kafka |
Схема данных и миграции
Ранние ошибки в схеме данных — самые дорогие. Несколько принципов:
Используйте UUID вместо serial/bigint для ID, если планируется горизонтальное масштабирование или публичный API. UUID v7 сортируемый и хорошо работает как кластерный индекс.
-- UUID v7 генерируется в приложении
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'draft',
total_cents INTEGER NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Триггер для updated_at (лучше чем в ORM)
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
Миграции — только вперёд, не backward-incompatible. Цикл: добавляем колонку (nullable) → деплоим код, который её пишет → делаем NOT NULL с DEFAULT → удаляем старую колонку.
Кэширование
Три уровня:
HTTP кэш — для публичных ресурсов. Cache-Control: public, max-age=3600, stale-while-revalidate=86400. CDN кэширует на краю, браузер — локально.
Application cache — Redis для данных, которые дорого вычислять. Паттерн Cache-Aside:
async function getProduct(id: string): Promise<Product> {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await db.product.findUniqueOrThrow({ where: { id } });
await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
return product;
}
// Инвалидация при обновлении
async function updateProduct(id: string, data: Partial<Product>) {
const updated = await db.product.update({ where: { id }, data });
await redis.del(`product:${id}`);
// Инвалидируем зависимые ключи
await redis.del(`category:products:${updated.categoryId}`);
return updated;
}
Query cache — PostgreSQL сам кэширует планы запросов. Правильные индексы важнее любого application-уровня.
Асинхронная обработка
Всё, что занимает больше 200ms или может упасть, должно идти в очередь:
- Отправка email
- Генерация PDF/изображений
- Интеграции с внешними сервисами
- Импорт данных
- Пересчёт агрегатов
// Паттерн: API принимает, ставит в очередь, отвечает 202
app.post('/api/orders/:id/invoice', async (req, res) => {
const { id } = req.params;
await queue.add('generate-invoice', {
orderId: id,
userId: req.user.id,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
res.status(202).json({ message: 'Счёт генерируется, пришлём на email' });
});
Наблюдаемость
Три столпа: логи, метрики, трассировки.
// Структурированные логи (Pino)
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
formatters: {
level: (label) => ({ level: label }),
},
});
// Привязываем request-id ко всем логам в рамках запроса
app.use((req, res, next) => {
req.log = logger.child({
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
method: req.method,
path: req.path,
});
next();
});
Метрики через Prometheus-формат: /metrics endpoint с RED-метриками (Rate, Errors, Duration) на каждый роут.
Сроки
Проектирование архитектуры — не одноразовый документ, а итеративный процесс. Первоначальное проектирование для нового продукта: одна-две недели на исследование требований, ADR (Architecture Decision Records) по ключевым решениям, схему данных, выбор технологического стека. Результат — не Visio-диаграмма, а набор проверяемых решений с обоснованием компромиссов.
Архитектурный ревью существующего проекта — три-пять дней: анализ кодовой базы, выявление узких мест, план эволюции без переписывания.







