Что такое паттерн Transactional Outbox (Inbox)?

Ответ

Transactional Outbox (иногда называемый Inbox для потребителя) — это архитектурный паттерн, гарантирующий точно-однократную (exactly-once) или хотя-бы-однократную (at-least-once) доставку сообщений/событий в распределённых системах. Он решает проблему атомарности обновления базы данных и отправки сообщения в брокер (например, RabbitMQ, Kafka).

Проблема: В микросервисной архитектуре типична операция: "сохранить заказ в БД И отправить событие OrderCreated". Если после сохранения в БД происходит сбой до отправки события, система становится несогласованной.

Решение Transactional Outbox:

  1. Атомарная запись: Сообщение сохраняется в специальную таблицу Outbox в той же транзакции, что и основное бизнес-состояние.
  2. Фоновая доставка: Отдельный фоновый процесс (Outbox Processor или Relay) периодически опрашивает таблицу Outbox, отправляет новые сообщения в брокер и помечает их как отправленные.

Пример реализации на C# (Entity Framework Core):

// 1. Модель сообщения в outbox
public class OutboxMessage
{
    public Guid Id { get; set; } // Для идемпотентности
    public DateTime OccurredOn { get; set; }
    public string Type { get; set; } // "OrderCreated"
    public string Payload { get; set; } // JSON данных события
    public DateTime? ProcessedDate { get; set; }
}

// 2. Использование в сервисе заказов
public async Task PlaceOrder(OrderDetails details)
{
    using var transaction = await _dbContext.Database.BeginTransactionAsync();

    try
    {
        // Сохраняем бизнес-объект
        var order = new Order { ... };
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync();

        // АТОМАРНО сохраняем событие в ту же БД
        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            OccurredOn = DateTime.UtcNow,
            Type = "OrderCreated",
            Payload = JsonSerializer.Serialize(new 
            { 
                OrderId = order.Id,
                Total = order.Total 
            })
        };
        _dbContext.OutboxMessages.Add(outboxMessage);

        await _dbContext.SaveChangesAsync(); // Одна транзакция!
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
    // Фоновый сервис (например, Hangfire job или IHostedService) 
    // позже заберёт и отправит это сообщение.
}

Преимущества:

  • Гарантированная доставка: Событие не потеряется даже при падении сервиса сразу после коммита транзакции.
  • Согласованность: Бизнес-данные и факт генерации события всегда синхронизированы.
  • Производительность: Основная операция не блокируется на сетевой вызов к брокеру.

Варианты: Для повышения производительности опроса таблицы Outbox можно использовать механизмы, отслеживающие изменения в БД (CDC), например, Debezium или SQL Server Change Tracking.

Ответ 18+ 🔞

Давай разберём эту вашу транзакционную аутбокс-хуяутбокс, а то народ путается, как хуй с пальцем.

Смотри, классическая проблема: твоему сервису нужно сделать две вещи — записать заказ в свою базу и отправить событие в очередь, типа «Заказ создан, все сюда!». И вот ты пишешь в базу, коммитишь, а потом пытаешься отправить сообщение в RabbitMQ, и тут — бац! — сеть легла, сервис упал, или просто кошка на кабель наступила. В итоге заказ в базе есть, а событие не ушло. Другие сервисы про заказ нихуя не знают, система в консистентном состоянии, как говно в проруби. Пиздец.

И что делать? А делать вот что: паттерн Transactional Outbox. Суть проще, чем кажется.

Как это работает, блядь:

  1. Пишем всё в одну транзакцию. Вместо того чтобы сначала в базу, а потом в очередь, мы сначала в базу, а сразу же, в той же самой транзакции, пишем запись о событии в специальную таблицу в этой же самой, мать её, базе. Называется она Outbox (или outbox_messages). Это как записка самому себе: «Не забудь отправить событие!».
  2. Отправляет отдельный мудак. Запускается фоновый процесс (джоба, воркер, отдельный сервис — не суть). Его задача — тупо мониторить эту таблицу Outbox, находить неотправленные записи, отправлять их в настоящую очередь (Kafka/RabbitMQ) и помечать как «отправлено».

Вот и вся магия. Если упало что-то после коммита — запись в аутбоксе уже есть, фоновый работяга её подберёт и отправит. Если упало до коммита — откатывается всё вместе, и заказа нет, и записки нет. Красота!

Пример на C#, чтобы не на словах:

Смотри, как это выглядит в коде. Представь, что у нас сервис заказов.

// Это наша напоминалка в базе
public class OutboxMessage
{
    public Guid Id { get; set; } // Уникальный айдишник, чтобы два раза не отправить
    public DateTime OccurredOn { get; set; } // Когда событие случилось
    public string Type { get; set; } // Тип события, например, "OrderCreated"
    public string Payload { get; set; } // Само событие в JSON
    public DateTime? ProcessedDate { get; set; } // Когда отправили (null = ещё не отправляли)
}

// А вот так этим пользуются
public async Task PlaceOrder(OrderDetails details)
{
    // Открываем транзакцию. Всё или ничего!
    using var transaction = await _dbContext.Database.BeginTransactionAsync();

    try
    {
        // 1. Сохраняем сам заказ (бизнес-данные)
        var order = new Order { ... };
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync();

        // 2. А ТЕПЕРЬ, ВНИМАНИЕ, СЕКРЕТНЫЙ ИНГРЕДИЕНТ!
        // В ЭТОЙ ЖЕ ТРАНЗАКЦИИ пишем напоминалку в аутбокс.
        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            OccurredOn = DateTime.UtcNow,
            Type = "OrderCreated",
            // Сериализуем событие в JSON
            Payload = JsonSerializer.Serialize(new 
            { 
                OrderId = order.Id,
                Total = order.Total,
                Items = details.Items
            })
        };
        _dbContext.OutboxMessages.Add(outboxMessage);

        // Сохраняем и то, и другое. ОДНИМ коммитом.
        await _dbContext.SaveChangesAsync();
        await transaction.CommitAsync(); // Вот теперь всё точно записалось
    }
    catch
    {
        // Если что-то пошло не так — откатываем ВСЁ. И заказ, и сообщение.
        await transaction.RollbackAsync();
        throw;
    }
    // А событие в очередь отправит наш фоновый воркер, который шаркает по таблице Outbox.
}

Почему это охуенно:

  • Не потеряется. Даже если сервис накроется медным тазом сразу после коммита — запись в аутбоксе уже в базе лежит. Фоновый процесс её рано или поздно увидит и отправит.
  • Консистентность на уровне. Состояние базы и факт «событие должно быть» — теперь неразрывны. Либо всё, либо нихуя.
  • Быстро. Основной бизнес-метод не ждёт, пока отработает сетевой вызов к брокеру. Он просто пишет в свою локальную базу и идёт дальше. Отправка — проблема фонового работяги.

Нюансы, конечно, есть:

Опрашивать таблицу SELECT * FROM outbox_messages WHERE processed_date IS NULL — это просто, но для хайлоада может быть тяжеловато. Умные дяди для этого используют Change Data Capture (CDC) — типа Debezium или встроенные фичи баз данных (типа Change Tracking в SQL Server), которые сами уведомляют о новых записях. Но это уже для больших и сложных систем, а начинать можно с обычного поллинга.

Короче, паттерн — огонь. Если делаешь распределённую систему и тебе важна надёжная отправка событий — бери на вооружение, не прогадаешь.