Рассмотрим две компоненты: A и B, которые взаимодействуют через RPC. Например, компонента A вызывает некую функцию/API компоненты B TPS0 раз в секунду. Например, QPS = TPS0 = 1000 вызовов в секунду.
Во время вызовов в продакшене на практике возникают временные (transient) ошибки, которые быстро появляются и исчезают. К ним относятся сетевые сбои, кратковременная перегрузка сервиса, таймауты и т. д.
Поэтому на практике почти всегда используют повторные попытки (retries): если ошибка относится к классу transient, мы просто повторяем вызов.
Но какая оптимальная стратегия ретраев?
Ограниченное и неограниченное число ретраев и последствия
Рассмотрим стратегию с ограниченным числом повторных попыток. Если возникает transient-ошибка, мы повторяем вызов, пока он не завершится успешно, но не более n раз. По истечении n попыток прекращаем повторения и возвращаем ошибку.
Очевидное следствие такого подхода — рост нагрузки на сервис B. Давайте рассчитаем это увеличение.
Увеличение нагрузки на компоненту B.
Пусть вначале мы делаем TPS0 запросов в секунду. Пусть вероятность успешного завершения вызова p. Тогда с первого раза успешно завершатся TPS0p вызовов. Для оставшихся TPS0(1-p) запросов мы сделаем ретрай. Т.е. мы уже сделаем TPS0 + TPS0(1-p) запросов после первого ретрая. Аналогично, из них TPS0(1-p)p завершатся успешно, и нам надо будет повторить запрос для TPS0(1-p)(1-p) запросов. Т.е. после второго ретрая мы сделаем
TPS0 + TPS0(1-p) + TPS0(1-p)^2
запросов. И т.д.
После n-ретраев мы сделаем:
TPS0 + TPS0(1-p) + TPS0(1-p)^2 + ... + TPS0(1-p)^n
запросов.
Или:
TPS0(1 + (1-p) + (1-p)^2 + ... + (1-p)^n)
В скобках у нас геометрическая прогрессия. Формула суммы геометрический прогрессии:
Sn = b1(1-q^n)/(1-q)
В нашем случае:
b1 = 1, q = (1-p), n => n+1 (первый вызов + n ретраев).
Тогда
TPS1 = TPS0 (1-(1-p)^(n+1))/(1-(1-p)) = TPS0(1 - (1-p)^(n+1))/p
Давайте посчитаем значения для некоторых p и n:
1) p = 99%, n = 5, TPS0 = 1000 => TPS1 = TPS0 * 1,0101010101. Рост на чуть более чем 1%. TPS1 = 1010.
2) p = 90%, n = 5, TPS0 = 1000 => TPS1 = TPS0 * 1.11111 = 1111. Рост на 11.1%
3) p = 50%, n = 5, TPS0 = 1000 => TPS1 = TPS0 * 1.96875 = 1968. Рост на 96.7%.
4) p = 10%, n = 5, TPS0 = 1000 => TPS1 = TPS0 * 4.68559 = 4685. Рост на 368%.
То есть выходит, что чем хуже работает сервис B, тем больше запросов мы отправляем. В большинстве случаев это приводит к тому, что мы «добиваем» сервис B своими ретраями.
На графике это выглядит вот так:
Зависимость нелинейная: чем хуже работает сервис B, тем быстрее растёт число запросов.
Что будет, если не ограничивать число ретраев?
В нашей формуле:
TPS1 = TPS0(1 - (1-p)^(n+1))/p
При n->бесконечности, (1-p)^(n+1) стремится к нулю. Тогда при n -> бесконечности формула упрощается до:
TPS1 = TPS0/p
При p->0, число запросов возрастает до бесконечности. В то время как при ограчисенном n оно ограниченно. Если изобразить это на одном графике:

Вначале графики очень близки друг к другу. При уменьшении p, число ретраев при неограниченном n растет до бесконечности.
Сколько запросов мы будем делать при p = 0 (сервис B упал)?
В нашей формуле надо устремить p->0 и посчитать предел:
TPS1 = TPS0(1 - (1-p)^(n+1))/p
TPS1(p->0) = lim (TPS0(1 - (1-p)^(n+1))/p), p->0
По правилу Лопиталя возьмем производную числителя и значенателя:
Производная знаменателя = p' = 1
Производная числителя = TPS0 * (0 - (n + 1) * (1 - p) ^ n * (0 - 1)) = TPS0 * (n + 1) * (1 - p) ^ n. p -> 0 => TPS0 * (n + 1)
Тогда предел равен = TPS0 * (n + 1) / 1 = TPS (n + 1)
Полученная формула для p = 0:
TPS1 = TPS0 * (n + 1)
Для n = 5: TPS1 = 1000 * (5 + 1) = 6000.
Этот предел можно посчитать проще. При p = 0, мы сделаем 1000 запросов в самом начале, потом еще 5*1000 попыток. Итого - 6000 запросов.
При n-> бесконечности мы будем делать бесконечное число запросов.
Вероятность успешного завершения вызова при n-ретраях
Вероятность успешного завершения одной попытки - p. Мы делаем максимум n - попыток. Нам надо посчитать вероятность того, что у нас хотя бы одна из n попыток завершится успехом. Это можно представить как:
P(хотя бы одна успешная попытка) = 1 - P(вероятность, что все попытки завершаться фейлом).
Вероятность, что первая попытка завершится провалом: 1-p.
Вероятность, что n-попыток завершатся провалом: (1-p)^n.
Тогда вероятность того, что хотя бы одна будет успешной::
P = 1 - (1 - p)^n
Графики вероятности успеха от вероятности успеха одной попытки для разных n выглядит так:
Чем больше число ретраев, тем медленнее спадает вероятность успеха. Очевидно, что чем больше число попыток, тем выше вероятность, что операция завершится успешно.
Потенциальные проблемы и решения
С одной стороны, чем больше число попыток, тем выше вероятность успешного завершения операции — это помогает скрывать временные (transient) ошибки. С другой стороны, как только у вызываемой компоненты возникают реальные проблемы (p → 0), мы резко увеличиваем на неё нагрузку: чем хуже её состояние, тем сильнее мы её нагружаем. В результате возникает так называемый retry storm: компонента, едва начав работать нестабильно, быстро перегружается и окончательно выходит из строя.
Более того, компоненту B будет сложно поднять, например, мы ее починили, но при подъеме на нее обрушиться трафик в разы больший, чем был изначально.
Что делать, чтобы это предотвратить?
- Ограничивайте число ретраев. Как мы видели ранее, если число ретраев не ограничивать, то при малых p, нагрузка растет до бесконечности. При ограниченном n, есть предел числа ретраев: (n + 1) * TPS0
- Используйте exponential backoff. Это стратегия, при которой интервал между ретраями постепенно увеличивается. Между первой и второй попыткой пауза небольшая, между второй и третьей — больше, между третьей и четвёртой — ещё больше и т. д. Такой подход снижает нагрузку на проблемную компоненту и даёт ей время восстановиться.
- Используйте circuit breaker. Когда компонента явно неисправна, правильнее прекратить все обращения к ней до её восстановления, а не доводить её до окончательной перегрузки и отказа.
- Используйте rate limiting/throttling. Чтобы не перегружать компоненту B, ограничивайте максимум запросов в единицу времени.
Смотри также:
Некоторые подходы к архитектуре приложений в Amazon
Обработка ошибок при вызове другой компоненты



Top comments (1)
circuit breaker всему голова