В этой статье мы рассмотрим, как устроены транзакции в Apache Ignite. Не будем останавливаться на концепции Key-Value хранилища, а перейдем сразу к тому, как это реализовано в Ignite. Начнем с обзора архитектуры, а затем проиллюстрируем ключевые моменты логики транзакций при помощи трейсинга. На простых примерах вы увидите, как работают транзакции (и по каким причинам могут не работать).
Кластер в Игнайте — это множество серверных и клиентских узлов, где серверные узлы объединены в логическую структуру в виде кольца, а клиентские узлы присоединены к соответствующим серверным. Основное отличие клиентских узлов от серверных в том, что первые не хранят данные.
Данные, с логической точки зрения, принадлежат партициям, которые в соответствии с некоторой affinity-функцией распределены по узлам (подробнее о распределении данных в Ignite). У основных (primary) партиций могут быть копии (backups).
Архитектура кластера в Apache Ignite накладывает на механизм транзакций определенное требование: консистентность данных в распределенной среде. Это означает, что данные, находящиеся на разных узлах, должны изменяться целостно с точки зрения ACID принципов. Существует ряд протоколов, позволяющих реализовать требуемое. В Apache Ignite используется алгоритм на основе двухфазного коммита, который состоит из двух этапов:
Отметим, что, в зависимости от уровня изолированности транзакции, механизма взятия локов и ряда других параметров, детали в фазах могут изменяться.
Рассмотрим, как происходят обе фазы, на примере следующей транзакции:
После получения подтверждающих сообщений от всех узлов, содержащих primary-партиции, узел координатор транзакций отправляет Commit-сообщение, как показано на рисунке ниже.
Транзакция считается завершенной в тот момент, когда координатор транзакций получил все подтверждающие (Acknowledgment) сообщения.
Чтобы рассмотреть логику работы транзакции, обратимся к трейсингу.
Обратимся к GridGain Control Center (подробный обзор инструмента) и взглянем на получившееся дерево спанов:
На иллюстрации мы видим, что корневой спан transaction, созданный в начале вызова transactions().txStart, порождает две условных группы спанов:
Давайте теперь рассмотрим детально prepare-фазу транзакции, которая, начавшись на узле координаторе транзакций (near-узел в терминах Apache Ignite), продуцирует спан transactions.near.prepare.
Попав на primary-партицию, prepare-запрос триггерит создание transactions.dht.prepare спана, в рамках которого осуществляется отправка prepare-запросов на бекапы tx.process.prepare.req, где они обрабатываются tx.dht.process.prepare.response и отсылаются обратно на primary-партицию, которая отправляет подтверждающее сообщение на координатор транзакций, попутно создавая спан tx.near.process.prepare.response. Finish-фаза в рассматриваемом примере будет аналогична prepare-фазе, что избавляет нас от необходимости детального ее разбора.
Кликнув по любому из спанов, мы увидим соответствующую метаинформацию:
Так, например, для корневого спана transaction мы видим, что он был создан на клиентском узле 0eefd.
Мы также можем увеличить степень детализации трейсинга транзакций, включив трейсинг коммуникационного протокола.
Теперь нам доступна информация о передаче сообщений по сети между узлами кластера, что, например, поможет ответить на вопрос, не была ли вызвана потенциальная проблема нюансами сетевого взаимодействия. Не будем подробно останавливаться на деталях, отметим лишь, что множество спанов socket.write и socket.read отвечают за, соответственно, запись в сокет и чтение того или иного сообщения.
Таким образом, мы видим, что реализация протокола распределенных транзакций в Apache Ignite близка к каноничной и позволяет получить должную степень консистентности данных в зависимости от выбранного уровня изоляции транзакций. Очевидно, что дьявол кроется в деталях и большой пласт логики остался за рамками разобранного выше материала. Так, например, мы не рассмотрели механизмы работы и восстановления транзакций в случае падения узлов, участвующих в ней. Сейчас мы это исправим.
Выше мы говорили, что в контексте транзакций в Apache Ignite можно выделить три типа узлов:
и две фазы самой транзакции:
Путем нехитрых вычислений получим необходимость обработки шести вариантов падений узла — от падения бекапа на prepare-фазе до падения координатора транзакций на finish-фазе. Рассмотрим эти варианты подробнее.
Такая ситуация не требует каких-либо дополнительных действий. Данные на новые backup-узлы доедут самостоятельно в рамках ребаланса с primary-узла.
Если нет риска получить неконсистентные данные, координатор транзакций выкидывает исключение. Это является сигналом к передаче управления по принятию решения о перезапуске транзакции или иному способу разрешения проблемы клиентскому приложению.
В таком случае координатор транзакции дожидается дополнительных NodeFailureDetection сообщений, после получения которых может принять решение об успешном завершении транзакции, если данные были записаны на backup-партициях.
Наиболее интересный случай — потеря контекста транзакции. В такой ситуации primary- и backup-узлы непосредственно обмениваются локальным транзакционным контекстом друг с другом, тем самым восстанавливая глобальный контекст, что позволяет принять решение о верификации коммита. Если, например, один из узлов сообщит, что не получал Finish-сообщение, то произойдет откат транзакции.
В приведенных примерах мы рассмотрели flow транзакций, проиллюстрировав его при помощи трейсинга, который в деталях показывает внутреннюю логику. Как видите, реализация транзакций в Apache Ignite близка к классической концепции двухфазного коммита с некоторыми ухищрениями в области производительности транзакций, связанными с механизмом взятия локов, особенностями восстановления после сбоев и логикой таймаута транзакций.
Необходимое отступление: кластер в Apache Ignite
Кластер в Игнайте — это множество серверных и клиентских узлов, где серверные узлы объединены в логическую структуру в виде кольца, а клиентские узлы присоединены к соответствующим серверным. Основное отличие клиентских узлов от серверных в том, что первые не хранят данные.
Данные, с логической точки зрения, принадлежат партициям, которые в соответствии с некоторой affinity-функцией распределены по узлам (подробнее о распределении данных в Ignite). У основных (primary) партиций могут быть копии (backups).
Как устроены транзакции в Apache Ignite
Архитектура кластера в Apache Ignite накладывает на механизм транзакций определенное требование: консистентность данных в распределенной среде. Это означает, что данные, находящиеся на разных узлах, должны изменяться целостно с точки зрения ACID принципов. Существует ряд протоколов, позволяющих реализовать требуемое. В Apache Ignite используется алгоритм на основе двухфазного коммита, который состоит из двух этапов:
- prepare;
- commit;
Отметим, что, в зависимости от уровня изолированности транзакции, механизма взятия локов и ряда других параметров, детали в фазах могут изменяться.
Рассмотрим, как происходят обе фазы, на примере следующей транзакции:
Transaction tx = client.transactions().txStart(PESSIMISTIC, READ_COMMITTED);
client.cache(DEFAULT_CACHE_NAME).put(1, 1);
tx.commit();
Prepare фаза
- Узел — координатор транзакций (near node в терминах Apache Ignite) — отправляет prepare-сообщение на узлы, содержащие primary-партиции для всех ключей, принимающих участие в данной транзакции.
- Узлы с primary-партициями отправляют Prepare-сообщение на соответствующие узлы с backup-партициями, если таковые имеются, и захватывают необходимые локи. В нашем примере backup-партиций две.
- Узлы с backup-партициями отправляют Acknowledge-сообщения на узлы с primary-патрициями, которые, в свою очередь, отправляют аналогичные сообщения на узел, координирующий транзакцию.
Commit фаза
После получения подтверждающих сообщений от всех узлов, содержащих primary-партиции, узел координатор транзакций отправляет Commit-сообщение, как показано на рисунке ниже.
Транзакция считается завершенной в тот момент, когда координатор транзакций получил все подтверждающие (Acknowledgment) сообщения.
От теории к практике
Чтобы рассмотреть логику работы транзакции, обратимся к трейсингу.
Для включения трейсинга в Apache Ignite необходимо выполнить следующие шаги:
- Включим модуль ignite-opencensus и зададим OpenCensusTracingSpi как tracingSpi посредством конфигурации кластера:
<bean class="org.apache.ignite.configuration.IgniteConfiguration"> <property name="tracingSpi"> <bean class="org.apache.ignite.spi.tracing.opencensus.OpenCensusTracingSpi"/> </property> </bean>
или
IgniteConfiguration cfg = new IgniteConfiguration(); cfg.setTracingSpi( new org.apache.ignite.spi.tracing.opencensus.OpenCensusTracingSpi());
- Зададим некоторый отличный от нуля уровень сэмплирования транзакций:
JVM_OPTS="-DIGNITE_ENABLE_EXPERIMENTAL_COMMAND=true" ./control.sh --tracing-configuration set --scope TX --sampling-rate 1
или
ignite.tracingConfiguration().set( new TracingConfigurationCoordinates.Builder(Scope.TX).build(), new TracingConfigurationParameters.Builder(). withSamplingRate(SAMPLING_RATE_ALWAYS).build());
Остановимся на нескольких моментах чуть подробнее:
- Конфигурация трейсинга относится к классу экспериментальных API и потому требует включения флага
JVM_OPTS="-DIGNITE_ENABLE_EXPERIMENTAL_COMMAND=true"
- Мы задали sampling-rate равным единице, таким образом, сэмплировать будут все транзакции. Это оправдано для целей иллюстрации рассматриваемого материала, но не рекомендуется к использованию в продакшене.
- Изменение параметров трейсинга, за исключением выставления SPI, имеет динамическую природу и не требует перезапуска узлов кластера. Ниже, в соответствующем разделе, доступные параметры настройки будут разобраны более подробно.
- Конфигурация трейсинга относится к классу экспериментальных API и потому требует включения флага
- Запустим PESSIMISTIC, SERIALIZABLE транзакцию с клиентского узла на кластере из трех узлов.
Transaction tx = client.transactions().txStart(PESSIMISTIC, SERIALIZABLE); client.cache(DEFAULT_CACHE_NAME).put(1, 1); tx.commit();
Обратимся к GridGain Control Center (подробный обзор инструмента) и взглянем на получившееся дерево спанов:
На иллюстрации мы видим, что корневой спан transaction, созданный в начале вызова transactions().txStart, порождает две условных группы спанов:
- Машинерию, связанную с захватом локов, инициированную put() операцией:
- transactions.near.enlist.write
- transactions.colocated.lock.map с подэтапами
- transactions.commit, созданный в момент вызова tx.commit(), который, как ранее упоминалось, состоит из двух фаз — prepare и finish в терминах Apache Ignite (finish-фаза тождественна commit-фазе в классической терминологии двухфазного коммита).
Давайте теперь рассмотрим детально prepare-фазу транзакции, которая, начавшись на узле координаторе транзакций (near-узел в терминах Apache Ignite), продуцирует спан transactions.near.prepare.
Попав на primary-партицию, prepare-запрос триггерит создание transactions.dht.prepare спана, в рамках которого осуществляется отправка prepare-запросов на бекапы tx.process.prepare.req, где они обрабатываются tx.dht.process.prepare.response и отсылаются обратно на primary-партицию, которая отправляет подтверждающее сообщение на координатор транзакций, попутно создавая спан tx.near.process.prepare.response. Finish-фаза в рассматриваемом примере будет аналогична prepare-фазе, что избавляет нас от необходимости детального ее разбора.
Кликнув по любому из спанов, мы увидим соответствующую метаинформацию:
Так, например, для корневого спана transaction мы видим, что он был создан на клиентском узле 0eefd.
Мы также можем увеличить степень детализации трейсинга транзакций, включив трейсинг коммуникационного протокола.
Настройка параметров трейсинга
или
JVM_OPTS="-DIGNITE_ENABLE_EXPERIMENTAL_COMMAND=true" ./control.sh --tracing-configuration set --scope TX --included-scopes Communication --sampling-rate 1 --included-scopes COMMUNICATION
или
ignite.tracingConfiguration().set(
new TracingConfigurationCoordinates.Builder(Scope.TX).build(),
new TracingConfigurationParameters.Builder().
withIncludedScopes(Collections.singleton(Scope.COMMUNICATION)).
withSamplingRate(SAMPLING_RATE_ALWAYS).build())
Теперь нам доступна информация о передаче сообщений по сети между узлами кластера, что, например, поможет ответить на вопрос, не была ли вызвана потенциальная проблема нюансами сетевого взаимодействия. Не будем подробно останавливаться на деталях, отметим лишь, что множество спанов socket.write и socket.read отвечают за, соответственно, запись в сокет и чтение того или иного сообщения.
Обработка исключений и восстановление после сбоев
Таким образом, мы видим, что реализация протокола распределенных транзакций в Apache Ignite близка к каноничной и позволяет получить должную степень консистентности данных в зависимости от выбранного уровня изоляции транзакций. Очевидно, что дьявол кроется в деталях и большой пласт логики остался за рамками разобранного выше материала. Так, например, мы не рассмотрели механизмы работы и восстановления транзакций в случае падения узлов, участвующих в ней. Сейчас мы это исправим.
Выше мы говорили, что в контексте транзакций в Apache Ignite можно выделить три типа узлов:
- Координатор транзакций (near node);
- Узел с primary-партицией для соответствующего ключа (primary node);
- Узлы с backup-партициями ключей (backup nodes);
и две фазы самой транзакции:
- Prepare;
- Finish;
Путем нехитрых вычислений получим необходимость обработки шести вариантов падений узла — от падения бекапа на prepare-фазе до падения координатора транзакций на finish-фазе. Рассмотрим эти варианты подробнее.
Падение бекапа как на prepare, так и на finish-фазах
Такая ситуация не требует каких-либо дополнительных действий. Данные на новые backup-узлы доедут самостоятельно в рамках ребаланса с primary-узла.
Падение primary-узла на prepare-фазе
Если нет риска получить неконсистентные данные, координатор транзакций выкидывает исключение. Это является сигналом к передаче управления по принятию решения о перезапуске транзакции или иному способу разрешения проблемы клиентскому приложению.
Падение primary-узла на finish-фазе
В таком случае координатор транзакции дожидается дополнительных NodeFailureDetection сообщений, после получения которых может принять решение об успешном завершении транзакции, если данные были записаны на backup-партициях.
Падение координатора транзакций
Наиболее интересный случай — потеря контекста транзакции. В такой ситуации primary- и backup-узлы непосредственно обмениваются локальным транзакционным контекстом друг с другом, тем самым восстанавливая глобальный контекст, что позволяет принять решение о верификации коммита. Если, например, один из узлов сообщит, что не получал Finish-сообщение, то произойдет откат транзакции.
Резюме
В приведенных примерах мы рассмотрели flow транзакций, проиллюстрировав его при помощи трейсинга, который в деталях показывает внутреннюю логику. Как видите, реализация транзакций в Apache Ignite близка к классической концепции двухфазного коммита с некоторыми ухищрениями в области производительности транзакций, связанными с механизмом взятия локов, особенностями восстановления после сбоев и логикой таймаута транзакций.