System Design: основное

Что такое System Design в контексте backend-разработки

System Design — это процесс проектирования архитектуры программной системы, которая отвечает на конкретные функциональные и нефункциональные требования. В backend-разработке это означает умение спроектировать распределённую систему, которая масштабируется, надёжна и может обрабатывать нагрузки реальной продукции.

Простое определение: System Design — это ответ на вопрос "как мне построить систему, которая может обслуживать миллионы пользователей, хранить терабайты данных, отказоустойчива к сбоям и имеет приемлемую latency?"

В контексте backend-разработки на Java это включает выбор:

  • Архитектурного паттерна (микросервисы, монолит, serverless)
  • Технологий хранения данных (SQL, NoSQL, key-value)
  • Паттернов масштабирования (репликация, шардирование, кеширование)
  • Инструментов интеграции (брокеры сообщений, API gateways, load balancers)
  • Подходов к отказоустойчивости и мониторингу

Роль System Design в реальной продуктовой разработке

На практике, когда команда начинает работать над новым продуктом или решает масштабировать существующий, инженеры проводят сессии по проектированию архитектуры. Эти сессии помогают избежать дорогостоящих архитектурных ошибок на поздних этапах развития. Senior разработчик должен возглавлять эти дискуссии, предлагать решения, которые работают не только сейчас, но и через год-два, когда нагрузка вырастет в 10 раз.

System Design — это про долгосрочное мышление:

  • Сегодня у вас 1000 пользователей и простая архитектура работает
  • Завтра 100 000 пользователей, и нужны кеши, база для чтения отдельно, брокер сообщений
  • Через год 1 млн пользователей, и архитектура должна была предусмотреть шардирование с самого начала

Отличие архитектурных разговоров на работе от собеседований

На собеседовании System Design отличается тем, что это симуляция: интервьюер даёт расплывчатую задачу, вы должны самостоятельно задать правильные вопросы, сделать разумные допущения и в течение 45 минут вывести логичную архитектуру на доске или в виртуальном whiteboard.

На работе:

  • У вас есть уже готовые требования, документация, данные по существующим нагрузкам
  • Обсуждение может занять несколько встреч
  • Можно полагаться на коллег и их опыт с конкретными технологиями
  • У вас есть доступ к аналитике, метрикам, логам

На собеседовании:

  • Требования намеренно неполные
  • 45–60 минут на весь процесс: требования + архитектура + обсуждение trade-off'ов
  • Все значения вы вычисляете сами (RPS, объём данных, количество серверов)
  • Интервьюер оценивает ваш процесс мышления, а не точность архитектуры

Что рекрутеры и интервьюеры ждут от Senior Java Backend инженера

Какие скиллы проверяются

System Design раунд на собеседовании Senior инженера проверяет несколько категорий компетенций:

  1. Инженерное мышление и структурированность. Интервьюер хочет видеть, что вы не прыгаете случайно между идеями, а следуете логичному процессу: требования → нагрузки → компоненты → масштабирование. Это показывает, что в реальной жизни вы сможете спроектировать систему, которая не развалится в production.

  2. Математические и практические вычисления. Умение делать back-of-the-envelope оценки (примерные расчёты на основе простых предположений) показывает, что вы способны понять масштаб проблемы. Интервьюер ценит, что вы можете сказать: "10 млн пользователей, каждый день 10 млрд запросов, это примерно 100 000 RPS в среднем" и на основе этого выбрать архитектуру.

  3. Знание технологий и практических решений. На уровне Senior ожидается, что вы знаете, когда использовать PostgreSQL, когда Cassandra, когда Redis. Это не про то, чтобы знать все инструменты, а про то, чтобы понимать trade-off'ы и уметь объяснить выбор.

  4. Коммуникация и trade-off анализ. Не существует идеального решения. Лучший кандидат показывает, что понимает: выбрав PostgreSQL, вы получаете ACID, но теряете горизонтальное масштабирование; выбрав Kafka, вы решаете проблему асинхронных очередей, но добавляете сложность в систему. Senior инженер озвучивает эти trade-off'ы вслух и обсуждает с интервьюером, какой выбор более приемлем в данных условиях.

  5. Product thinking. На уровне Senior ожидается, что вы не только думаете о технических ограничениях, но и о том, как система служит пользователю. Например, при проектировании ленты новостей важно понимать, что задержка в 1 секунду может раздражать, но задержка в 100 мс — это OK. Это влияет на выбор между eventual consistency и strong consistency.

Какие уровни глубины ожидаются от middle vs senior

Middle-level разработчик на System Design собеседовании может рассказать про основные компоненты системы (backend, DB, cache), назвать несколько технологий и выбрать одну. Ответ поверхностный, но логичный.

Senior-level инженер:

  • Спрашивает уточняющие вопросы, которые покрывают реальные требования
  • Дает рассчитанные оценки нагрузок (не просто "много пользователей", а конкретные RPS и GB в год)
  • Предлагает несколько альтернатив архитектуры, обсуждает их плюсы и минусы
  • Рассказывает про edge cases и проблемы масштабирования (например, hot keys в Redis при неправильном шардировании)
  • Обращает внимание на observability, security, disaster recovery — не только на "как это работает", но и на "что будет, если что-то пойдёт не так"
  • Говорит про эволюцию системы: "первый год ей хватит монолита, но когда нагрузка вырастет в 10 раз, нужно будет перейти на микросервисы"

Как проявляется product thinking на system design

Product thinking — это умение видеть систему через призму пользователя и бизнеса, а не только через призму технических ограничений.

Примеры:

  • При проектировании чата вы спрашиваете: "Критична ли доставка сообщений в порядке FIFO или eventual consistency приемлема?" Это зависит от того, ценит ли пользователь порядок или просто хочет получить все сообщения.
  • При проектировании URL shortener вы обсуждаете: "Нужна ли поддержка custom slugs (вроде bit.ly/myurl)? Это может привести к коллизиям, но пользователи это иногда хотят."
  • При проектировании платёжной системы вы думаете: "Если платёж завис, что хуже — заблокировать пользователя с сообщением 'попробуйте позже' или показать оптимистичное сообщение, но потом откатить транзакцию?" Это зависит от бизнес-метрик.

Senior инженер демонстрирует product thinking, когда в середине архитектурного обсуждения говорит: "Подождите, давайте уточним, может ли пользователь жить с задержкой в 5 минут? Если да, это сильно упрощает архитектуру."

Типовой формат задач на собеседовании

Примеры задач и их формулировки

System Design задачи на собеседованиях обычно звучат расплывчато и просто:

  • "Спроектируй чат, как в Telegram"
  • "Спроектируй ленту новостей"
  • "Спроектируй URL shortener"
  • "Спроектируй сервис загрузки видео"
  • "Спроектируй систему хранения файлов"
  • "Спроектируй сервис микротранзакций между пользователями"

Формулировка намеренно неполная. Задача кандидата — спросить конкретику.

Как обычно задают: формулировка, ограничения по времени, формат диалога

Интервьюер обычно говорит:

  • "Вот задача: спроектируй ленту новостей. У тебя есть 45 минут. Давай ты начнёшь с требований."
  • Дальше вы рисуете на доске/whiteboard и обсуждаете с интервьюером в реальном времени
  • Интервьюер может перебивать и задавать уточняющие вопросы типа: "А если трафик вырастет в 100 раз, что будет?"
  • Примерно через 30 минут интервьюер может предложить deep dive в какую-то конкретную часть (например: "Давай подробнее разберём, как масштабировать чтение из БД")

Ограничения по времени жёсткие: 45 минут — это меньше, чем кажется. Если вы 20 минут говорили про требования, то на архитектуру у вас осталось только 25 минут.

Что считается успешным ответом на уровне senior

Успешный ответ на уровне Senior не означает идеальную архитектуру. Это означает:

  1. Вы задали 5–10 уточняющих вопросов про требования и нагрузки.
  2. Вы сделали видимые, разумные вычисления (примерно такие: если 1 млн DAU, то в пиковый час это примерно 100 000 RPS, каждый запрос примерно 1 KB, значит в секунду 100 MB трафика).
  3. Вы нарисовали ясную high-level архитектуру на доске с 5–7 основными компонентами (gateway, backend, DB, cache, message broker и т.д.).
  4. Вы обсудили несколько важных trade-off'ов (например: "Если нам нужна сильная консистентность, используем PostgreSQL, но это усложнит масштабирование; если приемлема eventual consistency, можем использовать NoSQL и масштабировать горизонтально").
  5. Вы обсудили масштабирование по чтению (кеши, read replicas) и по записи (очереди, асинхронная обработка).
  6. Вы не запутались в деталях реализации, а оставались на уровне архитектурных решений.
  7. Вы логично ответили на 2–3 уточняющих вопроса от интервьюера (тип "а что, если...?").

Интервьюер не ожидает, что вы спроектируете идеальную, боевую систему. Он оценивает ваш процесс, логику и умение общаться.

Обязательные шаги при ответе: framework ответа

Сбор и уточнение функциональных требований

Первый шаг — никогда не начинайте рисовать архитектуру! Сначала спросите про требования. Это показывает структурированность и опыт.

Какие вопросы нужно задать:

  • Кто пользователи системы? (в контексте задачи могут быть пользователи, администраторы, third-party системы)
  • Какие основные operations/операции? (для чата: отправить сообщение, получить историю; для ленты: постить, читать ленту)
  • Какой scope: только одна страна или multiregion? Только веб или мобильные приложения?
  • Есть ли real-time требования или батч-обработка приемлема?
  • Сколько времени на консистентность? Нужна ли strong consistency (ACID) или eventual consistency (BASE) приемлема?

Примеры формулировок вашего вопроса:

  • "Давайте уточним scope: в системе есть только пользователи, которые отправляют друг другу сообщения, или также есть группы/каналы?"
  • "Нужна ли история всех сообщений или можно удалять сообщения после истечения срока?"
  • "Пиковая нагрузка в одной стране или это глобальная система?"

Как фиксировать границы системы:

  • Явно скажите: "Итак, я понял, что нам нужно реализовать: 1) отправку сообщений, 2) получение истории, 3) real-time уведомления о новых сообщениях. Правильно?"
  • Определите, что NOT в scope: "Я не буду проектировать video calling, это отдельная система, верно?"
  • Это показывает, что вы структурированы и не будете проектировать случайные features.

Сбор нефункциональных требований

Нефункциональные требования (NFR) — это performance, availability, scalability, latency, consistency. Эта часть часто упускают кандидаты, и это ошибка.

Нагрузка:

  • Сколько пользователей? (total, active, concurrent)
  • Сколько операций в секунду? (RPS — requests per second)
  • Какой рост ожидается? (в 2 раза в год или в 10 раз в месяц?)
  • Когда пиковая нагрузка? (все пользователи в одно время или распределены по часовым поясам?)

Пример: "Давайте скажем, что у нас есть 1 млн пользователей. В активные часы (часовой пояс UTC) примерно 100 000 одновременно онлайн. Пиковая нагрузка — примерно 100 000 RPS на чтение."

SLA и performance metrics:

  • Latency: какова должна быть p99 (99-й перцентиль) задержка? Например, для чата важно, чтобы сообщение доставилось за < 1 секунды.
  • Availability: 99% (примерно 14.4 минуты простоя в день) или 99.9% (около 86 секунд в день)?
  • Durability: все ли данные должны быть сохранены или можно потерять часть сообщений в крайних случаях?

Пример диалога: "Скажем, SLA — 99.9% availability и p99 latency на чтение < 500 ms."

Геораспределённость:

  • Система в одном регионе (datacenter) или нужна multiregion?
  • Если multiregion, нужна ли active-active (оба региона обслуживают трафик) или active-passive (один основной, другой на backup)?

Ограничения:

  • Бюджетные ограничения иногда влияют на выбор технологий (managed services дороже, чем self-hosted)
  • Constraints по срокам: "Нужно запустить за месяц" влияет на выбор известных, проверенных решений вместо experimental

Оценка примерной нагрузки и объёмов данных: back-of-the-envelope вычисления

Back-of-the-envelope (BOTE) вычисления — это примерные расчёты, которые показывают масштаб задачи. Интервьюер ценит, что вы можете быстро прикинуть на бумаге, сколько нужно памяти, дискового пространства, пропускной способности.

Простые примеры:

  1. Чат: сколько данных в год?

    • 1 млн пользователей, средний пользователь отправляет 10 сообщений в день
    • 1 млн * 10 = 10 млн сообщений в день
    • Среднее сообщение: 100 bytes + metadata (timestamp, sender_id, receiver_id) ~ 200 bytes
    • 10 млн * 200 bytes = 2 GB в день
    • В год: 2 GB * 365 = 730 GB ~ 1 TB в год
    • Вывод: за 10 лет нам нужно примерно 10 TB на хранение сообщений. Это разумно для одной базы, но нужно думать про архивирование.
  2. Ленты новостей: сколько RPS нам нужно обслужить?

    • 1 млн пользователей, в активный час 100 000 онлайн
    • Пользователь обновляет ленту примерно каждые 10 секунд в среднем
    • 100 000 / 10 = 10 000 RPS на чтение (обновление ленты)
    • Каждый пост из ленты ~ 1 KB, ленты на экране ~ 20 постов = 20 KB на запрос
    • 10 000 RPS * 20 KB = 200 MB/s трафика (вполне управляемо)
  3. URL shortener: сколько памяти на кеш?

    • 1 млн коротких ссылок в день
    • 80% запросов идут на 20% ссылок (правило Парето)
    • Если кешировать эти 20%, то 1 млн * 0.2 = 200 000 ссылок
    • Каждая ссылка: 8 bytes (id) + 100 bytes (long url) ~ 200 bytes
    • 200 000 * 200 bytes = 40 MB памяти. Это очень мало, хватит даже одного Redis на однопроцессорной машине.

Зачем интервьюеру видеть эти вычисления:

  • Это показывает, что вы не проектируете "на глаз", а делаете обоснованные выводы
  • Это помогает понять, какие компоненты архитектуры критичны (например, в примере с URL shortener выяснилось, что кеш очень мал, значит можно использовать простой Redis)
  • Это помогает интервьюеру глубже копать в нужное место (если вы сказали, что нужно 10 TB, интервьюер может спросить: "А как мы будем шардировать базу на 10 TB?")

Формирование high-level архитектуры

Основные компоненты систем backend, которые вы должны знать и уметь размещать в архитектуре:

  1. Client layer: веб-браузер, мобильное приложение, third-party клиент. На собеседовании часто не обсуждается детально, но важно помнить.

  2. API Gateway / Load Balancer: точка входа для всех запросов. Распределяет нагрузку между несколькими инстансами backend сервиса. Важен для масштабирования.

  3. Backend service: ваше приложение на Java/Kotlin. Обычно stateless, можно запустить несколько копий. На диаграмме обозначьте как "Backend (N instances)".

  4. Database (primary): основное хранилище данных. Для данных, требующих ACID (например, балансы аккаунтов, истории платежей). Обычно одна primary, несколько replicas для чтения.

  5. Cache: Redis или Memcached. Хранит часто читаемые данные в памяти. Быстро, но данные могут быть потеряны при перезагрузке.

  6. Message Broker: Kafka, RabbitMQ, или AWS SQS. Нужен для асинхронной обработки, decoupling компонентов, гарантирования доставки сообщений.

  7. Search engine: Elasticsearch для полнотекстовой индексации. Если нужно быстро искать по большим объёмам текста (например, поиск в ленте).

  8. Object Storage / File System: S3, GCS или local file system. Для хранения файлов, картинок, видео.

  9. Secondary (read) database или NoSQL: если нужны данные в другом формате или с другой консистентностью. Например, Cassandra для высокой write throughput, ElasticSearch для полнотекстового поиска.

Общая схема взаимодействия компонентов:

[Client] -> [API Gateway / LB] -> [Backend Service (N instances)]
                                       |
                                       +-> [Primary DB]
                                       |    |
                                       |    +-> [Read Replicas]
                                       |
                                       +-> [Redis Cache]
                                       |
                                       +-> [Message Broker (Kafka)]
                                       |
                                       +-> [Search Engine (ES)]
                                       |
                                       +-> [Object Storage (S3)]

На собеседовании не нужно рисовать лишний сложности. Начните с простой архитектуры (API GW -> Backend -> DB), а потом добавляйте компоненты по мере обсуждения требований и масштабирования.

Обсуждение выбора технологий и trade-off'ов

На уровне Senior ожидается, что вы не просто называете технологию, но и объясняете, почему. Вот типичные trade-off'ы, которые часто обсуждаются:

  1. RDBMS (PostgreSQL, MySQL) vs NoSQL (MongoDB, Cassandra)

    RDBMS плюсы:

    • ACID гарантии (Atomicity, Consistency, Isolation, Durability)
    • Мощные запросы (JOIN'ы, агрегации)
    • Хорошо знакомы разработчикам

    RDBMS минусы:

    • Сложнее масштабировать горизонтально (требует сложное шардирование)
    • Write throughput ограничен (обычно одна машина как primary)

    NoSQL плюсы:

    • Горизонтальное масштабирование "из коробки"
    • Высокая write throughput
    • Гибкая схема

    NoSQL минусы:

    • Слабые гарантии консистентности (часто только eventual consistency)
    • Более сложные запросы (нет JOIN'ов)
    • Требует бизнес-логики на уровне приложения

    Когда выбирать:

    • RDBMS: если данные сильно связаны (relational model), нужна ACID консистентность, query patterns известны заранее
    • NoSQL: если нужна высокая write throughput, данные слабо связаны, приемлема eventual consistency

    Пример из интервью: "Для истории сообщений, где порядок и гарантии важны, я выберу PostgreSQL с read replicas. Для feed cache (часто читаемые посты пользователя), где eventual consistency OK, выберу Cassandra или Redis."

  2. REST vs gRPC vs GraphQL

    REST плюсы:

    • Простой, стандартный протокол
    • Хорошо кешируется (HTTP-уровень кеширования)
    • Легко тестировать браузером

    REST минусы:

    • Overhead HTTP header'ов
    • Over/under fetching (клиент получает больше данных, чем нужно, или нужно делать несколько запросов)

    gRPC плюсы:

    • Бинарный формат (меньше payload)
    • Multiplexing (несколько запросов в одном connection)
    • Streaming встроен

    gRPC минусы:

    • Требует специального клиента и инструментов
    • Сложнее с браузером
    • Не кешируется стандартно

    GraphQL плюсы:

    • Клиент запрашивает ровно те данные, что нужны (no over/under fetching)
    • Один endpoint вместо множества REST'а

    GraphQL минусы:

    • Сложнее реализовать на backend
    • Может быть медленнее из-за сложных запросов
    • Кеширование более сложное

    Когда выбирать:

    • REST: для public API'ов, веб-клиентов, когда простота критична
    • gRPC: для микросервисной коммуникации, когда throughput важен
    • GraphQL: для клиентов с разнородными требованиями к данным (веб, мобильные приложения с разными экранами)
  3. Synchronous vs Asynchronous processing

    Synchronous плюсы:

    • Простой для понимания и отладки
    • Источник истины понятен (response от сервера это всё, что нужно знать)

    Synchronous минусы:

    • Если downstream сервис медленный, весь путь медленный
    • Нужно ждать ответа перед тем, как двигаться дальше

    Asynchronous плюсы:

    • Decoupling: если один сервис упал, другие могут работать
    • Масштабирование: можно добавить workers'ов для обработки очереди
    • Better latency для пользователя (быстрый ответ, обработка в background)

    Asynchronous минусы:

    • Сложнее отладить
    • Eventual consistency: данные обновляются не сразу
    • Нужна гарантия доставки (может быть дублирование)

    Когда выбирать:

    • Synchronous: для операций, где latency критичен и нужен мгновенный результат (например, получить данные пользователя)
    • Asynchronous: для операций, где результат не нужен сразу (например, отправить email, обработать изображение, обновить статистику)
  4. Single Master vs Multi Master Replication

    Single Master (Primary-Replica) плюсы:

    • Нет конфликтов при записи
    • Простая консистентность (все пишут в один primary)

    Single Master минусы:

    • Primary это bottleneck для writes
    • Failover требует выбрать новый primary (может быть сложно)

    Multi Master плюсы:

    • Разные регионы могут писать независимо
    • Лучше availability (если один master упал, другой может работать)

    Multi Master минусы:

    • Конфликты при одновременной записи (требует разрешения)
    • Сложнее консистентность

    Когда выбирать:

    • Single Master: для большинства систем, когда можно жить с тем, что все пишут в один primary
    • Multi Master: для geo-distributed систем, где каждый регион хочет писать локально без latency до главного центра
  5. Strong Consistency vs Eventual Consistency

    Strong Consistency плюсы:

    • Все читают одну версию данных
    • Нет surprises

    Strong Consistency минусы:

    • Медленнее (нужно координировать reads/writes)
    • Сложнее масштабировать (нужно синхронизировать все реплики)
    • Если network partition, система не может работать (CAP theorem)

    Eventual Consistency плюсы:

    • Быстрее (можно читать из любой реплики)
    • Легче масштабировать
    • Система продолжает работать даже при сетевых сбоях (partition tolerance)

    Eventual Consistency минусы:

    • Краткосрочное рассогласование данных (user видит старые данные)
    • Требует понимания бизнес-логики и толерантности к временным несогласованностям

    Когда выбирать:

    • Strong: для критичных данных (платежи, балансы аккаунтов)
    • Eventual: для данных, где небольшая задержка OK (feed timeline, лайки на пост)

Как проговаривать trade-off'ы вслух:

"Я выбираю PostgreSQL вместо NoSQL потому, что у нас есть сложные связи между данными и нужна ACID консистентность. Недостаток: write throughput будет ограничен одним primary сервером. Для решения этого, я добавлю read replicas для distribution чтения и рассмотрю sharding при дальнейшем росте. Это добавит complexity, но позволит системе расти."

Масштабирование, устойчивость, отказоустойчивость

Масштабирование по чтению:

  • Replicas: если много операций читают из БД, добавьте несколько read replicas (копии основной БД). Все пишут в primary, читают из replicas. Lag (задержка репликации) обычно в миллисекунды.
  • Cache layer: выносит часто читаемые данные в Redis/Memcached. Очень быстро, но требует инвалидации кеша при обновлении.
  • Secondary indices в ElasticSearch: если нужен быстрый поиск по определённым полям.
  • Пример: "Для ленты новостей я буду кешировать 1000 топовых постов в Redis. Когда пользователь открывает ленту, 99% запросов попадают в Redis (2 ms latency), 1% идёт в БД."

Масштабирование по записи:

  • Message Queue / Broker: не пишите сразу в БД, сначала добавьте в очередь (Kafka, RabbitMQ). Workers обрабатывают очередь и пишут в БД. Это decouples производителя и потребителя.
  • Batch writes: вместо записи каждого события отдельно, collect батч событий и запишите вместе. Это может быть в 10 раз быстрее.
  • Sharding: если primary до-пишет свой лимит, разделите данные на несколько БД по ключу (например, по user_id % N shards). Каждый shard независимо масштабируется.
  • Write-optimized DB: Cassandra, HBase для write-heavy операций.
  • Пример: "Для логирования событий я буду использовать Kafka queue. Events идут в очередь, batch workers каждые 10 секунд пишут батч в Cassandra (которая оптимизирована на writes). Cassandra через 30 минут пишет в S3 для archiving. Это позволяет обработать 1 млн writes в секунду."

Репликация и шардирование:

Репликация (Replication) — это копирование данных на несколько машин:

  • Master-Slave: один primary, многие secondaries. Все пишут в primary, читают из secondaries. Failover требует выбрать новый primary.
  • Master-Master: несколько machines могут быть masters. Каждая может принимать writes. Конфликты разрешаются по правилам (last-write-wins, vector clocks, etc.). Сложнее, но более resilient.
  • Quorum-based: для write используется quorum (большинство) machines, для read можно использовать меньше. Гарантирует консистентность.

Шардирование (Sharding) — это разделение данных по ключу на несколько БД:

  • Range-based: данные с user_id 0-1M в shard 1, 1M-2M в shard 2, etc. Просто для понимания, но может быть skew (неравномерное распределение).
  • Hash-based: hash(user_id) % N определяет shard. Более равномерно, но миграция shard'ов при добавлении новых шард'ов сложнее.
  • Directory-based: отдельная сервис или таблица хранит mapping из ключа в shard. Очень гибко, но требует extra сервиса.

Пример: "Для database с 1000 таблицами user profiles и 10 TB данных я буду использовать hash-based sharding на 10 шард'ов (1 TB каждый). Каждый shard на отдельной машине. Когда data растёт, добавлю ещё один shard и перемигрирую половину данных (используя consistent hashing, только половина данных трогается)."

Балансировка нагрузки (Load Balancing):

  • Round-robin: requests по очереди: req1 -> server1, req2 -> server2, req3 -> server3, req4 -> server1. Просто, но не учитывает состояние server'ов.
  • Least connections: отправляет request на server с наименьшим количеством активных connection'ов. Лучше, чем round-robin.
  • IP hash: hash(client_ip) определяет server. Гарантирует, что один client всегда идёт на один server (хорошо для session affinity).
  • Weighted: разные server'ы имеют разные веса (stronger machine может получить 2x трафика).

Отказоустойчивость (Fault Tolerance):

  • Health checks: load balancer периодически проверяет, живы ли backend server'ы. Если не отвечает, исключает из пула.
  • Retry logic: если запрос упал на одном server'е, попробуй на другом.
  • Circuit breaker: если сервис часто падает, временно не отправляй ему запросы. Даёт ему время на восстановление.
  • Graceful degradation: если database slow, показывай cached данные вместо ошибки.
  • Redundancy: все критичные компоненты (БД, message broker) имеют redundant копии.

Кластеры:

  • Несколько machines, работающих вместе, как одна единица. Cassandra cluster'а может иметь 100 machines, но приложение видит его как одну "базу данных".
  • Кластер автоматически обрабатывает failover: если одна machine упала, другие continue работать.
  • Data распределяется между machines (sharding на уровне cluster'а).

Как говорить про отказоустойчивость на уровне дизайн-интервью: "Для обеспечения 99.9% availability, я буду использовать: 1) несколько инстансов backend (min 3 для quorum), 2) primary-replica PostgreSQL с automatic failover, 3) Redis кластер на 3+ nodes, 4) health checks на load balancer'е, 5) circuit breaker на клиенте для graceful degradation. Если primary DB упадёт, slave's будет elected как новый primary (через Patroni или аналог). Если один backend сервер упадёт, load balancer исключит его и requests пойдут на остальные."

Данные, хранение, кеши и очереди на уровне концепций

Где хранить "истину" (Source of Truth):

  • Source of truth это хранилище, которое является authoritative для данных. Для истории сообщений в чате, source of truth это primary БД.
  • Cache не может быть source of truth, потому что может быть потеряна при перезагрузке. Cache это "view" данных из source of truth.
  • Если кеш содержит устаревшие данные (старевает), нужна инвалидация кеша и refresh из БД.

Какие данные кешировать:

  • Часто читаемые данные: профилю пользователя, конфиги приложения, топ-посты ленты.
  • Данные, которые дорого вычисляются: результаты поиска, агрегированные метрики.
  • Не кешируйте: данные, которые часто меняются (real-time курсы валют, если не OK 10-секундное отставание).

Инвалидация кеша:

  • TTL-based: автоматически удаляй данные из кеша через N секунд. Просто, но могут оставаться устаревшие данные между invalidation'ом.
  • Event-based: когда данные обновляются в БД, явно инвалидируй кеш (отправь сообщение в Kafka, которое обновит кеш'и). Более fresh, но нужен coordination.
  • Hybrid: TTL + event-based. Используй TTL как страховка, но явно инвалидируй на событие для freshness.

Когда имеет смысл вводить брокер сообщений:

  • Когда нужна асинхронная обработка: отправить email, обработать image, обновить статистику. App отправляет message в Kafka, worker'ы обрабатывают asynchronously.
  • Когда нужно decouple компоненты: service A не должна знать про service B. Просто посылает message в Kafka, service B подписывается и обрабатывает.
  • Когда нужна гарантия доставки и retry logic: message broker обеспечивает, что message будет доставлена хотя бы один раз.
  • Когда нужна buffering: если write rate спайк, message broker буферизует и smooth'ит processing.

Пример: "Когда пользователь постит в ленту, я не хочу блокировать его на время отправки notifications всем followers. Вместо этого: 1) post идёт в primary БД (strong consistency), 2) event публикуется в Kafka, 3) worker'ы subscribed к topic'у получают event и отправляют notifications. Pост видна followers'ям в течение миллисекунд (из кеша или read replica'и), notifications отправляются в background (может быть несколько минут)."

Краткий чек-лист System Design ответа на уровне senior

Блоки, по которым обязательно пройти в ответе на уровне Senior:

  1. Функциональные требования: что нужно реализовать (операции, scope, constraints на бизнес-логику).

  2. Нефункциональные требования: нагрузка (DAU, RPS, data volume), SLA (availability, latency), consistency requirements, geo-distribution.

  3. Back-of-the-envelope оценки: сколько машин, памяти, дискового пространства нужно. Показывает масштаб проблемы.

  4. High-level архитектура: основные компоненты (API GW, backend, DB, cache, message broker) и как они связаны. Рисунок на доске или диаграмма.

  5. Выбор технологий и trade-off'ы: почему выбираете PostgreSQL или NoSQL, synchronous или asynchronous, и какие минусы этого выбора.

  6. Масштабирование: как масштабировать по чтению (replicas, кеши, indices), по записи (queues, batching, sharding). Как обработать 10x рост нагрузки.

  7. Отказоустойчивость и reliability: redundancy, health checks, failover, circuit breakers, graceful degradation. Как система продолжает работать при failures.

  8. Data consistency и durability: где source of truth, как кешировать, когда использовать eventually consistent systems. Гарантии на уровне архитектуры.

  9. Observability и monitoring (optional, но ценится): как вы будете мониторить систему, что alertить (если RPS > threshold или latency > threshold).

  10. Security (optional, если время): как защищаете API (authentication, rate limiting), данные (encryption in transit/at rest, PII handling).

Пример структурированного плана ответа в 6–8 шагов:

Шаг 1 (5 минут): Уточнение функциональных требований.

  • Вопросы про операции, scope, constraints.
  • Фиксирование границ: что в scope, что нет.

Шаг 2 (5 минут): Сбор нефункциональных требований.

  • Нагрузка: DAU, RPS, data growth.
  • SLA: availability, latency, consistency.
  • Geo-distribution, budget constraints.

Шаг 3 (5 минут): Back-of-the-envelope оценки.

  • Вычисления: сколько RPS, сколько GB/TB данных, примерно сколько machines.
  • Показать примерные вычисления интервьюеру.

Шаг 4 (10 минут): High-level архитектура.

  • Нарисовать основные компоненты.
  • Обсудить data flow: как запрос идёт от клиента к backend к DB и обратно.
  • Обсудить асинхронные операции, если есть.

Шаг 5 (8 минут): Выбор технологий и trade-off'ы.

  • PostgreSQL vs NoSQL: почему выбираете одно.
  • REST vs gRPC: когда использовать.
  • Synchronous vs asynchronous: какие операции какие.
  • Обсудить минусы выбора и как их смягчить.

Шаг 6 (8 минут): Масштабирование и data layer.

  • Как масштабировать читы: replicas, cache, indices.
  • Как масштабировать writes: queues, sharding.
  • Как распределяются данные: sharding strategy.

Шаг 7 (5 минут): Отказоустойчивость и reliability.

  • Redundancy: primary-replica, кластеры.
  • Health checks и failover.
  • Graceful degradation и circuit breakers.

Шаг 8 (Оставшееся время): Deep dive на интересующий интервьюера топик.

  • Интервьюер может сказать: "Давай подробнее разберём, как ты будешь shardировать БД?"
  • Приготовьтесь к уточняющим вопросам типа "а если нагрузка вырастет в 100 раз?" или "как ты будешь мониторить latency p99?"

На что особенно обращают внимание интервьюеры

Структурированность мышления

Интервьюеры ценят, когда кандидат не скачет между идеями, а движется по чёткой логике:

  1. Сначала требования (что нужно сделать).
  2. Потом ограничения (какие нагрузки, SLA).
  3. Потом оценки (сколько машин, памяти нужно).
  4. Потом архитектура (общие компоненты).
  5. Потом детали (выбор технологий, масштабирование).

Кандидат, который прыгает ("А что если добавим Kafka? Погодите, а может Redis лучше? Нет, давайте ElasticSearch!") выглядит хаотичным, даже если идеи хорошие.

Пример хорошей структуры: "Сначала я уточню требования [список]. Потом оценю нагрузку [вычисления]. Потом предложу basic архитектуру [диаграмма]. Потом обсудим, как масштабировать [детали]. Наконец, обсудим reliability [failover, redundancy]. Давайте начнём?"

Умение считать и делать разумные допущения

Интервьюеры ценят:

  • Вы делаете явные допущения: "Давайте скажем, что у нас 1 млн active пользователей, каждый день 10 запросов в среднем."
  • Вы делаете видимые вычисления (не мысленно, а "вслух" или на доске): "1 млн * 10 = 10 млн запросов в день, это примерно 100 RPS в среднем."
  • Вы добавляете safety margin: "Пиковый час может быть 10x средний, так что примерно 1000 RPS на пиковой нагрузке."
  • Вы делаете разумные допущения про размеры данных: "Каждое сообщение примерно 200 bytes, включая metadata."

Кандидаты, которые говорят "в системе будет very big data, поэтому нужны big data technologies" выглядят неподготовленными.

Умение проговаривать trade-off'ы и ограничения

Хороший кандидат знает, что идеального решения нет:

  • "Я выбираю PostgreSQL, потому что нужна консистентность. Недостаток: сложнее масштабировать writes. Решение: используем read replicas для reads и message queue для bufferization writes."
  • "Я использую in-memory кеш для быстрого доступа. Риск: данные могут быть потеряны при перезагрузке. Решение: кеш это просто ускоритель, true data в БД, и мы можем пересчитать из БД при необходимости."

Интервьюеры ценят, что вы видите не только плюсы, но и минусы, и думаете про mitigation strategies.

Ясность и логичность коммуникации

  • Используйте ясный язык: "Я предлагаю три инстанса backend service'а, каждый слушает на localhost:8080, 8081, 8082. Load balancer'а распределяет трафик round-robin'ом между ними."
  • Не используйте жаргон без пояснений: если сказали "we'll shard by user_id", то поясните: "Sharding означает, что мы разделяем данные: users 0-1M в database 1, 1M-2M в database 2, и т.д."
  • Рисуйте диаграммы: не полагайтесь на слова, нарисуйте компоненты и arrows показывающие data flow.
  • Проверяйте понимание интервьюера: "Это понятно? Нужно ли мне подробнее объяснить part'ы про шардирование?"

Типичные ошибки кандидатов на System Design собеседованиях и как их избежать

Ошибка 1: Сразу рисовать архитектуру без прояснения требований

Симптомы: "Вот мой design: Load Balancer -> 10 Backend Servers -> Master-Slave PostgreSQL -> Redis -> Kafka -> ElasticSearch."

Проблема: вы не знаете, какие требования, поэтому архитектура может быть неправильной или overengineered. Может быть, система должна обслуживать только 1000 RPS, и вам не нужны 10 servers. Или может быть, нужна strong consistency, и вы не должны использовать Kafka (eventual consistency).

Как избежать: начните с вопросов.

  • "Давайте сначала уточним requirements. Сколько пользователей? Какие основные операции? Какой SLA?"
  • Проведите 5–10 минут на уточнение требований, потом уже рисуйте.

Ошибка 2: Игнорирование нефункциональных требований и нагрузок

Симптомы: вы спроектировали архитектуру для 1000 RPS, но интервьюер говорит: "А что если у нас 1 млн RPS?" и вся архитектура разваливается.

Проблема: нефункциональные требования определяют, нужны ли вам 2 servers или 200. Если вы их проигнорировали, вся архитектура может быть не масштабируема.

Как избежать: явно обсудите нагрузку и SLA.

  • "Итак, нефункциональные требования: 1 млн DAU, примерно 100 000 RPS на пиковой нагрузке, p99 latency < 500 ms, 99.9% availability, data consistency. Это верно?"
  • Делайте видимые расчёты: "100 000 RPS на одном server'е (допустим, 1000 RPS per server) требует 100 server'ов. Это много, нужно optimizer: добавить кеш, sharding и т.д."

Ошибка 3: Перегрузка деталей реализации вместо фокуса на архитектуре

Симптомы: вы говорите про implementation details вместо архитектурных решений.

  • "Мы будем использовать Spring Boot с @Transactional аннотацией, и это обеспечит ACID гарантии..." (это implementation detail, не архитектурное решение)
  • "Мы используем Jackson для serialization JSON'а..." (тоже detail)
  • "Я напишу retry logic с exponential backoff и jitter..." (implementation, не architecture)

Проблема: интервьюер проверяет System Design (architecture level), а не coding skills. Фокус на details показывает, что вы не видите big picture.

Как избежать: оставайтесь на уровне архитектуры.

  • Говорите про компоненты, взаимодействие, trade-off'ы, масштабирование.
  • Если интервьюер спросит про детали, ответьте кратко: "На implementation level мы используем Spring Boot с repositories, но на архитектурном уровне это просто 'Backend Service, которая читает/пишет из БД через DAL'."

Ошибка 4: Отсутствие trade-off'ов и альтернатив

Симптомы: вы выбираете решение и говорите, что это perfect choice, без обсуждения альтернатив.

  • "Мы используем NoSQL, потому что это лучше, чем SQL."
  • "Мы используем Kafka, потому что это самый популярный message broker."

Проблема: это показывает, что вы не глубоко знаете технологии и не можете принимать обоснованные решения. На самом деле, выбор между SQL и NoSQL зависит от requirements, и нет один "better".

Как избежать: всегда обсуждайте trade-off'ы.

  • "Я рассматриваю два варианта: PostgreSQL и Cassandra. PostgreSQL даёт ACID гарантии и мощные query'ы, но сложнее масштабировать horizontally. Cassandra даёт линейное масштабирование, но слабые consistency гарантии. Учитывая requirement на strong consistency в нашем случае, я выбираю PostgreSQL."

Ошибка 5: Непоследовательный или хаотичный стиль ответа

Симптомы: вы прыгаете между идеями, переделываете архитектуру несколько раз, говорите неуверенно.

  • "Ой, я забыл про кеш. Давайте переделаем всю архитектуру."
  • "А может использовать microservices? Нет, wait, может быть монолит лучше?"
  • "Хм, я не уверен, может Redis лучше, или Memcached?"

Проблема: это показывает неуверенность и слабое понимание. Даже если идеи потом оказываются хорошими, presentation wichtig.

Как избежать: подготовьтесь и придерживайтесь плана.

  • Перед ответом в голове пройдите логику: требования → нагрузки → компоненты → trade-off'ы.
  • Если во время ответа понимаете, что что-то упустили, скажите чётко: "Погодите, я понял, что недостаточно обсудили масштабирование по записи. Давайте добавим message queue в архитектуру."
  • Будьте уверены в выборе, даже если потом оказываются альтернативы: "Я выбираю PostgreSQL, потому что [причины]. Если бы requirements были другие, рассмотрели бы NoSQL, но сейчас PostgreSQL лучше fit."

Ошибка 6: Отсутствие обсуждения reliability и disaster recovery

Симптомы: вы спроектировали функциональную архитектуру, но когда интервьюер спрашивает "А что если primary database падёт?", вы не готовы к ответу.

Проблема: на production системы reliability это huge concern. Если primary database упадёт и у вас нет failover плана, система down'а. Хороший кандидат всегда думает про failure scenarios.

Как избежать: всегда включайте reliability в архитектуру.

  • "Для reliability я использую: 1) Primary-replica PostgreSQL с automatic failover (Patroni или управляемый service), 2) health checks на load balancer'е, 3) Redis cluster на 3+ nodes, 4) регулярное бекапирование к S3."
  • Подумайте про network partitions, hardware failures, bugs в code'е.

Ошибка 7: Игнорирование requirements, которые кажутся простыми

Симптомы: интервьюер говорит "А что если нам нужна full-text search на ленте?" а вы: "О, можно просто использовать LIKE в PostgreSQL."

Проблема: LIKE в PostgreSQL это O(n) операция и очень медленная на большых данных (млрд записей). Хороший кандидат сразу вспомнит ElasticSearch или другие search engines.

Как избежать: знайте, что каждый feature может иметь implications для архитектуры.

  • Когда интервьюер добавляет новый requirement, спросите себя: "Как это влияет на архитектуру? Нужна ли новая компонента?"

Заключение на уровне практики

System Design это не про знание всех технологий, а про структурированный процесс думания. Хороший кандидат может спроектировать любую систему, даже если раньше с ней не работал, потому что он знает фундаментальные компоненты (backend, DB, cache, queue, search), trade-off'ы, и методологию approach'а.

На собеседовании интервьюер смотрит на:

  1. Можешь ли ты задать правильные вопросы?
  2. Можешь ли ты считать и оценить масштаб?
  3. Знаешь ли ты, как компоненты работают и взаимодействуют?
  4. Можешь ли ты обсудить trade-off'ы?
  5. Думаешь ли ты про reliability и edge cases?
  6. Можешь ли ты это всё ясно объяснить?

Если на все вопросы "да", ты готов к собеседованию.

System Design: Сбор требований, расчет нагрузки

Роль сбора требований и capacity planning в System Design

Без чётких требований и понимания нагрузок проектирование архитектуры бессмысленно: неправильные базы, неподходящие паттерны, неработающая система в проде. Вся система строится под реальные потребности, сценарии и предсказуемые нагрузки.

В продуктовой разработке грамотный system design всегда начинается с разбора, «что именно и для кого нужно реализовать», как и сколько этим будут пользоваться, какие SLA требуются бизнесу.

На собеседовании любой опытный интервьюер начинает разбор с вопросов: «Какой функционал?», «Сколько пользователей?», «Какие требования по производительности?» — ожидая, что кандидат сам инициирует уточнение.

Функциональные требования

Функциональные требования — это описания основных сценариев использования системы: кто, что и с какими ограничениями делает.

Роли и сценарии бывают следующими:

  • Пользовательская роль ("User", "Admin", "Moderator").
  • Действия (создать заказ, оплатить, отправить сообщение).
  • Ограничения (например, пользователь может отправить до 10 сообщений в минуту).

Чёткая формулировка:

  • «Система позволяет зарегистрированному пользователю создавать и удалять свои записи, админ — просматривать все записи».
  • «Каждый заказ можно оплатить онлайн или через курьера».

Границы системы:

  • Важно обозначить, что именно включено в зону проектирования (core сервисы) и что выступает внешними системами (платёжные шлюзы, сторонние API, фронтенд).

Типовые вопросы, чтобы не пропустить сценарии:

  • «Какие основные действия может выполнить пользователь?»
  • «Есть ли ограничения на частоту запросов?»
  • «Какой минимальный и максимальный объём данных в одном действии?»
  • «Какие внешние системы должны быть интегрированы?»

Не стоит погружаться в подробности бизнес-логики до уровня полей и формул — задачи архитектуры требуют макроуровня и понимания потоков данных.

Нефункциональные требования

Нефункциональные требования (NFR) — это параметры качества работы системы, не связанные с функциональностью, но определяющие требования к инфраструктуре и архитектуре:

  • Производительность: задержки (latency, например, 100 мс P99), пропускная способность (throughput, например, 1000 RPS).
  • Доступность: можно ли получить доступ к сервису (например, 99.99% Uptime).
  • Надёжность: сохранность данных при сбоях, отсутствие потерь.
  • Долговечность: гарантии хранения данных (например, сообщение не теряется 5 лет).
  • Консистентность: насколько данные мгновенно и согласованно обновляются.
  • Масштабируемость: возможность работы и увеличения нагрузки.
  • Безопасность: аутентификация, авторизация, хранение паролей.
  • Геораспределённость: наличие пользователей и данных в нескольких регионах.

Важные определения:

  • SLI (Service Level Indicator) — конкретная метрика качества ("средняя задержка ответа = 150 мс").
  • SLO (Service Level Objective) — целевое значение ("P95 latency < 200 мс").
  • SLA (Service Level Agreement) — договорное обязательство ("Гарантируем 99.9% аптайм, штраф при нарушении").

Чёткие численные цели нужны всегда. Формулируем требования правильно:

  • Вместо «быстро» — «latency 100 мс P99».
  • Вместо «надёжно» — «данные не теряются ни при каких сбоях».
  • Вместо «много пользователей» — «пиковая нагрузка 5000 RPS, 1 млн MAU».

Основные метрики нагрузки

  • RPS/QPS (requests/queries per second): количество входящих/исходящих запросов и операций в секунду.
    • Например, «API должен обрабатывать 1000 RPS».
  • DAU/MAU (Daily/Monthly Active Users): активные уникальные пользователи в день/месяц.
  • Одновременно онлайн: максимальное количество единовременно активных пользователей — критично для расчёта конкурентных соединений.
  • Частота действий на пользователя: сколько действий делает пользователь в минуту, час, день.
  • Read/write ratio: отношение числа чтений к записям (например, 80% read, 20% write).
  • Размер запроса/ответа: средний объем данных per request (например, 2 КБ).
  • Профиль нагрузки: когда случаются пики (часы, дни, сезоны), особенности паттернов активности.

Базовый алгоритм оценки нагрузки (back-of-the-envelope вычисления)

  1. Оценка аудитории
    • Сколько MAU? Сколько DAU? Сколько одновременно онлайн?
    • «Зададим допущение: в месяц 120 тыс. MAU, в день 10 тыс. DAU, одновременных — до 1000».
  2. Оценка активности
    • Сколько действий/запросов делает средний пользователь?
    • «В среднем 10 запросов в день, в пике (например, в эвент) — 100».
  3. Расчёт RPS
    • RPS средний: DAU * среднее число запросов в сутки / 86400.
    • Пиковый RPS: учитываем пиковые интервалы активности.
    • «1000 пользователей x 5 запросов за 1 минуту = 83 RPS (в течение пика)».
  4. Оценка распределения нагрузки между компонентами
    • Gateway, backend, Database, Cache — сколько запросов каждой компоненте.
    • «Из 1000 входящих RPS: 800 идут в кеш, 200 — напрямую в БД».
  5. Оценка объёма хранения
    • Размер одной сущности: «Запись — 2 КБ данных».
    • Сколько сущностей в день: «100 тыс. новых сообщений ежедневно».
    • Объём данных: «100 тыс. x 2 КБ = 200 МБ в день, 6 ГБ в месяц».
    • Рост: «Через год — 72 ГБ новых».
    • Архивирование, TTL: «Старые данные архивируем через 12 месяцев».
  6. Оценка сетевого трафика
    • In/out: «1000 RPS x 2 КБ = 2 МБ/c входящего / исходящего трафика».

Примеры:

  • "Допустим, 10 000 DAU, каждый делает 20 запросов в среднем — это 200 000 запросов/день, что в среднем 2,3 RPS; в пик (10% DAU, 50 действий за час) — до ~14 RPS".
  • "Храним по 4 КБ/запись, приход новых = 20 000 * 4 КБ = 80 МБ/сутки данных".

Capacity planning для ключевых компонентов

Веб- и API-сервера

  • RPS делим на пропускную способность одного инстанса: «Если одна Java-инстанция обрабатывает 100 RPS, при нагрузке 800 RPS нужно минимум 8 инстансов плюс запас».
  • Latency: какой допустим (см. SLO).
  • Timeouts: устанавливаем грамотные границы отклика.
  • Connection pools: размер connection pool вычисляется из числа конкурентных соединений, p95-p99 задержек.

Базы данных

  • Количество запросов в БД на пользовательский запрос: «Каждый API-запрос — 2 чтения (User, Product), одна запись (Order)».
  • RPS для БД = API RPS * среднее число операций на запрос.
  • Предел одного инстанса: знание физического лимита (например, «PostgreSQL максимум 2000 QPS на c5.xlarge, дальше рост latency»).
  • Порог для репликации — когда чтения начинают доминировать.
  • Шардирование: при превышении объёмов или write QPS, когда RDS не масштабируется вертикально.

Кеши

  • Cache hit ratio — доля попаданий: «Если 80% запросов идёт в кеш, на БД попадает только 20% нагрузки».
  • Пример: было 1000 RPS, с кешем 0.8 hit-ratio — на storage попадает только 200 RPS.
  • Экономия на latency, разгрузка базы.

Очереди и брокеры сообщений

  • Оцениваем объём сообщений (msg/sec, MB/sec).
  • Скорость потребления: «Очередь Kafka способна принимать 5000 сообщений в секунду, каждая consumer group потребляет 1000 msg/sec».
  • Burst-нагрузки: очереди позволяют сгладить пики (выравнивание backlog), обработка в off-peak.

Работа с пиками и запас по мощности

  • Average vs Peak load: средние нагрузки всегда ниже пиковых. Дизайн обязателен под peak, иначе будут проблемы в highload.

  • Safety margin (коэффициент запаса): обычно 20–30% сверх оценки для непредвиденных всплесков.

  • Burst traffic, flash crowd, акции — увеличиваем запас к пиковым значениям.

  • Стратегии против пиков:

    • Autoscaling: автоматическое масштабирование инстансов по метрикам.
    • Rate limiting: ограничение числа запросов с одного пользователя / IP.
    • Очереди: буферизация входящего потока, асинхронная обработка.

Как презентовать вычисления на собеседовании

  • Проговаривать вслух все допущения: «Буду считать средний размер entity 2 КБ...»
  • Если нет точных данных — выбирать разумные "по умолчанию" (например, RPS для табличного CRUD — десятки/сотни).
  • Демонстрировать ход рассуждений: «Если API будет обслуживать 1000 DAU и на пике — до 100 одновременно онлайн, это значит concurrency...»
  • Аргументировать порядок цифр, а не абсолютное значение — этого достаточно интервьюеру.
  • Баланс: скорость и логика ценятся выше, чем абсолютная точность.

Capacity planning и выбор архитектурных решений

Знание нагрузок позволяет обосновать структуру архитектуры:

  • RDBMS vs NoSQL: при write-heavy и большом DAU — NoSQL, для транзакционного consistency — RDBMS.
  • Горизонтальное vs вертикальное масштабирование: увеличение количества инстансов против мощности одной машины.
  • Синхронное vs асинхронное взаимодействие: задержки и возможности работы "в фоне" для разгрузки.
  • Кеширование vs работа напрямую с БД: кеш для пиков/горячих данных, "холодная" база — только для уточняющих операций.

Пример эволюции:

  • Система для 1 000 пользователей — достаточно одной реплики RDBMS, без кеша.
  • При росте до 1 млн: появляются кеш, read-реплики, очереди, NoSQL для части данных, асинхронные воркеры.

Краткий чек-лист по сбору требований и оценке нагрузки

Кандидат всегда уточняет:

  • Для кого система (роли, типы пользователей)?
  • Какой базовый функционал нужен?
  • Какие сценарии — создание, обновление, просмотр, удаление?
  • Какие интеграции с внешними системами?
  • Какой объём активных пользователей (MAU, DAU, online)?
  • Сколько действий/запросов в сутки/час/секунду?
  • Какой профиль нагрузки? Какие пики?
  • Какие SLA/SLO по latency, availability, durability?
  • Какой размер сущностей/запросов?
  • Какой объём хранимых данных? Какой рост?
  • Есть ли требования по консистентности, безопасности?
  • География пользователей (один регион или весь мир)?

Алгоритм:

  1. Кто пользователи?
  2. Что делают?
  3. Как часто?
  4. Сколько одновременных действий?
  5. Какие есть ограничения и бизнес-правила?
  6. Какой ожидаемый объём данных?
  7. Какие критические NFR?
  8. Где и когда будут пики?

Эти вопросы надо озвучивать в начале обсуждения design-задачи, вплетая их в структуру ответа.

Типичные ошибки при сборе требований и оценке нагрузки

  • Полное игнорирование нефункциональных требований (вся фокусировка на сценариях).

  • Отсутствие численных оценок: «много пользователей», «высокая нагрузка» без цифр.

  • Приведение случайных, неоснованных на бизнесе или данных цифр.

  • Несогласованность: суммы не сходятся, RPS не совпадает с DAU/MAU.

  • Уход в детали на уровне кода до выяснения ключевых параметров.

  • Как избежать:

    • Всегда уточнять и озвучивать допущения («буду считать, что 10 000 DAU, пиковая нагрузка — 100 RPS»).
    • Быстро оценивать порядок величин, не детализацию.
    • Все NFR должны быть названы и озвучены архитектурные trade-off'ы.
    • Все цифры должны увязываться между собой и с логикой бизнеса.

System Design: архитектурные стили и паттерны для микросервисов, монолитов и event-driven систем на собеседованиях senior-разработчиков.

Зачем понимать архитектурные стили на System Design собеседовании

На интервью архитектурный стиль — это не академический выбор, а осознанное решение, которое влияет на всю траекторию разработки системы. Интервьюер оценивает, насколько кандидат понимает trade-off'ы между разными подходами и может ли обосновать выбор на основе требований.

Как выбор архитектурного стиля влияет на систему

Архитектурный стиль определяет:

  • Масштабируемость: микросервисы позволяют масштабировать отдельные компоненты, монолит часто требует горизонтального масштабирования всего приложения.
  • Скорость разработки: модульный монолит быстрый на старте, но может замедлить развитие при росте сложности; микросервисы добавляют overhead, но дают свободу отдельным командам.
  • Отказоустойчивость: event-driven системы с асинхронной обработкой обычно более устойчивы к сбоям отдельных компонентов, чем синхронные монолиты.
  • Модель данных: выбор между единой БД (монолит) и распределённым хранилищем (микросервисы) принципиально меняет подход к транзакциям и консистентности.
  • Операционная сложность: микросервисы требуют Kubernetes, логирования, трейсинга; монолит проще в эксплуатации, но может стать неуправляемым при росте.

Что хочет увидеть интервьюер

Интервьюер ищет кандидата, который:

  • Не выбирает паттерн "по тренду", а обосновывает решение требованиями (нагрузка, команда, временные горизонты).
  • Осознаёт trade-off'ы: "микросервисы дают параллельное развитие, но усложняют отладку и сетевые взаимодействия".
  • Показывает, что кодил на этих стилях: приводит примеры из реальных проектов (Spring Boot приложение с Kafka, Event Sourcing в Kafka, CQRS с отдельной read-моделью).
  • Может эволюционировать архитектуру: начал с монолита, вырос в модульный монолит, потом выделил микросервисы.
  • Обсуждает не только код, но и операционный контекст: CI/CD, мониторинг, disaster recovery.

Связь стилей с реальным опытом Java/Spring-разработчика

Типичный путь опытного backend-разработчика на Java:

  1. Классический Spring Boot монолит: @SpringBootApplication, однослойная или трёхслойная архитектура (controller → service → repository).
  2. Рост в модульный монолит: выделение bounded context-ов как пакетов или модулей Spring, использование spring-context, явные контракты между модулями.
  3. Переход на микросервисы: Spring Cloud, отдельные Spring Boot приложения, spring-cloud-gateway для API gateway, spring-kafka для async взаимодействия.
  4. Углубление в event-driven: spring-kafka, spring-cloud-stream, domain events, integration events, event sourcing с Apache Kafka или другим хранилищем событий.
  5. CQRS и проекции: в read-модели используются оптимизированные запросы, например, Elasticsearch или отдельная БД для аналитики.

На интервью важно показать, что ты понимаешь эту эволюцию не как набор фич, а как логичные ответы на растущие требования.


Обзор основных архитектурных стилей

Монолит

Определение: одно развёрнутое приложение, содержащее всю бизнес-логику и совместное использование одной модели данных, обычно одной базы данных.

Структура монолита

User Interface (Presentation)
  ↓
API Controllers (Spring @RestController)
  ↓
Services (бизнес-логика, Spring @Service)
  ↓
Repositories (доступ к БД, Spring @Repository)
  ↓
Single Database (PostgreSQL, MySQL)

Плюсы монолита

  • Простота деплоя: один jar/war файл, одна команда развёртывания (или Docker контейнер).
  • Целостная модель данных: транзакции ACID на уровне всего приложения, без распределённых транзакций и двухфазных коммитов.
  • Простота разработки на старте: не нужно думать про API контракты между сервисами, о сетевых сбоях, о синхронизации между компонентами.
  • Производительность: вызовы между компонентами — в памяти процесса, не через сеть, latency ~ микросекунды.
  • Лёгкая отладка: весь стек в одном процессе, breakpoint-ы и логирование работают прямолинейно.

Минусы монолита

  • Затруднённое масштабирование по отдельным компонентам: если нужна высокая пропускная способность для чтения продуктов, приходится масштабировать весь монолит, включая код авторизации, платежей и т.д.
  • Сложность изменений в больших кодовых базах: при росте монолита до миллионов строк кода усложняется добавление новых фич, возрастает риск побочных эффектов.
  • Технологический lock-in: если монолит написан на Java, сложно добавить компонент на Go или Python без больших архитектурных изменений.
  • Нарушение принципа единственной ответственности: со временем в монолите появляются слабо связанные модули (пользователи, заказы, платежи, рекомендации), которые мешают друг другу.
  • Сложность команд: трудно дать отдельной команде полное владение одной фичей без координации с другими.

Когда монолит — разумный выбор

  • Стартап или MVP: нужно быстро проверить идею, монолит позволит это сделать за недели.
  • Небольшая система: несколько сервисов (авторизация, каталог товаров, корзина), команда из 5–10 человек.
  • Требования к консистентности строгие: финансовые расчёты, где нужна ACID, микросервисы усложнят жизнь.
  • Операционный контекст простой: нет опыта с Kubernetes и распределёнными системами, нужна система, которую может поддерживать небольшая операционная команда.
  • Нет требования к технологическому многообразию: все компоненты системы хорошо реализуются на Java/Spring.

Модульный монолит

Определение: монолит, разделённый на явные, автономные модули (bounded context-ы), каждый с чёткой границей ответственности и внутренним контрактом. Это не просто пакеты в одном приложении, а явно спроектированная структура с минимальными перекрёстными зависимостями.

Отличие от "анемичного" монолита

Анемичный монолит — это когда одно приложение, но нет явных граней между компонентами:

// Анемичный монолит: OrderService зависит от UserService, 
// UserService от PaymentService, всё перепутано
@Service
public class OrderService {
    @Autowired private UserService userService;
    @Autowired private PaymentService paymentService;
    @Autowired private NotificationService notificationService;
    
    // Сложные зависимости, трудно изменять отдельные части
}

Модульный монолит — это когда чётко определены границы:

monolith-app/
├── user-module/
│   ├── domain/
│   │   └── User, UserRepository (интерфейс)
│   ├── application/
│   │   └── UserService
│   ├── api/
│   │   └── UserController
│   └── infrastructure/
│       └── UserRepositoryImpl (реализация)
├── order-module/
│   ├── domain/
│   │   └── Order, OrderRepository (интерфейс)
│   ├── application/
│   │   └── OrderService
│   ├── api/
│   │   └── OrderController
│   └── infrastructure/
│       └── OrderRepositoryImpl
└── shared/
    └── EventPublisher, UserDTO (для inter-module общения)

Явные границы модулей и внутренние контракты

В модульном монолите:

  • Каждый модуль имеет чётко определённый API: обычно это набор интерфейсов в пакете application или api, которые открыты для других модулей.
  • Зависимости между модулями идут только в одну сторону: например, order-module может зависеть от user-module, но не наоборот. Это предотвращает циклические зависимости.
  • Прямое обращение к domain-классам другого модуля запрещено: модули общаются через DTO или event-ы, не передавая domain-сущности.
  • Каждый модуль может иметь свою БД (хотя на деле часто одна общая БД с раздельными таблицами/схемами): это подготавливает к возможному переходу на микросервисы.

Примеры организации в Java/Spring

Spring Modulith (относительно новый проект Spring) помогает организовать модули:

// В одном приложении несколько модулей
// spring-modulith автоматически проверяет зависимости между модулями

// user-module
package com.example.shop.user;
public class User { ... }

// order-module
package com.example.shop.order;
@Service
public class OrderService {
    private final UserService userService; // Допустимо: явная зависимость
    // ...
}

// payment-module
package com.example.shop.payment;
@Service
public class PaymentService {
    // Не может зависеть от order-module, чтобы избежать цикла
}

Bounded context — концепция из Domain-Driven Design:

  • Order-module представляет bounded context заказов с собственной моделью (Order, OrderLine, OrderStatus).
  • User-module представляет bounded context пользователей с собственной моделью (User, Profile, Preferences).
  • Они общаются через чётко определённые API и события, не деля внутренние классы.

Независимое развитие модулей

В модульном монолите команда может:

  • Изменять внутреннюю реализацию Order-service (например, перейти на другую БД для заказов), не трогая остальной код.
  • Разрабатывать новые фичи в User-module параллельно с разработкой в Order-module, если они касаются разных bounded context-ов.
  • Нанять отдельного разработчика на Payment-module, зная, что он не будет случайно нарушать инварианты Order-module.

Когда модульный монолит эффективнее микросервисов

  • Ранние стадии роста: система выросла за пределы простого монолита (50–100 тыс. строк кода), но ещё не требует полной развёртки микросервисов.
  • Нет необходимости в независимом масштабировании: если все модули имеют примерно одинаковую нагрузку.
  • Основная команда одна: нет отдельных команд, которым нужна полная автономия по развёртыванию.
  • Требования к согласованности высоки: модульный монолит даёт транзакции ACID, которые трудно обеспечить в микросервисах.
  • Операционная простота: нет нужды в Kubernetes, Service Discovery, распределённом трейсинге с самого начала.

Микросервисы

Определение: архитектурный стиль, где система разбита на набор независимых сервисов, каждый с собственной БД, собственным процессом развёртывания, взаимодействующих через API (REST, gRPC) и/или асинхронные каналы (Kafka, RabbitMQ).

Ключевые признаки микросервисов

  • Раздельное хранилище данных: каждый микросервис отвечает за свою БД, нет общего хранилища.
  • Независимый деплой: изменение в Order-service не требует пересборки и переразворачивания User-service.
  • Технологическая независимость: Order-service может быть на Java/Spring, а Payment-service на Go или Node.js.
  • Раздельная scalability: Order-service можно масштабировать до 100 инстансов, в то время как User-service остаётся с 5.

Плюсы микросервисов

  • Независимое масштабирование: каждый сервис может быть масштабирован отдельно под свою нагрузку.
  • Технология-агностика: разные микросервисы могут использовать разные языки, БД, фреймворки в зависимости от требований.
  • Организационное разделение: каждой команде можно дать в полное владение один или два микросервиса (принцип "two-pizza team" в Amazon).
  • Гибкость разработки: команда может обновлять свой микросервис с собственной скоростью, не дожидаясь других.
  • Изоляция сбоев: если платёжный сервис падает, заказы могут ставиться в очередь и обрабатываться позже.

Минусы микросервисов

  • Сложность инфраструктуры: нужна orchestration платформа (Kubernetes), API Gateway, Service Discovery, Circuit Breaker, Distributed Tracing.
  • Распределённые транзакции: вместо ACID в одной БД, нужно использовать saga-паттерны (orchestration или choreography), что усложняет логику и требует компенсирующих транзакций.
  • Observability: логирование, метрики, трейсинг распределены между сервисами, требуется централизованное решение (ELK, Jaeger, Prometheus).
  • Сетевые сбои: вызовы между сервисами идут по сети, может быть задержка, таймауты, потеря пакетов. Нужны retry-логика, timeout-ы, fallback-и.
  • Увеличенная latency: вместо вызова функции в памяти (наносекунды), нужен HTTP/gRPC вызов (миллисекунды).
  • Сложность локальной разработки: вместо одного docker-compose up, нужно поднять десяток контейнеров с зависимостями, синхронизировать версии API.

Типичные анти-паттерны

  • Микросервисы "по таблицам": каждая таблица БД — отдельный микросервис (User-service, Order-service, OrderLine-service). Это приводит к распределённым транзакциям и рассеиванию бизнес-логики.
  • Слишком мелкие сервисы: сервис-агрегатор заказа вызывает Service1 → Service2 → Service3 → Service4 → Service5, и одна пользовательская операция требует 5 сетевых вызовов.
  • Неправильное разбиение по bounded context: Order-service зависит от User-service, User-service от Product-service, Product-service от Order-service (циклические зависимости).
  • Попытка использовать одну общую БД вместо раздельных: тогда это не микросервисы, а просто несколько приложений с анемичными сервисами.

Правильное разбиение на микросервисы

Правильное разбиение следует bounded context-ам из Domain-Driven Design:

  • User Context: управление профилем, авторизацией, настройками.
  • Product Context: каталог товаров, описания, изображения.
  • Order Context: создание заказов, управление статусами.
  • Payment Context: обработка платежей, интеграция с платёжными системами.
  • Notification Context: отправка email, SMS, push-уведомлений.

Каждый context имеет собственное представление сущностей (например, User в Payment-context — это только ID и платёжные реквизиты, а не весь профиль).


SOA, Serverless: краткий обзор

Service-Oriented Architecture (SOA)

Определение: архитектурный стиль, где система состоит из набора сервисов, предоставляющих бизнес-функции через чётко определённые интерфейсы (обычно SOAP/XML на рассвете SOA).

Отличие от микросервисов: SOA часто использует централизованную интеграционную шину (ESB — Enterprise Service Bus), в то время как микросервисы обычно предпочитают decentralized approaches (каждый сервис отвечает за свою интеграцию). SOA может быть более "монолитной" в плане управления, микросервисы — более автономные.

Для интервью: упомяни, что SOA — это предшественник микросервисов, и микросервисы — это более современный, лёгкий вариант SOA с меньшим overhead на интеграционную инфраструктуру.

Serverless

Определение: модель, где разработчик пишет функции (Lambda на AWS, Cloud Functions на GCP), а облачный провайдер управляет инфраструктурой, масштабированием и выставляет счёт за реальное использование.

Связь с микросервисами: serverless часто используется как дополнение к микросервисам. Например:

  • Асинхронная обработка событий через AWS Lambda + SNS/SQS.
  • Преобразование данных (ETL) через Cloud Functions.
  • Webhook-обработчики для внешних сервисов.

Для интервью: упомяни, что serverless удобен для I/O-bound операций и event-driven обработки, но может быть дороговат для long-running задач или CPU-heavy workloads. На System Design собеседовании serverless часто используется как часть решения, а не как основной архитектурный стиль (за исключением специальных кейсов).


Слоистая (Layered) архитектура

Определение: архитектурный стиль, где приложение разделено на горизонтальные слои, каждый с чётко определённой ответственностью. Это, вероятно, самый распространённый паттерн в Spring Boot приложениях.

Основные слои

1. Presentation Layer (API Layer)

Ответственность: приём HTTP-запросов, валидация входных данных, преобразование запросов в формат, понятный для application layer.

В Java/Spring: @RestController, @PostMapping, @GetMapping, обработка параметров, маршрутизация.

@RestController
@RequestMapping("/orders")
public class OrderController {
    @PostMapping
    public ResponseEntity<OrderDTO> createOrder(@RequestBody CreateOrderRequest request) {
        // Валидация на уровне контроллера
        Order order = orderService.createOrder(request.getUserId(), request.getItems());
        return ResponseEntity.ok(convertToDTO(order));
    }
}

2. Application/Service Layer

Ответственность: бизнес-логика, координация операций, управление транзакциями. Не содержит вызовы к БД, это делает infrastructure layer.

В Java/Spring: @Service, методы с @Transactional, вызовы repositories.

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserRepository userRepository;
    
    public Order createOrder(Long userId, List<OrderItemDTO> items) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException());
        Order order = new Order(user, items);
        // Логика проверки, расчётов и т.д.
        return orderRepository.save(order);
    }
}

3. Domain Layer

Ответственность: доменные сущности, бизнес-правила, которые не зависят от фреймворков и технологий. Не содержит Spring аннотации, это чистый бизнес-код.

В Java/Spring: POJO (Plain Old Java Object) классы, методы, которые реализуют бизнес-логику.

public class Order {
    private Long id;
    private User user;
    private List<OrderItem> items;
    private OrderStatus status;
    
    public Order(User user, List<OrderItem> items) {
        this.user = user;
        this.items = items;
        this.status = OrderStatus.PENDING;
    }
    
    public BigDecimal calculateTotal() {
        return items.stream()
            .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    
    public void confirm() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("Can only confirm pending orders");
        }
        this.status = OrderStatus.CONFIRMED;
    }
}

4. Infrastructure Layer

Ответственность: доступ к БД, внешним сервисам, файловой системе, кешу. Реализует интерфейсы, определённые в service layer.

В Java/Spring: @Repository, JPA entities, реализация custom запросов, взаимодействие с Kafka, Redis и т.д.

@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    List<OrderEntity> findByUserId(Long userId);
    // Custom запросы для специфичных случаев
}

@Service
public class OrderNotificationService {
    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;
    
    public void publishOrderCreated(Order order) {
        kafkaTemplate.send("orders.created", new OrderCreatedEvent(order.getId()));
    }
}

Горизонтальные зависимости: кто может зависеть от кого

Правило: зависимости идут только в одну сторону, сверху вниз:

Presentation Layer
        ↓
Application/Service Layer
        ↓
Domain Layer
        ↓
Infrastructure Layer
  • Presentation Layer зависит от Application Layer (контроллер вызывает сервис).
  • Application Layer зависит от Domain Layer (сервис работает с доменными объектами) и Infrastructure Layer (сервис вызывает repositories).
  • Domain Layer не зависит ни от кого (POJO, чистая бизнес-логика).
  • Infrastructure Layer зависит от Domain Layer (repository работает с доменными объектами).

Обратные зависимости запрещены: Infrastructure Layer не должна вызывать Application Layer (это приводит к циклам и нарушает слоистость).

Типичные нарушения слоистости и их последствия

Нарушение 1: Logic в Presentation Layer

// ❌ ПЛОХО: бизнес-логика в контроллере
@RestController
public class OrderController {
    @PostMapping("/orders")
    public ResponseEntity<OrderDTO> createOrder(@RequestBody CreateOrderRequest request) {
        // Здесь не должно быть бизнес-логики!
        BigDecimal total = request.getItems().stream()
            .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        
        if (total < BigDecimal.ZERO) {
            return ResponseEntity.badRequest().build();
        }
        // ...
    }
}

Последствия: трудно тестировать логику отдельно (нужен mock HTTP), сложно переиспользовать эту логику в других контроллерах (например, для gRPC endpoint-а), нарушается разделение ответственности.

Нарушение 2: Прямой доступ из Infrastructure к Presentation

// ❌ ПЛОХО: repository вызывает контроллер
@Repository
public class OrderRepositoryImpl {
    @Autowired
    private OrderController orderController; // ЦИКЛИЧЕСКАЯ ЗАВИСИМОСТЬ!
    
    public Order save(Order order) {
        // ...
        orderController.notifyUser(order); // Неправильно!
        return order;
    }
}

Последствия: циклические зависимости, невозможно тестировать repository отдельно, нарушение зоны ответственности.

Нарушение 3: Утечка инфраструктурных деталей в Domain

// ❌ ПЛОХО: доменная сущность зависит от Spring
@Entity // Spring/JPA аннотация в domain-классе!
@Table(name = "orders")
public class Order {
    @Id
    private Long id;
    @OneToMany
    private List<OrderItem> items;
}

Последствия: domain layer становится зависим от JPA, сложнее тестировать, нарушается чистота доменной модели, трудно менять БД (например, с JPA на MongoDB).

Пример: как описать layered architecture на System Design собеседовании

Кандидат проговаривает:

Я предлагаю слоистую архитектуру. Со стороны клиента приходят REST запросы в Presentation layer, где мы валидируем входные данные и преобразуем их. Затем запрос идёт в Application layer — это Spring Service, где размещена основная бизнес-логика: расчёты, проверки инвариантов, координация с другими сервисами. Service работает с чистыми доменными объектами — это Order, User, Payment. И наконец, Infrastructure layer — это repositories, которые маппят доменные объекты в БД.

Зависимости идут только в одну сторону, снизу вверх: Presentation → Service → Domain ← Infrastructure. Это позволяет легко тестировать бизнес-логику без Spring контекста, легко менять реализацию (например, БД), и обеспечивает чистоту кода.


Hexagonal Architecture (Ports and Adapters)

Определение: архитектурный стиль, где доменная логика расположена в центре (core), а взаимодействие с внешним миром (БД, API, queue-и) осуществляется через порты (интерфейсы) и адаптеры (реализации). Также известна как "Ports and Adapters" архитектура.

Концепция: порты и адаптеры

Порты

Портом называется интерфейс, который определяет, как domain layer хочет взаимодействовать с внешним миром.

Типы портов:

  1. Входящий порт (Inbound Port): это интерфейс, через который внешний мир вызывает бизнес-логику. Обычно это use case или application service.
// Входящий порт: как внешнее приложение или контроллер вызывает core logic
public interface CreateOrderUseCase {
    OrderResponse execute(CreateOrderRequest request);
}
  1. Исходящий порт (Outbound Port): это интерфейс, через который domain layer зависит от внешних сервисов (БД, платёжные системы, уведомления).
// Исходящий порт: как domain layer хочет сохранять заказы (не важно, в какой БД)
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(Long id);
}

// Исходящий порт: как domain layer хочет взаимодействовать с платёжной системой
public interface PaymentService {
    PaymentResult processPayment(Payment payment);
}

Адаптеры

Адаптером называется конкретная реализация порта, которая интегрирует domain layer с внешней технологией.

Входящие адаптеры:

// REST адаптер: входящий адаптер, который преобразует HTTP запрос в вызов domain logic
@RestController
@RequestMapping("/orders")
public class OrderRestAdapter {
    private final CreateOrderUseCase createOrderUseCase;
    
    public OrderRestAdapter(CreateOrderUseCase createOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
    }
    
    @PostMapping
    public ResponseEntity<OrderDTO> create(@RequestBody CreateOrderRequest request) {
        OrderResponse response = createOrderUseCase.execute(request);
        return ResponseEntity.ok(convertToDTO(response));
    }
}

Исходящие адаптеры:

// JPA адаптер: реализация OrderRepository через Spring Data JPA
@Repository
public class OrderJpaAdapter implements OrderRepository {
    private final OrderJpaRepository jpaRepository;
    
    public OrderJpaAdapter(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }
    
    @Override
    public void save(Order order) {
        OrderEntity entity = convertToEntity(order);
        jpaRepository.save(entity);
    }
    
    @Override
    public Optional<Order> findById(Long id) {
        return jpaRepository.findById(id).map(this::convertToDomain);
    }
}

// Kafka адаптер: реализация хранилища событий через Kafka
@Service
public class EventPublisherKafkaAdapter implements EventPublisher {
    private final KafkaTemplate<String, String> kafkaTemplate;
    
    @Override
    public void publish(DomainEvent event) {
        kafkaTemplate.send("events", event.toJson());
    }
}

Структура hexagonal архитектуры для Java/Spring сервиса

order-service/
├── domain/
│   ├── model/
│   │   ├── Order
│   │   ├── OrderLine
│   │   └── OrderStatus
│   ├── ports/
│   │   ├── inbound/
│   │   │   └── CreateOrderUseCase (интерфейс)
│   │   │   └── FindOrderUseCase (интерфейс)
│   │   └── outbound/
│   │       ├── OrderRepository (интерфейс)
│   │       ├── PaymentService (интерфейс)
│   │       └── NotificationService (интерфейс)
│   └── service/
│       ├── CreateOrderService (реализует CreateOrderUseCase)
│       └── FindOrderService (реализует FindOrderUseCase)
├── adapters/
│   ├── inbound/
│   │   ├── OrderRestAdapter (@RestController)
│   │   └── OrderGrpcAdapter (gRPC handler)
│   └── outbound/
│       ├── OrderJpaAdapter (@Repository, реализует OrderRepository)
│       ├── PaymentServiceHttpAdapter (HTTP client, реализует PaymentService)
│       └── NotificationServiceKafkaAdapter (Kafka publisher, реализует NotificationService)
└── config/
    └── HexagonalConfig (@Configuration, wire'ит все dependency-и)

Пример кода

// Domain model (не зависит от фреймворков)
public class Order {
    private Long id;
    private User user;
    private List<OrderLine> lines;
    private OrderStatus status;
    
    public Order(User user, List<OrderLine> lines) {
        this.user = user;
        this.lines = lines;
        this.status = OrderStatus.PENDING;
    }
    
    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("Can only confirm pending orders");
        }
        status = OrderStatus.CONFIRMED;
    }
}

// Входящий порт
public interface CreateOrderUseCase {
    CreateOrderResponse execute(CreateOrderRequest request);
}

// Реализация входящего порта (service layer)
@Service
public class CreateOrderService implements CreateOrderUseCase {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    
    public CreateOrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
    
    @Override
    @Transactional
    public CreateOrderResponse execute(CreateOrderRequest request) {
        User user = /* получить пользователя */;
        Order order = new Order(user, request.getLines());
        
        PaymentResult result = paymentService.processPayment(order.calculateTotal());
        if (!result.isSuccess()) {
            throw new PaymentFailedException();
        }
        
        order.confirm();
        orderRepository.save(order);
        
        return new CreateOrderResponse(order.getId(), "Order created successfully");
    }
}

// Исходящий порт
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(Long id);
}

// Реализация исходящего порта (адаптер)
@Repository
public class OrderJpaAdapter implements OrderRepository {
    private final OrderJpaRepository jpaRepository;
    
    public OrderJpaAdapter(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }
    
    @Override
    public void save(Order order) {
        OrderEntity entity = new OrderEntity();
        entity.setId(order.getId());
        // Map domain model to entity
        jpaRepository.save(entity);
    }
    
    @Override
    public Optional<Order> findById(Long id) {
        return jpaRepository.findById(id).map(entity -> {
            // Map entity to domain model
            Order order = new Order(/*...*/);
            return order;
        });
    }
}

// Входящий адаптер (REST)
@RestController
@RequestMapping("/orders")
public class OrderRestAdapter {
    private final CreateOrderUseCase createOrderUseCase;
    
    public OrderRestAdapter(CreateOrderUseCase createOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
    }
    
    @PostMapping
    public ResponseEntity<CreateOrderResponse> create(@RequestBody CreateOrderRequest request) {
        CreateOrderResponse response = createOrderUseCase.execute(request);
        return ResponseEntity.ok(response);
    }
}

Отличие от классической layered архитектуры

Слоистая (Layered) Hexagonal (Ports & Adapters)
Слои расположены горизонтально: Presentation → Service → Domain → Infrastructure Hexagon: Domain в центре, адаптеры по краям. Зависимости направлены к центру
Infrastructure layer знает о domain layer Domain layer не зависит от infrastructure (только от портов)
Service layer может напрямую вызывать repository Service реализует порт, repository реализует порт, они встречаются через DI
Проще для небольших систем Удобнее для тестирования и эволюции системы
Может привести к "толстому" service layer Разделение входящих и исходящих портов даёт ясность

Плюсы и минусы hexagonal архитектуры

Плюсы

  • Domain layer полностью не зависит от фреймворков: можно тестировать бизнес-логику без Spring контекста.
  • Лёгкая смена реализаций: если захотеть заменить JPA на MongoDB, меняешь только адаптер, domain и service-ы не трогаешь.
  • Чёткая граница между core и внешним миром: явные входящие и исходящие порты делают архитектуру прозрачной.
  • Удобна для микросервисов: каждый микросервис может быть структурирован по hexagonal, что улучшает тестируемость и поддерживаемость.

Минусы

  • Больше кода на старте: нужно писать интерфейсы портов, реализации адаптеров, mapping-и между domain и DTO.
  • Может быть overengineering для простых систем: если сервис — это просто CRUD с одной таблицей, hexagonal будет выглядеть как heavy machinery.
  • Сложность отладки: нужно понимать flow между адаптерами, портами и service-ами.
  • Требует дисциплины: легко нарушить архитектуру и "подмешать" infrastructure dependencies в domain.

Clean Architecture и Onion Architecture

Clean Architecture (Robert Martin)

Определение: архитектурный стиль, где система состоит из концентрических колец. В центре — доменные сущности (Entities), вокруг них — бизнес-правила (Use Cases), затем — interface adapters и фреймворки (Frameworks & Drivers). Зависимости всегда направлены в сторону центра, никогда наружу.

Кольца Clean Architecture

Кольцо 1: Entities (Доменные сущности)

Это сущности, содержащие бизнес-правила, которые используются многими use case-ами. Это самая стабильная часть кода, почти не меняется.

// Доменная сущность: правила применимы для многих операций
public class Order {
    private Long id;
    private List<OrderLine> lines;
    private BigDecimal totalAmount;
    
    public boolean canBeCancelled() {
        // Бизнес-правило: заказ можно отменить, только если он не доставлен
        return status != OrderStatus.DELIVERED;
    }
    
    public void addLine(OrderLine line) {
        if (line.getQuantity() <= 0) {
            throw new InvalidQuantityException();
        }
        lines.add(line);
        recalculateTotal();
    }
}

Кольцо 2: Use Cases (Бизнес-логика приложения)

Это правила, специфичные для particular use case-а. Сюда входит orchestration между несколькими entities, координация с внешними сервисами.

// Use Case: создание заказа
public class CreateOrderUseCase {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final UserFinder userFinder;
    
    public CreateOrderResponse execute(CreateOrderRequest request) {
        User user = userFinder.findById(request.getUserId());
        Order order = new Order(user, request.getLines());
        
        PaymentResult paymentResult = paymentService.charge(user, order.getTotalAmount());
        if (!paymentResult.isSuccess()) {
            throw new PaymentFailedException();
        }
        
        orderRepository.save(order);
        return CreateOrderResponse.success(order.getId());
    }
}

Кольцо 3: Interface Adapters

Это переводчики между use case-ами и внешним миром. Сюда входят REST контроллеры, Kafka listener-ы, repository реализации, HTTP client-ы для интеграции с внешними API.

// REST адаптер
@RestController
@RequestMapping("/orders")
public class CreateOrderController {
    private final CreateOrderUseCase createOrderUseCase;
    private final OrderPresenter presenter;
    
    @PostMapping
    public ResponseEntity<OrderDTO> create(@RequestBody CreateOrderRequest request) {
        CreateOrderResponse response = createOrderUseCase.execute(request);
        OrderDTO dto = presenter.present(response);
        return ResponseEntity.ok(dto);
    }
}

// Repository адаптер
@Repository
public class OrderRepositoryAdapter implements OrderRepository {
    private final OrderJpaRepository jpaRepository;
    
    @Override
    public void save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        jpaRepository.save(entity);
    }
}

Кольцо 4: Frameworks & Drivers

Это внешние библиотеки и фреймворки: Spring, Hibernate, Kafka client, HTTP library. Сюда также входит конфигурация приложения.

// Spring configuration
@Configuration
public class OrderConfig {
    @Bean
    public CreateOrderUseCase createOrderUseCase(
            OrderRepository orderRepository,
            PaymentService paymentService,
            UserFinder userFinder) {
        return new CreateOrderUseCase(orderRepository, paymentService, userFinder);
    }
    
    @Bean
    public OrderRepository orderRepository(OrderJpaRepository jpaRepository) {
        return new OrderRepositoryAdapter(jpaRepository);
    }
}

// Main application setup
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

Правило зависимостей Clean Architecture

Зависимости всегда направлены в сторону центра, никогда наружу. Это означает:

  • Entities не знают о Use Cases.
  • Use Cases не знают об Interface Adapters (контроллеры, repositories).
  • Interface Adapters могут знать о Use Cases и Entities.
  • Frameworks & Drivers могут знать обо всём, но это нежелательно.
Frameworks & Drivers (Spring, Kafka, HTTP)
        ↓
Interface Adapters (Controllers, Repositories)
        ↓
Use Cases (Business Logic)
        ↓
Entities (Business Rules)

Правильная зависимость:

public class CreateOrderUseCase {
    // Use Case зависит от Entity (правильно)
    public void createOrder(Order order) { }
    
    // Use Case может зависеть от Interface Adapter (через интерфейс, правильно)
    public CreateOrderUseCase(OrderRepository repository) { }
}

Неправильная зависимость:

// ❌ Entity зависит от Use Case (неправильно)
public class Order {
    private CreateOrderUseCase useCase;
}

// ❌ Entity зависит от Controller (неправильно)
public class Order {
    @Autowired
    private OrderController controller;
}

Onion Architecture (Jeffrey Palermo)

Определение: архитектурный стиль, очень похожий на Clean Architecture, но с немного другой терминологией. Система также состоит из слоёв, зависимости направлены к центру.

Слои Onion Architecture

UI Layer (Controllers, Views, REST endpoints)
        ↓
Application Services Layer
        ↓
Domain Model Layer (Entities, Value Objects, Domain Services)
        ↓
Infrastructure Layer (Database, External Services)

Отличие от Clean Architecture: Onion часто говорит про "Domain Model Layer" вместо "Entities" + "Use Cases", но суть та же — доменная логика в центре, зависимости к центру.

Связь Clean/Hexagonal/Onion

Все три архитектуры преследуют одну цель: разделить доменную логику от инфраструктурных деталей и деталей UI.

Аспект Clean Hexagonal Onion
Центр Entities + Use Cases Domain + Ports Domain Model
Переходная зона Interface Adapters Adapters (inbound/outbound) Application Services
Внешняя зона Frameworks & Drivers Технологические адаптеры Infrastructure
Направление зависимостей К центру К центру К центру
Ориентация На бизнес-правила На порты/адаптеры На доменную модель

Для интервью: упомяни, что все три подхода решают одну проблему — слабую связанность domain layer от фреймворков и UI, что делает код тестируемым и гибким.

Как объяснить Clean Architecture на интервью

Кандидат может сказать:

Я предлагаю Clean Architecture. В центре — доменные сущности, содержащие основные бизнес-правила. Например, Order содержит методы для валидации заказа, расчёта суммы, определения, можно ли его отменить.

Второй слой — Use Cases, это бизнес-операции, специфичные для нашей системы. CreateOrder — это use case, он берёт пользователя, создаёт заказ, координирует платёж и сохраняет результат.

Третий слой — Interface Adapters, это REST контроллеры, repository реализации, которые преобразуют между use case-ами и техническими деталями.

И наконец, Frameworks & Drivers — это Spring, Hibernate, Kafka, которые мы используем как инструменты.

Зависимости направлены только в сторону центра. Domain layer не знает о Spring, о БД, о HTTP — это даёт нам независимость от технологий и делает код легко тестируемым. Например, я могу протестировать Order и CreateOrderUseCase вообще без Spring контекста.


Event-driven архитектура

Определение: архитектурный стиль, где компоненты взаимодействуют друг с другом асинхронно через события. Вместо прямых вызовов между сервисами (synchronous RPC), сервисы публикуют события в некий брокер (Kafka, RabbitMQ, AWS SNS), и другие сервисы подписываются на эти события.

Типы взаимодействия в event-driven системах

1. Point-to-Point (Очереди)

Модель: один издатель событий отправляет сообщение в очередь, один подписчик получает и обрабатывает это сообщение. Сообщение удаляется после обработки.

Когда использовать: когда у события ровно один потребитель, и события нужно обрабатывать в порядке FIFO.

Order Service -> Kafka Queue -> Payment Service
                 (One message)   (Processes and removes)

Технология: RabbitMQ Queue, AWS SQS, Kafka Topic с одной partition для гарантирования порядка (в контексте point-to-point).

// Издатель
@Service
public class OrderService {
    @Autowired
    private KafkaTemplate<String, PaymentRequest> kafkaTemplate;
    
    public void createOrder(Order order) {
        // ...
        PaymentRequest paymentRequest = new PaymentRequest(order.getId(), order.getTotalAmount());
        kafkaTemplate.send("payment-queue", paymentRequest);
    }
}

// Подписчик
@Service
public class PaymentService {
    @KafkaListener(topics = "payment-queue", groupId = "payment-group")
    public void processPayment(PaymentRequest request) {
        // Обрабатываем платёж
        processPaymentWithProvider(request);
    }
}

2. Pub/Sub (Топики, Broadcast)

Модель: один издатель отправляет событие в топик, множество подписчиков получают копию этого события независимо друг от друга.

Когда использовать: когда событие интересует несколько сервисов (например, заказ создан → нужно отправить email, обновить статистику, отправить push-уведомление).

Order Service publishes "OrderCreated" event
        ↓
Topic: order-events
        ↙          ↓          ↘
    Email Service  Analytics Service  Notification Service
    (Independent processing)

Технология: Kafka Topic с несколькими consumer group-ами, RabbitMQ Topic exchange, AWS SNS.

// Издатель (Order Service)
@Service
public class OrderService {
    @Autowired
    private KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
    
    public void createOrder(Order order) {
        // ...
        OrderCreatedEvent event = new OrderCreatedEvent(order.getId(), order.getUserId());
        kafkaTemplate.send("order-events", event);
    }
}

// Подписчик 1 (Email Service)
@Service
public class EmailService {
    @KafkaListener(topics = "order-events", groupId = "email-group")
    public void sendConfirmationEmail(OrderCreatedEvent event) {
        // Отправляем email
    }
}

// Подписчик 2 (Analytics Service)
@Service
public class AnalyticsService {
    @KafkaListener(topics = "order-events", groupId = "analytics-group")
    public void trackOrderCreation(OrderCreatedEvent event) {
        // Записываем в аналитику
    }
}

Domain Events vs Integration Events

Domain Events

Определение: события, которые возникают внутри bounded context-а и отражают изменения в доменной модели. Они используются для internal communication внутри одного микросервиса или context-а.

Характеристики:

  • Возникают как результат изменения доменной сущности (Order.confirm() → OrderConfirmed event).
  • Обычно публикуются вместе с сохранением сущности (transactional outbox pattern).
  • Интересуют другие компоненты в пределах одного bounded context.
// Доменное событие
public class OrderConfirmedEvent extends DomainEvent {
    private Long orderId;
    private LocalDateTime confirmedAt;
    
    public OrderConfirmedEvent(Long orderId) {
        this.orderId = orderId;
        this.confirmedAt = LocalDateTime.now();
    }
}

// Возникает в domain model
public class Order {
    private List<DomainEvent> domainEvents = new ArrayList<>();
    
    public void confirm() {
        this.status = OrderStatus.CONFIRMED;
        domainEvents.add(new OrderConfirmedEvent(this.id)); // Событие в памяти
    }
    
    public List<DomainEvent> getDomainEvents() {
        return domainEvents;
    }
}

// Публикуется application service
@Service
public class CreateOrderService {
    @Autowired
    private DomainEventPublisher eventPublisher;
    
    @Transactional
    public void createOrder(CreateOrderRequest request) {
        Order order = new Order(...);
        orderRepository.save(order);
        
        // Публикуем domain events после сохранения
        order.getDomainEvents().forEach(eventPublisher::publish);
    }
}

Integration Events

Определение: события, используемые для связи между микросервисами или bounded context-ами. Они отражают факты, важные для других сервисов.

Характеристики:

  • Более "стабильные" (меньше меняются), так как on them зависят другие сервисы.
  • Могут включать данные для удобства подписчиков (не нужно дополнительно вызывать API издателя).
  • Публикуются в shared topic/exchange, который слушают сразу несколько consumer group-ов.
// Integration Event (может отличаться от domain event)
public class OrderCreatedIntegrationEvent {
    private Long orderId;
    private Long userId;
    private BigDecimal totalAmount;
    private List<OrderItemDTO> items;
    private LocalDateTime createdAt;
    
    public OrderCreatedIntegrationEvent(Order order) {
        this.orderId = order.getId();
        this.userId = order.getUser().getId();
        this.totalAmount = order.calculateTotal();
        this.items = order.getItems().stream()
            .map(OrderItemDTO::new)
            .collect(toList());
        this.createdAt = LocalDateTime.now();
    }
}

// Публикуется в shared topic
@Service
public class OrderPublisher {
    @Autowired
    private KafkaTemplate<String, OrderCreatedIntegrationEvent> kafkaTemplate;
    
    public void publishOrderCreated(Order order) {
        OrderCreatedIntegrationEvent event = new OrderCreatedIntegrationEvent(order);
        kafkaTemplate.send("orders-topic", order.getId().toString(), event);
    }
}

Преимущества Event-driven архитектуры

  • Слабая связанность: сервисы не знают друг о друге, знают только про события. Легко добавить нового подписчика без изменения издателя.
  • Лучшая масштабируемость: если Email Service медленный, не влияет на Order Service. Можно масштабировать независимо.
  • Гибкость: если в будущем нужно добавить интеграцию с SMS-провайдером, просто добавляем нового слушателя без изменения Order Service.
  • Асинхронная обработка: тяжелые операции (отправка email, обновление аналитики) могут происходить в фоне, не блокируя создание заказа.
  • Resilience: если Email Service упадёт, Order Service продолжает работать, email пошлётся позже.

Недостатки Event-driven архитектуры

  • Сложность отладки: flow данных между сервисами не очевидна, нужны хорошие логи и трейсинг.
  • Eventual consistency: если один сервис упадёт, данные могут быть несогласованы; нужно разбираться с retries и компенсирующими транзакциями.
  • Сложное тестирование: мало кто использует event-driven локально, нужны отдельные тесты для event handlers.
  • Задержка обработки: между публикацией события и его обработкой может быть задержка, система не сразу консистентна.
  • Потеря событий: если сообщение в Kafka теряется, может потребоваться replay'ить события или восстанавливаться вручную.
  • Порядок событий: в распределённой системе порядок может быть не гарантирован; нужны меры для обеспечения idempotency handlers.

Как проговаривать event-driven на System Design собеседовании

Кандидат может сказать:

В нашей системе будет много асинхронного взаимодействия. Когда создаётся заказ, Order Service публикует событие OrderCreated в Kafka. Это событие слушают несколько сервисов:

  • Payment Service получает событие и обрабатывает платёж асинхронно.
  • Email Service отправляет подтверждение пользователю.
  • Analytics Service обновляет метрики.

Все эти сервисы независимы; если Email Service медленный, это не влияет на создание заказа. Если Payment Service временно недоступен, события накапливаются в Kafka и обрабатываются позже.

Для гарантирования доставки событий используем transactional outbox pattern: event сохраняется в той же транзакции, что и Order, а затем отправляется в Kafka.


CQRS (Command Query Responsibility Segregation)

Определение: архитектурный паттерн, который разделяет операции на две категории: commands (операции, изменяющие состояние: create, update, delete) и queries (операции, только читающие данные). Команды и запросы могут использовать разные модели данных и разные хранилища.

Почему классический CRUD иногда не подходит

Проблема 1: Оптимизация для чтения и записи

В типичной системе число чтений обычно в 10–100 раз больше, чем число записей. Единая модель данных часто хорошо оптимизирована для записей (нормализованная БД), но плохо для чтений.

// Классический CRUD: одна модель для чтения и записи
@Entity
public class Order {
    @Id
    private Long id;
    @ManyToOne
    private User user;
    @OneToMany
    private List<OrderLine> lines;
    @Enumerated
    private OrderStatus status;
    // ... ещё поля
}

// Для отображения списка заказов с деталями нужны дорогостоящие JOIN-ы
// SELECT o.*, u.*, ol.* FROM orders o 
// JOIN users u ON o.user_id = u.id 
// JOIN order_lines ol ON o.id = ol.order_id
// WHERE o.user_id = ?

Проблема 2: Сложные запросы требуют денормализации

Если нужно отобразить "все заказы пользователя с общей суммой по категориям товаров", нормализованная модель требует множество JOIN-ов и вычислений, что может быть медленно.

// Запрос для отчёта (сложный)
SELECT 
    category,
    COUNT(*) as item_count,
    SUM(price * quantity) as total_amount
FROM orders o
JOIN order_lines ol ON o.id = ol.order_id
JOIN products p ON ol.product_id = p.id
WHERE o.user_id = ?
GROUP BY category

CQRS решение: разные модели для записи и чтения

Command Model (Write Model)

Оптимизирован для записи, обычно нормализован, следует ACID принципам.

// Command Model (Write)
public class Order {
    private Long id;
    private Long userId;
    private List<OrderLineCmd> lines;
    private OrderStatus status;
    
    public void addLine(OrderLineCmd line) {
        // Валидация и логика добавления
        if (line.getQuantity() <= 0) throw new InvalidQuantityException();
        this.lines.add(line);
    }
}

// Command Service обрабатывает операции записи
@Service
public class CreateOrderCommandService {
    public void execute(CreateOrderCommand cmd) {
        Order order = new Order(cmd.getUserId());
        cmd.getLines().forEach(order::addLine);
        orderRepository.save(order); // Сохраняем в основное хранилище
        
        // Публикуем событие для обновления read model
        eventPublisher.publish(new OrderCreatedEvent(order.getId(), order.getUserId()));
    }
}

Query Model (Read Model)

Оптимизирован для чтения, часто денормализован, содержит только нужные поля, может быть в Elasticsearch, отдельной БД или даже in-memory кеше.

// Query Model (Read)
public class OrderReadModel {
    private Long id;
    private String userName;
    private BigDecimal totalAmount;
    private int itemCount;
    private LocalDateTime createdAt;
    // ... только то, что нужно для UI
}

// Query Service только читает
@Service
public class OrderQueryService {
    private final OrderReadRepository readRepository;
    
    public List<OrderReadModel> getUserOrders(Long userId) {
        // Простой, быстрый запрос
        return readRepository.findByUserId(userId);
    }
    
    public OrderReadModel getOrderDetails(Long orderId) {
        return readRepository.findById(orderId);
    }
}

// Repository читает из денормализованной read model БД
@Repository
public interface OrderReadRepository extends JpaRepository<OrderReadModel, Long> {
    List<OrderReadModel> findByUserId(Long userId);
}

Синхронизация между Write и Read моделью

Когда command выполняется, нужно обновить read model:

User executes command
        ↓
Command Handler processes and saves to Write DB
        ↓
Domain Event is published
        ↓
Event Handler updates Read Model (Elasticsearch, Redis, separate DB)
        ↓
Query service reads from optimized Read Model

Реализация:

// Event handler обновляет read model
@Service
public class OrderEventHandler {
    @Autowired
    private OrderReadRepository readRepository;
    
    @EventListener(OrderCreatedEvent.class)
    public void onOrderCreated(OrderCreatedEvent event) {
        // Получаем полную информацию из write model
        Order order = orderRepository.findById(event.getOrderId());
        User user = userRepository.findById(order.getUserId());
        
        // Создаём denormalized read model
        OrderReadModel readModel = new OrderReadModel();
        readModel.setId(order.getId());
        readModel.setUserName(user.getName());
        readModel.setTotalAmount(order.calculateTotal());
        readModel.setItemCount(order.getLines().size());
        
        // Сохраняем в read store (Elasticsearch, Redis и т.д.)
        readRepository.save(readModel);
    }
    
    @EventListener(OrderCancelledEvent.class)
    public void onOrderCancelled(OrderCancelledEvent event) {
        readRepository.deleteById(event.getOrderId());
    }
}

Преимущества CQRS

  • Оптимизация чтения и записи отдельно: write model может быть нормализован для консистентности, read model — денормализован для скорости.
  • Разные хранилища под разные задачи: write может быть PostgreSQL (ACID), read может быть Elasticsearch (fast search) или Redis (быстрое кеширование).
  • Лучшая производительность на высоких нагрузках: read и write масштабируются независимо, read часто кешируется в памяти.
  • Сложные запросы становятся проще: вместо JPA JOIN-ов, в read model уже есть готовые данные.

Недостатки CQRS

  • Рост сложности: нужно синхронизировать две модели, обрабатывать ошибки синхронизации, восстанавливаться при сбоях.
  • Eventual consistency: между write и read может быть задержка, пользователь не сразу видит изменения.
  • Больше кода: нужна отдельная логика для read model, event handlers, synchronization.
  • Сложность отладки: если read model не синхронизирован с write, трудно отследить, где ошибка.
  • Overhead для простых систем: если система небольшая с несложными запросами, CQRS добавляет больше сложности, чем пользы.

Типичные кейсы, где CQRS оправдан

  • Высокие нагрузки по чтению: социальные сети, где одна запись (пост) читается миллионами.
  • Сложные запросы и отчёты: финансовые системы, аналитика, где need diverse views одних данных.
  • Разные пользовательские роли: админ видит полные данные, обычный пользователь видит ограниченные.
  • Интеграция с search engine-ами: Elasticsearch может быть read model для полнотекстового поиска.
  • Real-time dashboard: read model постоянно обновляется, dashboard просто читает из кеша.

Как описать CQRS на System Design собеседовании

Кандидат может сказать:

Для этой системы я предлагаю использовать CQRS. Write model будет в PostgreSQL, нормализован для консистентности. Когда пользователь создаёт заказ, Order Service сохраняет его в write model и публикует OrderCreated событие.

Отдельно поддерживаем Read Model, которая денормализована и оптимизирована для быстрого чтения. Когда приходит OrderCreated событие, Event Handler обновляет Read Model, добавляя с кешированием в Redis. Когда пользователь смотрит список своих заказов, мы просто читаем из Redis, без дорогостоящих JOIN-ов.

Это даёт нам независимую масштабируемость: write может обрабатывать много команд, read может быть оптимизирована для быстрого чтения. Между write и read может быть небольшая задержка (eventual consistency), но это нормально для этого юз-кейса.


Event Sourcing

Определение: архитектурный паттерн, где не хранится текущее состояние сущности, а вместо этого хранятся все события, которые произошли с этой сущностью. Состояние восстанавливается путём replaying всех событий в порядке.

Как выглядит модель данных при Event Sourcing

Традиционный подход (State Storage)

Orders Table
┌─────┬──────────────┬─────────┐
│ id  │ user_id      │ status  │
├─────┼──────────────┼─────────┤
│ 1   │ 100          │ SHIPPED │
└─────┴──────────────┴─────────┘

Проблема: мы не знаем, как заказ пришёл в статус SHIPPED. Была ли переплата? Был ли возврат? История потеряна.

Event Sourcing подход (Event Stream)

Order Events Table / Event Store
┌─────┬──────────────┬─────────────────────┬────────────┬─────────┐
│ seq │ aggregate_id │ event_type          │ event_data │ timestamp
├─────┼──────────────┼─────────────────────┼────────────┼─────────┤
│ 1   │ 1            │ OrderCreated        │ {...}      │ 10:00
│ 2   │ 1            │ PaymentProcessed    │ {...}      │ 10:01
│ 3   │ 1            │ OrderConfirmed      │ {...}      │ 10:02
│ 4   │ 1            │ OrderShipped        │ {...}      │ 10:05
└─────┴──────────────┴─────────────────────┴────────────┴─────────┘

Полная история. Мы можем восстановить состояние на любой момент времени.

Как восстанавливается состояние: Replaying и Projections

Replaying Events

// Event sourcing: восстанавливаем состояние путём replaying событий
public class Order {
    private Long id;
    private Long userId;
    private List<OrderLine> lines;
    private OrderStatus status;
    private BigDecimal totalAmount;
    
    // Пустой конструктор для восстановления из событий
    public Order() {}
    
    // Apply метод для каждого события
    public void apply(OrderCreatedEvent event) {
        this.id = event.getOrderId();
        this.userId = event.getUserId();
        this.lines = new ArrayList<>();
        this.status = OrderStatus.PENDING;
        this.totalAmount = BigDecimal.ZERO;
    }
    
    public void apply(OrderLineAddedEvent event) {
        OrderLine line = new OrderLine(event.getProductId(), event.getQuantity(), event.getPrice());
        this.lines.add(line);
        this.totalAmount = this.totalAmount.add(
            event.getPrice().multiply(BigDecimal.valueOf(event.getQuantity()))
        );
    }
    
    public void apply(PaymentProcessedEvent event) {
        if (event.isSuccess()) {
            this.status = OrderStatus.CONFIRMED;
        } else {
            this.status = OrderStatus.PAYMENT_FAILED;
        }
    }
    
    public void apply(OrderShippedEvent event) {
        this.status = OrderStatus.SHIPPED;
    }
}

// Восстановление заказа из события
public class OrderAggregateRepository {
    public Order getById(Long orderId) {
        List<DomainEvent> events = eventStore.getEvents(orderId);
        
        Order order = new Order();
        for (DomainEvent event : events) {
            order.apply(event); // Replaying
        }
        return order;
    }
}

Projections (Проекции)

Replaying каждый раз дорого. Используются projections — денормализованные представления, которые обновляются по мере поступления событий.

// Projection: денормализованная читаемая модель
@Entity
public class OrderProjection {
    @Id
    private Long id;
    private Long userId;
    private String userName;
    private BigDecimal totalAmount;
    private OrderStatus status;
    private LocalDateTime lastUpdated;
}

// Event Handler обновляет projection
@Service
public class OrderProjectionUpdater {
    @EventListener(OrderCreatedEvent.class)
    public void onOrderCreated(OrderCreatedEvent event) {
        OrderProjection projection = new OrderProjection();
        projection.setId(event.getOrderId());
        projection.setUserId(event.getUserId());
        projection.setStatus(OrderStatus.PENDING);
        projection.setLastUpdated(LocalDateTime.now());
        projectionRepository.save(projection);
    }
    
    @EventListener(OrderShippedEvent.class)
    public void onOrderShipped(OrderShippedEvent event) {
        OrderProjection projection = projectionRepository.findById(event.getOrderId());
        projection.setStatus(OrderStatus.SHIPPED);
        projection.setLastUpdated(LocalDateTime.now());
        projectionRepository.save(projection);
    }
}

// Запрос к projection (быстро)
public class OrderQueryService {
    public OrderProjection getOrder(Long id) {
        return projectionRepository.findById(id);
    }
}

Преимущества Event Sourcing

  • Полная история изменений: каждое событие сохранено, можем аудировать, кто что когда сделал.
  • Возможность переиграть события: если хотим изменить бизнес-логику обработки события, можем переиграть исторические события с новой логикой.
  • Временные запросы: можем запросить состояние сущности на любой момент времени (например, какой был статус заказа неделю назад).
  • Отладка: если система дала неправильный результат, можем проследить все события, которые привели к этому состоянию.
  • Синергия с CQRS: event sourcing естественно дополняет CQRS, события используются для обновления read model.

Недостатки Event Sourcing

  • Сложность реализации: нужна работающая event store, careful event design, преодоление race conditions при параллельных записях.
  • Требования к дисциплине моделирования: события должны быть immutable, неизменяемы по смыслу (если решили переименовать OrderCreated в CreateOrder, это breaking change для всех старых событий).
  • Сложные миграции: если нужно изменить структуру события (добавить поле), нужно мигрировать все исторические события.
  • Состояние растёт: с течением времени событий становится всё больше, replaying может замедлиться (решается через snapshots).
  • Операционная сложность: нужна надёжная event store, backup-и, мониторинг целостности события.

Snapshots для оптимизации

Если событий миллионы, replaying может быть медленным. Используются snapshots — "контрольные точки" состояния.

// Snapshot: сохранённое состояние на определённый момент
public class OrderSnapshot {
    private Long aggregateId;
    private Integer version; // Версия события, к которой относится snapshot
    private Order aggregateState; // Сохранённое состояние
}

// Восстановление с snapshots
public class OrderAggregateRepository {
    public Order getById(Long orderId) {
        // 1. Получаем последний snapshot
        Optional<OrderSnapshot> snapshot = snapshotStore.getLatest(orderId);
        
        Order order;
        int fromVersion = 0;
        if (snapshot.isPresent()) {
            order = snapshot.get().getAggregateState();
            fromVersion = snapshot.get().getVersion();
        } else {
            order = new Order();
        }
        
        // 2. Replaying только событий после snapshot
        List<DomainEvent> recentEvents = eventStore.getEvents(orderId, fromVersion);
        for (DomainEvent event : recentEvents) {
            order.apply(event);
        }
        
        return order;
    }
}

Когда Event Sourcing оправдан, а когда это оверинжиниринг

Event Sourcing оправдан:

  • Требование к полной истории: финансовые системы, где нужна полная аудиторская трасса.
  • Сложная бизнес-логика: если понять текущее состояние сложно, полная история помогает разобраться.
  • Интеграция с внешними системами: если нужно синхронизировать с несколькими внешними системами, история событий помогает восстанавливаться после сбоев.
  • Масштабные системы: event sourcing хорошо работает с CQRS для высоконагруженных приложений.

Оверинжиниринг (когда не использовать Event Sourcing):

  • Простые CRUD системы: если просто читаем и пишем в БД, event sourcing добавляет ненужную сложность.
  • Короткая история: если события хранятся недолго и удаляются (например, через неделю), смысла в полной истории нет.
  • Real-time требования: если нужна очень низкая latency, replaying событий может быть медленнее, чем просто чтение состояния.

Комбинирование архитектурных стилей и паттернов

На практике архитектурные стили и паттерны редко используются в чистом виде. Обычно комбинируются несколько для решения разных проблем. Интервьюер ценит кандидата, который понимает, как и когда комбинировать.

Модульный монолит + Event-driven внутри одного приложения

Сценарий: система выросла в модульный монолит (100 тыс. строк), но отдельные модули по-прежнему могут независимо развиваться.

Проблема: синхронное взаимодействие между модулями (order-module вызывает payment-module) приводит к слабой развязке и сложности тестирования.

Решение: используем async event-driven communication внутри монолита.

// Order Module: публикует событие
public class OrderService {
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    public void createOrder(CreateOrderRequest request) {
        Order order = new Order(...);
        orderRepository.save(order);
        
        // Публикуем event вместо прямого вызова
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
    }
}

// Payment Module: слушает событие
@Component
public class PaymentEventListener {
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // Обрабатываем платёж асинхронно
        processPayment(event.getOrderId());
    }
}

// Преимущества:
// - order-module не знает о payment-module (слабая связанность)
// - легко тестировать payment отдельно
// - если payment долгая операция, можно использовать @Async

Микросервисы + Event-driven + CQRS (типичный high-load сценарий)

Сценарий: high-load система с несколькими микросервисами, нужна высокая масштабируемость и читки, и записи.

Архитектура:

┌──────────────────────┐
│   API Gateway        │
└─────────┬────────────┘
          │
    ┌─────┴─────────────────────────────────────┐
    │                                           │
┌───▼──────────┐                    ┌──────────▼──────┐
│ Order        │                    │ Product         │
│ Microservice │                    │ Microservice    │
├──────────────┤                    ├─────────────────┤
│ Write Model  │                    │ Write Model     │
│ (PostgreSQL) │                    │ (PostgreSQL)    │
│              │                    │                 │
│ Read Model   │                    │ Read Model      │
│ (Elasticsearch)                   │ (Redis Cache)   │
└───┬──────────┘                    └────┬────────────┘
    │ Events                             │ Events
    └──────────────┬──────────────────────┘
                   │
            ┌──────▼──────┐
            │ Kafka Topic │
            │  (events)   │
            └─────────────┘

Детально:

  • Каждый микросервис имеет CQRS: order-service имеет write model в PostgreSQL и read model в Elasticsearch.
  • Event-driven связь между сервисами: когда Order Service создаёт заказ, он публикует OrderCreated событие в Kafka.
  • Read model обновляется асинхронно: Event Handler слушает события из других сервисов и обновляет свой read model.
// Order Service: Write command
@Service
public class CreateOrderCommandService {
    public void execute(CreateOrderCommand cmd) {
        Order order = new Order(cmd.getUserId(), cmd.getItems());
        orderRepository.save(order); // Write model
        eventPublisher.publish(new OrderCreatedEvent(order.getId(), order.getUserId()));
    }
}

// Order Service: Read query
@Service
public class OrderQueryService {
    @Autowired
    private OrderReadRepository elasticsearchRepository;
    
    public List<OrderDTO> getUserOrders(Long userId) {
        // Читаем из Elasticsearch, быстро
        return elasticsearchRepository.findByUserId(userId);
    }
}

// Payment Service: слушает события от Order Service
@Service
public class PaymentEventListener {
    @KafkaListener(topics = "order-events", groupId = "payment-group")
    public void onOrderCreated(OrderCreatedEvent event) {
        processPayment(event.getOrderId());
        // Публикует свои события
        eventPublisher.publish(new PaymentProcessedEvent(event.getOrderId()));
    }
}

Преимущества этого комбо:

  • Write и read масштабируются независимо.
  • Микросервисы слабо связаны через events.
  • Read model оптимизирована для быстрого поиска.
  • При падении одного микросервиса остальные продолжают работать.

Недостатки:

  • Высокая сложность инфраструктуры (Kubernetes, Kafka, Elasticsearch, мониторинг).
  • Eventual consistency требует тщательной обработки ошибок.
  • Нужна expertise в распределённых системах.

Hexagonal/Clean Architecture внутри каждого микросервиса

Сценарий: микросервисная архитектура, но каждый сервис нужно сделать гибким и тестируемым.

Решение: внутри каждого микросервиса используем hexagonal или clean architecture.

Order Microservice
├── domain/
│   ├── model/
│   │   └── Order, OrderLine (не зависят от фреймворков)
│   └── ports/
│       ├── OrderRepository (интерфейс)
│       └── PaymentService (интерфейс)
├── application/
│   ├── CreateOrderService (реализует CreateOrderUseCase)
│   └── FindOrderService (реализует FindOrderUseCase)
├── adapters/
│   ├── inbound/
│   │   ├── OrderRestAdapter (@RestController)
│   │   └── OrderEventAdapter (Kafka listener)
│   └── outbound/
│       ├── OrderJpaRepository (@Repository)
│       ├── PaymentHttpClient (HTTP адаптер к платёжному сервису)
│       └── EventPublisherKafka (Kafka publisher)
└── config/
    └── OrderMicroserviceConfig

Преимущества:

  • Domain logic полностью независима от Spring, Kafka, и т.д.
  • Легко заменить реализацию (например, БД).
  • Хорошо тестируется.
  • Гибкость при эволюции микросервиса.

Эволюция архитектуры

Архитектура не появляется вдруг, она эволюционирует вместе с растущей системой. Понимание пути эволюции показывает интервьюеру, что кандидат думает о долгосрочной масштабируемости.

Путь "монолит → модульный монолит → микросервисы"

Этап 1: Простой монолит (Startup, MVP)

Когда: первые месяцы, нужно быстро проверить гипотезу.

Архитектура: одно приложение, одна БД, все компоненты в одном процессе.

┌─────────────────────────────┐
│     Spring Boot App         │
├─────────────────────────────┤
│  Controllers                │
│  Services (Order, User)     │
│  Repositories               │
└─────────────────────────────┘
         ↓
    PostgreSQL

Размер команды: 1–3 разработчика.

Проблемы: нет, система маленькая.

Этап 2: Модульный монолит (Растущий стартап)

Когда: 6–12 месяцев, система достигла 50–100 тыс. строк кода, выявились явные модули.

Архитектура: одно приложение, но разбито на bounded context-ы (user, order, payment, notification), каждый с явной границей.

┌─────────────────────────────────────────────┐
│       Spring Boot App (модульный)           │
├──────────────┬──────────────┬────────────────┤
│ User Module  │ Order Module │ Payment Module │
├──────────────┼──────────────┼────────────────┤
│ domain/      │ domain/      │ domain/        │
│ application/ │ application/ │ application/   │
│ api/         │ api/         │ api/           │
└──────────────┴──────────────┴────────────────┘
         ↓
    PostgreSQL

Размер команды: 5–10 разработчиков, каждый работает на модулем.

Проблемы из этапа 1, которые решаются:

  • Границы между модулями ясны, нет хаотичных зависимостей.
  • Изменения в одном модуле не ломают другие.
  • Командам проще разрабатывать параллельно.

Новые проблемы:

  • Модули растут, некоторые достаточно большие для отдельного сервиса.
  • Разные модули нужны в разных масштабах (product search нужна масса replicas, auth может быть 1-2).

Этап 3: Микросервисы (Масштабный сервис)

Когда: 12–24 месяца, система требует разной масштабируемости по компонентам, разные команды нужна полная автономность.

Архитектура: отдельные микросервисы с собственными БД, асинхронная связь через события.

API Gateway
    ↓
┌───────────┬───────────┬──────────────┐
│User API   │Order API  │Payment API   │
├───────────┼───────────┼──────────────┤
│PostgreSQL │PostgreSQL │PostgreSQL    │
└────┬──────┴─────┬─────┴───────┬──────┘
     │            │              │
     └────────────┴──────────────┘
            Kafka (events)

Размер команды: 20–50 разработчиков, каждой команде дан микросервис.

Преимущества этапа 3:

  • User Service масштабируется независимо от Order Service.
  • Платёжный API может быть отказоустойчивым: если платёж падает, заказы всё равно создаются (в очередь).
  • Каждая команда может использовать свой стек (Java, Go, Node).

Проблемы этапа 3:

  • Сложность инфраструктуры (Kubernetes, observability, circuit breaker).
  • Distributed transactions (saga паттерн).
  • Eventual consistency усложняет бизнес-логику.

Какие сигналы говорят, что пора выходить за рамки монолита

Сигнал 1: Разные скорости изменений по модулям

Если payment-module меняется раз в месяц, а order-module раз в день, они тормозят друг друга в монолите (разные версии выпусков, разные testing cycles).

Каждый выпуск монолита требует:

- тестирования всего кода
- координации изменений между модулями
- синхронизации версий
→ Конфликты, задержки, баги в production

Решение: вывести в отдельный микросервис.

Сигнал 2: Независимые команды

Когда у company есть Team A (users), Team B (orders), Team C (payments), они мешают друг другу в одном монолите. Каждой команде нужна своя версия кода, свои releaseCycle.

Решение: дать каждой команде свой микросервис.

Сигнал 3: Ограничения по масштабированию

Если Product Search нужны 50 instances, но ещё 49 instances других компонентов монолита будут пусты и тратить ресурсы.

Решение: выделить service в отдельный микросервис, масштабировать независимо.

Сигнал 4: Разные требования к надёжности и SLA

Если payment service требует 99.99% uptime, а обычный user service может быть 99.9%, в монолите это трудно достичь.

Решение: микросервис может иметь отдельную инфра с более высокими требованиями.

Как рассказывать про эволюцию архитектуры на интервью

На собеседовании кандидат может сказать:

Я предлагаю начать с модульного монолита. Это позволит быстро проверить идею, запустить систему за недели. В монолите выделим явные bounded context-ы: user, order, payment. Каждый модуль будет иметь свою логику, свои repository, но все в одном приложении.

По мере роста, если сигналы покажут, что отдельные модули нужна разная масштабируемость или разные команды, мы постепенно выведем их в микросервисы. Например, когда product search нужна масса машин, выведем его в отдельный Product Service. Когда payment требует 99.99% uptime, выведем в отдельный Payment Service.

К году, если система вырастет, может получиться 5–10 микросервисов. Но главное — это эволюция, а не спешка в микросервисы с самого начала. Монолит даёт простоту, микросервисы дают масштабируемость.


Как выбирать архитектурный стиль под задачу на собеседовании

На System Design собеседовании кандидату часто дают неоформленные требования и просят спроектировать систему. Выбор архитектурного стиля — первый и самый важный шаг.

Тип задач и рекомендуемые архитектурные стили

Задача 1: Простой CRUD-сервис (REST API для БД)

Требования: создание, чтение, обновление, удаление записей. Нагрузка небольшая (10K–100K RPS).

Пример: каталог товаров, управление пользователями.

Рекомендуемая архитектура: Слоистая архитектура или Hexagonal (если нужна гибкость).

User Request
    ↓
REST Controller (Presentation)
    ↓
Service Layer (Business Logic)
    ↓
Repository (Data Access)
    ↓
PostgreSQL

Почему не микросервисы: нет необходимости в независимом масштабировании, система простая.

Задача 2: Социальная сеть (Instagram-like)

Требования: много пользователей, фиды, лайки, комментарии, нужна высокая read throughput.

Пример: Instagram, Twitter.

Рекомендуемая архитектура: Микросервисы + CQRS + Event-driven.

Микросервисы:

- User Service (профили, follow)
- Post Service (создание постов)
- Feed Service (фиды)
- Like Service (лайки)

Взаимодействие:

- Event-driven: Post Service публикует PostCreated → Feed Service обновляет фиды
- CQRS: Post Service пишет в PostgreSQL, читает из Redis/Elasticsearch

Read-heavy: фиды читаются в 1000 раз чаще, чем создаются посты
→ Нужна специальная read model

Почему микросервисы: разные масштабируемости (Feed Service нужен huge cache), разные SLA.

Почему CQRS: много read-ов, нужна оптимизированная read model.

Задача 3: Система покупок (e-commerce)

Требования: заказы, платежи, inventory, доставка. Нужна транзакционность и надёжность.

Рекомендуемая архитектура: Модульный монолит (если нагрузка умеренная) или Микросервисы + Saga для транзакций.

Модульный монолит:

- User Module
- Product Module  
- Order Module (с Saga паттерном для multi-step заказа)
- Payment Module
- Inventory Module

Взаимодействие:

- Event-driven: создание заказа → проверка inventory → обработка платежа → отправка email
- Saga паттерн для управления распределённой транзакцией

Почему не просто микросервисы: нужна ACID для заказов. Saga добавляет сложность, но если нагрузка умеренная, модульный монолит проще и надёжнее.

Задача 4: Real-time система (Uber, ride-sharing)

Требования: real-time обновления (ride position), нужна низкая latency, high concurrency.

Рекомендуемая архитектура: Микросервисы + Event-driven + WebSocket.

Микросервисы:

- Matching Service (находит водителя для пассажира)
- Ride Service (управление поездкой)
- Location Service (real-time позиции)
- Payment Service

Взаимодействие:

- WebSocket: browser получает обновления позиции в real-time
- Event-driven: Location Service публикует LocationUpdated → все подписчики узнают о перемещении
- Low latency требует оптимизирования (in-memory кеши, Redis)

Почему event-driven: нужна асинхронная обработка для скорости.

Задача 5: Финансовая система (платежи, переводы)

Требования: надёжность, auditability, консистентность, никаких потерь денег.

Рекомендуемая архитектура: Модульный монолит с Event Sourcing.

Модульный монолит:

- Account Module (управление счётами)
- Transaction Module (переводы)
- Payment Module (платежи)

Event Sourcing:

- Каждая операция сохраняется как событие
- State восстанавливается replaying событий
- Полная history для аудита

Почему Event Sourcing: финансовые регуляторы требуют полной истории всех операций. Event Sourcing даёт это из коробки.

Задача 6: Аналитика и BigData (события с множеством источников)

Требования: собрать события с множества источников, обработать, сохранить для анализа.

Рекомендуемая архитектура: Event-driven + CQRS, Kafka + Data Lake.

Event Sources
    ↓
Kafka (event stream)
    ↓
Processing Layer (Stream processing: Spark, Flink)
    ↓
Data Lake (S3, HDFS) + Analytics DB (Snowflake)
    ↓
BI Dashboard

Почему event-driven: события идут с множества источников, асинхронная обработка.

Почему CQRS: нужна разделение между потоком событий (write) и аналитическими запросами (read).

Какие вопросы о требованиях помогают сделать выбор

На собеседовании, прежде чем выбирать архитектуру, кандидат должен задать вопросы:

  1. Масштаб нагрузки: 1000 RPS или 1M RPS? Это определяет, нужны ли микросервисы.
  2. Соотношение читок к писькам: 1:1 или 100:1? Если много читок, нужна CQRS.
  3. Требования к консистентности: ACID или eventual consistency? Финансовые системы требуют ACID, социальные сети могут терпеть eventual consistency.
  4. Требования к latency: микросекунды или секунды? Real-time требует других архитектур, чем batch processing.
  5. Размер команды: 1 разработчик или 100? Влияет на выбор между монолитом и микросервисами (conway's law).
  6. История данных: нужна ли полная история? Event Sourcing vs State storage.
  7. Требуемая надёжность (SLA): 99.9% или 99.99%? Влияет на выбор инфраструктуры и подхода к отказоустойчивости.

Как объяснить решение

После выбора архитектурного стиля, кандидат должен объяснить:

  1. Почему выбран именно этот стиль: "Выбираю микросервисы, потому что нужна высокая read throughput и независимое масштабирование компонентов."
  2. Какие альтернативы были рассмотрены: "Рассматривал модульный монолит, но при 1M RPS он не масштабируется достаточно, микросервисы дают нам гибкость."
  3. Какие trade-off'ы: "Микросервисы добавляют complexity в инфраструктуре, но выигрыш в масштабируемости стоит того."

Примеры кратких "шаблонных" объяснений

Когда уместен модульный монолит

Я предлагаю модульный монолит. Система начинается с нескольких чётко определённых модулей (user, order, payment), нагрузка умеренная (100K RPS). Модульный монолит даёт нам простоту развёртывания и ACID транзакции между модулями. Если позже один из модулей потребует независимого масштабирования, мы сможем выделить его в отдельный микросервис без переписания логики.

Когда сразу говорить про микросервисы

Я предлагаю микросервисы с event-driven связью. Система требует 1M RPS, разные компоненты (feed, search, payment) имеют разные требования к масштабируемости и надёжности. Event-driven подход даёт нам асинхронную обработку, лучшую отказоустойчивость и слабую связанность. Каждый сервис может развиваться независимо.


Чек-лист обсуждения архитектурного стиля на System Design

Интервьюер ожидает, что кандидат будет структурировано проходить по архитектуре. Вот чек-лист пунктов, которые обязательно упомянуть:

Обязательные пункты

1. Разбиение на компоненты/сервисы/модули

Кандидат должен явно назвать основные компоненты системы.

Example answer:
"Система состоит из пяти микросервисов: User Service (авторизация, профили), 
Product Service (каталог), Order Service (заказы), Payment Service (платежи), 
Notification Service (отправка email/SMS)."

2. Границы ответственности

Каждый компонент должен иметь ясную ответственность.

Example answer:
"User Service отвечает только за управление профилями и авторизацией. 
Product Service отвечает за каталог товаров. Order Service координирует 
создание заказов и вызывает Payment Service для обработки платежа."

3. Модель данных и где живёт "истина"

Какие данные хранятся где? Где единый источник истины?

Example answer:
"Каждый микросервис имеет собственную БД. User Service имеет таблицу Users в PostgreSQL. 
Product Service имеет таблицу Products также в PostgreSQL. Order Service имеет Orders и 
OrderLines, но также может читать кеш Products из Redis."

4. Синхронные взаимодействия

REST, gRPC вызовы между компонентами. Когда используются?

Example answer:
"Order Service синхронно вызывает Product Service при создании заказа для 
проверки наличия товара. Это безопасно, потому что эта операция должна быть атомарной."

5. Асинхронные взаимодействия

События, очереди, Kafka topics.

Example answer:
"После успешного создания заказа Order Service публикует OrderCreated событие в Kafka. 
Payment Service слушает на этот topic и обрабатывает платёж асинхронно. 
Notification Service отправляет email пользователю."

6. Fault tolerance

Как система обрабатывает сбои?

Example answer:
"Если Payment Service недоступен, заказ остаётся в статусе PENDING, а событие 
остаётся в Kafka. Retry handler повторяет попытку платежа через 5 минут, максимум 
3 раза. Если платёж неудачен после 3 попыток, заказ переходит в статус PAYMENT_FAILED."

7. Scaling

Как компоненты масштабируются?

Example answer:
"Каждый микросервис может быть масштабирован независимо. Например, Product Service 
может иметь 50 instances, в то время как User Service может работать с 5. 
Используем Kubernetes для оркестрации."

8. Observability

Логирование, метрики, трейсинг.

Example answer:
"Все логи агрегируются в ELK stack. Метрики собираются Prometheus. 
Трейсинг выполняется через Jaeger, каждый запрос имеет trace ID, 
который распространяется между всеми микросервисами."

Пример структурированного ответа: 7 шагов

Кандидат может следовать этому плану:

  1. High-level overview: "Система состоит из X микросервисов, общающихся через REST API и Kafka."

  2. Каждый микросервис в отдельности: "User Service отвечает за... Product Service отвечает за..."

  3. Модели данных: "Каждый сервис имеет свою БД: User в PostgreSQL, Products в Elasticsearch..."

  4. Синхронные вызовы: "Order Service вызывает Product Service для проверки наличия..."

  5. Асинхронные события: "После создания заказа публикуется OrderCreated событие..."

  6. Обработка ошибок: "Если платёж падает, используется saga паттерн для компенсации..."

  7. Неё-функциональные требования: "Масштабируемость через Kubernetes, логирование через ELK..."


Типичные ошибки при обсуждении архитектурных стилей на собеседовании

Даже опытные разработчики совершают ошибки при обсуждении архитектуры на интервью. Вот список типичных и как их избежать.

Ошибка 1: Автоматический выбор микросервисов без аргументов

Неправильно:

Я предлагаю микросервисы. Это современная архитектура, её все используют. User Service, Product Service, Order Service, всё separate.

Проблема: нет обоснования. Интервьюер не видит понимания trade-off'ов.

Правильно:

Система требует 1M RPS, разные компоненты нужна разная масштабируемость. Product search может потребовать 100 instances, а auth может работать с 5. Микросервисы позволяют это. Однако это добавляет complexity инфраструктуры. Альтернатива — модульный монолит, если нагрузка умеренная, но для этой системы микросервисы оправданы.

Ошибка 2: Использование модных слов без понимания

Неправильно:

Используем CQRS и Event Sourcing. И ещё SAGA паттерн для транзакций.

Проблема: нет понимания, когда это нужно и какие это добавляет сложности.

Правильно:

CQRS используем, потому что read-load в 100 раз превышает write-load. Write model остаётся в PostgreSQL (нормализованная), read model в Elasticsearch (денормализованная, оптимизирована для search). Event handlers синхронизируют между ними.

Ошибка 3: Перегрузка деталей на уровне классов вместо архитектуры

Неправильно:

У нас будет UserService с методом createUser(), getUser()... и ещё OrderService с createOrder()...

Проблема: кандидат говорит про классы вместо архитектуры система. На System Design собеседовании важна система-уровень архитектура.

Правильно:

На уровне архитектуры система разбита на 5 микросервисов. Каждый микросервис имеет REST API для других сервисов и Kafka topic для event-driven общения. Внутри каждого микросервиса используем layered или hexagonal архитектуру.

Ошибка 4: Игнорирование организационного контекста

Неправильно:

Проектирую систему в вакууме, без учёта, как её будут разрабатывать и эксплуатировать.

Проблема: архитектура должна соответствовать организации (conway's law). Если нет DevOps команды, микросервисы будут nightmare.

Правильно:

Система разбита на 3 микросервиса, потому что в company есть 3 teams, каждой дан свой сервис. У нас есть DevOps team для управления Kubernetes. Если бы была одна team, предложил бы модульный монолит.

Ошибка 5: Отсутствие осознанных trade-off'ов

Неправильно:

Микросервисы лучше, чем монолит. Конец.

Проблема: нет понимания, что всё имеет цену.

Правильно:

Микросервисы дают независимое масштабирование и гибкость для teams, но добавляют операционную сложность (Kubernetes, observability, distributed tracing). Для небольшой системы (< 10K RPS) монолит проще и надёжнее. Для высоконагруженной системы (1M+ RPS) complexity микросервисов оправдана.

Ошибка 6: Использование модных архитектур там, где они не подходят

Неправильно:

Event Sourcing везде, это же лучше всех! Все события сохраняются, можем переиграть историю.

Проблема: Event Sourcing добавляет сложность миграции и требует дисциплины. Для простой системы CRUD это оверинжиниринг.

Правильно:

Event Sourcing используем только для domain-intensive operations, где важна полная история. Например, финансовые операции (каждый перевод должен быть audit-able). Для CRUD операций достаточно обычного state storage.

Рекомендации: как звучать как senior, а не как "архитектор по мемам из интернета"

  1. Задавайте уточняющие вопросы: не кидайтесь в дизайн, спросите про требования, нагрузку, команду.

  2. Обосновывайте каждый выбор: не просто "микросервисы", а "микросервисы, потому что нужна независимая масштабируемость компонентов, потому что [конкретные требования]".

  3. Обсуждайте trade-off'ы: "Монолит дал бы нам [плюсы], но микросервисы дают [другие плюсы]. Для этого случая микросервисы лучше, потому что".

  4. Эволюция, а не революция: не "сразу микросервисы", а "начнём с модульного монолита, потом эволюционируем в микросервисы".

  5. Учитывайте организацию: "System должна соответствовать структуре teams."

  6. Не используйте паттерны просто так: CQRS, Event Sourcing, SAGA — не потому что "это modern", а потому что решают конкретные проблемы.

  7. Обсуждайте операционный контекст: не только код, но и как это развёртывается, масштабируется, мониторится.

  8. Признавайте сложность: "Это добавляет complexity, но выигрыш в [масштабируемость/надёжность/гибкость] стоит того."


Итоговые мысли для подготовки к собеседованию

На System Design собеседовании архитектурный стиль — это не абстрактная теория, а практический инструмент для решения конкретных проблем.

Главное, что нужно помнить

  1. Нет идеальной архитектуры: каждый стиль имеет плюсы и минусы. Выбор зависит от requirements.

  2. Архитектура эволюционирует: начните с простого (монолит), добавляйте complexity по мере необходимости.

  3. Обосновывайте выбор: не потому что "это modern", а потому что решает конкретные требования.

  4. Думайте о trade-off'ах: масштабируемость vs complexity, consistency vs availability, fast delivery vs maintainability.

  5. Комбинируйте паттерны: микросервисы + CQRS + Event-driven часто используются вместе.

  6. Организация важна: архитектура должна соответствовать structure организации.

  7. Говорите структурировано: используйте чек-лист (компоненты, граны, модели данных, sync/async, fault tolerance).

Опытный кандидат на интервью не просто выбирает архитектурный стиль, а рассказывает историю о том, как система эволюционирует от простого монолита к сложной распределённой системе, и почему в каждый момент времени выбран именно этот стиль.

Сетевое взаимодействие

Введение

Сеть — это один из главных ограничивающих факторов в распределённых системах. При разработке архитектуры необходимо учитывать задержки передачи данных, надёжность соединений и пропускную способность каналов. Эффективное использование сетевого слоя определяет масштабируемость, отказоустойчивость и производительность системы.


Базовые сетевые концепции

Задержка (latency)

Время, за которое данные проходят от источника к получателю, измеряется в миллисекундах.

Типичные значения:

  • Обращение в один дата-центр: 1–5 мс
  • Региональное взаимодействие (внутри страны): 10–50 мс
  • Трансконтинентальный запрос (Нью-Йорк ↔ Сингапур): 100–300 мс

Влияние на дизайн: задержка накапливается при синхронных цепочках вызовов. Запрос вида A → B → C с задержкой 5 мс на каждый скачок требует минимум 10 мс на передачу данных, не считая обработки.

Пропускная способность (throughput) и ширина канала (bandwidth)

Пропускная способность — объём данных, успешно доставленных за единицу времени (Гбит/с, МБ/с, запросов/сек).

Ширина канала — теоретическое максимальное значение пропускной способности.

Типичные значения:

  • Между серверами в дата-центре: 10 Гбит/с
  • На одну машину: 1 Гбит/с
  • Через интернет: 100 Мбит/с – 1 Гбит/с

Расчёт требуемой полосы пропускания:

Если API обслуживает 100 000 запросов в секунду (RPS), каждый запрос весит 1 КБ (размер тела + заголовки), то требуемая пропускная способность:

(100,000 \times 1,\text{КБ} = 100,\text{МБ/с} = 800,\text{Мбит/с})

Добавляя оверхед протокола TCP/IP (~10%), получаем реальное требование: 880 Мбит/с.

Время в оба конца (RTT)

Время, за которое пакет идёт до получателя и возвращается обратно.

Влияние на протоколы:

  • Установление TCP-соединения (трёхфазное рукопожатие): примерно 3 × RTT
  • TLS handshake поверх TCP: дополнительно 2–3 × RTT

Пример: при RTT = 10 мс, установление защищённого соединения требует примерно 50 мс. Цепочка из пяти независимых соединений потребует 250 мс только на handshake'и.


TCP и UDP: выбор протокола транспортного уровня

TCP (Transmission Control Protocol)

Характеристики:

  • Гарантирует доставку всех пакетов в правильном порядке
  • При потере пакета переотправляет его автоматически
  • Требует установления соединения (трёхфазное рукопожатие)
  • Добавляет overhead для проверок и переотправок

Когда использовать:

  • Backend-API взаимодействие (потеря запроса неприемлема)
  • Микросервисное взаимодействие
  • Любые операции, требующие гарантии целостности данных
  • Финансовые операции, критичные бизнес-процессы

UDP (User Datagram Protocol)

Характеристики:

  • Отправляет пакеты без гарантии доставки или порядка
  • Отсутствует overhead установления соединения
  • Минимальная задержка

Когда использовать:

  • Real-time трафик (видеостриминг, VoIP), где небольшие потери приемлемы
  • Системы мониторинга и аналитики, допускающие потерю отдельных пакетов
  • DNS-запросы

Практический вывод: в backend-архитектуре микросервисов используется почти исключительно TCP, так как надёжность более важна, чем минимальная задержка.


Трёхфазное рукопожатие TCP и его влияние на задержки

Перед отправкой данных через TCP выполняется трёхфазное соединение:

  1. Клиент отправляет SYN
  2. Сервер отвечает SYN-ACK
  3. Клиент отправляет ACK

При RTT = 10 мс минимальная задержка установления соединения составляет 30 мс.

Проблема при наивном подходе: если приложение создаёт новое соединение для каждого запроса в цепочке из пяти синхронных вызовов, общая задержка только на handshake'и будет 5 × 30 = 150 мс.

Решение: использование пула соединений. Соединения переиспользуются для нескольких запросов подряд, амортизируя стоимость handshake'а.


TLS/HTTPS: шифрование и аутентификация

Назначение TLS

TLS (Transport Layer Security) обеспечивает:

  • Конфиденциальность: данные зашифрованы, третья сторона не может их прочитать
  • Целостность: данные не могут быть подделаны или изменены
  • Аутентификация: сервер доказывает свою подлинность через сертификат

TLS handshake и стоимость

После установления TCP-соединения (3 RTT) выполняется TLS handshake (дополнительно 2–3 RTT в зависимости от версии).

Итого: ~50 мс на установление защищённого соединения при RTT = 10 мс.

Оптимизация: TLS termination на edge

В production-архитектурах TLS расшифровывается на edge (балансировщик нагрузки, reverse proxy):

  1. Клиент отправляет HTTPS-запрос → edge LB расшифровывает
  2. Внутри trusted сети (дата-центр) трафик идёт unencrypted HTTP или по mTLS
  3. Это экономит CPU на backend-серверах
  4. Управление сертификатами централизовано

HTTP-протоколы

HTTP/1.1

Механизм сохранения соединения (keep-alive): в HTTP/1.0 каждый запрос требовал отдельного TCP-соединения. HTTP/1.1 ввёл keep-alive — одно соединение переиспользуется для нескольких запросов.

Ограничения HTTP/1.1:

Проблема Описание
Блокировка в начале очереди Если первый запрос долгий, остальные в очереди ждут ответа на него
Ограничение одновременных соединений Браузеры открывают 6–8 соединений к одному хосту — узкое место
Дублирование заголовков Каждый запрос повторяет одинаковые заголовки (User-Agent, Cookie)

HTTP/2

Решает основные проблемы HTTP/1.1.

Ключевые улучшения:

  • Мультиплексирование: один TCP-скачок несёт несколько виртуальных потоков (streams). Запросы и ответы могут идти в произвольном порядке. Блокировка в начале очереди решена на HTTP-уровне.

  • Сжатие заголовков (HPACK): повторяющиеся заголовки передаются индексами, не полными значениями.

  • Отправка сервером (Server Push): сервер может отправить ресурсы до того, как клиент их запросит (например, CSS вместе с HTML).

  • Бинарный формат: проще парсить, чем текстовый HTTP/1.1.

Применение: gRPC использует HTTP/2 как транспорт. Благодаря мультиплексированию тысячи параллельных RPC-вызовов могут идти по одному соединению без перехватывания друг друга.

HTTP/3 (QUIC)

Построен на QUIC — протоколе поверх UDP, а не TCP.

Преимущества:

  • 0-RTT соединение: если клиент уже подключался, новое соединение устанавливается без задержек
  • Миграция соединения: если клиент переехал с WiFi на 4G, соединение не разрывается
  • Лучшая задержка: нет блокировки на TCP-уровне (TCP переотправляет потерянные пакеты, блокируя всех остальных)

Статус (конец 2025): широко поддерживается браузерами и CDN, но в backend'е редко используется.

WebSocket и паттерны для двусторонней связи

Для real-time взаимодействия (уведомления, live-обновления) требуется двусторонняя или server-initiated связь.

Паттерн Двусторонний Задержка Overhead Применение
WebSocket Да Низкая Минимальный Chat'ы, live notifications, real-time данные
Длительное опрашивание Нет Средняя Высокий (много заголовков) Legacy-системы, везде работает HTTP
События от сервера (SSE) Нет (только сервер) Низкая Средний Server-to-client обновления, аналитика

WebSocket: после начального HTTP upgrade на ws://, это чистый TCP с минимальным оверхедом.

События от сервера (SSE): долгоживущее соединение, только сервер инициирует отправку. Проще WebSocket, если не нужна двусторонняя связь.

Длительное опрашивание: клиент отправляет запрос, сервер его держит открытым до получения данных (или до таймаута), затем отправляет ответ, и клиент отправляет новый запрос. Работает везде, где работает HTTP, но более затратно.


Синхронное и асинхронное взаимодействие между сервисами

Синхронные паттерны вызовов

REST/JSON

Характеристики:

  • HTTP запрос с JSON payload, ответ в JSON
  • Стандартные HTTP методы (GET, POST, PUT, DELETE) с явной семантикой
  • Простой контракт, легко отладить
  • Де-факто стандарт для публичных API

Когда применять: публичные API, операции требующие немедленного результата.

gRPC

Характеристики:

  • RPC поверх HTTP/2, использует Protocol Buffers для сериализации
  • Типобезопасный контракт, сгенерированные клиенты и сервера
  • Меньше payload'а за счёт бинарной сериализации
  • Лучше для высокочастотного inter-service трафика

Расчёт экономии: REST-запрос с JSON может весить 500 байт, тот же запрос в gRPC с protobuf — 100–150 байт. При 100 000 RPS экономия составляет примерно 350 Мбит/с.

Когда применять: микросервисное взаимодействие, high-throughput API, системы требующие низкой задержки.

GraphQL

Характеристики:

  • Язык запросов, позволяющий клиенту запросить ровно нужные поля
  • Решает проблемы over-fetching (лишние данные) и under-fetching (нужны данные из нескольких API)
  • Более сложен в реализации

Когда применять: мобильные клиенты (экономия трафика), разнородные фронтенд-клиенты с разными потребностями.

Плюсы синхронных вызовов

  • Простота: отправил запрос, получил ответ, операция завершена
  • Предсказуемая модель обработки ошибок
  • Подходит для операций требующих немедленный результат (пользователь видит ответ на экране)

Минусы синхронных вызовов

Каскадные сбои: если сервис B недоступен, и A синхронно вызывает B, то A не может ответить. Если C вызывает A, то C тоже не может ответить. Цепочка накрывается.

Умножение задержки: запрос A → B → C с задержкой 20 мс на каждый скачок требует 60 мс (плюс обработка).

Нелинейное масштабирование: при добавлении новых зависимостей система становится всё менее отказоустойчивой.

Асинхронные паттерны

Очереди и брокеры сообщений

Вместо синхронного вызова сервис отправляет сообщение в очередь. Другой сервис подписывается на очередь и обрабатывает асинхронно.

Популярные реализации:

  • RabbitMQ (message broker с queues и topics)
  • Apache Kafka (distributed event streaming)
  • AWS SQS (облачная очередь)
  • Redis Streams (simple очереди в памяти)

Event-driven архитектура

Система состоит из независимых компонентов, реагирующих на события.

Пример: после создания заказа публикуется событие OrderCreated, на которое подписаны:

  • Сервис биллинга (начислить платёж)
  • Сервис логистики (подготовить доставку)
  • Сервис аналитики (записать статистику)

Каждый сервис обрабатывает независимо, не зная о существовании других.

Плюсы асинхронных взаимодействий

  • Декаплинг: сервисы независимы, не знают друг о друге
  • Отказоустойчивость: если потребитель недоступен, сообщение остаётся в очереди, обработается позже
  • Буферизация нагрузки: пики трафика сглаживаются за счёт очереди
  • Масштабирование: легко добавить параллельных обработчиков
  • Нет retry storm'а: если задача не получилась, она ретраится внутри очереди, не создавая дополнительный трафик к зависимому сервису

Минусы асинхронных взаимодействий

  • Eventual consistency: данные согласованы не сразу, а со временем
  • Сложность отладки: ошибка может проявиться через час после отправки сообщения
  • Обязательная идемпотентность: сообщение может быть обработано несколько раз из-за гарантий доставки
  • Dead Letter Queue и мониторинг: нужны механизмы обработки ошибок и отслеживания потеринного трафика

Выбор между синхронным и асинхронным

Используй синхронные вызовы:

  • Когда нужен немедленный результат (пользователь видит ответ)
  • Короткие цепочки (максимум 2–3 вызова)
  • Low-latency операции

Переходи на асинхронные:

  • Когда операция может быть обработана позже (отправка писем, обработка файлов)
  • Долгие операции (риск таймаутов)
  • Высоконагруженные системы нуждаются в буферизации
  • Нужна отказоустойчивость к сбоям зависимых сервисов

Гибридный подход: критичные операции обрабатываются синхронно с коротким таймаутом, фоновая работа идёт в очередь.


Путь запроса от клиента до backend-сервиса

Типичная архитектура пути запроса

Клиент 
  ↓ (DNS)
Балансировщик L4 (DDoS protection)
  ↓
Балансировщик L7 / API Gateway (аутентификация, rate limiting)
  ↓
Backend-сервисы (обработка бизнес-логики)
  ↓
Кеши (Redis), БД (PostgreSQL), очереди (Kafka)

Роль каждого компонента

DNS

Преобразует доменное имя (example.com) в IP-адрес. Может возвращать несколько адресов в round-robin режиме или на основе географического расположения клиента.

CDN (Content Delivery Network)

Сеть серверов, расположенных географически близко к пользователям, кеширующая статический контент:

  • Картинки, CSS, JavaScript
  • Видеофайлы
  • Шрифты

Примеры: Cloudflare, Akamai, AWS CloudFront, Yandex.Cloud CDN.

Расчёт экономии: если 30% трафика — статический контент (видео, картинки), и с CDN задержка снижается с 200 мс до 20 мс, то для пользователя средняя задержка страницы снижается примерно на 5% (не 30%, так как статический контент не всегда критичен).

WAF (Web Application Firewall)

Фильтрует malicious трафик на уровне HTTP.

Защита от:

  • SQL injection
  • XSS (Cross-Site Scripting)
  • CSRF (Cross-Site Request Forgery)
  • DDoS на L7

Балансировщик L4 (Layer 4)

Распределяет трафик на основе IP-адреса и портов.

Характеристики:

  • Видит только адреса и порты, не смотрит в содержимое
  • Очень производительный
  • Используется перед несколькими L7-proxy'ями для первичного распределения

Балансировщик L7 / Reverse Proxy / API Gateway

Смотрит на HTTP-уровне: заголовки, URL, методы.

Функции:

  • Маршрутизация по URL-пути: /api/users/* → сервис users
  • Версионирование API: /v1/… vs /v2/…
  • Аутентификация (JWT, API ключи)
  • Rate limiting
  • Логирование
  • Трансформация запросов

Backend-сервисы

Обрабатывают бизнес-логику. Обычно представлены несколькими инстансами для высокой доступности.


Балансировка нагрузки: L4 и L7

L4 Load Balancer

Что видит: IP-адрес отправителя, IP-адрес получателя, порт, протокол (TCP/UDP).

Алгоритм работы: получает пакет, выбирает один из backend-серверов по алгоритму, перенаправляет пакет туда.

Плюсы:

  • Производительность: очень быстро
  • Универсальность: работает с любым протоколом
  • Простота реализации

Минусы:

  • Тупой роутинг: не видит содержимое
  • Неоптимальное распределение, если серверы разной мощности

L7 Load Balancer / Reverse Proxy

Что видит: всё что L4, плюс HTTP метод, URL path, query параметры, headers, cookies, body.

Возможности:

  • Умный роутинг (по URL, headers, версии)
  • Модификация запросов
  • Content-based switching

Минусы:

  • Более затратно по CPU
  • Может быть узким местом при очень высоких нагрузках

Алгоритмы балансировки нагрузки

Round Robin

Запросы распределяются по очереди: 1-й на сервер 1, 2-й на сервер 2, 3-й на сервер 3, затем снова на 1.

Плюсы: простейший, справедливое распределение при одинаковых серверах.

Минусы: не учитывает текущую нагрузку на сервер.

Weighted Round Robin

Каждому серверу назначен вес на основе мощности.

Пример: сервер 1 (вес 3), сервер 2 (вес 1). Из 4 запросов: 3 на сервер 1, 1 на сервер 2.

Least Connections / Least Outstanding Requests

Новый запрос отправляется на сервер с наименьшим количеством активных соединений.

Когда помогает: если запросы имеют очень разное время обработки.

Random с двумя вариантами (Power of Two Choices)

Выбираешь два случайных сервера, отправляешь на тот с меньшей нагрузкой.

Преимущество: снижает дисперсию нагрузки по сравнению с чистым random, дешевле чем fully optimal.

IP Hash / Session Affinity

Хеширование IP-адреса клиента определяет целевой сервер.

Плюсы: все запросы клиента идят на один сервер, переиспользуется local cache сессии.

Минусы: неравномерное распределение, при падении сервера клиент потеряет сессию.

Consistent Hashing

Используется в распределённых кешах (Redis Cluster) и хранилищах (Cassandra).

Идея: каждому серверу и ключу назначается позиция на hash ring. Ключ идёт на ближайший сервер по кругу.

Преимущество: при добавлении/удалении сервера, большинство ключей остаются на месте. Без consistent hashing'а добавление одного сервера перераспределило бы все ключи.

Расчёт: 3 сервера, 1000 ключей. Добавляешь 4-й сервер.

  • Без consistent hashing: переехали бы 750 ключей (75%)
  • С consistent hashing: переехали бы 250 ключей (25%)

Health-check'и и управление пулом инстансов

Active Health Checks

LB периодически отправляет запрос каждому backend-серверу.

Параметры:

  • Интервал проверки: 10 секунд
  • Таймаут: 2 секунды
  • Количество неудачных проверок для исключения: 2–3 подряд

Пример: HTTP GET на /health возвращает 200 OK с JSON:

{"status": "healthy"}

Passive Health Checks

LB отслеживает ошибки реальных запросов (5xx, таймауты). После нескольких ошибок сервер исключается.

Преимущество: не требует отдельных запросов.

Недостаток: может быть задержка накопления ошибок.

Статусы инстанса

  • Healthy: запросы идут на этот сервер
  • Unhealthy: новые запросы не идут, но мониторинг продолжается
  • Draining: новые запросы не идят, активные запросы доводятся до конца (graceful shutdown)
  • Возврат: когда health-check'и проходят, сервер возвращается в пул

Sticky Sessions (Session Affinity): проблемы и решения

Определение

Sticky sessions — когда запросы от одного клиента всегда идят на один и тот же backend-сервер. Появляются в stateful приложениях, где session-данные хранятся в памяти сервера.

Способы реализации

IP hash: LB хеширует IP-адрес, результат определяет сервер.

Cookie-based: LB устанавливает cookie (например, X-LB-Session: server-3), клиент отправляет её в запросах.

Проблемы sticky sessions

Неравномерная нагрузка: если один клиент генерирует много трафика, весь его трафик идёт на один сервер, создавая hot spot.

Потеря данных при сбое: если сервер с session-данными упадёт, клиент потеряет сессию.

Сложность масштабирования: нельзя просто добавить сервер и ожидать равномерное распределение.

Рекомендуемый подход: stateless архитектура

Лучшее решение: избегать sticky sessions, используя stateless-архитектуру.

Реализация:

  • Session-данные (user ID, permissions, cart) хранятся в Redis или другом external store
  • Каждый backend-сервер может обслуживать каждого клиента
  • При падении сервера данные не теряются

Преимущества:

  • Равномерное распределение нагрузки
  • Быстрое добавление/удаление серверов
  • Отказоустойчивость

TLS-терминация и внутренний трафик

TLS Termination на Edge

Модель:

  1. Клиент отправляет HTTPS-запрос → edge LB расшифровывает (TLS termination)
  2. Внутри кластера трафик идёт plain HTTP (если trusted сеть) или по mTLS
  3. Ответ идёт обратно

Преимущества:

  • Backend-сервисы не тратят CPU на шифрование
  • Централизованное управление сертификатами
  • LB может видеть HTTP-content и применять правила

mTLS (Mutual TLS) внутри кластера

Назначение: обе стороны аутентифицируют друг друга через сертификаты.

Когда требуется:

  • High-security окружения (финансовые системы, healthcare)
  • Multi-tenant архитектура, требующая гарантии аутентификации сервиса
  • Compliance требования (PCI DSS, HIPAA)

Реализация: обычно через service mesh (Istio, Linkerd), который управляет mTLS автоматически.


Горизонтальное масштабирование с помощью балансировщиков

Архитектура

Все backend-инстансы stateless. Перед ними стоит load balancer (или несколько для HA). При возрастании нагрузки добавляются новые инстансы, LB начинает маршрутить на них.

Ручное и автоматическое масштабирование

Ручное: DevOps добавляет инстанс в конфиг LB.

Автоматическое (autoscaling):

  • Система мониторит метрики (CPU, memory, RPS)
  • При превышении threshold'а автоматически запускаются новые инстансы
  • Они регистрируются в пуле LB
  • При снижении нагрузки инстансы shutdownятся

Требования для autoscaling:

  • Orchestration (Kubernetes, Nomad)
  • Metrics collection (Prometheus, InfluxDB)
  • Autoscaler (Kubernetes HPA, custom скрипт)

Многоуровневая балансировка

Для региональных систем:

Клиент
  ↓ (Global load balancer)
Global LB маршрутит в регион (US, EU, APAC)
  ↓ (Regional load balancer)
Regional LB выбирает зону (us-east-1, us-west-2)
  ↓ (Zonal load balancer или service discovery)
Backend-инстансы в зоне

Edge-паттерны: защита и управление трафиком

Rate Limiting и Throttling

Rate limiting — ограничение количества запросов от клиента за единицу времени.

Зачем:

  • Защита от DDoS: каждый бот может отправить только N запросов/сек
  • Защита от broken клиентов (приложение баговано, спамит)
  • Управление нагрузкой при перегрузке системы

Алгоритмы:

Token Bucket: у клиента есть "ведёрко" токенов. Каждый запрос тратит один токен. Токены пополняются со временем.

  • Лимит: 100 запросов в секунду
  • Максимум в "ведёрке": 100 токенов
  • Пополнение: 100 токенов/сек
  • Всплески: если система была простаивает, накопилось 100 токенов, клиент может отправить 100 запросов сразу

Sliding Window: считаем запросы за последние N секунд. Если больше лимита — отбиваем.

Leaky Bucket: запросы идят в очередь, обрабатываются с фиксированной скоростью, лишние отбиваются.

Где реализуется: обычно на API gateway или edge LB. Можно также на уровне приложения, но на edge эффективнее.

WAF (Web Application Firewall)

Фильтрует HTTP-трафик на основе правил.

Защита от:

  • SQL Injection
  • XSS (Cross-Site Scripting)
  • CSRF (Cross-Site Request Forgery)
  • DDoS на L7 (flood attacks)

API Gateway

Центральная точка входа для всех API-запросов.

Функции:

Функция Описание
Аутентификация Проверка JWT, API ключей, OAuth tokens
Авторизация Проверка прав доступа
Логирование Запись каждого запроса для аудита
Версионирование Маршрутизация /v1/… и /v2/… на разные сервисы
Rate limiting Ограничение запросов на пользователя
Трансформация Добавление заголовков, модификация body
Protocol conversion REST ↔ gRPC, GraphQL ↔ REST

Сетевые ошибки, таймауты и ретраи

Виды сбоев

Таймауты: запрос долго обрабатывается, клиент дает таймаут. Причины: перегрузка backend'а, зависание процесса, сетевая задержка.

Connection Reset: соединение разорвалось посередине. Причины: потеря пакетов, firewall, crash backend'а.

Partial Failures: один сервис в цепочке синхронных вызовов недоступен. Запрос A → B → C, B упал. A видит ошибку и возвращает её клиенту.

Ретраи и стратегии

Когда ретраить:

  • Таймауты (возможно временная перегрузка)
  • Connection reset (может быть временная проблема)
  • 503 Service Unavailable
  • 429 Too Many Requests (rate limit)

Когда не ретраить:

  • 4xx ошибки (Bad Request, Unauthorized, Forbidden)
  • POST-запросы без idempotency guarantee

Backoff-стратегии

Линейный backoff: 1 сек, 2 сек, 3 сек, 4 сек…

Экспоненциальный backoff: 1 сек, 2 сек, 4 сек, 8 сек, 16 сек…

Экспоненциальный лучше: если backend перегружен, не забиваем его множеством ретраев, даём время восстановиться.

Максимальная задержка: обычно 30–60 сек, чтобы не ждать вечность.

Джиттер (Jitter)

Добавляет случайную задержку к backoff'у, предотвращая synchronized retry storm (когда все клиенты ретраят в одно время).

Пример: вместо строго 2 сек, ретраим в 1.5–2.5 сек. Это разреживает ретраи по времени.

Retry Storm

Проблема: когда backend перегружен, все клиенты начинают ретраить, это перегружает backend ещё больше, создаётся positive feedback loop.

Решение: exponential backoff + jitter + circuit breaker (при многих ошибках просто отказываем клиенту вместо ретраев).

Идемпотентность (Idempotency)

Определение: операция идемпотентна, если вызов её несколько раз даёт тот же результат, что один раз.

Идемпотентные операции:

  • GET (не меняет состояние)
  • Создание ресурса с unique ID (повторное создание игнорируется или возвращает существующий)
  • DELETE (удаление уже удаленного — 404, но не критично)

Не идемпотентные:

  • Перевод денег (ретрай переведёт дважды)
  • Инкремент счетчика (каждый ретрай увеличит на 1)

Реализация идемпотентности:

  1. Добавить unique request ID (idempotency key) в заголовок
  2. На сервере сохранять результат последнего запроса с этим ID
  3. Если приходит повторный запрос с тем же ID, возвращаем сохранённый результат (не переэкзекутируем)

Пример:

POST /transfer
Idempotency-Key: abc123-def456

Request #1: Сервер выполняет трансфер, сохраняет результат
Request #2 (ретрай с тем же ключом): Сервер возвращает старый результат без переэкзекуции

Хранилище результатов: Redis с TTL (keep для нескольких часов) или database.


Сетевое взаимодействие в Java backend

Connection Pools

Проблема: каждое соединение требует 3-way handshake (~30 мс) + TLS handshake (~20 мс) = 50 мс на соединение. Если создавать новое для каждого запроса, половина времени тратится на setup.

Решение: переиспользовать соединения в пуле.

Параметры:

  • Min pool size: 5–10 соединений
  • Max pool size: 20–100 (в зависимости от нагрузки)
  • Connection timeout: как долго ждать свободное соединение
  • Idle timeout: через сколько закрыть неиспользуемое соединение (обычно 5–10 минут)

Таймауты

Connect timeout: ожидание установления TCP-соединения. Обычно 2–5 сек.

Read timeout: ожидание ответа после отправки запроса. Обычно 5–30 сек (зависит от операции).

Write timeout: ожидание готовности соединения к отправке. Обычно 2–5 сек.

Важность: без таймаутов запрос может зависнуть на бесконечность, thread pool заполнится, система упадёт.

Thread Pool и параллелизм

Обработка каждого запроса выполняется в потоке. Thread pool имеет:

  • Core size: обычно = количество CPU ядер
  • Max size: 100–500
  • Queue: очередь ожидающих задач

Связь с сетью: если thread pool из 50 потоков и каждый делает синхронный запрос с таймаутом 10 сек, система может обслуживать примерно 5 RPS при максимальной утилизации.

Расчёт: max_rps = pool_size / request_latency = 50 / 10 = 5 RPS.

Решение для high-concurrency: асинхронные фреймворки (Vert.x, Spring WebFlux, Kotlin coroutines), которые не привязывают потоки к соединениям.

Пример конфигурации на Java

// HTTP-клиент с pool'ом и таймаутами
HttpClient httpClient = HttpClient.newBuilder()
  .connectTimeout(Duration.ofSeconds(2))
  .build();

// Или с Apache HttpClient
RestTemplate restTemplate = new RestTemplate(
  new HttpComponentsClientHttpRequestFactory(
    HttpClientBuilder.create()
      .setConnectionManager(poolingConnectionManager)
      .setDefaultRequestConfig(
        RequestConfig.custom()
          .setConnectTimeout(2000)
          .setSocketTimeout(5000)
          .build()
      )
      .build()
  )
);

Практический пример архитектуры с расчётами

Система: платформа для обработки видео

Требования:

  • 10 000 одновременных пользователей
  • Каждый пользователь загружает видео (средний размер 500 МБ)
  • Обработка: кодирование в 3 формата (720p, 480p, 240p)
  • Результат доставляется через CDN

Расчёт нагрузки

Входящий трафик:

  • 10 000 пользователей × 500 МБ среднего размера / 3600 сек (в течение часа) = 1388 МБ/сек = 11 Гбит/сек
  • Требуемая пропускная способность с запасом: 15 Гбит/сек

Backend-обработка:

  • Задач в очереди: 10 000 ÷ 60 = примерно 167 видео обрабатывается одновременно (при среднем времени обработки 1 часа)
  • Если сервер кодирует 1 видео за час, нужно ~167 воркеров или машин для параллельной обработки

Хранилище результатов:

  • Трёхформатное видео: исходное (500 МБ) + 3 версии обычно 50–70% от исходного = 500 + 3×300 = 1400 МБ
  • За 10 000 загрузок: 10 000 × 1.4 ГБ = 14 ТБ

CDN доставка:

  • Статистика: 30% пользователей смотрят видео дважды (rewatch rate)
  • Примерно 13 000 view'ов в час от 10 000 пользователей
  • При среднем размере видео 300 МБ (одной версии) = 13 000 × 300 МБ / 3600 сек = 1083 МБ/сек = 8.7 Гбит/сек
  • CDN значительно снижает нагрузку на origin (может быть 90%+ cache hit rate)

Выбор технологий

Intake (приём видео):

  • L4 LB перед несколькими reverse proxy'ями
  • Reverse proxy с rate limiting (max 100 МБ/сек на пользователя)
  • Direct upload на S3 или похожее хранилище

Обработка:

  • Асинхронная очередь (Kafka или RabbitMQ)
  • 167+ воркеров (можно в Kubernetes контейнерах с autoscaling)
  • Воркер: ffmpeg для кодирования

Доставка:

  • CDN (Cloudflare, Akamai) — 90% трафика идёт туда
  • Origin: S3 или собственный object storage с LB

Сообщения между компонентами:

  • REST API для управления (создание задач, проверка статуса)
  • gRPC или internal events для синхронизации между воркерами и storage

Кеширование:

  • Redis для метаданных видео (title, description, views count)
  • TTL: 1 день для часто смотрящихся видео, 1 час для менее популярных

Сводная таблица выбора технологий и подходов

Сценарий Рекомендация Альтернатива Почему
Публичный API REST + HTTP/2 GraphQL, gRPC REST проще, поддержка широкая, для мобильных клиентов хорош GraphQL
Микросервисное взаимодействие gRPC + HTTP/2 REST с connection pooling gRPC быстрее (binary, мультиплексирование), лучше для high-throughput
Real-time уведомления WebSocket или SSE Long polling WebSocket — двусторонний, SSE — only server-initiated, оба лучше long polling
Асинхронная обработка Kafka или RabbitMQ AWS SQS, Redis Streams Kafka для высокой нагрузки и event sourcing, RabbitMQ — проще, SQS — cloud-first
Session хранилище Redis Memcached, database Redis поддерживает TTL, сложные структуры, репликацию; Memcached проще но нет персистентности
Балансировка для high-throughput L4 LB + gRPC L7 LB + REST L4 + gRPC — минимум overhead'а, L7 + REST — проще отладить
Балансировка для сложной логики L7 LB + REST L4 LB + gRPC L7 видит HTTP, может роутить по headers/paths; L4 — только порты
Distributed cache Redis Cluster с consistent hashing Memcached + consistent hash client lib Redis Cluster управляет sharding автоматически, Memcached требует клиентской логики
Service mesh (mTLS, observability) Istio или Linkerd Manual mTLS setup Service mesh автоматизирует управление сертификатами, трафиком, observability
API versioning Path-based (/v1/…, /v2/…) Header-based (X-API-Version) или Media Type Path-based проще кешировать, стандартнее; header-based требует внимания

Аналоги инструментов и технологий

Категория Основной инструмент Аналоги Различия
Message Broker RabbitMQ Apache Kafka, AWS SQS, NATS RabbitMQ — traditional AMQP, Kafka — event streaming, SQS — cloud, NATS — lightweight
HTTP Cache Varnish Nginx (caching module), HAProxy Varnish — specialized HTTP cache, Nginx — multi-purpose, HAProxy — TCP focus
Service Mesh Istio Linkerd, Consul Connect Istio — most featureful но heavy, Linkerd — lightweight, Consul — для service discovery
API Gateway Kong AWS API Gateway, Tyk, Apigee Kong — self-hosted, AWS — managed, Tyk — open-source, Apigee — enterprise
Load Balancer Nginx HAProxy, Caddy, AWS ALB/NLB Nginx — reverse proxy focus, HAProxy — TCP focus, AWS — cloud native
CDN Cloudflare Akamai, AWS CloudFront, Yandex CDN Cloudflare — application layer, Akamai — premium, AWS/Yandex — cloud providers
Session Store Redis Memcached, database, Tarantool Redis — rich data types, Memcached — simple, DB — persistence, Tarantool — high-performance
Protocol gRPC Thrift, Protocol Buffers (raw), flatbuffers gRPC — HTTP/2 transport, Thrift — cross-language RPC, Protobuf — just serialization

Ключевые принципы сетевой архитектуры

  1. Минимизация latency: избегай длинных цепочек синхронных вызовов, используй асинхронность где возможно.

  2. Избыточность на каждом уровне: multiple LB'ы, multiple backend'ы, multiple replicas в БД, multiple зоны доступности.

  3. Graceful degradation: если часть системы упала, остаток продолжает работать. Используй timeouts, ретраи, fallback'и.

  4. Мониторинг и observability: отслеживай latency, error rates, throughput на каждом уровне. Используй распределённый трейсинг (Jaeger, Zipkin).

  5. Кеширование на каждом уровне: browser cache (HTTP headers), CDN cache, application cache (Redis), query cache в БД. Но избегай stale data.

  6. Stateless architecture: сервисы не хранят state, state в external store (Redis, БД). Позволяет масштабировать горизонтально.

  7. Правильный выбор протокола: REST для simplicity, gRPC для performance, WebSocket для real-time, SSE для simpler real-time.

  8. Idempotency by default: все операции изменения должны быть идемпотентны (или иметь механизм идемпотентности).


Расчётные примеры и формулы

Пропускная способность

[\text{Required Bandwidth} = \text{RPS} \times \text{Average Request Size (bytes)} \times 8 / 1,000,000,000]

Пример: 50 000 RPS × 2 КБ × 8 / 1 млрд = 0.8 Гбит/с

Время на соединение

[\text{Total Connection Time} = 3 \times \text{RTT} + 2 \times \text{RTT}_{\text{TLS}} = 5 \times \text{RTT}]

При RTT = 10 мс: 50 мс на установление защищённого соединения

Capacity по thread pool'е

[\text{Max RPS} = \frac{\text{Thread Pool Size}}{\text{Average Request Latency (seconds)}}]

Пример: 100 потоков / 0.1 сек (100 мс) = 1000 RPS при 100% утилизации

Exponential backoff

[\text{Delay}_n = \text{Base Delay} \times 2^n + \text{Jitter}]

Пример: Base = 1 сек, попытка 1 = 2 сек, попытка 2 = 4 сек, попытка 3 = 8 сек (+ случайная jitter)

Consistent hashing — переезд ключей при добавлении сервера

[\text{Keys to Move} = \frac{\text{Total Keys}}{\text{Old Server Count} + 1}]

Пример: 1000 ключей, было 3 сервера, добавили 4-й: 1000 / 4 = 250 ключей переехали (25%)


Дополнительные ресурсы для углубления

Ключевые концепции для изучения

  1. TCP/IP stack — как работает transport layer
  2. Congestion control (TCP Reno, CUBIC) — как сеть избегает перегрузок
  3. BGP и routing — как данные находят путь через интернет
  4. Load balancing алгоритмы — power of two, consistent hashing, ECMP
  5. Observability — distributed tracing (Jaeger), metrics (Prometheus), logs (ELK)
  6. Chaos engineering — тестирование отказоустойчивости (введение latency, потери пакетов)
  7. Software-Defined Networking (SDN) — контроль трафика на уровне приложения

Практическое применение

  • Написать простой load balancer на Go/Java с round-robin и health-check'ами
  • Настроить Nginx как reverse proxy с multiple backend'ами
  • Протестировать timeout и retry поведение с tools вроде tc (traffic control) для эмуляции задержек
  • Использовать distributed tracing (Jaeger) для анализа inter-service вызовов

Проектирование API

Роль проектирования API в распределённых системах

API — это граница между компонентами распределённой системы. Плохо спроектированное API приводит к хрупкости: любое изменение требует синхронизации множества команд, появляются циклические зависимости, растёт количество ошибок при интеграции.

Качественный API обеспечивает:

  • независимую и параллельную работу команд;
  • упрощённую отладку через чёткие контракты;
  • эволюцию системы без ломающих изменений;
  • слабую связанность между сервисами.

Внешние (public) vs внутренние (internal) API

Внешние API (клиенты за пределами компании):

  • максимальная стабильность и обратная совместимость;
  • осторожное версионирование с поддержкой 2–3 версий;
  • строгая документация (OpenAPI/Swagger);
  • отказоустойчивость к разным типам клиентов и сетевым условиям.

Внутренние API (микросервисы):

  • быстрое обновление (все сервисы в одной системе);
  • эффективные форматы (gRPC, бинарные протоколы);
  • контрактная разработка (contract-first);
  • идемпотентность с логикой восстановления на уровне инфраструктуры.

REST API: ресурсно-ориентированный дизайн

Концепция ресурса

Ресурс — это абстракция сущности, которой присваивается уникальный идентификатор (URI). С ресурсом работают через стандартные HTTP-методы, а не через глаголы в URL.

Преимущества подхода:

  • кеширование на уровне HTTP (GET безопасен, PUT/DELETE идемпотентны);
  • знакомый паттерн, упрощающий разработку;
  • разделение логики контроллера и бизнес-логики;
  • использование стандартных фреймворков и инструментов мониторинга.

Моделирование ресурсов и связей

Базовые сущности:

/users — коллекция пользователей
/users/{id} — конкретный пользователь
/orders — коллекция заказов
/orders/{id} — конкретный заказ

Вложенные ресурсы:

/users/{userId}/orders — заказы конкретного пользователя
/orders/{orderId}/items — позиции в заказе

Вложенные ресурсы подходят для отношений «один-ко-многим» до 2–3 уровней глубины. При большей глубине используйте отдельные вызовы с фильтрацией:

// Правильно (глубина 2):
/users/{userId}/orders/{orderId}/items

// Плохо (глубина 3+):
/users/{userId}/orders/{orderId}/items/{itemId}/reviews

// Лучше:
/items?orderId={orderId}
/reviews?itemId={itemId}

Связи через идентификаторы:

Для больших систем возвращайте только идентификатор, а не вложенный объект:

{
  "id": "ord123",
  "userId": "user456",
  "amount": 99.99
}

или с явным указанием связи:

{
  "id": "ord123",
  "links": {
    "user": "/users/user456"
  },
  "amount": 99.99
}

Именование и структура URL

Правило: используйте существительные во множественном числе

✓ /users, /orders, /payments
✗ /getUsers, /createOrder, /deletePayment

Действие (глагол) кодируется в HTTP-методе, а не в URL. Исключение — нестандартные действия:

POST /orders/{id}:cancel
POST /payments/{id}:refund

Лучше спроектировать как отдельный ресурс:

POST /orders/{id}/cancellations

Иерархия URL отражает иерархию данных, но не создавайте искусственные иерархии:

// Правильно:
GET /orders/{orderId}
GET /orders?userId=user456

// Неправильно:
GET /users/{userId}/orders/{orderId}/status
// Вместо этого: GET /orders/{orderId} и вернуть всё, включая статус

Параметры запроса (query parameters):

GET /orders?userId=user456&status=pending&limit=20&offset=0
GET /users?name=John&createdAfter=2025-01-01

Параметры используются для фильтрации, пагинации, сортировки, а не для идентификации ресурса. Параметры опциональны.

HTTP-методы и семантика

Метод Safe Idempotent Cacheable Использование
GET Чтение данных, безопасен
POST Создание новых ресурсов
PUT зависит Полное обновление, идемпотентен
PATCH зависит зависит Частичное обновление
DELETE Удаление, идемпотентен

GET: Не меняет состояние. Может кешироваться. Повторный вызов даёт тот же результат.

POST: Создание нового ресурса. Ответ обычно 201 Created с заголовком Location и телом с созданным ресурсом.

PUT: Полное обновление или создание ресурса. Повторный вызов с той же сущностью не меняет результат на сервере.

PATCH: Частичное обновление. PATCH может быть неидемпотентен в зависимости от операции (например, {"counter": "+1"}).

DELETE: Удаление ресурса. Повторное удаление несуществующего ресурса должно вернуть 204 или 404.


Статус-коды и обработка ошибок

Основные группы статус-кодов

2xx — успех:

  • 200 OK — запрос выполнен, данные в теле ответа;
  • 201 Created — ресурс создан, Location указывает на новый ресурс;
  • 202 Accepted — запрос принят, обработка асинхронна;
  • 204 No Content — успех без тела ответа.

4xx — ошибка клиента:

  • 400 Bad Request — синтаксическая ошибка в запросе;
  • 401 Unauthorized — требуется аутентификация;
  • 403 Forbidden — нет прав доступа;
  • 404 Not Found — ресурс не найден;
  • 409 Conflict — конфликт состояния;
  • 422 Unprocessable Entity — данные валидны синтаксически, но семантически некорректны.

5xx — ошибка сервера:

  • 500 Internal Server Error — непредвиденная ошибка;
  • 502 Bad Gateway — промежуточный прокси не может соединиться с upstream-сервисом;
  • 503 Service Unavailable — сервис недоступен;
  • 504 Gateway Timeout — сервис не ответил за отведённое время.

Консистентный формат ошибок

{
  "code": "INSUFFICIENT_FUNDS",
  "message": "Not enough funds in the account",
  "details": {
    "balance": 50.00,
    "required": 150.00,
    "currency": "USD"
  },
  "traceId": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2025-11-27T12:00:00Z"
}

Поля:

  • code — постоянный идентификатор ошибки для автоматической обработки;
  • message — понятное описание;
  • details — дополнительный контекст;
  • traceId — для отслеживания в логах;
  • timestamp — время возникновения.

Для ошибок валидации:

{
  "code": "VALIDATION_ERROR",
  "message": "Invalid request parameters",
  "details": {
    "errors": [
      {"field": "email", "message": "Invalid email format"},
      {"field": "age", "message": "Must be >= 18"}
    ]
  },
  "traceId": "550e8400-e29b-41d4-a716-446655440000"
}

Контракты и структура данных

Структура JSON и Protobuf сообщений

Стабильные поля:

{
  "id": "ord123",
  "userId": "user456",
  "amount": 99.99,
  "currency": "USD",
  "createdAt": "2025-11-27T10:00:00Z"
}

Ядро контракта не должно меняться в течение жизни версии API.

Добавление новых полей:

{
  "id": "ord123",
  "userId": "user456",
  "amount": 99.99,
  "currency": "USD",
  "createdAt": "2025-11-27T10:00:00Z",
  "status": "pending",
  "estimatedDelivery": "2025-11-30T23:59:59Z"
}

Старые клиенты проигнорируют новые поля. Новые клиенты смогут их использовать. Это backward-compatible изменение.

Nullable vs обязательные поля:

message Order {
  string id = 1;
  string user_id = 2;
  double amount = 3;
  optional string shipping_address = 4;
  optional string notes = 5;
}

Консистентность контрактов между сервисами

Единый стиль полей: выберите одну конвенцию и придерживайтесь её везде

  • camelCase (популярно в Java мире);
  • snake_case (популярно в Python мире).

Соглашения по датам: всегда используйте ISO 8601

✓ 2025-11-27T12:00:00Z
✓ 2025-11-27T12:00:00+03:00
✗ 2025-11-27 12:00:00 (неправильный формат)
✗ 27.11.2025 12:00 (locale-specific)
✗ 1732694400 (Unix timestamp без контекста)

Соглашения по деньгам: передавайте сумму в основных единицах (доллары, евро) с фиксированной точностью

{
  "amount": 99.99,
  "currency": "USD"
}

или всегда в центах (целое число):

{
  "amountCents": 9999,
  "currency": "USD"
}

Соглашения по идентификаторам:

  • UUID: 550e8400-e29b-41d4-a716-446655440000 (стабильно, не раскрывает количество);
  • autoincrement: 12345 (короче, но раскрывает информацию);
  • custom prefix: ord_123456 (читаемо, указывает на тип).

Документирование контрактов

OpenAPI для REST:

openapi: 3.0.0
info:
  title: Order Service API
  version: 1.0.0

paths:
  /orders:
    get:
      parameters:

        - name: limit
          in: query
          schema:
            type: integer
            default: 10
      responses:
        '200':
          description: List of orders
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Order'

Protobuf для gRPC:

syntax = "proto3";

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
}

message Order {
  string id = 1;
  string user_id = 2;
  double amount = 3;
  google.protobuf.Timestamp created_at = 4;
}

Идемпотентность и надёжность операций

Определение идемпотентности

Идемпотентность — свойство операции, при котором повторный вызов с теми же параметрами даёт тот же результат, что и первый вызов.

Идемпотентно:   PUT /users/{id} с сущностью {name: "Alice"}
Не идемпотентно: POST /transfers с {from: acc1, to: acc2, amount: 100}
                 (первый вызов переводит 100, второй — ещё 100)

Почему идемпотентность критична в распределённых системах

  • Сетевые сбои: клиент отправил запрос, но не получил ответ (timeout). Не зная, обработан ли запрос, клиент повторяет. Если операция идемпотентна, безопасно повторить.

  • Восстановление инфраструктуры: Kubernetes может перезагрузить pod в середине обработки, балансировщик может перенаправить запрос на другой инстанс. Если операция идемпотентна, система восстановится.

  • Гарантии доставки: сложнее гарантировать ровно один раз. Идемпотентность — более достижимый идеал.

Примеры идемпотентных операций

PUT с фиксированным ID:

PUT /users/{id}
{ "name": "Alice", "email": "alice@example.com" }

Первый запрос создаёт или обновляет пользователя. Повторный запрос заменяет пользователя теми же данными — результат идентичен.

POST с идемпотентным ключом (idempotency key):

POST /transfers
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{ "from": "acc1", "to": "acc2", "amount": 100.00 }

Клиент генерирует уникальный идентификатор и отправляет его в заголовке Idempotency-Key. Сервер сохраняет ключ и результат. При повторном запросе с тем же ключом сервер возвращает кешированный результат.

DELETE:

DELETE /users/{id}

Первый вызов удаляет пользователя. Повторный вызов — ресурс уже удалён, вернуть 204 или 404. Итоговое состояние одинаковое.

Реализация идемпотентности

Сохранение результата:

Сервер поддерживает таблицу (или кеш в Redis):

| Idempotency-Key                      | Status | Response          |
|--------------------------------------|--------|-------------------|
| 550e8400-e29b-41d4-a716-446655440000| 200    | {"transferId":"tr123"} |

При повторном запросе с тем же ключом сервер находит результат и возвращает его без повторной обработки.

На уровне базы данных:

Composite unique key:

CREATE UNIQUE INDEX idx_order_payment ON payments(order_id, external_payment_ref);

Попытка повторного вставления вызывает ошибку, которую можно обработать как "уже обработано".


Синхронная и асинхронная коммуникация между микросервисами

Выбор стиля связи

REST/JSON (синхронная):

Order Service → GET /api/users/{userId} → User Service

Плюсы:

  • знакомо большинству разработчиков;
  • легко отлаживать (HTTP, curl/Postman);
  • HTTP-статус-коды соответствуют состоянию.

Минусы:

  • требует одновременной доступности обоих сервисов;
  • latency кумулируется;
  • сложнее обеспечить идемпотентность.

gRPC/Protobuf (синхронная, эффективная):

service UserService {
  rpc ValidateUser(ValidateUserRequest) returns (ValidateUserResponse);
}

Плюсы:

  • компактные бинарные сообщения (меньше network overhead);
  • двунаправленный стриминг;
  • строгая типизация;
  • лучше для high-throughput.

Минусы:

  • генерируемый код, требует обновления при изменении proto;
  • сложнее отлаживать;
  • требует HTTP/2;
  • требует специализированной библиотеки клиента.

События (асинхронные):

Order Service → publish OrderCreated → message broker → User Service subscribes

Плюсы:

  • слабая связанность (сервисы не знают друг о друге);
  • масштабируемость (можно добавить потребителей);
  • отказоустойчивость.

Минусы:

  • асинхронность (труднее отлаживать);
  • eventual consistency;
  • требует обработки дублирующихся сообщений.

Слабая связанность через API

Минимизация необходимых полей:

// Плохо (излишняя информация):
{
  "id": "ord123",
  "user": {
    "id": "user456",
    "name": "Alice",
    "email": "alice@example.com",
    "phone": "+1234567890",
    "address": { /* полный адрес */ }
  }
}
// Хорошо (только необходимое):
{
  "id": "ord123",
  "userId": "user456",
  "amount": 99.99
}

Отделение внутренних моделей от внешних DTO:

Внутренняя модель может содержать пароли и временные данные. При возврате через API используйте DTO, содержащий только публичные поля.


gRPC и бинарные протоколы

Основные концепции gRPC

gRPC — фреймворк для удалённых вызовов (RPC) поверх HTTP/2 с использованием Protobuf. Используется для синхронной коммуникации между сервисами при высокой пропускной способности и низкой latency.

Преимущества gRPC

Компактность сообщений:

Protobuf использует бинарный формат:

JSON:  {"id":"123","name":"Alice","email":"alice@ex.com"} ≈ 50 bytes
Proto: (то же самое) ≈ 20 bytes

При большом количестве запросов это значительно снижает сетевой трафик.

Строгая типизация:

Proto-файлы — единый источник правды. Генерируются Java/Go/Python классы, исключающие ошибки типизации.

Типы коммуникации:

  1. Unary — простой запрос-ответ
  2. Server streaming — сервер отправляет поток
  3. Client streaming — клиент отправляет поток
  4. Bidirectional streaming — обе стороны одновременно отправляют и получают

Недостатки gRPC

  • Требует специализированного клиента (не работает в браузере);
  • генерируемый код, требует обновления при изменении proto;
  • требует HTTP/2;
  • сложнее отлаживать.

Когда использовать gRPC

  • Внутренняя микросервисная коммуникация с требованиями по latency;
  • high-throughput, low-latency сценарии;
  • полиглотная среда с требованиями к типизации.

Когда не использовать:

  • Публичные API (используйте REST);
  • когда нужна простота и отладка;
  • когда клиенты внешние (браузеры, мобильные без специальной поддержки).

Версионирование API и эволюция контрактов

Зачем нужно версионирование

API постоянно меняется. Версионирование позволяет:

  • развивать API без ломания старых клиентов;
  • постепенно мигрировать на новые версии;
  • поддерживать 2–3 версии одновременно.

Стратегии версионирования

1. Версионирование в пути (URL path versioning):

GET /v1/users/{id}
GET /v2/users/{id}

Плюсы: явно видно в URL, легко отлаживать, просто маршрутизировать. Минусы: дублирование кода, громоздкий URL.

2. Версионирование через заголовки:

GET /users/{id}
Accept: application/vnd.example.v2+json

или

GET /users/{id}
X-API-Version: 2

Плюсы: URL остаётся чистым. Минусы: менее очевидно при отладке.

3. Content negotiation:

GET /users/{id}
Accept: application/vnd.example.v2+json

Стандартный подход IETF.

Backward-compatible vs breaking changes

Backward-compatible (безопасные):

  • Добавление новых опциональных полей;
  • добавление новых значений в enum;
  • добавление новых опциональных параметров запроса;
  • добавление новых эндпоинтов;
  • удаление deprecated полей после предупреждения.

Breaking changes (опасные):

  • Переименование или удаление существующего поля;
  • изменение типа поля;
  • изменение структуры;
  • изменение семантики.

Подход "expand-only" для внутренних контрактов

Если вы контролируете оба конца API, используйте "expand-only": никогда не переименовывайте и не удаляйте поля, только добавляйте новые.

// v1
message User {
  string id = 1;
  string name = 2;
  string email = 3;
}

// v2 (expand-only)
message User {
  string id = 1;
  string name = 2;
  string email = 3;
  string status = 4;     // только добавляем
  string phone = 5;      // только добавляем
  // никогда не удаляем старые поля
}

Это работает, потому что:

  • старые клиенты игнорируют новые поля;
  • новые клиенты могут их использовать;
  • не нужно поддерживать несколько версий proto.

Пагинация, фильтрация, сортировка

Пагинация

Limit/Offset:

GET /orders?limit=10&offset=0
GET /orders?limit=10&offset=10
GET /orders?limit=10&offset=20

Плюсы: просто реализовать, интуитивно понимается, можно прыгнуть на любую страницу.

Минусы: если данные вставляются/удаляются, может быть дублирование или пропуски; медленна на больших offset (full table scan).

Cursor-based:

GET /orders?limit=10&cursor=null
GET /orders?limit=10&cursor=ord_00010
GET /orders?limit=10&cursor=ord_00020

Плюсы: стабильна при вставках/удалениях, эффективна на больших датасетах, гарантирует отсутствие дублей и пропусков.

Минусы: невозможно прыгнуть на 100-ю страницу, сложнее реализовать, требует сохранения состояния на клиенте.

Фильтрация

Параметры запроса:

GET /orders?status=pending&userId=user456
GET /orders?createdAfter=2025-01-01&createdBefore=2025-12-31
GET /orders?minAmount=50&maxAmount=500

Соглашения по имени полей:

  • fieldname или filter_fieldname;
  • minValue, maxValue для диапазонов;
  • createdAfter, createdBefore для дат;
  • q для полнотекстового поиска.

Защита от чрезмерно сложных запросов:

  • максимальное количество условий (не более 10);
  • ограничение по сложности условий;
  • ограничение по длине параметров.

Сортировка

Параметры запроса:

GET /orders?sort=createdAt&order=desc
GET /orders?sort=amount&order=asc
GET /orders?sort=-createdAt  // минус означает desc

Ограничения:

  • сортировка только по индексированным полям;
  • заранее определите, какие поля сортируются.
private static final Set<String> SORTABLE_FIELDS = Set.of(
    "id", "createdAt", "amount", "status", "userId"
);

Rate limiting и защита API

Назначение rate limiting

  • Защита от DoS: злоумышленник не может перегрузить сервис;
  • Защита от багов: ошибка в клиенте не рушит систему;
  • Справедливое распределение ресурсов: один клиент не монополизирует ресурсы.

Виды лимитов

По IP-адресу:

Limit: 100 req/minute per IP

Плюсы: просто реализовать. Минусы: не работает за NAT или при балансировщиках.

По API-ключу/токену:

Authorization: Bearer api_key_12345
Limit: 10000 req/day per API key

Плюсы: гибко, привязано к клиенту. Минусы: требует аутентификации.

По пользователю/организации:

Authorization: Bearer jwt_token_...
Limit: 1000 req/hour per user

Плюсы: справедливое распределение по платёжному плану. Минусы: требует аутентификации.

Ответы при превышении лимита

Статус-код:

429 Too Many Requests

Заголовки с информацией:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1732694400
Retry-After: 60

Поля:

  • X-RateLimit-Limit — максимум запросов;
  • X-RateLimit-Remaining — остаток;
  • X-RateLimit-Reset — Unix timestamp сброса;
  • Retry-After — сколько секунд ждать.

Input validation

Ограничение длины строк:

@Size(min = 1, max = 100)
private String name;

@Email @Size(max = 255)
private String email;

Ограничения по числам:

@Min(1) @Max(1000)
int limit

Белые/чёрные списки:

if (sort != null && !SORTABLE_FIELDS.contains(sort)) {
    throw new BadRequestException("Invalid sort field");
}

На уровне API:

POST /orders
{ "items": [ ... ], "notes": "максимум 5000 символов" }
if (request.getItems().size() > 100) {
    throw new BadRequestException("Max 100 items");
}

Документация и контрактная разработка

Contract-first vs code-first

Contract-first:

  1. Дизайнируется контракт (OpenAPI, Proto);
  2. на его основе генерируются scaffold и SDK;
  3. реализуется логика;
  4. клиенты используют сгенерированные клиенты.

Плюсы: все договариваются до кодирования, меньше интеграционных проблем.

Code-first:

  1. Пишется код;
  2. из кода генерируется документация;
  3. клиенты интегрируются.

Плюсы: быстрее на малых проектах. Минусы: расхождение между доком и кодом.

Использование контрактов для генерации кода

OpenAPI Generator:

openapi-generator-cli generate -i openapi.yaml -g java -o ./generated-client
openapi-generator-cli generate -i openapi.yaml -g spring -o ./generated-server

gRPC:

protoc --java_out=. --grpc-java_out=. orders.proto

BFF (Backend for Frontend)

Концепция

Backend for Frontend (BFF) — это слой API, оптимизированный для конкретного типа клиента.

Mobile App ──────┐
Web App ─────────┤──→ BFF for Mobile
Desktop App ─────┘
                     ├──→ BFF for Web
                     │
                     └──→ Internal Microservices

Каждый клиент общается со своим BFF, который:

  • адаптирует контракты;
  • компонует данные из микросервисов;
  • кэширует;
  • применяет rate limiting.

Преимущества

  • Адаптация под UI: веб требует полных данных, мобильное — минимум;
  • Снижение N+1 problem: BFF делает запросы и компонует результат;
  • Кэширование: BFF кэширует ответы микросервисов;
  • Изоляция: изменения микросервиса не видны клиентам.

Разделение ответственности

Внутренние сервисные API:

  • минимальные, чёткие операции;
  • часто используется gRPC;
  • мелкозернистые ресурсы.

Внешние клиентские API (через BFF):

  • композитные объекты;
  • оптимизированы для UI;
  • адаптированы под нужды клиента.

Консистентность данных и границы транзакций

Почему нельзя делать "толстые" API

API должны отражать границы транзакций. "Толстые" API размывают границы.

Пример плохого дизайна:

POST /orders/process
{ "userId": "user456", "items": [...], "paymentMethod": "card", ... }

// Сервер делает:
// 1. Валидирует пользователя
// 2. Резервирует товар
// 3. Обрабатывает платёж
// 4. Создаёт заказ
// 5. Отправляет уведомление
// всё в одном запросе

Проблемы:

  • откат неатомарный;
  • неясно, где произошла ошибка;
  • невозможно переиспользовать операции;
  • сложно тестировать.

Правильный баланс

Слишком чатти:

1. POST /cart → cartId
2. POST /cart/{id}/items → добавить товар
3. POST /orders → создать заказ
4. POST /payments → платёж

Много запросов, большой latency.

Слишком толстый:

POST /checkout
{ userId, products, coupon, paymentMethod, ... }

Один запрос, но неясно, что происходит.

Правильный баланс:

1. POST /orders { userId, items } → orderId
2. POST /payments { orderId, paymentInfo } → paymentId
3. GET /orders/{id} → актуальный статус

Каждый API:

  • соответствует одной бизнес-операции;
  • имеет чёткий контракт;
  • может быть тестирован независимо;
  • может быть переиспользован в разных контекстах.

Ключевые принципы проектирования API

Основные аспекты, которые нужно обсудить

  1. Стиль: REST для публичного API, gRPC для микросервисов, события для асинхронности.

  2. Модель данных: ясные ресурсы, контракты, DTO.

  3. Идемпотентность: PUT идемпотентны, POST используют Idempotency-Key.

  4. Обработка ошибок: консистентный формат с кодом, сообщением, деталями, traceId.

  5. Версионирование: path versioning для публичного API (/v1, /v2), expand-only для внутреннего.

  6. Пагинация: cursor-based для масштабируемости, limit/offset для простоты.

  7. Rate limiting: по API-ключу, 429 при превышении.

  8. Документация: OpenAPI/Protobuf, contract-first подход.

  9. BFF: адаптированные слои для разных клиентов.

  10. Консистентность: чёткие границы транзакций, ни слишком толстые, ни слишком чатти API.

Антипаттерны, которых нужно избегать

  • Фокус только на URL без контракта;
  • отсутствие идемпотентности;
  • неправильная обработка ошибок;
  • игнорирование версионирования;
  • слишком чатти или слишком толстые API;
  • отсутствие rate limiting;
  • проброс внутренних деталей через API;
  • жёсткое связывание через общую БД.

Заключение

Проектирование API — это архитектурное решение, которое влияет на масштабируемость, отказоустойчивость и удобство интеграции. Ключевые моменты:

  • Контракт — более важен, чем конкретная реализация;
  • Идемпотентность — обязательна в распределённых системах;
  • Эволюция — API будут меняться, нужна стратегия;
  • Слабая связанность — минимизируйте необходимые поля;
  • Чёткие границы — каждый API — одна операция;
  • Защита — rate limiting и валидация обязательны;
  • Документация — единый источник правды.

Хранилища данных

Архитектурная роль хранилищ данных

Выбор хранилища является центральным архитектурным решением, определяющим производительность системы, масштабируемость, надёжность и стоимость эксплуатации. Хранилище взаимодействует со всеми компонентами архитектуры и оказывает прямое влияние на их проектирование.

Ключевые факторы при выборе хранилища

При архитектурном проектировании необходимо рассмотреть:

  • Модель данных: насколько естественно выражаются сущности в выбранной системе (JOIN в реляционной модели, встроенные документы в document store'ах)
  • Нагрузка на чтение/запись: профиль нагрузки влияет на применение индексов, денормализации, кешей и репликации
  • Консистентность: выбор между strong consistency и eventual consistency определяет архитектурные компромиссы
  • Доступность: требования к доступности указывают на необходимость репликации, отказоустойчивости и распределённых стратегий
  • Масштаб данных: объём данных и количество пользователей влияют на выбор между вертикальным и горизонтальным масштабированием

Типы хранилищ и их характеристики

Реляционные базы данных (RDBMS)

RDBMS организуют данные в таблицы со строками и столбцами. Данные связаны через первичные и внешние ключи, запросы выполняются с помощью SQL. Системы гарантируют ACID-свойства и применяются в случаях, где требуется сильная консистентность и сложные связи между данными.

Применение RDBMS:

  • Финансовые системы (бухгалтерия, платежи, транзакции)
  • Системы управления (ERP/CRM, управление клиентами, заказами, складом)
  • Системы с чёткой и стабильной структурой данных

ACID-гарантии:

  • Atomicity (Атомарность): транзакция либо полностью выполняется, либо откатывается целиком
  • Consistency (Согласованность): база данных переходит из одного согласованного состояния в другое с соблюдением всех ограничений целостности
  • Isolation (Изоляция): одновременные транзакции не влияют друг на друга, каждая видит согласованный снимок данных
  • Durability (Стойкость): после подтверждения транзакции данные сохраняются даже при сбое системы

Уровни изоляции транзакций

Уровни регулируют строгость видимости изменений между одновременными транзакциями:

  • Read Uncommitted: транзакция видит незафиксированные изменения других транзакций (грязное чтение)
  • Read Committed: видны только зафиксированные данные, но возможны неповторяемые чтения
  • Repeatable Read: транзакция видит согласованный снимок данных, исключены неповторяемые чтения, но возможны фантомные строки
  • Serializable: максимальная изоляция, транзакции выполняются так, как будто последовательно

Выбор уровня — это компромисс между корректностью и производительностью.

Модель данных в RDBMS: таблицы, ключи и связи

Реляционная модель использует:

  • Первичный ключ: уникальный идентификатор строки, гарантирует уникальность
  • Внешний ключ: ссылка на первичный ключ другой таблицы, обеспечивает целостность ссылок
  • Составной ключ: комбинация нескольких столбцов в качестве уникального идентификатора

Связи выражаются через JOIN'ы в SQL, позволяя гибко комбинировать данные, но требуя обработки этих связей в запросах.

Нормализация

Нормальные формы определяют структуру таблиц для минимизации дублирования и обеспечения целостности:

  • Первая нормальная форма (1NF): каждый столбец содержит атомарное значение
  • Вторая нормальная форма (2NF): все неключевые атрибуты зависят от всего первичного ключа
  • Третья нормальная форма (3NF): неключевые атрибуты не зависят друг от друга через другие неключевые атрибуты

Плюсы нормализации:

  • минимизация дублирования данных, экономия памяти
  • гарантия целостности при изменениях
  • ясная структура, удобство добавления новых сущностей

Минусы нормализации:

  • необходимость JOIN'ов для получения полной информации об агрегате
  • увеличение числа обращений к БД
  • усложнение запросов

На высоконагруженных системах полная нормализация становится узким местом для чтения.

Индексы в RDBMS

Индекс ускоряет поиск на основе B-дерева, обеспечивая поиск за O(log n) вместо O(n).

Типы индексов:

  • Индекс по одному столбцу: ускоряет поиск по этому столбцу
  • Составной индекс: на нескольких столбцах, порядок имеет значение (ускоряет поиск по префиксу)
  • Уникальный индекс: гарантирует уникальность значений

Баланс индексов:

Каждый индекс замедляет INSERT, UPDATE, DELETE операции, так как требует обновления индекса. На read-heavy системах индексы окупаются; на write-heavy требуется осторожный выбор.

Горизонтальное масштабирование RDBMS

Репликация (Primary–Replica):

  • Master получает все write операции
  • Replicas асинхронно применяют изменения из master
  • Read операции распределяются между master и replicas

Плюсы: масштабирование чтения, отказоустойчивость

Минусы: eventual consistency (replicas отстают), read-after-write проблема, сложность failover'а

Шардирование (горизонтальное партиционирование):

Данные разделяются между несколькими БД по шард-ключу (например, по user_id). Каждый шард хранит подмножество данных.

Плюсы: масштабирование очень больших объёмов данных

Минусы: cross-shard запросы сложны, cross-shard транзакции почти невозможны, перебалансировка при добавлении шардов требует движения данных

Партиционирование внутри СУБД

Логическое разделение таблицы внутри одного сервера:

  • Range partitioning: по диапазону значений (например, по дате)
  • Hash partitioning: по хеш-функции от ключа
  • List partitioning: по дискретному списку значений

Позволяет оптимизировать производительность и управление данными.

NoSQL базы данных

Мотивация для NoSQL

С ростом масштабов интернета традиционные RDBMS столкнулись с ограничениями:

  • Горизонтальное масштабирование требует сложного шардирования
  • Высокая нагрузка требует более простых моделей данных
  • Распределённые системы требуют высокой доступности при разделениях сети
  • Структура данных часто неизвестна заранее

BASE вместо ACID

Если ACID описывает свойства RDBMS, то BASE описывает распределённые NoSQL системы:

  • Basic Availability: система остаётся доступной даже при сбоях компонентов
  • Soft State: состояние может измениться из-за асинхронной репликации
  • Eventual Consistency: данные в конечном итоге становятся согласованными

BASE не означает ненадёжность, а представляет другой компромисс: доступность и производительность вместо immediate consistency.

Key-value хранилища

Модель хранения: неструктурированный ключ отображается на значение (строка, число, список, хеш). Нет встроенной поддержки запросов с условиями, агрегаций или JOIN'ов.

Характеристики:

  • O(1) доступ, высокая производительность
  • простое горизонтальное масштабирование
  • отсутствие сложности JOIN'ов

Применение:

  • Кеширование (Redis, Memcached)
  • Хранение сессий
  • Счётчики и рейтинги
  • Конфигурационные данные
  • Rate limiting

Ограничения:

  • Отсутствие возможности поиска без точного ключа
  • Отсутствие связей и JOIN'ов
  • Ограниченные или отсутствующие транзакции

Key-value хранилища используются как дополнительные, а не основные хранилища.

Document-ориентированные базы данных

Модель хранения: документы (JSON-подобные структуры) группируются в коллекции. Каждый документ может иметь свою структуру, поддерживается индексация и запросы по полям.

Преимущества:

  • Гибкая схема, нет enforcement структуры
  • Естественное соответствие объектам приложения
  • Встроенная поддержка вложенных структур и массивов

Применение:

  • Профили пользователей
  • Конфигурации и параметры
  • Каталоги товаров
  • Системы с вариативной структурой данных

Паттерны моделирования:

Embedding (вкладывание):

  • Плюсы: одна операция получает всё, быстрое чтение
  • Минусы: дублирование данных, увеличение размера документа

Reference (ссылка):

  • Плюсы: отсутствие дублирования
  • Минусы: требуется дополнительный запрос, возможны аномалии консистентности

На практике часто применяется комбинация: денормализованные данные для частых операций и ссылки для стабильных данных.

Wide-column (колоночные) хранилища

Модель данных: таблицы с динамическими семействами столбцов. Данные организованы вокруг partitioning key и sort key, оптимизированы для быстрых записей и последовательного доступа.

Оптимизация:

  • Write-optimized: данные сначала в памяти (memtable), затем на диск (sstable)
  • Range queries: быстрый доступ к диапазону строк
  • Time series: эффективное хранение метрик и логов

Применение:

  • Time series data (метрики, котировки, IoT)
  • Логирование и event streams
  • Аналитика
  • High-write нагрузки

Особенность: query-driven design — таблицы моделируются от запросов, не от сущностей.

Графовые базы данных

Модель данных: вершины (nodes) и рёбра (edges), где отношения являются первоклассным объектом системы.

Преимущества:

  • Естественное выражение связей
  • Быстрые обходы графа

Применение:

  • Социальные графы
  • Рекомендательные системы
  • Fraud detection
  • Анализ зависимостей

Ограничения:

  • Сложность горизонтального масштабирования
  • Узкий набор сценариев использования
  • Отсутствие стандартизации языков запросов

CAP-теорема и её применение

CAP: фундаментальный компромисс

CAP-теорема гласит, что при сетевом разделении система вынуждена выбрать между двумя из трёх свойств:

  • Consistency (Консистентность): все клиенты видят одинаковые данные
  • Availability (Доступность): система остаётся функциональной
  • Partition tolerance (Стойкость к разделению): система работает при разделении сети

Практические выборы:

  • CP-системы: гарантируют консистентность, жертвуют доступностью при разделении сети
  • AP-системы: гарантируют доступность, допускают временную несогласованность

PACELC: расширение CAP

PACELC дополняет CAP для нормальных условий работы:

  • P (Partition): при разделении сети выбирайте между A и C
  • E (Else): без разделения выбирайте между L (latency) и C (consistency)

Примеры:

  • PA/EL (DynamoDB, Cassandra): при разделении приоритет доступности, в норме оптимизация задержки
  • PC/EC (PostgreSQL synchronous): при разделении консистентность, в норме консистентность в приоритете
  • PC/EL (Google Spanner): консистентность в обоих случаях, но оптимизация задержки

Консистентность в распределённых системах

Strong consistency vs eventual consistency

Strong consistency (immediate consistency):

После операции write все последующие операции read видят обновленные данные. Гарантия: в каждый момент существует одна правда.

Eventual consistency:

После write есть период, когда реплики содержат разные значения. Гарантия: при отсутствии новых write операций, рано или поздно все реплики синхронизируются.

Частные гарантии консистентности

Read-your-write (RYW) consistency:

После write операции, если тот же клиент прочитает данные, увидит своё обновление. Реализуется через sticky sessions или версионирование.

Monotonic reads (MR) consistency:

Клиент никогда не видит старые значения после прочтения более новых. Реализуется через версионирование.

Causal consistency:

Если операция B причинно зависит от операции A, все реплики видят A перед B. Требует логических часов или версионирования.

Выбор между хранилищами

Матрица решений

Характеристика RDBMS Document Key-value Wide-column Graph
Модель данных Таблицы, JOIN'ы JSON, вложенные Ключ-значение Семейства столбцов Вершины, рёбра
Консистентность Strong Eventual Eventual Eventual Варьируется
Масштабирование Вертикальное Горизонтальное Горизонтальное Горизонтальное Сложное
Write производительность Средняя Хорошая Отличная Отличная Средняя
Read производительность Хорошая Хорошая Отличная Хорошая Хорошая для графа
Транзакции ACID Ограниченные Отсутствуют Отсутствуют Ограниченные
Сложность JOIN'ов Встроенная Ручная N/A N/A Встроенная

Критерии выбора по сценариям

Финансовые и платежные системы:

Выбор: RDBMS (PostgreSQL, Oracle) как основное хранилище + Redis для кеша

Причины: ACID необходима, структура чёткая, strong consistency критична, ошибки в eventual consistency приводят к потере денег

Логирование и аналитика:

Выбор: Wide-column (Cassandra, BigTable) или event log (Kafka)

Причины: экстремально высокий write volume, простая структура, eventual consistency приемлема

E-commerce: каталог и профили:

Выбор: Document store (MongoDB) для каталога + RDBMS для финансов

Причины: гибкая структура товаров, денормализованные профили; финансовые операции требуют ACID

Социальная сеть:

Выбор: RDBMS для основных сущностей + Redis для лент + специализированное хранилище для feed'ов

Причины: целостность, производительность лент, экстремальные читающие нагрузки

Нормализация и денормализация: архитектурный уровень

Нормализованный source of truth

Определение: source of truth — основное, авторитетное хранилище, где хранится истинное состояние системы.

Почему важно:

  • Определяет, какие данные правильные при несогласованности
  • Гарантирует целостность при восстановлении
  • Упрощает архитектуру системы

Денормализованные проекции

Проекция (view, materialized view) — представление данных, оптимизированное под конкретный сценарий использования.

Примеры проекций:

  • Кеш (Redis): быстрый доступ, TTL для инвалидации
  • Search index (Elasticsearch): полнотекстовый поиск
  • Materialized view: table, обновляемая по триггеру
  • Документное хранилище: денормализованные агрегаты
  • Data warehouse: факты для аналитики

Синхронизация:

  • Push-based: события из source of truth → message broker → consumers обновляют проекции
  • Pull-based: batch job'ы периодически обновляют проекции
  • Streaming: CDC или логи репликации обновляют проекции в реальном времени

Архитектурный паттерн

Типичная архитектура:

  1. PostgreSQL: source of truth, нормализованные данные
  2. Redis: кеш часто запрашиваемых данных
  3. Elasticsearch: индекс для полнотекстового поиска
  4. MongoDB: денормализованные проекции
  5. Data warehouse: аналитические данные

События синхронизируют все хранилища через message broker (Kafka) или CDC.

Кеширование

Паттерны кеширования

Cache-aside (Lazy loading):

  1. Приложение проверяет кеш
  2. При miss читает из БД
  3. Обновляет кеш для будущих запросов

Плюсы: гибко, приложение контролирует логику

Минусы: приложение отвечает за консистентность

Read-through:

Кеш автоматически загружает из БД при miss. Плюсы: просто, минусы: требуется конфигурация кеша.

Write-through:

При write приложение пишет и в кеш, и в БД синхронно. Плюсы: гарантия синхронизации, минусы: медленнее.

Write-behind (Write-back):

При write приложение пишет в кеш, кеш асинхронно пишет в БД. Плюсы: быстро, минусы: риск потери при crash'е кеша.

Инвалидация кеша

TTL (Time To Live): автоматическое удаление через время

Explicit invalidation: приложение явно удаляет при изменении данных

Event-driven invalidation: события из message broker инвалидируют кеш

Cache stampede

Проблема: популярный ключ истекает, сотни запросов одновременно идут в БД.

Решения:

  • Probabilistic early expiration
  • Locking на первый запрос при miss
  • Lazy loading с async refresh'ем в фоне

Расчёт нагрузок и ёмкостей

Оценка требуемого объёма хранилища

Основная формула: Storage = (объём одного объекта) × (количество объектов)

Пример: система с 100 миллионами пользователей, профиль каждого ≈ 5 KB:

Storage = 5 KB × 100,000,000 = 500 GB

С репликацией (factor 3): 1.5 TB

Факторы:

  • Индексы: обычно +30–50% от размера данных
  • Логи репликации (WAL): +10–20%
  • Запасный буфер: +20–50%

Оценка требуемой пропускной способности

IOPS (Input/Output Operations Per Second): количество операций в секунду

Пример: система с 1 миллионом пользователей, каждый пользователь генерирует 10 операций в день:

IOPS = (1,000,000 users × 10 ops/day) / (86,400 sec/day) ≈ 116 IOPS

С пиковой нагрузкой (10x): 1,160 IOPS

Оценка для read-heavy системы (80% read, 20% write):

Total IOPS = 1,160 / 0.2 = 5,800 IOPS (всего)
Read IOPS = 5,800 × 0.8 = 4,640
Write IOPS = 5,800 × 0.2 = 1,160

Оценка латентности

Типичные латентности при доступе:

  • SSD случайный доступ: 1–10 мс
  • SSD последовательный доступ: 0.1–1 мс
  • RAM доступ: 0.001–0.01 мс
  • Сетевой round-trip: 1–10 мс (LAN), 100+ мс (интернет)
  • JOIN операция: добавляет latency пропорционально количеству таблиц

Пример: SELECT с 3 JOIN'ами может быть 10–50 мс на обычной конфигурации. С кешем в Redis (0.5–1 мс): значительное улучшение.

Выбор ёмкости хранилища

Оценка машины:

  • PostgreSQL на одной машине: обычно 100–500 GB данных практично, большего требуется шардирование
  • Redis (в памяти): 16–256 GB на машину, за пределами требуется clustering
  • Elasticsearch: 50–100 GB на node (с 3-4 replicas это 200–400 GB total storage)

Правило большого пальца: планируйте на 10-кратный рост без архитектурных изменений.

Миграции схемы БД

Типы миграций

Additive: добавление столбцов, таблиц (обычно безопасно)

Destructive: удаление столбцов, таблиц (требует осторожности, может быть потеря данных)

Structural: изменение типов, переименование (требует тестирования)

Стратегии zero-downtime миграций

Blue-green deployment:

  1. Запустить новую версию с новой схемой (green)
  2. Перенести данные из blue в green
  3. Перенаправить трафик на green
  4. Снести blue

Canary deployment:

  1. Мигрировать часть данных
  2. Направить процент трафика на новую схему
  3. Постепенно увеличивать процент
  4. Полностью перейти на новую схему

Инструменты: Flyway, Liquibase (Java), управляют версионированием и применением миграций

Аналоги и альтернативные подходы

Выбор между RDBMS продуктами

Характеристика PostgreSQL MySQL Oracle MariaDB
Масштабируемость Хорошая Средняя Отличная Средняя
ACID Полная Полная (InnoDB) Полная Полная (InnoDB)
Стоимость Бесплатная Бесплатная Дорогая Бесплатная
Функции Rich Базовые Rich Базовые+
Надёжность Высокая Средняя Высокая Средняя

PostgreSQL предпочтителен для большинства систем благодаря мощи и надёжности.

Выбор между NoSQL документных хранилищ

Характеристика MongoDB CouchDB Firebase DynamoDB
Масштабируемость Горизонтальная Локальная Облачная Облачная
Консистентность Tunable Eventual Strong Tunable
Стоимость Хост + лицензия Бесплатная Pay-per-use Pay-per-use
Запросы Rich View-based Структурированные Ключ-значение
Подходит для Гибкая схема Distributed sync Мобильные приложения Serverless

Выбор между Key-value хранилищами

Характеристика Redis Memcached DynamoDB Aerospike
Тип памяти RAM + RDB RAM only Управляемая RAM + SSD
Структуры данных Rich String only Ключ-значение Ключ-значение
Persistence Есть Нет Управляемая Есть
Transact'ы Базовые Нет Ограниченные Нет
Масштабируемость Clustering Sharding external Встроенная Clustering

Redis используется для большинства кеширования благодаря структурам данных и надёжности.

Выбор между Wide-column хранилищами

Характеристика Cassandra HBase Bigtable DynamoDB
Модель Peer-to-peer Master-slave Управляемая Управляемая
Консистентность Eventual Strong Strong Tunable
Write throughput Отличная Хорошая Отличная Масштабируемая
Задержка 10–100 мс 1–10 мс 1–10 мс Варьируется
Оперативность Сложная Сложная Проще Проще

Cassandra для P2P распределённых систем, Bigtable/DynamoDB для облачных.

Резюме и принципы выбора

Систематический подход

  1. Определить модель данных: сущности, связи, структура
  2. Оценить нагрузку: read-heavy vs write-heavy, объём операций, объём данных
  3. Определить требования к консистентности: strong vs eventual
  4. Выбрать тип хранилища: на основе пунктов 1–3
  5. Планировать масштабирование: как система вырастет?
  6. Добавить слой кеша: для оптимизации
  7. Спланировать миграции и восстановление: RTO, RPO

Отсутствие серебряной пули

Единого идеального хранилища не существует. Правильное решение:

  • Совокупность хранилищ, каждое для своей роли
  • Нормализованный source of truth
  • Денормализованные проекции для оптимизации
  • Кеш для скорости

Эта архитектура требует управления консистентностью между хранилищами, но обеспечивает масштабируемость и производительность.

Практические примеры и выбор архитектур

Типовые архитектуры для разных доменов

1. Социальная сеть / Messenger

Стек хранилищ:

Профили + Посты (основные данные)
    ├─ PostgreSQL (RDBMS)
    │  ├─ Таблица users (нормализованные профили)
    │  ├─ Таблица posts (базовые данные)
    │  └─ Таблица user_follows (граф дружб)
    │
    ├─ Redis (кеш)
    │  ├─ user:{id} → JSON профиля (TTL 1 час)
    │  ├─ post:{id}:likes → счётчик лайков
    │  └─ feed:{user_id} → кеш ленты (TTL 30 мин)
    │
    ├─ Elasticsearch (индекс)
    │  └─ Индекс всех постов для полнотекстового поиска
    │
    ├─ Cassandra (лента и временные ряды)
    │  ├─ user_feed (лента пользователя, отсортирована по timestamp)
    │  └─ post_engagement (лайки, комментарии, по времени)
    │
    ├─ Kafka (event log)
    │  ├─ user_created
    │  ├─ post_created
    │  ├─ post_liked
    │  └─ user_followed
    │
    └─ S3/GCS (медиа)
       └─ Фото, видео, прикрепления

Принцип работы чтения профиля:

1. App запрашивает профиль user_id
2. Проверить Redis user:{id}
3. Cache hit → вернуть JSON (1 ms)
4. Cache miss → запрос PostgreSQL (5 ms)
5. Кешировать в Redis (TTL 1 час)
6. Вернуть клиенту

Принцип работы ленты:

1. App запрашивает ленту пользователя
2. Проверить Redis feed:{user_id}
3. Cache hit → вернуть список постов (1 ms)
4. Cache miss → запрос Cassandra (10–30 ms)
   SELECT * FROM user_feed WHERE user_id = ? 
   ORDER BY timestamp DESC LIMIT 20
5. Обогатить данные: получить профили авторов, счётчики лайков из Redis
6. Кешировать результат в Redis (TTL 30 мин)
7. Вернуть клиенту

Синхронизация при новом посте:

1. User создаёт пост
2. Запись в PostgreSQL
3. Отправить событие в Kafka (post_created)
4. Consumer'ы Kafka:

   - Индексируют в Elasticsearch
   - Добавляют в Cassandra user_feed подписчиков
   - Инвалидируют Redis кеш лент подписчиков
5. Счётчик лайков обновляется в Redis (volatile)
   Периодически sync в PostgreSQL (batch job)

2. E-commerce система

Стек хранилищ:

Каталог товаров
    ├─ MongoDB (document store)
    │  └─ Коллекция products (гибкая схема)
    │     {
    │       "_id": "prod_123",
    │       "name": "Товар",
    │       "price": 99.99,
    │       "attributes": {...}, // варьируется по типу товара
    │       "reviews": [...]
    │     }
    │
    ├─ Redis (кеш)
    │  ├─ product:{id} → JSON товара
    │  ├─ cart:{user_id} → содержимое корзины
    │  └─ inventory:{product_id} → текущий stock
    │
    └─ Elasticsearch
       └─ Индекс товаров для поиска и фильтрации

Заказы и платежи (финансовые)
    ├─ PostgreSQL (RDBMS, ACID)
    │  ├─ Таблица orders
    │  ├─ Таблица order_items
    │  ├─ Таблица payments
    │  └─ Таблица inventory (stock с lock'ами)
    │
    ├─ Redis (временный cache)
    │  └─ order:{id} → статус заказа (TTL 24 часа)
    │
    └─ Kafka (event log)
       ├─ order_created
       ├─ payment_processed
       └─ inventory_updated

Рекомендации
    ├─ Redis (горячие данные)
    │  └─ recommendations:{user_id} → список товаров
    │
    ├─ Cassandra (история покупок)
    │  └─ user_purchases (partition: user_id, cluster: timestamp)
    │
    └─ Batch job (нightly)
       └─ Analyse purchases → обновить recommendations в Redis

Процесс checkout'а:

1. User нажал «купить»
2. BEGIN TRANSACTION в PostgreSQL
3. SELECT inventory WHERE product_id = ? FOR UPDATE (блокировка)
4. Проверить stock >= quantity
5. UPDATE inventory SET quantity = quantity - quantity
6. INSERT INTO orders
7. INSERT INTO order_items
8. INSERT INTO payments (если платёж успешен)
9. COMMIT
10. Отправить событие order_created в Kafka
11. Инвалидировать Redis cart:{user_id}
12. Обновить recommendations (асинхронно)

Синхронизация инвентаря:

Inventory в Redis (кеш):
├─ product:123:stock → 150 (счётчик)
├─ DECR при добавлении в корзину (optimistic)
└─ Periodically reconcile с PostgreSQL

Если stock < 0 в Redis:
└─ Отклонить заказ при checkout'е (PostgreSQL имеет правду)

3. Финансовая система / Платежи

Стек хранилищ:

Основная БД (источник истины)
    └─ PostgreSQL (ACID, strong consistency)
       ├─ accounts (счета пользователей)
       ├─ transactions (все платежи)
       ├─ transaction_ledger (дебет/кредит для reconciliation)
       └─ user_balance_history (audit trail)

Replica для read-only (аналитика, отчёты)
    ├─ PostgreSQL Replica (async, отстаёт на несколько сек)
    │
    ├─ Redis (кеш)
    │  ├─ account:{id}:balance → текущий баланс (TTL 5 мин)
    │  └─ fraud_score:{user_id} → risk score (TTL 1 час)
    │
    └─ Elasticsearch (логирование)
       └─ Индекс для поиска по транзакциям

Архив (долгосрочное хранение)
    ├─ PostgreSQL архив (холодная таблица, partitioned по дате)
    │  └─ transactions_2023_* partitions
    │
    └─ Glacier / Archive (облачное хранилище)
       └─ Резервные копии старше 1 года

Гарантии при платеже:

Требования:
├─ RTO: 1 час (время восстановления)
├─ RPO: 15 минут (потеря данных max)
├─ ACID для каждого платежа
└─ No duplicate payments

Реализация:
├─ Синхронная репликация PostgreSQL на Standby
│  └─ Платёж записывается на Primary, затем на Standby
│  └─ Commit возвращается только после обоих
│
├─ Incremental backup каждые 15 минут
│  └─ WAL архивируется
│
├─ Idempotency key на клиенте
│  └─ transaction_id должна быть unique
│  └─ Повторный платёж с тем же ID возвращает ранний результат
│
└─ Audit log
   └─ Все платежи логируются для compliance

4. Логирование и мониторинг

Стек хранилищ:

Real-time инжест
    └─ Kafka (Message broker)
       ├─ Partition по server_id для порядка внутри сервера
       └─ Retention 24 часа

Time-series хранилище (горячие данные)
    ├─ Cassandra
    │  ├─ logs_by_server (partition: server_id+date, cluster: timestamp DESC)
    │  ├─ logs_by_level (partition: level+date, cluster: timestamp DESC)
    │  └─ Replication factor 3
    │  └─ Retention 30 дней
    │
    └─ InfluxDB (или Prometheus)
       └─ Метрики (CPU, memory, latency)

Full-text search
    └─ Elasticsearch
       ├─ Index logs (type: log)
       ├─ Rolling indexes logs-2024.01.15
       └─ Retention 7 дней (для performance)

Archive
    └─ S3 Glacier
       └─ Все логи > 30 дней (compressed)
       └─ Retention 1–7 лет (для compliance, audit)

Процесс инжеста:

1. Сервер отправляет лог в Kafka
2. Kafka buffer (smoothing peaks)
3. Consumer читает из Kafka
4. Пишет в Cassandra (async, buffered writes)
5. Пишет в Elasticsearch (async, bulk indexing)
6. Архивирует в S3 (batch job, ежедневно)

Запросы:

«Все ошибки сервера server_01 за последний час»
├─ Elasticsearch query (быстро: 10–50 ms)
│  GET logs/_search?q=server:server_01 level:ERROR timestamp:[now-1h TO now]

«Все логи пользователя за месяц»
├─ Cassandra query
│  SELECT * FROM logs_by_server 
│  WHERE server_id = 'server_01' AND date >= '2024-01-01'
│  ORDER BY timestamp DESC

«Какие сервера most frequently error'ят»
├─ Batch job на data warehouse
│  SELECT server_id, COUNT(*) as error_count
│  FROM logs_archive WHERE level = 'ERROR'
│  GROUP BY server_id
│  (Запускается раз в день)

Сравнение подходов для типовых проблем

Проблема: Очень быстро растущая система

Начало (день 1):

PostgreSQL single node
└─ Всё в одной БД

Неделя 1–2:

PostgreSQL + Redis
├─ Primary → Read replica
└─ Кеш горячих данных

Месяц 1–3:

PostgreSQL (primary-replica-replica)
├─ Redis (кеш)
├─ Elasticsearch (индекс для поиска)
└─ Kafka (event log для background jobs)

Месяц 3–6:

PostgreSQL (шардирование начинается)
├─ Shard 1: user_id 0–1M
├─ Shard 2: user_id 1M–2M
├─ Shard 3: user_id 2M–3M
└─ Остальной стек (кеш, индекс, логирование)

Месяц 6+:

Полиglot persistence
├─ PostgreSQL (основные данные, ACID)
├─ Cassandra (временные ряды, аналитика)
├─ Redis (кеш)
├─ Elasticsearch (поиск)
├─ Neo4j (социальный граф)
└─ S3 (медиа)

Проблема: Максимизация uptime

Минимальная архитектура (99.9% = ~44 минуты downtime/месяц):

Single PostgreSQL + Redis
├─ Backup каждый день
├─ Manual failover (1–2 часа)
└─ Acceptable для non-critical систем

Хорошая архитектура (99.99% = ~4 минуты downtime/месяц):

PostgreSQL Primary ↔ Standby (синхронная репликация)
├─ Automatic failover (1–2 минуты)
├─ Incremental backups каждый час
├─ Redis для кеша (потеря кеша — не критична)
└─ Suitable для большинства production систем

High-availability архитектура (99.999% = ~26 секунд downtime/месяц):

Data center 1:          Data center 2:
PostgreSQL Primary  ←→  PostgreSQL Standby
    ↓                       ↓
  Redis              (async replication)
    ↓
Load balancer (выбирает healthy datacenter)

├─ Synchronous replication к primary
├─ Async replication между DC'центрами
├─ Automatic geo-failover
├─ RPO: 0 (синхронно), RTO: 1–2 сек
└─ Suitable для финансовых / телеком систем

Проблема: Обработка экстремально высокой нагрузки

read-heavy (e.g., 100K read IOPS, 1K write IOPS):

Write → PostgreSQL Primary
Read → 
  ├─ Redis (90% hit rate) → 90K reads/sec
  ├─ Read replicas (9K reads/sec)
  └─ Fallback к primary (1K reads/sec)

Total throughput: 100K reads/sec достижимо

Write-heavy (e.g., 100K write IOPS):

Cassandra cluster (10 nodes)
├─ 10K write IOPS на node
└─ 100K total write IOPS

PostgreSQL не справит (max ~10K IOPS одной машины)

Mixed нагрузка с очень большим объёмом (petabytes):

Отделить по типам данных:
├─ Hot data (last 30 days) → PostgreSQL / Redis
├─ Warm data (1–12 месяцев) → Cassandra / HBase
└─ Cold data (архив) → S3 / Glacier

Routing logic на уровне приложения:
  if (age < 30 days) query PostgreSQL
  else if (age < 12 months) query Cassandra
  else query S3 archive

Типичные ошибки выбора

Ошибка 1: Выбор single технологии на все задачи

Неправильно:

Хранилище: MongoDB
├─ Финансовые операции (требуют ACID)
├─ Логирование (требуют high write throughput)
├─ Поиск (требуют полнотекстового индекса)
└─ Граф дружб (требуют traversal optimizations)

MongoDB может справить со всем, но неоптимально для каждого.

Правильно:

├─ PostgreSQL → финансовые операции
├─ Cassandra → логирование
├─ Elasticsearch → поиск
└─ Neo4j → социальный граф

Ошибка 2: Игнорирование консистентности требований

Неправильно:

Платежная система на DynamoDB (eventual consistency)
└─ Пользователь может дважды потратить деньги (race condition)

Правильно:

Платежная система на PostgreSQL (strong consistency)
└─ Платёж атомарен, нет race conditions

Ошибка 3: Забыт слой кеша

Неправильно:

Каждый запрос profile → PostgreSQL
└─ 100K RPS × 5 ms = высокая latency, нагрузка на БД

Правильно:

Запрос profile:
├─ Redis (TTL 1 час) → 1 ms, 90% hit rate
├─ PostgreSQL (10% miss) → 5 ms
└─ Средняя latency: 1.4 ms
└─ Нагрузка на БД: 10K RPS вместо 100K

Ошибка 4: Масштабирование запоздало

Неправильно:

День 1: PostgreSQL single node
День 30: 10M users, PostgreSQL на пределе
День 31: Срочно шардировать (24-часовой downtime)

Правильно:

День 1: PostgreSQL + Redis (заранее)
День 30: 10M users, система спокойно работает
День 60: Когда подойдёт time, добавить шардирование (zero downtime)

Контрольный список для Design Review

При выборе хранилищ для нового проекта:

Требования

Выбор хранилища

Слои оптимизации

Операционные требования

Мониторинг и наблюдение

Кеширование

Роль кеширования в архитектуре систем

Кеширование — один из основных инструментов оптимизации производительности и масштабируемости. При проектировании нагруженных систем необходимо тщательно рассчитать эффект кеширования и его влияние на архитектуру.

Основной эффект кеширования: расчёты нагрузки

Снижение latency: Доступ к данным из памяти (микросекунды) на 1000-10000 раз быстрее, чем из БД (миллисекунды) или диска (десятки миллисекунд).

Разгрузка БД: При hit ratio = 80% нагрузка на БД падает в 5 раз. Это позволяет обслуживать в 5 раз больше пользователей на той же конфигурации.

Пример расчёта:

  • Без кеша: 10 000 запросов/сек → 10 000 запросов в БД
  • С кешем при hit ratio 80%: 10 000 запросов/сек → 2 000 запросов в БД (скоращение в 5 раз)
  • Пропускная способность БД повышается за счёт кеша, позволяя служить в 5 раз больше пользователей

Снижение затрат на инфраструктуру: Дорогая БД требуется меньше; дешевая оперативная память справляется с пиками нагрузки; можно отложить масштабирование железа на 6-12 месяцев.

Улучшение качества обслуживания: Быстрый отклик приложения, плавная работа, лучший user experience.

Понимание компромиссов кеша

Кеширование — это компромисс между скоростью доступа и актуальностью данных. Кеш никогда не может быть полнотекущей копией БД, особенно для быстро изменяющихся данных. Понимание этих ограничений критично для выбора правильной стратегии.

Ключевые метрики кеша

Hit ratio (коэффициент попаданий) = hits / (hits + misses) показывает долю запросов, успешно обработанных кешем:

  • Менее 50% — кеш малоэффективен, требуется пересмотр стратегии
  • 50-80% — хорошее кеширование
  • Более 90% — отличное кеширование

Cache latency: Hit latency обычно < 1 мс. Рост до 10 мс указывает на проблемы (перегрузка, сетевые проблемы).

Memory efficiency: Соотношение размера кеша к количеству запросов, которые он экономит.

Eviction rate: Количество ключей, удаляемых в секунду из-за нехватки памяти. Высокий eviction rate указывает на недостаточный размер кеша.

Базовые понятия кеширования

Cache hit — запрос найден в кеше. Возврат данных быстр и эффективен.

Cache miss — запрос не найден в кеше. Система обращается к нижнему уровню (БД, API, диск). Это основной фактор, подлежащий оптимизации.

Warm cache (прогретый кеш) — кеш содержит популярные данные, hit ratio высокий, система работает эффективно. Достигается через pre-warming (предварительную загрузку) перед пиками.

Cold cache (холодный кеш) — только что запущенная система или очищенный кеш. Все первые запросы — miss'ы. Latency растёт, нагрузка на БД скачет вверх. Это явление требует специальной обработки через механизмы pre-warming и graceful fallback.

TTL (time to live) — время жизни записи в кеше. После истечения TTL запись считается невалидной. Пример: TTL=300 означает, что данные живут 5 минут, затем должны быть переполучены из источника.

Soft vs hard expiration:

  • Hard expiration: когда TTL истёк, данные удаляются, cache miss гарантирован
  • Soft expiration: данные помечены как устаревшие, но остаются доступны; в фоне происходит обновление

Eviction — удаление данных из кеша для освобождения памяти. Критичный механизм для ограниченной памяти.

Стратегии вытеснения данных

LRU (Least Recently Used) — удаляются давно не использовавшиеся данные. Логика: неиспользованные данные менее вероятно потребуются вновь. Это стандарт для большинства кешей.

LFU (Least Frequently Used) — удаляются редко используемые данные. Логика: редкие данные менее ценны, чем частые.

FIFO (First In, First Out) — удаляются самые старые записи независимо от частоты использования. Проще в реализации, но менее эффективно.

Random — случайное удаление. Простое, но непредсказуемое.

На практике LRU используется чаще всего благодаря хорошему балансу между эффективностью и сложностью реализации.

Source of truth — основное хранилище истинных данных, обычно БД. Кеш всегда вторичен: это копия, потенциально устаревшая, которая может быть потеряна без последствий для целостности системы.

Уровни кеширования в архитектуре

Комплексная система использует кеши на нескольких уровнях одновременно, каждый из которых оптимизирован под конкретный тип данных и нагрузки.

1. Кеширование на стороне клиента

Браузерный кеш: Браузер кеширует HTTP-ответы согласно заголовкам Cache-Control, ETag, Last-Modified, Expires.

Плюсы:

  • Нулевая нагрузка на сервер
  • Максимальная скорость доступа
  • Работает оффлайн

Минусы:

  • Сложное управление инвалидацией
  • Разные браузеры ведут себя по-разному
  • Ограниченный контроль версионирования

Типичное использование: статические ресурсы (CSS, JS, изображения) с TTL от часов до дней.

Мобильные/desktop клиенты: Локальное хранилище (SQLite, файловая система).

Плюсы: работает оффлайн, не требует интернета для доступа к предыдущим данным

Минусы: синхронизация между устройствами, версионирование, управление конфликтами при одновременных изменениях

2. Кеширование на edge (граничных узлах)

CDN (Content Delivery Network): Статический контент (изображения, видео, файлы) хранится на серверах, географически близких к пользователям.

Плюсы:

  • Глобальное распределение контента
  • Низкая latency для пользователей по всему миру
  • Масштабируемость

Минусы:

  • Требуется правильная настройка TTL
  • Инвалидация может быть медленной
  • Отсутствие персонализированного кеша

Edge API caching: Некоторые edge-сервисы (Cloudflare, Akamai) кешируют GET-запросы к API.

Применимо для: публичных ответов, идентичных для всех пользователей (например, списки стран, курсы валют)

Не применимо для: персонализированных данных (профиль пользователя, его заказы, приватная информация)

3. Кеширование на уровне gateway/reverse proxy

Reverse proxy (Nginx, HAProxy) может кешировать ответы от backend'а с критериями:

  • По пути: /api/products кешируется, /api/users/profile не кешируется (персональный)
  • По заголовкам: если ответ содержит Cache-Control: no-cache, не кешируется
  • По размеру: кешируются только ответы до определённого размера (например, до 1 МБ)

Микрокеши на несколько секунд: На уровне gateway устанавливается TTL = 5 секунд для популярных endpoint'ов. Это амортизирует пики нагрузки: если 1000 пользователей запросят один ресурс за 5 секунд, gateway пробивает в backend только один запрос.

Расчёт эффекта:

  • RPS (requests per second) = 1000
  • TTL = 5 сек
  • Backend получит: 1000 / 5 = 200 RPS (вместо 1000)
  • Нагрузка снижена в 5 раз

Преимущество: един точка кеширования для всех инстансов приложения. Не требует логику в коде каждого приложения.

4. Кеширование внутри приложения (in-memory)

Локальные LRU-кеши в памяти процесса:

  • Простейший вариант: ConcurrentHashMap с ручной реализацией LRU
  • Продвинутый вариант: встроённые структуры (LinkedHashMap в Java)
  • Высокий уровень: библиотеки с готовой логикой автоматического вытеснения

Плюсы:

  • Максимальная скорость (памяти процесса, без сетевых задержек)
  • Не требуется отдельный сервис
  • Гибкость в управлении логикой

Минусы:

  • Каждый инстанс приложения имеет свой независимый кеш
  • При масштабировании на N инстансов, данные размножаются в N раз
  • При перезагрузке инстанса кеш теряется
  • Риск утечек памяти при неправильном управлении LRU

Типичное использование: кеширование конфигураций, справочников (справочники, которые редко меняются), результаты вычисления feature flags.

5. Распределённые кеши

Redis, Memcached: Отдельный сервис (или кластер), доступный всем инстансам приложения.

Технологии:

  • Redis: более продвинутый, поддерживает различные структуры данных, персистентность, репликация
  • Memcached: простой, очень быстрый, только key-value, in-memory only

Плюсы:

  • Все инстансы видят один и тот же кеш, нет размножения данных
  • Масштабируемость кеша независимо от приложения
  • Встроенная поддержка кластеризации
  • Redis поддерживает персистентность (RDB, AOF)

Минусы:

  • Сетевая latency (хотя она остаётся низкой)
  • Дополнительная инфраструктура для масштабирования и отказоустойчивости
  • Риск: если кеш упадёт, весь трафик перенаправляется в БД (cache stampede)
  • Требует управления репликацией и sharding'ом

Расчёт пропускной способности Redis:

  • Redis single-threaded: примерно 50 000 - 100 000 ops/sec (в зависимости от размера данных)
  • Для 100 000 RPS требуется кластер из минимум 10 Redis узлов
  • При hit ratio 80%, 100 000 RPS → 20 000 запросов в Redis → один узел справляется

Комбинированный подход: многоуровневое кеширование

На практике Senior-система использует несколько уровней одновременно:

  1. Браузерный кеш (HTTP-заголовки) для статики
  2. CDN для глобально востребованного контента
  3. Gateway кеш на несколько секунд для амортизации пиков
  4. Локальный in-memory кеш в приложении для справочников и конфигов
  5. Распределённый Redis для горячих данных (профили, заказы, сессии)

Каждый уровень кеширует разные типы данных с разными TTL. Это обеспечивает оптимальное соотношение скорости, консистентности и масштабируемости.

Пример многоуровневого кеша для микросервисной архитектуры e-commerce:

  • Статика (CSS, JS, images): CDN, TTL=30 дней
  • Список категорий: Gateway, TTL=1 час + локальный кеш, TTL=1 день
  • Детали продукта: Gateway, TTL=5 минут + Redis, TTL=1 час
  • Корзина пользователя: Redis только, TTL=24 часа
  • Результаты поиска: Redis, TTL=5 минут (зависит от качества поиска и требований)

Паттерны кеширования

Паттерн кеширования определяет алгоритм взаимодействия между приложением, кешем и источником данных. Выбор паттерна зависит от требований консистентности, производительности и архитектуры.

Cache-aside (lazy loading)

Алгоритм:

  1. Приложение попытается прочитать данные из кеша
  2. При hit — возвращаем данные
  3. При miss — идём в БД, получаем данные, кладём в кеш, возвращаем приложению
if (cache.contains(key)) {
    return cache.get(key);
}
value = database.get(key);
cache.put(key, value);
return value;

Плюсы:

  • Простота реализации, логика вся на стороне приложения
  • Кеш — просто хранилище, не нужна специальная конфигурация
  • Гибкость: кеш можно обойти при необходимости

Минусы:

  • Cache miss всегда вызывает обращение в БД (тяжелая операция)
  • Race-ситуация: если несколько потоков одновременно получат miss, все обратятся в БД
  • Cold start: после перезагрузки кеша все первые запросы — miss'ы

Когда использовать: Основной паттерн. Применяется когда нужна простота, данные не критичны по свежести, допустимы occasional miss'ы.

Read-through

Алгоритм:

  1. Приложение читает только из кеша
  2. Кеш самостоятельно проверяет наличие данных
  3. При miss кеш сам обращается в БД и загружает данные
  4. Приложение получает результат, не зная о miss'е

Требования: Кеш должен быть "умным" — знать, как общаться с БД, какие логика запроса.

Плюсы:

  • Чистота кода приложения, нет логики fallback'а
  • Управление нагрузкой на БД происходит на уровне кеша

Минусы:

  • Более сложная инфраструктура кеша
  • Первый запрос на новый ключ всё равно медленный
  • Кеш должен знать способ получения данных (проблематично при множественных источниках)

Когда использовать: В системах с managed service для кеша (облачные решения, специализированные кеши).

Write-through

Алгоритм при записи:

  1. Записываем в кеш
  2. Синхронно записываем в БД
  3. Возвращаем успех только после обоих операций
cache.put(key, value);
database.put(key, value);
return success;

Гарантии: Кеш и БД всегда согласованы. При чтении из кеша данные всегда актуальны.

Плюсы:

  • Гарантия консистентности
  • Простая логика: write -> cache -> database
  • Safe для критичных данных

Минусы:

  • Запись медленнее: ждём ответа от обоих слоёв
  • Если БД откажет, вся операция не пройдёт
  • Не подходит для систем с высоким throughput на запись

Когда использовать: Когда консистентность критична, нагрузка на запись низкая или средняя (финансовые операции, заказы).

Write-behind / Write-back

Алгоритм:

  1. Записываем в кеш
  2. Немедленно возвращаем успех приложению
  3. Асинхронно (в фоне) записываем в БД
cache.put(key, value);  // return immediately
asyncWrite(database, key, value);  // later

Плюсы:

  • Запись очень быстрая: приложение получает ответ после кеша
  • Высокий throughput на запись
  • БД получает записи batch'ами, можно оптимизировать

Минусы:

  • Риск потери данных: если кеш упадёт ДО асинхронной записи, данные теряются
  • Асинхронная запись может завершиться с ошибкой, приложение об этом не узнает
  • Возможна несогласованность: при чтении из БД до завершения асинхронной записи будут старые данные

Когда использовать: Для данных, где потеря или задержка некритична (аналитика, логи, метрики, счётчики). Для критичных данных (финансы, заказы) опасно использовать.

Refresh-ahead

Алгоритм:

  • Отслеживаем, какие ключи часто читаются
  • ДО истечения TTL проактивно обновляем эти ключи в фоне
  • Когда TTL истекает, данные уже свежие

Плюсы:

  • Нет cache miss на горячих ключах
  • Гладкая работа без скачков latency
  • Гарантия актуальности данных для popular keys

Минусы:

  • Дополнительная логика для tracking и refresh
  • Может быть неэффективно для cold ключей (зачем обновлять то, что никто не читает)
  • Требует точную оценку популярности ключей

Когда использовать: Для критически важных горячих ключей, где miss полностью неприемлем.

Выбор данных для кеширования

Правильный выбор того, что кешировать, определяет эффективность всей системы.

Идеальные кандидаты на кеш

Справочники и редко меняющиеся данные:

  • Списки стран, городов, валют
  • Конфигурации и feature flags
  • Справочники профессий, категорий

Причины: читаются часто, пишутся редко, потеря или устаревание данных на несколько часов некритично.

Результаты тяжёлых вычислений или агрегатов:

  • Итоги за день/неделю/месяц
  • Результаты сложных JOIN'ов нескольких таблиц
  • Результаты ML-моделей

Причины: пересчитывать дорого, результаты одинаковы для всех пользователей, можно допустить задержку на обновление.

Популярные сущности (hot keys):

  • 20% пользователей генерируют 80% запросов (закон Парето)
  • Профили знаменитых людей в социальной сети
  • Трендовые новости

Причины: кеш даст максимальный эффект на популярных данных, hit ratio будет высоким.

Сессии и временные данные:

  • Данные сессии пользователя
  • Temporary tokens
  • Rate-limit счётчики

Причины: быстрый доступ критичен, персистентность не требуется, устаревание с TTL — нормально.

Данные, которые не стоит кешировать

Сильно изменяемые записи:

  • Баланс счёта (обновляется каждую секунду)
  • Статус заказа (часто меняется)
  • Real-time счётчики

Причины: кеш постоянно устаревает, стоимость инвалидации выше выгоды.

Расчёт: Если данные обновляются чаще, чем читаются повторно (ratio < 1:10), кеш неэффективен.

Чувствительные данные:

  • Пароли, приватные ключи, токены (никогда не кешируем)
  • PII (Personally Identifiable Information)

Причины: security-риск. Распределённый кеш может быть скомпрометирован. Локальный может быть выгружен на диск.

Данные, где критична последняя версия:

  • Текущая позиция в реальном времени
  • Статус платежа
  • Решения по fraud-detection

Причины: ошибка на несколько секунд может быть дорогой или опасной.

Альтернативный source of truth:

  • Избегаем неоднозначности: если кеш станет основным источником правды при сбое БД, система сломается

Принципы выбора для кеширования

"Читать чаще, чем писать": Если ключ читается в 10 раз чаще, чем пишется, кеш выгоден. Если ratio близок к 1:1, кеш бесполезен.

Формула ROI (return on investment) для кеша:

Выгода = (частота чтения - частота инвалидации) * (время из БД - время из кеша)
Стоимость = объём памяти * цена памяти + сложность управления инвалидацией

Разумная "грязность" данных: Senior-инженер понимает, что кеш не идеален. Данные могут быть на несколько секунд старше. Если это допустимо — кешируем.

Пример: профили пользователей с TTL=5 минут. Профили читаются в 1000 раз чаще, чем обновляются. За 5 минут может накопиться несколько изменений, но это приемлемо.

Проектирование ключей кеша

Ключ кеша — это адрес, по которому лежат данные. Плохой дизайн ключей приводит к конфликтам, дублированию и сложным ошибкам.

Структура ключа

Рекомендуемый формат:

<namespace>:<resource_type>:<resource_id>:<version>

Примеры:

  • user:profile:12345:v1 — профиль пользователя 12345, версия 1
  • product:details:67890:v2 — детали продукта 67890, версия 2
  • session:token:abcdef:v1 — данные сессии
  • cache:recommendations:user_12345:v1 — рекомендации для пользователя

Части ключа:

  • namespace: категория данных (user, product, session, order). Избегаем коллизий
  • resource_type: тип ресурса (profile, details, list, summary)
  • resource_id: уникальный идентификатор (user ID, product ID, order number)
  • version: версия формата (v1, v2). Нужна при изменении структуры данных

Требования к ключам

Уникальность: Разные данные — разные ключи. Коллизия приводит к возврату неправильных данных.

Предсказуемость: Приложение должно предсказать ключ для доступа. Не используем random или timestamp в ключе (если это не специальный случай).

Отсутствие конфликтов: Если сервис A использует user:123, а сервис B использует user:123 для своих данных — конфликт. Решение: prefix по сервису userservice:user:123 или auth_user:123.

Версионирование ключей

Проблема: Вы хранили User как {id, name, email}. Потом добавили phone. Старые записи в кеше не имеют phone. Новый код ожидает phone. Ошибка.

Решение: Меняем ключ на user:profile:123:v2. Старые записи user:profile:123:v1 останутся в кеше и будут проигнорированы (автоматически удалены по TTL). Новый код видит v2.

Процесс развёртывания:

  1. Старый код читает v1, пишет v1
  2. Выкатываем новый код. Он читает v2 (и fallback'и на v1 для старых данных)
  3. За несколько дней/недель v1 исчезают по TTL
  4. Код стабилизировался, удаляем поддержку v1

Преимущество: Zero-downtime развёртывание без необходимости очистки кеша.

TTL и стратегии инвалидации

TTL — механизм, гарантирующий, что данные в кеше не бесконечно устаревают. Это не единственный способ управлять свежестью.

TTL как основной механизм

Короткий TTL (10-60 секунд):

  • Плюсы: данные чаще свежие, минимум рисков несогласованности
  • Минусы: больше miss'ов, больше нагрузка на БД, hit ratio может упасть

Расчёт эффекта:

  • Если RPS = 1000, TTL = 10 сек, hit ratio = 90%
  • Miss RPS = 100. При miss требуется БД (10 ms). При hit < 1 ms
  • Среднее время ответа = 0.9 * 1 ms + 0.1 * 10 ms = 1.9 ms

Длинный TTL (часы, дни):

  • Плюсы: меньше miss'ов, высокий hit ratio, низкая нагрузка на БД
  • Минусы: данные могут быть сильно устаревшими, риск user confusion

Trade-off: Выбираем TTL в зависимости от требований свежести:

  • Для профилей пользователей: 5 минут (баланс между свежестью и производительностью)
  • Для справочников: 1 час (редко меняются)
  • Для статистики: 1 день (допустима старость)
  • Для трендов: 1 минута (нужна относительная свежесть)

Инвалидация по событиям

Идея: Не ждём, пока TTL истечёт. Когда данные меняются в БД, выбиваем ключ из кеша.

Процесс:

  1. Приложение обновляет запись в БД
  2. Тут же вызывает cache.delete(key) или публикует событие
  3. Следующий read получит fresh данные из БД
database.update(key, newValue);
cache.invalidate(key);

Плюсы:

  • Гарантия свежести: как только данные обновились, кеш невалидирован
  • Может использоваться с длинным TTL (кеш живёт долго, но инвалидируется по необходимости)

Минусы:

  • Требует явной логики инвалидации в коде
  • Риск "orphaned" данных: если забыть инвалидировать, данные в кеше будут стаканными
  • При асинхронной инвалидации есть окно рассогласования

Event-driven инвалидация в микросервисной архитектуре

В распределённой системе часто используют события (message broker):

  1. Сервис A обновляет User в БД
  2. Публикует событие UserUpdated в Kafka/RabbitMQ
  3. Сервис B слушает это событие и выбивает соответствующий ключ из Redis

Плюсы: Слабая связанность, асинхронность, масштабируемость

Минусы: Окно рассогласования (от ms до секунд), требуется инфраструктура для событий

Комбинированный подход

На практике используют:

  • TTL = 5 минут (страховка, если забыли инвалидировать)
  • Event-based инвалидация для критичных ключей (происходит в ms)
  • При необходимости — явная инвалидация после операции (для consistency-critical сценариев)

Проблемы кеширования и их решения

Cache stampede (thundering herd)

Суть: Популярный ключ с TTL истекает. Одновременно к кешу приходит множество запросов. Все получают miss, все идят в БД. БД получает spike нагрузки.

Пример расчёта:

  • RPS на ключ = 1000, hit ratio = 95%
  • TTL = 60 сек
  • В момент истечения: 950 одновременных miss'ов
  • 950 запросов в БД одновременно (вместо обычных 50)
  • БД загружена в 19 раз

Решение 1: Randomized TTL (джиттер)

ttl = 300 + random(0, 60);  // TTL от 300 до 360 секунд

Эффект: Ключи не истекают все сразу, spike размазывается.

Решение 2: Request coalescing / Locking

Когда первый запрос получил miss на ключ, он получает lock и идёт в БД. Остальные запросы ждут lock'а и получат результат от первого.

Решение 3: Soft TTL + background refresh

  • Soft TTL = 290 сек: данные помечены как потенциально устаревшие, но ещё валидны
  • Hard TTL = 300 сек: данные безусловно удаляются
  • Когда данные попадают в soft period, запускается background refresh

Процесс:

  1. Клиент читает ключ в soft period
  2. Возвращаем ему старые данные (быстро)
  3. В фоне обновляем ключ
  4. Следующий запрос получит новые данные

Преимущество: нет miss'ов, всегда есть данные.

Cold start / холодный старт

Суть: Система только запущена, кеш пуст. Все первые запросы — miss'ы. Latency в пол, нагрузка на БД максимальна.

Проблема: Пока кеш не прогреется (5-10 минут), система работает нестабильно.

Решение: Pre-warming

При старте запускаем batch-job, который загружает популярные ключи:

  1. Определяем top-100 ключей по частоте (из истории или конфига)
  2. После старта сервиса запускаем pre-warming
  3. Загружаем ключи из БД в кеш
  4. Через 30 сек система готова к трафику

Пример расчёта:

  • Top-100 ключей занимают 50% трафика
  • Без pre-warming: первые 10 минут 90% hit ratio (вместо обычного 95%)
  • С pre-warming: с первой минуты 95% hit ratio

Inconsistent cache (несогласованность кеша с БД)

Суть: Данные в кеше и БД не совпадают.

Пример:

  1. Код читает из кеша: name=Иван
  2. Одновременно другой процесс обновляет БД: name=Петр, но не инвалидирует кеш
  3. Кеш возвращает: name=Иван
  4. Несогласованность

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

Решение: Выбрать правильный паттерн (write-through для critical data, cache-aside с event-based инвалидацией для остального).

Кеш как single point of failure

Суть: Система полностью зависит от кеша. При падении кеша:

  1. Все miss'ы идят в БД
  2. Spike нагрузки на БД в 10+ раз
  3. БД может перегрузиться
  4. Cascading failure

Решение: Graceful degradation

  • Circuit breaker: если кеш не отвечает, пропускаем кеш и идём в БД
  • Fallback: если кеш упал, используем локальный кеш или timeout-friendly ответы
  • Replicas: кеш имеет replicas для отказоустойчивости
  • Load shedding: при spike нагрузки отклоняем низко-приоритетные запросы

Расчёт реплик:

  • Expected load на кеш = 100 000 RPS
  • Redis single узел = 50 000 ops/sec
  • Требуется: 100 000 / 50 000 = 2 primary узла (минимум)
  • Добавляем replicas: 2 primary + 2 replica = 4 узла total

Кеширование в микросервисной архитектуре

Локальный кеш vs распределённый кеш

Локальный in-memory кеш (в памяти процесса):

  • Хранится в памяти процесса приложения
  • Каждый инстанс имеет свой независимый кеш
  • Примеры: ConcurrentHashMap, LinkedHashMap, Guava Cache, Caffeine

Плюсы: очень быстро (нет сети)

Минусы: размножение данных на N инстансов, потеря при перезагрузке

Распределённый кеш (Redis, Memcached):

  • Отдельный сервис, доступный всем инстансам
  • Один источник истины для кеша

Плюсы: консистентность, масштабируемость

Минусы: сетевая latency, зависимость от сервиса

Типичный паттерн в production-системе

  1. Локальный кеш в каждом сервисе: справочники, конфиги, редко меняющиеся данные, TTL = час-день
  2. Распределённый Redis: горячие данные, требующие синхронизации, TTL = минуты

При обновлении данных:

  • Инвалидируем в Redis (быстро, доступно всем)
  • Инвалидируем локальные кеши (через message broker)

Шардирование Redis для масштабирования

Расчёт шардирования:

  • Требуемая пропускная способность: 100 000 RPS
  • Redis single узел обрабатывает: 50 000 ops/sec
  • Количество шардов: 100 000 / 50 000 = 2 шарда (минимум)
  • С replicas (2x redundancy): 2 шарда * 2 replicas = 4 узла

Стратегии шардирования:

  • Consistent hashing: распределение ключей по кольцу
  • Range-based: ключи по диапазонам (user IDs 0-1M, 1M-2M и т.д.)
  • Directory-based: отдельный сервис маршрутизации

Мониторинг и операционные аспекты кеша

Ключевые метрики

Hit ratio = hits / (hits + misses):

  • < 50% — неэффективно, требуется пересмотр
  • 50-80% — хорошо
  • 90% — отлично

Latency операций:

  • Cache hit: < 1 ms (для in-memory), 1-5 ms (для Redis)
  • Cache miss: 10-100 ms (зависит от БД)
  • Средний latency = hit_ratio * hit_latency + (1 - hit_ratio) * miss_latency

Memory utilization:

  • Текущий размер / лимит
  • Trend: растёт или стабилен
  • Eviction rate: ключей в sec, удаляемых по LRU

Availability:

  • Uptime процентов
  • Response time 95th/99th percentile

Capacity planning

Оценка размера кеша:

  1. Определяем size одного объекта (среднее): User = 5 KB
  2. Определяем count popular objects: 1M активных пользователей
  3. Нужно: 1M * 5 KB = 5 GB
  4. Добавляем резерв (1.5x): 5 GB * 1.5 = 7.5 GB
  5. Выбираем Redis instance: 8 GB

Прогнозирование роста:

  • Ежемесячно проверяем рост размера
  • Если за месяц вырос на 20%, через 5 месяцев будет 100% (требуется масштабирование)

Технологии для кеширования

Основные решения и сравнение

Технология Тип данных Throughput (ops/sec) Latency Персистентность Use case
Redis Key-Value, Strings, Lists, Sets, Sorted Sets, Streams 50K-100K 1-5 ms RDB, AOF Горячие данные, сессии, счётчики
Memcached Key-Value (strings only) 100K-200K 1-3 ms Нет Simple caching, краткосрочные данные
Hazelcast Distributed In-Memory 50K 1-2 ms Да Локальная система, edge computing
Caffeine (Java) In-Memory 100M+ ops/sec < 0.1 ms Нет Локальный кеш в приложении
Elasticsearch Document store (secondary) 10K 10-100 ms Да Полнотекстовый поиск, аналитика

Redis: основной выбор для распределённого кеша

Характеристики:

  • Single-threaded, но async I/O
  • Встроенные структуры данных (Strings, Lists, Sets, Sorted Sets, Streams, HyperLogLog)
  • Репликация master-slave
  • Clustering для горизонтального масштабирования
  • Персистентность (RDB snapshots, AOF write-ahead logs)
  • TTL на ключи
  • Transactions, Lua scripts

Варианты развёртывания:

  • Standalone: простой, для development
  • Sentinel: high availability, автоматический failover
  • Cluster: горизонтальное масштабирование

Когда использовать Redis: Когда нужны сложные структуры данных, транзакции, персистентность, масштабируемость.

Memcached: простое альтернативное решение

Характеристики:

  • Ультрапростой: только Key-Value для strings
  • Многопоточный
  • Очень быстрый
  • Нет персистентности
  • Нет репликации (требуется external solution)

Когда использовать: Когда нужна максимальная скорость и простота, не нужны сложные типы данных. Часто используется совместно с Redis.

Локальный кеш в Java: Caffeine

Характеристики:

  • High-performance in-memory cache
  • Автоматическое вытеснение (LRU, LFU, W-TinyLFU)
  • TTL и refresh по времени
  • Built-in loader для lazy loading
  • Асинхронная загрузка
  • Полная функциональность в памяти процесса

Использование:

Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .recordStats()
    .build();

Когда использовать: Справочники, конфиги, редко меняющиеся данные внутри одного инстанца.

Выбор технологии: матрица решений

Требование Решение Альтернатива
Очень быстрый локальный кеш Caffeine LinkedHashMap
Распределённый кеш, горячие данные Redis Memcached
Максимальный throughput, простота Memcached Redis
Кеш с репликацией и HA Redis Sentinel/Cluster Hazelcast
Микросервисы, edge Redis (distributed) + Caffeine (local) Только Redis

Принципы выбора подходов кеширования

Сравнительная таблица паттернов

Параметр Cache-aside Read-through Write-through Write-behind
Сложность реализации Простая Средняя Средняя Сложная
Консистентность Eventual Eventual Strong Weak
Latency при write Высокий Нет write path Высокий Низкий
Throughput Средний Средний Низкий Высокий
Persistence Нет Нет Да Да
Use case Большинство Managed cache Critical data Analytics, logs

Процесс выбора

Шаг 1: Определяем тип данных

  • Read-heavy (10:1 reads:writes) → cache-aside или read-through
  • Write-heavy (1:10 reads:writes) → write-through или write-behind
  • Mixed (1:1) → может быть, не кешировать

Шаг 2: Определяем требования консистентности

  • Strong consistency требуется → write-through
  • Eventual consistency OK → cache-aside, write-behind
  • No requirement → любой подход

Шаг 3: Определяем требования latency

  • Low latency на write критична → write-behind
  • Latency не критична → write-through, cache-aside
  • Very low latency читаю (< 1ms) → local in-memory cache

Шаг 4: Выбираем

Примеры:

  • Профили пользователей: Read-heavy (100:1), eventual consistency OK → cache-aside с Redis
  • Финансовые транзакции: Write-through требуется, consistency critical → write-through с синхронизацией
  • Логи и метрики: Write-heavy, loss OK → write-behind с batch писанием

Этот документ охватывает основные принципы, технологии, и подходы к кешированию в modern backend-архитектуре.

System Design: Очереди, брокеры сообщений и стриминг для асинхронного взаимодействия в микросервисной архитектуре с фокусом на надёжность, масштабирование и обработку отказов.

Роль очередей и стриминга в System Design

Очереди и брокеры сообщений — это один из ключевых компонентов высоконагруженных распределённых систем. Они решают фундаментальную задачу: развязать компоненты системы по времени и по нагрузке, позволяя им взаимодействовать независимо.

Зачем нужны очереди в распределённых системах

Синхронное взаимодействие через RPC или REST имеет критические недостатки. Если сервис A вызывает сервис B напрямую, то отказ B немедленно влияет на A. Кроме того, скорость A ограничена скоростью B, а всплески нагрузки мгновенно распространяются через систему. Очереди меняют эту динамику: продюсер (источник сообщений) и консьюмер (обработчик) больше не жёстко связаны по времени выполнения.

Брокер сообщений становится посредником. Продюсер публикует сообщение в очередь и продолжает работу, не дожидаясь обработки. Консьюмер берёт сообщение из очереди, когда готов, и обрабатывает его. Это асинхронное взаимодействие имеет три критических преимущества: слабая связанность (loosely coupled), буферизация пиков нагрузки и возможность масштабировать консьюмеров независимо от продюсеров.

Асинхронное взаимодействие как способ развязать компоненты

На собеседовании часто спрашивают: почему в твоей архитектуре используется очередь, а не прямой вызов? Ответ должен быть конкретным в контексте задачи. Очередь полезна, когда:

  • Обработка сообщения может занять время, и продюсер не должен ждать
  • Нужна защита от пиков нагрузки: всплески запросов буферизируются в очереди
  • Консьюмеров нужно масштабировать или перезагружать без влияния на продюсеров
  • Нужна дополнительная надёжность: сообщение сохранится на диск, даже если консьюмер упал

Интервьюер ждёт, что кандидат объяснит, почему для конкретной задачи очередь подходит лучше, чем других опций. Формулировка звучит так: «Мы используем очередь для обработки заказов, потому что операция платежа может занять секунды, а сам заказ нужно подтвердить немедленно. Очередь буферизирует пики, когда в час пик приходит 10x больше заказов, чем в среднем, и консьюмеры успешно обрабатывают их постепенно».

Event-driven архитектура как контекст

Когда интервьюер слышит фразу «event-driven архитектура», он ожидает, что кандидат продемонстрирует понимание того, что это означает на практике, а не просто назовёт buzzword. Event-driven подход подразумевает, что компоненты системы коммуницируют через события, происходящие в системе. Событие — это артефакт, представляющий факт того, что что-то произошло (OrderPlaced, PaymentReceived, UserRegistered).

На System Design-раунде это проявляется в способности кандидата объяснить цепочку: сервис генерирует событие → событие попадает в брокер → другие сервисы слушают это событие и реагируют. Это отличается от синхронного микросервисного взаимодействия, где OrderService явно вызывает PaymentService.

Базовые понятия

Сообщение (Message)

Сообщение — это атомарная единица информации, передаваемая между продюсером и консьюмером через брокер. Каждое сообщение состоит из двух частей: полезной нагрузки (payload) и метаданных (headers).

Полезная нагрузка содержит основную информацию. Метаданные включают ключи (keys), временные метки (timestamp), ID трассировки (trace-id, correlation-id), версию схемы и другие системные данные. Метаданные критичны для отслеживания и отладки цепочек обработки событий.

Формат сообщения выбирается в зависимости от требований:

  • JSON: читаемый, простой в отладке, но многословный
  • Avro: компактный, с вложенной схемой, поддержка эволюции схемы
  • Protobuf: компактный, типизированный, используется в gRPC

На System Design кандидат не должен углубляться в детали каждого формата, но должен объяснить, почему он выбрал именно этот. Например: «Мы используем Avro, потому что консьюмеры пишут разные команды (Python, Go, Java), и Avro гарантирует совместимость схем при эволюции».

Очередь (Queue) и топик (Topic/Stream)

Это два разных способа организации хранилища сообщений, и различие между ними фундаментально для понимания выбора брокера.

Очередь — традиционная модель, реализованная в RabbitMQ, AWS SQS и подобных системах. Сообщение попадает в очередь, один из консьюмеров берёт его и обрабатывает. После успешной обработки (подтверждения, ack) сообщение удаляется из очереди. Это модель point-to-point: каждое сообщение обрабатывается ровно одним консьюмером в группе.

Топик/Стриминговый лог — модель, используемая в Kafka и подобных системах. Топик — это неизменяемый лог событий. Сообщения публикуются в топик и остаются там (обычно определённое время или размер). Каждый консьюмер может присоединиться к топику, прочитать все (или с определённой точки) события и отслеживать свою позицию через смещение (offset). Множество консьюмеров могут одновременно читать одно и то же событие.

Отличие принципиально: очередь удаляет сообщение после обработки, лог событий — сохраняет. Это означает, что в очереди потребитель может потеряться, если не успеет прочитать, а в логе можно переиграть события сначала.

Продюсер (Producer) и консьюмер (Consumer)

Продюсер — это компонент, который генерирует и публикует сообщения в брокер. На практике это может быть REST API, обработчик задачи, модуль обработки платежей — что угодно, что нужно уведомить о событии.

Консьюмер — это компонент, который читает сообщения из брокера и обрабатывает их. Это может быть микросервис, который реагирует на события, или фоновый worker, обрабатывающий задачи.

На собеседовании кандидат должен ясно описать, какие компоненты системы будут продюсерами, какие консьюмерами, и почему. Например: «OrderService публикует событие OrderPlaced, оно идёт в топик, на который слушают NotificationService (отправляет письма) и AnalyticsService (обновляет метрики)».

Consumer Group

Consumer group — это концепция для масштабирования обработки сообщений. Несколько экземпляров консьюмера объединяются в группу с одним именем. Брокер автоматически распределяет очереди (или партиции) между членами группы так, чтобы каждое сообщение обрабатывалось ровно одним консьюмером в группе.

Если в группе 5 консьюмеров и 10 партиций, каждый консьюмер обрабатывает 2 партиции. Если консьюмер падает, брокер перераспределяет его партиции между оставшимися. Это позволяет масштабировать горизонтально, просто запустив дополнительные экземпляры.

На System Design это звучит так: «Мы запускаем 10 экземпляров сервиса обработки заказов в consumer group, каждый обрабатывает сообщения из 5 партиций, что даёт нам параллелизм в 50 одновременных обработок».

Очереди vs Стриминговые логи

Классическая очередь (RabbitMQ-подобный подход)

В классической модели очереди сообщение проходит следующий цикл:

  1. Продюсер публикует сообщение в очередь
  2. Один из консьюмеров в группе берёт сообщение
  3. Консьюмер обрабатывает сообщение
  4. Консьюмер отправляет подтверждение (ack)
  5. Брокер удаляет сообщение из очереди

Это модель competing consumers: несколько консьюмеров конкурируют за сообщения, и каждое сообщение обрабатывается ровно одним консьюмером. Очередь гарантирует, что сообщение не будет передано другому консьюмеру в той же группе.

Преимущества:

  • Простота: сообщение исчезает после обработки, не нужно управлять offsets
  • Справедливое распределение нагрузки: быстрые консьюмеры обрабатывают больше, медленные меньше
  • Память ограничена: старые сообщения удаляются

Недостатки:

  • Невозможно переиграть события: если всё ломается, события теряются
  • Нет истории: новый консьюмер не может узнать, какие события уже произошли
  • Ограниченная гибкость: если нужно, чтобы несколько разных обработчиков слушали одно событие, нужны разные очереди или fan-out, что усложняет архитектуру

Стриминговый лог (Kafka-подобный подход)

В модели стриминга сообщения образуют append-only лог. Каждое сообщение получает порядковый номер (offset). Консьюмер отслеживает, какой offset он уже прочитал, и может продолжить с этой точки.

Модель выглядит так:

  1. Продюсер публикует сообщение в топик, оно получает offset (например, 1000)
  2. Консьюмер читает сообщение и сохраняет свой текущий offset (1000)
  3. Если консьюмер падает и перезагружается, он знает, что уже прочитал до offset 1000, и начинает с 1001
  4. Разные консьюмеры могут быть на разных offsets, каждый читает в своём темпе
  5. Сообщение остаётся в логе (обычно определённое время или размер)

Преимущества:

  • Переигра событий: любой консьюмер может прочитать все события с начала
  • Распределённость: множество независимых консьюмеров могут читать одни и те же события
  • История: новый консьюмер может восстановить состояние, прочитав все события
  • Гибкость в архитектуре: pub/sub без привязки к числу подписчиков

Недостатки:

  • Управление offsets: консьюмер должен отслеживать свою позицию
  • Требует хранения: лог логирует события, что требует дискового пространства
  • Консьюмеры могут отставать на часы или дни, если не работают

Как выбирать между ними

Очередь подходит для задач, где каждое сообщение должно быть обработано ровно один раз, и история не важна. Пример: распределённая обработка задач (job queue), где задачи независимы, и новым воркерам не нужна история.

Стриминг подходит для систем, где требуется история событий, множество подписчиков, или возможность переигры. Пример: события в e-commerce, где разные сервисы (нотификация, аналитика, инвентарь) должны реагировать на один OrderPlaced, и могут потребоваться переигра событий при ошибке.

На собеседовании звучит так: «Для системы уведомлений нам подходит классическая очередь, потому что каждое письмо должно быть отправлено один раз, и историю писем нам не нужно. Но для синхронизации данных между сервисами мы используем Kafka, чтобы новые сервисы могли присоединиться и восстановить состояние, прочитав все события».

Модели доставки сообщений

At-most-once (доставка не более одного раза)

Семантика at-most-once означает, что сообщение либо доставлено ровно один раз, либо не доставлено вообще (потеряно). Не бывает дублей.

Как это реализуется:

  • Продюсер публикует сообщение, он отправляется в брокер
  • Если сеть разорвалась, продюсер не знает, дошло ли сообщение
  • Консьюмер читает сообщение и ack'ает его перед обработкой
  • Если консьюмер упадёт при обработке, сообщение уже ack'ено и потеряется

Где допустимо:

  • Метрики и логирование: потеря одного события некритична
  • Аналитика: погрешность в одно событие из миллиона не важна
  • Некритичные уведомления: если письмо не отправилось, это не катастрофа

Где неприемлемо:

  • Финансовые операции: потеря платежа недопустима
  • Заказы: потеря OrderPlaced приведёт к потерям компании
  • Критичные события: потеря любого события создаёт инкогерентность

At-least-once (доставка хотя бы один раз)

Семантика at-least-once означает, что сообщение гарантированно доставлено, но может быть доставлено несколько раз (дублирование возможно).

Как реализуется:

  • Продюсер публикует сообщение, ждёт ack от брокера
  • Консьюмер читает сообщение, обрабатывает его, и только потом ack'ает
  • Если консьюмер упадёт при обработке, сообщение остаётся в брокере и будет переиграно

Это означает возможность дублирования:

  • Продюсер отправляет сообщение, брокер получает, но ack теряется в сети
  • Продюсер думает, что не дошло, отправляет снова
  • Брокер получает два идентичных сообщения
  • Оба доходят до консьюмера

Это наиболее частая модель в реальных системах. Она требует одного ключевого свойства: идемпотентности обработчиков. Консьюмер должен быть спроектирован так, чтобы обработка одного сообщения дважды привела к тому же результату, что и один раз.

На собеседовании звучит так: «Мы используем at-least-once доставку, потому что потеря сообщения недопустима, но мы спроектировали обработчик как идемпотентный: если приходит платёж дважды с одним и тем же ID, первый раз пополняется баланс, второй раз операция игнорируется благодаря upsert в БД».

Exactly-once (доставка ровно один раз)

Exactly-once — это философская концепция, которая обычно означает не буквальное гарантирование (это невозможно в распределённых системах), а "exactly-once семантика обработки" (exactly-once processing semantics).

Что это означает:

  • На уровне приложения каждое сообщение обрабатывается ровно один раз
  • Это достигается комбинацией механизмов: именованные идентификаторы сообщений, transactional offsets, распределённые транзакции

Как реализуется на практике:

  • Консьюмер читает сообщение с ID 12345
  • Обрабатывает его (например, пополняет баланс на 100)
  • В одной транзакции записывает результат в БД и коммитит offset
  • Если система упадёт, при рестарте консьюмер не переигра это сообщение, потому что offset уже закоммитен
  • Если обработка не прошла, транзакция откатывается, и offset не обновляется

Ограничения:

  • Требует поддержки transactional writes в консьюмере и брокере
  • Замедляет обработку (каждое сообщение — отдельная транзакция)
  • Не решает проблему дублирования на стороне продюсера (если продюсер отправит дважды, два разных сообщения попадут в брокер)

На собеседовании кандидат должен объяснить, почему exactly-once часто не требуется: «Мы используем at-least-once с идемпотентными обработчиками, потому что exactly-once замедляет систему, а идемпотентность легко обеспечивается на уровне приложения через естественные бизнес-ключи».

Обсуждение delivery semantics на System Design

На раунде звучит так: «Для критичных операций нам нужна гарантия, что сообщение не потеряется. Мы используем at-least-once доставку, консьюмер читает сообщение, обрабатывает его, и только потом ack'ает. Но at-least-once означает возможность дублирования. Поэтому обработчик спроектирован как идемпотентный: платеж с одним ID обрабатывается ровно один раз благодаря уникальному ключу в БД».

Интервьюер оценивает, понимает ли кандидат разницу между моделями, и не смешивает ли их. Например, если кандидат говорит: «Мы используем Kafka и exactly-once», это хороший знак, что он читал документацию, но нужно убедиться, что он понимает цену и ограничения.

Надёжность, персистентность и репликация

Персистентные очереди

Персистентность означает, что сообщения записываются на диск, а не хранятся только в памяти. Это гарантирует, что сообщение не потеряется при падении ноды или отключении питания.

Как работает:

  • Продюсер отправляет сообщение
  • Брокер записывает сообщение на диск (или в WAL — write-ahead log)
  • Брокер отправляет ack продюсеру
  • Консьюмер позже читает сообщение с диска

Trade-offs:

  • Надёжность vs Latency: запись на диск медленнее, чем в памяти. Обычно запись требует нескольких миллисекунд.
  • Надёжность vs Пропускная способность: если каждое сообщение требует fsync (синхронизация на диск), пропускная способность падает. Для увеличения пропускной способности используется батчинг: несколько сообщений записываются вместе.

На собеседовании: «Мы используем персистентные очереди, потому что потеря заказов недопустима. Да, это добавляет несколько миллисекунд latency, но для асинхронного сценария это приемлемо. Брокер может обработать десятки тысяч сообщений в секунду благодаря батчингу и эффективной записи на диск».

Репликация в брокерах сообщений

Репликация означает, что каждое сообщение (или партиция) хранится на нескольких нодах. Если одна нода падает, данные остаются на других.

Как работает в Kafka (пример):

  • Топик имеет 3 партиции, каждая реплицируется на 3 ноде (replication factor = 3)
  • Партиция 0 имеет leader на ноде 1 и replicas на нодах 2, 3
  • Продюсер пишет в leader
  • Leader репликирует данные на replicas
  • Консьюмеры читают из leader или replicas (в зависимости от конфигурации)
  • Если нода 1 падает, брокер выбирает нового leader (нода 2 или 3)

Поведение при падении:

  • Если только репли упала, система продолжает работать нормально
  • Если leader упал, происходит избрание нового leader (занимает несколько секунд)
  • Во время избрания leader новые сообщения не могут быть написаны, но чтение может продолжаться (в зависимости от конфигурации)

На собеседовании: «Каждая партиция реплицируется на 3 сервера. Если один сервер падает, данные остаются на двух других. Если падает leader, брокер автоматически выбирает нового leader из replicas, и система продолжает работать. Это гарантирует, что система выдержит падение одного сервера».

Acknowledgements (ack/nack)

Ack (acknowledgement) — это сигнал от консьюмера к брокеру, что сообщение успешно обработано и может быть удалено (или offset может быть обновлён).

Существуют две модели:

Автоматический ack (auto-commit):

  • Консьюмер читает сообщение
  • Через определённое время (конфигурируется) брокер автоматически ack'ает offset
  • Если консьюмер упадёт, некоторые сообщения могут быть потеряны (те, которые были прочитаны, но не ack'ены)
  • Плюс: простота
  • Минус: возможна потеря

Ручной ack (manual commit):

  • Консьюмер читает сообщение
  • Обрабатывает его
  • Явно отправляет ack брокеру
  • Брокер обновляет offset
  • Если консьюмер упадёт до обработки, сообщение будет переиграно

Влияние на семантику:

  • Автоматический ack → at-most-once (может потеряться)
  • Ручной ack (ack после обработки) → at-least-once (может дублироваться)
  • Ручной ack (ack перед обработкой) → at-least-once и гарантия обработки (хотя обработка может быть незавершённой)

На практике используется ручной ack с обработкой перед ack'ем, что даёт at-least-once с минимальным риском потери.

Dead-letter queue (DLQ)

Dead-letter queue (DLQ) — это специальная очередь, в которую попадают сообщения, которые не удалось обработать. Это критический компонент надёжной системы обработки ошибок.

Назначение DLQ:

  • Сообщение, которое не поддаётся обработке, не должно заблокировать очередь
  • DLQ позволяет отделить "ядовитые" сообщения и обработать их отдельно
  • Это позволяет оператору понять, что пошло не так, и принять решение

Как это работает:

  1. Консьюмер читает сообщение
  2. Обрабатывает его, но получает ошибку
  3. Делает несколько попыток переобработки (с backoff)
  4. Если все попытки исчерпаны, сообщение отправляется в DLQ
  5. Отдельный процесс мониторит DLQ и алертирует операторов

Сценарии обработки DLQ:

  • Ручной анализ: оператор смотрит сообщение, понимает, что пошло не так (например, неверный формат), исправляет и перекладывает обратно в основную очередь
  • Автоматический реплей: после фиксации бага система автоматически переигрывает DLQ
  • Отдельный сервис: специальный сервис анализирует DLQ, пытается восстановить некорректные данные, логирует проблемы

На собеседовании: «При обработке платежей мы используем DLQ. Если платёж не удалось обработать (например, нет такого аккаунта) после 3 попыток, он отправляется в DLQ. Отдельный alert ненаходить оператору, что в DLQ появилось сообщение, и оператор может расследовать причину».

Заказ (Ordering) и партиционирование

Гарантии порядка

Порядок сообщений важен для многих бизнес-сценариев. Например, если OrderPlaced должен прийти перед OrderConfirmed, нарушение порядка приведёт к несогласованности.

Когда брокер может обеспечить порядок:

  • Сообщения в одной очереди / одной партиции обязательно обрабатываются в порядке, в котором они были опубликованы
  • Но если очереди/партиции несколько, глобального порядка нет

Пример: топик с 1 партицией гарантирует порядок. Топик с 5 партициями гарантирует порядок только внутри каждой партиции.

Цена сохранения порядка:

  • Если нужен глобальный порядок, нужна 1 партиция, это означает 1 консьюмер, параллелизм теряется
  • На одном консьюмере пропускная способность ограничена до ~10k msg/sec (зависит от сложности обработки)
  • Это часто неприемлемо для высоконагруженных систем

Партиции (Partitions)

Партиция — это основная единица распределения данных в брокере сообщений. Каждая партиция — это отдельный лог событий, и каждый консьюмер в группе обрабатывает одну или несколько партиций.

Преимущества партиционирования:

  • Параллелизм: N партиций → до N консьюмеров могут обрабатывать параллельно
  • Масштабирование: добавление консьюмеров масштабирует throughput линейно (пока нет партиций больше, чем консьюмеров)
  • Избежание хотспотов: разные партиции могут быть на разных нодах

Как связаны партиции и consumer group:

  • Consumer group с 5 членами и топик с 10 партициями
  • Брокер автоматически распределяет: консьюмер 1 обрабатывает партиции , консьюмер 2 обрабатывает , и т.д.
  • Каждая партиция обрабатывается ровно одним консьюмером в группе

Ключ партиционирования (Partition Key)

Partition key — это значение, которое определяет, в какую партицию попадёт сообщение. Брокер хеширует ключ и выбирает партицию.

Как влияет на порядок:

  • Если все сообщения для одного userId имеют одинаковый partition key (userId), они попадут в одну партицию и будут обработаны в порядке

Как влияет на распределение нагрузки:

  • Хороший partition key распределяет нагрузку равномерно между партициями
  • Плохой partition key приводит к дисбалансу

Примеры:

  • userId для событий пользователя → гарантирует порядок для одного пользователя
  • accountId для операций с счётом → гарантирует порядок для одного счёта
  • orderId для событий заказа → гарантирует порядок для одного заказа

На собеседовании: «Событие содержит partition key = userId. Все события для одного пользователя попадут в одну партицию и будут обработаны в порядке. Для системы нужно 50 партиций для даже распределения нагрузки по 50 миллионам пользователей».

Trade-off: Порядок vs Масштабирование

Это критическое понимание на System Design:

  • Если нужен глобальный порядок для всех сообщений, нужна 1 партиция, параллелизм невозможен
  • Если нужен порядок внутри логической сущности (userId, accountId), используется partition key, это позволяет масштабировать до N партиций
  • Если порядок не важен, можно игнорировать partition key, и нагрузка распределяется случайно

На собеседовании звучит так: «Для OrderPlaced и OrderCancelled нужен порядок (если OrderCancelled придёт до OrderPlaced, это ошибка). Мы используем partition key = orderId, это гарантирует, что все события для одного заказа обработаны в порядке. Мы имеем 100 партиций, что позволяет обрабатывать параллельно 100 различных заказов одновременно».

Идемпотентность и дедупликация

Зачем нужна идемпотентность при at-least-once доставке

At-least-once доставка означает, что сообщение может прийти дважды (или больше). Система должна быть спроектирована так, чтобы это не привело к некорректности.

Идемпотентность — это свойство операции, при котором выполнение её дважды даёт тот же результат, что и один раз. Например:

  • Неидемпотентная операция: balance += 100 (выполнится дважды → баланс +200)
  • Идемпотентная операция: balance = 100 (выполнится дважды → баланс = 100)

Подходы к идемпотентной обработке

Хранение processed-идентификаторов:

  • Консьюмер хранит ID всех уже обработанных сообщений в таблице или кеше
  • При получении нового сообщения проверяет, был ли он обработан
  • Если да, пропускает обработку
  • Минусы: нужна дополнительная таблица, нужно чистить старые ID'ы

Использование естественных ключей (business key):

  • Сообщение содержит бизнес-ключ (например, paymentId = "pay_12345")
  • В БД создаётся уникальный индекс на этом ключе
  • Операция выполняется через INSERT OR UPDATE (upsert)
  • Если такой paymentId уже есть, операция не изменит данные (или их обновит одинаково)
  • Минусы: требует переделки схемы БД

Идемпотентные операции в БД:

  • Вместо UPDATE balance SET balance = balance + 100 WHERE userId = 1, используется условное обновление
  • Например: UPDATE balance SET balance = 100 WHERE userId = 1 AND balance < 100 (обновит только если баланс меньше 100)
  • Это требует тщательного проектирования логики

Пример полной идемпотентной обработки платежа:

Консьюмер получает PaymentProcessed(paymentId='pay_123', userId=1, amount=100)
1. Проверяет: есть ли в БД Payment с paymentId='pay_123'?
2. Если есть, пропускает обработку (уже обработано)
3. Если нет, создаёт Payment(id='pay_123', userId=1, amount=100, status='processed')
   и обновляет balance: UPDATE users SET balance = balance + 100 WHERE id = 1
4. Отправляет ack брокеру
Если сообщение пришло дважды:

   - Второй раз Payment с paymentId='pay_123' уже есть, обработка пропускается
   - Баланс не увеличивается дважды

Дедупликация на стороне брокера и/или консьюмера

Дедупликация может быть реализована на разных уровнях:

На стороне консьюмера (рекомендуется):

  • Консьюмер отвечает за идемпотентность
  • Простая для реализации, не требует поддержки брокера
  • Возможны оптимизации: если сообщение уже обработано, не нужно обращаться в БД

На стороне брокера:

  • Брокер отслеживает ID сообщений и не позволяет отправить два идентичных
  • Требует поддержки брокера (Kafka с версии 0.11 поддерживает idempotent producer)
  • Не решает проблему дублирования на уровне приложения
  • Усложняет логику брокера

Комбинированный подход:

  • Брокер гарантирует, что он не пришлёт один message дважды продюсеру
  • Консьюмер ещё обеспечивает идемпотентность приложения

На практике используется комбинированный подход: брокер предотвращает дублирование на уровне сети, консьюмер обеспечивает идемпотентность приложения.

Как на собеседовании связать семантику доставки и требования к обработчикам

Звучит так: «Мы используем Kafka с at-least-once доставкой. Это означает, что сообщение может прийти дважды. Чтобы система оставалась корректной, каждый обработчик спроектирован как идемпотентный. Для платежей мы сохраняем paymentId в БД с уникальным индексом, и вторая попытка обработки платежа с тем же paymentId просто пропустит операцию. Для аналитики мы используем свойства бизнес-ключа — если событие пришло дважды, аналитика обновляется на то же значение, что и в первый раз».

Паттерны использования сообщений

Point-to-point

Модель point-to-point используется, когда одно сообщение должно быть обработано ровно одним консьюмером.

Как работает:

  • Один или несколько продюсеров публикуют сообщения в очередь
  • Несколько консьюмеров образуют group и конкурируют за сообщения
  • Каждое сообщение обрабатывается ровно одним консьюмером в группе

Пример: распределённая обработка фоновых задач.

PaymentQueue → [ConsumerInstance1, ConsumerInstance2, ConsumerInstance3]
Каждый ConsumerInstance берёт задачу и обрабатывает её
Если задача требует 10 сек, а задач 1000, время обработки: 1000 * 10 / 3 ≈ 3333 сек ≈ 55 минут

Когда подходит: распределённые задачи, асинхронная обработка запросов, балансировка нагрузки между воркерами.

Pub/Sub (публикация-подписка)

Модель pub/sub используется, когда одно событие должно быть доставлено множеству независимых подписчиков.

Как работает:

  • Продюсер публикует событие в топик
  • Множество разных потребителей (отдельные consumer groups) слушают топик
  • Каждый группа получает своё копию события

Пример: событие OrderPlaced.

OrderService публикует OrderPlaced в топик
NotificationService (group=notification) читает события и отправляет письма
AnalyticsService (group=analytics) читает события и обновляет метрики
InventoryService (group=inventory) читает события и резервирует товар
Каждый сервис обрабатывает события независимо в своём темпе

Когда подходит: широковещательные события, разделение ответственности, слабая связанность сервисов.

Fan-out

Fan-out — это паттерн, при котором одно событие рассылается в несколько очередей или топиков.

Как работает:

  • Событие попадает в exchange или topic
  • Exchange имеет несколько bindings, каждая привязывает событие к отдельной очереди
  • Событие копируется и отправляется во все очереди

Пример в RabbitMQ:

OrderPlaced → fanout exchange 'orders'
   → binding → queue 'notification'
   → binding → queue 'analytics'
   → binding → queue 'inventory'

Отличие от pub/sub:

  • Pub/sub: один топик, множество independent consumer groups
  • Fan-out: множество очередей, одна для каждого подписчика

На практике часто используется pub/sub, так как он более гибкий.

Request/Reply

Паттерн request/reply позволяет асинхронно отправить запрос и получить ответ через очередь.

Как работает:

  1. RequesterService отправляет сообщение запроса в очередь requests с указанием replyTo='responseQueueForRequester'
  2. ResponderService читает из requests, обрабатывает, отправляет ответ в responseQueueForRequester
  3. RequesterService читает из responseQueueForRequester и получает ответ

Корреляция запрос-ответ:

  • Каждый запрос имеет correlation-id (уникальный идентификатор)
  • Ответ имеет тот же correlation-id
  • Requester может сопоставить ответ с запросом

Пример:

RequesterService:
  Отправляет Message(correlationId='req_123', type='GetUserBalance', userId=1)
  Ждёт ответ с correlationId='req_123'

ResponderService:
  Получает Message(correlationId='req_123', type='GetUserBalance', userId=1)
  Обрабатывает: balance = 500
  Отправляет Message(correlationId='req_123', result=500)

RequestorService:
  Получает Message(correlationId='req_123', result=500)
  Знает, что это ответ на его запрос

Когда подходит: слабые связанные синхронные операции через очередь, когда нельзя использовать RPC (например, если ResponderService недоступен, запрос будет переиграться позже).

Event-carried state transfer

Паттерн event-carried state transfer подразумевает, что события несут полное состояние сущности.

Как работает:

  1. OrderService генерирует OrderPlaced с полной информацией заказа (items, amount, customer, address, и т.д.)
  2. Другие сервисы читают это событие и строят локальные проекции (кеши) данных заказа
  3. Если потребуется информация о заказе, сервис берёт из локальной проекции, не обращаясь в OrderService

Преимущества:

  • Слабая связанность: другие сервисы не вызывают OrderService API
  • Производительность: нет RPC при каждом запросе информации о заказе
  • Отказоустойчивость: если OrderService недоступен, локальные проекции остаются

Недостатки:

  • Данные могут быть устаревшими: если OrderService обновил заказ, другие сервисы узнают об этом с задержкой
  • Размер события: событие должно содержать все данные, может быть большим
  • Синхронизация: нужно обеспечить, чтобы все копии данных синхронизировались

На собеседовании: «Мы публикуем OrderPlaced с полной информацией заказа. NotificationService кеширует заказ локально, когда нужно отправить письмо, берёт данные из кеша вместо RPC в OrderService. Если заказ обновлён, OrderUpdated обновляет локальный кеш».

События в микросервисной архитектуре

Domain Events vs Integration Events

Domain Events (доменные события):

  • События, которые происходят внутри микросервиса (bounded context)
  • Описывают значимые изменения в доменной логике
  • Примеры: UserRegistered, OrderConfirmed, PaymentProcessed
  • Обычно обрабатываются внутри микросервиса для синхронизации состояния

Integration Events (интеграционные события):

  • События, которые пересекают границы микросервисов
  • Предназначены для уведомления других сервисов о значимых событиях
  • Примеры: OrderPlaced (рассылается в брокер), PaymentCompleted (читается другими сервисами)
  • Обычно проще по структуре, содержат только информацию для интеграции

Связь: доменные события обрабатываются внутри сервиса, и если нужно уведомить другие сервисы, издаётся интеграционное событие.

Пример:

OrderService получает команду PlaceOrder
  → внутренне генерирует доменное событие OrderPlaced
  → обновляет своё состояние на основе доменного события
  → издаёт интеграционное событие OrderPlaced в брокер сообщений
NotificationService слушает интеграционное событие OrderPlaced
  → обрабатывает его (отправляет письмо)

Event-driven архитектура

Event-driven архитектура — это подход, при котором компоненты системы коммуницируют через события, а не через синхронные RPC вызовы.

Принципы:

  1. Слабая связанность: сервис A не вызывает сервис B напрямую, а публикует событие, на которое B реагирует
  2. Асинхронность: обработка события может произойти с задержкой
  3. Масштабируемость: легко добавить новый обработчик события, не меняя издателя

Архитектурная топология:

Синхронная архитектура:
OrderService → (sync call) → PaymentService
           → (sync call) → InventoryService
           → (sync call) → NotificationService

Event-driven архитектура:
OrderService → publishes OrderPlaced → message broker
                                        → PaymentService (consumer group 1)
                                        → InventoryService (consumer group 2)
                                        → NotificationService (consumer group 3)

На собеседовании: «Мы используем event-driven архитектуру для интеграции микросервисов. Когда OrderService создаёт заказ, он публикует OrderPlaced в Kafka. NotificationService, PaymentService и InventoryService подписаны на это событие и реагируют независимо. Это позволяет добавить новый сервис (например, AnalyticsService), не меняя OrderService».

Проекционная модель

Проекционная модель — это подход, при котором сервис ведёт локальные проекции (представления) данных, полученные из событийных потоков.

Как работает:

  1. OrderService генерирует события об изменении заказов (OrderPlaced, OrderCancelled, OrderShipped)
  2. ReportingService читает эти события и строит локальное хранилище отчётов
  3. При запросе отчёта ReportingService берёт данные из локального хранилища, а не вызывает OrderService

Преимущества:

  • Оптимизированное хранилище: данные хранятся в той форме, которая оптимальна для запросов (например, денормализованные)
  • Производительность: запросы не требуют RPC
  • Гибкость: легко создать новую проекцию для других сценариев

Связь с CQRS:

  • CQRS (Command Query Responsibility Segregation) разделяет команды (записи) и запросы (чтения)
  • Проекционная модель часто используется с CQRS: проекции служат моделью чтения

Пример:

OrderService (Command Side):

  - Обрабатывает команды PlaceOrder, CancelOrder
  - Генерирует события OrderPlaced, OrderCancelled

OrderProjection (Query Side):

  - Читает события из Kafka
  - Обновляет таблицу orders в эластичном поиске
  - Когда приходит OrderPlaced, добавляет новый заказ в эластик
  - Когда приходит OrderCancelled, удаляет заказ из эластика

API Запрос GET /orders?status=shipped:

  - Читает из проекции в эластике (O(1) по сравнению с O(n) в основной БД)

Как на System Design рассказывать про события в микросервисах

Звучит так: «Когда заказ размещается, OrderService публикует доменное событие OrderPlaced. Это событие преобразуется в интеграционное событие и отправляется в Kafka. Несколько сервисов слушают это событие:

  • NotificationService отправляет письмо клиенту
  • InventoryService резервирует товар
  • AnalyticsService обновляет метрики

Каждый сервис строит свою проекцию на основе событий. Например, OrderAnalytics обновляет таблицу в Elasticsearch с агрегированными данными о заказах. Это позволяет быстро отвечать на аналитические запросы без обращения к основной БД».

Очереди и устойчивость к пикам нагрузки

Очередь как буфер

Очередь служит буфером между продюсером и консьюмером, сглаживая всплески нагрузки.

Сценарий:

Без очереди:

  - API получает 10,000 requests/sec в час пик
  - Каждый request обрабатывает 5 сек
  - Нужно 50,000 экземпляров сервиса обработки
  - Стоимость: астрономическая
  - Даже с 50k экземпляров будут таймауты

С очередью:

  - API получает 10,000 requests/sec
  - API кладёт задачу в очередь (операция <1ms)
  - API отвечает клиенту: "OK, ваша задача в обработке"
  - 100 консьюмеров обрабатывают из очереди в своём темпе
  - Очередь накапливает задачи во время пиков
  - Время обработки всей нагрузки: 10,000 requests * 5 sec / 100 consumers = 500 сек ≈ 8 минут

Backpressure

Backpressure (противодействие давлению) — это механизм, при котором медленный консьюмер замедляет продюсера, чтобы предотвратить переполнение.

Как работает:

Сценарий 1: без backpressure (наивный)
API: Отправляю сообщение в очередь
API: Отправляю сообщение в очередь
API: Отправляю сообщение в очередь
... 10,000 сообщений за секунду
Очередь: заполняется, занимает 100 GB памяти
Очередь: выходит за лимиты хранилища
Система: падает

Сценарий 2: с backpressure
API: Отправляю сообщение в очередь (успешно)
API: Отправляю сообщение в очередь (успешно)
... 1000 сообщений
Очередь: достигнута максимальная глубина
API: Отправляю сообщение в очередь (зависает, ждёт место)
API: Таймаут, возвращаю клиенту ошибку 503 Service Unavailable
Клиент: повторяет запрос позже

Консьюмер: обработал сообщение и удалил из очереди
Очередь: появилось место
API: может отправить сообщение

Backpressure не просто решает проблему переполнения, она даёт клиенту сигнал, что система перегружена, и клиент может:

  • Повторить запрос позже (с exponential backoff)
  • Показать пользователю ошибку "система перегружена"
  • Перенаправить трафик на другой сервер

Стратегии при переполнении очередей

Отбрасывание (dropping):

  • Если очередь переполнена, новые сообщения отбрасываются
  • Применимо только для некритичных данных (метрики, логи)
  • Минус: теряются данные

Отложенная обработка (queuing with TTL):

  • Сообщения остаются в очереди с TTL (time-to-live)
  • Если сообщение не обработано за TTL, оно удаляется
  • Применимо для задач, которые имеют смысл только в течение времени (например, "отправить напоминание")

Аварийные лимиты и алерты:

  • Устанавливаются пороги: если глубина очереди > 50% вместимости, срабатывает alert
  • Инженеры получают оповещение и могут добавить консьюмеров или оптимизировать обработку
  • Если очередь заполнится полностью, наступает критический alert

Деградация функционала:

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

На собеседовании: «Когда в час пик приходит 10x нагрузка, API кладёт задачи в очередь. Очередь может накапливать до 1 млн задач. Если глубина очереди превышает 50%, срабатывает alert, и автоскейлер добавляет консьюмеров. Если глубина достигает 100%, новые запросы получают ошибку 503, и клиент повторяет позже».

Как проговаривать использование очередей для управления нагрузкой

Звучит так: «Вместо синхронной обработки, которая требует масштабировать воркеры в зависимости от пика нагрузки, мы используем асинхронную очередь. API немедленно кладёт задачу и отвечает клиенту. Фиксированное количество консьюмеров обрабатывает задачи в своём темпе. Это позволяет использовать на 80% меньше ресурсов для пиковой нагрузки, потому что нагрузка сглаживается. Очередь также служит буфером при временных сбоях: если БД недоступна на 5 минут, очередь будет расти, но задачи не потеряются».

Масштабирование потребителей

Горизонтальное масштабирование консьюмеров

Горизонтальное масштабирование означает добавление новых экземпляров консьюмера для обработки большего количества сообщений.

Как работает:

Начальное состояние:

  - Топик с 10 партициями
  - 2 консьюмера в группе
  - Каждый обрабатывает 5 партиций
  - Пропускная способность: 100 сообщений/сек (до тех пор, пока не будут обработаны)

Добавили 3-го консьюмера:

  - Брокер перераспределяет партиции
  - Консьюмер 1: партиции [0, 1, 2, 3]
  - Консьюмер 2: партиции [4, 5, 6]
  - Консьюмер 3: партиции [7, 8, 9]
  - Потенциальная пропускная способность увеличилась в 1.5 раза (зависит от бизнес-логики)

Добавили 5-го консьюмера:

  - Консьюмер 1: партиции [0, 1]
  - Консьюмер 2: партиции [2, 3]
  - Консьюмер 3: партиции [4, 5]
  - Консьюмер 4: партиции [6, 7]
  - Консьюмер 5: партиции [8, 9]
  - 6-й консьюмер будет бездействовать (нет партиций)
  - Максимальный параллелизм = число партиций = 10

Процесс перераспределения (rebalancing):

  • Брокер уведомляет всех консьюмеров о необходимости перераспределения
  • Все консьюмеры останавливают обработку и коммитят свои offsets
  • Брокер пересчитывает распределение партиций
  • Консьюмеры получают новое распределение и возобновляют обработку
  • Время rebalancing: обычно 5-10 секунд

«Горячие ключи» (Hot Keys) и дисбаланс нагрузки

Hot key — это partition key, который генерирует непропорционально большой объём сообщений.

Пример:

Топик: UserEvents
Partition key: userId
Распределение:

  - userId 1-1000: 10 сообщений/сек каждый
  - userId 1001: 1,000,000 сообщений/сек (популярный пользователь)

Очки hash(userId) распределяются:

  - userId 1-1000 хешируются в разные партиции
  - userId 1001 хешируется в одну партицию

Результат:

  - Партиция с userId 1001 получает 99% всех сообщений
  - Партиция перегружена, консьюмер отстаёт
  - Другие партиции недогружены

Стратегии борьбы:

1. Композитные ключи:

Вместо:
  partition key = userId

Используй:
  partition key = userId + "_" + random(0, 100)

Результат:

  - userId 1001 теперь генерирует 100 разных partition keys
  - Сообщения распределяются в 100 разных партиций
  - Нагрузка распределяется равномерно
  
Минусы:

  - Теряется гарантия порядка для userId 1001
  - При запросе всех событий userId 1001 нужно читать из 100 партиций

2. Переразбиение (repartitioning):

  • Увеличить число партиций (например, с 10 до 100)
  • Даже hot key будет распределён хотя бы на несколько партиций
  • Требует пересливания всех данных (обычно делается offline)

3. Отдельная топик для hot keys:

Основная UserEvents топик:

  - Обычные пользователи

HighVolumeUserEvents топик:

  - Только для пользователей с объёмом > 100k msg/sec
  - Имеет больше партиций

Консьюмер читает из обеих топик и объединяет

4. Локальный батчинг на продюсере:

Вместо:
  loop:
    send_event(userId=1001, ...)

Используй:
  batch = []
  loop:
    batch.append(event(userId=1001, ...))
    if len(batch) >= 100:
      send_batch(batch)
      batch = []

Результат:

  - Отправляется 100 событий в одном сообщении
  - Нагрузка на брокер снижается в 100 раз
  - Latency увеличивается немного (из-за батчинга)

Ограничение Concurrency (параллелизма)

Увеличение числа консьюмеров увеличивает параллелизм, но это может перегрузить downstream-систему (БД, API, и т.д.).

Пример:

Сценарий: OrderProcessing сервис читает заказы из очереди и записывает в БД

Вариант 1: 100 консьюмеров, каждый обрабатывает 1 сообщение за раз
  - Потенциальный параллелизм: 100 одновременных записей в БД
  - БД: 10 потоков для записи
  - Очередь перед БД: 90 задач ждут
  - Результат: БД перегружена

Вариант 2: 10 консьюмеров, каждый обрабатывает 1 сообщение
  - Потенциальный параллелизм: 10 одновременных записей
  - БД: может обработать 10 потоков
  - Результат: система сбалансирована

Как ограничить параллелизм:

1. Уменьшить число консьюмеров:

  • Вместо 100 экземпляров, запустить 10
  • Минус: потеря масштабируемости, если бизнес-логика быстрая

2. Ограничить batch size (размер батча):

Consumer config:
  max.poll.records = 10 (вместо дефолтного 500)
  
Результат:

  - Консьюмер берёт максимум 10 сообщений за раз
  - Обрабатывает их последовательно
  - Отправляет ack
  - Берёт следующий батч

3. Семафор/限流 внутри обработчика:

Semaphore s = new Semaphore(50); // макс 50 одновременных обработок

while (true) {
  message = consumer.poll();
  s.acquire(); // ждёт, пока не будет место в семафоре
  
  executor.submit(() -> {
    process(message);
    s.release();
  });
}

На собеседовании: «Для OrderProcessing мы использовали бы 20 консьюмеров с batch_size = 10 и семафором максимум 50 одновременных задач. Это гарантирует, что БД не будет перегружена более чем 50 одновременными записями, при этом мы сохраняем масштабируемость для пиков нагрузки».

Очереди и транзакционность

Проблема «двух систем»: БД + Брокер

Одна из ключевых проблем в распределённых системах: как обеспечить, что событие публикуется только если транзакция в БД успешна?

Сценарий проблемы:

Наивный подход:
  1. BEGIN TRANSACTION
  2. UPDATE orders SET status = 'confirmed' WHERE id = 123
  3. COMMIT
  4. broker.publish(OrderConfirmed)

Проблема 1: Откат транзакции
  1. BEGIN TRANSACTION
  2. UPDATE orders SET status = 'confirmed' WHERE id = 123
  3. COMMIT успешен
  4. broker.publish(OrderConfirmed) → брокер недоступен
  5. Заказ в БД подтверждён, но событие не публикуется
  6. Другие сервисы не знают, что заказ подтверждён

Проблема 2: Отправка перед коммитом
  1. BEGIN TRANSACTION
  2. UPDATE orders SET status = 'confirmed' WHERE id = 123
  3. broker.publish(OrderConfirmed)
  4. COMMIT → откатывается (например, нарушение уникальности)
  5. Заказ не подтверждён в БД, но событие уже опубликовано
  6. Другие сервисы ломаются, пытаясь обработать подтвержденный заказ

Outbox Pattern

Outbox pattern решает эту проблему, используя единственный источник истины — саму БД.

Как работает:

1. BEGIN TRANSACTION
2. UPDATE orders SET status = 'confirmed' WHERE id = 123
3. INSERT INTO outbox (event_type, payload, created_at) 
   VALUES ('OrderConfirmed', '{...}', NOW())
4. COMMIT (транзакция покрывает и заказ, и outbox)

Отдельный процесс (Polling Publisher):
5. SELECT * FROM outbox WHERE published_at IS NULL ORDER BY created_at
6. Публикует каждое событие в брокер
7. UPDATE outbox SET published_at = NOW() WHERE id = ...

Гарантии:

  • Если транзакция откатывается, ни заказ, ни событие не создаются
  • Если брокер недоступен, событие остаётся в outbox и будет переиграно позже
  • Events публикуются в порядке, в котором они были созданы

Таблица outbox:

id | event_type | payload | created_at | published_at
---|------------|---------|------------|-------------
1  | OrderPlaced | {...} | 2024-01-01 10:00:00 | 2024-01-01 10:00:01
2  | OrderConfirmed | {...} | 2024-01-01 10:00:05 | NULL
3  | PaymentProcessed | {...} | 2024-01-01 10:00:07 | NULL

Transactional Outbox + Polling Publisher

На архитектурном уровне это выглядит так:

OrderService:

  - Spring Data Repository для Order
  - Spring Data Repository для Outbox
  - При создании заказа: save(Order) + save(OutboxEvent) в одной транзакции

PollingPublisher (отдельный процесс или сервис):

  - Каждые 100ms опрашивает: SELECT * FROM outbox WHERE published_at IS NULL
  - Для каждого события публикует в Kafka
  - UPDATE outbox SET published_at = NOW()
  - Идемпотентность достигается: если Kafka получил событие, но падение перед UPDATE,
    событие будет переиграно, но Kafka дедубликирует его

PollingPublisher может быть:

  - Отдельный сервис, читающий из БД OrderService
  - Встроенный в OrderService, запущенный в отдельном потоке

Оптимизации:

  • Батчинг: опубликовать 100 событий за раз вместо по одному
  • Дополнительная колонка для retry_count: перевести событие в DLQ если неудалось опубликовать 10 раз
  • Партиционирование outbox таблицы по дате для лучшей производительности

Как на собеседовании объяснить решение проблемы «БД + брокер»

Звучит так: «Мы используем Outbox pattern. Когда OrderService создаёт заказ, в одной транзакции записывается заказ в таблицу orders и событие OrderPlaced в таблицу outbox. Отдельный PollingPublisher опрашивает outbox каждые 100 миллисекунд и публикует события в Kafka. Если публикация успешна, он помечает событие как опубликованное. Это гарантирует, что событие никогда не потеряется (даже если Kafka упадёт) и не дублируется (даже если PollingPublisher упадёт). Новый консьюмер может присоединиться и переиграть все события из Kafka».

Dead-letter Очереди и Обработка Ошибок

Классы Ошибок

Не все ошибки одинаковы. Важно различать:

Временные ошибки (transient errors):

  • Сетевые таймауты: external API не ответил за 5 сек
  • Временная недоступность: БД была недоступна 10 сек, теперь доступна
  • Rate limiting: API ответил 429 (too many requests)
  • Характеристика: при повторе позже, вероятно, пройдёт
  • Стратегия: повторить с backoff

Постоянные ошибки (permanent errors):

  • Валидация данных: сообщение содержит невалидный userId
  • Логические ошибки: попытка перевести деньги со счёта без средств (может быть постоянная ошибка)
  • Неверный формат: JSON не парсится
  • Характеристика: повторы не помогут, нужна ручная интервенция
  • Стратегия: отправить в DLQ и алертить операторов

Пример различия:

Сообщение: {"paymentId": "pay_123", "amount": -100}

Постоянная ошибка:

  - amount = -100 валидно? Нет, сумма не может быть отрицательной
  - Повторы не помогут, данные некорректны
  - DLQ + alert

Временная ошибка:

  - При первой попытке вызов БД: Connection timeout
  - При повторе: БД теперь доступна
  - Retry с backoff

Ретраи

Retry (повторная попытка) — это стратегия обработки временных ошибок.

Параметры ретраев:

max_retries: 3 (максимум 3 попытки)
initial_delay: 100ms (первый ретрай через 100ms)
backoff_multiplier: 2 (каждый ретрай дольше в 2 раза)
max_delay: 60s (но не больше 60 сек)

Таблица ретраев:
Попытка 1: immediate (ошибка)
Попытка 2: через 100ms (ошибка)
Попытка 3: через 200ms (ошибка)
Попытка 4: через 400ms (ошибка)
Итого 3 ретрая, все не удались → DLQ

Какие ошибки заслуживают ретраев:

if (exception instanceof SocketTimeoutException) {
  retry(); // временная сетевая ошибка
}
if (exception instanceof ConnectionException) {
  retry(); // БД недоступна
}
if (statusCode == 429) {
  retry(); // Rate limiting
}
if (statusCode == 5xx) {
  retry(); // Server error
}

if (statusCode == 400 || statusCode == 404) {
  skipToDeadLetter(); // Permanent error
}

На практике в Spring Boot:

@Retryable(
  value = { TemporaryException.class },
  maxAttempts = 3,
  backoff = @Backoff(delay = 100, multiplier = 2)
)
public void processMessage(Message msg) {
  // бизнес-логика
}

@Recover
public void recover(TemporaryException e, Message msg) {
  // отправить в DLQ
  deadLetterService.send(msg);
}

Перенос в DLQ

Критерии, когда сообщение считается "ядовитым" (poison message):

  1. Все ретраи исчерпаны (max_retries превышено)
  2. Ошибка постоянного характера (валидация, логическая ошибка)
  3. Сообщение ломает инварианты (например, из-за некорректного формата)

Как попадает в DLQ:

Consumer:
  1. Читает сообщение из очереди
  2. Пытается обработать
  3. Ловит исключение
  4. Проверяет: это temporary или permanent?
  5. Если temporary и ретраи < max_retries: requeue (отправить обратно в очередь)
  6. Если permanent или ретраи исчерпаны: отправить в DLQ
  7. Отправить ack для исходного сообщения (чтобы оно не было переиграно)

DLQ консьюмер:

  - Читает из DLQ
  - Логирует сообщение и ошибку
  - Отправляет alert: "DLQ получило сообщение"
  - Сохраняет для анализа в отдельное хранилище

Сценарии обработки DLQ:

1. Ручной анализ:

Оператор:

  - Получает alert: "DLQ получило 5 сообщений"
  - Смотрит DLQ: все сообщения имеют невалидный userId
  - Понимает: бага в продюсере, он отправляет невалидные userId
  - Исправляет продюсер
  - Опционально: переигрывает сообщения из DLQ (если формат можно спасти)

2. Автоматический реплей:

После фиксации бага:
  1. Развернуть скрипт: SELECT * FROM dlq WHERE created_at > '2024-01-01 10:00'
  2. Для каждого сообщения: отправить обратно в основную очередь
  3. Консьюмер обработает сообщение повторно
  
Это работает, если баг был в обработчике, а не в самом сообщении

3. Отдельный сервис:

DLQAnalyzer сервис:

  - Читает из DLQ
  - Пытается восстановить сообщение (например, парсить из логов)
  - Классифицирует ошибку (временная vs постоянная)
  - Если можно спасти: отправить обратно
  - Если нельзя: отправить в архив и алертить

На собеседовании: «При обработке платежей, если консьюмер не может обработать сообщение после 3 ретраев, он отправляет его в DLQ. Отдельный DLQ консьюмер логирует сообщение и отправляет alert инженерам. Инженер может посмотреть, что пошло не так (например, невалидный formattorr в сообщении), исправить бага и переиграть сообщения из DLQ».

Лог Событий как Источник Истины

Событийный Лог (Event Log)

Событийный лог — это append-only структура данных, которая хранит полную историю всех событий, произошедших в системе.

Характеристики:

  • Append-only: сообщения только добавляются, не удаляются и не обновляются
  • Упорядоченность: каждое сообщение получает монотонно возрастающий номер (offset)
  • Переигра: можно прочитать все события с начала и воспроизвести любое прошлое состояние

Примеры событийных логов:

  • Kafka топик: append-only логирование сообщений
  • WAL (Write-Ahead Log) в БД: логирование всех изменений перед применением

Пример лога:

Offset | Timestamp | EventType | Payload
-------|-----------|-----------|----------
0      | 10:00:00  | UserCreated | {"id": 1, "name": "Alice", "email": "alice@example.com"}
1      | 10:00:05  | OrderPlaced | {"id": 1, "userId": 1, "amount": 100}
2      | 10:00:10  | PaymentProcessed | {"orderId": 1, "status": "success", "txnId": "tx_123"}
3      | 10:00:15  | OrderConfirmed | {"orderId": 1}
4      | 10:00:20  | OrderShipped | {"orderId": 1, "trackingId": "track_456"}

Event Sourcing (На Уровне Понятий)

Event sourcing — это архитектурный паттерн, при котором состояние сущности не хранится напрямую, а вычисляется путём воспроизведения всех событий, затрагивающих эту сущность.

Сравнение подходов:

Традиционный подход (state storage):

Таблица orders:
  id | status | amount | created_at
  1  | shipped | 100 | 2024-01-01 10:00
  
При запросе: SELECT status FROM orders WHERE id = 1 → "shipped"
При обновлении: UPDATE orders SET status = 'delivered'

Event sourcing подход:

Таблица order_events:
  orderId | eventType | payload | timestamp
  1 | OrderPlaced | {"amount": 100} | 2024-01-01 10:00:00
  1 | PaymentProcessed | {"status": "success"} | 2024-01-01 10:00:05
  1 | OrderConfirmed | {} | 2024-01-01 10:00:10
  1 | OrderShipped | {"trackingId": "track_456"} | 2024-01-01 10:00:15
  1 | OrderDelivered | {} | 2024-01-01 10:00:20

При запросе статуса:
  1. Читаем все события для orderId=1 в порядке
  2. Применяем их по очереди:

     - После OrderPlaced: status = "placed"
     - После PaymentProcessed: status = "confirmed"
     - После OrderShipped: status = "shipped"
     - После OrderDelivered: status = "delivered"
  3. Возвращаем: "delivered"

Преимущества event sourcing:

  • Полная аудитория: все изменения зафиксированы
  • Способность к переигре: можно восстановить любое прошлое состояние
  • Аналитика: легко видеть, как часто переходит из статуса в статус

Недостатки:

  • Сложность: нужно интерпретировать события для получения состояния
  • Производительность: воспроизведение всех событий медленнее, чем прямой запрос
  • Обновление логики: нужно мигрировать события при смене логики

Связь с CQRS и Проекциями

Event sourcing часто используется с CQRS (Command Query Responsibility Segregation):

  • Command Side: события хранятся в event store
  • Query Side: на основе события генерируются проекции (денормализованные представления)
Event Store:

  - OrderPlaced(id=1, amount=100)
  - PaymentProcessed(orderId=1, status=success)

Проекция в таблице Order (денормализованная):
  id | status | amount
  1 | confirmed | 100

Проекция в таблице UserBalance (отдельная):
  userId | totalSpent
  1 | 100

При запросе: берём из проекции (быстро), не воспроизводим события (медленно)
При написании события: обновляем проекции

Разница между Логом Интеграционных Событий и Event Store

Лог интеграционных событий (обычно Kafka):

  • События публикуются между сервисами
  • Используется для асинхронной интеграции
  • Может быть ephemeral (события удаляются через время)
  • Пример: OrderPlaced в Kafka, NotificationService слушает и отправляет письма

Event Store (обычно БД):

  • События хранятся в единственном источнике истины
  • Внутри сервиса для event sourcing
  • Обычно постоянный (events не удаляются)
  • Пример: все события для Order в PostgreSQL, используются для воспроизведения состояния

На практике часто используется комбинация:

OrderService:

  - Event Store (в БД): все события для заказов
  - Event Publisher: читает Event Store, публикует интеграционные события в Kafka
  
Другие сервисы:

  - Слушают интеграционные события в Kafka
  - Не имеют доступа к Event Store OrderService

Использование Очередей и Стриминга для Интеграций

Интеграция Между Внутренними Сервисами

Event-driven интеграция заменяет синхронные RPC вызовы асинхронными событиями.

До (синхронная интеграция):

OrderService.placeOrder(order):
  1. Создать заказ в БД
  2. Вызвать synchronously InventoryService.reserveItems(items)
     - Если InventoryService упал → весь placeOrder падает
     - Если InventoryService медленный → placeOrder ждёт
  3. Вызвать synchronously PaymentService.processPayment(amount)
     - Если PaymentService упал → весь placeOrder падает
  4. Вызвать synchronously NotificationService.sendEmail(...)
     - Если NotificationService упал → весь placeOrder падает
  5. Вернуть результат клиенту

После (асинхронная интеграция через события):

OrderService.placeOrder(order):
  1. Создать заказ в БД
  2. В outbox записать событие OrderPlaced
  3. Вернуть результат клиенту немедленно
  
PollingPublisher:

  - Публикует OrderPlaced в Kafka

InventoryService (слушает OrderPlaced):

  - Резервирует товар
  - Публикует ItemsReserved или ReservationFailed

PaymentService (слушает OrderPlaced):

  - Обрабатывает платёж
  - Публикует PaymentSucceeded или PaymentFailed

NotificationService (слушает OrderPlaced):

  - Отправляет письмо
  - Может отработать с задержкой

Преимущества:

  • Слабая связанность: OrderService не зависит от других сервисов
  • Отказоустойчивость: если PaymentService упал, OrderService продолжает работать
  • Масштабируемость: можно добавить новый сервис (например, AuditService) без изменения OrderService

Интеграция с Внешними Системами

Очереди используются как буфер при интеграции с нестабильными внешними API.

Сценарий:

OrderService должен отправить заказ в интеграцию с logistics API:

  - API может быть недоступна
  - API может быть медленной
  - API может рейт-лимитить запросы

Решение с очередью:
  1. OrderService создаёт заказ, публикует OrderPlaced
  2. LogisticsAdapter слушает OrderPlaced
  3. LogisticsAdapter берёт заказ из очереди (из своего темпа)
  4. LogisticsAdapter вызывает внешнее API
  5. Если API недоступна: сообщение остаётся в очереди, переиграется позже
  6. Если API рейт-лимитирует: LogisticsAdapter замедляется, очередь буферизирует нагрузку

Адаптер (логика):

LogisticsAdapter:

  - Читает из очереди с batch_size = 10 (не забомбить внешнее API)
  - Вызывает external_api.createShipment(order)
  - Если успешно: ack сообщение
  - Если failure с retry: положить обратно в очередь или переиграть позже
  - Если permanent failure: отправить в DLQ

Change Data Capture (CDC)

Change Data Capture (CDC) — это способ автоматически захватывать изменения в БД и публиковать их в качестве событий.

Как работает:

1. Основная БД (например, PostgreSQL):

   - Таблица orders: id | status | amount
   
2. CDC инструмент (например, Debezium):

   - Читает логи репликации БД (WAL, binlog, и т.д.)
   - Видит: UPDATE orders SET status = 'confirmed' WHERE id = 1
   - Генерирует событие: OrderUpdated(id=1, status='confirmed')
   - Публикует в Kafka топик 'db.orders.changes'
   
3. Другие системы читают из Kafka:

   - SearchService обновляет индекс в Elasticsearch
   - CacheService инвалидирует кеш
   - ReportingService обновляет отчёты

Примеры инструментов:

  • Debezium: универсальный CDC для PostgreSQL, MySQL, MongoDB, и т.д.
  • AWS DMS: Change Data Capture для AWS
  • LinkedIn Databus: внутренний CDC LinkedIn

Когда подходит:

  • Синхронизация между системами (одна система — source of truth, остальные зеркалят)
  • Миграция данных (новая система читает изменения из старой)
  • Aудит (все изменения логируются)

На собеседовании: «Для синхронизации OrderDB с SearchIndex, мы используем Debezium CDC. Все изменения в PostgreSQL автоматически захватываются и публикуются в Kafka. SearchService читает из Kafka и обновляет Elasticsearch. Это гарантирует консистентность между БД и индексом».

Observability и Эксплуатация Очередей и Стриминга

Основные Метрики

Нужно мониторить здоровье очередей и потребителей:

1. Длина очереди / Lag (отставание):

Lag = offset_of_new_messages - offset_of_consumer

Пример:

  - Продюсер опубликовал сообщения до offset 1000
  - Консьюмер обработал до offset 950
  - Lag = 1000 - 950 = 50

Метрика в Prometheus:
  kafka_consumer_lag{topic="orders", group="payment-service", partition="0"}

Интерпретация:

  • Lag = 0: консьюмер в ногу с продюсером (отличное состояние)
  • Lag = 100: консьюмер отстаёт на 100 сообщений (нормально, если не растёт)
  • Lag = 100,000 и растёт: консьюмер не поспевает за продюсером (критическая ситуация)

2. Скорость обработки (messages/sec):

Метрика: сообщений, обработанных за секунду

Отличное: 1000 msg/sec
Нормальное: 500 msg/sec
Плохое: 10 msg/sec (консьюмер очень медленный)

3. Время жизни сообщения в очереди (age):

Age = now() - message_timestamp

Пример:

  - Сообщение создано в 10:00:00
  - Сейчас 10:05:00
  - Age = 5 минут
  
Отличное: age < 1 сек (обрабатывается быстро)
Нормальное: age < 1 мин
Плохое: age > 1 часа (сообщение застряло)

4. Ошибки и DLQ сообщения:

Метрика 1: количество сообщений в DLQ
  - DLQ пуста: хорошо
  - В DLQ 10 сообщений: расследовать
  - В DLQ растёт: критическая ошибка

Метрика 2: частота ошибок (errors/sec)
  - Ошибки 0/sec: идеально
  - Ошибки 1-5/sec: допустимо (в зависимости от общего объёма)
  - Ошибки 100+/sec: проблема

Логирование

Лог-ориентированное отслеживание критично для отладки распределённых систем.

Correlation ID / Trace ID:

Исходный запрос:

  - API получает запрос: POST /orders
  - Генерирует traceId = "trace_12345"
  - Записывает в лог: traceId=trace_12345 msg="Order placed"

Событие в Kafka:

  - Сообщение содержит: {"traceId": "trace_12345", ...}

DownstreamService:

  - Читает из Kafka
  - Извлекает traceId = "trace_12345"
  - Записывает в лог: traceId=trace_12345 msg="Processing payment"

Когда нужна отладка:

  - Поиск по traceId=trace_12345 в логах всех сервисов
  - Полная цепочка: API → OrderService → Kafka → PaymentService → логи

Логирование ошибок:

При обработке сообщения:
  try {
    process(message);
  } catch (Exception e) {
    log.error("Failed to process message",
      "traceId", message.getTraceId(),
      "messageId", message.getId(),
      "error", e.getMessage(),
      "stackTrace", e);
    deadLetterQueue.send(message);
  }

Алерты

Алерты сообщают инженерам о проблемах.

Типичные алерты:

1. Растущий Lag:

   - Условие: lag растёт более 1000 сообщений в минуту
   - Действие: добавить консьюмеров или оптимизировать обработку

2. Растущий DLQ:

   - Условие: в DLQ появилось 10+ сообщений за час
   - Действие: расследовать причину, посмотреть ошибки

3. Консьюмер не работает:

   - Условие: no messages processed за 5 минут
   - Действие: проверить консьюмер, перезагрузить

4. Брокер упал:

   - Условие: брокер недоступен
   - Действие: критический alert, восстанавливать услугу

5. Недостаточно хранилища:

   - Условие: хранилище брокера > 80% полно
   - Действие: добавить дисковое пространство или удалить старые сообщения

На собеседовании: «Мы мониторим lag каждой consumer group. Если lag растёт свыше 10k сообщений, срабатывает alert. Мы также мониторим DLQ: если там появляется 5+ сообщений, это critical alert, потому что это означает ошибку обработчика. Все события с traceId логируются, что позволяет отследить цепочку от исходного запроса через все микросервисы».

Как Упомянуть Observability на System Design

Звучит так: «Для observability мы:

  1. Добавим correlation ID к каждому сообщению, что позволит отследить запрос через все сервисы
  2. Будем мониторить lag: если lag > 10k, значит консьюмер отстаёт
  3. Будем отслеживать количество сообщений в DLQ: рост означает ошибку в обработчике
  4. Запустим дашборд в Grafana с графиками: lag по каждой consumer group, частота ошибок, latency обработки
  5. Настроим алерты: если lag растёт, если DLQ растёт, если консьюмер мёртв

Это даст нам видимость в здоровье event-driven части системы».

Очереди и Java Backend

Клиенты для Брокеров

При работе с очередями в Java используются специализированные клиенты.

Kafka (KafkaProducer / KafkaConsumer):

// Продюсер
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.send(new ProducerRecord<>("orders", orderId, orderJson));

// Консьюмер
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("orders"));
for (ConsumerRecord<String, String> record : consumer.poll(Duration.ofMillis(100))) {
  process(record.value());
}
consumer.commitAsync();

Параметры:

Продюсер:

  - acks: how many replicas must acknowledge ("all", "1", "0")
  - retries: number of retries
  - batch.size: batch size in bytes
  - linger.ms: wait time before sending batch

Консьюмер:

  - group.id: consumer group name
  - auto.offset.reset: what to do if no offset ("earliest", "latest", "none")
  - enable.auto.commit: auto-commit offsets
  - session.timeout.ms: time before rebalancing if no heartbeat

RabbitMQ (AMQP):

Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.basicPublish("exchange", "routing.key", null, message.getBytes());

// Консьюмер
channel.basicConsume("queue", false, (tag, delivery) -> {
  process(delivery.getBody());
  channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
});

Spring Boot интеграции:

// Spring Kafka
@KafkaListener(topics = "orders", groupId = "payment-service")
public void handleOrderEvent(OrderPlaced event) {
  processPayment(event);
}

@KafkaTemplate
public void publish(OrderPlaced event) {
  kafkaTemplate.send("orders", event);
}

// Spring RabbitMQ (используется редко в микросервисах)
@RabbitListener(queues = "orderQueue")
public void handleOrder(Order order) {
  processOrder(order);
}

Обработчики Сообщений

Обработчик сообщения — это бизнес-логика, которая выполняется при получении сообщения.

Single-threaded обработка (последовательная):

while (true) {
  message = consumer.poll();
  try {
    process(message); // синхронная обработка, может занять 5 сек
  } catch (Exception e) {
    handleError(e);
  }
  consumer.commitAsync();
}

Пропускная способность: 1 / 5 сек = 0.2 msg/sec

Multi-threaded обработка (параллельная):

ExecutorService executor = Executors.newFixedThreadPool(50);

while (true) {
  List<Message> batch = consumer.poll(Duration.ofMillis(100)); // batch из 100 сообщений
  for (Message message : batch) {
    executor.submit(() -> {
      try {
        process(message);
        consumer.commitAsync();
      } catch (Exception e) {
        handleError(e);
      }
    });
  }
}

Пропускная способность: 50 * (1 / 5 сек) = 10 msg/sec

Spring Kafka по умолчанию использует multi-threaded обработку (параметр concurrency):

spring.kafka.listener.concurrency=50

Влияние бизнес-логики на throughput:

Бизнес-логика 1: валидировать заказ (быстро, 1ms)
  - Потенциальный throughput с 50 потоками: 50k msg/sec

Бизнес-логика 2: вызвать внешнее API (медленно, 500ms)
  - Потенциальный throughput с 50 потоками: 100 msg/sec

Вывод: throughput зависит от бизнес-логики, не только от числа потоков

Idempotent Handlers

Идемпотентные обработчики — это обработчики, которые безопасно обрабатывать один и тот же запрос дважды.

Минус (не идемпотентный):

public void handlePaymentProcessed(PaymentEvent event) {
  balance += event.getAmount(); // если придёт дважды, баланс += 2x
  userRepository.save(user);
}

Плюс (идемпотентный):

public void handlePaymentProcessed(PaymentEvent event) {
  // Подход 1: проверить, был ли обработан
  if (isProcessed(event.getPaymentId())) {
    return; // уже обработано, пропустить
  }
  
  // Подход 2: использовать upsert
  Payment payment = new Payment(event.getPaymentId(), event.getAmount());
  paymentRepository.upsert(payment); // INSERT ON CONFLICT UPDATE
  
  // Подход 3: использовать естественный ключ с уникальным индексом
  user.addTransaction(new Transaction(event.getPaymentId(), event.getAmount()));
  userRepository.save(user);
}

На практике в Spring Boot:

@KafkaListener(topics = "payments", groupId = "balance-service")
@Transactional
public void handlePayment(PaymentProcessed event) {
  // Проверить, был ли обработан
  Optional<ProcessedPayment> existing = processedPaymentRepository
    .findByPaymentId(event.getPaymentId());
  if (existing.isPresent()) {
    return; // идемпотентность: повторная обработка ничего не делает
  }
  
  // Обработать и записать в БД
  ProcessedPayment record = new ProcessedPayment(event.getPaymentId(), event.getAmount());
  processedPaymentRepository.save(record); // сохранить для идемпотентности
  
  // Обновить баланс
  User user = userRepository.findById(event.getUserId());
  user.addBalance(event.getAmount());
  userRepository.save(user);
}

Как на Собеседовании Говорить про Java-практику

Звучит так: «Для обработки событий мы используем Spring Kafka с 50 потоками обработки. Каждый обработчик читает сообщение, обрабатывает его (например, обновляет баланс в БД), и отправляет ack. Обработчик спроектирован как идемпотентный: первый раз платёж обрабатывается, второй раз он проверяет, был ли уже обработан по paymentId, и пропускает обработку. Это гарантирует, что при at-least-once доставке система остаётся корректной. Если обработка падает с исключением, сообщение переигрывается, а если после 3 ретраев всё ещё не работает, оно отправляется в DLQ».

Чек-лист Обсуждения Очередей и Стриминга на System Design

Вот структурированный подход к обсуждению event-driven части системы на собеседовании:

Какие Пункты Обязательно Покрыть

1. Зачем нужен брокер в данной системе:

Начить с очевидного: почему очередь подходит лучше, чем синхронный вызов?

Ответ:

  • Эта операция может быть медленной, и не нужно блокировать исходный запрос
  • Может быть пик нагрузки, который нужно буферизировать
  • Нужна слабая связанность между сервисами
  • Нужна надёжность: даже если downstream-сервис упал, сообщение будет сохранено

Примеры:

  • OrderService → отправить OrderPlaced → брокер → несколько подписчиков (Notification, Payment, Inventory)
  • Синхронный вызов OrderService → PaymentService: если PaymentService упал, весь заказ падает

2. Модель использования (очереди vs стриминг, point-to-point vs pub/sub):

Очень важно артикулировать различие:

  • Очередь (RabbitMQ): point-to-point, message удаляется после обработки
  • Стриминг (Kafka): pub/sub, events остаются, множество подписчиков

Ответ:

  • Для OrderPlaced нам подходит топик (Kafka), потому что несколько разных сервисов (Notification, Payment, Inventory) должны получить одно событие, и могут присоединяться новые подписчики
  • Для распределённых задач обработки изображений нам подходит очередь (RabbitMQ), потому что каждую задачу должен обработать ровно один воркер

3. Delivery semantics и идемпотентность обработчиков:

Это ключевой пункт, показывающий глубокое понимание:

  • Мы используем at-least-once доставку, потому что потеря события недопустима
  • Это означает, что событие может прийти дважды
  • Поэтому обработчик спроектирован как идемпотентный: используем upsert в БД или проверяем processed-ID

4. Партиционирование и порядок:

Объяснить, как достигается порядок и масштабируемость одновременно:

  • Топик имеет 50 партиций
  • Все события для одного userId имеют partition key = userId, поэтому попадают в одну партицию
  • Это гарантирует порядок событий для одного пользователя
  • 50 партиций обрабатываются 50 консьюмерами параллельно, что даёт масштабируемость

5. DLQ и стратегия обработки ошибок:

Показать понимание, что не все сообщения успешно обрабатываются:

  • Если обработка падает с исключением, консьюмер пытается повторить 3 раза с exponential backoff
  • Если все попытки исчерпаны, сообщение отправляется в DLQ
  • Отдельный процесс мониторит DLQ и алертирует инженеров
  • Инженер расследует причину (например, неверный формат сообщения) и переигрывает из DLQ после фиксации

6. Как очереди помогают с пиками нагрузки и backpressure:

Практический пример:

  • Без очереди: в час пик 10x нагрузка → нужно 10x больше ресурсов воркеров → затраты
  • С очередью: нагрузка буферизируется в очереди, 100 консьюмеров обрабатывают в своём темпе, всё обрабатывается за 2 часа вместо 20 минут

7. Как мониторятся и масштабируются консьюмеры:

Завершить описанием observability и операционной части:

  • Мониторим lag: если lag > 10k, добавляем консьюмеров
  • Мониторим DLQ: если растёт, критический alert
  • Correlation ID в логах позволяет отследить цепочку запроса через все сервисы

Пример Краткого Структурированного Ответа

Как вписать очереди в любую design-задачу:

«Для интеграции сервисов мы используем асинхронное взаимодействие через Kafka.

Архитектура:

- OrderService создаёт заказ, публикует OrderPlaced в Kafka
- TopicOrderPlaced имеет 50 партиций, каждая реплицируется на 3 ноды
- Partition key = orderId, гарантирует порядок событий для одного заказа
- 50 консьюмеров в каждой consumer group (NotificationService, PaymentService, InventoryService) обрабатывают параллельно

Надёжность:

- at-least-once доставка: события не потеряются
- Обработчики идемпотентны: используем natural key (orderId) в БД, upsert гарантирует идемпотентность
- Outbox pattern для OrderService: событие записывается в outbox вместе с заказом в одной транзакции, гарантирует публикацию

Обработка ошибок:

- Если PaymentService не может обработать платёж: 3 ретрая с exponential backoff
- Если все ретраи исчерпаны: отправляем в DLQ
- Отдельный мониторинг DLQ: alert если сообщения появились

Масштабирование:

- При пике нагрузки 10x: Kafka буферизирует 500k заказов в очереди
- Консьюмеры обрабатывают в своём темпе, очередь не переполняется
- Lag мониторится: при lag > 50k автоскейлер добавляет консьюмеров

Мониторинг:

- Correlation ID в каждом событии для трассировки
- Grafana дашборд: lag по партициям, frequency ошибок, latency обработки
- Алерты: если lag растёт, если DLQ растёт, если консьюмер мёртв
»

Типичные Ошибки Кандидатов при Обсуждении Очередей и Стриминга

Ошибка 1: «Магическая Kafka»

Минус:

Мы используем Kafka для решения этой проблемы.

Интервьюер думает: кандидат не объяснил, почему именно Kafka и как она решит задачу.

Плюс:

Для асинхронной интеграции между сервисами мы используем Kafka. Она позволяет OrderService опубликовать OrderPlaced, а NotificationService, PaymentService и InventoryService независимо читают это событие. Это дает нам слабую связанность и позволяет добавлять новые потребители без изменения OrderService. Кроме того, Kafka буферизирует события, если PaymentService временно недоступен, события не потеряются.

Ошибка 2: Игнорирование Delivery Semantics

Минус:

Мы публикуем событие в Kafka, и потребитель его обрабатывает.

Интервьюер думает: кандидат не знает, что может быть дублирование или потеря.

Плюс:

Мы используем at-least-once доставку, что означает, что событие может прийти дважды. Чтобы система оставалась корректной, каждый обработчик идемпотентен: для платежа мы используем upsert по paymentId, второе получение того же платежа просто игнорируется.

Ошибка 3: Отсутствие Стратегии Обработки Ошибок

Минус:

Если обработчик падает, мы просто логируем и забываем.

Интервьюер думает: какие сообщения теряются? как оператор узнает о проблеме?

Плюс:

Если консьюмер не может обработать сообщение, он пытается 3 раза с exponential backoff (100ms, 200ms, 400ms). Если все попытки не удались, сообщение отправляется в DLQ. Отдельный процесс мониторит DLQ, и при появлении сообщений срабатывает critical alert. Инженер смотрит логи и может переиграть сообщение после фиксации.

Ошибка 4: Непонимание Разницы между Очередью и Логом Событий

Минус:

Мы используем Kafka как очередь для распределённой обработки задач.

Интервьюер думает: Kafka не очередь, это лог событий. Кандидат путает концепции.

Плюс:

Для обработки фоновых задач мы используем RabbitMQ как точку-в-точку очередь, где каждая задача обрабатывается ровно одним воркером. Для асинхронной интеграции между сервисами мы используем Kafka как событийный лог, где множество потребителей читают одно событие независимо.

Ошибка 5: Перегрузка Buzzword'ами без Связи с Требованиями

Минус:

Мы используем Event Sourcing, CQRS и Kafka для достижения согласованности.

Интервьюер думает: кандидат просто перечисляет buzzword'ы, не объясняя, почему это нужно для конкретной задачи.

Плюс:

Для отчётов в реальном времени мы используем CQRS: OrderService (Command Side) обрабатывает команды PlaceOrder, генерирует события OrderPlaced. ReportingService (Query Side) слушает события и обновляет Elasticsearch для быстрого поиска. Это даёт нам отчёты в реальном времени без перегрузки основной БД. Event sourcing мы не используем, потому что OrderService не требует полной истории всех событий, только текущее состояние.

Рекомендации, Как Звучать как Senior

1. Объяснять Trade-offs:

Не просто говорить про функциональность, но про компромиссы:

  • at-least-once доставка требует идемпотентности обработчиков (trade-off: сложность за надёжность)
  • Гарантирование порядка требует 1 партиции (trade-off: порядок за масштабируемость)
  • Высокий replication factor требует больше ресурсов (trade-off: надёжность за ресурсы)

2. Говорить о Числах:

Интервьюеры ценят кандидатов, которые думают в масштабах:

  • «Если у нас 1000 заказов в секунду и каждый обработчик может обработать 100 заказов в секунду, нам нужно 10 потоков обработки»
  • «DLQ может расти на 1000 сообщений в минуту при ошибке, это потребует масштабирования»

3. Упоминать Operability:

Senior инженер думает не только о функциональности, но и о том, как это поддерживать:

  • Мониторинг lag, DLQ, latency
  • Алерты для критичных ситуаций
  • Runbook для общих проблем (lag растёт, DLQ растёт, консьюмер мёртв)

4. Показывать Опыт:

Рассказывать о реальных проблемах, которые встречались:

  • «В production был инцидент, когда один пользователь генерировал 1 млн сообщений в секунду (hot key), это перегрузило одну партицию. Мы добавили композитный partition key, чтобы распределить нагрузку».
  • «Мы упустили, что консьюмеры работают медленнее ожидаемого, lag вырос до 500k за ночь. Добавили больше потоков обработки».

Транзакции

Введение: Почему консистентность критична

В монолитных системах с единой базой данных консистентность гарантируется ACID-транзакциями на уровне СУБД. При разбиении системы на микросервисы с собственными хранилищами появляется сетевая граница, которая вносит принципиальные ограничения.

Сетевые ограничения в распределённых системах

На каждом этапе межсервисного взаимодействия может произойти:

  • Задержка (латенция): ответ идёт не мгновенно, типичные значения 1–100 мс в локальной сети, 100–500 мс между дата-центрами
  • Недоступность сервиса: один сервис временно не отвечает, вызов зависает до истечения timeout
  • Частичный отказ: часть инфраструктуры упала, система продолжает частично работать

Эти явления делают временное расхождение данных между компонентами нормой, а не исключением. Синхронизация происходит асинхронно через события или команды.

Бизнес-последствия неправильной консистентности

Недоработанная консистентность приводит к реальным потерям:

Сценарий Последствие
Дублирование платежей Пользователь нажимает кнопку "Оплатить" дважды из-за таймаута → списаны две суммы
Пропадающие заказы Заказ создан в БД, но событие не дошло до сервиса логистики → заказ зависает в статусе "создан"
Двойная бронь ресурса Два пользователя резервируют одну комнату отеля → оба получают подтверждение
Расхождение отчётов Финансовый отчёт показывает сумму A, реальный баланс B → несоответствие в аудите

Основные понятия

Бизнес-инварианты

Бизнес-инвариант — это условие, которое всегда должно быть истинным в согласованном состоянии системы.

Примеры инвариантов:

  • Баланс счёта ≥ 0 (в консервативных системах)
  • Заказ находится в одном и только в одном статусе из набора {создан, оплачен, отправлен, доставлен, отменён}
  • Количество товара на складе ≥ 0
  • Администратор не может удалить организацию, если он единственный активный администратор

На этапе проектирования архитектуры нужно определить:

  1. Какие инварианты обязательно требуют немедленного выполнения (strong consistency)
  2. Какие инварианты могут быть нарушены на короткое время (eventual consistency)
  3. Как система будет восстанавливаться при нарушении инварианта

Локальная vs глобальная консистентность

Локальная консистентность: гарантируется внутри одного сервиса в его БД через ACID-транзакции. Например, Account Service гарантирует консистентность баланса внутри своей БД.

Глобальная консистентность: согласованность данных между несколькими сервисами. Пример: при создании заказа нужно синхронизировать состояние в Order Service, Inventory Service, Payment Service, Notification Service.

Ключное понимание: глобальный ACID между сервисами недостижим из-за сетевых ограничений. Вместо этого используются паттерны для достижения глобальной консистентности на уровне бизнес-процессов.

ACID и BASE

Аспект ACID (монолит) BASE (микросервисы)
Атомарность Транзакция целиком выполняется или откатывается Локально атомарно внутри сервиса
Консистентность Инварианты всегда сохранены Инварианты сохраняются локально, глобально — в конечном итоге
Изоляция Транзакции не мешают друг другу Локальная изоляция, асинхронная координация между сервисами
Долговечность После коммита данные на диске Локально долговечно, восстановление через события
Доступность Зависит от одной БД Система остаётся доступна при частичных отказах
Состояние Фиксированное В процессе изменения (промежуточные статусы)

Модели консистентности

Strong Consistency (Сильная консистентность)

Определение: все клиенты видят одно и то же состояние данных сразу после записи.

Гарантия: после возврата успешного ответа клиенту, все последующие чтения вернут обновленные данные.

Где требуется:

  • Финансовые операции (переводы, платежи): баланс не должен быть отрицательным ни в какой момент
  • Критичные ресурсы (билеты, места в отеле): один ресурс не может быть продан дважды
  • Юридически значимые операции (контракты, соглашения)

Механизмы реализации:

  • Синхронные вызовы между сервисами (REST/gRPC с ожиданием ответа)
  • Глобальные распределённые транзакции (2PC, 3PC)
  • Блокировка ресурсов на время операции

Стоимость:

Метрика Влияние
Latency ↑ (из-за синхронной координации)
Throughput ↓ (блокировки удерживают ресурсы)
Availability ↓ (зависимость от нескольких сервисов)

Типичные значения для strong consistency в микросервисном контексте:

  • P50 latency: 50–200 мс (в зависимости от количества сервисов)
  • P99 latency: 500–2000 мс
  • Throughput: 100–1000 операций/сек на один инстанс

Eventual Consistency (Консистентность в конечном итоге)

Определение: данные не гарантируют быть одинаковыми сразу после записи, но через некоторое время все запросы вернут одно состояние.

Типичная временная шкала:

  • Оптимистичный случай: 1–10 мс (если события обрабатываются на том же сервере)
  • Нормальный случай: 50–500 мс (события через Kafka)
  • Пессимистичный случай: 1–5 сек (перегруженная система, многоуровневая обработка)

Где достаточно:

  • Статистика и счетчики (лайки, просмотры, комментарии)
  • Ленты и рекомендации (актуальность за часы, не за миллисекунды)
  • Нотификации (задержка в несколько секунд приемлема)
  • Кэши и денормализованные данные

Преимущества:

Метрика Улучшение
Latency ↓ (асинхронная обработка)
Throughput ↑ (нет блокировок)
Availability ↑ (система работает даже при отказе некоторых сервисов)

Типичные значения для eventual consistency:

  • P50 latency: 1–50 мс
  • P99 latency: 50–500 мс
  • Throughput: 10000+ операций/сек на один инстанс

Промежуточные модели

Read-your-writes: после записи пользователя его последующие чтения видят его же изменения, но другие пользователи могут видеть старые данные временно.

Применение: профили пользователей, черновики документов, персональные настройки.

Механизм: sticky routing (маршрутизация одного пользователя на одну реплику) или клиентское кэширование.

Monotonic reads: если вы прочитали версию A объекта, затем версию B, вы не вернётесь к версии A. Движение только вперёд.

Применение: ленты активности, логи событий.

Механизм: гарантия порядка обработки событий, маршрутизация на основе версии.

Monotonic writes: если вы записали изменение A, затем B, все системы увидят A раньше B.

Применение: критичные последовательности операций.

Механизм: версионирование записей, логирование.


Распределённые системы и частичные отказы

Типы отказов в распределённой системе

Сетевая задержка (latency):

  • Запросы идут по сети → могут задержаться на 1–100+ мс
  • Причины: перегруженные каналы, расстояние, маршруизирование, перегруженные коммутаторы
  • Нет способа узнать точное время доставки заранее

Потеря пакетов:

  • Несмотря на TCP гарантиям, на уровне приложения пакеты могут быть потеряны
  • Причины: переполнение буферов, ошибки оборудования, сброс соединения
  • Решение: retry логика с exponential backoff

Network partitioning (разделение сети):

  • Часть системы отключена от другой части из-за сбоя маршрутизатора, провайдера или кабеля
  • Получается два независимых "острова" сервисов
  • Каждый остров может работать, но синхронизация невозможна
  • Решение: выбрать, какой остров "выживает", другой переходит в read-only режим

Частичный отказ

В монолите: либо приложение работает, либо вообще не работает.

В микросервисной архитектуре: одновременно могут быть доступны одни сервисы и недоступны другие.

Пример:

Order Service: ✓ работает
Payment Service: ✓ работает  
Inventory Service: ✗ упал
Notification Service: ✗ недоступна (за firewall)

Последствия:

  • Операция может быть выполнена частично (заказ создан, но товар не зарезервирован)
  • Ресурсы могут зависнуть на одном из сервисов (lock удерживает БД)
  • Каскадный отказ: если Service A ждёт ответа от Service B, который упал, то потоки А заполняются, и А начинает отвечать медленнее или падает

Расчёт вероятности успешной операции

Если операция затрагивает N сервисов, каждый с доступностью Ai:

Общая доступность = A1 × A2 × ... × AN

Пример: 3 сервиса, каждый с 99.9% доступностью:

Общая доступность = 0.999 × 0.999 × 0.999 = 0.997 (99.7%)
Downtime в год = 365 × 24 × 60 × (1 - 0.997) = 1576 минут (~26 часов)

Вывод: с ростом количества сервисов доступность системы падает. Это создаёт давление на использование асинхронных паттернов (eventual consistency), чтобы избежать синхронной зависимости между всеми сервисами.


Классические распределённые протоколы

Two-Phase Commit (2PC)

Структура:

Координатор и N участников (БД, очереди). Цель: гарантировать, что либо все участники выполнят операцию, либо все откатят.

Фаза 1: Prepare (Vote)

  1. Координатор отправляет всем участникам: "Готовы ли вы выполнить операцию X?"

  2. Каждый участник:

    • Пытается выполнить операцию
    • Блокирует необходимые ресурсы (строки, таблицы)
    • Записывает в лог: "Готов" или "Не готов"
    • Не коммитит, оставляя блокировку активной
    • Отвечает координатору

Фаза 2: Commit/Abort

  • Если все ответили "Готов", координатор отправляет "Коммитьте!"
  • Все участники коммитят и освобождают блокировки
  • Если хоть один ответил "Не готов", координатор отправляет "Откатьте!"
  • Все откатывают, блокировки освобождаются

Визуализация:

Время   Координатор    БД1 (Account)        БД2 (Inventory)
T0      Prepare?       ──────────────→
T5                     Блокирует счёт A
T6                     "Готова"      ←──────
T7      Prepare?                               ──────────────→
T10                                           Блокирует товар
T11                                           "Готова"      ←──────
T12     Commit!        ──────────────→
T13                    Коммитит
T14                    Освобождает   ←──────
T15     Commit!                               ──────────────→
T16                                           Коммитит
T17                                           Освобождает   ←──────

Проблемы 2PC:

Проблема Описание Последствие
Single point of failure Если координатор упадёт, участники остаются заблокированы вечно Требуется manual intervention
Блокировки ресурсов Во время Prepare ресурсы заблокированы Пропускная способность ↓ на 50–80%
Latency Нужно ждать ответов от всех P99 latency 2–5 сек
Network partitioning Если происходит разделение сети, координатор не может связаться с участником Deadlock, нет согласованного решения

Типичные метрики для 2PC:

  • Latency: 100–500 мс (в идеальном случае), 2–5 сек (в реальности)
  • Throughput: 10–100 транзакций/сек на один координатор
  • Надёжность: 99% в хорошей сети, 95% в интернете

Three-Phase Commit (3PC)

3PC пытается улучшить 2PC, добавив третью фазу — Pre-commit.

Фазы:

  1. Prepare: как в 2PC, но координатор НЕ коммитит сразу
  2. Pre-commit: координатор отправляет "Я получил готовность от всех, готовьтесь к коммиту"
  3. Commit: финальный коммит

Идея: если координатор упадёт после Pre-commit, участники знают, что нужно коммитить (потому что Phase 2 пройдена). Если упадёт на Prepare, все откатывают.

Реальность: 3PC не решает главную проблему — network partitioning. Это доказано в FLP impossibility theorem: в асинхронной сети с потерями нет алгоритма, который гарантирует консенсус при любых сбоях.

На практике 3PC почти не используется, потому что:

  • Добавляет дополнительный раунд сетевых запросов (ещё +100 мс latency)
  • Всё равно не решает проблему partitioning
  • Сложнее реализовать и поддерживать

Почему уходят от 2PC/3PC в микросервисах

В современных микросервисных архитектурах 2PC/3PC используются редко, потому что:

  1. Требуют синхронной координации между многими сервисами → низкая throughput
  2. Имеют single point of failure → низкая надёжность
  3. Не работают при network partitioning → проблемы в production
  4. Требуют, чтобы все сервисы поддерживали протокол → tight coupling

Вместо этого используют локальные транзакции + асинхронные паттерны (Saga, Outbox).


Локальные транзакции и границы консистентности

Принцип: один сервис, одна БД, одна локальная транзакция

Это фундаментальный принцип микросервисной архитектуры:

  • Каждый микросервис владеет только своей БД
  • Другие сервисы не имеют прямого доступа к его БД
  • Взаимодействие идёт через API (REST/gRPC) или события
  • Каждый сервис может выбрать любую БД (PostgreSQL, MongoDB, Cassandra)

Следствие: каждый сервис может гарантировать консистентность только своих данных через локальные ACID-транзакции. Глобальная консистентность достигается через протоколы взаимодействия.

Определение границ сервиса

1. Bounded context (Domain-Driven Design)

Область, в которой действуют единые правила и определения:

  • Account Service: знает про счета, платежи, валюты, балансы
  • Order Service: знает про заказы, статусы заказов, позиции
  • Inventory Service: знает про товары, количество на складе, резервы

Границы между контекстами — это точки, где нужна координация через протоколы (Saga, события).

2. Инварианты

Какие ограничения должны быть гарантированно выполнены в пределах одного сервиса?

  • Account Service гарантирует: баланс ≥ 0
  • Order Service гарантирует: заказ в одном из допустимых статусов
  • Inventory Service гарантирует: количество ≥ 0

3. Данные

Какие таблицы/коллекции входят в этот сервис? Всё остальное — чужая ответственность.


Saga Pattern

Концепция

Saga — это долгоживущая бизнес-операция, разбитая на цепочку локальных шагов. Каждый шаг — локальная ACID-транзакция в одном сервисе. Если шаг не удаётся, запускаются компенсирующие действия для отката уже выполненных шагов.

Гарантия: либо все шаги выполнены успешно, либо система возвращена в согласованное состояние через компенсацию.

Orchestration vs Choreography

Orchestration (Оркестрация)

Архитектура: есть центральный Saga Orchestrator (отдельный сервис или компонент), который:

  • Получает команду (например, CreateOrder)
  • Отправляет команды другим сервисам в определённом порядке
  • Ждёт ответов
  • Решает, двигаться дальше или откатываться

Поток:

OrderOrchestrator
  ├─ [Шаг 1] Отправить InventoryService: "Зарезервируй товар X"
  │           Ждать ответ
  │           ✓ Успех → ReservationId=12345
  │
  ├─ [Шаг 2] Отправить PaymentService: "Зарезервируй платёж"
  │           Ждать ответ
  │           ✗ Ошибка (нет денег)
  │
  └─ [Отмена] Отправить InventoryService: "Отмени резерв 12345"
             Вернуть ошибку клиенту

Преимущества:

  • Явный поток (легко проследить логику в коде)
  • Просто добавить логирование и мониторинг
  • Возможность добавить условную логику

Недостатки:

  • Orchestrator — point of failure (если упадёт, Saga зависнет)
  • Orchestrator должен знать про все сервисы → тесная связанность
  • Если Orchestrator недоступен, новые Sagas не начнут

Метрики:

  • Latency: сумма latencies всех шагов + координационные задержки
    • Типично: 500 мс–2 сек для 3–5 шагов
  • Throughput: ограничен одним Orchestrator
    • Один инстанс: 100–500 Sagas/сек
    • Кластер: масштабируется до 10000+ Sagas/сек с load balancing

Choreography (Хореография)

Архитектура: нет центрального координатора. Каждый сервис слушает события из брокера и реагирует.

Поток:

1. User Service создаёт заказ, публикует: OrderCreated
2. Inventory Service слушает OrderCreated
   → резервирует товар
   → публикует: InventoryReserved
3. Payment Service слушает InventoryReserved
   → резервирует платёж
   → если успех: публикует PaymentReserved
   → если ошибка: публикует PaymentFailed
4. Inventory Service слушает PaymentFailed
   → отменяет резерв
   → публикует: InventoryCancelled
5. Order Service слушает PaymentReserved
   → обновляет статус на "confirmed"
   → публикует: OrderConfirmed

Преимущества:

  • Слабая связанность (сервисы не знают друг о друге)
  • Масштабируется лучше (нет центральной координации)
  • Естественная event-driven архитектура
  • Легко добавлять новые этапы (новый сервис просто слушает события)

Недостатки:

  • Сложнее проследить поток (события летят в разные стороны)
  • Труднее отладить — нужно мокировать много событий
  • Может быть сложнее гарантировать порядок событий
  • "Event spaghetti" — непонятная последовательность обработки

Метрики:

  • Latency: параллельная обработка, потенциально быстрее чем Orchestration
    • Типично: 200 мс–1 сек
  • Throughput: масштабируется лучше
    • 1000–5000+ Sagas/сек в распределённой системе

Сравнение паттернов

Аспект Orchestration Choreography
Связанность Тесная (Orchestrator знает всех) Слабая (через события)
Latency 500 мс–2 сек 200 мс–1 сек
Throughput 100–500 Sagas/сек на инстанс 1000–5000+ Sagas/сек
Сложность отладки Средняя Высокая
Масштабируемость Хорошая с кластером Отличная
Single point of failure Да (Orchestrator) Нет
Понятность кода Выше Ниже

Пример: Создание заказа

Требования:

  • Зарезервировать товар
  • Зарезервировать платёж
  • Создать заказ
  • Отправить уведомление

Orchestration вариант (Order Service содержит Orchestrator):

@Service
@Transactional
public class OrderOrchestrator {
    
    @Autowired private OrderRepository orderRepository;
    @Autowired private InventoryServiceClient inventoryClient;
    @Autowired private PaymentServiceClient paymentClient;
    @Autowired private NotificationServiceClient notificationClient;
    
    public Order createOrder(CreateOrderCommand cmd) {
        // Шаг 1: Зарезервировать товар
        ReservationResponse inventoryRes = inventoryClient.reserve(
            cmd.getProductId(), 
            cmd.getQuantity()
        );
        if (!inventoryRes.isSuccess()) {
            throw new BusinessException("Товар недоступен");
        }
        String reservationId = inventoryRes.getReservationId();
        
        try {
            // Шаг 2: Зарезервировать платёж
            PaymentResponse paymentRes = paymentClient.reserve(
                cmd.getAmount()
            );
            if (!paymentRes.isSuccess()) {
                // Компенсация: отмена резерва товара
                inventoryClient.cancelReservation(reservationId);
                throw new BusinessException("Недостаточно средств");
            }
            String paymentId = paymentRes.getPaymentId();
            
            // Шаг 3: Создать заказ (локальная транзакция)
            Order order = Order.builder()
                .userId(cmd.getUserId())
                .inventoryReservationId(reservationId)
                .paymentReservationId(paymentId)
                .status(OrderStatus.CONFIRMED)
                .build();
            orderRepository.save(order);
            
            // Шаг 4: Отправить уведомление (некритично, best effort)
            notificationClient.sendAsync(
                "order.created",
                order.getId()
            );
            
            return order;
            
        } catch (Exception e) {
            // Если что-то пошло не так, компенсируем
            inventoryClient.cancelReservation(reservationId);
            throw e;
        }
    }
}

Choreography вариант (события между сервисами):

// OrderService
@Service
public class OrderService {
    @Autowired private OrderRepository orderRepository;
    @Autowired private KafkaTemplate<String, OrderEvent> kafkaTemplate;
    
    public void createOrder(CreateOrderCommand cmd) {
        Order order = Order.builder()
            .userId(cmd.getUserId())
            .status(OrderStatus.PENDING)
            .build();
        orderRepository.save(order);
        
        kafkaTemplate.send("order-events", "OrderCreated", 
            new OrderCreatedEvent(order.getId(), cmd.getProductId(), cmd.getQuantity())
        );
    }
}

// InventoryService
@Service
public class InventoryConsumer {
    @Autowired private InventoryRepository inventoryRepository;
    @Autowired private KafkaTemplate<String, InventoryEvent> kafkaTemplate;
    
    @KafkaListener(topics = "order-events", groupId = "inventory-group")
    public void handleOrderCreated(OrderCreatedEvent event) {
        boolean reserved = inventoryRepository.reserve(
            event.getProductId(), 
            event.getQuantity()
        );
        
        if (reserved) {
            kafkaTemplate.send("inventory-events", "InventoryReserved",
                new InventoryReservedEvent(event.getOrderId())
            );
        } else {
            kafkaTemplate.send("inventory-events", "InventoryReservationFailed",
                new InventoryReservationFailedEvent(event.getOrderId())
            );
        }
    }
}

// PaymentService
@Service
public class PaymentConsumer {
    @Autowired private PaymentRepository paymentRepository;
    @Autowired private KafkaTemplate<String, PaymentEvent> kafkaTemplate;
    
    @KafkaListener(topics = "inventory-events", groupId = "payment-group")
    public void handleInventoryReserved(InventoryReservedEvent event) {
        Payment payment = Payment.builder()
            .orderId(event.getOrderId())
            .status(PaymentStatus.RESERVED)
            .build();
        
        if (paymentRepository.hasBalance(payment)) {
            paymentRepository.save(payment);
            kafkaTemplate.send("payment-events", "PaymentReserved",
                new PaymentReservedEvent(event.getOrderId())
            );
        } else {
            // Kompensation: отмена резерва товара
            kafkaTemplate.send("order-events", "PaymentFailed",
                new PaymentFailedEvent(event.getOrderId())
            );
        }
    }
}

TCC (Try-Confirm-Cancel)

Структура

TCC — это более строгий протокол, требующий от каждого участника реализовать три операции:

Try: подготовка и резервирование ресурса

  • Заблокировать ресурс в БД (деньги, товар)
  • Проверить, возможна ли операция
  • Оставить ресурс зарезервированным (в специальном состоянии)
  • Вернуть успех/ошибку

Confirm: окончательная фиксация

  • Окончательно применить изменения (вычесть деньги, уменьшить количество)
  • Освободить ресурс из состояния резервирования

Cancel: отмена

  • Освободить зарезервированный ресурс
  • Вернуть в исходное состояние

Взаимодействие

Coordinator: "Try? Зарезервируй 100 руб со счёта A"
Account Service:

  - Проверяет: баланс >= 100? Да
  - Помещает 100 в "frozen" (зарезервировано)
  - Отвечает: "OK, зарезервировано"

Coordinator: "Try? Зарезервируй 2 шт товара X"
Inventory Service:

  - Проверяет: количество >= 2? Да
  - Помещает 2 в "reserved"
  - Отвечает: "OK, зарезервировано"

Coordinator: "Все Try успешны, Confirm!"

Account Service:

  - Вычитает 100 из основного баланса
  - Удаляет из "frozen"

Inventory Service:

  - Вычитает 2 из основного количества
  - Удаляет из "reserved"

Когда использовать TCC

Подходит:

  • Критичные финансовые операции (переводы, платежи)
  • Когда нужна strong consistency между несколькими сервисами
  • Когда компенсирующие действия сложные или дорогие

Недостатки:

  • Требует явной реализации трёх операций в каждом сервисе
  • Более сложен в разработке, чем Saga
  • Требует дополнительной логики резервирования в БД
  • Все участники должны быть доступны (иначе блокируется)

Метрики:

  • Latency: 200–1000 мс (3 раунда сетевых запросов)
  • Throughput: 50–200 операций/сек на один Coordinator
  • Успешность: зависит от доступности всех участников

Outbox Pattern: Гарантированная публикация событий

Проблема двух систем

Когда есть две отдельные системы (БД и брокер сообщений), возникают проблемы:

Сценарий 1: БД успешна, Kafka падает
  INSERT INTO orders VALUES (1, 'pending') ✓
  PUBLISH TO kafka: OrderCreated            ✗
  
Результат: заказ в БД, но события нет → консюмеры ничего не знают

Сценарий 2: БД падает, Kafka успешна
  INSERT INTO orders VALUES (2, 'pending') ✗ (откат)
  PUBLISH TO kafka: OrderCreated            ✓
  
Результат: события прошли, но заказа в БД нет → консюмеры попытаются обновить несуществующий заказ

Решение: Outbox Pattern

Идея: не публиковать событие напрямую. Вместо этого:

  1. Одна локальная транзакция:

    • Сохранить бизнес-данные в таблицу
    • Сохранить событие в таблицу outbox_events
    • Коммит либо оба, либо ничего
  2. Отдельный worker (может быть polling job, может быть CDC):

    • Читать новые события из outbox_events
    • Публиковать в Kafka/RabbitMQ
    • Удалять из outbox после успешной публикации

Структура таблиц

-- Основная таблица
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    status VARCHAR,
    created_at TIMESTAMP
);

-- Outbox таблица
CREATE TABLE outbox_events (
    id BIGINT PRIMARY KEY,
    aggregate_id VARCHAR,      -- ID заказа
    event_type VARCHAR,        -- "OrderCreated", "OrderCancelled"
    event_data TEXT,           -- JSON с деталями события
    created_at TIMESTAMP,
    published BOOLEAN DEFAULT FALSE,
    published_at TIMESTAMP NULL
);

-- Индексы для быстрого поиска неопубликованных
CREATE INDEX idx_outbox_published ON outbox_events(published, created_at);

Реализация

@Service
public class OrderService {
    
    @Autowired private OrderRepository orderRepository;
    @Autowired private OutboxRepository outboxRepository;
    
    @Transactional
    public Order createOrder(CreateOrderCommand cmd) {
        // Шаг 1: создать заказ
        Order order = Order.builder()
            .userId(cmd.getUserId())
            .status(OrderStatus.CREATED)
            .build();
        orderRepository.save(order);
        
        // Шаг 2: записать событие в outbox (в той же транзакции!)
        OutboxEvent event = OutboxEvent.builder()
            .aggregateId(order.getId().toString())
            .eventType("OrderCreated")
            .eventData(objectMapper.writeValueAsString(order))
            .createdAt(Instant.now())
            .published(false)
            .build();
        outboxRepository.save(event);
        
        // Коммит транзакции: либо оба сохранены, либо ничего не сохранено
        return order;
    }
}

@Service
public class OutboxPublisher {
    
    @Autowired private OutboxRepository outboxRepository;
    @Autowired private KafkaTemplate<String, String> kafkaTemplate;
    
    @Scheduled(fixedDelay = 1000)  // Каждую секунду
    public void publishOutboxEvents() {
        List<OutboxEvent> unpublished = outboxRepository.findByPublishedFalse();
        
        for (OutboxEvent event : unpublished) {
            try {
                // Опубликовать в Kafka
                kafkaTemplate.send(
                    "order-events",
                    event.getAggregateId(),
                    event.getEventData()
                ).get(5, TimeUnit.SECONDS);
                
                // Отметить как опубликованное
                event.setPublished(true);
                event.setPublishedAt(Instant.now());
                outboxRepository.save(event);
                
            } catch (Exception e) {
                log.error("Failed to publish event: {}", event.getId(), e);
                // Retry на следующем проходе
            }
        }
    }
}

Гарантии Outbox Pattern

At-least-once доставка:

  • Событие будет опубликовано минимум один раз
  • Если worker упадёт после публикации, но до обновления БД, событие может быть опубликовано дважды

Потребитель должен быть идемпотентным: обработка одного события несколько раз должна давать тот же результат.


Inbox Pattern и идемпотентность

Проблема повторной доставки

Когда потребитель получает событие из Kafka:

1. Получаем событие "OrderCreated"
2. Обрабатываем: создаём платёж в БД
3. Готовим ответ в Kafka
4. Но соединение падает...
5. Kafka думает: "не получил ack, значит переслать ещё раз"
6. Потребитель получает одно и то же событие второй раз

Результат: платёж создан дважды → финансовая ошибка.

Решение: Inbox Pattern

Идея: на стороне потребителя вести таблицу inbox для отслеживания уже обработанных событий.

CREATE TABLE inbox_events (
    id BIGINT PRIMARY KEY,
    event_id VARCHAR UNIQUE,        -- ID события (уникальный ключ)
    event_type VARCHAR,
    event_data TEXT,
    processed BOOLEAN DEFAULT FALSE,
    processed_at TIMESTAMP NULL,
    received_at TIMESTAMP
);

CREATE INDEX idx_inbox_event_id ON inbox_events(event_id);
CREATE INDEX idx_inbox_processed ON inbox_events(processed);

Реализация идемпотентности

@Service
public class PaymentConsumer {
    
    @Autowired private InboxRepository inboxRepository;
    @Autowired private PaymentRepository paymentRepository;
    
    @KafkaListener(topics = "order-events", groupId = "payment-service")
    @Transactional
    public void handleOrderCreated(OrderCreatedEvent event) {
        // Шаг 1: проверить inbox
        if (inboxRepository.existsByEventId(event.getId())) {
            log.info("Event already processed: {}", event.getId());
            return;  // Пропускаем повторную обработку
        }
        
        // Шаг 2: обработать событие в транзакции с БД
        Payment payment = Payment.builder()
            .orderId(event.getOrderId())
            .amount(event.getAmount())
            .status(PaymentStatus.RESERVED)
            .build();
        paymentRepository.save(payment);
        
        // Шаг 3: записать в inbox (в той же транзакции!)
        InboxEvent inboxEvent = InboxEvent.builder()
            .eventId(event.getId())
            .eventType(event.getType())
            .eventData(event.toJson())
            .processed(true)
            .processedAt(Instant.now())
            .build();
        inboxRepository.save(inboxEvent);
        
        // Коммит: платёж и запись в inbox — либо оба, либо ничего
    }
}

Альтернативные подходы к идемпотентности

Вариант 1: Уникальный ключ в основной таблице

CREATE TABLE payments (
    event_id VARCHAR PRIMARY KEY,     -- ID события как ключ
    order_id BIGINT,
    amount DECIMAL,
    status VARCHAR,
    created_at TIMESTAMP
);

-- При повторной попытке обработки:

-- INSERT ... будет конфликт на event_id
-- Либо делаем INSERT ... ON CONFLICT DO UPDATE
-- Либо ловим исключение и считаем это OK

Вариант 2: Версионирование

// Если применяем изменение с version:
int updated = paymentRepository.updateIfVersion(
    orderId, 
    newStatus, 
    expectedVersion, 
    expectedVersion + 1
);

if (updated == 0) {
    // Уже был обновлён другим потоком
    log.info("Concurrent update, skipping");
}

Effectively-Once Delivery

Определение и достижимость

Exactly-once: событие обработано ровно один раз, без пропусков и дублей.

Проблема: в распределённой системе это теоретически недостижимо (FLP impossibility). После обработки события, при отправке подтверждения может случиться потеря пакета. Как узнать, был ли он обработан?

Effectively-once: практический компромисс, который выглядит как exactly-once для бизнеса, но внутри может обработать дважды.

Архитектура effectively-once

На стороне отправителя (Outbox):

INSERT INTO orders (id, ...) VALUES (...)
INSERT INTO outbox_events (aggregate_id, event_type, ...) VALUES (...)
COMMIT  -- Both или ничего
  • Гарантия: событие либо в БД и outbox, либо ничего
  • Гарантия: событие будет опубликовано (worker настойчиво пытается)

На стороне брокера (Kafka):

acks=all          -- Лидер ждёт acks от всех replicas
min.insync.replicas=2  -- Минимум 2 replica должны подтвердить
replication.factor=3   -- 3 копии события
  • Гарантия: at-least-once доставка (событие не потеряется)
  • Возможность: at-most-once при потере replicas (редкий случай)

На стороне потребителя (Inbox):

SELECT * FROM inbox WHERE event_id = ?
-- Если есть, skip
-- Если нет:
  BEGIN TRANSACTION
    process event
    INSERT INTO inbox VALUES (event_id, ...)
  COMMIT
  • Гарантия: одно событие обработано один раз в контексте потребителя
  • Даже если Kafka переправит дважды, второй раз будет skip

Результат: Effectively-Once

Sender: "Событие создано и в БД, и в outbox"
Broker: "Событие доставлено ≥1 раз"
Consumer: "Событие обработано ровно один раз"

==> Для пользователя: платёж прошёл один раз

Метрики effectively-once

Метрика Значение
Вероятность потери события <0.01% (99.99%)
Вероятность обработки дважды <0.1% (происходит, но идемпотентность справляется)
Гарантия порядка Обычно сохраняется, но возможны перестановки
Latency доставки 50 мс–5 сек

Eventual Consistency в бизнес-процессах

Дизайн с промежуточными статусами

Вместо скрытого асинхронного обновления, покажите пользователю явные статусы:

Заказ: "создан" → "на проверке" → "принят" → "готов к отправке" → "отправлен"
Платёж: "инициирован" → "обработка" → "зарезервирован" → "подтвержден"
Товар: "в наличии" → "зарезервирован" → "отправлен" → "доставлен"

Каждый статус отражает реальное состояние в какой-то системе. Пользователь видит прогресс.

Latency по этапам

Пользователь нажимает "Заказать"
  T=0 мс     → Сразу видит "заказ создан" (Order Service создал запись)
  T=200 мс   → "проверяется" (Inventory Service зарезервировал товар)
  T=500 мс   → "принят" (Payment Service зарезервировал платёж)
  T=1200 мс  → "готов" (все этапы Saga завершены)
  T=2000 мс  → SMS уведомление (Notification Service отправил)

Ключ: каждое состояние представимо и объяснимо. Нет "вот сейчас неизвестно что будет".

Восстановление при ошибках

Если что-то пошло не так на этапе обработки:

"Заказ не может быть принят: истекло время резерва товара"
"Заказ отменён: недостаточно средств"
"Система перепроверяет, пожалуйста, подождите..."

Пользователь видит конкретную причину, что не так.


Репликация и консистентность чтений

Архитектура Primary-Replica

Primary (Master)
  ├─ пишут все данные
  ├─ реплицирует в replicas
  └─ lag: <10 мс (обычно)

Replica 1 (Slave)
  ├─ lag: 50–100 мс от primary
  ├─ можно читать
  └─ может быть переполнена запросами

Replica 2
  ├─ lag: 50–100 мс
  ├─ для analytics/reports
  └─ более чувствительна к lag

Проблемы при чтении из replicas

Пример:

T=0 мс    Пользователь создаёт заказ, пишет в primary
T=10 мс   Запрос вернулся: "Заказ создан"
T=15 мс   Пользователь пытается прочитать заказ из replica
T=20 мс   Replica всё ещё отстаёт, вернула: "Заказ не найден"
T=70 мс   Replica синхронизировалась, теперь видит заказ

Результат: read-your-writes нарушена. Пользователь видит свои изменения исчезнувшими.

Стратегии решения

1. Read-your-writes гарантия:

После записи пользователя его следующие N чтений идут в primary.
Затем можно читать из replicas.

Время жизни гарантии: ~1-2 сек (максимум lag replicas)

2. Sticky маршрутизация:

Пользователь U1 пишет → маршрутируем на primary
Следующие 5 секунд все чтения U1 → маршрутируем на primary
Затем → разрешаем читать из replicas

3. Версионирование:

Запись возвращает version=42
При чтении требуем version >= 42
Replica, если у неё version < 42, ждёт репликации

4. Кэширование на клиенте:

Браузер помнит: "Я создал заказ с ID=123"
При чтении сначала проверяет локальный кэш
Если есть в кэше и свежий → показывает из кэша
Параллельно обновляет из сервера

Стратегия масштабирования чтений

95% операций → читают из replicas (денормализованные, быстрые) 4% критичных → читают из primary (свежие данные) 1% системных → специальная обработка (consistency checks, audits)

Метрики:

Метрика Primary Replica
P50 latency 5–20 мс 2–10 мс (кэшируется)
P99 latency 50–200 мс 20–100 мс
QPS на узел 1000–5000 5000–20000
Lag от primary 0 мс 50–500 мс

Распределённые блокировки

Use case

Распределённая блокировка нужна, когда несколько инстансов одного сервиса конкурируют за:

  • Единичное действие (ежедневный batch расчёт зарплаты)
  • Уникальность (пользователь создаёт аккаунт один раз, не несколько)
  • Ресурс (только один пользователь может зарезервировать последнее место)

Реализация на Redis

@Service
public class BatchService {
    
    @Autowired private RedisTemplate<String, String> redisTemplate;
    @Autowired private SalaryRepository salaryRepository;
    
    @Scheduled(cron = "0 0 * * * *")  // Каждый час
    public void runHourlySalaryBatch() {
        String lockKey = "salary-batch:hourly";
        String lockValue = UUID.randomUUID().toString();
        
        // Пытаемся захватить блокировку на 30 минут
        Boolean acquired = redisTemplate.getConnectionFactory()
            .getConnection()
            .setNX(
                lockKey.getBytes(),
                lockValue.getBytes(),
                Duration.ofSeconds(1800)
            );
        
        if (!acquired) {
            log.info("Another instance is running batch, skipping");
            return;
        }
        
        try {
            performSalaryCalculation();
        } finally {
            // Безопасное удаление (только если значение совпадает)
            String currentValue = (String) redisTemplate.opsForValue().get(lockKey);
            if (lockValue.equals(currentValue)) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}

Leader Election

Выбор одного лидера из N инстансов:

@Service
public class PaymentService {
    
    @Autowired private RedisTemplate<String, String> redisTemplate;
    
    @PostConstruct
    public void becomeLeader() {
        String lockKey = "leader:payment-service";
        
        // Все инстансы пытаются
        Boolean leader = redisTemplate.getConnectionFactory()
            .getConnection()
            .setNX(
                lockKey.getBytes(),
                myInstanceId.getBytes(),
                Duration.ofSeconds(10)  // Timeout
            );
        
        if (leader) {
            this.isLeader = true;
            startLeaderTasks();
        }
    }
    
    @Scheduled(fixedDelay = 5000)
    public void refreshLeadership() {
        if (isLeader) {
            String lockKey = "leader:payment-service";
            // Обновляем timeout (refresh lock)
            redisTemplate.expire(lockKey, Duration.ofSeconds(10));
        }
    }
}

Проблемы и решения

Проблема Решение
Deadlock (циркулярная зависимость блокировок) Определить порядок захвата блокировок
Split-brain (два лидера при разделении сети) Короткий timeout на блокировку, высокий heartbeat
Zombie leader (упавший лидер удерживает блокировку) Auto-release timeout, monitoring

Выбор подхода в зависимости от требований

Матрица решений

Требование Strong Consistency Eventual Consistency Recommended
Финансовые платежи Да Нет 2PC/TCC или Saga
Подтверждение платежа Желательно OK Saga + Orchestration
Статистика лайков Нет Да Event-driven Choreography
Резервирование товара Да Нет Saga + Orchestration
Отправка уведомлений Нет Да Асинхронная очередь
Создание заказа Зависит - Saga (для нескольких этапов)
Обновление профиля Нет Да Eventual или прямая запись

Дерево решений

Нужна ли МГНОВЕННАЯ консистентность во всей системе?
├─ ДА → Финансовый домен?
│       ├─ ДА → TCC или 2PC (сложнее, но сильнее)
│       └─ НЕТ → Saga + Orchestration
│
└─ НЕТ → Этапов в процессе больше 2?
         ├─ ДА → Saga + Choreography
         └─ НЕТ → Простой асинхронный вызов + Outbox

Калькулятор выбора

Для принятия решения оцените:

  1. Количество сервисов в операции: N

    • N ≤ 2 → синхронные вызовы (если < 100 мс)
    • N > 2 → асинхронные (Saga)
  2. Требуемая latency: L мс

    • L < 100 → synchronous (REST/gRPC)
    • 100 < L < 1000 → asynchronous (Saga)
    • L > 1000 → batch/eventual
  3. Допустимый lag: G мс

    • G < 100 → strong consistency
    • 100 < G < 1000 → eventual consistency
    • G > 1000 → eventual consistency с сильным lag
  4. Стоимость ошибки: C

    • C > $1000 → strong consistency обязательна
    • $100–1000 → Saga с компенсацией
    • C < $100 → eventual OK

Практическая реализация в Java/Spring

Локальные транзакции

@Service
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public Order createOrder(CreateOrderCommand cmd) {
        // Всё внутри одной транзакции
        Order order = new Order(cmd.getUserId(), cmd.getTotalAmount());
        orderRepository.save(order);
        
        // Если exception перед концом метода → откат
        // Если успешно завершился → коммит
        return order;
    }
}

Outbox + Inbox

// Отправитель
@Service
public class OrderCreationService {
    
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private OutboxRepository outboxRepository;
    
    @Transactional
    public Order createOrder(CreateOrderCommand cmd) {
        Order order = Order.builder()
            .userId(cmd.getUserId())
            .status(OrderStatus.PENDING)
            .build();
        orderRepository.save(order);
        
        // В одной транзакции!
        OutboxEvent event = OutboxEvent.builder()
            .aggregateId(order.getId().toString())
            .eventType("OrderCreated")
            .eventData(objectMapper.writeValueAsString(order))
            .build();
        outboxRepository.save(event);
        
        return order;
    }
}

// Потребитель
@Service
public class PaymentService {
    
    @KafkaListener(topics = "order-events", groupId = "payment-service")
    @Transactional
    public void handleOrderCreated(OrderCreatedEvent event) {
        // Проверка inbox
        if (inboxRepository.existsByEventId(event.getId())) {
            return;
        }
        
        // Обработка
        Payment payment = Payment.builder()
            .orderId(event.getOrderId())
            .amount(event.getAmount())
            .build();
        paymentRepository.save(payment);
        
        // В одной транзакции!
        InboxEvent inboxEvent = InboxEvent.builder()
            .eventId(event.getId())
            .eventType(event.getType())
            .build();
        inboxRepository.save(inboxEvent);
    }
}

Сравнение подходов

Таблица выбора паттерна

Паттерн Latency Throughput Сложность Когда использовать
Synchronous calls 50–200 мс 100–1K ops/s Низкая Критичные, быстрые операции
2PC 500–2000 мс 10–100 ops/s Очень высокая Редко; legacy системы
Saga + Orchestration 200–1000 мс 100–500 ops/s Средняя Сложные процессы, нужен контроль
Saga + Choreography 100–500 мс 1K–5K ops/s Высокая Масштабируемые процессы
Event-driven async 50–500 мс 5K–20K ops/s Средняя Статистика, notifications, logs
Outbox + Inbox не добавляет не добавляет Средняя Гарантированная доставка

Альтернативные технологии

Задача PostgreSQL Kafka Redis RabbitMQ Cassandra
Outbox/Inbox ✓✓ - Не рекомендуется
Распределённая блокировка - ✓✓ - Не рекомендуется
Event stream ✓ (WAL) ✓✓ - ✓ (SSTables)
Кэширование - - ✓✓ - ✓ (по-умолчанию)
Временные данные ✓✓ ✓ (TTL)

Заключение

Выбор подхода к консистентности зависит от:

  1. Бизнес-требований: какие инварианты критичны
  2. Технических ограничений: количество сервисов, требуемая latency
  3. Операционных возможностей: умение поддерживать сложные паттерны

Общий принцип: начните с простого (synchronous вызовы или eventual consistency), добавляйте сложность только при необходимости.

Для большинства случаев: Saga + Outbox + Inbox комбинация решает 90% проблем консистентности в микросервисах.

Надёжность

Определение надёжности в контексте системного дизайна

Надёжность системы — это комплексная характеристика, включающая доступность, отказоустойчивость, долговечность данных и способность системы работать в условиях сбоев и перегрузок. Это ключевой аспект системного проектирования, влияющий на бизнес-метрики: потери транзакций в e-commerce, репутационные убытки и штрафы в финтехе и банковском секторе, нарушение SLA и контрактные обязательства перед клиентами.


Базовые метрики надёжности

Доступность (Availability)

Доступность — доля времени, в течение которой система способна обслуживать запросы. Формально:

[ \text{Availability} = \frac{\text{Uptime}}{\text{Uptime} + \text{Downtime}} \times 100% ]

Классификация доступности по "девяткам":

Уровень доступности Годовое время простоя
99% (2 девятки) 87.7 часов
99.9% (3 девятки) 8.77 часа
99.99% (4 девятки) 52.6 минут
99.999% (5 девяток) 5.26 минут
99.9999% (6 девяток) 31.5 секунд

Каждая добавочная девятка экспоненциально увеличивает сложность архитектуры и инфраструктурные затраты. Для выбора целевого уровня доступности необходимо проанализировать business requirements:

  • Критичные системы (финтех, здравоохранение, авиация): 99.99% и выше;
  • Высокоприоритетные системы (социальные сети, электронная коммерция): 99.9%;
  • Стандартные системы (большинство веб-приложений): 99%;
  • Некритичные системы (аналитика, внутренние инструменты): 95-99%.

Надёжность (Reliability) и отказоустойчивость (Fault Tolerance)

Надёжность — вероятность того, что система успешно выполнит свою функцию без сбоев за определённый период. Это характеристика компонента или системы, измеряется через MTBF.

Отказоустойчивость — способность системы продолжать работать при отказе одного или нескольких компонентов, обеспечивая деградацию функциональности или полную работоспособность.

Различие на практике:

  • Надёжная, но не отказоустойчивая система: сломалось одно место в монолите — упала вся система;
  • Ненадёжная, но отказоустойчивая система: компоненты часто падают, но система продолжает работать через резервирование.

Архитектурно отказоустойчивость достигается через N+1 резервирование компонентов и изоляцию отказов.

Долговечность данных (Durability)

Гарантия, что данные не потеряются после успешной записи, даже при сбое системы. Это ортогональное свойство по отношению к доступности.

Реализуется через:

  • Синхронная репликация: данные должны быть успешно записаны на несколько физических узлов перед подтверждением клиенту;
  • Write-ahead logs (WAL): операция записывается в лог перед выполнением в основной структуре данных;
  • Резервные копии: периодическое копирование полного состояния с возможностью восстановления;
  • Разнесение данных: хранение копий в разных географических локациях и на разных типах носителей.

MTBF и MTTR

MTBF (Mean Time Between Failures) — среднее время между последовательными отказами компонента. Выражается в часах.

MTTR (Mean Time To Repair) — среднее время от момента обнаружения отказа до полного восстановления системы.

Доступность связана с этими метриками:

[ \text{Availability} = \frac{\text{MTBF}}{\text{MTBF} + \text{MTTR}} ]

Следовательно, есть два ортогональных пути повышения доступности:

  1. Повысить MTBF: улучшить качество компонентов, снизить частоту отказов через redundancy и лучший инженеринг;
  2. Снизить MTTR: внедрить автоматическое обнаружение отказов (health checks), быстрый failover, автоматическое восстановление.

Пример расчёта:

  • MTBF каждого сервера: 2000 часов (примерно 1 сбой в 2.5 месяца);
  • MTTR при автоматическом failover: 30 секунд;
  • При N+2 резервировании: MTBF системы ≈ 667 часов, MTTR = 30 сек;
  • Availability = 667 / (667 + 0.0083) ≈ 99.999% (5 девяток).

SLA, SLO, SLI

SLA (Service Level Agreement) — контрактное обязательство перед клиентом. Нарушение влечёт штрафы, скидки или возмещение.

SLO (Service Level Objective) — внутреннее целевое значение, жёстче SLA на 0.1–1%, обеспечивающее буфер для предупредительных действий.

SLI (Service Level Indicator) — конкретная измеримая метрика.

Типичные SLI для backend-систем:

Метрика Примеры целевых значений Как измерять
Latency p50: 50ms, p95: 150ms, p99: 200ms Время от запроса до ответа
Error Rate < 0.1% запросов с кодом 5xx Доля успешных запросов
Availability 99.9% успешных запросов за месяц Uptime метрика или синтетические проверки
Throughput ≥ 10,000 RPS Запросы в секунду
Data Loss 0 потери записанных данных Проверка консистентности

Взаимосвязь при расчёте error budget:

Если SLO = 99.9% доступности, то monthly error budget = 0.1% от всех запросов за месяц. При 1M запросов в день это ~300 запросов в день, которые могут отказать.


Технологии и подходы для надёжности

Design for Failure — Проектирование для отказов

Основной принцип

Предположить, что всё может отказать. Это не пессимизм, а реальность production-систем на масштабе.

Идентификация Single Point of Failure (SPOF)

SPOF — компонент, отказ которого приводит к полной недоступности системы.

Примеры SPOF:

  • Один load balancer перед всеми серверами приложения;
  • Одна БД без репликации;
  • Один узел в distributed cache без резервирования;
  • Одна сеть между дата-центрами.

Метод выявления: нарисовать архитектуру и для каждого компонента задать вопрос: "Что если он упадёт?" Если ответ "система станет недоступна", это SPOF.

Правило проектирования: для компонентов, от которых критична доступность, обеспечить N+1 или N+2 резервирование, где N — количество отказов, которые система должна пережить.

Анализ точек отказа по слоям

Слой Потенциальные SPOF Решение
Балансировка нагрузки Один load balancer Несколько LB с Virtual IP (VRRP) или DNS failover
Приложение Один инстанс сервиса Horizontal scaling, несколько инстансов за LB
Кеш Один redis/memcached узел Репликация, Redis Cluster или Memcached consistent hashing
БД Одна primary БД Replication (primary-replica), sharding, multi-region
Сеть Один маршрут между ДЦ Несколько независимых каналов, BGP failover

Graceful Degradation — Контролируемая деградация

Концепция

Система деградирует функционально при частичных сбоях, но не падает полностью. Функционал приоритизируется, и критичные операции сохраняются за счёт отключения некритичных.

Стратегии деградации

  1. Функциональная деградация: вернуть результат без опциональной функции вместо полного отказа.

    • Пример: при недоступности recommendation-сервиса показать товары без рекомендаций.
  2. Качественная деградация: вернуть результат более низкого качества.

    • Пример: при перегрузке поиска вернуть результаты только с точными совпадениями без ранжирования.
  3. Асинхронная деградация: перевести долгую операцию в асинхронный режим вместо синхронного ответа.

    • Пример: при timeout на 1 секунду переключить запрос на обработку через очередь задач.
  4. Кеширование результатов: использовать устаревшие данные при недоступности primary источника.

    • Пример: при отказе БД вернуть кешированный результат с меткой времени.

Проектирование деградации

Деградация требует предварительного проектирования:

  • Определить критичные и опциональные операции;
  • Заранее спроектировать fallback'и и альтернативные пути;
  • Реализовать мониторинг, который обнаруживает условия для деградации;
  • Настроить автоматическое переключение или очередь действий.

Булkheads (Перегородки) — Изоляция отказов

Принцип

Разделить систему на независимые отсеки так, чтобы отказ в одном не распространялся на остальные.

Виды булkheads

1. По типам запросов (Thread Pool Isolation)

Разные пулы потоков для разных типов запросов:

  • Пул A: обслуживание пользователей (20 потоков);
  • Пул B: admin-операции (5 потоков);
  • Пул C: batch-jobs (10 потоков).

Если batch-job заполнит все потоки в пуле C, это не повлияет на пул A.

Конфигурация в Java:

ThreadPoolExecutor userPool = new ThreadPoolExecutor(
    10, 20,                      // core, max threads
    60, TimeUnit.SECONDS,       // keep alive time
    new LinkedBlockingQueue<>(1000)  // queue size
);

Очередь размером 1000 обеспечивает буфер; превышение вызывает RejectedExecutionException.

2. По микросервисам (Service Isolation)

Отдельные инстансы для разных функций:

  • Service A (user-facing): 10 инстансов;
  • Service B (batch): 2 инстанса;
  • Service C (admin): 1 инстанс.

Если Service B зависнет, это не заблокирует Service A.

3. По клиентам или регионам (Shard Isolation)

Разные БД или инстансы для разных клиентов:

  • Клиент X: отдельная БД и инстанс сервиса;
  • Клиент Y: отдельная БД и инстанс сервиса.

Если клиент X испытывает проблемы, клиент Y не пострадает.

4. По ресурсам (Resource Isolation)

Ограничение ресурсов (CPU, память) на уровне контейнеров (cgroups в Linux, Docker):

# Dockerfile с ограничениями
# Максимум 2 ядра и 1 ГБ памяти
docker run -m 1G --cpus 2 my-service

Выбор стратегии булkheads

Уровень Когда использовать Преимущества Недостатки
Thread pools Одно приложение, разные типы запросов Легко реализовать, fine-grained контроль Требует тюнинга, мониторинга
Service isolation Микросервисная архитектура Естественная изоляция, масштабируемость Сетевые задержки, операционная сложность
Shard isolation Multi-tenant системы, высокие требования к надёжности Максимальная изоляция, справедливое распределение Усложнение развёртывания, синхронизация данных
Resource limits Kubernetes/контейнеры Гарантированный доступ к ресурсам Требует оркестрации (Kubernetes)

Fail-Fast — Быстрое обнаружение ошибок

Принцип

Обнаружить ошибки как можно раньше и быстро их вернуть, не тратя ресурсы на обработку изначально обреченных запросов.

Техники fail-fast

1. Таймауты на всех внешних вызовах

Установить явный timeout для каждого синхронного вызова:

// Java: HTTP клиент с таймаутом
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(5))
    .build();

client.send(request, HttpResponse.BodyHandlers.ofString());

Без таймаута система может зависнуть и потратить ресурсы на бесконечное ожидание.

2. Валидация при старте приложения

Проверить критичные зависимости и конфигурацию при запуске:

@Component
public class HealthCheck implements InitializingBean {
    @Override
    public void afterPropertiesSet() throws Exception {
        // Проверить доступность БД
        // Проверить доступность Redis
        // Проверить наличие обязательных конфигураций
    }
}

3. Лимиты на размер данных

Ограничить размер payload'а на уровне приложения:

@PostMapping("/upload")
public void uploadData(@RequestBody byte[] data) {
    if (data.length > 100 * 1024 * 1024) {  // 100 MB лимит
        throw new PayloadTooLargeException();
    }
}

4. Circuit Breaker для зависимых сервисов

Быстро откажи вместо попыток retry при нестабильности сервиса.

Circuit Breaker — Прерыватель цепи

Состояния

Состояние Поведение Переход
Closed Пропускает запросы нормально При failure threshold → Open
Open Блокирует все запросы, возвращает ошибку немедленно После timeout → Half-Open
Half-Open Пропускает тестовый запрос Success → Closed, Failure → Open

Пример реализации (Resilience4j)

CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)              // 50% failures
    .slowCallRateThreshold(50)             // 50% slow calls
    .slowCallDurationThreshold(Duration.ofSeconds(2))
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .recordExceptions(IOException.class)
    .ignoreExceptions(IllegalArgumentException.class)
    .build();

Defense in Depth — Многоуровневая защита

Архитектурный принцип

Несколько независимых уровней защиты и устойчивости. Отказ на одном уровне не компрометирует остальные.

Уровни защиты

Уровень Примеры техник Что защищает
Сеть Firewalls, DDoS mitigation, BGP failover От внешних атак, от сетевых сбоев
Приложение Input validation, rate limiting, sanitization От некорректных данных, от перегрузки
Данные Encryption, backup, soft delete, schema versioning От потери данных, от неправильных изменений
Инфраструктура Redundancy, health checks, automatic failover От отказа отдельных компонентов
Мониторинг Alerting, distributed tracing, metrics От незамеченных проблем

Высокая доступность (High Availability, HA)

Архитектуры резервирования

Active-Passive (Master-Replica)

Один активный узел обслуживает трафик, второй узел в режиме ожидания.

Схема:

Client → Load Balancer → Active (Primary)
                         ↓
                      Health Check
                         ↓
                    Passive (Replica)
                    (принимает реплику данных)

При отказе Active:
Client → Load Balancer → Passive становится Active

Характеристики:

  • MTTR: 10-30 секунд (время обнаружения + failover);
  • Utilization: 50% (половина ресурсов неиспользуется);
  • Сложность: низкая.

Когда использовать:

  • Stateful системы (БД, кеш), где сложно синхронизировать состояние;
  • Требование к доступности 99.9% (4 девятки).

Active-Active (Load Balanced)

Несколько активных узлов обслуживают трафик параллельно.

Схема:

Client → Load Balancer → Instance 1 (Active)
                      → Instance 2 (Active)
                      → Instance 3 (Active)

Характеристики:

  • MTTR: < 5 секунд (трафик перенаправляется оставшимся узлам);
  • Utilization: 85-95% (почти все ресурсы используются);
  • Сложность: средняя (требует stateless дизайна).

Когда использовать:

  • Stateless сервисы (REST API, микросервисы);
  • Требование к доступности 99.99% (5 девяток).

Условие для Active-Active:

  • Состояние должно быть либо stateless, либо реплицировано между узлами;
  • Запросы должны идемпотентны (повторный запрос = один и тот же результат).

N+1 и N+2 резервирование

N+1: N узлов обслуживают нагрузку, 1 узел в резерве. Система пережит отказ одного узла.

Расчёт для Active-Passive N+1:

  • 3 узла: 2 в работе (50% load на каждом), 1 в резерве;
  • При отказе одного: оставшийся узел берёт 100% load.

N+2: N узлов обслуживают нагрузку, 2 узла в резерве. Система пережит отказ двух узлов.

Расчёт:

  • 5 узлов: 3 в работе (33% load на каждом), 2 в резерве;
  • При отказе двух: оставшиеся 3 узла берут нагрузку.

Выбор N+1 vs N+2:

Сценарий Рекомендация
Одноcтельные сбои, низкий требование к доступности N+1
Одновременные сбои возможны, требование 99.99%+ N+2
Масштабные отказы (power failure, сетевой partition) N+3 или многорегионный

Multi-AZ (Multi-Availability Zone) развёртывание

Концепция

Развёрнуть систему в нескольких availability zones (АЗ) одного региона. АЗ — физически независимые дата-центры с отдельным питанием, сетью, охлаждением.

Преимущества:

  • Защита от сбоев одного дата-центра (power failure, сетевого оборудования);
  • Низкая latency между AZ (обычно < 1 мс);
  • Стоимость ниже, чем multi-region.

Недостатки:

  • Не защищает от региональных сбоев (землетрясения, интернет-выключение);
  • Требует большего операционного управления.

Типовая архитектура Multi-AZ

AZ-1              AZ-2              AZ-3
┌─────────┐      ┌─────────┐      ┌─────────┐
│ App-1   │      │ App-2   │      │ App-3   │
└────┬────┘      └────┬────┘      └────┬────┘
     │                │                │
     └────────────────┼────────────────┘
                      ↓
            Load Balancer (Multi-AZ)
            
Primary DB        Secondary DB      Tertiary DB
(AZ-1)           (AZ-2)            (AZ-3)
Sync Replica     Async Replica     Async Replica

Расчёт доступности Multi-AZ

При предположении:

  • Доступность каждого AZ: 99.9% (8.77 часов простоя в год);
  • Корреляция между АЗ: ~5% (незначительная);

Доступность системы в 3 AZ:

[ \text{Availability} \approx 1 - (1 - 0.999)^3 \approx 99.9997% ]

Это ~ 1.57 минут простоя в год.

Multi-Region развёртывание

Когда необходимо

  • Географическое распределение пользователей (низкая latency);
  • Требование к доступности 99.999% (5 девяток) и выше;
  • Комплайанс (данные должны оставаться в регионе);
  • Устойчивость к региональным сбоям.

Типы многорегионного развёртывания

1. Active-Passive (DR — Disaster Recovery)

Один регион активный, второй регион неиспользуемый, кроме как для восстановления.

Primary Region    Secondary Region
(Active)          (Standby)
┌──────────┐     ┌──────────┐
│ App      │────→│ App      │ (powered off)
│ DB       │────→│ DB       │ (replica, lag)
└──────────┘     └──────────┘

RTO (Recovery Time Objective): 5-30 минут (время переключения). RPO (Recovery Point Objective): минуты (потеря данных в пути репликации).

2. Active-Active (Multi-Master)

Оба региона обслуживают трафик, данные реплицируются в обе стороны.

Region A (Active)       Region B (Active)
┌─────────────┐        ┌─────────────┐
│ App Cluster │◄------→│ App Cluster │
│ DB Master   │◄------→│ DB Master   │
└─────────────┘        └─────────────┘
     ↑                       ↑
     └───────────────┬───────┘
          Async Replication

RTO: < 1 секунды (трафик уже идёт на другой регион). RPO: секунды (в зависимости от lag репликации).

Сложность: высокая. Требуется разрешение конфликтов при одновременных изменениях.

Геораспределение: выбор между multi-region и CDN

Характеристика Multi-Region CDN
Для чего Доступность, DR, локальность данных Низкая latency для static контента
Что реплицируется Полная система (приложение + БД) Только статический контент (JS, CSS, изображения)
Когда использовать Требование RTO < 1 мин или RPO < часа Контент меняется редко, нужна низкая latency
Стоимость Высокая (дублирует инфру) Низкая (pay-per-bandwidth)
Сложность Высокая (конфликты данных, синхронизация) Низкая (stateless кеширование)

Расчёты нагрузок и ёмкости

Анализ требований к масштабированию

Шаг 1: Определить нагрузку

Типовые вопросы:

  • Сколько пользователей ожидается через год?
  • Какой peak RPS (requests per second)?
  • Какой средний размер запроса/ответа?
  • Какой процент read vs write операций?

Пример для e-commerce:

  • 1 миллион пользователей;
  • Peak: 10,000 RPS (во время flash sale);
  • Среднее: 1,000 RPS;
  • 95% read, 5% write операций.

Шаг 2: Расчёт нагрузки на приложение

CPU/Memory на одном сервере:

Предположим:

  • Один request обрабатывается 100 мс (medium операция);
  • На одном сервере можно запустить 100 потоков;
  • Каждый thread занимает ~50 MB памяти.

Пропускная способность одного сервера:

[ \text{RPS per server} = \frac{\text{Threads}}{\text{Processing time}} = \frac{100}{0.1 \text{ sec}} = 1,000 \text{ RPS} ]

Требуемое количество серверов приложения для 10,000 peak RPS:

[ \text{Servers needed} = \frac{10,000}{1,000} = 10 \text{ серверов} ]

С N+2 резервированием (для отказоустойчивости):

[ \text{Total servers} = 10 + 2 = 12 \text{ серверов} ]

Шаг 3: Расчёт нагрузки на БД

Write нагрузка:

Если 10,000 RPS, 5% write:

  • Write RPS = 10,000 × 0.05 = 500 RPS;
  • Транзакции в секунду (TPS) = 500.

Storage нагрузка:

При 1M пользователей, 1KB данных на пользователя, хранение 1 года:

  • Базовый размер = 1M × 1KB = 1 GB;
  • Логи, временные данные: +20% = 1.2 GB;
  • Backup (3 копии) = 3.6 GB.

Выбор СУБД по нагрузке:

Нагрузка СУБД Причина
< 1,000 TPS, ACID важна PostgreSQL Надёжность, ACID гарантии
1,000-10,000 TPS, читаем MySQL + Read Replicas Горизонтальное масштабирование reads
> 10,000 TPS, high write Cassandra Линейное масштабирование writes
Unstructured, > 100GB MongoDB Flexible schema, horizontal scaling

Шаг 4: Расчёт нагрузки на кеш

Cache Hit Ratio расчёт:

При 95% read операций и 80% попадание в кеш:

  • Запросы, идущие в БД = 1,000 RPS × 5% write + 1,000 RPS × 95% read × (1 - 0.80);
  • = 50 + 190 = 240 RPS в БД;
  • Кеш должен обеспечить 1,000 RPS × 95% × 0.80 = 760 RPS.

Размер кеша:

Предположим:

  • TTL кеша: 1 час;
  • Средний размер кешированного объекта: 1 KB;
  • Уникальных запросов: 100,000.

Требуемый размер кеша: 100,000 × 1 KB = 100 MB (практически, 200-300 MB с overhead).

Выбор кеш-решения:

Решение Throughput Latency Persistance Когда использовать
Redis 50,000-100,000 RPS (single node) 0.1-1 ms AOF, RDB Session, cache, leaderboard
Memcached 100,000+ RPS (single node) 0.1-1 ms нет Read-heavy кеш
Redis Cluster 1M+ RPS (распределено) 1-5 ms Да Large-scale системы

Мониторинг и оповещение

Key Performance Indicators (KPI) для надёжности

Метрика Как измерять Целевое значение Инструменты
Error Rate (5xx errors) / total requests < 0.1% Prometheus, Datadog
p99 Latency 99-й процентиль времени ответа < 200 ms Prometheus, Jaeger
CPU Usage Average CPU on servers 60-70% Node Exporter
Memory Usage Percentage used 75-85% Node Exporter
Disk I/O Read/write bytes per second < 80% capacity iostat
DB Connection Pool Active connections 70-80% Application metrics
Queue Depth Messages in queue < 1000 RabbitMQ/Kafka metrics

Структура alerting'а

Severity levels:

Level Response Time Пример
Critical 5 минут Error rate > 5%, Downtime > 10 мин
High 15-30 минут Error rate > 1%, p99 latency > 1 sec
Medium 1-2 часа CPU > 90%, Disk > 85%
Low 4-8 часов CPU > 70%, Memory trending up

Инструменты и подходы мониторинга

Метрики (Metrics):

  • Prometheus + Grafana для сбора и визуализации;
  • Custom метрики из приложения через миддлвейр (middleware).

Логи (Logs):

  • ELK Stack (Elasticsearch, Logstash, Kibana) или Loki для центрального хранилища;
  • Structured logging (JSON format) для лучшего анализа.

Трассировка (Tracing):

  • Jaeger или Zipkin для distributed tracing;
  • Помогает выявить узкие места в микросервисной архитектуре.

Синтетическое мониторинг (Synthetic Monitoring):

  • Периодически отправлять фиктивные запросы;
  • Обнаруживать проблемы от внешней перспективы.

Выбор технологий и подходов

Матрица выбора для архитектуры надёжности

Требование Solution Примеры инструментов Trade-offs
Availability 99% Single-AZ, Active-Passive (1 standby) Standard load balancer, MySQL + replication Простота, MTTR 5-10 мин
Availability 99.9% Multi-AZ, Active-Active Multiple instances, PostgreSQL + replicas Большее complexity, better utilization
Availability 99.99% Multi-AZ + Multi-Region, circuit breakers Kubernetes, Cassandra/DynamoDB Высокая стоимость, operational complexity
Availability 99.999% Multi-Region Active-Active, chaos engineering Multi-Master DB, observability stack Экстремально сложно и дорого

Выбор модели репликации данных

Модель RPO (Recovery Point Objective) Стоимость Сложность Когда использовать
Synchronous Replication 0 (нет потери) Высокая (slower writes) Низкая Финтех, critical data
Asynchronous Replication Секунды-минуты Низкая Средняя Cache, analytics
Semi-Sync Replication < 1 second Средняя Средняя Баланс между надёжностью и скоростью

Выбор между vertical и horizontal scaling

Характеристика Vertical Scaling Horizontal Scaling
Метод Добавить мощность одному серверу (CPU, RAM) Добавить новые серверы
Предел Существует физический лимит (~256 CPU cores) Теоретически без лимита
Downtime Требует перезагрузки Нет downtime
Стоимость Дороже за единицу мощности Дешевле, но more operational overhead
Когда использовать До ~1,000 RPS, мало микросервисов > 1,000 RPS, распределённая система

Аналоги инструментов для обеспечения надёжности

Load Balancers

Инструмент Тип Advantages Недостатки Когда использовать
NGINX Application LB Легко конфигурировать, open-source Требует самостоятельного управления failover On-premise, small-medium setup
HAProxy Application LB Высокая пропускная способность, гибкий Старая документация Legacy системы, extreme performance
AWS ELB/ALB Cloud LB Managed, автоматический failover Vendor lock-in, latency для external requests AWS environment
Google Cloud Load Balancer Cloud LB Global redundancy, multi-region Дорого, complex pricing Google Cloud
Keepalived + VRRP Virtual IP Manager Простой failover between two LBs Только 2 узла, требует manual setup On-premise redundancy

Message Queues для асинхронной обработки

Queue Throughput Persistence Когда использовать
RabbitMQ 50,000 msg/sec Disk-based General purpose, guaranteed delivery
Apache Kafka 1M msg/sec Event log, retention policy Stream processing, event sourcing
AWS SQS 120,000 msg/sec (shared) AWS managed AWS ecosystem, simplicity
Redis Streams 500,000 msg/sec In-memory (with persistence) Real-time, low-latency queuing

Circuit Breaker & Retry Libraries

Library Language Features Когда использовать
Resilience4j Java Modular, composable, no annotations New Java projects
Hystrix Java Dashboard, thread pool isolation Legacy Microservices
Polly C#/.NET Simple, fluent API .NET/C# services
Retry Python Simple decorators Python services

Distributed Tracing

Tool Agent Query Language Интеграция
Jaeger Yes (auto-instrumentation) OTQL (limited) Kubernetes, Docker friendly
Zipkin Yes None (REST API) Good for old systems
DataDog Yes (APM) DataDog query Managed, expensive

Выводы и практические рекомендации

Принципы надёжной архитектуры

  1. Идентифицировать SPOF на уровне архитектуры: каждый критичный компонент должен иметь резервирование или быть распределённым;

  2. Спроектировать для отказов, а не избегать их: таймауты, retries, circuit breakers должны быть везде;

  3. Балансировать надёжность и стоимость: максимальная доступность требует экспоненциального роста инфраструктуры;

  4. Измерять и мониторить: SLI и SLO должны быть определены и отслеживаться в реальном времени;

  5. Планировать восстановление: MTTR — часто важнее, чем попытка полностью избежать сбоев;

  6. Тестировать отказы: chaos engineering (deliberate failure injection) показывает реальные проблемы;

  7. Документировать runbook'и: для каждого известного отказа должна быть процедура восстановления.

Типовые ошибки при проектировании надёжности

  1. Игнорирование SPOF: архитектура содержит критичные компоненты без резервирования;

  2. Слепая вера в autoscaling: autoscaling имеет delay и не помогает при внезапном spike; требуется буферизация через очереди и rate limiting;

  3. Отсутствие таймаутов: синхронные вызовы без таймаутов могут привести к cascade failures;

  4. Неправильный выбор уровня доступности: требование 99.99% для системы, которой достаточно 99.9%, приводит к переусложнению;

  5. Отсутствие мониторинга: система может деградировать, но никто этого не заметит;

  6. Игнорирование RPO/RTO: не учитывается, какой объём данных можно потерять и как быстро восстановиться;

  7. Отсутствие тестирования отказов: real-world проблемы выявляются только при production incidents.

Roadmap внедрения высокой доступности

Фаза 1 (MVP, 99% доступность):

  • Single-AZ, Active-Passive架構;
  • Basic health checks и manual failover;
  • Standard monitoring.

Фаза 2 (Growth, 99.9% доступность):

  • Multi-AZ, Active-Active архитектура;
  • Automatic failover через load balancer;
  • Circuit breakers и graceful degradation;
  • Comprehensive monitoring и alerting.

Фаза 3 (Scale, 99.99% доступность):

  • Multi-Region Active-Passive для DR;
  • Advanced observability (distributed tracing);
  • Chaos engineering и regular failover drills;
  • Dedicated SRE team.

Фаза 4 (Enterprise, 99.999% доступность):

  • Multi-Region Active-Active (если требуется);
  • Global load balancing;
  • Automated chaos engineering;
  • Zero-downtime deployment pipeline.

System Design: Observability

Роль observability в System Design

Observability — это способность понять внутреннее состояние системы на основе анализа её внешних выходов (логов, метрик, трейсов). Это отличается от мониторинга, который обычно означает проверку известных ранее условий (пороговые алёрты, графики по определённым метрикам). Observability отвечает на вопросы, которые ты раньше не предполагал: «Почему именно этот запрос занял 5 секунд?», «На каком сервисе образовалось узкое место?», «Какие пользователи пострадали при этом инциденте?»

На собеседовании по system design интервьюер ожидает, что Senior-инженер будет:

  • не просто упоминать «будут логи», а описывать структуру логирования, способ корреляции и анализа;
  • обосновывать выбор метрик через SLI/SLO, а не перечислять понравившиеся графики;
  • объяснять, как трассировка помогает в отладке многосервисных взаимодействий;
  • показывать, что observability встроена в процесс разработки и поддержки (не отдельный слой, а часть архитектуры).

Без собственной observability невозможно:

  • Отвечать пользователям на вопрос «когда станет лучше?» в условиях деградации;
  • Отличить проблему приложения от проблемы инфраструктуры;
  • Отладить баг в production-е за разумное время;
  • Обнаружить частичные отказы (когда система частично доступна);
  • Отследить цепь вызовов между десятками микросервисов;
  • Связать технические метрики с бизнес-результатами.

Три основных столпа observability

Логи (Logs)

Логи — это дискретные записи о событиях в системе с временной меткой и контекстом. Они отвечают на вопрос: «Что именно произошло и когда?»

Логирование используется для:

  • Отладки инцидентов: восстановления последовательности действий, которые привели к ошибке;
  • Аудита: отслеживания, кто и когда совершил критичное действие (изменение данных, вход, удаление);
  • Диагностики бизнес-проблем: например, почему платёж не прошёл, почему заказ не создался.

Логи содержат глубокие детали, которые невозможно захватить в метрику. Например, при ошибке может понадобиться сам текст ошибки, параметры запроса, стек вызовов — всё это идёт в логи.

Метрики (Metrics)

Метрики — это агрегированные числовые показатели, изменяющиеся во времени. Они отвечают на вопросы: «Как часто происходит событие?», «Какова средняя/максимальная задержка?», «Сколько ресурсов используется?»

Метрики группируются в точках времени (например, каждую минуту) и хранятся компактно, что позволяет анализировать тренды за недели и месяцы. Они используются для:

  • Построения SLI (Service Level Indicator) — конкретные измеримые показатели качества;
  • Определения SLO (Service Level Objective) — целевые уровни SLI;
  • Срабатывания алёртов — автоматических уведомлений при нарушении пороговых значений.

Трейсы (Traces, распределённая трассировка)

Трассировка — это отслеживание пути одного конкретного запроса через всю систему (через несколько сервисов, БД, очереди). Она отвечает на вопрос: «На каком этапе и почему запрос замедлился или упал?»

Основные понятия:

  • Trace — полный путь одного запроса от входа в систему до выхода;
  • Span — отдельный шаг внутри трассировки (вызов микросервиса, запрос к БД, обращение к кешу);
  • traceId — уникальный идентификатор всей цепи, прокидывается между всеми сервисами;
  • spanId — уникальный идентификатор конкретного span'а;
  • parentSpanId — ссылка на родительский span, показывает иерархию вызовов.

Дополняющие инструменты: события и профилирование

События (events) — более структурированные записи о значимых моментах, чем логи. Часто используются для отслеживания бизнес-операций (заказ создан, платёж начат, уведомление отправлено).

Профилирование (profiling) — анализ использования CPU, памяти, lock'ов на уровне функций. Помогает выявить неэффективный код, но применяется точечно на уровне отладки конкретных узких мест.

Логирование: принципы и практика

Структурированное логирование

Простое логирование строками вроде "User signed in" практически бесполезно при анализе проблем. Современные системы требуют структурированных логов в формате JSON или key-value:

{
  "timestamp": "2025-11-27T01:20:00Z",
  "level": "INFO",
  "service": "auth-service",
  "endpoint": "/api/users/login",
  "userId": "user-12345",
  "requestId": "req-abc123",
  "traceId": "trace-xyz789",
  "spanId": "span-456",
  "message": "User login successful",
  "duration_ms": 45,
  "status": 200
}

Преимущества структурированных логов:

  • Возможность фильтрации и поиска по конкретным полям (найти все логи для userId = user-12345);
  • Аналитика: построение графиков по полям логов;
  • Автоматический парсинг инструментами логирования;
  • Возможность корреляции с метриками и трейсами.

Ключевые поля в логах:

  • timestamp — момент события (используется для хронологии);
  • level — уровень серьёзности;
  • service — название сервиса (когда логи агрегируются из разных сервисов);
  • endpoint — HTTP-endpoint, который был вызван;
  • userId, accountId, orderId — бизнес-ключи (помогают найти логи для конкретного пользователя);
  • traceId, spanId — идентификаторы трассировки для корреляции;
  • duration_ms — время выполнения операции;
  • status, error — результат и тип ошибки;
  • custom_fields — специфичные для бизнес-домена (product_id, payment_amount).

Уровни логирования

DEBUG — детальная информация о выполнении (значения переменных, шаги алгоритма). Не должен попадать в production-логи без необходимости, так как создаёт объём.

INFO — значимые события (запрос пришёл, запрос завершился, операция началась). Это уровень, на котором обычно работают production-логи.

WARN — предупреждение о необычной, но не критичной ситуации (retry после временной ошибки, slow query, deprecated API). Требует внимания, но система продолжает работать.

ERROR — ошибка, которую нужно исследовать (падение запроса, исключение БД, внешний сервис недоступен). Обычно должна быть включена в алёрты.

Частая ошибка кандидатов: логировать как DEBUG или ERROR события, которые происходят часто. Это создаёт шум и делает логи бесполезными.

Объём логирования и баланс

Не все события нужно логировать на уровне INFO. Важен баланс:

  • Логируй старты и завершения критичных операций;
  • Логируй ошибки всегда;
  • Логируй медленные операции (slow query, медленный сетевой вызов);
  • Логируй изменения состояния (deployment, масштабирование);
  • Не логируй каждый шаг в цикле обработки (создаст миллионы записей);
  • Используй выборочное логирование (например, логируй только X% запросов на DEBUG-уровне для анализа производительности).

Централизованный сбор логов

Локальные логи в файлах на серверах непрактичны для анализа:

  • Нужно логиниться на каждый сервер;
  • Трудно искать информацию по всем хостам;
  • Логи теряются при перезагрузке контейнера.

Современная архитектура использует централизованный сбор логов:

  1. Приложение пишет логи на stdout (в контейнере/Kubernetes);
  2. Агент-сборщик (Filebeat, Logstash, Fluentd) собирает логи и отправляет в centralised хранилище;
  3. Система логирования (Elasticsearch, Loki, Splunk) индексирует логи;
  4. На логами можно искать, фильтровать, строить графики.

Единый формат и централизованное хранилище — обязательное требование на Senior-уровне.

Корреляция логов и запросов

Correlation ID и Trace ID

Критичный элемент observability — Trace ID (или Correlation ID, Request ID) — уникальный идентификатор, который следует за всеми действиями, связанными с одним клиентским запросом. Если клиент делает запрос, который проходит через 5 микросервисов, все эти сервисы должны логировать один и тот же traceId.

Генерация и прокидывание:

  1. API Gateway получает входящий запрос, генерирует новый traceId (или берёт из заголовка X-Trace-ID, если клиент его передал);
  2. Gateway добавляет traceId в контекст и прокидывает в дальние сервисы через HTTP-заголовок X-Trace-ID;
  3. Каждый сервис логирует этот traceId во всех своих событиях;
  4. Если сервис вызывает другой сервис, он передаёт traceId дальше;
  5. Если сервис кладёт сообщение в очередь, он включает traceId в payload.

Пример описания на собеседовании:

Когда пользователь создаёт заказ, API Gateway генерирует traceId, скажем, trace-abc123. Этот ID вкладывается в HTTP-заголовок и передаётся в order-service. Order-service логирует этот traceId, затем вызывает payment-service, передавая тот же traceId. Если payment упадёт, мы можем быстро найти все логи этого запроса по одному ID.

Привязка логов разных сервисов к одному запросу

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

grep traceId=trace-abc123 /all-logs

или через UI:

Show all logs where traceId = trace-abc123

Результат — полная история запроса: когда он пришёл на какой endpoint, какие микросервисы он посетил, какие запросы в БД были выполнены, где произошла ошибка (если была).

Многошаговые бизнес-процессы

Для асинхронных процессов (очереди сообщений) важно, чтобы traceId сохранялся:

  • Микросервис создаёт заказ, логирует событие order.created с traceId;
  • Событие кладётся в очередь с полем traceId в payload;
  • Downstream-сервис читает событие из очереди и извлекает traceId;
  • Все действия этого сервиса логируют тот же traceId;
  • В результате одна бизнес-операция (от создания до завершения) видна по одному traceId во всех логах, несмотря на асинхронность.

Метрики: типы и дизайн

Основные типы метрик

Counter (счётчик) — метрика, которая только растёт или обнуляется. Примеры: количество полученных запросов, количество ошибок, количество завершённых заказов. Используется для расчёта rate (скорость) — например, запросов в секунду.

Gauge (датчик) — метрика, которая может расти и падать. Примеры: текущая длина очереди, использованная память, количество активных соединений.

Histogram/Summary — метрика распределения значений. Используется для трассировки latency: вместо одного значения (среднее время) отслеживается распределение времён (сколько запросов заняло 10-50мс, сколько 50-100мс и т.д.).

Категории метрик

Инфраструктурные метрики (про ресурсы машины):

  • CPU utilization, user/system time;
  • Memory usage, heap usage (для Java);
  • Disk I/O, throughput;
  • Network I/O;
  • Thread count, open file descriptors.

Прикладные метрики (про приложение):

  • RPS (requests per second);
  • Latency (p50, p95, p99);
  • Error rate;
  • Queue length, lag;
  • Cache hit rate;
  • Database query latency;
  • Business metrics: transaction amount, conversion rate, user signups.

RED и USE модели

RED (Rate, Errors, Duration) — модель для выбора метрик по сервисам:

  • Rate — скорость входящих запросов (RPS);
  • Errors — доля ошибочных запросов (error rate);
  • Duration — latency (как долго выполняются запросы, p95/p99).

Эти три метрики дают полное понимание, здоров ли сервис.

USE (Utilization, Saturation, Errors) — модель для инфраструктурных ресурсов:

  • Utilization — процент использования ресурса (CPU, память);
  • Saturation — насколько ресурс перегружен (очередь, ожидание на lock'е);
  • Errors — ошибки на уровне ОС (dropped packets, failed allocations).

Используй RED для приложений и USE для инфраструктуры.

Averages vs percentiles

Одна из частых ошибок в observability — использовать только среднее значение latency (average). Проблема: если 99% запросов выполняются за 10мс, а 1% за 10 секунд, average может быть 110мс, что не отражает реальную картину для большинства пользователей.

Правильный подход:

  • p50 (медиана) — 50% запросов быстрее этого значения;
  • p95 — 95% запросов выполняются за это время или быстрее (популярная метрика для SLO);
  • p99 — самые требовательные 1% пользователей;
  • max — максимальное время (полезно для выявления редких скачков).

На собеседовании говори о percentiles вместо averages, это сразу показывает зрелость в observability.

Карта метрик по компонентам системы

Типичная архитектура требует мониторинга разных точек:

API Gateway:

  • RPS (request rate);
  • Error rate (4xx, 5xx);
  • Latency (p95, p99);
  • Connection count.

Микросервисы (приложения):

  • RPS per endpoint;
  • Latency per endpoint (p95, p99);
  • Error rate;
  • Business metrics (заказы, платежи);
  • Thread pool queue depth, active threads.

Базы данных:

  • Connection pool utilization;
  • Query latency (p95, p99);
  • Slow queries count;
  • Connections opened/closed rate;
  • Lock wait time.

Кеши (Redis, Memcached):

  • Hit rate, miss rate;
  • Latency (get, set);
  • Memory usage;
  • Eviction rate.

Очереди сообщений:

  • Queue depth (число сообщений в очереди);
  • Consumer lag (насколько consumer отстаёт от producer);
  • Processing rate;
  • DLQ (dead letter queue) count;
  • Message processing latency.

SLI, SLO, SLA в контексте observability

Service Level Indicator (SLI)

SLI — конкретная, измеримая метрика, которая показывает качество сервиса с точки зрения пользователя. SLI должна быть:

  • Измеримой (берётся из логов/метрик);
  • Объективной (не зависит от мнения);
  • Связанной с пользовательским опытом.

Примеры SLI:

  • "Доля успешных HTTP-запросов (status 2xx)", целевое значение 99.9%;
  • "Запросы, завершённые за < 300мс", целевое значение 95%;
  • "API доступен (не в maintenance)", целевое значение 99.95%.

Service Level Objective (SLO)

SLO — целевой уровень SLI за конкретный период времени. Это обязательство команды.

Пример:

SLO: 99.5% запросов должны быть успешными (2xx status) за любой 30-дневный период.

или

SLO: p95 latency для endpoint /api/orders <= 300мс за любой 7-дневный период.

SLO должны быть амбициозными, но реалистичными. Если SLO 99.99%, то 0.01% ошибок допущено — это ошибок budget.

SLA (Service Level Agreement)

SLA — внешний контракт с пользователями/клиентами, в котором указаны гарантии и последствия нарушения (скидка, компенсация).

SLA обычно либеральнее SLO. Например:

  • Внутренний SLO: 99.5% uptime;
  • Внешний SLA: 99.0% uptime (компенсация при нарушении).

Разница позволяет команде иметь буфер для развертывания, обновлений.

Error Budget

Error budget — допустимый объём ошибок/доуставок в период SLO.

Расчёт:

Если SLO: 99.5% успешных запросов за 30 дней, то error budget = 0.5% = 1 ошибка на 200 запросов.

На практике error budget рассчитывается за месяц:

  • За месяц приходит 1 млн запросов;
  • SLO = 99.5%, значит допустимо 5000 ошибок;
  • Это error budget.

Связь error budget с развертыванием:

Если error budget не потрачен — команда может безопасно развертывать, экспериментировать, делать рефакторинг. Если error budget почти исчерпан, стоит быть консервативнее с изменениями.

На собеседовании упоминание SLO и error budget показывает, что ты думаешь о business impact, а не просто о графиках.

Пример на собеседовании:

Мы определяем SLO: 99.5% запросов должны быть успешными. Это означает, что за месяц с 1 млн запросов допускается 5000 ошибок. Это наш error budget. Если мы потратили его в первую неделю, остаток месяца мы работаем в режиме минимизации риска. Если budget остался, можем развёртывать чаще.

Distributed Tracing (распределённая трассировка)

Определения и структура

Трассировка решает проблему микросервисной архитектуры: один запрос проходит через 5-10 сервисов, и непонятно, где он замедлился. Трассировка показывает полный путь.

Ключевые компоненты trace:

  • Trace — полный путь одного запроса;
  • Root Span — входная точка (например, HTTP GET на API Gateway);
  • Child Spans — дочерние операции (вызов to-service, запрос в БД, обращение к кешу);
  • Span attributes — дополнительные данные (status, latency, error message).

Структура выглядит как дерево:

Trace: trace-abc123
├─ Span: API Gateway (root)
│  ├─ Span: order-service /api/orders
│  │  ├─ Span: order-service -> DB query
│  │  └─ Span: order-service -> payment-service
│  │     ├─ Span: payment-service -> payment-gateway
│  │     └─ Span: payment-service -> DB write
│  └─ Span: response sent

Контекст и propagation трассировки

Каждый span содержит:

  • traceId — уникальный ID всей цепи (один и тот же для всех spans в trace);
  • spanId — уникальный ID этого span'а;
  • parentSpanId — ID родительского span'а (показывает иерархию).

Propagation (прокидывание контекста) — это механизм, который гарантирует, что все сервисы в цепи знают traceId:

  1. Root сервис создаёт span с новым traceId;
  2. Когда этот сервис вызывает другой сервис по HTTP, он добавляет HTTP-заголовки:
    X-Trace-ID: trace-abc123
    X-Span-ID: span-xyz
    X-Parent-Span-ID: span-root
    
  3. Downstream сервис получает эти заголовки и создаёт новый span (child) с тем же traceId;
  4. Процесс повторяется.

Для асинхронных очередей traceId кладётся в payload сообщения.

Что даёт трассировка

Поиск узких мест: Если end-to-end latency медленная, трассировка показывает, на каком span'е заняло больше времени. Например: 100мс из 200мс потрачено на payment-service.

Понимание цепочек зависимостей: Трассировка показывает, какие сервисы вызывают друг друга, и помогает выявить критичные зависимости.

Анализ параллельных операций: Если сервис параллельно делает запросы к 3 внешним API, трассировка покажет все три span'а и их взаимодействие.

Отладка ошибок: Если где-то в цепи произойдёт ошибка, трассировка покажет, на каком span'е и с каким статус-кодом.

Как упоминать на System Design

Пример формулировки:

Каждый микросервис инструментирован для distributed tracing. Когда запрос входит через API Gateway, ему присваивается traceId. Этот ID прокидывается через HTTP-заголовки между всеми сервисами и в сообщениях очередей. Это позволяет за одну query в трассировке увидеть полный путь запроса и выявить узкие места.

Интервьюер оценит краткость и конкретность.

Observability для синхронных и асинхронных взаимодействий

Синхронный REST/gRPC

При синхронных вызовах трассировка прямолинейна:

  1. Client делает HTTP GET /api/orders;
  2. API Gateway создаёт root span, присваивает traceId;
  3. Gateway вызывает order-service с X-Trace-ID в заголовке;
  4. order-service создаёт child span, логирует traceId;
  5. order-service, если нужно, вызывает user-service (новый child span);
  6. Все spans связаны через иерархию spanId/parentSpanId.

Метрики для синхронных взаимодействий:

  • Latency per endpoint (p95, p99);
  • Error rate по endpoint'ам;
  • Throughput (RPS).

Очереди и события

Асинхронная обработка усложняет трассировку, но принцип тот же:

  1. Order-service создаёт заказ, отправляет событие order.created в Kafka;
  2. Это событие содержит payload с traceId и spanId;
  3. Payment-service слушает Kafka, получает событие;
  4. Payment-service создаёт новый child span (свой spanId), указав в нём parentSpanId из события;
  5. Все действия payment-service логируют тот же traceId.

Важно: traceId — один на всю операцию, но spanId уникален на каждый сервис.

Микс синхронных и асинхронных шагов

Часто в одной бизнес-операции есть оба типа:

  1. Client POST /api/orders (синхронно);
  2. Order-service создаёт заказ в БД (синхронно);
  3. Order-service отправляет событие в очередь (асинхронно);
  4. Payment-service обрабатывает событие из очереди (асинхронно);
  5. Payment-service выполняет синхронный вызов к payment-gateway.

По traceId можно восстановить всю цепь, несмотря на асинхронность.

Метрики для асинхронной части

  • Queue depth — количество сообщений в очереди;
  • Consumer lag — отставание consumer'а от producer'а (как долго сообщение ждёт обработки);
  • Processing rate — скорость обработки сообщений consumer'ом;
  • DLQ count — количество сообщений, упавших в dead letter queue (требуют ручной обработки);
  • Processing latency — сколько времени занимает обработка одного сообщения (от получения до commit'а).

Дашборды и алёрты

Дизайн дашбордов

Плохой дашборд — это коллекция всех возможных графиков (зоопарк). Хороший дашборд показывает только ключевые SLI и сигналы, которые помогают принять решение.

Типы дашбордов:

Per-Service Dashboard — отдельный дашборд для каждого микросервиса:

  • RPS, error rate, latency (p95, p99);
  • CPU, memory, JVM heap;
  • Database connection pool utilization;
  • Очередь сообщений (depth, lag).

System Overview Dashboard — общий вид на всю систему:

  • Общий RPS;
  • Общий error rate;
  • Какие сервисы недоступны;
  • Критичные очереди.

Business Dashboard — для неинженеров (product managers, business);

  • Transaction count;
  • Revenue;
  • Conversion rate;
  • User signups.

Каждый дашборд должен отвечать на конкретный вопрос. На собеседовании упоминай дашборды по их назначению, не просто «у нас есть дашборды».

Алёрты

Алёрт — это автоматическое уведомление, когда что-то идёт не так. Ключевое правило: алёрт должен требовать действия.

Плохие алёрты:

  • "CPU > 70%" (может быть нормально);
  • "Disk space < 20%" (неясно, критично ли);
  • Слишком частые алёрты (alert fatigue).

Хорошие алёрты:

  • "Error rate > SLO на 30 минут" (требует расследования инцидента);
  • "P99 latency degraded в 2x за 10 минут" (указывает на проблему);
  • "DLQ count растёт" (сообщения не обрабатываются).

Алёрты по симптомам vs по причинам:

  • По симптомам (предпочтительно): error rate высокий, SLO нарушен, latency выросла. Это то, что видит пользователь;
  • По причинам: CPU 99%, memory exhausted. Это может быть проблема, а может и нет.

Настраивай алёрты по симптомам, это снижает alert fatigue.

Runbooks

Runbook — это краткая инструкция для инженера, когда сработал алёрт: что проверить, как отладить, на какие логи смотреть.

Пример:

Alert: Error rate > SLO
1. Проверь логи за последний час, фильтруй по status = 5xx
2. Посмотри, на каком endpoint'е концентрируются ошибки
3. Проверь трассировку (distributed tracing) для конкретных ошибок
4. Проверь metrics соответствующего backend-сервиса (CPU, memory, connections)
5. Если видна проблема в одном сервисе, начни его отладку
6. Если не ясно, запусти postmortem в конце инцидента

Runbook экономит время on-call инженера и гарантирует, что все следуют одному процессу.

Alert fatigue

Если алёртов слишком много и много false positives, инженеры начинают их игнорировать. Это опасно.

Рекомендации:

  • Алёрты только на события, требующие действия;
  • Используй SLO для алёртов, не абсолютные пороги;
  • Добавляй контекст к алёрту (не просто "CPU high", а "Error rate выросла на 20% за 5 минут");
  • Регулярно проверяй эффективность алёртов (сколько из них привело к action).

Observability и дизайн отказоустойчивости

Как наблюдаемость помогает устойчивости

Паттерны устойчивости (timeout, retry, circuit breaker, fallback) помогают системе пережить частичные отказы. Observability позволяет убедиться, что эти паттерны работают правильно.

Пример:

  1. Service A вызывает Service B с timeout 5 секунд;
  2. Service B упал, запрос timeout'ится;
  3. Service A активирует circuit breaker и начинает возвращать fallback ответ;
  4. Метрики показывают: число timeout'ов растёт, circuit breaker открыт;
  5. Логи показывают, какие именно запросы упали;
  6. Трассировка показывает, что latency на Service B зашкалилась.

Без observability мы бы не знали, что происходит.

Обнаружение частичных отказов

Частичный отказ — это когда система частично доступна. Например, payment-service упал, но order-service всё ещё работает.

Как это выявить:

  • По метрикам: error rate на конкретном endpoint'е высокий, остальные OK;
  • По логам: концентрация ошибок в одном сервисе;
  • По трассировке: вижу, что все span'ы, вызывающие payment-service, падают.

Без observability можно упустить частичный отказ, пока пользователи не пожалуются.

Использование логов и трассировок при инцидентах

Когда инцидент происходит:

  1. Смотрим на алёрты, определяем, что произошло (error rate выросла);
  2. По трассировке понимаем, на каком endpoint'е и на каком сервисе проблема;
  3. Смотрим на логи конкретного сервиса, ищем ошибки;
  4. По трассировке видим цепь запросов, которая привела к ошибке;
  5. Быстро локализуем корневую причину.

На собеседовании покажи, что observability — это не просто "я вижу, что система упала", а "я вижу, где, почему и как к этому привело".

Observability в Java backend

Логирование в Java

Структурированные логи:

В Java для структурированного логирования используют обычно JSON-форматирование (через Logback, Log4j2 с JSON encoder'ом или специальные библиотеки).

logger.info("User login", 
  "userId", userId,
  "duration_ms", duration,
  "status", 200,
  "traceId", traceId
);

В результате получается JSON в логе, который легко парсится.

Контекст логов (MDC — Mapped Diagnostic Context):

В многопоточной среде важно, чтобы каждый логируемый event содержал контекст (userId, traceId, requestId). MDC позволяет это делать без передачи контекста в каждую функцию:

MDC.put("traceId", traceId);
MDC.put("userId", userId);
logger.info("Processing order"); // автоматически включит traceId и userId

Когда логируется сообщение, MDC подставляет эти значения.

Проблема с асинхронностью:

В асинхронном коде (CompletableFuture, reactive streams) контекст MDC может потеряться, потому что работа выполняется в другом потоке. Решение: явно копировать MDC или использовать reactive libraries, которые это поддерживают.

Метрики в Java

JVM-метрики:

Каждое Java-приложение должно отслеживать:

  • Heap usage (используется/максимум);
  • GC pauses (как долго выполняется garbage collection);
  • Thread count;
  • Open file descriptors.

Эти метрики помогают выявить утечки памяти, неправильную конфигурацию heap'а.

Пулы потоков и соединений:

  • Thread pool active/queued/total;
  • Connection pool active/idle/total;
  • Queue size (если используются очереди).

Метрики пула помогают выявить, когда ресурсы иссякают.

Прикладные метрики:

  • RPS per endpoint (используют Counter для increments);
  • Latency per endpoint (используют Timer/Histogram);
  • Business metrics: transaction count, error count.

Обычно используют библиотеки вроде Micrometer, которые работают с Prometheus, Grafana и т.п.

Трассировка в Java

Автоматическая трассировка:

Некоторые фреймворки (Spring Cloud, Quarkus) и библиотеки (OpenTelemetry instrumentation agents) автоматически создают span'ы для:

  • HTTP запросов (входящих и исходящих);
  • Запросов к БД;
  • Кешу и очередям.

Ручная трассировка:

Для custom-логики можно создавать span'ы вручную:

try (var span = tracer.startSpan("process-order")) {
  span.setAttribute("orderId", orderId);
  // logic
}

Важное требование: все входные/выходные точки (HTTP, gRPC, очереди) должны быть покрыты трассировкой. Иначе можно потерять контекст при кросс-сервис вызывов.

Как говорить про observability в Java на собеседовании

На собеседовании не нужно упоминать конкретные библиотеки (Logback, Micrometer, OpenTelemetry), если на это не настаивают. Вместо этого:

Приложение логирует структурированные JSON-логи с traceId в каждое сообщение. Используется MDC для контекста. Метрики собираются по RED-модели (RPS, error rate, latency), включая JVM-метрики. Distributed tracing покрывает все вызовы между сервисами и к БД, с автоматическим прокидыванием traceId через HTTP-заголовки и сообщения очередей.

Это демонстрирует понимание концепции, независимо от tool.

Observability как часть цикла разработки

«Ты строишь — ты поддерживаешь»

На Senior-уровне разработчик отвечает не только за написание кода, но и за то, как код работает в production. Это означает, что разработчик:

  • Добавляет нужные логи и метрики;
  • Определяет SLI/SLO для своего сервиса;
  • Настраивает алёрты и runbook'и;
  • Отреагирует на инцидент, если алёрт сработал.

Observability — это часть work items разработчика, не отдельный team.

Инструментирование при разработке фич

Когда разработчик получает задачу (например, «добавить новый endpoint»), он не просто пишет код, а:

  1. Добавляет структурированное логирование с полезными полями;
  2. Добавляет метрики (RPS, latency, error rate для нового endpoint'а);
  3. Добавляет бизнес-метрики (если это важно, например, «платежи обработаны»);
  4. Покрывает distributed tracing;
  5. Определяет SLO для endpoint'а (например, p95 < 300мс);
  6. Добавляет алёрты, если latency нарушит SLO;
  7. Пишет runbook для типичных ошибок.

Это гарантирует, что сервис observable с дня release'а, не после первого инцидента.

Постмортемы и улучшения

После каждого inцидента (даже небольшого) команда проводит postmortem:

  1. Что произошло;
  2. Почему мы не заметили раньше (отсутствовал алёрт? логи не помогли?);
  3. Что добавить в observability, чтобы в следующий раз заметить быстрее.

Часто результат postmortem'а — добавление нового метрики или лога, которые раньше не отслеживались.

Чек-лист обсуждения observability на System Design

Когда ты отвечаешь на вопрос по system design, убедись, что упомянул эти пункты:

Логирование:

Метрики:

Трассировка:

SLI/SLO:

Дашборды и алёрты:

Инцидент-менеджмент:

Пример краткого блока про observability в ответе:

Observability построена на трёх столпах. Все логи структурированы в JSON и централизованно агрегируются с correlation ID для сопоставления событий в разных сервисах. Метрики собираются по RED-модели для приложений и USE для инфраструктуры, с фокусом на SLI (RPS, error rate, p95 latency). Distributed tracing покрывает все вызовы, с автоматическим прокидыванием trace ID через микросервисы и очереди. Дашборды показывают ключевые SLI, алёрты срабатывают при нарушении SLO. После каждого инцидента проводится postmortem, результаты питают улучшения в observability.

Такой блок звучит как Senior-инженер, который реально эксплуатировал сложные системы.

Типичные ошибки кандидатов и как их избежать

Ошибка 1: Игнорирование observability

Некоторые кандидаты вообще не упоминают логирование, метрики, трассировку. Ответ звучит так: «Приложение логирует ошибки, есть мониторинг».

Что ожидает интервьюер: конкретные подробности.

Как звучать как Senior: упомяни структурированные логи, correlation ID, RED-метрики, distributed tracing. Даже если интервьюер не спрашивал, брось фразу: «И конечно, observability:» — сразу покажешь, что понимаешь важность.

Ошибка 2: Только инфраструктурные метрики

Ответ: «Мониторим CPU, память, disk, network». Забывает про RPS, error rate, business metrics.

Правильный подход: инфраструктурные метрики нужны, но главное — прикладные метрики (RED) и бизнес-метрики, которые связаны с SLI/SLO.

Ошибка 3: Отсутствие correlation ID

Логи есть, но непонятно, как найти логи одного запроса, если он проходит через 5 сервисов.

Правильный подход: всегда упомяни correlation ID/trace ID, генерируемый на gateway и прокидываемый дальше.

Ошибка 4: Избыток логов без стратегии

«Логируем всё на DEBUG-уровне, чтобы ничего не потерять». Результат — логи неподъёмного объёма, дорого хранить, невозможно искать.

Правильный подход: стратегическое логирование — INFO-уровень для обычных событий, DEBUG выключен в production или включён выборочно.

Ошибка 5: Нет связи между метриками и алёртами

«Есть метрики, есть алёрты, но почему-то алёрты срабатывают без видимой причины».

Правильный подход: алёрты по симптомам (SLO violation), не по абсолютным порогам. Каждый алёрт имеет runbook. Используется SLO-based alerting.

Ошибка 6: Забывают про асинхронные операции

Обсуждают observability для синхронных вызовов, но упускают очереди, события. Трассировка не охватывает асинхронных участков.

Правильный подход: упомяни, что trace ID переносится в сообщения очередей, метрики собираются для lag, DLQ, queue depth.

Как избежать:

  1. Перед собеседованием проговори полный сценарий: синхронный запрос → асинхронное событие → обработка → результат. Где будут логи? Метрики? Трассировка?
  2. Всегда завершай раздел observability пунктом про асинхронность;
  3. Если спрашивают про observability, упомяни все три столпа (логи, метрики, трейсы), не только один.

Безопасность

Роль безопасности в архитектуре

Безопасность формирует архитектурные решения с самого начала проектирования. Требования безопасности определяют выбор способа хранения сессий (что влияет на масштабируемость), решение по многотенантности (определяет схему БД), выбор между JWT и сессиями (влияет на логику обновления токенов и наличие централизованного состояния).

Встроение безопасности в архитектуру на ранних этапах предотвращает несовместимость компонентов. Например, выбранная микросервисная архитектура может оказаться несовместимой с простой моделью авторизации на уровне базы данных или потребует переделки логирования аудита.

Бизнес-риски и их архитектурное влияние

Грамотный дизайн безопасности закрывает следующие бизнес-риски:

  • Утечка данных пользователей. Создаёт репутационный ущерб, регуляторные штрафы (GDPR, PCI DSS, локальное законодательство) и обязательства перед клиентами.
  • Захват аккаунтов и несанкционированный доступ. Слабая аутентификация или отсутствие защиты от перебора паролей позволяет получить доступ к чужому аккаунту.
  • Мошенничество и финансовые потери. Недостаточная авторизация позволяет получить доступ к чужим ресурсам (изменение баланса, скачивание файлов, платежи).
  • Нарушение изоляции между тенантами. Логическая ошибка в авторизации приводит к утечке данных одного тенанта к другому.
  • DDoS и истощение ресурсов. Отсутствие ограничения частоты запросов (rate limiting) позволяет перегрузить систему.
  • Регуляторные штрафы. В финансовой сфере, здравоохранении и электронной коммерции нарушение стандартов безопасности влечёт штрафы и ограничения.

Базовые принципы безопасности

Defense in Depth — многослойная защита

Защита системы строится на нескольких независимых слоях. Компрометация одного слоя не должна привести к скомпрометации всей системы.

Слой Механизмы Примеры реализации
Сетевой Сегментация, файрволлы, закрытие портов Микросервисы в кластере недоступны напрямую из интернета, только через API gateway
Приложение Аутентификация, авторизация, валидация входных данных, защита от инъекций JWT-токены на каждом запросе, проверка прав пользователя на объект
Данные Шифрование, маскирование, ограничение доступа БД, row-level security Чувствительные поля (номер карты, SSN) хранятся в зашифрованном виде, обращение логируется
Управление и мониторинг Логирование, аудит, алёрты на аномалии, сканирование уязвимостей Обнаружение аномальных паттернов доступа, оповещения о множественных неудачных попытках

Практический эффект: Даже если JWT скомпрометирован, rate limiting защитит от массовых попыток его использования, логирование позволит обнаружить аномалию, а ограниченные права ограничат ущерб.

Principle of Least Privilege

Каждый пользователь, сервис и процесс должен иметь только те права, которые необходимы для выполнения функции. Это минимизирует поверхность атаки и ущерб от компрометации.

Применение на разных уровнях:

  • Уровень пользователя: обычный пользователь не имеет привилегий администратора; модератор может блокировать посты, но не удалять пользователей; аналитик может читать отчёты, но не редактировать финансовые данные.
  • Уровень сервисов: сервис A может вызывать только определённые API сервиса B, не может обращаться к БД сервиса B напрямую; worker-процесс имеет доступ только к своей очереди сообщений.
  • Уровень учётных записей БД: приложение подключается с ограниченным набором разрешений (SELECT на определённые таблицы, но не DROP).

Zero Trust

Концептуальный подход, при котором ни один запрос и ни одна сеть не считаются надёжными по умолчанию. Каждый запрос требует проверки, каждый пользователь — аутентификации, каждый сервис — авторизации.

На практике:

  • Для пользователей: многофакторная аутентификация, VPN для доступа к системе, даже если пользователь в корпоративной сети.
  • Для микросервисов: взаимная аутентификация между сервисами (mTLS), даже если оба в одном кластере.
  • Для хранилищ: защита подключения к БД паролем или сертификатом, даже если БД в приватной подсети.

Security by Design

Требования безопасности определяют архитектурные решения, а не добавляются потом. Примеры интеграции:

  • Выбор OAuth2 + JWT определяет: механизм обновления токенов, систему хранения состояния сессии, логирование аудита.
  • Решение о многотенантности определяет: схему БД, логику фильтрации запросов, индексирование для изоляции данных.
  • Шифрование данных требует: управления ключами, ротации, контроля доступа к ключам, мониторинга использования.

Аутентификация

Модели аутентификации и их характеристики

Логин/Пароль

Базовая модель: пользователь предоставляет учётные данные, которые система проверяет.

Защита паролей:

  • Хеширование, не шифрование: пароли должны быть необратимо преобразованы (bcrypt, scrypt, Argon2), а не зашифрованы. Это предотвращает восстановление пароля даже при компрометации базы данных.
  • Соль: каждый пароль должен иметь уникальную соль для предотвращения атак по словарю на несколько учётных записей одновременно.
  • Стоимость хеширования: алгоритм должен быть медленным, чтобы затруднить перебор паролей. Современные алгоритмы (Argon2, scrypt) требуют значительных вычислительных ресурсов для каждой попытки.

Защита от атак:

  • Rate limiting: ограничение количества попыток входа с одного IP-адреса или аккаунта предотвращает перебор паролей.
  • Капча: после нескольких неудачных попыток требуется пройти проверку CAPTCHA.
  • Уведомления: отправка уведомлений при входе с нового устройства или IP-адреса позволяет пользователю заметить несанкционированный доступ.

Недостатки:

  • Пользователи часто используют слабые пароли.
  • Подвержена фишингу (пользователь вводит пароль на поддельном сайте).
  • Требует безопасного транспорта (HTTPS) для передачи пароля.

OAuth 2.0

Открытый стандарт делегирования прав доступа без передачи пароля.

Принцип работы:

  1. Пользователь перенаправляется на сервер авторизации (например, Google, Facebook).
  2. Сервер авторизации проверяет личность пользователя и запрашивает согласие на доступ к ресурсам.
  3. Пользователь перенаправляется обратно на приложение с кодом авторизации.
  4. Приложение обменивает код на токен доступа на сервере авторизации.
  5. Приложение использует токен доступа для получения данных пользователя.

Преимущества:

  • Пароль никогда не передаётся третьему приложению.
  • Пользователь может отозвать доступ без изменения пароля.
  • Сервер авторизации может применять собственные механизмы защиты (многофакторная аутентификация).
  • Стандартизированный протокол с множеством реализаций.

Недостатки:

  • Сложнее в реализации для приложения.
  • Зависит от доступности сервера авторизации.
  • Требует интеграции с поставщиком идентификации.

JWT (JSON Web Token)

Самозаверяющийся токен, содержащий информацию о пользователе.

Структура:

header.payload.signature
  • Header: тип токена (JWT) и алгоритм подписи (HS256, RS256).
  • Payload: утверждения (claims) — данные о пользователе (ID, роль, срок действия).
  • Signature: подпись токена, созданная при помощи секретного ключа.

Проверка:

Сервер может проверить подлинность токена без обращения к базе данных, проверив подпись с использованием публичного ключа.

Применение в микросервисной архитектуре:

Параметр Логика
Создание API gateway выдаёт JWT после успешной аутентификации
Хранение Клиент хранит JWT в памяти или localStorage
Передача JWT отправляется в заголовке Authorization для каждого запроса
Проверка Каждый сервис может независимо проверить подпись JWT без обращения к централизованному хранилищу
Обновление Используются токены обновления (refresh tokens) с более длительным сроком действия

Преимущества:

  • Масштабируемость: не требует централизованного хранилища сессий.
  • Может быть проверен на уровне API gateway или каждым сервисом независимо.
  • Содержит контекст пользователя (роль, идентификатор тенанта).

Недостатки:

  • Токен нельзя отозвать мгновенно (остаётся действительным до истечения срока).
  • Если ключ скомпрометирован, все выданные токены становятся уязвимыми.
  • Размер токена может быть большим, что влияет на пропускную способность.

Управление жизненным циклом токенов:

  • Access Token: короткоживущий (15 минут - 1 час), используется для доступа к ресурсам.
  • Refresh Token: долгоживущий (дни или недели), используется для получения нового access token без повторной аутентификации.

Хранение refresh token:

Подход Преимущества Недостатки
HTTP-only cookie Защита от XSS-атак, автоматическая передача в запросе Требует обработки CORS для кросс-доменных запросов
localStorage Простота реализации, кроссдоменные запросы Уязвим для XSS-атак
sessionStorage Автоматически очищается при закрытии вкладки Уязвим для XSS-атак, теряется при обновлении страницы
Redis (на сервере) Быстрый доступ, возможность отзыва, управление сроком действия Требует централизованного хранилища, снижает масштабируемость

Сессии на сервере

Сервер хранит данные сессии в памяти или централизованном хранилище (Redis, Memcached).

Процесс:

  1. При входе сервер создаёт сессию и выдаёт идентификатор сессии.
  2. Клиент отправляет идентификатор в cookie или заголовке при каждом запросе.
  3. Сервер проверяет сессию и восстанавливает контекст пользователя.

Архитектурные решения для масштабируемости:

Подход Сценарий применения Расчеты нагрузки
Локальные сессии (память) Монолит или один сервер До 10 тыс. активных сессий в памяти (~1-5 МБ на сессию)
Redis Микросервисы, несколько серверов О(1) для get/set, Redis может хранить 10-100 млн сессий в оперативной памяти (зависит от объёма памяти)
Sticky sessions (маршрутизация) Несколько серверов, низкая критичность потери сессии Нагрузка неравномерна, один сервер может получить все запросы одного пользователя
Распределённые хранилища (Redis Cluster, Memcached) Высокая надёжность, отказоустойчивость Репликация добавляет задержку, но обеспечивает отказоустойчивость

Преимущества:

  • Сессия может быть отозвана мгновенно (удаление из хранилища).
  • Поддержка сложных операций (добавление/удаление данных из сессии).
  • Совместимость с браузерами, использующими cookies.

Недостатки:

  • Требует централизованного хранилища при масштабировании на несколько серверов.
  • Может стать узким местом под высокой нагрузкой.
  • Требует синхронизации между сервисами в микросервисной архитектуре.

Сравнение подходов аутентификации

Параметр Логин/Пароль OAuth 2.0 JWT Сессии на сервере
Сложность Низкая Высокая Средняя Средняя
Масштабируемость Хорошая Хорошая Отличная Средняя (без Redis)
Отзыв токена Мгновенный Мгновенный Требует списка чёрных списков Мгновенный
XSS-защита Зависит от реализации Зависит от реализации localStorage уязвим Cookies HTTP-only
Поддержка нескольких устройств Хорошая Хорошая Отличная Требует синхронизации
Мобильные приложения Сложно (HTTPS) Хорошо Отличная Сложно (без синхронизации)
Микросервисная архитектура Требует API gateway Требует API gateway Независимая проверка Требует Redis
Стоимость вычислений Хеширование пароля (высокая) Обмен кода на токен (средняя) Проверка подписи (низкая) Поиск в хранилище (средняя)

Выбор механизма аутентификации

Монолитное приложение с веб-клиентом:

  • Логин/Пароль + HTTP-only cookies с сессиями на Redis.
  • Причина: простота, встроенная защита от XSS (HTTP-only), возможность мгновенного отзыва.

Микросервисная архитектура:

  • OAuth 2.0 + JWT (для разных поставщиков идентификации).
  • Причина: каждый сервис может независимо проверить JWT, масштабируемость, не требует централизованного хранилища сессий.

Мобильное приложение:

  • OAuth 2.0 + JWT с refresh token в secure storage (Keychain на iOS, KeyStore на Android).
  • Причина: пароль не передаётся приложению, токены для нескольких запросов, безопасное хранилище.

Публичное API:

  • API ключи (для simple интеграции) или OAuth 2.0 (для сложных сценариев).
  • Причина: простота использования, возможность управления доступом на уровне API ключа.

Авторизация

Модели авторизации

Role-Based Access Control (RBAC)

Доступ определяется на основе роли пользователя.

Структура:

Пользователь → Роль → Право → Ресурс

Примеры ролей:

  • admin (полный доступ)
  • editor (создание и редактирование контента)
  • viewer (только чтение)

Преимущества:

  • Простая реализация и понимание.
  • Легко масштабируется с добавлением новых ролей.

Недостатки:

  • Жесткая структура, сложно реализовать исключения.
  • Не учитывает контекст (сам ли пользователь создал ресурс).

Пример реализации на уровне API:

@RequiresRole("editor")
public void updatePost(Post post) { ... }

Attribute-Based Access Control (ABAC)

Доступ определяется на основе атрибутов: пользователя, ресурса, окружения.

Примеры атрибутов:

Категория Примеры
Пользователь возраст, отдел, уровень допуска
Ресурс тип данных, классификация (public/private), владелец
Окружение IP-адрес, время дня, географическое местоположение

Примеры правил:

  • "Пользователь может редактировать документ, если он владелец или имеет роль editor в отделе документа."
  • "Доступ к финансовым данным разрешён только с IP-адресов корпоративной сети и с 9:00 до 18:00."

Преимущества:

  • Гибкость: учитывает множество факторов.
  • Более точный контроль доступа.

Недостатки:

  • Сложность реализации.
  • Производительность: требует вычисления правил на каждый запрос.
  • Требует централизованной системы управления правилами.

Access Control List (ACL)

Явный список того, кто может выполнить какое действие с ресурсом.

Примеры:

Файл /documents/report.pdf:

  - user1: read, write
  - user2: read
  - group_admin: read, write, delete

Преимущества:

  • Простота понимания: кто имеет доступ к каждому ресурсу ясно.
  • Гибкость: индивидуальный контроль на уровне ресурса.

Недостатки:

  • Масштабируемость: ACL для каждого ресурса требует хранения в БД.
  • Сложность управления при большом количестве ресурсов и пользователей.

Уровни авторизации

Уровень Endpoint (операция)

Проверка, может ли пользователь выполнить операцию.

Пример:

POST /admin/users/delete → проверка роли "admin"

Реализация:

  • Аннотации: @RequiresRole("admin")
  • Middleware в API gateway

Уровень Объекта (ресурс)

Проверка, может ли пользователь получить доступ к конкретному объекту.

Пример:

GET /documents/123 → проверка, принадлежит ли документ пользователю или пользователь имеет доступ

Реализация:

  • Фильтрация запросов: SELECT * FROM documents WHERE owner_id = :user_id OR shared_with_id = :user_id
  • Проверка после выборки: if (document.owner_id != current_user_id) throw AccessDenied()

Уровень Поля

Проверка, может ли пользователь видеть или редактировать определённое поле объекта.

Пример:

GET /users/123 → пользователь видит name, но не email (если не имеет роли admin)

Реализация:

  • Сериализация: выбор полей на основе роли пользователя
  • Маскирование в БД: вычисленные столбцы

Уровень Данных (Row-Level Security)

Изоляция данных на уровне СУБД, чтобы запросы пользователя возвращали только доступные строки.

Пример PostgreSQL Row-Level Security:

CREATE POLICY org_isolation ON documents
  USING (org_id = current_setting('app.current_org_id')::int);

Преимущества:

  • Защита данных на уровне БД, невозможно обойти на уровне приложения.
  • Упрощение логики авторизации в коде.

Недостатки:

  • Усложнение работы с БД.
  • Производительность: требует дополнительной фильтрации.

Выбор модели авторизации

Сценарий Модель Причина
Простой blog с ролями (admin, editor, viewer) RBAC Простота реализации и управления
Социальная сеть с правами на уровне поста ABAC + Resource-level Сложные правила: друзья, подписчики, блокировки
Документооборот с индивидуальным доступом ACL Явный контроль на уровне каждого документа
Финансовая система с множеством правил ABAC Гибкость для регуляторных требований
Многотенантная система RBAC + ABAC на уровне тенанта Комбинация для простоты и безопасности

Многотенантность (Multitenancy)

Архитектурные модели

Отдельная БД для каждого тенанта

Структура:

Tenant A: database_tenant_a
Tenant B: database_tenant_b

Преимущества:

  • Полная изоляция данных.
  • Простая резервная копия и восстановление для одного тенанта.
  • Легче реализовать сложные правила GDPR (удаление всех данных).

Недостатки:

  • Сложность управления: требуется управление десятками или сотнями БД.
  • Неэффективное использование ресурсов: каждая БД требует памяти, даже если неактивна.
  • Сложность миграций: нужно применить схему ко всем БД.

Масштабирование:

При 1000 тенантов и размере БД ~10 ГБ потребуется 10 ТБ хранилища. Требуется метаслой для маршрутизации подключений:

Запрос → Резолвер (tenant_id → connection_string) → Подключение к БД

Общая БД с фильтрацией на уровне приложения

Структура:

Единая БД:

  - documents (tenant_id, user_id, data)
  - users (tenant_id, user_id, name)

Все запросы фильтруются по tenant_id текущего пользователя

Реализация:

SELECT * FROM documents 
WHERE tenant_id = :current_tenant_id AND owner_id = :current_user_id

Преимущества:

  • Единое управление хранилищем и ресурсами.
  • Лучше использование памяти и кэша (один индекс на все тенанты).

Недостатки:

  • Риск утечки данных если забыть добавить фильтр tenant_id.
  • Сложнее удалить все данные одного тенанта.
  • Производительность может деградировать при росте объёма данных.

Защита от утечек:

  • Постоянное добавление фильтра tenant_id во все запросы → использование ORM с автоматическим фильтром.
  • Row-Level Security в БД: правила БД гарантируют изоляцию.

Общая БД с Row-Level Security

Структура:

Единая БД с правилами безопасности на уровне строк

PostgreSQL пример:

CREATE POLICY tenant_isolation ON documents
  FOR SELECT USING (tenant_id = (SELECT id FROM current_tenant()));

CREATE POLICY tenant_isolation ON users
  FOR SELECT USING (tenant_id = (SELECT id FROM current_tenant()));

Преимущества:

  • Защита данных на уровне БД, разработчик не может случайно обойти.
  • Простота на уровне приложения: обычные запросы без фильтров.
  • Эффективность ресурсов.

Недостатки:

  • Требует поддержки Row-Level Security в СУБД.
  • Производительность: может быть медленнее обычных запросов.
  • Сложнее отлаживать проблемы доступа.

Расчёт производительности:

Для каждого запроса требуется дополнительная проверка прав. На большом объёме данных (100 млн строк) это добавляет 5-15% задержки в зависимости от индексирования.

Выбор модели многотенантности

Параметр Отдельная БД Общая БД Row-Level Security
Количество тенантов < 100 100-10000 > 1000
Размер тенанта > 1 ГБ < 100 ГБ < 10 ГБ
Критичность изоляции Очень высокая Высокая Критична
Управление Сложно Средне Средне
Производительность Хорошая Хорошая (при индексировании) Средняя
Стоимость хранилища Высокая Низкая Низкая

Рекомендации:

  • SaaS с небольшим количеством корпоративных клиентов: отдельная БД (проще управление, полная изоляция).
  • Платформа с множеством малых пользователей: общая БД с Row-Level Security (эффективность ресурсов, безопасность).
  • Гибридный подход: небольшие тенанты в общей БД, крупные тенанты в отдельных БД.

Защита между сервисами

Service-to-Service Communication

В микросервисной архитектуре сервисы должны аутентифицироваться и авторизироваться друг перед другом.

mTLS (Mutual TLS)

Взаимная аутентификация между сервисами через сертификаты.

Как работает:

  1. Сервис A имеет приватный ключ и сертификат.
  2. При подключении к сервису B сервис A предоставляет свой сертификат.
  3. Сервис B проверяет подпись сертификата и идентифицирует сервис A.
  4. Сервис B предоставляет свой сертификат, сервис A проверяет его.
  5. Соединение зашифровано.

Управление сертификатами:

  • Lifetime: 6-12 месяцев (требует управления ротацией).
  • Хранилище: /etc/ssl/certs/ или специализированное хранилище.
  • Ротация: автоматическая ротация перед истечением срока.

Реализация в Kubernetes:

Service mesh (Istio, Linkerd) автоматически управляет mTLS между подами.

Преимущества:

  • Аутентификация обеих сторон.
  • Шифрование трафика.
  • Стандартный механизм безопасности.

Недостатки:

  • Управление сертификатами: требует инфраструктуры для выдачи и ротации.
  • Сложность отладки: требуется декодирование трафика.
  • Производительность: криптографические операции добавляют задержку (обычно 1-3 мс).

Service Tokens (JWT для сервис-сервис)

Сервис получает JWT-токен для доступа к другим сервисам.

Процесс:

  1. Сервис A запрашивает токен у сервиса авторизации (или использует встроенный механизм).
  2. Сервис авторизации выдаёт JWT с идентификатором сервиса A.
  3. Сервис A включает токен в запрос к сервису B.
  4. Сервис B проверяет подпись токена и идентифицирует сервис A.

Хранение токенов:

  • В переменных окружения: SERVICE_TOKEN=...
  • В Secret Management системе: HashiCorp Vault, Kubernetes Secrets.

Управление жизненным циклом:

  • Короткий TTL (5-15 минут): требует частого обновления, снижает риск компрометации.
  • Длинный TTL (24+ часа): проще управление, но выше риск.

Сравнение с API ключами:

Параметр Service Token (JWT) API ключ
Информация Содержит контекст (ID сервиса, роль) Только идентификатор
Управление Автоматическое обновление Ручная ротация
Проверка Независимая проверка подписи Требует обращения к хранилищу
Масштабируемость Хорошая (no state) Средняя (требует состояния)

Allowlisting (API разрешений)

Явный список разрешённых вызовов между сервисами.

Пример:

Сервис User может вызывать:

  - Сервис Order: GET /orders/:user_id
  - Сервис Notification: POST /send-email

Сервис Order может вызывать:

  - Сервис User: GET /users/:id
  - Сервис Payment: POST /charge

Реализация:

  • На уровне API Gateway: проверка источника (имя сервиса) и целевого эндпоинта.
  • На уровне сервиса: проверка идентификатора сервиса в токене.

Преимущества:

  • Ясность: какие сервисы могут вызывать друг друга.
  • Безопасность: сервис не может получить доступ к неразрешённым ресурсам.

Недостатки:

  • Управление: требуется обновление списков при добавлении новых эндпоинтов.
  • Масштабируемость: при большом количестве сервисов список становится большим.

Выбор подхода защиты между сервисами

Сценарий Подход Причина
Kubernetes с Service Mesh mTLS Автоматическое управление, встроенный мониторинг
Традиционная инфраструктура mTLS или Service Tokens Контролируемое управление сертификатами
Простая система с несколькими сервисами Service Tokens + Allowlisting Простота реализации
Критичная система mTLS + Allowlisting Многослойная защита

Управление и хранение секретов

Типы секретов

  • Пароли БД: используются для подключения приложения к БД.
  • API ключи: используются для доступа к внешним сервисам (payment gateway, email service).
  • Приватные ключи: используются для подписи данных или шифрования.
  • Сертификаты: используются для mTLS и HTTPS.

Откуда НЕ хранить секреты

В коде или конфигурационных файлах:

# ❌ Неправильно
DATABASE_PASSWORD: "super_secret_password"
API_KEY: "sk-1234567890"

Риск: случайная публикация в Git, просмотр другими разработчиками, утечка при компрометации репозитория.

В переменных окружения (частично):

export DATABASE_PASSWORD="super_secret_password"

Риск: видны при ps aux, сохраняются в истории shell, видны в логах процессов.

На диске в открытом виде:

cat /etc/config/.secret

Риск: видны при компрометации сервера.

Правильное хранение секретов

Secret Management системы

HashiCorp Vault:

Централизованное хранилище секретов с контролем доступа и аудитом.

Процесс:

  1. Приложение аутентифицируется в Vault (например, через Kubernetes ServiceAccount).
  2. Приложение запрашивает секрет: GET /v1/secret/data/database/password
  3. Vault проверяет права и возвращает секрет.
  4. Приложение использует секрет для подключения к БД.

Управление:

Операция Описание
Хранение Секреты хранятся в зашифрованном виде с использованием Shamir's Secret Sharing (требуется 3 из 5 ключей для расшифровки)
Ротация Автоматическое изменение пароля БД или API ключа через определённый промежуток времени
Аудит Логирование всех доступов к секретам
Поиск и замена Поиск использования старого секрета в приложении перед ротацией

Ротация пароля БД:

Vault имеет права на БД PostgreSQL
1. Генерирует новый пароль
2. Обновляет пароль в БД: ALTER USER app_user WITH PASSWORD '...'
3. Возвращает новый пароль приложению
4. Приложение обновляет строку подключения

Расчёт времени жизни секретов:

  • API ключи от внешних сервисов: 30-90 дней (потому что сложно ротировать).
  • Пароли БД: 7-30 дней (можно ротировать автоматически).
  • JWT токены для сервисов: 5-15 минут (часто обновляются).

Стоимость инфраструктуры:

Vault требует высокой доступности: минимум 3 ноды с репликацией. На облаке: ~$500-1000/месяц для small/medium системы.

Kubernetes Secrets

Встроенный механизм Kubernetes для хранения конфиденциальных данных.

Использование:

apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
data:
  username: YXBwX3VzZXI=  # base64
  password: cGFzc3dvcmQxMjM=  # base64
containers:

  - name: app
    env:

      - name: DB_PASSWORD
        valueFrom:
          secretKeyRef:
            name: db-credentials
            key: password

Преимущества:

  • Встроено в Kubernetes.
  • RBAC для контроля доступа.
  • Работает с Service Accounts.

Недостатки:

  • По умолчанию хранятся в etcd БЕЗ шифрования (требуется включить encryption at rest).
  • Ограниченный аудит.
  • Нет встроенной ротации.

Рекомендация: Kubernetes Secrets подходит для базового использования, для критичных систем требуется Vault или аналог.

AWS Secrets Manager, GCP Secret Manager

Облачные сервисы управления секретами.

Преимущества:

  • Встроены в облако.
  • Управление правами через IAM.
  • Автоматическая ротация (для RDS, например).

Недостатки:

  • Привязка к облаку (сложнее миграция).
  • Дополнительная стоимость (~$0.40 за секрет в месяц на AWS).

Выбор:

Система Сценарий
Vault On-premises, гибридное облако, требуется полный контроль
Kubernetes Secrets Простые системы в Kubernetes, низкий уровень критичности
AWS/GCP Secrets Manager Системы в облаке, требуется интеграция с облачными сервисами
Комбинированный подход Критичные системы: Vault для управления, Kubernetes Secrets для распределения

Передача секретов приложению

Через переменные окружения

docker run -e DB_PASSWORD="..." myapp

Риск: видны в docker inspect, логируются при перезагрузке.

Через файлы (tmpfs)

docker run -v /run/secrets:/run/secrets myapp

Секреты хранятся в памяти (tmpfs), недоступны при остановке контейнера.

Через Secret Management API

Приложение подключается к Vault при запуске и получает секреты.

String password = vaultClient.read("secret/database/password");

Наиболее безопасный способ.

Защита от распространённых атак

Injection атаки (SQL, Command, XML)

Злоумышленник вводит вредоносный код в место ввода пользователя.

SQL Injection пример:

Input: ' OR '1'='1
Query: SELECT * FROM users WHERE username = '' OR '1'='1'
Результат: все пользователи

Защита:

  • Подготовленные запросы (Prepared Statements): отделяет код от данных.
PreparedStatement stmt = connection.prepareStatement("SELECT * FROM users WHERE username = ?");
stmt.setString(1, userInput);
  • ORM: автоматическое экранирование (Hibernate, JPA).
  • Input validation: проверка формата и длины ввода.
  • Принцип наименьших привилегий: пользователь БД имеет только SELECT разрешение.

Cross-Site Scripting (XSS)

Злоумышленник вводит JavaScript код, который выполняется в браузере жертвы.

Пример:

<!-- Данные из БД: <img src=x onerror="alert('XSS')"> -->
<div>{{ user_comment }}</div>
<!-- Результат: выполнится alert() -->

Типы:

  • Stored XSS: вредоносный код хранится в БД.
  • Reflected XSS: вредоносный код передаётся в URL.
  • DOM-based XSS: вредоносный код выполняется при манипулировании DOM.

Защита:

  • HTML экранирование: преобразование спецсимволов.
< → &lt;
> → &gt;
" → &quot;
  • Content Security Policy (CSP): указание браузеру, какие источники JavaScript разрешены.
Content-Security-Policy: script-src 'self' https://trusted.com
  • Валидация входных данных: проверка формата и содержимого.

Cross-Site Request Forgery (CSRF)

Злоумышленник заставляет браузер пользователя выполнить запрос от его имени.

Пример:

<!-- Вредоносный сайт attacker.com -->
<img src="https://bank.com/transfer?to=attacker&amount=1000">

Если пользователь авторизован в bank.com, браузер отправит запрос с его cookies.

Защита:

  • CSRF токен: уникальный токен для каждой формы.
<form method="POST" action="/transfer">
  <input type="hidden" name="csrf_token" value="unique_token_123">
  ...
</form>

Браузер не может передать токен при cross-origin запросе.

  • Same-Site Cookie: указание браузеру не отправлять cookies при cross-origin запросах.
Set-Cookie: session_id=...; SameSite=Strict
  • Проверка Referer/Origin: проверка источника запроса на сервере.

Rate Limiting (защита от брутфорса и DDoS)

Ограничение количества запросов от одного источника за определённый период.

Стратегии:

Стратегия Описание Применение
Token Bucket Каждый IP имеет "корзину" токенов, запрос требует 1 токен. Токены добавляются с фиксированной скоростью. Общее rate limiting
Leaky Bucket Запросы добавляются в очередь, обрабатываются с фиксированной скоростью. Защита от всплесков
Sliding Window Отслеживание количества запросов в скользящем окне времени. Точное подсчёты

Реализация:

Redis: ограничение 100 запросов за 60 секунд на IP
SET rate:limit:user:192.168.1.1 100 EX 60
DECR rate:limit:user:192.168.1.1
IF result < 0: отклонить запрос

Расчёт параметров:

  • Нормальный пользователь: 100 запросов/минуту (интерактивное использование).
  • API клиент: 1000 запросов/минуту (программный доступ).
  • Endpoint входа: 5 попыток/5 минут (защита от брутфорса).

Обработка превышения:

  • 429 Too Many Requests: стандартный HTTP код.
  • Retry-After: заголовок с рекомендуемым временем повтора.
HTTP/1.1 429 Too Many Requests
Retry-After: 60

Логирование и аудит

Что логировать

Критичные события:

Событие Информация
Вход/выход user_id, timestamp, IP, user_agent, успех/неудача
Изменение прав who, what (role/permission), when, reason
Доступ к чувствительным данным user_id, resource_id, timestamp, action
Ошибки аутентификации/авторизации username, IP, timestamp, причина
Административные действия admin_id, action, target, timestamp
Изменение конфигурации what, old_value, new_value, timestamp

НЕ логировать:

  • Пароли.
  • Полные номера кредитных карт (только последние 4 цифры).
  • Ключи API в открытом виде.
  • Персональные данные если не требуется.

Хранение логов

Требования:

  • Неизменяемость: логи не должны быть отредактированы после создания.
  • Конфиденциальность: логи содержат чувствительные данные.
  • Доступность: логи должны быть быстро доступны для анализа.
  • Ретенция: хранение согласно регуляторным требованиям (часто 1-7 лет).

Хранилища:

Хранилище Преимущества Недостатки Стоимость
Файлы на диске Просто Масштабирование, отказоустойчивость Низкая
ELK (Elasticsearch) Полнотекстовый поиск, анализ Высокая стоимость хранилища $500-5000/месяц
CloudWatch/Stackdriver Управляемый сервис Привязка к облаку, дорого Зависит от объёма
Архивация в S3/GCS Дешёво для долгосрочного хранения Низкая скорость доступа $0.023/ГБ/месяц на S3

Архитектура:

Приложение → Сборщик логов (Fluentd) → Elasticsearch → Kibana (визуализация)
                                      ↓
                                    S3 (архив)

Расчёт объёма:

  • Типичное приложение: 100 логов/сек = 8.6 млн логов/день.
  • Размер лога: 500 байт.
  • Объём в день: 4.3 ГБ.
  • Месячное хранилище (30 дней): 129 ГБ.
  • Годовое архивирование: 1.5 ТБ.

Затраты:

  • Elasticsearch для 129 ГБ: 10-20 ГБ RAM = $3000-5000/месяц (облако).
  • S3 для архива: 1.5 ТБ × $0.023 = $35/месяц.

Аудит действий пользователей

Отслеживание действий пользователя для обнаружения мошенничества или нарушений.

Пример правила аномалии:

ALERT: Пользователь загружает > 10 ГБ данных за 1 час
ALERT: Пользователь использует 5+ IP-адресов за 10 минут
ALERT: Множественные неудачные попытки входа (> 10) за 5 минут

Реализация:

  • Правила на уровне приложения: проверка паттернов в коде.
  • SIEM (Security Information and Event Management): Splunk, ELK — автоматическое обнаружение паттернов.

Шифрование данных

Шифрование в пути (in transit)

Защита данных при передаче между компонентами.

HTTPS/TLS:

Стандартный протокол для зашифрованной передачи данных по интернету.

Минимальные требования:

  • TLS версия: ≥ 1.2 (TLS 1.3 предпочтительно).
  • Cipher suite: использование сильных алгоритмов (AES-256-GCM).
  • Сертификаты: выданные доверенным CA (Certificate Authority).

Конфигурация Nginx:

server {
    listen 443 ssl http2;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:...';
    ssl_certificate /etc/ssl/certs/cert.pem;
    ssl_certificate_key /etc/ssl/private/key.pem;
}

Стоимость:

  • Let's Encrypt: бесплатно (требует обновления каждые 3 месяца).
  • Коммерческие CA: $50-500/год в зависимости от типа сертификата.

mTLS между сервисами:

То же самое, но с взаимной аутентификацией (обе стороны проверяют сертификаты друг друга).

Шифрование в покое (at rest)

Защита данных, хранящихся на диске.

Уровни шифрования:

Уровень Описание Применение
Диск/Том Шифрование всего диска (LUKS, BitLocker) Основной слой защиты
База данных Встроенное шифрование (Transparent Data Encryption в PostgreSQL) Специфичная защита для БД
Приложение Шифрование отдельных полей перед сохранением Максимальная защита для чувствительных данных

Прозрачное шифрование в PostgreSQL:

-- Шифрование всей БД при создании
CREATE DATABASE secure_db WITH ENCRYPTION 'on';

-- Или после создания (требует перестроения)
ALTER DATABASE secure_db SET password_encryption = 'scram-sha-256';

Шифрование отдельных полей:

CREATE TABLE users (
    id SERIAL,
    name TEXT,
    ssn TEXT ENCRYPTED WITH (ENCRYPTION = 'PGP_ASYM')
);

INSERT INTO users (ssn) VALUES (pgp_pub_encrypt('123-45-6789', dearmor(public_key)));

Управление ключами:

  • Key Derivation Key (KDK): мастер-ключ, используется для расшифровки других ключей.
  • Data Encryption Key (DEK): ключ для расшифровки данных.
  • Ротация: регулярная смена ключей (ежегодно или при компрометации).

Архитектура управления ключами:

KDK (зашифрован в HSM) → DEK (зашифрован KDK) → Данные (зашифрованы DEK)

HSM (Hardware Security Module) — специализированное устройство для безопасного хранения ключей.

Стоимость:

  • Программное шифрование: встроено в СУБД, бесплатно.
  • HSM: $5000-50000 (физическое устройство).
  • Облачное управление ключами (AWS KMS): ~$1/ключ/месяц.

Выбор подхода шифрования

Сценарий Подход Причина
Публичный API HTTPS (TLS 1.2+) Обязательно для всего интернета
Микросервисы в Kubernetes mTLS через Service Mesh Автоматизация
Критичные данные (карты, SSN) Приложение + шифрование на уровне БД Несколько слоёв защиты
Данные на диске Прозрачное шифрование БД Баланс между простотой и безопасностью
Очень критичные данные Шифрование на уровне приложения + управление ключами Полный контроль

Масштабирование системы безопасности

Трёхслойная архитектура

┌─────────────────────────────────────────┐
│       Client (Browser, Mobile)          │
└─────────────────┬───────────────────────┘
                  │ HTTPS/mTLS
┌─────────────────▼───────────────────────┐
│         API Gateway                     │
│ - Rate Limiting                         │
│ - JWT проверка                          │
│ - Маршрутизация                         │
└─────────────────┬───────────────────────┘
                  │ mTLS
┌─────────────────▼───────────────────────────────────────┐
│         Микросервисы                                    │
│ - Аутентификация (от gateway)                           │
│ - Авторизация (RBAC/ABAC)                              │
│ - Логирование операций                                 │
│ - Валидация входных данных                             │
└─────────────────┬───────────────────────────────────────┘
                  │
┌─────────────────▼───────────────────────┐
│    База данных                          │
│ - Row-Level Security (многотенантность) │
│ - Шифрование чувствительных полей      │
│ - Логирование доступа                   │
└─────────────────────────────────────────┘

Расчёт нагрузки на компоненты безопасности

JWT проверка на API Gateway:

  • Операция: проверка подписи JWT (RSA-2048).
  • Стоимость: ~5 мс на операцию.
  • Пропускная способность: 200 запросов/сек на одном процессорном ядре.
  • Для 10,000 запросов/сек требуется: 10000 / 200 = 50 процессорных ядер.

Rate Limiting с Redis:

  • Операция: DECR + TTL проверка.
  • Стоимость: ~1 мс.
  • Пропускная способность Redis: 10,000+ операций/сек на одном ядре.
  • Для 10,000 запросов/сек требуется: 1 инстанс Redis.

Row-Level Security в PostgreSQL:

  • Дополнительная задержка: 5-15% на каждый запрос (зависит от индексирования).
  • Для 1000 запросов/сек к БД: дополнительно 50-150 мс задержки.

Рекомендуемая архитектура:

Компонент Конфигурация Стоимость
API Gateway 4 ноды × 8 ядер = 32 ядра $500/месяц (облако)
Redis 2 ноды с репликацией (32 ГБ RAM) $200/месяц
БД 8 ядер + 64 ГБ RAM $1000/месяц
Vault 3 ноды $500/месяц
ELK для логов Elasticsearch (10 ГБ RAM) $1000/месяц
Итого $3200/месяц

Для системы с 10,000 рпс и 1 млн активных пользователей.

Сравнительная таблица подходов безопасности

Параметр Маленькая система Средняя система Крупная система
Аутентификация Логин/пароль + сессии OAuth 2.0 + JWT OAuth 2.0 + JWT + MFA
Авторизация RBAC RBAC + ABAC ABAC + микросегментация
Многотенантность Общая БД Общая БД + RLS Гибридный подход (отдельные БД для крупных)
Service-to-Service API ключи Service Tokens mTLS + Allowlisting
Управление секретами Переменные окружения Kubernetes Secrets Vault
Шифрование TLS для API TLS + шифрование полей TLS + at-rest + HSM
Логирование Файлы на диске ELK SIEM (Splunk) + архивирование
Rate Limiting Простая память Redis Распределённое с clustering
Инфраструктура 1-2 сервера 5-10 серверов 50+ серверов / облако
Затраты $500-1000/месяц $3000-5000/месяц $10000-50000/месяц

Стандарты и регуляция

OWASP Top 10

10 наиболее критичных уязвимостей веб-приложений (обновляется каждые 4 года).

2021 версия:

  1. Broken Access Control
  2. Cryptographic Failures
  3. Injection
  4. Insecure Design
  5. Security Misconfiguration
  6. Vulnerable and Outdated Components
  7. Authentication Failures
  8. Software and Data Integrity Failures
  9. Logging and Monitoring Failures
  10. Server-Side Request Forgery (SSRF)

PCI DSS (Payment Card Industry Data Security Standard)

Требования для систем, работающих с данными кредитных карт.

Основные требования:

  • Шифрование данных в пути и в покое.
  • Контроль доступа (аутентификация и авторизация).
  • Регулярное сканирование уязвимостей и тестирование на проникновение.
  • Ведение журналов аудита.

GDPR (General Data Protection Regulation)

Регуляция защиты данных в ЕС.

Основные требования:

  • Согласие на обработку данных.
  • Право на удаление (Right to be forgotten).
  • Уведомление об утечках данных в течение 72 часов.
  • Оценка влияния (Data Protection Impact Assessment) для рискованных обработок.

HIPAA (Health Insurance Portability and Accountability Act)

Требования для систем, работающих с медицинскими данными в США.

Основные требования:

  • Шифрование PHI (Protected Health Information).
  • Контроль доступа.
  • Аудит доступа.
  • Управление ключами.

Практические рекомендации

Для стартапа (< 10000 пользователей)

  1. Аутентификация: Логин/пароль + сессии на Redis.
  2. Авторизация: RBAC (несколько ролей: admin, user, guest).
  3. Защита: HTTPS, CSRF токены, rate limiting.
  4. Управление: Пароли БД в Kubernetes Secrets или переменных окружения.
  5. Логирование: Файлы на диске, базовый мониторинг.
  6. Стоимость: $500-1000/месяц.

Для растущей системы (10000-1000000 пользователей)

  1. Аутентификация: OAuth 2.0 + JWT (если микросервисы).
  2. Авторизация: RBAC + ABAC (для сложных правил).
  3. Многотенантность: Общая БД с Row-Level Security.
  4. Service-to-Service: Service Tokens или mTLS.
  5. Управление: Vault для управления секретами.
  6. Защита: Все из категории стартапа + DDoS protection.
  7. Логирование: ELK для централизованного логирования.
  8. Стоимость: $5000-15000/месяц.

Для критичной системы (> 1000000 пользователей, финансовые данные)

  1. Аутентификация: OAuth 2.0 + JWT + MFA (двойная аутентификация).
  2. Авторизация: ABAC с микросегментацией.
  3. Многотенантность: Гибридный подход (крупные клиенты — отдельные БД).
  4. Service-to-Service: mTLS + Allowlisting + Service Tokens.
  5. Управление: Vault + HSM для критичных ключей.
  6. Защита: Все предыдущее + WAF, DDoS mitigation, SIEM.
  7. Логирование: SIEM (Splunk), долгосрочное архивирование.
  8. Тестирование: Регулярное тестирование на проникновение.
  9. Соответствие: PCI DSS, GDPR, HIPAA (в зависимости от типа).
  10. Стоимость: $50000-500000/месяц.

System Design: Java/Spring practice

Роль Java/Spring-практики в System Design

На собеседовании по system design важно не просто нарисовать компоненты — требуется связать архитектурные решения с поведением конкретного стека. Senior Java Backend-инженер отличается тем, что понимает, как выбор технологии влияет на надёжность, пропускную способность и задержку системы.

Интервьюер ожидает услышать не «это аннотация @Async» или «это настройка в properties», а объяснение того, как поведение Java runtime влияет на решение задачи. Например, если говорим о высоконагруженной системе, нужно не просто сказать «возьмём асинхронную обработку», а объяснить, почему потоки — ограниченный ресурс, что происходит при их исчерпании, и как это связано с общим дизайном.

На system design обычно всплывают вопросы:

  • Как модель обработки запросов (блокирующая vs неблокирующая) влияет на предельную нагрузку?
  • Почему connection pool'ы должны быть спроектированы отдельно для разных зависимостей?
  • Как GC-паузы могут превратить стабильную систему в мертвую?
  • Какие таймауты нужно расставить везде, кроме как везде?
  • Почему ORM без контроля над запросами приводит к коллапсу БД?

Senior Java-инженер говорит про runtime-поведение, про limits и backpressure, про observability, которая позволяет увидеть, что идёт не так. Это не про синтаксис языка, а про управление ресурсами.

Модель исполнения запросов в Java-сервисе

Потоки и пул потоков

В классической блокирующей модели, которая по умолчанию используется в типичных Spring Boot приложениях, каждый входящий HTTP-запрос обрабатывается отдельным потоком из пула потоков веб-сервера (обычно это Tomcat). Один запрос — один поток, до конца обработки.

Пул потоков имеет конечный размер. Если пришло 100 запросов, а в пуле только 50 потоков, то следующие 50 запросов встанут в очередь и будут ждать, когда поток освободится. Это принципиальное ограничение: нельзя же создать по потоку на каждый запрос — потоки стоят дорого (память, контекст-переключения ОС).

На практике типичный размер пула потоков — от 50 до 200 потоков, в зависимости от профиля нагрузки. Если все потоки заняты и заблокированы (например, ждут ответа от БД), система не может обработать новые запросы, даже если CPU свободен.

Это имеет прямое влияние на throughput и latency:

  • Если пул слишком маленький (10 потоков), при нормальной нагрузке много запросов встанут в очередь, latency вырастет.
  • Если пул слишком большой (1000 потоков), произойдёт много контекст-переключений ОС, кеши процессора испортятся, latency снова вырастет. Плюс потребление памяти будет огромным.
  • Оптимальный размер зависит от того, как часто потоки блокируются. Если запросы в основном CPU-bound (вычисления), пул должен быть примерно размером количества ядер CPU. Если IO-bound (ожидание БД, сети), пул может быть больше, потому что пока один поток ждит IO, другие могут работать.

На собеседовании важно сказать: «В нашей системе потоки — ограниченный ресурс. Мы контролируем размер пула в зависимости от профиля запросов. Если видим, что очередь растёт, это сигнал либо на масштабирование сервиса, либо на оптимизацию кода».

Блокирующий vs неблокирующий IO

Классическая сервлет-модель (которая используется в Spring MVC) — это блокирующая модель. Когда поток делает вызов к БД, он блокируется и ждёт ответа. В это время не может ничего другого делать.

Неблокирующая (реактивная, асинхронная) модель основана на event loop'е. Один поток обрабатывает много запросов одновременно, не блокируясь. Когда данные не готовы, контроль передаётся другому запросу. Когда данные приходят, обработка этого запроса продолжается. Это даёт гораздо большую эффективность потоков.

На практике выбор между блокирующей и неблокирующей моделью — это компромисс:

Блокирующая модель подходит, когда:

  • Нагрузка умеренная (до нескольких тысяч одновременных соединений).
  • IO-операции быстрые (БД на локальной сети, latency < 10мс).
  • Команда привычна к синхронному коду (проще для отладки, тестирования).
  • Не требуется обрабатывать массу очень медленных клиентов.

Неблокирующая модель имеет смысл, когда:

  • Надо обрабатывать десятки тысяч одновременных соединений.
  • Много медленного IO (внешние API, которые отвечают с задержкой, WebSocket'ы).
  • CPU-bound операции минимальны, основное время уходит на IO.

На system design ответе про Java-backend обычно достаточно упомянуть: «Для этой нагрузки используем классическую блокирующую модель с пулом потоков размером X, потому что IO-операции в основном локальные и быстрые. Если бы требовалось обрабатывать много одновременных медленных соединений, рассмотрели бы реактивный стек, но это усложнит архитектуру». На вопрос про event loop можно добавить: «Event loop позволяет одному потоку обрабатывать много соединений, но требует полностью асинхронного кода, включая все библиотеки. Это не подходит, если у нас есть синхронные зависимости (стандартный JDBC, синхронные HTTP-клиенты)».

Thread pools и ограничение параллелизма

Основные типы пулов в Java-приложениях

В Java-сервисе обычно работает сразу несколько пулов потоков, каждый с собственной целью:

Пул запросов веб-сервера обрабатывает входящие HTTP-запросы. Это главный пул, его размер мы только что обсуждали. Для Tomcat типичная конфигурация — 200 потоков.

Общие пулы задач (ExecutorService'ы) используются для асинхронных задач: обработка очередей, отправка email, генерация отчётов. Они отдельны от пула веб-сервера, чтобы длинные задачи не блокировали обработку HTTP-запросов.

Отдельные пулы под блокирующие операции — это bulkhead-паттерн. Если у вас есть синхронный HTTP-клиент к внешнему API или JDBC к БД, и этот вызов может зависнуть, выделяют отдельный пул потоков. Все потоки из этого пула могут зависнуть, но остальные потоки продолжат обрабатывать запросы.

Принципы настройки пулов

Если пул слишком маленький:

  • Задачи встают в очередь, выполняются долго.
  • Если задачи критичные для ответа клиенту, latency растёт.
  • Если задачи долгие и независимые (background jobs), можно пережить, но пиковые нагрузки будут обработаны медленно.

Если пул слишком большой:

  • Много потоков потребляет память (каждый поток — это стек ~1МБ, плюс JVM overhead).
  • Если все потоки попытаются одновременно обратиться к БД (например), то БД может не выдержать (даже если в коде есть пул соединений).
  • Много контекст-переключений, CPU впустую тратит время на управление потоками.

Связь с профилем нагрузки:

  • CPU-bound (вычисления, обработка данных в памяти): пул ≈ количество CPU ядер. Зачем больше потоков, если они будут конкурировать за один CPU?
  • IO-bound (запросы к БД, вызовы API, диск): пул может быть в N раз больше количества ядер. В то время как один поток ждит IO, другой может работать на CPU. Типичное правило: пул = ядра × (1 + IO_ожидание / CPU_время).

На собеседовании формулировка: «Размер пула потоков выбираем исходя из профиля запросов. Если это в основном быстрые вычисления и локальная БД, пул размером ядра × 1.5. Если много внешних API-вызовов, пул может быть больше. Мониторим queue length — если растёт, это сигнал к масштабированию или оптимизации».

Булкхед: изоляция пулов

Представьте: у вас есть основная БД (быстрая), и есть медленный внешний API. Если в основном пуле потоков все 200 потоков встанут на вызов к внешнему API и зависнут, то ни один новый запрос не сможет обработаться — даже если это простой SELECT из локальной БД.

Булкхед-паттерн решает это: создаём отдельный пул потоков (скажем, 20) специально для вызовов к медленному API. Все потоки из этого пула могут зависнуть, но основной пул останется работать.

На уровне implementation это может выглядеть как создание отдельного ExecutorService для каждого потенциально опасного downstream'a:

ExecutorService slowApiPool = Executors.newFixedThreadPool(20);
// ...
Future<Response> result = slowApiPool.submit(() -> callSlowAPI());

Или через аннотации (Spring Async с выделенным пулом):

@Async("slowApiExecutor")
public void callSlowAPI() { ... }

Это не просто оптимизация — это архитектурная необходимость для high-availability систем. Отказ одной зависимости не должен убить весь сервис.

На system design упоминаем: «Критичные зависимости изолируют своими пулами потоков. Если внешний API зависнет, это не повлияет на основную функциональность сервиса».

Connection pool'ы: БД, HTTP, messaging

Пул соединений к БД

Каждое соединение к БД имеет значительную стоимость: аутентификация, инициализация сессии, выделение памяти. Нельзя открывать новое соединение на каждый SQL-запрос.

Connection pool (обычно HikariCP в Spring Boot) держит готовые к использованию соединения. Когда приложению нужно выполнить запрос, оно берёт соединение из пула, использует и возвращает. Соединение остаётся живым для следующего использования.

Размер пула должен быть оптимален:

  • Слишком маленький пул (5 соединений): при нагрузке приложение будет ждать, когда освободится соединение. Latency возрастает.
  • Слишком большой пул (200 соединений): в БД есть собственный лимит на количество соединений. Если приложение откроет 200 соединений, а других приложений будет 5, БД может не выдержать. Плюс каждое соединение потребляет память у БД.

Типичный размер пула — 10-20 для небольших сервисов, 20-40 для средних, зависит от нагрузки и требований к latency.

На практике возникает проблема, когда разработчики создают пул размером 100 «про запас». В результате:

  1. Если несколько приложений работают с одной БД, они быстро исчерпают лимит соединений на БД.
  2. В момент пика нагрузки все соединения заняты, система замирает.
  3. Мониторы не видят проблем (CPU и память сервера приложения в норме), а система не отвечает.

На system design: «Пул соединений к БД рассчитываем исходя из нагрузки. Если среднее количество одновременных запросов к БД — 10, пул 15-20. Мониторим использование пула (active connections), если приближается к лимиту, это сигнал к оптимизации или к добавлению реплик БД».

Пул HTTP-соединений

При вызове внешнего сервиса через HTTP нужно установить TCP-соединение. Это медленно: нужна сетевая задержка туда-обратно, TLS handshake. Если на каждый запрос открывать новое соединение, это будет очень неэффективно.

HTTP-клиент (например, использующий Apache HttpClient или Java HttpClient) держит пул соединений и переиспользует их. На одном соединении можно отправить много запросов (HTTP Keep-Alive).

Для каждого внешнего сервиса нужно подумать:

  • Сколько одновременных запросов будет?
  • Какой максимальный размер пула соединений?
  • Есть ли лимит на уровне сервера (reverse proxy может ограничить connections per IP)?

Типичная конфигурация: 20-50 соединений на downstream-сервис, в зависимости от нагрузки.

Таймауты для соединений

Connection timeout — время, в течение которого ждём установления соединения. Read timeout — время, в течение которого ждём ответа после отправки запроса. Write timeout (если поддерживается) — время на отправку данных.

Если таймауты не установлены или слишком большие:

  • Если сервер БД или API недоступен, приложение будет зависать на неопределённое время.
  • Потоки будут заблокированы, пул истощится.
  • Новые запросы встанут в очередь, latency растёт.
  • В итоге весь сервис станет неотзывчивым.

Типичные значения:

  • Connect timeout: 2-5 секунд.
  • Read timeout для локальной БД: 5-30 секунд (зависит от сложности запросов).
  • Read timeout для внешнего API: 10-60 секунд (может быть медленнее).

На собеседовании: «На каждый external call (БД, HTTP, очередь) ставим таймауты. Connect timeout 5 сек, read timeout зависит от SLA downstream-сервиса. Если тайм-аут истекает, запрос падает с ошибкой, поток освобождается, система не зависает».

Память и GC в контексте System Design

Куча, поколения и их роль в производительности

В Java памяти приложение выделяется heap (куча). На куче живут все объекты. JVM периодически запускает garbage collector (сборщик мусора), который ищет неиспользуемые объекты и освобождает память.

Современные GC'ы (например, G1GC, которая по умолчанию в Java 9+) делят кучу на поколения: молодое (young generation) и старое (old generation). Идея: большинство объектов живут недолго (создаются, используются в одном запросе, удаляются). Их сборка должна быть частой, но быстрой.

Когда молодое поколение переполняется, запускается Minor GC — быстрая сборка молодого поколения. Это может вызвать кратковременную паузу (от миллисекунд до десятков миллисекунд).

Когда старое поколение переполняется, запускается Major GC — медленная сборка. Это может вызвать паузу на несколько секунд.

GC-паузы и latency

На собеседовании по system design про GC спрашивают редко как про инструмент, но часто как про источник latency'я. Представьте: у вас SLA «ответ за 100мс, p99 < 200мс», и вдруг на p99 начинают выскакивать паузы на 500мс. Это GC.

Если размер heap слишком маленький, GC запускается часто, и даже малые паузы суммируются.

Если heap слишком большой, Major GC редкие, но когда они случаются, паузы огромные (может быть несколько секунд). В это время весь сервис не отвечает на новые запросы (stop-the-world).

Профиль аллокаций тоже важен. Если на горячих путях (обрабатываемых часто) создаётся много временных объектов, это увеличивает GC-давление.

На практике на production обычно настраивают:

  • Heap размером, при котором old generation не переполняется под нормальной нагрузкой.
  • Мониторят процент времени, потраченный на GC — если больше 10-15%, это хороший сигнал к оптимизации.
  • Логируют GC-события (какие паузы, как часто) для анализа.

Стратегии избежания GC-проблем на уровне дизайна

Избежание избыточных аллокаций на горячих путях: если в обработчике каждого запроса создаётся список/строка/дата, это умножается на RPS. Сотни тысяч объектов в секунду — огромное GC-давление. Вместо этого переиспользуют пулы объектов (object pools) или переструктурируют код.

Кеширование с осторожностью: большой в-памяти кеш (скажем, миллионы записей) может стать источником постоянного GC-давления. Если кеш растёт, это заполняет heap, запускается GC. Лучше ограничить размер кеша (например, LRU с максимальным количеством элементов) или использовать off-heap кеш (Redis).

Лимиты на размер объектов: если объекты, передаваемые между сервисами (в JSON/protobuf), имеют поля «на всякий случай», это увеличивает сериализацию и потребление памяти. Стоит быть прижимистыми с полями.

Мониторинг GC и связь с поведением системы

Типичные симптомы GC-проблем:

  • Stop-the-world пауза: latency скачок на несколько сотен миллисекунд или секунд. Обычно это Major GC.
  • Частые minor GC: логи показывают, что каждую секунду запускается Minor GC. Это может указывать на то, что молодое поколение мало, или на избыток аллокаций.
  • Heap memory растёт: если heap использование растёт, но не падает после GC, это может быть утечка памяти или недостаточный размер heap'а.

На system design про GC можно сказать: «Мониторим GC-события: частоту и длительность пауз. Если видим длинные stop-the-world паузы, это напрямую влияет на p95/p99 latency. Может потребоваться увеличение heap'а, оптимизация аллокаций или переход на более совершенный GC (например, ZGC с низкими паузами)».

Конфигурация таймаутов, ретраев и circuit breaker'ов

Таймауты везде

На собеседовании это один из самых важных пунктов для Senior-инженера. Таймауты — это не опция, это обязательность.

На каждый external call нужны таймауты:

  • Вызов к БД: connect timeout 5сек, read timeout 30сек.
  • HTTP к внешнему API: connect timeout 5сек, read timeout 60сек (может быть медленнее).
  • Запрос в очередь (получение сообщения): зависит от очереди, но обычно есть.

Без таймаутов: если downstream-сервис зависнет, приложение тоже зависнет на неопределённое время.

С таймаутами: если ответа нет за 30 сек, бросаем исключение, поток освобождается, система остаётся отзывчивой.

На практике таймауты настраивают отдельно для каждого downstream'a, потому что они имеют разные SLA. Критичная локальная БД может быть быстрой (5-10 сек разумно). Внешний платёжный API может быть медленнее (60 сек или даже больше).

Формулировка на собеседовании: «На все external calls устанавливаем таймауты, специфичные для каждой зависимости. Для БД — несколько десятков секунд, для внешних API — может быть минута или больше, в зависимости от их SLA. Это гарантирует, что сбой одной зависимости не убивает весь сервис».

Ретраи и backoff

Временные сбои случаются: сеть пакеты потеряет, сервер на миг перегрузится. Ретраи помогают: если запрос не прошёл, повторяем несколько раз.

Но ретраи нужно делать осторожно:

Число попыток: обычно 2-3 для идемпотентных операций (GET, PUT с пустой body). Для неидемпотентных (POST) — 1 (без ретраев) или нужна гарантия идемпотентности.

Backoff: если просто повторять сразу, при массовом сбое получим «шторм ретраев» — каждый клиент повторяет, нагрузка на сервер не падает, всё ещё медленнее. Вместо этого делают экспоненциальный backoff: первый ретрай через 100мс, второй через 200мс, третий через 400мс. Это даёт серверу время восстановиться.

Типичная конфигурация:

Попытка 1: сразу
Попытка 2: ждём 100мс + random jitter
Попытка 3: ждём 200мс + random jitter

На собеседовании: «Для идемпотентных операций (GET) ретраим 2-3 раза с экспоненциальным backoff. Для неидемпотентных (POST) требуется либо идемпотентный ключ (idempotency key в header), либо мы не ретраим. Это защищает от штормов ретраев при массовых сбоях».

Circuit breaker: выключатель вокруг зависимостей

Представьте: внешний API сломался, начинает возвращать 500-ки. Если мы будем продолжать слать туда ретраи (даже с backoff), мы:

  1. Тратим ресурсы на безполезные запросы.
  2. Замедляем обработку собственных запросов (ждём таймаута).
  3. Увеличиваем нагрузку на сломанный API.

Circuit breaker решает это: отслеживаем количество ошибок. Когда ошибок слишком много (скажем, 50% последних 100 запросов), переходим в состояние Open: все следующие запросы к этому сервису сразу падают с ошибкой, без попыток. Это даёт сломанному API время восстановиться. Через некоторое время (скажем, 30 сек) переходим в Half-Open: пробуем один запрос. Если успешно, возвращаемся в Closed (нормальный режим). Если опять ошибка, возвращаемся в Open.

На практике circuit breaker'ы часто встроены в специализированные library'и (Hystrix, Resilience4j и т.д.), но идея та же: не заливать мертвый сервис запросами.

На system design: «Для критичных зависимостей используем circuit breaker'ы. При большом числе ошибок автоматически отключаем запросы, даём зависимости время восстановиться. Это защищает как мас систему, так и downstream-сервис от перегруза».

Сериализация и форматы данных

JSON vs бинарные форматы

При передаче данных между сервисами нужно выбрать формат. JSON — текстовый, читаемый, универсальный. Бинарные форматы (protobuf, Avro, MessagePack) — компактнее и быстрее, но менее читаемы.

На уровне System Design это влияет на:

Размер передачи: бинарные форматы занимают меньше места. Если передаёшь миллионы записей в день, разница в размере напрямую влияет на пропускную способность сети.

Latency сериализации/десериализации: бинарные форматы быстрее. Если сервис обрабатывает 10K RPS и каждый запрос требует сериализации, то разница между 1мс (JSON) и 0.1мс (protobuf) суммируется в значительный оверхед.

Человеческая читаемость: JSON легче отлаживать (просто открыл логи в браузере). Бинарные форматы требуют специальных инструментов.

На практике выбирают так:

  • Внешние API (когда работаешь с клиентами): JSON (стандарт de facto, клиентам легче).
  • Внутренние микросервисы: может быть protobuf или другой быстрый формат (если требуется высокая производительность).
  • Real-time streaming (события в очередь): может быть компактный формат типа Avro.

На собеседовании: «Для этой задачи используем JSON (стандарт, все понимают), но если требуется передавать большой объём данных (млн записей/день), рассмотрели бы более компактный формат вроде protobuf. Trade-off между простотой и эффективностью».

Стоимость де/сериализации в Java

В Java сериализация/десериализация — это вполне дорогая операция:

  • Парсинг JSON-строки в Java-объект требует выделения памяти (аллокации), что увеличивает GC-давление.
  • Сложные DTO с вложенными объектами требуют много временных объектов.
  • CPU потребляет цикл на обход всех полей.

На горячих путях (обрабатываемых часто) это суммируется. Если сервис обрабатывает 10K RPS и каждый запрос требует десериализации большого JSON, то 10% CPU может уйти только на это.

Типичные ошибки:

  • «Толстые DTO»: если создаёшь DTO со всеми возможными полями, даже если клиенту нужны только 3 из 30 полей, ты тратишь CPU и память на парсинг лишних 27 полей.
  • Вложенные структуры: каждый вложенный уровень требует создания отдельного объекта.
  • Много little objects: если JSON содержит array из 1000 объектов, создаются 1000 Java-объектов, пусть даже маленьких. Это нагрузка на GC.

Стратегии оптимизации:

  • Использовать selectively parsed fields (десериализовать только нужные поля).
  • Кешировать десериализованные объекты, если они переиспользуются.
  • Использовать streaming deserialization для больших data set'ов, чтобы не держать всё в памяти одновременно.

На system design: «Контролируем размер передачи данных. Десериализация дорогая на горячих путях. Передаём только нужные поля, используем streaming если объём велик».

Версионирование и backward compatibility

Когда система растёт, схемы меняются. Могли добавить новое поле в DTO или переименовать существующее. Если это сломает старых клиентов — проблема.

Расширяемые схемы позволяют добавлять новые поля без ломания: старый клиент просто игнорирует неизвестные поля, новый клиент может их обработать.

Backward-compatible изменения:

  • Добавление нового опционального поля (с default значением).
  • Удаление неиспользуемого поля (если клиенты не проверяют его наличие).

Non-backward-compatible:

  • Удаление обязательного поля.
  • Изменение типа поля.
  • Переименование поля без alias'а.

На практике при микросервисной архитектуре может быть ситуация, когда старые инстансы сервиса A взаимодействуют с новыми инстансами сервиса B. Если схема поломана, система начинает отправлять ошибки.

На собеседовании: «Версионируем данные в соответствии с API versioning strategy. Новые поля добавляем как опциональные. Критичные изменения требуют нового API endpoint'а или версии. Это позволяет раскатывать изменения без downtime'а».

Работа с базой данных на уровне Java-кода

ORM vs прямой доступ к БД

ORM (Object-Relational Mapping) библиотеки (например, Hibernate) автоматически маппируют Java-объекты на таблицы БД. Плюсы:

  • Быстрая разработка: не нужно писать SQL вручную.
  • Единый слой маппинга: легче менять структуру БД.
  • Автоматический кеш сущностей (first-level cache).

Минусы:

  • Скрытые запросы: ORM может отправить запросы, которых ты не ожидаешь (N+1 problem).
  • Неоптимальные join'ы: ORM может сгенерировать неэффективный SQL.
  • Сложность дебага: когда производительность падает, не всегда понятно, почему.

Прямой доступ (native SQL, JDBC) — ты сам пишешь SQL. Минусы:

  • Больше кода, медленнее разработка.
  • Нужно самому маппировать результаты на объекты.

Плюсы:

  • Полный контроль над запросами.
  • Понятно, что отправляется в БД.
  • Легче оптимизировать.

На практике обычно используется гибридный подход: ORM для простых CRUD операций, native SQL для сложных отчётов и аналитики.

Типичные ошибки: N+1, полная загрузка, отсутствие лимитов

N+1 problem: при загрузке списка User'ов ORM загружает их в один запрос. Но если в коде обращаешься к user.getOrders(), ORM выполняет отдельный запрос на каждого User'а (1 запрос на каждого из N User'ов). В результате вместо 2 запросов получается 1 + N запросов.

Решение: явно указать fetch-strategy (загрузить Orders с Users в одном join'е).

Загрузка огромного графа сущностей: если загружаешь User и через него Posts, Comments, Likes, и каждый Comment имеет Author, Reply, и т.д. — ORM может загрузить половину БД.

Решение: загружать только нужные отношения, ограничивать глубину.

Отсутствие лимитов и пагинации: если в коде написано session.createQuery("from User").list() без LIMIT, и в БД 10 млн User'ов, то одна строка кода займёт гигабайты памяти и загрузит половину БД.

Решение: всегда использовать LIMIT и OFFSET (пагинация). Максимальный размер — 1000 записей за раз.

Оптимизации на уровне сервиса

Селективные поля: вместо SELECT * FROM user, SELECT id, name, email FROM user. Не загружаем тяжёлые поля (например, description с текстом в килобайты).

Отдельные read-модели: если требуется много разнообразных представлений данных (user со всеми друзьями, user с последними постами, user со статистикой), лучше иметь отдельные таблицы для каждого случая или денормализованные представления. Это быстрее, чем один сложный join'е.

Явная пагинация и лимиты: при любом чтении — LIMIT 100 OFFSET 0. Это не просто оптимизация, это архитектурная необходимость.

На system design: «Контролируем нагрузку на БД из кода. Используем ORM для простых операций, но понимаем, какие запросы он генерирует. На горячих путях переходим на native SQL. Обязательно пагинация и лимиты на все чтения. Это предотвращает ситуации, когда один запрос загружает половину БД».

Интеграция с очередями и стримингом в Java-сервисах

Продюсеры и консьюмеры

Очередь сообщений (RabbitMQ, Kafka, AWS SQS) позволяет асинхронной обработке: один сервис отправляет сообщение, другой обрабатывает асинхронно.

Продюсер (publisher) отправляет сообщение в очередь. Это быстро — отправил, забыл.

Консьюмер (subscriber) читает сообщения из очереди и обрабатывает. В Java это обычно отдельные потоки (рабочие) или отдельное приложение, которое только консьюмит.

На уровне архитектуры:

  • Консьюмер работает в отдельном пуле потоков (как мы говорили про булкхеды).
  • Можно иметь несколько консьюмеров на одного продюсера (параллельная обработка).
  • Скорость обработки сообщений может быть меньше, чем скорость их прихода, — сообщения встают в очередь.

Batch-размеры и prefetch: при чтении из очереди консьюмер обычно не берёт по одному сообщению, а берёт batch (скажем, 100 сообщений). Это эффективнее. Prefetch-параметр контролирует, сколько сообщений консьюмер держит локально до их обработки — это trade-off между latency'ем обработки и нагрузкой на очередь.

Идемпотентные обработчики

Если потребитель упадёт после обработки сообщения, но до отправки acknowledgment'а, очередь переотправит сообщение. Или по сетевой ошибке сообщение может быть отправлено дважды.

Если обработчик не идемпотентен (не защищён от дублей), сообщение обработается дважды: например, деньги снимутся с счёта дважды.

Идемпотентный обработчик гарантирует, что обработка одного сообщения несколько раз имеет тот же эффект, что и обработка один раз. На практике это делают через таблицу в БД:

IF NOT EXISTS (SELECT FROM processed_messages WHERE message_id = ?)
  THEN обработать сообщение, INSERT message_id

Аккуратность с транзакциями: если обработка и вставка in separate transactions, может быть race condition (два потока одновременно обрабатывают одно сообщение). Лучше в одной transaction'е.

Backpressure: ограничение параллелизма

Если продюсер отправляет сообщения быстро, а консьюмер не успевает, очередь растёт. Если очередь растёт бесконечно, израсходуется память на сервере очереди.

Backpressure — это механизм замедления. Когда очередь растёт (слишком много необработанных сообщений), потребитель паузирует обработку (не берёт новые сообщения), позволяя продюсеру понять, что нужно замедлиться.

На Java-уровне это часто реализуется ограничением на число одновременных обрабатываемых сообщений (скажем, не более 100 одновременно). Когда достигается лимит, консьюмер не берёт новые сообщения из очереди.

На system design: «При интеграции с очередями используем отдельный пул потоков для обработки сообщений. Консьюмеры идемпотентны (дублирование сообщений не вредит). Ограничиваем параллелизм, чтобы не перегружать БД или downstream-сервисы».

Конфигурация, профили окружений и feature-flags

Профили окружений

В разработке нужны разные конфигурации для разных окружений:

  • Dev: быстрые таймауты, маленькие пулы, verbose логирование.
  • Stage: близко к prod, но с тестовыми данными.
  • Prod: большие пулы, консервативные таймауты, минимальное логирование.

При запуске приложения передаётся параметр профиля: spring.profiles.active=prod.

В коде конфигурация отличается:

  • Размер пула потоков: dev=10, prod=100.
  • Таймауты: dev=60сек, prod=30сек.
  • Endpoint для внешнего API: dev=localhost:8080/mock, prod=api.production.com.
  • Уровень логирования: dev=DEBUG, prod=INFO.

Безопасное управление конфигурацией

Секреты (пароли БД, API-ключи) не должны быть в коде. Обычно:

  • В коде хардкодятся значения по умолчанию или плейсхолдеры.
  • При запуске на prod переопределяются через переменные окружения или конфиг-серверы.

Типичный подход:

spring.datasource.password=${DB_PASSWORD}

При запуске: docker run -e DB_PASSWORD=secret123 app:latest.

Или через конфиг-сервер (Spring Cloud Config, Consul):

  • Приложение при старте обращается к конфиг-серверу, получает всю конфигурацию.
  • Секреты хранятся в конфиг-сервере, зашифрованы.

Feature-flags

Feature-flag позволяет включать/выключать фичи без релиза новой версии приложения. На практике:

if (featureFlags.isEnabled("new_checkout_flow")) {
  // новая логика
} else {
  // старая логика
}

Feature-flags также позволяют A/B тестирование: новую фичу включают для 10% трафика, смотрят метрики, если хорошо — для 50%, потом для 100%.

На практике feature-flags хранятся в БД или специальном сервисе (например, LaunchDarkly). При каждом запросе приложение проверяет флаг (с кешированием, чтобы не бить БД).

На system design это важно: фичи раскатываются постепенно, можно быстро откатить проблемную фичу без нового деплоя.

На собеседовании: «Используем feature-flags для безопасной раскатки новых фич. Даже если запушили bug, можно выключить флаг и откатить без нового релиза. Это критично для high-availability систем».

HTTP-клиенты и интеграции с внешними сервисами

Персональные HTTP-клиенты на каждый downstream

Вместо одного универсального HTTP-клиента, для каждого внешнего сервиса (payment API, analytics service, mail service) создают отдельный клиент со своей конфигурацией.

Почему?

  • Разные таймауты: payment API может иметь 60сек SLA, analytics может быть медленнее.
  • Разные пулы соединений: payment API критичен, даём ему больше соединений.
  • Разные retry-стратегии: для платежей нужна идемпотентность, для аналитики можно отдельно.
  • Разные circuit breaker'ы: отказ analytics не должен повлиять на payment.
  • Разное логирование: для payment логируем мало (не пишем в логи номера карт), для analytics можем быть verbose.

На практике это означает:

@Bean
public PaymentApiClient paymentApiClient() {
  return new PaymentApiClient()
    .withTimeout(60_000)
    .withConnectTimeout(5_000)
    .withPoolSize(50)
    .withCircuitBreaker(new CircuitBreakerConfig()...);
}

@Bean
public AnalyticsClient analyticsClient() {
  return new AnalyticsClient()
    .withTimeout(120_000)
    .withConnectTimeout(10_000)
    .withPoolSize(20)
    // может быть более толерантен к ошибкам
}

Конфигурация ретраев и лимитов

Для идемпотентных операций (GET, DELETE, PUT) можно ретраить: если запрос не прошёл, повторяем несколько раз.

Для неидемпотентных операций (POST) нужна гарантия идемпотентности через idempotency-key или вообще не ретраим.

На практике:

POST /payments — не ретраим (может создать платёж дважды)
POST /payments с header X-Idempotency-Key: uuid — ретраим (сервер гарантирует, что один ключ = одна операция)
GET /user/123 — ретраим спокойно

Лимиты на количество одновременных запросов защищают от того, что один клиент подавит downstream. Если в пуле потоков 200 потоков, и все они попытаются одновременно обратиться к payment API, это может её убить. Вместо этого ограничиваем: максимум 50 одновременных запросов к payment API, остальные встанут в очередь.

Observability HTTP-клиентов

Важно логировать исходящие запросы:

  • URL, метод, код ответа.
  • Latency (сколько времени ушло на запрос).
  • Ошибки.

Без логирования: если payment API начинает отвечать медленнее, неизвестно где проблема — у нас или у них.

С логированием: видим в логах, что все запросы к payment API идут 5 минут.

При логировании не пишем чувствительные данные (номера карт, пароли) — нужна маска.

Метрики по downstream'ам:

  • RPS (запросов в секунду на каждый сервис).
  • Error rate (процент ошибок).
  • Latency (p50, p95, p99).

Эти метрики показывают здоровье integration'а с каждым downstream'ом.

На собеседовании: «Для интеграции с внешними сервисами используем отдельные клиенты, каждый с собственной конфигурацией, retry-логикой и circuit breaker'ом. Логируем все запросы (без чувствительных данных), собираем метрики latency и error rate. Это позволяет быстро заметить проблемы».

Старт, прогрев и эксплуатационные аспекты

Время старта Java-сервиса

Java-приложение не стартует мгновенно:

  1. Инициализация JVM: загрузка классов, инициализация внутренних структур.
  2. Инициализация контекста (Spring): парсинг конфигурации, создание бинов, инстанцирование контроллеров, сервисов, репозиториев.
  3. Инициализация зависимостей: создание пулов потоков, connection pool'ы к БД, подключение к очередям.
  4. Миграции БД (если используется Flyway/Liquibase): обновление схемы.

Типичное время: 10-30 сек для простого приложения, может быть минуты для сложного.

Это критично при деплое: если сервис нужно перезагрузить часто (rolling updates), медленный старт означает долгое время, когда сервис не обрабатывает запросы.

Оптимизации:

  • Ленивая инициализация (не инстанцировать всё при старте, а по требованию).
  • Параллельная инициализация (инстанцировать независимые бины одновременно).
  • Отключение ненужных feature'ов в prod-конфигурации.

Прогрев (warmup)

Даже если сервис стартовал, он не сразу работает с максимальной эффективностью. JIT-компилятор JVM нуждается в времени, чтобы скомпилировать горячие пути в machine code.

На холодном старте первые запросы обрабатываются медленнее. Это может быть заметно: p99 latency первых минут может быть в 2-3 раза выше, чем после прогрева.

Прогрев на практике:

  • Отправляют синтетические запросы (нагруз-тесты) в новый инстанс.
  • Это нужно делать до того, как отправлять реальный трафик.
  • Обычно реализуется через healthcheck'и, которые отправляют запросы.

Прогрев кешей:

  • Если используется в-памяти кеш, его можно заполнить при старте популярными элементами.
  • Это уменьшит cache miss'ы в первые минуты.

На system design: «При деплое нового инстанса даём ему время на прогрев перед полной отправкой трафика. Это уменьшает latency spike'и».

Graceful shutdown

Когда приложение нужно выключить (новая версия, обновление хоста), нельзя просто убить процесс. Запросы, которые в процессе, потеряются.

Graceful shutdown:

  1. Останавливаем приём новых запросов (health check'и начинают отвечать 503).
  2. Даём текущим запросам время завершиться (обычно 30 сек).
  3. Закрываем пулы потоков, соединения.
  4. Выход.

На уровне Java это может быть реализовано через shutdown hook'и (специальные обработчики сигналов SIGTERM).

На практике:

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  applicationContext.close(); // закрыть контекст Spring
  executorService.shutdown(); // завершить пулы потоков
  // закрыть другие ресурсы
}));

Orchestrator'ы (Kubernetes) знают про graceful shutdown и дают время на завершение перед force-kill'ом.

Влияние на стратегию деплоя

Rolling update: вместо выключения всех старых инстансов и запуска новых, выключают по одному, запускают новые. Это позволяет сохранить доступность. Но если каждый инстанс стартует 30 сек, rolling update может занять долго.

Blue-green: два полных набора инстансов (синие и зелёные). Раскатываем на зелёных, потом переключаем трафик. Быстро, но дороже (нужны два набора ресурсов).

Canary: отправляем новую версию на 10% инстансов, мониторим метрики, потом на 50%, потом на 100%. Если bug, откатываем с минимальным ущербом.

Выбор стратегии зависит от того, как быстро стартует приложение, как часто нужно раскатывать, какой допуск на downtime.

На system design: «При раскатке используем rolling update с graceful shutdown. Новые инстансы получают трафик только после прогрева (проверим health check'и). Это минимизирует latency spike'и».

Observability Java-сервиса

Логи

Структурированные логи — это не просто текстовые строки, а JSON с полями:

{
  "timestamp": "2025-11-27T01:32:00Z",
  "level": "INFO",
  "traceId": "abc123def456",
  "service": "payment-service",
  "message": "Payment processed",
  "paymentId": "pay_789",
  "amount": 99.99,
  "duration_ms": 245
}

Структурированные логи легко парсить, агрегировать, фильтровать. Например, можем найти все логи за час с трассировкой платежа.

traceId (или correlation ID) — это уникальный идентификатор запроса. Он генерируется при входящем запросе и пробрасывается дальше (в вызовы БД, HTTP-запросы к другим сервисам). В логах видна цепочка: входящий запрос → вызов БД → вызов внешнего API. Это критично для отладки.

MDC (Mapped Diagnostic Context) — это механизм, который позволяет привязать контекст (traceId, userId и т.д.) к потоку. Все логи в этом потоке автоматически содержат этот контекст. На уровне идей (не конкретную реализацию): когда логируем, не нужно вручную добавлять traceId в каждый лог, он там автоматически.

На практике:

MDC.put("traceId", generatedId);
logger.info("Processing payment"); // traceId автоматически в логе

Метрики

Метрики — это числовые значения, которые меняются со временем. Примеры:

  • RPS (requests per second) на каждый endpoint.
  • Latency: p50, p95, p99.
  • Error rate: процент неудачных запросов.
  • Пул потоков: active threads, queue size.
  • Connection pool: active connections, idle connections, total size.
  • GC: количество паузы, их длительность.

Метрики позволяют видеть тренды: если RPS растёт, latency растёт, error rate растёт — это сигналы, что нужно масштабировать.

На практике обычно используются инструменты типа Prometheus (сбор метрик), Grafana (визуализация). Приложение предоставляет метрики на специальном endpoint'е (например, /metrics), Prometheus периодически их забирает.

На Java обычно используется Micrometer — library, которая предоставляет единый API для сбора метрик, независимо от backend'а (Prometheus, Datadog, CloudWatch и т.д.).

Трейсы

Trace — это запись всех операций для одного входящего запроса. Включает:

  • Спан входящего HTTP-запроса (от получения до ответа).
  • Спаны вызовов БД (SELECT, INSERT).
  • Спаны HTTP-вызовов к другим сервисам.
  • Спаны обработки сообщений.

Каждый спан содержит:

  • Start time и duration (когда началась, сколько заняла).
  • Теги (метаданные).
  • Ошибки (если случились).

Трейсы позволяют увидеть bottleneck'и: например, в одном запросе 100мс уходит на входящий HTTP, 800мс на вызов БД, 50мс на обработку. Видно, что узкое место — БД.

На Java обычно используется OpenTelemetry или Jaeger для сбора и анализа трейсов.

На практике трейсы дорогие (требуют ресурсов), поэтому обычно собирают не все запросы, а выборку (например, 1% или все медленные запросы > 1 сек).

На system design: «Используем логи (структурированные с traceId), метрики (RPS, latency, error rate), и трейсы (для отладки bottleneck'ов). Вместе они дают полную картину того, что происходит в системе».

Типичные анти-паттерны Java/Spring в System Design

Монолит без границ модулей

Приложение растёт, и весь код кладут в один модуль. Нет чётких границ между слоями (controller, service, repository). Это приводит к:

  • Циклическим зависимостям.
  • Сложности тестирования (нужно загружать весь контекст).
  • Невозможности разделить компоненты на микросервисы потом (сильная связанность).

На system design это значит, что приложение тяжело масштабировать. Если нужна разработка параллельно, команды будут постоянно конфликтовать в коде.

Как избежать: четко структурировать код. Пакеты по фичам, не по слоям. Зависимости идут только вниз (controller → service → repository). API между фичами чётко определены.

Отсутствие лимитов

Нет лимитов на:

  • Размер пула потоков (может вырасти и израсходовать память).
  • Размер connection pool'а к БД (может перегрузить БД).
  • Параллелизм на потребителе очередей (может перегрузить downstream).
  • Размер результата запроса к БД (может вернуть млн записей).
  • Размер кеша (может вырасти и съесть всю память).

Результат: при пике нагрузки всё падает. Нет graceful degradation.

Как избежать: везде где есть очереди, пулы, кеши, запросы — ставим лимиты. Логируем, когда лимит близок.

Отсутствие таймаутов на внешних вызовах

Если downstream-сервис зависнет, приложение тоже зависнет. При масштабировании все инстансы одновременно зависнут, система недоступна.

Как избежать: на КАЖДЫЙ вызов (к БД, HTTP, очередь) таймаут. Если нет ответа за N сек, ошибка, поток освобождается.

Использование ORM без контроля

ORM может сгенерировать неэффективный SQL (N+1, лишние join'ы). При растущей нагрузке производительность падает экспоненциально.

Как избежать: контролировать, какие запросы отправляются. Логировать SQL. На prod мониторить slow query log БД. При обнаружении проблем — либо оптимизировать запрос, либо переходить на native SQL.

Никакой observability

Нет traceId'ов, нет структурированных логов, нет метрик. Когда система падает, неизвестно почему. Отладка занимает часы.

Как избежать: с самого начала внедрить логи с traceId, собирать базовые метрики (RPS, latency, ошибки).

Жёсткая привязка к реализациям

Код жёстко привязан к конкретным классам БД (только PostgreSQL), к конкретным очередям (только RabbitMQ), к конкретным HTTP-клиентам. Когда требуется менять (например, с PostgreSQL на MongoDB), половину приложения нужно переписывать.

Как избежать: зависимости идут на интерфейсы, не на конкретные реализации. Конфигурация определяет, какая реализация используется. Это даёт гибкость.

На собеседовании: «Мы избегаем этих анти-паттернов. Код структурирован с чёткими границами. На все внешние вызовы таймауты. Контролируем нагрузку через лимиты на пулы и запросы. Используем observability для отладки. Это позволяет системе масштабироваться и надёжно работать при высоких нагрузках».

Чек-лист Java/Spring-аспектов в System Design-ответе

На собеседовании, когда интервьюер просит спроектировать систему, важно упомянуть Java/Spring-специфичные аспекты в правильный момент.

Какие пункты упомянуть

  1. Модель обработки запросов и thread pools: «Используем блокирующую модель с пулом потоков размером X (зависит от профиля запросов). Каждый входящий запрос обрабатывается отдельным потоком. Потоки — ограниченный ресурс, поэтому пул конечный». Если нагрузка экстремально высокая (миллионы одновременных соединений), можно добавить: «На таких объёмах рассмотрели бы реактивный стек, но это усложнит архитектуру».

  2. Connection pool'ы: «К БД используем connection pool (HikariCP или аналог) размером Y. К каждому внешнему сервису отдельный пул с собственной конфигурацией, чтобы отказ одной зависимости не повлиял на остальные».

  3. Таймауты и ретраи: «На все внешние вызовы (БД, HTTP, очередь) устанавливаем таймауты. Для идемпотентных операций используем exponential backoff ретраи 2-3 раза. Это защищает от зависаний и штормов ретраев».

  4. Circuit breaker'ы: «Оборачиваем критичные зависимости в circuit breaker'ы. Если % ошибок превышает threshold, цепь открывается, запросы падают сразу, даём зависимости время восстановиться».

  5. Работа с БД: «Используем ORM для простых CRUD, но контролируем генерируемые запросы. На горячих путях переходим на native SQL. Все чтения с лимитами и пагинацией (LIMIT 100). Нет N+1 запросов, нет загрузки огромных графов сущностей».

  6. Обработка очередей: «Консьюмеры работают в отдельном пуле потоков. Обработчики идемпотентны (используем deduplication table). Ограничиваем параллелизм, чтобы не перегрузить downstream».

  7. Observability: «Логируем все запросы (входящие, к БД, HTTP) со структурированными логами и traceId. Собираем метрики: RPS, latency (p50/p95/p99), error rate по endpoint'ам и downstream'ам. Это позволяет быстро заметить проблемы и найти bottleneck'и».

  8. Эксплуатационные аспекты: «Приложение поддерживает graceful shutdown. При деплое даём инстансам время на прогрев. Используем rolling update с health check'ами. Это минимизирует downtime и latency spike'и».

Как формулировать, чтобы звучать как Senior

Вместо: «Мы будем использовать Spring Boot с Tomcat, обработка запросов асинхронной».

Звучит как: «Мы используем Spring Boot с блокирующей моделью обработки (один поток на запрос). Пул потоков в Tomcat настраиваем на основе профиля нагрузки — если это в основном быстрые операции к локальной БД, пул ≈ ядра × 1.5. На каждый downstream'а (БД, внешний API) отдельный connection pool с собственными таймаутами. Это гарантирует, что отказ одной зависимости не повлияет на остальные».

Вместо: «Мониторим систему через логирование и метрики».

Звучит как: «Используем структурированные логи JSON с traceId, позволяющим проследить запрос через все сервисы. Собираем метрики: RPS, latency (p95, p99), ошибки по endpoint'ам и downstream'ам. Используем трейсы (OpenTelemetry) для отладки bottleneck'ов. Это дает полную visibility в систему и позволяет быстро диагностировать проблемы».

Вместо: «Connection pool к БД большой, чтобы вмещать много запросов».

Звучит как: «Размер connection pool'а к БД определяется числом одновременных запросов, которое мы ожидаем. Если пул слишком маленький, запросы встанут в очередь. Если слишком большой, может перегрузить БД (она имеет собственный лимит соединений). Типичный размер — 20-40 для сервиса с пиком в 1000 RPS. Мониторим active connections — если приближается к лимиту, это сигнал к оптимизации».

На собеседовании важно показать, что ты не просто пользователь framework'а, а понимаешь runtime-поведение и влияние выборов на надёжность системы.

System Design: Typical tasks

Зачем разбирать типовые задачи System Design

Почему интервьюеры используют одни и те же форматы

На собеседованиях по system design постоянно появляются одни и те же задачи: URL shortener, чат, лента новостей, хранилище файлов. Это не случайность. Такие задачи проверяют не знание конкретного фреймворка, а навык архитектурного мышления в условиях ограничений: масштаб, надёжность, консистентность данных, отказоустойчивость. Каждая из этих задач имеет хорошо изученный набор trade-off'ов, что позволяет интервьюеру быстро оценить уровень кандидата по его выбору и аргументации.

Интервьюеры ценят типовые задачи потому, что они демонстрируют практическое понимание: сможет ли Senior разработчик грамотно масштабировать систему, если она вдруг получит в 100 раз больше пользователей? Сможет ли он выделить узкие места и выбрать правильное хранилище, вместо того чтобы везде втискивать MySQL или везде кидать всё в Kafka?

Какие навыки проверяются через типовые задачи

Структурирование ответа. Кандидат должен озвучить последовательность шагов, а не хаотично прыгать между компонентами. Senior разработчик начинает с требований, переходит к оценке нагрузки, затем к архитектуре. Это показывает зрелость мышления и опыт разработки больших систем, где порядок анализа критичен.

Способность считать нагрузку. Вместо «много пользователей» нужно говорить конкретные цифры: если в день 1 млн новых коротких ссылок, это X RPS на запись, Y объёма хранилища, Z пиковая нагрузка в часы активности. Это показывает, что разработчик может оценить, когда нужна горизонтальная масштабируемость, когда кеш спасает ситуацию, а когда нужно сложное шардирование.

Выбор архитектурных паттернов с аргументацией. Почему именно Redis, а не RocksDB? Почему асинхронная обработка сообщений, а не синхронное хранилище? Senior должен объяснить, почему он выбирает решение, рассказать о конкурирующих вариантах и их недостатках в контексте задачи.

Проговаривание масштабирования, отказоустойчивости и моделирования данных. Недостаточно сказать «используем базу данных». Нужно объяснить, как она партиционируется, как восстанавливается при сбое, как гарантируется консистентность или как мы готовы жить с eventual consistency. Как обновляются кеши? Что произойдёт, если один из сервисов упадёт?

Как подготовка на типовых задачах помогает решать новые задачи

Типовые задачи работают как набор блоков LEGO. Новая задача на собеседовании редко полностью неизвестна. Обычно это комбинация или вариация: например, расписание занятий — это граф с расписанием (как в соцсети — граф), кеширование критично (как в URL shortener), нужны уведомления (как в чате). Разработчик, который глубоко разобрался в четырёх-пяти типовых задачах, может быстро переиспользовать паттерны, адаптировать их и предложить непротиворечивое решение на новую задачу.

Кроме того, типовые задачи учат видеть типичные ошибки и антипаттерны. После десятка раз обсуждения масштабирования фида становится понятно, почему fan-out on write работает для малых аудиторий, но падает при Oprah Effect (когда один влиятельный пользователь постит). Эта интуиция переносится на любую новую задачу.

Общий шаблон решения любой System Design задачи

Последовательность шагов

Каждую задачу нужно разворачивать в строгом порядке. Отклоняться от порядка можно, но риск забыть что-то критичное резко растёт.

1. Уточнение функциональных требований. Что система должна делать? Какие операции? На URL shortener: создание ссылки, редирект, статистика кликов. На чате: отправка сообщений, получение истории, изменение статуса. Это звучит очевидно, но на собеседовании нужно озвучить это вслух: это показывает системное мышление и дисциплину, плюс интервьюер может подкорректировать нарратив.

Полезная фраза: «Давайте сначала зафиксируем, что система должна поддерживать: создание — чтение — обновление — удаление. А эти операции синхронные или есть асинхронные?»

2. Уточнение нефункциональных требований и нагрузок. Сколько пользователей? Как часто они выполняют операции? Какова требуемая latency? Нужна ли долговечность данных? Нужна ли high availability?

На этом этапе уточняется профиль нагрузки: read-heavy, write-heavy, balanced? Профиль важен, потому что определяет архитектуру. Read-heavy система — это кеши везде; write-heavy — это расчёт на репликацию и шардирование.

3. Оценка RPS, объёмов данных, профиля нагрузки. Из требований вытекают цифры. Если в день 1 млн создаваемых ссылок, это ~12 RPS на создание (1 млн / 86400 сек). Если на каждую ссылку в среднем 100 редиректов, это ~1200 RPS на редирект. На собеседовании это обсуждается вслух:

«Прикинем: пусть миллион пользователей, каждый создаёт по одной ссылке в день. Это примерно 12 RPS на запись. Если на ссылку приходит в среднем 100 кликов, то на чтение — 1200 RPS. Это означает, что прямое хранилище данных должно выдерживать, но кеш спасёт нас от перегруза.»

Объём хранилища считается так: если в год создаётся ~365 млн ссылок, и каждая занимает примерно 300 байт (ключ, URL, метаданные), это примерно 100 ГБ в год. На пять лет — 500 ГБ, что всё ещё умещается на одном диске, но требует репликации и бэкапов.

4. Предложение high-level архитектуры. На основе цифр строится набросок: какие компоненты взаимодействуют? Как распределяется нагрузка? На этом этапе рисуются основные блоки: API gateway, сервисы, хранилища, кеши, очереди. Нет деталей реализации — только структура.

5. Проработка модели данных и хранилищ. Какие сущности? Какие поля? Какие индексы? Какое хранилище: реляционное, NoSQL, комбинированное? Здесь делаются первые серьёзные trade-off'ы: например, колоночное хранилище для аналитики vs. строковое для OLTP.

6. Сетевое взаимодействие, кеши, очереди. Как компоненты общаются? HTTP, gRPC? Где кешируются данные? Где нужны асинхронные очереди для разделения нагрузки? На этом этапе становится ясно, как система справляется с пиками.

7. Масштабирование и отказоустойчивость. Как система масштабируется с ростом нагрузки? Как она восстанавливается, если один узел упадёт? Партиционирование, репликация, failover?

8. Observability, безопасность, эволюция. Как будет видно, что система работает? Какие логи, метрики? Кто имеет доступ? Как она будет эволюционировать, если требования изменятся?

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

На собеседовании обычно 45–60 минут. За это время нужно быть и широким, и глубоким. Стратегия:

Начни с широтой. Озвучи всю архитектуру на высоком уровне за 10–15 минут. Это показывает системный взгляд.

Затем углубись в одно-два направления. Интервьюер обычно сам скажет, что его интересует: «Расскажи подробнее про кеширование» или «Как ты будешь шардировать базу?» Если интервьюер молчит, углубись в самое критичное: для URL shortener — это редирект и кеши, для фида — это fan-out логика.

Не зацикливайся на деталях. Если ты 20 минут рассказываешь про индексы в PostgreSQL, а про масштабирование не упомянул, это ошибка. Senior разработчик понимает, когда сказать «детали индексации — отдельная большая тема, сейчас скажу в общих чертах, а если интересует — углубимся».

Полезная фраза: «Я вижу, что тут можно углубиться в консистентность и репликацию. Я могу рассказать подробнее, или ты хочешь, чтобы я сначала закончил с общей картиной?»

Задача 1: Проектирование URL shortener

Требования и сценарии использования

URL shortener сокращает длинные URL вроде https://www.example.com/very/long/path?with=parameters&and=more в короткую ссылку вроде https://short.co/abc123. Основные операции:

  1. Создание короткой ссылки: пользователь отправляет длинный URL, получает обратно уникальный короткий идентификатор.
  2. Редирект: пользователь посещает коротко ссылку, система находит оригинальный URL и перенаправляет (HTTP 301 или 302).
  3. Статистика (опционально): подсчёт кликов, информация о том, откуда пришёл пользователь (рефератор, браузер, устройство).

Нефункциональные требования:

  • RPS: На создание ссылок примерно столько же, сколько на редиректы, или редиректов значительно больше (зависит от сценария). Пусть система должна выдерживать 1000 RPS на редирект и 10 RPS на создание.
  • Latency: На редирект желательно <100 мс, на создание <500 мс. Редирект критичен, потому что пользователь ждёт страницы в браузере.
  • Долговечность: Ссылка должна работать долго. Обычно 5+ лет или до явного удаления.
  • Availability: 99.9% uptime на редиректы.

Оценка нагрузки

Предположим типовый сценарий: 100 млн пользователей, в день создаётся 1 млн новых ссылок, на каждую ссылку в среднем 100 редиректов.

  • RPS на создание: 1 млн ссылок в день / 86400 секунд ≈ 12 RPS (в среднем, но пики могут быть в 5–10 раз выше).
  • RPS на редирект: 1 млн * 100 / 86400 ≈ 1160 RPS (в среднем).
  • Пиковые нагрузки: В дневные часы RPS может быть выше в 5 раз, в ночные — ниже.
  • Объём данных: Если хранить 5 лет, это 1825 млн записей (~1.8 млрд). Каждая запись: ключ (10 байт), URL (300 байт), метаданные (100 байт) = 410 байт. Итого: 1.8 * 10^9 * 410 байт ≈ 730 ГБ только исходных данных. Плюс статистика (кто, откуда, когда кликнул) — может быть 10 раз больше.

Этот расчёт показывает: редирект — это read-heavy операция, требует кеша. Хранилище должно выдерживать, но кеш спасает. Статистика требует отдельного хранилища или асинхронной обработки, потому что писать её синхронно в основное хранилище слишком дорого.

High-level архитектура

[Client] 
   ↓
[API Gateway] (балансирует нагрузку)
   ↓
[Shortener Service] (создание ссылок) & [Redirect Service] (редирект)
   ↓
[Cache Layer (Redis)] ← кеш маппинга short → long
   ↓
[Primary Database] (хранилище маппинга)
   ↓
[Analytics Service] (опционально, обработка событий кликов)

API Gateway: Балансирует нагрузку между несколькими инстансами shortener сервиса. Может быть простой round-robin или на основе текущей нагрузки.

Shortener Service: Инстансы сервиса, которые создают короткие ссылки, генерируют ключи, записывают в БД, кешируют результат. Может быть несколько инстансов для горизонтального масштабирования.

Redirect Service: Отдельные инстансы (или те же) для редирект'а. Первый запрос идёт в кеш (Redis), если там нет — в БД. Ответ возвращается быстро.

Cache Layer (Redis): Хранит маппинг short key → long URL. TTL может быть большим (например, часы или дни), потому что URL не часто меняется. Cache miss на редирект означает ход в БД, что медленнее, но приемлемо для редких случаев.

Primary Database: RDBMS или NoSQL, хранит маппинг short → long, метаданные. Требует репликации для надёжности.

Analytics Service (опционально): Асинхронный сервис, который обрабатывает события кликов. На каждый редирект можно логировать событие в Kafka или аналог, а потом обрабатывать и сохранять статистику в отдельном хранилище или в основной БД в не-критичные моменты.

Модель данных

Основная таблица хранит маппинг:

Поле Тип Описание
short_key VARCHAR(10) PRIMARY KEY Например, «abc123xyzp»
original_url TEXT Оригинальный длинный URL
created_at TIMESTAMP Когда создана ссылка
user_id BIGINT (опционально) Кто создал ссылку
expiration_date TIMESTAMP (опционально) Когда ссылка истечёт
is_active BOOLEAN Удалена ли ссылка

Индексы:

  • PRIMARY KEY на short_key для быстрого поиска по редирект'у.
  • INDEX на created_at для удаления старых записей.
  • INDEX на user_id, если нужна история ссылок пользователя.

Для статистики отдельная таблица:

Поле Тип
short_key VARCHAR(10) (FOREIGN KEY)
click_timestamp TIMESTAMP
referrer VARCHAR(500)
user_agent VARCHAR(500)
country VARCHAR(2)

Но писать каждый клик синхронно — дорого. Вместо этого клики логируются в Kafka или очередь, обрабатываются асинхронно, в конце дня или часа агрегируются в аналитическое хранилище (например, ClickHouse или BigQuery).

Генерация коротких ключей

Есть несколько подходов, каждый с trade-off'ами:

1. Инкрементальный ID + base62/base64 кодирование.

Идея: использовать глобальный счётчик или последовательность БД, переводить его в base62 (62 символа: 0–9, a–z, A–Z).

Пример: ID = 123456 → base62 = "w7e".

Плюсы:

  • Детерминирован. Нет коллизий.
  • Компактен. На 62^10 комбинаций приходится очень много записей.
  • Предсказуем для внутреннего использования.

Минусы:

  • Предсказуемость — это и минус. Пользователь может угадать соседние ссылки, если узнает паттерн.
  • Генерация счётчика требует единой системы (например, Zookeeper или centralized service), что может быть узким местом.

Пример реализации логики:

long id = getNextId();  // Из Zookeeper или БД sequence
String shortKey = toBase62(id);

2. Случайные ключи фиксированной длины.

Идея: генерировать random string из 10 символов в base62. Вероятность коллизии считается по birthday paradox.

Плюсы:

  • Неопредсказуемый, меньше проблем с безопасностью.
  • Не требует глобального счётчика.
  • Распределяется равномерно.

Минусы:

  • Нужно проверять коллизии при вставке. Если сгенерили ключ, который уже есть, нужно заново генерировать.
  • На больших объёмах вероятность коллизии растёт (birthday paradox: на N = 62^6 ~ 56 млрд возможных ключей, коллизия появляется при примерно √N ~ 7.5 млн ссылок с вероятностью >50%).

3. Гибридный подход.

UUID с последующим хешированием или сокращением. Например, MD5(UUID) → первые 8 символов.

На собеседовании обычно выбирают подход 1 или 2, в зависимости от требований:

«Для нашей системы выбираю инкрементальный ID с base62, потому что это детерминировано, компактно, и коллизий не будет. Если проблемы с предсказуемостью, можно добавить случайный компонент в ключ, например, timestamp + random, но простой инкремент удобнее.»

Пути оптимизации

Кеширование маппинга short → long: Redis с TTL (time-to-live) 1–24 часа. При редирект'е сначала проверяем Redis, затем БД. Такая стратегия (write-through): при создании ссылки пишем в БД и тут же кешируем.

На какой hit rate рассчитываем? Если большинство ссылок создаются, но не сразу кликаются, hit rate может быть 70–80%. Оставшиеся 20–30% идут в БД, что приемлемо.

Read-heavy профиль: Архитектура должна отражать, что редирект'ов на порядки больше, чем создания. Можно реплицировать БД для чтения (master-slave), распределить read replicas по географии.

Защита от злоупотреблений:

  • Rate limiting на создание ссылок: максимум X ссылок в минуту на одного пользователя.
  • Валидация URL: не принимаем явный фишинг или известные вредоносные домены.
  • Чёрный список: если ссылка помечена как фишинг, деактивируем её.
  • CAPTCHA при массовом создании.

Масштабирование и отказоустойчивость

Разделение путей создания и редирект'а: Create-сервис и Redirect-сервис могут масштабироваться независимо. Редирект требует больше ресурсов, поэтому инстансов Redirect-сервиса может быть больше.

Репликация хранилища: Primary-Secondary (Master-Slave) репликация. Write идёт в master, read может идти и в master, и в slave replicas. При падении master, slave поднимается как новый master. На собеседовании говорим: «Используем асинхронную репликацию, готовы к некоторому lag'у между master и slave. Для критичных операций типа создания ссылки можно использовать синхронную репликацию, но это медленнее.»

Шардирование БД: По ключу short_key. Например, если ключ начинается на 'a–g', идёт в shard 1; 'h–n' — в shard 2, и т.д. Каждый shard имеет master и replicas. Это позволяет масштабировать до больших объёмов.

Единственная сложность: перебалансировка шардов при добавлении новых шардов. Обычно это плановая операция, требует downtime или сложные схемы на уровне приложения.

Geo-replication: Если система глобальна, копируем данные в разные регионы (US, EU, APAC). Редирект идёт в ближайший регион, создание может идти в основной регион или в ближайший (зависит от consistency требований).

Пример того, как Senior проговаривает решение вслух

«Итак, URL shortener. Сначала требования: создание ссылки, редирект, опционально статистика. Масштаб: пусть 100 млн пользователей, 1 млн новых ссылок в день. Это примерно 12 RPS на создание, но редиректов на 100 раз больше — 1200 RPS на чтение. Профиль clear: read-heavy.

High-level: API Gateway перед несколькими инстансами сервиса. Сервис обрабатывает создание и редирект. Кеш (Redis) для маппинга short → long. БД (RDBMS) как source of truth. Опционально, async очередь для аналитики.

На редирект сначала лезем в Redis — если там есть, возвращаем быстро. Если cache miss, идём в БД, кешируем на будущее, возвращаем. На создание: генерируем short key (инкремент + base62), пишем в БД, кешируем, возвращаем.

Масштабирование: Replicas БД для чтения, может быть несколько Redirect-сервисов на одного Create-сервиса. Если шарды нужны, шардируем по short key. Данные огромные (700 ГБ за 5 лет), так что планируем replikation и backup.

Отказоустойчивость: master-slave для БД, failover автоматический. Если один инстанс сервиса упадёт, другой берёт его нагрузку. Кеш ненужен для корректности (только для скорости), так что его loss не критичен.

Trade-off: инкрементальный ID vs random. Выбираю инкремент, потому что гарантирует уникальность и коллизий не будет. Цена — немного предсказуемости, но с маскированием (например, XOR с salt) можно решить.

Что углубить: консистентность данных между master и slave? Потребление трафика на репликацию? Детали shardирования?»

Задача 2: Проектирование чата/мессенджера

Требования

Система должна поддерживать:

  1. 1:1 чаты: Два пользователя обмениваются сообщениями.
  2. Групповые чаты: Несколько пользователей в одном чате.
  3. Доставка в near real-time: Сообщение должно дойти быстро, желательно <100–200 мс.
  4. Оффлайн-доставка: Если получатель оффлайн, сообщение сохраняется, доставляется при переходе онлайн.
  5. Статусы доставки: Отправлено (sent), доставлено (delivered), прочитано (read).
  6. Online/offline статус: Пользователь видит, онлайн ли собеседник.

Нефункциональные требования:

  • DAU (daily active users): 50 млн пользователей, 10 млн online одновременно.
  • Сообщения в день: примерно 100 млн (в среднем 10 сообщений на пользователя в день, но это очень вариативно).
  • RPS на отправку: 100 млн сообщений / 86400 сек ≈ 1160 RPS (в среднем, пики выше).
  • Latency на доставку: <300 мс.
  • Availability: 99.9%.

Нагрузочные характеристики

Читать сообщения намного чаще, чем писать. Например, пользователь может прочитать старый чат несколько раз на день, но написать несколько сообщений. Ratio read:write может быть 10:1 или выше.

Профиль пиковой нагрузки: вечеры и выходные (в часы активности нагрузка в 5–10 раз выше среднего).

Групповые чаты vs 1:1: в групповых чатах одно сообщение нужно доставить нескольким получателям (fan-out 1 → N). Если в чате 1000 участников, отправка одного сообщения требует 1000 доставок.

High-level архитектура

[Client (HTTP + WebSocket)]
   ↓
[API Gateway + Load Balancer]
   ↓
[Connection Manager Service] (управление WebSocket/long polling сессиями)
   ↓
[Message Router Service] (маршрутизирует сообщения между получателями)
   ↓
[Message Storage] (хранилище сообщений) & [Message Queue (Kafka/RabbitMQ)]
   ↓
[Presence Service] (онлайн-статусы пользователей)
   ↓
[Notification Service] (отправка офлайн-уведомлений, push)

Connection Manager: Держит открытые WebSocket соединения с клиентами. Может быть несколько инстансов, каждый обслуживает тысячи соединений. На падение одного инстанса клиент переподключается к другому.

Message Router: Получает сообщение от клиента, проверяет права (нужно ли отправителю разрешено писать в этот чат?), маршрутизирует к получателям (ищет, какие Connection Manager'ы обслуживают получателей). Если получатель онлайн, доставляет напрямую; если офлайн, кидает в очередь (Message Queue).

Message Storage: Хранилище сообщений. Write-heavy, потому что каждое сообщение нужно сохранить. Read часто (история чата). Требует быстрого append, быстрого поиска по (chat_id, timestamp).

Message Queue: Например, Kafka с topicами per chat или per user. Используется для асинхронной обработки: офлайн-сообщений, аналитики, синхронизации между инстансами.

Presence Service: Хранит информацию о том, какой Connection Manager обслуживает какого пользователя. Redis с TTL (например, 30 сек). При подключении пользователя обновляем presence, при disconnect удаляем.

Notification Service: Для оффлайн-пользователей отправляет push-уведомления через FCM/APNS.

Сетевое взаимодействие: WebSocket vs long-polling vs SSE

WebSocket: Двусторонний канал, TCP соединение остаётся открытым. Сервер может отправить сообщение клиенту в любой момент. Низкая latency, идеально для real-time чата. Сложность: управление длительными соединениями, нужна перебалансировка при падении сервера.

Long-polling: Клиент периодически (например, каждые 5 сек) запрашивает у сервера: «Есть ли новые сообщения?» Сервер либо отвечает сразу, либо ждёт (до timeout'а), пока не появятся данные. Проще в реализации (стандартный HTTP), но latency выше и traffic выше (много пустых запросов).

Server-Sent Events (SSE): Гибрид: клиент открывает HTTP соединение, сервер отправляет события по мере появления. Однонаправленный (только server → client), поэтому на client → server всё равно нужен HTTP.

На собеседовании обычно выбирают WebSocket для чата, потому что latency критичен: «Выбираю WebSocket, потому что нужна низкая latency доставки сообщений. Long-polling будет слишком медленным для real-time чата, и traffic выше.»

Сложность WebSocket: держать миллионы открытых соединений требует специальной инфраструктуры. Каждое соединение занимает память (в Node.js — ~100 кБ на соединение, в Java чуть больше). Для 10 млн online пользователей это ТБ памяти на одном сервере — невозможно. Решение: распределить соединения между множеством инстансов Connection Manager'ов. Например, на каждом инстансе ~100 К соединений, нужно 100 инстансов. Балансировка может быть по IP клиента или по хешу user_id (sticky sessions).

Модель данных

Таблица messages:

Поле Тип Описание
message_id BIGINT PRIMARY KEY Уникальный ID сообщения
chat_id BIGINT ID чата (диалога или группы)
sender_id BIGINT ID отправителя
content TEXT Текст сообщения
created_at TIMESTAMP Время отправки
edited_at TIMESTAMP (опционально) Если редактировалось
status ENUM sent, delivered, read

Индексы:

  • PRIMARY KEY на message_id.
  • COMPOSITE INDEX на (chat_id, created_at) для быстрого получения истории чата.

Таблица chats:

Поле Тип
chat_id BIGINT PRIMARY KEY
is_group BOOLEAN
name VARCHAR(255) (для групп)
created_at TIMESTAMP
last_message_at TIMESTAMP

Таблица chat_members (для групп):

Поле Тип
chat_id BIGINT
user_id BIGINT
joined_at TIMESTAMP
PRIMARY KEY (chat_id, user_id)

Таблица presence (Redis):

user_id:presence → {server_id, last_heartbeat, status (online/away/offline)}

Хранится в Redis с TTL 60 сек. Раз в 30 сек клиент отправляет heartbeat.

Доставка и гарантии

At-least-once доставка: Сообщение может быть доставлено несколько раз. Нужна идемпотентность на клиенте (дублеты должны игнорироваться или мержиться).

Пример: клиент отправляет сообщение, сервер его обрабатывает, но ответ теряется. Клиент переотправляет, сервер снова обрабатывает. Результат: два одинаковых сообщения. Решение: каждое сообщение имеет client_message_id (generated by client), сервер дедублирует на этой основе.

Офлайн-очередь: Если пользователь оффлайн при отправке сообщения, оно сохраняется в Kafka или БД в таблице offline_messages. При подключении пользователя сервер отправляет ему все офлайн-сообщения.

Синхронизация между инстансами: Если пользователь был подключен к Connection Manager'у 1, потом переподключился к Manager'у 2, оба должны знать о сообщениях. Решение: Message Queue (Kafka) с topicом per user. Оба Manager'ы подписаны, получают все сообщения.

Масштабирование

Шардирование по пользователям: Presence-данные (кто онлайн) шардируются по user_id. Например, user_id % 100 = shard number. Каждый shard обслуживает Presence-сервис. Это даёт возможность масштабировать lookups: «Кто онлайн из списка друзей?»

Шардирование по чатам: Сообщения шардируются по chat_id. Chat 1–1000 в БД 1, Chat 1001–2000 в БД 2, и т.д. Это даёт линейное масштабирование по количеству чатов.

Fan-out при групповых чатах: Если пользователь отправит сообщение в группу из 100 участников, нужно доставить его 100 получателям. На уровне высокой нагрузки это может быть проблемой (100 операций вместо 1). Решение:

  1. Fan-out on write: Сразу при отправке создаём 100 delivery records, затем обрабатываем параллельно.
  2. Fan-out on read: Храним сообщение один раз, при чтении группового чата клиент видит это сообщение (нет дублирования). Быстрее на запись, но чтение медленнее.

Большинство систем используют гибрид: fan-out on write для active чатов (мало участников, много сообщений), fan-out on read для broadcast-чатов (много участников, редко пишут).

Дополнительные аспекты

Шифрование: На транспортном уровне — TLS (HTTPS, WSS). End-to-end шифрование на уровне идей (на собеседовании вы не будете реализовывать, но скажете, что нужен алгоритм типа Signal Protocol). На собеседовании: «End-to-end требует управления ключами, что сложно, но для приватных чатов это может быть требованием. На данном этапе ограничусь транспортным шифрованием.»

Антиспам и anti-abuse: Rate limiting на отправку сообщений (максимум X сообщений в минуту), чёрный список спамеров, фильтрация известных вредоносных ссылок.

Пример последовательности ответа Senior-инженера

«Итак, мессенджер. Требования: 1:1 и групповые чаты, онлайн-статусы, доставка <300 мс, офлайн-буферизация. Масштаб: 10 млн online одновременно, 100 млн сообщений в день — это ~1200 RPS.

High-level: Connection Manager'ы держат WebSocket соединения, Message Router'ы маршрутизируют сообщения, Message Storage хранит, Presence Service отслеживает online. Message Queue (Kafka) для асинхронности и офлайн-сообщений.

На отправку: клиент отправляет сообщение через WebSocket, Message Router проверяет права, маршрутизирует. Если получатель онлайн и подключен к другому Manager'у, отправляем через очередь. Если оффлайн, кидаем в offline queue для позднейшей доставки.

Масштабирование: на каждом Connection Manager'е ~100 К соединений, значит нужно ~100 инстансов для 10 млн online. Сообщения шардируются по chat_id. Presence шардируется по user_id.

Гарантии доставки: at-least-once, дедубликация на уровне client_message_id. Сообщения могут приходить в разном порядке (если маршруты разные), но обычно это приемлемо.

Если бы был очень большой групповой чат (100 К участников), fan-out on write был бы слишком дорогим. Тогда переходим на fan-out on read: одна запись, все видят при чтении. Но для обычных групп fan-out on write работает хорошо.

Trade-off: WebSocket vs long-polling. Выбираю WebSocket для latency, хотя infra сложнее. Long-polling был бы проще, но latency 5 сек неприемлем для real-time чата.

Углубить можно: детали дедубликации? Как обрабатывать order гарантии? Как backpressure при пиковых нагрузках?»

Задача 3: Проектирование ленты новостей/социального фида

Требования

Система отображает ленту (feed) постов для пользователя:

  1. Пользователь подписан на других пользователей (друзья/followers).
  2. При открытии ленты видит посты людей, на которых подписан.
  3. Сортировка: по времени (новые сверху) или по алгоритму (hot posts, engagement-based).
  4. Действия: лайк, комментарий, репост на посты.
  5. Обновление ленты: при добавлении нового поста пользователя, в ленту его подписчиков появляется этот пост.

Нефункциональные требования:

  • DAU: 100 млн.
  • RPS на read ленты: при 100 млн DAU и среднем сеансе 5–10 запросов ленты в день, это примерно 100 млн * 10 / 86400 ≈ 11 500 RPS (read-heavy).
  • RPS на запись (новый пост): если каждый активный пользователь пишет в среднем 1 пост в день, это примерно 50 млн / 86400 ≈ 580 RPS (write-light).
  • Latency на загрузку ленты: <500 мс.
  • Consistency: eventual — допускаем задержку в появлении поста в ленте на несколько секунд.

Нагрузка

Extreme read-heavy: на каждый пост приходится ~100–1000 чтений (подписчики читают). Write-light: пост создаётся редко. Профиль пиковой нагрузки: вечер и выходные.

Fan-out проблема: один новый пост должен появиться в лентах всех подписчиков. Если у пользователя 1 млн подписчиков, это 1 млн обновлений.

High-level архитектура

[Client]
   ↓
[API Gateway]
   ↓
[Feed Service] (read) & [Post Service] (write)
   ↓
[Feed Cache (Redis)] (проекция фида для user)
   ↓
[Post Storage] (хранилище постов)
   ↓
[Graph Database] (граф подписок) or [Cache]
   ↓
[Message Queue (Kafka)] (события: новый пост, лайк)
   ↓
[Async Processors] (обновление фидов, аналитика)

Feed Service: Получает ленту пользователя. Сначала кеш (Redis), потом собирает из источников (подписки), если нужно.

Post Service: Создаёт новый пост, пишет в Post Storage. Публикует событие в Kafka.

Post Storage: RDBMS или NoSQL, хранит посты.

Feed Cache: Redis с проекцией ленты пользователя (первые 100–1000 постов). Инвалидируется при новом посте в источниках.

Graph Database или Cache подписок: Хранит граф подписок (user → followers). Используется для fan-out.

Kafka: События типа "new_post{user_id, post_id, timestamp}".

Async Processors: Слушают события, обновляют feed cache'и подписчиков.

Push vs pull-модель формирования фида

Это ключевое решение.

Fan-out on write (push-модель):

При создании поста сразу обновляем feed cache'и всех подписчиков.

Псевдокод:

createPost(userId, content):
  post = storePost(userId, content)
  followers = getFollowers(userId)  // Из cache или DB
  for follower_id in followers:
    addToFeedCache(follower_id, post)
  publishEvent("newPost", post)

Плюсы:

  • Read операция быстрая: feed уже готов в cache.
  • Пользователь видит ленту сразу при открытии.

Минусы:

  • Write операция дорогая. Если пользователь имеет 1 млн followers, нужно обновить 1 млн записей.
  • Проблема "Oprah Effect": если влиятельный пользователь имеет 10 млн followers и постит, система перегружается.
  • Waste on спам: если пользователь часто постит, обновляем feed много раз.

Fan-out on read (pull-модель):

При чтении ленты собираем посты из подписок в реальном времени.

Псевдокод:

getFeed(userId):
  following = getFollowing(userId)  // Кого пользователь подписан
  posts = mergeSorted(
    getPostsFrom(following[0]),
    getPostsFrom(following[1]),
    ...
  )
  return posts[:100]  // Первые 100

Плюсы:

  • Write операция дешёвая. Просто сохраняем пост.
  • Избегаем "Oprah Effect".
  • Гибкость: можно менять алгоритм фида без переобработки всех данных.

Минусы:

  • Read операция дорогая. Нужно запросить посты у всех источников (подписок) и смержить.
  • Высокая latency (~1–2 сек вместо <200 мс).
  • Нагрузка на запросы во время пиков чтения.

Гибридный подход:

Используем обе модели:

  • Для активных пользователей и hot posts — fan-out on write. Feed рассчитывается заранее.
  • Для неактивных пользователей и cold posts — fan-out on read. Экономим memory на cache.
  • Граница: если пользователь имеет >1 млн followers, используем fan-out on read при их постинге. Если пользователь имеет <1000 followers, fan-out on write.

На собеседовании выбор зависит от требований:

«Для социальной сети типа Twitter или Instagram, где большинство пользователей имеют <10К followers, push (fan-out on write) работает хорошо. Кешируем первые 1000 постов в Redis, очень fast read. Но для случаев типа Oprah (10 млн followers), переходим на pull, потому что push был бы слишком дорогим. Или комбинируем: push для малых аудиторий, pull для больших.»

Модели данных

Таблица posts:

Поле Тип
post_id BIGINT PRIMARY KEY
user_id BIGINT
content TEXT
created_at TIMESTAMP
updated_at TIMESTAMP
like_count INT
comment_count INT

Индексы:

  • PRIMARY KEY на post_id.
  • INDEX на (user_id, created_at) для получения постов пользователя.

Таблица followers (граф подписок):

Поле Тип
follower_id BIGINT
following_id BIGINT
created_at TIMESTAMP
PRIMARY KEY (follower_id, following_id)

Таблица likes:

Поле Тип
post_id BIGINT
user_id BIGINT
created_at TIMESTAMP
PRIMARY KEY (post_id, user_id)

Feed cache (Redis):

user_feed:{user_id} → [post_id1, post_id2, ...]  (TTL: 1 час или event-based invalidation)

Кеширование

Кеш популярных постов: Posts, которые получают много лайков/комментариев, кешируются отдельно. При чтении ленты сначала показываем popular posts, потом recent.

Кеш ленты: Первые 100–1000 постов в feed cache'е. При прокрутке вниз загружаем следующий batch (pagination).

Инвалидация: При лайке поста обновляем like_count и инвалидируем кеш популярных постов. При новом посте инвалидируем feed cache'и подписчиков (в push-модели) или не инвалидируем (в pull-модели).

Масштабирование

Шардирование по пользователям: Feed cache per user шардируется по user_id. User 1–1000 на Redis shard 1, и т.д. Даёт горизонтальную масштабируемость.

Шардирование по постам: Post Storage шардируется по post_id или по user_id. Например, посты user 1–1000 в БД 1, и т.д.

Разделение горячих и холодных данных: Recent posts (< 7 дней) в быстром хранилище (SSD). Old posts в archival storage (объектное хранилище типа S3). При чтении старой истории идём туда.

Async обработка: Аналитика постов (вычисление engagement score), обновление feed cache'ей — выполняется асинхронно через Kafka.

Пример того, как Senior описывает дизайн фида

«Социальная лента. Требования: показ постов подписок, лайки, комментарии, масштаб 100 млн DAU, 11 К RPS на чтение, <500 мс latency.

High-level: Post Service создаёт посты, Feed Service читает фид. Redis cache для быстрого read. Graph хранит подписки. Kafka для асинхронных событий.

Ключевое решение: push vs pull. Я выбираю push (fan-out on write) для большинства пользователей, потому что read-latency критична, и большинство имеют <100 К followers. Когда пользователь постит, обновляем feed cache'и его подписчиков. Read операция: просто достаём из cache.

Исключение: influencers с >1 млн followers. Для них используем pull: пост сохраняется, но не кешируется в feed'ы 1 млн пользователей. Вместо этого при чтении ленты pull'им посты из источников (followers).

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

На новый пост: стохастически обновляем feed cache'и подписчиков async через Kafka. Это даёт eventual consistency: фид обновляется в течение нескольких секунд, но не критично.

Масштабирование: feed cache шардируется по user_id, posts по post_id. Горячие данные (recent posts) на SSD, cold на S3.

Trade-off: push — быстрый read, но дорогой write. Pull — дешевый write, но медленный read. Гибридный подход балансирует оба.

Что углубить: детали шардирования? Как считаем engagement score? Как обрабатываем удаление постов?»

Задача 4: Проектирование сервиса хранения файлов (аналог S3)

Требования

Система для хранения больших бинарных объектов:

  1. Upload: загрузка файла на сервер.
  2. Download: скачивание файла.
  3. Метаданные: описание файла, размер, тип, владелец.
  4. Права доступа (ACL): кто может читать, писать, удалять.
  5. Версионирование (опционально): хранение нескольких версий файла.

Нефункциональные требования:

  • Объём хранилища: ПБ (петабайты).
  • RPS upload: примерно 100–1000 RPS.
  • RPS download: примерно 10 000–100 000 RPS (читают намного чаще).
  • Latency upload: <5 сек.
  • Latency download: <1 сек.
  • Долговечность данных: 99.99999% (шесть девяток).
  • Availability: 99.9%.

Нагрузочные характеристики

Extreme read-heavy: большинство операций — скачивание. Write операции — редко (создание объектов). Профиль: пики в рабочее время (download'ы).

Размеры файлов варьируются от КБ до ГБ. Для больших файлов нужна загрузка по частям (multipart upload).

High-level архитектура

[Client]
   ↓
[API Gateway]
   ↓
[Metadata Service] (управление метаданными, ACL)
   ↓
[Upload/Download Service] (обработка upload'а и download'а)
   ↓
[Blob Storage] (хранилище данных, распределённое)
   ↓
[Replication Manager] (репликация, долговечность)
   ↓
[CDN/Edge Cache] (для быстрого download'а)

Metadata Service: Хранит информацию о файлах: bucket, key, size, owner, permissions, version.

Upload/Download Service: Обрабатывает загрузку и скачивание. Для больших файлов поддерживает parallel chunks.

Blob Storage: Распределённое хранилище данных (пример: HDFS, Ceph, или собственное решение). Данные разбиты на nodes по консistent hashing.

Replication Manager: Обеспечивает репликацию данных для долговечности. Например, каждый blob реплицируется на 3 ноды в разных зонах.

CDN/Edge Cache: Для download'а статических файлов используется CDN (Content Delivery Network). Кешируется на edge серверах близко к пользователям.

Хранение и репликация

Chunking (разбиение на части): Большой файл разбивается на parts (chunks) размером, например, 100 МБ. Каждая часть хранится отдельно, что позволяет параллельную загрузку и скачивание.

Пример: файл 1 ГБ → 10 parts по 100 МБ. Клиент загружает 10 parts параллельно, каждый на свой сервер.

Репликация: Каждый blob (или part) реплицируется на N ноды (обычно 3). Если один диск упадёт, данные остаются на других. Репликация может быть:

  • Синхронная: Write подтверждается, когда данные записаны на все 3 ноды. Медленнее, но гарантирует immediate durability.
  • Асинхронная: Write подтверждается, когда записано на 1 ноду, остальные реплицируются в фоне. Быстрее, но риск loss при сбое.

На собеседовании: «Использую асинхронную репликацию для скорости upload'а. Данные записываются на primary ноду, затем асинхронно реплицируются на 2 secondary ноды. Если primary падает до репликации, есть риск loss, но на практике это очень редко.»

Quorum-based consistency: При write требуем, чтобы N/2+1 нод подтвердили запись. При read требуем, чтобы N/2+1 ноды были доступны (ensuring freshness). Это балансирует между availability и consistency.

Модель данных

Таблица objects (метаданные):

Поле Тип Описание
object_id UUID PRIMARY KEY Уникальный ID объекта
bucket_id UUID ID bucket'а (namespace)
key VARCHAR(1024) Ключ объекта (как путь)
size BIGINT Размер в байтах
content_type VARCHAR(100) MIME type
owner_id BIGINT Владелец
created_at TIMESTAMP Когда создан
updated_at TIMESTAMP Когда обновлён
version_id BIGINT (опционально) Версия при версионировании

Индексы:

  • PRIMARY KEY на object_id.
  • COMPOSITE INDEX на (bucket_id, key) для быстрого поиска по ключу.

Таблица blocks (части файла):

Поле Тип
block_id UUID PRIMARY KEY
object_id UUID FOREIGN KEY
block_index INT
size INT
hash VARCHAR(64)
created_at TIMESTAMP

Индексы:

  • PRIMARY KEY на block_id.
  • INDEX на (object_id, block_index) для получения блоков объекта в порядке.

Таблица acl (права доступа):

Поле Тип
object_id UUID
user_id BIGINT
permission ENUM
PRIMARY KEY (object_id, user_id, permission)

Доставка

Прямые ссылки на скачивание: Пользователь может скачать файл напрямую по ссылке: https://storage.example.com/bucket/key. Сервер находит metadata, проверяет права, затем стримирует данные.

Pre-signed URL (временные ссылки): Сервер может генерировать временную ссылку (действительную 1 час), которая позволяет скачать файл без аутентификации. Полезно для sharing.

Пример: https://storage.example.com/bucket/key?signature=xxx&expires=2025-11-27T15:00:00Z

CDN: Для часто скачиваемых файлов используется CDN (например, CloudFront). Файл кешируется на edge-серверах, closer to users, что снижает latency и разгружает origin.

Безопасность и доступ

Аутентификация: Клиент подтверждает личность (например, AWS IAM keys). Сервис проверяет credentials перед операцией.

Авторизация (ACL): На каждый объект может быть установлена ACL, определяющая, кто может read/write/delete.

Шифрование: На транспортном уровне TLS (HTTPS). На уровне покоя (at-rest) данные могут быть зашифрованы с master key, хранящейся отдельно.

На собеседовании: «На уровне требований используем TLS на транспорте. Для очень sensitive данных можем добавить encryption at-rest, но это усложняет систему (нужно управление ключами).»

Масштабирование

Горизонтальное масштабирование фронт-сервисов: Upload/Download Service может быть несколько инстансов за Load Balancer. Каждый обрабатывает некоторое количество запросов.

Распределение объектов по хранилищам: Используется consistent hashing (или похожее) для распределения блобов по ноды. Если blob_id = hash(bucket, key), то blob находится на ноде с ID = closest(hash(blob_id)).

При добавлении новой ноды некоторые блобы перемещаются на неё (re-hashing). Это операция в фоне.

Разделение по географии: Данные реплицируются не только на разные ноды, но и на разные регионы (US, EU, APAC). Download'ы направляются на ближайший регион для latency.

Tiered storage: Recent/hot data на SSD (быстро), old/cold data на HDD или архивное хранилище (дешево).

Пример того, как Senior описывает дизайн хранилища

«Сервис хранения файлов. Требования: upload/download ПБ данных, 99.99999% durability, <1 сек latency на download.

High-level: API обрабатывает upload/download, Metadata Service хранит мета, Blob Storage распределённо хранит данные, Replication Manager обеспечивает репликацию.

На upload больших файлов: поддерживаем multipart upload — клиент загружает 100-мегабайтные chunks параллельно. Каждый chunk пишется на Blob Storage.

На Blob Storage: используем consistent hashing для распределения блобов по ноды. Каждый blob реплицируется на 3 ноды в разных зонах. Асинхронная репликация: write возвращается клиенту, как только записано на primary ноду, затем secondary ноды реплицируют в фоне.

Долговечность 99.99999% означает, что в год может быть потеря в наносекунды. На 1 млрд файлов это значит, что в год теряется ~1 файл. Как мы достигаем? Репликация на 3 ноды в разных зонах снижает вероятность одновременной потери до negligible.

На download: часто скачиваемые файлы кешируются на CDN. Клиент скачивает из ближайшего edge, что даёт latency <500 мс. На cold download идём на origin.

Масштабирование: инстансы Upload/Download Service горизонтально масштабируются. Blob Storage узлов добавляются по мере роста данных, consistent hashing автоматически перераспределяет нагрузку.

Безопасность: TLS на транспорте, ACL на объектах, аутентификация через ключи.

Trade-off: синхронная vs асинхронная репликация. Выбираю асинхронную для latency, готов принять small risk.

Углубить: детали consistent hashing? Как обновляем metadata при multipart upload? Как обрабатываем сбой ноды во время upload'а?»

Сравнение решений и общие паттерны

Какие элементы повторяются во всех задачах

API слой (Gateway): На фронте всегда API Gateway, который балансирует нагрузку, обрабатывает аутентификацию, rate limiting. На собеседовании это не обсуждается подробно (base component), но упоминается.

Кеши (Cache Layer): Почти во всех задачах есть кеш (Redis, Memcached):

  • URL shortener: кеш маппинга short → long.
  • Чат: кеш online-статусов, recent messages.
  • Лента новостей: кеш ленты пользователя, popular posts.
  • Хранилище файлов: CDN кеш для download'ов.

Кеши решают задачу read-heavy нагрузки, уменьшают latency, разгружают основное хранилище.

Очереди/Message Bus (Kafka, RabbitMQ): Для асинхронной обработки:

  • URL shortener: очередь для статистики.
  • Чат: очередь для офлайн-сообщений, аналитики.
  • Лента новостей: очередь для обновления feed cache'ей.
  • Хранилище файлов: очередь для репликации, cleanup.

Очереди декаплируют компоненты, позволяют обрабатывать пики нагрузки, гарантируют at-least-once доставку.

Хранилища данных (Database/Object Storage): RDBMS (PostgreSQL, MySQL) для структурированных данных, NoSQL (MongoDB, DynamoDB) для неструктурированных, Object Storage (S3, GCS) для больших бинарных объектов. Выбор зависит от профиля данных.

Балансировщики нагрузки (Load Balancers): Распределяют трафик между инстансами сервиса. Могут быть простые (round-robin) или умные (based on load, latency).

Мониторинг и логирование: На собеседовании кратко упоминается: «Логируем все операции, собираем метрики (RPS, latency, error rate), алертим на anomalies.»

Явное отделение горячих и холодных данных

Это паттерн, повторяющийся во всех больших системах:

  • Hot data: Часто запрашиваемые, свежие данные. Хранятся в памяти (кеш) или на быстром хранилище (SSD).
  • Cold data: Редко запрашиваемые, старые данные. Хранятся на дешёвом, медленном хранилище (HDD, архив).

Примеры:

  • URL shortener: recent links в cache, old links на диск.
  • Чат: recent messages в памяти, old messages на archival storage.
  • Лента новостей: recent posts в feed cache, old posts на S3.
  • Хранилище файлов: часто скачиваемые файлы на SSD, старые на архив.

На собеседовании это показывает понимание cost-effectiveness: «Сохранять всё на SSD дорого. Вместо этого разделяем по горячести и храним hot data быстро, cold data дешево.»

Асинхронная обработка тяжёлых частей

Вместо синхронной обработки (клиент ждёт результата), некоторые операции выполняются асинхронно:

  • URL shortener: Статистика кликов логируется асинхронно (не блокируем редирект).
  • Чат: Доставка офлайн-сообщений асинхронно.
  • Лента новостей: Обновление feed cache'ей асинхронно при новом посте.
  • Хранилище файлов: Репликация блобов асинхронно.

Это даёт низкую latency на критичные операции (например, редирект), в то время как некритичные части обрабатываются в фоне.

Какие вопросы всегда стоит себе задавать

Где source of truth? Где хранятся данные, которые считаются истиной? Для URL shortener — БД (short → long маппинг). Кеш — это просто копия, её loss не критичен.

Что кешируем и как инвалидируем? Кеш помогает read'ам, но нужно управлять consistency. Если пост удалили, нужно его удалить из feed cache'я.

Где узкие места по чтению и записи? Если система read-heavy, кеши и replicas для чтения. Если write-heavy, нужна масштабируемость на запись (шардирование, partitioning).

Как выдерживаем пики? Очереди буферизируют нагрузку. Например, при миллионе постов в минуту не обрабатываем всё синхронно, а кидаем в очередь.

Как будем эволюционировать архитектуру? Начинаем с простого монолита, потом разделяем на сервисы. Какой порядок разделения?

На собеседовании проговаривание этих вопросов показывает систематичность мышления.

Типичные ошибки при решении типовых задач System Design

Сразу прыгать в детали реализации вместо архитектуры

Неправильно: «Я используем Spring Boot, создам контроллер @RestController, аннотирую @GetMapping, используя JPA @Entity…»

Правильно: «На высоком уровне есть API слой, слой бизнес-логики, слой данных. API обрабатывает запрос, бизнес-логика делает вычисления, слой данных сохраняет. Давайте разберём, какие компоненты нужны на каждом слое…»

System Design — это архитектура, не реализация. Интервьюер не интересуется синтаксисом Java или названиями аннотаций. Его интересует: как система масштабируется? Как данные организованы?

Игнорирование нагрузок и чисел

Неправильно: «Много пользователей, много данных, используем базу данных, она справится.»

Правильно: «Пусть 100 млн DAU, каждый создаёт 1 пост в день. Это 100 млн / 86400 ~ 1200 RPS на запись. Плюс читают посты 10 раз в день — 12 000 RPS на чтение. Это означает, что read-heavy, кеши спасят.»

Цифры показывают понимание. Без цифр кажется, что кандидат не знает, как оценивать.

Отсутствие обсуждения данных и хранилищ

Неправильно: «Используем RabbitMQ везде, потому что это популярно.»

Правильно: «Хранилище данных — критичное решение. Для маппинга short → long нужна быстрая lookup, поэтому RDBMS с индексом. Для статистики кликов нужна эффективная append, поэтому time-series БД (InfluxDB) или Kafka + batch write.»

Data modeling и storage selection — это основа System Design.

Отсутствие кешей и очередей там, где очевидны read-heavy и burst-нагрузки

Неправильно: «Все запросы идут напрямую в БД.»

Правильно: «БД справляется с 1200 RPS на чтение, но если пики в 10 раз, это 12 000 RPS. Добавляем Redis кеш для горячих данных, hit rate ~80%, значит, только 20% запросов идут в БД — ~2400 RPS, что выдержит.»

Кеши и очереди — это не optional. Это основные компоненты больших систем.

Перегрузка buzzword'ами без связи с требованиями

Неправильно: «Используем Kafka, CQRS, Event Sourcing, микросервисы, Kubernetes, все потому что они крутые.»

Правильно: «Для этой задачи нужна асинхронная обработка, поэтому Kafka. CQRS не нужен, потому что consistency requirements невысокие. Event Sourcing было бы полезно, если нужна полная история, но это усложняет. Микросервисы можем рассмотреть, если требует scale, но пока хватит простой архитектуры.»

Buzzword'ы без обоснования показывают поверхностное знание. Senior разработчик объясняет, зачем нужно каждое решение.

Неспособность объяснить масштабирование и восстановление после сбоев

Неправильно: «Система масштабируется с помощью облака.»

Правильно: «При добавлении нагрузки горизонтально масштабируем инстансы сервиса за Load Balancer. Хранилище шардируем по ключу, каждый shard реплицируется на 3 ноды. При падении ноды, failover автоматический на другую.»

Конкретные механизмы масштабирования и отказоустойчивости — это важно.

Краткие рекомендации

Чтобы звучать как Senior, а не как человек, выучивший один паттерн:

  1. Говори чисел. Не «много RPS», а конкретно: «1200 RPS на чтение». Это показывает, что ты считал, а не угадал.

  2. Обсуждай trade-off'ы. Не выбирай один вариант, обсуди две-три альтернативы, их плюсы и минусы, и объясни, почему выбрал именно этот.

  3. Будь готов к follow-up. Если интервьюер спрашивает: «А что если…?» — не пугайся. Это показывает интерес. Переадаптируй решение.

  4. Говори о данных. Где они хранятся? Как реплицируются? Как гарантируем consistency? Это не детали, это основа.

  5. Упоминай observability. Логи, метрики, алерты. Как будет видно, что система работает или падает?

  6. Будь готов углублять. На каждый компонент можно потратить отдельный собеседование. Если интервьюер просит углубить, это хороший знак.

  7. Не торопись. Лучше медленно, но системно, чем быстро, но хаотично. На собеседовании это больше ценится.

Практика формулировок ответа

Пример реплик кандидата, показывающие структурированный заход

Начало: «Хорошо, давайте разберём эту задачу систематично. Сначала уточню требования:

  1. Функциональные: что система должна делать?
  2. Нефункциональные: какая нагрузка, latency, availability?
  3. Оценю RPS и объёмы.
  4. Предложу high-level архитектуру.
  5. Разберу модель данных.
  6. Обсужу масштабирование.

Ты согласен с таким порядком, или ты хочешь сосредоточиться на чём-то конкретном?»

Проговаривание допущений и чисел: «Я буду исходить из предположения, что система должна поддерживать 100 млн DAU. Каждый пользователь в среднем выполняет 10 операций в день. Это примерно 100 млн * 10 / 86400 ~ 11 500 RPS на пике (предполагая неравномерное распределение).

Также предполагаю, что нужна eventual consistency, потому что real-time sync дорого. Ты согласен с этими предположениями?»

Ясные trade-off'ы: «У меня есть два варианта для хранения: RDBMS или NoSQL. RDBMS гарантирует ACID и простую консистентность, но сложнее масштабировать. NoSQL легче масштабировать, но нужно самому управлять консистентностью.

Я выбираю RDBMS, потому что требования к консистентности высокие, и data model структурирован. Если бы требовалась extreme масштабируемость, переходил бы на NoSQL.»

Как завершать ответ

Краткое резюме решения: «Итак, в summarize: система состоит из API Gateway, сервисов бизнес-логики, Redis cache для hot data, PostgreSQL для persistent storage, Kafka для асинхронной обработки. На чтение идём в cache, на запись — в БД и кеш. Масштабируем горизонтально сервисы и добавляем read replicas на БД. Отказоустойчивость обеспечивается master-slave репликацией и автоматическим failover.»

Указание, какие части можно углубить: «Если интересует, можем углубить несколько направлений:

  1. Как организуем шардирование при дальнейшем росте?
  2. Как гарантируем, что очередь не будет отставать при пиках?
  3. Как тестируем систему на отказоустойчивость (chaos engineering)?
  4. Как мониторим и алертим?

Что из этого интересует больше всего?»

Такая структура показывает Senior уровень: система, ты не просто рассказал про компоненты, а продумал возможные углубления и готов к follow-up.


Финальные мысли:

Типовые задачи System Design на собеседования — это не случайность. Они проверяют именно то, что нужно Senior Backend разработчику: умение анализировать требования, считать нагрузки, выбирать архитектурные решения с обоснованием и масштабировать системы. Подготовка на этих четырёх задачах (URL shortener, чат, лента новостей, хранилище файлов) дает solid foundation для любой новой задачи. Ключ — не зубрить готовые решения, а понимать logic за каждым выбором, чтобы адаптировать на новые requirements.

Системный дизайн Messenger Avito

Шаг 1: Сбор требований и уточнения (7-10 минут)

Функциональные требования (базовые + дополнительные)

Базовые (из задачи):

  • ✅ Отправить сообщение
  • ✅ Принять и прочитать сообщение
  • ✅ Чаты связаны с объявлениями (инициализация через "Написать")
  • ✅ P2P чаты продавец-покупатель
  • ✅ Просмотр списка чатов
  • ✅ Real-time доставка (<3 секунд)

Дополнительные вопросы интервьюеру:

Q: Нужны ли статусы сообщений (отправлено/доставлено/прочитано)? A: Да, как минимум "доставлено" и "прочитано"

Q: Поддержка медиа-файлов (фото товара, документы)? A: Да, изображения обязательно для демонстрации товара

Q: Нужна ли история сообщений и как долго хранить? A: Да, минимум 1 год для разрешения споров

Q: Ограничения на размер сообщений? A: Текст до 1000 символов, изображения до 10MB

Q: Блокировка пользователей/спам-защита? A: Да, возможность заблокировать в чате + детекция спама

Q: Уведомления (push/email)? A: Push-уведомления обязательно, email опционально

Q: Групповые чаты с несколькими покупателями? A: Нет, только P2P

Нефункциональные требования

Производительность:

  • Latency: <3 сек для доставки сообщений
  • Throughput: поддержка пиковых нагрузок
  • Concurrent users: до 500K одновременно

Масштабируемость:

  • Пользователи: 50M зарегистрированных
  • DAU: 5M активных пользователей в день
  • Объявления: 100M активных объявлений

Надёжность:

  • Availability: 99.9% (≈8.76 часа простоя в год)
  • Durability: сообщения не должны теряться
  • Backup: ежедневные бэкапы чатов

Безопасность:

  • Authentication: интеграция с Avito auth
  • Privacy: только участники чата видят сообщения
  • Moderation: детекция спама и неприемлемого контента

Шаг 2: Расчёт нагрузки и объёма данных (8-10 минут)

Пользовательская активность

Базовые метрики:

- Зарегистрированных пользователей: 50M
- DAU (Daily Active Users): 5M  
- Активных объявлений: 100M
- Соотношение продавец/покупатель: 1:4

Активность в чатах:

- % пользователей, использующих чаты: 40% = 2M/день
- Новых чатов в день: 500K (10% от объявлений имеют активность)
- Сообщений на чат в среднем: 8 (2-3 обмена)
- Общих сообщений в день: 500K × 8 = 4M

Расчёт QPS (Queries Per Second)

Сообщения:

- Средний QPS: 4M / (24×3600) = 46 QPS
- Peak QPS: 46 × 10 = 460 QPS (вечерние часы)

API вызовы:

- Отправка сообщений: 460 QPS
- Получение истории чатов: 460 × 5 = 2,300 QPS  
- Список чатов пользователя: 2M / (24×3600) = 23 QPS
- Проверка новых сообщений: 2,300 QPS
- Обновление статусов: 460 × 2 = 920 QPS

Общий API QPS: ~6,200 QPS

Объём данных

Сообщения:

Структура сообщения:

- message_id: 8 байт
- chat_id: 8 байт  
- sender_id: 8 байт
- content: 200 символов × 2 = 400 байт (средний текст)
- created_at: 8 байт
- message_type: 1 байт
- status: 1 байт
- Итого: ~440 байт на сообщение

Ежедневный объём:

- 4M сообщений × 440 байт = 1.76GB/день
- За год: 1.76GB × 365 = 642GB
- За 3 года (с учётом роста): ~2TB

Чаты:

Структура чата:

- chat_id: 8 байт
- listing_id: 8 байт
- buyer_id: 8 байт
- seller_id: 8 байт
- created_at: 8 байт
- last_message_at: 8 байт
- status: 1 байт
- Итого: ~50 байт на чат

Объём чатов:

- 500K новых чатов/день × 50 байт = 25MB/день
- За год: 25MB × 365 = 9GB (незначительно)

Медиа-файлы:

Предположения:

- 30% сообщений содержат изображения
- Средний размер изображения: 2MB
- Сжатие и thumbnails: ×1.5 = 3MB на изображение

Объём медиа:

- 4M × 30% × 3MB = 3.6TB/день
- За год: 3.6TB × 365 = 1.3PB

Пропускная способность сети

Ingress (входящий):

- Текстовые сообщения: 460 QPS × 440 байт = 202KB/s
- Изображения: 138 QPS × 2MB = 276MB/s
- Общий ingress: ~280MB/s

Egress (исходящий):

- Доставка сообщений: 460 QPS × 440 байт × 2 = 404KB/s
- Отдача изображений: через CDN = 500MB/s
- История чатов: 2,300 QPS × 10KB = 23MB/s
- Общий egress: ~530MB/s

Шаг 3: Выбор типа взаимодействия (5 минут)

Анализ альтернатив для real-time коммуникации

1. HTTP Polling ❌

GET /api/chats/{chat_id}/messages/new каждые 5 секунд

Плюсы:

- Простота реализации
- Работает через любые proxy/firewall

Минусы:

- Высокая латентность (2.5 сек в среднем)
- Много пустых запросов (95%+ waste)
- Нагрузка на сервер: 2M пользователей × 0.2 QPS = 400K QPS
- НЕ соответствует требованию <3 сек

2. HTTP Long Polling ⚠️

GET /api/chats/{chat_id}/messages/wait (hold до 30 сек)

Плюсы:

- Низкая латентность (~100ms)
- Меньше waste запросов
- Fallback для старых браузеров

Минусы:

- Сложность управления connections
- Проблемы с proxy timeout
- Держит HTTP connections открытыми
- Сложность load balancing

3. Server-Sent Events (SSE) ⚠️

GET /api/chats/{chat_id}/events (EventSource)

Плюсы:

- Встроенная browser поддержка
- Automatic reconnection
- One-way от сервера к клиенту

Минусы:

- Только server→client (нужен отдельный API для отправки)
- Ограничения concurrent connections в браузере
- Проблемы с некоторыми proxy

4. WebSocket ✅ ВЫБОР

WS /api/chats/connect

Плюсы:

- Full-duplex communication
- Низкая латентность (<100ms)
- Эффективное использование bandwidth
- Нативная поддержка в браузерах
- Ideal для chat applications

Минусы:

- Сложность в поддержке (reconnection logic)
- Статeful connections (sticky sessions)
- Больше memory на сервере

ОБОСНОВАНИЕ: WebSocket optimal для chat с требованием <3 сек

Шаг 4: API Design (5 минут)

REST API + WebSocket

# === AUTHENTICATION ===
POST /api/v1/auth/token
{
  "avito_token": "...",
  "device_id": "mobile_123"
}

# === CHAT MANAGEMENT ===
# Создание чата (инициация из объявления)
POST /api/v1/chats
{
  "listing_id": 12345,
  "initial_message": "Здравствуйте, интересует ваш товар"
}
Response: {
  "chat_id": "550e8400-e29b-41d4-a716-446655440000",
  "listing_id": 12345,
  "participant_ids": [67890, 54321],
  "created_at": "2025-01-14T10:30:00Z"
}

# Получение списка чатов пользователя
GET /api/v1/chats?limit=20&offset=0
Response: {
  "chats": [
    {
      "chat_id": "550e8400-e29b-41d4-a716-446655440000",
      "listing_title": "iPhone 14 Pro",
      "participant_name": "Иван Петров",
      "last_message": "Когда можно посмотреть?",
      "last_message_at": "2025-01-14T15:20:00Z",
      "unread_count": 2,
      "chat_status": "active"
    }
  ],
  "total": 45
}

# === MESSAGE OPERATIONS ===
# Отправка сообщения (через WebSocket)
WS Message: {
  "action": "send_message",
  "chat_id": "550e8400-e29b-41d4-a716-446655440000",
  "content": "Добрый день! Товар ещё актуален?",
  "message_type": "text",
  "client_message_id": "client_123"
}

# Получение истории сообщений
GET /api/v1/chats/{chat_id}/messages?limit=50&before_message_id=abc123
Response: {
  "messages": [
    {
      "message_id": "msg_789",
      "sender_id": 67890,
      "content": "Добрый день! Товар ещё актуален?",
      "message_type": "text",
      "created_at": "2025-01-14T15:20:00Z",
      "status": "read",
      "read_at": "2025-01-14T15:21:00Z"
    }
  ]
}

# Пометка сообщений как прочитанных
POST /api/v1/chats/{chat_id}/mark_read
{
  "last_read_message_id": "msg_789"
}

# === MEDIA UPLOAD ===
POST /api/v1/media/upload
Content-Type: multipart/form-data
Response: {
  "media_id": "media_456",
  "url": "https://cdn.avito.ru/chat/images/media_456.jpg",
  "thumbnail_url": "https://cdn.avito.ru/chat/thumbs/media_456_thumb.jpg"
}

# === WEBSOCKET EVENTS ===
WS /api/v1/chats/connect?auth_token=jwt_token

# Входящие события:
{
  "event": "message_received",
  "chat_id": "550e8400-e29b-41d4-a716-446655440000", 
  "message": {
    "message_id": "msg_999",
    "sender_id": 54321,
    "content": "Да, товар доступен",
    "created_at": "2025-01-14T15:25:00Z"
  }
}

{
  "event": "message_status_updated",
  "message_id": "msg_789",
  "status": "read"
}

{
  "event": "typing_indicator",
  "chat_id": "550e8400-e29b-41d4-a716-446655440000",
  "user_id": 54321,
  "typing": true
}

Шаг 5: Database Schema (7 минут)

Выбор архитектуры: монолит vs микросервисы

Аргументация выбора микросервисов:

Почему микросервисы для Avito Messenger:

1. BUSINESS ALIGNMENT:

   - Chat Service независим от основного Avito
   - Разные команды могут развивать параллельно
   - Различные SLA требования

2. TECHNICAL BENEFITS:

   - Независимое масштабирование (chat vs listings)
   - Разные технологии (WebSocket servers vs CRUD APIs)
   - Изоляция отказов

3. AVITO CONTEXT:

   - Уже существует микросервисная архитектура
   - Интеграция с User Service, Listing Service
   - Потребуется интеграция с уведомлениями

Альтернатива (монолит):

- Проще в начале
- Меньше network overhead
- НО: не масштабируется для разных команд Avito

Микросервисная схема

1. Chat Service Database (PostgreSQL)

-- Чаты
CREATE TABLE chats (
    chat_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    listing_id BIGINT NOT NULL,
    buyer_id BIGINT NOT NULL,
    seller_id BIGINT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_message_at TIMESTAMP,
    last_message_id UUID,
    chat_status VARCHAR(20) DEFAULT 'active', -- active, blocked, archived
    buyer_unread_count INT DEFAULT 0,
    seller_unread_count INT DEFAULT 0
);

-- Индексы для списка чатов пользователя  
CREATE INDEX idx_chats_buyer ON chats(buyer_id, last_message_at DESC);
CREATE INDEX idx_chats_seller ON chats(seller_id, last_message_at DESC);
CREATE INDEX idx_chats_listing ON chats(listing_id);

-- Составной индекс для уникальности чата
CREATE UNIQUE INDEX idx_chats_unique ON chats(listing_id, buyer_id, seller_id);

2. Message Service Database (Cassandra - обоснование выбора)

Почему Cassandra для сообщений:

1. WRITE HEAVY: 460 QPS записи, высокая write throughput
2. TIME-SERIES: сообщения append-only с timestamp ordering  
3. PARTITION TOLERANCE: автоматический sharding
4. SCALABILITY: linear scaling при добавлении узлов

Альтернативы:

- PostgreSQL: ограниченная write scalability
- MongoDB: хорошо, но Cassandra лучше для time-series
-- Cassandra schema
CREATE TABLE messages (
    chat_id UUID,
    message_id UUID,
    created_at TIMESTAMP,
    sender_id BIGINT,
    content TEXT,
    message_type VARCHAR(20), -- text, image, document
    media_url TEXT,
    status VARCHAR(20), -- sent, delivered, read
    read_at TIMESTAMP,
    PRIMARY KEY (chat_id, created_at, message_id)
) WITH CLUSTERING ORDER BY (created_at DESC, message_id ASC);

-- Таблица для поиска сообщений по ID
CREATE TABLE messages_by_id (
    message_id UUID PRIMARY KEY,
    chat_id UUID,
    created_at TIMESTAMP,
    sender_id BIGINT,
    content TEXT,
    message_type VARCHAR(20),
    media_url TEXT,
    status VARCHAR(20),
    read_at TIMESTAMP
);

3. User Service Database (существующий Avito)

-- Подключаемся к существующему User Service
-- Получаем данные через API calls

-- Кеширование в Chat Service
CREATE TABLE user_cache (
    user_id BIGINT PRIMARY KEY,
    username VARCHAR(100),
    avatar_url VARCHAR(500),
    cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

4. Connection Service Database (Redis)

# Активные WebSocket соединения
user_connections:{user_id} = {
  "server_id": "chat-server-3",
  "socket_id": "ws_12345",
  "connected_at": "2025-01-14T10:30:00Z"
}
TTL: 300 seconds (heartbeat)

# Typing indicators
typing:{chat_id} = {
  "user_id": 67890,
  "expires_at": 1705234200
}
TTL: 10 seconds

# Message delivery queue для offline пользователей
offline_messages:{user_id} = [
  {
    "chat_id": "550e8400-e29b-41d4-a716-446655440000",
    "message_id": "msg_123",
    "created_at": "2025-01-14T10:30:00Z"
  }
]
TTL: 30 days

Ключи шардирования

Chat Service (PostgreSQL):

-- Шардинг по user_id для списка чатов
-- Проблема: как получить чаты где пользователь = buyer ИЛИ seller?

-- Решение: денормализация
CREATE TABLE user_chats (
    user_id BIGINT,
    chat_id UUID,
    role VARCHAR(10), -- buyer, seller
    listing_id BIGINT,
    other_user_id BIGINT,
    last_message_at TIMESTAMP,
    unread_count INT DEFAULT 0,
    PRIMARY KEY (user_id, last_message_at DESC, chat_id)
);

-- Шардинг: user_id % 16 = shard_number
-- Один запрос получает все чаты пользователя

Message Service (Cassandra):

Ключ партиционирования: chat_id
Clustering key: created_at DESC, message_id

Преимущества:

- Все сообщения чата на одном узле
- Эффективные range queries по времени
- Автоматическое распределение по chat_id

Недостатки:

- Hot partition если один чат очень активный
- Но для P2P чатов это маловероятно

Шаг 6: Архитектура системы (10 минут)

Высокоуровневая архитектура

                        [Internet/Mobile Apps]
                                 |
                          [Load Balancer L7]
                                 |
                        [API Gateway (Kong)]
                                 |
          ┌─────────────────────┼─────────────────────┐
          |                     |                     |
   [Chat Service]    [Message Service]    [Media Service]
          |                     |                     |
   [PostgreSQL]           [Cassandra]            [S3 + CDN]
          |                     |
          └─────────────────────┼─────────────────────┘
                                |
                    [Redis Cluster (Connection/Cache)]
                                |
                    [Message Queue (Kafka)]
                                |
                    [Notification Service]

Детальная архитектура компонентов

1. API Gateway (Kong/Zuul)

Функции:

- Authentication (JWT validation)
- Rate limiting (10 сообщений/минуту на пользователя)
- Request routing к микросервисам  
- Request/response logging
- API versioning

Конфигурация rate limiting:

- Chat API: 100 requests/minute per user
- Message send: 10 messages/minute per user  
- Media upload: 5 uploads/minute per user

2. Chat Service (Node.js/Go)

Ответственность:

- WebSocket connection management
- Real-time message routing
- Chat creation/management
- Integration с Listing Service для проверки объявлений

WebSocket Management:

- Sticky sessions через Load Balancer
- Connection registry в Redis
- Heartbeat monitoring (30 сек)
- Graceful reconnection logic

Scaling: 5 инстансов × 10K connections = 50K concurrent

3. Message Service (Java/Scala)

Ответственность:

- Message persistence в Cassandra
- Message status updates (delivered/read)
- Message history API
- Integration с модерацией

Асинхронная обработка:

- Message Queue (Kafka) для decoupling
- Batch processing для статистики
- Dead letter queue для failed messages

4. Media Service (Go/Python)

Ответственность:

- Image upload/processing
- Thumbnail generation
- CDN integration
- Content moderation (AI для неприемлемого контента)

Processing pipeline:

- Upload → S3 staging
- Resize/optimize → multiple formats
- Upload → S3 production + CDN
- Virus scanning

Балансировка нагрузки

Load Balancer выбор и обоснование:

L4 vs L7 выбор для WebSocket:

L4 (Transport Layer):
+ Высокая производительность
+ Поддержка sticky sessions по IP
- Нет application-level routing
- Сложнее debugging

L7 (Application Layer):  ✅ ВЫБОР
+ WebSocket-aware routing
+ Health checks на application level
+ Routing по headers (user_id)
+ Better observability
- Немного больше latency

Алгоритм: Consistent Hashing по user_id
Причина: WebSocket требует sticky sessions

Конфигурация HAProxy:

# WebSocket routing
backend chat_servers
    balance source
    hash-type consistent
    stick-table type string len 32 size 100k expire 30m
    stick on hdr(X-User-ID)
    
    server chat1 10.0.1.10:8080 check
    server chat2 10.0.1.11:8080 check  
    server chat3 10.0.1.12:8080 check

Асинхронное взаимодействие (Kafka)

Message Topics:

1. chat.messages.created
   - Новые сообщения для persistence
   - Consumers: Message Service, Analytics
   
2. chat.messages.status_updated  
   - Обновления статусов (delivered/read)
   - Consumers: Message Service, Push Service
   
3. chat.moderation.required
   - Сообщения требующие модерации
   - Consumers: Moderation Service
   
4. chat.analytics.events
   - События для аналитики
   - Consumers: Analytics Service

Partitioning: по chat_id для ordering гарантий
Replication: 3 replicas для durability

Message Flow через Kafka:

1. User A отправляет сообщение через WebSocket
2. Chat Service валидирует и публикует в Kafka
3. Chat Service отправляет ACK пользователю A (status: sent)
4. Message Consumer читает из Kafka → сохраняет в Cassandra
5. Если User B online → Chat Service доставляет через WebSocket
6. Если User B offline → сохраняет в Redis queue
7. User B читает → публикует status update в Kafka

Шаг 7: Надёжность и масштабируемость (8 минут)

Обеспечение SLA 99.9%

1. Database Reliability

PostgreSQL (Chat Service):

- Primary-Secondary setup (streaming replication)
- Automatic failover с Patroni
- Connection pooling (PgBouncer): 100 connections per server
- Read replicas для read-heavy операций (список чатов)

Cassandra (Message Service):  

- 3 узла кластер, RF=3
- Multi-datacenter для disaster recovery
- Automatic repair processes
- Monitoring с Nodetool

2. Service Reliability

Circuit Breaker pattern:

- Message Service → Chat Service calls
- Chat Service → User Service calls  
- Timeout: 5 секунд
- Failure threshold: 5 errors in 1 minute
- Half-open retry: after 30 seconds

Retry logic:

- Message delivery: exponential backoff (1s, 2s, 4s, 8s)
- API calls: 3 retries with jitter
- Kafka produce: infinite retries with backoff

3. Graceful Degradation

Сценарии деградации:
1. Cassandra недоступна:

   - Сообщения в Kafka queue (retention 7 дней)
   - Read-only mode для истории
   - WebSocket продолжает работать

2. Redis недоступен:

   - WebSocket connections в memory
   - Отключение typing indicators
   - Offline messages в PostgreSQL

3. Media Service недоступен:

   - Только текстовые сообщения
   - Отложенная обработка медиа

Масштабирование

Horizontal Scaling Strategy:

Chat Service:

- Auto-scaling по CPU (target: 70%)
- Min: 3 instances, Max: 20 instances
- Rolling deployment без downtime
- Connection draining при scale-down

Message Service:  

- Stateless сервис, легко масштабируется
- Auto-scaling по Kafka consumer lag
- Min: 2 instances, Max: 10 instances

Cassandra:

- Добавление узлов без downtime
- Rebalancing данных автоматически
- Monitoring memory и CPU per node

Sharding Evolution:

Текущий план (до 10M пользователей):

- PostgreSQL: 4 шарда по user_id
- Cassandra: 3 узла, автоматическое распределение

Future (10M+ пользователей):

- PostgreSQL: 16 шардов
- Cassandra: 9 узлов (3 per datacenter)
- Read replicas по географиям

Monitoring и Alerting

Business Metrics (KPI):

1. Message Delivery Success Rate
   - Target: >99.5%
   - Alert: <99% за 5 минут
   
2. Message Delivery Latency
   - Target: p95 < 2 секунды
   - Alert: p95 > 5 секунд за 2 минуты
   
3. WebSocket Connection Success Rate
   - Target: >99%
   - Alert: <98% за 1 минуту
   
4. Chat Creation Success Rate
   - Target: >99.9%
   - Alert: <99% за 1 минуту

5. Daily Active Chats
   - Track: growth/decline trends
   - Alert: >20% drop day-over-day

Technical Metrics:

Application Level:

- API response time (p50, p95, p99)
- Error rate по endpoints
- WebSocket connection count
- Kafka consumer lag
- Memory/CPU utilization per service

Database Level:

- PostgreSQL: connection pool usage, query time, replication lag
- Cassandra: read/write latency, compaction pending, disk usage  
- Redis: memory usage, key expiration rate, connection count

Infrastructure Level:

- Network latency между сервисами
- Disk I/O на database nodes
- Load balancer connection errors
- CDN cache hit ratio для медиа

Alerting Rules:

Critical (PagerDuty):

- Message delivery failure >1% за 2 минуты
- WebSocket connection drops >10% за 1 минуту  
- Database primary down
- Kafka cluster unavailable

Warning (Slack):

- API latency p95 >3 секунды за 5 минут
- Database connection pool >80%
- Disk usage >85%
- Error rate >0.5% за 10 минут

Info (Dashboard):

- Daily/hourly traffic patterns
- User engagement metrics
- Storage growth trends

Шаг 8: Security и Moderation (3 минуты)

Authentication & Authorization

JWT Token Integration:

- Использование существующего Avito auth
- Token содержит: user_id, permissions, exp
- Refresh logic для long-lived connections

WebSocket Authentication:

- JWT в query parameter при подключении
- Periodic token refresh через ping/pong
- Automatic disconnect при expired token

Spam Protection & Moderation

Rate Limiting (по приоритету):
1. Message sending: 10 сообщений/минуту
2. Chat creation: 5 чатов/час  
3. Media upload: 3 файла/час

Content Moderation:

- Real-time keyword filtering
- ML-based spam detection (TensorFlow Serving)
- Image content analysis для неприемлемого контента
- Manual review queue для edge cases

Auto-blocking Rules:

- Identical messages >3 раза → temporary block
- Reported by >3 пользователя → auto-review
- External links → require approval

Шаг 9: Рассмотрение альтернатив (3 минуты)

Database Alternatives

Вместо Cassandra для сообщений:

MongoDB:
+ Более привычный для команды
+ Лучшие query возможности
+ Transactions support
- Меньше write throughput чем Cassandra
- Сложнее horizontal scaling

PostgreSQL + Partitioning:
+ Знакомая технология
+ ACID transactions
+ Rich query capabilities
- Write bottleneck при scale
- Сложнее sharding логика

DynamoDB:
+ Managed service (меньше ops)
+ Auto-scaling
+ Predictable performance
- Vendor lock-in
- Дороже при высокой нагрузке
- Сложнее complex queries

ВЫБОР Cassandra: оптимальна для append-only сообщений с high write load

Architecture Alternatives

Event Sourcing + CQRS:

Альтернативный подход:

- Event Store для всех chat events
- Separate read models для queries
- Perfect audit trail

Плюсы:
+ Полная история событий
+ Легко добавлять новые projections
+ Natural event-driven architecture

Минусы:

- Complexity overhead для simple chat
- Eventual consistency challenges
- Learning curve для команды

РЕШЕНИЕ: Не подходит для MVP, рассмотреть при scale >10M пользователей

Serverless Architecture:

AWS Lambda + API Gateway:
+ Auto-scaling
+ Pay per request
+ Zero server management

Минусы:

- Cold start latency (не подходит для <3 сек requirement)
- WebSocket limitations в Lambda
- Vendor lock-in
- Сложность debugging

РЕШЕНИЕ: Не подходит для real-time WebSocket requirements

Message Queue Alternatives

Redis Streams вместо Kafka:

Redis Streams:
+ Проще setup и management  
+ Lower latency
+ Integrated с Redis cache

Минусы:

- Меньше throughput чем Kafka
- Нет built-in partitioning
- Single point of failure

RabbitMQ:
+ Feature-rich messaging
+ Dead letter queues
+ Management UI

Минусы:  

- Сложнее scaling
- Меньше throughput

ВЫБОР Kafka: лучше для high-throughput и уже используется в Avito

Шаг 10: План развертывания и миграции (2 минуты)

Phase 1: MVP (месяцы 1-2)

Цель: Базовый функционал для тестирования

Компоненты:

- Chat Service (простой WebSocket server)  
- Message API (REST только)
- PostgreSQL для всех данных
- Redis для WebSocket connections
- Базовый UI integration

Ограничения:

- Только текстовые сообщения
- Без real-time (polling каждые 5 сек)
- Single datacenter
- Manual deployment

Phase 2: Production Ready (месяцы 3-4)

Добавления:

- WebSocket real-time messaging
- Cassandra для message storage
- Kafka для async processing
- Media upload support
- Load balancing + auto-scaling
- Monitoring и alerting
- CI/CD pipeline

Phase 3: Scale & Features (месяцы 5-6)

Оптимизации:

- Multi-region deployment
- Advanced moderation
- Push notifications
- Analytics dashboard
- Performance optimizations
- Disaster recovery procedures

Миграция существующих данных:

Если есть существующая система:

1. Dual-write period:

   - Новые сообщения в обе системы
   - Read from old system
   - Data consistency validation

2. Backfill historical data:

   - Batch migration в off-peak hours
   - Chat metadata migration
   - Message history migration
   - Media files migration

3. Cutover:

   - Feature flag для переключения reads
   - Monitoring для validation
   - Rollback plan готов

Заключение: Финальная архитектура

Выбранная архитектура

                    [CDN (CloudFlare)]
                           |
                    [Load Balancer L7]
                           |
                   [API Gateway (Kong)]
                           |
        ┌─────────────────┼─────────────────┐
        |                 |                 |
   [Chat Service]  [Message Service]  [Media Service]
   WebSocket+REST     REST+Kafka        Upload+CDN
        |                 |                 |
   [PostgreSQL]      [Cassandra]          [S3]
   4 shards          3-node cluster    Multi-region
        |                 |                 |
        └─────────────────┼─────────────────┘
                          |
                   [Redis Cluster]
                   Connection mgmt
                          |
                   [Kafka Cluster]
                   Async messaging
                          |
                 [Notification Service]
                   Push notifications

Ключевые решения с обоснованием:

  1. WebSocket для real-time: единственный способ достичь <3 сек latency
  2. Микросервисы: независимое развитие и масштабирование компонентов
  3. Cassandra для сообщений: оптимальна для write-heavy, time-series данных
  4. PostgreSQL для чатов: ACID для metadata, привычная технология
  5. Kafka для async: надёжная доставка и декаплинг сервисов
  6. Sharding по user_id: эффективные queries для списка чатов пользователя

Соответствие требованиям:

  • Latency <3 сек: WebSocket обеспечивает ~100ms
  • 6,200 QPS: архитектура поддерживает с запасом
  • 99.9% availability: redundancy на всех уровнях
  • Интеграция с объявлениями: через Listing Service API
  • P2P чаты: простая модель данных
  • Список чатов: эффективный sharding по user_id

Метрики успеха:

  • Business: 40% пользователей используют чаты, конверсия объявление→сделка +15%
  • Technical: p95 latency <2 сек, 99.5% delivery rate, 99.9% uptime
  • User Experience: <100ms typing indicators, instant read receipts

Эта архитектура обеспечивает надёжную основу для Messenger Avito с возможностью масштабирования до 50M пользователей и легкой интеграции с существующей экосистемой Avito.