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

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

Микросервисы и суровая реальность

У микросервисной архитектуры, несомненно, много плюсов (множество независимых компонентов легко разрабатывать, обновлять и масштабировать), но, конечно, есть и минусы. Такой подход порождает огромное количество зависимостей между разными компонентами, что требует дополнительных ресурсов.

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

Схема мультипликации трафика с 15 000 RPS на входе до 200 000 RPS внутри системы
Схема мультипликации трафика с 15 000 RPS на входе до 200 000 RPS внутри системы

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

Высокие RPS, большие нагрузки — все понимают, что это сложно. Но что это значит для конкретного разработчика, который следит за парой сервисов? Зачем ему думать о том, как трафик мультиплицируется и как он вообще идёт через всю систему?

Посмотрим на проблему глазами нашего героя — сферического абстрактного бэкендера Васи.

Знакомьтесь, Вася. И его проблемы

Вася — собирательный образ

Он хороший разработчик, отвечает за несколько сервисов, пишет на любом языке, но до недавнего времени не имел дела с микросервисами.

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

Несогласованное цунами

Вася отвечает за сервис: алерты молчат, ресурсы в норме, активных релизов нет. Тишь да гладь… И вдруг Васе звонит робот мониторинга с «приятной» новостью: «У вас горят алерты, половина запросов отдаёт 500, не хватает CPU!»

Типичная картина во время инцидента. Всё красное, ничего не понятно, но очень страшно
Типичная картина во время инцидента. Всё красное, ничего не понятно, но очень страшно

Вася бежит проверять мониторинг, открывает графики и не верит своим глазам. Количество вызовов его сервиса утроилось. Минуту назад было тихо, а теперь графики ползут вверх. Откуда взялась нагрузка?

Тот самый скачок на графике. Причины пока неизвестны
Тот самый скачок на графике. Причины пока неизвестны

Разгадка проста. Соседняя команда выкатила новую фичу, которая начала активно дёргать сервис B. Вот только сервис B задействует С, а C, в свою очередь, обращается к сервису Васи.

В микросервисной архитектуре всё взаимосвязано (было/стало)
В микросервисной архитектуре всё взаимосвязано (было/стало)

И команды A и B обсуждали запуск новой фичи. «Нагрузку выдержим?» — спросила команда A. «Легко справимся», — ответила команда B. Они прикинули цифры, проверили свои сервисы, договорились и включили функциональность. Вот только никто не подумал, что этот трафик пойдёт дальше и накроет ничего не подозревающего Васю. К сожалению, такое бывает чаще, чем кажется.

Ретрай-шторм

Вася решил, что это разовый случай, и ничего не предпринял. Зря. Теперь он сам выкатывает новую функциональность, предусмотрительно закрыв её фича‑флагом. Релиз прошёл успешно, а потом Вася включает флаг и видит 500-е ошибки.

График появления ошибок после релиза фичи
График появления ошибок после релиза фичи

Вася понимает, что проблема в его коде, и откатывает фича‑флаг, но ошибки не уходят. Сервис лёг и даже не может откатить конфиг. Вместо того чтобы спокойно пойти на обед, Васе приходится принимать экстренные меры.

Что произошло? Из‑за новой фичи сервис Васи начал «пятисотить». Клиент получил ошибку и сделал повторный запрос — ретрай. И ещё один. И ещё. Вместо одного запроса их стало приходить в 27 раз больше. Получился ретрай‑шторм — ситуация, когда система сама себя загоняет в могилу.

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

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

Как выжить в мире высоких RPS

Чтобы снова не оказаться в такой ситуации, Васе нужно научиться: 

  • переживать RPS, превышающие допустимые значения;

  • размазывать пиковый трафик, чтобы он не так сильно бил по производительности;

  • оптимизировать взаимодействие компонентов, чтобы избавиться от ненужных вызовов и снизить трафик.

Вася провёл исследование и нашёл несколько полезных инструментов.

Рейт-лимитер

Рейт‑лимитер — программный механизм, который ограничивает количество операций за определённый промежуток времени. Этакий вышибала, контролирующий число посетителей на входе в клуб.

Вот некоторые популярные реализации рейт‑лимитера:

  • Token Bucket;

  • Leaky Bucket;

  • Fixed Window;

  • Sliding Window.

Подробнее о каждом можно почитать в статье Rate Limiting Algorithms — System Design.

В Яндекс Еде мы используем Token Bucket с burst. Это позволяет быстро реагировать на запросы и переживать кратковременные пики, не жертвуя полезным трафиком. К сожалению, настроить рейт‑лимитер достаточно сложно.

Как работает Token Bucket с burst у нас
  • Сначала лимитер потратит остатки основного бюджета. Допустим, если в бюджете в данный момент осталось 20 единиц, то лимитер разрешит пропустить 20 запросов.

  • Дальше лимитер потратит burst‑бюджет, то есть разрешит пропустить, например, ещё 42 запроса.

  • Если прошла 1 секунда с начала использования burst‑бюджета, все остатки в нём сгорают. Это сделано для того, чтобы он не тратился по чуть‑чуть при незначительном превышении лимита трафиком.

  • burst‑бюджет восстанавливается не раньше чем через 10 секунд после того, как трафик стал ниже лимита.

Рейт-лимитер — дело тонкое
Рейт‑лимитер — дело тонкое

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

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

Мы постоянно следим за тем, как используются лимиты, потому что профиль нагрузки меняется с каждой новой фичей.

Итак, Вася настроил рейт‑лимитер, и опасный трафик начал срезаться

Умные ретраи

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

Он продолжил поиски и нашел несколько подходов, которые можно внедрить:

  • Экспоненциальный бэкофф (Exponential Backoff) — каждый следующий ретрай делается через увеличивающийся промежуток времени. Первая попытка через секунду, вторая — через две, третья — через четыре, и так далее. Всё равно что звонить по занятому номеру, но с каждым разом выдерживать всё более долгую паузу.

  • Jitter (дрожание) — к задержке ретрая добавляется небольшой случайный элемент. Представьте, что 10 000 запросов упали одновременно и должны повториться через секунду. Без jitter они придут на сервис одновременно. С jitter — «размажутся» во времени: часть через 1,1 с, другая порция через 0,9 с, ещё одна через 1,2 с. Это сглаживает пиковые нагрузки.

  • Ретрай‑бюджет (Retry Budget) — ограничение на общее количество ретраев в системе. Выдаём клиентам бюджет, например 100 токенов в час. Они тратят по одному токену на каждый запрос, и, если сервис недоступен, бюджет быстро заканчивается. Ретраи прекращаются, давая сервису возможность восстановиться.

  • Заголовок Retry‑After — с его помощью сервер сам может подсказать клиенту, через какое время стоит повторить запрос, в зависимости от своей текущей нагрузки.

После того как Вася настроил всё это у клиентов своего сервиса, графики нагрузки стали плавнее.

Оптимизация взаимодействия

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

Архитектурные ошибки
Архитектурные ошибки

В Яндекс Еде так было, когда мы выносили из монолита функциональность с пользователями. Пользователи пронизывают весь монолит, так что много рефакторить было нельзя. Поэтому просто взяли и подменили поход в базу данных в дата‑провайдере походом в сервис пользователей. 

Мы переключили весь монолит на работу с этим сервисом, но получили проблему. Раньше в коде, где нужен был пользователь, был быстрый доступ к базе, а теперь у нас поход по HTTP, и в рамках одного запроса на создание заказа мы ходили в сервис пользователей 11 раз. Мы решили эту проблему рантайм‑кешем. Найти такие кейсы, которые не так заметны, конечно, сложновато.

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

HTTP-запросы внутри транзакций

Вася изучал, как другие сервисы работают с его API, и заметил опасную штуку: некоторые клиенты делали запросы внутри открытой транзакции. 

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

Чтобы найти все проблемные места, Вася написал простую проверку: при каждом HTTP‑запросе скрипт проверяет, не висит ли открытая транзакция. Если висит — записывает WARN‑лог и отправляет метрику. Так сразу видно, с какими сервисами есть проблемы.

Deadline Propagation

Вася продолжил копать дальше и нашёл ещё одну проблему. Некоторые клиенты отваливались по тайм‑ауту раньше, чем сервис успевал ответить. Например, у клиента тайм‑аут 300 миллисекунд, а цепочка вызовов внутри системы работает все 500. Клиент уже ушёл с ошибкой, а сервис продолжает жечь ресурсы впустую.

Сервисы C и D работают впустую — клиент уже отвалился по тайм-ауту
Сервисы C и D работают впустую — клиент уже отвалился по тайм‑ауту

Чтобы исправить ситуацию, можно использовать Deadline Propagation, то есть передавать оставшееся время жизни запроса в заголовках. 

Этот подход подразумевает, что каждый сервис в цепочке отнимает потраченное на работу время. Если дедлайн просрочен — просто выкидываем исключение, не дёргая другие сервисы.

Прерываем цепочку и экономим ресурсы сервисов C и D
Прерываем цепочку и экономим ресурсы сервисов C и D

Обрывать запрос можно не только между сервисами, но и внутри процесса конкретного сервиса. Это зависит от реализации и языка программирования. Например, в PHP мы делали чекпоинты перед запросом в базу данных или перед HTTP‑запросом. Проверяли, не просрочен ли deadline, и, если просрочен, выкидывали исключение с ошибкой.

История одного инцидента

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

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

Предыстория: побег от монолита

У нас в Яндекс Еде есть большой монолит на PHP. Мы потратили год, чтобы вынести из него процессинг заказов в отдельный сервис на Go. Мы полностью переключились на новую схему, и теперь осталось проверить, что монолит не влияет на цикл заказа. 

Архитектура Яндекс Еды — было/стало
Архитектура Яндекс Еды — было/стало

Для этого написали хендлер, который по команде заставлял монолит отдавать 500-ю ошибку на все запросы и останавливал все его фоновые процессы. 

Затем провели несколько успешных тестов на отдельных группах пользователей. И вот настал момент истины: полностью временно отключить монолит для всех пользователей.

У нас получилось без проблем отключить монолит на 1 и 5 минут. Мы были на 99% уверены, что финальная 10-минутная проверка тоже пройдёт гладко.

Что могло пойти не так?

Час X. Фоновые процессы остановились, эндпоинты монолита начали отдавать 500. Всё как ожидалось, но внезапно мы увидели рост таймингов монолита до 35 секунд.

Мы хотели сломать монолит, и мы его сломали
Мы хотели сломать монолит, и мы его сломали

Мы не ожидали такого поведения, но решили, что раз монолит не влияет на флоу заказа, то можно подождать и посмотреть…

Первый алерт пришёл от сервиса создания заказов — checkout начал отдавать 500. Это означало, что у пользователей пропала возможность создавать заказы.

Пользователи просто не могли сделать заказ
Пользователи просто не могли сделать заказ

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

Пик обращений в поддержку показывает, как сильно мы задели пользователей
Пик обращений в поддержку показывает, как сильно мы задели пользователей

Вася тоже был уверен, что всё настроено правильно. Но в проде что‑то сломалось, и мы разгребали ситуацию до 7 часов вечера.

Разбор полётов

Что же произошло на самом деле?

Если в нашем монолите происходят исключения (exceptions), мы отправляем их в Sentry — систему сбора ошибок. Довольно старая интеграция, так что у нас не было графиков и мониторинга нагрузки на него.

Обработка ошибок в нашей системе
Обработка ошибок в нашей системе

Мы написали хендлер, имитирующий падение монолита неправильно: вместо того чтобы просто вернуть код 500, он выбрасывал исключение (exception), которое превращалось в нужный нам респонс. А монолит исправно отправлял все необработанные исключения в Sentry.

Система не была рассчитана на такой поток, RPS подскочил. Sentry начал тормозить, тайм‑ауты запросов выросли до 1 секунды, что резко снизило производительность самого монолита.

У нас примерно 150 подов, на каждом по 20 PHP‑FPM‑процессов, а это значит, что монолит мог обрабатывать 3000 запросов одновременно. В обычном режиме с откликом 200 мс это 15 000 RPS. Когда каждый процесс стал залипать на секунду из‑за тайм‑аута Sentry, его пропускная способность упала в 5 раз — до 3000 RPS.

Расчёт падения пропускной способности монолита
Расчёт падения пропускной способности монолита

Хорошо, мы сами себе выстрелили в ногу, обрушив производительность монолита, но почему упало всё остальное? Ведь мы тестировали работу системы без него.

Ошибка восприятия архитектуры

Когда мы настраивали тайминги, мы думали, что архитектура выглядит так: checkout → order‑flow → синхронизация с монолитом, но, оказалось, реальная схема выглядела по‑другому. Это была некоторая ошибка восприятия. Мы говорили про синхронизацию заказа с монолитом и представляли её иначе, забыв про реальное положение дел. 

Неправильная (слева) и правильная (справа) схемы архитектуры. Почувствуйте разницу
Неправильная (слева) и правильная (справа) схемы архитектуры. Почувствуйте разницу

Такая архитектура появилась из‑за дедлайнов. Чтобы сформировать запрос в монолит для синхронизации, нужно обработать данные, насытить и только потом передать. Эта логика уже была написана в checkout, и, вместо того чтобы переносить её в другой сервис, мы решили «срезать угол».

Мы не посмотрели на эндпоинт между checkout и монолитом, а в этом вызове скрывалось две проблемы: тайминг ручки был 30 секунд, а поход в монолит был внутри транзакции.

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

Но это ещё не все проблемы!

Наш новый процессинг тоже использовал сервис checkout, так что он тоже начал тормозить. Причём процессинг обрабатывает разные сигналы от других систем, например от логистики или интеграций, чтобы обеспечить движение уже созданных заказов. И когда внутри процессинга начали заканчиваться воркеры, все эти сигналы стали поступать в очередь.

Очередной график
Очередной график

Очередь делилась на четыре шарда, и в какой‑то момент у нас случилось метастабильное состояние одного из шардов: очередь на этом шарде начала разбираться крайне медленно. 

Это привело к тому, что каждая четвёртая случайная задача выполнялась очень долго. Например, мы могли отправить заявку в интеграцию, ресторан мог приготовить заказ, а вот курьер к этому моменту даже не назначился. Или наоборот: курьер приходил забирать заказ, а ресторан даже не знал о нём. 

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

Инструменты — ничто, понимание — всё 

Что в такой ситуации мог бы посоветовать Вася? 

  • Правильные тайм‑ауты, согласованные на всех стыках, особенно на скрытом вызове checkout → монолит и в Sentry.

  • Никаких HTTP‑вызовов внутри транзакций.

  • Rate Limiting — точная настройка на входе в монолит могла бы ограничить нагрузку.

  • Deadline Propagation — если бы на эндпоинте синхронизации был настроен дедлайн‑пропагейшен, checkout не ждал бы 30 секунд ответа от перегруженного монолита.

Конечно, мы применяем данные практики и подходы, и это позволяет нам свести риски к минимуму. Но, как оказалось, не убирает их совсем. Чтобы сделать систему ещё более надёжной, нужно улучшать наблюдаемость (Observability). Нужно видеть все связи, тайминги и точки отказа в реальном времени.

Если бы у нас были графики нагрузки и тайм‑аутов для Sentry, мы бы сразу засекли аномалию. Если бы был удобный инструмент, показывающий реальный путь запросов, он бы немедленно выявил скрытый вызов checkout → монолит. Если бы не было HTTP‑запроса внутри транзакции, тайминги монолита не повлияли бы на пользователей.

Без наблюдаемости даже лучшие практики бессильны против сложности распределённых систем. Мораль проста: не просто стройте отказоустойчивые сервисы. Стройте прозрачные системы.

А вы сталкивались с подобными проблемами? Как вы добиваетесь прозрачности и какие инструменты используете? Делитесь опытом в комментариях.

Комментарии (0)