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

Optimistic locking in a stateless application with JPA / Hibernate

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

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

约束条件

堆栈是 Spring 与 JPA (Hibernate),如果这应该有任何相关性的话。

仅使用 @Version 时出现问题

在许多文档中,您似乎只需要用 @Version 装饰一个字段,而 JPA/Hibernate 会自动检查版本。但这只有在加载的 objects 及其当时的当前版本保存在内存中直到更新更改同一实例时才有效。

在无状态应用程序中使用 @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 的项目,其中 return 为 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 的项目,其中 return 为 Item(id = 1, version = 2, name = "b"),它更改 name 并保留 Item(id = 1, version = 2, name = "c")。 Hibernate 将版本增加到 3貌似没有冲突!

正如您在第 6 步中看到的那样,问题是 EntityManager 在更新之前立即重新加载了 Item 当时的当前版本 (version = 2)。客户端B用version = 1开始编辑的信息丢失了,Hibernate无法检测到冲突。客户端 B 执行的更新请求必须持久化 Item(id = 1, version = 1, name = "b")(而不是 version = 2)。

JPA/Hibernate 提供的自动版本检查只有在初始 GET 请求上加载的实例在服务器上的某种客户端 session 中保持活动时才会起作用,并且会稍后由各自的客户更新。但是在 stateless 服务器中,必须以某种方式考虑来自客户端的版本。

可能的解决方案

显式版本检查

可以在应用程序服务的方法中执行显式版本检查:

@Transactional
fun changeName(dto: ItemDto) {
    val item = itemRepository.findById(dto.id)
    if (dto.version > item.version) {
        throw OptimisticLockException()
    }
    item.changeName(dto.name)
}

优点

缺点

可以通过额外的包装器来防止忘记检查(ConcurrencyGuard 在我下面的示例中)。存储库不会直接 ​​return 项目,而是会强制执行检查的容器。​​

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

缺点是在某些情况下不需要检查(read-only 访问)。但可能还有另一种方法returnEntityForReadOnlyAccess。另一个缺点是 ConcurrencyGuard class 会给存储库的域概念带来技术方面的影响。

按 ID 和版本加载

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

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

如果 findByIdAndVersion 找到具有给定 ID 但版本不同的实例,则会抛出 OptimisticLockException

优点

缺点

正在更新显式版本

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

优点

缺点

更新版本属性 显式突变

可以将版本参数传递给可以在内部更新版本字段的变异方法。

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

    fun changeName(name: String, version: Int) {
        this.version = version
        this.name = name
    }
}

优点

缺点

此模式的一个变体是直接在加载的 object.

上设置版本
@Transactional
fun changeName(dto: ItemDto) {
    val item = itemRepository.findById(dto.id)
    it.version = dto.version
    item.changeName(dto.name)
}

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

使用相同的 ID 创建一个新的 Object

可以在应用程序中创建与要更新的 object 具有相同 ID 的新 object。此 object 将在构造函数中获得版本 属性。新创建的 object 然后将合并到持久性上下文中。

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

优点

缺点

问题

你会如何解决它,为什么?有更好的主意吗?

相关

The server loads the item with the EntityManager which returns Item(id = 1, version = 1, name = "a"), it changes the name and persist Item(id = 1, version = 1, name = "b"). Hibernate increments the version to 2.

这是对 JPA 的误用 API,也是您的错误的根本原因。

如果您改用 entityManager.merge(itemFromClient),将自动检查乐观锁定版本,并 "updates from the past" 拒绝。

一个警告是 entityManager.merge 将合并实体的 整个 状态。如果您只想更新某些字段,那么使用普通 JPA 会有些混乱。具体来说,因为you may not assign the version property,所以一定要自己查看版本。但是,该代码很容易重用:

<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方式,版本检查是持久化技术的一个实现细节,因此应该出现在repository实现中。

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

当您从数据库加载记录以处理更新请求时,您必须将加载的实例配置为具有客户端提供的相同版本。但不幸的是,当一个实体被管理时,它的版本 cannot be changed manually 是 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参数污染,我将entityIdversion建模为一个领域概念,由一个值表示名为 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());
 }

所以最后,ItemDtoupdateItem() 应用程序服务看起来像:

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());
}

总而言之,此解决方案可以:

  • 工作单元模式仍然有效
  • 存储库API 不会填充版本参数
  • 有关控制版本的所有技术细节都封装在 BaseRepository 中,因此没有技术细节泄漏到域中。

注:

  • setVersion() 仍然需要从域中公开 entity.But 我同意它,因为从存储库获取的实体是托管的,这意味着即使开发人员调用也不会影响实体setVersion()。如果实在不想让开发者调用setVersion()。您可以简单地添加一个 ArchUnit test 来验证它只能从 BaseRepository.
  • 调用

为了防止并发修改,我们必须在某处跟踪项目的哪个版本被修改。

如果应用程序是有状态的,我们可以选择将此信息保存在服务器端,可能在会话中,尽管这可能不是最佳选择。

在无状态应用程序中,此信息必须一路传到客户端,并随着每个变异请求返回。

因此,IMO,如果防止并发修改是一项功能要求,那么在变异 API 调用中包含项目版本信息不会污染 API,它会使其完整。

这里所有的解释和建议都非常有帮助,但由于最终的解决方案有点不同,我认为值得分享。

直接操作 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 方法)。因此,第一个显式检查将防止在客户端编辑开始和 显式版本检查 之间发生并发修改。 自动版本检查 会阻止显式检查和数据库最终更新之间的并发修改。