System design: как проектировать систему под нагрузку от и до
Весь трек до этого момента был про словарь: слои и порты, монолит и сервисы, события, CQRS, саги, стили API, кэш, устойчивость, serverless, ADR. Словарь — необходимое, но недостаточное условие. Человек, знающий все паттерны наизусть, всё ещё может спроектировать систему, которая ляжет на первом же маркетинговом всплеске: потому что system design — это не выбор паттернов, а последовательность решений, каждое из которых сужает пространство следующих, и умение на каждом шаге понимать, какими числами это решение оправдано.
Эта статья — про сам процесс. Мы пройдём его целиком на одном сквозном примере (новостная лента социального сервиса), но каркас универсален: те же семь шагов работают для платёжного шлюза, для системы уведомлений и для внутренней аналитики. В конце — разбор того, как этот же маршрут выглядит на 45-минутном собеседовании, где времени в шестьдесят раз меньше, чем в жизни.
Главная мысль, которую стоит удержать: архитектура — это не картинка, а набор обоснованных ограничений. Картинка с прямоугольниками и стрелками не является дизайном. Дизайном она становится тогда, когда для каждой стрелки вы можете назвать её RPS, размер сообщения, что происходит при её обрыве и почему выбран именно этот способ связи.
1. Что такое system design и чем он не является
Определим предмет. System design — это деятельность по превращению набора требований (функциональных и, что важнее, нефункциональных) в структуру из компонентов, контрактов и данных, которая эти требования выполняет при заданных ограничениях по деньгам, срокам и компетенциям команды.
Три частых подмены, от которых стоит отказаться сразу:
Подмена 1: дизайн = диаграмма компонентов. Диаграмма — артефакт коммуникации, а не решение. Два принципиально разных дизайна легко выглядят одинаково на слайде: «сервис A пишет в сервис B» скрывает выбор между синхронным вызовом с таймаутом 200 мс и асинхронной публикацией в лог событий, а это решение определяет всё остальное — доступность, согласованность, модель ошибок, стоимость эксплуатации.
Подмена 2: дизайн = выбор технологий. «Берём Kafka, Postgres и Kubernetes» — это не дизайн, это закупка. Технология отвечает на вопрос «чем», дизайн — на вопрос «какие свойства нам нужны и какой ценой». Технологии выбираются последними и меняются легче всего; модель данных и границы сервисов — первыми и меняются тяжелее всего.
Подмена 3: дизайн = масштабирование. Масштабирование — один из атрибутов качества, и далеко не всегда главный. Система, которая обрабатывает миллион RPS, но теряет платежи, хуже системы на тысячу RPS, которая их не теряет. Порядок приоритетов почти всегда: корректность → доступность → латентность → стоимость → пропускная способность.
Практический критерий качества дизайна: можно ли по нему предсказать поведение системы при отказе любого одного компонента и при трёхкратном росте трафика. Если да — это дизайн. Если нет — это схема.
2. Каркас процесса: семь шагов и обратные связи
Процесс не линеен. Оценки меняют требования («вы правда хотите строгую консистентность глобально? это будет стоить 200 мс на запись»), выбранная модель данных меняет API, найденное узкое место переписывает схему. Но порядок первого прохода важен: он не даёт начать с технологий.
функциональные + SLO"] --> E["2. Оценки на салфетке
RPS, объём, полоса"] E --> A["3. Контракт API
и модель ошибок"] A --> D["4. Модель данных
и паттерны доступа"] D --> H["5. Эскиз архитектуры
путь записи и путь чтения"] H --> B["6. Узкие места
и углубления"] B --> O["7. Отказы, деградация,
эксплуатация"] O --> V{"Проходит по SLO,
бюджету и срокам?"} V -->|"Нет: не хватает ёмкости"| B V -->|"Нет: требования нереалистичны"| R V -->|"Нет: данные не ложатся"| D V -->|"Да"| ADR["ADR + план эволюции"] style R fill:#4a7fb5,fill-opacity:0.35 style E fill:#c98a2b,fill-opacity:0.35 style ADR fill:#5aa469,fill-opacity:0.35
Каждый шаг производит артефакт, который можно показать другому человеку: таблицу SLO, табличку с оценками, спецификацию API, схему таблиц, две диаграммы последовательности (запись/чтение), список узких мест с числами, матрицу отказов. Если какого-то артефакта нет — шаг не пройден, а «мы это обсудили» не считается.
3. Шаг 1. Требования: функциональные, нефункциональные, отсечённые
Ошибка номер один — начать проектировать раньше, чем понятно, что именно строим. Требования делятся на три списка, и третий не менее важен, чем первые два.
Функциональные. Что система умеет, в терминах наблюдаемого поведения. Формулируйте глаголами и с субъектом: «пользователь публикует пост длиной до 4 КБ», «пользователь видит ленту постов тех, на кого подписан, в обратном хронологическом порядке», «автор удаляет свой пост, и он исчезает из лент в течение минуты». Из последней формулировки уже видно, что удаление — не мгновенное, и это осознанное решение, а не случайность.
Нефункциональные (атрибуты качества). Здесь живёт настоящая архитектура. Требование «система должна быть быстрой» бесполезно; требование «p99 открытия ленты ≤ 200 мс при 100 000 RPS в пике» — проектное ограничение, из которого выводятся кэши, денормализация и топология размещения.
требования")) Производительность p50 / p95 / p99 латентность пропускная способность RPS холодный старт Надёжность доступность SLO RPO допустимая потеря данных RTO время восстановления Масштабируемость горизонтальная по данным горизонтальная по трафику эластичность к всплескам Согласованность строгая для критичных инвариантов read-your-writes для UX конечная для агрегатов Эксплуатация наблюдаемость скорость релиза стоимость на запрос Безопасность аутентификация и авторизация изоляция данных арендаторов аудит и приватность
Отсечённое (non-goals). Список того, что мы сознательно НЕ делаем в этой итерации: нет ранжирования ML-моделью, нет мультирегиональной записи, нет гарантии порядка между разными авторами. Non-goals — самый дешёвый инструмент управления сложностью: каждое отсечённое требование убирает целый пласт механики.
SLO, SLI и бюджет ошибок
Нефункциональные требования нужно приземлить в измеримые величины. SLI — сам показатель (доля запросов ленты, отдающихся успешно за ≤ 200 мс). SLO — целевое значение SLI (99,9 % за 28-дневное окно). Бюджет ошибок — разрешённая доля нарушений: 0,1 % от 30 дней ≈ 43 минуты в месяц.
Бюджет ошибок — это не бюрократия, а инструмент принятия решений. Он превращает спор «давайте ещё поднадёжнее» в арифметику: если за месяц потрачено 10 % бюджета, команда катит фичи; если 90 % — команда чинит надёжность. Разница между 99,9 % и 99,99 % — это не «одна девятка», а порядок стоимости: резервирование по регионам, автоматический failover, дежурства 24/7. Прежде чем брать четвёртую девятку, убедитесь, что её реально просят и готовы за неё платить — подробнее об этой логике в устойчивости.
Полезная деталь: доступность зависимости умножается. Синхронная цепочка из пяти сервисов по 99,9 % каждый даёт 99,5 % — 3,6 часа простоя в месяц. Каждая синхронная зависимость на критическом пути ухудшает вашу доступность, и это главный числовой аргумент в пользу асинхронности и кэшей с фолбэком.
Требования сквозного примера
| Требование | Значение |
|---|---|
| DAU | 100 млн |
| Постов в сутки | 50 млн |
| Открытий ленты в сутки | 2 млрд |
| p99 чтения ленты | ≤ 200 мс |
| p99 публикации поста | ≤ 500 мс |
| Задержка появления поста в ленте | ≤ 10 с (p95) |
| Доступность чтения | 99,95 % |
| Доступность записи | 99,9 % |
| Хранение | 5 лет, посты неизменяемы после публикации |
| Non-goals | ML-ранжирование, мультирегиональная запись, редактирование постов |
4. Шаг 2. Оценки на салфетке: превращаем требования в числа
Back-of-the-envelope estimation — навык, который отличает проектировщика от рисовальщика схем. Смысл не в точности (ошибка в 2–3 раза допустима), а в определении порядка величины: он решает, нужен ли шардинг вообще, влезают ли горячие данные в память, упрётесь ли вы в сеть.
Базовые числа, которые надо знать наизусть
| Операция | Порядок |
|---|---|
| Обращение к L1-кэшу | ~1 нс |
| Обращение к оперативной памяти | ~100 нс |
| Чтение 1 МБ последовательно из RAM | ~50 мкс |
| Round-trip внутри дата-центра | ~0,5 мс |
| Случайное чтение с NVMe SSD | ~100 мкс |
| Чтение 1 МБ с SSD | ~1 мс |
| Случайное чтение с HDD | ~10 мс |
| Round-trip между континентами | ~150 мс |
| Один запрос в Redis (в пределах ДЦ) | ~0,2–1 мс |
| Один простой запрос в SQL по индексу | ~1–5 мс |
Из этой таблицы выводятся почти все правила больших систем: сетевой хоп внутри ДЦ дороже обращения к памяти в 5000 раз, а межконтинентальный — в полтора миллиона раз. Поэтому «сходить в другой сервис» никогда не бесплатно, поэтому N+1 по сети убивает, поэтому мультирегиональная синхронная запись — это минимум +150 мс на каждый коммит. Оригинал таблицы — Latency Numbers Every Programmer Should Know Джеффа Дина в интерактивной версии Колина Скотта.
Считаем нашу ленту
"""Оценки ёмкости для новостной ленты. Все числа — порядки, не точные значения."""
SEC_PER_DAY = 86_400
# --- Трафик ---
DAU = 100_000_000
posts_per_day = 50_000_000
feed_opens_per_day = 2_000_000_000
write_rps_avg = posts_per_day / SEC_PER_DAY # ~580 RPS
read_rps_avg = feed_opens_per_day / SEC_PER_DAY # ~23 000 RPS
# Пик к среднему для соцсети — 3–5x (вечерний прайм-тайм + часовые пояса)
PEAK = 4
write_rps_peak = write_rps_avg * PEAK # ~2 300 RPS
read_rps_peak = read_rps_avg * PEAK # ~93 000 RPS
read_write_ratio = read_rps_avg / write_rps_avg # ~40:1
# --- Объём хранения ---
POST_BYTES = 4 * 1024 # текст поста
META_BYTES = 500 # id, автор, время, счётчики, флаги
bytes_per_day = posts_per_day * (POST_BYTES + META_BYTES) # ~225 ГБ/сутки
bytes_per_year = bytes_per_day * 365 # ~82 ТБ/год
bytes_5y_replicated = bytes_per_year * 5 * 3 # ~1,2 ПБ с репликацией x3
# --- Горячие данные ---
# Правило 80/20: ~20% контента даёт ~80% чтений; горячее окно — последние 2 суток
hot_bytes = bytes_per_day * 2 * 0.2 # ~90 ГБ — влезает в кэш-кластер
# --- Fan-out на запись ---
AVG_FOLLOWERS = 200 # медиана мала, среднее вытянуто «звёздами»
fanout_writes_per_sec = write_rps_avg * AVG_FOLLOWERS # ~116 000 записей/с в инбоксы
fanout_peak = write_rps_peak * AVG_FOLLOWERS # ~460 000 записей/с в пике
# --- Полоса пропускания на чтении ---
FEED_PAGE_ITEMS = 20
RESPONSE_BYTES = FEED_PAGE_ITEMS * 1200 # ~24 КБ на страницу ленты
egress_peak_gbps = read_rps_peak * RESPONSE_BYTES * 8 / 1e9 # ~18 Гбит/с
for name, value in [
("запись, пик RPS", write_rps_peak),
("чтение, пик RPS", read_rps_peak),
("отношение чтение/запись", read_write_ratio),
("хранение за 5 лет, ТБ", bytes_5y_replicated / 1e12),
("горячие данные, ГБ", hot_bytes / 1e9),
("fan-out записей/с в пике", fanout_peak),
("исходящий трафик, Гбит/с", egress_peak_gbps),
]:
print(f"{name:35s} {value:,.1f}")
Что эти семь чисел уже решили за нас, ещё до единой стрелки на схеме:
- Отношение 40:1 — система читающая. Значит, оптимизируем чтение, даже ценой удорожания записи. Это прямой аргумент за fan-out на запись и за материализованные представления (CQRS).
- 90 ГБ горячих данных — влезают в кэш-кластер из десятка машин. Значит, целевой сценарий чтения — попадание в кэш, а база данных нужна для промахов и холодной истории.
- 460 000 записей/с в инбоксы в пике — это уже не «просто вставим в Postgres». Нужен либо распределённый LSM-стор, либо ограничение размера инбокса, либо гибрид с pull.
- 1,2 ПБ за пять лет — одна машина исключена, шардинг обязателен, холодные данные надо отправлять в дешёвое объектное хранилище.
- 18 Гбит/с egress — заметная статья расходов; часть отдаём через CDN, медиа выносим на отдельный путь.
- Пик 4x — планировать ёмкость надо по пику, а не по среднему, причём с запасом.
Закон Литтла и почему нельзя грузить систему «под завязку»
Единственная формула, которую действительно нужно помнить: L = λ · W, где L — среднее число заявок в системе, λ — интенсивность поступления, W — среднее время в системе. Из неё выводится размер пула: чтобы обслуживать 10 000 RPS при среднем времени обработки 20 мс, нужно минимум 10 000 · 0,02 = 200 одновременно работающих обработчиков. Если в пуле 100 — очередь будет расти неограниченно, независимо от того, насколько быстрые у вас процессоры.
Второе следствие — нелинейный рост задержки при приближении утилизации к единице. Для простейшей модели M/M/1 среднее время ожидания W = W₀/(1 − ρ), где ρ — утилизация. При ρ = 0,5 задержка удваивается, при ρ = 0,9 — вырастает в десять раз, при ρ → 1 уходит в бесконечность.
Практический вывод: целевая утилизация на проде — 50–70 %, не выше. Оставшееся — запас на всплеск, на потерю узла и на деградацию соседа. Команды, «оптимизирующие расходы» до 90 % утилизации, обычно платят за это инцидентами, а не экономят.
Отсюда же растёт метастабильность отказов: система, ушедшая за колено, не возвращается сама после снятия нагрузки, потому что накопленная очередь и ретраи поддерживают её в перегрузе. Это разобрано в статье об устойчивости; на этапе дизайна достаточно заложить ограничение параллелизма и сброс нагрузки.
5. Шаг 3. Контракт API: что видит внешний мир
API проектируется до внутренностей, потому что он фиксирует, какие данные обязаны быть доступны вместе и с какой свежестью. Полный разбор стилей — в статье об API; здесь — минимум, влияющий на нагрузку.
# Чтение ленты. Пагинация — курсорная, НЕ offset:
# offset заставляет БД сканировать пропущенные строки и ломается при вставках.
GET /v1/feed?cursor=<opaque>&limit=20
200 OK
items: [{post_id, author_id, text, created_at, media[], counters}]
cursor: "<opaque-строка: (score, post_id) в base64>"
stale: false # true, если отдали деградированный кэш
Cache-Control: private, max-age=15
# Публикация. Идемпотентность обязательна: мобильный клиент ретраит по таймауту.
POST /v1/posts
Idempotency-Key: 3f8c... # клиентский UUID, живёт 24 часа
body: {text, media_ids[], visibility}
202 Accepted
post_id: "01HQ..." # ULID: сортируемый по времени, генерируется клиентом сервиса
state: "published" # пост виден автору сразу, в чужих лентах — в течение 10 с
Три решения, которые здесь приняты и стоят дороже, чем кажется:
Курсорная пагинация вместо offset. При LIMIT 20 OFFSET 100000 база честно прочитает
100 020 строк и выбросит 100 000. Курсор (created_at, post_id) превращает это в поиск по
индексу за O(log n) и даёт стабильный результат при параллельных вставках.
202 вместо 201 на публикации. Мы явно говорим клиенту: пост принят, распространение асинхронно. Это честно отражает fan-out и снимает с пути записи требование дождаться 120 000 вставок. Но при этом автор обязан увидеть свой пост немедленно — это отдельный механизм (read-your-writes), к которому вернёмся ниже.
Idempotency-Key. Без него ретрай клиента по таймауту создаёт дубль поста. Ключ хранится
в быстром сторе с TTL 24 часа; повторный запрос с тем же ключом возвращает тот же post_id.
Идемпотентность на границе — тот же приём, что и в
сагах, просто применённый к
публичному API.
6. Шаг 4. Модель данных и паттерны доступа
В нагруженных системах данные проектируются от запросов, а не от сущностей. Сначала выписывается список паттернов доступа с их частотой, потом под них подбирается хранилище и раскладка.
| Паттерн доступа | Частота | Требование |
|---|---|---|
| Получить страницу ленты пользователя | 93 000 RPS пик | ≤ 50 мс, обратный хрон. порядок |
| Получить пост по id (батчом до 20) | 93 000 RPS пик | ≤ 10 мс, попадание в кэш |
| Вставить пост | 2 300 RPS пик | ≤ 100 мс, durability |
| Получить список подписчиков автора | 2 300 RPS пик | до 40 млн строк для «звёзд» |
| Получить список подписок пользователя | при промахе кэша | до 5 000 строк |
| Счётчики лайков/комментариев | 93 000 RPS пик | конечная согласованность допустима |
Ключевые решения по данным:
Разные шард-ключи для разных задач. FOLLOW нужен в двух направлениях: «кто на меня
подписан» (для fan-out на запись) и «на кого я подписан» (для fan-out на чтение). Один
шард-ключ не обслужит оба паттерна эффективно — поэтому граф хранится дважды, в двух
раскладках. Это классический размен: дублирование данных ради устранения кросс-шардовых
запросов.
Инбокс ограничен по размеру. Хранить всю историю ленты для каждого пользователя бессмысленно: 99 % запросов касаются первых 3–5 страниц. Инбокс подрезается до 800 записей; при попытке уйти глубже система переключается на медленный путь с вычислением на лету. Это экономит на порядок больше места, чем любое сжатие.
-- Инбокс: раскладка под один запрос «страница ленты».
-- Первичный ключ подобран так, чтобы строки одного пользователя лежали физически рядом
-- и читались одним range-скэном в нужном порядке — без сортировки на лету.
CREATE TABLE inbox (
user_id BYTEA NOT NULL, -- шард-ключ
score BIGINT NOT NULL, -- метка времени в мкс, для обратного порядка
post_id BYTEA NOT NULL,
author_id BYTEA NOT NULL,
PRIMARY KEY (user_id, score DESC, post_id)
) PARTITION BY HASH (user_id);
-- Страница ленты: курсор — это (score, post_id) последнего показанного элемента.
SELECT post_id, author_id, score
FROM inbox
WHERE user_id = $1
AND (score, post_id) < ($2, $3) -- кортежное сравнение = один поиск по индексу
ORDER BY score DESC, post_id DESC
LIMIT 20;
-- Сложность: O(log n + k), где k = 20. Никаких сортировок, никаких сканов таблицы.
Счётчики отделены от постов. Лайки меняются на порядки чаще, чем текст поста. Держать их в одной строке — значит инвалидировать кэш поста при каждом лайке и создавать горячую строку с конкуренцией за блокировку. Счётчики живут отдельно, обновляются агрегированными пачками и допускают приблизительность (см. кэширование и масштабирование).
7. Шаг 5. Эскиз архитектуры: путь записи и путь чтения
Дизайн удобнее описывать не «компонентами», а двумя-тремя сквозными путями. Для ленты их два, и они устроены принципиально по-разному — это и есть CQRS в чистом виде.
иначе классическая проблема двойной записи PS-->>C: 202 Accepted {post_id} PS->>CH: инвалидация/прогрев ленты автора (read-your-writes) OB-->>FW: PostPublished {post_id, author_id, ts} FW->>DB: читать подписчиков автора (курсором, пачками по 5000) alt автор — обычный пользователь FW->>IB: батч-вставка в инбоксы подписчиков FW->>CH: точечная инвалидация горячих лент else автор — «звезда» (> 100k подписчиков) FW->>CH: пометить пост в celebrity-ленте автора Note over FW,CH: инбоксы НЕ трогаем —
подмешаем на чтении end
Ключевой приём на записи — транзакционный outbox: пост и событие о нём фиксируются в одной локальной транзакции, а отдельный релей вычитывает outbox и публикует в брокер. Без этого «записали в базу, потом отправили в Kafka» гарантированно теряет события при падении между двумя операциями. Механика подробно разобрана в статье о сагах и outbox.
с флагом stale=true — деградация вместо ошибки
Обратите внимание на два места, где дизайн защищает p99: батч-чтение постов вместо N отдельных запросов (иначе 20 сетевых хопов вместо одного, и p99 страницы = p99 самого медленного из двадцати) и частичная деградация вместо ошибки. Оба приёма — прямое следствие арифметики хвостовых задержек: при fan-out на 20 запросов вероятность попасть хотя бы в один p99-случай равна 1 − 0,99²⁰ ≈ 18 %.
8. Шаг 6. Углубления: где реально ломается лента
Первый эскиз всегда ломается в предсказуемых местах. Задача шестого шага — найти их и углубиться именно туда, а не рисовать остальные квадратики.
8.1. Fan-out на запись против fan-out на чтение
Ни одна из двух чистых стратегий не работает на реальном графе подписок, потому что распределение числа подписчиков — степенное: медиана в районе сотни, а верхний хвост уходит в десятки миллионов. Fan-out на запись отлично обслуживает медиану и убивается на хвосте; fan-out на чтение наоборот.
Отсюда — гибрид, стандартное отраслевое решение (именно так исторически устроена лента Twitter, см. доклад Timelines at Scale): обычные авторы разносятся по инбоксам на записи, «звёзды» подмешиваются на чтении.
from dataclasses import dataclass
CELEBRITY_THRESHOLD = 100_000 # порог подбирается по данным, не по красоте числа
INBOX_CAP = 800 # глубина инбокса: 40 страниц по 20
BATCH = 5_000 # размер пачки записи в инбоксы
@dataclass(frozen=True)
class PostPublished:
post_id: str
author_id: str
score: int # микросекунды с эпохи, монотонные в пределах автора
def handle_post_published(ev: PostPublished, deps) -> None:
"""Обработчик события публикации. Идемпотентен: повторная доставка безопасна,
потому что вставка в инбокс — upsert по (user_id, post_id)."""
author = deps.users.get(ev.author_id)
# Ветка «звезды»: не размножаем. Читатель подмешает пост на чтении.
if author.follower_count >= CELEBRITY_THRESHOLD:
deps.celebrity_feed.push(ev.author_id, ev.post_id, ev.score, cap=INBOX_CAP)
return
# Обычный автор: разносим пачками, курсором по графу подписчиков.
# Курсор обязателен: держать 100k id в памяти воркера — путь к OOM,
# а главное — к невозможности возобновить работу после падения.
cursor = None
while True:
followers, cursor = deps.graph.followers_page(ev.author_id, cursor, BATCH)
if not followers:
break
rows = [(uid, ev.score, ev.post_id, ev.author_id) for uid in followers]
deps.inbox.upsert_batch(rows) # идемпотентно по PK
deps.inbox.trim_async(followers, INBOX_CAP) # подрезка — фоном, не на горячем пути
deps.cache.invalidate_many(f"inbox:{{}}", followers)
if cursor is None:
break
# Стоимость на пост: O(F) записей, где F — число подписчиков, при F < 100k.
# Латентность публикации при этом O(1): запись возвращает 202 до начала fan-out.
# Пиковая нагрузка на inbox-стор: write_rps_peak × среднее F ≈ 460k записей/с —
# распределяется по шардам по user_id, то есть равномерно.
Порог «звезды» — не константа, а рычаг настройки: понижая его, вы уменьшаете нагрузку на запись и увеличиваете на чтение. Правильное значение находится измерением, а не спором.
8.2. Горячие ключи и грозовой фронт кэша
93 000 RPS на чтении означают, что промах кэша по популярному посту — событие катастрофическое: тысячи запросов одновременно обнаруживают отсутствие ключа и уходят в базу (cache stampede, он же thundering herd). База, рассчитанная на трафик промахов, получает трафик попаданий.
Два обязательных механизма: single-flight (только один запрос идёт за данными, остальные ждут его результата) и вероятностное раннее обновление (ключ обновляется до истечения TTL с вероятностью, растущей по мере приближения к нему, — приём из статьи Vattani et al. Optimal Probabilistic Cache Stampede Prevention).
package feed
import (
"context"
"math"
"math/rand"
"time"
"golang.org/x/sync/singleflight"
)
type Loader struct {
cache Cache
db Store
sf singleflight.Group // схлопывает конкурентные промахи по одному ключу
beta float64 // агрессивность раннего обновления, обычно 1.0
}
// Get возвращает пост, защищая базу от лавины промахов.
func (l *Loader) Get(ctx context.Context, id string) (*Post, error) {
key := "post:" + id
if e, ok := l.cache.GetEntry(ctx, key); ok && !shouldRefreshEarly(e, l.beta) {
return e.Post, nil
}
// Все горутины, промахнувшиеся по одному ключу, разделят один поход в базу.
v, err, _ := l.sf.Do(key, func() (any, error) {
start := time.Now()
p, err := l.db.LoadPost(ctx, id)
if err != nil {
return nil, err
}
// Сохраняем и данные, и стоимость их вычисления: она нужна формуле ниже.
l.cache.SetEntry(ctx, key, Entry{
Post: p,
Delta: time.Since(start),
Expiry: time.Now().Add(jitter(10 * time.Minute)),
})
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Post), nil
}
// shouldRefreshEarly: XFetch. Чем дороже пересчёт и чем ближе истечение,
// тем выше шанс обновиться заранее — истечения «размазываются» во времени.
func shouldRefreshEarly(e Entry, beta float64) bool {
gap := float64(e.Delta) * beta * -math.Log(rand.Float64())
return time.Now().Add(time.Duration(gap)).After(e.Expiry)
}
// jitter защищает от синхронного истечения тысяч ключей, записанных в одну секунду
// (например, после прогрева кэша при деплое).
func jitter(d time.Duration) time.Duration {
return d + time.Duration(rand.Int63n(int64(d/5)))
}
Отдельная проблема — горячий ключ, который не спасает single-flight: пост знаменитости
собирает столько чтений, что упирается сам узел кэша, хранящий этот ключ. Решения:
реплицировать горячие ключи на несколько узлов с суффиксом (post:{id}:r{0..7}) и читать
случайную реплику, либо держать локальный in-process кэш на API-узлах с TTL в единицы секунд.
Второе почти всегда дешевле и эффективнее: 2-секундный локальный кэш срезает 99 % трафика
к популярному ключу ценой двухсекундной устаревшести.
8.3. Read-your-writes и восприятие согласованности
Мы вернули 202 Accepted и разносим пост асинхронно. Автор, обновивший экран через
секунду, своего поста не увидит — и напишет в поддержку, что «сервис теряет посты».
Формально система корректна, фактически — сломана.
Приём: на записи синхронно вставляем пост в собственный инбокс автора (одна запись, дешёвая) и помечаем сессию автора «недоверять кэшу N секунд». Это частный случай сессионной согласованности: глобально система остаётся конечно-согласованной, но каждый пользователь видит собственные действия немедленно.
Правило, которое стоит запомнить: пользователи почти никогда не замечают отсутствия строгой согласованности между собой, но мгновенно замечают её отсутствие для собственных действий. Проектируйте согласованность от восприятия, а не от учебника.
8.4. Что делать со шардированием
При 1,2 ПБ данных шардинг неизбежен. Три решения, которые надо принять явно:
Ключ шардирования. Для постов — hash(author_id): даёт равномерность и локальность
«все посты автора рядом». Для инбоксов — hash(user_id): страница ленты читается с одного
шарда. Шардировать по времени (created_at) — распространённая ошибка: весь трафик записи
идёт в последний шард, остальные простаивают.
Способ распределения. Хеш по модулю числа узлов ломается при добавлении узла (переезжает почти всё). Consistent hashing с виртуальными узлами переносит только долю 1/N — стандартный выбор; разбор — в статье о масштабировании.
Респлит. Заранее ответьте на вопрос «как мы удвоим число шардов, не останавливая сервис». Обычный ответ — «логических шардов сразу много (например, 1024), физических мало, и мы переносим логические между физическими». Это решение стоит принять на старте: задним числом оно превращается в многомесячную миграцию.
9. Шаг 7. Отказы, деградация и эксплуатация
Дизайн не закончен, пока не описано поведение при отказах. Для каждого компонента на критическом пути отвечаем на три вопроса: что видит пользователь, что делает система, как мы об этом узнаём.
| Отказ | Что видит пользователь | Поведение системы | Сигнал |
|---|---|---|---|
| Недоступен Post Store | лента без части постов, stale=true |
отдаём из кэша, помечаем деградацию | рост доли stale |
| Недоступен Inbox Store | лента строится fan-out на чтении, медленнее | переключение на pull-путь, лимит 200 подписок | рост p99 чтения |
| Отстаёт fan-out воркер | новые посты появляются с задержкой | лаг растёт, приоритет активным пользователям | лаг консьюмера |
| Упал кэш целиком | резкий рост латентности | сброс нагрузки: 30 % запросов получают 503 | рост промахов, срабатывание брейкера |
| Потеря шарда БД | часть пользователей без ленты | failover на реплику, RPO ≈ секунды | алерт на репликацию |
| Всплеск 10x | лента медленнее, но работает | адаптивный лимит параллелизма, очередь | утилизация выше 70 % |
или p99 > SLO Повышенная --> Норма: нагрузка спала Повышенная --> Деградация: p99 > 2×SLO
или брейкер открыт Деградация --> Повышенная: зависимость восстановилась Деградация --> Отказ: очередь растёт
несмотря на деградацию Отказ --> Деградация: сброс нагрузки сработал note right of Повышенная отключаем необязательное: счётчики, рекомендации, превью ссылок end note note right of Деградация отдаём кэш любой свежести, урезаем страницу до 10 элементов, stale=true end note note right of Отказ load shedding: часть запросов отбрасывается сразу, чтобы остальные обслуживались в SLO end note
Ключевая идея этой диаграммы: деградация — это спроектированное состояние, а не авария. Система, у которой есть только «работает» и «не работает», при перегрузке всегда выбирает второе. Система с промежуточными режимами переживает всплеск, потеряв качество, а не доступность.
Что должно попасть в дизайн-документ помимо схем:
- Наблюдаемость. Какие метрики (RED: rate, errors, duration — по каждому пути), какие трассировки (сквозной trace-id от клиента до fan-out воркера), какие логи. Правило: метрика существует до фичи, а не после первого инцидента.
- Планирование ёмкости. Сколько узлов на текущий пик, при какой утилизации триггерится масштабирование, каков lead time закупки/квоты.
- Стоимость. Оценка на 1 млн запросов. Дизайн, который дороже выручки, — плохой дизайн, как бы красив он ни был.
- План отката. Как выключить fan-out на запись и вернуться на pull, если новая ветка сломалась. Feature flag на уровне архитектурного решения — недооценённая практика.
10. Эволюция: одна и та же система на разных масштабах
Опасная ошибка — проектировать сразу конечную архитектуру. У систем есть стадии, и на каждой оптимальное решение разное. Строить fan-out воркеры и шардинг для сервиса с тысячей пользователей — это не предусмотрительность, а растрата.
Правило перехода между стадиями: менять архитектуру, когда упёрлись в измеренное ограничение, а не когда прочитали статью. Каждая стадия должна быть дешёвой в эксплуатации и оставлять открытым путь к следующей — это ровно то свойство, которое архитектурные решения называют эволюционностью, а модульный монолит — правильной стартовой точкой для большинства систем.
11. Типичные ошибки
Начинать с технологий. «Возьмём Kafka» до того, как посчитан объём событий, — почти гарантированно лишняя операционная сложность. Сначала числа, потом коробки.
Оптимизировать среднее вместо хвоста. Пользователь ощущает p99, а не p50: при 20 запросах на страницу «редкий» медленный случай перестаёт быть редким. Все SLO формулируйте в перцентилях.
Забывать про кросс-шардовые запросы. Модель данных, где типичный запрос требует данных с трёх шардов, отменяет выгоду шардирования: латентность становится максимумом из трёх, а доступность — произведением.
Не считать стоимость. Дизайн с ежемесячным счётом больше выручки — не дизайн. Стоимость на запрос — такой же атрибут качества, как латентность.
Проектировать «на всякий случай». Абстракция «на будущее», которую никогда не используют, — чистый долг. Проектируйте под требования плюс один порядок роста, не больше.
Игнорировать операционную сторону. Пятнадцать сервисов при команде из четырёх человек — это не архитектура, а расписание дежурств. Учитывайте закон Конвея: структура системы повторит структуру команд.
Считать, что асинхронность бесплатна. Она переносит сложность, а не убирает: появляются лаг, повторная доставка, порядок, дедупликация, ретенция — вся механика из событийной архитектуры.
Не описывать деградацию. Если в дизайне нет ответа «что видит пользователь, когда зависимость лежит», ответ по умолчанию — 500-я ошибка.
12. Тот же маршрут за 45 минут: формат собеседования
System design как интервью-жанр проверяет ровно этот процесс, только сжатый. Работающий тайминг:
| Минуты | Что делать |
|---|---|
| 0–5 | Уточнить требования, зафиксировать масштаб и non-goals. Обязательно вслух. |
| 5–10 | Оценки: RPS, объём, соотношение чтение/запись. Числа — на доску. |
| 10–15 | API и модель данных. Паттерны доступа перечислить явно. |
| 15–25 | Эскиз: путь записи и путь чтения. Стрелки подписать нагрузкой. |
| 25–40 | Углубление в 1–2 узких места (интервьюер обычно направляет). |
| 40–45 | Отказы, деградация, метрики, что бы улучшили при большем времени. |
Что реально оценивается: умение задавать уточняющие вопросы, готовность назвать trade-off вслух («выбираю fan-out на запись, плачу удорожанием записи в 200 раз, выигрываю чтение»), корректность порядков величин и способность углубиться, когда просят. Что не оценивается: знание конкретных продуктов и количество нарисованных прямоугольников. Ответ «я бы поставил Cassandra» без объяснения, какие свойства нужны, слабее ответа «нужен стор с высокой скоростью записи, сортированными ключами и линейным масштабированием — подойдёт LSM-семейство».
Чеклист дизайна
Перед тем как считать дизайн готовым, проверьте, что можете ответить на каждый пункт:
- Записаны функциональные требования, SLO и явные non-goals.
- Посчитаны: пиковые RPS чтения и записи, объём данных на горизонте, размер горячего множества, исходящий трафик.
- Определено соотношение чтение/запись и выбрана оптимизируемая сторона.
- Перечислены паттерны доступа с частотами; модель данных выведена из них.
- Выбраны шард-ключи, описан план респлита.
- Нарисованы путь записи и путь чтения; на каждой стрелке — нагрузка и таймаут.
- Целевая утилизация ≤ 70 %, запас на потерю узла заложен.
- Описано поведение при отказе каждой зависимости и режимы деградации.
- Есть идемпотентность на записи и защита от лавины промахов на чтении.
- Указаны метрики RED по каждому пути и алерты по бюджету ошибок.
- Посчитана стоимость на 1 млн запросов.
- Записан ADR: контекст, варианты, решение, последствия.
Мини-итог
- System design — процесс, а не каталог. Порядок шагов защищает от преждевременного выбора технологий: требования → оценки → API → данные → пути → узкие места → отказы.
- Оценки на салфетке принимают половину архитектурных решений ещё до первой схемы. Семь чисел (пиковые RPS, объём, горячее множество, fan-out, полоса, соотношение чтение/запись, коэффициент пика) стоят больше, чем час обсуждения.
- Данные проектируются от запросов. Шард-ключ — самое дорогое в изменении решение.
- Хвост важнее среднего: p99 и fan-out-арифметика определяют, будет ли система ощущаться быстрой.
- Утилизация выше 70 % — заём под проценты, которые платятся инцидентами.
- Деградация — спроектированное состояние. Система без промежуточных режимов при перегрузке выбирает отказ.
- Архитектура имеет стадии. Правильная — та, что дёшева сейчас и оставляет открытым переход к следующей.
Источники
- Martin Kleppmann. Designing Data-Intensive Applications — фундамент: репликация, партиционирование, согласованность, потоки. Если читать одну книгу по теме, то эту.
- Alex Xu. System Design Interview, vol. 1–2 — компактный разбор типовых задач с оценками.
- Google SRE. Site Reliability Engineering и The SRE Workbook — SLO, бюджеты ошибок, планирование ёмкости.
- Jeff Dean. Latency Numbers Every Programmer Should Know — интерактивная версия таблицы порядков.
- Dean & Barroso. The Tail at Scale, CACM 2013 — почему хвостовая латентность доминирует при fan-out.
- Neil Gunther. Guerrilla Capacity Planning — закон Литтла, универсальный закон масштабируемости, практика планирования ёмкости.
- Vattani, Chierichetti, Lowenstein. Optimal Probabilistic Cache Stampede Prevention, VLDB 2015.
- Raffi Krikorian. Timelines at Scale — эталонный разбор гибридного fan-out на реальной ленте.
- Bronson, Amsden et al. TAO: Facebook’s Distributed Data Store for the Social Graph, USENIX ATC 2013.
- The Amazon Builders’ Library — короткие статьи практиков про таймауты, ретраи, load shedding и планирование ёмкости.
- Netflix Tech Blog и High Scalability — разборы реальных систем.
Что дальше
На этом трек «Архитектурные паттерны» закончен. Мы прошли путь от карты стилей и слоёных архитектур через монолит, микросервисы, события, CQRS, саги, API, масштабирование, устойчивость, serverless и архитектурные решения — к тому, как всё это складывается в проектирование конкретной системы.
Куда идти дальше, зависит от того, где у вас сейчас тоньше:
- Проектируете границы и модель предметной области — трек Domain-Driven Design: ограниченные контексты, агрегаты, контекстные карты. Это прямое продолжение разговора о границах сервисов.
- Хотите глубже в алгоритмическую основу оценок и структур данных — Алгоритмы и Структуры данных.
- Строите системы обработки данных, конвейеры и хранилища — Data Engineering.
- Нужен язык, на котором всё это удобно писать под нагрузкой — Go для сервисов и Elixir для конкурентных и отказоустойчивых систем; для фронтенда и типизированных контрактов — TypeScript.
- Хотите закрепить принципы и приёмы уровнем ниже — Принципы и Паттерны проектирования.
И общий маршрут по всем трекам портала — Дорожная карта.