Spring声明式事务不生效?

背景

本篇博文将会讲一讲Spring中使用@Transactional注解会出现的不生效问题。事务的生效与否,一般不是我们冒烟自测的范围,测试也不会去测,但是一旦上线后,事务出现不生效的情况,就可能引发较大的问题,甚至会带来损失。所以,使用好事务注解是非常重要的,尤其是注意哪些场景下会出现事务失效。

事务失效效常见情况分析

  • 1、Transactional注解必须修饰在public方法上面,如果不是public方法,则事务不会生效。
    原理说明
    因为声明式事务是通过SpringAOP代理来实现的,当在类中声明一个事务方法时,Spring会创建一个代理类,以便在调用该类的方法时应用事务管理。
    SpringAOP的实现有JDK动态代理和CGLIB代理,对于实现了接口的方法使用JDK动态代理,那些没有实现接口的方法,则使用CGLIB代理。但是不管哪种代理,都必须保证目标方法是public方法;
    因为Spring AOP在运行时会创建代理对象来拦截目标方法的调用,并在代理对象中添加事务管理的逻辑;如果不是public,那么代理对象就无法直接调用目标方法。
    举例说明
package com.example.demo3.commonpitfalls;
import com.example.demo3.commonpitfalls.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("transactional")
public class TransactionalPit {

    @Autowired
    private UserService userService;

    @GetMapping("wrong")
    public int wrong1(@RequestParam("name") String name) {
        return userService.createUserWrong1(name);
    }
}

package com.example.demo3.commonpitfalls.service;

import com.example.demo3.commonpitfalls.dto.UserEntity;
import com.example.demo3.commonpitfalls.dto.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@Service
@Slf4j
public class UserService {

    @Autowired
    private UserRepository userRepository;

    // 一个公共方法供Controller调用,内部调用事务性的私有方法
    public int createUserWrong1(String name) {
        try {
            this.createUserPrivate(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }

    //标记了@Transactional的private方法
    @Transactional
    private void createUserPrivate(UserEntity entity) {
        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }
}
package com.example.demo3.commonpitfalls.dto;

import lombok.Data;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

import static javax.persistence.GenerationType.AUTO;
import static javax.persistence.GenerationType.IDENTITY;

@Entity
@Data
@Table(name = "user_entity")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    private String name;
    public UserEntity() {}
    public UserEntity(String name) {
        this.name = name;
    }
}
package com.example.demo3.commonpitfalls.dto;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
    List<UserEntity> findByName(String name);
}



当我们传入一个name ,按照代码中的逻辑,会抛出一个异常,然后会期望触发事务的回滚,但我们的执行结果却是数据库成功保存了这个name。那就说明事务并未生效。
仔细看我们的事务注解,即没有指定回滚异常,而且还用在了private方法上面。Spring AOP代理对象无法访问private的方法,从而导致事务没有生效。
修改为方法修饰符为public

controller增加一个方法
    @GetMapping("wrong2")
    public int wrong2(@RequestParam("name") String name) {
        return userService.createUserWrong2(name);
    }
service中增加两个方法
     // 一个公共方法供Controller调用,内部调用事务性的公有方法
    public int createUserWrong2(String name) {
        try {
            this.createUserPublic(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }
    //标记了@Transactional的public方法
    @Transactional
    public void createUserPublic(UserEntity entity) {
        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }


从结果发现,这次非法name又保存成功了,说明事务依然没有生效。仔细观察我们的调用方式,使用了this, 在一个方法的内部自调用了另一个带有事务注解的方法。那么,这里给出事务的第二个失效情况。
2、Transactional注解必须通过代理过的类从外部调用目标方法才能生效, 否则会事务失效。
这是因为 Spring 使用代理来实现事务,自调用会绕过代理,导致事务不生效!
修改代码

service
首先注入本类的service
 @Autowired
 UserService userService;
然后,使用userService去调用。
// 一个公共方法供Controller调用,外部调用事务性的公有方法
    public int createUserWrong2(String name) {
        try {
           userService.createUserPublic(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }

经过调试发现,userService是由SpringCglib增强过的类,故访问的方法也是代理后的方法,具有事务的特性。

再次测试,控制台抛出了异常,数据库已经无法保存这个非法的name了。

说明事务生效了,之前执行过的save, 也在后面有执行了回滚。
这边说的外部调用,也可以是直接从另外一个类调用本来UserService类的方法!
3、事务生效后,如何捕获异常,并保证一定回滚。
有些错误的理解,认为只要有异常,事务一定会回滚,实则不然。
一般,我们写代码的时候,都这样定义事务,即@Transactional(rollbackFor = Exception.class),这个含义指的是只要方法中遇到异常,那么就执行回滚。
因为默认情况下,出现RuntimeException(非受检异常)或Error的时候,Spring才会回滚事务。如果是受检异常,Spring认为受检异常一般是业务异常,或者说是类似另一种方法的返回值,出现这样的异常可能业务还能完成,所以不会主动回滚;而 Error或RuntimeException 代表了非预期的结果,应该回滚。
浅浅说一下受检异常和不受检异常。受检异常是指在编译时需要进行处理的异常,必须通过 try-catch 或 throws 关键字进行处理,否则编译器会报错。常见的受检异常包括 IOException、FileNotFoundException、SQLException 等。不受检异常,也称为运行时异常(Runtime Exceptions),是指继承自 RuntimeException 的异常类。这些异常在编译时不会被强制要求进行处理。一些常见的不受检异常包括 NullPointerException、ArrayIndexOutOfBoundsException、ArithmeticException 等。
不受检异常通常是由程序逻辑错误或者运行时环境导致的异常,通常应该通过编码实践避免这些异常的发生。

public class UserService {
        @Autowired
        private UserRepository userRepository;
        @Autowired
        UserService userService;
        // 异常无法传播出方法,导致事务无法回滚
        @Transactional
        public void createUserWrong1(String name) {
            try {
                userRepository.save(new UserEntity(name));
                throw new RuntimeException("error");
            } catch (Exception ex) {
                log.error("create user failed", ex);
            }
        }
        @Transactional
        public void createUserWrong2(String name) throws IOException {
            userRepository.save(new UserEntity(name));
            otherTask();
        }
        //因为文件不存在,一定会抛出一个 IOException
        private void otherTask() throws IOException {
            Files.readAllLines(Paths.get("file-that-not-exist"));
        }

以上代码中的两种情况, 事务都不会回滚。第一种情况是异常没有传播出方法,是由于方法内catch了所有异常,所以异常RuntimeException无法从方法传播出去,事务自然无法回滚;第二种情况是属于受检异常,createUserWrong2能将这个受检异常传播出去,但事务看到了这种异常,默认不会进行回滚。
针对第一种情况的解决方式可以是,我们手动在catch中让事务执行回滚。运行后,debug日志中,我们会看到Transactional code has requested rollback, 这个就表示是手动回滚。
针对第二种情况,既然不能回滚受检异常,那么我们就改变这个模式,让事务遇到不再区分受检还是不受检,只要是异常,那么就进行回滚。

  @Transactional(rollbackFor = Exception.class)
        public void createUserWrong2(String name) throws IOException {
            userRepository.save(new UserEntity(name));
            otherTask();
        }

改完以后,会发现可以正常回滚。一般,建议用这个方式。@Transactional(rollbackFor = Exception.class)
这块顺便提一下Transactional的其他属性。

  • timeout 属性
    事务的超时时间,默认值为 -1。如果超过该时间限制但事务还没有完成,则自动回滚事务。
  • readOnly 属性
    指定事务是否为只读事务,默认值为 false;为了忽略那些不需要事务的方法,比如读取数据,可以设置 read-only 为 true。
  • rollbackFor属性
    用于指定能够触发事务回滚的异常类型,可以指定多个异常类型。
  • noRollbackFor属性
    抛出指定的异常类型,不回滚事务,也可以指定多个异常类型。

总结的说,这个例子中,我们展现的是一个复杂的业务逻辑,其中有数据库操作、IO 操作,在 IO 操作出现问题时,希望让数据库事务也回滚,以确保逻辑的一致性。在有些业务逻辑中,可能
会包含多次数据库操作,我们不一定希望将两次操作作为一个事务来处理,这时候就需要仔细考虑事务传播的配置了,否则也可能踩坑。
4、事务传播配置是否符合自己的业务逻辑
有时,事务传播级别没有设置正确,业务逻辑和我们预期的不一致,事务会失效,但是在排查问题的时候,我们一下子不容易看出来是什么地方出现的错误。
那我先来普及一下声明式事务有哪些传播级别吧。

业务处理里,应该按照业务逻辑选择使用什么样的事务传播级别。以下是一些常见的业务场景和如何应用不同的事务传播级别:

  • Service 层方法调用
    REQUIRED:最常见的传播级别,确保方法在一个事务内执行。适用于大多数业务方法,保证数据一致性。
    REQUIRES_NEW:在一个新的事务中执行方法,适用于需要独立事务处理的情况,如异步处理。
  • 批量操作
    REQUIRES_NEW:对于大批量操作,可以使用新的事务,以避免长时间的数据库锁定。
  • 异常处理
    NESTED:对于需要部分回滚的场景,可以使用嵌套事务,确保部分操作成功后可以继续处理。
  • 并发情况
    REQUIRED:在高并发环境下,确保事务隔离,避免数据竞争。
  • 服务调用
    SUPPORTS:对于只读操作,可以使用支持当前事务,提高性能。
  • 数据同步
    REQUIRED:确保数据同步的操作在同一个事务内,以保证数据的一致性。
  • 多数据库操作
    REQUIRES_NEW:处理跨多个数据库的操作时,可以使用新的事务,确保各数据库操作独立。
  • 异步处理
    REQUIRES_NEW:在异步处理中,可以使用新的事务,避免影响主流程的事务。
    5、使用了不支持事务的存储引擎。
    这种情况必然会使事务注解失效。使用注解前,务必确认一下存储引擎是否支持事务。

总结

本文针对业务代码中最常见的使用数据库事务的方式,即 Spring 声明式事务,结了使用上可能遇到的三类坑,包括:
第一,因为配置不正确,导致方法上的事务没生效。我们务必确认调用 @Transactional 注解标记的方法是 public 的,并且是通过 Spring 注入的 Bean 进行调用的。
第二,因为异常处理不正确,导致事务虽然生效但出现异常时没回滚。Spring 默认只会对标记 @Transactional 注解的方法出现了 RuntimeException 和 Error 的时候回滚,如果
我们的方法捕获了异常,那么需要通过手动编码处理事务回滚。如果希望 Spring 针对其他异常也可以回滚,那么可以相应配置 @Transactional 注解的 rollbackFor 和
noRollbackFor属性来覆盖其默认设置。
第三,如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是 @Transactional 注解的 Propagation 属性。

posted @ 2024-09-12 17:29  heyhy  Views(80)  Comments(0Edit  收藏  举报
Title