1. 从一个真实痛点说起为什么你的代码里到处都是重复逻辑刚入行那会儿我写过一个用户管理模块注册、登录、改密码、注销四个接口。每个接口里都塞了几乎一样的日志打印、参数校验、事务开启和提交。当时觉得没什么能跑就行。直到有一天产品说“日志格式要统一加上请求来源标识”我打开代码一看四个方法里各有一份日志代码还有另外三个模块也复制了同样的逻辑。那天下午我改了十几个文件改完还漏了两处上线后日志格式一半新一半旧排查问题的时候差点把自己坑死。这件事让我第一次认真思考一个问题那些横跨多个业务方法的公共逻辑到底应该放在哪里日志、事务、权限校验、性能监控、缓存读写这些东西跟业务本身没关系但每个业务方法又都离不开它们。如果每个方法都手写一遍代码会变得又臭又长改一处要动十处这就是典型的代码纠缠和散弹式修改。Spring 的 AOPAspect-Oriented Programming面向切面编程就是来解决这个问题的。它的核心思路很朴素把那些“跟业务无关但业务又需要”的逻辑单独抽出来写成一个个“切面”然后通过配置或注解的方式让这些切面在合适的时机自动“织入”到业务方法的前后。业务代码里只留纯粹的业务逻辑日志、事务这些脏活累活交给切面去干。这篇文章我会从实际使用的角度把 Spring AOP 的核心概念、底层原理、实操配置、常见坑点全部拆开讲一遍。不管你是刚接触 Spring 的新手还是用了几年但一直停留在“会写 Aspect 注解”阶段的开发者应该都能从中拿到一些能直接用的东西。文章里涉及的所有代码和配置都是我实际项目里跑过的你可以直接抄作业。2. 核心概念拆解别被术语吓到它们其实就是这么回事2.1 用“公司报销流程”理解 AOP 的六个核心术语Spring AOP 的文档里有一堆术语切面、连接点、切点、通知、目标对象、代理、织入。第一次看的时候我头都大了后来发现用公司报销流程来类比一下子就清楚了。假设你是一家公司的员工你要报销一笔差旅费。你的核心动作是“填报销单”但报销这件事不只是填单子还需要财务审核、领导签字、出纳打款。这些环节就是围绕“报销”这个核心动作的横切逻辑。切面Aspect整个报销流程的规则集合包括审核标准、签字层级、打款方式。在代码里一个用Aspect标注的类就是一个切面。连接点Join Point报销流程中所有可能被插入额外操作的时机点比如“提交前”“审核后”“打款前”。在 Spring AOP 里连接点就是方法的执行时机。切点Pointcut你实际选择在哪些时机点插入操作。比如你只关心“提交前”这个点那这个点就是切点。切点通过表达式来定义比如execution(* com.example.service.*.*(..))。通知Advice在切点真正执行的操作。比如“提交前打印日志”“审核后发送通知”。通知分为前置、后置、环绕、异常、最终五种类型。目标对象Target被切面织入的原始业务对象也就是你的 Service 实例。代理ProxySpring 为了让切面生效会为目标对象生成一个代理对象你调用的是代理代理在调用目标方法前后插入了切面逻辑。织入Weaving把切面逻辑插入到目标方法的过程。Spring AOP 是在运行时通过动态代理完成织入的。把这六个概念串起来就是你定义了一个切面切面里配置了切点和通知Spring 在运行时为目标对象生成代理代理在切点对应的连接点上执行通知完成织入。2.2 通知的五种类型什么时候该用哪一种通知类型决定了切面逻辑在目标方法执行的哪个阶段生效。很多人写切面的时候只会用Around其实五种类型各有适用场景选对了代码更清晰。通知类型注解执行时机典型用途前置通知Before目标方法执行前参数校验、权限检查、日志记录后置返回通知AfterReturning目标方法正常返回后返回结果加工、缓存写入、成功日志后置异常通知AfterThrowing目标方法抛出异常后异常日志、告警发送、事务回滚标记后置最终通知After目标方法执行后无论成败资源释放、清理上下文环绕通知Around包裹目标方法全程性能监控、事务控制、缓存读写我个人的经验是能用前置/后置解决的就别用环绕。环绕通知虽然强大但写起来复杂容易忘记调用proceed()导致业务方法不执行而且异常处理逻辑要自己写稍不注意就会吞掉异常。只有在需要控制目标方法是否执行、或者需要修改返回值的时候才用环绕。2.3 切点表达式精准定位你要拦截的方法切点表达式是 AOP 里最容易写错的部分。Spring AOP 支持多种切点指示器最常用的是execution还有annotation、within、this、target等。execution的完整语法是execution(修饰符? 返回类型 包名.类名.方法名(参数类型) 异常类型?)其中?表示可选*表示任意..表示任意参数或任意层级包。几个实际项目里常用的例子// 拦截 com.example.service 包下所有类的所有 public 方法 Pointcut(execution(public * com.example.service.*.*(..))) // 拦截所有以 save 开头的方法 Pointcut(execution(* com.example.*.save*(..))) // 拦截标注了 Loggable 注解的方法 Pointcut(annotation(com.example.annotation.Loggable)) // 拦截 com.example.controller 包及其子包下所有类的所有方法 Pointcut(execution(* com.example.controller..*.*(..)))注意execution表达式里的包名不要写错*和..的区别要搞清楚。com.example.service.*只匹配 service 包下的类不包含子包com.example.service..*才包含子包。我见过有人把..写成*结果子包里的方法死活拦不住排查了半天。3. 底层原理Spring AOP 到底是怎么把切面“塞”进你的方法里的3.1 动态代理JDK 代理和 CGLIB 代理的选择逻辑Spring AOP 的底层依赖动态代理。动态代理分两种JDK 动态代理和 CGLIB 动态代理。它们的选择逻辑很简单如果目标对象实现了至少一个接口Spring 默认使用JDK 动态代理。如果目标对象没有实现任何接口Spring 使用CGLIB 动态代理。你也可以通过proxy-target-classtrue强制使用 CGLIB。JDK 动态代理的原理是在运行时创建一个实现了目标对象所有接口的代理类代理类持有目标对象的引用在调用接口方法时插入切面逻辑。它的优点是 JDK 原生支持不需要额外依赖缺点是只能代理接口方法目标类自己的方法代理不了。CGLIB 动态代理的原理是在运行时生成目标类的子类覆盖目标类的非 final 方法在覆盖方法里插入切面逻辑。它的优点是可以代理没有接口的类缺点是生成子类需要额外开销而且无法代理 final 方法和 private 方法。我实测下来的经验是如果你的 Service 有接口用 JDK 代理就够了性能更好如果没有接口或者你需要代理类自己的方法就用 CGLIB。Spring Boot 从 2.x 开始默认使用 CGLIB因为很多项目为了省事不写接口直接用类。3.2 代理对象的创建时机Bean 生命周期里的那一步Spring 在创建 Bean 的时候会检查这个 Bean 是否被切面匹配。如果匹配就不返回原始对象而是返回代理对象。这个过程发生在 Bean 的初始化之后具体是在BeanPostProcessor的postProcessAfterInitialization方法里。你可以这样理解Spring 容器启动时先按正常流程创建你的 Service Bean然后 AOP 基础设施会问一句“这个 Bean 有没有被切面盯上”如果有就生成一个代理对象替换原始对象放进容器。之后你从容器里拿到的就是代理对象调用方法时自然就走了切面逻辑。这里有一个非常重要的点只有从 Spring 容器里拿到的 Bean 才是代理对象。如果你在代码里直接new了一个 Service 实例切面是不会生效的。这也是为什么有些人在工具类里手动 new 对象发现日志切面没打印还以为是切面写错了。3.3 自调用失效一个几乎每个人都踩过的坑自调用失效是 Spring AOP 最经典的坑。什么叫自调用就是一个 Service 类里的 A 方法调用了同一个类里的 B 方法而 B 方法上配置了切面。这时候你调用 A 方法B 方法的切面不会生效。原因很简单你从容器里拿到的是代理对象调用 A 方法时走的是代理代理执行完切面逻辑后调用目标对象的 A 方法。但在 A 方法内部调用 B 方法时用的是this.B()这个this是目标对象本身不是代理对象。所以 B 方法的切面逻辑根本没有机会执行。解决办法有三个把 B 方法挪到另一个 Service 里通过注入的方式调用。这是最推荐的做法符合单一职责原则。注入自己在 Service 里注入ApplicationContext或者自身类型的 Bean通过context.getBean(Service.class).B()调用。这样拿到的是代理对象切面会生效。使用 AopContext.currentProxy()需要开启exposeProxytrue然后通过((Service) AopContext.currentProxy()).B()调用。这个方式代码侵入性较强不太推荐。我个人的建议是优先用第一种方案。自调用本身往往意味着职责划分不够清晰把方法拆到不同的 Service 里既解决了 AOP 失效问题又让代码结构更合理。4. 实操落地从零搭一个可复用的日志切面4.1 环境准备与依赖引入下面我以一个 Spring Boot 项目为例完整走一遍 AOP 的配置流程。假设你已经有一个能跑起来的 Spring Boot 项目版本 2.7.x 或 3.x 都可以。首先引入 AOP 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency这个 starter 会自动引入spring-aop和aspectjweaver并且自动配置 AOP 支持。如果你用的是非 Spring Boot 项目需要手动引入aspectjweaver并在配置类上添加EnableAspectJAutoProxy。4.2 定义一个自定义注解作为切点标记直接拦截整个包的方法有时候太粗暴更好的做法是定义一个注解只拦截标注了该注解的方法。这样切面的作用范围更精准也更容易维护。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface LogRecord { String value() default ; boolean recordParams() default true; boolean recordResult() default true; }这个注解有三个属性value用于描述操作类型recordParams和recordResult控制是否记录参数和返回值。这样不同的方法可以根据需要灵活配置。4.3 编写日志切面完整代码与逐行解析Aspect Component Slf4j public class LogRecordAspect { Around(annotation(logRecord)) public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable { long startTime System.currentTimeMillis(); String methodName joinPoint.getSignature().toShortString(); Object[] args joinPoint.getArgs(); // 前置记录请求日志 if (logRecord.recordParams()) { log.info([{}] 方法 {} 开始执行参数{}, logRecord.value(), methodName, Arrays.toString(args)); } else { log.info([{}] 方法 {} 开始执行, logRecord.value(), methodName); } Object result null; try { // 执行目标方法 result joinPoint.proceed(); // 后置返回记录结果日志 if (logRecord.recordResult()) { log.info([{}] 方法 {} 执行成功返回值{}, logRecord.value(), methodName, result); } else { log.info([{}] 方法 {} 执行成功, logRecord.value(), methodName); } return result; } catch (Throwable e) { // 后置异常记录异常日志 log.error([{}] 方法 {} 执行异常异常信息{}, logRecord.value(), methodName, e.getMessage(), e); throw e; } finally { // 最终记录耗时 long cost System.currentTimeMillis() - startTime; log.info([{}] 方法 {} 执行结束耗时{}ms, logRecord.value(), methodName, cost); } } }逐行解析几个关键点Around(annotation(logRecord))表示拦截所有标注了LogRecord注解的方法并且把注解实例注入到方法参数logRecord里这样切面里可以直接读取注解的属性。joinPoint.proceed()是执行目标方法的关键必须调用否则业务方法不会执行。它的返回值就是目标方法的返回值。异常处理里throw e很重要不能吞掉异常否则上层调用者感知不到失败。finally块里记录耗时无论成功失败都会执行适合做性能监控。4.4 在业务方法上使用切面Service public class OrderService { LogRecord(value 创建订单, recordParams true, recordResult true) public Order createOrder(CreateOrderRequest request) { // 纯业务逻辑不需要写任何日志代码 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setAmount(request.getAmount()); order.setStatus(OrderStatus.CREATED); orderRepository.save(order); return order; } LogRecord(value 查询订单, recordParams true, recordResult false) public Order queryOrder(String orderNo) { return orderRepository.findByOrderNo(orderNo); } }这样业务代码里干干净净日志逻辑全部集中在切面里。以后要改日志格式只改切面一个地方就行。4.5 切面执行顺序控制Order 注解的实际效果一个项目里往往有多个切面比如日志切面、事务切面、权限切面。它们的执行顺序会直接影响结果。比如权限校验应该在日志记录之前事务切面应该在业务方法最外层。Spring 通过Order注解或者Ordered接口来控制切面顺序。数值越小优先级越高越先执行。Aspect Component Order(1) public class PermissionAspect { ... } Aspect Component Order(2) public class LogRecordAspect { ... } Aspect Component Order(3) public class TransactionAspect { ... }执行顺序是PermissionAspect 的前置逻辑 → LogRecordAspect 的前置逻辑 → TransactionAspect 的前置逻辑 → 目标方法 → TransactionAspect 的后置逻辑 → LogRecordAspect 的后置逻辑 → PermissionAspect 的后置逻辑。注意Order只对同类型的通知有效。如果两个切面都用了Around顺序按Order来如果一个用Before一个用AroundBefore会在Around的proceed()之前执行这个顺序不受Order控制。实际项目中建议统一用Around或者统一用Before/After避免顺序混乱。5. 常见问题与排查技巧实录5.1 切面不生效的六种原因与排查路径切面不生效是最高频的问题。我整理了一个排查清单按顺序检查基本能定位到原因。排查项检查内容解决方法依赖是否引入 spring-boot-starter-aop补上依赖注解切面类是否有 Aspect 和 Component补上注解切点切点表达式是否匹配目标方法用日志打印切点匹配情况代理目标对象是否从容器获取改为注入方式获取自调用是否存在类内部方法调用拆分方法或注入自身final目标方法是否为 final/private改为非 final 的 public 方法其中“切点表达式是否匹配”最难排查。我的技巧是在切面里加一行日志打印joinPoint.getSignature()如果这行日志没打印说明切点没匹配上。然后用Pointcut把表达式单独抽出来逐步缩小范围测试。5.2 环绕通知里忘记调用 proceed() 的后果这个坑我踩过一次排查了一个小时。当时写了一个缓存切面逻辑是“先查缓存缓存有就返回没有就查数据库”。结果代码写成了Around(...) public Object cache(ProceedingJoinPoint joinPoint) throws Throwable { String key buildKey(joinPoint); Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } // 忘记调用 joinPoint.proceed() 了 return null; }结果所有走这个切面的方法都返回 null数据库根本没查。原因是缓存没命中时代码直接 return null 了没有调用proceed()执行目标方法。记住一个原则环绕通知里除了缓存命中等明确要短路的情况其他所有路径都必须调用proceed()。建议在方法开头就写好Object result joinPoint.proceed();的骨架再往里填逻辑。5.3 切面里获取方法参数的几种方式对比在切面里获取目标方法的参数有好几种方式各有适用场景。// 方式一通过 JoinPoint.getArgs() Object[] args joinPoint.getArgs(); // 方式二通过 Around 的参数绑定 Around(execution(* com.example.service.*.*(..)) args(param1, param2)) public Object around(ProceedingJoinPoint jp, String param1, Integer param2) { ... } // 方式三通过反射获取方法签名 MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); Parameter[] parameters method.getParameters();方式一最通用但拿到的是 Object 数组需要自己按顺序对应参数名。方式二最直观但切点表达式要写得很精确方法参数变化时表达式也要跟着改。方式三可以拿到参数名和注解信息适合需要做参数级处理的场景。我一般用方式一然后在切面里通过MethodSignature拿到参数名数组和getArgs()的返回值一一对应拼成一个 Map 方便日志输出。5.4 性能考量切面多了会不会拖慢系统这是很多人关心的问题。我的实测数据是一个简单的日志切面单次执行耗时在 0.1ms 到 0.5ms 之间具体取决于日志输出的复杂度和参数序列化的开销。对于 QPS 几千的系统这个开销基本可以忽略。但有几个地方需要注意避免在切面里做重量级操作比如同步写数据库、调用远程接口。这些操作应该异步化或者放到消息队列里。参数序列化要控制不要无脑把大对象转 JSON 打日志。我见过有人把整个 List 结果集打进日志一次请求打了几 MB 的日志磁盘直接爆了。切点表达式要尽量精确不要用execution(* *(..))这种全匹配否则每个方法调用都要走一遍切点匹配逻辑。如果确实需要做性能监控建议用Around记录耗时但只记录超过阈值的慢请求避免日志量过大。6. 进阶用法把 AOP 用在事务、缓存和权限上6.1 事务切面Transactional 背后的 AOP 机制Spring 的Transactional本质上就是一个 AOP 切面。它的切点匹配所有标注了Transactional的方法通知逻辑是方法执行前开启事务方法正常返回后提交事务方法抛出异常后回滚事务。理解这一点之后很多事务失效的场景就说得通了自调用失效同类方法调用不走代理事务不生效。private 方法失效CGLIB 无法代理 private 方法。异常类型不对默认只回滚RuntimeException和Error受检异常不回滚。需要配置rollbackFor Exception.class。多数据源未指定多个事务管理器时不指定transactionManager会报错。我实际项目里的做法是Service 层方法统一加Transactional(rollbackFor Exception.class)并且避免在同类里自调用事务方法。如果确实需要自调用就把事务方法拆到另一个 Service 里。6.2 缓存切面自定义注解实现方法级缓存除了 Spring 自带的Cacheable我们也可以自己写一个缓存切面灵活性更高。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface MethodCache { String key(); int expireSeconds() default 300; } Aspect Component public class MethodCacheAspect { Autowired private RedisTemplateString, Object redisTemplate; Around(annotation(methodCache)) public Object cache(ProceedingJoinPoint joinPoint, MethodCache methodCache) throws Throwable { String key buildCacheKey(joinPoint, methodCache.key()); Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } Object result joinPoint.proceed(); if (result ! null) { redisTemplate.opsForValue().set(key, result, methodCache.expireSeconds(), TimeUnit.SECONDS); } return result; } private String buildCacheKey(ProceedingJoinPoint joinPoint, String keyPrefix) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); String methodName signature.getMethod().getName(); Object[] args joinPoint.getArgs(); return keyPrefix : methodName : Arrays.toString(args); } }这个切面的关键点是缓存 key 的构建。我用“前缀 方法名 参数数组”的方式生成 key简单直接。但要注意参数对象的toString()方法如果参数是复杂对象且没有重写toString()生成的 key 会包含对象地址导致缓存永远命中不了。解决办法是让参数对象重写toString()或者用 JSON 序列化参数。6.3 权限切面方法级权限校验的轻量实现权限校验也是 AOP 的经典应用场景。下面是一个基于注解的权限校验切面Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String[] value(); } Aspect Component Order(1) public class PermissionAspect { Around(annotation(requirePermission)) public Object check(ProceedingJoinPoint joinPoint, RequirePermission requirePermission) throws Throwable { String[] required requirePermission.value(); SetString userPermissions getCurrentUserPermissions(); for (String permission : required) { if (!userPermissions.contains(permission)) { throw new PermissionDeniedException(缺少权限 permission); } } return joinPoint.proceed(); } private SetString getCurrentUserPermissions() { // 从当前登录用户上下文获取权限集合 return UserContext.getCurrentUser().getPermissions(); } }这个切面用Order(1)保证在日志切面之前执行权限不通过直接抛异常不会进入业务逻辑也不会打业务日志。注意权限切面里不要做太重的操作比如每次调用都查数据库。建议把用户权限缓存在本地缓存或者 Redis 里减少数据库压力。7. 我踩过的那些坑和总结出来的经验7.1 切面里注入 Bean 为 null 的问题在切面类里用Autowired注入其他 Bean有时候会报空指针。原因通常是切面类没有被 Spring 管理或者切面类的创建时机早于依赖的 Bean。解决办法确保切面类上有Component注解并且注入的 Bean 也在容器里。如果确实存在循环依赖可以用Lazy延迟注入或者通过ApplicationContext在方法执行时动态获取。7.2 切面日志太多导致磁盘爆满这个问题我在生产环境遇到过。日志切面把每个方法的参数和返回值都打出来结果一天产生了 50GB 日志磁盘直接写满服务挂了。后来我做了三件事参数日志只记录关键字段不记录整个对象。比如订单对象只记录订单号和金额不记录商品明细。大结果集不记录返回值只记录结果条数。日志级别动态调整正常情况用 INFO 记录方法名和耗时需要排查问题时临时开启 DEBUG 记录详细参数。7.3 切面顺序不对导致事务不回滚有一次我写了一个异常处理切面用Around捕获了所有异常并转成了统一返回格式。结果发现事务不回滚了。原因是异常处理切面的Order值比事务切面小先执行它把异常吞掉了事务切面感知不到异常自然不回滚。解决办法是调整Order值让事务切面的优先级最高Order值最小确保事务切面能感知到原始异常。或者异常处理切面在捕获异常后重新抛出让事务切面处理。7.4 关于 AOP 使用边界的个人建议AOP 很强大但不是所有横切逻辑都适合用 AOP。我的判断标准是适合用 AOP日志、事务、缓存、权限、性能监控、幂等控制。这些逻辑跟业务无关且多个方法都需要。不适合用 AOP业务规则校验、数据转换、流程编排。这些逻辑跟业务强相关放在切面里会让代码更难理解和维护。另外切面不要写得太复杂。一个切面只做一件事切点表达式尽量精确通知逻辑尽量简单。如果发现切面里出现了复杂的条件分支和业务判断那说明这个逻辑可能不应该放在切面里。最后分享一个小技巧在开发阶段可以在切面里加一行log.debug(切面命中{}, joinPoint.getSignature())这样能快速确认切点是否匹配到了预期的方法。上线前把日志级别调到 INFO 以上这行日志就不会输出不影响性能。