繁体   English   中英

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

[英]Optimistic locking in a stateless application with JPA / Hibernate

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

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

约束

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

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

仅使用@Version问题

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

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

  1. 客户端 A 加载id = 1 item 并获取Item(id = 1, version = 1, name = "a")
  2. 客户端 B 加载id = 1 item 并获取Item(id = 1, version = 1, name = "a")
  3. 客户端 A 修改 item 并将其发送回服务器: Item(id = 1, version = 1, name = "b")
  4. 服务器使用返回Item(id = 1, version = 1, name = "a")EntityManager加载项目,它更改name并保留Item(id = 1, version = 1, name = "b") Hibernate 将版本增加到2
  5. 客户端 B 修改项目并将其发送回服务器: Item(id = 1, version = 1, name = "c")
  6. 服务器使用返回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 和版本加载

实体可以通过 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方法与“工作单元”模式相矛盾

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

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 的新对象

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

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

优点

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

缺点

  • ID 和版本作为技术属性是域类接口的一部分
  • 创建新对象将阻止使用在域中具有意义的变异方法。 也许有一个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参数污染,我将entityIdversion建模为一个域概念,由一个名为EntityIdentifier的值对象表示:

public class EntityIdentifier {
    private Integer id;
    private Integer version;
}

然后有一个BaseRepository来通过EntityIdentifier加载一个实体。 如果EntityIdentifierversion为 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 测试来验证它只能从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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM