Spring Data JPA 原理与实战第八天 @Entity回调和JPA乐观重试机制

564 篇文章 136 订阅

13 如何正确使用 @Entity 里面的回调方法?

本课时我要介绍的是 @Entity 的回调方法。

为什么要讲回调函数呢?因为在工作中,我发现有些同事会把这个回调方法用得非常复杂,不得要领,所以我专门拿出一个课时来为你详细说明,并分享我的经验供你参考。我将通过“语法 + 实践”的方式讲解如何使用 @Entity 的回调方法,从而达到提高开发效率的目的。下面开始本课时的学习。

Java Persistence API 里面规定的回调方法有哪些?

JPA 协议里面规定,可以通过一些注解,为其监听回调事件、指定回调方法。下面我整理了一个回调事件注解表,分别列举了 @PrePersist、@PostPersist、@PreRemove、@PostRemove、@PreUpdate、@PostUpdate、@PostLoad注解及其概念。

回调事件注解表

image (5).png

语法注意事项

关于上表所述的几个方法有一些需要注意的地方,如下:

  1. 回调函数都是和 EntityManager.flush 或 EntityManager.commit 在同一个线程里面执行的,只不过调用方法有先后之分,都是同步调用,所以当任何一个回调方法里面发生异常,都会触发事务进行回滚,而不会触发事务提交。

  2. Callbacks 注解可以放在实体里面,可以放在 super-class 里面,也可以定义在 entity 的 listener 里面,但需要注意的是:放在实体(或者 super-class)里面的方法,签名格式为“void ()”,即没有参数,方法里面操作的是 this 对象自己;放在实体的 EntityListener 里面的方法签名格式为“void (Object)”,也就是方法可以有参数,参数是代表用来接收回调方法的实体。

  3. 使上述注解生效的回调方法可以是 public、private、protected、friendly 类型的,但是不能是 static 和 finnal 类型的方法。

JPA 里面规定的回调方法还有一些,但不常用,我就不过多介绍了。接下来,我们看一下回调注解在实体里面是如何使用的。

JPA Callbacks 的使用方法

这里我介绍两种方法,是你可能会在实际工作中用到的。

第一种用法:在实体和 super-class 中使用

第一步:修改 BaseEntity,在里面新增回调函数和注解,代码如下:

package com.example.jpa.example1.base;
import lombok.Data;
import org.springframework.data.annotation.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.Instant;
@Data
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
   @Id
   @GeneratedValue(strategy= GenerationType.AUTO)
   private Long id;
// @CreatedBy 这个可能会被 AuditingEntityListener覆盖,为了方便测试,我们先注释掉
   private Integer createUserId;
   @CreatedDate
   private Instant createTime;
   @LastModifiedBy
   private Integer lastModifiedUserId;
   @LastModifiedDate
   private Instant lastModifiedTime;
//  @Version 由于本身有乐观锁机制,这个我们测试的时候先注释掉,改用手动设置的值;
   private Integer version;
   @PreUpdate
   public void preUpdate() {
      System.out.println("preUpdate::"+this.toString());
      this.setCreateUserId(200);
   }
   @PostUpdate
   public void postUpdate() {
      System.out.println("postUpdate::"+this.toString());
   }
   @PreRemove
   public void preRemove() {
      System.out.println("preRemove::"+this.toString());
   }
   @PostRemove
   public void postRemove() {
      System.out.println("postRemove::"+this.toString());
   }
   @PostLoad
   public void postLoad() {
      System.out.println("postLoad::"+this.toString());
   }
}

上述代码中,我在类里面使用了@PreUpdate、@PostUpdate、@PreRemove、@PostRemove、@PostLoad 几个注解,并在相应的回调方法里面加了相应的日志。并且在 @PreUpdate 方法里面修改了 create_user_id 的值为 200,这样做是为了方便我们后续测试。

第二步:修改一下 User 类,也新增两个回调函数,并且和 BaseEntity 做法一样,代码如下:

package com.example.jpa.example1;
import com.example.jpa.example1.base.BaseEntity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
import javax.persistence.*;
import java.util.List;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "addresses",callSuper = true)
@EqualsAndHashCode(callSuper=false)
public class User extends BaseEntity {// implements Auditable<Integer,Long, Instant> {
   private String name;
   private String email;
   @Enumerated(EnumType.STRING)
   private SexEnum sex;
   private Integer age;
   @OneToMany(mappedBy = "user")
   @JsonIgnore
   private List<UserAddress> addresses;
   private Boolean deleted;
   @PrePersist
   private void prePersist() {
      System.out.println("prePersist::"+this.toString());
      this.setVersion(1);
   }
   @PostPersist
   public void postPersist() {
      System.out.println("postPersist::"+this.toString());
   }
}

我在其中使用了 @PrePersist、@PostPersist 回调事件,为了方便我们测试,我在 @PrePersist 里面将 version 修改为 1。

第三步:写一个测试用例测试一下。

@DataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Import(JpaConfiguration.class)
public class UserRepositoryTest {
    @Autowired
    private UserRepository userRepository;
    @MockBean
    MyAuditorAware myAuditorAware;
    /**
     * 为了和测试方法的事务分开,我们在 init 里面初始化数据做新增操作
     */
    @BeforeAll
    @Rollback(false)
    @Transactional
    public void init() {
        //由于测试用例模拟 web context 环境不是我们的重点,这里利用@MockBean,mock掉我们的方法,期待返回13这个用户ID
       Mockito.when(myAuditorAware.getCurrentAuditor()).thenReturn(Optional.of(13));
        User u1 = User.builder()
                .name("jack")
                .email("123456@126.com")
                .sex(SexEnum.BOY)
                .age(20)
                .build();
        //没有save之前 version是null
        Assertions.assertNull(u1.getVersion());
        userRepository.save(u1);
        //这里面触发保存方法,这个时候我们将version设置成了1,然后验证一下
        Assertions.assertEquals(1,u1.getVersion());
    }
    /**
     * 测试一下更新和查询
     */
    @Test
    @Rollback(false)
    @Transactional
    public void testCallBackUpdate() {
        //此时会触发@PostLoad事件
        User u1 = userRepository.getOne(1L);
        //我们从db里面重新查询出来,验证一下version是不是1
        Assertions.assertEquals(1,u1.getVersion());
        u1.setSex(SexEnum.GIRL);
        //此时会触发@PreUpdate事件
        userRepository.save(u1);
        List<User> u3 = userRepository.findAll();
        u3.stream().forEach(u->{
            //我们从db查询出来,验证一下CcreateUserId是否为我们刚才修改的200
           Assertions.assertEquals(200,u.getCreateUserId());
        });
    }
    /**
     * 测试一下删除事件
     */
    @Test
    @Rollback(false)
    @Transactional
    public void testCallBackDelete() {
        //此时会触发@PostLoad事件
        User u1 = userRepository.getOne(1L);
        Assertions.assertEquals(200,u1.getCreateUserId());
        userRepository.delete(u1);
        //此时会触发@PreRemove、@PostRemove事件
        System.out.println("delete_after::");
    }
}

我们通过测试用例验证了回调函数的事件后,看一下输出的 SQL 和日志:

image (6).png

我们通过上图的日志也可以看到响应的回调函数被触发了,并且可以看到我们在insert之前执行 prePersist 日志、在 insert 之后执行 postPersist 日志、在 select 之后执行 postLoad 方法的日志,以及在 update 的 sql 前后执行的 preUpdate 和 postUpdate 日志。

如果我们执行上面 remove 的测试用例,也会得到一样的效果:在 delete sql 之前会执行 preRemove 的方法并且打印日志,在 delete sql 之后会执行 postRemove 方法并打印日志。

那么使用这种方法,回调函数里面发生异常会怎么样呢?这也是你可能会遇到的问题,我来告诉你解决办法。

我们稍微修改一下上面的 @PostPersist 方法,手动抛一个异常出来,看看会发生什么。

@PostPersist
public void postPersist() {
   System.out.println("postPersist::"+this.toString());
   throw new RuntimeException("jack test exception transactional roll back");
}

我们再跑测试用例就会发现,其中发生了 RollbackException 异常,这样的话数据是不会提交到 DB 里面的,也就会导致数据进行回滚,后面的业务流程无法执行下去。

Could not commit JPA transaction; nested exception is javax.persistence.RollbackException: Error while committing the transaction
org.springframework.transaction.TransactionSystemException: Could not commit JPA transaction; nested exception is javax.persistence.RollbackException: Error while committing the transaction

所以在使用此方法时,你要注意考虑异常情况,避免不必要的麻烦。

第二种用法:自定义 EntityListener

第一步:自定义一个 EntityLoggingListenner 用来记录操作日志,通过 listener 的方式配置回调函数注解,代码如下:

package com.example.jpa.example1.base;
import com.example.jpa.example1.User;
import lombok.extern.log4j.Log4j2;
import javax.persistence.*;
@Log4j2
public class EntityLoggingListener {
    @PrePersist
    private void prePersist(BaseEntity entity) {
    //entity.setVersion(1); 如果注释了,测试用例这个地方的验证也需要去掉
        log.info("prePersist::{}",entity.toString());
    }
    @PostPersist
    public void postPersist(Object entity) {
        log.info("postPersist::{}",entity.toString());
    }
    @PreUpdate
    public void preUpdate(BaseEntity entity) {
    //entity.setCreateUserId(200); 如果注释了,测试用例这个地方的验证也需要去掉
        log.info("preUpdate::{}",entity.toString());
    }
    @PostUpdate
    public void postUpdate(Object entity) {
        log.info("postUpdate::{}",entity.toString());
    }
    @PreRemove
    public void preRemove(Object entity) {
        log.info("preRemove::{}",entity.toString());
    }
    @PostRemove
    public void postRemove(Object entity) {
        log.info("postRemove::{}",entity.toString());
    }
    @PostLoad
    public void postLoad(Object entity) {
    //查询方法里面可以对一些敏感信息做一些日志
        if (User.class.isInstance(entity)) {
            log.info("postLoad::{}",entity.toString());
        }
    }
}

在这一步骤中需要注意的是:

  1. 我们上面注释的代码,也可以改变 entity 里面的值,但是在这个 Listener 的里面我们不做修改,所以把 setVersion 和 setCreateUserId 注释掉了,要注意测试用例里面这两处也需要修改。

  2. 如果在 @PostLoad 里面记录日志,不一定每个实体、每次查询都需要记录日志,只需要对一些敏感的实体或者字段做日志记录即可。

  3. 回调函数时我们可以加上参数,这个参数可以是父类 Object,可以是 BaseEntity,也可以是具体的某一个实体;我推荐用 BaseEntity,因为这样的方法是类型安全的,它可以约定一些框架逻辑,比如 getCreateUserId、getLastModifiedUserId 等。

第二步:还是一样的道理,写一个测试用例跑一下。

这次我们执行 testCallBackDelete(),看看会得到什么样的效果。

2020-10-05 13:55:19.332  INFO 62541 --- [    Test worker] c.e.j.e.base.EntityLoggingListener       : prePersist::User(super=BaseEntity(id=null, createUserId=13, createTime=2020-10-05T05:55:19.246Z, lastModifiedUserId=13, lastModifiedTime=2020-10-05T05:55:19.246Z, version=null), name=jack, email=123456@126.com, sex=BOY, age=20, deleted=null)
2020-10-05 13:55:19.449  INFO 62541 --- [    Test worker] c.e.j.e.base.EntityLoggingListener       : postPersist::User(super=BaseEntity(id=1, createUserId=13, createTime=2020-10-05T05:55:19.246Z, lastModifiedUserId=13, lastModifiedTime=2020-10-05T05:55:19.246Z, version=0), name=jack, email=123456@126.com, sex=BOY, age=20, deleted=null)
2020-10-05 13:55:19.698  INFO 62541 --- [    Test worker] c.e.j.e.base.EntityLoggingListener       : postLoad::User(super=BaseEntity(id=1, createUserId=13, createTime=2020-10-05T05:55:19.246Z, lastModifiedUserId=13, lastModifiedTime=2020-10-05T05:55:19.246Z, version=0), name=jack, email=123456@126.com, sex=BOY, age=20, deleted=null)
2020-10-05 13:55:19.719  INFO 62541 --- [    Test worker] c.e.j.e.base.EntityLoggingListener       : preRemove::User(super=BaseEntity(id=1, createUserId=13, createTime=2020-10-05T05:55:19.246Z, lastModifiedUserId=13, lastModifiedTime=2020-10-05T05:55:19.246Z, version=0), name=jack, email=123456@126.com, sex=BOY, age=20, deleted=null)
2020-10-05 13:55:19.798  INFO 62541 --- [    Test worker] c.e.j.e.base.EntityLoggingListener       : postRemove::User(super=BaseEntity(id=1, createUserId=13, createTime=2020-10-05T05:55:19.246Z, lastModifiedUserId=13, lastModifiedTime=2020-10-05T05:55:19.246Z, version=0), name=jack, email=123456@126.com, sex=BOY, age=20, deleted=null)

通过日志我们可以很清晰地看到 callback 注解标注的方法的执行过程,及其实体参数的值。你就会发现,原来自定义 EntityListener 回调函数的方法也是如此简单。

细心的你这个时候可能也会发现,我们上面其实应用了两个 EntityListener,所以这个时候 @EntityListeners 有个加载顺序的问题,你需要重点注意一下。

关于 @EntityListeners 加载顺序的说明
  1. 默认如果子类和父类都有 EntityListeners,那么 listeners 会按照加载的顺序执行所有 EntityListeners;

  2. EntityListeners 和实体里面的回调函数注解可以同时使用,但需要注意顺序问题;

  3. 如果我们不想加载super-class里面的EntityListeners,那么我们可以通过注解 @ExcludeSuperclassListeners,排除所有父类里面的实体监听者,需要用到的时候,我们再在子类实体里面重新引入即可,代码如下:

@ExcludeSuperclassListeners
public class User extends BaseEntity {
......}

看完了上面介绍的两种方式,关于 Callbacks 注解的用法你是不是已经掌握了呢?我强调需要注意的地方你要重点看一下,并切记在应用时不要搞错了。

上面说了这么多回调函数的注解使用方法,那么它的最佳实践是什么呢?

JPA Callbacks 的最佳实践

我以个人经验总结了几个最佳实践。

1.回调函数里面应尽量避免直接操作业务代码,最好用一些具有框架性的公用代码,如上一课时我们讲的 Auditing,以及本课时前面提到的实体操作日志等;

2.注意回调函数方法要在同一个事务中进行,异常要可预期,非可预期的异常要进行捕获,以免出现意想不到的线上 Bug;

3.回调函数方法是同步的,如果一些计算量大的和一些耗时的操作,可以通过发消息等机制异步处理,以免阻塞主流程,影响接口的性能。比如上面说的日志,如果我们要将其记录到数据库里面,可以在回调方法里面发个消息,改进之后将变成如下格式:

public class AuditLoggingListener {
   @PostLoad
   private void postLoad(Object entity) {
      this.notice(entity, OperateType.load);
   }
   @PostPersist
   private void postPersist(Object entity) {
      this.notice(entity, OperateType.create);
   }
   @PostRemove
   private void PostRemove(Object entity) {
      this.notice(entity, OperateType.remove);
   }
   @PostUpdate
   private void PostUpdate(Object entity) {
      this.notice(entity, OperateType.update);
   }
   private void notice(Object entity, OperateType type) {
      //我们通过active mq 异步发出消息处理事件
      ActiveMqEventManager.notice(new ActiveMqEvent(type, entity));
   }
   @Getter
   enum OperateType {
      create("创建"), remove("删除"),update("修改"),load("查询");
      private final String description;
      OperateType(String description) {
         this.description=description;
      }
   }
}

4.在回调函数里面,尽量不要直接在操作 EntityManager 后再做 session 的整个生命周期的其他持久化操作,以免破坏事务的处理流程;也不要进行其他额外的关联关系更新动作,业务性的代码一定要放在 service 层面,否则太过复杂,时间长了代码很难维护;(ps:我曾经看到有人把回调函数用得十分复杂,做各种状态流转逻辑,时间长了连他自己也不知道是干什么的,耦合度太高了,你一定要谨慎。)

5.回调函数里面比较适合用一些计算型的transient方法,如下面这个操作:

public class UserListener {
    @PrePersist
    public void prePersist(User user) {
        //通过一些逻辑计算年龄;
        user.calculationAge();
    }
}

6.JPA 官方比较建议放一些默认值,但是我不是特别赞同,因为觉得那样不够直观,我们直接用字段初始化就可以了,没必要在回调函数里面放置默认值。

那么除了日志,还有没有其他实战应用场景呢?

确实目前除了日志,Auditing 稍微公用一点,其他公用的场景不多。当遇到其他场景,你可以根据不同的实体实际情况制定自己独有的 EntityListener 方法,如下:

@Entity
@EntityListeners(UserListener.class)
public class User extends BaseEntity {// implements Auditable<Integer,Long, Instant> {
   @Transient
   public void calculationAge() {
      //通过一些逻辑计算年龄;
      this.age=10;
   }
   ......//其他不重要的省略
}

例如,User 中我们有个计算年龄的逻辑要独立调用,就可以在持久化之前调用此方法,新建一个自己的 UserListener 即可,代码如下:

public class UserListener {
    @PrePersist
    public void prePersist(User user) {
        //通过一些逻辑计算年龄;
        user.calculationAge();
    }
}

以上,关于 JPA Callbacks 在一些实际场景中的最佳实践就介绍这些,希望你在应用的时候多注意找方法,避免不必要的操作,也希望我的经验可以帮助到你。

JPA Callbacks 的实现原理,事件机制

那么 callbacks 的实现原理是什么呢?其实很简单,Java Persistence API规定:JPA 的实现方需要实现功能,需要支持回调事件注解;而 Hibernate 内部负责实现,Hibernate 内部维护了一套实体的 EventType,其内部包含了各种回调事件,下面列举一下:

public static final EventType<PreLoadEventListener> PRE_LOAD = create( "pre-load", PreLoadEventListener.class );
public static final EventType<PreDeleteEventListener> PRE_DELETE = create( "pre-delete", PreDeleteEventListener.class );
public static final EventType<PreUpdateEventListener> PRE_UPDATE = create( "pre-update", PreUpdateEventListener.class );
public static final EventType<PreInsertEventListener> PRE_INSERT = create( "pre-insert", PreInsertEventListener.class );
public static final EventType<PostLoadEventListener> POST_LOAD = create( "post-load", PostLoadEventListener.class );
public static final EventType<PostDeleteEventListener> POST_DELETE = create( "post-delete", PostDeleteEventListener.class );
public static final EventType<PostUpdateEventListener> POST_UPDATE = create( "post-update", PostUpdateEventListener.class );
public static final EventType<PostInsertEventListener> POST_INSERT = create( "post-insert", PostInsertEventListener.class );

更多的事件类型,你可以通过查看 org.hibernate.event.spi.EventType 类,了解更多;在 session factory 构建的时候,EventListenerRegistryImpl 负责注册这些事件,我们看一下 debug 的关键节点:

image (7).png

通过一步一步断点,再结合 Hibernate 的官方文档,可以了解内部 EventType 事件的创建机制,由于我们不常用这部分原理,知道有这么回事即可,你有兴趣也可以深入 debug 研究一下。

总结

到这里,本课时内容就介绍这么多。这一节,我们分析了语法,列举了实战使用场景及最佳实践,相信通过上面提到的异常、异步、避免死循环等处理方法,你已经知道回调函数的正确使用方法了。其中最佳实践场景也欢迎你补充,我们可以一起探讨。

下一课时,我们将迎来很多人都感兴趣的“乐观锁机制和重试机制”相关内容,到时候我会告诉你它们在实战中都是怎么使用的。

点击下方链接查看源码:(不定时更新)


14 乐观锁机制和重试机制在实战中应该怎么用

你好,欢迎来到第 14 课时,本课时我要为你揭晓乐观锁机制的“神秘面纱”,在前面的留言中,我看到很多人对这部分内容很感兴趣,因此希望通过我的讲解,你可以打开思路,真正掌握乐观锁机制和重试机制在实战中的用法。那么乐观锁到底是什么呢?它的神奇之处到底在哪?

什么是乐观锁?

乐观锁在实际开发过程中很常用,它没有加锁、没有阻塞,在多线程环境以及高并发的情况下 CPU 的利用率是最高的,吞吐量也是最大的。

而 Java Persistence API 协议也对乐观锁的操作做了规定:通过指定 @Version 字段对数据增加版本号控制,进而在更新的时候判断版本号是否有变化。如果没有变化就直接更新;如果有变化,就会更新失败并抛出“OptimisticLockException”异常。我们用 SQL 表示一下乐观锁的做法,代码如下:

select uid,name,version from user where id=1;
update user set name='jack', version=version+1 where id=1 and version=1

假设本次查询的 version=1,在更新操作时,加上这次查出来的 Version,这样和我们上一个版本相同,就会更新成功,并且不会出现互相覆盖的问题,保证了数据的原子性。

这就是乐观锁在数据库里面的应用。那么在 Spring Data JPA 里面怎么做呢?我们通过用法来了解一下。

乐观锁的实现方法

JPA 协议规定,想要实现乐观锁可以通过 @Version 注解标注在某个字段上面,并且可以持久化到 DB 即可。其支持的类型有如下四种:

  • intorInteger

  • shortorShort

  • longorLong

  • java.sql.Timestamp

这样就可以完成乐观锁的操作。我比较推荐使用 Integer 类型的字段,因为这样语义比较清晰、简单。

注意:Spring Data JPA 里面有两个 @Version 注解,请使用 @javax.persistence.Version,而不是 @org.springframework.data.annotation.Version。

我们通过如下几个步骤详细讲一下 @Version 的用法。

第一步:实体里面添加带 @Version 注解的持久化字段。

我在上一课时讲到了 BaseEntity,现在直接在这个基类里面添加 @Version 即可,当然也可以把这个字段放在 sub-class-entity 里面。我比较推荐你放在基类里面,因为这段逻辑是公共的字段。改动完之后我们看看会发生什么变化,如下所示:

@Data
@MappedSuperclass
public class BaseEntity {
   @Id
   @GeneratedValue(strategy= GenerationType.AUTO)
   private Long id;
   @Version
   private Integer version;
   //......当然也可以用上一课时讲解的 auditing 字段,这里我们先省略
}

第二步:用 UserInfo 实体继承 BaseEntity,就可以实现 @Version 的效果,代码如下:

@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(callSuper = true)
public class UserInfo extends BaseEntity {
   @Id
   @GeneratedValue(strategy= GenerationType.AUTO)
   private Long id;
   private Integer ages;
   private String telephone;
}

第三步:创建 UserInfoRepository,方便进行 DB 操作。

public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {}

第四步:创建 UserInfoService 和 UserInfoServiceImpl,用来模拟 Service 的复杂业务逻辑。

public interface UserInfoService {
   /**
    * 根据 UserId 产生的一些业务计算逻辑
    */
   UserInfo calculate(Long userId);
}
@Component
public class UserInfoServiceImpl implements UserInfoService {
   @Autowired
   private UserInfoRepository userInfoRepository;
   /**
    * 根据 UserId 产生的一些业务计算逻辑
    * @param userId
    * @return
    */
   @Override   @org.springframework.transaction.annotation.Transactional
   public UserInfo calculate(Long userId) {
      UserInfo userInfo = userInfoRepository.getOne(userId);
      try {
         //模拟复杂的业务计算逻辑耗时操作;
         Thread.sleep(500);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      userInfo.setAges(userInfo.getAges()+1);
      return userInfoRepository.saveAndFlush(userInfo);
   }
}

其中,我们通过 @Transactional 开启事务,并且在查询方法后面模拟复杂业务逻辑,用来呈现多线程的并发问题。

第五步:按照惯例写个测试用例测试一下。

@ExtendWith(SpringExtension.class)
@DataJpaTest
@ComponentScan(basePackageClasses=UserInfoServiceImpl.class)
public class UserInfoServiceTest {
   @Autowired
   private UserInfoService userInfoService;
   @Autowired
   private UserInfoRepository userInfoRepository;
   @Test
   public void testVersion() {
      //加一条数据
      UserInfo userInfo = userInfoRepository.save(UserInfo.builder().ages(20).telephone("1233456").build());
      //验证一下数据库里面的值
      Assertions.assertEquals(0,userInfo.getVersion());
      Assertions.assertEquals(20,userInfo.getAges());
      userInfoService.calculate(1L);
      //验证一下更新成功的值
      UserInfo u2 =  userInfoRepository.getOne(1L);
      Assertions.assertEquals(1,u2.getVersion());
      Assertions.assertEquals(21,u2.getAges());
   }
   @Test
   @Rollback(false)
   @Transactional(propagation = Propagation.NEVER)
   public void testVersionException() {
      //加一条数据
  userInfoRepository.saveAndFlush(UserInfo.builder().ages(20).telephone("1233456").build());
      //模拟多线程执行两次
      new Thread(() -> userInfoService.calculate(1L)).start();
      try {
         Thread.sleep(10L);//
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //如果两个线程同时执行会发生乐观锁异常;
      Exception exception = Assertions.assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
         userInfoService.calculate(1L);
         //模拟多线程执行两次
      });
      System.out.println(exception);
   }
}

从上面的测试得到的结果中,我们执行 testVersion(),会发现在 save 的时候, Version 会自动 +1,第一次初始化为 0;update 的时候也会附带 Version 条件。我们通过下图的 SQL,也可以看到 Version 的变化。

Drawing 0.png

而当面我们调用 testVersionException() 测试方法的时候,利用多线程模拟两个并发情况,会发现两个线程同时取到了历史数据,并在稍后都对历史数据进行了更新。

由此你会发现,第二次测试的结果是乐观锁异常,更新不成功。请看一下测试的日志。

Drawing 1.png

通过日志又会发现,两个 SQL 同时更新的时候,Version 是一样的,是它导致了乐观锁异常。

注意:乐观锁异常不仅仅是同一个方法多线程才会出现的问题,我们只是为了方便测试而采用同一个方法;不同的方法、不同的项目,都有可能导致乐观锁异常。乐观锁的本质是 SQL 层面发生的,和使用的框架、技术没有关系。

那么我们分析一下,@Version 对 save 的影响是什么,怎么判断对象是新增还是 update?

@Version 对 Save 方法的影响

通过上面的实例,你不难发现,@Version 底层实现逻辑和 @EntityListeners 一点关系没有,底层是通过 Hibernate 判断实体里面是否有 @Version 的持久化字段,利用乐观锁机制来创建和使用 Version 的值。

因此,还是那句话:Java Persistence API 负责制定协议,Hibernate 负责实现逻辑,Spring Data JPA 负责封装和使用。那么我们来看下 Save 对象的时候,如何判断是新增的还是 merge 的逻辑呢?

isNew 判断的逻辑

通过断点,我们可以进入SimpleJpaRepository.class 的 Save 方法中,看到如下图显示的界面:

Drawing 2.png

然后,我们进入JpaMetamodelEntityInformation.class 的 isNew 方法中,又会看到下图显示的界面:

Drawing 3.png

其中,我们先看第一段逻辑,判断其中是否有 @Version 标注的属性,并且该属性是否为基础类型。如果不满足条件,调用 super.isNew(entity) 方法,而 super.isNew 里面只判断了 ID 字段是否有值。

第二段逻辑表达的是,如果有 @Version 字段,那么看看这个字段是否有值,如果没有就返回 true,如果有值则返回 false。

由此可以得出结论:如果我们有 @Version 注解的字段,就以 @Version 字段来判断新增 / update;如果没有,那么就以 @ID 字段是否有值来判断新增 / update。

需要注意的是:虽然我们看到的是 merge 方法,但是不一定会执行 update 操作,里面还有很多逻辑,有兴趣的话你可以再 debug 进去看看。

我直接说一下结论,merge 方法会判断对象是否为游离状态,以及有无 ID 值。它会先触发一条 select 语句,并根据 ID 查一下这条记录是否存在,如果不存在,虽然 ID 和 Version 字段都有值,但也只是执行 insert 语句;如果本条 ID 记录存在,才会执行 update 的 sql。至于这个具体的 insert 和 update 的 sql、传递的参数是什么,你可以通过控制台研究一下。

总之,如果我们使用纯粹的 saveOrUpdate方法,那么完全不需要自己写这一段逻辑,只要保证 ID 和 Version 存在该有的值就可以了,JPA 会帮我们实现剩下的逻辑。

实际工作中,特别是分布式更新的时候,很容易碰到乐观锁,这时候还要结合重试机制才能完美解决我们的问题,接下来看看具体该怎么做。

乐观锁机制和重试机制在实战中应该怎么用?

我们先了解一下 Spring 支持的重试机制是什么样的。

重试机制详解

Spring 全家桶里面提供了@Retryable 的注解,会帮我们进行重试。下面看一个 @Retryable 的例子。

第一步:利用 gradle 引入 spring-retry 的依赖 jar,如下所示:

implementation 'org.springframework.retry:spring-retry'

第二步:在 UserInfoserviceImpl 的方法中添加 @Retryable 注解,就可以实现重试的机制了,代码如下:

Drawing 4.png

第三步:新增一个RetryConfiguration并添加@EnableRetry 注解,是为了开启重试机制,使 @Retryable 生效。

@EnableRetry
@Configuration
public class RetryConfiguration {
}

第四步:新建一个测试用例测试一下。

@ExtendWith(SpringExtension.class)
@DataJpaTest
@ComponentScan(basePackageClasses=UserInfoServiceImpl.class)
@Import(RetryConfiguration.class)
public class UserInfoServiceRetryTest {
   @Autowired
   private UserInfoService userInfoService;
   @Autowired
   private UserInfoRepository userInfoRepository;
   @Test
   @Rollback(false)
   @Transactional(propagation = Propagation.NEVER)
   public void testRetryable() {
      //加一条数据
    userInfoRepository.saveAndFlush(UserInfo.builder().ages(20).telephone("1233456").build());
      //模拟多线程执行两次
      new Thread(() -> userInfoService.calculate(1L)).start();
      try {
         Thread.sleep(10L);//
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //模拟多线程执行两次,由于加了@EnableRetry,所以这次也会成功
      UserInfo userInfo = userInfoService.calculate(1L);
      //经过了两次计算,年龄变成了 22
      Assertions.assertEquals(22,userInfo.getAges());
      Assertions.assertEquals(2,userInfo.getVersion());
   }
}

这里要说的是,我们在测试用例里面执行 @Import(RetryConfiguration.class),这样就开启了重试机制,然后继续在里面模拟了两次线程调用,发现第二次发生了乐观锁异常之后依然成功了。为什么呢?我们通过日志可以看到,它是失败了一次之后又进行了重试,所以第二次成功了。

通过案例你会发现 Retry 的逻辑其实很简单,只需要利用 @Retryable 注解即可,那么我们看一下这个注解的详细用法。

@Retryable 详细用法

其源码里面提供了很多方法,看下面这个图片。

Drawing 5.png

下面对常用的 @Retryable 注解中的参数做一下说明:

  • maxAttempts:最大重试次数,默认为 3,如果要设置的重试次数为 3,可以不写;

  • value:抛出指定异常才会重试;

  • include:和 value 一样,默认为空,当 exclude 也为空时,默认异常;

  • exclude:指定不处理的异常;

  • backoff:重试等待策略,默认使用 @Backoff@Backoff 的 value,默认为 1s,请看下面这个图。

Drawing 6.png

其中:

  • value=delay:隔多少毫秒后重试,默认为 1000L,单位是毫秒;

  • multiplier(指定延迟倍数)默认为 0,表示固定暂停 1 秒后进行重试,如果把 multiplier 设置为 1.5,则第一次重试为 2 秒,第二次为 3 秒,第三次为 4.5 秒。

下面是一个关于 @Retryable 扩展的使用例子,具体看一下代码:

@Service
public interface MyService {
    @Retryable( value = SQLException.class, 
      maxAttempts = 2, backoff = @Backoff(delay = 100))
    void retryServiceWithCustomization(String sql) throws SQLException;
}

可以看到,这里明确指定 SQLException.class 异常的时候需要重试两次,每次中间间隔 100 毫秒。

@Service 
public interface MyService { 
  @Retryable( value = SQLException.class, maxAttemptsExpression = "${retry.maxAttempts}",
            backoff = @Backoff(delayExpression = "${retry.maxDelay}")) 
  void retryServiceWithExternalizedConfiguration(String sql) throws SQLException; 
}

此外,你也可以利用 SpEL 表达式读取配置文件里面的值。

关于 Retryable 的语法就介绍到这里,常用的基本就这些,如果你遇到更复杂的场景,可以到 GitHub 中看一下官方的 Retryable 文档:https://github.com/spring-projects/spring-retry。下面再给你分享一个我在使用乐观锁+重试机制中的最佳实践。

乐观锁+重试机制的最佳实践

我比较建议你使用如下配置:

@Retryable(value = ObjectOptimisticLockingFailureException.class,backoff = @Backoff(multiplier = 1.5,random = true))

这里明确指定 ObjectOptimisticLockingFailureException.class 等乐观锁异常要进行重试,如果引起其他异常的话,重试会失败,没有意义;而 backoff 采用随机 +1.5 倍的系数,这样基本很少会出现连续 3 次乐观锁异常的情况,并且也很难发生重试风暴而引起系统重试崩溃的问题。

到这里讲的一直都是乐观锁相关内容,那么 JPA 也支持悲观锁吗?

除了乐观锁,悲观锁的类型怎么实现?

Java Persistence API 2.0 协议里面有一个 LockModeType 枚举值,里面包含了所有它支持的乐观锁和悲观锁的值,我们看一下。

public enum LockModeType
{
    //等同于OPTIMISTIC,默认,用来兼容2.0之前的协议
    READ,
    //等同于OPTIMISTIC_FORCE_INCREMENT,用来兼容2.0之前的协议
    WRITE,
    //乐观锁,默认,2.0协议新增
    OPTIMISTIC,
    //乐观写锁,强制version加1,2.0协议新增
    OPTIMISTIC_FORCE_INCREMENT,
    //悲观读锁 2.0协议新增
    PESSIMISTIC_READ,
    //悲观写锁,version不变,2.0协议新增
    PESSIMISTIC_WRITE,
    //悲观写锁,version会新增,2.0协议新增
    PESSIMISTIC_FORCE_INCREMENT,
    //2.0协议新增无锁状态
    NONE
}

悲观锁在 Spring Data JPA 里面是如何支持的呢?很简单,只需要在自己的 Repository 里面覆盖父类的 Repository 方法,然后添加 @Lock 注解并指定 LockModeType 即可,请看如下代码:

public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<UserInfo> findById(Long userId);
}

你可以看到,UserInfoRepository 里面覆盖了父类的 findById 方法,并指定锁的类型为悲观锁。如果我们将 service 改调用为悲观锁的方法,会发生什么变化呢?如下图所示:

Drawing 7.png

然后再执行上面测试中 testRetryable 的方法,跑完测试用例的结果依然是通过的,我们看下日志。

Drawing 8.png

你会看到,刚才的串行操作完全变成了并行操作。所以少了一次 Retry 的过程,结果还是一样的。但是,你在生产环境中要慎用悲观锁,因为它是阻塞的,一旦发生服务异常,可能会造成死锁的现象。

总结

本课时的内容到这里就介绍完了。在这一课时中,我为你详细讲解了乐观锁的概念及使用方法、@Version 对 Save 方法的影响,分享了乐观锁与重试机制的最佳实践,此外也提到了悲观锁的使用方法(不推荐使用)。

那么现在,你又掌握了 JPA 的一项技能,希望你可以多动手实践,不断总结经验,以提高自己的技术水平。

下一课时,我们看看 JPA 对 Web MVC 开发者都做了哪些支持呢?

点击下方链接查看源码(不定时更新)
https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
JPAJava Persistence API的缩写,是Java EE规范中用于ORM(对象关系映射)的API。它定义了一组接口和注解,使开发人员可以通过编写面向对象的代码来操作数据库。引用提到了在pom.xml中添加了两个依赖,即org.springframework.data:spring-data-jpa和org.springframework.boot:spring-boot-starter-data-jpa,这是使用Spring Data JPA时需要添加的依赖。 Spring Data JPA是在JPA规范下对Repository层进行封装的实现。它提供了一套简化的方法和规范,使开发人员可以更轻松地进行数据库操作。引用中的代码片段展示了如何定义一个符合Spring Data JPA规范的DAO层接口。通过继承JpaRepository和JpaSpecificationExecutor接口,我们可以获得封装了基本CRUD操作和复杂查询的功能。 关于JPASpring Data JPA的区别,引用提到了一个很好的解释。JPA是一种规范,而Spring Data JPA是在JPA规范下提供的Repository层的实现。通过使用Spring Data JPA,我们可以方便地在不同的ORM框架之间进行切换,而不需要更改代码。Spring Data JPA还对Repository层进行了封装,省去了开发人员的不少麻烦。 综上所述,JPAJava EE规范中的API,而Spring Data JPA是在JPA规范下的Repository层的实现。Spring Data JPA封装了JPA规范,提供了更方便的方法和规范,使开发人员可以更轻松地进行数据库操作。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [JPASpring-Data-JPA简介](https://blog.csdn.net/benjaminlee1/article/details/53087351)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 50%"] - *2* [JPA & Spring Data JPA详解](https://blog.csdn.net/cd546566850/article/details/107180272)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

办公模板库 素材蛙

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

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

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

打赏作者

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

抵扣说明:

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

余额充值