主题
界面
主题
《Spring:IOC 与依赖注入》 解决了"对象怎么装配",本节解决"重复的逻辑怎么消除"。
日志、耗时统计、权限校验、事务——这些逻辑不属于任何单个业务方法,但几乎每个业务方法都需要。AOP 的作用就是把它们从业务代码里抽出来。
而它最有价值的实战知识是:什么情况下 AOP 会失效——尤其是"同类内部调用"这个坑,它同时也是 @Transactional 失效的头号原因。
完成本节后,你应该能够:
@Around 实现耗时统计;@Transactional 失效的四种常见场景;@Configuration + @Bean 装配吗?(《IOC 与依赖注入》)看一段"日志代码淹没业务逻辑"的例子:
public void place(Order order) {
long start = System.currentTimeMillis();
log.info("开始下单:{}", order.orderNo()); // ← 日志
try {
if (!permissionChecker.hasPermission(order.userId())) { // ← 权限
throw new ForbiddenException();
}
transactionTemplate.execute(status -> { // ← 事务
repository.save(order);
return null;
});
log.info("下单成功:{},耗时 {} ms", order.orderNo(), System.currentTimeMillis() - start);
} catch (Exception exception) {
log.error("下单失败:{}", order.orderNo(), exception);
throw exception;
}
}业务逻辑只有 repository.save(order) 一行,其余都是"横切关注点"。
AOP 的写法:
@Transactional
@RequiresPermission
@Timed
public void place(Order order) {
repository.save(order); // 只剩业务逻辑
}同样的能力,四行代码,而且注解可以被复用到所有方法上。
| 术语 | 含义 | 对应代码 |
|---|---|---|
| 切面(Aspect) | 横切关注点的模块化 | @Aspect 标注的类 |
| 连接点(Join Point) | 程序执行中"可以插入切面"的点 | Spring AOP 里只有方法调用 |
| 切点(Pointcut) | "在哪些连接点插入"的表达式 | execution(* ...OrderService.place(..)) |
| 通知(Advice) | "插入后做什么" | @Before / @Around 等 |
| 织入(Weaving) | 把切面"接进"目标对象的过程 | Spring 用运行时动态代理实现 |
Spring AOP 的一个重要限制:连接点只有方法调用(不像 AspectJ 还支持字段访问、构造器调用)。
这带来一个直接影响:你没法用 Spring AOP 拦截"字段赋值"——这也是为什么它够用(业务关注点大多是方法级的)。
| 通知 | 时机 | 能否阻止方法执行 | 能否拿到返回值 |
|---|---|---|---|
@Before | 方法执行前 | ❌ | ❌ |
@AfterReturning | 方法正常返回后 | ❌ | ✅ |
@AfterThrowing | 方法抛异常后 | ❌ | 能拿到异常 |
@After | 方法结束后(无论成功失败) | ❌ | ❌ |
@Around | 包围方法 | ✅(可以不调用 proceed()) | ✅ |
@Around 是功能最强的——它能做其他四种的全部事情,也能改写返回值、吞掉异常、重试。
@Around("execution(* cn.kb.learn.spring.AopLesson$OrderService.audit(..))")
Object aroundAudit(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println(" [@Around] 开始");
Object result = joinPoint.proceed(); // 放行(必须调用,否则业务方法不会执行)
System.out.println(" [@Around] 结束");
return result;
}三个容易踩的点:
proceed() → 业务方法根本不会执行(这是"加了 AOP 后方法没跑"的常见原因);@Around 必须返回 Object,且要 return 结果(否则调用方拿到 null);@Around 里 catch 了异常却不重新抛出,等于吞掉了业务异常。运行时的实测输出:
=== 1. 从容器里拿到的其实是「代理对象」 ===
Bean 的实际类型:cn.kb.learn.spring.AopLesson$OrderService$$SpringCGLIB$$0
可以当作 OrderService 用吗:true
注意类名里的 $$SpringCGLIB$$ —— 业务类被「包」了一层。关键认知:
| 事实 | 说明 |
|---|---|
| 容器里的是代理对象 | 不是原始的 OrderService 实例 |
| 代理继承了原类 | 所以 instanceof OrderService 为 true(能当原类型用) |
类名里带 $$SpringCGLIB$$ | 这就是代理的"指纹"——排查 AOP 问题时的第一眼 |
两种代理方式:
| 方式 | 条件 | 特点 |
|---|---|---|
| JDK 动态代理 | 目标实现了接口 | 基于接口生成代理(Proxy.newProxyInstance) |
| CGLIB | 目标没有接口(或强制使用) | 继承目标类生成子类 |
Spring Boot 2.x 之后的默认:强制使用 CGLIB(spring.aop.proxy-target-class=true)——因为 CGLIB 能代理"没有接口的类",行为更统一。
CGLIB 的两个限制(这是"为什么 final 方法不能加切面"的原因):
| 限制 | 后果 |
|---|---|
| 不能代理 final 类 | 无法继承,代理创建失败 |
| 不能覆盖 final 方法 | 该方法的切面不生效 |
实测输出:
=== 4. 内部调用:切面失效(AOP 最常见的坑)===
[业务] 下单(带审计):ORD-002
[业务] 审计记录已写入:ORD-002
注意上面**没有出现 [@Around 审计]** 的日志。
=== 5. 对照:直接从容器取出代理调用 audit(),切面就生效了 ===
[@Around 审计] 开始,参数=[ORD-002]
[业务] 审计记录已写入:ORD-002
[@Around 审计] 结束,耗时 1586 微秒同一个 audit() 方法,通过内部调用不走切面、通过代理调用走切面。
原因:
void placeWithAudit(String orderNo) {
System.out.println("[业务] 下单(带审计):" + orderNo);
this.audit(orderNo); // ← this 是"原始对象",不是代理
}调用链对比:
外部调用:调用方 → 【代理对象】.placeWithAudit() → 【代理】查切点 → 原始对象.placeWithAudit()
内部调用:原始对象.placeWithAudit() → this.audit() → 原始对象.audit() ← 完全没经过代理this 指向原始对象(代理只是"外面的一层皮"),所以内部调用绕过了代理,也就绕过了所有切面。
三种修复方式:
| 方式 | 做法 | 评价 |
|---|---|---|
| ① 拆到另一个 Bean | 把 audit 移到独立的 AuditService,注入使用 | ✅ 推荐(职责也更清晰) |
| ② 注入自己 | @Autowired private OrderService self; 然后 self.audit(...) | ⚠️ 能工作但有点绕(且要注意循环依赖) |
③ AopContext.currentProxy() | 需要 @EnableAspectJAutoProxy(exposeProxy = true) | ⚠️ 侵入性强 |
同样的坑在 @Transactional 上更危险(见 5.5)。
@Before("execution(* ...OrderService.internal(..))")
void before() { }
private void internal() { } // ✗ 切面不生效原因:Spring AOP 基于代理,private 方法无法被代理覆盖。
注意:protected / 包级方法在 CGLIB 下"可能"能被代理,但行为依赖具体版本与配置——统一用 public 就不必纠结。
public final class OrderService { } // ✗ 无法被 CGLIB 继承 → 切面不生效
public void place() { }
public final void query() { } // ✗ final 方法无法被覆盖 → 该方法切面不生效排查方法:看容器里的 Bean 类名带不带 $$SpringCGLIB$$——不带就说明没被代理。
@Transactional:AOP 最重要的应用 它的原理:
@Transactional
public void place(Order order) {
repository.save(order);
}等价于(简化):
public void place(Order order) {
transactionManager.begin(); // ← 切面在方法前开启事务
try {
repository.save(order); // 业务逻辑
transactionManager.commit(); // ← 方法正常返回则提交
} catch (RuntimeException exception) {
transactionManager.rollback(); // ← 抛异常则回滚
throw exception;
}
}所以 @Transactional 就是一个切面——它失效的原因与 5.4 节完全一样。
五种失效场景:
| # | 场景 | 现象 | 修复 |
|---|---|---|---|
| 1 | 同类内部调用 | 事务不生效(最危险:看起来有事务,实际每个操作各自提交) | 拆到另一个 Bean,或注入自己 |
| 2 | 方法不是 public | 不生效 | 改成 public |
| 3 | 类或方法被 final 修饰 | 不生效 | 去掉 final |
| 4 | 异常被吞掉 | 抛了异常但事务提交了 | 让异常抛出去,或显式 setRollbackOnly() |
| 5 | 抛的是受检异常 | 默认不回滚 | 用 @Transactional(rollbackFor = Exception.class) |
第 1 条为什么最危险:
@Transactional
public void placeOrder(Order order) {
repository.save(order); // ← 期望与下面一起回滚
this.deductStock(order); // ✗ 内部调用:deductStock 上的 @Transactional 不生效
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductStock(Order order) {
stockRepository.decrease(...); // ← 它可能自己提交了,不受外层控制
}结果:外层事务回滚时,扣减的库存不会回滚——数据不一致,而且测试很难发现(因为正常路径都正确)。
第 5 条的细节:
@Transactional
public void place() throws IOException {
repository.save(order);
throw new IOException("IO 错误"); // ✗ 默认不回滚(受检异常)
}原因:Spring 的默认规则是"运行时异常(RuntimeException)与 Error 回滚,受检异常不回滚"。
修复:@Transactional(rollbackFor = Exception.class)。
这条规则的历史原因:Java 的受检异常代表"可预期的业务状况"(如"余额不足"),而运行时异常代表"程序错误"——早期设计者认为前者不该回滚。但在实践中,这几乎总是反直觉的,所以建议一律显式写 rollbackFor = Exception.class。
execution(* cn.kb.learn.spring.OrderService.place(..))
// ↑ 返回类型(* 表示任意)
// ↑ 类与方法的全限定名
// ↑ (..) 表示任意参数常用的几种:
| 表达式 | 含义 |
|---|---|
execution(* com.example.service.*.*(..)) | service 包下所有类的所有方法 |
execution(* com.example..*Service.*(..)) | 任意层级下以 Service 结尾的类 |
execution(public * *(..)) | 所有 public 方法 |
@annotation(org.springframework.transaction.annotation.Transactional) | 所有标注了某注解的方法 ✓ 很实用 |
within(com.example.service..*) | 某包下的所有方法 |
实践建议:用注解切点比用类名匹配更好——它让"要不要加切面"变成每个方法自己的决定:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed { }
@Around("@annotation(cn.kb.learn.spring.Timed)")
Object aroundTimed(ProceedingJoinPoint joinPoint) throws Throwable { ... }
// 使用时只需标注
@Timed
public void place(Order order) { ... }这也是"注解 + 反射 + AOP"三者配合的完整展示(《反射》、《注解》 讲的两块知识在这里合体了)。
@Before("execution(* cn.kb.learn.spring.AopLesson$OrderService.place(..))")
void beforePlace(JoinPoint joinPoint) {
System.out.println("即将调用:" + joinPoint.getSignature().getName()
+ ",参数=" + Arrays.toString(joinPoint.getArgs()));
}@Around("@annotation(Timed)")
Object aroundTimed(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
System.out.println(joinPoint.getSignature().getName()
+ " 耗时 " + (System.nanoTime() - start) / 1_000 + " 微秒");
}
}注意 finally:无论成功还是异常都要记录耗时——这是 @Around 相比 @AfterReturning 的价值。
@Around("...")
Object around(ProceedingJoinPoint joinPoint) {
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
log.error("出错了", throwable);
return null; // ✗ 异常被吞掉
}
}后果:业务异常消失,调用方以为成功了——如果外层有事务,它不会回滚(《事务与隔离级别》)。
修复:记完日志后重新抛出:
} catch (Throwable throwable) {
log.error("出错了", throwable);
throw throwable; // ✓ 或者包装成业务异常再抛
}文件:labs/spring-core/src/main/java/cn/kb/learn/spring/AopLesson.java(节选)
public class AopLesson {
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AopConfig.class)) {
OrderService service = context.getBean(OrderService.class);
System.out.println("=== 1. 从容器里拿到的其实是「代理对象」 ===");
System.out.println(" Bean 的实际类型:" + service.getClass().getName());
System.out.println("=== 2. @Before:外部调用 place(),切面生效 ===");
service.place("ORD-001");
System.out.println("=== 4. 内部调用:切面失效(AOP 最常见的坑)===");
service.placeWithAudit("ORD-002");
System.out.println("=== 5. 对照:直接从容器取出代理调用 audit(),切面就生效了 ===");
service.audit("ORD-002");
}
}
static class OrderService {
void place(String orderNo) {
System.out.println(" [业务] 下单:" + orderNo);
}
/** 内部调用:this.audit(...) 不经过代理,所以 audit 上的切面不会执行 */
void placeWithAudit(String orderNo) {
System.out.println(" [业务] 下单(带审计):" + orderNo);
this.audit(orderNo);
}
void audit(String orderNo) {
System.out.println(" [业务] 审计记录已写入:" + orderNo);
}
}
@Aspect
static class LoggingAspect {
@Before("execution(* cn.kb.learn.spring.AopLesson$OrderService.place(..))")
void beforePlace(JoinPoint joinPoint) {
System.out.println(" [@Before] 即将调用:" + joinPoint.getSignature().getName()
+ ",参数=" + java.util.Arrays.toString(joinPoint.getArgs()));
}
@Around("execution(* cn.kb.learn.spring.AopLesson$OrderService.audit(..))")
Object aroundAudit(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
System.out.println(" [@Around 审计] 开始,参数="
+ java.util.Arrays.toString(joinPoint.getArgs()));
Object result = joinPoint.proceed();
System.out.println(" [@Around 审计] 结束,耗时 "
+ (System.nanoTime() - start) / 1_000 + " 微秒");
return result;
}
}
@Configuration
@EnableAspectJAutoProxy
static class AopConfig {
@Bean OrderService orderService() { return new OrderService(); }
@Bean LoggingAspect loggingAspect() { return new LoggingAspect(); }
}
}运行命令:
cd labs
./mvnw -pl spring-core compile exec:exec -Dexec.mainClass=cn.kb.learn.spring.AopLesson预期输出:
=== 1. 从容器里拿到的其实是「代理对象」 ===
Bean 的实际类型:cn.kb.learn.spring.AopLesson$OrderService$$SpringCGLIB$$0
可以当作 OrderService 用吗:true
=== 2. @Before:外部调用 place(),切面生效 ===
[@Before] 即将调用:place,参数=[ORD-001]
[业务] 下单:ORD-001
=== 3. @AfterReturning:拿到返回值 ===
[@AfterReturning] query 返回:订单详情 ORD-001
调用方拿到的结果:订单详情 ORD-001
=== 4. 内部调用:切面失效(AOP 最常见的坑)===
[业务] 下单(带审计):ORD-002
[业务] 审计记录已写入:ORD-002
注意上面**没有出现 [@Around 审计]** 的日志。
=== 5. 对照:直接从容器取出代理调用 audit(),切面就生效了 ===
[@Around 审计] 开始,参数=[ORD-002]
[业务] 审计记录已写入:ORD-002
[@Around 审计] 结束,耗时 1586 微秒四处值得对照:
| 输出 | 含义 |
|---|---|
...$$SpringCGLIB$$0 | 容器里的是代理对象——类名就是证据 |
第 2 步有 [@Before] 日志 | 外部调用经过代理 → 切面生效 |
第 4 步没有 [@Around] 日志 | 内部调用绕过代理 → 切面失效 |
第 5 步又有 [@Around] 日志 | 同一个方法,换条调用路径就生效了 |
第 4、5 步的对比是本节的核心——同一个 audit() 方法,一次不走切面、一次走切面,差别只在于"调用是否经过代理"。
service.place("ORD-001") 处打断点:观察 service 变量的实际类型是 ...$$SpringCGLIB$$0——这不是 OrderService,而是它的子类;placeWithAudit 内部(this.audit(...) 那一行)打断点:观察 this 的类型是 OrderService 本身(不是代理)——这就是切面失效的直接原因;@Around 通知里打断点:观察 joinPoint.proceed() 前后的调用栈——业务方法是由切面"放行"的;@EnableAspectJAutoProxy 注册了 AnnotationAwareAspectJAutoProxyCreator——它是一个 BeanPostProcessor,在 Bean 初始化后决定"要不要包装成代理"(这是 AOP 生效的机制)。见 5.4、5.5 节。这是本节最重要的错误。
自查方法:凡是 this.xxx() 或直接 xxx() 调用本类另一个"带注解的方法",都要警惕。
@Around 忘了 proceed() @Around("...")
Object around(ProceedingJoinPoint joinPoint) {
System.out.println("开始");
return null; // ✗ 业务方法不会执行
}症状:加了切面后业务方法"不工作了",但没有任何报错。
@Transactional 吞掉异常 @Transactional
public void place() {
try {
repository.save(order);
ThirdPartyClient.call(); // 抛异常
} catch (Exception exception) {
log.error("调用失败", exception); // ✗ 只有日志,没有抛出
}
}后果:异常被吞掉 → 事务认为方法正常返回 → 提交——数据不一致。
修复:throw exception; 或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
见 5.5 节第 5 条。修复:rollbackFor = Exception.class。
@Around("execution(* com.example..*(..))")
Object around(ProceedingJoinPoint joinPoint) throws Throwable {
auditService.saveFullStackTrace(joinPoint); // ✗ 每个方法都写一次数据库
return joinPoint.proceed();
}后果:所有方法都变慢——切面在"每个被匹配的方法"上都执行。
修复:
execution(* com.example..*(..)) 这种"全包"写法);@Aspect
class TransactionAspect { } // 事务切面
@Aspect
class LoggingAspect { } // 日志切面问题:两个切面谁先执行? 如果没有指定顺序,行为不确定——而"日志里有没有记录到事务回滚"取决于顺序。
修复:用 @Order(n) 显式排序(数字小的先执行);@Transactional 的顺序可用 @EnableTransactionManagement(order = ...) 控制。
一条经验:事务切面应该在"最外层"——这样它包裹的范围最大(包括日志)。
目标:把 AOP 的失效场景亲手复现一遍。
步骤:
@Timed 注解 + 切面:统计被标注方法的耗时,用 finally 保证异常时也记录;audit 改成 private,观察切面失效;OrderService 改成 final class,观察容器启动报错(无法生成 CGLIB 代理);AuditService,把 audit 移过去,在 OrderService 里注入它——确认切面生效了;@Order:写两个切面(一个记日志、一个记耗时),不加 @Order 跑一次、加上 @Order 跑一次,对比执行顺序;execution(* com.example.controller..*.*(..)))验收方式:能说出第 3 步两种失效的具体表现、第 4 步的修复为什么有效。
扩展任务:实现一个"幂等切面"——用注解 @Idempotent(key = "#orderNo") 标记方法,切面在方法执行前检查 key 是否处理过(可以用一个内存 Set 模拟),已处理则直接返回。说明这个切面在生产环境应该用什么存储 key。
基础题
代码阅读题
@Service
public class UserService {
@Autowired
private UserRepository repository;
public void register(String username) {
validate(username);
repository.save(new User(username));
}
@Transactional
public void validate(String username) {
if (repository.exists(username)) {
throw new IllegalArgumentException("用户名已存在");
}
}
}validate 上的 @Transactional 生效吗?为什么?编程题
@Audited(action = "...");finally 或 @AfterThrowing),且不能吞掉异常;List 收集审计记录,运行几个方法后打印全部记录;@Audited 的方法"(提示:@annotation(...) 切点)。综合任务
@Transactional 失效场景(含代码示例);提示 1(方向):第 4 题的关键是 register 里调用的是 this.validate(...)。
提示 2(关键点):第 5 题的关键是"检查与插入之间有时间窗口"——这正是 《锁》 讲的竞态条件。
提示 3(接近答案):第 7 题的"最危险"是部分失效——即"有的事务生效、有的不生效",因为它在测试时经常表现为"正常路径都是对的"。
推论:你不能用 Spring AOP 拦截"字段赋值"。如果真有这种需求(如"记录字段变更历史"),只能改成"通过方法修改"或用 JPA 的实体监听器。
$$SpringCGLIB$$(或 $Proxy 开头,那是 JDK 动态代理)。排查技巧:service.getClass().getName() 一眼就能看出有没有被代理——这是排查 AOP 问题的第一步。
this 指向的是"原始对象",不是代理对象。外部调用:调用方 → 【代理】→ 查切点 → 执行通知 → 原始对象的方法
内部调用:原始对象的方法 → this.另一个方法() ← 完全不经过代理代理只是"外面的一层皮":一旦进入原始对象内部,this 就再也不会经过代理了。
注意这一点与语言无关——任何基于代理的 AOP 都有这个限制(Python 的装饰器、Java 的 Spring AOP 都类似)。这是"代理"这种实现方式的固有属性。
第 4 题:不生效。
register 里调用的是 this.validate(username)——内部调用绕过代理,所以 validate 上的 @Transactional 不会生效。
而且这里还有个误导:register 本身没有 @Transactional,所以整个 register 完全没有事务——两次数据库操作(exists 查询 + save 插入)各自独立提交。
第 5 题:更严重的问题是"检查-插入"的竞态条件。
public void register(String username) {
validate(username); // 检查:用户名是否存在
repository.save(new User(username)); // 插入
}并发下的执行序列:
时刻 1 线程 A:exists("alice") → false
时刻 2 线程 B:exists("alice") → false ← 两个线程都判断"不存在"
时刻 3 线程 A:save("alice") → 成功
时刻 4 线程 B:save("alice") → 也成功 ← 重复用户名!这个问题的本质与 《锁机制:行锁、表锁与乐观悲观锁》 的"超卖"完全一样:"先检查后执行"在并发下不安全。
正确的解法(三层):
| 层面 | 做法 |
|---|---|
| 数据库层(必须) | 给 username 加唯一约束:UNIQUE (username)——这是最后一道防线 |
| 应用层 | 捕获唯一约束冲突异常,转成"用户名已存在"的友好提示 |
| 事务 | 用 @Transactional 包住(注意要从外部调用,或拆到另一个 Bean) |
为什么唯一约束才是关键:应用层的 exists 检查只是"友好提示",它挡不住并发——只有数据库的唯一约束能真正保证不重复(《表设计与约束》 讲过"约束是最后一道防线")。
这段代码的教训:"加个事务"解决不了所有并发问题——事务保证的是原子性,而唯一性要靠约束。
package cn.kb.learn.spring;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class AuditLesson {
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface Audited {
String action();
}
record AuditRecord(String action, String method, String args, String result,
long costMicros, boolean success) { }
static class AuditLog {
private final List<AuditRecord> records = new ArrayList<>();
void add(AuditRecord record) {
records.add(record);
}
void printAll() {
System.out.println("审计记录 " + records.size() + " 条:");
for (AuditRecord record : records) {
System.out.printf(" [%s] %s(%s) → %s,耗时 %d 微秒,成功=%s%n",
record.action(), record.method(), record.args(),
record.result(), record.costMicros(), record.success());
}
}
}
static class OrderService {
@Audited(action = "下单")
public String place(String orderNo) {
return "订单已创建 " + orderNo;
}
@Audited(action = "取消订单")
public String cancel(String orderNo) {
throw new IllegalStateException("订单状态不允许取消:" + orderNo);
}
/** 没有 @Audited,切面不会记录 */
public String query(String orderNo) {
return "订单详情 " + orderNo;
}
}
@Aspect
static class AuditAspect {
private final AuditLog auditLog;
AuditAspect(AuditLog auditLog) {
this.auditLog = auditLog;
}
@Around("@annotation(audited)")
Object audit(ProceedingJoinPoint joinPoint, Audited audited) throws Throwable {
long start = System.nanoTime();
String result = null;
boolean success = false;
try {
Object returned = joinPoint.proceed();
result = String.valueOf(returned);
success = true;
return returned;
} catch (Throwable throwable) {
result = throwable.getClass().getSimpleName() + ": " + throwable.getMessage();
throw throwable; // 关键:不吞异常
} finally {
auditLog.add(new AuditRecord(
audited.action(),
joinPoint.getSignature().getName(),
Arrays.toString(joinPoint.getArgs()),
result,
(System.nanoTime() - start) / 1_000,
success));
}
}
}
@Configuration
@EnableAspectJAutoProxy
static class AuditConfig {
@Bean AuditLog auditLog() { return new AuditLog(); }
@Bean OrderService orderService() { return new OrderService(); }
@Bean
AuditAspect auditAspect(AuditLog auditLog) {
return new AuditAspect(auditLog);
}
}
public static void main(String[] args) {
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AuditConfig.class)) {
OrderService service = context.getBean(OrderService.class);
service.place("ORD-001");
service.query("ORD-001"); // 不被记录
try {
service.cancel("ORD-001");
} catch (IllegalStateException exception) {
System.out.println("预期异常:" + exception.getMessage());
}
context.getBean(AuditLog.class).printAll();
}
}
}预期输出(节选):
预期异常:订单状态不允许取消:ORD-001
审计记录 2 条:
[下单] place([ORD-001]) → 订单已创建 ORD-001,耗时 1586 微秒,成功=true
[取消订单] cancel([ORD-001]) → IllegalStateException: 订单状态不允许取消:ORD-001,
耗时 42 微秒,成功=false三个设计要点:
| 要点 | 说明 |
|---|---|
@annotation(audited) 切点 | 只有标注了 @Audited 的方法才被记录——把"要不要审计"变成每个方法的决定 |
finally 里记录 | 无论成功失败都留下审计——这对"失败的操作"尤其重要 |
throw throwable | 绝不吞异常——否则外层事务不会回滚(5.5 节) |
注意 query 方法没有被记录——因为它没有 @Audited ✓ 这就是"注解切点"相比"类名匹配"的优势。
顺带一个生产细节:这个 AuditLog 是内存 List,单例 + 多线程下不安全(《线程与同步基础》)。生产环境应该:
| 需求 | 方案 |
|---|---|
| 异步写 | 用 @Async 或 MQ |
| 持久化 | 写入数据库的 audit_log 表(注意:写审计日志不该影响主事务,应该用 REQUIRES_NEW 或事务外写) |
| 高频场景 | 先写本地队列,批量落库(否则会成为性能瓶颈) |
@Transactional 失效排查指南:
## 五种失效场景
| # | 场景 | 症状 | 为什么 | 如何验证 | 如何修复 |
|---|------|------|--------|---------|---------|
| 1 | **同类内部调用** | 部分操作生效、部分没生效 | this 不经过代理 | 在两个方法里各打日志,看事务是否开启 | 拆到另一个 Bean |
| 2 | 方法非 public | 完全不生效 | 代理无法覆盖 | 改成 public 再试 | 改成 public |
| 3 | final 类/方法 | 完全不生效(或启动报错) | CGLIB 无法继承/覆盖 | 看 Bean 类名有没有 $$SpringCGLIB$$ | 去掉 final |
| 4 | 异常被吞 | 抛了异常但数据还在 | 切面感知不到异常 | 在 catch 里加日志确认是否真的抛出 | 重新抛出 |
| 5 | 受检异常 | 抛了异常但没回滚 | 默认只回滚 RuntimeException | 换成 RuntimeException 再试 | rollbackFor = Exception.class |
## 自检清单(提交前逐条确认)
- [ ] 事务方法是从**外部**调用的吗?(不是 this.xxx())
- [ ] 方法是 **public** 吗?
- [ ] 类和方法**没有 final** 吗?
- [ ] 异常**抛出去**了吗?(不是只记日志)
- [ ] 写了 `rollbackFor = Exception.class` 吗?
- [ ] 事务里**没有远程调用**吗?([S3-004](../mysql/S3-004) 的长事务陷阱)
- [ ] 事务范围**尽量小**吗?(只包住必须原子化的操作)
- [ ] 用日志确认过**事务真的开启了**吗?
## 如何用日志验证事务生效
打开 Spring 的事务日志:
logging.level.org.springframework.transaction=DEBUG
观察输出:
Creating new transaction with name [com.example.OrderService.place]
Acquired Connection [...] for JDBC transaction
Initiating transaction commit ← 提交
Committing JDBC transaction on Connection [...]
如果**看不到 `Creating new transaction`**,说明事务没生效 ✓哪一种最危险:第 1 种(同类内部调用)。
为什么它比"完全不生效"更危险:
| 情况 | 表现 | 危险程度 |
|---|---|---|
| 完全不生效 | 所有操作都没事务 | ⚠️ 中等——测试时容易发现(数据不一致很明显) |
| 部分失效 | 外层有事务、内层没有 | 🔴 最高——因为: ① 正常路径完全正确(数据看起来没问题) ② 测试时很难暴露(需要构造异常场景) ③ 只在回滚时出错(而回滚是少数路径) |
具体例子:
@Transactional
public void placeOrder(Order order) {
repository.save(order);
this.deductStock(order); // ✗ 内部调用:deductStock 的 @Transactional 不生效
paymentGateway.charge(order); // 如果这里抛异常
}结果:外层事务回滚(订单没了),但 deductStock 里的扣减已经独立提交了——库存被扣了,订单却不存在。
修复:
@Service
public class OrderService {
private final StockService stockService; // 拆到另一个 Bean
@Transactional
public void placeOrder(Order order) {
repository.save(order);
stockService.deductStock(order); // ✓ 经过代理,事务生效(且默认加入外层事务)
paymentGateway.charge(order); // 异常 → 整体回滚
}
}注意 deductStock 的传播行为:默认是 REQUIRED(加入外层事务)✓ 这样它就会跟着外层一起回滚。
如果写成了 REQUIRES_NEW(开启独立事务),即使外层回滚,扣减也不会撤销——这是另一个需要警惕的坑。
一句话总结:
"部分失效"最危险,因为它在 90% 的情况下看起来是对的,只在 10% 的异常路径上出错。
而恰恰是那 10% 的场景(失败、回滚、并发冲突)才是事务真正要保护的地方。
@Around 做耗时统计;$$SpringCGLIB$$)判断 Bean 是否被代理;this 不是代理);@Transactional 的五种失效场景,并知道"部分失效"最危险;rollbackFor = Exception.class。《REST API 设计》:把业务能力暴露成规范的接口。