使用JPA / Hibernate在无状态应用程序中进行乐观锁定

dea*_*mon 20 java hibernate jpa optimistic-locking optimistic-concurrency

我想知道在无法在请求之间保留具有特定版本的实体实例的系统中,实现乐观锁定(乐观并发控制)的最佳方法是什么。这实际上是一个非常常见的场景,但是几乎所有示例都基于在请求之间(在http会话中)保存已加载实体的应用程序。

如何在尽可能少的API污染的情况下实现乐观锁定?

约束条件

  • 该系统是根据域驱动设计原则开发的。
  • 客户端/服务器系统
  • 无法在请求之间保留实体实例(出于可用性和可伸缩性的原因)。
  • 技术细节应尽可能少污染域的API。

栈是带有JPA(休眠)的Spring,如果这有任何关系的话。

@Version仅使用时出现问题

在许多文档中,您似乎所需要做的就是用装饰一个字段,@Version并且JPA / Hibernate将自动检查版本。但是,只有将加载的对象及其当前版本保存在内存中,直到更新更改了同一实例,这才起作用。

@Version在无状态应用程序中使用时会发生什么:

  1. 客户端A使用加载项目id = 1并获取Item(id = 1, version = 1, name = "a")
  2. 客户B加载项目id = 1并获取Item(id = 1, version = 1, name = "a")
  3. 客户端A修改项目并将其发送回服务器: Item(id = 1, version = 1, name = "b")
  4. 服务器加载EntityManager返回的项目Item(id = 1, version = 1, name = "a"),它更改name和持久化Item(id = 1, version = 1, name = "b")。Hibernate将版本增加到2
  5. 客户端B修改项目并将其发送回服务器:Item(id = 1, version = 1, name = "c")
  6. 服务器加载EntityManager返回的项目Item(id = 1, version = 2, name = "b"),它更改name和持久化Item(id = 1, version = 2, name = "c")。Hibernate将版本增加到3看似没有冲突!

如您在步骤6中所看到的,问题在于EntityManager version = 2在更新之前立即重新加载了Item 的当时最新版本()。客户端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)
}
Run Code Online (Sandbox Code Playgroud)

优点

  • 域类(Item)不需要从外部操纵版本的方法。
  • 版本检查不是域的一部分(version属性本身除外)

缺点

  • 容易忘记
  • 版本字段必须为公开
  • 不使用框架的自动版本检查(在最新的时间点)

可以通过额外的包装程序来防止忘记支票(ConcurrencyGuard在下面的示例中)。存储库不会直接返回项目,而是会执行检查的容器。

@Transactional
fun changeName(dto: ItemDto) {
    val guardedItem: ConcurrencyGuard<Item> = itemRepository.findById(dto.id)
    val item = guardedItem.checkVersionAndReturnEntity(dto.version)
    item.changeName(dto.name)
}
Run Code Online (Sandbox Code Playgroud)

不利的一面是,在某些情况下,该检查是不必要的(只读访问)。但是可能还有另一种方法returnEntityForReadOnlyAccess。另一个缺点是,ConcurrencyGuard该类会将技术方面带入存储库的域概念。

按ID和版本加载

可以通过ID和版本来加载实体,以便在加载时显示冲突。

@Transactional
fun changeName(dto: ItemDto) {
    val item = itemRepository.findByIdAndVersion(dto.id, dto.version)
    item.changeName(dto.name)
}
Run Code Online (Sandbox Code Playgroud)

如果findByIdAndVersion将查找具有给定ID但版本不同的实例,OptimisticLockException则将引发。

优点

  • 不可能忘记处理版本
  • version 不会污染域对象的所有方法(尽管存储库也是域对象)

缺点

  • 储存库API的污染
  • findById 初始加载(开始编辑时)仍然需要没有版本的软件,并且这种方法很容易被意外使用。

使用显式版本更新

@Transactional
fun changeName(dto: itemDto) {
    val item = itemRepository.findById(dto.id)
    item.changeName(dto.name)
    itemRepository.update(item, dto.version)
}
Run Code Online (Sandbox Code Playgroud)

优点

  • 并非实体的每种变异方法都必须使用版本参数来污染

缺点

  • 存储库API被技术参数污染 version
  • 显式update方法将与“工作单元”模式相矛盾

突变时明确更新版本属性

可以将version参数传递给可变方法,而这些方法可以在内部更新version字段。

@Entity
class Item(var name: String) {
    @Version
    private version: Int

    fun changeName(name: String, version: Int) {
        this.version = version
        this.name = name
    }
}
Run Code Online (Sandbox Code Playgroud)

优点

  • 不可能忘记

缺点

  • 所有变异域方法中的技术细节泄漏
  • 容易忘记
  • 这是不允许直接改变管理实体的版本属性。

这种模式的一种变体是直接在加载的对象上设置版本。

@Transactional
fun changeName(dto: ItemDto) {
    val item = itemRepository.findById(dto.id)
    it.version = dto.version
    item.changeName(dto.name)
}
Run Code Online (Sandbox Code Playgroud)

但这会使暴露的版本直接暴露给读写,并且会增加出错的可能性,因为此调用很容易被遗忘。但是,并非每种方法都会被version参数污染。

创建具有相同ID的新对象

可以在应用程序中创建一个与要更新的对象具有相同ID的新对象。该对象将在构造函数中获取version属性。然后,新创建的对象将合并到持久性上下文中。

@Transactional
fun update(dto: ItemDto) {
    val item = Item(dto.id, dto.version, dto.name) // and other properties ...
    repository.save(item)
}
Run Code Online (Sandbox Code Playgroud)

优点

  • 适用于各种修改
  • 不可能忘记的版本属性
  • 不变的对象很容易创建
  • 在许多情况下,无需先加载现有对象

缺点

  • ID和版本作为技术属性是域类接口的一部分
  • 创建新对象将阻止使用具有域含义的突变方法。也许有一种changeName方法应该只对更改而不对名称的初始设置执行某些操作。在这种情况下不会调用这种方法。也许可以通过特定的工厂方法来减轻这种不利影响。
  • 与“工作单元”模式冲突。

您将如何解决?为什么?有更好的主意吗?

有关

mer*_*ike 7

服务器使用EntityManager加载项目,该项目返回Item(id = 1,version = 1,名称=“ a”),它更改名称并保留Item(id = 1,version = 1,名称=“ 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;
}
Run Code Online (Sandbox Code Playgroud)

然后您可以简单地执行以下操作:

public Item updateItem(Item itemFromClient) {
    Item item = find(itemFromClient);
    item.setName(itemFromClient.getName());
    return item;
}
Run Code Online (Sandbox Code Playgroud)

根据不可修改字段的性质,您还可以执行以下操作:

public Item updateItem(Item itemFromClient) {
    Item item = entityManager.merge(itemFromClient);
    item.setLastUpdated(now());
}
Run Code Online (Sandbox Code Playgroud)

对于以DDD方式执行此操作,版本检查是持久性技术的实现细节,因此应在存储库实现中进行。

要通过应用程序的各个层传递版本,我发现使版本成为域实体或值对象的一部分很方便。这样,其他层不必显式与version字段进行交互。