В предыдущей статье я описал стандартный транзакционный паттерн outbox — практичное решение, которое гарантирует: операции с базой данных и отправка событий либо успешно выполняются вместе, либо не выполняются вовсе. Это изящный обходной путь для того факта, что распределённые системы «из коробки» не поддерживают распределённые транзакции. Используя outbox-таблицу и фоновый процесс-реле, мы можем держать сервисы развязанными, сохраняя при этом гарантии согласованности.

Но когда это реализовано — и особенно когда попадает в продакшн — начинают проявляться шероховатости.

Поллинг добавляет в систему задержку. Даже если интервал опроса короткий, скажем 50 мс или 100 мс, всё равно остаётся разрыв между моментом, когда бизнес-операция завершилась, и моментом, когда соответствующее событие опубликовано. И самое неприятное: эта задержка ещё и непредсказуема. Она зависит от момента опроса, нагрузки на систему, времени ответа базы данных и многого другого. Иногда это 50 мс, иногда 2 секунды, иногда и больше. Кроме того, регулярный поллинг создаёт нагрузку на базу. На вид это может казаться «ну подумаешь, один маленький SELECT время от времени». Но несколько реплик, которые опрашивают базу, дорогие блокировки, и даже простой «SELECT» начинают отъедать весьма заметную долю CPU, I/O и блокировок базы данных. В одной системе, с которой я работал и где использовалась Amazon RDS, это стало причиной довольно неприятного инцидента. И наконец — мониторинг. Стандартная реализация транзакционного outbox требует хорошего мониторинга. Многие вспоминают об этом слишком поздно. Некоторые библиотеки дают мониторинг «из коробки», но далеко не все.

Итак, какие у нас есть варианты улучшить производительность стандартной реализации транзакционного outbox? Я называю это «оптимистичной отправкой» (возможно, название не совсем корректное, но мне нравится — отсылка к оптимистичным блокировкам).

Начнём с небольшого мысленного эксперимента. Допустим, база данных AWS RDS в одном экземпляре (single-instance) гарантирует 99,5% доступности, а AWS SQS — 99,9%. Это не какие-то запредельные цифры, в AWS или Azure/GCP можно добиться и более высоких показателей доступности. Тем не менее какова будет совокупная доступность RDS и SQS в таком случае? Чуть выше 99,4% (0.999 * 0.995). Это значит, что можно ожидать отказ примерно в 0,6% времени. Это около 1 минуты 26 секунд в день и 10 минут в неделю. Не так уж страшно, правда? На практике всё даже лучше: скорее можно ожидать отдельных сбоев на уровне отдельных транзакций/событий в течение дня.

И что это нам даёт? Давайте немного сдвинем мышление — не архитектуру. Мы по-прежнему пишем события в outbox-таблицу и по-прежнему держим запущенным процесс-реле — ничего критичного не выкидываем. Однако после коммита транзакции мы сразу же пытаемся отправить событие в брокер:

  • Если получилось? ✅ Мы «подчищаем» запись в outbox (либо удаляем, либо помечаем как отправленную).

  • Если не получилось? ❌ Ничего не делаем. Процесс-реле подхватит её позже — ровно так же, как делал всегда.

Outbox превращается из очереди «по умолчанию» в резервную очередь, а процесс-реле становится запасным механизмом на случай отказа. То есть мы не убираем процесс-реле, у него просто становится меньше работы. Он всё ещё опрашивает базу данных, но гораздо реже — и ищет только сообщения, которые не удалось отправить в течение заданного времени, скажем 30 секунд. Также можно спокойно оставить один процесс-реле, вместо того чтобы пытаться его бесконечно улучшать. В идеальном и наиболее вероятном сценарии процесс-реле вообще ничего не отправляет.

Вот высокоуровневый поток для «счастливого пути» и для резервного сценария:

И вот простой пример на C#:

/// <summary>
/// Создаёт пользователя и пытается «оптимистично» отправить событие `UserCreated`.
/// </summary>
public async Task CreateUserAsync(UserDto input)
{
    // Create domain object
    var user = new User { Id = Guid.NewGuid(), Email = input.Email };
    _dbContext.Users.Add(user);


    // Prepare outbox event alongside domain entity
    var userCreatedEvent = new UserCreated { user.Id, user.Email }
    var outboxEvent = new OutboxEvent
    {
        Id = Guid.NewGuid(),
        EventType = userCreatedEvent.GetType().Name,
        Payload = JsonSerializer.Serialize(userCreatedEvent),
        CreatedAt = DateTime.UtcNow
    };
    _dbContext.OutboxEvents.Add(outboxEvent);
    
    // Atomic insert of domain state and outbox event to database
    await _dbContext.SaveChangesAsync();

    try
    {
        // Attempt to publish right after commit
        await _eventPublisher.PublishAsync(outboxEvent);

        // Mark sent event for deletion
        _dbContext.OutboxEvents.Remove(outboxEvent);

        // Send delete command to database
        await _dbContext.SaveChangesAsync();
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Optimistic send failed. Relay will handle it.");
    }
}

Теперь давайте разберём возможные проблемы такого подхода:

Поскольку большинство событий отправляется сразу, а часть задерживается, порядок доставки не гарантируется. Если порядок важен, потребители должны уметь с этим работать, либо такая реализация не подходит для конкретной задачи. Если честно, стандартная реализация тоже «из коробки» не гарантирует доставку строго по порядку.

Если в резервной логике задано «слишком короткое» допустимое окно задержки, в системе может появиться много дублей. Но, как и при любой доставке по принципу at-least-once, потребитель должен уметь их обрабатывать.

Наблюдаемость всё равно нужна. От неё никуда не деться, но в данном случае она может быть чуть проще.

Плюс этого подхода в том, что ничего не нужно перестраивать: достаточно немного улучшить «счастливый»/дефолтный путь. Если вы уже используете что-то вроде MediatR или другую реализацию сквозной функциональности (cross-cutting concerns) для отправки outbox-событий, это будет небольшая доработка всего в нескольких местах кодовой базы.


Научиться работе с лучшими инструментами по разработке микросервисной архитектуры можно на практическом курсе под руководством экспертов. Готовы к серьезному обучению? Пройдите вступительный тест.

Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:

  • 16 февраля, 20:00. «Реализация паттерна CQRS с использованием библиотеки MediatR и ASP.NET CORE». Записаться

  • 18 февраля, 20:00. «Паттерны системы декомпозиции на микросервисах — как проектировать масштабируемую архитектуру». Записаться

  • 19 февраля, 20:00. «Kafka без магии: практический разбор для питонистов». Записаться