В предыдущей статье я описал стандартный транзакционный паттерн 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 без магии: практический разбор для питонистов». Записаться
