Transactional Outbox template простыми словами

И мало кто хочет признаться, что в 2026 году он сидит на монолите с Java 8 в бэке. Но есть в этих ваших микросервисах (как и в любой распределённой системе) одна неприятная штука — согласованность данных. Вернее, их несогласованность.
Нет-нет да и разъедутся наборы каких-нибудь транзакций в двух смежных системах.
Знакомо? Наверняка. Вот об этом сегодня и поговорим.
Точнее, поговорим об одном из популярнейших решений проблем распределённых транзакций —шаблоне Transactional Outbox. Пройдём путь от поделки на коленке до prod-like версии. Попробуем сделать это без тонн кода, сиквенсов и прочей «духоты», ограничившись лишь простыми наглядными примерами.
По дороге разберём частые ошибки программистов, борющихся за консистентность своих данных, и научимся грамотно их обходить.
А в чём, собственно, дело?
Представьте, что у вас есть вымышленный «ПриветСервис» и там такое:
@Service
public class PrivetService {
@Autowired private PrivetRepository privetRepository;
@Autowired private HttpClient httpClient;
@Value("${bestFriendUrl}") private String bestFriendUrl;
public void register(PrivetEntity privetEntity) {
privetRepository.save(privetEntity);
httpClient.post(bestFriendUrl, privetEntity.toJson());
}
}Суть понятна — при регистрации «привета» мы не только сохраняем его в БД (privetRepository), но и оповещаем дружественный микросервис POST-запросом.
А что, если bestFriendUrl ответит ошибкой? Или вообще не ответит?
Тогда в БД мы наш привет сохраним, а наш друг, скорее всего, останется без привета.
Возможно, в этом нет большой проблемы: ну, не получит — и не получит.
Тогда всё можно оставить как есть.
Но давайте рассмотрим ситуацию, при которой, не получив привет, наш друг страшно расстроится, а наш хрупкий мир потеряет консистентность.
Ловушка простых решений
А давайте договоримся, что если привет не удалось отправить другу, то и в своей БД мы его тоже хранить не будем, а сам метод register выкинет какую-то ошибку.
Сказано — сделано. Оборачиваем метод в транзакцию и получаем ожидаемое поведение.
@Transactional
public void register(PrivetEntity privetEntity) {
privetRepository.save(privetEntity);
httpClient.post(bestFriendUrl, privetEntity.toJson());
}Всё? Нет, надо ещё позаботиться, чтобы httpClient.post выбрасывал исключение, если получит что-то, отличное от 200 ОК (точнее, 2хх). Впрочем, такое поведение заложено во многих клиентах по умолчанию.
Теперь точно всё? Нет, стало намного хуже. Одну проблему мы исправили — и получили долгие транзакции в нашей БД. Ведь теперь транзакция натянута не только на сохранение привета в БД, но и на работу с внешним сервисом. HTTP-запросы могут занимать секунды и даже минуты, что категорически неприемлемо для БД-транзакций. А если bestFriendUrl вообще не отвечает, то это может привести к полной деградации базы. Да, сработают таймауты, но они, как правило, немаленькие.
При этом сам PrivetService может быть глубоко вторичным, и мы точно не хотим, чтобы при недоступности дружественного сервиса страдало всё наше приложение. Кажется, об этом совсем не говорят на курсах микросервисной архитектуры, а зря.
Итак, мы получили классическую распределённую транзакцию — надо данные записать в две разные системы и соблюсти при этом атомарность. Ещё её называют проблемой двойной записи.
Совсем простого решения этой проблемы нет. Наиболее простым и элегантным, на мой взгляд, является шаблон Transactional Outbox.
А что, собственно, делать?
Идея простая — вместо того, чтобы сразу пытаться отправить HTTP-запрос в другой сервис, мы просто оставим в БД информацию о необходимости такой отправки. А сам HTTP-запрос сделаем потом, в рамках какой-то джобы (планировщиком).
Где хранить информацию о необходимости отправки привета другу?
Можно ввести дополнительное поле-флаг прямо в таблице с приветами — что-то типа is_sent.
Пусть по умолчанию оно равно false. Тогда наша функция регистрации сильно упростится.
public void register(PrivetEntity privetEntity) {
privetRepository.save(privetEntity);
}Но дополнительно нам придётся написать планировщик, который будет регулярно сканировать таблицу приветов и отправлять HTTP-запрос по тем записям, где is_sent = false.
@Scheduled(fixedDelay = 10_000)
public void sendToFriend() {
List<PrivetEntity> sentIsFalse = privetRepository.findAllBySentIsFalse();
for (PrivetEntity privetEntity : sentIsFalse) {
try {
httpClient.post(bestFriendUrl, privetEntity.toJson());
privetEntity.setSent(true);
privetRepository.save(privetEntity);
} catch (Exception e) {
e.printStackTrace();
}
}
}Логика понятна — если сообщение успешно отправлено другу, устанавливаем поле is_sent в true и сохраняем запись.
Если не удалось отправить — просто печатаем ошибку, надеемся, что сбой временный, и пытаемся переотправить следующим запуском планировщика (в примере через 10 секунд).
Польза от такого подхода
Она колоссальная!
Во-первых, вынесение работы с внешним сервисом в отдельный процесс сильно разгружает саму функцию регистрации.
Вы не поверите, насколько проще потом отлаживать и тестировать такую систему. Да и сама функция регистрации теперь работает намного быстрее! Пользователю в моменте не надо ждать ответа долгих внешних вызовов.
Во-вторых, теперь даже если дружественный сервис временно перестал отвечать, это не повод не регистрировать новые приветы. Мы можем продолжить сбор данных, а как друг снова станет доступен — получит все новые приветы.
Это очень важно для микросервисной архитектуры — недоступность одного не самого важного звена не должна блокировать работу системы в целом.
И, наконец, мы решили проблему двойной записи и добились согласованности данных: если удалось сохранить привет в БД, то рано или поздно он доедет и до друга. Да, это не полная согласованность, а согласованность в конечном итоге (Eventual Consistency), т.к. данные до друга могут приехать сильно позже, чем появятся в вашей БД. Но мы понимаем, что имеем дело с распределенной системой и полной согласованности тут априори не достичь.
Зачем нужна отдельная таблица?
Всё хорошо? Не совсем. Для начала вы заметите, что процесс sendToFriend постоянно сканирует таблицу приветов, а иногда и обновляет её. Если к таблице и так много обращений, это может привести к существенному замедлению.
Далее, представьте, что у вас есть ещё PokaService и там есть функция регистрации прощаний.
public void register(PokaEntity pokaEntity) {
pokaRepository.save(pokaEntity);
httpClient.post(bestFriendUrl, pokaEntity.toJson());
}Что же, нам теперь и в таблицу poka добавлять поле is_sent? А сколько таких полей в другие таблицы нам ещё придётся добавить?
Всё это как бы намекает, что для управления отправкой REST-запросов лучше выделить отдельную таблицу, а не нагружать продуктовые таблицы техническими полями и постоянным сканом.
Назовём её outbox_event. Выглядеть она будет примерно так:
id | event_type | object_id |
1 | privet | 1 |
2 | privet | 2 |
3 | poka | 1 |
Теперь, когда нам надо отправить привет другу, мы будет добавлять строку в эту таблицу с event_type = privet и object_id = privet.id (уникальный идентификатор привета).
И аналогично для прощаний.
А функция регистрации привета будет выглядеть так:
@Transactional
public void register(PrivetEntity privetEntity) {
privetRepository.save(privetEntity);
outboxRepository.save(new OutboxEntity("privet", privetEntity.getId()));
}Да, в функции register снова два запроса, но теперь нет проблемы объединить их в транзакцию — ведь это обращения к одной и той же БД.
Именно в этом суть Transactional Outbox — заменить вызовы внешних сервисов на запросы к реляционной БД, которые легко объединяются в транзакцию.
Планировщик придётся переделать — теперь он сканирует таблицу outbox_event по нужным event-ам и отправляет REST-запросы.
@Scheduled(fixedDelay = 60_000)
public void sendToFriend() {
List<OutboxEntity> events = outboxRepository.findByEventType("privet");
for (OutboxEntity event : events) {
try {
var privetId = event.getObjectId();
PrivetEntity privetEntity = privetRepository.findById(privetId);
httpClient.post(bestFriendUrl, privetEntity.toJson());
outboxRepository.deleteById(event.getId() );
} catch (Exception e) {
e.printStackTrace();
}
}
}На каждый тип события (event_type) лучше сделать отдельный планировщик, чтобы проблемы с отправкой одного типа сообщений не тормозили все остальные. К счастью, такие планировщики похожи друг на друга, как js-фреймворки, и легко в дальнейшем шаблонизируются.
И ни в коем случае не ставьте @Transactional над методом планировщика! Иначе вы снова обернёте в длинную транзакцию работу с БД и внешний вызов.
Кроме REST-запросов в сервисах могут быть любые другие внешние вызовы.
Например, работа с Kafka.
public void register(PrivetEntity privetEntity) {
privetRepository.save(privetEntity);
httpClient.post(bestFriendUrl, privetEntity.toJson());
kafkaTemplate.send("my-friend-topic", privetEntity.getId(), privetEntity.toAvro()).get();
}Поступаем, понятное дело, аналогично — меняем немедленную отправку привета в Kafka на сохранение в outbox_event с каким-то новым типом.
@Transactional
public void register(PrivetEntity privetEntity) {
privetRepository.save(privetEntity);
outboxRepository.save(new OutboxEntity("privet", privetEntity.getId()));
outboxRepository.save(new OutboxEntity("privet-kafka", privetEntity.getId()));
}Ну, и добавляем новый планировщик.
Толстый аутбокс
Пока что планировщикам хватало лишь пары event + object_id для того, чтобы восстановить данные и отправить сообщение. Но не всегда требуемая для отправки информация хранится в продуктовых таблицах. Или она может быть изменена до того, как планировщик приступит к отправке, и тогда сообщение поедет друзьям не в оригинальном виде.
Хороший выход тогда — просто сохранять сообщение для отправки в самой outbox_event.
id | event_type | object_id | message |
1 | privet | 1 | {"id": 1, "from": "Петя", "to": "Вася", "desc": "Чё как" } |
2 | privet | 2 | {"id": 2, "from": "Вася", "to": "Петя", "desc": "Всё чётко, сам как?"} |
3 | poka | 1 | {"id": 1, "from": "Дима", "to": "Ваня", "desc": "Денег нет"} |
4 | privet-kafka | 1 | eyAiaWQiOiAxLCAiZnJvbSI6ICLQn9C10YLRjyIsI |
5 | privet-kafka | 2 | CJ0byI6ICLQktCw0YHRjyIsICJkZXNjIjogItCn0ZEg0LrQsNC6IiB9 |
Теперь работа планировщиков будет совершенно автономной и не давать дополнительной нагрузки на продуктовые таблицы.
Если сообщения «толстые», то и outbox_event ощутимо наберёт массу. Но это не страшно, если чистить таблицу после успешной отправки сообщений (как это реализовано сейчас).
А что, если не чистить? Например, мы хотим для истории хранить все сообщения, в т.ч. отправленные? Самое простое — добавить в outbox_event поле sent_at и проставлять его после успешной отправки. И да, в планировщиках мы будем выбирать только записи, где sent_at = null.
Но если обмен данными интенсивный, то outbox_event всё равно придётся как-то периодически чистить. И чтобы отделять очень старые записи от не очень, придётся добавить поле created_at.
Да и вообще, это поле очень полезно для задания сортировок и понимания «возраста» сообщения.
В дальнейшем вам наверняка захочется ввести ограничение на количество отправок одного сообщения. Иначе какое-нибудь бракованное сообщение будет из раза в раз отфутболиваться принимающей стороной и крутиться в планировщике, как сурок.
Не вопрос — добавляем поле attempt (по умолчанию 0) и инкрементим его при каждой попытке отправки. А в планировщиках отбираем теперь только записи с attempt меньше 3, например.
Кстати, все «зависшие» сообщения теперь легко достаются из базы по фильтру attempt = 3 и разбираются отдельно.
Ещё одна полезная возможность — отложенные сообщения. Например, мы не хотим отправлять сообщение «как только — так сразу», а хотим подождать до 7 утра следующего дня. Или хотим отправить в момент старта какой-то акции.
Думаю, вы догадались — всё решается добавлением ещё одного поля. На этот раз —start_date_time. Пишем туда null (если хотим отправить как можно скорее) либо желаемые дату и время. В планировщиках же отбираем те сообщения, где start_date_time равно null либо находится в прошлом.
id | event_type | object_id | message | created_at | sent_at | attempt | start_date_time |
1 | privet | 1 | {"id": 1,...} | 2026-10-10… | 2026-10-10… | 1 | null |
2 | privet | 2 | {"id": 2,...} | 2026-10-11… | 2026-10-11… | 1 | null |
3 | poka | 1 | {"id": 1,...} | 2026-10-10… | null | 0 | 2027-01-01… |
4 | privet-kafka | 1 | eyAiaW… | 2026-10-10… | 2026-10-10… | 1 | null |
5 | privet-kafka | 2 | CJ0byI6… | 2026-10-11… | 2026-10-11… | 1 | null |
Ну, вы поняли — растить аутбокс вширь можно бесконечно. Всё зависит от ваших потребностей. Например, на нашем проекте в outbox_event 9 полей, и пока хватает.
А как насчёт скорости?
Ранее мы упомянули, что каждому типу сообщений лучше сопоставить свой планировщик (и свой поток), чтобы проблемы с отправкой одного типа не сказывались на всех прочих. Да и побыстрее всё будет, в несколько потоков-то. И тут у нас нет проблем.
Но что, если мы хотим распараллелить работу планировщика одного типа? Например, запускать на всех нодах нашего приложения, а не только на одной? Тогда надо позаботиться о том, чтобы работающие в параллели джобы не воровали записи друг друга. Иначе одна и та же запись может быть отправлена дважды (или не отправлена вообще из-за особенностей алгоритма).
Итак, надо как-то блокировать записи outbox_event в момент захвата их одним потоком. И как-то разблокировать их потом.
В PostgreSQL для этого есть SQL-конструкция FOR UPDATE SKIP LOCKED. Она блокирует выбранные строки на время текущей транзакции, чтобы другие сессии не могли их изменить.
Если хотите разобраться, как это работает, добро пожаловать под кат, там больше «духоты» и кода, зато пример близкий к реальному проду.
Пример:
public interface OutboxRepository extends JpaRepository<OutboxEventEntity, Long> {
@Transactional
@Query(value = """
WITH cte AS (
SELECT *
FROM outbox_event
WHERE
event_type = :eventType
AND sent_at IS NULL
AND attempt < :maxAttempts
AND (
locked_at IS NULL
OR locked_at < now() - interval '10' minutes
)
AND (
start_date_time IS NULL
OR now() >= start_date_time
)
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT :limit
)
UPDATE outbox_event o
SET locked_at = now(), attempt = attempt + 1
FROM cte
WHERE o.id = cte.id
RETURNING o.*;
""", nativeQuery = true)
List<OutboxEventEntity> findByEventType(
@Param("eventType") String eventType,
@Param("limit") int limit,
@Param("maxAttempts") int maxAttempts
);
}Запрос состоит из двух частей:
CTE (Common Table Expression) с именем cte — это виртуальная таблица, которая находит и блокирует пачку подходящих для обработки событий.
UPDATE — основная часть, которая помечает выбранные события как «взятые в работу» (ставит метку времени и увеличивает счётчик попыток) и возвращает их данные приложению.
Часть 1: Как работает выборка (SELECT ... WITHIN cte)
Внутри CTE происходит поиск записей по следующим условиям:
event_type = :eventType — фильтр по типу события. Позволяет воркерам обрабатывать только свои типы задач и под каждый тип писать свой обработчик.
sent_at IS NULL — игнорируем уже отправленные записи.
attempt < :maxAttempts — защита от «ядовитых» сообщений (Poison Pills). Если событие стабильно падает с ошибкой, мы не будем крутить его вечно. После достижения лимита оно просто перестанет выбираться.
Блок работы с зависшими задачами (locked_at IS NULL OR locked_at < now() - interval '10' minute):
Если locked_at IS NULL, событие новое, его ещё никто не трогал.
Если прошло больше 10 минут с момента блокировки, значит, предыдущий воркер взял задачу, но «умер» (Crash, OOM, завис сетевой сокет) и не успел её завершить. Это механизм автоматического сброса таймаута.
Блок отложенного старта (start_date_time IS NULL OR now() >= start_date_time):
Позволяет делать отложенные публикации. Если start_date_time задан в будущем, запрос будет игнорировать запись, пока это время не наступит.
ORDER BY created_at — гарантирует соблюдение хронологического порядка (FIFO). Старые события обрабатываются в первую очередь.
LIMIT :limit — батчинг (пакетирование). Воркер забирает не всю таблицу, а ровно столько, сколько может обработать за раз (например, по 100 штук), что снижает нагрузку на сеть и память.
Часть 2: Как работает обновление (UPDATE ... FROM cte)
После того как CTE нашла и заблокировала ID строк, выполняется UPDATE:
UPDATE outbox_event o
SET locked_at = now(), attempt = attempt + 1
FROM cte
WHERE o.id = cte.id
RETURNING o.*;locked_at = now() — мы обновляем метку времени блокировки. Теперь для всех остальных воркеров (в других транзакциях) эти строки станут невидимыми на ближайшие 10 минут (согласно условию в CTE).
attempt = attempt + 1 — инкрементируем счётчик попыток. Если в процессе обработки в коде приложения произойдёт ошибка, при следующем поиске (через 10 минут) у этой записи будет на одну попытку больше.
RETURNING o.* — база данных не просто обновляет строки, а сразу возвращает весь контент этих записей в приложение. Приложению не нужно делать повторный SELECT.
Плюсы такой реализации
Атомарность: Выборка, блокировка и обновление происходят в рамках единого шага. Исключены любые race conditions.
Отказоустойчивость: Если воркер упадёт в процессе отправки события, блокировка снимется через указанный таймаут (10 минут), и строка снова станет доступна для других. Это, кстати, дополнительно гарантирует минимальный таймаут между повторами одного и того же сообщения
Простота отладки: Все состояния (повторы, время отправки, типы) лежат в обычной SQL-таблице. В них легко заглянуть глазками (и там же поменять, при необходимости).
Обратите также внимание на аннотацию @Transactional. Без нее сделать атомарный захват записей вместе с одновременным обновлением (полей locked_at и attempt) не получится.
ВАЖНО! Транзакция короткая, она не «натягивается» на всю работу планировщика, а нужна только для того, чтобы безопасно в многопоточной среде захватить записи и поставить на них блокировку (locked_at)
Код планировщика приводить не буду, чтобы совсем уж не забивать эфир кодом. Да он, в целом, довольно простой. Главное, не забыть обновить sent_at после успешной отправки (либо удалить обработанную запись, если вам не нужна история)
И ни в коем случае не ставьте @Transactional над методом планировщика!
В чём подвох?
Ну, раз мы так подробно расписали плюсы нашего любимого аутбокса, пора сказать пару слов и о минусах.
Шаблон Transactional Outbox не гарантирует доставку типа Exactly-Once (ровно один раз).
Представьте: планировщик взял запись, успешно отправил её другу, получил 200 OK и... упал.
А также не успел выполнить финальный UPDATE (проставить sent_at или удалить запись). Через 10 минут таймаут блокировки сбросится, другой воркер честно подберет это же сообщение и отправит другу еще раз.
Вывод: Принимающая сторона (ваш друг) обязательно должна быть идемпотентной. То есть уметь проверять дубликат, если такой привет он уже обрабатывал.
Далее, аутбокс, по сути, реализует очередь на вашей БД. Это создает дополнительную небольшую нагрузку БД, к этому тоже надо быть готовым.
А что, обязательно писать аутбокс самим?
Да, в принципе, нет: есть куча опенсорсных реализаций, например, https://github.com/raedbh/spring-outbox.
Но если вы ищете что-то прям эталонное, типа spring-boot-outbox-template, то такого модуля нет. И, скорее всего, не будет.
Потому что реализация этого шаблона довольно сильно зависит от специфики проекта и ваших потребностей. И проще самому за пару часов накидать основу из 3-4 классов и потом её дотюнить, чем тащить в код чужую библиотеку и долго с ней разбираться.
Ну и, написав свой код, вы гораздо лучше разберётесь с outbox-template и нащупаете свою пригоршню не самых очевидных моментов.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.