Skip to content

Spring AOP

AOP 负责处理横切逻辑。横切逻辑是指那些不属于某个业务动作本身,却在很多业务动作前后都要重复发生的处理,例如事务、日志、审计、权限、限流、幂等、性能统计。

Spring AOP 的实现方式是代理。调用方拿到的是代理对象,代理对象先执行通用逻辑,再把调用交给真实业务对象。可以把它理解成进入方法前的一道门禁:该检查的检查,该记录的记录,该开启事务的开启事务,然后才放行到真正的方法。

Spring AOP 与事务总结图

总结图把事务失效现象归到代理入口、Advisor 匹配、线程资源和异常规则四类证据。判断事务结果之前,应先证明调用确实进入了代理和 TransactionInterceptor。

Spring AOP 代理调用路径

配套视频:EP17:Advisor 怎样匹配 Bean 并生成代理 解释代理形成;EP18:@Transactional 怎样提交或回滚 解释一次事务调用。

详细位置

  • 本页说明 AOP 的适用边界、代理机制、事务失效、切点设计和排查方式。
  • Bean 创建与注入见 IoC 容器
  • HTTP 请求进入 Service 前的链路见 Spring MVC
  • 自动配置与启动装配见 Spring Boot

适用边界

适合 AOP 的逻辑通常有三个特征:

  • 重复出现:很多方法都需要同类处理。
  • 与业务主线可分离:去掉后,业务流程仍然能被清楚描述。
  • 触发规则稳定:可以通过注解、包路径、类名或方法签名准确识别。

不适合 AOP 的逻辑:

  • 关键业务分支,例如订单是否可取消、库存是否可扣减。
  • 只服务于一两个方法的普通步骤。
  • 会改变调用者对方法语义理解的隐藏逻辑。
  • 只能依赖脆弱命名规则匹配的方法。

判断方式:如果这段逻辑写成 AOP 后,读业务方法的人会误判业务结果,就不应该用 AOP。

基本术语

术语含义直观理解
Aspect切面一组横切逻辑
Pointcut切点哪些方法会被拦住
Advice通知拦住之后执行什么
Target目标对象真正处理业务的对象
Proxy代理对象调用方实际访问的外层对象
Join Point连接点Spring AOP 中主要是方法执行点

业务开发中最常见的是“用注解标记方法,再由切面处理”。例如某个方法需要审计,就加审计注解;某个方法需要幂等,就加幂等注解。注解是业务代码和横切逻辑之间的契约。

代理机制

代理机制的关键不是“JDK 动态代理还是 CGLIB”,而是“调用有没有经过代理对象”。

会经过代理的调用:

  • Controller 调用容器注入的 Service。
  • 一个 Service 调用另一个容器注入的 Service。
  • 外部调用从 Spring 容器拿到的 Bean。

不会经过代理的调用:

  • 对象内部使用 this 调用自己的另一个方法。
  • 手动 new 出来的对象。
  • 私有方法、构造方法这类不是外部代理入口的方法。

这就是很多 @Transactional、缓存注解、自定义切面失效的根本原因:注解在方法上,但调用没有走到代理门口。

代理怎样形成

Bean 创建接近完成时,AbstractAutoProxyCreator 会检查当前 Bean 是否匹配 Advisor。匹配成功后,它把目标对象、拦截器链和代理策略交给 ProxyFactory,最终返回代理对象放入单例池。后续其他 Bean 注入到的通常是这个代理,而不是最初实例化的对象。

Spring AOP 代理创建流程

方式常见选择条件需要注意
JDK 动态代理目标对象通过接口暴露能力代理类型面向接口,直接按实现类取对象可能产生误解
CGLIB 类代理没有合适接口或明确使用类代理final 类或 final 方法不能被子类覆盖增强

选择哪一种代理不是排查的第一步。第一步仍然是确认调用方拿到的对象是否为代理,以及这次调用是否从代理外部进入。

事务与 AOP

@Transactional 可以看作 Spring 提供的内置切面。它在方法调用前开启事务,在方法正常返回后提交事务,在异常符合回滚规则时回滚事务。

事务失效常见原因:

现象原因处理方式
同类内部调用事务方法不生效this 调用绕过代理把事务边界放到外部入口,或拆到另一个 Service
异常发生但没有回滚异常被捕获后吞掉记录后继续抛出,或明确设置回滚规则
手动创建对象后事务不生效对象不是 Spring Bean由容器注入对象
方法不可见或被限制代理无法作为外部入口增强把事务放在清晰的 public 应用服务方法上

事务边界通常放在应用服务层,而不是 Controller 或 Repository。Controller 太靠近协议,Repository 太靠近数据访问细节;Service 更适合表达“一次业务操作要保持一致”。

下面的写法看起来给 createOrder() 加了事务,但 placeOrder() 通过 this 直接调用目标方法,事务拦截器没有机会介入:

java
@Service
public class OrderService {
    public void placeOrder(OrderCommand command) {
        this.createOrder(command); // 绕过代理
    }

    @Transactional
    public void createOrder(OrderCommand command) {
        // 写订单、扣库存
    }
}

更清晰的设计是把事务放到外部可见的应用服务入口,调用方直接调用代理公开的方法;如果两个步骤确实需要不同事务边界,再拆成两个职责明确的 Bean。

事务内部发生了什么

TransactionInterceptor 在进入业务方法前取得 TransactionAttribute,选择 PlatformTransactionManager,并根据传播行为决定新建事务、加入现有事务或挂起现有事务。数据库事务还会把连接等资源绑定到当前线程,使同一调用链中的数据访问共享事务资源。

传播行为当前已有事务当前没有事务常见用途
REQUIRED加入新建默认业务事务
REQUIRES_NEW挂起原事务并新建新建独立审计、必须单独提交的动作
SUPPORTS加入非事务执行可选事务的只读查询
MANDATORY加入抛出异常强制要求上层提供事务

传播行为描述的是调用边界,不是数据库隔离级别。隔离级别处理并发读写可见性,传播行为处理方法调用时如何使用事务,两者不要混用。

一次事务调用的完整执行过程

当 Controller 调用 orderService.create() 时,首先进入代理而不是目标对象。代理按顺序执行 Advisor 对应的拦截器,TransactionInterceptor 从方法、目标类和注解中解析 TransactionAttribute,再根据限定符或默认规则选择 PlatformTransactionManager。对于 JDBC,事务管理器从 DataSource 取得连接,关闭自动提交,并把连接持有者绑定到当前线程。

之后目标方法调用 Repository。Repository 再通过 Spring 的数据访问设施取得连接时,会复用当前线程已经绑定的资源,而不是另开一个与事务无关的连接。业务方法正常返回,拦截器提交物理事务并解绑、释放资源;异常符合回滚规则时则回滚。@Transactional 能覆盖多次数据库操作,不是因为注解传播到了 Repository,而是因为同一调用线程上的数据访问共享了事务资源。

这也给出了三个清晰边界。异步线程不会天然继承原线程的事务;跨 HTTP 或消息调用的远端服务不会加入本地数据库事务;手动使用不受 Spring 管理的数据源连接,也不会自动复用线程绑定资源。声明式事务解决的是一个进程内、一个事务管理器所管理资源的调用边界,不是分布式一致性的通用答案。

REQUIRED 内层失败为什么可能在外层提交时报错

假设 OrderService.place() 开启 REQUIRED 事务,并调用同样使用 REQUIREDInventoryService.deduct()。内层加入的是同一个物理事务。如果库存更新抛出运行时异常,事务会被标记为 rollback-only。即使外层捕获异常并继续返回,外层最终请求提交时也不能把一个已标记回滚的事务提交,于是会得到 UnexpectedRollbackException

java
@Transactional
public void place(Order order) {
    orderRepository.save(order);
    try {
        inventoryService.deduct(order.items());
    } catch (InsufficientStockException ex) {
        log.warn("deduct failed", ex); // 捕获异常并不会清除 rollback-only
    }
}

正确处理取决于业务语义:订单和库存必须原子成功,就让异常继续传播并整体回滚;允许库存失败但订单保留,就需要重新设计状态机和补偿,而不是简单吞异常;确实需要独立提交的审计可使用独立 Bean 上的 REQUIRES_NEW,但要考虑额外连接和内外事务提交顺序。传播行为不是“遇到异常换个注解”的修补工具,它表达的是业务动作之间是否共享同一个提交命运。

回滚规则不是“发生异常就回滚”

默认声明式事务通常对 RuntimeExceptionError 回滚,对受检异常不自动回滚。更关键的是,事务拦截器只能处理从代理调用中传播出来的异常。业务方法在内部捕获异常并正常返回,代理看到的就是成功;异常在事务方法提交后才发生,例如消息异步发送失败,也不会倒推已经提交的数据库事务。

设计事务方法时,应同时写清数据库修改范围、异常传播方式、外部副作用和重试语义。把远程支付、发消息和数据库写入全部包进长事务,既不能让远端操作参与本地回滚,还会长时间占用连接。常见做法是先在短事务中保存业务状态和事件记录,再由事务外的可靠投递机制处理远端副作用。

切点设计

切点设计的目标是“准”。过宽会误伤,过窄会漏掉。

常见方式:

方式适合场景风险
按注解匹配审计、幂等、操作日志、业务开关需要开发者显式标记
按包路径匹配某一层统一治理,例如 Service 层日志包结构变动会影响范围
按方法签名匹配命名规范非常稳定的项目命名变化可能误伤或漏掉
组合切点需要精确限定范围表达式复杂后可读性下降

优先使用注解表达业务意图。比如“这个方法需要审计”比“所有 save* 方法都审计”更稳定。

通知类型

通知执行时机适合做什么
前置通知方法执行前权限检查、参数快速校验
后置通知方法结束后,无论成功失败清理上下文
返回通知方法正常返回后成功审计、成功指标
异常通知方法抛出异常后异常统计、失败审计
环绕通知包住整个调用事务、耗时、限流、幂等

能用更窄通知时,不要默认使用环绕通知。环绕通知能力最大,也最容易隐藏调用行为。

工程场景

操作审计

审计记录“谁在什么时候做了什么”。它适合 AOP,因为审计不应混进每个业务方法的主流程。业务方法只标记需要审计,切面统一读取用户、动作、结果和异常。

需要注意:

  • 审计失败是否影响业务成功,要有明确规则。
  • 审计内容不要记录敏感明文,例如密码、完整 Token。
  • 审计切点应尽量用注解,不要靠方法名猜测。

幂等控制

幂等控制用于防止重复提交。它适合 AOP 的前提是 Key 规则清晰、失败处理清晰、重复请求返回策略清晰。

需要注意:

  • Key 应来自业务唯一信息,例如订单号、请求号、用户 ID 和动作组合。
  • 占位或锁必须有过期时间。
  • 方法失败后是否释放 Key,要按业务语义决定。
  • 幂等不是简单加锁,它影响用户重复请求的最终语义。

日志与耗时

日志和耗时统计适合 AOP,但应该克制:

  • 不要打印大对象和敏感字段。
  • 不要在高频方法上做复杂序列化。
  • 日志要包含可关联的请求 ID 或业务 ID。
  • 耗时统计应能区分接口耗时、数据库耗时、外部调用耗时。

排查路径

注解不生效

按顺序检查:

  1. 目标类是否是 Spring Bean。
  2. 调用方拿到的是否是代理对象。
  3. 调用是否从外部进入,而不是 this 内部调用。
  4. 切点是否能匹配方法。
  5. 方法可见性和类结构是否适合代理。
  6. 异常是否按预期继续传播。

切面执行顺序不对

多个切面同时命中时,应显式设置顺序。通常权限、限流、幂等这类“可能拒绝调用”的逻辑更靠前;事务包住需要一致性的业务操作;日志和指标负责记录结果。

性能异常

检查切点是否过宽,是否在高频方法中做了参数深拷贝、JSON 序列化、远程写日志等重操作。AOP 越靠底层、命中越频繁,越需要控制成本。

源码与运行验证

排查代理问题时,可以按下面的证据顺序推进:

  1. 打印 bean.getClass(),确认实际类型是否包含代理特征。
  2. 使用 AopUtils.isAopProxy(bean)isJdkDynamicProxy()isCglibProxy() 判断代理类型。
  3. AbstractAutoProxyCreator.wrapIfNecessary() 查看 Bean 是否匹配 Advisor。
  4. TransactionInterceptor.invoke() 查看事务调用是否真正进入拦截器。
  5. 检查日志中的事务开始、提交或回滚是否与业务异常一致。

不要用“方法上有注解”作为事务已生效的证据。代理类型、拦截器断点、数据库结果和事务日志才能形成完整闭环。

总结

AOP 的核心是代理边界。横切逻辑应该像方法外层的统一检查站,而不是隐藏业务流程的暗门。排查时先确认对象是否由容器管理,再确认调用是否经过代理,最后检查切点、顺序、异常传播和性能成本。

别急,先让缓存热一下。