- PVSM.RU - https://www.pvsm.ru -
Автор: Денис Цыплаков [1], Solution Architect, DataArt
Одной из проблем при построении микросервисных архитектур и особенно при миграции монолитной архитектуры на микросервисы часто становятся транзакции. Каждый микросервис отвечает за собственную группу функций, возможно, управляет данным, ассоциированными с этой группой, и может обслуживать запросы пользователя либо автономно, либо посылая запросы другим микросервисам. Все это прекрасно работает, пока нам не требуется обеспечить консистентность данных, которыми управляют разные микросервисы.
Например, наше приложение работает в каком-то большом интернет-магазине. Кроме всего прочего, у нас есть три отдельных, слабо связанных между собой бизнес-области:
Каждая из этих трех областей включают множество непересекающихся функций и может быть представлена в виде нескольких микросервисов.
Есть одна проблема. Предположим, человек купил товар, товар упаковали и отправили курьером. Кроме прочего, нам надо указать, что на складе стало на одну единицу товара меньше, отметить, что процесс доставки товара начался, и если товар отправляется, скажем, в Китай, позаботиться о бумагах для таможни. Если в приложении происходит сбой (например, крэшится нода) на второй или третьей стадии процесса, наши данные приходят в неконсистентное состояние, и всего несколько таких сбоев могут привести к достаточно неприятным проблемам для бизнеса (например, визиту таможенников).
В классической монолитной архитектуре такого рода проблема просто и элегантно решается транзакциями в базе данных. Но как быть, если мы используем микросервисы? Даже если мы из всех сервисов используем одну БД (что не очень изящно, но в нашем случае возможно), работа с этой БД идет из разных процессов, и растянуть транзакцию между процессами у нас не получится.
У проблемы есть несколько решений:
В большинстве случаев последний вариант оказывается наиболее приемлемым. Он не сильно усложняет обрабатывающий запрос, правда, работает в несколько раз дольше, но, как правило, это приемлемо для такого рода операцией. Он также требует чуть более сложной организации данных для отсечения повторных запросов, но в этом тоже ничего суперсложного нет.
Схематически один из вариантов обработки транзакций с использованием очередей и Eventual consistency может выглядеть так:
При этом каждое сообщение, получаемое сервисом, проверяется на уникальность, и если сообщение с таким UUID уже было обработано, игнорируется.
Здесь база (базы) данных в каждый момент времени находится в немного неконсистентном состоянии, т. е. товар на складе уже отмечен как находящийся в процессе доставки, но собственно задания на доставку еще нет, оно появится через секунду-другую. Но при этом у нас есть 99.999 % (по сути, это число равняется уровню надежности сервиса очередей) гарантии, что задание на отправку появится. Для большинства бизнесов это приемлемо.
В статье я хочу рассказать об еще одном способе решения проблемы транзакционности в микросервисных приложениях. Несмотря на то, что микросервисы лучше всего работают, когда у каждого сервиса своя база данных, для небольших и среднего размера систем все данные, как правило, легко умещаются в современную реляционную базу данных. Это справедливо практически для любой внутренней системы предприятия. Т. е. часто жесткой необходимости разделять данные между разными физическими машинами у нас нет. Мы можем хранить данные разных микросервисов в несвязанных между собой группах таблиц одной базы. Это особенно удобно, если вы разделяете на сервисы старое, монолитное приложение и уже разделили код, но данные все еще живут в одной базе. Однако проблема разделения транзакций все еще остается — транзакция жестко привязана к сетевому соединению и, соответственно, процессу, который открыл это соединение, а процессы у нас разделены. Как быть?
Выше я описал несколько распространенных способов решения проблемы, но далее хочу предложить еще один способ для частного случая, когда все данные лежат в одной базе. Этот способ я не рекомендую пытаться реализовать в настоящем проекте, но он достаточно любопытен, чтобы я его изложил в статье. Ну и вдруг он все же пригодится в каком-то частном случае.
Суть его очень проста. Транзакция ассоциирована с сетевым соединением, при этом база данных на самом деле не знает, кто сидит на том конце открытого сетевого соединения. Ей все равно, главное, чтобы в сокет поступали корректные команды. Понятно, что обычно сокет принадлежит эксклюзивно одному процессу на стороне клиента, но я вижу как минимум три способа это обойти.
На уровне кода базы данных для баз данных, код которых мы можем менять, делая свою сборку БД, реализуем механизм передачи транзакции между соединениями. Как это может работать с точки зрения клиента:
Этот способ самый легковесный в использовании, но требует модификации кода БД, прикладные программисты этим обычно не занимаются, для этого требуется много специальных навыков. Передавать данные, скорее всего, придется между процессами БД, да и баз данных, код которых мы можем спокойно поменять по большому счету одна — PostgreSQL. Вдобавок, работать это будет только для unmanaged-серверов, в RDS или Cloud SQL с этим не пойдешь.
Схематично это выглядит так:
Второе, что приходит в голову, — тонкая манипуляция сокетами соединений с базой данных. Мы можем сделать некий “Reverse socket proxy”, который направляет команды, поступающие от нескольких клиентов, на определенный порт в один поток команд к БД.
По сути это приложение, очень похожее на pgBouncer, только, вдобавок к его стандартному функционалу, делающее некоторые манипуляции с потоком байтов от клиентов и умеющее по команде подставлять один клиент вместо другого.
Этот способ мне категорически не нравится, для его реализации необходимо подчищать бинарные пакеты, циркулирующие между сервером и клиентами. И он все еще требует много системного программирования. Привел я его исключительно для полноты списка.
Мы можем сделать gateway JDBC-драйвер — берем стандартный JDBC-драйвер для конкретной БД, пусть это будет PostgreSQL. Оборачиваем класс и ко всем его внешним методам делаем HTTP-интерфейсы (можно и не HTTP, но разница небольшая). Далее мы делаем еще один JDBC-драйвер — фасад, который все вызовы методов перенаправляет в gateway JDBС. Т. е. по сути мы распиливаем существующий драйвер на две половинки и связываем эти половинки по сети. Получаем вот такую схему компонент:
NB!: Как мы видим, все три варианта похожи, разница заключается только в том, на каком уровне мы осуществляем передачу соединения и каким инструментарием для этого пользуемся.
После этого мы учим наш драйвер делать по сути тот же трюк с UUID-транзакцией, который описан в способе 1.
В коде Java-приложения использование этого способа может выглядеть следующим образом.
Ниже приведен код некоторого сервиса, который начинает транзакцию, делает изменения в БД и передает ее дальше другому сервису для завершения. В коде мы используем прямую работы с JDBC-классами. В 2019 году так, конечно, никто не делает, но для простоты примера код упрощен.
// В реальном приложении мы, конечно, соединение “руками”
// не открываем
Class.forName("org.postgresql.FacadeDriver");
var connection = DriverManager.getConnection(
"jdbc:postgresqlfacade://hostname:port/dbname","username", "password");
// Делаем какие-то изменения в БД
statement = dbConnection.createStatement();
var statement.executeUpdate(“insert ...”);
/* Все, мы сделали изменения и готовы передать транзакцию дальше.
transactionUUID(int) Это псевдо-функция, до БД она не доходит, а
обрабатывается JDBC gateway-сервисом. В ResultSet возвращается
единственная строка с одним полем типа Varchar, содержащим UUID.
После выполнения этого запроса соединение блокируется и на
все запросы возвращает ошибку. Чтобы его разблокировать, надо
дать команду на возобновления транзакции с данным UUID.
Число 60 — это таймаут, после которого транзакция откатывается.
В реальных приложениях такие запросы делаются с помощью, например,
JDBCTemplate. Здесь для иллюстративности у меня ResultSet
*/
var rs = statement.executeQuery(“select transactionUUID(60)”);
String uuid = extractUUIDFromResultSet(rs);
// передаем дальнейшую обработку удаленному сервису
remoteServiceProxy.continueProcessing(uuid, otherParams);
// Больше в рамках этого соединения никаких операций совершать нельзя
// освобождаем все ресурсы и выходим.
closeEverything();
return;
// Здесь мы продолжаем с места, где был вызван метод
// remoteServiceProxy.continueProcessing(...)
// Точно так же открываем соединение.
Class.forName("org.postgresql.FacadeDriver");
var connection = DriverManager.getConnection(
"jdbc:postgresqlfacade://hostname:port/dbname","username", "password");
// Теперь нам надо сказать Gateway JDBC, что мы продолжаем
// транзакцию. Команда continue transaction не идет в БД, а обрабатывается
// gateway JDBC
statement = dbConnection.createStatement();
statement.executeUpdate(“continue transaction ”+uuid);
// Все, мы подсоединились к транзакции, стартовавшей в другом сервисе и
// можем продолжать наполнять базу данными
statement.executeUpdate(“update ...");
// Завершаем транзакцию
connection.commit();
return;
Рассмотрим возможные побочные эффекты такого архитектурного решения.
Поскольку на самом деле настоящий пул коннектов у нас будет находится внутри JDBC gateway — пулы конектов в сервисах лучше вообще выключить, т. к. они будут захватывать и держать внутри сервиса коннект, который мог бы быть использован другим сервисом.
Плюс к этому, после получения UUID и ожидания передачи в другой процесс соединение по сути становится нерабочим, и с точки зрения frontend JDBC, оно авто-закрывается, а с точки зрения gateway JDBC, его надо удерживать, не отдавая никому, кроме того, кто придет с нужным UUID.
Другими словами, двойной менеджмент пула конектов в Gateway JDBC и внутри каждого из сервисов может давать сложно уловимые, неприятные ошибки.
С JPA я вижу две возможные проблемы:
Механизм управления транзакциями фреймворка Spring, пожалуй, задействовать не получится, и управлять ими вам придется вручную. Почти уверен, что его можно расширить — например, написать custom scope — но чтобы сказать наверняка, надо изучить, как там устроено расширение Spring Transactions, а я туда пока не смотрел.
Еще раз предупреждаю: не пытайтесь повторить этот трюк дома в настоящем проекте, если у вас нет очень четкого объяснения, зачем вам это надо, и убедительных доказательств, что по-другому вообще никак нельзя.
Всех с первым апреля!
Автор: DataArt
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/313319
Ссылки в тексте:
[1] Денис Цыплаков: https://habr.com/ru/users/semenych/
[2] Источник: https://habr.com/ru/post/446288/?utm_campaign=446288
Нажмите здесь для печати.