为啥使用 Spring Data JPA 更新实体时@Transactional 隔离级别不起作用?

Posted

技术标签:

【中文标题】为啥使用 Spring Data JPA 更新实体时@Transactional 隔离级别不起作用?【英文标题】:Why does @Transactional isolation level have no effect when updating entities with Spring Data JPA?为什么使用 Spring Data JPA 更新实体时@Transactional 隔离级别不起作用? 【发布时间】:2021-01-02 04:41:01 【问题描述】:

对于这个基于spring-boot-starter-data-jpa 依赖和H2 内存数据库的实验项目,我定义了一个带有两个字段(idfirstName)的User 实体,并通过扩展声明了一个UsersRepository CrudRepository 接口。

现在,考虑一个提供两个端点的简单控制器:/print-user 读取同一个用户两次,间隔打印出其名字,/update-user 用于在两次读取之间更改用户的名字。请注意,我特意设置了Isolation.READ_COMMITTED 级别,并期望在第一个事务的过程中,通过相同 id 检索两次的用户将具有不同的名称。但相反,第一笔交易两次打印出相同的值。为了更清楚,这是完整的操作序列:

    最初,jeremy 的名字设置为 Jeremy。 然后我调用/print-user,它会打印出Jeremy 并进入睡眠状态。 接下来,我从另一个会话中调用 /update-user,它会将 jeremy 的名字更改为 Bob。 最后,当第一个事务在睡眠后被唤醒并重新读取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>吗? 这里为了简单而省略了。 您可能尚未提交更新中的更改。您可能需要在设置后添加此行usersRepository.save(user) 当一个方法是事务性的,那么在该事务中检索到的实体处于托管状态,这意味着对它们所做的所有更改都将在事务结束时自动填充到数据库中。因此,save() 调用是多余的。 findById 看起来不对,应该是 findByFirstName 吗? 【参考方案1】:

您的代码有两个问题。

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

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

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

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

休眠:选择 person0_.id 作为 id1_0_0_,person0_.city 作为 city2_0_0_,person0_.name 作为 name3_0_0_ from person person0_ where person0_.id=?

触发对 Bob 的更新,它选择然后更新记录。

休眠:选择 person0_.id 作为 id1_0_0_,person0_.city 作为 city2_0_0_,person0_.name 作为 name3_0_0_ from person person0_ where person0_.id=?

Hibernate:更新人集 city=?, name=? id=?

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

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

【讨论】:

我试过了。即使在/update-user 事务结束时显式调用usersRepository.save(user),结果也是一样的。 save 不会提交您的数据。您应该使用saveAndFlush() 或从baeldung.com/spring-data-jpa-save-saveandflush 显式调用commit():当我们使用save() 方法时,与保存操作相关的数据将不会被刷新到数据库,除非并且直到显式调用flush()或 commit() 方法。 好的。我用JpaRepository 替换了CrudRepository 并添加了明确的usersRepository.saveAndFlush(user); - 没有任何改变 @escudero380 我已经更新了答案。在您的情况下,似乎第二次读取是从缓存中检索值。你需要刷新缓存。 @GoviS,是的。我刚刚通过构造函数注入EntityManager,并按照您所说的调用entityManager.refresh(user1, LockModeType.PESSIMISTIC_WRITE);。现在/print-user 交易按预期打印JeremyBob【参考方案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"

【讨论】:

对不起,我不认为我明白你的意思。那我应该使用哪个隔离级别?我通过显式调用存储库的 save() 方法尝试了所有这些方法,然后从存储库中重新读取了值,但这并没有解决问题。 @escudero380 您应该允许printName() 使用此隔离级别READ_UNCOMMITTED 读取未提交的数据,或者显式调用flush()commit()。我已经在@GoviS 的帖子下解释了这一点。 save() 不提交数据...并且在 READ_COMMITTED 事务下无法检索未提交的数据(因为它只允许读取已提交的数据)。 另外,请考虑在您的控制器和存储库之间使用服务,因为您的实现不遵守关注点分离。您的存储库应该只处理数据并将其传输到服务,服务将调用存储库以提供答案,您的存储库应该只将数据转换为 db 的实体 根据您的建议,我尝试了两种技术:1)用READ_UNCOMMITTED 替换隔离级别,用于printName() 方法,但它没有改变任何东西; 2)然后,我将usersRepository.saveAndFlush(user) 添加到changeName() 方法,但它再次没有帮助。 我的评论不仅仅是关于服务层。这里的答案是“永远无法达到承诺”。在我看来,它已经达到了(好吧,当然是在数据库异常的情况下回滚)。

以上是关于为啥使用 Spring Data JPA 更新实体时@Transactional 隔离级别不起作用?的主要内容,如果未能解决你的问题,请参考以下文章

Spring Data JPA 中使用Update Query更新实体类问题

即使 JPA 实体不脏,我是不是可以强制 spring-data 更新可审计字段?

如何在 Spring Data 中漂亮地更新 JPA 实体?

当我不想更新实体时,Spring Data JPA 会更新它

Spring Data Jpa:保存方法仅返回选择,但不执行插入

Spring Jpa Data Repository使用LinkedEntity for ManyToMany关系保存(更新)