DEV Community

faangmaster
faangmaster

Posted on

Retries

Рассмотрим две компоненты: 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
Enter fullscreen mode Exit fullscreen mode

запросов. И т.д.
После n-ретраев мы сделаем:

TPS0 + TPS0(1-p) + TPS0(1-p)^2 + ... + TPS0(1-p)^n
Enter fullscreen mode Exit fullscreen mode

запросов.
Или:

TPS0(1 + (1-p) + (1-p)^2 + ... + (1-p)^n)
Enter fullscreen mode Exit fullscreen mode

В скобках у нас геометрическая прогрессия. Формула суммы геометрический прогрессии:

Sn = b1(1-q^n)/(1-q)
Enter fullscreen mode Exit fullscreen mode

В нашем случае:

b1 = 1, q = (1-p), n => n+1 (первый вызов + n ретраев).
Enter fullscreen mode Exit fullscreen mode

Тогда

TPS1 = TPS0 (1-(1-p)^(n+1))/(1-(1-p)) = TPS0(1 - (1-p)^(n+1))/p
Enter fullscreen mode Exit fullscreen mode

Давайте посчитаем значения для некоторых 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
Enter fullscreen mode Exit fullscreen mode

При n->бесконечности, (1-p)^(n+1) стремится к нулю. Тогда при n -> бесконечности формула упрощается до:

TPS1 = TPS0/p
Enter fullscreen mode Exit fullscreen mode

При 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)
Enter fullscreen mode Exit fullscreen mode

Полученная формула для p = 0:

TPS1 = TPS0 * (n + 1)
Enter fullscreen mode Exit fullscreen mode

Для n = 5: TPS1 = 1000 * (5 + 1) = 6000.

Этот предел можно посчитать проще. При p = 0, мы сделаем 1000 запросов в самом начале, потом еще 5*1000 попыток. Итого - 6000 запросов.

При n-> бесконечности мы будем делать бесконечное число запросов.

Вероятность успешного завершения вызова при n-ретраях

Вероятность успешного завершения одной попытки - p. Мы делаем максимум n - попыток. Нам надо посчитать вероятность того, что у нас хотя бы одна из n попыток завершится успехом. Это можно представить как:

P(хотя бы одна успешная попытка) = 1 - P(вероятность, что все попытки завершаться фейлом).
Enter fullscreen mode Exit fullscreen mode

Вероятность, что первая попытка завершится провалом: 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)

Collapse
 
marcopolo123 profile image
Marco Polo

circuit breaker всему голова