Привет! Меня зовут Серёжа. Я работаю Java‑разработчиком. В этой статье хочу поделиться своим первым опытом реализации паттерна «outbox».

Решил я это сделать по нескольким причинам:

  1. Зафиксировать для себя «на бумаге» архитектурное решение и полученный опыт.

  2. Поделиться знаниями и идеями с теми разработчиками, кому только предстоит впервые выполнить похожую задачу.

  3. Получить от профессионалов комментарии и замечания по улучшению моего решения.

Итак, давайте начнём!

Что вообще за outbox и зачем он нужен?

Для начала давайте разберёмся, что же это за паттерн такой и какую задачу он решает. Для конкретного примера вспомним обычную электронную почту: у нас есть отправитель, получатель и сами письма. Что же тут может быть сложного? Отправителю нужно просто на адрес получателя отправить письмо. Однако давайте попробуем добавить в нашу беззаботную жизнь некоторую нагрузку и проблемы. Посмотрим, на какие вопросы мы сможем наткнуться:

  1. Письмо ушло на отправку, но случился сетевой сбой, и до получателя так ничего и не дошло. Сохранилась ли у отправителя информация о письме, чтобы без проблем совершить переотправку?

  2. На отправителя в один момент упала задача по отправке сразу 1000 писем. Как в таком случае отправить все письма и ничего не растерять?

  3. На отправителя начинает постепенно расти всё большая и большая нагрузка. Как тогда не запутаться и отследить, какое письмо успешно доставилось до получателя, какое нужно переотправить, какое находится в процессе отправки, а какое только ожидает своей очереди?

Как минимум из перечисленных подводных камней становится понятно, что подход в лоб, то есть «отправитель → получатель» без вспомогательных и промежуточных инструментов, нам не подойдёт. В помощь коммуникации нужно какое‑то решение, которое бы:

  1. Сохраняло данные и метаданные сообщения на случай переотправки.

  2. Не боялось большого количества сообщений и гарантированно отправляло получателю каждое из них.

  3. Для каждого сообщения сохраняло статус, чтобы можно было без проблем проводить своевременный анализ и на его основе принимать решения.

На ум приходит следующая идея: просто решим проблему наличием брокера сообщений. И да, данный инструмент устранит большую часть вышеперечисленные проблем: сообщения не потеряются, нагрузку можно разгрузить очередью, а недоставленные письма будут повторно обработаны. Однако что, если нам нужно для каких‑то целей, например, некоторое время хранить отправленные письма или быстро и просто отвечать на вопрос «а на какой стадии сейчас находится именно вот это письмо?». В этом случае брокер сообщений уже может быть не так удобен. Тогда на ум приходит вторая идея: создать табличку в базе данных, в которую перед отправкой сохранить все данные письма, а затем периодически ходить в эту табличку каким‑то фоновым процессом, чтобы принимать разные решения: какое письмо нужно отправить в первый раз, какое нужно переотправить, а какое трогать не нужно, потому что оно уже успешно отправилось в прошлый раз и так далее

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

Разница решений с outbox и без него
Разница решений с outbox и без него

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

Задача

Наша команда создаёт новый бэкенд для одного из клиентских сервисов. Одной из обязанностей данной системы является отправка email клиентам. Да, получилось совсем как в примере, на котором я объяснял логику самого паттерна) Из этого контекста и родилась моя незамысловатая задача: уметь гарантированно отправлять письма с вложениями клиентам на почту. Важным уточнением будет то, что саму отправку сообщения на почту получателя производит другой внешний сервис, а мне лишь нужно гарантированно передать всю необходимую информацию.

Для ещё лучшего понимания контекста задачи — кратко расскажу о нашей архитектуре: бэкенд состоит из оркестратора и микросервисов, с которыми он общается. Соответственно, стандартный процесс обработки запроса выглядит так: фронт → оркестратор → внутренний микросервис → внешний сервис → внутренний микросервис → оркестратор → фронт.

И напоследок поделюсь стеком, который был у меня на руках для этой задачи:

  1. Язык: Java 25.

  2. Фреймворк: Spring 6.2+, Spring Boot 3.5+.

  3. Система сборки: Maven.

  4. БД: PostgreSQL.

Решение

Из ТЗ сразу становится понятно, что, если мы хотим давать гарантию отправки писем во внешний сервис, то нужно реализовать тот самый outbox. Это, в свою очередь, означает, что нужно сделать табличку для хранения информации по письмам, а также механизмы, которые будут постоянно с этой табличкой взаимодействовать.

Уточнение про гарантию

Я уже много раз сказал слово «гарантия» по отношению к данному паттерну, но что оно означает здесь на самом деле? Давайте порассуждаем: раз мы храним данные о нашем письме, и если они корректны, если работают все системы и инструменты для общения, то никто нам не мешает, как минимум, один раз точно передать сообщение. Однако можем ли мы говорить, что письмо гарантированно уйдёт к получателю только один раз? Скорее нет. В любом случае могут быть какие‑то баги в логике, может быть ситуация, что письмо успешно отправилось, а из‑за падения сети не произошла смена статуса на «SUCCESS_SENT»; при распределённой системе могут быть ошибки в работе с транзакциями и так далее. Здесь следует понимать, что именно получатель на своей стороне должен иметь механизм дедупликации, чтобы случайно не обработать один и тот же запрос несколько раз.

Итог здесь такой: в данном случае под гарантией в outbox подразумевается режим «at‑least‑once», а не «exactly_once».

Теперь после декомпозиции задачи мы получаем следующие 4 требования к нашему решению:

  1. Сервис должен уметь принимать запрос на отправку письма и сразу сохранять его к себе в табличку «outbox».

  2. В сервисе должен быть шедулер, то есть тот самый фоновый процесс, который регулярно будет ходить в таблицу «outbox» и забирать те письма, которые нужно отправить.

  3. В сервисе должен быть шедулер, который будет ходить в эту же табличку и удалять успешно отправленные сообщения, чтобы чистить место в базе.

  4. Для писем должна быть разработана статусная модель, чтобы в каждый момент времени понимать, что мы делаем. Для своей задачи мне оказались необходимы и достаточны следующие статусы:

Статус

Описание

NOT_YET_SENT

Письмо уже попало в таблицу «outbox», но ещё ни разу не было отправлено.

IN_PROCESS

Письмо уже забрал шедулер из базы, но сама отправка ещё не осуществилась.

REQUIRED_RESEND

В прошлый раз письмо отправить по какой‑то причине не получилось, поэтому нужно повторить попытку.

FAILED_NOT_RETRYABLE

После прошлой попытки отправки письмо получило такой тип ошибки, из‑за которой повторную отправку нет смысла осуществлять. Например, это может означать, что для получателя указан некорректный формат email. Как результат — помечаем, что данное письмо требует ручного анализа и обработки.

FAILED_AFTER_SEVERAL_RESEND

Письмо пробовали отправлять n раз, но успеха так и не случилось. Помечаем, что оно требует ручного анализа и обработки.

SUCCESS_SENT

Письмо успешно отправилось.

Учитывая весь контекст и план решения задачи, у нас получается следующая архитектура:

архитектура решения с outbox
архитектура решения с outbox

Предлагаю разобрать каждый из шагов детальнее:

  1. Оркестратор → интегратор для отправки сообщений. На этом первом шаге нужно просто принять запрос в микросервис‑интегратор для отправки письма. Тут же дополнительно я реализовал фильтр, который проводит валидацию email получателя на корректность и проверяет, что на отправку уходит разрешённый тип документа, а также разрешённые типы вложений к письму.

  2. Интегратор для отправки сообщений → outbox. Здесь для начала мы обогащаем запрос, например, сквозными идентификаторами, временными отметками и так далее, а после — сохраняем всю информацию в табличку. Это один из ключевых моментов системы, ведь теперь, когда данные письма зафиксированы, нам не страшны ни сетевые сбои, ни поломки на стороне принимающего внешнего сервиса, потому что мы всегда сможем сделать ретраи.

  3. Outbox ↔ шедулер для отправки сообщений. Тут необходимо реализовать шедулер, который будет регулярно ходить в базу, брать оттуда батч писем со статусами «NOT_YET_SENT», “REQUIRED_RESEND” и «IN_PROCESS», а затем отправлять их во внешний сервис. Если вам нужно вспомнить или узнать, как работать с шедулерами в Spring, то держите отличную статью на эту тему. Также на данном шаге важно в голове держать следующую информацию: раз мы работаем с базой, то, скорее всего, работаем и с транзакциями. Так вот, на данном шаге при работе нескольких транзакций возможна конкуренция за одни и те же записи. Лично я для решения этой потенциальной проблемы использовал в SQL‑запросе конструкцию «FOR UPDATE SKIP LOCKED», а также для только что полученных сообщений из базы меняю их статус на «IN_PROCESS» и указываю время так называемой аренды, чтобы другие транзакции не имели к ним доступа. Если время аренды истечёт, а письмо так и не перейдёт в статус «SUCCESS_SENT», то считаем его зависшим и следующей транзакцией возьмём в обработку заново.

  4. Шедулер для отправки сообщений → сервис отправки сообщений. Здесь просто в цикле прохожусь по каждому сообщению из выбранного батча, с помощью мапперов формирую запрос для внешней системы и делаю отправку. После отправки каждого письма я для себя «карандашиком» перевожу их в статус «SUCCESS_SENT». Да, на этом этапе мы ещё не на 100% уверены, что письмо точно дошло до получателя, ведь могут случиться какие‑то помехи на стороне внешнего сервиса. Как одно из решений данной проблемы — поставить на мониторинг очередь внешнего провайдера, чтобы либо убедиться в успешности окончательной доставки, либо начать предпринимать какие‑то меры. Также отмечу, что на данном этапе могут возникнуть ещё две потенциальные технические проблемы. Во‑первых, важно помнить о том, что ни в коем случае нельзя допустить такого архитектурного решения, где запрос во внешнюю систему происходит внутри транзакции. Сеть обязательно упадёт в самый неподходящий момент и непонятно насколько → транзакции из‑за этого будут также непонятно сколько висеть → пул соединений к базе быстро исчерпается → новые запросы будут долго ждать своей обработки. Во‑вторых, раз мы на данном этапе проходимся циклом по батчу сообщений, возможна проблема N+1 запросов. Лично в моём случае таблица одна, опасаться нечего, но нужно быть осторожным, если таблиц будет несколько.

  5. Шедулер для удаления успешно отправленных сообщений ↔ outbox. Понятно, что с течением времени количество писем со статусом «SUCCESS_SENT» будет всё расти и расти. Как минимум — мы надеемся на это. Соответственно, будет расти и размер базы. Решение этой проблемы простое: берём и удаляем эти записи. Но не сразу. Нужно, чтобы к успешно отправленным письмам можно было вернуться. Как пример — расследование инцидентов. Если что‑то и произойдёт, то мы будем иметь возможность обратиться к исходному пейлоаду. Как итог — держим записи в таблице после успешной доставки, например, 7 дней, а затем удаляем.

Дополнения к решению

В этом разделе я решил поделиться другими доработками, которые не относятся напрямую к паттерну «outbox», но тем не менее сделали моё решение более полноценным. Думаю, эти идеи и для других разработчиков дадут почву для размышления и доработок.

  1. Параметры ретраев. Тут речь идёт о следующем: сколько попыток мы выделяем на переотправку одного сообщения, какой размер батча сообщений будут выбирать шедулеры из базы, как часто шедулеры будут ходить в базу и так далее. Ответ здесь такой, что подбор осуществляется только эмпирически, так как очень сильно всё зависит от контекста, количества запросов и так далее. Поэтому путь проходит через пробы и ошибки: сказали, что шедулер ходит в базу каждые 10 секунд → увидели на тестах, что письмо доставляется слишком долго → обновили значение до 2 секунд → заметили, что много запросов совершается в базу вхолостую → увеличили паузу до 5 секунд → получили хороший результат для текущей нагрузки.

  2. Классификация ошибок при отправке и реагирование на них. Если при неуспешной отправке сообщения мы просто будем переводить его всегда в статус «REQUIRED_RESEND», то это, конечно, хорошо, но только на первых порах. Если задуматься, то желательно понимать — а почему вообще предыдущая отправка письма не осуществилась? Если, например, дело в сети, то, конечно же, мы не виноваты, и нужно попробовать отправить сообщение позже, когда сеть восстановится. Но дело же может быть и в том, что принимающая система не приняла запрос по причине непройденной валидации каких‑то полей — в этом случае просто бесполезно делать вторую, третью и четвёртую переотправки, ведь результат будет всегда один и тот же. Для решения этой проблемы и была придумана система классификации ошибок, где при неудаче мы смотрим на контекст и принимаем решение — нужно ли отправлять сообщение повторно или это не имеет смысла, а после этого переводим сообщение в соответствующий статус: «REQUIRED_RESEND» или «FAILED_NOT_RETRYABLE». Таким образом мы можем меньше нагружать как нашу систему, так и минимизировать кол‑во бесполезных запросов вовне.

  3. Динамически увеличивающаяся пауза при ретраях. Здесь ситуация следующая: если при неуспешных попытках отправки мы будем делать ретраи с данным сообщением, например, каждые 5 секунд, то вероятность того, что внешний сервис починится или наладится работа с брокером сообщений за такой небольшой промежуток времени, довольно мала. Поэтому здесь было предпринято следующее решение: перед каждой новой отправкой делать паузу, которая рассчитывается по формуле «кол‑во попыток отправки × n секунд/минут». И да, с одной стороны, при данном решении письмо может доставляться не так быстро до получателя, но лично в моём случае это и не требуется по бизнес‑процессу. Зато, с другой стороны, мы даём больше свободы на разрешение проблем с независящими от нас сервисами и инструментами.

  4. Механизм переключения для нескольких провайдеров доставки. Эта доработка сильно привязана к контексту, но всё равно полезна и в общем случае. Если представить, что внешний провайдер доставки сообщений сломается по какой‑то причине, то непонятно, насколько долго мы остаёмся без данного функционала. Для подстраховки я добавил интеграцию и со вторым доставщиком. Как результат: во‑первых, мой сервис перестал зависеть от конкретной реализации внешней системы; во‑вторых, в случае необходимости мне не составит особого труда провести интеграцию и с третьей системой; ну и, в‑третьих, сам процесс доставки сообщения стал менее уязвим от внешних обстоятельств.

  5. Мониторинг кол‑ва сообщений по каждому из статусов. Без observability невозможно увидеть ни инциденты, ни аномалии, ничего. В первую очередь, конечно же, настраиваются RED‑метрики как для самого сервиса, так и для внешних систем. О том, что это такое, очень понятно и подробно описано вот в этой статье. Однако куда не менее важной метрикой в моём случае стало количество сообщений в каждом из статусов. С её помощью в каждый момент времени я понимаю, например, сколько писем успешно отправилось, сколько проблемных писем нужно обязательно разобрать руками и насколько эффективно, а также своевременно проходит очистка базы от успешно отправленных сообщений.

А что, если не outbox?

Подходя к концу статьи, разумно задать следующий вопрос: а можно ли решать подобную задачу другими способами? Есть ли иные паттерны или готовые инструменты? И да, конечно же, есть. Вот некоторые из них:

  1. Готовые outbox‑решения. В первую очередь стоит упомянуть о том, что существуют готовые реализации паттерна «outbox», которые предоставляют тот же функционал, что и самописное решение, но с доступом «из коробки». Вот один из таких примеров. Сам этот инструмент или подобные ему я пока что не пробовал, никакие комментарии дать не могу, но обязательно доберусь до этого в будущем.

  2. Push‑системы. Здесь идея базируется на следующей логике: при реализации паттерна «outbox» мы сами периодически ходим в базу, чтобы забрать сообщения, то есть делаем так называемый поллинг. Понятное дело, что без дополнительного SQL‑запроса мы не знаем, есть ли сейчас подходящие сообщения для нас или нет. Так вот, иной подход заключается в том, чтобы от самой базы поступал сигнал о наличии нового сообщения, которое можно брать в обработку. Такие инструменты называются пуш‑системами. Известный пример — Debezium. Тут для меня аналогично: про сам инструмент наслышан, но пока что опыта взаимодействия с ним не имею.

Вот такая сравнительная таблица у меня получилась по перечисленным инструментам:

Подход

Как это работает

Плюсы

Минусы

Самописный outbox

Таблица в БД + свои шедулеры

Полный контроль над статусной моделью и логикой ретраев

Нужно писать и поддерживать шедулер, статусную модель, чистку

Готовые outbox‑библиотеки

Та же идея, но реализация уже написана за нас

Меньше своего кода и багов

Нужно разобраться в чужих абстракциях, меньше гибкости под нестандартные кейсы

Push‑системы

Демон читает журнал транзакций БД и сам публикует изменения

Не нагружает БД лишними запросами, снимает необходимость в шедулере

Нужно поднимать и поддерживать отдельную инфраструктуру — заметный рост сложности в поддержке

Заключение

В данной статье мы разобрали мою первую попытку самописной реализации паттерна «outbox», а также тот вид задач, который он решает. Как я и говорил ранее, буду открыт к критике от более опытных специалистов, чтобы посмотреть на своё решение с другой точки зрения.

Напоследок делюсь полезными ссылками:

  1. Ещё раз кратко и по делу про outbox, но другими словами.

  2. Мой телеграм‑канал, где я делюсь своими знаниями по разработке.

Спасибо! До встречи!