В этой статье рассмотрим уровни изоляции на примере задачи: написать веб-приложение с одной веб-страницей, которое выдает число обращений к ней (или хитов).
Таблица
Пусть это число хранится в таблице hits PostgreSQL с одной строкой:

data.sql:
insert into hits (id, count) values (1, 0);
Число хитов хранится в поле count, начальное значение 0. Каждое обращение к веб-странице должно увеличивать значение в поле count на 1.
Сервис и репозиторий
При запросе страницы идет обращение к сервису, который, в свою очередь, обращается к репозиторию HitRepository:
@Transactional(propagation = Propagation.REQUIRED) @Repository public interface HitRepository extends CrudRepository<Hits, Long> { @Query("select id, count from hits where id=:id") Hits getCount(long id); @Modifying @Query("update hits set count=count+1 where id=:id") void updateCount(long id); }
Сервис HitService:
@Service public class HitService { @Autowired private HitRepository hitRepository; @Transactional(isolation = Isolation.READ_COMMITTED) public HitsDto updateAndReturnCount() { hitRepository.updateCount(1l); Hits hits = hitRepository.getCount(1l); return HitsDto.fromHits(hits); } }
Веб-приложение создает отдельный поток для каждого запроса, таким образом обращение к методу updateAndReturnCount() из контроллера (его напишем ниже) будет идти из нескольких потоков. И можно протестировать параллельные транзакции.
@Transactional
Метод сервиса аннотирован @Transactional, уровень изоляции указан:
isolation = Isolation.READ_COMMITTED
Но его можно и не указывать, поскольку в PostgreSQL это уровень, используемый по умолчанию. Он бы и так использовался.
Методы репозитория тоже аннотированы @Transactional с propagation:
(propagation = Propagation.REQUIRED)
Но propagation тоже можно было не указывать, поскольку значение Propagation.REQUIRED — значение по умолчанию. Оно означает, что когда методы репозитория вызываются из @Transactional-метода, новая транзакция для них не создается, а используется та, что снаружи. Вообще propagation нужен, если один @Transactional-метод вызывается из другого — чтобы указать, создавать ли для внутреннего метода отдельную транзакцию или использовать существующую, как вообще реагировать на наличие/отсутствие внешней транзакции (можно еще выбросить исключение, если она есть либо ее нет, можно выполнить метод вне транзакции и другие редкие варианты).
Таким образом, у нас транзакция будет одна, создается она в методе сервиса updateAndReturnCount(), и уровень изоляции для нее READ_COMMITTED.
Контроллер
Создадим веб-страницу, которая выдает число (оно также выводится в консоль):
@RestController public class MainController { @Autowired private HitService hitService; @GetMapping("/") public HitsDto main() { HitsDto hitsDTO = hitService.updateAndReturnCount(); System.out.println(hitsDTO.getCount()); return hitsDTO; } }
Результат верный
Если протестировать приложение с помощью JMeter (у меня создано 500 потоков), то заметим, что подсчет идет правильно, в консоль выводятся значения счетчика, и это последовательные числа.
Происходит это потому, что update блокирует модифицируемую строку от параллельных update до конца транзакции. (От параллельных select не блокирует, но поскольку select стоит после update, параллельные select и не вызываются, пока транзакция не закончится.) Все происходит последовательно.
Если поменять местами select и update
Если же поставить select перед update:
@Service public class HitService { @Autowired private HitRepository hitRepository; @Transactional(isolation = Isolation.READ_COMMITTED) public HitsDto updateAndReturnCount() { Hits hits = hitRepository.getCount(1l); //теперь эта строка первая hitRepository.updateCount(1l); //а эта вторая return HitsDto.fromHits(hits); } }
то возникнет ошибка. Числа будут выводиться не всегда последовательно.
Это происходит потому, что два потока могут одновременно войти и считать одно и то же старое значение, а уж потом приступить к update (и update выполнятся по очереди). select не блокирует запись от параллельных считываний и модификаций. Вот update блокирует запись от параллельных update до конца транзакции.
Поэтому в базе update будет вести подсчет правильно, но вот select (а значит и сервисный метод) может выдавать ложные значения.
Проиллюстрировать этот вариант можно так (начальное значение count=1 до обеих транзакций):

- T1 начинается.
- T1 выбирает count, он равен 1.
- T2 начинается.
- T1 изменяет count, прибавляя к нему 1 (теперь он равен 2) и блокирует ее от параллельных update до конца T1.
- T2 выбирает count, он равен 1 (не проблема, что строка в процессе модификации, от чтения то блокировки нет. Считывается старое подтвержденное (committed) значение 1.
- T2 пытается изменить count (сделать ее count=2), но обнаруживает, что строка заблокирована, и надо ждать окончания параллельной транзакции.
- T1 делает commit, а значит T2 может продолжить работу.
- T2 заново считывает строку и обнаруживает, что она поменялась. (Но id, по которому ищем строку, такой же, так что строка находится). К новому значению count прибавляется 1. Теперь count=3.
- T2 делает commit.
В итоге две транзакции увеличили count на 2 (каждая на один), что правильно. Итоговый count=3. Но вот select вернул в обоих случаях 1. Так что программа в таблице считает запросы верно, а в контроллере (и в консоли) выдает не всегда последовательные значения.
REPEATABLE READ
Попробуем поменять уровень изоляции метода на REPEATABLE_READ
@Transactional(isolation = Isolation.REPEATABLE_READ) public HitsDto updateAndReturnCount() { Hits hits = hitRepository.getCount(1l); hitRepository.updateCount(1l); return HitsDto.fromHits(hits); }
Этот уровень требует повторяемого чтения, то есть теперь нашу транзакцию не устроит тот случай, когда update пересчитал значение и обнаружил, что оно не такое, как в select (и вообще не такое, как в начале транзакции). Но к сожалению, это «не устроит» реализуется с помощью отката и исключения. Так что если установить в JMeter 500 тредов, чтоб случилась параллельная транзакция, то возникнет исключение, и вместо подсчета номера запроса вернется страница ошибки. В общем последовательность будет такая:
Разница с предыдущим случаем выделена жирным:
- T1 начинается.
- T1 выбирает count, он равен 1.
- T2 начинается.
- T1 изменяет count, прибавляя к нему 1 (теперь он равен 2) и блокирует ее от параллельных update до конца T1.
- T2 выбирает count, он равен 1 (не проблема, что строка в процессе модификации, от чтения то блокировки нет. Считывается старое подтвержденное (committed) значение.
- T2 пытается изменить count (сделать ее count=2), но обнаруживает, что строка заблокирована, и надо ждать окончания параллельной транзакции.
- T1 делает commit, а значит T2 может продолжить работу.
- T2 заново считывает строку и обнаруживает, что она поменялась. (Но id, по которому ищем строку, такой же, так что строка находится). Поскольку уровень изоляции REPEATABLE_READ, транзакцию T2 не устраивает, что значение поменялось по сравнению с тем, что было до начала T2.
- T2 делает rollback.
Проблема в том, что программа будет время от времени выдавать страницу ошибки при возникновении параллельной транзакции вместо того, чтобы учесть и посчитать запрос:
org.postgresql.util.PSQLException: ОШИБКА: не удалось сериализовать доступ из-за параллельного изменения
Хотя числа в консоль будут выдаваться строго последовательно.
Надо сказать, что если сделать update первым, а select вторым и оставить уровень REPEATABLE_READ, то конфликт и откат все равно будет. update не перенесет, что запись изменилась параллельной транзакцией независимо от того, где он расположен.
Явная блокировка select .. for update
Но можно ли как-то разместить сначала select, а потом update, и чтобы все работало? Да, если использовать явную блокировку select for update. Явная блокировка — не оптимистичная, она не надеется, что конфликт не возникнет. Она просто заставит параллельный select подождать.
Результат будет идентичен первому варианту, когда сначала идет update, а потом select Итак, теперь в репозитории метод getCount() использует явную блокировку:
@Transactional(propagation = Propagation.REQUIRED) @Repository public interface HitRepository extends CrudRepository<Hits, Long> { @Query( "select id, count from hits where id=:id for update") Hits getCount(long id); @Modifying @Query("update hits set count=count+1 where id=:id") void updateCount(long id); }
Она блокирует как параллельные select for update, так и update, то есть заставляет их ждать до конца своей транзакции. То есть до конца этой транзакции:
@Transactional(isolation = Isolation.READ_COMMITTED) public HitsDto updateAndReturnCount() { Hits hits = hitRepository.getCount(1l); hitRepository.updateCount(1l); return HitsDto.fromHits(hits); }
При этом тоже нужен уровень изоляции READ_COMMITTED, при REPEATABLE_READ транзакция не стерпит, что значение уже кто-то параллельно менял. Хоть select for update и будет ждать.
Итоги
Мы рассмотрели аннотацию @Transactional и простое приложение, которое иллюстрирует влияние уровня изоляции на корректность результата.
Код примера на GitHub.
Про настройку propagation аннотации @Transactional можно почитать тут.
Как же классно вы описываете сложные вещи!
Они реально становятся простыми.
Респект за статью очень понятные примеры