Skip to content

Spring:AOP 与事务 ​

S3-007必学高级约 90 分钟JDK 21企业开发基础 · Spring 核心与 MVC

1. 本节定位 ​

《Spring:IOC 与依赖注入》 解决了"对象怎么装配",本节解决"重复的逻辑怎么消除"。

日志、耗时统计、权限校验、事务——这些逻辑不属于任何单个业务方法,但几乎每个业务方法都需要。AOP 的作用就是把它们从业务代码里抽出来。

而它最有价值的实战知识是:什么情况下 AOP 会失效——尤其是"同类内部调用"这个坑,它同时也是 @Transactional 失效的头号原因。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 AOP 的五个核心术语(切面、连接点、切点、通知、织入);
  • 说出五种通知的执行时机,并用 @Around 实现耗时统计;
  • 说清"从容器里拿到的其实是代理对象",并能在类名里认出来;
  • 说出 JDK 动态代理与 CGLIB 的适用条件;
  • 说出 AOP 失效的三种情况,并能解释"为什么内部调用不生效";
  • 说出 @Transactional 失效的四种常见场景;
  • 知道"事务方法应该是 public + 从外部调用"这条硬规矩。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看一段"日志代码淹没业务逻辑"的例子:

java
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 的写法:

java
@Transactional
@RequiresPermission
@Timed
public void place(Order order) {
    repository.save(order);              // 只剩业务逻辑
}

同样的能力,四行代码,而且注解可以被复用到所有方法上。

5. 核心概念 ​

5.1 五个术语 ​

术语含义对应代码
切面(Aspect)横切关注点的模块化@Aspect 标注的类
连接点(Join Point)程序执行中"可以插入切面"的点Spring AOP 里只有方法调用
切点(Pointcut)"在哪些连接点插入"的表达式execution(* ...OrderService.place(..))
通知(Advice)"插入后做什么"@Before / @Around 等
织入(Weaving)把切面"接进"目标对象的过程Spring 用运行时动态代理实现

Spring AOP 的一个重要限制:连接点只有方法调用(不像 AspectJ 还支持字段访问、构造器调用)。

这带来一个直接影响:你没法用 Spring AOP 拦截"字段赋值"——这也是为什么它够用(业务关注点大多是方法级的)。

5.2 五种通知 ​

通知时机能否阻止方法执行能否拿到返回值
@Before方法执行前❌❌
@AfterReturning方法正常返回后❌✅
@AfterThrowing方法抛异常后❌能拿到异常
@After方法结束后(无论成功失败)❌❌
@Around包围方法✅(可以不调用 proceed())✅

@Around 是功能最强的——它能做其他四种的全部事情,也能改写返回值、吞掉异常、重试。

java
@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;
}

三个容易踩的点:

  1. 忘了 proceed() → 业务方法根本不会执行(这是"加了 AOP 后方法没跑"的常见原因);
  2. @Around 必须返回 Object,且要 return 结果(否则调用方拿到 null);
  3. 异常处理要小心:@Around 里 catch 了异常却不重新抛出,等于吞掉了业务异常。

5.3 代理:AOP 的实现方式(实测) ​

运行时的实测输出:

text
=== 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 方法该方法的切面不生效

5.4 AOP 失效的三种情况(本节重点) ​

失效一:同类内部调用(最常见) ​

实测输出:

text
=== 4. 内部调用:切面失效(AOP 最常见的坑)===
    [业务] 下单(带审计):ORD-002
    [业务] 审计记录已写入:ORD-002
    注意上面**没有出现 [@Around 审计]** 的日志。

=== 5. 对照:直接从容器取出代理调用 audit(),切面就生效了 ===
    [@Around 审计] 开始,参数=[ORD-002]
    [业务] 审计记录已写入:ORD-002
    [@Around 审计] 结束,耗时 1586 微秒

同一个 audit() 方法,通过内部调用不走切面、通过代理调用走切面。

原因:

java
void placeWithAudit(String orderNo) {
    System.out.println("[业务] 下单(带审计):" + orderNo);
    this.audit(orderNo);                // ← this 是"原始对象",不是代理
}

调用链对比:

text
外部调用:调用方 → 【代理对象】.placeWithAudit() → 【代理】查切点 → 原始对象.placeWithAudit()
内部调用:原始对象.placeWithAudit() → this.audit() → 原始对象.audit()      ← 完全没经过代理

this 指向原始对象(代理只是"外面的一层皮"),所以内部调用绕过了代理,也就绕过了所有切面。

三种修复方式:

方式做法评价
① 拆到另一个 Bean把 audit 移到独立的 AuditService,注入使用✅ 推荐(职责也更清晰)
② 注入自己@Autowired private OrderService self; 然后 self.audit(...)⚠️ 能工作但有点绕(且要注意循环依赖)
③ AopContext.currentProxy()需要 @EnableAspectJAutoProxy(exposeProxy = true)⚠️ 侵入性强

同样的坑在 @Transactional 上更危险(见 5.5)。

失效二:方法不是 public ​

java
@Before("execution(* ...OrderService.internal(..))")
void before() { }

private void internal() { }              // ✗ 切面不生效

原因:Spring AOP 基于代理,private 方法无法被代理覆盖。

注意:protected / 包级方法在 CGLIB 下"可能"能被代理,但行为依赖具体版本与配置——统一用 public 就不必纠结。

失效三:final 类或 final 方法 ​

java
public final class OrderService { }       // ✗ 无法被 CGLIB 继承 → 切面不生效

public void place() { }
public final void query() { }             // ✗ final 方法无法被覆盖 → 该方法切面不生效

排查方法:看容器里的 Bean 类名带不带 $$SpringCGLIB$$——不带就说明没被代理。

5.5 @Transactional:AOP 最重要的应用 ​

它的原理:

java
@Transactional
public void place(Order order) {
    repository.save(order);
}

等价于(简化):

java
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 条为什么最危险:

java
@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 条的细节:

java
@Transactional
public void place() throws IOException {
    repository.save(order);
    throw new IOException("IO 错误");     // ✗ 默认不回滚(受检异常)
}

原因:Spring 的默认规则是"运行时异常(RuntimeException)与 Error 回滚,受检异常不回滚"。

修复:@Transactional(rollbackFor = Exception.class)。

这条规则的历史原因:Java 的受检异常代表"可预期的业务状况"(如"余额不足"),而运行时异常代表"程序错误"——早期设计者认为前者不该回滚。但在实践中,这几乎总是反直觉的,所以建议一律显式写 rollbackFor = Exception.class。

5.6 切点表达式要点 ​

java
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..*)某包下的所有方法

实践建议:用注解切点比用类名匹配更好——它让"要不要加切面"变成每个方法自己的决定:

java
@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"三者配合的完整展示(《反射》、《注解》 讲的两块知识在这里合体了)。

6. 递进示例 ​

6.1 最小示例:@Before ​

java
@Before("execution(* cn.kb.learn.spring.AopLesson$OrderService.place(..))")
void beforePlace(JoinPoint joinPoint) {
    System.out.println("即将调用:" + joinPoint.getSignature().getName()
            + ",参数=" + Arrays.toString(joinPoint.getArgs()));
}

6.2 业务示例:@Around 做耗时统计 ​

java
@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 的价值。

6.3 反例:在切面里吞异常 ​

java
@Around("...")
Object around(ProceedingJoinPoint joinPoint) {
    try {
        return joinPoint.proceed();
    } catch (Throwable throwable) {
        log.error("出错了", throwable);
        return null;                     // ✗ 异常被吞掉
    }
}

后果:业务异常消失,调用方以为成功了——如果外层有事务,它不会回滚(《事务与隔离级别》)。

修复:记完日志后重新抛出:

java
    } catch (Throwable throwable) {
        log.error("出错了", throwable);
        throw throwable;                 // ✓ 或者包装成业务异常再抛
    }

7. 完整可运行代码 ​

文件:labs/spring-core/src/main/java/cn/kb/learn/spring/AopLesson.java(节选)

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(); }
    }
}

运行命令:

powershell
cd labs
./mvnw -pl spring-core compile exec:exec -Dexec.mainClass=cn.kb.learn.spring.AopLesson

预期输出:

text
=== 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() 方法,一次不走切面、一次走切面,差别只在于"调用是否经过代理"。

8. Debug 观察点 ​

  1. 在 service.place("ORD-001") 处打断点:观察 service 变量的实际类型是 ...$$SpringCGLIB$$0——这不是 OrderService,而是它的子类;
  2. 在 placeWithAudit 内部(this.audit(...) 那一行)打断点:观察 this 的类型是 OrderService 本身(不是代理)——这就是切面失效的直接原因;
  3. 在 @Around 通知里打断点:观察 joinPoint.proceed() 前后的调用栈——业务方法是由切面"放行"的;
  4. 在容器启动时观察:@EnableAspectJAutoProxy 注册了 AnnotationAwareAspectJAutoProxyCreator——它是一个 BeanPostProcessor,在 Bean 初始化后决定"要不要包装成代理"(这是 AOP 生效的机制)。

9. 常见错误 ​

错误 1:同类内部调用导致切面/事务失效 ​

见 5.4、5.5 节。这是本节最重要的错误。

自查方法:凡是 this.xxx() 或直接 xxx() 调用本类另一个"带注解的方法",都要警惕。

错误 2:@Around 忘了 proceed() ​

java
@Around("...")
Object around(ProceedingJoinPoint joinPoint) {
    System.out.println("开始");
    return null;                          // ✗ 业务方法不会执行
}

症状:加了切面后业务方法"不工作了",但没有任何报错。

错误 3:@Transactional 吞掉异常 ​

java
@Transactional
public void place() {
    try {
        repository.save(order);
        ThirdPartyClient.call();          // 抛异常
    } catch (Exception exception) {
        log.error("调用失败", exception);   // ✗ 只有日志,没有抛出
    }
}

后果:异常被吞掉 → 事务认为方法正常返回 → 提交——数据不一致。

修复:throw exception; 或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

错误 4:受检异常不回滚 ​

见 5.5 节第 5 条。修复:rollbackFor = Exception.class。

错误 5:切面里做重活 ​

java
@Around("execution(* com.example..*(..))")
Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    auditService.saveFullStackTrace(joinPoint);      // ✗ 每个方法都写一次数据库
    return joinPoint.proceed();
}

后果:所有方法都变慢——切面在"每个被匹配的方法"上都执行。

修复:

  1. 切点要精确(不要用 execution(* com.example..*(..)) 这种"全包"写法);
  2. 切面里的逻辑要轻(写日志、记指标,别做数据库操作);
  3. 需要重活时改成异步。

错误 6:切面顺序不确定 ​

java
@Aspect
class TransactionAspect { }      // 事务切面

@Aspect
class LoggingAspect { }          // 日志切面

问题:两个切面谁先执行? 如果没有指定顺序,行为不确定——而"日志里有没有记录到事务回滚"取决于顺序。

修复:用 @Order(n) 显式排序(数字小的先执行);@Transactional 的顺序可用 @EnableTransactionManagement(order = ...) 控制。

一条经验:事务切面应该在"最外层"——这样它包裹的范围最大(包括日志)。

10. 动手实验 ​

目标:把 AOP 的失效场景亲手复现一遍。

步骤:

  1. 运行第 7 节的命令,核对输出(重点是第 4、5 步的对比);
  2. 自己写一个 @Timed 注解 + 切面:统计被标注方法的耗时,用 finally 保证异常时也记录;
  3. 复现失效场景:
    • 把 audit 改成 private,观察切面失效;
    • 把 OrderService 改成 final class,观察容器启动报错(无法生成 CGLIB 代理);
  4. 修复内部调用:新建一个 AuditService,把 audit 移过去,在 OrderService 里注入它——确认切面生效了;
  5. 观察 @Order:写两个切面(一个记日志、一个记耗时),不加 @Order 跑一次、加上 @Order 跑一次,对比执行顺序;
  6. 思考题:如果你要给"所有 Controller 的方法"加统一的日志,切点该怎么写?(提示:execution(* com.example.controller..*.*(..)))

验收方式:能说出第 3 步两种失效的具体表现、第 4 步的修复为什么有效。

扩展任务:实现一个"幂等切面"——用注解 @Idempotent(key = "#orderNo") 标记方法,切面在方法执行前检查 key 是否处理过(可以用一个内存 Set 模拟),已处理则直接返回。说明这个切面在生产环境应该用什么存储 key。

11. 练习题 ​

基础题

  1. 判断:Spring AOP 可以拦截字段的读写。(对 / 错)
  2. 填空:AOP 通过 ______ 实现;容器里 Bean 的类名带 ______ 说明被代理了。
  3. 简答:为什么"同类内部调用"会导致切面失效?

代码阅读题

java
@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("用户名已存在");
        }
    }
}
  1. validate 上的 @Transactional 生效吗?为什么?
  2. 更严重的问题是什么?(提示:想想"并发注册同一个用户名")

编程题

  1. 实现一个"审计日志切面":
    • 定义注解 @Audited(action = "...");
    • 切面在方法执行后记录:方法名、参数、返回值、耗时、是否成功;
    • 要求:异常也要记录(用 finally 或 @AfterThrowing),且不能吞掉异常;
    • 用一个内存 List 收集审计记录,运行几个方法后打印全部记录;
    • 额外要求:让切面支持"只记录标注了 @Audited 的方法"(提示:@annotation(...) 切点)。

综合任务

  1. 设计一个"事务失效排查指南":
    • 列出 5 种 @Transactional 失效场景(含代码示例);
    • 每种给出:症状、为什么失效、如何验证、如何修复;
    • 设计一个"自检清单"(5-8 条),让开发者在提交代码前能自己检查;
    • 说明:哪一种是"最危险"的?为什么它比"完全不生效"更危险?
    • 额外要求:写一段"如何用日志验证事务真的生效了"的说明(提示:观察 SQL 的执行与提交顺序)。
分级提示

提示 1(方向):第 4 题的关键是 register 里调用的是 this.validate(...)。

提示 2(关键点):第 5 题的关键是"检查与插入之间有时间窗口"——这正是 《锁》 讲的竞态条件。

提示 3(接近答案):第 7 题的"最危险"是部分失效——即"有的事务生效、有的不生效",因为它在测试时经常表现为"正常路径都是对的"。

12. 参考答案 ​

基础题
  1. 错。Spring AOP 的连接点只有"方法调用"(不像 AspectJ 支持字段、构造器)。这是它的设计边界,也是它"够用"的原因——业务关注点绝大多数是方法级的。

推论:你不能用 Spring AOP 拦截"字段赋值"。如果真有这种需求(如"记录字段变更历史"),只能改成"通过方法修改"或用 JPA 的实体监听器。

  1. 动态代理;$$SpringCGLIB$$(或 $Proxy 开头,那是 JDK 动态代理)。

排查技巧:service.getClass().getName() 一眼就能看出有没有被代理——这是排查 AOP 问题的第一步。

  1. 因为 this 指向的是"原始对象",不是代理对象。
text
外部调用:调用方 → 【代理】→ 查切点 → 执行通知 → 原始对象的方法
内部调用:原始对象的方法 → this.另一个方法()  ← 完全不经过代理

代理只是"外面的一层皮":一旦进入原始对象内部,this 就再也不会经过代理了。

注意这一点与语言无关——任何基于代理的 AOP 都有这个限制(Python 的装饰器、Java 的 Spring AOP 都类似)。这是"代理"这种实现方式的固有属性。

代码阅读题(第 4、5 题)

第 4 题:不生效。

register 里调用的是 this.validate(username)——内部调用绕过代理,所以 validate 上的 @Transactional 不会生效。

而且这里还有个误导:register 本身没有 @Transactional,所以整个 register 完全没有事务——两次数据库操作(exists 查询 + save 插入)各自独立提交。

第 5 题:更严重的问题是"检查-插入"的竞态条件。

java
public void register(String username) {
    validate(username);                    // 检查:用户名是否存在
    repository.save(new User(username));   // 插入
}

并发下的执行序列:

text
时刻 1   线程 A:exists("alice") → false
时刻 2   线程 B:exists("alice") → false        ← 两个线程都判断"不存在"
时刻 3   线程 A:save("alice") → 成功
时刻 4   线程 B:save("alice") → 也成功         ← 重复用户名!

这个问题的本质与 《锁机制:行锁、表锁与乐观悲观锁》 的"超卖"完全一样:"先检查后执行"在并发下不安全。

正确的解法(三层):

层面做法
数据库层(必须)给 username 加唯一约束:UNIQUE (username)——这是最后一道防线
应用层捕获唯一约束冲突异常,转成"用户名已存在"的友好提示
事务用 @Transactional 包住(注意要从外部调用,或拆到另一个 Bean)

为什么唯一约束才是关键:应用层的 exists 检查只是"友好提示",它挡不住并发——只有数据库的唯一约束能真正保证不重复(《表设计与约束》 讲过"约束是最后一道防线")。

这段代码的教训:"加个事务"解决不了所有并发问题——事务保证的是原子性,而唯一性要靠约束。

编程题(第 6 题)
java
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();
        }
    }
}

预期输出(节选):

text
预期异常:订单状态不允许取消: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 或事务外写)
高频场景先写本地队列,批量落库(否则会成为性能瓶颈)
综合任务(第 7 题)

@Transactional 失效排查指南:

markdown
## 五种失效场景

| # | 场景 | 症状 | 为什么 | 如何验证 | 如何修复 |
|---|------|------|--------|---------|---------|
| 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 种(同类内部调用)。

为什么它比"完全不生效"更危险:

情况表现危险程度
完全不生效所有操作都没事务⚠️ 中等——测试时容易发现(数据不一致很明显)
部分失效外层有事务、内层没有🔴 最高——因为:
① 正常路径完全正确(数据看起来没问题)
② 测试时很难暴露(需要构造异常场景)
③ 只在回滚时出错(而回滚是少数路径)

具体例子:

java
@Transactional
public void placeOrder(Order order) {
    repository.save(order);
    this.deductStock(order);          // ✗ 内部调用:deductStock 的 @Transactional 不生效
    paymentGateway.charge(order);     // 如果这里抛异常
}

结果:外层事务回滚(订单没了),但 deductStock 里的扣减已经独立提交了——库存被扣了,订单却不存在。

修复:

java
@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% 的场景(失败、回滚、并发冲突)才是事务真正要保护的地方。

13. 掌握标准 ​

  • [ ] 能说出 AOP 的五个术语与五种通知,会用 @Around 做耗时统计;
  • [ ] 能通过类名($$SpringCGLIB$$)判断 Bean 是否被代理;
  • [ ] 能解释"内部调用为什么绕过切面"(this 不是代理);
  • [ ] 能说出 AOP 失效的三种情况与修复方式;
  • [ ] 能说出 @Transactional 的五种失效场景,并知道"部分失效"最危险;
  • [ ] 记住两条硬规矩:事务方法要 public + 从外部调用、rollbackFor = Exception.class。

14. 扩展资源 ​

15. 下一步学习 ​

《REST API 设计》:把业务能力暴露成规范的接口。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引