Spring Boot 2 / 3 注解实战大全:用法、坑点与版本差异
下面按“通用核心 → 配置绑定 → Web 与校验 → 数据与事务 → 异步与调度 → 缓存 → 测试 → 安全 → 条件与自动配置 → 版本差异速查”来组织。每个注解只讲三件事:怎么用、什么情况下不生效、什么情况下会咬人。Spring Boot 3 的独有变化和 2 中常见但 3 中已废弃的用法,会在条目中直接标出。
一、启动与通用核心注解
@SpringBootApplication
用法:标注在主类上,组合了 @SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan。需要排除某个自动配置类时用 exclude:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})坑点(高频) :exclude 只能排除自动配置类,不能排除你自己用 @Component / @Controller 标注的普通组件。如果你写 @SpringBootApplication(exclude = {SomeController.class}),它不会生效,因为 SomeController 不是自动配置类。排除普通组件要用 @ComponentScan(excludeFilters = ...) 或 @Profile 控制。
Spring Boot 3:行为与 2 一致。如果被排除的自动配置类不在 classpath 上,用 excludeName 指定全限定名字符串。
@Import
用法:把不在 @ComponentScan 扫描范围内的配置类或普通类导入容器。常见于自定义 Starter、多模块公共配置。
@Import({CommonConfig.class, SecurityConfig.class})坑点:@Import 导入的类如果同时被 @ComponentScan 扫到,会被注册两次,导致 Bean 定义冲突或初始化逻辑执行两次。自定义 Starter 中尤其容易踩:配置类放在 com.example.starter.config,主应用扫描包又恰好覆盖它。
@Lazy
用法:延迟 Bean 初始化,直到第一次被使用。可以标在 @Component / @Service 类上,也可以标在 @Bean 方法上,还可以标在注入点上。
坑点一(最经典) :@Lazy 不能解决构造器注入的循环依赖。构造器注入要求依赖在对象实例化时必须存在,而 @Lazy 只是把代理的创建推迟了,并没有改变构造器调用时机。字段注入 + @Lazy 可以打破循环,构造器注入 + @Lazy 通常仍然报循环依赖。
坑点二:@Lazy 只对单例 Bean 的实例化过程有效。大量滥用会把启动期的错误推迟到运行期,问题暴露更晚,排查成本更高。把它当“性能优化开关”全局打开,往往得不偿失。
Spring Boot 3:行为不变。虚拟线程场景下,@Lazy 也不会改变 @Async 的线程池选择逻辑。
@Value
用法:从配置文件或 SpEL 表达式注入单个值。
@Value("${app.name}")
private String appName;
@Value("${app.port:8080}")
private int port;
@Value("#{systemProperties['os.name']}")
private String osName;坑点(几乎人人踩过) :@Value 不能直接注入 static 字段。Spring 依赖注入是基于对象的,静态字段属于类,不在容器管理的实例属性范围内。字段一旦写成 static,注入结果就是 null 或默认值。需要静态变量时,用 @PostConstruct 把非静态注入值赋给静态字段,或者改用 @ConfigurationProperties。
坑点二:@Value("${key}") 在 key 不存在且没有默认值时启动直接失败;写成 @Value("${key:}") 得到空字符串,写成 @Value("${key:#{null}}") 才能得到 null。需要区分“空串”和“未配置”时要注意这个差异。
与 @ConfigurationProperties 的选择:层级多、字段多、需要类型安全校验时用 @ConfigurationProperties;单个简单值、SpEL 表达式、环境变量回退时用 @Value。
@Profile
用法:按激活的 profile 注册 Bean。
@Bean
@Profile("dev")
public DataSource devDataSource() { ... }坑点:@Profile 是 @Conditional 的封装,但它只认激活的 profile。没有激活任何 profile 时,@Profile("default") 的 Bean 才会生效;如果你只写了 @Profile("dev") 而启动时没带 dev,这个 Bean 根本不存在,注入它的地方会直接报 NoSuchBeanDefinitionException。不要在核心依赖上随意加 @Profile 而不给默认值。
二、配置绑定:@ConfigurationProperties
基本用法
@Component
@ConfigurationProperties(prefix = "app.datasource")
public class DataSourceProps {
private String url;
private int poolSize = 10;
// getters / setters
}核心优势:层级绑定、宽松命名(pool-size、poolSize、POOL_SIZE 都能匹配)、支持 JSR-303 校验、IDE 元数据提示。
坑点一:静态属性不支持绑定
@ConfigurationProperties 只对实例字段做绑定,static 字段不在绑定范围内。Spring Boot 文档明确:binding on static properties is not supported。如果你有一个 static 配置字段,它的 setter 也带 static,绑定会静默失败,值永远是 null 或初始值。
坑点二:嵌套类必须是 static
嵌套的配置类必须声明为 static。非静态内部类隐式持有外部类引用,Spring 无法正确实例化并绑定,通常表现为嵌套属性全为 null,且没有任何报错。
坑点三:AOT / Native Image 下嵌套属性需要 @NestedConfigurationProperty
在普通 JVM 模式下,嵌套的配置属性类通常能自动识别。但在 GraalVM Native Image 或 AOT 场景下,非内部类的嵌套配置属性必须显式标注 @NestedConfigurationProperty,否则不会被检测到,也无法绑定。如果你的项目未来要走 Native,这一条要从一开始就注意。
Spring Boot 3 变化:@ConstructorBinding 的位置
Spring Boot 3 中,@ConfigurationProperties 类如果只有一个带参数的构造函数,不需要在类级别加 @ConstructorBinding,应该直接移除。@ConstructorBinding 在 3.0 中已被标记为废弃,并在 3.2 中移除;它原本的作用是“指定用哪个构造器绑定”,现在构造器绑定成为默认行为,不需要再用注解声明。在 2.x 中类级别加 @ConstructorBinding 是常见写法,迁移到 3 时一定要去掉,否则可能因废弃注解导致编译或启动问题。
坑点四:与 @Value 混用导致绑定顺序混乱
同一个配置类里同时使用 @Value 和 @ConfigurationProperties 时,@Value 在 @ConfigurationProperties 绑定之前执行,可能读到旧值或默认值。配置绑定类里尽量不要混用两种方式。
三、Web 与参数校验
@RequestBody
用法:把请求体 JSON 反序列化为对象。
坑点一:DTO 必须有无参构造器(或 Jackson 可识别的构造方式),字段必须有 getter/setter(Lombok @Data 可以)。缺少无参构造器时 Jackson 反序列化直接失败,通常报 HttpMessageNotReadableException。
坑点二:@RequestBody 和表单提交(application/x-www-form-urlencoded)不能混用。用 @RequestBody 接表单参数会得到 415 Unsupported Media Type 或空对象。表单场景用 @RequestParam 或直接绑定对象(不加 @RequestBody)。
@Valid vs @Validated:嵌套校验的“静默放过”
用法:
- @Valid(jakarta.validation.Valid):标在 @RequestBody 参数上,触发该对象的校验。
- @Validated(Spring 的):可以标在类上、方法参数上,支持分组校验。
最危险的坑:嵌套对象不会自动级联校验。如果 OrderRequest 里有一个 Address 字段,Address 上标了 @NotBlank,但 OrderRequest 里的 Address 字段没有标 @Valid,那么 Address 内部的约束一条都不会执行,外层对象依然“校验通过”。这是生产环境中最常见的校验绕过方式之一,而且不报错、不警告。嵌套 DTO 的字段上必须显式加 @Valid,列表元素也要考虑 List<@Valid Item> 的写法。
第二个坑:Spring Boot 从 2.3 开始,spring-boot-starter-web 不再自动包含 spring-boot-starter-validation。如果你只引入了 web starter,所有 @Valid 都会“静默不生效”,没有任何异常,校验器根本不在 classpath 上。需要显式添加 spring-boot-starter-validation。
第三个坑:@Valid 不生效还有一个容易被忽略的原因——它必须标在被 Spring 绑定处理的参数上。标在 Service 方法参数上不会自动触发,除非该类上有 @Validated 并且走了方法级校验代理。
@RestControllerAdvice / @ExceptionHandler
用法:全局异常处理,配合 @ExceptionHandler 统一返回格式。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException e) { ... }
}坑点:@RestControllerAdvice 会捕获所有控制器抛出的异常。如果写得过于宽泛(比如捕获 Exception.class 后返回一个“成功”格式的响应),业务异常、系统异常、第三方调用异常全部被吞掉,前端和调用方看到的永远是 200,真正的问题被掩盖。至少要把业务异常、参数校验异常、系统异常分开处理,不要在全局兜底里把所有异常都映射成成功码。
与 @ResponseStatus 的混用坑:在 @ExceptionHandler 方法上同时使用 @ResponseStatus 和返回值时,如果返回值类型没有走 @ResponseBody 序列化(比如返回 String 但没有 @RestControllerAdvice 或 @ResponseBody),响应可能不是你以为的 JSON,而是视图解析失败或空响应体。用 @RestControllerAdvice 可以避免这个问题,因为它自带 @ResponseBody 语义。
四、数据与事务:@Transactional
用法
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) { ... }
}坑点一:同类调用不走代理,事务完全失效
这是 @Transactional 第一大坑。在同一个类中,方法 A 调用方法 B,即使 B 上标了 @Transactional,B 的事务也不会生效,因为调用发生在目标对象内部,没有经过 Spring 的代理对象。典型场景:Service 里的一个 public 方法调用本类的另一个带事务的 public 方法。解决方案:把事务方法拆到另一个 Bean 中,或者通过 AopContext.currentProxy() 获取代理(需要开启 exposeProxy,但不推荐),或者用 TransactionTemplate 手动控制。
坑点二:非 public 方法上不生效
Spring 的事务代理默认只拦截 public 方法。把 @Transactional 标在 private、protected、包级方法上,事务不会启动,而且通常不会有任何提示。
坑点三:异常类型不对,回滚不发生
默认只对 RuntimeException 和 Error 回滚,受检异常(Exception 的子类但不是 RuntimeException)默认不回滚。需要受检异常也回滚时,必须写 @Transactional(rollbackFor = Exception.class)。
坑点四:多数据源 / 多事务管理器时不指定,事务可能落到错误的数据源
手动配置多数据源后,容器中存在多个 TransactionManager。@Transactional 不指定 transactionManager 时,默认按类型查找,可能找到的不是你以为的那个,导致写操作没有事务保护,或者回滚到了错误的数据源。多数据源场景必须显式写 @Transactional("mainTransactionManager")。
坑点五:readOnly = true 不是“只读开关”
@Transactional(readOnly = true) 主要是给底层驱动和 ORM 的提示,不是数据库层面的强制只读。在 MySQL 上,它可能影响 Hibernate 的 flush 模式;但如果代码里执行了写操作,很多数据库仍然会写入。不要把它当作“只读保护”来依赖。
Spring Boot 3:@Transactional 的核心行为不变,包名也不变(它来自 Spring Framework,不是 Jakarta EE)。但 Spring Framework 6 对代理机制和 AOT 有一些内部调整,事务代理的创建在 Native Image 下需要额外的运行时提示,普通 JVM 模式下无感知。
五、异步与调度
@Async
用法:让方法在独立线程中执行。必须在配置类或启动类上显式开启 @EnableAsync。
坑点一:同类调用不走代理,异步变同步。和 @Transactional 一样,本类方法调用本类的 @Async 方法,异步不生效,方法会在当前线程同步执行。
坑点二:@Async 方法返回值只能是 void 或 Future / CompletableFuture。返回其他类型时,调用方拿到的是 null,而且不会有编译错误。
坑点三:不配置 TaskExecutor 时,Spring 使用默认的 SimpleAsyncTaskExecutor,每次调用新建线程,没有池化,没有队列。高频调用场景下会疯狂创建线程,直接把系统打爆。必须自定义 ThreadPoolTaskExecutor 并指定给 @Async(通过 @Async("myExecutor") 或全局配置)。
Spring Boot 3.2+ 与虚拟线程:在 application.properties 中添加 spring.threads.virtual.enabled=true 后,@Async 的默认执行器会被替换为虚拟线程执行器,无需改代码。但要注意:虚拟线程并不适合 CPU 密集型任务,也不适合需要长时间持有锁或使用 ThreadLocal 的场景,盲目开启可能带来新的问题。
@Scheduled
用法:定时任务。必须开启 @EnableScheduling。
坑点一:默认调度线程池只有一个线程。多个 @Scheduled 方法会串行执行,一个任务卡住,后面的全部延迟。必须配置 ThreadPoolTaskScheduler 或使用 spring.task.scheduling.pool.size 调整。
坑点二:fixedRate 和 fixedDelay 语义不同,用错会导致任务堆积或频率不符预期。fixedRate 按固定频率触发,不等待上一次完成;fixedDelay 等待上一次完成后延迟指定时间再触发。长任务场景用 fixedRate 很容易造成并发执行同一任务。
坑点三:@Scheduled 方法同样受代理限制,同类调用不会重新调度;而且 @Scheduled 和 @Transactional 叠加时,事务管理器不明确可能导致事务不生效或落到错误数据源。
与 @RefreshScope 的冲突:在 Spring Cloud 环境中,@RefreshScope 和 @Scheduled 同时使用时,刷新作用域可能导致定时任务代理被重建,出现任务“消失”或重复执行的问题。
六、缓存注解
@Cacheable / @CachePut / @CacheEvict
用法:在方法上声明缓存读取、更新、清除。需要 @EnableCaching。
@Cacheable(value = "users", key = "#id")
public User findById(Long id) { ... }
@CacheEvict(value = "users", key = "#id")
public void updateUser(Long id, User user) { ... }坑点一(集群环境致命) :Spring Cache 的默认实现(ConcurrentMapCacheManager)是本地缓存,集群部署时每个实例各存一份。@CacheEvict 只清除当前实例的缓存,其他实例的缓存仍然是旧值。用 Redis 作为 CacheManager 可以缓解,但 @CacheEvict(allEntries = true) 在 Redis 上会执行 KEYS 命令,生产环境大 key space 下可能阻塞 Redis。集群场景优先用精确 key 清除,避免 allEntries。
坑点二:同类调用不走代理,缓存注解全部失效。本类方法调用本类的 @Cacheable 方法,缓存不会命中也不会写入,和事务、异步是同一类代理问题。
坑点三:@Cacheable 的方法返回值如果是可变对象,缓存中保存的是引用,调用方修改返回值可能“污染”缓存。需要防御性拷贝时,缓存 DTO 而不是实体,或者返回不可变对象。
坑点四:@CacheEvict 默认在方法执行成功后清除缓存。如果方法抛异常,缓存不会被清除,旧数据继续存在。需要无论成败都清除时,用 beforeInvocation = true。
七、测试注解:Spring Boot 3 的重灾区
@MockBean / @SpyBean → @MockitoBean / @MockitoSpyBean
Spring Boot 3.4+:@MockBean 和 @SpyBean 被标记为废弃,推荐使用 @MockitoBean 和 @MockitoSpyBean。但迁移不是简单换名字。
坑点(非常隐蔽) :@SpyBean 的传统行为是“创建一个新的 spy 实例,然后用它替换上下文中的原始 Bean”。@MockitoSpyBean 默认使用包装策略:它要求目标 Bean 已经存在于应用上下文中,然后在现有实例周围创建代理。如果目标 Bean 是通过 @ComponentScan 自动发现的,而你没有显式的 @Bean 方法声明它,@MockitoSpyBean 可能报“无法选择要包装的 bean”,或者行为与 @SpyBean 不一致。解决方案是为需要 spy 的组件添加显式的 @Bean 配置,或者暂时保留 @SpyBean 并逐步迁移。
另一个坑:@MockitoBean 不能用在类型上(没有 @MockitoBeans 对应注解),只能标在字段或参数上。从 @MockBean 批量迁移时,类级别的写法需要拆开。
@SpringBootTest 与 @ExtendWith
Spring Boot 3:JUnit 5 是唯一支持的测试框架,@RunWith(SpringRunner.class) 不再可用,必须用 @ExtendWith(SpringExtension.class),或者直接依赖 @SpringBootTest 自带的扩展。从 2.x 迁移时,测试类上的 @RunWith 如果不改,测试根本不会以 Spring 方式启动,注入的 Bean 全是 null。
Records 与 Bean Validation 的测试盲区
Spring Boot 3 支持 Java Records 作为 DTO,但 Records 与 Bean Validation 的集成存在限制:构造函数参数上的校验注解在 JSON 反序列化场景下可能不生效。如果你用 record CreateUserRequest(@NotBlank String name) 并期望 @Valid 触发校验,很可能校验静默通过。需要校验的 DTO 暂时用普通类 + getter/setter,或者显式测试验证 Records 在你当前 Spring Boot 版本下的实际行为。
八、安全注解
@PreAuthorize / @Secured / @RolesAllowed
用法:
@PreAuthorize("hasRole('ADMIN') or #order.customerId == authentication.principal.id")
public void cancel(Order order) { ... }
@Secured("ROLE_ADMIN")
public void adminOnly() { ... }坑点一(静默忽略) :同时使用 @PreAuthorize 和 @Secured 时,@Secured 会被静默忽略。顺序是 @PreAuthorize 优先级最高,@Secured 其次,@RolesAllowed 最低。混用时不要以为两个条件都在生效。
坑点二:@Secured 的角色字符串默认自动加 ROLE_ 前缀,@PreAuthorize("hasRole('ADMIN')") 也会自动加 ROLE_,但 @PreAuthorize("hasAuthority('ROLE_ADMIN')") 不会加。混用 hasRole 和 hasAuthority 时,前缀规则不同,容易导致明明角色对了却拒绝访问。
Spring Boot 3 / Spring Security 6:@PreAuthorize 核心注解不变,但配置方式有调整。Spring Security 6 中方法级安全需要显式 @EnableMethodSecurity(替代旧的 @EnableGlobalMethodSecurity),并且默认不再自动启用某些旧式配置。如果从 2.x 迁移后 @PreAuthorize 不生效,先检查是否漏了 @EnableMethodSecurity。
九、条件注解与自动配置(自定义 Starter 重灾区)
@ConditionalOnClass:元注解上的 value() 会静默失效
用法:
@Configuration
@ConditionalOnClass(DataSource.class)
public class DataSourceConfig { ... }最隐蔽的坑:把 @ConditionalOnClass 当作元注解(组合到自定义注解上)时,value() 属性会静默失效,条件判断被完全忽略,Bean 照样注册。
// 危险写法:条件静默失效
@Target({ElementType.TYPE, ElementType.METHOD})
@ConditionalOnClass(ClassThatMayNotExistAtRuntime.class)
public @interface ConditionalOnOptionalComponent {}
@Component
@ConditionalOnOptionalComponent
public class SomeComponent { ... } // 仍然被实例化原因在于元注解通过 Java 反射读取,会触发类加载;当目标类不存在时,ClassNotFoundException 被静默吞掉,条件被跳过。正确做法:把 @ConditionalOnClass 直接标在组件或配置类上,不要包一层自定义注解。
@ConditionalOnBean / @ConditionalOnMissingBean 的顺序问题
这两个条件依赖已经注册的 Bean 定义。在自动配置中,如果当前配置类比它依赖的 Bean 配置类先加载,@ConditionalOnBean 判断时目标 Bean 还不存在,条件不满足,配置被跳过;但启动完成后你发现容器里确实有那个 Bean,只是“晚了一步”。Spring Boot 官方建议:@ConditionalOnMissingBean / @ConditionalOnBean 主要用于自动配置类中,并且要注意配置类的加载顺序。用户自己的 @Configuration 中滥用这两个注解,行为往往不符合预期。
Spring Boot 3 自动配置的注册机制彻底变了
Spring Boot 2:自动配置类写在 META-INF/spring.factories 中,配置类通常用 @Configuration + @EnableAutoConfiguration。
Spring Boot 3:spring.factories 的自动配置机制被移除,改为 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,每行一个全限定类名。Spring Boot 3 不会回退到 spring.factories,路径不存在或类名拼错时,自动配置静默不加载,没有报错。
同时,自动配置类应使用 @AutoConfiguration 替代 @Configuration。@AutoConfiguration 自带 @Configuration 语义,不要再叠加 @Configuration,否则该类可能被加载两次,引发 Bean 定义冲突。
迁移自定义 Starter 时的检查清单:删除 spring.factories;创建 AutoConfiguration.imports;自动配置类改用 @AutoConfiguration;启动时加 debug=true,检查 Automatically configured: 列表中你的类是否出现。没出现就是没生效,不要靠猜。
十、Spring Boot 3 版本差异速查
Jakarta EE 包名迁移(影响最广,没有之一)
Spring Boot 3 基于 Jakarta EE 9+,所有 javax.* 企业级 API 注解/类型迁移到 jakarta.*。只改依赖版本不改 import,直接编译失败或运行时 ClassNotFoundException。
| Spring Boot 2(javax) | Spring Boot 3(jakarta) |
|---|---|
| javax.servlet.* | jakarta.servlet.* |
| javax.servlet.annotation.WebServlet | jakarta.servlet.annotation.WebServlet |
| javax.persistence.Entity / Id / Table | jakarta.persistence.* |
| javax.validation.Valid / Constraints | jakarta.validation.* |
| javax.annotation.PostConstruct / PreDestroy | jakarta.annotation.* |
| javax.transaction.* | jakarta.transaction.* |
| javax.websocket.* | jakarta.websocket.* |
真正的坑不在 import 本身,而在第三方依赖:老版本的 Swagger2、部分 Dubbo/RocketMQ 客户端、旧版 Hibernate 没有适配 jakarta.*,强行引入会导致依赖体系崩溃。升级前必须把所有直接和间接依赖的 Jakarta 兼容性过一遍。
废弃/移除注解速查
| 注解/机制 | Spring Boot 2 | Spring Boot 3 |
|---|---|---|
| @ConstructorBinding | 类级别常用 | 3.0 废弃,3.2 移除;单构造器自动绑定,无需注解 |
| spring.factories(自动配置) | 标准机制 | 移除;改用 AutoConfiguration.imports |
| @Configuration + @EnableAutoConfiguration(自动配置类) | 常见组合 | 推荐 @AutoConfiguration |
| @MockBean / @SpyBean | 标准 | 3.4+ 废弃,用 @MockitoBean / @MockitoSpyBean |
| @RunWith(SpringRunner.class) | JUnit 4 写法 | JUnit 5 下不可用,用 @ExtendWith(SpringExtension.class) |
| @EnableGlobalMethodSecurity | Spring Security 5 | Spring Security 6 用 @EnableMethodSecurity |
| @EnableBatchProcessing(自动配置场景) | 推荐 | 不再推荐;使用它会令自动配置退避 |
Spring Boot 3 新增/增强的注解相关能力
Micrometer 可观测性注解:@Timed、@Counted、@Observed 在 Spring Boot 3.2+ 中,引入 spring-boot-starter-aop 后切面会被自动配置,不需要手动注册 TimedAspect 等基础设施。这是 3 中“注解开箱即用”的明显改进。但要注意避免双重埋点:如果你的代码或依赖已经对同一个方法做了手动指标记录,再加 @Timed 会导致指标重复计数。
@NestedConfigurationProperty 在 AOT 下的必要性:JVM 模式下普通嵌套配置类通常能绑定,但 Native Image / AOT 下非内部类的嵌套属性必须标注 @NestedConfigurationProperty,否则静默不可绑定。如果项目计划走 Native,从开发阶段就要加上。
最后:三条跨注解的“通用坑”
第一,代理边界是大多数“注解不生效”的总根源。 @Transactional、@Async、@Cacheable、@PreAuthorize 都依赖 Spring AOP 代理。同类调用、非 public 方法、this 调用,全部绕过代理,注解等于没写。遇到“注解不生效”时,先问:这个调用经过代理了吗?
第二,“静默失效”比“报错”更危险。 条件注解元注解使用、Bean Validation 嵌套字段漏 @Valid、@ConfigurationProperties 静态字段、Spring Boot 3 自动配置文件路径错误——这些坑的共同特征是不报错、不警告,直到生产环境出现脏数据、校验绕过或配置不生效才暴露。测试时不仅要测 happy path,还要专门构造“应该失败”的用例来验证注解确实在拦截。
第三,Spring Boot 3 迁移中“换包名”只是第一步。 真正的工作量在:自动配置注册机制重写、废弃注解清理、测试注解替换、第三方依赖 Jakarta 兼容性确认。把这几项列成检查清单逐项过,比启动报错后再逐个修要快得多。