接手一个Spring项目,眼瞅着代码规模上来了、启动越来越慢,各种奇怪问题也开始冒头,这是每个做后端开发的人都会经历的阶段。Spring的生态太庞大了,依赖管理、Bean生命周期、事务、代理机制,每一条线都可能埋雷,而且很多时候不是报错明显,而是"看起来正常但行为不对"。这篇东西我不打算写成一本流水账式的官方文档,而是按我实际排查过的方向,挑几个高频、容易让人卡壳、网上答案又比较零散的问题,把原理和可落地的解法串一遍。
1. 启动阶段的问题:不是所有报错都叫"配置写错"
1.1 依赖版本漂移导致类找不到或行为诡异
产出环境跑着跑着突然抛ClassNotFoundException,但本地测试一切正常,这种问题十有八九是依赖版本漂移。Spring Boot本身提供了统一的依赖管理(BOM),但你只要在pom.xml里手动给某个Spring组件指定了<version>,就可能覆盖掉Boot默认的版本,导致不同模块之间用着不兼容的版本。更隐蔽的是间接依赖冲突,比如引入了A、B两个库,它们分别传递依赖了不同版本的Spring核心包,Maven仲裁出其中一个版本,而这个版本和你代码里编译的版本不一致,运行时就会出现“有些类有、有些类没有”的情况。
排查的时候先别急着翻代码,直接看依赖树:
mvn dependency:tree -Dincludes=org.springframework重点看有没有多个版本号。处理方式不外乎两种:如果是自己的模块,把<version>去掉,交给Boot统一管理;如果是间接依赖冲突,在pom.xml中用<dependencyManagement>显式锁定版本,或者对冲突的那条依赖路径加<exclusions>排除掉。不要图省事全用exclusions,它会带来连锁反应,能锁版本就锁版本。
1.2 启动慢不是只靠"加内存"就能解决
一个Spring Boot应用启动卡了三十几秒,而且卡住的位置在HikariCP连接池初始化附近,很多人第一反应是分配内存不够。但实测下来,大部分启动慢的根因是Bean初始化过程中触发了对数据库、Redis、外部接口的同步调用,而这些服务响应超时,导致启动流程一直阻塞在等待上。
先用启动参数定位耗时点:
java -jar app.jar --debugSpring Boot会在控制台打印自动配置报告,能看出哪些条件匹配失败。更细的耗时分布可以借助Actuator的/actuator/startup接口,开启spring.boot.startup.tracking=true后,它会输出每个Bean的创建耗时。如果某个Bean初始化要等外部服务,考虑把连接改为懒加载,或者在@ConfigurationProperties对应的类上延迟初始化连接对象。
这里有个实务技巧:排查启动慢时,优先看日志里"Started Application in xx seconds"之前的最后几行,往往问题就出在最后一个初始化的Bean上,而不是平均分配的。
2. Bean创建与循环依赖:别被"三级缓存"骗了
2.1 构造器注入循环依赖为什么启动直接失败
Spring解决循环依赖靠的是提前暴露单例Bean,但前提是Bean实例已经创建出来,只是属性还没填充完。字段注入和Setter注入之所以能绕过循环依赖,是因为它们可以先用一个半成品对象占坑,后面再补属性。构造器注入就惨了,因为构造器执行完才算"创建出来",无法提前暴露,所以两个Bean互相通过构造器引用对方时,直接抛BeanCurrentlyInCreationException。
我见过很多新手把项目从字段注入改成构造器注入之后,突然冒出一堆循环依赖报错,第一反应是"Spring坏了"。其实不是,而是代码里本身就存在不合理的互相引用。举个例子:
@Component public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } } @Component public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService = orderService; } }启动直接挂。正解不是退回字段注入,而是拆依赖:把UserService里对OrderService的调用抽到一个新组件,或者用@Lazy延迟其中一个代理的初始化。用@Lazy的时候要注意,它生成的是代理对象,不是真身,如果被代理的方法内部还依赖了调用方上下文,调试时容易看到奇怪的代理栈。
2.2 字段注入导致的"启动不报错、调用时空指针"
字段注入最大的坑不是说它不能被Spring管理,而是它把"依赖关系"藏得太深。一个类在单元测试里直接new出来,所有@Autowired字段全是null,跑起来就是NPE。更麻烦的是有些Bean在启动阶段能被创建,但它是CGLIB代理或动态代理,字段注入在代理逻辑里根本不生效,业务调用时才爆。
如果代码里充斥着字段注入,想快速梳理依赖关系,可以先把所有@Autowired字段列出来,检查哪些是集合类型List<Xxx>、Map<String, Xxx>,这类注入最容易被忽略。Spring会把同类型的所有Bean装进集合,如果某个实现类没加@Component,集合里就少一个元素,业务侧没有任何报错,只是功能缺了一块。这种“缺项”比NPE更难查,我排查过一个案例,某地图服务里明明配好了三个实现类,结果一次调用只匹配到两个,就是因为另一个实现类被包扫描路径漏掉了。加上@Component并确认包路径后问题消失。
3. 事务管理失效:最常见的三个"想当然"
3.1 同类内部调用绕过代理,@Transactional形同虚设
事务是Spring里被误解最深的机制。@Transactional不是方法级别的锁,而是通过AOP代理在方法调用前后做事务处理。问题就出在"代理"两个字上——外部调用orderService.save()时,调用的是代理对象;但类内部save()调用saveDetail()时,用的是this,也就是原始对象,压根没走代理,@Transactional自然不生效。
典型场景:
@Service public class OrderService { public void save() { // 业务逻辑 this.saveDetail(); // 这里的事务注解被忽略 } @Transactional(rollbackFor = Exception.class) public void saveDetail() { // 写订单明细 } }解决思路有三条,我按推荐度排序:
- 把
saveDetail()挪到另一个Service中,通过构造器注入调用,让事务方法走代理; - 在同一个类里注入自身代理,
@Autowired或构造器注入OrderService self,调用时用self.saveDetail()(但要注意构造器自注入的循环问题); - 使用编程式事务
TransactionTemplate,把事务逻辑显式写出来。
TransactionTemplate是我个人最推荐的做法。它虽然没有注解那么优雅,但把事务的边界、回滚逻辑、异常处理全部暴露在代码里,排查问题时一目了然,而且不存在代理失效问题。示例:
@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(TransactionTemplate transactionTemplate) { this.transactionTemplate = transactionTemplate; } public void save() { transactionTemplate.execute(status -> { // 业务逻辑 saveDetail(); return null; }); } }3.2 异常被"吃"掉或类型不对,回滚根本不触发
@Transactional默认只在运行时异常(RuntimeException)和Error时回滚,受检异常(比如IOException)不会触发回滚。很多人以为不管抛什么异常都该回滚,结果数据库里留下了半截数据。正确的兜底写法是:
@Transactional(rollbackFor = Exception.class)但更隐蔽的是异常被catch住然后吞掉了:
@Transactional(rollbackFor = Exception.class) public void createOrder() { try { // 业务逻辑 } catch (Exception e) { log.error("下单失败", e); // 没有重新抛出,事务认为一切正常,正常提交 } }这种问题光看代码甚至发现不了,得靠数据库日志。排查事务问题时,我建议开启Spring的事务日志:
logging.level.org.springframework.transaction.interceptor=TRACE它会打印每个切入点的事务状态,能看到Creating new transaction、Committing还是Rolling back。如果TRACE里显示Rolling back但数据没回滚,再查是不是多数据源下配置了多个PlatformTransactionManager,而@Transactional没指定transactionManager。多数据源环境下,必须显式指定事务管理器,比如@Transactional(transactionManager = "orderTransactionManager"),否则Spring会按类型查找,找错了就静默用错管理器。
4. 异步与定时任务:不起眼的"定时不执行"和"线程池爆炸"
4.1 @Async自调用与线程池耗尽
@Async的原理和@Transactional一样,都是代理。同一个类里this.callAsync()调用标注了@Async的方法,异步效果直接消失,方法变成同步执行。这不是Spring的bug,而是动态代理的设计约束。所以异步方法要么定义在别的Bean上,要么注入自身代理再调用。
比异步失效更常被忽视的是线程池配额问题。@Async默认用的SimpleAsyncTaskExecutor,每次任务都new一个线程,没有复用,也没上限。高并发下异步任务一多,线程数能飙到上千,内存和CPU直接被拖垮。生产环境一定要自定义线程池:
@Bean(name = "bizTaskExecutor") public ThreadPoolTaskExecutor bizTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("biz-task-"); executor.setRejectedExecutionHandler(new CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; }随后在异步方法上指定:
@Async("bizTaskExecutor") public void processBizData() { ... }这里有三个配置参数特别值得说明。setQueueCapacity控制积压任务的数量,超过队列后才创建新线程,如果队列太长(比如上万),线程永远升不到MaxPoolSize,任务全在排队,延迟反而加大。CallerRunsPolicy很实用,线程池满时不让任务丢弃,而是让调用线程自己去执行,保住任务的可靠性。WaitForTasksToCompleteOnShutdown则保证容器关闭时,在跑的异步任务能执行完,否则可能丢任务。
4.2 @Scheduled的任务错过、重复、阻塞
@Scheduled默认只有一个调度线程,如果你有多个定时任务,其中某个任务执行时间超过调度间隔,后续任务会被推迟。更麻烦的是,如果某个任务内部阻塞了(比如调用外部接口超时),整个调度线程都卡住,其它任务全部停摆。这种情况在日志里反而看不到报错,只是业务数据更新不规律。
处理方式:给定时任务配置独立线程池。
@Configuration public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }注意spring.task.scheduling.pool.size这个配置项,它不是万能的,某些情况下@Scheduled使用的还是默认的TaskScheduler。直接用SchedulingConfigurer最稳妥。另外,定时任务要考虑幂等——同一时刻任务重复执行(比如多实例部署)可能导致数据重复处理。轻量的解决方案是数据库加唯一索引,或者用一个分布式锁;简单的可以在方法入口用ConcurrentHashMap记录执行状态,但多实例下无效。
fixedDelay和cron的选择也有讲究:fixedDelay是"上次执行完+固定间隔",适合防止重入;cron是按时间点触发,任务执行超时也不会跳,但可能产生重叠执行。需要任务严格不重叠时,用fixedDelay,并配合@Locks之类的分布式组件。
5. 配置管理的滑铁卢:多环境、热更新和"我以为改了"
5.1 多环境配置互相覆盖,profile切了半天还是同一套
Spring Boot的配置优先级有严格顺序,不少开发者搞不清application.yml、application-dev.yml、application-prod.yml和外部配置文件的覆盖关系。比如你想让本地连测试库,改了application-dev.yml里的数据库地址,但启动时没有指定spring.profiles.active=dev,配置永远不生效;或者你在application.yml里写了一份公共配置,又在application-dev.yml里写了一份覆盖配置,结果因为两个文件的属性结构不完全一致,覆盖只生效了部分字段。
配置优先级从高到低大致是:命令行参数 > Java系统属性 > OS环境变量 >application-{profile}.yml>application.yml> 默认配置。所以最可控的方式是:基础配置放application.yml,差异配置放profile文件,启动时通过环境变量或启动参数显式指定profile。注意profile文件不是完全替换基础配置,而是覆盖同名属性。
如果项目有多个profile(dev/test/stage/prod),我倾向于在application.yml里只保留默认值,每个环境用独立的profile文件,并结合配置中心做动态调整。还有一个容易翻车的细节:spring.config.import如果导入了不存在的位置,启动可能直接失败;导入时用optional:前缀可以避免这个问题。
5.2 @ConfigurationProperties改完不生效
一个被@ConfigurationProperties绑定的配置类,没有加@Component或没有在配置类上启用@EnableConfigurationProperties,启动时Spring根本不会创建这个Bean,所有属性全是null。这是最常见的"配置没生效"原因。
另一个常见问题是配置在静态工具类里读取不到。静态方法和静态变量不归Spring容器管,把配置值注入到静态变量是行不通的。正确做法是注入到一个普通Bean,再通过实例方法读取。
@Component @ConfigurationProperties(prefix = "demo.push") public class PushProperties { private String appKey; private String appSecret; // getter/setter }然后在调用方注入PushProperties。
不过这个类的更新时机要留心,@ConfigurationProperties默认在启动时绑定一次,运行期修改配置文件并不会自动刷新。如果项目引入了配置中心,需要配合@RefreshScope才能让配置热生效。@RefreshScope会重建Bean,所以作用在配置类上时,依赖它的其它Bean也可能需要重新注入,否则可能出现旧Bean持有旧配置的"半热更新"现象。
6. 一套通用的Spring问题排查思路
6.1 逆着异常栈找Spring框架层
Spring的异常栈很有特征。BeanCreationException说明Bean实例化失败,BeanDefinitionStoreException说明BeanDef存放在解析时出错,UnsatisfiedDependencyException表示某个依赖无法满足。很多新手一看到异常栈抛了几十层就懵,实际只需要盯住"Caused by"的那一段,那里才是真正的根因。Spring自己产生的异常信息往往比较绕——比如“Error creating bean with name 'xxx'”,真正的错误在后面的Caused by里,也许是连接池配置错误、Redis连不上、找不到某个类。
所以我处理Spring问题第一步永远是:从日志里找到第一个Caused by,往前翻是调用链,往后翻是框架包装。真正需要理解的,往往就那几行。
6.2 线程转储与动态代理的“诡异”现象
Spring大量使用JDK动态代理和CGLIB,在排查问题时,断点打进去发现调用的对象类型是一个带着$$EnhancerBySpringCGLIB字样的类,这不代表代码写错了,而是说明它正在走代理逻辑。代理对象在调试时看到某个属性为空,不能直接断定"这个配置没注入"——它可能只是还没走目标方法。
线程卡死或任务堆积时,比较有效的手段是抓线程转储。Java应用可以执行:
jstack -l <pid>重点看处于BLOCKED、WAITING状态的线程,以及它们是否同时持有一个锁。两个线程互相持有对方需要的锁,就是死锁。Spring的线程池如果核心线程设太小、队列太长,大量任务堆积在WAITING状态,jstack里会看到很多ThreadPoolExecutor.getTask()等待,这时候再结合/actuator/metrics/executor的指标,就能判断是池子配置不合理还是任务本身执行慢。
6.3 最小复现工程:把问题缩小到能一眼看穿
遇到复杂疑难问题,我强烈建议不要在原项目里反复试,而是快速搭一个最小复现工程。把依赖拆到只剩Spring Boot基础包和问题相关模块,把业务逻辑精简为一个Controller + 一个Service,先跑通,再逐步增加条件,到问题复现为止。这样做有两个好处:一是排除自己项目的干扰配置;二是得到一个可以贴进工单的造案例子,别人帮你排查时省了大量沟通成本。
我自己处理过最复杂的一个事务失效案例,根因居然是@EnableTransactionManagement和某个自动配置类的加载顺序冲突。这个问题在原项目里光靠代码审查看不出来,硬是在最小工程里复现后才定位到。所以有时候不要嫌搭Demo麻烦,排查效率和它相比,完全是划算的。
7. 最后说点个人体会
Spring这套框架,用久了会发现大多数问题是相似的。要说哪类最坑,我认为不是报错,而是不报错的行为异常,比如事务没回滚、定时任务没跑、异步变成同步。这类问题没法靠编译器提示,只能靠原理理解加日志验证。排查的时候养成一个习惯:遇到任何"我以为应该……结果没有"的现象,先问一句"我用的这个机制到底基于什么原理",答案往往就藏在动态代理、Bean作用域、配置优先级这三件事里。纸上得来终觉浅,实际操作几次之后,这些东西自然会沉淀成直觉。