大厂不宠@Transactional,背后藏着啥秘密?

大厂不宠@Transactional,背后藏着啥秘密?

@Transactional,看似美好实则暗藏玄机

在 Java 开发的江湖中,Spring 框架无疑是一位举足轻重的大侠,而@Transactional注解,则像是大侠手中一把看似普通却威力巨大的宝剑。对于广大 Java 开发者来说,它简直就是事务管理的神器。只要在方法或者类上轻轻加上这个注解,就仿佛给代码施了一个神奇的魔法,事务管理那复杂的开启、提交、回滚等操作,都能自动帮你搞定。

比如在一个电商系统里,处理订单的场景中,创建订单记录和扣减商品库存这两个操作,必须保证要么都成功,要么都失败,不然就会出现订单创建了但库存没扣,或者库存扣了但订单没创建的尴尬情况。这时,@Transactional注解就派上用场了,只要在处理订单的方法上加上它,就能轻松保证这一系列操作的原子性,确保数据的一致性 ,就像给业务操作上了一把安全锁。

但你以为有了这把宝剑就可以在江湖上横着走了吗?太天真啦!在大厂的复杂业务场景和严苛的性能要求下,@Transactional注解这把宝剑却逐渐暴露出了它的 "短板",这也是为什么大厂一般都不太推荐使用它的原因。接下来,就让我们一起深入剖析一下其中的缘由。

事务失效的 "坑" 大集合

(一)访问权限挖的 "坑"

在 Java 的世界里,有着四种访问权限控制符,就像是四把不同的钥匙,分别是private(私有)、default(默认,即不加任何修饰符)、protected(受保护)和public(公共) ,它们各自掌管着不同级别的访问权限。private这把钥匙最为神秘,被它守护的属性和方法,只有本类的对象才能窥探其中的奥秘,子类和其他包的类都只能望而却步;default这把钥匙相对宽松一些,只要在同一个包内,就可以自由访问;protected这把钥匙不仅本类和子类可以使用,即使子类在不同的包中也能获得一定的访问权限;而public这把钥匙则是最开放的,无论跨类还是跨包,都畅通无阻。

当我们把@Transactional注解这颗 "魔法宝石" 镶嵌在非public方法(比如private修饰的方法)上时,就会发现它的魔法突然失灵了,事务无法生效。这背后的原因,要从 Spring 的事务管理机制说起。Spring 在扫描带有@Transactional注解的方法时,就像一个严格的审查官,默认只对public方法感兴趣。这是因为 Spring 的事务管理底层依赖于 AOP(面向切面编程),而 AOP 在生成代理对象时,对于非public方法的处理比较特殊,无法像public方法那样正常地添加事务增强逻辑,所以事务就失效了 。从源码的角度来看,在AbstractFallbackTransactionAttributeSource类中的computeTransactionAttribute方法里,有这样一段代码:

java 复制代码
if (allowPublicMethodsOnly() &&!Modifier.isPublic(method.getModifiers())) {
    return null;
}

这段代码清晰地表明,如果方法不是public的,Spring 就会直接返回null,不会为该方法创建事务相关的属性,事务自然也就无法生效了。

(二)final 修饰符的 "坑"

在 Java 中,final关键字就像是一个 "紧箍咒",被它修饰的类无法被继承,被它修饰的方法无法被重写,被它修饰的变量也不能被重新赋值。当我们把事务方法定义成final时,就会掉进事务失效的 "坑" 里。这是因为 Spring 事务的底层实现,是基于 AOP 的动态代理机制。在运行时,Spring 会为被@Transactional注解的类创建一个代理对象,这个代理对象就像是一个 "事务管家",在方法执行前后,悄悄地插入事务开启、提交和回滚等逻辑。

但是,当方法被final修饰时,情况就变得棘手了。对于基于类的 CGLIB 代理来说,它是通过生成子类的方式来实现代理功能的,而final修饰的方法无法被重写,就像给方法上了一把坚固的锁,CGLIB 无法在子类中对其进行代理增强;对于基于接口的 JDK 动态代理,final修饰的方法同样无法被代理,因为 JDK 动态代理是基于接口实现的,final方法不符合代理的条件。所以,一旦事务方法被final修饰,Spring 就无法为其添加事务管理的功能,事务也就失效了。

(三)内部方法调用的 "坑"

在实际开发中,我们可能会遇到这样的场景:在同一个类中,一个方法内部直接调用另一个带有@Transactional注解的事务方法,结果却发现事务并没有按照预期生效。比如在一个UserService类中:

java 复制代码
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
        this.updateUserStatus(user.getId());// 这里直接调用本类的方法
    }

    @Transactional
    public void updateUserStatus(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.setStatus("ACTIVE");
        userRepository.save(user);
        throw new RuntimeException("更新状态失败");
    }
}

按照我们的预期,当updateUserStatus方法抛出异常时,整个事务应该回滚,包括createUser方法中保存用户的操作。但实际情况却是,createUser方法的事务不会回滚,updateUserStatus方法的事务也没有生效,导致数据不一致。这是因为在同一个类中,方法之间的内部调用是通过this关键字来实现的,而this指向的是当前类的实例对象,并不是 Spring 生成的代理对象。Spring 的事务管理是基于 AOP 实现的,只有通过代理对象调用方法时,事务增强逻辑才会生效。所以,这种内部调用的方式会绕过代理对象,使得事务无法生效。

为了解决这个问题,有以下几种方法:

  1. 自我注入(Self-Injection) :将Service类注入到自身,然后通过注入的代理对象调用方法。如下代码:
java 复制代码
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private UserService self; // 自我注入

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
        self.updateUserStatus(user.getId());// 通过代理对象调用
    }

    @Transactional
    public void updateUserStatus(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.setStatus("ACTIVE");
        userRepository.save(user);
        throw new RuntimeException("更新状态失败");
    }
}
  1. 使用 AopContext 获取当前代理对象 :通过AopContext.currentProxy()获取当前代理对象,然后调用方法。使用这种方法需要在启动类上开启@EnableAspectJAutoProxy(exposeProxy = true)。代码如下:
java 复制代码
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
        ((UserService) AopContext.currentProxy()).updateUserStatus(user.getId());// 通过代理对象调用
    }

    @Transactional
    public void updateUserStatus(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.setStatus("ACTIVE");
        userRepository.save(user);
        throw new RuntimeException("更新状态失败");
    }
}
  1. 将方法拆分到另一个 Service 类中 :将需要事务管理的方法放到另一个Service类中,然后通过依赖注入调用。示例如下:
java 复制代码
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private UserStatusService userStatusService;

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
        userStatusService.updateUserStatus(user.getId());// 通过另一个Service调用
    }
}

@Service
public class UserStatusService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void updateUserStatus(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.setStatus("ACTIVE");
        userRepository.save(user);
        throw new RuntimeException("更新状态失败");
    }
}

其他不容忽视的潜在问题

(一)异步调用带来的麻烦

在追求高性能和高并发的大厂场景中,异步调用就像是一位 "救星",常常被用来提升系统的响应速度和吞吐量。比如在一个电商系统中,当用户下单成功后,需要发送订单确认短信和邮件通知用户,这些操作比较耗时,如果放在主线程中执行,会导致用户等待时间过长,影响用户体验。这时候,就可以将发送短信和邮件的方法标记为异步方法,使用@Async注解来实现,这样主线程就可以快速返回,提高系统的响应性能 。

但如果在异步方法上使用@Transactional注解,就会出现事务失效的问题。这是因为异步方法是在一个新的线程中执行的,而 Spring 的事务管理是基于线程绑定的,不同的线程拥有不同的事务上下文。当在主线程中调用异步方法时,异步方法所在的新线程并没有获取到主线程的事务上下文,就像一个 "外来者",无法融入主线程的事务体系中,所以事务注解会失效。例如:

java 复制代码
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private SmsService smsService;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        smsService.sendSmsAsync(order.getUserId(), "订单创建成功");// 异步发送短信
    }
}

@Service
public class SmsService {
    @Async
    @Transactional
    public void sendSmsAsync(Long userId, String message) {
        // 模拟发送短信操作
        // 这里可能会抛出异常
        throw new RuntimeException("短信发送失败");
    }
}

在上述代码中,sendSmsAsync方法是异步方法,并且加上了@Transactional注解,当该方法抛出异常时,createOrder方法中的事务并不会回滚,因为异步方法的事务和主线程的事务是相互独立的,无法实现事务的统一管理 。

(二)子类重写方法的隐患

在面向对象编程的世界里,继承是一个非常强大的特性,它允许子类继承父类的属性和方法,并且可以根据自身需求对父类的方法进行重写。当涉及到事务管理时,子类重写父类带有@Transactional注解的方法,情况就变得复杂起来了。

假设有一个父类BaseService,其中有一个方法doBusiness加上了@Transactional注解:

java 复制代码
@Service
public class BaseService {
    @Transactional
    public void doBusiness() {
        // 业务逻辑
    }
}

然后有一个子类SubService继承了BaseService并重写了doBusiness方法:

java 复制代码
@Service
public class SubService extends BaseService {
    @Override
    public void doBusiness() {
        // 子类重写的业务逻辑
    }
}

这时候,如果子类SubService没有在重写的doBusiness方法上添加@Transactional注解,那么父类的注解是否会生效呢?答案是不一定,这取决于 Spring 的事务代理方式。如果 Spring 使用的是基于接口的 JDK 动态代理,那么只有当子类实现了父类的接口时,父类的注解才会生效;如果使用的是基于类的 CGLIB 代理,子类重写方法时,如果方法不是final的,父类的注解可能会生效,但如果子类的方法访问修饰符比父类更严格,比如父类是public,子类是protected,那么注解会失效 。

为了避免这种不确定性带来的问题,最好在子类重写的方法上显式地加上@Transactional注解,明确指定事务的配置,这样可以保证事务的行为符合我们的预期,就像给事务上了一把 "双保险"。

(三)异常处理的 "暗礁"

在编写代码时,异常处理是一个非常重要的环节,它就像是程序的 "安全网",可以防止程序因为异常而崩溃。在使用@Transactional注解进行事务管理时,异常处理也有着一些容易被忽视的 "暗礁"。

@Transactional注解默认情况下只会回滚RuntimeException及其子类(即未检查异常),对于Checked异常(即受检查异常),如果没有被捕获,事务不会自动回滚。比如在一个保存用户信息的方法中:

java 复制代码
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void saveUser(User user) throws IOException {
        userRepository.save(user);
        // 模拟一个受检查异常
        throw new IOException("保存用户信息失败");
    }
}

在上述代码中,saveUser方法抛出了IOException,这是一个Checked异常,由于@Transactional注解默认不会回滚Checked异常,所以即使抛出了异常,事务也不会回滚,userRepository.save(user)的操作可能已经提交到数据库,导致数据不一致。

为了解决这个问题,可以在@Transactional注解中显式指定需要回滚的异常类型,比如@Transactional(rollbackFor = Exception.class),这样就可以让事务回滚所有类型的异常;或者在方法内部捕获Checked异常,然后手动抛出RuntimeException,将其转化为未检查异常,从而触发事务回滚 。

大厂的替代方案思路

既然@Transactional注解存在这么多问题,那大厂在实际开发中是如何进行事务管理的呢?其实,大厂更倾向于使用编程式事务管理,结合实际场景来灵活地控制事务。

编程式事务管理就像是一位 "精细的工匠",开发者可以通过手动编写代码,精确地控制事务的开启、提交和回滚,就像工匠精心雕琢每一个细节一样。在 Spring 框架中,提供了TransactionTemplate类来支持编程式事务管理。比如在一个电商系统中,处理订单的场景下,创建订单主记录、插入订单明细和扣减商品库存这三个操作必须保证原子性,使用编程式事务管理可以这样实现:

java 复制代码
@Service
public class OrderService {
    @Autowired
    private TransactionTemplate transactionTemplate;
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private OrderItemMapper orderItemMapper;
    @Autowired
    private ProductMapper productMapper;

    public Boolean createOrder(OrderCreateDTO dto) {
        // 参数校验等非事务核心逻辑在事务外执行
        if (dto.getUserId() == null || CollectionUtils.isEmpty(dto.getItems())) {
            throw new IllegalArgumentException("参数错误:用户ID或订单项不能为空");
        }
        return transactionTemplate.execute(status -> {
            try {
                // 创建订单主记录
                Order order = new Order();
                order.setUserId(dto.getUserId());
                order.setTotalAmount(calculateTotal(dto.getItems()));
                order.setStatus(OrderStatus.PENDING);
                orderMapper.insert(order);
                // 创建订单明细
                for (OrderItemDTO item : dto.getItems()) {
                    OrderItem orderItem = new OrderItem();
                    orderItem.setOrderId(order.getId());
                    orderItem.setProductId(item.getProductId());
                    orderItem.setQuantity(item.getQuantity());
                    orderItem.setPrice(item.getPrice());
                    orderItemMapper.insert(orderItem);
                    // 扣减库存,库存不足时抛出异常触发回滚
                    int rows = productMapper.reduceStock(item.getProductId(), item.getQuantity());
                    if (rows == 0) {
                        throw new RuntimeException("商品[" + item.getProductId() + "]库存不足");
                    }
                }
                return true; // 全部成功,自动提交事务
            } catch (Exception e) {
                // 发生异常时,事务自动回滚
                log.error("创建订单失败", e);
                return false;
            }
        });
    }
    // 计算订单总金额等非事务核心逻辑在事务外执行
    private BigDecimal calculateTotal(List<OrderItemDTO> items) {
        return items.stream()
               .map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity())))
               .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

在上述代码中,通过TransactionTemplateexecute方法来定义事务的边界,将创建订单的核心业务逻辑放在execute方法的回调函数中。在回调函数内部,先执行创建订单主记录的操作,然后循环插入订单明细并扣减库存,如果在任何一步出现异常,事务会自动回滚。而参数校验和计算订单总金额等非核心逻辑放在事务外执行,这样可以减少事务的执行时间,降低数据库锁的占用时间,提高系统的性能 。

相比于@Transactional注解,编程式事务管理具有以下优势:

  1. 事务范围精确控制:可以在方法内部任意定义事务的边界,将非事务核心逻辑排除在事务之外,减少事务对资源的占用时间,提高系统的并发性能 。

  2. 支持动态业务逻辑 :在事务执行过程中,可以根据业务条件动态地决定是否提交或回滚事务,比如在上述扣减库存的例子中,根据库存扣减的结果来决定是否回滚事务,而@Transactional注解是基于方法级别的声明式事务管理,无法在方法内部进行如此灵活的控制 。

  3. 便于调试和维护 :由于事务管理的代码是显式编写的,当出现事务相关的问题时,更容易定位和调试,而@Transactional注解的事务管理逻辑是通过 AOP 动态织入的,调试起来相对困难 。

总结与启示

在 Java 开发的旅程中,@Transactional注解曾经是我们眼中事务管理的得力助手,它以简洁的声明式方式,为我们处理事务提供了极大的便利。然而,通过深入剖析我们发现,在大厂复杂多变的业务场景和对系统性能、稳定性的严苛要求下,它却存在着诸多隐患和局限性,如事务失效的各种场景、异步调用和子类重写方法带来的问题以及异常处理不当导致的事务回滚异常等 。

这也提醒着广大开发者,技术的选择和使用绝不能盲目,每一个技术工具都有它的适用场景和潜在风险。我们需要深入理解事务管理的底层原理,不仅仅满足于表面的使用,更要探究其背后的实现机制。在实际开发中,要根据具体的业务场景,谨慎地选择事务管理的方式。对于简单的业务场景,@Transactional注解或许依然可以胜任;但在复杂的业务逻辑、高并发的场景以及对事务控制要求精细的情况下,编程式事务管理等更灵活的方式可能是更好的选择 。

只有这样,我们才能在开发过程中,避免陷入各种技术陷阱,打造出高性能、高稳定性的系统,为用户提供更加优质的服务。

相关推荐
奋斗小强2 小时前
内存危机突围战:从原理辨析到线上实战,彻底搞懂 OOM 与内存泄漏
后端
小码哥_常2 小时前
Spring Boot接口防抖秘籍:告别“手抖”,守护数据一致性
后端
心之语歌2 小时前
基于注解+拦截器的API动态路由实现方案
java·后端
None3212 小时前
【NestJs】基于Redlock装饰器分布式锁设计与实现
后端·node.js
初次攀爬者2 小时前
Kafka + KRaft模式架构基础介绍
后端·kafka
洛森唛2 小时前
Elasticsearch DSL 查询语法大全:从入门到精通
后端·elasticsearch
拳打南山敬老院3 小时前
Context 不是压缩出来的,而是设计出来的
前端·后端·aigc
初次攀爬者3 小时前
Kafka + ZooKeeper架构基础介绍
后端·zookeeper·kafka
LucianaiB3 小时前
Openclaw 安装使用保姆级教程(最新版)
后端