Порой простое и очевидное решение может потянуть за собой хвост проблем в будущем. Например, добавление ретраев.

Меня зовут Денис Исаев, и я работаю в Яндекс Go. Сегодня я поделюсь опытом решения проблем с отказоустойчивостью из-за ретраев. Основано на реальных инцидентах в системе из 800 микросервисов.

Этот пост — продолжение вымышленных историй о разработчике Васе, который несколько лет назад разбирался с идемпотентностью в распределённых системах. Теперь перед ним новые задачи — получится ли справиться с ними в этот раз? Давайте узнаем.

Источник фото: China Daily / REUTERS

Терминология

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

  • Клиент — в контексте этой статьи это бэкенд-микросервис, который отправляет запрос в другой бэкенд-микросервис. Там, где этот термин обозначает мобильное приложение или фронтенд, об этом написано явно.

  • Сервер или сервис — взаимозаменяемые термины для обозначения бэкенд-микросервиса, в который клиент делает запросы.

Зарождение ретраев

Бэкенд-разработчик Вася работал в команде платформы заказов одного из приложений Такси. В один из дней он изучал жалобу пользователя об ошибках в приложении. В логах сервиса заказов были пятисотки из-за тайм-аута к сервису цен. «Опять флапнуло», — подумал Вася и решил добавить три перезапроса при ошибках от сервиса цен. Вася решил, что это было безопасно, ведь все API были идемпотентны. Такие перезапросы часто называют ретраями (от англ. retry).

Перезапросы, которые добавил Вася, были реализованы как простой цикл. На код-ревью тимлид продуктовой команды Олег рассказал Васе о проблеме retry storm: когда сервису цен станет плохо, ретраи добьют его и замедлят восстановление. Олег подсказал, что у этой проблемы есть решение в виде exponential backoff.

Exponential backoff в виде псевдокода
MAX_RETRY_COUNT = 3
MAX_DELAY_MS = 1000
DELAY_BASE_MS = 50

attempt_count = 0
max_attempt_count = MAX_RETRY_COUNT + 1

while True:
    result = do_network_request(...)
    attempt_count += 1
    if result.code == OK:
        return result.data
    if attempt_count == max_attempt_count:
        raise Error(result.error)
    
    delay = min(DELAY_BASE_MS * pow(2, attempt_count), MAX_DELAY_MS)
    sleep(delay)

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

Вот Вася и решил проверить с помощью симуляции, правда ли exponential backoff так сильно помогает.

Симуляция показала: есть польза от exponential backoff

В симуляции задействованы клиенты и сервер. Каждый клиент делает один запрос и ждёт ответа с тайм-аутом 100 мс. В интервале [0.5 с; 1 с] эмулируется даунтайм сервера: он специально отдаёт ошибку на 100% запросов (график «Аптайм сервера») с обычным latency ответа (10 мс + 5 мс на сетевой round-trip). Клиент ретраит ошибку или тайм-аут три раза, совершая в общей сложности до четырёх запросов.

Сначала Вася запустил симуляцию простых ретраев:

График «Амплификация нагрузки на сервер» показал, что серверный RPS вырос в четыре раза после начала ретраев. Такой рост числа запросов без роста естественной нагрузки назовём амплификацией нагрузки.

Затем Вася попробовал применить exponential backoff в симуляции:

Польза от exponential backoff (зелёная линия) подтвердилась: амплификация нагрузки сильно уменьшилась. Однако Васе показалось странным, что нагрузка на сервер растёт скачкообразно при использовании exponential backoff, но он не знал, как это объяснить.

Олег удивился, что в симуляции эффект от exponential backoff такой слабый. Изучив код симуляции, он увидел, что новые клиенты создаются независимо от здоровья сервера. То есть связка «клиенты + сервер» моделируется как система без отрицательной обратной связи (open loop). Это как кондиционер без датчика текущей температуры: он просто всегда охлаждает. В то время как хороший кондиционер адаптирует мощность охлаждения в зависимости от близости текущей температуры помещения к целевой температуре. Такие системы с отрицательной обратной связью называются closed loop системами.

Олег знал, что exponential backoff особенно хорошо работает как раз в таких closed loop системах. И Вася спросил: «Насколько это вообще реалистично? Точно ли наш production — это closed loop?»

«Наш production не closed loop, но обладает некоторыми его элементами. Например, многие сервисы работают по модели “1 запрос – 1 поток” с ограничением на число потоков. Если latency запросов к другому сервису сильно растёт, все потоки становятся заняты ожиданием ответа от другого сервиса. Поэтому новый запрос в другой сервис не сформируется, пока не завершится хотя бы один старый запрос. Получается цикл обратной связи», — подытожил Олег.

В closed loop симуляции эффект от exponential backoff намного больше

Вася добавил в симуляцию лимит на количество активных клиентов: ожидающих ответа сервера или спящих между ретраями.

В closed loop системе амплификация нагрузки стала заметно меньше в период даунтайма сервера. Отрыв exponential backoff от простых ретраев стал значительно больше. Вася стал разбираться, почему же так произошло. Он быстро понял, что в момент даунтайма:

  1. Растёт latency запросов из-за экспоненциальных пауз между ретраями.

  2. Из-за этого растёт количество активных клиентов (закон Литтла: при росте latency в 5 раз число активных клиентов тоже вырастает в 5 раз).

  3. Следовательно, система быстро упирается в лимит на количество активных клиентов.

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

  5. Поэтому нагрузка на сервер уменьшается.

И снова Вася заметил странные артефакты на графике амплификации нагрузки: синусоиды RPS. Оказалось, что все клиенты, получившие первую ошибку в t=0.5 с, ждут одинаковую задержку 0.1 с. Вскоре после этого, как только достигнут лимит на число активных клиентов, новые запросы перестают отправляться на сервер. Но и старые не отправляются, потому что они ждут 0.1 с. Этим объясняется первый провал в t=0.6 с.

Затем всё те же клиенты ждут 0.2 с, а новые клиенты не могут сформировать новые запросы, потому что ни один активный клиент ещё не завершил все три ретрая. Это объясняет провал в t=0.8 с.

Клиенты волнами шлют запросы, синхронизировавшись друг с другом. При этом нагрузка на сервер пониженная, что неэффективно утилизирует его ресурсы. Резкий рост RPS после восстановления в t=1.25 с — это следствие такой неэффективности. Дело в том, что все ожидающие клиенты перестают упираться в лимит и массово шлют запросы.

Вася убедился, что exponential backoff точно стоит применить. Осталось решить проблему синхронизации между клиентами, найденную в симуляции.

Синхронизация между клиентами

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

Вася нашёл в интернете решение: добавить случайную задержку (jitter) в паузу между ретраями. Такой jitter можно добавить разными способами. Вася решил использовать метод Full Jitter.

jitter в виде псевдокода
…  # Такой же код, как выше в exponential backoff

while True:
    …  # Такой же код, как выше в exponential backoff

    delay = min(DELAY_BASE_MS * pow(2, attempt_count), MAX_DELAY_MS)
    delay = random_between(0, delay)
    sleep(delay)

И снова Вася решил провалидировать идею jitter с помощью симуляции.

Симуляция подтвердила, что jitter уменьшает синхронизацию клиентов

Вася добавил в симуляцию jitter методом Full Jitter и получил такие графики:

При использовании jitter (красная линия) нагрузка на сервер более равномерная во время даунтайма. Это значит, CPU сервера простаивает меньше, а пик амплификации нагрузки после даунтайма приходится раньше (t=1.1 с против t=1.25 с) и будет меньшей длины. В итоге система быстрее восстанавливается.

Вася поигрался с параметрами симуляции и обнаружил интересный момент: эффект от добавления jitter ещё более заметен при уменьшении запаса по CPU (вместо 4x — 2x) и наблюдении за клиентскими таймингами. Вот что получилось:

Итак, Вася успешно провалидировал, что jitter помогает уменьшить синхронизацию между клиентами и ускоряет восстановление.

По итогам Вася убедился, что предложение Олега на код-ревью о внедрении exponential backoff всё же очень верное. Вася внедрил exponential backoff и jitter. Также он вынес логику ретраев из сервиса в общую библиотеку http-клиентов во фреймворке userver. Благодаря добавленным ретраям, исходная проблема решилась: флапы ошибок из-за тайм-аутов к сервису цен полностью исчезли. При этом ретраи были безопасными, то есть без риска привести к retry storm.

Часовое падение

Спустя три года в системе было 500 микросервисов. Каждый второй поход между сервисами был обложен ретраями. Они были правильными: с exponential backoff, jitter, на уровне общей библиотеки.

Но однажды весь бэкенд прилёг на целый час: в новом релизе сервиса заказов начались массовые ошибки. Релиз откатили за 10 мин, но после этого бэкенд не ожил. Ещё через 20 минут команда поняла, что поможет только полное снятие нагрузки с бэкенда. Рейт-лимитером оставили трафик только от 1% пользователей. Система ожила. Начали плавно пускать по 5% пользователей в течение получаса.

Неудачный релиз сделал Вася, поэтому он и подготовил пост-мортем с выводами. Причиной инцидента был баг в новой фиче, приводящий к segfault сервиса при тайм-аутах Redis. В пост-мортеме были только следующие выводы:

  • подтюнить алерты, чтобы реагировать быстрее;

  • зафиксить баг с segfault;

  • написать тесты на случай тайм-аута Redis и других СУБД.

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

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

Разработчик из платформы заказов Лёша проверил график RPS у сервиса-оркестратора (только он и ходит в сервис заказов). На нём нагрузка была не 9x, а 3x от обычной. То есть либо оркестратор всегда делает три запроса, либо это амплификация ретраями. Это ставило под сомнение теорию Васи об ожидающих пользователях.

Лёша поделился, что симптомы долгого восстановления напоминают ему проблему metastable failure state (MFS). Обычно система восстанавливается сама после устранения триггера (неудачного релиза). Но если этого не происходит, то такое состояние системы называют MFS. В этом инциденте так и случилось. Лёша предположил, что к такому могли привести ретраи.

MFS показался Васе магией, но он понял, что такие проблемы и правда могут быть вызваны ретраями. Вася ушёл разбираться, почему оркестратор превратил 3x в 9x RPS.

Достаточно ли exponential backoff

Вася убедился, что оркестратор ходит в сервис заказов ровно один раз. Но там есть два ретрая. Они используют exponential backoff и jitter. А значит, тут всё безопасно, и утроение нагрузки не может быть вызвано ретраями. Вася не смог найти причину амплификации нагрузки и попросил помощи у разработчика Ивана, который работает в команде водительской платформы.

Иван объяснил, что exponential back-off при ретраях не избавляет от амплификации нагрузки, а лишь откладывает её на время. Начиная с определённого момента даунтайма, exponential backoff никак не помогает уменьшению нагрузки на сервер.

Иван нарисовал, что происходит с сервером при ретраях. Каждый запрос выполняется ровно одну секунду, и каждую секунду на сервер приходит новый запрос (буквы A–Z). Начиная с первой секунды все запросы начинают отдавать ошибку. Если клиент делает три ретрая без exponential backoff, то амплификация 4x происходит уже на четвёртой секунде:

Если применить экспоненциальные задержки 1 с → 2 с → 4 с, то та же амплификация 4x всё равно случится, но позже — на 11-й секунде:

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

Мир Васи немного пошатнулся. Три года назад он узнал о технике exponential backoff, прочитал десяток статей о ней, подтвердил её пользу симуляциями и даже выступил на митапе с результатами своих исследований! Вася не поверил сразу и решил провалидировать сказанное Иваном любимым методом — в симуляции.

Симуляция подтвердила, что exponential backoff лишь откладывает амплификацию

Чтобы провалидировать эффект откладывания, Вася удлинил период даунтайма сервера с [0.5 с; 1.0 с] до [0.5 с; 1.5 с].

Для начала Вася сэмулировал open loop систему и сравнил простые ретраи с рандомизированными экспоненциальными:

Вася увидел, что амплификация 4x у exponential backoff происходит, но чуть позже. Поэтому он и не видел данный эффект в предыдущих симуляциях: даунтаймы были слишком короткие.

Но Вася помнил, что у production есть элементы closed loop системы, поэтому он добавил лимит на число активных клиентов:

«Супер! Отложенной амплификации нагрузки во время даунтайма нет!» — подумал Вася. Но решил перепроверить и поиграться с лимитом на число активных клиентов. Оказалось, если поднять этот лимит с 30% от нормального RPS до 40%, то ситуация меняется:

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

Итак, Вася подтвердил, что амплификация при exponential backoff точно такая же, как и с простыми ретраями, только наступает попозже.

Фундаментальная проблема ретраев

Вася осознал, что exponential backoff — не серебряная пуля. Но он не понимал одну вещь: почему ретраи и амплификация ими нагрузки это вообще проблема? Раз есть ретраи, значит, есть ошибки, следовательно, система нездорова — именно в этом и проблема, а не в ретраях! Вася сходил за советом к Паше — руководителю водительской платформы.

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

Паша показал на примере: допустим, у системы запас по CPU x2, а клиенты не поддерживают ретраи:

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

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

Красным отмечены запросы, завершившиеся ошибкой или тайм-аутом. Зелёным — успешные запросы.

Но в реальности будет по-другому:

  1. При нехватке capacity система начнёт медленно работать. Вырастут очереди запросов на сервере. Начнутся тайм-ауты на клиентах, что вызовет новые ретраи.

  2. Тайм-ауты и ошибки будут равномерно возникать как в обычных, так и ретрайных запросах. Поэтому область после восстановления выделена жёлтым цветом. Часть этих запросов завершится успешно, а часть оттаймаутит или вернёт ошибку.

В реальности ситуация будет выглядеть скорее так:

Главная проблема: из-за ретраев система не восстанавливается сразу после устранения триггера. В варианте без ретраев восстановление случается почти сразу.

Паша подвёл итог: время восстановления после устранения триггера зависит от объёма запросов, обрушившихся на сервер. Поэтому важно уменьшать нагрузку на сервер для ускорения восстановления. А ретраи, наоборот, только увеличивают нагрузку.
При этом время восстановления растёт более чем линейно при росте нагрузки. Цепочка такая: больше запросов на сервер → больше тайм-аутов или ошибок получено клиентами → больше ретраев → ещё больше запросов на сервер.

Вася провалидировал тезисы Паши с помощью симуляции

В отличии от прошлых симуляций Вася сделал так, что у клиентов не одинаковый тайм-аут (время ожидания ответа) 100 мс, а случайный из списка — 100 мс, 200 мс, 300 мс. Это ближе к реальными системам. Также Вася сделал у клиентов два ретрая вместо трёх. Симуляция показала следующее:

Даунтайм завершился в t=1.5 с, но полностью ошибки ушли только в t=2.3 с для схемы с ретраями. Клиенты без ретраев восстановились моментально в t=1.5 c. Таким образом, Вася провалидировал, что любые ретраи удлиняют восстановление (даже ретраи с экспоненциальной задержкой).

Вася поблагодарил Пашу и ушёл размышлять над всем этим.

Жизнь без ретраев

«Хорошо, без ретраев система восстанавливается быстрее. Давайте тогда избавимся от них», — думал Вася. Но что делать с разовыми ошибками сервера в нормальном состоянии системы, если ретраев вообще нет?

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

«Как понять, что сервису плохо? Считать процент ошибок от сервиса на клиенте?» — спросил Вася. Женя рекомендовал рассмотреть две техники:

  • Retry circuit breaker (retry cb). Клиент сервиса полностью отключает ретраи, если процент ошибок сервиса превышает порог, например, 10%. Как только процент ошибок за условную минуту становится ниже порога, то ретраи возвращаются. При проблемах сервиса на него вообще не идёт дополнительная нагрузка от ретраев.

  • Retry budget (или adaptive retry). Ретраи допускаются всегда, но в пределах бюджета, например, не более 10% от количества успешных запросов. При проблемах сервиса на него может идти не более 10% дополнительного трафика.

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

Retry circuit breaker или retry budget?

После изучения этих двух техник у Васи возник ряд вопросов:

  1. Какую из этих техник выбрать на практике?

  2. Зачем он вообще делал exponential backoff с jitter, если ретраи надо просто минимизировать или отключать? Стоит ли совмещать exponential backoff с данными техниками?

  3. Нужно ли считать процент ошибок глобально между всеми клиентами? Насколько плохо считать только локальную статистику?

По первому вопросу Женя скинул Васе ссылку на сравнение этих двух техник. По другим вопросам Женя рекомендовал Васе провести исследование самостоятельно.

Вася провалидировал и сравнил обе техники с помощью симуляции

Вася добавил в симуляцию техники retry budget и retry circuit breaker поверх простых ретраев. И решил сравнить их, exponential backoff в сочетании с jitter, простые ретраи и отсутствие ретраев:

Всё как и говорил Женя: retry budget (розовая линия) и retry cb (коричневая линия) создают меньше избыточной нагрузки на сервер, что позволяет быстрее восстановиться: в t=1.0 с, а не в t=1.25 с.

Васе стало интересно: а как себя поведут эти техники, если сервер будет отдавать не 100% ошибок, а меньше? Он решил воспроизвести сценарий частичного отказа: в интервале [0.5 с; 1.0 с] на сервере будет 30% ошибок. Также Вася решил посмотреть на график аптайма со стороны клиентов. Вот результаты симуляции:

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

На графиках было слишком много линий, и Вася оставил только самые нужные для сравнения двух техник:

Вася чётко увидел, что retry budget генерирует дополнительные 10% нагрузки на сервер ретраями, но повышает клиентский аптайм.

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

  • aws sdk использует retry budget (HasRetryQuota);

  • grpc-клиент для go тоже использует retry budget (RFC и код).

«Раз они используют retry budget, то и я буду», — решил Вася.

Реализация опенсорс клиентов также подсказала Васе ответы на два оставшихся вопроса:

  • exponential backoff и jitter тоже нужны, они дополняют retry budget;

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

Вася провёл симуляцию: для долгоживущих клиентов локальная статистика ведёт себя идентично глобальной, а exponential backoff не сильно влияет на амплификацию.

В итоге Вася запланировал принести на ретро такой action item: дополнить текущий exponential backoff техникой retry budget с бюджетом 10%. Глобально статистику не синхронизировать, а реализовать через простой token bucket. В симуляции — пример реализации этой техники.

Разные решения одной проблемы

На ретро Вася получил ряд возражений по идее внедрить retry budget. Двое коллег спросило, зачем это делать, раз есть exponential backoff.

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

Вася парировал, что если сервер упадёт, кончится по памяти или CPU, то он не сможет себя защитить. Затем Кирилл с Васей обсудили, поможет ли load shedding на сервере, но в итоге решили, что толстый клиент в данном случае это нормально.

Руководитель b2b разработки Илья предложил вместо ограничения ретраев применить паттерн circuit breaker (cb). Идея паттерна в том, что клиент вообще перестаёт ходить в сервис, если процент ошибок сервиса выше порога. Не будет походов в сервис — не будет и ретраев. Вася готов был согласиться с идеей, но решил сначала провалидировать её в симуляции.

Вася сравнил circuit breaker с retry budget в симуляции

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

Вася запрограммировал симуляцию для circuit breaker с порогом 10% и эмуляции отказа одного из пяти шардов (ошибки 20% пользователей):

«Что за кардиограмма?!» — подумал Вася. Но тут же понял, в чём дело: ошибки 20% пользователей вызывают срабатывание circuit breaker по порогу 10%. После этого circuit breaker отключает походы клиента в сервис на какое-то время, поэтому аптайм, амплификация и CPU падают до 0. Затем небольшая часть трафика на короткое время пропускается для сбора статистики — это пики «кардиограммы». Статистика показывает, что процент ошибок выше порога, и походы в сервис опять отключаются. Проблема в том, что плохо только одному шарду, а отключаются походы во все.

В такой конфигурации circuit breaker скорее вреден — retry budget явно лучше. Поэтому Вася решил проверить circuit breaker с порогом в 50% при тех же 20% пользователей с ошибками:

Вася сделал вывод, что circuit breaker с порогом 50% не делает хуже при отказе одного из пяти шардов, но и проблему амплификации нагрузки он не лечит.

По итогам симуляции Вася обнаружил, что circuit breaker (cb) — это более опасный паттерн, чем retry budget. В случае отказа одного шарда сервиса circuit breaker может отключить поход во все шарды. Поэтому у cb должен быть намного более высокий порог срабатывания, чем у retry cb или budget, например, 50%. А значит, и допустимая амплификация нагрузки будет выше. По итогам обсуждений Вася и его коллеги пришли к тому, что circuit breaker нужен, но скорее как дополнение к retry budget.

Между ретро к Васе подошёл Андрей из платформы надёжности: «А ты не думал применить паттерн deadline propagation вместо retry budget? Идея паттерна в том, что клиент в запросе к серверу передаёт значение тайм-аута. Сервер в процессе обработки запроса регулярно проверяет: не истёк ли тайм-аут на клиенте. Если истёк, то можно завершать обработку запроса ошибкой».

Вася не понял, как deadline propagation может быть заменой отключению ретраев: это же вообще разные идеи. Андрей объяснил: «Если серверу плохо, то клиент шлёт ретрай по тайм-ауту. В момент отправки ретрая сервер либо ещё не начал выполнять исходный запрос, либо находится в процессе выполнения. В обоих случаях благодаря deadline propagation сервер может сразу прервать выполнение запроса. Таким образом, deadline propagation может компенсировать эффект от амплификации ретраями».

«Звучит правдоподобно, но сложно оценить эффективность техники», — подумал Вася. Поэтому он опять обратился к симуляции.

Симуляция показала, что deadline propagation эффективен, но недостаточно

Вася запрограммировал deadline propagation в симуляции и запустил в сравнении с простыми ретраями и retry budget:

Вася удивился результатам: почему-то выросла амплификация нагрузки, а время восстановления и очередь на сервере при этом сократились. После изучения он смог объяснить этот эффект:

  • Амплификация нагрузки у простых ретраев (retry-2x) должна быть 300%, но запас по CPU даёт достичь только ~200% амплификации. Deadline propagation быстро завершает запросы на сервере, что позволяет делать больше запросов в единицу времени. Поэтому амплификация растёт.

  • Сервер быстрее восстанавливается, потому что очередь запросов короче. А короче она, потому что сервер быстрее возвращает ошибки на запросы, которые уже не ждёт клиент.

При этом Вася заметил, что deadline propagation работает хуже, чем retry budget. Васе было интересно понять, почему так происходит — вдруг симуляция некорректна?

Он докопался до сути: deadline propagation обрывает многие запросы посередине их выполнения. Но к этому моменту они уже потратили CPU сервера. В случае с retry budget многие из этих запросов не дошли бы до сервера, потому что часть из них ретрайные. Поэтому deadline propagation — это скорее дополнение к retry budget.

Вася был приятно удивлён, что техника deadline propagation частично решает проблемы ретраев. Эта техника менее эффективна, чем retry budget, но она служит хорошим дополнением.

Финальное ретро по инциденту

Вася собрал финальное ретро по часовому инциденту. Он подготовил сводную таблицу со всеми action items, которые обсуждались по нему:

Action item

Обобщаемость на другие инциденты

Эффективность для данного инцидента

Вердикт

Подтюнить алерты

низкая

высокая

делаем, дёшево

Зафиксить баг с segfault

низкая

высокая

делаем, root cause

Автотесты на тайм-аут СУБД

низко-средняя

высокая

делаем, гигиена

Retry circuit breaker или retry budget

высокая

высокая

делаем retry budget, ключевой action item

Circuit breaker

высокая

средняя

не берём как action item, но делаем в будущем

Deadline propagation

высокая

средне-высокая

стартуем как долгосрочный проект

Вася подытожил: 3x амплификация нагрузки была вызвана ретраями, поэтому важно взять retry budget как самый критичный action item. Вася верил, если бы эта техника была реализована, система восстановилась не за 50 минут после отката релиза, а за 10. Также Вася предложил запланировать разработку deadline propagation как долгосрочный проект.

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

Заключение

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

Благодаря разбору инцидента, Вася прошёл путь от «флапает — добавим ретраи» до хорошего понимания рисков даже от exponential backoff. Он познакомился с техниками exponential backoff и jitter, с законом Литтла и closed-loop системами, с концепцией metastable failure state, с проблемой амплификации ретраями и техниками retry circuit breaker и retry budget, а ещё с техниками circuit breaker и deadline propagation.

Впереди у Васи был не менее интересный путь познания глубин ретраев, но об этом мы поговорим в следующей части.

P.S. А ещё мы проводим митапы про отказоустойчивость, чтобы помогать Васе и другим разработчикам работать с высоконагруженными системами. Вот видеозаписи с последнего митапа.

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


  1. fuCtor
    27.09.2023 07:40

    Для корректной работы circuit breaker нужно отказаться от промежуточных балансировщиков, либо перенести circuit breaker на них. В случае клиентской балансировки, клиент видит все хосты и может выкидывать их точечно, а не весь балансер разом.


    1. jirfag Автор
      27.09.2023 07:40

      В статье это скомкано, но имелся ввиду кейс, когда определенная часть заказов или пользователей сбоит. Но скорее не по причине плохого хоста, а из-за выпадания одного шарда СУБД или поломанной логики для части заказов (например, только для Доставки или только за кэш).


  1. propell-ant
    27.09.2023 07:40
    +16

    негативной обратной связи

    Васе на заметку: в русском языке есть устоявшееся название "Отрицательная обратная связь"

    https://ru.m.wikipedia.org/wiki/Обратная_связь_(техника)


    1. jirfag Автор
      27.09.2023 07:40
      +6

      Вася передает благодарность, в статье поправил


  1. ClearThree
    27.09.2023 07:40
    +7

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


  1. BHYCHIK
    27.09.2023 07:40
    +1

    Денис, а не рассматривали внедрений сервис мешей для решения этих задач? Я стараюсь такие задачи на уровне инфры решать. Интересен ваш опыт.


    1. jirfag Автор
      27.09.2023 07:40
      +1

      Хорошее замечание. Пока у нас это реализовано внутри языковых фреймворков (userver, go, java), со временем хотим унести все в service mesh. Экспериментируем с envoy, у него есть в тч retry budget.

      При этом на практике довольно важно понимать что это за техники вокруг ретраев и почему они существуют, а не просто использовать готовое.


  1. storoj
    27.09.2023 07:40
    +2

    Не будет ли полезным сделать короткий вывод о том, что ретраи это почти как криптография: "не разбираешься – не лезь, иначе сделаешь только хуже"?


    1. jirfag Автор
      27.09.2023 07:40
      +2

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


  1. Miranda11
    27.09.2023 07:40
    +1

    Спасибо за интересную историю)


  1. devcrab
    27.09.2023 07:40
    +2

    Прежде всего, спасибо за классную статью с большим количеством примеров, схем, графиков!

    В статье есть такая фраза:

    Вася внедрил exponential backoff и jitter. Также он вынес логику ретраев из сервиса в общую библиотеку http-клиентов во фреймворке userver.

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


    1. antoshkka
      27.09.2023 07:40
      +6

      Ретраи с exponential backoff c jitter в userver работают из коробки для клиентов (пример). Retry budget для клиентов сейчас не доступен в опенсорс, но есть возможность вручную выставлять на стороне сервера ограничение на RPS на "ручку"; и ограничение RPS на сервер целиком выставляется автоматически логикой Congestion Control. Плюс во все компоненты (сервер, клиенты, базы данных) проинтегрирован Deadline Propagation

      Так что сервис на userver убить ретраями просто так не получится.

      В добавок, сейчас есть Congestion Control для Монги (и скоро появится для PostgreSql!). Так что есть автоматика, которая осознаёт что база данных чувствует себя плохо даже без ретраев, и помогает выйти ей из MFS.

      P.S.: Постараемся вынести retry budget для клиентов в опенсорс версию, чтобы userver понежнее относился к сервисам-клиентам


      1. devcrab
        27.09.2023 07:40

        Постараемся вынести retry budget для клиентов в опенсорс версию, чтобы userver понежнее относился к сервисам-клиентам

        Звучит здорово, спасибо! а подскажите еще, почему выбрали retry budget, а не retry circuit breaker?


        1. Kvento
          27.09.2023 07:40

          Retry budget меньше нагружает сервер при отказе. В статье подробно рассказано, почему его выбрали


  1. Ru6aKa
    27.09.2023 07:40
    +4

    ИМХО проблема в том что нужна соответствующая инфраструктура для запуска микросервисом. И если говорить проще, то Вася был с самого начала прав, и автоскейлинг подов решил бы бы проблему storm retry.

    Нужно правильно реализовать пробы, startup, readiness, liveness. Проба startup понятно, микросервис запустился, прочитал конфигурацию, прогрел кеш и сделал другие нужные действия. Сложнее дело обстоит с readiness и liveness - обработчик этих проб должен быть в своем потоке, и не блокироваться входящими соединениями. Если такие пробы будут в общем обработчике соединений, то шторм запросов просто приведет к тому что liveness не будет проходить, и контейнер будет циклически перезапускаться. В идеальной ситуации liveness проба должна всегда быть успешной если микросервис работает(это проба про работу контейнера, а не микросервиса), тоесть данная проба нужна только для перезапуска контейнера из-за фатальной ошибки. Проба readiness самая сложная, она должна проверять что микросервис работает в нужном режиме, образно говоря для примера с микросервисом цен, такая проба всегда должна делать запрос на получения цены (чтобы полностью проверить подключение к базе, подключение к редису и т.д.)

    При этом есть одно но, автоскейлинг на основе % памяти/cpu работает только для простых случаев(или обычных cpu bound задач), для более сложных нужно чтобы микросервис собирал и отдавал телеметрию. И в этом случае автоскейлинг будет работать так, если например ответов с 500 больше 40% (не с пода, а со всех подов, статистику берем с сервера телеметрии), то начинаем скейлиться, например х2.

    И когда нормально работает телеметрия, то ее можно уже вкрутить и в readiness пробу, образно говоря перед запросом на получения цены, проверить в данных телеметрии процент 500, и если он больше 80% например, то проба вернет fail, и соответственно на контейнер перестанет попадать трафик, контейнер всем ответит и вернется в нормальное состояние.

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


    1. jirfag Автор
      27.09.2023 07:40
      +1

      спасибо за идею, и по правде мы не использовали автоскейлинг в Такси. Но боюсь он намного сложнее чем retry budget:

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

      2. чтобы скейлить сервис выше какого-то числа подов порой нужно напилить мультиплексер коннектов к базе

      3. автоскейлинг надо дружить с load shedding и рейт-лимитерами если юзается только сигнал про cpu usage

      4. механика circuit breaker через readiness пробу уязвима к тем же проблемам что описаны в статье: либо порог будет слишком большой (50+%), либо будет на выпадании шарда базы отрубать все. С большим порогом есть шанс войти в состояние когда переходы слишком резкие, и система не может работать при полном возврате трафика, и работает по синусоиде. Мы такое тоже ловили.

      не отрицаю что автоскейлинг делать нужно, и мы планируем, челленджу лишь его огромную сложность и риски относительно retry budget


      1. propell-ant
        27.09.2023 07:40

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

        Надо сказать, не в каждой крупной компании можно закрепить такое видение ("авария однажды случится") у руководства.

        А как девопсы относятся к ретрай-бюджету? Настройки бюджета живут не на сервисе, а на потребителе, и потребителей может быть много. Получается, что нужно как-то синхронизировать настройки бюджетов между потребителями? Ведь иначе любой криво настроенный потребитель может создать амплификацию и снова привет ручной ступенчатый ввод нагрузки по 5%?


        1. jirfag Автор
          27.09.2023 07:40

          у нас по дефолту в инфре микросервисов (в userver) выставлено 10%. Во всех новых сервисах оно автоматом начинает работать так. При этом можно оверрайдить и отключать, и есть такие единичные случаи, но это отдельная проблема.


      1. Ru6aKa
        27.09.2023 07:40

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

        1-е и должно решать использованием метрик перед обращением к внешним ресурсам, если в начале /get/price сделать запрос в кеш метрик и увидеть что коннекты к базе (или другому сервису) обрабатывают с задержкой, то нету смысла делать еще один запрос, надо отдать клиенту ошибку. Да вместо базы может быть все что угодно, и если для этого есть метрика, то ее лучше использовать. Да можно по таймеру отдавать ошибку клиенту, если ответ идет дольше чем надо от базы или другого сервиса, но это только в том случае, если нету метрик.

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

        2-е да это нужно, для меня прям почти идеальный пример такого мультиплексор cloud-sql-proxy, потому что он собирает метрики, есть ендпоинты для проб startup, liveness, readiness и можно держать коннекты к разным базам и из-за этого можно на localhost забиндить на разные порты например основную бд и реадонли реплики.

        3-е и 4-е я как раз и против скейлинга на основе cpu usage для не cpu bounded задач, надо использовать метрики которые относятся к запросам (количество ошибок, среднее, перцентили). Ну и совмещение подхода, если микросервис перегружен запросами по внутренним метрикам, то микросервис на время(например 1 мин) вывести из обслуживания(через /readiness пробу). Микросервис вернется к обработке трафика как только перемелет все входящие запросы из очереди и метрики выровняться. А для того чтобы не было массового вывода таких микросервисов из работы, скейлинг должен сработать раньше.

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


        1. jirfag Автор
          27.09.2023 07:40

          1 - да, и вот это довольно дорого. Интересно насколько дешево у вас удалось похожее сделать? Мы сначала смотрели на envoy adaptive concurrency чтобы эту проблему решить, там свои сложности всплыли после тестов. В итоге решили вместо envoy запилить самим адаптивные пулы коннектов к СУБД в userver (то что выше @antoshkka называет congestion control). И это довольно дорогая разработка вышла на месяцы. А retry budget для нового go фреймворка внутри мы например за пару дней сделали.

          2 - у нас такие мультиплексоры были в Такси, мы от них из-за сложностей отказались, но снова думаем о них :) по опыту факапов с mongos и самописными мультиплексорами - сложно, рискованно, легко набажить. У вас все гладко тут было?


          1. Ru6aKa
            27.09.2023 07:40
            +1

            Ну с микросервисами оно все дорого.

            1-е я делал так, в контейнере основным процессом был monit он собственно отвечал за запуск cloud_sql_proxy, haproxy и приложения (причем внутри конфигов monit были свои чеки для запуска/перезапуска нужных частей и что самое главное monit позволяет задавать зависимости, ну и не менее приятное что можно слать алерты для разных событий запуска/перезапуска, ну и прям как бонус, но я это не использовал, что monit умеет собирать статистику по использованию cpu/memory и с ней работать/слать алерты). Пробы были такие, startup - успешный вызов /bin/true(контейнер запустился), liveness проба pgrep -x /usr/bin/monit(главный процесс запустился). Проба readiness хитрая, сам haproxy отвечал всегда 200 кодом, но при этом внутри был чек на /healtz ендпоинт приложения (если чек не прошел то возвращаем ошибку, если прошел то ответ от приложения). Внутри healtz и была сделана проверка на работоспособность - запрос к бд с дефолтовыми параметрами (для вашего примера запрос цены всегда одного и того же маршрута), с таймером на получение ответа, если таймер сработал раньше ответа из бд, то healtz возвращает ошибку.

            Если бы внутри приложения были метрики и возможность обращения к ним, тогда бы haproxy не был бы нужен и схема была чуть проще.

            Пришлось городить огород с monit, потому что в кубере нету зависимостей и порядка запуска контейнеров, стратегия перезапуска странная (перезапуск всего пода, если один из контейнеров не здоров, поэтому нельзя так просто cloud_sql_proxy запустить в виде sidecar контейнера).

            2-е да в с cloud-sql-proxy все было гладко, как раз из-за наличия проб, поэтому monit нормально мониторил эту проксю, конфигурацию такого плана

            check process cloud_sql_proxy with pidfile "/var/run/app/cloud_sql_proxy.pid"
              start program = "/etc/init.d/start_cloud_sql_proxy"
              stop program = "/bin/sh -c 'kill -s SIGTERM `cat /var/run/app/cloud_sql_proxy.pid`'"
              if failed host 127.0.0.1 port 9090 protocol HTTP request "/startup" then restart
              if failed host 127.0.0.1 port 9090 protocol HTTP request "/liveness" then restart
              group proxy

            сам /etc/init.d/start_cloud_sql_proxy не особо сложный и просто обертка для запуска с нужными параметрами, но можно было это все напрямую вставить в monit конфиг

            #!/usr/bin/env bash
            
            set -euo pipefail
            
            SERVICE_CMD="/usr/local/bin/cloud-sql-proxy --health-check --private-ip ${INSTANCE_CONNECTION_NAME}"
            SERVICE_LOG="/var/log/app/cloud_sql_proxy.log"
            SERVICE_PID="/var/run/app/cloud_sql_proxy.pid"
            
            ${SERVICE_CMD} >${SERVICE_LOG} 2>&1 &
            echo ${!} > ${SERVICE_PID}


            1. jirfag Автор
              27.09.2023 07:40

              понятно, спасибо за детали!


  1. Leksat
    27.09.2023 07:40
    +1

    А у вас нет такой же статьи, только на английском?) С коллегами поделиться.


    1. jirfag Автор
      27.09.2023 07:40
      +2

      Пока нет, планирую. Когда переведу - в ответ на ваш комментарий прикреплю ссылку.