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

Почему это так часто ломается
Команды обычно добавляют очереди и фоновые воркеры раньше, чем решают, где проходит настоящая линия фиксации (commit).
Запрос сохраняет одну строку, планирует сохранить другую, публикует событие и возвращает успех. На локальном тестировании это часто выглядит нормально. Сбои начинаются, когда один шаг медлит, падает или выполняется не в том порядке.
Проблема живёт в пробелах. Запрос может создать запись заказа, а затем упасть до того, как запишутся позиции заказа. Воркер может сразу увидеть новый ID заказа и искать данные, которых ещё нет. Одна часть системы говорит «готово», а другая — «не найдено».
Именно поэтому граница между фиксацией в базе и публикацией сообщений так важна. Если запись и событие не подчиняются одному ясному правилу, вы можете отправить «заказ оплачен», хотя запись об оплате откатится через секунду. Событие уже ушло. Другие системы доверяют ему и действуют по тому, что на самом деле не подтвердилось.
Пользователи не воспринимают это как техническую ошибку. Они видят экран успеха, а служба поддержки находит противоречащие записи. В одной таблице — новый аккаунт, в другой — нет профиля. Письмо подтверждения ушло, но доставка так и не началась. Небольшие ошибки во времени превращаются в дорогие бизнес-проблемы.
В продакшене всё это только ухудшается. Сети таймаутятся. Воркеры подхватывают одну и ту же задачу дважды. Серверы приложений падают после одной записи и до следующей. Ни одного из этих сцен в ревью кода не видно, и именно поэтому команды этого не замечают.
Многое из исходного кода стартапа скользит в эту форму шаг за шагом. Кто‑то добавляет сохранение. Другой — воркер. Третий — событие для аналитики или биллинга. Каждый шаг сам по себе кажется разумным. Полный поток в итоге остаётся без единого источника правды о том, что должно фиксироваться вместе, и данные начинают расходиться.
Сначала нарисуйте линию фиксации
Начните с одного пользовательского действия, а не с очереди. Клиент нажимает «Оформить заказ», менеджер одобряет возврат или пользователь меняет пароль. Задайте простой вопрос: какие факты должны быть истинны, прежде чем вы безопасно вернёте успех?
Эти факты определяют линию фиксации. Если приложение говорит «заказ оформлен», в базе уже должны быть заказ, итоговая цена и любая резервируемая позиция, которую вы обещали держать сразу. Поместите только те изменения строк внутрь одной транзакции.
Здесь командам нужно быть строже. Многие баги появляются, когда смешивают обязательные факты с побочными эффектами, которые могут подождать. Отправка письма, обновление кэша, запись аналитики и постановка в очередь могут быть связаны с заказом, но им не нужны одинаковые гарантии. Если любой из них упадёт, пользователь всё равно должен увидеть реальный заказ в базе.
Выберите одну запись как источник правды для действия. Во многих потоках это основная строка заказа, инвойса или аккаунта. Дайте ей чёткий статус и позвольте другим записям ссылаться на неё. Тогда воркеры смогут читать эту запись позже и решать, что ещё нужно сделать.
Возьмите простой поток оплаты. Внутри транзакции создайте заказ, сохраните итоговую сумму, отметьте оплату как полученную, если у вас уже есть подтверждение, и резервируйте товар, если вы обещали удержание запаса. После того как транзакция зафиксируется, отправьте письмо с чеком, обновите кэш, запишите аналитику и опубликуйте событие для логистики.
Такое разделение делает отказы скучными. Если письмо тормозит или очередь недоступна пять минут, служба поддержки всё ещё может открыть заказ и увидеть правду. В этом и смысл: зафиксируйте факты, от которых люди зависят, а затем пусть воркеры занимаются остальным.
Что должно фиксироваться вместе
Транзакция должна содержать только те факты, которые решают, успешно ли выполнен запрос. Если эти факты распределены по нескольким фиксациям, ваша система может одновременно рассказывать две разные истории.
Начните со смены статуса, которая определяет результат. Если заказ перешёл из «ожидает» в «подтверждён», этот статус должен фиксироваться вместе со строками, которые делают подтверждение реальным.
Обычно это включает записи, которые предотвращают двойное резервирование или двойное списание. Думайте о местах для сидений, списаниях инвентаря, бухгалтерских проводках или уникальном использовании купонного кода. Если статус говорит «выполнено», а защитные строки отсутствуют, работа на самом деле не завершена.
Дублирующиеся запросы должны быть частью той же беседы. Если клиент повторяет запрос из‑за таймаута сети, приложению нужна запись идемпотентности в той же транзакции. Она даёт ясный ответ на «мы уже обработали этот запрос?». Без неё harmless retry может создать второй заказ, второй платёж или второе резервирование.
Если вы планируете публиковать событие позже, добавьте строку outbox в тот же коммит. Это безопасная форма паттерна outbox. Приложение фиксирует бизнес‑результат и инструкцию опубликовать позже одновременно. Воркер сможет прочитать outbox после фиксации и пытаться снова, пока не получится.
На практике хорошая транзакция часто включает всего четыре вещи:
- Строку, чей статус определяет успех.
- Строки, которые предотвращают двойное использование денег, запасов, мест или кредитов.
- Запись идемпотентности для запроса.
- Запись outbox для последующей публикации событий.
Держите медленные сетевые вызовы вне транзакции. Не ждите email, брокера очереди, API платёжной системы или вебхука при открытых блокировках базы. Это усложняет поведение при ошибках и повышает вероятность конфликтов.
Правило простое: сначала зафиксируйте правду, затем сообщите внешнему миру. Если коммит завершился, ретраи безопасны. Если коммит упал, никакой воркер не должен действовать, как будто он прошёл.
Что может подождать до фиксации
Если строка в базе корректна и пользователь может продолжить работу, остальное обычно можно выполнить позже. Сюда входит работа, которая информирует людей, ускоряет чтение или питает другие системы.
Письмо с квитанцией — очевидный пример. Если клиент оформил заказ, заказ и запись об оплате должны зафиксироваться вместе. Письмо — нет. Если ваш почтовый провайдер тормозит 20 секунд, вы всё равно должны сохранить заказ и отправить квитанцию позже.
Обновления поискового индекса обычно тоже после фиксации. Основная база данных — источник правды. Поиск помогает быстрее находить данные, но устаревший результат на минуту обычно лучше, чем сломанная покупка или регистр. Та же логика для обновления кэша: при промахе приложение может прочитать из базы и восстановить кэш.
Вебхуки тоже должны подождать. Внешний endpoint вне вашего контроля. Если их сбой становится частью вашего пути фиксации, их даунтайм становится вашим. Запишите событие, зафиксируйте свои данные и позвольте воркеру доставлять вебхук с ретраями и backoff.
Отчётность и аналитика почти никогда не должны быть в основной транзакции. Дашборды и счётчики помогают позже. Они не должны решать, существует ли заказ, сохранён ли возврат или создан ли аккаунт.
Хорошее практическое правило: если пользователь всё ещё согласен оставить успешное действие, когда этот побочный эффект упадёт, переместите его после фиксации. Паттерн outbox — чистый способ это сделать. Зафиксируйте бизнес‑данные и запись события вместе, затем воркер займётся email, вебхуками, аналитикой, индексами поиска или обновлением кэша после коммита.
Пройдите тест из пяти шагов
Команды часто усложняют это. Большинство споров о границе фиксации происходит потому, что смешивают бизнес‑действие и окружающие его дополнительные работы.
Быстрый тест всё прояснит.
- Опишите действие одним коротким предложением. «Клиент оформляет заказ» или «админ подтверждает возврат» достаточно. Если нужно три предложения — транзакция, вероятно, пытается сделать слишком много.
- Отметьте строки, которые делают действие истинным. Для заказа это может быть строка заказа, статус оплаты и резерв товара. Если эти строки зафиксированы, действие произошло. Если одна отсутствует — не произошло.
- Вынесите медленные побочные эффекты из транзакции. Email, вебхуки, индексация поиска, прогрев кэша, генерация PDF и вызовы сторонних сервисов должны подождать.
- Добавьте запись outbox в тот же коммит для всего, что должно случиться следующим. Это даст воркеру надёжную «сделать позже» запись вместо догадки.
- Протестируйте самый уродливый сценарий краша. Зафиксируйте транзакцию, затем притворитесь, что приложение умерло до отправки сообщения, email или вебхука. Если система может восстановиться, прочитав outbox и повторив попытку, граница надёжна. Если работа пропадает, дизайн всё ещё дыряв.
Этот тест задаёт полезный вопрос: что должно быть истинно в момент успешного коммита?
Если вы не можете ответить простым языком, остановитесь до добавления очередей или воркеров. Нарисуйте линию снова, сделайте её уже и оставьте только те строки, которые делают действие пользователя реальным.
Простой поток заказа
Поток заказа становится гораздо проще, когда вы проводите одну жёсткую линию на уровне фиксации базы. Всё, что делает покупку реальной, должно пересечь эту линию вместе. Всё остальное может подождать.
Представьте, что клиент нажимает «Купить» в маленьком интернет‑магазине. Приложение открывает одну транзакцию и записывает три строки: заказ, изменение запаса и запись outbox с текстом «отправить квитанцию по заказу 4821». Затем выполняет коммит.
Этот коммит — момент, когда бизнес‑действие становится истинным. До него ничего не считается. После него заказ существует, запас уменьшен, и система имеет надёжную пометку о том, что последующая работа ещё нужна.
Поток короткий:
- Клиент отправляет заказ.
- Приложение сохраняет заказ, обновление запаса и запись outbox в одной транзакции.
- База делает коммит.
- Воркер читает outbox и отправляет квитанцию.
Если сначала отправить письмо или запустить воркер до фиксации, можно получить квитанцию для заказа, который так и не завершился. Клиенты это замечают быстро.
Паттерн outbox решает это без большой сложности. Воркер не решает, реален ли заказ. Коммит уже это решил. Воркер занимается только работой, которая может быть выполнена позже.
Теперь представьте, что почтовый провайдер таймаутит. Это раздражает, но проблема меньшая. Заказ всё ещё в силе, потому что бизнес‑данные уже зафиксированы. Воркер сможет повторить попытку позже, когда почта восстановится.
Вот в чём смысл. Данные заказа и изменения запаса требуют одного коммита в базе. Отправка квитанции — нет. Когда команды смешивают это, появляются тикеты в поддержку, повторные отправки и странные остатки на складе.
Где очереди и воркеры подходят
Очереди должны находиться после фиксации в базе, а не внутри той части, которая решает, действительно ли основное изменение корректно. Если ваше приложение создаёт сообщение из данных в памяти, а транзакция откатывается, очередь всё равно будет утверждать, что изменение произошло. Теперь база и воркер расходятся.
Более безопасная схема сначала записывает зафиксированные данные, затем публикует из этого сохранённого состояния. Паттерн outbox делает именно это. Приложение сохраняет основную запись и запись outbox в одной транзакции. Если транзакция провалится — исчезнут обе. Если она зафиксируется — процесс отправки позже может публиковать событие, не догадываясь, что произошло.
Практический поток прост: сохраните основную запись и необходимые дочерние записи. Сохраните запись outbox в той же транзакции. Сделайте коммит. Затем пусть отправщик читает outbox и пушит задачу или событие.
Последний шаг важнее, чем кажется. Воркеры должны читать окончательно сохранённое состояние, а не доверять объекту, который приложение держало в памяти несколько миллисекунд назад. Сообщение может нести ID записи, тип события и, возможно, номер версии. Воркер может подтянуть реальную строку и действовать по тому, что действительно зафиксировано.
Каждой задаче также нужен стабильный ID. Используйте ID из outbox или комбинируйте ID записи с типом задачи. Это даёт безопасные повторы. Если воркер упадёт после отправки письма или вызова платёжного шлюза, вы сможете повторить побочный эффект, не переписывая основной заказ, счёт или запись пользователя.
Держите воркеры узкими. Один воркер отправляет квитанцию. Другой синхронизирует данные с биллинг‑системой. Третий пишет аудит. Маленькие воркеры ломаются меньшими кусками, их проще повторить, мониторить и заменить.
Ошибки, которые раскалывают ваши данные
Большинство ошибок согласованности — не ошибка очереди. Это ошибка проектирования границ фиксации.
Одна распространённая ошибка — публикация в очередь до фиксации транзакции. Воркер просыпается быстро, ищет новую строку заказа или оплаты и ничего не находит или видит полузаполненные данные. Теперь у вас есть событие «выполнено», а база говорит «возможно». Если нужно публиковать после фиксации, запишите outbox в той же транзакции и отправляйте его только после успешного коммита.
Другая ошибка — держать транзакцию открытой, пока приложение ждёт ответа от API. Платёжный шлюз, почтовый сервис или проверка на мошенничество могут занять секунды. За это время блокировки остаются открытыми, другие запросы накапливаются, таймауты растут. Держите транзакцию короткой. Сначала зафиксируйте локальные факты, затем вызывайте внешние сервисы отдельно.
Команды также разбивают данные, когда впихивают все последующие задачи в тот же коммит. Создание заказа и резервирование запаса могут быть вместе. Отправка квитанции, обновление аналитики и пуш в CRM обычно — нет. Когда всё это в одно транзакции, одна медленная задача может задержать весь запрос.
Ретраи могут превратить мелкий дефект в дорогую проблему. Если воркер повторно пытает списать платёж или отправить письмо без проверки идемпотентности, клиенты получат двойные списания или тройные сообщения. Каждый путь ретрая должен иметь стабильный ID, ясный статус и одно место, куда записывать «уже сделано».
Воркеры также ломаются тихо. Некоторые читают частичную строку и пытаются догадываться о недостающем состоянии. Эти догадки распространяют баги. Воркер должен либо видеть достаточно зафиксированных данных, чтобы действовать уверенно, либо ждать.
Простейший тест‑запах помогает. Если воркер должен догадываться — коммит был слишком рано. Если транзакция ждёт сеть — она слишком длинная. Если ретраи могут повторять перемещение денег или сообщения — задача небезопасна. Если очередь может выполниться до появления строки — поток раскололся.
Быстрые проверки перед добавлением воркера
Фоновый воркер помогает только после того, как вы решите, что уже должно быть истинно, когда запрос заканчивается. Большинство ошибок согласованности зарождаются раньше, чем очередь.
Начните с записей, а не с воркера. Если пользователь оформляет заказ, возможно, строка заказа и резерв инвентаря должны меняться в одной транзакции. Лог записи email — нет. Если вы не можете указать точные записи, которые должны расти и уменьшаться вместе, линия фиксации всё ещё размыта.
Затем посмотрите на ретраи. Воркеры ретраят, потому что серверы падают, сети таймаутят, задачи подбирают дважды. Если одна и та же задача может создать второй возврат, отправить второй пакет или снова уменьшить запас, это не готово. Нужна идемпотентность или хотя бы явная защита в базе.
Задайте ещё один вопрос: можно ли восстановить событие только из зафиксированных данных? Если событие зависит от объекта в памяти, который исчезает при падении процесса, у вас всё ещё есть разрыв. Поэтому паттерн outbox часто безопаснее: вы фиксируете бизнес‑данные и запись события вместе, затем публикуете из того, что уже сохранено.
Лаг тоже важен. Если воркер опаздывает на пять минут, пользователь всё равно должен увидеть корректный результат на экране. Приветственное письмо может подождать. Аналитика может подождать. Индексация поиска может подождать. Баланс аккаунта, права доступа и остатки на складе обычно не могут.
Один краш‑тест многое покажет: сохраните основную транзакцию, провалитесь до публикации, убейте процесс, перезапустите воркер и посмотрите, сможет ли система восстановиться без догадок. Если этот тест оставляет пропущенные события, дублирующую работу или явную ложь для пользователя, остановитесь и перерисуйте границу.
Следующие шаги для вашей команды
Начните с одного пользовательского потока, с которым команда сталкивается каждую неделю: оформление заказа, выставление счёта или регистрация аккаунта. Положите его на бумагу. Нарисуйте линию между работой, которая должна зафиксироваться в одной транзакции базы, и тем, что может выполниться позже.
Это упражнение быстро разрешает много споров. Команды часто разговаривают о очередях сначала, но правило фиксации — прежде. Если линия размыта, ретраи и падения воркеров превратят небольшую ошибку в раскол данных.
Хорошая первая итерация проста. Выберите один поток и назовите точные строки, которые должны существовать вместе после фиксации. Отметьте всё, что отправляет почту, вызывает внешние сервисы или обновляет поиск, как работу после коммита. Если ваш обработчик запроса публикует события напрямую, переместите запись в таблицу outbox, сохраняемую в той же транзакции. Затем запишите одно короткое правило в документации команды, что остаётся внутри транзакции для этого потока.
Держите правило достаточно коротким, чтобы новый инженер мог следовать ему без трёх вопросов. Например: «Создавать заказ, резервировать запас и записывать outbox в одной транзакции. Отправлять подтверждение по email после фиксации.»
Потом протестируйте правило в условиях отказа, а не только в сценарии успеха. Убейте приложение после коммита до запуска воркера. Повторите ту же задачу дважды. Смоделируйте таймаут при публикации события. Команда должна точно знать, что происходит в каждом случае.
Это не требует длинного процесса. Одна сессия у доски, одно правило на бумаге и одна тренировка отказа могут быстро убрать беспорядок. После этого повторите обзор для следующего потока.
Если хотите второе мнение перед добавлением очередей и воркеров, Oleg Sotnikov at oleg.is помогает стартапам и маленьким командам как Fractional CTO по архитектуре, инфраструктуре и практической AI‑помощи в разработке. Короткий обзор обычно дешевле, чем уборка рассинхрона после релиза.
Часто задаваемые вопросы
What is a transaction boundary?
Это точка, где ваше приложение решает: «это действие теперь реально». Поместите строки, которые делают пользовательское действие истинным, внутри одной транзакции базы данных, зафиксируйте их вместе, и только затем запускайте отправку писем, событий или воркеры.
What should commit together in an order flow?
Фиксируйте строки, которые определяют успех. Для заказа это обычно запись заказа, итоговая цена или известное состояние оплаты, любая резервируемая пообещанная наличность/запас, а также записи идемпотентности и outbox, если вы их используете.
What can wait until after the commit?
Всё, без чего пользователь может обойтись несколько минут. Письма с квитанциями, обновления кэша, индексация поиска, аналитика и вебхуки обычно выполняются после фиксации, потому что сам заказ остаётся в силе, даже если эти шаги временно провалились.
Why is publishing to a queue before commit risky?
Потому что очередь может опередить базу данных. Воркер может подобрать задачу, найти новую запись и обнаружить отсутствующие или частично записанные данные — или действовать по событию, которое затем откатилось.
When do I need the outbox pattern?
Когда событие или задача должны следовать за успешной записью, но вы не хотите отправлять сообщение внутри транзакции. Запишите бизнес-строки и запись outbox в одном коммите, а затем воркер опубликует событие позже, прочитав из сохранённого состояния.
Why does idempotency matter here?
Идемпотентность предотвращает повторные создания одного и того же результата при ретраях. Сохраните стабильный ID запроса или задачи вместе с основной записью, чтобы приложение могло ответить: «мы уже обработали это», вместо того чтобы создать второй заказ, возврат или платёж.
Should I call external APIs inside the transaction?
По возможности держите внешние вызовы вне транзакции. Если вы ждёте email, вебхук или сторонний API при открытых блокировках, это увеличивает вероятность таймаутов и контеншенa. Сначала зафиксируйте локальную правду, потом вызывайте другие сервисы.
What should a worker use as its source of truth?
Читайте зафиксированное состояние, а не полагайтесь только на тело сообщения. Небольшое сообщение с ID записи и типом задачи удобно: воркер может загрузить финальную строку из базы и действовать по тому, что реально зафиксировано.
How do I test whether my commit line is correct?
Смоделируйте один неприятный сбой. Зафиксируйте транзакцию, умышленно завершите процесс до публикации, затем перезапустите путь воркера и проверьте, сможет ли система догрузить пропущенную работу без догадок, потерь данных или повторного списания средств.
When should a small team get outside help with this design?
Просите помощи, когда команда постоянно видит дублирующиеся задания, рассинхрон данных, странные несоответствия статусов или долгие споры о том, что должно быть внутри транзакции. Короткий архитектурный аудит обычно дешевле, чем исправление рассинхрона в продакшне.