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

Ответ

Transactional Outbox — это паттерн надежной доставки сообщений, который решает проблему двойной записи (dual write). Проблема возникает, когда нужно атомарно изменить состояние в базе данных и опубликовать событие в брокере сообщений (например, Kafka или RabbitMQ).

Если делать это последовательно, система становится ненадежной:

  • Запись в БД успешна, а отправка в брокер — нет. Результат: состояние изменилось, но никто об этом не узнал.
  • Отправка в брокер успешна, а запись в БД — нет. Результат: разослано ложное событие.

Как работает паттерн:

  1. В той же базе данных, где хранятся основные данные, создается специальная таблица outbox (почтовый ящик).
  2. Изменение данных и запись события в таблицу outbox происходят в рамках одной транзакции БД.
  3. Отдельный асинхронный процесс (называемый Relay или Publisher) периодически опрашивает таблицу outbox.
  4. Обнаружив новые события, Relay пытается доставить их в брокер сообщений.
  5. После успешной доставки Relay помечает событие в outbox как обработанное (или удаляет его).

Пример на Go:

func CreateOrder(db *sql.DB, order Order) error {
    tx, err := db.Begin()
    if err != nil { return err }
    defer tx.Rollback() // Безопасный откат по умолчанию

    // 1. Сохраняем заказ в основной таблице
    _, err = tx.Exec("INSERT INTO orders...", order.ID, order.Details)
    if err != nil { return err }

    // 2. Сериализуем событие и записываем его в outbox-таблицу
    eventPayload, _ := json.Marshal(map[string]interface{}{"order_id": order.ID})
    _, err = tx.Exec(
        "INSERT INTO outbox (event_type, payload) VALUES (?, ?)", 
        "OrderCreated", 
        eventPayload,
    )
    if err != nil { return err }

    // 3. Коммитим транзакцию. Только после этого изменения станут видны
    return tx.Commit()
}

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

  • Атомарность и консистентность: Гарантирует, что событие будет создано тогда и только тогда, когда основная операция с данными успешна.
  • Отказоустойчивость: Если брокер сообщений временно недоступен, события остаются в outbox и будут отправлены позже, когда связь восстановится. Потеря сообщений исключена.
  • Разделение ответственностей: Основной сервис отвечает только за бизнес-логику, а доставкой сообщений занимается отдельный компонент (Relay).

Ответ 18+ 🔞

А, слушай, смотри, вот эта вся хуйня про Transactional Outbox — это, блядь, такая палочка-выручалочка, когда тебе надо и в базу записать, и в Кафку пиздануть событие, а сделать это одновременно нихуя не получается.

Представь себе, сука, классический пиздец: твой сервис создаёт заказ в PostgreSQL, а потом сразу пытается орать в очередь: «Эй, все, заказ создан!». И тут — бац! — база ответила «ок», а Кафка легла. И что? Заказ есть, а события нет. Все остальные сервисы нихуя не знают и сидят, как дауны, ждут. Либо наоборот: в Кафку отправил, а база потом сказала «ой, всё, ошибка constraint». И пошло событие в мир про заказ, которого нихуя нет. Классический dual write, ёпта, пиздец и разъеб системы.

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

  1. Берёшь свою основную базу, где заказы лежат, и создаёшь в ней ещё одну табличку, outbox. Типа почтовый ящик, куда кидаешь конвертики-события.
  2. Когда делаешь операцию (типа CreateOrder), ты в рамках ОДНОЙ, мать его, транзакции делаешь две вещи:
    • Пишешь заказ в основную таблицу orders.
    • И тут же, сука, пишешь событие «OrderCreated» в эту самую таблицу outbox. Всё в одном транзакционном конверте, блядь!
  3. Если что-то пошло не так — откатывается всё вместе, нихуя нигде не остаётся. Красота!
  4. А потом, отдельно, у тебя бегает какой-нибудь фоновый демон — Relay, Publisher, называй как хочешь. Его работа — тупо мониторить таблицу outbox, выгребать оттуда неотправленные события и тыкать их в брокер (Kafka, RabbitMQ, etc.). Отправил — пометил как отправленное или вообще удалил. Если брокер сдох — демон подождёт и попробует ещё раз. События-то в надёжном месте лежат, никуда не денутся.

Вот смотри, как это выглядит в коде, на голом Go, без всяких наворотов:

func CreateOrder(db *sql.DB, order Order) error {
    // Начинаем транзакцию, всё или ничего, блядь
    tx, err := db.Begin()
    if err != nil { return err }
    defer tx.Rollback() // Подстраховка на случай пиздеца

    // 1. Ложим заказ в основную таблицу
    _, err = tx.Exec("INSERT INTO orders (id, details) VALUES (?, ?)", order.ID, order.Details)
    if err != nil { return err }

    // 2. А теперь, не вылезая из транзакции, хуячим событие в outbox
    eventPayload, _ := json.Marshal(map[string]interface{}{"order_id": order.ID})
    _, err = tx.Exec(
        "INSERT INTO outbox (event_type, payload) VALUES (?, ?)", 
        "OrderCreated", 
        eventPayload,
    )
    if err != nil { return err }

    // 3. И только если всё прошло — коммитим разом!
    return tx.Commit()
}

Видишь? Никаких промежуточных состояний. Или заказ и событие о нём сохранены вместе, или нихуя. А уж демон потом, в своём темпе, вытащит это событие из outbox и доставит куда надо. Если Кафка в моменте сдохла — ну и хуй с ней, повторит попытку.

Чем это, блядь, хорошо?

  • Атомарность, ёпта! Нет этой ебаной проблемы «записал в одно место, а в другое — нет». Всё решает транзакция базы данных, проверенная временем хуйня.
  • Живучесть. Брокер упал? Похуй. События в надёжном хранилище (в той же БД) дожидаются своего часа. Ничего не теряется.
  • Разделение труда. Основной сервис не парится о доставке, он просто кидает записки в почтовый ящик. А отдельный курьер (Relay) уже развозит их по адресам. Архитектурно чисто, блядь.

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