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

При разработке энтерпрайз приложений зачастую с базами данных взаимодействуют посредством ORM технологии, в мире джавы наиболее известна технология JPA (Java Persistence API) и её реализации — Hibernate и EclipseLink. JPA позволяет взаимодействовать с базой данных в терминах объектов предметной области, предоставляет кэш, репликацию кэша при наличии кластера в middle tier-е.

Как это обычно происходит:

  1. На бэкэнд приходит REST запрос обновить документ, в теле запроса — новое состояние.
  2. Начинаем транзакцию.
  3. Бэкэнд запрашивает существующее состояние документа у EntityManager-а, который может вычитать его из базы, а может достать из кэша.
  4. Далее мы берём объект прибывший в теле запроса смотрим на него, сравниваем с состоянием объекта представляющего запись в базе данных.
  5. На основе этого сравнения вносим необходимые изменения.
  6. Коммитим транзакцию.
  7. Возвращаем ответ клиенту.

Где здесь порылась собака? Смотрите, мы взяли данные, скорее всего из кэша, возможно уже протухшие, возможно сервер прямо сейчас обрабатывает конкурентный запрос на изменение того же документа, и данные протухают ровно в момент когда мы делаем все эти сравнения. На основе этих данных сомнительной достоверности и тела REST запроса мы принимаем решения о внесении изменений в базу и коммитим их. Тут встаёт вопрос, что за лажу мы только что записали в базу данных?

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

  • Нарушение констрейнтов уникальности.
  • Нарушение ссылочной целостности.

И так, если наша транзакция прошла, то «лажа», которую мы закоммитили чуть выше, удовлетворяет констрейнтам. Осталось настроить констрейнты так, чтобы удовлетворяющие им данные представляли собой валидные бизнес-сущности.

Вот максимально примитивный и искусственный пример:

@Entity
public class Document {
    @Id
    private String name;
    
    @Lob
    private String content;

    // getters and setters пропущены
}

@ApplicationScoped
@Transactional // транзакции начнаются перед вызовом безнес-метода и завершаются по его окончанию
public class DocumentService {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public void createDocument(String name, String content) {
        // скорее всего никакого запроса к базе данных здесь не будет,
        // с большой долей вероятности мы получим закэшированный объект 
        Document documentEntity = entityManager.find(Document.class, name);
        
        if (documentEntity != null) {
            throw new WebApplicationException(Response.Status.CONFLICT); // конфликт имен!
        }

        // возможно прямо сейчас другой тред конкурентно создает документ с таким же именем
        documentEntity = new Document();
        documentEntity.setName(name);
        documentEntity.setContent(content);
        
        entityManager.persist(documentEntity);
    }
}

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

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

Попробуем что-нибудь посложнее. Допустим мы хотим иметь возможность защитить документ от удаления. Заводим новую таблицу:

@Entity
public class DocumentLock {
    @Id
    @GeneratedValue
    private Long id;
    
    @OneToOne
    private Document document;
    
    @Basic
    private String lockedBy;

    // getters, setters
}

И добавляем в класс Document:

    @OneToOne(mappedBy = "document")
    private DocumentLock lock;

Теперь чтобы защитить документ от удаления достаточно создать DocumentLock ссылающийся на документ. Логика удаляющая документ:

    public void deleteDocument(String name) {
        Document documentEntity = entityManager.find(Document.class, name);
        
        if (documentEntity == null) {
            throw new NotFoundException();
        }
        
        DocumentLock lock = documentEntity.getLock();
        
        if (lock != null) {
            throw new WebApplicationException(
                    "Document is locked by " + lock.getLockedBy(), 
                    Response.Status.BAD_REQUEST);
        }
        
        entityManager.remove(documentEntity);
    }

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

  1. Убедитесь, что каскадное удаление отключено, в случае каскадных удалений, удаление документа приведет к удалению всех записей, которые на него ссылаются. Т.е. наличие записи о бизнес-локе ничему не помешает.
  2. На самом деле код выше позволяет повесить несколько локов на один документ, т.е. требуется настроить ещё констрейнт уникальности.
  3. Пример сугубо синтетический, скорее всего имеет смысл поместить данные о владельце бизнес-лока прямо в документ, а не заводить отдельную таблицу. И затем использовать явный пессимистичный лок для проверки отсутствия этого бизнес-лока при удалении документа.

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

Подводя итоги: даже используя устаревшие и сомнительные данные (что вполне может иметь место при работе с БД посредством JPA) при принятии решения о внесении изменений в базу данных, и даже если конкурентно вносятся конфликтующие изменения, механизм транзакций не позволит нам сделать ничего, что нарушит ссылочную целостность либо не будет соответствовать наложенным констрейнтам, все действия объединённые данной транзакцией и приводящие к данному плачевному итогу будут отменены в соответствии с принципом атомарности. Просто имейте это ввиду моделируя данные и аккуратно расставляйте констрейнты.
Поделиться с друзьями
-->

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


  1. forketyfork
    03.04.2017 12:41

    1. yannmar
      03.04.2017 12:44

      Не, отличная вещь. Можно пользоваться. Там даже пояснение в статье есть, что примеры синтетические и есть ещё вариант с пессимистичными локами, но можно и без явных локов обходится в ряде задач.


      1. tyanigor
        03.04.2017 23:10
        +1

        Зачем создавать свой велосипед в виде entity lock, когда для таких случаев есть оптимистичные блокировки.


        1. yannmar
          03.04.2017 23:11

          Это синтетический пример, для демонстрации работы механизма ссылочной целостности и отката транзакции при её нарушении.


  1. sshikov
    03.04.2017 21:58

    >Тут встаёт вопрос, что за лажу мы только что записали в базу данных?
    >Здесь нам и помогут транзакции.

    Вы не отразили одну важную тему. Смотрите — у вас есть клиент, он достает и хранит состояние базы на некоторый момент, чтобы показывать его пользователю. Пользователь — это человек, по сравнению с компьютером он тормоз. Он посмотрел на данные, сходил пообедать, вернулся, вздремнул, и нажал кнопку «Обновить все нафиг». И да, он смотрел на давно устаревшие данные, кто бы сомневался. Но дело не в этом.

    Вы хотите сказать, что между select (перед показом формы человеку) и update (по нажатию кнопки) у вас все время длилась одна и таже транзакция, которая удерживала блокировку? Часа так три-четыре?

    И constraints, кстати не спасают от потери изменений. А это намного более частый случай, чем изменения иерархии, например.


    1. yannmar
      03.04.2017 23:22

      Он посмотрел на данные, сходил пообедать, вернулся, вздремнул, и нажал кнопку «Обновить все нафиг». И да, он смотрел на давно устаревшие данные, кто бы сомневался.

      Это несколько другая история, нужно целую отдельную статью писать про кондишионал хттп реквесты, е-таги, оптимистичные локи.

      Вы хотите сказать, что между select (перед показом формы человеку) и update (по нажатию кнопки) у вас все время длилась одна и таже транзакция, которая удерживала блокировку? Часа так три-четыре?

      Ничего подобного я не хочу сказать, в тексте границы транзакции чётко обозначены — начинается при входе в безнес-метод, завершается по выходу из него

      И constraints, кстати не спасают от потери изменений. А это намного более частый случай, чем изменения иерархии, например.

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


      1. sshikov
        04.04.2017 09:21

        Я согласен, это другая история, и у вас правда в примере одна транзакция. Я собственно к тому, что неактуальные данные у клиента — это совершенно нормально, клиент это зачастую человек, и он может долго над данными думать. И тут JPA и его кеши в общем-то не при чем совершенно, это будет так на любой технологии, даже без кеша между вашим select и последующим update данные в базе могут измениться. Т.е. хотите что-то с этим сделать — либо select for update (и не держите транзакцию часами), либо…

        Ну в общем это не о том, что у вас что-то неправильно, а скорее небольшое дополнение.


    1. tankomazzz
      06.04.2017 22:25

      И constraints, кстати не спасают от потери изменений. А это намного более частый случай, чем изменения иерархии, например.

      версионность


      1. sshikov
        07.04.2017 12:53

        А что версионность? Вы знаете какое-то универсальное решение, чтобы ее получить под JPA?


  1. FireWolf2007
    04.04.2017 13:01

    Если честно, то ожидал в статье увидеть что-то хоть чуть-чуть близкое к тому как с JPA это все делать, а на деле кричащий заголовок, 2 примитивных примера и очевидные факты для тех кто чуть-чуть познакомился с СУБД.
    Ни о каком JPA толком ничего нет.
    Продолжение-то хоть намечается?


    1. yannmar
      04.04.2017 13:46

      Продолжение-то хоть намечается?

      Возможно. Есть пара идей. Вам какой аспект всего этого был наиболее непонятен? Про что бы хотели прочитать?