@Transactional, уровни изоляции

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

Таблица

Пусть это число хранится в таблице hits PostgreSQL с одной строкой:

Таблица hits
Таблица hits

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 до обеих транзакций):

Если уровень изоляции READ_COMMITTED и select первый
  1. T1 начинается.
  2. T1 выбирает count, он равен 1.
  3. T2 начинается.
  4. T1 изменяет count, прибавляя к нему 1 (теперь он равен 2) и блокирует ее от параллельных update до конца T1.
  5. T2 выбирает count, он равен 1 (не проблема, что строка в процессе модификации, от чтения то блокировки нет. Считывается старое подтвержденное (committed) значение 1.
  6. T2 пытается изменить count (сделать ее count=2), но обнаруживает, что строка заблокирована, и надо ждать окончания параллельной транзакции.
  7. T1 делает commit, а значит T2 может продолжить работу.
  8. T2 заново считывает строку и обнаруживает, что она поменялась. (Но id, по которому ищем строку, такой же, так что строка находится). К новому  значению count прибавляется 1. Теперь count=3.
  9. 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 тредов, чтоб случилась параллельная транзакция, то возникнет исключение, и вместо подсчета номера запроса вернется страница ошибки. В общем последовательность будет такая:

Разница с предыдущим случаем выделена жирным:

  1. T1 начинается.
  2. T1 выбирает count, он равен 1.
  3. T2 начинается.
  4. T1 изменяет count, прибавляя к нему 1 (теперь он равен 2) и блокирует ее от параллельных update до конца T1.
  5. T2 выбирает count, он равен 1 (не проблема, что строка в процессе модификации, от чтения то блокировки нет. Считывается старое подтвержденное (committed) значение.
  6. T2 пытается изменить count (сделать ее count=2), но обнаруживает, что строка заблокирована, и надо ждать окончания параллельной транзакции.
  7. T1 делает commit, а значит T2 может продолжить работу.
  8. T2 заново считывает строку и обнаруживает, что она поменялась. (Но id, по которому ищем строку, такой же, так что строка находится). Поскольку уровень  изоляции REPEATABLE_READ, транзакцию T2 не устраивает, что значение поменялось по сравнению с тем, что было до начала T2.
  9. 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 можно почитать тут.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *