rollback-only異常令我對事務有了新的認識

在天上飛的的程序員 發佈 2022-09-17T21:43:51.312180+00:00

背景環境相關環境配置:SpringBoot+PostGreSQLSpring Data JPA問題兩個使用 Transaction 註解的 ServiceA 和 ServiceB,在 A 中引入了 B 的方法用於更新數據 ,當 A 中捕捉到 B 中有異常時,回滾動作正常執行,但是

背景

環境

相關環境配置:

  • SpringBoot+PostGreSQL
  • Spring Data Jpa

問題

兩個使用 transaction 註解的 ServiceA 和 ServiceB,在 A 中引入了 B 的方法用於更新數據 ,當 A 中捕捉到 B 中有異常時,回滾動作正常執行,但是當 return 時則出現org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only異常。

代碼示例:

ServiceA

@Transactional
public class ServiceA {
  @Autowired
  private ServiceB serviceB;

  public Object methodA() {
    try{
      serviceB.methodB();
    } catch (Exception e) {
      e.printStackTrace();
    }
    
    return null;
  }
}

ServiceB

@Transactional
public class ServiceB {
  public void methodB() {
    throw new runtimeException();
  }
}

知識回顧

@Transactional

Spring Boot 默認集成事務,所以無須手動開啟使用 @EnableTransactionManagement 註解,就可以用 @Transactional 註解進行事務管理。

@Transactional 的作用範圍

  1. 方法 :推薦將註解使用於方法上,不過需要注意的是:該註解只能應用到 public 方法上,否則不生效。
  2. :如果這個註解使用在類上的話,表明該註解對該類中所有的 public 方法都生效。
  3. 接口 :不推薦在接口上使用。

@Transactional 的常用配置參數

關於事務傳播機制的詳細介紹,可以參考這篇文章。

@Transactional 事務註解原理

@Transactional 的工作機制是基於 AOP 實現的,AOP 又是使用動態代理實現的。如果目標對象實現了接口,默認情況下會採用 JDK 的動態代理,如果目標對象沒有實現了接口,會使用 CGLIB 動態代理。

如果一個類或者一個類中的 public 方法上被標註@Transactional 註解的話,Spring 容器就會在啟動的時候為其創建一個代理類,在調用被@Transactional 註解的 public 方法的時候,實際調用的是,TransactionInterceptor 類中的 invoke()方法。這個方法的作用就是在目標方法之前開啟事務,方法執行過程中如果遇到異常的時候回滾事務,方法調用完成之後提交事務。

Spring AOP 自調用問題

若同一類中的其他沒有 @Transactional 註解的方法內部調用有 @Transactional 註解的方法,有@Transactional 註解的方法的事務會失效。

這是由於Spring AOP代理的原因造成的,因為只有當 @Transactional 註解的方法在類以外被調用的時候,Spring 事務管理才生效。

關於 AOP 自調用的問題,文章結尾會介紹相關解決方法。

@Transactional 的使用注意事項總結

  1. @Transactional 註解只有作用到 public 方法上事務才生效,不推薦在接口上使用;
  2. 避免同一個類中調用 @Transactional 註解的方法,這樣會導致事務失效;
  3. 正確的設置 @Transactional 的 rollbackFor 和 propagation 屬性,否則事務可能會回滾失敗。

Spring 的 @Transactional 註解控制事務有哪些不生效的場景?

  • 資料庫引擎是否支持事務(MySQL的MyISAM引擎不支持事務);
  • 註解所在的類是否被加載成Bean類;
  • 註解所在的方法是否為 public 方法;
  • 是否發生了同類自調用問題;
  • 所用數據源是否加載了事務管理器;
  • @Transactional 的擴展配置 propagation(事務傳播機制)是否正確。
  • 方法未拋出異常
  • 異常類型錯誤(最好配置rollback參數,指定接收運行時異常和非運行時異常)

案例分析

構建項目

1、創建 Maven 項目,選擇相應的依賴。一般不直接用 MySQL 驅動,而選擇連接池。

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.2.6.RELEASE</version>
  <relativePath/> 
</parent>

<properties>
  <java.version>1.8</java.version>
  <mySQL.version>8.0.19</mysql.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql.version}</version>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.18</version>
  </dependency>
</dependencies>

2、配置 application.yml

spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/mysql_db?serverTimezone=Hongkong&characterEncoding=utf-8&useSSL=false
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: none
    open-in-view: false
    properties:
      hibernate:
        order_by:
          default_null_ordering: last
        order_inserts: true
        order_updates: true
        generate_statistics: false
        jdbc:
          batch_size: 5000
    show-sql: true
logging:
  level:
    root: info # 是否需要開啟 sql 參數日誌
    org.springframework.orm.jpa: DEBUG
    org.springframework.transaction: DEBUG
    org.hibernate.engine.QueryParameters: debug
    org.hibernate.engine.query.HQLQueryPlan: debug
    org.hibernate.type.descriptor.sql.BasicBinder: trace
  • hibernate.ddl-auto: update 實體類中的修改會同步到資料庫表結構中,慎用。
  • show_sql 可開啟 hibernate 生成的 SQL,方便調試。
  • open-in-view指延時加載的一些屬性數據,可以在頁面展現的時候,保持 session 不關閉,從而保證能在頁面進行延時加載。默認為 true。
  • logging 下的幾個參數用於顯示 sql 的參數。

3、MySQL 資料庫中創建兩個表

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) DEFAULT NULL,
  `age` int DEFAULT NULL,
  `address` varchar(100) DEFAULT NULL,
  `created_date` timestamp NULL,
  `last_modified_date` timestamp NULL,
  PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;

CREATE TABLE `job` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) DEFAULT NULL,
  `user_id` bigint(20) NOT NULL,
  `address` varchar(100) DEFAULT NULL,
  `created_date` timestamp NULL,
  `last_modified_date` timestamp NULL,
  PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;

4、創建實體類並添加 JPA 註解

目前只創建兩個簡單的實體類,User 和 Job

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@EqualsAndHashCode(of = "id")
@SuperBuilder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public abstract class BaseDomain implements Serializable {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @CreatedDate
  private LocalDateTime createdDate;

  @LastModifiedDate
  private LocalDateTime lastModifiedDate;
}

@Entity
@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true)
@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
@SuperBuilder
public class User extends BaseDomain {

  private String name;

  private Integer age;

  private String address;

  @OneToMany(cascade = CascadeType.ALL)
  @JoinColumn(name = "user_id")
  private List<Job> jobs = new ArrayList<>();
}

@Entity
@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true)
@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
@SuperBuilder
public class Job extends BaseDomain {

  private String name;

  @ManyToOne
  @JoinColumn
  private User user;

  private String address;
}

5、創建對應的 Repository

實現 JpaRepository 接口,生成基本的 CRUD 操作樣板代碼。並且可根據 Spring Data JPA 自帶的 Query Lookup Strategies 創建簡單的查詢操作,在 IDEA 中輸入 findBy 等會有提示。

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

  List<User> findByAddress(String address);

  User findByName(String name);

  void deleteByName(String name);
}

@Repository
public interface JobRepository extends JpaRepository<Job, Long> {

  List<Job> findByUserId(Long userId);
}

6、創建 UserService 及其實現類

public interface UserService {

  List<UserResponse> getAll();

  List<UserResponse> findByAddress(String address);

  UserResponse query(String name);

  UserResponse add(UserDTO userDTO);

  UserResponse update(UserDTO userDTO);

  void delete(String name);
}

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

  private final UserRepository userRepository;

  @Override
  public List<UserResponse> getAll() {
    List<User> users = userRepository.findAll();
    return users.stream().map(this::toUserResponse).collect(Collectors.toList());
  }

  @Override
  public List<UserResponse> findByAddress(String address) {
    List<User> users = userRepository.findByAddress(address);
    return users.stream().map(this::toUserResponse).collect(Collectors.toList());
  }

  @Override
  public UserResponse query(String name) {
    if (!Objects.equals("hresh", name)) {
      throw new RuntimeException();
    }
    User user = userRepository.findByName(name);
    return toUserResponse(user);
  }

  @Override
  public UserResponse add(UserDTO userDTO) {
    User user = User.builder().name(userDTO.getName())
        .age(userDTO.getAge()).address(userDTO.getAddress()).build();
    userRepository.save(user);

    return toUserResponse(user);
  }

  @Override
  public UserResponse update(UserDTO userDTO) {
    User user = userRepository.findByName(userDTO.getName());
    if (Objects.isNull(user)) {
      throw new RuntimeException();
    }

    user.setAge(userDTO.getAge());
    user.setAddress(userDTO.getAddress());
    userRepository.save(user);

    return toUserResponse(user);
  }


  @Override
  public void delete(String name) {
    userRepository.deleteByName(name);
  }

  private UserResponse toUserResponse(User user) {
    if (user == null) {
      return null;
    }
    List<Job> jobs = user.getJobs();
    List<JobItem> jobItems;
    if (CollectionUtils.isEmpty(jobs)) {
      jobItems = new ArrayList<>();
    } else {
      jobItems = jobs.stream().map(job -> {
        JobItem jobItem = new JobItem();
        jobItem.setName(job.getName());
        jobItem.setAddress(job.getAddress());
        return jobItem;
      }).collect(Collectors.toList());
    }
    return UserResponse.builder().name(user.getName()).age(user.getAge()).address(user.getAddress())
        .jobItems(jobItems)
        .build();
  }
}

7、UserController

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;
  private final JobService jobService;

  @GetMapping
  public List<UserResponse> queryAll() {
    return userService.getAll();
  }

  @GetMapping("/address")
  public List<UserResponse> findByAddress(@RequestParam("address") String address) {
    return userService.findByAddress(address);
  }

  @GetMapping("/{name}")
  public UserResponse getByName(@PathVariable("name") String name) {
    return userService.query(name);
  }

  @PostMapping
  public UserResponse add(@RequestBody @Validated(Add.class) UserDTO userDTO) {
    return userService.add(userDTO);
  }

  @PutMapping
  public UserResponse update(@RequestBody @Validated(Update.class) UserDTO userDTO) {
    return userService.update(userDTO);
  }

  @DeleteMapping
  public void delete(@RequestParam(value = "name") @NotBlank String name) {
    userService.delete(name);
  }

  @PostMapping("/jobs")
  public void addJob(@RequestBody @Validated(Update.class) JobDTO jobDTO) {
    jobService.add(jobDTO);
  }
}

最後來看一下整個項目的結構以及文件分布。

基於上述代碼,我們將進行特定知識的學習演示。

事務回滾

構建必要的代碼如下:

//UserController.java
@GetMapping("/users")
public List<User> queryAll() {
  return userApplication.findAll();
}

//UserApplication.java
@Service
@Transactional
public class UserApplication {

  @Autowired
  private UserService userService;
  @Autowired
  private UserRepository userRepository;

  public List<User> findAll() {
    try {
      userService.query("hresh2");
    } catch (Exception e) {
    }

    return userRepository.findAll();
  }
}

//UserServiceImpl.java
@Override
@Transactional
public UserResponse query(String name) {
  if (!name.equals("hresh")) {
    throw new IllegalArgumentException("name is forbidden");
  }
  return null;
}

public void validateName(String name) {
  if (!name.equals("hresh")) {
    throw new IllegalArgumentException("name is forbidden");
  }
}

我們利用 postman 來進行測試,發現報錯結果和預期不大一樣:

關鍵信息變為了 Transaction silently rolled back because it has been marked as rollback-only,這裡我們暫不討論錯誤提示信息為何發生了改變,先集中討論報錯原因。

根據基礎知識中介紹的@Transactional 的作用範圍和傳播機制可知,當我們在 Service 文件類上添加 @Transactional 時,該註解對該類中所有的 public 方法都生效,且傳播機制默認為 PROPAGATION_REQUIRED,即如果當前存在事務,則加入該事務;如果當前沒有事務,則創建一個新的事務。

在這種情況下,外層事務(UserApplication)和內層事務(UserServiceImpl)就是一個事務,任何一個出現異常,都會在 findAll()執行完畢後回滾。如果內層事務拋出異常 IllegalArgumentException(沒有catch,繼續向外層拋出),在內層事務結束時,Spring 會把內層事務標記為「rollback-only」;這時外層事務發現了異常 IllegalArgumentException,如果外層事務 catch了異常並處理掉,那麼外層事務A的方法會繼續執行代碼,直到外層事務也結束時,這時外層事務想 commit,因為正常結束沒有向外拋異常,但是內外層事務是同一個事務,事務已經被內層方法標記為「rollback-only」,需要回滾,無法 commit,這時 Spring 就會拋出org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only,意思是「事務靜默回滾,因為它已被標記為僅回滾」。

報錯原因分析到此為止,現在我們來分析一下為何自建簡易代碼復現時,錯誤提示發生了變化,那麼就直接深入代碼來分析一下。

根據日誌列印的結果來看,rollback-only 異常發生於 org.springframework.transaction.support.AbstractPlatformTransactionManager 文件中:

public final void commit(TransactionStatus status) throws TransactionException {
    if (status.isCompleted()) {
      throw new IllegalTransactionStateException("Transaction is already completed - do not call commit or rollback more than once per transaction");
    } else {
      DefaultTransactionStatus defStatus = (DefaultTransactionStatus)status;
      //isLocalRollbackOnly()獲取的是AbstractTransactionStatus類中的rollbackOnly屬性,默認為false
      if (defStatus.isLocalRollbackOnly()) {
        if (defStatus.isDebug()) {
          this.logger.debug("Transactional code has requested rollback");
        }

        this.processRollback(defStatus, false);
      } else if (!this.shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
        //shouldCommitOnGlobalRollbackOnly默認實現是false。這裡是指如果發現事務被標記全局回滾並且在全局回滾標記情況下不應該提                          // 交事務的話,那麼則進行回滾。
        // defStatus.isGlobalRollbackOnly()進行判斷是指讀取DefaultTransactionStatus中EntityTransaction對象的                                                        // rollbackOnly標誌位,即判斷TransactionStatus是否等於MARKED_ROLLBACK
        if (defStatus.isDebug()) {
          this.logger.debug("Global transaction is marked as rollback-only but transactional code requested commit");
        }

        this.processRollback(defStatus, true);
      } else {
        this.processCommit(defStatus);
      }
    }
  }

  private void processCommit(DefaultTransactionStatus status) throws TransactionException {
    try {
      boolean beforeCompletionInvoked = false;

      try {
        boolean unexpectedRollback = false;
        this.prepareForCommit(status);
        this.triggerBeforeCommit(status);
        this.triggerBeforeCompletion(status);
        beforeCompletionInvoked = true;
        if (status.hasSavepoint()) {
          if (status.isDebug()) {
            this.logger.debug("Releasing transaction savepoint");
          }

          unexpectedRollback = status.isGlobalRollbackOnly();
          status.releaseHeldSavepoint();
        } else if (status.isNewTransaction()) {
          if (status.isDebug()) {
            this.logger.debug("Initiating transaction commit");
          }

          unexpectedRollback = status.isGlobalRollbackOnly();
          this.doCommit(status);
        } else if (this.isFailEarlyOnGlobalRollbackOnly()) {
          unexpectedRollback = status.isGlobalRollbackOnly();
        }

        if (unexpectedRollback) {
          throw new UnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only");
        }
      } 

      //.........
  }

  public final void rollback(TransactionStatus status) throws TransactionException {
    if (status.isCompleted()) {
      throw new IllegalTransactionStateException("Transaction is already completed - do not call commit or rollback more than once per transaction");
    } else {
      DefaultTransactionStatus defStatus = (DefaultTransactionStatus)status;
      this.processRollback(defStatus, false);
    }
  }

  private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
    try {
      boolean unexpectedRollback = unexpected;

      try {
        this.triggerBeforeCompletion(status);
        if (status.hasSavepoint()) {
          if (status.isDebug()) {
            this.logger.debug("Rolling back transaction to savepoint");
          }

          status.rollbackToHeldSavepoint();
        } else if (status.isNewTransaction()) {
          // 判斷當前事務是否是個新事務,false表示參與現有事務或不在當前事務中
          if (status.isDebug()) {
            this.logger.debug("Initiating transaction rollback");
          }

          this.doRollback(status);
        } else {
          if (status.hasTransaction()) {
            // 參與現有事務
            if (!status.isLocalRollbackOnly() && !this.isGlobalRollbackOnParticipationFailure()) {
              if (status.isDebug()) {
                this.logger.debug("Participating transaction failed - letting transaction originator decide on rollback");
              }
            } else {
              if (status.isDebug()) {
                this.logger.debug("Participating transaction failed - marking existing transaction as rollback-only");
              }

              this.doSetRollbackOnly(status);
            }
          } else {
            this.logger.debug("Should roll back transaction but cannot - no transaction available");
          }

          if (!this.isFailEarlyOnGlobalRollbackOnly()) {
            unexpectedRollback = false;
          }
        }
      } catch (Error | RuntimeException var8) {
        this.triggerAfterCompletion(status, 2);
        throw var8;
      }

      this.triggerAfterCompletion(status, 1);
      if (unexpectedRollback) {
        throw new UnexpectedRollbackException("Transaction rolled back because it has been marked as rollback-only");
      }
    } finally {
      this.cleanupAfterCompletion(status);
    }

  }

結合上述代碼,通過斷點調試,大致可以梳理出如下邏輯:

1、當內層事務(UserServiceImpl)中的 query 拋出異常後,開始進行回滾,即進入 rollback()方法,接著進入 processRollback()方法,此時第二個入參的值為 false;

2、進入 processRollback()方法後,首先判斷事物是否擁有 savepoint(回滾點),如果有,就回滾到設置的 savepoint;接著判斷當前事務是否是新事務,因為這裡是內外層事務,其實是同一個事務,所以判斷結果為 false;但 hasTransaction()判斷為 true,接著進入 if 方法體,isLocalRollbackOnly()為 false,isGlobalRollbackOnParticipationFailure()為 true(globalRollbackOnParticipationFailure默認情況下為true,表示異常全局回滾),那麼只能執行 doSetRollbackOnly()方法,此處只是補充列印一下日誌;緊接著調用 isFailEarlyOnGlobalRollbackOnly()方法,這裡主要是獲取 failEarlyOnGlobalRollbackOnly 欄位的值,默認情況下 failEarlyOnGlobalRollbackOnly 開關是關閉的,這個開關的作用是如果開啟了程序則會儘早拋出異常。最終 unexpectedRollback 欄位仍為 false,所以沒有拋出 Transaction rolled back because it has been marked as rollback-only 異常。

3、內層事務方法調用結束後,回到外層方法,在事務提交時,即執行 commit()方法,實際上執行的是 processCommit()方法。該方法中的邏輯和 processRollback()方法有些重疊,此時判斷當前事務是新事務,所以 unexpectedRollback 就被賦值為 true,最終拋出 Transaction silently rolled back because it has been marked as rollback-only 異常。

上面我們簡述了自定義代碼時,為何只能得到 Transaction silently rolled back because it has been marked as rollback-only 異常,但一開始在項目代碼中確實遇到了 Transaction rolled back because it has been marked as rollback-only 異常(尷尬的是,後來我也沒能再復現該錯誤)。網上查閱了很多資料,發現自定義的代碼並沒有問題,但很多博主依據類似代碼卻能得到Transaction rolled back because it has been marked as rollback-only 異常。這裡我個人還是覺得挺疑惑的,一度認為是自己哪裡出了問題,最後實在復現不出來就放棄了,個人姑且認為是 JPA 或事務管理的版本問題。

rollback-only異常產生的原因

對於上述測試代碼,稍微改變一下,最後結果也有所不同,這裡就不贅述了,可以參考這篇文章。

從上述分析看,產生 rollback-only 異常需要同時滿足以下前提:

1.事務方法嵌套,位於同一個事務中,方法位於不同的文件;

2.子方法拋出異常,被上層方法捕獲和消化。

解決方法

1、捕獲異常時,手動設置上層事務狀態為 rollback 狀態

  @Transactional
  public List<User> findAll() {
    try {
      userService.query("hresh2");
    } catch (Exception e) {
      TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }

    return userRepository.findAll();
  }

日誌輸出如下所示:

2、修改事務傳播機制,比如說將內層事務的傳播方式指定為@Transactional(propagation= Propagation.NESTED),外層事務的提交和回滾能夠控制嵌套的內層事務回滾;而內層事務報錯時,只回滾內層事務,外層事務可以繼續提交。

但嘗試Propagation.NESTED與 Hibernate JPA 一起使用將導致 Spring 異常,如下所示:

JpaDialect does not support savepoints - check your JPA provider's capabilities

這是因為 Hibernate JPA 不支持嵌套事務。

導致異常的 Spring 代碼是:

private SavepointManager getSavepointManager() {
  ...
    SavepointManager savepointManager= getEntityManagerHolder().getSavepointManager(); 
  if (savepointManager == null) {
    throw new NestedTransactionNotSupportedException("JpaDialect does not support ...");
  }

  return savepointManager;
}

可以考慮用 Propagation.REQUIRES_NEW 代替一下。

3、如果這個異常發生時,內層需要事務回滾的代碼還沒有執行,則可以@Transactional(noRollbackFor = {內層拋出的異常}.class),指定內層也不為這個異常回滾。

//UserServiceImpl.java
@Override
@Transactional(noRollbackFor = IllegalArgumentException.class)
public UserResponse query(String name) {

  if (!name.equals("hresh")) {
    throw new IllegalArgumentException("name is forbidden");
  }
  return null;
}

4、內層方法取消@Transactional 註解,這樣就不會發生回滾操作。

事務失效

接下來我們分析事務是否生效的問題。雖然大家對於同類自調用會導致事務失效這一知識點朗朗上口,但你真的了解嗎?具體來說就是類A的方法a()調用方法b(),方法b()配置了事務,那麼該事務在調用時不會生效。

Case 1

UserServiceImpl 中的兩個方法

  public List<UserResponse> findByAddress(String address) {
    List<User> users = userRepository.findByAddress(address);

    UserResponse userResponse = query("hresh");
    return users.stream().map(this::toUserResponse).collect(Collectors.toList());
  }

  @Transactional
  public UserResponse query(String name) {
    User user = userRepository.findByName(name);
    return toUserResponse(user);
  }

UserRepository 定義的查詢方法

  @EntityGraph(
      attributePaths = {"jobs"}
  )
  List<User> findByAddress(String address);

根據上述代碼可知,findByAddress()方法沒有配置事務,而 query()方法配置了事務,日誌輸出如下:

由上可知,query()方法的事務配置沒有生效。我們進一步猜測,如果 query()方法中拋出異常,數據會回滾嗎?答案可想而知,沒有事務就不會回滾。

Case 2

如果類A的方法a()調用方法b(),方法a()、b()都配置了事務,那麼又是什麼結果呢?我們只需在 findByAddress()方法加上 @Transactional 註解,重新執行代碼,結果如下:

根據結果可知,findByAddress()方法的事務生效了,但 query()方法的事務沒有生效,因為它們兩個共享同一個事務。

Case 3

在測試上述場景的過程中,我發現了一個有意思的情況,就是關於 save()方法的調用。

  public UserResponse add(UserDTO userDTO) {
    System.out.println("事務開啟");
    User user = User.builder().name(userDTO.getName())
        .age(userDTO.getAge()).address(userDTO.getAddress()).build();
    userRepository.save(user);

    return toUserResponse(user);
  }

控制台輸出為:

明明我們沒有加@Transactional 註解,為什麼會輸出事務相關內容呢?這裡可以深入源碼進行分析,看看 JPA 自帶的 save 方法是如何實現的,具體實現是在 SimpleJpaRepository 文件中。

  @Transactional
  public <S extends T> S save(S entity) {
    if (this.entityInformation.isNew(entity)) {
      this.em.persist(entity);
      return entity;
    } else {
      return this.em.merge(entity);
    }
  }

如果在 add 方法中調用配置了事務的 query()方法,日誌輸出為:

根據結果可知,query()方法的事務沒有生效。且事務生效的範圍僅在 save()方法上,而非 add()方法,如果此時 query()方法中拋出異常,add()方法是不會回滾的。感興趣的朋友可以測試一下。

Case 4

如果此時在 add()方法上添加 @Transactional 註解,執行代碼,控制台輸出如下:

因為 Transactional 的傳播機制默認為 REQUIRED,即如果上下文中已經存在事務,那麼就加入到事務中執行,如果當前上下文中不存在事務,則新建事務執行。所以 save()方法的加入到了 add()方法的事務中。

如果此時 query()方法中拋出異常,不管 query()方法是否添加@Transactional 註解,add()方法都是會回滾的。

事務失效原因分析

事務不生效的原因在於,Spring 基於 AOP 機制實現事務的管理,不管是通過 @Authwired 來注入 UserService,還是其他方式,調用UserService 的方法時,實際上是通過 UserService 的代理類調用 UserService 的方法,代理類在執行目標方法前後,加上了事務管理的代碼。

因此,只有通過注入的 UserService 調用事務方法,才會走代理類,才會執行事務管理;如果在同類直接調用,沒走代理類,事務就無效。 注意:除了@Transactional,@Async 同樣需要代理類調用,異步才會生效。

以前只是知道同類自調用會導致事務失效,剛學習了事務失效的背後原因,除此之外,在網上查閱資料的時候,又發現解決事務失效的三種方法,這裡簡單給大家介紹一下。

Way 1

@Service
//@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

  @Autowired
  private UserRepository userRepository;
  @Autowired
  private UserService userService;

  @Override
  @Transactional
  public UserResponse query(String name) {
    System.out.println("query方法事務開啟");
    User user = userRepository.findByName(name);
    return toUserResponse(user);
  }

  @Override
  public UserResponse add(UserDTO userDTO) {
    System.out.println("事務開啟");
    User user = User.builder().name(userDTO.getName())
        .age(userDTO.getAge()).address(userDTO.getAddress()).build();
    userRepository.save(user);

    userService.query(user.getName());
    return toUserResponse(user);
  }
}

因為 Spring 通過三級緩存解決了循環依賴的問題,所以上面的寫法不會有循環依賴問題。

但是使用@RequiredArgsConstructor 會出現循環依賴的問題,究其原因,是因為@RequiredArgsConstructor 是 Lombok 的註解,屬於是構造器注入。

由此引出一個問題,為何@Autowired 來注入對象不會出現循環依賴,而@RequiredArgsConstructor 不行?

循環調用其實就是一個死循環,除非有終結條件。Spring 中循環依賴場景有:

  • 構造器的循環依賴
  • field 屬性的循環依賴

對於構造器的循環依賴,Spring 是無法解決的,只能拋出 BeanCurrentlyInCreationException 異常表示循環依賴,所以下面我們分析的都是基於 field 屬性的循環依賴。

Spring 只解決 scope 為 singleton 的循環依賴,對於scope 為 prototype 的 bean Spring 無法解決,直接拋出 BeanCurrentlyInCreationException 異常。

我們使用@Autowired,將其添加到欄位上,所以即使出現循環依賴,Spring 也可以應對。

Way 2

通過 ApplicationContext 獲取到當前代理類,

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

  private final UserRepository userRepository;
  private final ApplicationContext applicationContext;

  @Override
  @Transactional
  public UserResponse query(String name) {
    System.out.println("query方法事務開啟");
    User user = userRepository.findByName(name);
    return toUserResponse(user);
  }

  @Override
  public UserResponse add(UserDTO userDTO) {
    System.out.println("事務開啟");
    User user = User.builder().name(userDTO.getName())
        .age(userDTO.getAge()).address(userDTO.getAddress()).build();
    userRepository.save(user);

    UserService bean = applicationContext.getBean(UserService.class);
    bean.query(user.getName());
    return toUserResponse(user);
  }
}

不管要什麼解決方案,都要儘量避免出現循環依賴,實在不行就使用@Autowired。

擴展

數據持久化自動生成新增時間

在 spring jpa 中,支持在欄位或者方法上進行註解 @CreatedDate、@CreatedBy、@LastModifiedDate、@LastModifiedBy,從字面意思可以很清楚的了解,這幾個註解的用處。

  • @CreatedDate 表示該欄位為創建時間時間欄位,在這個實體被 insert 的時候,會設置值
  • @CreatedBy 表示該欄位為創建人,在這個實體被 insert 的時候,會設置值
  • @LastModifiedDate、@LastModifiedBy 同理。

如何使用上述註解,並啟用它們?

首先申明實體類,需要在類上加上註解 @EntityListeners(AuditingEntityListener.class),其次在 application 啟動類中加上註解 EnableJpaAuditing,或者定義一個 config 類,同時在需要的欄位上加上 @CreatedDate、@CreatedBy、@LastModifiedDate、@LastModifiedBy 等註解。

在 jpa.save 方法被調用的時候,時間欄位會自動設置並插入資料庫,但是 CreatedBy 和 LastModifiedBy 並沒有賦值,因為需要實現 AuditorAware 接口來返回你需要插入的值。

import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;

@Configuration
public class UserIDAuditorBean implements AuditorAware<Long> {
    @Override
    public Long getCurrentAuditor() {
        SecurityContext ctx = SecurityContextHolder.getContext();
        if (ctx == null) {
            return null;
        }
        if (ctx.getAuthentication() == null) {
            return null;
        }
        if (ctx.getAuthentication().getPrincipal() == null) {
            return null;
        }
        Object principal = ctx.getAuthentication().getPrincipal();
        if (principal.getClass().isAssignableFrom(Long.class)) {
            return (Long) principal;
        } else {
            return null;
        }
    }
}

問題記錄

Method threw 'java.lang.StackOverflowError' exception. Cannot evaluate com.msdn.hresh.domain.User.toString()

問題出現的原因:debug 模式下,因為 User 類和 Job 類相互引用,以及都加了 lombok 的 @Data 註解,@Data 註解會生成 toString()方法,而這兩個類在使用 toString()方法時,會不斷的互相循環調用引用對象的方法,導致棧溢出。

解決辦法:

1、刪去@Data 註解,用@Getter 和@Setter 來代替;

2、重寫 toString()方法,覆蓋@Data 註解實現的 toString(),注意不要再互相循環調用方法。

推薦使用第一種方法。

總結

使用 Spring 框架進行開發給我們提供了便利,隱藏了很多事務控制的細節和底層繁瑣的邏輯,極大的減少了開發的複雜度。但是,如果我們對底層源碼多一些了解的話,對於開發和問題排查都會有所幫助。不過學習源碼本身就是一件枯燥的事情,需要時再去研究源碼,動力更強一些,效率更高一些。



關鍵字: