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

Балансировка, кэши и CDN. Когда хватит одного большого сервера, куда девать состояние, как распределять запросы, сколько на самом деле даёт кэш и как защитить систему от собственного успеха.

Серия «Системный дизайн», часть 2. Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Вертикально или горизонтально

Вертикальное масштабирование — взять машину побольше. Сегодня это 192 ядра, терабайты RAM и NVMe на миллион IOPS. Ни строчки кода не меняется.

  • Горизонтальное — больше одинаковых машин. Нужны балансировщик, stateless-код, а для данных — репликация и шардирование.
  • Миллион DAU — это обычно сотни RPS, а не десятки тысяч. Сначала посчитайте.
  • Горизонтально масштабируют ради отказоустойчивости раньше, чем ради нагрузки: двух серверов хватает обоим требованиям.

Stateless: состояние — наружу

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

  • Сессии — в Redis или в подписанном токене (JWT). Sticky sessions — костыль: падение сервера разлогинит всех его пользователей.
  • Загрузки — сразу в S3 по presigned URL, а не на локальный диск.
  • Локальный кэш — можно, но только как копия: с коротким TTL.
  • Фоновые задачи и cron — в очереди, а не в памяти одного экземпляра.

Где балансировать: DNS, L4, L7

Обычно уровни стоят цепочкой: DNS выбирает регион, L4 раздаёт TCP-соединения по L7-прокси, а те — HTTP-запросы по приложениям.

DNS

Разные IP для разных клиентов: GeoDNS, Route 53, NS1. Грубо: клиенты и резолверы кэшируют ответ дольше TTL, о падении сервера DNS узнаёт с опозданием на минуты.

выбор региона и дата-центра
L4

Видит только IP и порт, раздаёт TCP-соединения по хешу 5-tuple. Миллионы пакетов в секунду на сервер: LVS/IPVS, Maglev, AWS NLB. Одно долгое соединение HTTP/2 или gRPC целиком уходит на один бэкенд.

дёшево и быстро, но слепо
L7

Терминирует TLS, читает HTTP: маршрутизирует по пути, заголовку, cookie, балансирует каждый запрос и каждый gRPC-стрим, делает повторы и таймауты. nginx, HAProxy, Envoy, AWS ALB — десятки тысяч RPS на ядро.

умно, но дороже по CPU
клиент

Клиент сам знает список бэкендов и выбирает (gRPC, service mesh с sidecar). Нет лишнего хопа, но нужен discovery и одинаковая логика во всех клиентах.

для внутренних сервисов

Алгоритмы балансировки

Пять серверов, один из них втрое медленнее — у него «шумный сосед» или GC. Round robin этого не замечает и отправляет ему ту же пятую часть запросов. Очередь растёт, и p99 улетает.

  • Least connections смотрит на число запросов в работе — медленный сервер сам набирает очередь и получает меньше.
  • Power of two choices: выбрать два случайных и взять менее загруженный. Почти так же хорошо, и не нужна глобальная картина.

Выберите алгоритм и поднимите нагрузку. Квадратики — запросы в очереди сервера.

Консистентное хеширование

Иногда запросы одного ключа нужно отправлять на один и тот же сервер: там его локальный кэш, WebSocket или шард данных. Простое hash(key) % N при добавлении сервера перемешивает почти всё.

На кольце каждый ключ принадлежит ближайшему серверу по часовой стрелке. Добавьте или уберите сервер: переедут только ключи соседнего участка, около 1/N. Виртуальные узлы выравнивают доли. В nginx это hash $cookie_uid consistent, в Envoy — ring_hash и maglev.

Health checks и draining

  • Активная проверка: балансировщик раз в N секунд дёргает /health и после 2–3 неудач подряд исключает сервер. До этого момента часть запросов уходит в никуда.
  • Пассивная (outlier detection): смотреть на ошибки живых запросов и исключать сервер после 5 подряд — реагирует за доли секунды.
  • Draining: перед остановкой сервер перестаёт получать новые запросы и доделывает текущие. Без этого каждый деплой — всплеск 502.

Проверка готовности (readiness) должна проверять сам сервис, а не его зависимости: если /health ходит в базу, то при сбое базы балансировщик выключит все серверы сразу.

Проверьте себя

Перед сервисом стоят 40 экземпляров L7-балансировщика, у каждого свой счётчик соединений. Все они используют least connections. Сервер перезапустился и вернулся в пул пустым. Что произойдёт?

Каждый балансировщик видит у нового сервера ноль соединений и независимо выбирает именно его — стадное поведение. Сервер с холодным кэшем и непрогретым JIT захлёбывается. Поэтому в распределённых балансировщиках берут power of two choices — случайность разводит их по разным серверам, — и включают slow start: вес нового сервера растёт постепенно.

От одного сервера к сотням

Архитектура растёт не по моде, а по узким местам. На каждом шаге что-то упирается в потолок, и вы добавляете ровно тот компонент, который его снимает.

Нажимайте «Пользователей ×10». Красным — что сломается следующим, зелёным — что только что добавили. Оценка: 100 запросов на пользователя в день, пик втрое выше среднего, 3 запроса к БД на API-запрос.

Где кэшировать

Чем ближе к пользователю, тем быстрее ответ и тем труднее инвалидация. Самый дешёвый запрос — тот, который до вас не дошёл.

клиент0 мс

Браузер и мобильное приложение: Cache-Control, ETag. Сбросить нельзя — только дождаться TTL или сменить URL (app.3f9a.js).

CDN5–30 мс

Точка присутствия рядом с пользователем вместо 100–200 мс до другого континента. Статика, медиа, иногда публичные API.

reverse proxy~1 мс

nginx proxy_cache, Varnish перед приложением: готовые HTTP-ответы целиком.

в процессе~100 нс

Словарь или LRU в памяти приложения. Самый быстрый, но у каждого из N экземпляров своя копия и своя степень устаревания.

распределённый0,2–1 мс

Redis или Memcached: один кэш на всех, ~100 тыс. операций в секунду на ядро. Подробнее о Redis — в презентации про бэкенд-стек.

базабуферы

Buffer pool и кэш ОС. Работает сам, но запрос всё равно проходит разбор, план и выполнение.

Как кэш стоит рядом с базой

cache-aside

чтение: кэш → промах → БД → SET в кэш
запись: БД → DEL ключа

Приложение само управляет кэшем. Стандарт по умолчанию: кэш упал — работаем медленнее, но работаем. Риск — гонки и устаревшие данные до TTL.

read-through

чтение: кэш сам идёт в БД при промахе

То же, но логика загрузки внутри кэша или библиотеки (Caffeine, DAX). Код чище, кэш становится обязательным звеном.

write-through

запись: кэш → БД синхронно

Кэш всегда свежий для того, что через него записали. Запись медленнее, и в кэш попадает много того, что никто не прочитает.

write-behind

запись: кэш → ответ → БД потом, пачками

Быстрая запись и сглаживание пиков. Но упал кэш до сброса — данные потеряны. Годится для счётчиков и метрик, не для денег.

Ни один паттерн не даёт транзакции «база + кэш»: между записью в базу и операцией с кэшем всегда есть окно, когда они расходятся, а процесс может упасть посередине. Поэтому TTL ставят всегда — как страховку.

Математика hit ratio

До базы доходит доля промахов: RPS × (1 − hit). Поэтому 99% и 95% — это не «на 4% лучше», а впятеро меньше нагрузки на базу.

  • Средняя задержка: hit × tкэш + miss × (tкэш + tБД).
  • p99 определяется промахами, пока их больше 1%.
  • Если база выдерживает только трафик промахов, кэш стал несущей стеной. Холодный старт или падение кэша кладёт базу.

TTL, инвалидация, вытеснение

  • TTL — верхняя граница устаревания и страховка от ошибок инвалидации. Добавляйте случайный разброс ±10%, иначе ключи, записанные вместе, вместе и истекут.
  • Инвалидация по событию: удалить ключ после записи в БД или по потоку изменений (CDC из WAL через Debezium). Либо версия в ключе: user:42:v7.
  • Вытеснение, когда память кончилась: LRU выбрасывает давно не читанное, LFU — редко читанное и переживает разовый скан. В Redis — maxmemory-policy allkeys-lru или allkeys-lfu. С noeviction запись в полный кэш падает с ошибкой.

Горячие ключи

  • Ключ на 200 тыс. чтений в секунду живёт на одном шарде Redis, а одно ядро тянет около 100 тыс. Кластер из 50 узлов не поможет.
  • Лечат локальным кэшем на 1–5 секунд перед Redis, копиями ключа feed:top#1..8 со случайным выбором при чтении и чтением с реплик.
  • Найти: redis-cli --hotkeys (при политике LFU), метрики по ключам в клиенте.

Cache stampede

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

  • Блокировка: SET lock NX PX 5000 — пересчитывает один, остальные ждут или получают старое значение.
  • Склейка запросов (single flight) внутри процесса: один запрос в БД на экземпляр приложения.
  • Раннее вероятностное обновление: незадолго до TTL кто-то один обновит ключ заранее, и промаха не будет вовсе.

Проверьте себя

Сервис при изменении профиля делает UPDATE в базе, а затем SET user:42 с новым значением в Redis. TTL — сутки. Иногда пользователи видят старое имя весь день. Почему?

Запрос A обновил базу первым, запрос B — вторым, но SET от B дошёл до Redis раньше, чем от A. В базе новое значение, в кэше — старое, и так до истечения TTL. Удаление ключа вместо записи (DEL) убирает эту гонку почти полностью; короткий TTL ограничивает ущерб от оставшихся.

CDN

  • Сотни точек присутствия кэшируют ответы рядом с пользователем. Каждая кэширует отдельно — и каждая ходит за промахами к вам.
  • Origin shield — промежуточный уровень: все точки спрашивают его, и до origin доходит один запрос на объект.
  • Ключ кэша — URL плюс то, что вы в него добавили. Cookie или utm-метки в ключе превращают один объект в тысячи.
  • Purge занимает секунды, но для статики проще версионировать имена файлов и ставить TTL на год.
  • Динамику CDN ускоряет иначе: TLS рядом с пользователем и прогретые соединения до origin.

Rate limiting

  • Fixed window — счётчик на минуту: INCR + EXPIRE. Просто, но на стыке окон проходит двойной лимит.
  • Sliding window — считает последние 60 секунд: точный журнал меток времени или взвешенная сумма двух окон.
  • Token bucket — токены капают с постоянной скоростью до ёмкости корзины. Разрешает всплески, но держит среднюю скорость.

Где: на входе (API gateway, nginx limit_req) — по ключу API или IP, и перед дорогими зависимостями. Распределённый лимит — счётчики в Redis. Отказ — 429 с Retry-After.

Backpressure и сброс нагрузки

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

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

Автоскейлинг и его задержка

Автоскейлер видит нагрузку с опозданием, решает раз в 15–60 секунд, а новая машина поднимается минуты: загрузить образ, стартовать, прогреть кэши и JIT. Плавный рост он догоняет, резкий всплеск — нет.

  • Держите запас: цель загрузки 50–70%, а не 90%.
  • Предсказуемые пики — расписанием, заранее.
  • От внезапных всплесков спасает не автоскейлинг, а сброс нагрузки и очереди.

Проверьте себя

Лимит — 100 запросов в минуту на ключ API, алгоритм fixed window. Сколько запросов клиент может успешно сделать за 2 секунды?

100 запросов в последнюю секунду одного окна и ещё 100 в первую секунду следующего: счётчик обнулился на границе. Если бэкенду важна мгновенная скорость, берут token bucket с маленькой ёмкостью или sliding window — и ограничивают сразу на двух масштабах: в секунду и в минуту.

На практике и на интервью

  • Сначала оценка: RPS в пике, доля записи, объём данных. Числа решают, нужен ли вообще кэш или шардирование.
  • Смотрите на p99 и насыщение (длину очередей), а не на среднее и CPU.
  • Для каждого компонента скажите, что будет при его отказе: кэш упал, балансировщик потерял сервер, CDN промахнулся.
  • Нагрузочный тест — до запуска, на реальной форме трафика, с ростом до отказа.

# какой кэш ответил и сколько он уже держит ответ
curl -sI https://example.com/app.js | grep -iE 'cache-control|age|x-cache'

# нагрузка с распределением задержек
wrk -t4 -c200 -d30s --latency http://localhost:8080/api
k6 run --vus 200 --duration 1m load.js

# Redis: попадания, промахи, горячие ключи
redis-cli INFO stats | grep keyspace_
redis-cli --hotkeys
redis-cli --latency

# nginx: статус кэша и время ответа апстрима
log_format c '$uri $upstream_cache_status $upstream_response_time';

Главное

  • Посчитайте нагрузку. Один сервер тянет больше, чем кажется; второй нужен ради отказоустойчивости.
  • Stateless-приложение, состояние — в базе, Redis и S3. Тогда горизонтальное масштабирование — это просто число экземпляров.
  • Балансируйте по нагрузке, а не по кругу: least connections или power of two choices. Health checks, outlier detection и draining обязательны.
  • Кэш делит нагрузку на базу на долю промахов. Планируйте его падение, stampede и горячие ключи.
  • CDN — с origin shield и чистым ключом кэша.
  • Перегрузку встречайте лимитами, ограниченными очередями и быстрыми отказами: автоскейлинг всегда опаздывает.

Узкое место всегда есть. Вопрос только в том, знаете ли вы, где оно.