Ответ
Transactional Outbox — это паттерн надежной доставки сообщений, который решает проблему двойной записи (dual write). Проблема возникает, когда нужно атомарно изменить состояние в базе данных и опубликовать событие в брокере сообщений (например, Kafka или RabbitMQ).
Если делать это последовательно, система становится ненадежной:
- Запись в БД успешна, а отправка в брокер — нет. Результат: состояние изменилось, но никто об этом не узнал.
- Отправка в брокер успешна, а запись в БД — нет. Результат: разослано ложное событие.
Как работает паттерн:
- В той же базе данных, где хранятся основные данные, создается специальная таблица
outbox(почтовый ящик). - Изменение данных и запись события в таблицу
outboxпроисходят в рамках одной транзакции БД. - Отдельный асинхронный процесс (называемый Relay или Publisher) периодически опрашивает таблицу
outbox. - Обнаружив новые события, Relay пытается доставить их в брокер сообщений.
- После успешной доставки 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. Суть проще, чем кажется, блядь.
- Берёшь свою основную базу, где заказы лежат, и создаёшь в ней ещё одну табличку,
outbox. Типа почтовый ящик, куда кидаешь конвертики-события. - Когда делаешь операцию (типа
CreateOrder), ты в рамках ОДНОЙ, мать его, транзакции делаешь две вещи:- Пишешь заказ в основную таблицу
orders. - И тут же, сука, пишешь событие «OrderCreated» в эту самую таблицу
outbox. Всё в одном транзакционном конверте, блядь!
- Пишешь заказ в основную таблицу
- Если что-то пошло не так — откатывается всё вместе, нихуя нигде не остаётся. Красота!
- А потом, отдельно, у тебя бегает какой-нибудь фоновый демон — 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) уже развозит их по адресам. Архитектурно чисто, блядь.
Вот и весь сказ, а то некоторые думают, что это какая-то ракетостроительная магия. Обычная, хитрая жопа, которая спасает от пиздеца в распределённых системах.