处理过时域对象(Grails / GORM / Hibernate)引起的并发问题的策略

时间:2014-08-03 16:19:28

标签: java spring hibernate grails transactions

我喜欢将此问题称为“可重复查找器”问题,因为它在某种意义上与“不可重复读取”相反。因为hibernate重用附加到其会话的对象,所以查找程序的结果可能包含一些现在过时的旧版本对象。

问题在技术上是一个Hibernate设计问题,但由于Hibernate会话隐含在Grails和Grails中,域对象很长(对我来说HTTP请求很长)我决定在Grails / GORM的上下文中提出这个问题。 / p>

我想问一下这里的专家是否有任何常用的策略来处理这个问题。

考虑一下:

    class BankAccount {
      String name
      Float amount

      static constraints = {
        name unique: true
      }
    }

和'componentA'代码:

    BankAccount.findByName('a1')

'componentB代码:

    def result = BankAccount.findAll()

假设componentA首先执行,然后是其他逻辑,然后是componentB,组件B的结果由视图呈现。组件A和B不希望彼此了解太多。

这样,componentB结果包含旧版本的BankAccount'a1'。

许多非常令人尴尬的事情都可能发生。如果BankAccounts已被同时修改,则所呈现的列表可以包含2个名称为“a1”的项目(唯一性看起来已经转向用户!)或者帐户之间的转帐可以显示为部分应用的交易(如果资金从a2转移到a1那么它将显示从a2中扣除但是还没有a1)。 这些问题令人尴尬,可能会降低用户对应用程序的信心。

ADDED 9/24/2014:这是一个令人瞩目的例子,这个断言可能会失败:

  BankAccount.findAllByName('a1').every{ it.name == 'a1' }

可以在任何链接的JIRA门票或我的博客中找到这种情况的示例。 )

ADDED 9/24/2014:注意:在实现equals()方法时使用数据库强制唯一键的看似合理的建议不是并发安全的。您可能会获得具有相同值的2个对象不同的“商业关键”。)

可能的解决方案似乎是添加了很多discard()调用或很多withNewSession()调用,并处理LazyIntializationExeption和DuplicateKeyException等。
但是,如果我这样做,为什么我使用hibernate / GORM?从每个查询返回的每个对象上调用刷新看起来简直太荒谬了。

我目前的想法是在某些关键领域使用短会话/新会话是最好的方法,但在所有情况下都没有解决问题,只是一些关键的应用领域。

这是Grails应用程序必须使用的东西吗? 你能指点我关于这个问题的任何文件/讨论吗?

2014年9月24日编辑: 相关Grails JIRA门票:https://jira.grails.org/browse/GRAILS-11645, Hibernate JIRA:https://hibernate.atlassian.net/browse/HHH-9367(遗憾地被拒绝了), 我的博客有更详细的例子:http://rpeszek.blogspot.com/2014/08/i-dont-like-hibernategrails-part-2.html

ADDED 10/17/2014:我收到了几条回复,声明这是任何数据库应用程序/任何ORM问题。这不正确。

确实可以通过使用长事务(Hibernate会话长度/ HTTP请求长度)+设置高于REPEATABLE READ的正常DB隔离级别来避免此问题。这个解决方案根本不可接受(为什么我们有跨国服务,如果应用程序正常工作,我们需要HTTP请求长事务!?)

数据库应用程序和其他ORM不会出现此问题。他们不需要长时间的交易就可以工作,只需READ COMMITTED即可防止问题。

现在已经2个月了,因为我在这里发布了这个问题,并没有收到有意义的答案。这只是因为这个问题没有答案。这是Hibernate可以修复的东西,而不是Grails应用程序可以解决的问题。 ADDED 10/17 / 2014-END

4 个答案:

答案 0 :(得分:6)

这是我自己尝试回答这个问题。

ADDED 9/24/2014 这个问题根本没有很好的解决办法。可悲的是,HHHH-9367 JIRA的机票被Hibernate拒绝了,而不是一个错误'该票证中建议的唯一解决方案是使用刷新(我假设需要将所有查询更改为看起来像:

BankAccount.findAllBy...(...).each{ it.refresh() }

就个人而言,我不同意这是一个有意义的解决方案。)

正如我上面所解释的,如果Hibernate / GORM查询返回一组DomainObjects及其中的一些 对象已经处于休眠会话中(由先前的查询填充),查询将返回这些旧对象,并且这些对象将不会自动刷新。这可能会导致一些难以发现的并发问题。我称之为可重复查找器问题。

这与二级缓存无关。这个问题是由于即使没有配置第二级缓存,hibernate的工作原理也是如此。 (2014年9月24日编辑:而且,这不是任何ORM,任何数据库应用程序问题,该问题都是特定于使用Hibernate的。)

对您的应用程序的影响:

我只能解释我所知道的影响,我并未声称这些是唯一的影响)。

域对象通常具有一组关联的约束/逻辑规则,这些约束/逻辑规则通常需要保存在几个记录中,并由应用程序或数据库本身强制执行。我将从FP和测试中借用一个术语,并将其称为这些属性'。

示例属性: 在上面的BankAccount示例中,名称唯一性(由DB强制执行)是一个属性(例如,您可以在定义equals()方法时使用它),如果在帐户之间转移资金, 这些账户中的总金额需要是一个常数 - 这是一个属性 如果我修改了我的BankAccount类并添加了' branch'与它的关联:

BankBranch branch

然后这也是一个属性:

assert BankAccount.findAllByBranch(b).every{it.branch == b}.

(编辑,这个属性在技术上应该由DB强制执行并且实现finder方法,并且开发人员可能认为它是安全的并且不会破坏。事实上大多数'其中'标准和你的应用程序在hibernate下面的某个地方使用'加入定义了类似性质的属性。)。

可重复的查找程序问题可能导致大多数属性在并发使用下中断(可怕的东西!)。例如,我在这里重申我写的一段代码 在问题中链接的相关JIRA票证:

... a1 has branch b1
BankAccount.findByName('a1')

... concurrently a1 is moved to branch b2
//fails because stale a1.branch == b1
assert BankAccount.findAllByBranch(b2).every{it.branch == b2} 

您的应用程序可能使用显式和隐式属性,并且可能具有强制执行它们的逻辑。 例如,应用程序可能依赖于唯一的名称,如果它们不是唯一的,则会异常或返回错误的结果(可能名称本身用于定义equals())。这是明确的用法。 应用程序可能会提供列表视图,如果列表显示违反了属性,则会非常尴尬(分支b2下的帐户列表显示某些具有分支b1的帐户 - 这是隐式用法)。任何此类案件都会受到#34;可重复查找器的影响。

如果Grails代码(不是数据库约束)用于强制执行属性,那么除了可重复查找器之外'需要解决更明显的并发问题。 (我这里不讨论这些。)

发现问题:

这仅适用于破碎的属性。我不知道可重复的查找器是否会导致其他问题。

所以,我认为第一步是识别应用程序中的所有属性(EDITED:会有很多属性,可能需要检查太多 - 所以,专注于可能同时更改的域对象可能是关键点。),第二步是确定应用程序(隐式或显式)使用这些属性的位置和方式以及它们是如何实施的。需要检查每个这些需求的代码,以验证可重复的查找器不是问题。

这可能是一个好主意,只需启用SQL跟踪(以及跟踪每个HTTP请求的开始和结束位置),并检查来自'来自&#39的任何表名的已识别区域的日志跟踪; SQL的一部分。如果这样的表格出现不止一次 根据请求,这可能是问题的良好指示。良好的功能测试覆盖率可以帮助生成此类日志文件。

这显然是一个不重要的过程,这里没有防弹解决方案。

解决问题:

对先前查询的对象使用discard()或运行依赖于单独的hibernate会话中的某些应用程序属性/属性的查询应该可以解决问题。使用新的会话方法应该更加防弹。我不建议在这里使用refresh()。 (注意,hibernate不提供公共API来查询附加到其会话的对象。)
使用新会话将公开应用程序 一些新问题,如LazyInitalizationException或DupicateKeyException。相比之下,这些都是微不足道的。

SIDE注意:我个人认为框架设计决策导致代码在添加额外查询时中断:一个可怕的设计缺陷。

将Hibernate与Active Record(我不太了解)进行比较很有意思。 Hibernate采用ORM纯粹主义的方法尝试将RDBMS变为OO,Active Record采取了“不分享”的方式。更接近数据库的方法 让DB处理更复杂的并发问题 当然,在Active Record node.children.first()。parent!= parent但是这样的坏事呢? 我承认不理解hibernate决定在执行新查询时不刷新其缓存中的对象的原因。 他们一直担心副作用吗? Hibernate和Grails可以游说改变吗?因为这似乎是最好的长期解决方案。 (2014年9月24日编辑:我让Hibernate解决问题的努力失败了。)

ADDED(2014/08/12): 重新思考Grails应用程序的设计并将GORM / Hibernate仅用作非常薄的持久层也可能有所帮助。通过严格控制在每个请求期间发出的查询来设计此类层应该最小化此问题。这显然不是Grails框架所倡导的,(2014年9月24日编辑,它只会减少并不能解决问题。)

经过深思熟虑后,我觉得这可能是Grails / Hibernate技术堆栈中的一个重要逻辑漏洞。如果你关心并发,确实没有好的解决方案,你应该担心。

答案 1 :(得分:4)

可重复读取是数据库事务中preventing lost updates的一种方式。大多数应用程序使用读取 - 修改 - 写入数据访问模式,打破数据库事务边界和pushing transactions to the application-layer

Hibernate使用transactional write-behind policy,因此实体状态转换会尽可能延迟,以减少与DML语句关联的数据库锁定。

在应用程序级事务中,第一级缓存充当应用程序级可重复读取机制。但是,虽然数据库锁定在使用物理事务时确保了可重复的读取一致性,但对于应用程序级事务,您需要应用程序级锁定机制。这就是为什么你应该首先使用乐观锁定。

乐观锁定允许其他人修改以前加载的数据,同时阻止您更新陈旧数据。

这不是平等的。无论如何,数据库约束应始终强制执行唯一的业务键。

对于有关帐户更新的操作,您应该使用单个数据库事务来确保通过锁定获取(SELECT FOR UPDATE)的安全性或使用乐观锁定,因此当其他人更新您的数据时,您将获得陈旧的实体异常。

我可以replicate your use case。该实体从第一级缓存中重用。对于SQL查询,您可以自由加载并发更改。只要您加载实体以便稍后更新它们就应该没问题,因为乐观锁定机制会阻止您保存过时数据。

如果仅使用HQL / JPQL进行查看,则可能需要使用投影。

答案 2 :(得分:1)

Marc Palmer 关于这些问题的好文章。我发现它非常有趣。在文章的最后,他给出了一些"解决方案"这可能符合你们中的一些人的需要。

The false optimism of GORM and Hibernate

答案 3 :(得分:0)

据我所知,问题归结为数据库事务隔离不足。

我还建议任何应用程序都可以存在此问题,任何数据库访问框架。

在数据库事务中,您必须假设您是数据库的唯一访问者并且在该事务中具有一致的数据库视图。

提交后,您可能会发现状态已发生变化,与您所做的更改不相符,您的交易将回滚。

如果您只进行了只读访问,那么您必须仍然依赖于事务范围内的一致性,并且数据库会阻止您进行其他并发修改。

Hibernate的二级缓存跨越事务,因此应该在有并发修改时清除它,并且在任何情况下,数据库都可以被其他应用程序修改,因此应谨慎使用二级缓存。

但你已经说过二级缓存不是你的问题。嗯,我同意。您的问题听起来像数据库中的事务隔离程度不足。这可以解决吗?