Оригинал: https://www.milanjovanovic.tech/blog-covers/mnw_026.png?imwidth=1920

Работа с микросервисами достаточно сложная, как и с любой распределенной системой. В распределенной системе многое может пойти не так, и об этом даже написаны научные статьи. Если вы хотите углубиться в эту тему, советую почитать про заблуждения распределенных вычислений (fallacies of distributed computing). Уменьшение количества возможных точек отказа должно быть одной из целей инженера, который проектирует распределенную систему. В этом выпуске мы постараемся достичь именно этого, используя паттерн Outbox.

Как реализовать надежную связь между компонентами в распределенной системе?

Паттерн Outbox — это элегантное решение этой проблемы, позволяющее достичь транзакционных гарантий в рамках одного сервиса и обеспечить доставку сообщений во внешние системы по принципу "как минимум один раз" (at-least-once).

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

Какую проблему решает паттерн Outbox?

Чтобы понять, какую проблему решает Outbox, нам сначала нужна сама проблема.

Вот пример потока регистрации пользователя. Здесь происходит несколько действий:

  1. Сохранение пользователя в базу данных

  2. Отправка приветственного письма пользователю

  3. Публикация события UserRegisteredEvent в брокер

public async Task RegisterUserAsync(User user, CancellationToken token)
{
    _userRepository.Insert(user);

    await _unitOfWork.SaveChangesAsync(token);

    await _emailService.SendWelcomeEmailAsync(user, token);

    await _eventBus.PublishAsync(new UserRegisteredEvent(user.Id), token);
}

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

Но что произойдет, если одна из этих операций завершится некорректно?

  1. База данных недоступна, и сохранение пользователя не удается

  2. Почтовый сервис не работает, и отправка письма падает с ошибкой

  3. Публикация события в брокер не проходит

Также представьте ситуацию, когда вам удалось сохранить пользователя в БД, отправить ему приветственное письмо, но не удалось опубликовать UserRegisteredEvent для уведомления других сервисов. Как вы будете восстанавливаться после такого сбоя?

Паттерн Outbox позволяет атомарно обновлять базу данных и отправлять сообщения в брокер сообщений.

Реализация паттерна Outbox

  1. — добавить в базу данных таблицу, представляющую Outbox (Исходящие) сообщения. Мы можем назвать эту таблицу OutboxMessages, и она предназначена для хранения всех сообщений, которые должны быть гарантированно доставлены потребителю. Теперь вместо прямых запросов к внешним сервисам мы просто сохраняем сообщение как новую строку в таблице Outbox. Сaми же сообщения, обычно, хранятся в базе данных в формате JSON.

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

Обратите внимание, что благодаря повторным попыткам (retries) у вас теперь реализована доставка сообщений "как минимум один раз" (at-least-once). В случае успеха сообщение будет опубликовано ровно один раз, а в случае повторных попыток — более одного раза.

Мы можем переписать метод RegisterUserAsync из предыдущего примера, теперь он будет использовать паттерн Outbox:

public async Task RegisterUserAsync(User user, CancellationToken token)
{
    _userRepository.Insert(user);

    _outbox.Insert(new UserRegisteredEvent(user.Id));

    await _unitOfWork.SaveChangesAsync(token);
}

Outbox является частью той же транзакции, что и наш Unit of Work, поэтому мы можем атомарно сохранить пользователя в базу данных и также сохранить OutboxMessage. Если сохранение в БД не удастся, вся транзакция откатится, и никакие сообщения не будут отправлены в шину сообщений.

А так как мы перенесли публикацию UserRegisteredEvent в рабочий процесс, нам нужно добавить обработчик, чтобы мы могли отправить приветственное письмо пользователю. Вот пример этого в классе SendWelcomeEmailHandler:

public class SendWelcomeEmailHandler : IHandle
{
    private readonly IUserRepository _userRepository;
    private readonly IEmailService _emailService;

    public SendWelcomeEmailHandler(
        IUserRepository userRepository,
        IEmailService emailService)
    {
        _userRepository = userRepository;
        _emailService = emailService;
    }

    public async Task Handle(UserRegisteredEvent message)
    {
        var user = await _userRepository.GetByIdAsync(message.UserId);

        await _emailService.SendWelcomeEmailAsync(user);
    }
}

Архитектура проекта на основе Outbox

Оригинал: https://www.milanjovanovic.tech/blogs/mnw_026/outbox.png?imwidth=1920

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

Дополнительные материалы

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

Чего не хватает - так это более подробной информации о том, как реализовать шаблон "Outbox", поэтому вот несколько видеороликов, которые вы можете посмотреть:

Комментарии (0)