← назад к разделу

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

Знать названия блоков недостаточно. Каждый блок что-то даёт и чего-то стоит. Понять эту цену — и есть суть системного дизайна.

узкое место реплики и кеш много чтений шардинг много записей очередь пики нагрузки поиск, аналитика запросы не по ключу ограничение частоты клиент перебирает

Блоки собраны по тому, какую нагрузку они снимают: сначала называют симптом на стрелке, потом берут блок.

Обязательно

Сервисы без состояния и балансировка

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

Это работает только если сервис без состояния (stateless): каждый запрос обрабатывается независимо, без памяти о предыдущих. Сессия пользователя хранится в токене, а не в памяти сервера. Файлы — в отдельном хранилище, а не на диске процесса.

Что даёт: можно добавлять копии под нагрузку; падение одной копии незаметно — остальные продолжают работать.

Что стоит: нужно явно выносить любое состояние — в базу, в кеш, в хранилище файлов.

Балансировщик: что в нём решают

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

На каком уровне. Балансировщик четвёртого уровня видит только адреса и порты: он раскидывает соединения, не заглядывая внутрь. Быстрый, дешёвый, ничего не умеет решать по содержимому. Балансировщик седьмого уровня разбирает HTTP: он видит путь, заголовки, куки — и потому умеет направлять по пути (/api/orders в один сервис, /api/payments в другой), завершать шифрование, переписывать заголовки, повторять неудачный запрос на другой копии. Практически: на седьмом уровне живёт вся маршрутизация, и почти всё, что рисуют на схемах как «балансировщик», — это он. Четвёртый уровень остаётся для не-HTTP протоколов и для входа перед седьмым.

По какому правилу распределять. По кругу (все копии по очереди) — умолчание, хорошо при одинаковых запросах. По числу активных соединений — заметно лучше, когда запросы разной тяжести: копия, занятая долгим запросом, не получает новых. По хешу от чего-то (адрес клиента, идентификатор) — когда нужна привязка. И самое полезное практическое знание: выбор правила почти никогда не решает проблему производительности, а вот проверки живости и таймауты — решают.

Как узнаёт, что копия жива. Проверка живости — это адрес, интервал и порог. Из них считается время реакции на отказ: интервал × порог, обычно 10–30 секунд. Всё это время часть запросов уходит в мёртвую копию, и закрывается это окно клиентскими повторами, а не ускорением проверки. Отдельно: проверка должна отвечать «готов принимать трафик», а не «процесс жив», — иначе балансировщик отправит запросы в копию, которая ещё прогревается. Разбор двух видов проверок — в статье про проверки здоровья (Java, Go, Node, Python).

Что делать с состоянием у клиента. Липкие сессии (когда все запросы одного пользователя идут в одну копию) — обычно признак проблемы, а не решение: они мешают выкату (копию гасят, и пользователь теряет сессию), перекашивают нагрузку (активные пользователи попали на одну копию) и не дают масштабировать честно. Правильный ход — вынести состояние (токен, внешняя сессия, кеш), и тогда липкость не нужна вовсе. Оставляют её в двух случаях: старое приложение, которое иначе не работает, и долгие соединения, где переключение стоит дорого.

Долгие соединения и выкат. Тут самая частая неожиданность: балансировщик распределяет соединения, а не запросы. Клиент с постоянным соединением (обычное дело для HTTP с keep-alive и для соединений между сервисами) попадает на копию один раз и живёт на ней часами — а значит, новая копия не получает трафика, пока кто-нибудь не переподключится. Отсюда три следствия: после добавления копий нагрузка выравнивается не сразу; при выкате соединения надо закрывать корректно и давать клиенту переоткрыть их (разбор — в статье про стратегии релиза); для веб-сокетов и потока событий переключение означает разрыв, и клиент обязан уметь переподключаться.

Обратный прокси и шлюз API

Самый частый квадратик на схемах, у которого обычно нет ни имени, ни обязанностей. Разведём два понятия, потому что их путают.

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

Шлюз API — тот же рубеж, но с обязанностями уровня продукта: проверка токена (один раз на входе, а не в каждом сервисе), ограничение частоты по клиенту, ключи доступа для внешних потребителей, версии публичного контракта, преобразование форматов, склейка нескольких вызовов в один ответ для мобильного приложения, учёт использования.

Что здесь важно решить на дизайне: что попадает на рубеж, а что остаётся в сервисах. Хорошее правило: на рубеж выносят то, что одинаково для всех (проверка подписи токена, ограничение частоты, журнал доступа, шифрование), а внутри сервисов остаётся всё, что зависит от предметной области (права на конкретный объект, бизнес-правила, валидация смысла). Нарушение этого правила в обе стороны узнаваемо: шлюз, который знает, что «заказ можно отменить только до отправки», превращается в монолит с правилами; сервис, который сам проверяет подпись токена и считает лимиты, повторяет это в каждом экземпляре по-своему.

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

Планировщик и фоновые задачи

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

Три вопроса, которые решают на дизайне.

Где запускается. Задача внутри сервиса (аннотация на методе) — просто, но при нескольких копиях выполнится на каждой: пять копий пришлют пять писем. Лечится внешней блокировкой: копия, которая первой взяла блокировку, выполняет задачу, остальные пропускают. Вариант получше для тяжёлых задач — отдельная задача в кластере по расписанию: она живёт своей жизнью, не мешает обслуживанию запросов, её можно масштабировать и перезапускать отдельно.

Что если не выполнилась. Пропущенный запуск (сервис лежал в момент, когда надо было запускаться) — нормальное событие, и решение принимают заранее: догонять пропущенное (тогда нужна отметка «до какого момента обработано», а не «запустись в 3:00») или пропустить. Надёжная схема почти всегда одна: задача работает от состояния в данных, а не от факта запуска — «отправить всё, что не отправлено и старше пяти минут», и тогда пропуск просто задерживает работу, а не теряет её.

Сколько она делает за раз. Задача, которая выбирает «все необработанные записи», однажды выберет миллион и положит базу или упрётся в память. Порциями с ограничением, с возможностью остановиться и продолжить — и с отдельным вопросом «а если порций стало слишком много, мы вообще догоним?». Отставание фоновой обработки — такая же метрика, как время ответа, и про неё стоит помнить на дизайне.

Родственный блок — отложенные задачи по событию: «через три дня напомнить, если не оплатил». Хранят их не в памяти, а в таблице или очереди с отложенной доставкой, потому что перезапуск сервиса не должен терять напоминания.

Кеширование

Представьте справочник, который лежит у вас на столе. Вместо того чтобы каждый раз идти в библиотеку за одной и той же книгой, вы держите нужные страницы рядом.

Кеш — это быстрое временное хранилище (обычно в памяти, например Redis). Часто запрашиваемые данные кладут туда, и следующие обращения отвечают за доли миллисекунды. Порядок величин тут важно держать в голове: точечное чтение из PostgreSQL по индексу — это единицы миллисекунд, и кеш перед ним выигрывает немного. Кеш окупается там, где за данными стоит запрос на десятки и сотни миллисекунд — с соединениями, сортировкой и агрегацией, — или вызов чужого сервиса по сети.

Что даёт: скорость чтения и разгрузку базы данных.

Что стоит: данные в кеше могут устареть. Когда оригинал меняется, кеш нужно сбрасывать или обновлять — это называется инвалидацией, и сделать её правильно сложнее, чем кажется. Ещё одна ловушка: если кеш пустой и тысячи запросов приходят одновременно — все ударят в базу разом (cache stampede).

Кеш появляется не «на всякий случай», а когда база реально не справляется с нагрузкой на чтение.

А «не справляется» — это сколько? Чтобы не гадать, полезно держать в голове несколько порядков (подробная таблица — в статье про надёжность и масштабируемость):

ЧтоПорядок
Обращение в память процесса100 наносекунд
Обращение в кеш по сети0,2–1 миллисекунда
Простой запрос в базу по индексу1–5 миллисекунд
Запрос с соединениями и сортировкой10–100 миллисекунд
PostgreSQL, простые чтения по индексудесятки тысяч в секунду
PostgreSQL, запись с фиксациейединицы — десятки тысяч в секунду
Кеш в памяти по сетисотни тысяч операций в секунду

Как этим пользоваться в разговоре о блоках: если у вас 500 чтений в секунду по индексу — база даже не заметила, и кеш здесь лишняя сложность. Если 30 тысяч чтений в секунду — вот теперь разговор про кеш и копии для чтения. Если 20 тысяч записей в секунду — одна база этого не даст, и разговор идёт про разделение данных. Числа отличаются на порядки, и почти любое решение из этой статьи определяется тем, в какой порядок вы попали.

Репликация базы данных

Что если основная база упадёт? Или на неё придёт слишком много запросов?

Репликация — это механизм, при котором все изменения с главного сервера (мастера) автоматически копируются на один или несколько дополнительных серверов (реплики). Если мастер недоступен, новым мастером может стать одна из реплик — но сама по себе она им не станет. Нужен отдельный механизм переключения: он замечает, что мастер не отвечает, выбирает реплику, переводит её в режим записи и разворачивает на неё трафик. В своём хозяйстве это обычно Patroni, в облаке — кнопка или автоматика провайдера.

Что даёт: устойчивость к отказу; читать можно с реплик, не нагружая мастер.

Что стоит: репликация не мгновенная — реплика всегда немного отстаёт. Если пользователь только что что-то сохранил и сразу читает, он может получить старые данные. Это лечится направлением «чтения своего» на мастер. Отставание же стоит денег и при переключении: реплика догоняет мастер асинхронно, поэтому последние записи, которые пользователю уже подтвердили, после переключения могут просто пропасть.

Важный момент: репликация не ускоряет запись — все изменения по-прежнему идут через один мастер.

Шардинг

Репликация не помогает, когда данных становится так много, что они не помещаются на один сервер. Тогда применяют шардинг: данные делят на части (шарды) и хранят каждую часть на отдельном сервере.

Делить можно по-разному, и от способа зависит почти всё. Самый очевидный вариант — по диапазонам ID: пользователи 1–1 000 000 на одном сервере, 1 000 001–2 000 000 на другом. Он же самый коварный: ID выдаются по возрастанию, значит вся новая запись всегда падает в последний шард, а остальные стоят без дела. Поэтому чаще шардируют по остатку от деления хэша ключа — тогда новые записи размазываются ровно. Плата за это — при добавлении сервера остатки пересчитываются и переезжать приходится почти всем данным; чтобы переезжала только малая часть, придумали консистентное хэширование. Деление по диапазонам остаётся уместным там, где диапазон и есть смысл запроса, — например по датам, когда старые куски целиком уходят в архив.

Что даёт: данные и запись масштабируются горизонтально — можно хранить терабайты и петабайты.

Что стоит: выбор ключа шардирования — сложная задача. Неверный выбор приводит к тому, что один шард перегружен, а другие пустые. Запросы, которые затрагивают несколько шардов, медленнее и сложнее. Перераспределение данных при изменении схемы шардирования — болезненная операция.

Правило: сначала вертикальный рост (более мощный сервер), затем партиционирование внутри одной базы, и только потом шардинг — когда числа действительно требуют.

Очереди и события

Представьте, что у вас есть интернет-магазин. Когда покупатель оформляет заказ, нужно: сохранить заказ, списать деньги, отправить письмо, уведомить склад. Если делать всё это синхронно — пользователь ждёт несколько секунд.

Очередь позволяет разделить это во времени: сохранили заказ, ответили пользователю «принято», а остальное — письмо, уведомление склада — выполняется в фоне.

Популярные инструменты: Kafka, RabbitMQ.

Что даёт: скорость ответа пользователю; сглаживание пиков нагрузки (очередь накапливает задачи в пик и отдаёт их равномерно); один источник событий может обрабатываться несколькими независимыми потребителями.

Что стоит: результат доступен не сразу — это называется eventual consistency. Плюс брокер — ещё один компонент инфраструктуры, за которым нужно следить.

И второе, что стоит: очередь гарантирует доставку «хотя бы раз», а не «ровно раз»: сообщение переотправляется, пока потребитель его не подтвердил, а подтвердить можно только после обработки. Если потребитель упал между обработкой и подтверждением, сообщение придёт снова. Поэтому обработка должна быть идемпотентной — повтор не должен создавать второй заказ или второе письмо, — а дедупликация живёт у потребителя (или отправителя), который знает, что уже сделал.

Доставка изменений в реальном времени

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

Опрос. Клиент раз в N секунд спрашивает «что нового». Самый простой способ и совершенно нормальный выбор, когда обновления не срочные: 10 тысяч клиентов с опросом раз в 30 секунд — это 330 запросов в секунду, обычная нагрузка. Цена — задержка до интервала опроса и постоянный поток пустых ответов; лечится дешёвым ответом «ничего не изменилось» (проверка по метке версии) и тем, что интервал увеличивают для неактивной вкладки.

Долгий опрос. Клиент спрашивает, а сервер не отвечает, пока не появится событие или не выйдет срок (30–60 секунд). Задержка почти нулевая, запросов мало, работает через любые прокси. Цена — открытое соединение на каждого клиента и необходимость аккуратно обрабатывать обрывы. Приём хорош как переходный: он не требует ничего нового на стороне инфраструктуры.

Поток событий от сервера. Одно долгоживущее соединение, по которому сервер посылает события, когда захочет; поверх обычного HTTP, с автоматическим переподключением и продолжением с последнего события. Идеальный выбор, когда поток односторонний (сервер сообщает, клиент только слушает): уведомления, статусы, обновления цен. Дешевле веб-сокетов в эксплуатации, потому что это обычный HTTP.

Веб-сокеты. Двустороннее соединение: и сервер, и клиент могут отправлять в любой момент. Нужны там, где клиент тоже постоянно отправляет: чат, совместное редактирование, игры. Цена самая высокая: отдельный протокол, своя маршрутизация, своё масштабирование.

И пятый способ, не про экран: пуш-уведомления. Когда приложение закрыто, доставку берёт на себя платформа (система уведомлений мобильной платформы или браузера). Это не альтернатива предыдущим, а другой случай: клиента нет в сети.

Что на дизайне важнее выбора способа — масштабирование долгих соединений, и это три вопроса:

  • Сколько соединений держит одна копия. Порядок — десятки тысяч на копию при аккуратной настройке; ограничение обычно не процессор, а память на соединение и предел файловых дескрипторов. Отсюда прикидка: сто тысяч одновременных пользователей — это несколько копий только под соединения.
  • Как событие находит нужное соединение. Пользователь подключён к копии номер три, а событие для него сгенерировала копия номер один. Значит, нужна общая шина: копия публикует событие, все копии подписаны, и та, у которой есть соединение этого пользователя, отправляет. Это стандартная схема, и её отсутствие — самая частая причина «у меня работает на одной копии, а в проде нет».
  • Что при выкате. Долгие соединения рвутся при каждом выкате копии, и это нормально, если клиент переподключается и догоняет пропущенное (по идентификатору последнего полученного события). Если догонять нечем, каждый выкат теряет уведомления.

Практический порядок выбора: начинать с опроса, если задержка в секунды терпима; поток событий от сервера, если нужна скорость и поток односторонний; веб-сокеты — только когда нужен двусторонний обмен. Разбор живого экрана на сквозном примере — в статье про систему уведомлений.

Полнотекстовый поиск

Обычная база данных умеет искать точные значения: WHERE name = 'Иван'. Но она плохо справляется с поиском по смыслу: «найди всё про котов», с учётом опечаток, с релевантностью результатов.

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

Что даёт: полнотекстовый поиск с релевантностью, фасеты (фильтры), подсказки.

Что стоит: это второе хранилище, которое нужно держать синхронным с основной базой. Данные в поиске появляются с небольшим опозданием (лаг индексации). Поиск по подстроке в таблице на тысячу записей — не повод заводить Elasticsearch.

Аналитика

Транзакционная база (PostgreSQL, MySQL) оптимизирована для быстрого чтения и записи отдельных строк. Но аналитические запросы работают иначе: «сколько заказов было за прошлый месяц по всем регионам» — это агрегация по миллиардам строк.

Для таких задач существуют колоночные базы данных, например ClickHouse. Данные в них хранятся по столбцам, что позволяет очень быстро считать агрегаты по большим объёмам.

Что даёт: ответы на аналитические запросы за секунды вместо часов.

Что стоит: отдельный пайплайн для доставки данных (например, через Kafka), данные в аналитике обновляются с задержкой.

Хранилище файлов и CDN

Хранить файлы (изображения, видео, документы) в базе данных — плохая идея: база станет огромной, а работать с файлами неудобно. Для файлов существует объектное хранилище — по модели Amazon S3. Оно рассчитано на хранение миллиардов объектов любого размера.

CDN (сеть доставки контента) — это распределённая сеть серверов, расположенных географически близко к пользователям. Когда пользователь из Владивостока запрашивает картинку, она отдаётся с ближайшего узла CDN, а не едет с сервера в Москве.

Что даёт хранилище: практически неограниченный объём, удобная работа с файлами через ссылки.

Что даёт CDN: меньше время загрузки для пользователей по всему миру; разгрузка основного сервера.

Что стоит: когда файл обновился, закешированная в CDN старая версия может оставаться ещё некоторое время (инвалидация). Приватные файлы требуют подписанных ссылок с ограниченным сроком жизни.

Rate limiting и защита от перегрузки

Что если один пользователь или скрипт отправляет тысячи запросов в секунду? Без защиты сервис ляжет.

Rate limiting — ограничение числа запросов: не больше 100 в минуту с одного пользователя. При превышении возвращается ошибка 429 (Too Many Requests).

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

Что даёт: защита от злоупотреблений и пиковой нагрузки; предсказуемое поведение под давлением.

Что стоит: легитимные пользователи при превышении лимита получат отказ. Нужно думать, по какому признаку ограничивать: по IP, по пользователю, по API-ключу.

Чем именно считать лимит

«Не больше 100 в минуту» можно посчитать четырьмя способами, и они ведут себя по-разному на границе окна.

Фиксированное окно. Считаем запросы в текущей минуте, в начале следующей счётчик обнуляется. Просто и дёшево (один счётчик с истечением), а проблема известная: клиент делает 100 запросов в последнюю секунду минуты и ещё 100 в первую секунду следующей — двести запросов за две секунды при лимите сто в минуту.

Скользящее окно. Считаем запросы за последние 60 секунд от текущего момента. Честно, границы нет, но дороже: нужно помнить времена запросов (или считать по частям соседних окон, что даёт хорошее приближение дешевле).

Ведро токенов. В ведро с ёмкостью, скажем, 100 токенов капают токены с постоянной скоростью (100 в минуту); каждый запрос забирает токен, нет токенов — отказ. Главное свойство: разрешает всплеск размером в ёмкость ведра, но держит средний темп. Это то, что обычно нужно на практике, — и поэтому ведро токенов самый распространённый выбор.

Дырявое ведро. Обратная картинка: запросы наливаются в ведро и вытекают с постоянной скоростью; ведро полно — отказ. Даёт ровный исходящий поток, поэтому применяется там, где надо не защититься, а не перегрузить чужой сервис (отправка писем, обращения к платёжному провайдеру с их лимитом).

Три практических вопроса, которые важнее выбора алгоритма:

  • Где хранить счётчики. У сервиса несколько копий, и лимит должен быть общим — значит, счётчик во внешнем хранилище в памяти, с атомарным увеличением. Локальный счётчик на копию означает лимит, умноженный на число копий: иногда это приемлемо (и дешевле), но это надо знать и назвать.
  • Несколько уровней сразу. Обычная схема — лимит на пользователя (защита от одного буйного), лимит на адрес (защита от неавторизованных), лимит на сервис целиком (защита от всех разом). Первые два защищают от злоупотребления, третий — от перегрузки, и это разные вещи.
  • Что отвечать клиенту. Код 429 — половина ответа. Вторая половина — когда повторять: заголовок Retry-After (в секундах или датой) и, по-хорошему, заголовки с текущим состоянием лимита (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset). Без Retry-After вежливый клиент начнёт повторять сразу и будет получать отказы в цикле, добавляя нагрузку ровно тогда, когда её надо снизить. И встречное правило для вашего клиента к чужому сервису: получил 429 — читаешь Retry-After, а если его нет, ждёшь с ростом паузы и с разбросом, а не в цикле.

Отдельно стоит сказать, чего ограничение частоты не делает: оно не защищает от перегрузки собственными запросами (пересчёт, миграция, фоновые задачи — они идут мимо), не спасает от медленного соседа и не заменяет обратное давление внутри системы. Это рубеж на входе, и у него своя, узкая задача.

Согласованность в распределённых системах

Покупатель оплатил заказ на одном сервере, а страницу заказа отдаёт второй, который про оплату ещё не знает, — что показывать: «не оплачено» или подождать, пока серверы договорятся? Когда данные хранятся на нескольких серверах, этот вопрос встаёт при каждом сбое сети между ними.

Ответ на него формализует теорема CAP: в распределённой системе при сетевом сбое нельзя одновременно гарантировать доступность (сервис отвечает) и согласованность. Нужно выбирать.

Согласованность в CAP — не расплывчатое «все видят одно и то же», а вполне конкретное и жёсткое свойство: линеаризуемость. Любое чтение обязано вернуть результат последней завершённой записи, как если бы данные лежали на одной машине и никакой распределённости не было. Это строже, чем «реплики рано или поздно сойдутся» из раздела про репликацию: отставшая на полсекунды реплика линеаризуемость уже нарушает, хотя данные на ней и не потеряны.

На практике это решают по-разному в зависимости от требований:

  • Единственный мастер для записи (PostgreSQL) — простое и надёжное решение.
  • Оптимистичная блокировка (optimistic locking) — перед обновлением проверяем, что данные не изменились с момента чтения; если изменились — повторяем.
  • Распределённые блокировки (через Redis или ZooKeeper) — дорого и хрупко; почти всегда есть способ проще.

Алгоритмы консенсуса (например, Raft) используются внутри распределённых систем — но не везде, где кажется. В Kafka на Raft работает только контроллер кластера: режим KRaft годен для прода с версии 3.3, а в 4.0 ZooKeeper убрали совсем. Контроллер держит метаданные — кто лидер какой партиции, какие узлы живы. Сами сообщения через кворум не проходят: партиция реплицируется по схеме ISR, лидер пишет, реплики догоняют, и запись считается подтверждённой, когда её приняли реплики из списка синхронных. Такие алгоритмы берут готовыми, а не реализуют самостоятельно.

Дополнительно: при первом чтении можно пропустить

Глубже: сколько копий и в скольких зонах: N+1 и всплескрасширенное

Балансировка и сервисы без состояния выше решают, как раскладывать копии; сколько их нужно, считают отдельно, и это то, что проверяют вопросами про 45 экземпляров.

От пика, а не от среднего. Пиковая нагрузка делится на пропускную способность одного экземпляра, измеренную под настоящей нагрузкой, а не взятую из документации; к результату добавляют запас 30–50 % на неравномерность и на то, что экземпляр под 100 % загрузки отвечает медленно. Пик в тысячу запросов при 50 на экземпляр это 20 экземпляров, с запасом 30.

Отказ зоны. Копии стоят в трёх зонах, и потеря одной убирает треть мощности в момент, когда трафик не уменьшился. Чтобы после отказа осталось нужное число, заранее держат больше: нужные 30 делят на две трети и получают 45, по 15 в каждой зоне. Это и есть правило N+1 в терминах зон: запас на одну зону, а не на одну машину. С двумя зонами запас двукратный, что дороже, поэтому три зоны это минимум для сервиса, которому нельзя лежать.

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

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

Глубже: кэш глубже одного абзацарасширенное

Абзац про кэширование называет идею. Три из четырёх вопросов теста бьют в детали, и вот они.

Способы записи. Cache-aside: приложение ищет в кэше, при промахе идёт в базу и кладёт результат в кэш; просто, кэш может отставать от базы на срок жизни. Сквозная запись: каждое изменение пишет и в базу, и в кэш; кэш свежий, запись дороже. Отложенная запись: сначала в кэш, в базу потом; быстро и опасно при падении кэша. Для чтения справочников cache-aside, для счётчиков и профилей сквозная.

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

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

Горячий ключ. Один ключ, который читают в тысячу раз чаще остальных, упирается в один узел кэша. Его размножают: десять копий под ключами с суффиксом, чтение с случайной, запись во все; и локальный кэш в памяти экземпляра на секунду, который снимает с сетевого кэша почти всё для самых горячих. Отрицательные ответы («такого нет») тоже кэшируют с коротким сроком, иначе несуществующие идентификаторы прошивают кэш насквозь. Как всё это выглядит в Redis с командами, разбирает статья про шаблоны кэширования.

Глубже: идентификаторы и распределение по узламрасширенное

Шардирование выше приводит «по диапазону идентификаторов» как пример; какой идентификатор, решает, будет ли шард горячим.

Виды идентификаторов. Счётчик из базы: короткий, монотонный, предсказуемый (по номеру заказа видно объём и можно перебирать), не работает при нескольких узлах записи. UUID четвёртой версии: случайный, глобально уникальный, генерируется где угодно, но случайные вставки раздувают индекс базы. UUID седьмой версии: первые биты это время, дальше случайность; уникален, сортируется по времени, вставки идут в конец индекса; выбор по умолчанию для новых систем. Снежинки (Snowflake, схема Twitter, свои варианты у Instagram и Sony): 64 бита из метки времени, номера узла и счётчика; короче UUID и тоже сортируются, но требуют раздачи номеров узлам и часов, которые не идут назад. Короткий ключ сокращателя ссылок: счётчик, переведённый в base62, даёт шесть-семь символов; предсказуемость лечат перемешиванием бит перед кодированием.

Монотонный ключ и перекос. Шардирование по диапазону монотонного ключа кладёт все новые записи на последний шард, и он горячий всегда, а остальные хранят историю; так устроен и сортируемый идентификатор со временем в старших битах. Лечат хешированием ключа для распределения (порядок внутри шарда теряется) или солью: несколько логических шардов на один горячий диапазон.

Консистентное хеширование. Хеш по модулю числа узлов при добавлении узла перекладывает почти все ключи. Кольцо хешей кладёт узлы и ключи на окружность, ключ принадлежит ближайшему узлу по часовой стрелке, и добавление узла перемещает лишь долю ключей, равную единице на число узлов; виртуальные узлы (сотня точек на физический узел) выравнивают нагрузку. Так устроены распределение в Cassandra и балансировка в кэшах; перебалансировка при этом всё равно переносит данные, и её планируют, а не получают внезапно, о чём статья про секционирование.

Коротко

  • Базовых блоков — около десятка, и почти любая крупная система собирается из них. Каждый блок решает конкретную проблему и несёт конкретную цену — знать оба.
  • Stateless + балансировка — основа горизонтального масштабирования; любое состояние в процессе ломает масштабирование. Копии считают от пика и измеренной ёмкости экземпляра с запасом, потом делят на долю зон, остающихся после отказа одной: нужные 30 в трёх зонах дают 45; автомасштабирование опаздывает на минуты, под известный всплеск масштабируют заранее.
  • Кеш появляется когда измерили, что база не справляется с чтением; без измерений — лишняя сложность. Кэш: cache-aside для чтения, сквозная запись для свежести, срок с разбросом, прогрев после выката, замок на пересчёт с отдачей устаревшего, размножение горячего ключа и локальный кэш на секунду, кэш отрицательных ответов.
  • Репликация даёт устойчивость и масштабирование чтения, но не записи. Шардинг — крайняя мера, когда данные не помещаются на один узел. Идентификаторы: UUIDv7 по умолчанию, счётчик предсказуем и не распределяется, монотонный ключ греет последний шард; консистентное хеширование с виртуальными узлами переносит долю ключей, а не все.
  • Очереди развязывают компоненты во времени; потребители должны быть идемпотентными. Поиск (Elasticsearch) нужен когда важна релевантность, а не просто совпадение. Аналитика (ClickHouse) — отдельная база для агрегатов по большим данным. CDN снижает задержку для географически распределённых пользователей.
  • Rate limiting защищает от пиков и злоупотреблений. При сетевом сбое в распределённой системе выбирают: доступность или согласованность — причём согласованность в CAP означает линеаризуемость, самое строгое из возможных обещаний. Лимит считают ведром токенов (разрешает всплеск, держит средний темп) на общем счётчике, в несколько уровней, и обязательно отвечают Retry-After — иначе клиент повторяет в цикле.
  • В балансировщике решают четыре вещи: уровень (маршрутизация живёт на седьмом), правило распределения, проверка живости с временем реакции интервал × порог, и что делать с состоянием у клиента; липкие сессии — признак проблемы, а долгие соединения означают, что новая копия не получит трафика сама.
  • Рубеж на входе: обратный прокси решает технические задачи, шлюз API — общие продуктовые (токен, лимиты, версии); на рубеж выносят одинаковое для всех, права на конкретный объект остаются в сервисах.
  • Планировщик: при нескольких копиях нужна внешняя блокировка или отдельная задача в кластере, работать надо от состояния в данных, а не от факта запуска, и порциями с метрикой отставания.
  • Реальное время: опрос при терпимой задержке, поток событий от сервера для односторонних обновлений, веб-сокеты только для двустороннего обмена; масштабирование решают число соединений на копию, общая шина между копиями и догон пропущенного после выката.

Что почитать дальше