使用 JPA/休眠的无状态应用程序中的乐观锁定

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

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

约束

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

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

仅使用时出现问题@Version

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

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

  1. 客户端 A 加载包含和获取的项目id = 1Item(id = 1, version = 1, name = "a")
  2. 客户端 B 加载项目并获取id = 1Item(id = 1, version = 1, name = "a")
  3. 客户端 A 修改项目并将其发送回服务器:Item(id = 1, version = 1, name = "b")
  4. 服务器加载带有 返回的项目,它会更改 和 持久化 。休眠将版本递增为 。EntityManagerItem(id = 1, version = 1, name = "a")nameItem(id = 1, version = 1, name = "b")2
  5. 客户端 B 修改项目并将其发送回服务器:。Item(id = 1, version = 1, name = "c")
  6. 服务器加载带有 返回的项目,它会更改 和 持久化 。休眠将版本递增为 。似乎没有冲突!EntityManagerItem(id = 1, version = 2, name = "b")nameItem(id = 1, version = 2, name = "c")3

正如您在步骤 6 中看到的,问题在于 EntityManager 在更新之前重新加载 Item 的当前版本 ()。客户端 B 开始编辑的信息丢失,休眠无法检测到冲突。客户端 B 执行的更新请求必须保留(而不是 )。version = 2version = 1Item(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)
}

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

按 ID 和版本加载

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

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

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

优点

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

缺点

  • 存储库 API 的污染
  • findById没有版本无论如何都需要初始加载(编辑开始时),并且此方法很容易意外使用

使用显式版本更新

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

优点

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

缺点

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

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

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

@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 的新对象。此对象将获取构造函数中的版本属性。然后,新创建的对象将合并到持久性上下文中。

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

优点

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

缺点

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

问题

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

相关


答案 1

服务器使用 EntityManager 加载项目,该 EntityManager 返回 Item(id = 1,版本 = 1,name = “a”),它会更改名称并保留 Item(id = 1,版本 = 1,名称 = “b”)。休眠将版本递增至 2。

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

如果您改用,乐观锁定版本将自动检查,并拒绝“过去的更新”。entityManager.merge(itemFromClient)

一个警告是,这将合并实体的整个状态。如果您只想更新某些字段,那么普通的JPA会有点混乱。具体来说,由于您可能不分配版本属性,因此必须自行检查版本。但是,该代码很容易重用:entityManager.merge

<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 方式执行此操作,版本检查是持久性技术的实现细节,因此应该在存储库实现中进行。

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


答案 2

从 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 会被参数污染的担忧,我将建模并作为一个域概念,它由一个名为 :versionentityIdversionEntityIdentifier

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

然后有 一个 通过 加载实体。如果 in 为 NULL,它将被视为最新版本。其他实体的所有存储库都将扩展它,以便重用此方法:BaseRepositoryEntityIdentifierversionEntityIdentifier

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中的版本仅用于处理乐观锁定。

为了使它更通用且易于使用,我还将定义一个接口,以便在实现它后可以加载任何内容(例如DTO)的支持实体。EntityBackableBaseRepository

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()仍然需要从域实体中公开。但是我对此感到满意,因为从存储库获取的实体是托管的,这意味着即使开发人员调用实体也不会对实体产生影响。如果你真的不希望开发人员调用.您只需添加一个 ArchUnit 测试即可验证它只能从 中调用。setVersion()setVersion()BaseRepository

推荐