Transactional Outbox: гарантия доставки сообщений

Я помогаю Golang, Java и C# разработчикам находить работу и проходить собеседования: резюме и отклики, которые доходят до HR, спокойный собес и торг по офферу. Разбираю механику найма изнутри, я провёл более 100 собеседований. Без воды, только чек-листы и готовые фразы.

transactional outboxoutboxat least once

"Как гарантировать доставку асинхронных сообщений на стороне отправителя?" - Этот вопрос подводит нас, пожалуй, к самому популярному вопросу на собеседовании в контексте распределенных взаимодействий. А именно, об использовании архитектурного паттерна Transactional Outbox.

Когда приложение публикует сообщения в брокер, возникает проблема консистентности данных.

👤 Пример: Создание пользователя

Task<long> CreateUser(string email);

Метод CreateUser создает пользователя по email и отправляет сообщение UserCreated с информацией о пользователе в брокер для обработки другими сервисами. Например, для отправки email с подтверждением регистрации.

🔄 Стандартная реализация

Реализацию метода можно описать следующими шагами:

public Task<long> CreateUser(string email)
{
  // 1. Создать пользователя в БД
  // 2. Отправить сообщение UserCreated в брокер сообщений
}

⚠️ Проблемы стандартного подхода

В этом сценарии может возникнуть ситуация, при которой данные о пользователе сохранятся в БД на этапе 1, а сообщение не отправится в брокер на этапе 2 из-за проблем с сетью или недоступности брокера. Сервис отправки email не получит сообщения и не отправит email пользователю, что приведет к неконсистентности системы.

Можно предположить, что изменение порядка шагов 1 и 2 поможет, но в этом случае возникает обратная ситуация. Сообщение отправится в брокер, а данные о пользователе не сохранятся в БД.

💡 Решение: Паттерн Transactional Outbox

Для того чтобы гарантировать атомарное сохранение данных и отправку сообщения применяют паттерн Transactional Outbox.

📦 Этап 1: Сохранение в Outbox

Вместо того, чтобы сразу отправлять сообщение в брокер. Приложение будет сохранять сообщение в специальную таблицу outbox в БД в одной транзакции с сохранением данных о пользователе. Таким образом в случае возникновения проблем при обработке запроса CreateUser данные о пользователе и сообщение сохранятся в БД вместе, или не сохранятся вовсе.

public Task<long> CreateUser(string email)
{
  // 1. Открыть транзакцию
  // 2. Создать пользователя в БД
  // 3. Сохранить сообщение UserCreated в БД
  // 4. Подтвердить транзакцию
}

📤 Этап 2: Обработка Outbox

Далее задача по расписанию в транзакции читает несколько сообщений из таблицы outbox, отправляет их в брокер сообщений и удаляет или помечает обработанными. Это может быть фоновая задача в приложении с API или отдельно стоящий сервис. После отправки вычитанных сообщений в брокер и удаления, выполняется подтверждение транзакции.

Task ProcessOutbox()
{
  // 1. Открыть транзакцию к БД
  // 2. Прочитать 10 сообщений из таблицы outbox
  // 3. Отправить сообщения в брокер
  // 4. Удалить сообщения
  // 5. Подтвердить транзакцию
}

🔧 Как это работает

Таким образом создание пользователя с отправкой сообщений разбивается на два этапа:

  1. Атомарное сохранение пользователя и сообщения в БД;
  2. Отправка сообщений из таблицы outbox в брокер.

⚡️ Семантика At Least Once

На этапе 2 возможны ситуации, когда после отправки части прочитанных в транзакции сообщений, брокер оказывается недоступным. В этом случае транзакция отменяется и в следующей сообщения могут быть отправлены повторно. Поэтому Transactional Outbox гарантирует доставку сообщения хотя бы один раз. Это семантика - At Least Once.

✅ Итоги

В совокупности использование Transactional Outbox гарантирует консистентное состояние системы на стороне отправки сообщений. Либо бизнес-операция выполняется целиком, либо не выполняется вовсе.

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

#техничка #backend

Читайте так же