Балансировка, кэши и CDN. Когда хватит одного большого сервера, куда девать состояние, как распределять запросы, сколько на самом деле даёт кэш и как защитить систему от собственного успеха.
Серия «Системный дизайн», часть 2. Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Вертикальное масштабирование — взять машину побольше. Сегодня это 192 ядра, терабайты RAM и NVMe на миллион IOPS. Ни строчки кода не меняется.
Сервер приложения, который ничего не помнит между запросами, можно добавить, убить или перезапустить в любой момент. Значит, всё состояние должно жить где-то ещё.
Обычно уровни стоят цепочкой: DNS выбирает регион, L4 раздаёт TCP-соединения по L7-прокси, а те — HTTP-запросы по приложениям.
Разные IP для разных клиентов: GeoDNS, Route 53, NS1. Грубо: клиенты и резолверы кэшируют ответ дольше TTL, о падении сервера DNS узнаёт с опозданием на минуты.
выбор региона и дата-центраВидит только IP и порт, раздаёт TCP-соединения по хешу 5-tuple. Миллионы пакетов в секунду на сервер: LVS/IPVS, Maglev, AWS NLB. Одно долгое соединение HTTP/2 или gRPC целиком уходит на один бэкенд.
дёшево и быстро, но слепоТерминирует TLS, читает HTTP: маршрутизирует по пути, заголовку, cookie, балансирует каждый запрос и каждый gRPC-стрим, делает повторы и таймауты. nginx, HAProxy, Envoy, AWS ALB — десятки тысяч RPS на ядро.
умно, но дороже по CPUКлиент сам знает список бэкендов и выбирает (gRPC, service mesh с sidecar). Нет лишнего хопа, но нужен discovery и одинаковая логика во всех клиентах.
для внутренних сервисовПять серверов, один из них втрое медленнее — у него «шумный сосед» или GC. Round robin этого не замечает и отправляет ему ту же пятую часть запросов. Очередь растёт, и p99 улетает.
Выберите алгоритм и поднимите нагрузку. Квадратики — запросы в очереди сервера.
Иногда запросы одного ключа нужно отправлять на один и тот же сервер: там его локальный кэш, WebSocket или шард данных. Простое hash(key) % N при добавлении сервера перемешивает почти всё.
На кольце каждый ключ принадлежит ближайшему серверу по часовой стрелке. Добавьте или уберите сервер: переедут только ключи соседнего участка, около 1/N. Виртуальные узлы выравнивают доли. В nginx это hash $cookie_uid consistent, в Envoy — ring_hash и maglev.
/health и после 2–3 неудач подряд исключает сервер. До этого момента часть запросов уходит в никуда.Проверка готовности (readiness) должна проверять сам сервис, а не его зависимости: если /health ходит в базу, то при сбое базы балансировщик выключит все серверы сразу.
Перед сервисом стоят 40 экземпляров L7-балансировщика, у каждого свой счётчик соединений. Все они используют least connections. Сервер перезапустился и вернулся в пул пустым. Что произойдёт?
Архитектура растёт не по моде, а по узким местам. На каждом шаге что-то упирается в потолок, и вы добавляете ровно тот компонент, который его снимает.
Нажимайте «Пользователей ×10». Красным — что сломается следующим, зелёным — что только что добавили. Оценка: 100 запросов на пользователя в день, пик втрое выше среднего, 3 запроса к БД на API-запрос.
Чем ближе к пользователю, тем быстрее ответ и тем труднее инвалидация. Самый дешёвый запрос — тот, который до вас не дошёл.
Браузер и мобильное приложение: Cache-Control, ETag. Сбросить нельзя — только дождаться TTL или сменить URL (app.3f9a.js).
Точка присутствия рядом с пользователем вместо 100–200 мс до другого континента. Статика, медиа, иногда публичные API.
nginx proxy_cache, Varnish перед приложением: готовые HTTP-ответы целиком.
Словарь или LRU в памяти приложения. Самый быстрый, но у каждого из N экземпляров своя копия и своя степень устаревания.
Redis или Memcached: один кэш на всех, ~100 тыс. операций в секунду на ядро. Подробнее о Redis — в презентации про бэкенд-стек.
Buffer pool и кэш ОС. Работает сам, но запрос всё равно проходит разбор, план и выполнение.
чтение: кэш → промах → БД → SET в кэш
запись: БД → DEL ключа
Приложение само управляет кэшем. Стандарт по умолчанию: кэш упал — работаем медленнее, но работаем. Риск — гонки и устаревшие данные до TTL.
чтение: кэш сам идёт в БД при промахе
То же, но логика загрузки внутри кэша или библиотеки (Caffeine, DAX). Код чище, кэш становится обязательным звеном.
запись: кэш → БД синхронно
Кэш всегда свежий для того, что через него записали. Запись медленнее, и в кэш попадает много того, что никто не прочитает.
запись: кэш → ответ → БД потом, пачками
Быстрая запись и сглаживание пиков. Но упал кэш до сброса — данные потеряны. Годится для счётчиков и метрик, не для денег.
Ни один паттерн не даёт транзакции «база + кэш»: между записью в базу и операцией с кэшем всегда есть окно, когда они расходятся, а процесс может упасть посередине. Поэтому TTL ставят всегда — как страховку.
До базы доходит доля промахов: RPS × (1 − hit). Поэтому 99% и 95% — это не «на 4% лучше», а впятеро меньше нагрузки на базу.
hit × tкэш + miss × (tкэш + tБД).user:42:v7.maxmemory-policy allkeys-lru или allkeys-lfu. С noeviction запись в полный кэш падает с ошибкой.feed:top#1..8 со случайным выбором при чтении и чтением с реплик.redis-cli --hotkeys (при политике LFU), метрики по ключам в клиенте.Популярный ключ истёк. Пока первый запрос пересчитывает его 300 мс, ещё сотни запросов тоже видят промах и идут в базу с тем же самым запросом. База тормозит, пересчёт идёт дольше, промахов ещё больше.
SET lock NX PX 5000 — пересчитывает один, остальные ждут или получают старое значение.Сервис при изменении профиля делает UPDATE в базе, а затем SET user:42 с новым значением в Redis. TTL — сутки. Иногда пользователи видят старое имя весь день. Почему?
SET от B дошёл до Redis раньше, чем от A. В базе новое значение, в кэше — старое, и так до истечения TTL. Удаление ключа вместо записи (DEL) убирает эту гонку почти полностью; короткий TTL ограничивает ущерб от оставшихся.INCR + EXPIRE. Просто, но на стыке окон проходит двойной лимит.Где: на входе (API gateway, nginx limit_req) — по ключу API или IP, и перед дорогими зависимостями. Распределённый лимит — счётчики в Redis. Отказ — 429 с Retry-After.
Когда запросов больше, чем сервер может обработать, очередь растёт. Как только ожидание превысило таймаут клиента, сервер работает впустую: отвечает тем, кто уже ушёл. Полезная пропускная способность падает до нуля.
503 лучше бесконечной очереди.Автоскейлер видит нагрузку с опозданием, решает раз в 15–60 секунд, а новая машина поднимается минуты: загрузить образ, стартовать, прогреть кэши и JIT. Плавный рост он догоняет, резкий всплеск — нет.
Лимит — 100 запросов в минуту на ключ API, алгоритм fixed window. Сколько запросов клиент может успешно сделать за 2 секунды?
# какой кэш ответил и сколько он уже держит ответ
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';
Узкое место всегда есть. Вопрос только в том, знаете ли вы, где оно.