SPRING AOP · @TRANSACTIONAL · JDK 8 / 17

你调用的,真是它本人吗?

Spring 递给你的 Bean,很多时候是个替身。日志、权限、缓存、事务,都是这个替身顺手替你做的。

经纪人Proxy

站在真身前面。所有调用先到他这儿,他照着规矩加点料,再转给真身。

Spring 启动时生成 · CGLIB 或 JDK 代理
规矩本Aspect

只写两件事:管哪些方法(切点),什么时候做什么(通知)。经纪人照着办。

一个 @Aspect 类就是一本
草稿本@Transactional

方法里的改动先写在同一张草稿上。全部成功一起生效,出错整张撕掉。

Spring 自带的一条规矩
配角 真身 Target,你写的那个类 连接 Connection,草稿本其实是一个关掉自动提交的连接 事务管理器 TransactionManager,管借连接、提交、回滚

AOP 的六个词

听着玄。换成大白话:经纪人拿着规矩本,在合适的时刻插一手。

切面Aspect

规矩本。一个 @Aspect 类就是一本。

切点Pointcut

名单:管哪些方法。execution(* com.shop.service..*.*(..))

通知Advice

什么时候做什么。一共五种,见右图。

连接点JoinPoint

能插手的时刻。Spring AOP 只认“方法被调用”这一种。

代理Proxy

经纪人。真正接电话的那个对象。

织入Weaving

把规矩缝到经纪人身上。Spring 在启动时做,AspectJ 在编译时做。

@Around包住全程,还能决定要不要叫醒真身(不调 proceed() 就不调)
@Before进门前
真身方法 placeOrder()
@AfterReturning正常返回后
@AfterThrowing抛异常后
@After不管成败都执行,像 finally

跟着一次调用走一趟

选一个剧情,点「下一步」。留意数据库里那几行:是草稿、已提交、已撕掉,还是不该留下却留下了。

调用方Controller
代理对象 · OrderService$$SpringCGLIB$$0
经纪人Proxy
日志切面@Order(1) 外层
事务@Transactional
真身OrderService
数据库同一个连接
  • 还没写入
1/9
调用方 · Controller · orderService.placeOrder(cmd)
Controller:下单!它手里的 orderService 其实是代理。

    替身什么时候套上

    Bean 出生的流水线上,第 4 站给它穿上外套。之后谁来要这个 Bean,拿到的都是替身。

    1. 1
      实例化new OrderService()
    2. 2
      属性注入@Autowired
    3. 3
      初始化@PostConstruct
    4. 4
      套上替身有规矩点名就生成代理BeanPostProcessor
    5. 5
      进容器单例池里存的是替身
    6. 6
      被注入别人拿到的是替身
    7. 7
      销毁@PreDestroy

    @PostConstruct 里开不了事务

    第 3 站时还没穿外套,而且是 this 调用。启动后要做事,改用 ApplicationRunner,从另一个 Bean 里调。

    怎么确认拿到的是替身

    打印 orderService.getClass(),看到 OrderService$$SpringCGLIB$$0 就是;或者用 AopUtils.isAopProxy(orderService)。

    一次 @Transactional 调用里,草稿本的一生

    从连接池借连接 setAutoCommit(false) 绑到当前线程 执行方法 commit()或rollback() 解绑,还连接 提交后回调

    连接绑在线程上(ThreadLocal)。同一线程里的 Mapper、Repository 自动拿到同一个连接;换个线程就不是了。想在提交成功后再发消息,用 @TransactionalEventListener,它默认在提交后才触发。

    事务的两个旋钮

    第一个旋钮管“草稿本怎么共用”,第二个管“能看到别人多少草稿”。

    传播行为 propagation:A 开着事务,去调另一个 Bean 的 B

    REQUIRED默认

    B 写在 A 的纸上。B 出错,整张撕掉。

    A 就算 catch 住 B 的异常,提交时也会收到 UnexpectedRollbackException。

    REQUIRES_NEW

    B 拿一张新纸,自己提交或回滚,再把 A 的纸拿回来。

    适合“失败也要留下”的记录。会多占一个连接。

    NESTED

    同一张纸,先夹一枚书签(savepoint)。B 出错只撕到书签处。

    JDBC、MyBatis 支持;JPA(Hibernate)不支持。

    SUPPORTS有纸就一起写,没有就不用纸
    NOT_SUPPORTED把纸收起来,不用事务
    MANDATORY必须已经有纸,否则报错
    NEVER有纸就报错

    隔离级别 isolation:能看到别人多少草稿

    脏读看到别人还没提交的草稿
    不可重复读同一行读两次,值变了
    幻读同样的条件查两次,多出几行
    级别脏读不可重复读幻读
    READ_UNCOMMITTED会会会
    READ_COMMITTED防住会会
    REPEATABLE_READ防住防住会(InnoDB 靠 MVCC 和间隙锁挡住了大部分)
    SERIALIZABLE防住防住防住

    默认 Isolation.DEFAULT 就是用数据库自己的:MySQL InnoDB 是 REPEATABLE_READ,PostgreSQL 和 Oracle 是 READ_COMMITTED。其他小旋钮:readOnly = true、timeout = 5(秒)、rollbackFor / noRollbackFor。

    三种替身做法

    Spring AOP 只会前两种。AspectJ 不找替身,直接改你的类。

    对比项 JDK 动态代理 CGLIB 代理 AspectJ 织入
    怎么做造一个实现同样接口的替身造一个子类当替身直接改你类的字节码
    前提必须有接口类和方法不能是 final用 ajc 编译,或加 -javaagent
    什么时候做启动时启动时编译期或类加载期
    谁默认用它纯 Spring,且类有接口时Spring Boot 2.0 起默认;类没有接口时要手动开启
    自己调自己拦不到拦不到拦得到
    private、final、static拦不到拦不到拦得到
    按实现类注入不行,只能按接口可以可以

    你天天在用的替身

    这些注解自己什么也不做,干活的都是替身。

    @Transactional

    开事务,提交或回滚

    @Cacheable

    先查缓存,命中就不叫醒真身

    @Async

    丢进线程池,马上返回

    @PreAuthorize

    调用前查权限(Spring Security)

    @Validated

    放在类上,检查方法参数

    @Retryable

    失败自动重试(Spring Retry)

    你的 @Aspect

    操作日志、限流、分布式锁、数据权限

    @Configuration(例外)

    它本身就是 CGLIB 子类,背后没有另一个真身,所以 @Bean 方法互相调用也拦得到。

    前 7 个有同一个弱点:自己调自己,全部失效。
    自己写一条规矩,再看事务长什么样。@Aspect 需要 aspectjweaver(Boot 里加 AOP starter),@Transactional 不需要。
    // ① 规矩本:给 service 包里的方法计时
    @Slf4j
    @Aspect
    @Component
    @Order(1)                                                  // 数字小 → 在外层
    public class TimingAspect {
    
        @Pointcut("execution(* com.shop.service..*.*(..))")     // 切点:管哪些方法
        void serviceMethods() {}
    
        @Around("serviceMethods()")                             // 通知:怎么管
        public Object time(ProceedingJoinPoint pjp) throws Throwable {
            long start = System.currentTimeMillis();
            try {
                return pjp.proceed();                           // 交给下一层,最终到真身
            } finally {
                log.info("{} 耗时 {}ms", pjp.getSignature().toShortString(),
                         System.currentTimeMillis() - start);
            }
        }
    }
    
    // ② 草稿本:事务也是替身身上的一条规矩
    @Service
    public class OrderService {
        private final OrderMapper orderMapper;
        private final StockMapper stockMapper;
        // 构造器注入省略
    
        @Transactional(rollbackFor = Exception.class)           // 受检异常也回滚
        public Long placeOrder(OrderCmd cmd) {
            orderMapper.insert(cmd.toOrder());                  // 同一个连接
            stockMapper.deduct(cmd.getSkuId(), cmd.getCount()); // 同一个连接
            return cmd.getOrderId();
        }
    }

    JDK 8 和 JDK 17:原理不变,细节变了

    JDK 8 对应 Spring 5 / Boot 2,JDK 17 对应 Spring 6+ / Boot 3+。替身还是那个替身。

    Spring 5 · Boot 2

    JDK 8+
    • @Transactional 只认 public 方法,加在其他方法上静默失效
    • 代理类名长这样:OrderService$$EnhancerBySpringCGLIB$$9f8e7d6c
    • @PostConstruct 在 javax.annotation 包。JDK 11 起它被移出 JDK,Boot 2 已帮你带上依赖
    • 5.2.7 起,同一个切面里的 @After 改到 @AfterReturning / @AfterThrowing 之后执行

    Spring 6+ · Boot 3+

    JDK 17+
    • protected、包可见的方法也能加 @Transactional(CGLIB 代理时);private 依然不行
    • 代理类名变短:OrderService$$SpringCGLIB$$0
    • 包名换成 jakarta.annotation.PostConstruct;JTA 的事务注解变成 jakarta.transaction.Transactional,日常仍推荐 Spring 自己的 @Transactional
    • 打 GraalVM 原生镜像时,代理类在构建期提前生成(AOT)
    没变的:Boot 默认 CGLIB只拦方法调用自己调自己失效默认只对 RuntimeException 回滚传播行为和隔离级别

    @Transactional 失效的 10 种情况

    大部分都能归到一句话:调用没经过替身,或者替身不知道出了错。

    自己调自己

    this.placeOrder() 不经过替身。把方法挪到另一个 Bean,或注入自己(加 @Lazy)再通过它调用。

    private、final、static 方法

    替身拦不到。Spring 5 里非 public 的方法也不行。

    不是 Spring Bean

    自己 new 出来的对象没有替身。

    异常被 catch 吞了

    替身以为一切正常,照样提交。要么继续往外抛,要么调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

    抛的是受检异常

    默认照样提交。写上 rollbackFor = Exception.class。

    内层失败,外层 catch

    两层都是 REQUIRED 时,草稿已被标成“只能撕”,外层提交时抛 UnexpectedRollbackException。

    换了线程

    @Async、new Thread、parallelStream 里写库,拿到的不是同一个连接,也就不在同一个事务里。

    锁放在事务里面

    解锁早于提交,别人会读到旧数据。给锁切面加更小的 @Order,让它包在事务外面。

    事务里调远程接口、发消息

    连接被长时间占着;消息发出去了,事务却回滚了。用 @TransactionalEventListener 等提交后再发。

    数据库不支持事务

    MySQL 的 MyISAM 表没有事务,换成 InnoDB。