为什么使用Spring Data JPA更新实体时,@Transactional隔离级别没有效果?

6
针对这个实验项目,它基于spring-boot-starter-data-jpa依赖和H2内存数据库。我定义了一个包含两个字段(idfirstName)的User实体,并通过扩展CrudRepository接口声明了一个UsersRepository
现在,考虑一个简单的控制器,它提供了两个端点:/print-user读取同一个用户两次,并打印出其名字,有一定的时间间隔;/update-user用于在这两次读取之间更改用户的名字。请注意,我故意设置了隔离级别为Isolation.READ_COMMITTED,并期望在第一次事务过程中,通过相同id检索两次的用户将具有不同的名字。但是,第一次事务打印相同的值两次。为了使问题更加清晰,这是完整的操作序列:
1.最初,jeremy的名字设置为Jeremy。 2.然后,我调用/print-user,它打印出Jeremy并进入睡眠状态。 3.接下来,我从另一个会话中调用/update-user,它将jeremy的名字更改为Bob。 4.最后,在第一次事务在休眠后被唤醒并重新读取jeremy用户时,它再次打印出Jeremy作为他的名字,即使第一个名字已经被更改为Bob(如果我们打开数据库控制台,它现在确实存储为Bob,而不是Jeremy)。
看起来设置隔离级别在这里没有效果,我想知道为什么。
@RestController
@RequestMapping
public class UsersController {

    private final UsersRepository usersRepository;

    @Autowired
    public UsersController(UsersRepository usersRepository) {
        this.usersRepository = usersRepository;
    }

    @GetMapping("/print-user")
    @ResponseStatus(HttpStatus.OK)
    @Transactional (isolation = Isolation.READ_COMMITTED)
    public void printName() throws InterruptedException {
        User user1 = usersRepository.findById("jeremy"); 
        System.out.println(user1.getFirstName());
        
        // allow changing user's name from another 
        // session by calling /update-user endpoint
        Thread.sleep(5000);
        
        User user2 = usersRepository.findById("jeremy");
        System.out.println(user2.getFirstName());
    }


    @GetMapping("/update-user")
    @ResponseStatus(HttpStatus.OK)
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public User changeName() {
        User user = usersRepository.findById("jeremy"); 
        user.setFirstName("Bob");
        return user;
    }
    
}

findById(....) 应该返回一个 Optional<User> 吗? - Andrew S
这里为简单起见省略了。 - escudero380
您可能没有提交更新中的更改。在设置后,您可能需要添加此行 usersRepository.save(user) - Shababb Karim
1
当一个方法是事务性的时候,那么在这个事务中检索到的实体处于托管状态,这意味着对它们所做的所有更改都将自动传播到数据库,在事务结束时。因此,save()调用是多余的。 - escudero380
findById看起来不对,应该是findByFirstName吗? - Nathan Hughes
显示剩余4条评论
2个回答

6

您的代码存在两个问题。

您在同一个事务中执行了两次 usersRepository.findById("jeremy");,很有可能第二次读取正在从 Cache 中检索记录。当您第二次读取记录时,需要刷新缓存。我更新了使用 entityManager 的代码,请查看如何使用 JpaRepository 完成此操作。

User user1 = usersRepository.findById("jeremy"); 
Thread.sleep(5000);
entityManager.refresh(user1);    
User user2 = usersRepository.findById("jeremy");

这是我的测试用例日志,请检查SQL查询:

  • 第一个读取操作已完成。线程正在等待超时。

Hibernate:select person0_.id as id1_0_0_, person0_.city as city2_0_0_, person0_.name as name3_0_0_ from person person0_ where person0_.id =?

  • 触发了针对Bob的更新,首先进行选择,然后更新记录。

Hibernate:select person0_.id as id1_0_0_, person0_.city as city2_0_0_, person0_.name as name3_0_0_ from person person0_ where person0_.id =?

Hibernate:update person set city =?,name =?where id =?

  • 现在线程从Sleep中唤醒并触发第二个读取。我看不到任何数据库查询被触发,即第二个读取来自缓存。

第二个可能出现的问题是/update-user端点处理程序逻辑。您正在更改用户的名称但没有将其持久化,仅调用setter方法不会更新数据库。因此,当其他端点的线程唤醒时,它会打印Jeremy。

因此,您需要在更改名称后调用userRepository.saveAndFlush(user)。

@GetMapping("/update-user")
@ResponseStatus(HttpStatus.OK)
@Transactional(isolation = Isolation.READ_COMMITTED)
public User changeName() {
    User user = usersRepository.findById("jeremy"); 
    user.setFirstName("Bob");
    userRepository.saveAndFlush(user); // call saveAndFlush
    return user;
}

此外,您需要检查数据库是否支持所需的隔离级别。您可以参考H2事务隔离级别

我尝试过了。即使在“/update-user”事务的最后显式调用usersRepository.save(user),结果仍然是相同的。 - escudero380
1
save 方法不会提交您的数据。 您应该使用 saveAndFlush() 方法或显式调用 commit() 方法。当我们使用 save() 方法时,与保存操作相关联的数据将不会刷新到数据库,除非进行显式调用 flush()commit() 方法。 - IQbrod
好的。我用JpaRepository替换了CrudRepository,并添加了显式的usersRepository.saveAndFlush(user); - 没有任何变化。 - escudero380
@escudero380 我已经更新了答案。在你的情况下,似乎第二次读取正在从缓存中检索值。你需要刷新缓存。 - Govinda Sakhare
@GoviS,是的。我只是通过构造函数注入了EntityManager,并按照您说的调用了entityManager.refresh(user1, LockModeType.PESSIMISTIC_WRITE);。现在/print-user事务按预期打印了JeremyBob - escudero380
显示剩余2条评论

2

您的更新方法@GetMapping("/update-user")已经设置了隔离级别@Transactional(isolation = Isolation.READ_COMMITTED),因此在此方法中永远不会到达commit()步骤。

您必须更改隔离级别或在事务中读取您的值以提交更改 :) user.setFirstName("Bob");不能确保您的数据将被提交

线程摘要将如下所示:

A: Read => "Jeremy"
B: Write "Bob" (not committed)
A: Read => "Jeremy"
Commit B : "Bob"

// Now returning "Bob"

@escudero380,你应该允许printName()在隔离级别为READ_UNCOMMITTED时读取未提交的数据,或者显式调用flush()commit()。 我已经在@GoviS的帖子下解释过了。 save()不会提交数据...而且未提交的数据在READ_COMMITTED事务下无法检索(因为它只允许读取已提交的数据)。 - IQbrod
请考虑在控制器和存储库之间使用服务,因为您的实现不遵守关注点分离原则。您的存储库应仅处理数据并将其传输到服务,服务将调用存储库以提供答案,而您的存储库应仅将数据转换为数据库实体。 - IQbrod
根据您的建议,我尝试了两种技术:1)将隔离级别替换为READ_UNCOMMITTED以用于printName()方法,但是它并没有改变任何东西;2)然后,我在changeName()方法中添加了usersRepository.saveAndFlush(user),但仍然没有帮助。 - escudero380
@GPI 这条评论以“也”开头,意味着它不是问题的核心。我同意你的观点,GoviS 在这里给出了正确的答案。 - IQbrod
1
我的评论不仅仅是关于服务层。这里的答案说“永远不会到达commit”。在我看来,它确实被执行了(或者在出现数据库异常时回滚)。 - GPI
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接