Ретраи и джиттер: почему сервис падает во второй раз в момент восстановления
Повтор запроса — самая дешёвая на вид мера и самый простой способ добить сервис: он добавляет нагрузку ровно тогда, когда её труднее всего принять, а одинаковое расписание повторов собирает тысячу клиентов в один миг. Урок разбирает механизм на модели: что с этим делает выдержка, что делает джиттер и чем за него приходится платить.
Полное техническое изложение
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: постоянный интервал — не «просто попроще»
Первая стратегия, которую пишут, если не думать: повторять каждые сто миллисекунд, пока не выйдет.
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
Две цифры стоит прочитать вместе. 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 секунд.
В этой строке удвоение доказано арифметикой: шесть повторов дают 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
Впустую ушло 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
Пик упал с тысячи до десяти — в сто раз. Ни один клиент не стал вести себя скромнее: каждый по-прежнему повторяет запрос до успеха. Изменилось только то, что они перестали делать это одновременно.
Отсюда формулировка, которой стоит отвечать: джиттер не уменьшает нагрузку, он её размазывает. Нагрузку создаёт сам повтор — джиттер решает лишь, в какой момент повторы придут. Общее число запросов при этом даже слегка выросло (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"])
Практика · оцените
Проверка знаний
Сервис был недоступен две секунды, тысяча клиентов повторяла запрос с экспоненциальной выдержкой без джиттера. Что произойдёт в момент восстановления?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Повтор усиливает нагрузку, а одинаковое расписание синхронизирует её в пики. Каждая повторная попытка — это дополнительный запрос к сервису, которому и так плохо; и если все клиенты выжидают между попытками одинаково, то и возвращаются они одинаково — одним мигом. Отсюда две меры, и они про разное: увеличивать паузу после каждой неудачи — чтобы повторов было меньше, — и выбирать её случайно — чтобы они не совпадали.
- Отсюда главное следствие: сервис ложится второй раз ровно в момент восстановления. Модель: тысяча клиентов, одинаковая выдержка — и в момент, когда сервис ожил, приходит пик в 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 — но это результат именно этой модели с этим зерном случайности, а не универсальная «цена джиттера»: воспроизводится направление, а не число. Числа этого урока вообще модельные: они получены симуляцией с явно названными допущениями, а не замером живой системы — сеть без задержек, отказ мгновенный, все клиенты стартуют одновременно.
На самом деле
- Создают. Каждая повторная попытка — это дополнительный запрос, и при отказе нагрузка на упавший сервис растёт, а не только перераспределяется. Расписание повторов добавляет к этому второй эффект: одинаковые паузы синхронизируют клиентов и собирают усиленную нагрузку в пики. В модели постоянный интервал дал 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. Удвоение — это снижение темпа при повторных неудачах, а не проявление вежливости. - Только идемпотентный. Повтор операции с побочным эффектом — это второе списание, второе письмо, вторая отгрузка. Пауза здесь ничего не меняет; меняет ключ идемпотентности, по которому сервер возвращает прежний результат вместо нового действия.
- Выдержка и джиттер управляют одним клиентом, а перегрузку создаёт сумма. В цепочке из трёх слоёв по три попытки один запрос пользователя превращается в двадцать семь запросов к последнему сервису. Это обрывает только бюджет повторов — правило на уровне системы.
Что разобрано
- Что здесь на самом деле спрашивают
- База: зачем вообще повторять и почему это опасно
- Механизм 1: постоянный интервал — не «просто попроще»
- Механизм 2: экспоненциальная выдержка — про темп, а не про терпение
- Механизм 3: джиттер — это про расписание, а не про вежливость
- Глубже: чего модель не показывает
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
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