排查过几个月的Spring Boot问题之后,我最大的感受是:这个框架真正出错的地方,往往不在它报错的地方。你有没有遇到过这种情形——接口偶尔卡个两三秒,日志里却干干净净;配置文件明明改对了,重启之后行为还是老样子;代码逻辑怎么看都没问题,启动的时候却告诉你某个Bean找不到。Spring Boot把“约定优于配置”做到了极致,开箱即用是真的方便,可一旦出了状况,那一层层的自动装配、条件注解、代理机制就像一层雾,把真正的根因藏在了你第一眼看不到的地方。
这篇文章是一份问题总结,按一个项目从启动到上线的顺序,把日常开发里见到过最多、也帮同行排查过最多的六类问题拆开揉碎。每一类我都写清楚是什么现象、根因出在哪、用什么思路定位、最后怎么解决。内容不追求面面俱到,更希望你在遇到相似状况的时候能拿过来对照排查,少走几步弯路。
1. 启动失败:版本冲突与依赖缺失是头号元凶
1.1 先分清:这是哪种启动失败
启动失败可以粗略分成两种。第一种是有报错的:控制台直接甩出异常堆栈,常见的像ClassNotFoundException、NoClassDefFoundError、BeanCreationException。第二种是没报错的:你启动main方法,进程闪几下就退出,连一行异常都看不见。第一种看着吓人,其实最好解决,因为堆栈里基本已经告诉你去哪查了。第二种更难受,什么提示都没有,排查全靠摸索。
有报错的情况里,NoClassDefFoundError和ClassNotFoundException非常高频。前者是类在编译期存在、运行期却找不到了,后者是压根没被加载到。这两兄弟出现时,不要急着去百度报错信息,第一步永远是打开pom.xml,检查你本地引入的依赖到底有没有被打进运行环境。
没报错直接退出的情况,最常见的是端口被占用,或者启动类连Spring容器都没拉起来。后者往往跟你IDE的启动配置有关,比如运行环境选错了JDK版本,或者工作目录不对。我见过不止一次,项目在同事机器上跑得好好的,到自己机器上一启动就退出,最后发现是IDE里默认的JRE版本和项目要求的对不上。
1.2 版本冲突:依赖树是唯一的照妖镜
Spring Boot通过spring-boot-dependencies对一大批常用依赖做了版本统一管理,这是它能“开箱即用”的底气。但这份管理有个前提:你不能在pom.xml里乱写版本号。一旦你手动指定了某个依赖的版本,就相当于绕开了BOM约束,冲突就此埋下。
这里有个很典型的场景。项目里用了spring-boot-starter-data-redis,这套Starter默认用的是Lettuce作为Redis客户端。但有人为了方便,又单独引入了老版本的jedis。按说两者可以共存,可如果你代码里两种客户端都在用,或者某个第三方库传递依赖了特定版本的JDBC驱动、JSON库,启动时就会冒出各种莫名其妙的Bean创建失败,报错信息却指向一个乍看和Redis没关系的地方。
排查这种问题,我只用一招,依赖树:
# 查看某个特定依赖的完整传递链条 mvn dependency:tree -Dincludes=redis.clients:jedis # 查看所有版本冲突的地方 mvn dependency:tree -Dverbose | grep -i conflictdependency:tree会把当前项目的依赖以树形结构全部打出来,谁引了谁、最后解析成哪个版本,一目了然。看到冲突后,处理方式一般三种:在pom.xml里用<exclusions>把多余的传递依赖排除掉;或者把版本统一到<dependencyManagement>里,强制指定你想要的版本;再或者干脆升级到Spring Boot官方推荐的版本。
我自己的习惯是,只要遇到启动期的类加载问题,先跑一遍dependency:tree -Dverbose,大概率能看到某个依赖被解析成了两个不同版本。比对着异常堆栈猜来猜去效率高太多了。
1.3 别忽略Java版本和编译版本
Spring Boot 2.7要求Java 8起步,Spring Boot 3.x直接强制Java 17。你把一个基于Spring Boot 3的项目拿到Java 8环境去跑,启动时会毫不客气地报UnsupportedClassVersionError,意思就是“class文件版本号比JVM能认的高”。
真正隐蔽的是另一种情况:本地IDE用的JDK 17,代码编译的时候也正常,但打包机或服务器上装的是JDK 8,运行时直接翻车。检查顺序很简单——先在目标服务器上执行java -version,再对比pom.xml里的<java.version>或是<maven.compiler.source>、<maven.compiler.target>,确保两边一致。别问我为什么会把这两个搞反,哪个项目里都有过。
1.4 端口被占用:别急着换端口
Web server failed to start. Port 8080 was already in use.这句话大概是Spring Boot开发者听过最多的启动报错了。Linux上查占用用lsof -i:8080,Windows上用netstat -ano | findstr :8080,查到PID再taskkill掉,这是常规操作。
但我想多说一句:查出来之后,最好想一下这个进程为什么还活着。很多时候是IDE调试时启动过多次服务,旧进程没退干净,或者上次是用命令行起的服务,窗口关了进程还在。盲目把端口从8080改成8081,过一会儿8081也被占——治标不治本。顺手看一眼进程是什么时候起来的,比对一下是不是自己之前的残留,往往比反复改端口省事得多。
顺带提一个冷门配置:如果你在多实例测试环境,可以把server.port设成0,Spring Boot会用随机端口启动,对本地起多实例调试挺方便。生产环境一般不这么干。
1.5 启动慢:懒加载是止痛药,不是治疗方案
启动慢也算一种启动问题,而且很多项目启动慢到让人怀疑人生。慢的根源其实跟Spring Boot本身关系不大——框架初始化那些Bean是有固定开销的,真正拖时间的通常是你自己的代码:某个InitializingBean实现里做了全量数据加载、某个@PostConstruct方法里去调了远程接口、或者classpath扫描的范围大得离谱。
开发环境有个取巧的办法:
spring.main.lazy-initialization=true这个配置会把Bean初始化推迟到第一次被使用,启动速度会有肉眼可见的提升。但我得提醒一句:生产环境不建议开。因为你把问题从启动阶段推迟到了请求阶段,线上第一个请求可能直接超时,而且有些初始化失败会延迟暴露,比启动时立刻失败更难查。
真正想把启动时间压下来,还是要从根上抠:减少扫描路径、把启动时的重逻辑改成异步、关掉用不到的自动配置。
2. 配置不生效:优先级、绑定失败与缩进陷阱
2.1 配置文件优先级:先搞清楚它在读哪个
“为什么我改了application.yml就是没效果?”这类问题的排查难度不高,但高频出现。之所以高频,是因为很多人默认项目里只加载一个配置文件,而Spring Boot的配置来源比想象中多得多——命令行参数、环境变量、外部配置文件、jar包内的配置文件,它们之间有极其严格的优先级。
简化之后排序如下(从高到低):
| 优先级 | 配置来源 | 示例 |
|---|---|---|
| 最高 | 命令行参数 | java -jar app.jar --server.port=9090 |
| 高 | 操作系统环境变量 | SERVER_PORT=9090 |
| 中 | jar包外的配置文件 | 与jar同目录的config/application.properties |
| 低 | jar包内的profile配置 | application-prod.yml |
| 最低 | jar包内的默认配置 | application.properties/application.yml |
另一个容易踩的规则是:application.properties和application.yml同时存在时,两个文件都会被读取,内容做合并,但同一个key冲突时,application.properties说了算。
所以在排查“配置改了没生效”时,第一件事不是检查自己的拼写,而是看看有没有更高优先级的配置源在跟你作对。云环境、容器环境尤其如此,很多时候运维同学已经预设了一堆环境变量,DB_HOST、SERVER_PORT这种东西早在部署平台层面被覆盖了,你本地改再多次都无济于事。
2.2 yml缩进:一个空格引发的静默Bug
YAML对缩进极度敏感,而且它有个特性——解析不了的时候不一定会报错。最常见的两个错误:一是用了Tab键,二是在冒号后面没加空格。
server: port: 8080 # 正确:冒号后面必须有空格server: port:8080 # 错误:这是key为"port:8080"空值的一行,不是端口配置port:8080在YAML解析器眼里,整个被当成一个字符串key,Value为空。Spring Boot读配置的时候找不到server.port这个key,干脆就用默认值8080,一切正常启动,没有任何异常。这是整篇清单里最阴险的一类问题——完全不报错,行为就是不对。
排查这种问题最快的方法,是把值打出来看。最土的办法是在启动类里加一行System.out.println(System.getenv())或者打印配置值;更推荐的是打开Actuator的/actuator/env端点,直接看生效的配置到底是啥。另外现在IDE对YAML缩进基本都有高亮提示,看到红色波浪线不要无视。
2.3 @ConfigurationProperties绑定失败:不报错,就是null
@ConfigurationProperties是Spring Boot读取配置的推荐姿势,但也有一堆静默失败的点:
- 类上没加
@Component,也没通过@EnableConfigurationProperties注册,配置类压根没进Spring容器; prefix写错,跟YAML里的层级对不上;- 字段没有setter方法,Spring没法赋值(如果用构造函数绑定,则要提供对应的构造函数);
- 字段类型写错了,比如用
String去接一个数字,类型转换失败后同样可能是null。
正确姿势大概是这样的:
@Component @ConfigurationProperties(prefix = "app.datasource") public class AppDataSourceProperties { private String url; private String username; // getter和setter必须有,否则值绑不进去 public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } // ... }配套的YAML:
app: datasource: url: jdbc:mysql://localhost:3306/demo username: root这里还要记住一个概念:松散绑定。配置文件里写的是my-server-config,Java字段叫myServerConfig,Spring Boot会自动对齐,不需要你逐字对应。但如果你把字段拼错成myServerCongfig,那就只能得到个null,而且没有任何日志提示。我建议配好之后,启动时打印一下配置类的toString(),确认值真的进来了。
2.4 @Value配static字段,一辈子也注入不进去
@Component public class DemoService { @Value("${app.name}") private static String appName; // 永远是null }把@Value标在static字段上,是新手特别容易踩的坑。因为依赖注入是Spring对Bean实例做的操作,static字段属于类本身,不在Spring的管理范围内。Spring在创建Bean实例时没法给静态字段赋值,所以那个字段永远是默认值。
解决办法不是去掉static,就是改成实例字段再通过getter暴露。如果非要静态访问,可以设计成一个实例字段加一个静态getter,或者把配置值放进一个单独的配置Bean里统一管理,业务类再去引用它。
2.5 配置里的特殊字符和敏感信息,别直接写明文
YAML文件里有一个很不起眼但一旦撞上就很闹心的问题:特殊字符。比如数据库密码是abc&def或者abc#123,YAML解析器会在&或#附近搞出诡异行为,导致配置加载异常。
最稳妥的办法,一是用引号包起来,二是彻底不要把这类敏感信息写进配置文件,改用环境变量占位:
spring: datasource: password: ${DB_PASSWORD}环境变量占位符的写法是${VAR_NAME:默认值},冒号后面是找不到环境变量时的兜底值。这套机制成本最低,效果却立竿见影——至少密码不落盘了。再往上走就是引入一套配置加密方案,但先把明文从配置文件里挪走,这是底线。
3. 注解与自动配置失灵:Bean没注册、事务没走代理
3.1 @SpringBootApplication的扫描边界,卡死过很多人
@SpringBootApplication其实是由@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan三个注解组合出来的。它默认扫描的是“启动类所在包及其子包”,很多人会忽视这个限制,把controller、service、dao这些类的包路径放得跟启动类平级,甚至放到了完全不相干的位置。启动时一切正常,但一注入就报NoSuchBeanDefinitionException。
处理方式有两种。要么把代码全部挪进启动类所在包下面,结构规整,也最符合Spring Boot的约定;要么干脆在启动类上显式指定扫包范围:
@SpringBootApplication @ComponentScan(basePackages = "com.example") public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这里有个副作用你得知道:一旦显式写了@ComponentScan,默认扫描范围就失效了,classpath下所有Bean都按你指定的包去扫。别到时候发现原来能扫到的Bean突然没了,就是因为这个覆盖行为。
3.2 自动配置没触发:用debug报告看条件
Spring Boot的自动配置类上挂着一大堆条件注解,@ConditionalOnClass、@ConditionalOnProperty、@ConditionalOnMissingBean。你引入一个Starter,不代表对应的自动配置就一定会生效——它要求类路径上存在某个底层类、配置里满足某个属性、或者容器里还没有某个Bean。条件不满足时,自动配置会静默跳过,不报错。
排查这类问题,最有效的方法是临时在配置里打开debug模式:
debug=true启动日志里会出现两大段输出:Positive matches和Negative matches。前者列出的自动配置生效了,后者列出的自动配置没生效,并且会标注条件是什么、为什么没满足。比如你引入Redis Starter后RedisAutoConfiguration出现在Negative里,下面通常会写明缺了哪个类,或者@ConditionalOnClass没命中。看到那个原因,问题就解决一大半了。
3.3 事务失效:先问自己一句“走代理了吗”
@Transactional大概是Spring里最容易被误解的注解,它的本质是AOP代理。一旦你绕过代理调方法,事务就会静默失效。最常见的失效场景是同一个类里的方法A调用方法B,B上标了@Transactional——实际执行的是this.B(),这个调用没有经过Spring生成的代理对象,也就没有任何事务逻辑被织入。
事务失效的典型原因,按出现频率排列:
- 同一个类内部自调用,事务方法没有被外部代理拦截;
- 方法不是
public,@Transactional只对public方法真正生效; - 异常被try-catch吞掉了,Spring感知不到,也就不会回滚;
- 数据库存储引擎不支持事务,比如MyISAM;
- 多数据源环境下事务管理器没配对。
排查思路很简单:先确认这个事务方法是不是从外部Bean进入的,然后看异常有没有被自己吞掉。想解决自调用问题,最直接的办法是把事务方法拆到另一个Service类里,让调用变成跨Bean调用;也有人用@Lazy注入自己的代理对象,但我个人更倾向于拆类,语义更清晰,也不用绕弯子。
3.4 @Async和@Scheduled不生效:不是注解的锅
@Async不生效,十有八九是两件事之一:忘了在启动类或配置类上加@EnableAsync,或者在你自己的类里自调用异步方法。和事务一样,异步也是靠代理实现的,自调用必然失效。
还有一个容易忽略的点:如果@Async方法是void签名,方法内的异常不会被外层捕获,默认的AsyncUncaughtExceptionHandler只是把异常打在日志里。排查异步任务失败时,别只盯着调用方的日志,去看看有没有专门的异步异常输出。
@Scheduled与此类似,要记得@EnableScheduling。另外定时任务默认是单线程执行的,多个任务如果都很重,其实在排队。需要并发的话,得自己实现SchedulingConfigurer来指定线程池。还有多实例部署场景,同一个定时任务会在所有实例上各执行一遍。涉及发消息、送积分这类逻辑,必须上分布式锁,这跟Spring本身无关,但确实是部署了Spring Boot应用之后才会冒出来的问题。
3.5 覆盖默认Bean的正确姿势,不是写一个新Bean
有人想替换Spring Boot默认的ObjectMapper,直接在配置类里写了个@Bean返回新的ObjectMapper,结果发现完全没生效。原因是Spring Boot的自动配置里写着@ConditionalOnMissingBean(ObjectMapper.class),而自动配置的执行时机比你自定义Bean的注册时机更早,条件一判断发现容器里还没有自定义的ObjectMapper,框架就自己创建了一个默认的。等到你的@Bean方法执行完,容器里已经有了两个ObjectMapper,但很多组件已经绑定了框架自己创建的那个。
跟自动配置抢时机不是好路子。Spring Boot针对常见组件提供了定制钩子,比如Jackson就有Jackson2ObjectMapperBuilderCustomizer,你要做的不是重造一个ObjectMapper,而是通过Customizer往里加配置,让框架在创建默认ObjectMapper时把你的定制并进去。这样从根本上避免了覆盖失效的问题,也保留了自动配置的完整能力。
4. 运行期故障:连接池耗尽、序列化异常与内存告警
4.1 连接池耗尽:先查HikariCP再说别的
接口偶发超时的头号嫌疑对象,我建议先查连接池。Spring Boot默认数据源是HikariCP,默认最大连接数是10。数据库连接不是越多越好,但很多接口处理得慢,一个请求捧着连接不放,10个连接在并发稍高时很快就被占满。
当日志里出现Connection is not available, request timed out after 30000ms或者HikariPool-1 - Connection is not available时,就是连接池耗尽的典型信号。
HikariCP几个关键参数值得记住:
| 参数 | 默认值 | 说明 |
|---|---|---|
maximum-pool-size | 10 | 最大连接数 |
connection-timeout | 30000ms | 获取连接的超时时间 |
minimum-idle | 与maximum-pool-size相同 | 最小空闲连接数 |
max-lifetime | 1800000ms(30分钟) | 连接最大存活时间 |
idle-timeout | 600000ms | 空闲连接超时回收 |
如果你能拿到峰值QPS和单请求平均占用连接时间,可以做一个粗略估算:
连接数 ≈ 每秒请求数(QPS) × 单个请求占用连接的时间(秒) / 60这只能作为起步值,真实环境一定要靠压测校准。更要紧的是排查连接为什么被占这么久。某次我处理一个类似问题,发现连接池被占满根本不是池子配置的锅,而是一个接口的SQL没走索引,单次查询秒级返回。流量稍微上来一点,10个连接全在跑慢查询,其他请求全部排队。这时候调大连接池是治标,优化SQL、补索引才是治本。
4.2 Jackson序列化:日期格式和循环引用
返回JSON时日期格式不对,是运行期另一个高频问题。LocalDateTime是Java 8之后的主流日期类型,但Jackson默认把它序列化成一长串数组,前端收到根本没法直接用。
想全局统一时间格式,常见的做法是在配置里加:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss注意,这段配置对LocalDateTime并不生效,它只管java.util.Date那种老类型。要管住LocalDateTime,要么在字段上一个一个加注解:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime;要么写一个全局定制的Jackson配置,注册JavaTimeModule并设置时间格式。我个人的建议是配置好全局的格式,这样新写的字段不用每个人都记得加注解。
还有一个更烦人的问题:循环引用。两个实体类双向关联时,序列化会无限递归,直接抛Infinite recursion。这个问题的本质不是Jackson配置,而是你的接口把带关联的实体直接裸返回给了前端。正解是搞一层DTO,把实体引用换成基本类型和字符串,从结构上切断环。偷懒一时爽,后面的锅都是自己的。
4.3 日志问题,很多时候不在代码里
日志不打印、打印了不滚动、滚动后丢了,这些都算运行期问题。先说一个高频原因:配置文件的名字。Spring Boot下,logback-spring.xml和logback.xml都能被读取,但如果是logback-spring.xml,Spring Boot会多处理一层,支持<springProfile>、<springProperty>这类扩展能力。扬言能用profile来区分环境的日志配置,只有logback-spring.xml能做到。
另一个常见情况是日志文件只写了几天就停了,检查一遍往往是磁盘满了,或者运行应用的系统用户对日志目录没有写权限。日志文件越来越大、几天不滚动,则是滚动策略没配好。还有异步日志场景,AsyncAppender里有queueSize和discardingThreshold两个参数,队列满的时候可能会丢弃TRACE、DEBUG、INFO级别的日志。排查“日志少了一段”的时候,先看看是不是异步队列丢的,别上来就怀疑业务代码没执行。
4.4 内存与OOM:先自检JVM参数和线程池
Spring Boot应用本质上是一个多线程Java进程,连接池、Tomcat线程池、异步线程池、定时任务线程池,每个池子都在抢资源。OOM出现时,先别急着怀疑框架,按这个顺序排查:
第一步,确认JVM参数。-Xms和-Xmx设了多少?是不是没设直接用了默认值?很多应用其实压根没配过JVM参数就跑上了生产。第二步,看有没有堆外内存消耗大户,比如Netty、NIO一类的组件。第三步,再考虑代码层面的问题,比如某个集合无限增长、缓存没设上限。
生产环境的JVM参数,我建议至少加这三项:
-Xms2g -Xmx2g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdump.hprof前两个值设为相同,可以避免JVM在运行中反复扩容收缩。后面两个保证OOM发生时把堆快照留下来,事后分析有据可查。
如果应用跑在Docker容器里,还有个特殊坑:容器内存上限不会被老版本JVM正确识别,JVM把宿主机内存当成可用内存来分配堆,结果很容易把容器内存打满,然后被内核直接杀死。解决方式是设置-XX:MaxRAMPercentage,按容器内存的百分比来分配堆,而不是固定写死-Xmx。
4.5 接口偶发超时的破案路径
偶发超时之所以难查,是因为它不规律。我自己的排查路径基本固定:
先看数据库。有没有慢SQL,慢日志开没开。这是成本最低的一步,也是命中率极高的一步。
再看连接池。连接池耗尽时,请求会卡在获取连接那一步,表现为偶发超时。
再看GC。如果GC停顿时间很长,接口可能被世界停住几百毫秒。用jstat -gcutil或者查看GC日志,就能知道暂停频率和持续时间。
最后抓线程dump。jstack -l <pid>,抓两到三次,看请求线程到底卡在哪里。大量线程处于BLOCKED状态,通常是在抢锁或等数据库连接;大量WAITING状态,可能是在等线程池里的空闲线程。
这条路径不用什么高深工具,从便宜到贵的顺序排查,大多数偶发超时都能找到根。
5. 打包部署后水土不服:本地能跑、线上趴窝的典型场景
5.1 Fat Jar打包后才会暴露的依赖问题
开发环境用IDE直接跑,classpath跟你pom.xml声明的依赖基本一致。打好包之后就不同了——Spring Boot的Fat Jar把应用代码放进BOOT-INF/classes/,把依赖放进BOOT-INF/lib/,目录结构跟本地完全不同。有些在IDE下正常的依赖打架问题,打包后才暴露出来。
no main manifest attribute这个报错,基本可以确定是spring-boot-maven-plugin没有正确配置,或者打包过程把原来的manifest覆盖了。遇到Fat Jar内依赖问题,最快的排查方式是把jar解压,看看BOOT-INF/lib下面的依赖清单:
unzip app.jar -d app/ ls app/BOOT-INF/lib/ | grep "jedis"直接看实际打进去的是什么版本,比猜来猜去直观得多。
5.2 配置外部化:环境差异的根治方案
同一份jar要跑多个环境,最怕配置文件里写了死参数。数据库地址、Redis地址、消息队列地址,这些在不同环境一定是不一样的。如果你把它们写死在application.yml里,那每次部署都要改配置文件重新打包,版本管理也乱套。
正确的做法是占位符加环境变量:
spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/demo username: ${DB_USER:root} password: ${DB_PASSWORD:}冒号后面的是默认值。本地不设环境变量时跑默认值,生产环境把环境变量配上就行。再进一步可以引入配置中心,但把环境相关配置从应用配置文件里剥离开,这一步是基础。
时区问题也是部署后高频出现的。数据库连上了,查出来的时间差8个小时,先别怀疑代码,去看看服务器时区。date命令输出里能看到CST还是UTC,把服务器时区调整为跟数据库、前端约定一致,通常用Asia/Shanghai。
5.3 Docker部署:时区、JVM和健康检查三件套
容器部署现在已经是主流了,但有几个坑几乎人人都踩过。
第一个是时区。很多基础镜像默认是UTC时间,应用的日志和业务数据都比北京时间少8小时。解决方式是Dockerfile里加两行:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone注意基础镜像里可能没有tzdata这个包,如果没有,需要先安装。
第二个是JVM内存,就是前面说的MaxRAMPercentage。在容器里跑Java应用,最好别用固定的-Xmx,因为容器内存可能会调,用百分比更省心。
第三个是健康检查。Spring Boot Actuator本身提供了/actuator/health端点,可以在Dockerfile里配合HEALTHCHECK指令,让容器编排系统能够感知应用状态。
第四个是优雅停机。Spring Boot从2.3开始支持:
server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s配置之后,服务收到终止信号时会先停止接收新请求,再等待正在处理的请求完成,最多等30秒,然后才真正关闭。这能明显减少发版时的请求失败率。
5.4 网关代理与上传限制:两个隐藏的配置
server.servlet.context-path一旦设置,所有接口URL都会加一个前缀。这个配置本身没什么复杂,真正麻烦的是上游网关、前端如果没有同步改,线上会突然冒出一堆404。排查404时,先看看是不是context-path相关的配置不一致。
反向代理场景还有个经典问题:服务拿到的客户端IP是代理IP,而不是真实用户IP。Spring Boot可以设置:
server.forward-headers-strategy=framework让它识别X-Forwarded-For、X-Forwarded-Proto这类标准的代理头。不配置的话,基于IP做限流、风控、日志分析的功能都会出错。
还有上传文件大小限制。很多人只看到接口报413或者FileSizeLimitExceededException,不知道背后有两个参数:
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=20MBmax-file-size控制单个文件大小,max-request-size控制整个请求体大小。只改前者不改后者,一个请求里带多个文件时照样失败。
5.5 生产日志与监控:Actuator端点别全开
生产环境的日志和监控,很多是应用上线之后才回头补的。我的建议是,新项目一初始化就把日志滚动策略配好,按天滚动、保留15天,权限控制好,别让进程因为日志目录没权限而挂掉。
Actuator端点也要提前规划。health、info、metrics这几个是基础,按需开放。不要图省事把management.endpoints.web.exposure.include=*直接甩到生产环境,这个星号会把一堆内部端点暴露出去,其中shutdown端点是谁调谁停服务的危险分子。生产环境至少要把shutdown排除掉,其他的按需最小化开放。
6. 一套顺手的问题排查工具与命令清单
6.1 启动阶段排查:--debug与Actuator是最佳搭档
启动时加--debug参数,或者在application.properties里写debug=true,日志里会出现Positive matches和Negative matches两段内容。
Positive matches:哪些自动配置生效了;Negative matches:哪些自动配置没生效,以及没生效的条件原因。
这是定位自动配置失灵最直接的入口。另一个好东西是Actuator的在线端点:
/actuator/beans:列出容器里所有Bean;/actuator/configprops:配置属性绑定情况;/actuator/env:当前生效的全部配置来源和值;/actuator/metrics:JVM、连接池等运行指标;/actuator/health:健康检查。
开发环境可以把include=*放开,生产环境按需开放。
6.2 依赖树与排除依赖
依赖问题回到Maven命令。mvn dependency:tree是基础,配合-Dincludes参数可以精确过滤:
mvn dependency:tree -Dincludes=org.springframework:spring-core想剔除冗余的传递依赖,在pom.xml里这样写:
<dependency> <groupId>com.example</groupId> <artifactId>some-library</artifactId> <exclusions> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> </exclusion> </exclusions> </dependency>要是多个模块都用同一个依赖的不同版本,在根pom.xml的<dependencyManagement>里把版本统一掉,比在子模块里一个个排除高效得多。
6.3 运行期三件套:jps、jstack、jstat
线上问题定位,离不了这三条命令:
# 找到进程ID jps -l # 查看线程状态,抓线程dump jstack -l <pid> > thread_dump.txt # 观察GC情况 jstat -gcutil <pid> 1000jstack输出里大量BLOCKED状态,通常是在抢锁或者等待数据库连接;大量WAITING状态,可能是在等线程池的空闲线程,或是被某个异步操作挂住了。jstat看的是YGC、FGC的次数和耗时,FGC频繁且耗时长,基本可以确定堆内存或GC参数需要调整。
内存溢出之后,用heap dump分析工具打开-XX:HeapDumpPath留下的hprof文件,看是哪些对象把堆占满了,定位就非常直接。
如果环境不方便加太多Agent,还有一个非常好用的内存诊断工具,叫arthas。它在线上可以直接看线程、内存、类的加载情况,还能trace方法的调用耗时,对定位接口慢、调用链问题特别有用。
6.4 一个“现象到方向”的速查表
把前面这些整理成一个速查表,遇到问题先对着看:
| 现象 | 首选排查方向 |
|---|---|
启动报NoClassDefFoundError | 依赖版本冲突、Java版本不一致 |
启动报Bean找不到 | 包扫描范围、自动配置条件未满足 |
| 配置改了没生效 | 配置文件优先级、环境变量覆盖 |
| 事务没回滚 | 自调用、非public、异常被吞 |
@Async/@Scheduled不执行 | 缺@EnableAsync/@EnableScheduling、自调用 |
| 接口偶发超时 | 连接池耗尽、慢SQL、GC停顿 |
| 本地正常线上挂 | 外部化配置、时区、容器内存 |
| 日志查不到内容 | logback-spring.xml命名、异步丢日志、权限 |
6.5 新项目上线前的配置检查清单
最后分享一份我在实际开发中沉淀下来的上线前检查清单。不一定每条都适用每个项目,但对照着过一遍,能把大量低级的、重复性的坑提前拦掉:
- 确认配置文件中没有明文密码,敏感信息用环境变量占位;
- 连接池参数不是默认值,至少经过一轮压测验证;
- JVM参数已设置,
-Xms和-Xmx相同,OOM时留下heap dump; - 日志按天滚动,保留天数合理,异步日志丢弃策略可接受;
- Actuator只开放必要端点,
shutdown不外露; - 设置了
server.shutdown=graceful和超时时间; - 服务器的时区、容器的时区已统一;
- 健康检查已接入,容器编排能感知应用状态;
- 多实例部署环境,定时任务、缓存更新都考虑了分布式场景。
排查Spring Boot问题这些年,我最大的体会是:先把“不可能”排除掉,再往深处找。配置没生效,先确认它到底读没读到;事务没回滚,先确认调用路径有没有走代理;接口慢,先看数据库和GC,而不是一上来就怀疑框架。绝大多数的问题,最后查出来的根因都很朴素。真正让人绕远路的,往往不是问题本身有多难,而是Spring Boot那层自动约定,带着你在错误的方向上越走越远。希望这份总结,能帮你少走几段这样的弯路。