Зачем нужен Outbox Pattern

Ответ

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

Проблема, которую решает Outbox:

В распределенных системах часто возникает необходимость изменить данные в базе данных и одновременно отправить сообщение (например, в брокер сообщений, такой как Kafka или RabbitMQ). Если эти две операции выполняются отдельно, существует риск несогласованности: либо данные изменятся, но сообщение не отправится (например, из-за сбоя сети или брокера), либо сообщение отправится, но транзакция с данными откатится.

Суть паттерна:

  1. Атомарная запись: Вместо прямой отправки сообщения, оно записывается в специальную таблицу outbox в той же транзакции базы данных, что и изменение бизнес-данных. Это гарантирует, что либо оба действия (изменение данных и запись в outbox) успешно завершатся, либо ни одно из них.
  2. Фоновая отправка: Отдельный фоновый процесс (например, поллер или механизм Change Data Capture - CDC) периодически считывает сообщения из таблицы outbox, отправляет их в брокер сообщений и помечает как отправленные (или удаляет).

Пример на Go (псевдокод):

func CreateOrder(order Order) error {
    tx := db.Begin() // Начинаем транзакцию
    if tx.Error != nil {
        return tx.Error
    }

    // 1. Сохраняем бизнес-данные
    err := tx.Create(&order).Error
    if err != nil {
        tx.Rollback()
        return err
    }

    // 2. Создаем сообщение для Outbox в той же транзакции
    outboxMsg := OutboxMessage{
        Topic:   "order.created",
        Payload: order.ToJSON(), // Сериализованные данные заказа
        Status:  "pending",
    }
    err = tx.Create(&outboxMsg).Error
    if err != nil {
        tx.Rollback()
        return err
    }

    // 3. Коммитим транзакцию. Если здесь произойдет сбой, ни данные, ни сообщение не будут сохранены.
    return tx.Commit()
}

// Фоновый процесс (пример логики)
func ProcessOutbox() {
    // Периодически читаем неотправленные сообщения
    messages := db.Where("status = ?", "pending").Find(&[]OutboxMessage{}).Error
    for _, msg := range messages {
        err := messageBroker.Publish(msg.Topic, msg.Payload)
        if err == nil {
            // Если успешно отправлено, помечаем как отправленное
            db.Model(&msg).Update("status", "sent")
        } else {
            // Логируем ошибку, сообщение останется в статусе 'pending' для повторной попытки
        }
    }
}

Плюсы Outbox Pattern:

  • Атомарность: Гарантирует, что изменение данных и публикация сообщения происходят как единая атомарная операция.
  • Надежность: Предотвращает потерю сообщений даже при сбоях системы или брокера сообщений.
  • At-Least-Once Delivery: Обеспечивает, что сообщение будет отправлено как минимум один раз. Важно: Потребители сообщений должны быть идемпотентными, чтобы корректно обрабатывать возможные дубликаты, так как фоновый процесс может повторно отправить сообщение в случае временных сбоев.
  • Простота отладки: Все связанные данные (бизнес-данные и сообщения) находятся в одной базе данных, что упрощает отладку и мониторинг.

Минусы Outbox Pattern:

  • Дополнительная таблица в БД: Требует создания и управления дополнительной таблицей outbox.
  • Фоновый обработчик: Необходимость реализации и поддержки отдельного фонового процесса для отправки сообщений.
  • Задержка: Сообщения отправляются не мгновенно, а с небольшой задержкой, зависящей от частоты работы фонового обработчика.

Ответ 18+ 🔞

А, слушай, смотри, вот есть такая классическая засада в распределённых системах. Представь себе: ты в базе данных заказ создал, и тут же надо в Кафку пискнуть, мол, «эй, все, заказ создан, обрабатывайте!». И вроде всё просто, да? Ну, код написал: сначала INSERT в БД, потом Publish в брокер. И живёшь спокойно.

А потом наступает пиздец.

Сценарий первый: заказ в базу записался, а Кафка в этот момент легла — сеть упала, нода перезагрузилась, хуй с горы упал. Сообщение не ушло. Система в неконсистентном состоянии: данные есть, а событие о них — нет. Потребители нихуя не знают про заказ.

Сценарий второй: сообщение в Кафку улетело, а потом транзакция к базе откатилась — там, проверка какая-то не прошла, констрейнт сработал. Получается, событие есть, а данных, блядь, нет. Потребители начинают обрабатывать хуйню.

И вот ты сидишь, чешешь репу, и думаешь: «Ну ёпта, как же это по-человечески сделать? Чтобы либо всё, либо нихуя?».

И тут на сцену выходит, блядь, Outbox Pattern. Паттерн такой, хитрая жопа.

Суть его, внатуре, проста как три копейки:

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

Вот смотри, как это в коде выглядит (псевдокод на Go):

func CreateOrder(order Order) error {
    tx := db.Begin() // Запулили транзакцию
    if tx.Error != nil {
        return tx.Error
    }

    // 1. Кладём заказ в основную таблицу
    err := tx.Create(&order).Error
    if err != nil {
        tx.Rollback()
        return err
    }

    // 2. А ТУТ, ВНИМАНИЕ, ФИШКА! В той же транзакции пишем в outbox.
    outboxMsg := OutboxMessage{
        Topic:   "order.created",
        Payload: order.ToJSON(), // Сериализованный заказ
        Status:  "pending",
    }
    err = tx.Create(&outboxMsg).Error
    if err != nil {
        tx.Rollback() // Откатываем ВСЁ, если outbox не записался
        return err
    }

    // 3. И только теперь коммитим. Всё или ничего.
    return tx.Commit()
}

// А это где-то в другом месте работает наш фоновый труженик
func ProcessOutbox() {
    for {
        // Находим неотправленное
        var messages []OutboxMessage
        db.Where("status = ?", "pending").Find(&messages)

        for _, msg := range messages {
            err := messageBroker.Publish(msg.Topic, msg.Payload)
            if err == nil {
                // Ура, отправил! Помечаем.
                db.Model(&msg).Update("status", "sent")
            } else {
                // Бля, не вышло. Ну ок, попробуем в следующий раз.
                log.Println("Не удалось отправить:", err)
            }
        }
        time.Sleep(5 * time.Second) // Спим, чтобы не доёбывать базу
    }
}

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

  • Атомарность, ёпта! Данные и сообщение — это теперь единое целое в рамках транзакции. Согласованность на уровне базы.
  • Надёжность овердохуища. Даже если Кафка сдохла на неделю, твои сообщения терпеливо ждут в табличке. Как только она оживёт — работничек их отправит.
  • At-Least-Once Delivery по умолчанию. Сообщение гарантированно уйдёт. Но, внимание, ебаный рот! Это значит, могут быть дубли. Поэтому потребители обязаны быть идемпотентными — чтобы десять одинаковых событий обработать как одно.
  • Отладка — просто песня. Всё лежит в одной базе. Глянул в outbox — и сразу видишь, что должно было уйти, а что уже ушло.

Ну и подводные камни, куда ж без них:

  • Лишняя таблица в БД. Теперь надо за ней следить, индексы строить, может, даже чистить старые записи.
  • Ещё один сервис/процесс. Этот фоновый отправщик — его тоже надо писать, деплоить и мониторить. Если он сдохнет, сообщения так и будут висеть в pending.
  • Задержка, блядь. Сообщение уйдёт не мгновенно после коммита, а когда поллер проснётся и отработает. Обычно это секунды, но для каких-то супер-реалтайм систем может быть критично.

Вот такой вот паттерн. Не серебряная пуля, но от многих граблей спасает. Главное — понять, когда он тебе реально нужен, а когда ты просто архитектурный мазохист.