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

Posted

技术标签:

【中文标题】使用 JPA / Hibernate 在无状态应用程序中进行乐观锁定【英文标题】:Optimistic locking in a stateless application with JPA / Hibernate 【发布时间】:2019-12-30 20:19:41 【问题描述】:

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

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

约束

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

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

仅使用@Version 时出现问题

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

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

    客户端 A 使用 id = 1 加载项目并获取 Item(id = 1, version = 1, name = "a") 客户端 B 使用 id = 1 加载项目并获取 Item(id = 1, version = 1, name = "a") 客户端 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。 客户端 B 修改项目并将其发送回服务器: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 在更新之前立即重新加载项目的当前版本 (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 和版本加载

实体可以通过ID和版本加载,这样冲突就会在加载时显示出来。

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

如果 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)

优点

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

缺点

Repository API 被技术参数version 污染 显式 update 方法会与“工作单元”模式相矛盾

在突变时显式更新版本属性

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

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

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

优点

不可能忘记

缺点

所有变异域方法中的技术细节泄漏 容易忘记 直接更改托管实体的版本属性为not allowed。

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

@Transactional
fun changeName(dto: ItemDto) 
    val item = itemRepository.findById(dto.id)
    it.version = dto.version
    item.changeName(dto.name)

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

创建一个具有相同 ID 的新对象

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

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

优点

一致的各种修改 不可能忘记版本属性 不可变对象很容易创建 很多情况下不需要先加载现有对象

缺点

ID 和版本作为技术属性是领域类接口的一部分 创建新对象会阻止使用在域中有意义的变异方法。也许有一个changeName 方法应该只对更改而不是对名称的初始设置执行特定操作。在这种情况下不会调用这样的方法。也许这个缺点可以通过特定的工厂方法来缓解。 与“工作单元”模式冲突。

问题

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

相关

Optimistic locking in a RESTful application Managing concurrency in a distributed RESTful environment with Spring Boot and Angular 2(这基本上是上面使用 HTTP 标头实现的“显式版本检查”)

【问题讨论】:

不,这不是它的工作方式。它不会“重新应用”任何东西。它的作用是为您的查询添加额外的约束,使它们看起来像 UPDAT .... WHERE id=X 和 VERSION=y。中间不需要保留任何东西。它是有代价的,但它的失败很小。 我认为您必须在每个读取查询中使用 version 的假设是错误的。您只能通过 ID 阅读。版本用于写操作。 API 无污染,不允许并发修改。请记住,它不是版本控制系统。它更像是写操作上下文中的人工复合 PK。恕我直言,这就是您所需要的,并且应该符合您的要求。没有必要使用findByIdAndVersion之类的东西,只需findById 如果 2 个用户在同一个实体上工作并且有它的“思考时间”,那么两个用户将拥有相同版本的相同实体。如果两者都尝试使用相同的版本号对其进行更新,那么首先执行此操作的(字面意思)将更新数据库中的实体。另一个将有 OptimisticLockException,因为它现在已经过时的实体版本并且不走运 - 必须用新版本重做他对新实体的工作。 您的第 6 点表明版本控制根本不起作用。在 STEP 6 中应该抛出 OptimisticLockException。仔细检查您的配置。简而言之 - 不应该使用版本控制进行更新。您的期望是正确的,但由于某些原因,它不适用于您的情况(让您认为这是设计使然)。您的期望与使用 @Version 进行版本控制的工作方式完全一致。 您使用的是EntityManager#merge 吗?如果您手动更新(就像您在示例 sn-ps 中所做的那样),难怪它不适合您。而不是事先 fetchig,只需执行EntityManager#merge(dto)。我认为这是关于版本控制由于误用而无法正常工作的 XY 问题。 【参考方案1】:

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

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

【讨论】:

【参考方案2】:

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

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

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

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

【讨论】:

考虑version 参数“API 污染”,因为它将技术方面带入领域。冲突需要通过业务定义的一些规则来处理是对的,但是检测冲突的方式是技术方面的。【参考方案3】:

当您从数据库加载记录以处理更新请求时,您必须将该加载的实例配置为具有客户端提供的相同版本。但不幸的是,当一个实体被管理时,它的版本 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() 仍然需要从域实体中公开。但我可以接受,因为从存储库获取的实体是受管理的,这意味着即使开发人员调用 setVersion() 也不会影响实体。如果您真的不希望开发人员致电setVersion()。您可以简单地添加一个ArchUnit test 来验证它只能从BaseRepository 调用。

【讨论】:

如果实体先前已在此持久性上下文中加载,则分离和合并技巧可能会产生意想不到的后果。具体来说,entityManager.find 将返回前一个实例,然后detach 从持久性上下文中删除该实例。如果最初加载的代码在此之后继续使用原始引用,这可能会导致令人惊讶的行为。例如,以后对该对象的更新不会写入数据库,如果另一个实体继续引用前一个实例,JPA 会抛出异常。 是的。你说的对。它可能会产生如此惊人的后果。我同意您的观点,除非 Hibernate 具有特定功能以允许将来使用此用例(手动更改托管实体的版本),否则我们自己检查版本会更容易。【参考方案4】:

服务器使用返回 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 会有些混乱。具体是因为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 方式执行此操作,版本检查是持久性技术的一个实现细节,因此应该发生在存储库实现中。

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

【讨论】:

加载和修改实体是unit of work 模式的本质。但也许它在这里没有正确应用。我是否正确理解itemFromClient 将包含来自客户端的version?如果是这样:您是否会从 DTO 实例化一个新的 Item 对象并将 ID 和版本传递给 Item 构造函数? 是的,域类将包含版本。要回答有关与 DTO 之间的转换的问题,我需要知道您使用这些 DTO 的目的。 (由于 DDD 没有提到 DTO,这并不明显......)。就我个人而言,我尽量避免不必要的转换,并且更喜欢在应用程序的所有层中使用域类... ...当然你有很多很好的理由来使用它们。只是我可以想象 DTO 的不同用途,相应的解决方案也不同。 DTO 是 Json 消息的类型安全表示。它可以代表一个完整的对象或仅代表一个更新。在第一种情况下,可以从 DTO 构造一个新实体,尽管域类会在构造函数中公开技术 version。但是对先前状态的验证是不可能的。在第二种情况下,我将加载实体并将参数从 DTO 传递到变异方法。这将允许根据先前的状态进行检查。示例:“新名称必须始终比旧名称长”只能在以前的状态下强制执行。 关键问题是你的 DTO 是否是域的一部分,即它们是否是 DDD 值对象。如果是这样,您可以将它们传递给域类甚至存储库,从而使应用程序服务不必与单个属性进行交互。在这些情况下,有些人甚至更喜欢使用命令模式,声明一个 ItemUpdateCommand 具有对 Item 类内部的特权访问。但无论如何,我们在这里偏离了主题,这些评论框对于讨论建筑风格来说太小了。以适合您架构的任何方式传递版本:-)

以上是关于使用 JPA / Hibernate 在无状态应用程序中进行乐观锁定的主要内容,如果未能解决你的问题,请参考以下文章

JPA 在带有 DTO 和乐观锁定的 RESTful Web 应用程序中合并?

HSLQDB + JPA2(使用 Hibernate)- 尝试 TRUNCATE SCHEMA 时应用程序卡住

JPA/Hibernate 在 Spring Boot 应用程序中插入不存在的表

Play + JPA + Hibernate + PostgreSQL:无法创建表

在 JPA/Hibernate 中使用 @OnetoMany 的实体中不存在时从数据库中删除子记录(Spring 引导应用程序)

是否有包含使用 JPA 或 Hibernate 的 Elastic Beanstalk 教程?