【发布时间】: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方法应该只对更改而不是对名称的初始设置执行特定操作。在这种情况下不会调用这样的方法。也许这个缺点可以通过特定的工厂方法来缓解。 - 与“工作单元”模式冲突。
问题
您将如何解决它,为什么?有更好的主意吗?
相关
【问题讨论】:
-
不,这不是它的工作方式。它不会“重新应用”任何东西。它的作用是为您的查询添加额外的约束,使它们看起来像 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 问题。
标签: java hibernate jpa optimistic-locking optimistic-concurrency