Как понять и подружиться с транзакциями и JPA
При разработке энтерпрайз приложений зачастую с базами данных взаимодействуют посредством 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) при принятии решения о внесении изменений в базу данных, и даже если конкурентно вносятся конфликтующие изменения, механизм транзакций не позволит нам сделать ничего, что нарушит ссылочную целостность либо не будет соответствовать наложенным констрейнтам, все действия объединённые данной транзакцией и приводящие к данному плачевному итогу будут отменены в соответствии с принципом атомарности. Просто имейте это ввиду моделируя данные и аккуратно расставляйте констрейнты.