! ?

Транзакционные стратегии

Автор: Mark Richards, Director and Sr. Technical Architect, Collaborative Consulting, LLC
Опубликовано: 09.07.2010
Версия текста: 1.1
Стратегия высокого параллелизма
Компромиссы
Основная структура и характеристики
Реализация транзакционной стратегии
Реализация
Заключение
Стратегия высокой производительности
Локальные транзакции и компенсирующие фреймворки
Компромиссы и проблемы
Существующие компенсирующие фреймворки
Заключение

Стратегия высокого параллелизма

Стратегии слоя API и Client Orchestration, которые рассматривались в предыдущей статье, – это базовые стратегии, применимые в большинстве стандартных бизнес-приложений. Они просты, надежны, их относительно легко реализовать, они предоставляют высочайший уровень целостности и непротиворечивости данных. Однако может случиться так, что вам понадобится снизить область видимости транзакции для увеличения пропускной способности и производительности, а также улучшения параллельного доступа к БД. Как добиться этого, по-прежнему поддерживая высокий уровень целостности и непротиворечивости данных? Ответ – использовать транзакционную стратегию высокого параллелизма.

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

Как и стратегия слоя API, стратегия высокого параллелизма освобождает клиентский слой от всякой ответственности за транзакции. Это, однако, значит, что вы можете выполнять только один вызов из клиентского слоя для каждой логической единицы работы (logical unit of work, LUW). Стратегия высокого параллелизма призвана уменьшить охват транзакции, чтобы ресурсы блокировались на меньшее время, тем самым увеличивая пропускную способность и повышая характеристики паралеллизма и производительность.

Выгоды от использования такой стратегии во многом зависят от того, какую БД вы используете и как она сконфигурирована. Некоторые СУБД (такие, как Oracle и MySQL, использующий движок InnoDB), не выполняют реальных блокировок, в отличие, например, от SQL Server без уровня изоляции Snapshot. Чем больше блокировок, неважно, с обеспечением совместного доступа или эксклюзивных, тем больше это повлияет на параллелизм, производительность и пропускную способность БД (и, следовательно, на ваше приложение).

Но получение и удерживание блокировок в БД – это только часть истории с высоким параллелизмом. На параллелизм и пропускную способность влияет также то, когда вы снимаете блокировки. Независимо от используемой СУБД, затягивание транзакции сверх необходимого приводит к удерживанию блокировок дольше, чем нужно. При высоком параллелизме это может вызвать эскалацию блокировок с уровня строк на уровень страниц, и – в исключительных обстоятельствах – с уровня страниц до уровня таблицы. В большинстве случаев у вас нет контроля над эвристиками, используемыми движком СУБД для определения, когда выполняется эскалация уровня блокировки. Некоторые СУБД (например, SQL Server) позволяют отключить блокировку на уровне страниц, в надежде, что не произойдет эскалации блокировок с уровня строки до уровня таблицы. Иногда такое срабатывает, но в большинстве случаев вы не заметите улучшений в параллельном доступе, на который, возможно, надеялись.

В итоге, в сценариях с высоким параллелизмом, чем дольше удерживаются блокировки (эксклюзивные или нет), тем больше шансов, что возникнет одна из следующих проблем:

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

Компромиссы

Стратегия высокого параллелизма реагирует на требования высокого параллелизма снижением охвата транзакции до уровня, минимально возможного в данной архитектуре. Это приводит к более быстрому завершению (фиксации или откату) транзакции, чем при использовании стратегии слоя API. Однако, как учит нас история хорошего корабля Vasa (см. Ресурсы), вы не можете иметь все и сразу. Жизнь состоит из компромиссов, и обработка транзакций – не исключение. Не можете же вы впрямь рассчитывать одновременно и на такой же уровень надежности обработки транзакций, что и в стратегии слоя API, и на максимальное количество параллельных обращений и максимальную пропускную способность при пиковых нагрузках.

Так чем же придется пожертвовать при использовании стратегии высокого параллелизма? В зависимости от дизайна приложения, вам, возможно, придется выполнять операции чтения вне транзакций, даже когда операция чтения выполняется в целях изменения данных. «Минуточку!» – скажете вы: «Так нельзя – это может кончиться изменением данных, которые уже были изменены после их чтения!» Это правильное замечание, и заодно – это точка старта игры в компромиссы. При этой стратегии, поскольку вы не удерживаете блокировок данных на чтение, шансы на получение исключений, говорящих об устаревших данных, при операциях обновления увеличивается. Однако, как и в случае корабля Vasa, придется выбирать, какие характеристики важнее: надежная, пуленепробиваемая транзакционная стратегия (типа стратегии слоя API) или высокий параллелизм и пропускная способность. В ситуациях с большим количеством параллельных обращений очень трудно обеспечить и то, и другое, а если попытаться достичь этого, может оказаться, что приложение не делает хорошо ни того, ни другого.

Второй компромисс – это общая нехватка транзакционной надежности. Эту стратегию трудно реализовать, долго разрабатывать и тестировать, она больше подвержена ошибкам, чем стратегии слоя API или Client Orchestration. Учитывая все это, стоит сперва проанализировать текущую ситуацию, чтобы определить, является ли использование этой стратегии правильным выбором. Поскольку стратегия высокого параллелизма – производная от стратегии слоя API, неплохо начать со стратегии слоя API и испытать приложение под высокой нагрузкой (выше ожидаемой пиковой загрузки). Если окажется, что пропускная способность недостаточна, производительность низка, время ожидания велико (вплоть до зависания), можно переходить к стратегии высокого параллелизма.

Далее я покажу некоторые характеристики стратегии высокого параллелизма и два подхода к ее реализации.

Основная структура и характеристики

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


Рисунок 1. Архитектурные слои и транзакционная логика.

Некоторые из правил и характеристик стратегии слоя API применяются – но не все. Заметьте, что клиентский слой на рисунке 1 не содержит транзакционной логики. Это значит, что в этой транзакционной стратегии могут применяться клиенты любого типа, включая Web-клиенты, Web-сервисы и Java Message Service (JMS). Как видите, транзакционная стратегия распределена по слоям, лежащим ниже клиента, но не полностью. Некоторые транзакции могут начинаться в слое API, некоторые – в бизнес-слое, а некоторые даже в слое DAO. Такая непоследовательность – одна из причин того, что эту стратегию трудно реализовать, поддерживать и регулировать.

В большинстве случаев вы обнаружите, что вам требуется использовать модель программных транзакций, чтобы снизить охват транзакций, но иногда можно использовать и модель декларативных транзакций. Однако нельзя смешивать две эти модели в одном приложении. Хорошая мысль – придерживаться при использовании этой стратегии модели программных транзакций, чтобы не получить проблем по дороге. Если же вы считаете, что сможете использовать модель декларативных транзакций, не забудьте пометить все public-методы записи (insert, update и delete) во всех слоях, начинающие транзакции, транзакционным атрибутом REQUIRED. Этот атрибут указывает, что нужна транзакция, и что она будет начата методом, если еще не существует.

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

Реализация транзакционной стратегии

Для реализации транзакционной стратегии высокого параллелизма можно использовать два пути. Техника read-first включает группировку операций чтения вне транзакции на самом высоком из возможных слое приложения (обычно слое API). Низкоуровневая техника lower-level включает начало транзакции на самом низком из возможных архитектурных слоев, где еще возможно поддерживать атомарность и изоляцию операций обновления.

Техника read-first

Техника read-first включает рефакторинг (или написание) логики приложения и workflow, чтобы вся обработка и операции чтений выполнялись до начала транзакции. Этот подход исключает ненужные блокировки, но может привести к исключениям из-за устаревших данных, если данные будут обновлены до того, как вы зафиксируете свою работу. Чтобы справиться с этим, используйте версионность, если вы используете какой-либо ORM-фреймворк.

Чтобы проиллюстрировать технику read-first, я начну с кода, реализующего транзакционную стратегию слоя API. В листинге 1 транзакция начинается в слое API и заключает в себе всю единицу работы, включая все операции чтения, обработку и обновление.

Листинг 1. Использование стратегии слоя API
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void processTrade(TradeData trade) throws Exception 
{
   try 
   {
      // сперва проверяем и вставляем сделку
      TraderData trader =          service.getTrader(trade.getTraderID());
      validateTraderEntitlements(trade, trader);
      verifyTraderLimits(trade, trader);
      performPreTradeCompliance(trade, trader);
      service.insertTrade(trade);

      // теперь изменяем счет
      AcctData acct = service.getAcct(trade.getAcctId());
      verifyFundsAvailability(acct, trade);
      adjustBalance(acct, trade);
      service.updateAcct(trade);

      // последующая обработка
      performPostTradeCompliance(trade, trader);
   } 
   catch (Exception up) 
   {
      ctx.setRollbackOnly();
      throw up;
   }
}

Заметьте, что в листинге 1 вся обработка заключена в транзакции Java Transaction API (JTA), включая все проверки. Если запустить метод processTrade() под профилировщиком, можно увидеть время исполнения, аналогичное показанному в таблице 1.

Таблица 1.

Название метода

Время исполнения (ms)

Service.getTrader()

100

validateTraderEntitlements()

300

verifyTraderLimits()

500

performPreTradeCompliance()

2300

Service.insertTrade()

200

Service.getAcct()

100

verifyFundsAvailability()

600

adjustBalance()

100

Service.updateAcct()

100

performPostTradeCompliance()

1800

Метод processTrade() выполняется немного больше 6 секунд (6100 ms). Поскольку транзакция начинается в начале исполнения метода и заканчивается – в конце, продолжительность транзакции – также 6100 ms. В зависимости от типа используемой БД и ее настроек вы будете использовать разделяемые или эксклюзивные блокировки на всем протяжении транзакции (с начала операций чтения). Более того, любая операция чтения, выполняемая из метода, вызываемого методом processTrade(), также должна удерживать блокировки в БД. Как можно догадаться, удерживание блокировок в БД на протяжении 6 секунд в данном случае не масштабируется для поддержки высокой нагрузки.

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

Теперь я исправлю код из листинга 1, применив технику read-first транзакционной стратегии высокого параллелизма. Первое, на что можно обратить внимание в листинге 1 – операции insert и update занимают вместе только 300 ms (я предполагаю, что другие методы, вызываемые методом processTrade() не выполняют операций обновления). Базовая техника состоит в выполнении операций чтений и не связанной с изменением данных обработки вне транзакции, и в помещении в транзакцию только операций изменения данных. Код в листинге 2 демонстрирует рефакторинг, необходимый для уменьшения охвата транзакции с сохранением атомарности:

Листинг 2. Использование стратегии высокого параллелизма (техника read-first)
public void processTrade(TradeData trade) throws Exception 
{
   UserTransaction txn = null;
   try 
   {
      // Сначала проверяем сделку
      TraderData trader = service.getTrader(trade.getTraderID());
      validateTraderEntitlements(trade, trader);
      verifyTraderLimits(trade, trader);
      performPreTradeCompliance(trade, trader);

      // Теперь изменяем счет
      AcctData acct = service.getAcct(trade.getAcctId());
      verifyFundsAvailability(acct, trade);
      adjustBalance(acct, trade);
      performPostTradeCompliance(trade, trader);

      // Начало транзакции и выполнение изменений
      txn = (UserTransaction)ctx.lookup("UserTransaction");
      txn.begin();
      service.insertTrade(trade);
      service.updateAcct(trade);
      txn.commit();
   } 
   catch (Exception up) 
   {
      if (txn != null) 
      {
         try 
         {
            txn.rollback();
         } 
         catch (Exception t) 
         {
            throw up;
         }
      }
      throw up;
   }
}

Заметьте, что я поместил методы insertTrade() и updateAcct() в конец метода processTrade() и обернул их в программную транзакцию. Продолжительность транзакции в новом коде – всего 300 ms, намного меньше, чем 6100 ms в Листинге 1. Еще раз, цель состоит в снижении времени, проводимого в БД, за счет чего повышается общий параллелизм работы с БД и, следовательно, возможность приложения работать параллельно с большим числом пользователей. Благодаря тому, что код из листинга 2 проводит в БД только 300 ms, вы можете (теоретически) получить в 20 раз большую пропускную способность.

Как вы можете видеть в таблице 2, исполнение кода в пределах транзакции занимает всего 300 ms:

Таблица 2.

Название метода

Время исполнения (ms)

service.insertTrade()

200

service.updateAcct()

100

Хотя это и существенное улучшение с точки зрения параллелизма работы с БД, техника read-first содержит риск: поскольку объекты, предназначенные для обновления, не блокируются, кто угодно может изменить эти не блокированные сущности в течение данной LUW. Поэтому нужно гарантировать, что объекты, вставляемые или изменяемые в БД, не изменяются более чем одним пользователем одновременно. В предыдущем сценарии безопасно можно предположить, что только один трейдер будет работать с конкретной сделкой и счетом в любой момент времени. Однако это не всегда верно, и могут возникать исключения из-за устаревания данных.

Еще одно: при использовании Enterprise JavaBeans (EJB) 3.0, вы должны сказать контейнеру, что планируете использовать программное управление транзакциями. Это можно сделать с помощью аннотации @TransactionManagement(TransactionManagementType.BEAN). Обратите внимание, что это аннотация уровня класса (а не уровня метода), показывающая, что вы не можете совмещать декларативную и программную модели транзакций в одном классе. Выберите одну из двух и придерживайтесь ее.

Низкоуровневая техника

Предположим, что вы хотите использовать модель декларативных транзакций, чтобы упростить обработку транзакций и увеличить пропускную способность в сценарии с большим количеством параллельных обращений пользователей. В этом случае вы будете использовать в этой транзакционной стратегии технику lower-level. Эта техника предполагает те же компромиссы, что и техника read-first: операции чтения обычно выполняются вне контекста транзакции. Реализация этой техники скорее всего потребует рефакторинга кода.

Я снова начну с примера из листинга 1. Вместо использования программных транзакций в том же методе можно переместить операции обновления в другой public-метод в стеке вызовов. Тогда, когда с операциями чтения и обработки будет покончено, вы сможете вызвать новый метод обновления; он начнет транзакцию, вызовет метод обновления и завершится. Эта техника показана в листинге 3.

Листинг 3. Использование стратегии высокого параллелизма (техника lower-level)
@TransactionAttribute(TransactionAttributeType.SUPPORTS)
public void processTrade(TradeData trade) throws Exception 
{
   try 
   {
      // Сначала проверяем сделку
      TraderData trader = service.getTrader(trade.getTraderID());
      validateTraderEntitlements(trade, trader);
      verifyTraderLimits(trade, trader);
      performPreTradeCompliance(trade, trader);

      // Теперь изменяем счет
      AcctData acct = 
        service.getAcct(trade.getAcctId());
      verifyFundsAvailability(acct, trade);
      adjustBalance(acct, trade);
      performPostTradeCompliance(trade, trader);

      // Выполнение изменений
      processTradeUpdates(trade, acct);
   } 
   catch (Exception up) 
   {
      throw up;
   }
}

@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void processTradeUpdates
   (TradeData trade, AcctData acct) throws Exception 
{
   try 
   {
      service.insertTrade(trade);
      service.updateAcct(trade);
   } 
   catch (Exception up) 
   {
      ctx.setRollbackOnly();
      throw up;
   }
}

При такой технике вы начинаете транзакцию на самом нижнем уровне стека вызовов, тем самым уменьшая время, проводимое в БД. Заметьте, что метод pro­cessTradeUpdates() изменяет только сущности, измененные или созданные родительским методом (или вышележащим). Опять же, вместо транзакции продолжительностью в 6 секунд вы получаете транзакцию длиной всего в 300 ms.

Теперь о грустном. В отличие от стратегий слоя API или Client Orchestration, стратегия высокого параллелизма не имеет какого-то последовательного подхода к реализации. Поэтому-то рисунок 1 и выглядит как лицо опытного хоккеиста (включая выбитые зубы). При некоторых вызовах API транзакция должна начинаться и заканчиваться в слое API, а в других случаях она может ограничиваться только слоем DAO (например, при обновлениях одиночных таблиц в пределах LUW). Сложность в том, чтобы идентифицировать методы, которые разделены между множеством клиентских запросов, и гарантировать, что, если транзакция была начата в методе более высокого уровня, она будет использоваться и на более низких уровнях. К сожалению, эффект может оказаться таким, что метод более низкого уровня, не являющийся владельцем транзакции, может откатить транзакцию в случае исключения. В результате родительский метод, начавший транзакцию, не может выполнить корректирующие действия для этого исключения и получит исключение, если попытается откатить (или фиксировать) транзакцию, уже помеченную для отката.

Реализация

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

Заключение

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

Стратегия высокой производительности

В предыдущих частях этой серии вы узнали о том, как реализовать три транзакционные стратегии:

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

Транзакции необходимы, чтобы обеспечить высокий уровень целостности и непротиворечивости данных. Но транзакции дороги; они потребляют ценные ресурсы и могут замедлять приложения. Если у вас есть высокоскоростное приложение, где каждая миллисекунда на счету, вы можете до некоторой степени соблюсти принципы ACID (атомарность, целостность, изоляция и надежность), реализуя транзакционную стратегию высокой производительности. Как будет показано ниже, стратегия высокой производительности не так надежна, как остальные транзакционные стратегии, и это не лучший выбор для всех случаев использования высокопроизводительных приложений. Но в некоторых случаях эта стратегия позволит вам обеспечить самую высокую из возможных скорость обработки и все еще поддерживать некоторую степень целостности и непротиворечивости данных.

Локальные транзакции и компенсирующие фреймворки

ПРИМЕЧАНИЕ

Локальные транзакции в EJB 3

Чтобы использовать локальные транзакции с сессионными бинами EJB 3, в начале сессии используется аннотация @TransactionManagement(Transaction­Management­Type.BEAN), говорящая контейнеру не обрабатывать транзакции. Эта аннотация не позволяет менеджеру транзакций контейнера брать на себя управление обработкой транзакций.

С точки зрения хранения данных, самый быстрый способ выполнить операцию обновления БД – использование локальных транзакций совместно с хранимыми процедурами. Локальные транзакции (иногда именуемые также транзакциями БД) – это транзакции, которые управляются СУБД, а не контейнером. Вам не нужно при этом писать транзакционную логику в приложении (например, аннотацию @Transactional в Spring или аннотацию @TransactionAttribute в EJB 3).

Хранимые процедуры работают быстро потому, что они предварительно скомпилированы и хранятся на сервере БД. Они не обязательны в стратегии высокой производительности, и их эффективность и производительность породили несколько интересных споров (см. "So, are Database Stored Procedures Good or Bad?" в Ресурсах). Использование хранимых процедур может снизить переносимость приложения, увеличить сложность и снизить общую возможность изменять приложение. Но в целом они быстрее, чем JDBC SQL-выражения, и если производительность имеет большее значение, чем переносимость и простота поддержки – это хороший выбор. К тому же, ничто не мешает использовать любой JDBC-фреймворк вместе с простым SQL, если это нужно.

Если локальные транзакции так быстры, почему они не используются повсеместно? Основная причина в том, что если не использовать такую технику, как connection passing, не получится соблюсти традиционные ACID-свойства транзакций. При использовании локальных транзакций операции обновления БД трактуются как индивидуальные единицы работы, а не как единое целое. Другое ограничение локальных транзакций состоит в том, что их нельзя использовать с традиционными ORM-фреймворками, например, Hibernate, TopLink или OpenJPA. Вы ограничены такими JDBC-фреймворками, как iBATIS или Spring JDBC (см. Ресурсы), или же собственноручно написанным DAO-фреймворком.

ПРИМЕЧАНИЕ

Connection passing

Connection passing – это техника, при которой в отсутствие какого-либо надежного контейнерного менеджера транзакций вы выставляете флаг autocommit у объекта Connection в false и передаете подключение к БД между вызовами методов. Закончив с подключением, вы выполняете commit() у объекта Connection, чтобы зафиксировать изменения, или rollback(), чтобы отменить их. Использование передачи подключений в целом плохая идея, в основном потому, что вы пытаетесь работать за менеджер транзакций контейнера, но менее эффективно и с большей вероятностью ошибки. Если вы обнаружите, что используете передачу подключений, вам лучше переключиться на программную или декларативную модель транзакций.

Стратегия высокой производительности основана на использовании локальных транзакций. Но подождите – если модель локальных транзакций не поддерживает основные ACID-свойства, как же может транзакционная стратегия, основанная на этой модели, быть хорошей стратегией? Ответ в том, что транзакционная стратегия высокой производительности использует модель локальных транзакций вместе с компенсирующим фреймворком. Каждая индивидуальная операция обновления БД существует независимо, но в случае ошибки компенсирующий фреймворк обеспечивает откат индивидуальных транзакций, тем самым поддерживая атомарность и согласованность.

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


Рисунок 2. Локальные транзакции без компенсирующего фреймворка

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


Рисунок 3. Локальные транзакции с компенсирующим фреймворком.

Эту технику часто называют «расслабленным ACID» (relaxed ACID). Это типичное транзакционное решение для таких случаев, как долго выполняющиеся транзакции в сервисно-ориентированной архитектуре, использующей BPEL (Business Process Execution Language) для оркестровки процессов или для использования Web-сервисов. В некоторых случаях завершение транзакционной единицы работы может занимать минуты, часы, а в некоторых случаях даже дни. Нереалистично предполагать, что ресурсы могут быть заняты столько времени. Кроме того сложно (а иногда невозможно) распространять транзакцию на разные гетерогенные платформы или по HTTP (как в случае Web-сервисов).

Концепция «расслабленного ACID» применима и к короткоживущим транзакциям. В случае транзакционной стратегии высокой производительности продолжительность транзакции измеряется в секундах, а не в минутах. Однако к ней применимы те же принципы – в критических ситуациях нужно максимизировать параллелизм базы данных и минимизировать время ожидания и продолжительность обработки. Более того, требуется использовать наиболее быстрые средства из возможных для выполнения операций обновления БД. Это достигается с помощью использования локальных транзакций и хранимых процедур. Компенсирующий фреймворк нужен только на случай ошибки; если все идет нормально, он ни во что не вмешивается. Параллелизм работы БД – на максимуме, операции обновления выполняются самым быстрым способом из возможных, а в случае ошибки обо всем за вас позаботится компенсирующий фреймворк. Похоже на нирвану, правда? Однако это не она.

Компромиссы и проблемы

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

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

Некоторые деловые ситуации или технологии несовместимы с транзакционной стратегией высокой производительности. В частности, сценарии асинхронной обработки становятся крайне высокорискованными при использовании компенсирующих фреймворков и «расслабленного ACID». В некоторых обстоятельствах вам придется обменять некоторую часть производительности на более медленную, но поддерживающую ACID транзакционную стратегию. Другой компромисс при использовании этой стратегии состоит в том, что вы не сможете использовать ORM-фреймворки, требующие программной или декларативной модели транзакций. Это не ограничивает вас простым JDBC-кодом; существует множество JDBC-фреймворков, включая iBATIS (фреймворк SQL-отображения с открытым исходным кодом) и Spring JDBC. Кроме того, вы можете использовать свой собственный DAO-фреймворк. Запрет ORM может потребовать от вас еще одного компромисса – в легкости поддержки и выборе технологии для лучшей производительности с поддержкой транзакций.

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

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

Остальная часть статьи описывает существующие компенсирующие фреймворки и показывает простую реализацию, иллюстрирующую описанную мной концепцию.

Существующие компенсирующие фреймворки

ПРИМЕЧАНИЕ

Та же концепция, другие проблемы

Компенсирующие фреймворки обычно ассоциируются с длинными транзакциями (long-running transactions, иногда именуемыми L-R). Обычно они применяются в серверах бизнес-процессов (таких, как Microsoft BizTalk Server, Oracle WebLogic Integration, Oracle BPEL Process Manager и IBM WebSphere Process Server). Компенсирующие фреймворки используются также для разрешения проблем, связанных с транзакциями, в решениях, использующих Web-сервисы. К сожалению, эти фреймворки не подходят для реализации транзакционной стратегии высокой производительности, подразумевающей отдельные короткие действия, требующие координации, а не длинные транзакции.

На платформе Java имеется пара компенсирующих фреймворков: J2EE Activity Service for Extended Transactions (JSR 95) и JBoss Business Activity Framework (см. Ресурсы). Они предоставляют регистрацию, обмен сообщениями и логику компенсирующих триггеров (но не сами методы обновления или отката). Как и компенсирующие фреймворки, описанные во врезке «Та же концепция, другие проблемы», эти фреймворки в основном рассчитаны на работу с длинными транзакциями или с Web-запросами, и их трудно использовать в стратегии высокой производительности. В результате вам, скорее всего, придется создавать собственный компенсирующий фреймворк.

Спецификация J2EE Activity Service в основном рассчитана на разработчиков серверов приложений, но вы можете применить те же концепции в собственном компенсирующем фреймворке. Поэтому в этом разделе я изложу краткое введение в J2EE Activity Service, чтобы объяснить вам, как работают компенсирующие фреймворки.

J2EE Activity Service for Extended Transactions основывается на OMG Activity Service (см. Ресурсы). Спецификация J2EE Activity Service определяет набор интерфейсов и классов, которые координируют и контролируют исполнение действий внутри активности (activity). Активность – это набор зарегистрированных действий (таких, как операции обновления БД). Активности контролируются и координируются контроллером, представляющим собой встраиваемый протокол, обычно реализуемый как дополнительный подключаемый модуль. Каждая активность включает набор сигналов (signal set, ja­vax.activity.SignalSet), который посылает сигналы (ja­vax.activity.Signal) каждому зарегистрированному действию. На рисунке 4 показано концептуальное представление процесса компенсации.


Рисунок 3. Концептуальное представление процесса компенсации.

Активности должны зарегистрироваться в контроллере (или, точнее, в компоненте-менеджере компенсации в контроллере). После завершения активности она посылает сигнал (SUCCESS или FAILURE) контроллеру. Если контроллер получает сигнал SUCCESS, он посылает сигнал компоненту-координатору (в данном случае PROPAGATE), тем самым активизируя следующую активность. Обратите внимание на шаг 8 на рисунке 3 – там контроллеру посылается сигнал FAILURE. В этом случае контроллер посылает сигнал FAILURE координатору, тем самым запуская компенсирующие действия. Хотя это и не показано на рисунке 3, контроллер также отслеживает сигналы, которыми обмениваются компенсирующие действия и координатор, чтобы убедиться, что действия по откату успешно завершены.

Реализация собственного компенсирующего фреймворка

Создание собственного компенсирующего фреймворка выглядит грандиозной задачей, но это не слишком сложно – хотя и долго. Вы можете реализовать собственный компенсирующий фреймворк, используя простой Java-код, или использовать более сложные методики. Из соображений простоты я покажу вам простой пример, используя простой Java-код, чтобы вы поняли концепцию; креативность и прочие забавы я оставлю на ваше усмотрение.

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

Чтобы не загружать вас ненужными деталями, я буду считать, что у меня в БД уже есть следующие хранимые процедуры:

Я также пропущу показ DAO-классов, использующих JDBC-код CallableStatement, чтобы сфокусироваться на коде, наиболее существенном для этой стратегии. Поскольку реализация собственного компенсирующего координатора может быть разной и довольно многословной, я покажу только нижележащую структуру и дам комментарии по созданию остального кода реализации.

В зависимости от вашей реализации стратегии техники, используемой для компенсирующих изменений, аннотации и логика, используемые для управления операциями обновления/отмены могут находиться в слое API или в слое DAO приложения. Чтобы проиллюстрировать технику реализации стратегии высокой производительности, я буду использовать простой пример биржевого приложения, где логика координации компенсации находится в слое API. В этом примере с биржевой сделкой ассоциированы две активности: вставка сделки в БД (активность 1) и изменение баланса счета, отражающее сделку (активность 2). Обе активности реализованы в отдельных методах, использующих локальные транзакции и хранимые процедуры. Координатор компенсации (CompController) отвечает за управление компенсацией и откат активностей в случае ошибок.

В листинге 1 показан метод без использования компенсирующих транзакций. На классы AcctDAO и TradeDAO, на которые ссылается метод processTrade(), содержат JDBC-логику выполнения перечисленных выше хранимых процедур. Для краткости я пропущу эти классы.

Листинг 1. Пример без компенсирующих транзакций.
public class TradingService 
{

   private AcctDAO acctDao = new AcctDAO();
   private TradeDAO tradeDao = new TradeDAO();

   public void processTrade(TradeData trade) throws Exception 
   {

      try 
      {
         // изменение баланса счета
         AcctData acct = acctDao.getAcct(trade.getAcctId());
         if (trade.getSide().equals("BUY")) 
         {
            acct.setBalance(acct.getBalance()
               - (trade.getShares() * trade.getPrice()));
         } 
         else 
         {
            acct.setBalance(acct.getBalance()
               + (trade.getShares() * trade.getPrice()));
         }

         // вставляем сделку и обновляем состояние счета
         long tradeId = tradeDao.insertTrade(trade);
         acctDao.updateAcct(acct);

      } 
      catch (Exception up) 
      {
         throw up;
      }
   }
}

Заметьте, что в листинге 1 отсутствует управление транзакциями (нет программных или декларативных транзакционных аннотаций или кода). В случае ошибки в методе updateAcct(), сделка, вставленная методом insertTrade(), не откатывается, что приводит к несогласованности в БД. Этот код быстр, но он не поддерживает транзакционных ACID-свойств.

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

Листинг 2. Пример компенсирующего фреймворка.
public class CompController 
{

   // содержит активности и callback-метод для отмены результатов активности
   private Map compensationMap;

   // содержит список активных областей компенсации 
   // и номера последовательностей активностей
   private Map compensationScope;

   public CompController() 
   {
      // загружает карту компенсации, содержащую callback-классы и методы
   }

   public void startScope(String compId) 
   {
      // посылает jms сообщение start, содержащее compId как JMSXGroupId
   }

   public void registerAction(
      String compId, String action, Object data) 
   {
      // посылает jms сообщение data, содержащее номер последовательности 
      // и данные, используя compId как JMSXGroupId.
      // CompController управляет номерами последовательностей внутренне,
      // используя буфер compensationScope, 
      // и сохраняет их в поле сообщения JMSXGroupSeq
   }

   public void stopScope(String compId) 
   {
      // удаляет все сообщения, имеющие compId как JMSXGroupID, 
      // не предпринимая действий
      // Удаляет compId-вхождения из буфера compensationScope
   }

   public void compensate(String compId) 
   {
      // берет все сообщения с compId как JMSXGroupID и обрабатывает их 
      // в обратном порядке, используя карту компенсации 
      // и отражение для вызова методов обращения
      // Удаляет compId-вхождения из буфера compensationScope
   }
}

Атрибут compensationMap содержит предварительно загруженный список всех активностей (по именам) и соответствующий класс и метод (по именам) обращающей активности. В данном примере он должен содержать следующие вхождения: {"insertTrade", "TradeDAO.in­sert­TradeComp"} и {"updateAcct", "AcctDAO.updateAcct­Comp"}. Атрибут compensationScope содержит список список активных компенсаций по compId и зарегистрированных на текущей момент активностей. Этот буфер используется для получения следующего номера последовательности, используемого методом registerAction(). Остальные методы говорят сами за себя.

Заметьте, что в реализации координатора я использую службу сообщений Java Message Service (JMS). Я выбрал эту технику в основном потому, что она позволяет гарантировать (благодаря сохранению сообщений и гарантированной доставки), что в случае сбоя в процессе компенсации, транзакции, которые не удалось откатить, останутся в очереди JMS и могут быть обработаны другим потоком. Сообщения JMS также предоставляют возможности асинхронной регистрации активностей и выполнения компенсации, еще более ускоряя работу кода приложения. Конечно, хранение компенсирующей информации в памяти существенно ускоряет обработку, но может привести к еще большей несогласованности БД в случае отказа координатора.

Пример исходного кода в листинге 3 показывает технику применения компенсирующего фреймворка к исходному коду приложения из листинга 1.

Листинг 3. Пример кода, использующего компенсирующий фреймворк.
public class TradingService 
{

   private CompController compController = new CompController();
   private AcctDAO acctDao = new AcctDAO();
   private TradeDAO tradeDao = new TradeDAO();

   public void processTrade(TradeData trade) throws Exception 
   {

      String compId = UUID.randomUUID().toString();
      try 
      {
         // начало компенсации
         compController.startScope(compId);

         // получение исходных значений счета и задание баланса счета
         AcctData acct = acctDao.getAcct(trade.getAcctId());
         double oldBalance = acct.getBalance();
         if (trade.getSide().equals("BUY")) 
         {
            acct.setBalance(acct.getBalance()
               - (trade.getShares() * trade.getPrice()));
         } 
         else 
         {
            acct.setBalance(acct.getBalance()
               + (trade.getShares() * trade.getPrice()));
         }

         // вставляем сделку и изменяем счет
         long tradeId = tradeDao.insertTrade(trade);
         compController.registerAction(compId, "insertTrade", tradeId);

         acctDao.updateAcct(acct);
         compController.registerAction(compId, "updateAcct", oldBalance);

         // закрытие компенсации
         compController.stopScope(compId);

      } 
      catch (Exception up) 
      {
         compController.compensate(compId);
         throw up;
      }
   }
}

В листинге 3 обратите внимание, что при определении транзакционной единицы работы вы первым делом начинаете компенсацию, используя метод startScope(). Затем вы должны сохранить исходный баланс, чтобы передать его координатору при регистрации активности. После завершения активности вы регистрируете ее с помощью метода registerAction(). Это говорит координатору, что операция обновления БД успешно завершена и должна быть добавлена в список возможных активностей для компенсации. Если вся LUW завершается удачно, вы вызываете метод stopScope(), удаляющий все ссылки из координатора. В случае исключения вызывается метод compensate(), который заботится об отмене результатов активностей, зафиксированных в БД.

Исходный код листингов 2 и 3, конечно, далек от рабочего варианта, но он иллюстрирует технику, позволяющую создать собственный компенсирующий фреймворк. Ваш компенсирующий фреймворк может использовать собственные аннотации, аспекты (или перехватчики), или даже ваши собственные DSL для компенсации, чтобы сделать код более интуитивно понятным. Вы также не обязаны использовать асинхронный обмен сообщениями JMS в компенсирующем фреймворке, но я считаю его полезным при работе с проблемами, сопровождающими сбои компенсации.

Заключение

Решитесь ли вы использовать стратегию высокой производительности, во многом зависит от компромиссов, на которые вам придется пойти при этом. Однако если производительность стоит первой в списке приоритетов, и если приложение уже достаточно надежно и свободно от ошибок, это подходящая стратегия, обеспечивающая хоть какой-то уровень целостности и непротиворечивости БД, и не вредящая производительности. Стоит ли рекомендовать этот подход, если производительность стоит не на первом месте? Конечно, нет. Всегда лучше сперва попробовать использовать в приложении традиционные ACID-свойства. Но если вы готовы пожертвовать некоторой частью согласованности БД и целостности данных ради производительности, стоит рассмотреть стратегию высокой производительности.

В этой серии статей я показал некоторые проблемы и вызовы, связанные с обработкой транзакций, и продемонстрировал четыре транзакционные стратегии, которые можно использовать при создании надежных транзакционных решений в ваших приложениях. Обработка транзакций со стороны кажется простой, но когда вы начинаете применять ее в различных сценариях бизнес-приложений, она становится довольно сложной. Целью этих статей было снизить эту сложность и осветить методики и стратегии, упрощающие главную задачу – поддержание высокого уровня целостности и согласованности данных. Я надеюсь, что эти статьи дали вам знания, необходимые для повышения надежности ваших приложений и данных с точки зрения обработки транзакций.

Ресурсы


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

Copyright 1994-2016 "-"