При разработке энтерпрайз приложений зачастую с базами данных взаимодействуют посредством ORM технологии, в мире джавы наиболее известна технология JPA (Java Persistence API) и её реализации — Hibernate и EclipseLink. JPA позволяет взаимодействовать с базой данных в терминах объектов предметной области, предоставляет кэш, репликацию кэша при наличии кластера в middle tier-е.
Как это обычно происходит:
- На бэкэнд приходит REST запрос обновить документ, в теле запроса — новое состояние.
- Начинаем транзакцию.
- Бэкэнд запрашивает существующее состояние документа у EntityManager-а, который может вычитать его из базы, а может достать из кэша.
- Далее мы берём объект прибывший в теле запроса смотрим на него, сравниваем с состоянием объекта представляющего запись в базе данных.
- На основе этого сравнения вносим необходимые изменения.
- Коммитим транзакцию.
- Возвращаем ответ клиенту.
Где здесь порылась собака? Смотрите, мы взяли данные, скорее всего из кэша, возможно уже протухшие, возможно сервер прямо сейчас обрабатывает конкурентный запрос на изменение того же документа, и данные протухают ровно в момент когда мы делаем все эти сравнения. На основе этих данных сомнительной достоверности и тела 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);
}
Смотрите, мы проверили, что лока нет, но использовали для это закэшированные данные возможно уже устаревшие, а возможно устаревающие прямо во время проверки. В этом случае наш код удаляя документ попытается нарушить ссылочную целостность данных и значит наша транзакция не пройдёт. Пара замечаний:
- Убедитесь, что каскадное удаление отключено, в случае каскадных удалений, удаление документа приведет к удалению всех записей, которые на него ссылаются. Т.е. наличие записи о бизнес-локе ничему не помешает.
- На самом деле код выше позволяет повесить несколько локов на один документ, т.е. требуется настроить ещё констрейнт уникальности.
- Пример сугубо синтетический, скорее всего имеет смысл поместить данные о владельце бизнес-лока прямо в документ, а не заводить отдельную таблицу. И затем использовать явный пессимистичный лок для проверки отсутствия этого бизнес-лока при удалении документа.
В реальных задачах ссылочная целостность здорово помогает при хранении иерархически организованных данных: штат организации, структура каталогов и файлов. В этом случае, например, если мы удаляем начальника и конкурентно в параллельной транзакции назначаем ему подчиненного, ссылочная целостность гарантирует, что успешно завершится только одна из этих операций и структура организации останется валидной (у каждого сотрудника кроме директора есть начальник). При этом на момент начала обеих операций каждая из них выглядела осуществимой.
Подводя итоги: даже используя устаревшие и сомнительные данные (что вполне может иметь место при работе с БД посредством JPA) при принятии решения о внесении изменений в базу данных, и даже если конкурентно вносятся конфликтующие изменения, механизм транзакций не позволит нам сделать ничего, что нарушит ссылочную целостность либо не будет соответствовать наложенным констрейнтам, все действия объединённые данной транзакцией и приводящие к данному плачевному итогу будут отменены в соответствии с принципом атомарности. Просто имейте это ввиду моделируя данные и аккуратно расставляйте констрейнты.
Комментарии (11)
sshikov
03.04.2017 21:58>Тут встаёт вопрос, что за лажу мы только что записали в базу данных?
>Здесь нам и помогут транзакции.
Вы не отразили одну важную тему. Смотрите — у вас есть клиент, он достает и хранит состояние базы на некоторый момент, чтобы показывать его пользователю. Пользователь — это человек, по сравнению с компьютером он тормоз. Он посмотрел на данные, сходил пообедать, вернулся, вздремнул, и нажал кнопку «Обновить все нафиг». И да, он смотрел на давно устаревшие данные, кто бы сомневался. Но дело не в этом.
Вы хотите сказать, что между select (перед показом формы человеку) и update (по нажатию кнопки) у вас все время длилась одна и таже транзакция, которая удерживала блокировку? Часа так три-четыре?
И constraints, кстати не спасают от потери изменений. А это намного более частый случай, чем изменения иерархии, например.yannmar
03.04.2017 23:22Он посмотрел на данные, сходил пообедать, вернулся, вздремнул, и нажал кнопку «Обновить все нафиг». И да, он смотрел на давно устаревшие данные, кто бы сомневался.
Это несколько другая история, нужно целую отдельную статью писать про кондишионал хттп реквесты, е-таги, оптимистичные локи.
Вы хотите сказать, что между select (перед показом формы человеку) и update (по нажатию кнопки) у вас все время длилась одна и таже транзакция, которая удерживала блокировку? Часа так три-четыре?
Ничего подобного я не хочу сказать, в тексте границы транзакции чётко обозначены — начинается при входе в безнес-метод, завершается по выходу из него
И constraints, кстати не спасают от потери изменений. А это намного более частый случай, чем изменения иерархии, например.
Кстати здесь есть варианты. Например, мы можем хранить историю изменений, с помощью ссылочной целостности и констрейнтов мы сумеем гарантировать линейность этой истории. Изменения не потеряются.sshikov
04.04.2017 09:21Я согласен, это другая история, и у вас правда в примере одна транзакция. Я собственно к тому, что неактуальные данные у клиента — это совершенно нормально, клиент это зачастую человек, и он может долго над данными думать. И тут JPA и его кеши в общем-то не при чем совершенно, это будет так на любой технологии, даже без кеша между вашим select и последующим update данные в базе могут измениться. Т.е. хотите что-то с этим сделать — либо select for update (и не держите транзакцию часами), либо…
Ну в общем это не о том, что у вас что-то неправильно, а скорее небольшое дополнение.
tankomazzz
06.04.2017 22:25И constraints, кстати не спасают от потери изменений. А это намного более частый случай, чем изменения иерархии, например.
версионностьsshikov
07.04.2017 12:53А что версионность? Вы знаете какое-то универсальное решение, чтобы ее получить под JPA?
FireWolf2007
04.04.2017 13:01Если честно, то ожидал в статье увидеть что-то хоть чуть-чуть близкое к тому как с JPA это все делать, а на деле кричащий заголовок, 2 примитивных примера и очевидные факты для тех кто чуть-чуть познакомился с СУБД.
Ни о каком JPA толком ничего нет.
Продолжение-то хоть намечается?yannmar
04.04.2017 13:46Продолжение-то хоть намечается?
Возможно. Есть пара идей. Вам какой аспект всего этого был наиболее непонятен? Про что бы хотели прочитать?
forketyfork
А чем вам не угодил стандартный механизм пессимистичных блокировок?
yannmar
Не, отличная вещь. Можно пользоваться. Там даже пояснение в статье есть, что примеры синтетические и есть ещё вариант с пессимистичными локами, но можно и без явных локов обходится в ряде задач.
tyanigor
Зачем создавать свой велосипед в виде entity lock, когда для таких случаев есть оптимистичные блокировки.
yannmar
Это синтетический пример, для демонстрации работы механизма ссылочной целостности и отката транзакции при её нарушении.