Как стать автором
Обновить

Комментарии 33

«Однако, как только у нас появляется отдельная система для хранения очереди сообщений (мы используем RabbitMQ), сразу возникают проблемы с транзакционностью. Например, если мы хотим сохранить в БД отметку о том, что мы отправили сообщение в Rabbit, не так уж и просто гарантировать, чтобы отметка была сохранена только в случае успешной отправки сообщения»
Включаете подтверждение исполнения задач (ack) у консумера. И в конце обработки задания перед ack публикуете данные о том что задача выполнилась в очередь с результатами. Всё и ваш велосипед не нужен =).
Ack при обработке мы используем, конечно же, но проблема с транзакционностью, которую я описал, относится к транзакции отправки сообщения.

Но и при обработке сообщения одного только Ack'а не достаточно. Если после публикации данных в очередь с результатами упадет Ack, то это сообщение будет обработано два раза. Поэтому обработку сообщения всегда лучше делать идемпотентной.
«Если после публикации данных в очередь с результатами упадет Ack»?
куда упадёт?
если сообщение не удалось отправить что с ним должно произойти, надо пробовать отправить его ещё раз?
Ack актуален только при обработке сообщения, при отправке Ack вызвать нельзя. Если сохранять информацию об успешной обработке сообщения где-либо (в другой очереди или в SQL базе), нет гарантии, что после этого отработает Ack (хотя бы из-за недоступности сети, но в реальных условиях между завершением транзакции и Ack может выполнятся еще код, который также может упасть). В этом случае сообщение через некоторое время обработается еще раз, чего мы не хотим.

Что делать, если падает именно отправка сообщения, я подробно написал в статье.
Ack актуален если задачу надо 100% выполнить, это элемент надёжности, раз вы не хотите гарантированной доставки сообщений, значит это не нужно.

«при отправке Ack вызвать нельзя»
на самом деле у раббита есть и подтверждение публикации сообщений — www.rabbitmq.com/confirms.html (ни разу им не пользовался, у нас нет с этим проблем)

Короче ещё раз перечитал, судя по всему вам вообще асинхронная отправка писем не нужна, раз вы пытаетесь это сделать в течение SQL транзакции, отправляйте синхронно и будет вам счастье.
Мы хотим гарантированной доставки сообщений, поэтому используем Ack. Но Ack не гарантирует, что сообщение не будет обработано несколько раз, поэтому обработку сообщений нужно делать идемпотентной.

При отправке нам необходимо гарантировать, что сообщение будет добавлено в очередь только в случае успешного завершения SQL-транзакции. В зависимости от того в какой момент отправляется сообщение могут быть две разные проблемы:
  • SQL-транзакция выполнилась успешно, но потом отправка сообщения в очередь упала (если мы отправляем сообщение после завершения SQL-транзакции)
  • Сообщение успешно попало в очередь, но потом SQL-транзакция была откачена (если мы отправляем сообщение до завершение SQL-транзакции)

Как вы предлагаете использовать confrim для того, чтобы избежать обеих проблем?

Синхронная отправка email нас не устраивает (хотя бы потому, что при этом в случае downtime email-шлюза, не работают все системы, которым нужно отправлять email).
В течение SQL транзакции мы только добавляем сообщение в промежуточную очередь. Практически сразу после завершения транзакции, мы перекладываем это сообщение в очередь, которую обрабатывает система-получатель. Обработка сообщения (отправка email, если система-получатель — это email-шлюз) полностью асинхронна и система-отправитель не ждет, когда она будет завершена.
Хорошо, вот такой вариант:

2 очереди с ручным подтверждением задач + durable(чтобы пережить рестарт сервера):

1й воркер(для бизнес логики до отправки):
0. проверяем guid(см п. 2) не выполнялась ли бизнес логика для этой задачи, если выполнялась ack'каем задачу
1. запускаем SQL транзакцию
2. тут пишем в базу какой-то guid чтобы потом эту транзакцию не делать ещё раз, если ackнуть не удасться
3. делаем бизнес логику
4. отправляем сообщение в очередь отправки
5. комиттим транзакцию
6. ack'аем задание

2й воркер(отправка почты):
1. отправляем
2. ack'аем задание

И третьей очереди не надо + промежуточных обращений к БД, с БД работает только первый воркер. Как такую схему сможете поломать?
Если 1ый воркер сломается на 6ой задаче нужна же еще логика восстановления?
И если на шаге 1 упал, тоже беда?
Если сломался на 1 шаге, то значение о выполненной бизнес логике не запишется, задача не будет отмечена выполненной и будет передана другому воркеру или повторно обработана этим же.

Если сломается на 6 шаге, то это задание будет передано другому воркеру (или этому же). Он на 0 шаге увидит что бизнес логика выполнялась и тупо ack'нет это задание.
Все понял, спасибо. Перепутал что где ask'ается :)

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

Мне кажется системе-отправителю имеет смысл писать в свою БД идентификаторы сообщений перед отправкой в MQ только с одной целью — быть более устойчивой если MQ не работает или не отправит сообщение за разумное время. В этом случае переотправлять сообщения в MQ.

Ну отправка почты вообще ненадёжная штука, то есть даже отдав сообщение почтовому серверу вы не можете быть уверены на 100% что письмо дойдёт (поправьте меня если я не прав). И риск того что между отправкой и ack что-то произойдёт минимален, да и человек не особо напряжётся что ему придёт 2 одинаковых письма.

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

P.S. В общем я считаю что защита обсчёта бизнес логики важна, а отправки почты нет.
Немного устал повторять, но статья про другое)

Проблему с тем, что отправка email на почтовый сервер — не идемпотентная операция, мы решили немного усложнив логику работы второго воркера. Полностью она выглядит так:
  1. Получаем запись об email из БД (если ее там нет, значит транзакция первого воркера не была закомичена, и сообщение можно выбросить).
  2. Проверяем, что статус записи — «Новая» (если нет, значит сообщение уже обрабатывалось и скорее всего email был отправлен, поэтому сообщение выбрасывается).
  3. Изменяем статус записи об email на «Отправляется» и коммитим транзакцию
  4. Отправляем email.
  5. Изменяем статус записи об email на «Отправлено» (это просто атомарная операция без какой либо транзакции).
  6. Ack'аем сообщение.

При таком подходе мы не гарантируем, что email будет отправлен в 100% случаев, но гарантируем, что один email не будет отправлен два раза. Если упадет отправка email, то запись об email останется в невалидном статусе «Отправляется» (хотя если запись в этом статусе — есть вероятность, что email все таки отправился и упало изменение статуса). У нас настроен мониторинг, который проверяет количество таких записей и шлет алерт если их много. Сейчас сообщения в таком статусе остаются крайне редко — только если возникают проблемы на уровни сети.
Вы сами пришли к противоречию. Два тезиса:

1) но гарантируем, что один email не будет отправлен два раза.
2) то запись об email останется в невалидном статусе «Отправляется» (хотя если запись в этом статусе — есть вероятность, что email все таки отправился

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

Получаем запись об email из БД (если ее там нет, значит транзакция [системы — отправителя] не была закомичена, и сообщение можно выбросить).
1 и 2 не противоречит друг другу.
Если email остался в статусе «Отправляется», это может означать два варианта:
  1. Упала отправка email
  2. Упал update статуса

Т.е. если сообщение повисло в этом статусе мы не знаем наверняка было оно отправлено или нет. Эти сообщения мы автоматически не отправляем, поэтому гарантируется, что один email не будет отправлен два раза.

В вашем упрощенном подходе email-шлюз должен иметь доступ к БД системы-отправителя. Если вам не нужно выделять email-шлюз в отдельную систему, и вы считаете его частью CRM системы (и у них одна общая БД на двоих), то это вполне валидный подход.
Еще раз поясню, вопрос (да и вся статья) был про транзакцию отправки сообщения в очередь. Вы же описываете транзакцию обработки сообщения из очереди.

Обработка сообщения у нас устроена примерно так как вы описали, но вы упустили два нюанса:
  1. Так как вы генерируете ключ идемпотентности (guid) при обработке сообщения, вы не сможете его использовать, если транзакция упадет — он просто нигде не сохранится. Ключ идемпотентности должен быть в исходном сообщении и генерировать его должна система-отправитель.
  2. Если транзакция упадет на 5-ом шаге, то она повторится через некоторое время и во второй очереди окажется 2 одинаковых сообщения. Чтобы этого избежать нужно использовать тот же подход, который мы используем при отправке сообщения в очередь — 2ой воркер должен проверить, что запись с guid есть в БД,
Хотя второй воркер нам нужен не для поддержания надежности, а из-за других нюансов (не буду в это углубляться).

В большинстве случаев можно использовать более простые алгоритмы.
Если мы хотим гарантировать, что email не будет отправлен два раза (и при этом не сможем гарантировать, что он будет отправлен):
  1. Проверяем, что в БД нет записи об email из БД с переданным ключом идемпотентности (если есть — выбрасываем сообщение).
  2. Вставляем запись об email из БД со статусом «Новая».
  3. Коммитим транзакцию.
  4. Отправляем email.
  5. Изменяем статус записи об email на «Отправлено» (это просто атомарная операция без какой либо транзакции).
  6. Ack'аем сообщение.

Если мы хотим гарантировать, что email будет отправлен хотя бы один раз (и при этом не сможем гарантировать, что email не будет отправлен два раза):
  1. Проверяем, что в БД нет записи об email из БД с переданным ключом идемпотентности (если есть — выбрасываем сообщение).
  2. Вставляем запись об email из БД.
  3. Отправляем email.
  4. Коммитим транзакцию.
  5. Ack'аем сообщение.

Если использовать последний алгоритм, при падении коммита будут ситуации, когда письмо уже было отправлено, а записи в базе о нем еще нет (но позже она появится). Если мы отслеживаем открытие письма и изменяем при этом статус записи об email в БД, то лучше будет все-таки разделить отправку и выполнение SQL-транзакции по разным воркерам (это гарантирует нам, что email будет отправлен только после поялвения записи в БД).
Дружище! Так это самое главное — что вы жертвуете гарантированной отправкой уведомлений! Этого в явном виде нет в статье. Я это понял из комментариев и то не с первого раза.

Поясню контекст.

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

Заголовок статьи о транзакциях между mq и sql — что тоже подразумевает неизбежность выполнения или откат запланированных действий.

Применение термина идемпотентность в контексте отправки почты означает, что многократное повторение действия отправки эквивалентно однократному. Т.е. если отправляем почту данным получателям — то не важно сколько вызвался воркер, но все равно отправляем в итоге один раз. А у нас — или отправится или нет. И мы не знаем при повторах что делать а просто игнорируем дублирующиеся сообщения.

Все это привело в меня в непонимание.

Но Если мы жертвуем гарантированной отправкой сообщения — ситуация конечно меняется. Тогда вопрос стоит в выработки решения, где эти жертвы минимальны и от этого надо отталкиваться и это должно быть предметом анализа как мне кажется.
Жертвуем гарантированностью мы на этапе обработки сообщения (отправки email). И это частный случай. При отправке платежей, например, мы можем гарантировать 100% отправку, так как сервисы ОСМП идемпотенты.

Как уже писали, выше идемпотентность отправки email мы гарантировать не можем, но мы можем хотя бы гарантировать идемпотентность нашей бизнес логики, сохраняющей информацию об отправке в БД email-шлюза.

То, что мы сейчас обсуждаем выходит за рамки статьи, и в ней про это я и не собирался писать. Статья описывает как сделать распределенную транзакцию, отправляющую сообщение в очередь и сохраняющую информацию об этом в системе-отправителе. Обработка сообщения не является частью этой транзакции.
Поняно. У нас проблема в общей терминологии. Это не транзакция в каноническом смысле.

Если речь шла о том как продолжить процесс в MQ только после успешной транзакции в системе-отправителе, убрав таким образом возможные дубли процессов — то ваш метод решает задачу.
Какой стек у вас используется?
Для .NET гляньте NServiceBus и те патерны которые они предлагают для distributed applications.
NServiceBus работает как надстройка над MSMQ, который умеет интегрироваться с MS DTC.
MSMQ нас не устраивает, а для RabbitMQ NServiceBus не может сделать распределенную транзакцию.
Наверное я что-то упустил. Перечитал еще раз и не помогло. Помогите разобраться пожалуйста.

Имеем — система получатель, которая использует идентификатор сообщения как идемпотентный ключ.
Имеем — система отправитель, которая генерит этот идентификатор сообщения.

Вопрос такой — почему система отправитель не отсылает сообщение сразу в целивую очередь?

Это для того чтобы все работало в ситуации недоступности MQ, где БД является более надежной? При этом мы помещаем идентификатор сообщения в БД и переотправляем сообщение в MQ по таймауту?
Так как мы отсылаем сообщение в очередь до коммита, мы не можем гарантировать, что транзакция была завершена (коммит транзакции может упасть, хоть это и маловероятно). Поэтому нам нужна промежуточная очередь, при обработке сообщения из которой мы проверяем, что запись с идемпотентным ключом есть в БД (а значит транзакция была успешно закоммичена).

Если отправлять сообщение после коммита SQL транзакции, то может возникнуть ситуация, когда запись в БД об отправке есть, а сама отправка упала.
Если мой тезис не верный и вы не пытаетесь обезопасить себя от неработающего MQ, то пусть генерит ваша система отправитель сообщение с уникальным идентификатором и отправляет сразу в MQ.

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


Ерунду я написал какую-то. Ничего это не снизит.

Итого: если уверенность достаточна в MQ — отправляйте сразу воркеру, отправляющему письма.

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

Далее отмечать выполнение (или удаление записи) в журнале по факту выполнения задачи.

Можно еще переотправлять в MQ если достигнут таймаут ожидания завершения задачи. В самом MQ задача на отправку сообщения может иметь TTL чтобы снизить количество дублей при длительном простое MQ.

100% уверенности, что отправление сообщения в MQ не упадет, у нас нет.

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

Подход с промежуточной очередью для отправки работает быстрее и при его использовании чаще всего можно обойтись без дополнительных таблиц, так что он даже проще, чем вариант со списком задач в БД.
то список этих задач в итоге превратится в очередь. Проблемы с использованием SQL в качестве очереди я описал в конце статьи.


Конечно это так. Есть минусы. Вопрос только в контексте задачи. Если мы помещая эту задачу в SQL DB будем еще и вызывать MQ для старта процесса, а после отправки удалять, то в этой БД может быть не так много задач в итоге, требующих переотправки.

Второй хинт — это при переотправки одним потоком вытаскивать Только идентификаторы задач и по ним стартовать процесс в mq где уже пойдет распараллеливание с вытаскиванием аргументов задачи и т.д.

При таком подходе кролик осилит на обычном сервере более 1000 стартов процессов в секунду (помещений сообщений в очередь). Для задач где почтовое уведомление актуально хотя бы час (не сообщение с подтверждением регистрации), мы можем себе позволить переотправить минимум 3,6 миллиона сообщений в час.
Да это вроде рабочий вариант. Хочу только уточнить пару нюансов:
  1. Воркер, переотправляющий сообщения, должен вытаскивать только старые сообщения (например, отправленные более минуты назад), причем скорее всего придется использовать уровень изоляции ReadUncomitted. Иначе SELECT всех сообщений будет блокировать обработку текущих.
  2. Этот подход, как и наш, может добавлять дубли сообщений в очередь.

На мой взгляд вариант с промежуточной очередью все-таки проще, а также обладает следующими преимуществами:
  1. Можно обойтись без дополнительной таблицы, если в SQL транзакции и так создается сущность с уникальным ключом.
  2. Его можно использовать и для отправки писем о регистрации.
Контекст этой ветки обсуждения о том, что MQ может быть не достаточно надежным и соответственно вариант с промежуточной очередью не рассматривается. Надо городить журнал (очередь) на стороне системы.
Ну SQL мы тоже не считаем 100% надежным. Мы не стремимся обработать запрос пользователя в 100% случаев. Но если одна из операций упала (SQL транзакция или отправка в очередь), то другую операцию мы должны откатить.
Понятно что вы рассматриваете только попытку отправки сообщения в mq и транзакцию БД, а о гарантированном выполнении вы не заботитесь.

Мысль же была о том, что если операция важная (это не ваш случай), то на mq систему полагаться не стоит до определенной степени (даже если она приняла ваше сообщение). И тогда имеет смысл регистрация процессов на стороне системы отправителе для последующей переотправки.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий