Масштабируемость и параллелизм в системном проектировании
Модель и масштаб — Основы БД, опорные темы, проектирование БД, пакетная работа. Карта — о разделе.
Play ITЗагрузка интерактивного демо…
Системное проектирование
Системное проектирование — это фундаментальная дисциплина, объединяющая в себе инженерное мышление, архитектурные паттерны и эмпирический опыт. Она отвечает не столько за написание кода, сколько за построение логически целостных, технически устойчивых и операционно поддерживаемых систем. В отличие от алгоритмического программирования, результат которого ограничен одной задачей, системное проектирование оперирует контекстами — взаимодействием множества подсистем, эволюцией требований, ограничениями инфраструктуры и неопределённостью внешней среды.
Параллелизм в таких системах — инструмент достижения масштабируемости. При этом важно отделить понятие масштабируемости от производительности. Производительность — это абсолютная метрика: сколько операций система обрабатывает за единицу времени в текущей конфигурации. Масштабируемость — это относительная способность — как изменится производительность при добавлении ресурсов (вычислительных, сетевых, хранилищных) и при изменении нагрузки.
То, как проектируется система, определяет её пределы роста — не только в терминах пользовательской нагрузки, но и в измерении сложности сопровождения, адаптивности к новым требованиям и стоимости эксплуатации. Именно поэтому системное проектирование начинается задолго до написания первой строки кода — с чёткой формулировки целей, ограничений и критериев успеха.
Практическая рамка масштабирования — до выбора технологий
Перед обсуждением Kubernetes, брокеров и шардирования полезно зафиксировать рамку проектного решения:
- текущая и пиковая нагрузка;
- допустимая задержка для ключевых сценариев;
- бюджет на инфраструктуру и командную поддержку;
- целевая модель отказа и восстановления.
Когда эти параметры заданы, архитектурные решения становятся проверяемыми, а не вкусовыми.
Инфраструктурный словарь для обсуждения (балансировка, кэш, шардирование, autoscaling) — в 12 концепций распределённой архитектуры. Семь стратегий именно на уровне БД (индексы, репликация, шардинг и др.) — обзор в разделе управления РСУБД.
Мини-план capacity planning
- Сформируйте базовую нагрузочную модель (
RPS/QPS, read/write ratio, пики). - Определите узкие места по CPU, памяти, I/O и сетевой задержке.
- Проверьте три стратегии — вертикальное масштабирование, горизонтальное, декомпозиция.
- Для каждой стратегии зафиксируйте цену, риски и предельную ёмкость.
- Примите решение и оформите его в ADR с метриками контроля.
Практические метрики, которые стоит добавить сразу
- QPS (или RPS) и concurrency (одновременные активные запросы) по критичным endpoint.
- Среднее и перцентили response time (p50, p95, p99) — см. шпаргалку ниже.
- Длина и возраст сообщений в очередях.
- Ошибки по причинам (
timeout,dependency,validation). - Утилизация пулов соединений и потоков.
- Время восстановления после частичного отказа.
Антипаттерны масштабирования
- Горизонтальное масштабирование без контроля состояния сессий.
- Увеличение числа инстансов при неустранённом узком месте в базе.
- Агрессивные retry без ограничений, усиливающие перегрузку.
- Оценка только по средним значениям без хвостовых задержек.
Эти ошибки часто создают иллюзию роста, но через несколько релизов приводят к росту стоимости и нестабильности.
Системное проектирование как процесс
Процесс системного проектирования не является линейной последовательностью шагов от анализа до реализации. Это циклическая, часто итеративная деятельность, в которой каждый этап уточняет предыдущий и формирует контекст для следующего.
Наиболее распространённая модель такого процесса включает в себя несколько ключевых фаз:
-
Определение требований — функциональных и некоторых нефункциональных. Здесь выявляются ожидания по отказоустойчивости, задержкам, пропускной способности, совместимости, конфиденциальности. Именно нефункциональные требования (NFR — non-functional requirements) задают рамки архитектурных решений. Например, требование "время отклика не более 200 мс при 95-м процентиле" уже исключает ряд решений по хранению и маршрутизации запросов.
-
Моделирование нагрузки и сценариев использования — на этом этапе строится образ реального использования — какие операции будут происходить, с какой частотой, откуда идёт трафик (внутренние или внешние клиенты, API-вызовы или веб-интерфейс), какие данные изменяются и как часто. Моделирование позволяет выявить узкие места до их появления в эксплуатации.
-
Выбор архитектурных стилей и паттернов — здесь решается, будет ли система монолитной, сервис-ориентированной, event-driven или основанной на потоках данных. Этот выбор должен соответствовать профилю нагрузки, требованиям к задержкам, существующим компетенциям команды и долгосрочной стратегией развития.
Play ITЗагрузка интерактивного демо…
-
Прототипирование критических путей — часто игнорируемый, но решающий этап. Он включает создание минимальной реализации наиболее "чувствительных" или "рискованных" компонентов — например, системы маршрутизации запросов, механизма шардинга или алгоритма консистентного распределения данных. Результат — подтверждение или опровержение гипотез о масштабируемости.
-
Документирование архитектурных решений не менее важно, чем техническая реализация. Архитектурные решения (ADR — Architecture Decision Records) фиксируют мотивацию каждого ключевого выбора: почему была принята одна модель распределения, а не другая; почему выбран конкретный брокер сообщений; какие альтернативы рассматривались и почему они были отклонены. Без такой фиксации быстро теряется контекст, и каждое последующее изменение превращается в авантюру.
Системное проектирование — это совместная ответственность всех участников процесса: от аналитиков до DevOps-инженеров. Чем раньше специалисты начинают мыслить системно — тем выше вероятность избежать "архитектурных долгов".
Проектирование с нуля
Когда система создается с нуля, у команды есть редкая возможность — избежать наследия. Однако "чистый лист" — это возможность самостоятельно установить ограничения. На практике это означает, что ошибки проектирования на ранних этапах могут закрепиться на долгие годы и стоить значительно дороже, чем аналогичные ошибки в зрелой системе.
Первым шагом в проектировании с нуля является формулировка границ домена. Это способ минимизировать связность между компонентами. Например, в сервисе укорочения URL можно выделить три логических домена — ввод и валидация URL, хранение и индексация коротких ключей, перенаправление по запросу. Каждый из них может развиваться независимо — при условии, что между ними определены чёткие контракты взаимодействия.
Особое внимание уделяется интерфейсам — API, форматам данных, механизмам обмена, соглашениям о версионировании. В системе, проектируемой с нуля, интерфейсы должны быть стабильными с самого начала: изменение контракта между компонентами в будущем потребует синхронизированной модификации всех зависимых частей — задача, растущая экспоненциально с ростом числа компонентов.
Также важно разграничить логику приложения и инфраструктурную логику. Например, механизм репликации данных или балансировки нагрузки должен быть вынесен за пределы бизнес-кода и управляем на уровне инфраструктуры или платформы. Это позволяет изменять топологию системы без переписывания ядра.
Ключевой принцип при проектировании с нуля — разделение по осям масштабирования. Согласно модели Scale Cube (хотя мы не будем приводить её визуализацию), масштабирование можно осуществлять по трём измерениям — X — клонирование экземпляров, Y — функциональная декомпозиция, Z — разделение по данным (шардинг). Хорошо спроектированная система с самого начала предусматривает возможность движения по всем трём осям — даже если в начальной версии используется только одна.
Проектирование с учётом будущего масштабирования
Масштабируемость — это встроенная характеристика архитектуры. Чтобы система могла эффективно масштабироваться, проектировщик должен заранее учитывать ряд факторов.
Во-первых, отказ от глобального состояния. Любое состояние, которое должно быть единым для всей системы (например, глобальный счётчик, единая очередь без шардинга), становится потенциальным узким местом. Даже если сегодня нагрузка позволяет использовать единый Redis-инстанс для хранения данных сессий, завтра он может стать барьером масштабируемости. Поэтому предпочтение отдаётся локальному состоянию, дедупликации на уровне приложения, или распределённым координационным механизмам (Raft, Paxos, ZAB — с явными требованиями к согласованности и эксплуатацией координатора).
Во-вторых, асинхронность как стандарт. Синхронные вызовы легко понимать и отлаживать, но они создают жёсткие временные зависимости и снижают устойчивость к сбоям. Если один сервис отвечает 500 мс дольше обычного, это может вызвать каскадный таймаут в цепочке синхронных вызовов. Асинхронные взаимодействия (через очереди, события, потоки) позволяют изолировать компоненты, буферизовать нагрузку и обеспечить graceful degradation.
В-третьих, измеряемость с самого начала. Масштабируемость нельзя оценить без метрик. Система должна проектироваться так, чтобы в ней были встроены механизмы сбора — времени обработки запроса, глубины очередей, использования CPU и памяти, числа ошибок. Эта информация должна быть доступна для мониторинга и для адаптивного поведения — например, автоматического снижения качества ответа при росте latency, чтобы сохранить доступность.
Наконец, проектирование с расчётом на масштабирование требует явного указания компромиссов. Нельзя одновременно получить максимальную согласованность, доступность и разделение по сети — это практическое следствие CAP-теоремы, с которым мы встретимся чуть позже. Архитектор должен заранее определить, какие свойства являются приоритетными, и документировать это в виде политики — например, "в случае сетевого разделения предпочтение отдается доступности, согласованность достигается асинхронно".
Реструктуризация существующих систем
Проектирование системы, уже находящейся в эксплуатации, — особенно если она функционирует "в плачевном состоянии" — представляет собой задачу иного порядка сложности. Здесь нет чистого листа, но есть работающая (хотя и хрупкая) логика, пользователи, SLA и долговая нагрузка. Такие системы часто страдают от:
- неявных зависимостей (например, скрытые вызовы через общий файл или глобальную переменную);
- "монолитного" разрастания без границ ("Big Ball of Mud");
- отсутствия документации по поведению, а не только по коду;
- несогласованности данных из-за множества точек записи;
- жёсткой привязки к конкретной инфраструктуре (например, к одному серверу БД).
В таких условиях классический подход "переписать всё заново" почти всегда заканчивается провалом — слишком высока стоимость, слишком велика неопределённость, слишком велик риск потери знаний, заложенных в текущем коде.
Вместо этого применяется стратегия поэтапного развязывания зависимостей, известная как Strangler Fig Pattern. Суть подхода — постепенная замена функциональности старой системы новыми, изолированными компонентами, которые подключаются через прослойку (facade, proxy, API gateway). По мере того как старые участки кода заменяются, старая система "задушается" — как фиговое дерево, обвивающее и замещающее старое дерево.
Ключевые шаги реструктуризации:
-
Инвентаризация и картирование — выявление всех входов и выходов системы (API, файлы, события, базы данных), а также построение графа вызовов. Инструменты вроде OpenTelemetry, distributed tracing или даже статического анализа (например, через Roslyn для C# или AST для Python) позволяют восстановить картину, даже если документация отсутствует.
-
Выделение "безопасных" границ — поиск мест, где можно вставить границу между компонентами с минимальным риском. Это могут быть:
- точки ввода/вывода (например, внешние API);
- операции, выполняемые в фоне (обработка файлов, рассылки);
- read-heavy операции, где можно временно допустить eventual consistency.
-
Внедрение прослоек согласования — например, адаптеров между новым и старым кодом, канареечного развёртывания, двойной записи (dual-write) для постепенного перехода на новую схему БД.
-
Параллельная эксплуатация и валидация — новая система работает одновременно со старой, и результаты сравниваются — по метрикам (latency, error rate), по семантике (корректность данных), по нагрузке. Только после подтверждения эквивалентности трафик перенаправляется.
Такой путь долог, но он управляем. Он сохраняет ценность существующей системы, не разрывая контракты с пользователями, и позволяет постепенно заменять "кирпичи" без остановки "здания".
CAP-теорема и её практические интерпретации
CAP-теорема — одна из самых часто цитируемых и при этом наиболее неправильно понимаемых концепций в распределённых системах. Суть её формулировки проста: в условиях сетевого разделения (Partition tolerance — P) невозможно одновременно обеспечить и согласованность (Consistency — C), и доступность (Availability — A). Выбирая два из трёх, проектировщик принимает принципиальное архитектурное решение.
В любой системе, работающей более чем на одном узле, разделение сети — норма. Линии связи могут затормозить, пакеты — потеряться, узлы — перезагрузиться. Поэтому реальный выбор сводится не к "C, A или P", а к поведению системы при разделении — будет ли она отказываться от обработки запросов (жертвуя A в пользу C), или продолжать отвечать, но с возможной несогласованностью (жертвуя C в пользу A).
Однако даже такая трактовка требует уточнений.
Согласованность (C) в контексте CAP — это линейная согласованность (linearizability): каждый оператор чтения должен возвращать результат последней завершённой записи, как если бы все операции выполнялись строго последовательно на одном узле. Это сильная форма согласованности, и она дорого обходится в распределённой среде. Многие системы предпочитают более слабые модели — eventual consistency, causal consistency, session consistency. Это осознанный выбор более слабой семантики в обмен на доступность или задержку.
Доступность (A) в CAP — это строгая форма: каждый запрос к нерабочему узлу должен всё равно получать ответ — успешный или ошибочный — без таймаутов. На практике под "доступностью" часто понимают высокую вероятность ответа (например, 99.99 percent uptime), но CAP требует 100 percent в отсутствие отказов. Это различие критично: система может быть практически доступной, но формально — не-A по CAP.
Наконец, Partition tolerance (P) означает "продолжает функционировать корректно даже при разделении". Корректность здесь — в рамках выбранной семантики — если выбрана C, то при разделении часть узлов должна "замолчать", чтобы не нарушить линейную согласованность.
Практическая интерпретация CAP — проектирование политики реагирования на разделение. Например:
-
Система с приоритетом C (например, банковские транзакции): при сетевом разрыве между кластерами часть запросов будет отклоняться с ошибкой 503, пока разделение не устранено. Это осознанное ограничение доступности для гарантии согласованности.
-
Система с приоритетом A (например, социальный фид): при разделении каждый сегмент продолжает принимать записи и читать локальные копии. Конфликты разрешаются позже (например, с помощью векторных часов или last-write-wins). Это отложенная согласованность как архитектурный приём.
CAP не запрещает гибридные стратегии. Можно проектировать систему, где одни подмножества данных (например, профили пользователей) требуют C, а другие (например, лайки под постами) — A. Это достигается через разделение по доменам и раздельное управление согласованностью.
Шардинг, репликация и консистентное хеширование
Эти три механизма — основные инструменты управления масштабируемостью и отказоустойчивостью в распределённых системах хранения и обработки.
Репликация
Репликация — это создание копий данных на нескольких узлах. Цель — повысить доступность, снизить задержки чтения (за счёт размещения реплик ближе к пользователям) и обеспечить отказоустойчивость.
Существует два основных подхода:
-
Синхронная репликация: запись считается успешной только после подтверждения от всех (или большинства) реплик. Это обеспечивает сильную согласованность, но увеличивает latency и снижает доступность при сбое узлов.
-
Асинхронная репликация: запись подтверждается сразу на ведущем узле, а реплики обновляются позже. Это ускоряет запись и повышает доступность, но создаёт окно, в котором чтение с реплики может вернуть устаревшие данные.
Важно различать ведущую (leader-based) и безлидерную (leaderless) репликацию. В ведущей модели есть один узел, принимающий все записи; при его падении кластер выбирает нового лидера (Raft, ZAB, Paxos). В безлидерной модели любой узел может принимать запись; согласование идёт через кворумы W/R, как в Dynamo или Cassandra — выше доступность, сложнее разрешение конфликтов. Обзор Bully, Ring, Paxos, Raft и ZAB — алгоритмы выбора лидера.
Шардинг
Шардинг — это горизонтальное разделение данных по нескольким узлам, при котором каждый узел хранит только часть данных. В отличие от репликации, шардинг повышает пропускную способность (write throughput) и ёмкость, но отказоустойчивость сам по себе даёт только в связке с репликацией каждого шарда.
Типичные цели шардинга — выйти за пределы одного сервера по объёму и записи, снизить конкуренцию за диск и CPU, локализовать сбои. Краткая шпаргалка по типам, ключу и маршрутизации — в 12 концепций §9.
Типы шардинга
- По диапазону (range) — строки с ключом в интервале
0…999на shard 1,1000…1999на shard 2. Удобно для отчётов по периодам и регионам; риск "горячего" последнего диапазона при монотонном ключе. - По справочнику (directory) — отдельная таблица или сервис сопоставляет значение (
region = EU) и идентификатор шарда. Гибко переносить тенанта между шардами, но каталог — ещё одна точка согласованности. - По ключу / хешу (key-based) —
hash(user_id) → shard_id. Равномернее при высокой кардинальности ключа; один пользователь всегда на одном шарде.
Выбор ключа шардирования
Хороший ключ обеспечивает равномерную нагрузку, локальность запросов и предсказуемый маршрут. Проверяют три свойства:
- Кардинальность — много уникальных значений; иначе данные сосредоточатся на нескольких шардах.
- Частота — значения встречаются сопоставимо часто; иначе возникает hot partition (один шард обслуживает львиную долю трафика).
- Монотонность — автоинкремент, timestamp и последовательные ID направляют новые записи на один "хвостовой" шард; для таких полей берут составной ключ (
tenant_id,order_id) или хеш от стабильного бизнес-идентификатора.
Дополнительно на практике требуют, чтобы типичный запрос укладывался в один шард (без scatter-gather по всему кластеру).
Маршрутизация запросов
- Узлы с топологией — клиент подключается к любому узлу кластера; узел перенаправляет запрос на владельца данных (часть распределённых СУБД).
- Выделенный маршрутизатор — прокси между приложением и шардами (
mongos, Vitess, шард-aware connection pool). - Логика в приложении — сервис сам вычисляет шард и открывает пул соединений к нужному хосту.
Пример: в сервисе укорочения URL ключом шардирования может быть хеш от оригинального URL или генерируемый короткий код. Простое id % N при добавлении шарда (N+1) перекладывает почти все строки — для больших систем используют консистентное хеширование (см. ниже).
Консистентное хеширование
Консистентное хеширование — это алгоритм распределения ключей по узлам, при котором при добавлении или удалении узла перераспределяется только небольшая часть ключей, а не все.
В классическом хешировании (key % N) изменение числа узлов приводит к полной перетасовке. В консистентном хешировании ключи и узлы отображаются на кольцо (виртуальное пространство), и каждый ключ назначается первому узлу по часовой стрелке от его позиции. При добавлении нового узла он "забирает" часть ключей только у одного соседа.
Для устранения неравномерности (например, из-за неравномерного распределения хешей) часто используется виртуальное шардирование: каждый физический узел представляется несколькими виртуальными узлами на кольце. Это позволяет добиться балансировки даже при небольшом числе физических серверов.
Консистентное хеширование лежит в основе таких систем, как Amazon Dynamo, Riak, и используется в балансировщиках нагрузки (например, для sticky sessions без центрального хранилища).
Однако важно понимать: консистентное хеширование не решает проблему согласованности данных. Оно лишь оптимизирует распределение — сама семантика записи и чтения определяется отдельно (например, через quorum-модель — W + R > N, где W — число подтверждённых записей, R — число прочитанных реплик, N — общее число реплик).
Оценка нагрузки
Проектирование масштабируемой системы невозможно без количественной оценки ожидаемой и пиковой нагрузки. При этом важно измерять "средние" значения и процентили (percentiles) — 50-й (медиана), 95-й, 99-й, 99.9-й. Пользователь редко замечает среднюю задержку, но почти всегда — хвостовые задержки ("почему один запрос занял 5 секунд, хотя обычно — 50 мс`?").
Четыре базовые метрики производительности
На практике четыре показателя закрывают большую часть разговоров о нагрузке и скорости ответа. Их удобно держать в голове как пару "сколько в секунду" и пару "сколько одновременно / сколько ждать".
| Метрика | Англ. | Что измеряет | Где смотреть |
|---|---|---|---|
| Запросы в секунду | QPS (Queries Per Second), часто RPS (Requests Per Second) | Сколько входящих запросов система принимает и обрабатывает за секунду | Балансировщик, API Gateway, метрики приложения |
| Транзакции в секунду | TPS (Transactions Per Second) | Сколько завершённых единиц работы в секунду (часто с записью в БД) | СУБД, бизнес-метрики, нагрузочные тесты |
| Параллельность | Concurrency | Сколько запросов одновременно в обработке (в очереди + на воркерах) | Пул потоков, длина очереди, inflight в Prometheus |
| Время ответа | RT (Response Time) | Интервал от старта запроса до получения ответа клиентом | APM, логи с $request_time, p95/p99 |
QPS — "сколько стучатся в дверь". Один HTTP-вызов = один запрос. Внутри него может быть несколько обращений к БД, но с точки зрения клиента это один QPS.
TPS — "сколько дел прошло до конца". Пример: оплата = запрос к API + списание в БД + запись в журнал. Клиент видит один запрос (1 QPS), а завершённая транзакция — одна бизнес-операция (1 TPS). При микросервисах один пользовательский сценарий может дать несколько TPS на разных сервисах.
Concurrency — сколько запросов "внутри системы" прямо сейчас. Рост concurrency при том же QPS обычно значит, что запросы стали дольше (очередь, блокировки, медленная БД).
Response time (RT) — длительность одного запроса. Различают:
- Сквозной RT — от клиента до ответа (сеть + все промежуточные узлы);
- Компонентный RT — например, только время ответа БД на запрос приложения.
Средний RT в дашборде часто обманывает: для SLA и UX смотрите p95/p99 (см. раздел Latency ниже).
Связь QPS, concurrency и времени ответа
В установившемся режиме (нагрузка стабильна, очередь не растёт бесконечно) работает закон Литтла (Little's Law):
QPS = Concurrency / Avg RT
Avg RT — среднее время ответа в секундах (если RT в миллисекундах, сначала делите на 1000).
Интуиция: чем больше одновременных запросов обрабатывается и чем короче каждый из них, тем выше пропускная способность. Пример — concurrency = 100, средний RT = 0,2 с (200 мс) → QPS ≈ 100 / 0,2 = 500.
Обратная задача — capacity planning — при целевых 2000 QPS и ожидаемом RT 100 мс нужно выдерживать concurrency порядка 2000 × 0,1 = 200 одновременных запросов (пулы соединений, воркеры, лимиты балансировщика).
Формула связывает три наблюдаемые величины. На практике фиксируют SLA по RT, измеряют concurrency под нагрузкой и проверяют, хватает ли QPS. Если RT растёт, а QPS падает — ищите узкое место (CPU, диск, блокировки, внешние API), а не только "добавить серверы".
Сетевая диагностика RT и TTFB — в Сеть для диагностики бэкенда.
QPS (queries per second)
Это базовая метрика интенсивности. Однако QPS сам по себе малополезен без контекста:
- Каково соотношение чтений и записей? Записи обычно дороже.
- Какова "тяжеловесность" запроса? Один запрос может читать 1 строку, другой — делать JOIN по миллионам, третий — запускать машинное обучение.
- Есть ли "всплески" (burst)? Система, выдерживающая 1000 QPS равномерно, может упасть под 5000 QPS за 1 секунду.
Поэтому оценка QPS всегда сопровождается профилем запроса — тип, объём затрагиваемых данных, вычислительная сложность.
TPS (transactions per second)
TPS фиксирует успешно завершённые транзакции — коммит в БД, подтверждённый платёж, доставленное сообщение. Неудачные попытки в TPS обычно не входят (их считают отдельно как error rate).
QPS и TPS расходятся, когда:
- один HTTP-запрос порождает несколько транзакций (саги, outbox);
- несколько запросов объединяются в одну транзакцию (batch);
- "запрос" лёгкий, а транзакция тяжёлая (длинные блокировки, 2PC).
Для OLTP-систем в capacity planning часто важнее пиковый TPS на запись, чем средний QPS на чтение.
Объём данных
Важны три измерения:
-
Общий объём — сколько данных будет накоплено за год/пять лет. Это влияет на выбор хранилища (HDD vs SSD), стратегию архивирования, стоимость хранения.
-
Скорость роста — сколько данных добавляется в секунду/минуту. Это определяет пропускную способность каналов записи, частоту компактификации, нагрузку на фоновые процессы (индексация, репликация).
-
Размер отдельной записи/ответа — маленькие записи позволяют эффективно использовать пакетную обработку и кэширование; большие — требуют потоковой передачи, разбиения на чанки, отказа от некоторых оптимизаций (например, in-memory кэширования целых объектов).
Latency и response time
Response time (RT) — длительность конкретного запроса; latency в документации и мониторинге часто означает то же для сквозного пути, но термин шире (задержка сегмента сети, реплики, диска). В проектировании полезно явно писать, чей RT измеряется (клиент, сервис, БД).
Задержка — это не просто "время отклика". В распределённой системе latency складывается из множества компонентов:
- Сетевая задержка (RTT между клиентом и сервером, между сервисами);
- Время обработки на CPU (включая ожидание в очереди на выполнение);
- Время ожидания ввода-вывода (чтение с диска, ожидание ответа от БД);
- Время сериализации/десериализации (особенно для больших объектов или сложных форматов);
- Время ожидания блокировок (в случае contention на shared state).
Критически важно различать сквозную (end-to-end) latency и компонентную. Например, сервис может отвечать за 100 мс, но 80 мс из них — ожидание ответа от внешнего API. Оптимизация такого сервиса без работы с зависимостью бесполезна.
Хорошая практика — строить латентностный бюджет — выделять долю задержки на каждый этап (например, 20 мс на сеть, 30 мс на БД, 50 мс на обработку). Это позволяет выявлять узкие места до того, как они проявятся в продакшене.
Принципы отказоустойчивости и graceful degradation
Отказоустойчивость — это управляемая деградация в условиях стресса — сбоев компонентов, всплесков нагрузки, сетевых аномалий. Цель — обеспечить приемлемый уровень сервиса даже в нештатных ситуациях.
Ключевой принцип — изоляция отказов. В монолитной системе сбой в одном модуле может привести к падению всего приложения. В распределённой архитектуре отказ должен быть локализован. Для этого применяются:
-
Circuit breaker — паттерн, который "размыкает" вызовы к зависшему или медленному сервису после определённого числа ошибок, возвращая заглушку или кэшированный ответ. Через время происходит "проверка" — если сервис отвечает, цепь замыкается снова. Это предотвращает каскадные отказы.
-
Bulkhead — выделение ресурсов (потоков, памяти, соединений) под отдельные компоненты или типы запросов. Например, в веб-сервере можно выделить отдельный пул потоков для "критических" операций (авторизация, оплата) и отдельный — для "фоновых" (логгирование, аналитика). Если фоновые операции зависнут, критические останутся доступны.
-
Timeout и deadline propagation — каждый вызов должен иметь чёткое ограничение по времени, и это ограничение должно учитываться при каскадных вызовах. Например, если внешний запрос имеет deadline 500 мс, а он вызывает два внутренних сервиса, то на каждый из них может быть выделено не более 200 мс с запасом. Отсутствие deadline’ов ведёт к "зависанию" всей цепочки.
Grateful degradation ("благодарное понижение качества") — это архитектурная стратегия. Она предполагает, что система заранее знает, какие функции можно отключить или упростить при росте нагрузки, не нарушая основного сценария.
Примеры:
- Видео-стриминг при нехватке пропускной способности снижает разрешение, но продолжает транслировать звук.
- Поисковая система при перегрузке возвращает результаты без ранжирования по релевантности, но с быстрым временем отклика.
- Веб-приложение при высокой нагрузке отключает "тяжёлые" виджеты (чат, аналитика), оставляя только основной контент.
Graceful degradation должен быть спроектирован и протестирован. Нельзя полагаться на то, что "система сама найдёт выход". Сценарии деградации должны быть явно описаны, реализованы и регулярно проверены (например, через chaos engineering — преднамеренные сбои в staging-среде).
Другой аспект — восстановление после отказа. Здесь важны механизмы (retry, fallback, checkpointing) и политики:
- Когда делать retry? Только для идемпотентных операций. Повторная отправка платежа — риск двойного списания.
- Какой backoff использовать? Экспоненциальный с jitter’ом (случайной задержкой), чтобы избежать синхронных волн повторных запросов.
- Что делать при полном отказе компонента? Переключаться на резервный кластер? Использовать readonly-режим? Отдавать кэшированные данные с пометкой "устаревшие"?
Отказоустойчивость — это культура, а не набор инструментов. Она требует документирования сценариев отказа, регулярных post-mortem’ов без поиска виноватых, и включения "режимов деградации" в планы мониторинга и оповещения.
Примеры архитектур
Рассмотрим три классических случая системного проектирования, каждый из которых иллюстрирует разные аспекты масштабируемости, параллелизма и компромиссов.
1. Picture hosting (сервис хранения изображений)
Типичный пример — аналог Flickr или внутренний CDN для изображений. Основные требования:
- Высокая пропускная способность на чтение (95
percent+ трафика — GET); - Умеренная интенсивность записи;
- Большие объёмы данных (миллионы файлов, терабайты);
- Низкая latency на чтение;
- Отказоустойчивость и долговечность (изображения не должны пропадать).
Архитектурные решения:
-
Шардинг по хешу от имени файла. Например,
sha256(file_id)→ первые 4 символа определяют шард (/0a/3f/...). Это обеспечивает равномерное распределение и локальность. -
Репликация на уровне хранилища. Каждый шард представлен как минимум тремя физическими узлами (например, в разных дата-центрах). Запись требует подтверждения от двух из трёх (quorum
W=2, R=2, N=3), чтение — от одного (R=1), но при конфликте запрашиваются все три (R=3) для разрешения. -
Content-Addressable Storage (CAS). Файл идентифицируется по хешу его содержимого. Это позволяет автоматически дедуплицировать одинаковые изображения (например, аватарки по умолчанию) и проверять целостность при чтении.
-
Многоуровневое кэширование:
- L1: CDN (ближайший POP к пользователю);
- L2: кэш на уровне приложения (например, Redis с ограниченным TTL);
- L3: локальный диск шарда (чтобы избежать повторного чтения с медленного хранилища).
-
Асинхронная обработка метаданных. После загрузки изображения его метаданные (размер, EXIF, миниатюры) обрабатываются в фоне через очередь. Основной ответ приходит сразу — "файл принят", а генерация превью не блокирует интерфейс.
-
Экспирация и архивирование. "Тёплые" данные (активно читаемые) хранятся на SSD; "холодные" (редко запрашиваемые) — перемещаются в объектное хранилище (например, S3 Glacier). Это снижает стоимость.
Особый акцент — на отказе от централизованной БД для хранения путей. Вместо единой таблицы images(id, path, user_id) используется директорная структура на диске или метаданные в заголовках HTTP-ответов. Это устраняет узкое место и позволяет масштабировать чтение почти линейно.
2. URL shortener (сервис укорочения ссылок)
Кажущаяся простота задачи (принять URL → вернуть короткий код → при запросе кода сделать редирект) скрывает серьёзные требования к масштабируемости:
- Очень высокий QPS на чтение (редиректы);
- Умеренный QPS на запись (генерация новых ссылок);
- Строгие требования к latency (редирект должен быть быстрее, чем прямой переход по длинному URL);
- Необходимость уникальности коротких кодов;
- Возможность масштабирования записи (генерация кодов) независимо от чтения.
Архитектурные решения:
-
Генерация ID вне зависимости от БД. Используется распределённый ID-генератор (например, Twitter Snowflake) — 41 бит времени, 10 бит идентификатора узла, 12 бит последовательности. Это позволяет генерировать уникальные, почти сортируемые ID без обращения к центральному хранилищу.
-
Преобразование ID в короткий код через base62 (цифры + a-z + A-Z). Например, ID
123456789→1xK5. Код фиксированной длины (например, 6 символов дают ~56 млрд комбинаций). -
Хранение в key-value хранилище. Ключ — короткий код, значение — оригинальный URL. Такие хранилища (Redis, DynamoDB, RocksDB) обеспечивают O(1) чтение с минимальной latency.
-
Кэширование "на лету". Поскольку чтение доминирует, каждый редирект кэшируется на уровне балансировщика (например, nginx с
proxy_cache) или CDN. При первом запросе кода происходит обращение к backend’у, последующие — обслуживаются из кэша. -
Шардинг по префиксу кода. Например, коды
a*,b*, … распределяются по разным шардам. Это позволяет масштабировать запись: генератор ID может направлять новые коды в наименее загруженный шард. -
Read-only реплики для редиректов. Запись идёт в ведущий шард, чтение — в реплики. Это разгружает ведущий узел.
-
Предварительное создание пула кодов. На старте сервис генерирует пул ID (например, 1 млн штук), и выдаёт их по мере поступления запросов. Это исключает contention на генератор.
В такой системе никакой логики не выполняется при редиректе. Это чистый HTTP 301/302 с указанием Location. Любая дополнительная обработка (аналитика, проверка прав, гео-фильтрация) должна быть опциональной и асинхронной — иначе растёт latency и снижается масштабируемость.
Сводка задачи и соседние сценарии (rate limiter, чат, лента, уведомления) — System Design — пять классических задач.
3. Рекомендательные системы
Это наиболее сложный класс систем, где сталкиваются требования к:
- Высокой точности (релевантность рекомендаций);
- Низкой latency (отклик за
<100 мс); - Масштабируемости (миллионы пользователей, миллиарды событий);
- Адаптивности (модели должны обновляться в реальном времени или почти в реальном).
Рекомендательные системы редко строятся как единый компонент. Их разделяют на три слоя:
-
Offline-обработка — обучение глубоких моделей (например, matrix factorization, двух- или трёхслойные нейросети) на исторических данных (покупки, просмотры, лайки). Используются распределённые фреймворки (Spark MLlib, TensorFlow Extended). Результат — набор "сырых" предсказаний (например, оценка вероятности клика для каждого item’а у каждого пользователя).
-
Nearline-обработка — обновление рекомендаций при значимых событиях (например, добавление в корзину, покупка). Используются потоковые движки (Flink, Kafka Streams), которые пересчитывают "ближайший" набор рекомендаций за секунды или минуты.
-
Online-обслуживание — формирование финального списка при запросе. Здесь происходит:
- Выбор кандидатов из нескольких источников (популярное, персонализированное, новое);
- Ранжирование с учётом контекста (время суток, устройство, геолокация);
- Фильтрация (например, исключение уже купленного);
- Применение политик (диверсификация, fairness).
Архитектурные особенности:
-
Feature store — централизованное хранилище признаков (features), используемых и при обучении, и при inference. Это гарантирует consistency между offline и online.
-
Модель как сервис (MaaS) — инференс вынесен в отдельные микросервисы, которые могут масштабироваться независимо. Часто используются оптимизированные runtime’ы (ONNX Runtime, TensorFlow Serving).
-
Кэширование персонализированных списков — для активных пользователей предварительно рассчитанные рекомендации кэшируются (например, в Redis по
user_id). При запросе возвращается кэш, а обновление происходит асинхронно. -
Fallback-стратегии — если персонализированная модель не отвечает, используется:
- Популярное "сейчас" (топ за 24 часа);
- Рекомендации по схожим пользователям (collaborative filtering on-the-fly);
- Случайная выборка из категории.
-
A/B-тестирование на уровне архитектуры — трафик разделяется на сегменты, каждый из которых получает рекомендации от разных моделей. Метрики (CTR, conversion) собираются автоматически, и победившая модель постепенно раскатывается.
Ключевой компромисс в рекомендательных системах — между точностью и latency. Сложная модель может давать +5 percent к CTR, но работать 200 мс. Простая — на 20 мс, но с меньшей точностью. Архитектор должен явно определить, где проходит граница приемлемости — и закодировать это в SLA.