Кто-то, кто заявляет о своей любви к микросервисам, вероятно, сделал это неправильно. Тот, кто находит это слегка неудобным, вероятно, делает что-то не совсем неправильное. Тот, кто это сильно ненавидит, вероятно, инженер-девопс.
При написании микросервисов легко забыть присущую им распределенную и хрупкую природу, особенно если вся команда работает только над одним сервисом. Нам нужно знать о неявных сетевых затратах и сбоях и полностью осознавать их.
Идеальный микросервис выполняет только одну задачу. Он не зависит от других сервисов, как охотник в темном лесу, не делает никаких предположений об окружающем. Он заботится только о собственной устойчивости и выживании.
С другой стороны, микросервис, который знает о своем окружении, хрупок. Чем больше приходится иметь дело с кодом вашего приложения, тем больше предположений вы должны делать. В этом посте мы рассмотрим, как можно абстрагировать распределенную транзакцию от кода приложения и позволить окружающей инфраструктуре заниматься этим.
Распределенная транзакция - это транзакция, охватывающая несколько баз данных в сети с сохранением свойств ACID. Если транзакция требует, чтобы службы A и B записывали в свою базу данных и выполняли откат в случае сбоя A или B, то это распределенная транзакция.
Его преобладание в микросервисах связано с распределенным характером архитектуры, в которой транзакции обычно неявно распределяются. Однако это относится не только к микросервисам.
Сложная проблема
Чтобы понять, почему распределенная транзакция сложна, давайте взглянем на учебник, но очень распространенный пример из реальной жизни - систему заказов и платежей.
Скажем, у нас есть система управления запасами, система заказов и платежная система, каждая из которых моделируется как микросервис. Границы четкие, система заказов принимает заказ, система инвентаризации распределяет запасы, а платежная система занимается только вопросами, связанными с оплатой и возвратом.
Транзакция с одним заказом = создание заказа + резерв + оплата в любом порядке. Сбой в любой момент во время транзакции должен отменить все, что было до него. Например, сбой платежа должен привести к тому, что система инвентаризации освободит зарезервированные запасы, а система заказов отменит заказ.

Как реализовать хрупкую распределенную транзакцию
Наивная реализация обычно использует связанный HTTP-вызов для RPC между службами, это нормально, если вы работаете над быстрой демонстрацией.
Когда поступает запрос на заказ, система заказов принимает заказ и выполняет HTTP-вызов в систему инвентаризации, чтобы зарезервировать запас. Если резервирование запаса прошло успешно, он вызывает платежную систему, чтобы попытаться произвести оплату с помощью кредитной карты, предоставленной пользователем. В противном случае запасы не зарезервированы.

Теперь, если оплата не удалась, мы должны откатить резервирование запасов и создание заказа.

Некоторые серьезные недостатки этого подхода.
- Ошибка распределенной системы - сильно зависит от стабильности сети на протяжении всей транзакции.
- Транзакции могут оказаться в неопределенном состоянии
- Уязвимость к изменениям топологии - каждая система явно знает о своей зависимости
HTTP-вызовы блокируются на неопределенный срок, представьте, что платежный сервис вызывает какой-либо сторонний API, такой как PayPal или Stripe, транзакция фактически выходит из-под вашего контроля. Что произойдет, если API упадет или будет ограничен. Или нарушение сети на сетевом пути. Или одна из трех служб не работает по любой из 1000 причин, от ошибки в приложении до обрыва подводного кабеля.
Мы могли бы установить тайм-аут клиента. Но что бы вы установили? 5 с? 10 с? 30-е годы? Любое число является произвольным и подразумевает неявное предположение о сети. Фактически, когда время ожидания соединения истекло, оно вообще ничего не говорит о состоянии транзакции, а просто делает вывод, что вызов занял больше указанного вами времени ожидания.

Конкретно, если система инвентаризации смогла зарезервировать некоторые запасы, но платежная система по какой-либо причине вышла из строя, мы не можем сказать, что платеж не состоялся. Если мы рассматриваем тайм-аут как сбой, мы бы откатили резервирование запаса и отменили заказ, но платеж действительно прошел, возможно, внешний платежный API занимает больше времени, чем обычно, или сбой в сети, поэтому мы отключили соединение до платежный сервис имеет шанс ответить. Теперь транзакция одновременно находится в состояниях "Оплачено" и "Выпущено со склада".
Все эти знания об окружающих сервисах вынуждают сервис иметь дело со спецификой, вместо того, чтобы улучшать свою общую отказоустойчивость. Эта изначальная форма распределенной транзакции во многом зависит от взаимодействия с другими службами и надежности сети. Он очень уязвим к изменениям топографии или малейшим нарушениям сети.
Некоторые могут предложить такие вещи, как экспоненциальный откат Retry, но это хорошее улучшение уже хорошо спроектированной системы, вы не можете применить его здесь как бандаж. Давайте посмотрим, как мы можем преобразовать это во что-то более надежное.
Надежная стратегия
Должен иметь следующие основные свойства:
- Нет явной межсервисной связи
- Не делает предположений о надежности сети и услуг.
- Глобальная транзакция как серия локальных транзакций ACID
- Транзакция всегда находится в определенном состоянии
- Состояние транзакции не управляется
- В конечном итоге последовательный, но, тем не менее, последовательный
- Реактивный
Мы можем добиться этого с помощью Saga Pattern, он моделирует глобальную распределенную транзакцию как серию локальных ACID-транзакций с компенсацией в качестве механизма отката. Глобальная транзакция перемещается между различными определенными состояниями в зависимости от результата выполнения локальной транзакции. Обычно существует 2 вида реализации саги.
- Оркестровка
- Хореография
Отличие заключается в способе перехода между состояниями, о первом мы поговорим в этом посте.
Сага, основанная на оркестровке
Этот тип саги является естественным развитием наивной реализации, потому что его можно постепенно внедрять.
Оркестратор
Или диспетчер транзакций, это грубая услуга, которая существует только для облегчения саги. Он отвечает за координацию глобального потока транзакций, то есть за связь с соответствующими службами, участвующими в транзакции, и за организацию необходимых компенсационных действий. Оркестратор знает о глобальной распределенной транзакции, но отдельные службы знают только о своей локальной транзакции.
Прежде чем мы перейдем к концепции компенсации, давайте немного улучшим архитектуру, удалив явную зависимость между сервисами.
Откажитесь от HTTP-звонка
Вспомните службу инвентаризации, которая застревает в ожидании платежной службы из-за RPC в середине своей локальной транзакции? В идеале локальная ACID-транзакция сервиса должна состоять всего из двух шагов:
- Локальная бизнес-логика
- Уведомить брокера о проделанной работе
Вместо того, чтобы вызывать другую службу в середине транзакции, позвольте службе выполнять свою работу в рамках своей области и публикует статус через брокера сообщений. Это все. Нет длинного синхронного блокирующего вызова где-то в середине транзакции. Мы будем использовать Kafka в качестве брокера в этом примере по причине, которая станет очевидной позже.

Для проницательного читателя да, уведомление брокера сообщений нарушает свойства ACID локальной транзакции, что как бы сводит на нет весь смысл. Что делать, если уведомление не удается? Данные уже записаны в базу данных!
Обмен сообщениями через транзакционные исходящие сообщения
Чтобы убедиться, что 2 шага находятся в одной ACID-транзакции, мы можем использовать шаблон Transactional Outbox.
Когда мы записываем результат локальной транзакции в базу данных, сообщение о выполненной работе также включается как часть транзакции в таблицу исходящих сообщений. Таким образом, локальная транзакция и уведомление находятся в одной транзакции, но четко разграничены. Сообщение о проделанной работе готово к передаче брокером сообщений для публикации.
Теперь КАК и КОГДА мы принимаем сообщение? У базы данных должен быть способ уведомить брокера сообщений о том, что что-то изменилось.
Сбор данных об изменениях
Шаблон Change Data Capture (CDC) работает именно так. Kafka Connect - отличный инструмент для сбора изменений в базе данных и потоковой передачи их в Kafka. Это платформа, которая предоставляет различные реализации соединителя поставщика, в зависимости от вашего Source, может быть база данных Postgresql, корзина S3 или даже очередь RabbitMQ. Kafka Connect предоставляет только несколько встроенных коннекторов. Если вы используете Postresql, вам необходимо установить его коннектор. Debezium предоставляет реализацию коннектора для Postgresql.

Компенсация
Теперь, когда все службы и оркестраторы не знают друг друга, и мы разобрались с проблемой локальной транзакции, как нам смоделировать глобальную распределенную транзакцию? После того, как служба выполнила свою работу, она отправляет сообщение брокеру (это может быть сообщение об успешном или неудачном завершении работы). Если Платежная система публикует сообщение об ошибке, оркестратор должен иметь возможность «откатить» действия, выполненные системой заказов и инвентаризации.
В этом случае каждая услуга должна реализовать свой вариант компенсирующей функции. Система инвентаризации, которая предоставляет функцию ReserveStock, должна также обеспечивать компенсирующую функцию ReleaseStock. Платежная система, которая предоставляет функцию Pay, также должна обеспечивать компенсационную функцию Refund. И т.п.
Затем оркестратор прослушивает события сбоя и публикует соответствующее компенсирующее событие.


Идемпотентность запроса обработки
Важно, чтобы все службы, участвующие в распределенной транзакции, правильно обрабатывали дублированный запрос, в противном случае удачи. Обычно это достигается за счет использования уникального ключа идемпотентности во всей распределенной транзакции и сохранения ключа вместе с ответом API. Всякий раз, когда появляется дикий дублированный запрос, ищите в хранилище "ключ-значение" Ключ идемпотентности и возвращайте ответ API, в противном случае рассматривайте его как новый запрос.
Это открывает целый ящик пандоры распределенного кэширования, который мы не будем здесь обсуждать.
Отношение к антихрупкости
Хотя это не совсем антихрупкость, потому что эта архитектура не процветает в присутствии события черного лебедя, но она строго придерживается основного принципа антихрупкости.
Наивная реализация будет работать идеально, вероятно, около 90% времени, но подвергнется 10% -ной катастрофе с огромными потерями (это зависит от вашего бизнеса). Архитектура, основанная на сагах, может показаться менее оптимизированной из-за в конечном итоге согласованной модели, но она терпима к колебаниям сети, и ее обратная сторона ограничена в случае катастрофы.
Заключение
Это не средство для применения «традиционной транзакции» на уровне распределенной системы. Скорее, он моделирует транзакции как конечный автомат, при этом локальная транзакция каждой службы действует как функция перехода между состояниями.
Это гарантирует, что транзакция всегда находится в одном из множества определенных состояний. В случае сбоя сети вы всегда можете решить проблему и возобновить транзакцию с последнего известного состояния.
Следуй за мной @ https://twitter.com/darrenbkl