Deep Engineering
Средний·Опубликовано·25 МИН

Ретраи и джиттер: почему сервис падает во второй раз в момент восстановления

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

Полное техническое изложение

TL;DR

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

Отсюда главное следствие: сервис ложится второй раз ровно в момент восстановления. Модель: тысяча клиентов, одинаковая выдержка — и в момент, когда сервис ожил, приходит пик в 1000 запросов за один десятимиллисекундный шаг. Та же модель с полным джиттером даёт пик 10, то есть в 100 раз ниже, а общее число запросов при этом даже слегка растёт — 6906 против 6000. Джиттер меняет расписание, а не объём работы.

Дальше — то, что отличает знающего от читавшего. Постоянный интервал — худшая из трёх стратегий: 20 000 запросов за время недоступности и ни одного обслуженного клиента, потому что попытки кончились раньше, чем сервис ожил; экспоненциальная выдержка на той же модели отправляет 5000 вместо 20 000 — вчетверо меньше. Само удвоение паузы — не выдумка библиотек: tcp(7) пишет, что шесть повторов начального SYN покрывают примерно 127 секунд, а 127 — это в точности 1 + 2 + 4 + 8 + 16 + 32 + 64. И у джиттера есть цена: в той же модели все клиенты обслужены к 13 290 мс вместо 3100 — но это результат именно этой модели с этим зерном случайности, а не универсальная «цена джиттера»: воспроизводится направление, а не число. Числа этого урока вообще модельные: они получены симуляцией с явно названными допущениями, а не замером живой системы — сеть без задержек, отказ мгновенный, все клиенты стартуют одновременно.

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

Что здесь на самом деле спрашивают

Лестница обычно такая:

  1. «Что делать, если запрос не прошёл?» — разминка, на которой звучит слово «повторить».
  2. «Сколько раз повторять и с какой паузой?» — здесь начинается содержание.
  3. «Почему экспоненциальная выдержка, а не фиксированная пауза?» — вопрос про амплификацию нагрузки.
  4. «Что такое джиттер и зачем он?» — главный вопрос темы.
  5. «Сервис лежал и восстановился — почему он тут же лёг снова?» — вопрос про стадный эффект.
  6. «Как ограничить ретраи на уровне системы?» — вопрос про бюджет повторов.
модель с допущениямиЧисла ниже — из симуляции bench/retries/storm.py, а не из замера живой системы. Допущения перечислены в докстринге скрипта: дискретное время шагом 10 мс, тысяча клиентов стартует одновременно, недоступность ровно 2 с, мгновенный отказ, сеть без задержек.

Числа этого урока получены моделью, а не прогоном против настоящей системы. Стадный эффект — свойство расписания повторов, и чтобы его увидеть, нужны тысяча клиентов и воспроизводимая недоступность. Модель даёт и то и другое; цена — все выводы верны ровно настолько, насколько верны допущения, и потому допущения названы поимённо.

База: зачем вообще повторять и почему это опасно

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

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

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

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

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

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

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

Механизм 1: постоянный интервал — не «просто попроще»

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

CONSTANT INTERVAL
-----------------
  first wave at t=0 (all clients at once)  1000
  peak in one 10 ms step after recovery    0
  when that peak happened, ms              0
  requests sent into the outage (wasted)   20000
  total requests sent                      20000
  clients served                           0
  all clients served by, ms                -1
модель с допущениямиbench/retries/storm.py, модель. Двадцать тысяч запросов — это тысяча клиентов по двадцать попыток; предел попыток в модели — двадцать.

Две цифры стоит прочитать вместе. 20 000 запросов ушли в сервис, который не мог ответить: тысяча клиентов исчерпала по двадцать попыток каждая. И ноль обслуженных: к моменту, когда сервис ожил, у клиентов кончились попытки.

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

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

Механизм 2: экспоненциальная выдержка — про темп, а не про терпение

Вторая стратегия удваивает паузу после каждой неудачи: 100 мс, 200, 400, 800. Это не изобретение прикладных библиотек — так ведёт себя само ядро, когда повторяет начальный пакет установления соединения:

The maximum number of times initial SYNs for an active TCP connection attempt will be retransmitted … The default value is 6, which corresponds to retrying for up to approximately 127 seconds.

перевод

Максимальное число повторных передач начальных SYN при активной попытке TCP-соединения… Значение по умолчанию — 6, что соответствует повторам примерно в течение 127 секунд.

tcp(7), tcp_syn_retries

В этой строке удвоение доказано арифметикой: шесть повторов дают 127 секунд только в одном случае — если паузы равны 1, 2, 4, 8, 16, 32 и 64. При постоянной паузе шесть повторов уложились бы в единицы секунд.

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

В модели это выглядит так:

EXPONENTIAL, NO JITTER
----------------------
  first wave at t=0 (all clients at once)  1000
  peak in one 10 ms step after recovery    1000
  when that peak happened, ms              3100
  requests sent into the outage (wasted)   5000
  total requests sent                      6000
  clients served                           1000
  all clients served by, ms                3100
модель с допущениямиbench/retries/storm.py, модель. Пять тысяч бесполезных запросов вместо двадцати тысяч — прямое следствие удвоения пауз.

Впустую ушло 5000 запросов вместо 20 000 — вчетверо меньше. И все клиенты в итоге обслужены.

Но посмотрите на строку, ради которой урок и написан: пик после восстановления — 1000 запросов в один десятимиллисекундный шаг. Все клиенты ждали одинаково, поэтому и вернулись одинаково. Сервис, который только что встал, получает всю тысячу разом.

Механизм 3: джиттер — это про расписание, а не про вежливость

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

EXPONENTIAL + FULL JITTER
-------------------------
  first wave at t=0 (all clients at once)  1000
  peak in one 10 ms step after recovery    10
  when that peak happened, ms              2000
  requests sent into the outage (wasted)   5906
  total requests sent                      6906
  clients served                           1000
  all clients served by, ms                13290
модель с допущениямиbench/retries/storm.py, модель, зерно 20260905. Пик и время восстановления зависят от зерна; направление обоих эффектов — нет.

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

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

И вот цена, которую в разговоре обычно забывают: все обслужены к 13 290 мс вместо 3100. Размазав пик, мы размазали и восстановление. Это настоящий компромисс, а не бесплатное улучшение: джиттер выбирают, когда пик опаснее задержки, — то есть почти всегда для сервиса, который от пика ложится, и не всегда для клиента, которому нужен ответ поскорее.

И сразу граница, без которой это число опасно уносить с собой. 13 290 мс — не «цена джиттера» вообще. Это результат вот этой модели с вот этими допущениями: тысяча клиентов, недоступность ровно две секунды, экспоненциальные границы 100, 200, 400, 800 и зерно случайности 20260905. Смените зерно — получите другое число; смените длительность недоступности или число клиентов — тоже. Воспроизводится здесь направление: восстановление растягивается, и растягивается тем сильнее, чем шире диапазон, из которого выбирается пауза. Правило поэтому пишется от направления, а не от числа: за срезанный пик всегда платят более поздним «обслужены все», и цену эту в своей системе измеряют, а не берут отсюда.

Глубже: чего модель не показывает

Три вещи, о которых стоит сказать самому, — и это тоже часть хорошего ответа.

Ретраи не бесплатны для того, кто их делает. Каждый повтор держит соединение и обработчик у клиента; при массовых повторах клиент упирается в собственные лимиты (предыдущие уроки: дескрипторы, очередь accept) раньше, чем сервис успевает ожить.

Повторять можно не всё. Повтор безопасен для операции, которая идемпотентна: чтение, запись с ключом, отмена по идентификатору. Для «списать деньги» повтор — это второе списание, и здесь нужна не пауза, а ключ идемпотентности.

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

Как отвечать на собеседовании

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

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Хороший ответ отличают три вещи. Первая — вы разводите два эффекта и не путаете их: объём нагрузки создаёт сам повтор, и уменьшает его выдержка (в модели 5000 запросов вместо 20 000); форму нагрузки задаёт расписание, и её меняет джиттер, ничего не убавляя (6906 запросов против 6000, а пик — 10 вместо 1000). Вторая — вы упоминаете идемпотентность: не всякую операцию можно повторять, и пауза тут не поможет. Третья — вы называете бюджет повторов как системное ограничение, потому что без него каждый слой умножает повторы предыдущего.

И одна аккуратность в числах. Цену джиттера — «все обслужены к 13 290 мс вместо 3100» — называть можно и стоит, но с оговоркой, что это результат конкретной модели с конкретным зерном, а не постоянная величина. Универсально здесь только направление: срезанный пик оплачивается более поздним моментом, когда обслужены все. Тот, кто произносит 13 290 без этой оговорки, выдаёт один прогон за закон.

Дальше спросят

Спросят дальше

Почему сервис лёг второй раз ровно в момент восстановления?

Короткий ответ

Потому что все клиенты ждали одинаково. Экспоненциальная выдержка без джиттера синхронизирует их: у всех паузы 100, 200, 400, 800 — и следующая попытка приходится на один и тот же миг.

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

Спросят дальше

Сколько попыток ставить?

Короткий ответ

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

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

Спросят дальше

Всякую ли операцию можно повторять?

Короткий ответ

Нет. Повтор безопасен только для идемпотентной операции: чтение, запись по известному ключу, отмена по идентификатору. Для операции с побочным эффектом — списать, отправить, начислить — повтор означает второе выполнение.

Лечится это не выдержкой, а ключом идемпотентности: клиент присылает идентификатор попытки, сервер запоминает результат и на повтор возвращает тот же ответ вместо нового действия. Тогда повтор становится безопасным, и всё остальное из этого урока начинает работать.

Спросят дальше

Что такое бюджет ретраев?

Короткий ответ

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

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

Частые заблуждения

Утверждение

ретраи не создают нагрузку, они только собирают её в кучу

На самом деле

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

Утверждение

экспоненциальная выдержка решает проблему стадного эффекта

На самом деле

Она решает проблему амплификации: в модели впустую ушло 5000 запросов вместо 20 000. Стадный эффект она не трогает — наоборот, синхронизирует клиентов: пик после восстановления в модели равен 1000, то есть всем клиентам разом.

Утверждение

джиттер снижает нагрузку

На самом деле

Он её размазывает. Нагрузку создаёт сам повтор, а джиттер решает только, когда повторы придут: общее число запросов в модели даже слегка выросло — 6906 против 6000, — а вот пик после восстановления упал с 1000 до 10. Уменьшает число повторов не джиттер, а выдержка: 5000 запросов вместо 20 000.

Утверждение

джиттер — бесплатное улучшение, его надо включать всегда

На самом деле

У него есть цена, и в этой модели она такая: все клиенты обслужены к 13 290 мс с джиттером против 3100 мс без него. Размазав пик, вы размазали и восстановление. Само число — свойство этой модели и этого зерна случайности, а не константа; воспроизводится направление. Джиттер выбирают, когда пик опаснее задержки.

Утверждение

удвоение паузы — это просто разумная эвристика прикладного кода

На самом деле

Так же ведёт себя ядро: tcp(7) сообщает, что шесть повторов начального SYN покрывают примерно 127 секунд, а такое число получается только при паузах 1, 2, 4, 8, 16, 32 и 64. Удвоение — это снижение темпа при повторных неудачах, а не проявление вежливости.

Утверждение

повторять можно любой запрос, лишь бы с паузой

На самом деле

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

Утверждение

если у каждого клиента выдержка и джиттер, система защищена

На самом деле

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

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона модели, а не назначен.

Практика · что напечатает

Тысяча клиентов, сервис недоступен две секунды. Модель прогоняется тремя стратегиями повтора. Печатаются три числа: пик запросов в один десятимиллисекундный шаг после восстановления при экспоненциальной выдержке без джиттера; тот же пик с полным джиттером; и сколько клиентов обслужено при постоянном интервале в сто миллисекунд. Что напечатает этот код?
constant = simulate(delay_constant)
plain = simulate(delay_exponential)
jittered = simulate(delay_full_jitter)
print(plain["peak_after"])
print(jittered["peak_after"])
print(constant["served"])

Практика · оцените

Тысяча клиентов возвращается после двухсекундной недоступности. Во сколько раз полный джиттер снижает пик запросов по сравнению с экспоненциальной выдержкой без джиттера?
раз

Проверка знаний

Вопрос 1 из 6

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

Источники и что читать дальше

1 ИСТОЧНИК

  1. tcp(7), Linux man-pages 6.7Официальная документация. Экспоненциальная выдержка не как совет из статьи, а как поведение самого ядра — в описании повторов начального SYN: «The maximum number of times initial SYNs for an active TCP connection attempt will be retransmitted … The default value is 6, which corresponds to retrying for up to approximately 127 seconds» (Максимальное число повторных передач начальных SYN при активной попытке TCP-соединения … Значение по умолчанию — 6, что соответствует повторам примерно в течение 127 секунд). Арифметика тут и есть доказательство удвоения: шесть повторов с паузами 1, 2, 4, 8, 16, 32 и 64 секунды дают ровно 127.https://man7.org/linux/man-pages/man7/tcp.7.html