Spring Boot注解实战:从参数绑定到自定义切面的组合拳
最近在给团队做代码评审连着看了几个项目的接口和事务部分发现一个很有意思的现象Spring Boot 的注解大家都会写但真正把注解“用对”的人并不多。很多人知道GetMapping是接收 GET 请求但并不知道为什么接口上明明加了Transactional却照样不回滚知道RestControllerAdvice能统一捕获异常却不知道多个切面同时存在时执行顺序会失控更常见的是自定义注解做操作日志时切面怎么都不生效最后查了一圈才发现是Retention写成了CLASS。说白了注解这个东西单个看都不难难的是在真实项目里把它们组合起来用。每一个注解背后都牵扯到 Spring 的代理机制、Bean 生命周期、事务传播行为和 AOP 拦截顺序只要有一个环节理解不到位线上就是各种玄学 Bug。这篇文章我不打算讲教科书式的注解清单而是按实战项目里的真实场景把 Controller 层参数绑定、数据校验、MyBatis 数据访问、事务控制、自定义注解落地这几个环节串起来顺带把注解引起的性能问题长事务、异步线程池失控、序列化超时、启动变慢一并整理了。内容偏向“干过活的人”的视角适合已经写过一段时间 Spring Boot、但想系统梳理注解用法和排查经验的开发者。看完之后你可以直接拿着里面的代码和排查清单去对照自己的项目。1. 注解驱动开发为什么实战里讲究“组合拳”1.1 从 XML 到注解Spring Boot 简化配置的设计逻辑早期 Spring 那套 XML 配置我相信老一批开发者都还有心理阴影一个项目里动辄十几个 xmlbean、property、constructor-arg来回嵌套改一个对象依赖关系要全局搜索替换。后来 Spring 引入注解本质上是把“配置”这件事实行了内聚——让 Bean 的声明、依赖关系、行为定义直接写在使用者身边代码即文档。Spring Boot 把这条路走到了极致核心就是“约定优于配置”。你不需要手动注册一大堆 Bean因为SpringBootApplication本身就是一个组合注解展开来看是三个注解的叠加SpringBootApplication // 等价于下面三个注解的组合 Configuration // 标记这是一个配置类允许定义 Bean EnableAutoConfiguration // 打开自动配置机制 ComponentScan // 默认扫描当前包及其子包这里我建议每个人都在 IDE 里按住 Ctrl 点进SpringBootApplication源码看一眼你会看到AliasFor这种注解元标注meta-annotation的用法它把两个注解中的属性互相绑定让exclude、scanBasePackages这些参数能直接写在顶层注解上。理解了AliasFor再看 Spring 里大量的“组合注解”设计就会豁然开朗。但组合不是乱加。真实项目里我总结了三套出现频率最高的注解组合基本上所有业务接口都离不开它们场景核心注解组合解决的问题Controller 入参接受与校验RestControllerValidatedValid Bean Validation 约束注解参数接收、格式校验、错误快速返回Service 层数据一致性与访问TransactionalMapper系列注解事务边界、数据库读写映射横切关注点日志、限流、权限自定义注解 AspectAround无侵入地给业务方法附加通用能力这三个组合不是孤立存在的一个请求从进入 Controller 到写库往往是第一套负责接参第二套负责数据第三套包裹在外部做横切。你如果能把这套链条在脑子里串起来后面排查问题会顺很多。1.2 为什么单个注解“看起来对”却不生效项目里最常见的 BUG 并不是注解写错而是注解“看起来写对了实际上没生效”。根本原因在于注解只是元数据它本身没有任何行为必须有一个“解释器”去读取它并触发逻辑。Spring 里这个解释器绝大多数是 BeanPostProcessor 加上动态代理也就是熟知的 AOP。举个典型例子。Transactional的生效依赖 Spring AOP 生成一个代理对象事务逻辑包在代理里如果你的 Service 类被final修饰CGLIB 无法生成子类代理Transactional就会静默失效。再比如自定义注解你定义了注解、也写了切面但Retention取值如果只保留到CLASS阶段运行时反射根本读不到切面自然不触发。这也是为什么我一直强调“组合拳”思维注解要生效背后必须有完整的配套链路。写注解之前先问自己三件事这个注解的作用目标是类还是方法运行时是否还能被反射读取配套的处理器比如切面、校验器、配置类是否已经被 Spring 接管把这三件事想清楚比背一百个注解 API 都管用。2. Controller 到数据层核心注解的组合规范2.1 Controller 层组合RestController 与参数绑定的实战细节现在项目基本都用RestController它本身是ControllerResponseBody的组合省去了每个方法写ResponseBody的重复劳动。但我见过不少新手在控制器里混用RestController和Controller返回视图时又忘了加ResponseBody导致返回了一个 JSON 字符串却是 “text/html” 的 Content-Type前端解析直接报错。这个坑虽然低级但线上真的遇到过团队里定了规范纯 API 服务一律RestController绝不混用。参数绑定这块RequestParam、PathVariable、RequestBody、RequestHeader四件套务必分清使用场景RestController RequestMapping(/api/users) public class UserController { // GET /api/users/1001?fieldsname,ageverbosetrue GetMapping(/{id}) public ResultUserVO getUser( PathVariable(id) Long id, // URL 路径参数 RequestParam(value fields, required false) String fields, // 查询参数 RequestParam(value verbose, defaultValue false) boolean verbose ) { // ... } // POST /api/users body: {name:xiao,age:18} PostMapping public ResultLong createUser(Valid RequestBody UserCreateDTO dto) { // ... } }有几个实操要点第一RequestParam默认required true前端没传参数直接 400。如果参数可空必须显式写required false或给defaultValue否则联调时你会被前端“为什么没传就报错”问得很尴尬。第二GET 请求千万不要配RequestBody。HTTP 语义上 GET 可以有 body但大多数框架和网关、浏览器代理都会剥离或忽略它Spring 解析时也可能读到空对象导致反序列化异常。凡是 GET 接口的筛选条件统一用RequestParam或路径参数。第三PathVariable(id)的字符串值要和路径模板里的占位符一致。如果你在 IDEA 里设置了-parameters编译参数参数名能被编译器保留可以省略value但为了兼容性和团队协作我建议显式写出来不要依赖编译器参数这种隐性行为。顺带回答一个很多人在知乎上问的问题对外部第三方提供的 OpenAPI 接口到底要不要单独拆服务我的经验是如果只是几个接口放在同一个服务里用独立模块controller.open包即可但必须在RequestMapping上用统一的路径前缀区分比如/open/api/v1/**如果第三方流量和内部接口差异很大频率、鉴权、限流策略完全不同那就拆独立网关路由或独立服务避免互相拖垮。这个决策和注解本身无关但和RequestMapping的路由规划直接相关路由设计好了后续加限流、加鉴权切面才省事。2.2 数据校验组合Validated Bean Validation 的层次划分Spring Boot 2.3 之前spring-boot-starter-web默认带 Bean ValidationHibernate Validator2.3 之后改成了可选依赖如果你Valid不生效先检查有没有引入spring-boot-starter-validation。这个坑我至少帮三个同事排查过。校验注解的用法不是简单的“在字段上加NotNull”而是要分两个层次字段校验实体 DTO 内部字段加NotNull、NotBlank、Size、Min、Pattern等约束注解。方法参数校验入参是基本类型或RequestParam时需要在 Controller 类上加Validated让 Spring 启动方法级校验MethodValidationPostProcessor并在参数上直接加约束注解。RestController RequestMapping(/api/orders) Validated // 开启方法级校验用于 RequestParam/PathVariable 上的约束 public class OrderController { GetMapping(/page) public ResultPageVOOrderVO page( RequestParam Min(1) int page, RequestParam Max(100) Min(1) int size ) { // ... } PostMapping public ResultLong create(Valid RequestBody OrderCreateDTO dto) { // Valid 触发 dto 内部的约束校验 } }注意Validated和Valid的区别Valid是 JSR-303 标准注解可以触发级联校验对象内嵌套对象继续校验Validated是 Spring 增强版支持分组校验也能让RequestParam上的约束在方法调用前被验证。Controller 类上加了ValidatedRequestBody里的嵌套 DTO 才会递归校验只加Valid的话方法参数上的Min(1)这类约束不会触发。分组校验是进阶玩法我一般用在“新增和更新共用同一个 DTO”的场景。通过Validated(UpdateGroup.class)指定分组NotNull(groups UpdateGroup.class)让 id 字段只在更新时必填。这个组合能少写一整套 DTO但分组类定义要提前约定好否则代码可读性反而下降。校验失败时MethodArgumentNotValidException和ConstraintViolationException要分别在RestControllerAdvice中处理前者对应RequestBody校验失败后者对应方法参数校验失败。这个细节非常影响错误信息格式我在第 5 章的问题排查里会再展开。2.3 数据访问层组合MyBatis 注解与事务注解的黄金搭配现在很多项目用 MyBatis注解式 SQL 在简单 CRUD 上非常清爽。最基础的组合是给 Mapper 接口加Mapper然后在启动类或配置类上加MapperScan扫描包否则 Spring 不会为这些接口生成代理实现MapperScan(com.example.project.mapper) SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }写 SQL 注解时几个容易踩的点第一多参数必须用Param绑定。MyBatis 解析 SQL 中的#{name}时如果参数多于一个且没有Param它默认用param1、param2或者直接抱错。实际项目中我要求所有 Mapper 方法参数都强制加Param哪怕只有一个参数保持一致性。第二动态 SQL 在注解里的写法是script标签包裹。很多人用惯了 XML 里的whereif在注解里就卡住了。其实可以直接内联Select(script select * from user where 11 if testname ! null and name ! \\and name like concat(%, #{name}, %)/if order by id desc /script) ListUserDO search(Param(name) String name);注意script内的字符串拼接很容易因为引号转义出问题我建议复杂 SQL 还是回 XML Mapper 写注解模式只承接简单 SQL。这不是偷懒纯粹是维护成本的考量——拼接第五个if时你就会明白为什么 MyBatis 官方更推荐 XML。第三自增主键回填用Options(useGeneratedKeys true, keyProperty id)写在Insert旁边插入后主键直接回填到入参对象的 id 字段。很多人不知道这个组合插入完再查一遍主键白白多一次查询。第四字段映射下划线转驼峰。在application.yml里配置map-underscore-to-camel-case: true而不是在每个查询上写Results。我见过有人把几十个查询都写了ResultsSQL 改了字段还要同步改映射纯属自虐。遇到需要自定义映射的极少数场景再用ResultsResult组合处理。事务注解Transactional是 Service 层的事不要在 Controller 层加。我自己在团队里定的规范是事务只出现在 Service 实现类的业务方法上Controller 只做参数接收和响应封装。为什么因为 Controller 里有参数校验、有响应转换这些 IO 和计算如果被包进事务事务时长会被无谓拉长直接坑掉数据库连接池。这个点放到第 4 章性能部分细说。Transactional默认只对RuntimeException和Error回滚受检异常比如IOException不会触发回滚。如果业务里确实需要“受检异常也回滚”必须显式声明Transactional(rollbackFor Exception.class) public void processOrder(OrderDTO dto) throws BusinessException { // ... }顺带提醒BusinessException这种自定义异常建议继承RuntimeException一方面省去方法签名上写受检异常的麻烦另一方面也符合 Spring 对“未预期异常应导致事务回滚”的设计直觉。3. 自定义注解 AOP日志切面的完整落地3.1 自定义注解的基本功Target 与 Retention 的选择不少同学已经写过自定义注解但多半是从网上抄了个模板说不出为什么这么写。这里我把基本功讲透。定义注解时Target决定它能标在哪里Retention决定它能活多久Target({ElementType.METHOD, ElementType.TYPE}) // 可以标在方法上也可以标在类上 Retention(RetentionPolicy.RUNTIME) // 运行时保留反射可读 Documented public interface OperationLog { String module() default ; // 所属模块 String action() default ; // 操作类型如 add/update/delete String remark() default ; // 备注 }Retention有三个取值SOURCE编译期丢弃、CLASS保留到 class 文件但运行时不可反射、RUNTIME运行时可见。Spring AOP 的切面是在运行时通过反射读取目标方法上的注解来匹配的所以业务切面用的注解必须选RUNTIME。如果你选了CLASS切面匹配会直接失败而且失败得很安静没有报错非常阴。Inherited是可选的它表示子类能否继承父类上的该注解。注意Inherited对“父类方法上的注解”并不生效只对“父类类上的注解”生效很多人在这里有误解。业务切面通常标在方法上所以大部分场景不需要Inherited。3.2 OperationLog 从零到上线的完整实现我拿一个真实落地的OperationLog组件举例。目标是在用户管理、订单管理等模块的关键操作上记录操作人、操作模块、操作内容、执行耗时异步写入日志表。第一步定义注解本身字段用module、action、description三个属性就够了别设计得太重太重的注解会让业务代码可读性变差。第二步写切面Aspect Component public class OperationLogAspect { private final OperationLogService logService; private final ObjectMapper objectMapper; public OperationLogAspect(OperationLogService logService, ObjectMapper objectMapper) { this.logService logService; this.objectMapper objectMapper; } Around(annotation(operationLog)) public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start System.currentTimeMillis(); boolean success true; String errorMsg null; try { return joinPoint.proceed(); } catch (Throwable e) { success false; errorMsg e.getMessage(); throw e; } finally { long cost System.currentTimeMillis() - start; OperationLogRecord record buildRecord(joinPoint, operationLog, success, errorMsg, cost); // 异步写库这里用简单线程池生产环境建议塞 MQ 或者用 Async logService.saveAsync(record); } } private OperationLogRecord buildRecord(ProceedingJoinPoint joinPoint, OperationLog operationLog, boolean success, String errorMsg, long cost) { OperationLogRecord record new OperationLogRecord(); record.setModule(operationLog.module()); record.setAction(operationLog.action()); record.setMethod(joinPoint.getSignature().toShortString()); record.setArgs(getArgsJson(joinPoint.getArgs())); record.setSuccess(success); record.setErrorMsg(errorMsg); record.setCostMs(cost); record.setOperator(CurrentUserHolder.getUserId()); // 从上下文中取操作人 return record; } private String getArgsJson(Object[] args) { try { return objectMapper.writeValueAsString(args); } catch (Exception e) { return []; } } }这个切面有几个细节值得注意用Around(annotation(operationLog))直接把注解实例作为切点参数绑定切面方法里直接拿到注解的字段值不用再去Method上反射获取性能更好代码更简洁。finally块里写日志保证异常也能记录。但注意finally里不要再抛出异常否则会覆盖业务异常所以我用saveAsync异步落库既不影响主流程 RT也避免了日志写库失败拖垮业务。参数序列化要防止敏感信息泄漏比如密码、Token 不能打到日志里。我在getArgsJson里会对包含密码字段的 DTO 做脱敏处理实际项目中用JsonIgnore标注敏感字段再序列化或者直接过滤黑名单参数名。第三步在业务方法上使用Service public class UserService { OperationLog(module 用户管理, action 新增用户, description 创建新账号) Transactional(rollbackFor Exception.class) public Long createUser(UserCreateDTO dto) { // ... } }这里就是“组合拳”的典型应用OperationLog负责审计Transactional负责数据一致性两个注解互不干扰但共同工作。如果自定义注解的切面和事务切面都有注意顺序——日志切面放在最外层更好这样记录的耗时包含事务提交时间审计信息更全。3.3 切面顺序与反射性能的取舍多个切面同时作用于一个方法时执行顺序由Order或Ordered接口控制。Order(1)数值越小优先级越高对它做Around时先进入Order不写时默认Ordered.LOWEST_PRECEDENCE执行顺序不保证这点切记。我常用的顺序策略是限流切面最外层最先拦截、快速拒绝日志切面次之事务切面最内层离业务最近。原因很直观被限流拒绝的请求不应该产生业务日志更不应该占用数据库连接而日志如果想记录事务是否提交成功就必须包裹在事务切面外层。性能方面自定义注解切面的主要开销在反射和参数序列化。避免在切面里频繁调用method.getAnnotation()或者反复解析 SpEL 表达式。annotation(operationLog)绑定已经把注解实例传进来了如果需要解析表达式比如Cacheable那种 SpEL key建议把SpelExpressionParser和EvaluationContext缓存起来不要每次执行时 new 一个 Parser。另外Java 编译器有一个“增量注解处理”incremental annotation processing机制IDEA 里偶尔会看到jps: 增量注解进程已禁用的提示。如果你在跑自定义注解处理器比如 Lombok、MapStruct这个提示意味着增量编译可能不可用但不影响最终产物。真要排查编译期注解相关的问题可以先做一次 clean build排除增量编译缓存干扰。4. 注解与性能优化接口变慢的隐形坑4.1 Transactional 滥用导致的长事务与连接池耗尽这是我在实战里见到影响面最大的性能杀手没有之一。很多人习惯在 Service 类上直接加Transactional于是整个类的所有方法都被事务包裹问题来了一个方法里既有数据库操作又调第三方 RPC还做了 Excel 导出整个过程持有一个数据库连接。HikariCP 默认最大连接数是 10。当并发上来10 个连接全部被长事务占住后续请求全部进入等待接口 RT 直线飙升最终连接池抛Connection is not available, request timed out。而且连接池满了之后在途请求会堵在 Tomcat 线程池里线程池又会被耗尽整个应用呈现假死状态。优化原则很简单说三遍都不为过事务范围越小越好事务内不要有远程调用和 IO。// 错误示范整个方法都在事务里包含 3 次 RPC 调用 Transactional public void createOrder(OrderDTO dto) { saveOrder(dto); userClient.deductBalance(dto.getUserId(), dto.getAmount()); // 远程调用 warehouseClient.lockStock(dto.getItems()); // 远程调用 } // 更合理的做法先做远程调用和前置校验最后才开事务写库 public void createOrder(OrderDTO dto) { userClient.deductBalance(dto.getUserId(), dto.getAmount()); warehouseClient.lockStock(dto.getItems()); saveOrderWithTx(dto); // 这个方法内部单独加 Transactional }另外Transactional(readOnly true)用在纯查询方法上可以让数据库优化器走只读路径也允许部分数据库如 PostgreSQL跳过某些锁但这个收益通常不大别把它当银弹重点是缩短事务时长。如果某些场景没法用声明式事务精确控制边界我建议直接用TransactionTemplate做编程式事务Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void createOrder(OrderDTO dto) { // 先做远程调用和校验... transactionTemplate.execute(status - { saveOrder(dto); updateStock(dto.getItems()); return null; }); } }TransactionTemplate的最大优势是事务边界完全在你的掌控中不会因为“顺手加了个注解”把整个方法甚至整个类拖下水。遇到嵌套事务时也比注解的PROPAGATION_REQUIRES_NEW更容易理解。4.2 Async 的线程池陷阱默认执行器并不适合生产Async让方法异步执行看起来很简单但生产环境里十个配置九个是裸奔的。如果不做任何配置Spring 框架层面的默认执行器是SimpleAsyncTaskExecutor它每次执行都新开一个线程既不复用也不限制并发高峰流量下线程数可以膨胀到几百上千个最终把内存和 CPU 打爆。Spring Boot 2.1 之后引入了TaskExecutionAutoConfiguration会自动配置一个applicationTaskExecutor线程池表面上是线程池了但它的maxPoolSize和queueCapacity默认值开得非常大突发流量时线程数会失控增长任务全堆在无界队列里导致内存堆积。换句话说默认配置在简单 demo 里没问题线上一定要显式配置。我的做法是单独定义一个Bean的线程池并指定到Async上Configuration public class AsyncConfig { Bean(bizExecutor) public ThreadPoolTaskExecutor bizExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(biz-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } } // 使用 Async(bizExecutor) public void sendNotification(NotifyDTO dto) { // ... }CallerRunsPolicy是我比较推荐的拒绝策略队列满时让提交任务的线程自己执行而不是直接丢弃任务或抛异常性能会降但不丢数据。如果你对任务丢失零容忍可以配合消息队列做缓冲。Async还有一个隐藏坑异步方法里的异常不会传递回调用方。调用方拿到的只是“任务已提交”执行线程里爆了异常日志里可能无声无息。建议配置AsyncUncaughtExceptionHandler统一捕获并告警否则线上任务失败了都无人知晓。还有一个容易忽略的组合Async方法里再调Transactional方法事务是在异步线程里开启的和调用方线程的事务完全隔离不要指望它能合并进同一个事务。4.3 序列化注解对接口响应时间的影响接口性能瓶颈不一定在数据库很多时候在 JSON 序列化。列表接口返回一百条数据每条数据里冗余字段一大把Jackson 序列化耗时可能占整个接口 RT 的 30% 以上。这个数据我自己实测过一个订单列表接口把所有字段无脑返回时JSON 序列化耗时占 25%精简掉无用的关联对象和冗余字段后接口 RT 下降接近 20%。控制输出内容的注解组合很关键public class OrderVO { JsonInclude(JsonInclude.Include.NON_NULL) // null 字段不参与序列化减小报文 private String remark; JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime; // 统一时间格式避免默认 ISO 格式 JsonIgnoreProperties({password, salt}) // 类级别忽略字段 private UserBriefVO creator; }几个要点JsonIgnore标记不想输出的字段常用于实体类中的敏感字段或反向引用。特别注意如果实体类上有OneToMany这种关联关系正向和反向都要考虑通常在一端加JsonIgnore防止递归序列化否则会抛 “Infinite recursion” 异常。JsonFormat里必须写timezone GMT8。LocalDateTime 本身不带时区但如果你项目中混用了Date类型Jackson 默认按 UTC 序列化线上会差 8 小时。我见过全站时间错 8 小时的事故就是漏了时区参数。JsonInclude(NON_NULL)可以加在类级别也可以全局配置。响应报文的体积直接影响带宽和客户端解析时间移动端场景收益尤其明显。大列表接口用 DTO 而不是直接返回实体类再用JsonProperty重命名字段。实体类字段名往往和前端约定的字段名不一致用注解做映射比在 Service 层手写转换复制要省事得多但注意JsonProperty会让 Jackson 序列化和反序列化都改名别只想着输出忘了入参受影响。4.4 从注解角度优化应用启动时间启动时间也是“性能优化”的一部分。Spring Boot 启动慢很大的原因在于自动配置引入了大量你根本用不到的 Bean。SpringBootApplication(exclude ...)可以按需排除比如你的服务只连 MySQL不连 Redis、不连 ES就可以排除对应的自动配置类SpringBootApplication(exclude { RedisAutoConfiguration.class, ElasticsearchRestClientAutoConfiguration.class, DataSourceAutoConfiguration.class // 如果你的服务不访问数据库 })但这个 exclude 列表要维护随项目演进容易过时。更轻量的做法是控制ComponentScan的扫描范围。默认扫描整个主类所在包及其子包如果你把大量不相关的配置类、工具类都塞在同一个包下启动时就要扫描和推断很多无用 Bean。限定basePackages或者干脆在配置类上用ComponentScan的excludeFilters排除无关类都能减少启动开销。Lazy可以把某些重量级 Bean 的初始化延迟到第一次使用时但我不建议大范围使用因为延迟初始化会把启动压力转嫁到首次请求线上首请求 RT 可能骤增而且延迟初始化的 Bean 在排查问题时更难定位。更适合的场景是某些冷门功能模块比如导出模板引擎、脱敏加密器这类组件确实可以Lazy加载。条件装配也是启动优化的利器。ConditionalOnProperty可以根据配置项决定是否创建 Bean比如Configuration public class FeatureToggleConfig { Bean ConditionalOnProperty(name biz.notify.enabled, havingValue true) public NotifyService notifyService() { return new NotifyService(); } }这样通过一个配置项就能在部署时决定组件是否加载比代码里写 if/else 优雅也方便灰度。至于启动耗时到底优化到什么程度可以用 Spring Boot Actuator 的startup指标或第三方的spring-context-indexer做辅助。实际经验一个中型服务把无用的自动配置排除掉、扫描范围收窄之后启动时间从 15 秒降到 6 秒是很常见的收益。5. 常见问题与排查技巧实录5.1 Transactional 自调用失效代理机制的前因后果经典问题同一个类里A 方法调用 B 方法B 方法标了Transactional事务不生效。原因在于 Spring 事务是通过代理实现的——调用方持有的其实是代理对象但你在类内部用this调用时调用的是原始对象的方法代理逻辑被绕过了。Service public class UserService { public void register(UserDTO dto) { // 这里 this.createUser 是原始对象调用Transactional 不生效 this.createUser(dto); } Transactional(rollbackFor Exception.class) public void createUser(UserDTO dto) { // ... } }解决方案有三个按推荐程度排序把事务方法独立到另一个 Service 类让调用方通过注入的代理对象调用注入自身代理Autowired Lazy private UserService self;然后self.createUser(dto)改成编程式事务TransactionTemplate彻底绕开 AOP 代理问题。另外注意Spring Boot 2.x 默认使用 CGLIB 代理如果类或方法是final的CGLIB 无法继承和覆盖Transactional同样会失效。所以规范是被 Spring 管理的类和方法不要加final。5.2 自定义注解切面不生效按顺序排查五件事自定义注解加切面之后没反应我总结了一套固定排查顺序每次遇到直接按这个过切面类是否被 Spring 管理有没有Component或通过Bean注册如果写在Configuration类里但漏了注解切面不会实例化。有没有引入 AOP 支持依赖Spring Boot 项目要加spring-boot-starter-aop里面带aspectjweaver纯spring-context项目还要显式EnableAspectJAutoProxy。注解的Retention是不是RUNTIME不是的话切面读不到。切点表达式是否匹配annotation(com.xxx.OperationLog)包名、类名、方法名有没有写错我多次遇到包名路径复制的时候丢了一层。是不是自调用类内部互相调用同样会绕过切面和事务失效一个原理。其中第 4 条最容易踩因为 Spring 对切点表达式的匹配失败是完全静默的。建议第一次调试时在切面里临时打印日志确认有没有走进切面方法没有输出再回头查表达式。5.3 Cacheable 的穿透、击穿、雪崩注解解决不了所有问题Cacheable好用但把它当万能缓存就会出事。它本质只是“缓存读取逻辑的声明式封装”缓存穿透查询不存在的数据每次都打到 DB、击穿热点 key 过期瞬间大量请求打到 DB、雪崩大量 key 同时过期这三个问题注解本身并不能完全防御必须配合参数和缓存策略。穿透的解法之一是把 null 也缓存起来。用unless参数控制Cacheable(value userCache, key #id, unless #result null) public UserDO getUser(Long id) { // 查库 }这样查不到的 key 也会被缓存为 null后续同样的空 key 请求直接走缓存不会打穿 DB。当然空值缓存要设置较短的 TTL避免脏数据堆积。击穿可以用sync trueCacheable(value hotCache, key #id, sync true) public HotData getHot(Long id) { // 只放行一个线程去查库其余线程等待缓存填充 }synctrue对单个 key 做互斥让缓存失效瞬间只有一个请求穿透到 DB相当于内置了互斥锁。注意sync只对单 key 生效多个不同 key 同时失效还是会打到 DB那是雪崩问题需要靠缓存 TTL 随机化比如在业务代码里给 TTL 加随机偏移量来解决。缓存注解底层需要CacheManager。如果你不配置任何缓存管理器Spring Boot 默认用ConcurrentMapCache纯内存、无 TTL、无持久化重启即清空。生产环境建议换成 Redis 并配置默认 TTL同时用CacheConfig(cacheNames ...)在类级别统一缓存名避免每个方法都手写 value。5.4 MyBatis 注解下的 N1 查询SQL 层面根治MyBatis 配合注解开发最容易出现的性能问题是 N1主查询返回 N 条记录循环里每条再查一次关联表最终执行了 N1 条 SQL。接口上面写着Select看着干干净净实际上数据库被刷了成百上千次。我的排查手段很简单开发环境打开 MyBatis 的 SQL 日志或者用自定义的慢 SQL 拦截器看到循环里同一条 SQL 反复打八九不离十就是 N1。解法是聚合查询把循环查库改成一次 IN 查询Select(script select * from order_item where order_id in foreach collectionorderIds itemorderId open( separator, close) #{orderId} /foreach /script) ListOrderItemDO selectByOrderIds(Param(orderIds) CollectionLong orderIds);然后在 Service 层把结果按 orderId 分组内存里组装避免循环查库。如果实体关系复杂、多层嵌套Results注解做嵌套映射也能实现“一次查询自动递归装配”但动态 SQL 和映射的维护成本都不低。我在真实项目里的建议是与其追求 MyBatis 注解的“万能”不如把复杂查询放到 XML Mapper 里维护注解负责简单 SQLXML 负责复杂 SQL各取所长。这个分工配合好N1 问题的发生概率会大幅下降。5.5 写在最后的个人体会给这篇文章收个尾我不想做什么总结只想分享一个我在实际项目里反复验证的体会注解不是越炫越好也不是用得越多越好。每加一个注解都是给系统增加一层隐性的代理逻辑代码表面上看不出代价但运行时性能和排查复杂度都在累积。我在做代码评审时越来越关注“注解组合的可读性”和“副作用边界”——一个方法上堆了七八个注解每个注解背后都是切面或代理执行顺序稍微错位线上就是疑难杂症。建议大家在自己的项目里做一次注解“体检”统计哪些Transactional标在了类级别、哪些Async用的默认执行器、哪些Cacheable没有配 TTL、自定义注解的切面有没有缓存反射结果。这些问题在线下很难发现但流量一大全部现形。先把注解的组合关系理清楚再谈性能优化这才是 Spring Boot 实战里最稳的路。