Изоляция и распространение транзакций в Spring

В статье, посвящённой декларативному управлению транзакциями в я обещал отдельно описать изоляцию транзакций друг от друга и их распространение. Это время пришло.

Изоляция транзакций

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

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

  • Serializable — транзакции полностью изолируются друг от друга и ни одна транзакция ни коим образом не влияет на другие. Самая низкая степень параллельности транзакций.
  • Repeatable read — изменения данных, которые были прочитаны в транзакции ранее в транзакцию не попадают, другие транзакции не могут изменять данные, прочитанные этой транзакцией. Возможен эффект фантомного чтения, степерь параллельности транзакций выше, чем у Serializable.
  • Read commited — транзакции получают изменения в данных от других транзакций, которые были успешно подтверждены. Возможны эффекты фантомного чтения и неповторяемого чтения. Этот уровень изоляции обычно используется по умолчанию в базах данных и обеспечивает хорошую степень параллельности транзакций.
  • Read uncommited — самый низший уровень изоляции транзакций, который гарантирует только, что изменения, внёсённые одной транзакцией, не будут перезаписаные другой транзакцией. Подвержден всем эффектам влияния транзакций друг на друга. Обеспечивает наивысшую степерь параллельности транзакций.

В Spring эти уровни представлены в виде enum Isolation, члены которого передаются в аннотацию @Transactional:

Распространение транзакций

Когда вызывается метод с @Transactional происходит особая уличная магия: proxy, который создал Spring, создаёт persistence context (или соединение с базой), открывает в нём транзакцию и сохраняет всё это в контексте нити исполнения (натурально, в ThreadLocal). По мере надобности всё сохранённое достаётся и внедряется в бины. Привязка транзакций к нитям (threads) позволяет использовать семантику серверов приложений J2EE, в которой гарантируется, что каждый запрос получает свою собственную нить.

Таким образом, если в вашем коде есть несколько параллельных нитей, у вас будет и несколько параллельных транзакций, которые будут взаимодействовать друг с другом согласно уровням изоляции. Но что произойдёт, если один метод с @Transactional вызовет другой метод с @Transactional? В Spring можно задать несколько вариантов поведения, которые называются правилами распространения.

  • Propagation.REQUIRED — применяется по умолчанию. При входе в @Transactional метод будет использована уже существующая транзакция или создана новая транзакция, если никакой ещё нет
  • Propagation.REQUIRES_NEW — второе по распространённости правило. Транзакция всегда создаётся при входе метод с Propagation.REQUIRES_NEW, ранее созданные транзакции приостанавливаются до момента возврата из метода.
  • Propagation.NESTED — корректно работает только с базами данных, которые умеют savepoints. При входе в метод в уже существующей транзакции создаётся savepoint, который по результатам выполнения метода будет либо сохранён, либо откачен. Все изменения, внесённые методом, подтвердятся только поздее, с подтверждением всей транзакции. Если текущей транзакции не существует, будет создана новая.
  • Propagation.MANDATORY — обратный по отношению к Propagation.REQUIRES_NEW: всегда используется существующая транзакция и кидается исключение, если текущей транзакции нет.
  • Propagation.SUPPORTS — метод с этим правилом будет использовать текущую транзакцию, если она есть, либо будет исполнятся без транзакции, если её нет.
  • Propagation.NOT_SUPPORTED — одно из самых забавных правил. При входе в метод текущая транзакция, если она есть, будет приостановлена и метод будет выполняться без транзакции.
  • Propagation.NEVER — правило, которое явно запрещает исполнение в контексте транзакции. Если при входе в метод будет существовать транзакция, будет выброшено исключение.

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

Куда же ставить @Transactional?

Классическое приложение обычно имеет многослойную архитектуру:

контроллеры > слой логики > слой доступа к данным > слой ORM

Где здесь место для @Transactional? Слой ORM обычно никто не пишет сам и использует какое-либо стандартное решение, в которое аннотации не вставишь.

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

Слой логики представляется идеальным местом для @Transactional: именно здесь набор запросов к базе оформляется в единую осмысленную операцию в приложении.  Зная, что  делает ваше приложение, вы можете чётко разграничить логические единицы работы в нём и расставить границы транзакций.

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

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