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

Если коротко, то Spring обрабатывает исключения в методах, помеченных @Transactional, следующим образом:

  • RuntimeException'ы приводят к откату транзакции, checked exception'ы не приводят;

  • RuntimeException'ы приводят к откату транзакции в момент, когда exception пересекает границы @Transactional-метода. Даже если вы перехватите это исключение выше по стеку, транзакция все равно откатится;

  • Этим поведением можно управлять через атрибуты rollbackFor / noRollbackFor у аннотации @Transcational.

Пример 1

@Slf4j
@Component
public class Bean {
  private final BeanRepository repository;

  public Bean (BeanRepository repository) {
    this.repository = repository;
  }

  @Transactional
  public void saveItems(List<Item> items) {
    for(Item item: items) {
      try {
        saveItem(item);
      } catch(RuntimeException e) {
        log.error("Could not save item {}", item, e);
      }
    }
  }

  @Transactional
  public void saveItem(Item item) {
    if(item.foo().equals("BAD_ITEM")) 
      throw new RuntimeException();

    repository.save(item);
  }
}

Что будет, если мы передадим для сохранения список Item'ов, у которых 0-й и 2-й элемент НЕ являются BAD_ITEM'ами, а 1-й - является?

  1. Ничего не будет сохранено, поскольку RuntimeException в методе saveItem откатит транзакцию;

  2. Будет сохранен только 0-й элемент;

  3. Будет сохранен 0-й и 2-й элемент.

Ответ

Правильный ответ - 3 (ну, как правило, подробнее - ниже). Когда мы из компонента так вызываем метод того же компонента, это просто вызов внутреннего метода, а не метода прокси-объекта. Он попросту игнорирует аннотацию @Transactional у saveItem и не будет создавать новый транзакционный контекст. Почему так происходит можно прочитать в статьях, на которые я ссылался в начале. А значит RuntimeException не пересечет границу @Transactional-метода.

Если мы используем compile-time weaving, либо если мы перепишем код, чтобы вызов происходил через inner.saveItem, то правильным ответом будет 1.

Пример 2

@Component
public class Bean {
  private final BeanRepository repository;

  public Bean(BeanRepository repository) {
    this.repository = repository;
  }

  @Transactional
  public void saveItem(Item item) {
    if(item.foo().equals("BAD_ITEM")) 
      throw new RuntimeException();

    repository.save(item);
  }
}

@Slf4j
@Controller
public class Controller {
  private final Bean bean;

  public Controller(Bean bean) {
    this.bean = bean;
  }
  
  public void saveItems(List<Item> items) {
    for(Item item: items) {
      try {
        bean.saveItem(item);
      } catch(RuntimeException e) {
        log.error("Could not save item {}", item, e);
      }
    }
  }
}

Тот же вопрос: что будет, если мы передадим для сохранения список Item'ов, у которых 0-й и 2-й элемент НЕ являются BAD_ITEM'ами, а 1-й - является?

  1. Ничего не будет сохранено;

  2. Будет сохранен только 0-й элемент;

  3. Будет сохранен 0-й и 2-й элемент.

Ответ

Правильный ответ, по-прежнему, 3. Теперь транзакция с RuntimeException'ом откатится, но этот код создает 3 транзакции. Остальные 2 будут закоммичены.

Пример 3

Все то же самое, но теперь метод saveItems тоже @Transactional:

@Slf4j
@Controller
public class Controller {
  ...
  @Transactional
  public void saveItems(List<Item> items) {
    for(Item item: items) {
      try {
        bean.saveItem(item);
      } catch(RuntimeException e) {
        log.error("Could not save item {}", item, e);
      }
    }
  }
}
  1. Ничего не будет сохранено;

  2. Будет сохранен только 0-й элемент;

  3. Будет сохранен 0-й и 2-й элемент.

Ответ

А вот теперь правильный ответ - 1. По умолчанию, @Transactional использует Propagation.REQUIRED, который приведет к тому, что saveItem будет использовать транзакцию, открытую для saveItems. При ошибке она будет помечена как rollbackOnly.

Пример 4

А теперь добавим в контроллер ограничение, что RuntimeException'ы не должны откатываться.

@Slf4j
@Controller
public class Controller {
  ...
  @Transactional(noRollbackFor = RuntimeException.class)
  public void saveItems(List<Item> items) {
      try {
        bean.saveItem(item);
      } catch(RuntimeException e) {
        log.error("Could not save item {}", item, e);
      }
  }
}
  1. Ничего не будет сохранено;

  2. Будет сохранен только 0-й элемент;

  3. Будет сохранен 0-й и 2-й элемент.

Ответ

Правильный ответ все еще 1. noRollbackFor влияет только на аннотируемый метод, его поведение не "наследуется" компонентами ниже по стеку вызовов, даже если они используют ту же транзакцию. Поэтому saveItem пометит транзакцию как rollbackOnly.

Пример 5

Перенесем noRollbackFor на saveItem. А у контроллера уберем код по перехватыванию RuntimeException.

@Component
public class Bean {
  private final BeanRepository repository;

  public Bean(BeanRepository repository) {
    this.repository = repository;
  }

  @Transactional(noRollbackFor = RuntimeException.class)
  public void saveItem(Item item) {
    if(item.foo().equals("BAD_ITEM")) 
      throw new RuntimeException();

    repository.save(item);
  }
}

@Slf4j
@Controller
public class Controller {
  private final Bean bean;

  public Controller(Bean bean) {
    this.bean = bean;
  }

  @Transactional  
  public void saveItems(List<Item> items) {
    for(Item item: items) {
      bean.saveItem(item);
    }
  }
}
  1. Ничего не будет сохранено

  2. Будет сохранен только 0-й элемент;

  3. Будет сохранен 0-й и 2-й элемент.

Ответ

Правильный ответ - 1. Да, теперь saveItem не откатывает транзакцию, но с самим-то RuntimeException'ом он ничего не делает. Exception пролетит через контроллер, и уже у его @Transactional-метода вызовет откат транзакции.

Пример 6

Пометим оба @Transactional-метода так, чтобы они не откатывали runtime-исключения:

@Component
public class Bean {
  private final BeanRepository repository;

  public Bean(BeanRepository repository) {
    this.repository = repository;
  }

  @Transactional(noRollbackFor = RuntimeException.class)
  public void saveItem(Item item) {
    if(item.foo().equals("BAD_ITEM")) 
      throw new RuntimeException();

    repository.save(item);
  }
}

@Slf4j
@Controller
public class Controller {
  private final Bean bean;

  public Controller(Bean bean) {
    this.bean = bean;
  }

  @Transactional(noRollbackFor = RuntimeException.class)
  public void saveItems(List<Item> items) {
    for(Item item: items) {
      bean.saveItem(item);
    }
  }
}
  1. Ничего не будет сохранено

  2. Будет сохранен только 0-й элемент;

  3. Будет сохранен 0-й и 2-й элемент.

Ответ

Правильный ответ - 2. Транзакция не откатится, но из-за исключения обработка списка остановится. Тот же эффект будет, если вообще убрать аннотации @Transactional, либо если эту аннотацию убрать только у контроллера.

Пример 7

У Spring Data в интерфейсе CrudRepository есть такой вот метод:

void deleteAllById(Iterable<? extends ID> ids)

Что если ему на вход передать среди существующих идентификаторов один несуществующий? Я бы ожидал, что он удалит те, что есть, и проигнорирует те, которых нет (ну, как и обычный оператор delete в SQL). И да, Spring Data, именно так и сделает, но только если вы используете Spring Data 3. А ему на данный момент что-то около полугода. Если вы используете более раннюю версию, вы получите ошибку EmptyResultDataAccessException (Spring Data писали странные люди, и да, пошла она к черту эта ваша обратная совместимость).

@Component
public class Bean {
  private final BeanRepository repository;

  public Bean(BeanRepository repository) {
    this.repository = repository;
  }

  @Transactional(noRollbackFor = EmptyResultDataAccessException.class)
  public void deleteItems(List<Item> items) {
    repostory.deleteAllById(items.stream().map(Item::id).toList());
  }
}

Что будет, если в середине списка несуществующий айдишник в Spring Data 2.x?

Ответ

Транзакция полностью откатится. У Repository модифицирующие методы сами помечены как @Transactional, поэтому фактически этот пример эквивалентен примеру номер 4.

Вы можете в своем репозитории переопределить метод deleteAllById, указав уже ему noRollbackFor = EmptyResultDataAccessException.class, но это плохая идея. deleteAllById просто в цикле вызывает deleteById. Он тоже помечен как @Transactional, но это неважно, как мы выяснили в примере номер 1. Когда deleteById сгенерирует исключение, транзакция не откатится, но цикл прервется. В итоге, вы окажетесь в ситуации примера номер 6, когда половина Item'ов удалилась, а половина - нет.

Выход - либо вместо deleteAllById проверять на стороне сервиса, что Item существует, а потом уже вызывать deleteById, либо удалять модифицирующим JPQL-запросом.

Пример 8

public interface BeanRepository extends JpaRepository<Item, Long> { 
  @Override
  @Transactional(noRollbackForClassName = "org.springframework.dao.EmptyResultDataAccessException", rollbackFor = DataAccessException.class, noRollbackFor = RuntimeException.class)
  void deleteAllById(Iterable<? extends Long> longs);
}

Что будет если какого-то из айдишников нет?

Ответ

Ну, во-первых, автора такого пул-реквеста, скорее всего, побьют на code-review. Возможно, ногами.

Ответ - сработает то правило, которое содержит ближайшего родителя брошенного Exception'а. В нашем случае - noRollbackForClassName, поэтому часть Item'ов удалится, часть - нет.

Если родителя нет среди правил, будет использоваться дефолтное поведение (RuntimeException и Error откатываем, checked - не трогаем). Есть еще совсем упоротый вариант, когда написано что-то вроде rollbackFor = RuntimeException.class, noRollbackFor = RuntimeException.class. Spring разрешит сделать и это, к сожалению. Судя по исходному коду, всегда сработают noRollback-правила.

Итоги

  1. Атрибуты rollbackFor / noRollbackFor управляют только поведением транзакции в случае возникновения исключений. Сами исключения по-прежнему пробрасываются выше по стеку;

  2. Атрибуты rollbackFor / noRollbackFor не наследуются @Transactional-методами ниже по стеку вызовов, даже если используется Propagation.REQUIRED;

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

Комментарии (0)