null

Автоматические повторные попытки с помощью Spring-аннотации @Retryable

Статья основана на труде "Using Spring’s @Retryable Annotation for Automatic Retries" автора Alexander Obregon

Введение

В сегодняшнем переплетенном мире, приложения зачастую взаимодействуют друг с другом, а также с внешними сервисами, базами данных и другими ресурсами. Во время такого взаимодействия, могут возникать временные неполадки, сетевые задержки, тайм-ауты, сбои в работе сторонних сервисов. Все вышеперечисленное привносит неопределенность в гарантию получения ожидаемых результатов. Если в вашем приложении есть критический участок кода, который подвержен подобным сценариям сбоя, то естественное желание - чтобы код был устойчивым и способным к самовосстановлению, по крайней мере, для проблем, которые непостоянные и временные. Именно здесь на помощь приходит аннотация Spring @Retryable, которая добавляет уровень отказоустойчивости в разрабатываемые приложения.

Когда нам необходимо повторять попытки?

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

В идеальных условиях, сервис выполняет HTTP-запрос и ему возвращаются данные. Но в реальности так может быть не всегда, иногда возникают различные неполадки. К примеру, сервер, на котором расположено АПИ, может быть сильно загружен, или же ваше приложение, само, может сталкиваться с сетевыми проблемами, задержками и т.д. Если не обрабатывать подобные случаи и оставлять все на самотек, то в итоге будет страдать качество, быстродействие и надежность ваших приложений, что может плохо отразиться на удовлетворенности пользователей от использования ваших программных продуктов и соответственно привести к потерям в бизнесе.

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

Вот простейший пример:

public class ManualRetryService {

    public String fetchDataFromRemote() {
        int attempts = 0;
        while(attempts < 3) {
            try {
                // Make the API call
                return "Успешно";
            } catch (MyNetworkException e) {
                attempts++;
            }
        }
        throw new MyCustomException("Так и не удалось вызвать АПИ даже после 3 попыток повторения");
    }
}

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

Как аннотация @Retryable упрощает весь процесс?

Spring Framework упрощает задачу выполнения повторений с помощью аннотации @Retryable. Мы просто добавляем ее в нужные нам компоненты и фреймворк делает за нас всю "грязную работу". Вот как будет выглядеть наш предыдущий пример, если мы перепишем его с @Retryable:


import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class MyService {

    @Retryable(MyNetworkException.class)
    public String fetchDataFromRemote() {
        // Make the API call that might fail
        return "Успешно";
    }
}

В приведенном выше коде, Spring будет автоматически повторять метод fetchDataFromRemote, в случае если при его выполнении возникнет исключение MyNetworkException.

Очевидно, что код стал намного чище и проще для понимания и поддержки. По мимо этого, мы можем легко дополнить его другими опциями. О них мы поговорим далее в этой статье.

Что скрывается под капотом?

Когда вы аннотируете метод с помощью @Retryable, Spring создает прокси вокруг этого метода. Это позволяет фреймворку перехватывать вызовы метода и прозрачно добавлять к нему логику повторных попыток. Принцип работы схож с другими возможностями Spring, которые используют прокси, например, с управлением транзакциями с помощью аннотации @Transactional.

 

Так почему же стоит попробовать @Retryable, а не реализовывать все вручную?

1) Чистота кода: Бизнес-логика остается отделенной от логики отказоустойчивости (не смешивается).

2) Удобство: Легче расширять или изменять конфигурацию логики повторных попыток, не затрагивая при этом бизнес-код.

3) Понятный код: С помощью аннотаций легко понять замысел разработчика, легко понять ожидаемое поведения метода.

 

Используя аннотацию @Retryable, вы можете добавить мощную и гибкую логику повторных попыток в свои методы, не усложняя кодовую базу. Это позволит вам сосредоточиться на бизнес-логике, в то время как фреймворк будет ответственен за реализацию отказоустойчивости.

Настройка @Retryable

Аннотация @Retryable - это не универсальное решение, а скорее - настраиваемая функция, которая может адаптироваться к множеству сценариев. Ее гибкость достигается благодаря богатому набору параметров конфигурации, которые позволяют точно настроить управление повторными попытками.

Задание типов отслеживаемых исключений

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

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

К примеру:


@Retryable(value = { MyNetworkException.class, TimeoutException.class })
public String fetchRemoteData() {
    // Во время сетевого вызова может произойти ошибка
    return "Данные";
}

Настройка количества попыток

По умолчанию @Retryable будет осуществлять максимум три попытки повторения, однако можно легко настроить это поведение, задав атрибут maxAttempts:


@Retryable(value = MyNetworkException.class, maxAttempts = 5)
public String fetchRemoteData() {
    // Во время сетевого вызова может произойти ошибка
    return "Данные";
}

Задержки между попытками

Часто бывает полезно ввести задержку между повторными попытками. Это может помочь в ситуациях, когда внешний сервис может быть временно перегружен. Задержка настраивается с помощью атрибута backoff аннотации @Backoff.

Вот пример, в котором задается двухсекундная задержка между попытками повторения:


@Retryable(value = MyNetworkException.class, backoff = @Backoff(delay = 2000))
public String fetchRemoteData() {
    // Во время сетевого вызова может произойти ошибка
    return "Данные";
}

Экспоненциальная задержка

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


@Retryable(value = MyNetworkException.class,
           backoff = @Backoff(delay = 1000, multiplier = 2))
public String fetchRemoteData() {
    // Во время сетевого вызова может произойти ошибка
    return "Данные";
}

Соединяем воедино несколько параметров

Практическая польза @Retryable раскрывается в полной мере, когда вы начинаете комбинировать ее доступные атрибуты.

Например:


@Retryable(value = { MyNetworkException.class, TimeoutException.class },
           maxAttempts = 5,
           backoff = @Backoff(delay = 1000, multiplier = 2))
public String fetchRemoteData() {
    // Во время сетевого вызова может произойти ошибка
    return "Данные";
}

В примере выше метод будет повторно вызываться максимум до 5 раз, в случаях если будут происходить исключения MyNetworkException и TimeoutException. При этом, повторные вызовы будут происходить с задержкой между друг другом, начиная с задержки в 1000 миллисекунд и с дальнейшим ее удвоением с каждой последующей попыткой.

Добавляем условия

Могут возникнуть ситуации, когда необходимо динамически управлять выполнением повторной попытки, основываясь на каком-либо условии или возникшем исключении. Для этого можно использовать атрибут condition, который принимает выражение на синтаксисе SpEL.


@Retryable(value = MyNetworkException.class,
           condition = "#{#root.args[0] != 'no-retry'}")
public String fetchRemoteData(String controlFlag) {
    // Во время сетевого вызова может произойти ошибка
    return "Данные";
}

В этом примере повторная попытка не будет выполняться, если аргумент controlFlag равен 'no-retry'.

Разбираемся в параметрах

Аннотация @Retryable имеет много различных параметров для настройки логики повторного выполнения. Эти параметры спроектированы так, чтобы работать в гармонии и чтобы обеспечить надежный механизм повторных попыток прямо из коробки. Нужны ли вам простые повторные попытки с фиксированными интервалами или сложные механизмы вроде экспоненциального отката с повторными попытками на основе условий, понимание этих параметров поможет вам все реализовать без особых усилий.

параметр value

Параметр value задает какие исключения должны инициализировать повторную попытку. В качестве значения он может принимать массив классов Throwable. По умолчанию, если value не задан явно, повторная попытка инициализируется для всех исключений, расширяющих Throwable.


@Retryable(value = { MyNetworkException.class, TimeoutException.class })
public String execute() {
    // Code
}

параметр include

Как и параметр value, параметр include позволяет указать исключения, которые должны вызвать повторную попытку. Разница в том, что include позволяет указывать исключения в дополнение к тем, которые уже определены параметром value.


@Retryable(value = MyNetworkException.class, include = TimeoutException.class)
public String execute() {
    // Code
}

параметр exclude

В противоположность include, параметр exclude позволяет определить какие исключения не должны вызывать повторную попытку.


@Retryable(value = Exception.class, exclude = IllegalArgumentException.class)
public String execute() {
    // Code
}

параметр maxAttempts

Параметр maxAttempts определяет максимальное количество попыток повторения вызова метода. Значение по умолчанию равно 3.


@Retryable(maxAttempts = 5)
public String execute() {
    // Code
}

параметр backoff

Параметр backoff позволяет реализовать задержку между повторными попытками. В качестве значения он принимает аннотацию @Backoff, в которой вы можете указать задержку в миллисекундах и необязательный множитель для экспоненциальной задержки.


@Retryable(backoff = @Backoff(delay = 2000, multiplier = 2))
public String execute() {
    // Code
}

параметр condition

Параметр condition позволяет указать булево условное выражение на синтаксисе SpEL (Spring Expression Language). Логика повторной попытки будет активирована только в том случае, если это выражение будет равно true.


@Retryable(condition = "#{#arg > 100}")
public String execute(int arg) {
    // Code
}

параметр stateful

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


@Retryable(stateful = true)
public String execute() {
    // Code
}

параметр listeners

Параметр listeners позволяет указать бин, который будет уведомляться при каждой попытке повтора. Этот бин должен реализовывать интерфейс RetryListener. Это может быть полезно для ведения журнала, метрик и т.п.


@Retryable(listeners = "myRetryListenerBean")
public String execute() {
    // Code
}

Соединяем все воедино


@Retryable(value = { MyNetworkException.class, TimeoutException.class }, 
           maxAttempts = 5, 
           backoff = @Backoff(delay = 2000, multiplier = 2), 
           condition = "#{#arg != 'no-retry'}")
public String execute(String arg) {
    // Code
}

В этом примере метод будет повторен до 5 раз и только в случае возникновения MyNetworkException или TimeoutException. Повторные попытки будут происходить с начальной задержкой в 2000 миллисекунд, с удвоением задержки с каждой попыткой, и будут выполняться только в том случае, если аргумент arg не равен 'no-retry'.

Соединяем аннотацию @Retryable с аннотацией @Recover

При использовании аннотации @Retryable необходимо продумать что произойдет, если все повторные попытки окажутся неудачными. Хотя повторные попытки могут увеличить шансы на успешное выполнение операции, они не могут гарантировать его. Именно здесь на помощь приходит аннотация @Recover.

Аннотация @Recover позволяет определить fallback метод, который будет вызван, когда все попытки повторных попыток, настроенные с помощью @Retryable, будут исчерпаны. Fallback метод предназначен для выполнения альтернативной логики, такой как: отправка сообщения об ошибке, попытка подключения к резервной службе или обновление состояния приложения для отражения сбоя.

Вот простой пример, иллюстрирующий его использование:


import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class MyService {

    @Retryable(MyNetworkException.class)
    public String fetchDataFromRemote() {
        // Во время сетевого вызова может произойти ошибка
        return "Данные";
    }

    @Recover
    public String recover(MyNetworkException e) {
        // Логика запасного варианта
        return "Данные по умолчанию";
    }
}

В этом примере, если метод fetchDataFromRemote вызовет исключение MyNetworkException и исчерпает все попытки восстановления, будет вызван метод recover, возвращающий "Данные по умолчанию" в качестве запасного варианта.

Соответствие типов исключений

Список параметров метода @Recover должен совпадать со списком параметров метода @Retryable. Однако список параметров @Recover должен также содержать еще один параметр и этот параметр должен быть первым в списке. Речь идет о параметре, указывающим тип исключения, для которого мы хотим инициализировать запасной вариант.

Например, если метод @Retryable принимает два параметра следующим образом:


@Retryable(MyNetworkException.class)
public String fetchData(String param1, int param2) {
    // Сетевой вызов
}

Тогда сигнатура метода @Recover должна быть следующей:


@Recover
public String recover(MyNetworkException e, String param1, int param2) {
     // Логика запасного варианта
}

Задание нескольких запасных вариантов

Вы можете задать несколько методов @Recover для разных типов исключений. Таким образом, вы можете выполнять различную логику восстановления в зависимости от типа исключения, которое привело к неудаче всех повторных попыток.

Вот пример того, как это можно настроить:


@Retryable(value = { MyNetworkException.class, TimeoutException.class })
public String fetchDataFromRemote() {
    // Сетевой вызов
}

@Recover
public String recover(MyNetworkException e) {
    return "Данные по умолчанию для исключения MyNetworkException";
}

@Recover
public String recover(TimeoutException e) {
    return "Данные по умолчанию для исключения TimeoutException";
}

В этом примере есть два метода @Recover: один для MyNetworkException и другой для TimeoutException. Соответствующий метод @Recover будет вызван в зависимости от исключения, которое привело к исчерпанию повторных попыток.

Добавляем условия

Подобно @Retryable, вы также можете добавлять условия в методы @Recover:


@Recover
public String recover(MyNetworkException e, String param1) {
    if ("special_case".equals(param1)) {
        return "Special Recovery Logic";
    }
    return "General Recovery Logic";
}

Когда следует использовать @Recover

В то время как @Retryable может помочь восстановиться после преходящих сбоев, @Recover вступает в игру, когда вам приходится иметь дело с более постоянными проблемами или когда вы хотите выполнить "план Б" после того, как все попытки повторных попыток не увенчались успехом.

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

Примеры использования @Retryable

Вызовы удаленных сервисов

Если ваше приложение зависит от удаленного сервиса, который может быть временно недоступен или испытывать периодические проблемы, использование @Retryable может повысить вероятность успешного завершения операции.


@Retryable(MyNetworkException.class)
public String fetchFromRemoteService() {
    // HTTP request to an external API
    return "Data";
}

Распределенные системы

В микросервисах или распределенных архитектурах часто случаются сбои в работе сети или временная недоступность сервисов. @Retryable может обеспечить устойчивость вашей системы к таким сбоям.

@Retryable(TimeoutException.class)
public void sendMessageToQueue(String message) {
    // Send message to a message queue
}

Базы данных

Иногда операции с базой данных могут завершиться неудачей из-за блокировок или временных проблем с соединением. Повторное выполнение транзакции часто позволяет решить эти проблемы.


@Retryable(DatabaseException.class)
public void updateDatabaseRecord() {
    // Update database record
}

Операции с файлами

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


@Retryable(IOException.class)
public void writeFile() {
    // Write to a file
}

Повторные попытки, основанные на составных, сложных условиях

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


@Retryable(value = CustomException.class, condition = "#{#someArg > 100}")
public void complexConditionMethod(int someArg) {
    // Do something
}

Ограничения использования @Retryable

Влияние на производительность

Каждая повторная попытка потребляет ресурсы, будь то циклы процессора, память или даже пропускная способность сети. Чрезмерное количество повторных попыток может привести к падению производительности.

Подходит не для всех ошибок

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

Каскадирование сбоев

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

Потенциальные сложности в системах с состоянием

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

Обработка ошибок

Использование методов @Recover может привести к разрозненной логике обработки ошибок, что может привести к затруднениям в проектах с большой кодовой базой.

Заключение

Аннотации @Retryable и @Recover в Spring предлагают элегантный, декларативный подход к добавлению логики повторных попыток и отказоустойчивости в ваши приложения. Несмотря на богатый набор настраиваемых опций, важно использовать их с умом, помня как о преимуществах их применения, так и об ограничениях. Понимая глубину их возможностей и применяя их с умом, вы сможете значительно повысить отказоустойчивость и надежность вашего приложения.