Spring Data JPA的使用踩坑:关于缓存与快照

最近在修复组里项目的一个bug时,发现这个bug是对Spring Data JPA的使用不当所导致。在修复成功这个bug后,由于对Spring Data JPA的了解甚少,所以我打算把解决bug过程中查阅的相关资料写成博客做个总结,博客的内容主要针对初学者,内容简单。
首先模拟一下bug产生的过程,下列代码的逻辑可能会与我们正常编写的代码逻辑有点不一致,但重要的是通过代码去理解bug产生的原因:

@Service
public class UserService {
    

    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void updateUser(String name) {
    
        User user = userRepository.findById(1).get();
        
        System.out.println("user name: " + user.getName());
        System.out.println("user age: " + user.getAge());
        System.out.println("name will be updated to " + name);
        
        userRepository.updateUserName(name, 1);
        
        User user1 = userRepository.findById(1).get();
        
        System.out.println("user1 age: " + user1.getAge());
        System.out.println("age will be updated to " + 18);
        user1.setAge(18);
        userRepository.save(user1);
    }
}
@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
    

    @Query(value = "update User set name = ?1 where id = ?2 ")
    @Modifying
    void updateUserName(String name, Integer id);
}

UserServiceupdateUser(String name)方法的原先设想的作用是:先调用UserRepository.findById()方法查出User表中id为1的记录,然后调用UserRepository.updateUserName(String name, Integer id)根据传入的参数name去修改User表中id为1的记录中name列的值,再调用UserRepository.findById()方法查询出更改名字后的User表中id为1的记录,并将该记录的age列的值修改为18。但方法执行后的结果却有点出乎意料:


我们可以发现,User表中id为1的记录虽然age被成功修改为18,但是name却依然还是jack,这是为什么?在解释这个现象之前,需要了解关于Spring Data JPA的一些概念:

一级缓存

Spring Data JPA的一级缓存就是当使用自定义Repositoryfind()或者findxx()方法去查询记录时,第一次会去查询数据库,然后会把查询结果存放到内存中作为缓存,后面再查询相同的记录时会直接把缓存中的结果返回,不再去查询数据库。


上面updateUser()方法执行的日志中,我们可以看到只执行了一条select语句,所以User user1 = userRepository.findById(1).get();这条语句并没有进行数据库查询,user1其实就是user,UserRepository.updateUserName(name, 1)这句代码执行的结果是修改了数据库中User表的id为1的记录中的name值,而缓存中的User对象的name属性值依然为“jack”。

flush()

flush()方法将缓存中所有发生修改的实体的状态信息同步到数据库中。当在一个事务内通过save()方法去update一个从数据库中查询出来的实体时,Spring Data JPA并不会马上执行Update SQL语句,将修改同步到数据库,而是等到事务提交时才会通过某种机制(下面会对该机制进行描述)决定是否调用flush()方法将缓存中的实体信息同步到数据库中,当调用 flush()方法时才会执行Update SQL语句。

快照区

Spring Data JPA除了一级缓存外,还有一个快照区,当将查询结果放到一级缓存中时,会同时复制一份数据放入快照区中,Spring Data JPA通过快照区与缓存中的数据是否一致来判断数据从数据库查询出来后是否发生过修改。
在上面例子中,当执行User user = userRepository.findById(1).get();这句代码后,一级缓存区和快照区都会同时保存一个User实例,如下图:


当方法执行完user1.setAge(18);后,缓存区和快照区的User实例中的状态信息如下:


Spring Data JPA在事务提交时,为了保持数据库和缓存的数据同步,会清理一级缓存并根据主键字段值判断一级缓存中的对象属性值和快照中的对象属性值是否一致,如果两个对象的属性值不一致,则调用flush()方法执行Update SQL语句,将缓存的内容同步到数据库,并更新快照;如果一致,则不调用flush()方法。

所以在上述代码执行的日志中我们可以看到当updateUser()方法执行结束(updateUser()方法使用了@Transactional注解,方法执行结束会提交事务)时会通过会根据缓存区中user的属性值(name为“jack”, age为18)去修改对应的数据库记录(打印了Update SQL语句),导致UserRepository.updateUserName(name, 1)这句代码执行的效果被覆盖。

如果想要在事务提交前把修改的实体信息同步到数据库,必须在调用save()方法后手动调用flush()方法将修改的实体同步到数据库中,或者使用saveAndFlush()方法去保存修改的实体(其实saveAndFlush()方法就是在调用save()方法后调用flush()方法同步数据而已)。

如果将UserService中的updateUser()方法修改为:

    @Transactional
    public void updateUser(String name) {
    
        User user = userRepository.findById(1).get();

        System.out.println("user name: " + user.getName());
        System.out.println("user age: " + user.getAge());
        System.out.println("name will be updated to " + name);

        user.setName(name);
        userRepository.saveAndFlush(user);

        User user1 = userRepository.findById(1).get();

        System.out.println("user1 age: " + user1.getAge());
        System.out.println("age will be updated to " + 18);
        user1.setAge(18);
        userRepository.saveAndFlush(user1);
    }



可以看到,name和age都被成功修改为“tom”和18, 这是因为我们把第一次查询出来的user的name给设置成了“jack”, 而user1和user又是同一个对象,所以执行user1.setAge(18)后user1的name和age分别为“tom”和18。

同时,可以发现上述代码执行日志中,只在方法执行完时打印了一条update语句,这也证明了其实save()方法并没有生效,而是事务提交完后Spring Data JPA自动调用flush()方法去更新数据库中的数据的(可以把两个save方法调用去掉,结果还是一样)。

如果我们将上述代码中的userRepository.save(user);修改为userRepository.saveAndFlush(user);后则可以看到方法在执行userRepository.saveAndFlush(user);后就打印出了update语句,如下图:


但要注意的是,即使你使用saveAndFlush()将修改的实体信息同步到数据库,但如果在事务提交前发生事务回滚,数据库中的数据也会相应地进行回滚。

既然知道问题出现在哪,怎么解决呢?

解决方法: 将自定义的update语句中@Modifying注解中的clearAutomatically 属性值设置为true。

clearAutomatically属性设置为true表示,执行完自定义的update语句后会将一级缓存给清空,则User user1 = userRepository.findById(1).get();这条语句在执行时必须重新去数据库中查询数据,保证了对象user1中的name值是更新后的值“tom”。代码和执行日志如下:

@Service
public class UserService {
    

    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void updateUser(String name) {
    
        User user = userRepository.findById(1).get();

        System.out.println("user name: " + user.getName());
        System.out.println("user age: " + user.getAge());
        System.out.println("name will be updated to " + name);
        userRepository.updateUserName(name, 1);
        User user1 = userRepository.findById(1).get();

        System.out.println("user1 age: " + user1.getAge());
        System.out.println("age will be updated to " + 18);
        user1.setAge(18);
    }
}

@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
    

    @Query(value = "update User set name = ?1 where id = ?2 ")
    @Modifying(clearAutomatically = true)
    void updateUserName(String name, Integer id);
}


总结
使用Spring Data JPA来进行update操作时,要注意事务的使用和缓冲区和数据库之间的数据同步。

flush()方法用于将缓冲区的数据给同步到数据库中,在JPA中,当一个事务进行提交时,JPA会自动调用flush()方法去同步数据到数据库并清空缓存。

当一个自定义Repository类去继承JpaRepository<T, ID>时,你会发现当你调用自定义Repositorysave()方法时,实际执行的是JpaRepository<T, ID>的子类SimpleJpaRepository<T, ID>save()方法,该方法上的@Transactional注解的隔离级别为默认值Propagation.REQUIRED,即当前方法上下文有事务就采用当前事务,没有事务则开启一个事务。所以当你在service层的方法中调用方save()方法去将修改的实体对象的状态信息保存到数据库时,

如果service的方法没有通过@Transactional去开启一个事务,则save方法会开启一个事务,当save()方法执行结束事务提交时会调用flush()方法执行SQL语句将修改的对象信息同步到数据库。
如果service的方法有通过@Transactional去开启一个事务,则save的执行并不会起到将数据保存到数据库的作用(没有执行Update SQL语句),而是在事务提交时Spring Data JPA才会自动调用flush()方法去同步数据到数据库。
但是需要注意的是,在执行自定义的SQL语句前,如果缓存中的数据与快照中的数据不一致,为了保证数据库的数据为最新版本,Spring Data JPA 会自动调用flush()方法来将缓存中的数据同步到数据库中。以下列代码为例:

@Service
public class UserService {
    

    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void updateUser(String name) {
    
        User user = userRepository.findById(1).get();
        System.out.println("user age is " + user.getAge());
        user.setAge(18);
        System.out.println("user age change to 18");
        userRepository.updateUserName(name, 1);
    }
}

@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
    

    @Query(value = "update User set name = ?1 where id = ?2 ")
    @Modifying
    void updateUserName(String name, Integer id);
}

代码执行日志如下:


可以看出在执行userRepository.updateUserName(name, 1);这句代码前,因为缓存中的user对象的age值发生过修改,所以Spring Data JPA通过flush()方法将缓存中的对象信息更新到数据库中。

Spring Data JPA为程序员封装了很多实用的方法,程序员可以方便地使用Spring Data JPA去编写数据访问层代码,但有时候,框架为我们做的太多反而会成为一个缺点,因为当我们对框架的机制不理解时,会错用框架提供的方法,从而导致错误发生,这种错误有时候难以通过debug找出来。所以当使用一个框架时,应该对它的机制要有适当的理解。

PS:好久没有写过博客了,写这一篇博客花了一天的时间才写出来,果然写作这种东西要花时间去练的,都怪自己太懒了。

  • 14
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LuckyTHP

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值