使用 JPA/休眠的无状态应用程序中的乐观锁定
我想知道在无法在请求之间保留具有特定版本的实体实例的系统中实现乐观锁定(乐观并发控制)的最佳方法是什么。这实际上是一个非常常见的场景,但几乎所有示例都基于在请求之间保存加载的实体的应用程序(在http会话中)。
如何在尽可能减少API污染的情况下实现乐观锁定?
约束
- 该系统基于领域驱动设计原则开发。
- 客户端/服务器系统
- 实体实例不能在请求之间保留(出于可用性和可伸缩性原因)。
- 技术细节应该尽可能少地污染域的API。
堆栈是带有JPA(Hibernate)的Spring,如果这应该有任何相关性。
仅使用时出现问题@Version
在许多文档中,看起来您需要做的就是装饰字段,JPA / Hibernate将自动检查版本。但是,只有当加载的对象及其当前版本保留在内存中,直到更新更改同一实例时,这才有效。@Version
在无状态应用程序中使用时会发生什么情况:@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")
- 服务器加载带有 返回的项目,它会更改 和 持久化 。休眠将版本递增为 。
EntityManager
Item(id = 1, version = 1, name = "a")
name
Item(id = 1, version = 1, name = "b")
2
- 客户端 B 修改项目并将其发送回服务器:。
Item(id = 1, version = 1, name = "c")
- 服务器加载带有 返回的项目,它会更改 和 持久化 。休眠将版本递增为 。似乎没有冲突!
EntityManager
Item(id = 1, version = 2, name = "b")
name
Item(id = 1, version = 2, name = "c")
3
正如您在步骤 6 中看到的,问题在于 EntityManager 在更新之前重新加载 Item 的当前版本 ()。客户端 B 开始编辑的信息丢失,休眠无法检测到冲突。客户端 B 执行的更新请求必须保留(而不是 )。version = 2
version = 1
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)
}
如果找到具有给定 ID 但具有不同版本的实例,则会抛出 an。findByIdAndVersion
OptimisticLockException
优点
- 不可能忘记处理版本
-
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
- 与“工作单元”模式冲突。
问题
你会如何解决它,为什么?有没有更好的主意?
相关
- RESTful 应用程序中的乐观锁定
- 使用Spring Boot和Angular 2在分布式RESTful环境中管理并发性(这基本上是使用HTTP标头实现的上述“显式版本检查”)