从项目里一个很典型的现象说起Mapper 接口能查出数据Service 层加了 Transactional 却死活不回滚Log 切面也跟没写一样。这种问题十有八九不是 SQL 写错了而是没弄懂 Spring AOP、注解配置和 MyBatis 三者是怎么协作的——AOP 负责拦截行为注解负责声明规则MyBatis 负责把 SQL 映射成对象三者各管一段但配合起来才能让事务、日志、性能监控这些横切逻辑真正跑起来。这篇文章我会把三者的协作原理拆开再给出一套可以直接落地的整合示例适合刚接触 Spring 生态的读者也适合写了大半年业务代码却对代理机制半懂不懂的朋友。1. 为什么把 AOP、注解配置和 MyBatis 放在一起讲1.1 三者的真实关系AOP 是粘合剂注解是开关MyBatis 是数据通道很多初学者有一个误区觉得 AOP 是 Spring 的一个独立模块MyBatis 是另一个独立框架注解配置只是省了 XML 而已。实际项目里这三者经常是绑在一起出现的。MyBatis 只负责一件事把接口方法映射成 SQL 执行并返回结果。但一个完整的业务操作不只是执行 SQL还需要开启事务、提交事务、回滚、记录日志、统计耗时这些横切逻辑如果散落在每个 Service 方法里代码会非常难看。于是 Spring 引入了 AOP用代理对象包住目标 Bean在方法调用前后插入横切逻辑。而注解配置的作用是让这个拦截规则包上一层“开关”——用 Transactional 标明事务边界用自定义注解标明需要记录日志这样 AOP 才知道该拦谁、不该拦谁。这三者结合起来才能让开发者在 Service 层只写业务代码剩下的横切逻辑全部交给框架。你可以把 AOP 理解为安检通道MyBatis 理解为通往数据库的传送带注解则是通道口的指示牌。指示牌说“这个方法是事务性的”安检就会在进入前开启事务在出来时提交或回滚指示牌说“这个方法是只读查询”安检就直接放行。没有 AOP注解配置就失去了执行者没有注解配置AOP 就不知道该拦截谁。而 MyBatis 作为数据访问层恰好提供了“方法到 SQL”的天然切面——每个 Mapper 方法的调用本质上都可以被代理拦截这也是 MyBatis 本身基于动态代理实现的原因。1.2 全注解 vs XML整合方案选型背后的逻辑我见到的项目里关于 MyBatis 和 Spring 的整合方式讨论最多的就是“到底用 XML 还是用注解”。有人觉得 MyBatis 就是 XML 起家的不用 XML 没灵魂也有人追求全注解一个 Mapper 接口上一个 Select 完事。我的观点是这不该是一个二选一的问题而是一个按场景取舍的问题。注解方式适合简单 SQL比如单表查询、按主键查数据、固定的 CRUD。用 Select、Insert、Update、Delete 直接写在 Mapper 接口方法上代码量少阅读时一眼就能看到 SQL 和方法的对应关系。但一旦涉及动态 SQL比如查询条件要根据入参拼装、批量插入要循环 values注解方式就会非常难受要么写一堆 script 标签要么拼接字符串可维护性急剧下降。此时 XML 方式有明显优势XML 里的 、 、 结构清晰DBA 评审 SQL 时也能直接打开 XML 文件看。从整合的角度来看注解配置的另一个作用是大量减少 Spring 配置文件。早年用 Spring XML 配 DataSource、配 SqlSessionFactoryBean、配 MapperScannerConfigurer配置项多到让人崩溃。后来 Spring Boot 时代引入了 starter这些全部自动装配你再也不用手写数据源的 Bean 定义。再加上 MapperScan 一条注解替代了 MapperScannerConfigurer整个整合过程变得非常轻。但这不代表 XML 就该被抛弃我目前的做法是数据源配置和事务开关用注解复杂 SQL 和动态条件走 XML mapper 文件。两者并不冲突反而各得其所。2. 底层原理动态代理与 MyBatis 整合的根基2.1 JDK 动态代理与 MyBatis MapperProxy 工作机制MyBatis 最核心的机制就是你没有写 Mapper 接口的实现类却可以直接注入并使用它。这听起来像魔法实际就是 JDK 动态代理的功劳。MyBatis 启动时会扫描所有 Mapper 接口为每个接口创建一个代理实例代理逻辑集中在 MapperProxy 中。当你调用 mapper 的某个方法时代理对象会把方法调用拦截下来解析出方法对应的 MappedStatement也就是一条 SQL 的定义然后交给 SqlSession 去执行。JDK 动态代理要求目标对象必须实现接口它通过 Proxy.newProxyInstance 生成一个实现了同样接口的代理类所有方法调用都会被 InvocationHandler 接管。Mapper 接口天然满足这个条件——它们本来就是接口所以 MyBatis 可以用 JDK 动态代理来生成实现。这也解释了为什么 MyBatis 的 Mapper 接口不能是普通 class必须是 interface。你可以把它理解成你给框架一张“菜单”接口定义框架照着菜单上的菜名方法名找到后厨的菜谱SQL后厨做好后由服务员代理对象端给你。整个过程你只面对菜单不需要知道后厨是谁。在实际排查问题时知道这一点非常有用。比如有人会问“为什么我的 Mapper 方法断点打不进去”因为在代理模式下你调用的是代理对象的方法而实际执行逻辑在 MapperProxy.invoke 里你的 Mapper 接口本身没有实现代码自然没有断点可打。如果你想确认一个对象是否被代理可以打印它的类名如果是 com.sun.proxy.$Proxy 开头说明是 JDK 动态代理。2.2 CGLIB 动态代理Spring AOP 在默认场景下的真实形态JDK 动态代理有一个限制目标类必须有接口。可 Service 类往往不写接口——这是现在非常普遍的做法。那 Spring 的 Transactional 切面怎么生效答案是 CGLIB。CGLIB 通过生成目标类的子类来实现代理代理类继承目标类并重写方法在重写时插入拦截逻辑。Spring 从 4.0 开始默认的事务代理方式就是 CGLIB目标类有没有接口都能代理。CGLIB 代理有个经典坑被代理类的方法不能是 final因为 final 方法无法被子类重写private 方法也无法被拦截因为子类根本看不到它。所以你在 Service 里写的 private 方法调 private 方法事务或切面都不会生效。另一个坑是父类引用调用子类方法时的自调用问题下面第 4 节会详细讲。CGLIB 代理对象与 JDK 代理对象的类名也不同前者一般是 Class$$EnhancerByCGLIB$$ 开头你如果看到这类类名也可以确认代理已生成。Spring AOP 在整合 MyBatis 时实际拦截链路是调用方注入的是 Service 的代理对象 → 代理对象执行切面逻辑比如开启事务→ 事务内调用真实的 Service 方法 → Service 方法里注入的 Mapper 也是代理对象 → Mapper 代理执行 SQL。所以一个简单的业务调用可能嵌套了两层代理。理解了这层结构你在排查问题时就会知道该在哪一层加日志、在哪一层打断点。2.3 注解配置是如何驱动 AOP 逻辑的注解配置在 Spring AOP 里起到两个作用一是声明切面规则二是标记拦截点。Aspect 注解告诉 Spring“该类是一个切面”Pointcut 定义拦截规则Before、AfterReturning、Around 定义增强逻辑在何时执行。而 Transactional、自定义业务注解这类标记型注解则会作为切点表达式的匹配条件。Spring 启动时会解析这些注解生成 Advisor通知器再把 Advisor 与目标 Bean 进行匹配匹配成功就为 Bean 生成代理。这里要更正一个常见误解很多人以为“只要加了 Transactional方法就会自动具备事务”。其实 Transactional 本身不执行任何逻辑它只是给方法打了一个标记真正干活的是 AOP 切面里的 TransactionInterceptor。这个拦截器拿到 Transactional 的属性比如传播行为、隔离级别、超时时间在方法执行前连接数据库开启事务在方法正常返回后提交捕获到 RuntimeException 就回滚。所以注解配置的本质是把“要不要开事务”“哪种异常回滚”这些信息以声明的方式交给 AOP让横切逻辑按声明执行。在整合 MyBatis 时这个机制尤其重要——你把 Transactional 加在 Service 方法上该方法里调用多个 Mapper 操作只要其中一步抛出异常所有操作一起回滚这正是注解配置 AOP MyBatis 三者协作的典型成果。3. 实操落地搭建一个可以运行的 AOP MyBatis 示例3.1 工程结构与依赖配置这里用 Spring Boot 作为基础框架因为 Spring Boot 已经把大部分自动装配做完了我们只需要把核心组件接好即可。用一个简单的用户注册 余额扣减场景来演示用户表插入一条记录同时为用户开通一个账户两步操作需要在同一个事务里完成如果第二步失败第一步也要回滚。同时给操作加上耗时统计日志。工程依赖只需要三块spring-boot-starter-web提供 Web 环境、mybatis-spring-boot-starter整合 MyBatis、mysql-connector-java数据库驱动。如果是 Maven 项目pom.xml 核心部分是这样的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyapplication.yml 里需要配数据源和 MyBatis 的基本项。如果使用注解 SQLmybatis.mapper-locations 都可以不配但建议还是把 XML 路径配上为后续复杂查询留余地。打印 SQL 的配置也建议在开发环境打开后面排查问题时没有 SQL 日志会非常痛苦。spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case 这一项值得单独强调。数据库字段通常用下划线命名比如 real_nameJava 属性通常用驼峰命名比如 realName。开启这个配置后MyBatis 会自动完成映射省去你在 resultMap 里一个个手动指定字段的麻烦。我见过很多项目没开这个开关所有的别名都要靠 SQL 里写 AS代码冗余不说还容易漏字段强烈建议开启。3.2 注解方式定义 Mapper 与 XML 方式并存先定义一个实体类 User 和 Account字段与数据库列对应。然后写 Mapper 接口。简单方法用注解方式比如按用户名查询、插入一条用户记录Mapper public interface UserMapper { Select(SELECT * FROM user WHERE username #{username}) User findByUsername(String username); Insert(INSERT INTO user(username, nickname) VALUES(#{username}, #{nickname})) Options(useGeneratedKeys true, keyProperty id) int insert(User user); }Options(useGeneratedKeys true) 这里有一个很多人会忽略的细节插入操作如果省略它User 对象里的 id 是不会被数据库自增主键回填的。想要在插入后立刻拿到自增 id这个注解必须加。而在 XML 方式里对应的写法是insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO user(username, nickname) VALUES(#{username}, #{nickname}) /insertAccountMapper 我建议用 XML 来写因为实际业务里往往需要加一层余额判断比如“扣减前检查余额是否充足”这种动态 SQL 用注解写很难受。XML 方式如下update iddeductBalance UPDATE account SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount} /update注意这里的 SQL 利用了数据库条件判断UPDATE 语句影响行数为 0 时说明余额不足应用层通过返回 int 值判断是否成功。这种原子性写法比先 SELECT 再 UPDATE 要安全也能避免并发扣款时余额超扣。3.3 用注解定义自定义切面并统计耗时现在定义一个业务注解 OpLog用于标记需要记录操作日志的方法Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface OpLog { String value() default ; }Retention 必须设置为 RUNTIME这一点很关键——如果选成 CLASS 或 SOURCE运行期无法通过反射读取注解切面就匹配不到任何方法。然后定义一个切面类用 Aspect 注解标记Aspect Component public class OpLogAspect { private static final Logger log LoggerFactory.getLogger(OpLogAspect.class); Around(annotation(opLog)) public Object around(ProceedingJoinPoint joinPoint, OpLog opLog) throws Throwable { long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; log.info(操作[{}]成功, 耗时{}ms, 参数{}, opLog.value(), cost, Arrays.toString(joinPoint.getArgs())); return result; } catch (Exception e) { long cost System.currentTimeMillis() - start; log.error(操作[{}]失败, 耗时{}ms, 异常{}, opLog.value(), cost, e.getMessage()); throw e; } } }切点表达式 annotation(opLog) 表示匹配所有标注了 OpLog 注解的方法同时把注解实例绑定到参数 opLog 上。这样你在切面里就能直接拿到注解上配置的属性比如操作名称。joinPoint.proceed() 是执行目标方法的入口所有目标方法返回值必须通过它返还给调用方如果这里面忘记调用 proceed()方法体根本不会执行这是新手最容易踩的空洞。还有个隐藏考点方法上标注的注解参数 opLog 要生效必须让切点表达式里的参数名和切面方法入参名保持一致而且编译时不能开启 -parameters 之外的某种改名策略。在实际项目中如果发现注解切面没生效先检查这两处Maven 编译器插件里最好把 parameters 设为 true。3.4 Service 层整合事务、AOP 与 MyBatis 的协同现场Service 类演示三者的组合。注册用户在同一个事务里完成用户插入和账户开通然后给整个方法加上 OpLog 注解记录操作耗时Service public class RegisterService { private final UserMapper userMapper; private final AccountMapper accountMapper; public RegisterService(UserMapper userMapper, AccountMapper accountMapper) { this.userMapper userMapper; this.accountMapper accountMapper; } Transactional(rollbackFor Exception.class) OpLog(注册新用户) public void register(User user, double initBalance) { userMapper.insert(user); Account account new Account(); account.setUserId(user.getId()); account.setBalance(initBalance); accountMapper.insert(account); } Transactional(rollbackFor Exception.class) public boolean consume(Long userId, double amount) { int affected accountMapper.deductBalance(userId, amount); return affected 0; } }Transactional 里的 rollbackFor Exception.class 也是项目里经常出错的一个点。默认情况下Spring 只对 RuntimeException 回滚事务如果代码里抛出的是 checked exception比如 IOException、自定义的 BusinessException 继承自 Exception事务不会回滚。很多项目踩过这个坑方法 catch 住异常没抛、或者抛了个受检异常数据该回滚却没回滚。所以如果没有特定需求建议统一写成 rollbackFor Exception.class让所有异常都触发回滚。在这个示例中调用 register 方法时Spring 生成的代理对象会先进入事务拦截器开启数据库事务然后进入我们自定义的 OpLog 切面记录耗时之后才真正执行业务方法。业务方法里调用 userMapper.insert 和 accountMapper.insert这两个 Mapper 本身就是 MyBatis 的动态代理它们会各自执行 SQL但所有 SQL 都在同一个事务连接里执行。任何一个 Mapper 方法抛出异常异常经过外层切面时被记录并重新抛出事务拦截器捕获后执行回滚。这一步步扣下来你就能直观看到 AOP、注解配置、MyBatis 三方是如何串成一条链的。4. 整合中的典型坑位与排查思路4.1 事务注解不生效三个绕不开的内因我接手过的项目里事务不生效大概就三类原因。第一类是代理未生成或代理失效最常见的就是同一个类内部方法互相调用比如 public 方法 A 调用 public 方法 BB 上有 Transactional但你期望事务生效——实际上不生效。因为调用者是 this也就是目标对象自身不是 Spring 生成的代理对象事务拦截器根本没加入到调用链里。解决办法是把 B 方法移到另一个 Service Bean 里或者注入自身代理构造器注入或 Lazy 注入也可以手动从 ApplicationContext 里获取代理对象。第二类是异常被吞掉。代码里 try-catch 捕获了异常但没重新抛出事务拦截器看到方法正常返回自然选择提交。这就相当于你告诉它“一切正常”它不会替你去查数据库状态。解决办法是 catch 里记录日志后继续 throw或者改用 TransactionTemplate 手动控制提交回滚。第三类是数据库表不支持事务。MySQL 的 MyISAM 引擎虽然现在不常见但如果你手头的表恰好是 MyISAM事务声明得再完美也没用因为引擎层就不支持。排查时可以执行 SHOW TABLE STATUS 查看 Engine 字段把表改成 InnoDB。另外还要检查事务方法是否被 final 修饰CGLIB 无法代理 final 方法。这些因素叠加在一起会让事务注解时灵时不灵排查时按“代理—异常—存储引擎”三层顺序走基本能覆盖大多数场景。4.2 自定义切面不执行从切点和代理两个方向查自定义切面不触发先看切点表达式是否正确。annotation 方式只匹配注解标注的方法execution 方式匹配方法签名比如 execution(public * com.example.service..(..))注意包名后面的 .* 只匹配当前层不含子包。如果切面类上漏了 Component或者没有通过其他方式注册为 Spring BeanSpring 根本不会把它当作切面管理。此时启动日志里会看到“No Aspect annotation found”之类的提示。另一个方向是检查目标方法是否在代理上调用。如果方法是通过 this 调用的或者在一个没有经过代理的对象上调用切面不会生效。这里有个简便的验证方法在配置里开启 AOP 日志然后在启动日志里搜索“Applying Aspect”或“Advisor for”字样确认目标 Bean 是否被代理、应用了哪些通知。如果你发现目标 Bean 的 class 是原本的类名而没有 $Proxy 或 EnhancerByCGLIB 字样说明代理没有生成优先检查切点表达式是否匹配、切面类是否注册为 Bean。4.3 MyBatis 缓存引发的“假数据”和“修改丢失”问题MyBatis 的一级缓存默认开启作用域是 SqlSession。在 Spring 整合环境下每次数据库操作都会新建 SqlSession用完后关闭所以一级缓存基本不会造成跨请求的脏数据问题。但有一个例外如果你在同一个 Service 事务里先查询后更新再查询第一次查询结果会缓存在当时的 SqlSession 里更新操作会清理缓存第二次查询会重新从数据库取这是正常行为。真正容易出问题的是二级缓存它在 Mapper 级别共享跨 SqlSession 生效。二级缓存一旦开启如果表数据被其他系统直接改了或者你的 XML 里用了多表 JOIN很可能会读到过期数据。另外二级缓存的默认序列化也可能让某些字段类型出问题。我的建议是核心业务表不开二级缓存开之前确认该 Mapper 涉及的表都是单表操作或结果集可完全预期开启后必须设置 flushCache 的更新操作配置防止脏读。项目里曾经遇到过一个“用户更新了头像前端刷新还是旧图”的案例最后查下来就是二级缓存里存了旧数据清掉缓存立刻恢复正常从那以后我对二级缓存的态度就非常保守。4.4 整合调试SQL 日志、代理类名与断层定位整合调试时日志配置能省一半时间。mybatis.configuration.log-impl 设为 StdOutImpl 后每次 SQL 执行都会打出完整的 PreparedStatement 参数和结果。如果你觉得太吵可以改用 Slf4jImpl然后在 logback 里单独给 mapper 包配置 DEBUG 级别。想要观察 AOP 的代理行为Spring 在启动阶段有专门的日志类别把 logging.level.org.springframework.aop 设为 DEBUG就能看到切面匹配和代理生成的过程。定位“事务/切面/MyBatis 哪个环节出了问题”一个很实用的技巧是打印关键对象的真实类名。在 Service 里注入一个 Bean 后打印 bean.getClass().getName()看类名是否包含 EnhancerByCGLIB 或 $Proxy。如果包含说明 Spring 已代理不包含说明代理没生成。在 Mapper 里打印 userMapper.getClass().getName()会看到 $Proxy 或 MapperProxy 相关类名说明 MyBatis 代理正常。把这两层确认完再配合 SQL 日志判断是否进入了事务基本可以把问题定位到具体环节而不是靠猜。5. 注解配置与 AOP 扩展从事务到更多实用切面5.1 多切面共存时的执行顺序与控制一个真实系统不会只有一个切面。事务切面、日志切面、权限校验切面、接口耗时统计切面可能同时作用于同一个方法这时执行顺序就非常重要。Spring AOP 默认不保证不同切面的顺序但你可以通过 Order 注解控制数字越小越先执行前置逻辑越后执行后置逻辑。我的习惯是权限校验切面最外层Order(1)日志切面次之Order(2)事务切面最内层Order(Integer.MAX_VALUE)。这样外部请求先进鉴权再进入业务操作事务切面贴近目标方法避免权限校验耗时占据事务时长。在整合 MyBatis 的场景里这个顺序还影响连接释放效率。如果事务切面在最外层整个日志记录流程都会占用数据库连接如果反一下事务尽早提交连接尽快归还连接池。所以涉及多切面时顺序不是洁癖问题而是资源效率问题。5.2 自定义注解的更多落地场景数据权限与操作审计除了事务自定义注解配合 AOP 还能做不少对 MyBatis 整合项目非常有用的事。比如数据权限在查询方法上标注 DataScope切面里动态改写 SQL 条件把当前用户可见的部门或数据范围追加进去。再比如操作审计标注 AuditLog 的方法切面里把入参、返回值、操作人、操作时间统一写入审计表业务代码里完全不用手动记录。实现思路和我上面演示的 OpLog 高度一致区别在于切面里要调用另一个 Mapper 去写审计记录。有一点需要注意如果审计记录 Mapper 和业务 Mapper 在同一个事务里业务出错时审计记录也会一起回滚这通常不是我们想看到的。合理做法是让审计日志的写入走独立事务比如在切面里用 REQUIRES_NEW 传播行为或直接使用单独的数据源连接。这里又要回头理解 AOP 的执行时机——AfterReturning 里写审计日志时外层事务可能还没有提交如果事务最终回滚审计逻辑可能收到的是错误信号。把审计放在 try-catch 的 finally 块里、配上错误容忍逻辑会稳得多。5.3 缓存、重试、限流注解驱动横切逻辑的想象空间AOP 不只是 Spring 独有它已经成为 Java 后端架构里一个基础的扩展手段。配合自定义注解可以封装缓存逻辑标注 Cacheable 的方法切面里先去查 Redis命中则直接返回未命中则执行方法并回填缓存。可以封装重试逻辑标注 Retry 的方法在指定异常时自动重试指定次数。还可以封装限流逻辑标注 RateLimit 的方法基于令牌桶或滑动窗口做流量控制。这些实现的核心骨架都离不开“切点匹配注解—切面执行增强—目标方法照常运行”这个模式。理解了本文示例中的 OpLog 和事务切面你其实已经掌握了这一类扩展的通用写法。在 MyBatis 项目里一个很实用的扩展是慢 SQL 统计切面定义一个哨兵 Mapper 方法或者直接在切面里获取 Mapper 代理的方法签名和参数记录 SQL 执行的耗时超过阈值的单独打印警告。这个方法不需要修改任何 Mapper 代码加一个切面就好对排查线上慢 SQL 非常友好。6. 整合中的配置细节一处疏漏全盘失效6.1 事务管理器与数据源的对应关系Spring 的 Transactional 生效前提是要有一个事务管理器。如果你引入了 spring-boot-starter-jdbc 或 mybatis-spring-boot-starterSpring Boot 会自动创建一个 DataSourceTransactionManager 并注册到容器一般不需要手动配置。但如果你配了多个数据源或者引入了某些特殊连接池事务管理器可能绑定了错误的数据源导致事务和实际执行的 SQL 不在同一个连接上最终“事务没反应”或“不知道回滚到哪”。确认方式很简单在配置类里显式声明一个事务管理器并指定 dataSource如果有多个数据源还要用 Primary 标明主数据源。一个很隐蔽的坑是连接池换成了 Druid但没引入 druid-spring-boot-starter而是单独配置 DruidDataSource 数据源 Bean。这时如果没把事务管理器指向这个 BeanTransactional 用的还是 Spring Boot 自动装配的数据源可能与你真正执行 SQL 的数据源不一致。这类问题往往表现为SQL 正常执行、方法正常提交但回滚时另一条连接上的事务早已提交。所以多数据源项目里把事务管理器明确写出来是最好的防御方式。6.2 包扫描边界Mapper 没注入、切面没匹配MapperScan 的扫描路径如果设置过宽可能扫到其他项目里的同名接口导致启动时报“Invalid bound statement”设置过窄则 Mapper 接口不会被注册注入时直接报 NoSuchBeanDefinitionException。这类问题的排查思路是检查启动类或配置类上的 MapperScan 注解确认扫描的包路径包含了所有 Mapper 接口且没有包含无关接口。也可以改用 Mapper 注解标注每个 Mapper 接口省去扫包的担忧——但那样代码会多一点适合 Mapper 数量不多的项目。切面匹配的扫描边界同样值得警惕。Aspect 类本身要被 Spring 管理如果它放在默认包扫描路径之外切面类不会被加载。这种问题比 Mapper 扫描失效更隐蔽因为启动时没有任何报错只是运行后发现切面没触发。排查方法是在启动日志中搜索切面类的 Bean 创建记录如果根本没创建说明扫描路径没覆盖到。6.3 编译与管理工具链对注解配置的影响注解配置在运行期的生效依赖字节码里保留的注解信息。Java 注解的 Retention 策略为 RUNTIME才能被反射读取这个前面提过。但还有一个容易被忽略的点如果你的构建过程使用了某些字节码插件比如混淆工具、或者旧版本的 Lombok 配合编译参数有误注解信息可能在字节码处理中丢失。同时AOP 切面里 annotation(opLog) 的参数绑定依赖编译器保留方法参数名。Maven 编译器插件中设置了 true 可以保证参数名不被当作 arg0、arg1 之类的占位符替换掉否则切面方法里注解参数绑定就会失败。工具链对代理也有影响。一些框架比如 Spring Native 或 GraalVM 场景下动态代理的创建方式与常规 JVM 不同需要额外的反射配置。如果你的项目将来要迁移到这类环境AOP 部分的配置工作量会大不少。现阶段绝大多数项目仍然运行在传统 JVM 上把握住“注解保留到运行期、参数名保留原样、代理类可生成”这三个前提注解配置和 AOP 就能稳定工作。7. 写在最后的一点实战体会这套东西我踩过最多的坑倒不是原理不清楚而是“以为自己清楚原理一排查发现代理根本没生成”。如果你现在也在为“事务不回滚”“切面不执行”头疼先别急着调代码先把目标 Bean 的类名打出来看看。看见 $Proxy 或 EnhancerByCGLIB 尾巴再往下查切点和异常看不见就从扫描路径和 Bean 注册查起。这条顺序在绝大多数项目里都很有效。文章里的示例工程是个极简骨架实际项目里切面数量更多、注解组合更复杂但核心逻辑永远是这一套AOP 做拦截注解做声明MyBatis 做数据通道把三者之间的代理链条摸透了后面加任何横切功能都能举一反三。