[英]Optimistic locking in a stateless application with JPA / Hibernate
我想知道在无法在请求之间保留具有特定版本的实体实例的系统中实现乐观锁定(乐观并发控制)的最佳方法是什么。 这实际上是一个非常常见的场景,但几乎所有示例都基于将在请求之间(在 http 会话中)保存已加载实体的应用程序。
如何在尽可能少的 API 污染的情况下实现乐观锁?
堆栈是带有 JPA (Hibernate) 的 Spring,如果这应该有任何相关性的话。
@Version
问题在许多文档中,看起来您需要做的就是用@Version
装饰一个字段,JPA/Hibernate 会自动检查版本。 但这只有在加载的对象及其当前版本保留在内存中时才有效,直到更新更改同一实例。
在无状态应用程序中使用@Version
会发生什么:
id = 1
item 并获取Item(id = 1, version = 1, name = "a")
id = 1
item 并获取Item(id = 1, version = 1, name = "a")
Item(id = 1, version = 1, name = "b")
Item(id = 1, version = 1, name = "a")
的EntityManager
加载项目,它更改name
并保留Item(id = 1, version = 1, name = "b")
。 Hibernate 将版本增加到2
。Item(id = 1, version = 1, name = "c")
。Item(id = 1, version = 2, name = "b")
的EntityManager
加载项目,它更改name
并保留Item(id = 1, version = 2, name = "c")
。 Hibernate 将版本增加到3
。 貌似没有冲突! 正如您在第 6 步中看到的,问题在于 EntityManager 在更新之前立即重新加载了 Item 的当前版本 ( version = 2
)。 Client B 用version = 1
开始编辑的信息丢失了,Hibernate 检测不到冲突。 客户端 B 执行的更新请求必须保留Item(id = 1, version = 1, name = "b")
(而不是version = 2
)。
JPA/Hibernate 提供的自动版本检查只有在初始 GET 请求上加载的实例在服务器上的某种客户端会话中保持活动状态时才会起作用,并且稍后将由相应的客户端更新。 但是在无状态服务器中,必须以某种方式考虑来自客户端的版本。
可以在应用程序服务的方法中执行显式版本检查:
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
if (dto.version > item.version) {
throw OptimisticLockException()
}
item.changeName(dto.name)
}
优点
Item
) 不需要从外部操作版本的方法。缺点
可以通过额外的包装器(在我下面的示例中为ConcurrencyGuard
)来防止忘记检查。 存储库不会直接返回项目,而是会强制执行检查的容器。
@Transactional
fun changeName(dto: ItemDto) {
val guardedItem: ConcurrencyGuard<Item> = itemRepository.findById(dto.id)
val item = guardedItem.checkVersionAndReturnEntity(dto.version)
item.changeName(dto.name)
}
缺点是在某些情况下不需要检查(只读访问)。 但可能还有另一种方法returnEntityForReadOnlyAccess
。 另一个缺点是ConcurrencyGuard
类将为存储库的域概念带来技术方面。
实体可以通过 ID 和版本加载,以便在加载时显示冲突。
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findByIdAndVersion(dto.id, dto.version)
item.changeName(dto.name)
}
如果findByIdAndVersion
找到具有给定 ID 但版本不同的实例,则会抛出OptimisticLockException
。
优点
version
不会污染域对象的所有方法(尽管存储库也是域对象)缺点
findById
并且这种方法很容易被意外使用@Transactional
fun changeName(dto: itemDto) {
val item = itemRepository.findById(dto.id)
item.changeName(dto.name)
itemRepository.update(item, dto.version)
}
优点
缺点
version
污染update
方法与“工作单元”模式相矛盾version 参数可以传递给可以在内部更新 version 字段的 mutating 方法。
@Entity
class Item(var name: String) {
@Version
private version: Int
fun changeName(name: String, version: Int) {
this.version = version
this.name = name
}
}
优点
缺点
这种模式的一个变体是直接在加载的对象上设置版本。
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
it.version = dto.version
item.changeName(dto.name)
}
但这会公开直接公开进行读写的版本,并且会增加出错的可能性,因为这个调用很容易被遗忘。 然而,并不是每个方法都会被version
参数污染。
可以在应用程序中创建与要更新的对象具有相同 ID 的新对象。 此对象将在构造函数中获取 version 属性。 然后,新创建的对象将合并到持久性上下文中。
@Transactional
fun update(dto: ItemDto) {
val item = Item(dto.id, dto.version, dto.name) // and other properties ...
repository.save(item)
}
优点
缺点
changeName
方法应该只对更改而不是对名称的初始设置执行某个操作。 在这种情况下不会调用这样的方法。 也许这个缺点可以通过特定的工厂方法来减轻。你将如何解决它,为什么? 有更好的主意吗?
服务器使用返回 Item(id = 1, version = 1, name = "a") 的 EntityManager 加载项目,它更改名称并保留 Item(id = 1, version = 1, name = "b")。 Hibernate 将版本增加到 2。
这是对 JPA API 的滥用,也是您的错误的根本原因。
如果您改用entityManager.merge(itemFromClient)
,则会自动检查乐观锁定版本,并拒绝“过去的更新”。
一个警告是entityManager.merge
将合并实体的整个状态。 如果您只想更新某些字段,那么使用普通 JPA 会有些麻烦。 具体来说,因为您可能没有分配 version 属性,所以您必须自己检查版本。 但是,该代码很容易重用:
<E extends BaseEntity> E find(E clientEntity) {
E entity = entityManager.find(clientEntity.getClass(), clientEntity.getId());
if (entity.getVersion() != clientEntity.getVersion()) {
throw new ObjectOptimisticLockingFailureException(...);
}
return entity;
}
然后你可以简单地做:
public Item updateItem(Item itemFromClient) {
Item item = find(itemFromClient);
item.setName(itemFromClient.getName());
return item;
}
根据不可修改字段的性质,您还可以执行以下操作:
public Item updateItem(Item itemFromClient) {
Item item = entityManager.merge(itemFromClient);
item.setLastUpdated(now());
}
对于以 DDD 方式执行此操作,版本检查是持久化技术的一个实现细节,因此应在存储库实现中进行。
为了通过应用程序的各个层传递版本,我发现将版本作为域实体或值对象的一部分很方便。 这样,其他层就不必与版本字段显式交互。
当您从 DB 加载记录以处理更新请求时,您必须将该加载的实例配置为具有客户端提供的相同版本。 但不幸的是,当一个实体被管理时,它的版本不能按照JPA 规范的要求手动更改。
我尝试跟踪 Hibernate 源代码,但没有注意到有任何 Hibernate 特定功能可以绕过此限制。 值得庆幸的是,版本检查逻辑很简单,我们可以自己检查。 返回的实体仍然是托管的,这意味着工作单元模式仍然可以应用于它:
// the version in the input parameter is the version supplied from the client
public Item findById(Integer itemId, Integer version){
Item item = entityManager.find(Item.class, itemId);
if(!item.getVersoin().equals(version)){
throws new OptimisticLockException();
}
return item;
}
由于担心 API 会被version
参数污染,我将entityId
和version
建模为一个域概念,由一个名为EntityIdentifier
的值对象表示:
public class EntityIdentifier {
private Integer id;
private Integer version;
}
然后有一个BaseRepository
来通过EntityIdentifier
加载一个实体。 如果EntityIdentifier
的version
为 NULL,则将其视为最新版本。 其他实体的所有存储库都将扩展它以重用此方法:
public abstract class BaseRepository<T extends Entity> {
private EntityManager entityManager;
public T findById(EntityIdentifier identifier){
T t = entityManager.find(getEntityClass(), identifier.getId());
if(identifier.getVersion() != null && !t.getVersion().equals(identifier.getVersion())){
throws new OptimisticLockException();
}
return t;
}
注意:此方法并不意味着以确切版本加载实体的状态,因为我们在这里不进行事件溯源,也不会在每个版本中存储实体状态。 加载实体的状态将始终是最新版本,EntityIdentifier 中的版本仅用于处理乐观锁定。
为了使其更通用且易于使用,我还将定义一个EntityBackable
接口,以便BaseRepository
一旦实现它就可以加载任何支持的实体(例如 DTO)。
public interface EntityBackable{
public EntityIdentifier getBackedEntityIdentifier();
}
并将以下方法添加到BaseRepository
:
public T findById(EntityBackable eb){
return findById(eb.getBackedEntityIdentifier());
}
所以最后, ItemDto
和updateItem()
应用服务看起来像:
public class ItemDto implements EntityBackable {
private Integer id;
private Integer version;
@Override
public EntityIdentifier getBackedEntityIdentifier(){
return new EntityIdentifier(id ,version);
}
}
@Transactional
public void changeName(ItemDto dto){
Item item = itemRepository.findById(dto);
item.changeName(dto.getName());
}
总而言之,此解决方案可以:
BaseRepository
,因此没有技术细节泄漏到域中。笔记:
setVersion()
仍然需要从域实体中公开。但是我可以接受,因为从存储库中获取的实体是被管理的,这意味着即使开发人员调用setVersion()
也不会对实体产生影响。 如果您真的不希望开发人员调用setVersion()
。 您可以简单地添加一个ArchUnit 测试来验证它只能从BaseRepository
。这里所做的所有解释和建议都非常有帮助,但由于最终解决方案略有不同,我认为值得分享。
直接操作version
无法正常工作,并且与 JPA 规范冲突,因此没有选择。
最终的解决方案是 JPA Hibernate 的显式版本检查 + 自动版本检查。 在应用层执行显式版本检查:
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
rejectConcurrentModification(dto, item)
item.changeName(dto.name)
}
为了减少重复,实际检查发生在一个单独的方法中:
fun rejectConcurrentModification(dto: Versioned, entity: Versioned) {
if (dto.version != entity.version) {
throw ConcurrentModificationException(
"Client providing version ${dto.version} tried to change " +
"entity with version ${entity.version}.")
}
}
实体和 DTO 都实现了Versioned
接口:
interface Versioned {
val version: Int
}
@Entity
class Item : Versioned {
@Version
override val version: Int = 0
}
data class ItemDto(override val version: Int) : Versioned
但是从两者中提取version
并将其传递给rejectConcurrentModification
将同样有效:
rejectConcurrentModification(dto.version, item.verion)
应用层显式检查的明显缺点是它可能会被遗忘。 但是由于存储库必须提供一种方法来加载没有版本的实体,因此将版本添加到存储库的find
方法也不安全。
应用层显式版本检查的好处是它不会污染域层,除非version
需要从外部读取(通过实现Versioned
接口)。 实体或存储库方法都是域的一部分,不会受到version
参数的污染。
在可能的最新时间点不执行显式版本检查并不重要。 如果在这次检查和数据库的最终更新之间,另一个用户修改了同一个实体,那么 Hibernate 的自动版本检查将生效,因为在更新请求开始时加载的版本仍然在内存中(在堆栈中)我的示例中的changeName
方法)。 因此,第一次显式检查将防止在客户端编辑开始和显式版本检查之间发生并发修改。 并且自动版本检查将防止显式检查和数据库的最终更新之间的并发修改。
为了防止并发修改,我们必须在某处跟踪正在修改哪个版本的项目。
如果应用程序是有状态的,我们可以选择将此信息保留在服务器端,可能在会话中,尽管这可能不是最佳选择。
在无状态应用程序中,这些信息必须一直传到客户端,并随着每个变化的请求返回。
因此,IMO,如果防止并发修改是一项功能要求,那么在变异 API 调用中包含项目版本信息不会污染 API,而是使其完整。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.