---
title: "Коли повторні спроби лише шкодять: як захистити систему від перевантаження"
url: https://laravelukraine.com/blog/koli-povtorni-sprobi-lise-skodiat-iak-zaxistiti-sistemu-vid-perevantazennia
date: 2026-10-27
source: https://wendelladriel.com/blog/when-retries-make-things-worse-protecting-your-system-from-overload
---

# Коли повторні спроби лише шкодять: як захистити систему від перевантаження

> Це переклад статті Венделла Адріела [When Retries Make Things Worse: Protecting Your System From Overload](https://wendelladriel.com/blog/when-retries-make-things-worse-protecting-your-system-from-overload).

### Вступ

Припустімо, провайдер розрахунку вартості доставки починає відповідати за дві секунди замість 100 мілісекунд. Він досі повертає коректні розрахунки. Немає помилок автентифікації, немає зіпсованих відповідей і немає очевидної причини припинити надсилати запити.

Наш застосунок чекає. До оформлення замовлення доходить усе більше покупців. З'єднання лишаються зайнятими, у воркерів менше місця для нових запитів, і частина викликів завершується за таймаутом. HTTP-клієнт повторює ці виклики, бо таймаут схожий на тимчасову проблему.

Тепер провайдер отримує більше спроб саме тоді, коли має менше ресурсів, щоб їх обробити. Оформлення замовлення сповільнюється, тож ми додаємо воркерів застосунку. Вони надсилають ще більше запитів тому самому провайдеру, який і так ледве справляється.

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

Перш ніж додавати те чи інше, я б запитав, чи безпечно повторювати операцію і чи може система дозволити собі ще одну спробу. Статті [Idempotency: What, Why and How](https://wendelladriel.com/blog/idempotency-what-why-and-how) присвячена першому питанню. Тут нам треба відповісти на друге.

Наш запит вартості доставки лише читає дані. Приклади написані на PHP, але проблема ресурсів стосується будь-якого застосунку, який чекає на інший сервіс.

### Повільна залежність може виснажити здоровий застосунок

Поки запит чекає, він займає ресурси. Залежно від середовища виконання це воркер, з'єднання, пам'ять під стан запиту або місце в пулі з'єднань. Асинхронне середовище не виділяє окремий потік під кожне очікування, але відкладена робота однаково має свою ціну.

Наведені нижче числа ілюструють проблему ресурсів. Це не виміри з продакшену:

```text
Вхідні запити вартості:          100 за секунду
Середня відповідь провайдера:    0.1 секунди
Середня кількість одночасних:    близько 10

Вхідні запити вартості:          100 за секунду
Середня відповідь провайдера:    2 секунди
Потреба в одночасних викликах:   близько 200
```

Для стабільної системи закон Літтла (Little's Law) пов'язує середній обсяг роботи в системі з інтенсивністю надходження і середнім часом перебування в ній. Другий розрахунок показує, скільки одночасних викликів знадобилося б, щоб утримати ту саму пропускну здатність за повільнішої відповіді. Якщо в нашому пулі лише 50 місць, ми не можемо підтримувати такий потік без очікування або відмов.

Починає рости черга. Тепер запити чекають на з'єднання ще до того, як провайдер їх узагалі побачить. Завантаження процесора в нашому застосунку може лишатися низьким, бо вузьке місце - очікування на введення-виведення. Масштабування лише за процесором цю проблему може не помітити.

Якщо покупцеві потрібен розрахунок вартості протягом секунди, а більша частина цієї секунди минає в очікуванні з'єднання, провайдерові лишається замало часу, щоб повернути корисний результат.

### Рахуйте спроби на всьому шляху виклику

Початковий запит - це один логічний запит вартості. Спроба - це один виклик провайдера, включно з першим викликом і всіма повторними. Ведіть для них окремі лічильники.

Якщо щосекунди надходить 100 початкових запитів і кожен доходить до трьох спроб, провайдер може отримати 300 спроб за секунду. Три спроби означають початковий виклик плюс два повтори.

Якщо кілька рівнів повторюють запити незалежно, їхні дозволи множаться:

```text
Браузер (3 спроби загалом)
  |
  v
Застосунок (3 спроби загалом)
  |
  v
Клієнт провайдера (3 спроби загалом)

3 x 3 x 3 = до 27 спроб до провайдера
на одну початкову дію користувача
```

Ця верхня межа передбачає, що кожна зовнішня спроба повторює всю роботу нижче за течією, а кожен внутрішній рівень вичерпує власний дозвіл. Реальний трафік не завжди сягатиме цієї межі. Але конфігурація все одно її допускає - саме тоді, коли залежність найменше здатна з цим упоратися.

[Стаття AWS Builders' Library про таймаути й повторні спроби](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/) описує це посилення і радить обирати для багатьох операцій одну точку повторів. Розділ [Handling Overload](https://sre.google/sre-book/handling-overload/) від Google теж пояснює, чому рішення про повтори потребують обмежень на рівні всіх запитів, а не лише максимальної кількості спроб для кожного окремого запиту.

Для нашого запиту вартості я б зробив власником повторних спроб адаптер провайдера. Він знає, які збої провайдер вважає тимчасовими і чи дозволено ще одну спробу. Решта застосунку не обгортає адаптер ще одним циклом повторів. Повтори в SDK чи проксі треба врахувати в цьому рішенні або вимкнути для цього виклику.

### Дайте всій операції дедлайн

Таймаут обмежує одне очікування. Дедлайн обмежує час, відведений на завершення всієї операції.

Таймаут 500 мілісекунд на кожну з трьох спроб може витратити 1,5 секунди лише на очікування викликів - ще до затримок між спробами, отримання з'єднання чи роботи самого застосунку. Покупець може перестати чекати задовго до початку третьої спроби.

Натомість виділяйте час із залишку бюджету того, хто викликає. Припустімо, на запит вартості й обробку відповіді в оформленні замовлення лишилося 900 мілісекунд. 50 мілісекунд ми резервуємо на повернення результату. Усе всередині запиту вартості має поділити між собою решту - 850 мілісекунд:

```text
Бюджет запиту вартості: 850 мс
+------------------------------------------------------------+
| Перша спроба | Затримка | Друга спроба | Залишок часу      |
+------------------------------------------------------------+
0                                                       850 мс

Друга спроба отримує те, що лишилося.
Вона не отримує ще 850 мс.
```

Конкретні значення потребують вимірювання затримок і навантажувального тестування. Надто короткий таймаут може штучно створювати збої, відкидаючи виклики, які завершилися б нормально. Надто довгий таймаут тримає дефіцитні ресурси зайнятими протягом усього інциденту.

Для вимірювання часу, що минув, усередині процесу використовуйте монотонний годинник. У 64-бітному PHP [`hrtime(true)`](https://www.php.net/manual/en/function.hrtime.php) повертає ціле число наносекунд від довільної точки відліку. Ця точка не є спільною міткою часу для різних серверів, тому не передавайте сире значення як віддалений дедлайн.

Для викликів між сервісами передавайте залишок бюджету через механізм дедлайнів, який підтримує протокол. [Посібник gRPC про дедлайни](https://grpc.io/docs/guides/deadlines/) описує таке передавання і те, що робота застосунку має співпрацювати зі скасуванням. На межі довіри також обмежуйте бюджет, який надіслав викликач, максимумом, що його приймає наш сервіс.

Перевірте, що саме покриває таймаут транспорту. DNS, встановлення з'єднання, TLS, очікування в пулі з'єднань і читання відповіді можуть мати окремі налаштування, тож таймаут читання, який виглядає розумно в конфігурації клієнта, ще може лишати весь виклик необмеженим.

### Обмежуйте одночасну роботу, перш ніж вона стане в чергу

Ліміт конкурентності (Concurrency Limit) обмежує, скільки спроб може виконуватися одночасно. Обмеження частоти запитів (Rate Limiting) обмежує, скільки спроб може початися за певний час. Ці механізми розв'язують різні проблеми.

За 100 викликів на секунду провайдерові, який відповідає за 100 мілісекунд, потрібно набагато менше одночасних ресурсів, ніж тому самому провайдерові, коли він відповідає за дві секунди. Фіксована частота запитів не запобігає такому зростанню одночасної роботи.

Для провайдера вартості доставки поставте перевірку допуску до отримання з'єднання. Якщо вільного місця немає, поверніть контрольований результат «недоступно» або скористайтеся резервним варіантом, погодженим із бізнесом. Не переносьте очікування непомітно в необмежену чергу семафора.

Ліміт допуску потребує чіткої області дії. Ліміт у 10 викликів на кожній із 20 реплік дозволяє 200 викликів на весь кластер. Додавання реплік збільшує цей дозвіл, якщо ми не скоригуємо розподіл. Для спільної зовнішньої квоти доречнішим може бути шлюз або узгоджений розподіл.

Тримайте окремі ресурси для залежностей, які мають відмовляти незалежно одна від одної. Очікування розрахунків доставки не повинні займати весь пул, потрібний для читання інформації про товари. Таке розділення часто називають перегородками (Bulkhead). Воно однаково вимагає, щоб решті застосунку вистачало ресурсів на обслуговування не пов'язаних запитів.

Те, що клієнт завершив виклик за таймаутом, не доводить, що провайдер припинив роботу. Якщо ми звільнимо локальне місце і допустимо інший виклик, поки запит із таймаутом досі виконується на віддаленому боці, провайдер може виконувати більше роботи, ніж показує наш локальний лічильник активних викликів.

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

### Задайте бюджет повторних спроб на рівні всіх запитів

Максимум у три спроби все одно дозволяє кожному початковому запиту потроїти навантаження під час відмови. Бюджет повторних спроб (Retry Budget) обмежує додаткові спроби на рівні багатьох початкових запитів.

Один із варіантів - накопичувати токени для повторів у міру надходження початкових запитів. У цьому прикладі кожен початковий запит дає 0,1 токена. Кожен повтор витрачає один токен, а баланс не може перевищити 20 токенів. Якщо починати з порожнього балансу, 100 початкових запитів заробляють щонайбільше 10 повторів.

Цей дозвіл можна виразити цілочисельними кредитами:

```php
final class RetryBudget
{
  private int $credits = 0;

  public function recordOriginal(): void
  {
    $this->credits = min(200, $this->credits + 1);
  }

  public function tryConsume(): bool
  {
    if ($this->credits < 10) {
      return false;
    }

    $this->credits -= 10;

    return true;
  }
}
```

Десять кредитів становлять один токен, що дає змогу обійтися без обчислень із рухомою комою. Баланс вміщує кредитів щонайбільше на 20 повторів, навіть після кількох годин без жодного збою.

Об'єкт має жити довше за окремий запит і бути спільним для всіх викликів, якими він керує. Новий бюджет усередині кожного запиту вартості починався б порожнім, заробляв би лише один кредит і ніколи не дозволив би повтор. Спільний об'єкт у межах одного процесу дає бюджет цьому процесу, а не всьому кластеру.

Для конкурентних потоків чи процесів операція «прочитати й витратити» потребує атомарного сховища або механізму повторів, який уже дає таку гарантію. Клас вище демонструє політику для одного послідовного власника. Це не розподілений лічильник. Шлюз, довгоживучий клієнт або SDK із задокументованими квотами повторів часто краще підходять на роль власника цієї поведінки, ніж код застосунку.

Раніше накопичені токени дозволяють сплеск, тож за короткий період спостереження повтори можуть перевищити 10% від початкових запитів. Ця політика також ніяк не обмежує початковий трафік. Зберігайте ліміт допуску, навіть коли частка повторів здається низькою.

### Затримка між спробами без утримання місця в залежності

Затримка між спробами (Backoff) відкладає наступну спробу. Джитер (Jitter) розкидає ці затримки, щоб багато клієнтів не повторювали запити в ту саму мить.

З повним джитером (Full Jitter) обирайте випадкову затримку від нуля до верхньої межі, яка зростає. У нашому прикладі межа починається з 25 мілісекунд і подвоюється після кожної невдалої спроби, до 200 мілісекунд. Випадкова затримка однаково рахується в дедлайн операції.

Якщо провайдер надсилає коректний `Retry-After`, чекайте щонайменше стільки. Якщо часу не лишається, щоб і почекати, і зробити ще одну корисну спробу, зупиніться. Скорочувати запитану затримку, щоб утиснути ще один повтор, означає ігнорувати сигнал провайдера про перевантаження.

Звільняйте місце в залежності перед паузою, а перед наступною спробою знову намагайтеся його отримати. Якщо тримати це місце під час затримки, для інших викликів лишається менше ресурсів.

У синхронному PHP-запиті `usleep()` однаково займає воркер застосунку. Звільнення місця в залежності не звільняє цей воркер. Тримайте затримки повторів в інтерактивних запитах короткими й обмеженими. Довгі очікування на відновлення мають жити в надійному фоновому завданні або в наступній дії користувача - з власною політикою допуску.

### Зберіть рішення в одному власнику повторних спроб

Власникові повторних спроб потрібен адаптер провайдера, який приймає таймаут, і обмежувач, що може відмовити в роботі без очікування. Це інтерфейси застосунку, а не API Laravel чи якогось пакета:

```php
interface QuoteClient
{
  public function fetch(string $postcode, int $timeoutMs): array;
}

interface ConcurrencyLimiter
{
  public function tryAcquire(): bool;

  public function release(): void;
}

final class TemporaryQuoteFailure extends RuntimeException
{
  public function __construct(
    public readonly ?int $retryAfterMs = null,
  ) {
    parent::__construct(
      'The quote provider is temporarily unavailable.',
    );
  }
}
```

`QuoteClient::fetch()` має дотримуватися переданого загального таймауту спроби для всієї транспортної роботи, за яку він відповідає, і вимикати приховані повтори. Він перетворює на `TemporaryQuoteFailure` лише задокументовані тимчасові збої. Інші помилки передаються далі без нової спроби. Адаптер за потреби розбирає `Retry-After` у невід'ємну тривалість.

Обмежувач має отримувати місце атомарно і без очікування. Його реалізація належить до тієї області дії ресурсів, яку ми обрали раніше. PHP-об'єкт, створений на один запит, не може забезпечити ліміт на весь кластер.

З такими контрактами власник повторних спроб виглядає так:

```php
function fetchQuote(
  QuoteClient $client,
  ConcurrencyLimiter $limiter,
  RetryBudget $budget,
  string $postcode,
  int $remainingMs,
): array {
  $deadline = hrtime(true) + (min($remainingMs, 900) - 50) * 1_000_000;
  $budget->recordOriginal();

  for ($attempt = 1; $attempt <= 3; $attempt++) {
    $availableMs = (int) floor(($deadline - hrtime(true)) / 1_000_000);

    if ($availableMs < 50) {
      throw new RuntimeException('Quote deadline exhausted.');
    }

    if (! $limiter->tryAcquire()) {
      throw new RuntimeException('Quote capacity unavailable.');
    }

    try {
      if ($attempt > 1 && ! $budget->tryConsume()) {
        throw new RuntimeException('Quote retry budget exhausted.');
      }

      return $client->fetch($postcode, min(300, $availableMs));
    } catch (TemporaryQuoteFailure $failure) {
      if ($attempt === 3) {
        throw $failure;
      }
    } finally {
      $limiter->release();
    }

    $delayMs = random_int(0, min(200, 25 * 2 ** ($attempt - 1)));
    $delayMs = max($delayMs, $failure->retryAfterMs ?? 0);
    $availableMs = (int) floor(($deadline - hrtime(true)) / 1_000_000);

    if ($availableMs < $delayMs + 50) {
      throw $failure;
    }

    usleep($delayMs * 1_000);
  }

  throw new LogicException('The attempt loop must return or throw.');
}
```

Мінімум у 50 мілісекунд - це приклад політики, яка вирішує, чи вистачить наступній спробі часу, щоб бути корисною. Обмеження в 300 мілісекунд на спробу і 900 мілісекунд на операцію теж потребують налаштування за спостережуваними затримками. Це не рекомендовані значення за замовчуванням для будь-якого провайдера.

На шлях повторних спроб потрапляє лише `TemporaryQuoteFailure`. Локальна відмова через брак ресурсів і вичерпаний бюджет повторних спроб повертають керування викликачеві без нової спроби до провайдера. `Retry-After`, довший за залишок часу, теж зупиняє цикл. Жодне з цих рішень не скидає дедлайн операції.

Фрагмент зосереджений на рішеннях про повтори. Межа в продакшені має представляти вичерпання дедлайну, ресурсів і бюджету повторних спроб окремими результатами або типами винятків, а потім перетворювати їх на контракт відповіді ендпоїнта. Їй також потрібні телеметрія і підтримка скасування з боку транспорту. Наведені вище інтерфейси цих реалізацій не дають.

Ця політика стосується запиту вартості, який лише читає дані. Списання платежу потребує задокументованого контракту ідемпотентності, перш ніж повторювати його після будь-якого таймауту. Таймаут каже нам лише про те, що клієнт не отримав відповіді. Він не каже, що провайдер не виконав жодної дії з побічним ефектом.

> **Примітка перекладача.** У Laravel повторні спроби найчастіше налаштовують через `Http::retry()` для HTTP-клієнта та властивості `$tries` і `$backoff` (або метод `backoff()`) у завданнях черги. Логіка статті стосується і їх: якщо на виклик провайдера в завданні накладаються `Http::retry()` і `$tries`, спроби множаться так само, як у схемі вище, тож варто визначити один рівень, який відповідає за повтори.

### Вирішіть, що означає відмова для покупця

Контроль допуску (Admission Control) свідомо лишає частину роботи невиконаною. Нам треба вирішити, що побачить покупець, ще до того, як інцидент змусить зробити цей вибір.

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

Для HTTP-ендпоїнта тимчасову недоступність сервісу можна позначити кодом `503`. Обмеження частоти для конкретного клієнта може використовувати `429`. Жоден із цих кодів не є дозволом на негайні автоматичні повтори. Визначте поведінку клієнта, поважайте переданий `Retry-After` і не показуйте нескінченно крутну кнопку, за якою ховається постійний потік нових запитів.

Відкидання надлишкової роботи - це скидання навантаження (Load Shedding). Зворотний тиск (Backpressure) означає, що виробник реагує на сигнали про ресурси і сповільнює виробництво. Відповідь про перевантаження не створює жодного зворотного тиску, якщо викликач одразу ж надсилає той самий запит знову.

Запобіжник (Circuit Breaker) може зупиняти виклики після повторюваних збоїв і згодом дозволяти обмежені пробні запити. Він буває корисним, коли залежність стабільно недоступна. Для цього прикладу я б спершу налаштував дедлайни, ліміти допуску й облік повторних спроб. Запобіжник додає ще одну політику зі станом, а погано контрольований пробний запит на відновлення може знову запустити перевантаження.

### Протестуйте сповільнення, а потім відновлення

Тест повторних спроб, який один раз кидає виняток і з другої спроби отримує успіх, мало що доводить про перевантаження. Він ніколи не створює конкуренції за ресурси.

Використайте локальну заглушку провайдера і генератор навантаження із заданою інтенсивністю надходження запитів. Для контрольованого експерименту налаштуйте заглушку на чотири місця для виконання, 20 мілісекунд на обробку в нормальному режимі і 120 мілісекунд під час сповільнення. Надсилайте 50 початкових запитів за секунду.

За 120 мілісекунд на виклик чотири місця теоретично можуть завершувати близько 33 викликів за секунду, якщо не зважати на накладні витрати. Наші 50 початкових запитів за секунду перевищують цю потужність ще до першого повтору. Тож експеримент створює перевантаження, навіть якщо провайдер сам не повертає жодних помилок.

Проженіть той самий профіль трафіку на двох конфігураціях:

- Базова: до трьох спроб загалом, таймаут спроби 300 мілісекунд, без ліміту допуску і без спільного бюджету повторних спроб.
- Обмежена політика: чотири локальні місця для залежності, спільний бюджет повторних спроб, джитер і те саме обмеження кількості спроб. Дайте обом конфігураціям однаковий загальний дедлайн, щоб порівняння не винагороджувало обмежену політику лише за довше очікування.

Почніть зі здорового стану, увімкніть сповільнення, а потім поверніть нормальний час обробки, не зупиняючи початковий трафік. Тестуйте досить довго, щоб побачити, чи розсмоктується накопичена робота. Також проженіть контрольний тест у здоровому стані з увімкненим захистом, щоб переконатися, що він не відкидає нормальний трафік.

Генератор трафіку має подавати 50 початкових запитів за секунду незалежно від того, чи завершилися попередні. Цикл, який чекає на кожну відповідь, перш ніж надіслати наступний запит, зменшує навантаження в міру того, як застосунок сповільнюється, і це може приховати перевантаження. Переконайтеся, що сам генератор не відстає від свого розкладу.

Вимірюйте початкові запити і спроби до провайдера окремо. Фіксуйте успішні розрахунки, повернуті в межах дедлайну, таймаути, локальні відмови, глибину черги провайдера і пікову кількість одночасної роботи. Вимірюйте затримку успішних запитів окремо від затримки швидких відмов, бо дашборд, що змішує обидві, може показати чудовий час відповіді системи, яка відкидає майже все.

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

Повторіть експеримент із провайдером, який ігнорує скасування. Порахуйте роботу, яка ще виконується після того, як клієнт завершив виклик за таймаутом. Цей варіант показує розрив між обмеженням локального очікування і обмеженням виконання на віддаленому боці - те, що пропустила б слухняна заглушка всередині процесу.

### Зробіть рішення про повтори спостережуваними

Лічильник помилок не скаже нам, чи допомагає поведінка відновлення. Для операції розрахунку вартості я б окремо відстежував:

- Частоту початкових запитів і частоту спроб до провайдера, включно з прихованими спробами транспорту.
- Успішні початкові запити, завершені в межах дедлайну операції.
- Здійснені повтори, повтори, які врятували запит, і повтори, у яких відмовив бюджет.
- Поточну кількість активних викликів, відмови в допуску і час очікування в пулі з'єднань.
- Затримку провайдера, таймаути спроб і вік роботи в черзі.

Поділіть кількість спроб до провайдера на кількість початкових запитів за той самий період спостереження, щоб побачити посилення. Пам'ятайте, що спроби можуть перетинати межі періодів. Аналізуйте відновлення після сповільнення, а не чекайте, що одне середнє значення на дашборді пояснить увесь інцидент.

Зберігайте ідентифікатори запитів і трасувань у трасуваннях або структурованих логах. Не використовуйте їх як мітки метрик, де кожне нове значення створює ще один часовий ряд. Відокремлюйте відмову в допуску від збою провайдера, щоб оператор не сприйняв захист, який працює як слід, за ще одну помилку провайдера.

### Підсумок

Коли залежність сповільнюється, я б утримувався від збільшення кількості воркерів чи повторних спроб, доки ми не зрозуміємо, куди йдуть час і ресурси. Більше викликачів не створять ресурсів усередині провайдера.

Я радше прийняв би кілька явних відмов у розрахунку вартості, ніж дозволив би запитам доставки забрати ресурси, потрібні решті оформлення замовлення. Цей вибір потребує заздалегідь погодженої відповіді для покупця, а не резервного варіанту, вигаданого нашвидкуруч під час відмови.

Оберіть одну залежність, повтори якої ви не можете порахувати на всьому шляху виклику. Порахуйте спроби, сповільніть її в контрольованому тесті і продовжуйте надсилати трафік, поки вона відновлюється.
