做Java开发这些年,Spring Boot里的注解是天天见、天天用,但说句实在话,很多人对注解的理解停留在“加上就能用”的层面。不信你回想一下,是不是见过这些场景:@Transactional加了但数据没回滚、@Async调了但接口还是同步等待、自己写的自定义注解切面死活不拦截、@ConfigurationProperties绑定了一堆配置结果全是 null。这些坑在技术群里几乎每周都有人问一遍。
这篇文章不打算再列一份“注解大全”式的清单,而是挑进阶开发里最常用、最容易翻车的几类注解,结合实战把背后的运行机制、正确写法和排查思路一次讲清楚。无论你是刚跑通 Spring Boot 项目、准备往深里钻的新手,还是已经写了两三年业务代码、想系统补一遍原理的开发者,都能从里面找到自己需要的答案。读完不敢说让你成为注解专家,但至少下次遇到“注解不生效”,你的第一反应不再是乱试一气。
1. 先搞清楚:注解到底是怎么“生效”的
1.1 注解只是元数据,真正干活的是“处理器”
很多人以为注解本身有魔法,加上@Service这个类就变成 Bean 了。其实注解的本质就是贴在某些代码元素上的“标签”,本身不包含任何逻辑。真正让标签发挥作用的是外部的“处理器”。
处理器大致分三类。第一类是框架内建的扫描器,典型代表是 Spring 的ClassPathBeanDefinitionScanner,它扫描@Component、@Service、@Repository,把它们注册成 Bean。第二类是 Bean 的后置处理器,比如AutowiredAnnotationBeanPostProcessor,它专门处理@Autowired、@Value,在 Bean 实例化完成后做依赖注入。第三类是 AOP 切面,比如@Transactional就是通过TransactionInterceptor这个切面在方法调用前后管理事务的。
用个生活化的类比:注解就像快递盒上的面单,上面写着从哪来、到哪去、里面是什么。但面单本身不会让快递动起来,是分拣系统的扫码枪(扫描器)和快递员(处理器)在干活。你光贴面单不发货,快递当然不会自己跑。
理解这一点对排查问题特别重要。当你发现一个注解没生效,首先要问的不是“这个注解为什么不行”,而是“哪个处理器在消费它”,然后顺着处理器的触发条件去查。八成问题就出在“该类没有被 Spring 管理”或者“该方法没走代理”上。
1.2 生命周期决定了注解能不能被你反射拿到
Java 注解有三种保留策略,定义在@Retention里,这直接决定了注解的“寿命”。
SOURCE类型注解在编译期就被丢弃,只存在于源代码里。比如@Override、@SuppressWarnings,这种注解纯粹是给编译器看的,编译完就消失了。CLASS类型会保留到 class 文件里,但运行期反射读不到,这是默认值,你要是定义一个注解不写@Retention,那它就是 CLASS。RUNTIME类型不仅进 class 文件,运行期还能通过反射读取,Spring 自己的@Service、@Transactional、@Autowired全是 RUNTIME。
这个区别能解释很多“灵异事件”。比如你自定义了一个注解想去切面里拦截,结果怎么都不生效,打开注解定义一看,没加@Retention,默认是 CLASS,运行期根本反射不到,切面自然匹配不上。
还有个容易混淆的点是 Lombok。Lombok 的@Data、@Slf4j走的是编译期注解处理器,在 javac 编译阶段就生成 getter、setter、构造函数等代码,所以 IDE 里需要额外装 Lombok 插件帮它模拟编译期行为。而 Spring 的注解走的是运行期反射,两者机制完全不同。这也解释了为什么 Lombok 生成的代码在反编译工具里能看到,但你在源码里永远找不到。
2. @Transactional 进阶:那些年我们踩过的回滚坑
2.1 五种让事务静默失效的场景
@Transactional可能是 Spring Boot 里使用率最高也最容易出问题的注解。我归纳了五种让事务悄悄失效的经典场景,每条都是真实项目里遇到过的。
第一种是同类内部自调用。这是一个 Bean 里的方法互相调用,比如UserService的register()调用了同类里的insertUser(),而insertUser()上标了@Transactional。因为 Spring 事务基于 AOP 代理实现,外部调用register()走的是代理,但内部调用this.insertUser()直接绕过代理,等于没加事务一样。
第二种是方法不是 public。Spring 官方文档明确说了,@Transactional只能用在 public 方法上,私有方法上标注是无效的,因为代理机制无法拦截 private 方法。
第三种是异常被 try-catch 吞掉了。事务回滚靠的是异常传播,你把异常在方法内部捕获了,事务拦截器根本看不见异常,自然就不会回滚。常见的错误写法是在catch块里只记日志不往外抛。
第四种是抛出的不是RuntimeException。Spring 默认只对RuntimeException和Error回滚,受检异常(比如IOException)默认是提交事务。这就是为什么经常有人说“我明明抛异常了怎么没回滚”。
第五种是传播行为配错了。比如在事务方法里调用另一个标记了REQUIRES_NEW的方法,如果这个调用发生在同类内部,同样会因为自调用问题不生效,外部事务照旧。
2.2 传播行为的实战选择
传播行为是事务的“扩音器”,Spring 定义了七种,但实战中真正需要你反复权衡的其实就三种:REQUIRED、REQUIRES_NEW、NESTED。
REQUIRED是默认值,当前有事务就加入,没有就新建。这能覆盖九成以上场景,进方法要么新开事务,要么跟着已有事务走,符合业务直觉。
REQUIRES_NEW是把当前事务挂起,新建一个独立事务。常见场景是事务里要发一条操作日志,日志写失败了不能影响主业务的提交。你总不能让整个订单回滚就为了一条日志写不进去吧。
NESTED是基于数据库保存点(Savepoint)的嵌套事务。和REQUIRES_NEW的区别在于,嵌套事务回滚只会回滚到保存点,外层事务还能决定最终是提交还是回滚。这适合子任务失败后重试或者改数据这种场景。不过要注意,NESTED底层依赖 JDBC 的保存点功能,有些数据库或连接池配置下可能表现不一样。
我的经验是:先用默认REQUIRED想清楚业务边界,确实需要独立事务再换REQUIRES_NEW,别一上来就各种高级传播行为往代码里堆。另外,凡是涉及传播行为的调用,一定要确认调用链走的是代理,是跨类的调用,否则你在配置层玩出花来都是白搭。
2.3 rollbackFor:受检异常的“回滚陷阱”
接上文,Spring 默认只回滚运行时异常,这本身不算坑,真正的坑是很多人不知道。看下面这个例子:
@Transactional public void createOrder(Order order) throws IOException { orderDao.insert(order); FileUtils.write("order.txt", order.toString()); }如果write抛了IOException,它是个受检异常,方法虽然标了@Transactional,但事务照样提交,订单数据就落库了,文件却没写成功。这种“半成功”的状态在业务上是灾难。
一贯的解法是显式声明回滚规则:
@Transactional(rollbackFor = Exception.class) public void createOrder(Order order) throws IOException { orderDao.insert(order); FileUtils.write("order.txt", order.toString()); }rollbackFor指定了哪些异常触发回滚,Exception.class就是把所有受检异常也纳入回滚范围。更精细的话可以指定具体的异常类型,比如rollbackFor = IOException.class。
还有一个容易忽略的点:事务方法里如果自己捕获了异常并做了降级处理,又想让事务回滚,可以用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来手动标记回滚。因为异常没抛出去,事务拦截器不知道,只能手动“通知”它。不过这种情况比较少见,我更推荐把“需要回滚”和“不需要回滚”的逻辑拆开,别在一个大事务里又抓又抛的。
3. 条件装配注解:让 Bean 的加载“看情况”来
3.1 @Conditional 家族的核心逻辑
Spring Boot 的自动配置之所以强大,靠的是@Conditional系列注解。它们的核心逻辑是:在 Bean 定义注册之前,先过一个“条件闸门”,条件满足才注册,不满足就跳过。
最常用的是@ConditionalOnProperty,它根据配置项来决定是否加载:
@Configuration public class SmsAutoConfiguration { @Bean @ConditionalOnProperty(name = "sms.provider", havingValue = "aliyun") public SmsSender aliyunSmsSender() { return new AliyunSmsSender(); } @Bean @ConditionalOnProperty(name = "sms.provider", havingValue = "tencent") public SmsSender tencentSmsSender() { return new TencentSmsSender(); } }这段配置的意思是:sms.provider=aliyun的时候只注入阿里云短信实现,=tencent的时候只注入腾讯云实现。两个 Bean 的返回类型都是SmsSender接口,但条件让它们互斥。这样做的好处是,切换短信厂商只需要改配置,不用动代码。
matchIfMissing属性也值得一说。设为 true 后,配置项缺失时条件也成立。这在“默认开启某功能,配置里显式设为 false 才关闭”的场景下非常好用:
@ConditionalOnProperty(name = "sms.enabled", havingValue = "true", matchIfMissing = true)除了@ConditionalOnProperty,还有@ConditionalOnClass(classpath 里有某个类才生效)、@ConditionalOnBean(容器里有某个 Bean 才生效)、@ConditionalOnMissingBean(容器里没有某个 Bean 才生效)。最后这两个在自动配置类里出镜率极高,几乎是 Spring Boot starter 的标配。
3.2 功能开关与多环境适配的实战
实际项目里,条件注解最大的价值在于把“代码逻辑”和“运行时环境”解耦。我用过一个比较典型的例子:订单系统的支付回调通知,测试环境用 Mock 通知服务,生产环境用真实的通知服务。以前的做法是 if-else 判断环境变量,后来发现每加一个环境就要改一次代码,烦不胜烦。
改用条件注解后,代码清爽多了:
@Configuration public class NotifyAutoConfiguration { @Bean @ConditionalOnProperty(name = "app.notify.mock", havingValue = "true") public NotifyService mockNotifyService() { return new MockNotifyService(); } @Bean @ConditionalOnProperty(name = "app.notify.mock", havingValue = "false", matchIfMissing = true) public NotifyService realNotifyService() { return new RealNotifyService(); } }开发环境配app.notify.mock=true就能发假通知测流程,生产环境不配或者配 false 就走真实服务。不用在代码里写任何环境判断。
条件注解还有一种高级玩法:实现Condition接口写自定义条件。比如要根据服务器 CPU 核数来决定注册哪个线程池配置:
public class HighCpuCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { return Runtime.getRuntime().availableProcessors() >= 8; } }然后配合@Conditional(HighCpuCondition.class)使用。这种玩法不常见,但说明条件注解的扩展性很强。
关键避坑点:@ConditionalOnBean的判断时机非常讲究,它依赖 BeanDefinition 的注册顺序。如果你在自己写的配置类里用@ConditionalOnBean去判断“容器里是否已有某个 Bean”,很可能在自动配置阶段拿到的是 false,因为用户配置的 Bean 还没注册进来。这也是为什么 Spring Boot 官方文档建议:自动配置类里少用@ConditionalOnBean,优先用@ConditionalOnMissingBean。后者是“如果没有我才上”,时序风险小得多。
4. @ConfigurationProperties 与校验注解的黄金组合
4.1 为什么说 @Value 输给了 @ConfigurationProperties
Spring Boot 读取配置有两种主流姿势:@Value和@ConfigurationProperties。新手可能觉得@Value用着挺顺手的,一行注解取一个值。但到了配置项多起来,你就能体会到@Value的麻烦:一个类里七八个@Value散落在字段上,可读性差不说,还容易出现拼写错误导致的注入失败。
@ConfigurationProperties的做法是把一类配置整体绑定到一个对象上:
@Component @ConfigurationProperties(prefix = "app.order") public class OrderProperties { private int timeoutSeconds; private boolean autoConfirm; private List<String> notifyChannels; private Map<String, String> customHeaders; // getter / setter 必须写 }配置文件里这么写:
app.order.timeout-seconds=30 app.order.auto-confirm=true app.order.notify-channels=email,sms app.order.custom-headers.X-Request-Id=order-service app.order.custom-headers.X-App-Name=shop注意timeoutSeconds对应了timeout-seconds,这就是 Spring Boot 的宽松绑定规则:配置项里可以用 kebab-case(短横线分隔),字段里用 camelCase(驼峰),也能互相映射。这是@Value做不到的,@Value("${app.order.timeoutSeconds}")严格匹配,写错大小写就注入失败。
Spring Boot 2.2 之后,不需要@Component也能生效,可以在配置类上写@EnableConfigurationProperties(OrderProperties.class)来注册,更推荐的做法是启动类上直接加@ConfigurationPropertiesScan扫描所有的@ConfigurationProperties类。
Spring Boot 3 还支持用 record 做配置绑定,甚至不需要 getter/setter:
@ConfigurationProperties(prefix = "app.order") public record OrderProperties(int timeoutSeconds, boolean autoConfirm) { }这样写配置类天然不可变,字段更安全,也省掉了大堆样板代码。
4.2 参数校验:@Validated + 校验注解的正确姿势
配置类本身只能拿值,拿到的值合不合法是另一回事。比如timeoutSeconds配成 -1,业务跑起来肯定出事。这时候要让校验注解和配置绑定一起工作。
在@ConfigurationProperties类上标注@Validated,字段上加@Min、@Max、@NotNull这些 Bean Validation 注解:
@Component @Validated @ConfigurationProperties(prefix = "app.order") public class OrderProperties { @NotNull @Min(value = 1, message = "超时时间至少1秒") @Max(value = 300, message = "超时时间不能超过300秒") private Integer timeoutSeconds; // ... }启动时如果配置不合法,应用会直接启动失败并报出校验信息,早失败总比运行期出事故要好。
在 Controller 层,@Valid和@Validated的区别也常让人混淆。@Valid是 Jakarta Bean Validation 的标准注解,用在@RequestBody参数上能触发级联校验,也就是嵌套对象里的字段也会递归校验。@Validated是 Spring 的封装,它支持分组校验,还能标注在类上启用方法参数校验。
实际开发中建议记这么一条:普通的请求体校验用@Valid就够了,需要分组校验(比如新增时必填、更新时可空)再上@Validated。
4.3 自定义校验注解:实现 ConstraintValidator
内置校验注解覆盖不了所有业务规则,比如手机号格式、订单状态流转合法性,这时候就要写自定义校验注解。实现一个自定义校验注解有两步:定义注解和编写校验器。
先看定义:
@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = PhoneValidator.class) public @interface Phone { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }再看校验器:
public class PhoneValidator implements ConstraintValidator<Phone, String> { @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) { return true; } return value.matches("^1[3-9]\\d{9}$"); } }ConstraintValidator<Phone, String>第一个泛型是注解类型,第二个是校验目标字段的类型。isValid返回 true 表示校验通过。注意一个细节:null 值处理。通常建议 null 交还给@NotNull去管,自定义校验器只负责校验“非空情况下的格式”,这样职责清晰,也能复用。
自定义校验注解和@Constraint这个三元组是绑定关系,那三个groups、payload属性一个都不能少,它们是 Bean Validation 规范要求的模板。把这个过程跑通之后,你就能把很多散落在 Service 代码里的 if-else 判断收拢到注解里,再用在 DTO 字段上,代码质量和可维护性都会上一个台阶。
5. 自定义注解从零到一:配合 AOP 完成“注解即逻辑”
5.1 定义注解的语法与规范
看@Transactional是怎么把“事务”这件事做成一个注解的,你就理解了自定义注解的威力。注解本身可以理解为“配置的浓缩”:把一套跨切面的逻辑参数化、声明式地贴到目标代码上。
定义一个基本注解要写三件事:目标(@Target)、生命周期(@Retention)、属性。很多新手踩过这个坑:@Target不写或者写错,导致注解贴的位置不符合预期,处理器找不到。
比如下面这个记录操作日志的注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogOperation { String value() default ""; boolean persist() default true; }@Target(ElementType.METHOD)表示只能用在方法上,@Retention(RetentionPolicy.RUNTIME)保证运行期可以被反射读取,这是 AOP 拦截的前提。属性定义了value和persist,都有默认值,所以使用时可以@LogOperation("创建订单"),也可以只写@LogOperation不传参。
属性命名有几个约定俗成的点:单属性时建议命名为value,这样使用方可以直接写@LogOperation("xxx")省掉属性名;boolean 属性多用xxxEnabled这类命名;属性值类型可以是基本类型、String、Class、枚举、数组,但不可以是普通对象。如果要在注解里嵌套“对象属性”,得用注解类型来代替,稍微绕一点。
5.2 AOP 拦截注解的完整落地代码
注解定义好了,关键是要有人消费它。下面是一个完整的 AOP 切面,拦截带有@LogOperation的方法,把业务名、参数、执行耗时统一记录到日志里,还能根据persist属性决定要不要落库。
@Aspect @Component public class LogOperationAspect { private static final Logger log = LoggerFactory.getLogger(LogOperationAspect.class); @Around("@annotation(logOperation)") public Object around(ProceedingJoinPoint joinPoint, LogOperation logOperation) throws Throwable { long start = System.currentTimeMillis(); String operation = logOperation.value(); // 记录入参 Object[] args = joinPoint.getArgs(); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; log.info("操作[{}]成功, 耗时{}ms, 参数={}", operation, cost, args == null ? "[]" : Arrays.toString(args)); if (logOperation.persist()) { saveLog(operation, joinPoint, cost, true); } return result; } catch (Throwable e) { long cost = System.currentTimeMillis() - start; log.error("操作[{}]失败, 耗时{}ms, 原因: {}", operation, cost, e.getMessage()); if (logOperation.persist()) { saveLog(operation, joinPoint, cost, false); } throw e; } } private void saveLog(String operation, ProceedingJoinPoint joinPoint, long cost, boolean success) { // 这里可以异步写入操作日志表 } }切点表达式@annotation(logOperation)是核心:Spring AOP 会匹配所有标注了@LogOperation的方法,并且把注解对象作为参数传进通知方法,这样就能在切面里读取注解属性,真正做到“一个注解,一套逻辑”。
这个玩法熟练之后,你可以做很多事:操作日志、权限校验、幂等控制、接口限流、数据脱敏……常见套路都是用注解承载“元信息”,用切面承载“通用逻辑”。
这里提醒一个容易翻车的细节:AOP 切面自身必须是 Spring 管理的 Bean,否则切点根本不会注册。另外@annotation(logOperation)的形式要求注解定义里@Retention是RUNTIME,这一点前面反复强调过,是自定义注解配合 AOP 的最低门槛。
5.3 @AliasFor 与组合注解的高阶玩法
注解和注解之间还能“组合”。Spring 的@GetMapping本质上就是组合注解,它是@RequestMapping的一个变体,通过元注解继承父注解的语义,再定义自己的别名属性。
@AliasFor就是实现这种组合的关键。它有两个用途:第一,注解内部属性互相别名;第二,把子注解的属性“透传”给元注解属性。看一个实战例子,把“事务 + 操作日志”合并成一个业务注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Transactional(rollbackFor = Exception.class) @LogOperation("") public @interface TransactionalLog { @AliasFor(annotation = LogOperation.class, attribute = "value") String operation() default ""; }使用方写:
@TransactionalLog(operation = "创建订单") public void createOrder(Order order) { // 业务代码 }方法上同时具备了事务回滚和日志记录的能力,而且配置项还能在注解使用处覆盖。这种组合注解在高并发、多团队协作的项目里很有价值,它能把你团队内部的“标准操作包”固化成语义化注解,新人看到注解名大概就知道这个方法是干嘛的,不用一层层剥源码。
@AliasFor的另一个常用场景是让注解的使用体验更友好。比如你定义一个@Metrics注解,内部有过期时间属性ttl,但大家习惯写value:
public @interface Metrics { @AliasFor("ttl") long value() default 300; @AliasFor("value") long ttl() default 300; }这样@Metrics(600)和@Metrics(ttl = 600)都能用,编译期@AliasFor一致性检查还能帮忙揪出手误的地方。
6. @Async 与 @Scheduled:异步和定时任务的进阶细节
6.1 @Async 为什么经常“假装异步”
很多人第一次用@Async都试过一个方法:加上注解,调一下,发现主线程没等它,觉得“成了”。但放到生产环境就露馅:异步任务一多,线程混乱、OOM、任务并发量失控、ThreadLocal里的数据串了,各种奇葩事故都来了。
@Async不生效的绝大多数原因和@Transactional一样:同一个类里的自调用。你调this.asyncMethod()走的不是代理,切面逻辑压根不执行。解决办法也和事务一样:把异步方法拆到单独的 Bean 里,或者在启动类上开启@EnableAsync后再注入自身代理。
再一个容易忽略的点是线程池。Spring Boot 在没有显式配置TaskExecutor时,默认会给你一个线程池,但这套默认参数到了高并发场景容易出问题。生产环境务必自定义线程池:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }线程池的几个参数务必根据业务量算好:核心线程、最大线程、队列容量、拒绝策略。CallerRunsPolicy在队列满时让调用线程自己执行,这种“降级”策略比直接丢弃请求要安全。还有一个潜在的坑:异步方法里的ThreadLocal值默认是拿不到的,因为执行线程不是发起调用时的那个线程。需要传上下文的话,要么显式传参,要么用TaskDecorator在任务装饰器里手动拷贝上下文。
6.2 @Scheduled 定时任务的并发问题与线程池配置
@Scheduled用起来比@Async简单,但并发问题更隐蔽。
默认情况下 Spring 用单线程调度器执行所有定时任务,这意味着你写了五六个@Scheduled方法,执行时间一旦错开不明显,它们其实是串行的。一个任务卡住了,其他到点的任务全在后面排队,表现为定时任务整体延迟甚至不执行。
解决思路有两个:配置文件里调线程池大小:
spring.task.scheduling.pool.size=4或者更精细一点,实现SchedulingConfigurer配置调度器:
@Configuration @EnableScheduling public class SchedulingConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(4)); } }@Scheduled的三个常用调度属性也要分清:fixedRate表示上一次开始执行后间隔固定时间再次执行,不管上次执行了多久;fixedDelay是上一次执行完毕后等待固定时间再执行;cron表达式最灵活但最容易写错。线上排查问题的时候,看到任务执行频率和你预期不符,先去看看用的是哪种属性。
还有一条操作纪律:定时任务方法里记得做异常兜底。单线程调度器下,如果某个定时任务抛出未捕获异常,整个调度器都可能停摆,所有定时任务跟着罢工。稳妥的做法用 try-catch 包住任务主体,再结合告警把异常暴露出来。
7. 监控场景下的 Spring 注解:Actuator 自定义端点
7.1 自定义 Endpoint 的注解写法
Spring Boot Actuator 自带了一堆监控端点,但真实业务里总有“我想给运维一个只看业务数据的接口”这种需求,这时候可以用@Endpoint系列注解自定义端点。
引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>然后写一个端点:
@Component @Endpoint(id = "appInfo") public class AppInfoEndpoint { @ReadOperation public Map<String, Object> info() { return Map.of( "name", "order-service", "version", "2.1.0", "jvmVersion", Runtime.version().toString(), "activeProfiles", Arrays.toString(System.getenv().getOrDefault("SPRING_PROFILES_ACTIVE", "").split(",")) ); } @WriteOperation public void refresh(@Selector String key, String value) { // 动态修改一些运行时配置,比如开关开关 } }@ReadOperation标注的方法是 GET 请求,@WriteOperation是 POST,@DeleteOperation是 DELETE,组合起来就是一个完整的 REST 风格管理端点。这里有个小细节:方法参数上的@Selector会把参数拼进 URL 路径,比如/actuator/appInfo/xxx,可以灵活设计 API 形态。
自定义端点是 Team 里做运维平台、灰度发布、功能开关时的利器。不用再为了“临时查个配置”去登录服务器看日志,调一下接口全都有了。
7.2 健康检查与指标暴露的实践
除了自定义端点,Actuator 还允许你写自己的健康检查器。业务系统往往依赖外部服务,比如 Redis、数据库、第三方 API。这些依赖挂没挂,光靠 Spring 自动检查不够,你得自己告诉 Spring“这个外部服务我的判定标准是什么”。
@Component public class RemoteApiHealthIndicator implements HealthIndicator { @Override public Health health() { boolean reachable = checkRemoteApi(); if (reachable) { return Health.up() .withDetail("remoteApi", "reachable") .build(); } return Health.down() .withDetail("remoteApi", "unreachable") .build(); } private boolean checkRemoteApi() { // 用短超时探活,比如 HttpClient 请求一下健康接口 return true; } }实现HealthIndicator接口并注册成 Bean,Actuator 的/actuator/health输出里会自动带上这个组件的状态。监控系统会定时轮询健康端点,一旦返回 DOWN 就告警,这个方法升级后,你的监控覆盖率会明显提升,而不是只盯着“进程还活着”。
Metrics 这块,Micrometer 是 Spring Boot 的仪表盘,它不是在方法上贴注解那么简单的玩法,更多是通过代码模板埋点。但有个注解值得关注:@Timed。在 Spring AOP 的加持下,标注了@Timed的方法会自动记录执行耗时、调用次数等指标,配合 Prometheus 接入,接口性能曲线就能可视化。不过用@Timed要注意:需要引入micrometer-registry-prometheus以及aspectjweaver,启动类上要加@EnableAspectJAutoProxy,别问我是怎么知道这个坑的。
8. 实战答疑:注解失效排查与编译期问题处理
8.1 注解不生效的排查速查表
把上面所有内容沉淀成一张表,基本覆盖了我这几年遇到的八成注解问题:
| 症状 | 最可能原因 | 排查手段 |
|---|---|---|
| @Transactional 不生效 | 同类内部自调用 | 拆分 Bean,让调用跨类走代理 |
| 事务抛异常不回滚 | 异常被 catch 或不是运行时异常 | 检查是否吞异常,补 rollbackFor |
| @Async 调用还是同步 | 没加 @EnableAsync 或自调用 | 启动类开启,拆分 Bean |
| 自定义注解 AOP 不拦截 | Retention 不是 RUNTIME | 改注解生命周期 |
| @ConfigurationProperties 绑定为 null | 字段没有 getter/setter | 补上或改用 record |
| 校验注解不生效 | Controller 参数没写 @Valid | 加 @Valid 或 @Validated |
| @Scheduled 任务不按点执行 | 默认单线程调度器被阻塞 | 配线程池,查阻塞任务 |
| 注解处理器生成的代码找不到 | IDEA 增量编译没有完整执行注解处理 | 全量 Rebuild 或改用 Maven 构建 |
排查这类问题的总思路其实就一句话:让代码“走代理、过切面、有处理者”。注解不生效,九成是某个环节没接上。
8.2 IDEA “jps 增量注解进程已禁用”是怎么一回事
有段时间我改了一个实体类上的字段,IDEA 编译结果老是报“找不到 getter/setter”,然后控制台出现这么一行警告:
java: jps incremental annotation processing is disabled. Partial recompilation results may be inaccurate.这句话的坑在于,IDEA 的增量编译默认不完整执行注解处理器(比如 Lombok、MapStruct),它只重编你改动过的文件,依赖这些注解处理器生成代码的其他文件没有同步重编,就会出现“目标代码明明存在,但编译还是失败”的情况。这也解释了热搜词里“class 文件 override 注解为什么会丢失”一类的疑惑:并不是注解真的从 class 文件里消失了,而是增量编译没有触发重新生成。
解决办法按优先级排有三条。第一,遇到这种诡异编译问题,先试Build -> Rebuild Project做一次全量编译,能解决绝大多数“改了一行代码,编译器疯了”的现象。第二,如果你是重度使用 Lombok/MapStruct 的项目,建议日常构建用 Maven 或 Gradle 而不是 IDE 内置 build,命令行构建是完整执行注解处理的。第三,IDEA 的编译警告可以通过 Help -> Edit Custom VM Options 加入-Djps.track.ap.dependencies=false消除提示,但这只是治标,核心还是理解增量编译的局限性。
最后再分享一个我在实际开发里反复用到的小技巧:如果某个注解的行为在你预期之外,先别急着百度“XXX 注解为什么不生效”,而是到对应的处理器源码里看一眼触发条件。比如想看@ConditionalOnProperty是怎么判断的,就找OnPropertyCondition;想看@Transactional是怎么拦截的,就找TransactionInterceptor。源码是很枯燥,但带着问题去读,十分钟你可能就能定位到问题根源,这比在群里问人、碰运气试配置要高效太多了。