【问题标题】:The Lost Update - Java, Spring and JPA丢失的更新 - Java、Spring 和 JPA
【发布时间】:2017-09-14 02:22:32
【问题描述】:

我在工作中遇到了一个问题,我花了几个月的时间来解决它,这让我发疯了。

这件事很难解释,它涉及我不允许讨论的领域的一些特殊性,而且我无法复制粘贴确切的代码。我会尽量用一些有代表性的例子来说明自己。

简单地说,系统包含一个根实体,我们称它为 MainDocument 实体。在这个实体周围,有几个实体在绕行。 MainDocument 实体有一个状态。我们称这个状态为“MainDocumentState”。

public class MainDocument {
   @OneToOne
   @JoinColumn(name = "document_state_id")
   MainDocumentState state;
   @Version
   long version = 0L;
}

大约有 10 个状态可用,但在此示例中将重点介绍其中两个。让我们打电话给他们,ReadyForAuthorizationAuthorized

这就是你需要知道的例子。

关于我们正在使用的技术:

  1. 春天
  2. GWT Web 应用程序
  3. Java 1.6
  4. 休眠
  5. JPA
  6. Oracle 数据库。

关于问题本身:

系统中有一部分很关键,负责处理大部分传入流量。我们将此部分称为“授权部分”。在此部分,我们通过我国海关和边境保护局提供的 SOAP WS 发送信息,以授权MainDocument 针对海关。

代码如下所示:

@Transactional
public void authorize(Integer mainDocId) {

  MainDocument mainDocument = mainDocumentService.findById(mainDocId);
  // if document is not found, an exception is thrown.
  Assert.isTrue(mainDocument.notAutorized(), "The document is       already authorized");
  // more bussiness logic validations happen here. This validations are not important for the topic discussed here. They make sure that the document meets some basic preconditions.

  try {

   Transaction aTransaction = transactionService.newTransaction(); // creates a transaction, an entity stored in the database that keeps track of all the authorization service calls
   try {
    Response response = wsAuthroizationService.sendAuthorization(mainDocument.getId(), mainDocument.getAuthorizationId()); // take into account that sometimes this call can take between 2-4 minutes. 
    catch (Exception e) {
     aTransaction.failed();
     transactionService.saveOrUpdate(aTransaction);
     throw e;
    }
    // the behaviour is the same for every error code.
    if (response.getCode() != 0) {
     aTransaction.setErrorCode(resposne.getCode());
     transactionService.saveOrUpdate(aTransaction);
     throw AuthroizationError("Error on auth");
    }
    aTransaction.completed();
    mainDocument.setAuthorizationCode(0);
    mainDocument.authorize(); // will change state to "Authorized"
   } catch (Exception e) {
    mainDocument.authorize(); // will not change state because   authorizationCode != 0 or its null.
   } finally {
    saveOrUpdate(mainDocument);
   }
  }

丢失更新何时发生以及它如何影响系统:

  1. MainDocument id: 1@Thread-1 尝试授权
  2. 文档未授权,继续执行
  3. 通过webservice授权OK
  4. 事务关闭并发生提交。
  5. 当 1 提交时,MainDocument 1@Thread-2 进来,并尝试 进行身份验证。
  6. 1 尚未持久化,Thread-2 尝试进行身份验证。
  7. 线程 2 被 WS 拒绝,响应为“文档 1 已被授权”。
  8. Thread-2 尝试提交。
  9. Thread-1 首先提交文档 1,Thread-2 在第二位提交。

id:1 的 MainDocument 以 ReadyForAuthorization 状态持久化,而正确的状态应该是 Authorized。

之所以会出现复杂性,是因为它几乎不可能重现。它只发生在生产环境中,即使我尝试用数百次调用淹没服务器,我也无法获得相同的行为。

实施的解决方案:

  1. 线程屏障,如果两个具有相同MainDocument id的线程尝试授权,最后进入的线程被拒绝。它是用一个方面实现的,顺序为 100,因此它在 @Transactional 提交之后执行。在方面拦截并从屏障中移除线程之前,对事务提交的堆栈跟踪进行了测试和检查。
  2. @Version,适用于系统的其他部分,当一个提交试图覆盖来自旧事务的另一个提交时,会引发 OptimisticLockException。在这种情况下,不会引发 OptimisticLockException。
  3. “事务”与 @Transactional(propagation = REQUIRES_NEW) 保持一致,因此它独立于主事务并且已正确提交。通过这些事务,很明显丢失更新是一个问题,因为我们可以看到带有成功消息的已完成事务,并且 MainDocument 保持不同的状态,server.log 上没有显示任何错误。
  4. 使用 Imperva SecureSphere,我们可以审计特定表上的所有更新。我们可以清楚地看到第一个事务以正确的状态提交,而第二个事务覆盖了第一个事务。

如果有并发和事务管理经验的人能给我一些关于如何调试或重现问题的有用提示,或者至少实施一些解决方案来减轻损失,我将不胜感激。

需要明确的是,每小时有超过 1000 个请求,其中 99.99% 的请求正确结束。每月出现此问题的案例总数约为 20 个。

于 17 年 9 月 13 日添加:

如果需要,我们正在使用的saveOrUpdate 方法:

   * "http://blog.xebia.com/2009/03/23/jpa-implementation-patterns-saving-detached-entities/" >JPA
   * implementation patterns: Saving (detached) entities</a>
   * 
   * @param entity
   */
  protected E saveOrUpdate(E entity) {
    if (entity.getId() == null) {
      getJpaTemplate().persist(entity);
      return entity;
    }
    if (!getJpaTemplate().getEntityManager().contains(entity)) {
      return merge(entity);
    }
    return entity;
  }

【问题讨论】:

  • MainDocument 不应在方法中过去。而是传递文档 ID 并通过 ID 在方法中检索。您也可以为文档尝试悲观锁(SELECT FOR UPDATE)。要重现尝试在方法中添加 sleep(...)
  • 是的,对不起,代码不具体,这是正确的标头:public UnaDeclaracionJuradaResult informarDestinacionAduana( final InformarDestinacionAduanaParameter parametroInformarDestinacion) {那个Parameter对象有id,实体是用findById获取的。
  • 由于事务率非常低,您是否尝试过调整数据库的事务隔离设置(或者只是检查它是什么,对于初学者来说)看看它是否改变了什么?如果不出意外,它消除了一个可能的问题来源。
  • @OneToOne MainDocumentState 状态; - 您确定整个表格中每个州只有一个文档吗?关联应该是多对一吗?
  • @dimirsen 实际上你是对的,它应该是“@ManyToOne”。问题是我不知道这与这个特定问题有什么关系。总之值得一试。

标签: java spring hibernate jpa concurrency


【解决方案1】:

主要问题是并发性。 您的代码现在的样子,它正在尝试检查实体是否被授权,何时应该检查它是否被授权或正在被授权的过程中。

这就引出了一个重要的问题: 如何检查一个实体是否已经在整个系统中被操纵?

我遇到过一些看起来相似的情况,包括代码在集群中运行的情况。我发现的最佳工作解决方案是使用某种形式的数据库锁。

@Version 应该是一个很好且快速的解决方案,但您说它不能正常工作。您还说您可以使用工具审核数据库,在这种情况下检查版本字段的行为会很有趣。

没有@Version,我会尝试一些“硬核”悲观数据库锁。建议的解决方案当然不是唯一的,也不是最好的。

1 - 创建一个新表。该表将存储正在处理的文档的 ID。 PK 应该是文档 ID,或任何其他确保同一文档在此表中不会有重复项的内容。

2 - 在您的代码中,在检索实体之前,检查 id 是否在步骤 1 中创建的表中。如果不是,请继续。 如果是,则假定它正在被处理并且什么都不做。

3 - 在您的代码中,在检索实体之后,您必须将 ID 插入在步骤 1 中创建的表中。
如果文档未被授权,则插入成功并继续该过程。
如果有任何机会同时执行两个请求,则其中一个请求将获得约束违反异常(或类似的东西)。那么您的代码应该假定文档正在被授权。
重要:插入必须在新事务中执行。用于在新表中持久化 Id 的 spring bean 应将其方法标记为 @Transaction(propagation = Propagation.REQUIRES_NEW)

4 - 调用 Webservice 并正确处理响应后,从步骤 1 中创建的表中删除 Id。它也应该在单独的事务中执行。
考虑在 finally 块中执行此操作,因为如果发生任何其他运行时错误,则应从表中删除文档 ID。

如何调试:

  • 在本地环境中运行应用程序,并在后面设置断点 在插入新表之前检索实体。如果你想调试你当前的代码,那么我会把断点放在 Assert 语句之后。

  • 在您的开发机器中打开两个不同的浏览器,并执行触发此代码的用例。您也可以要求团队成员从他的机器上执行它。

  • 您应该会看到您的 IDE 显示在断点处执行两次的代码。之后,让两个处决一个接一个地运行,享受表演。应该重现该场景。

  • 这基本上模拟了两个同时请求。

注意事项:

  • 我选择使用数据库表,因为即使应用程序部署在集群环境(多个应用程序服务器实例)中,此解决方案也能正常工作。
  • 如果只运行一个实例,您可以尝试使用跨请求共享的对象,但如果将来您需要使用集群扩展您的应用程序,那么该解决方案将不起作用。此外,您还必须处理线程安全问题。
  • 您也可以尝试使用数据库锁定,但您必须小心不要将表/行锁定太久。此外,JPA 没有任何特定的操作来对表/行执行锁定(至少我找不到),因此您必须处理原生 SQL。

【讨论】:

  • 实际上我们并没有在集群中运行,并且我们实现了一个类似的解决方案,在我们的开发环境中测试时它可以工作(是的,我不敢相信,并发的好处)。它被称为 ThreadQueue,它锁定另一个具有相同文档 ID 的线程进入。 ThreadQueue 具有原子操作,因此并发性不是问题。 ThreadQueue 嵌入在一个切面中,该切面通过注解@PooledTransactional 激活。此方面设置为 Ordered.HIGHEST_PRECEDENCE + 10 的执行顺序。
  • Ordered 接口取自这里:docs.spring.io/spring-framework/docs/current/javadoc-api/org/… 事务提交后,方面启动并释放特定线程正在使用的资源(文档)。 ThreadQueue 允许相同的线程 Thread.currentThread().getId() 以相同的 Document Id 进入,但拒绝任何其他尝试使用已经存在的文档 ID 进入的线程(http 请求)。这已在我们的开发环境中进行了测试,并且可以正常工作。
  • 并发可能非常棘手。我提到集群是因为我不止一次看到团队为类似的问题制定了一些非常复杂的解决方案,但几天后当他们想起生产环境是集群的并且根本无法解决问题时才崩溃。感谢您的反馈,如果我的回答有点(或很多)自命不凡,我们深表歉意。
  • 我们实际上有一个查询来跟踪队列中的所有线程,但是我们没有办法检测这种请求滑出 ThreadQueue 并继续执行的情况。使用您的解决方案,我们可以轻松进行审计,因为所有数据都被保留下来,以后可以检索以进行更深入的分析,因此我们将其考虑在内。谢谢
猜你喜欢
  • 1970-01-01
  • 2019-09-09
  • 1970-01-01
  • 2016-07-14
  • 2021-03-15
  • 1970-01-01
  • 1970-01-01
  • 2013-09-13
  • 1970-01-01
相关资源
最近更新 更多