凌晨两点,生产环境的告警群突然炸了。你打开日志,看到一段熟悉的红色堆栈——NullPointerException,但翻遍代码也找不到空值来源。类似的场景在SpringBoot开发中反复上演。异常并不想折磨你,它在努力告诉你真相,只是你还没学会听。排查问题不是找bug,而是还原异常想要表达的故事。
很多人习惯先搜答案,再贴代码。真正的老手会先停下来,观察异常出现的上下文、触发条件、调用链路。SpringBoot的异常体系庞杂,但几乎都遵循同一个逻辑:异常是代码运行时最诚实的自我介绍。当你把异常当成“犯罪现场”,把堆栈当成“目击者证词”,定位问题就有了方向。下面这些思路,是我多年踩坑换来的地图。
别急着看日志,先看异常本身
拿到异常的第一反应,应该是分类。SpringBoot的异常大概能分成三大类:启动期异常、运行期异常、以及“看似正常但结果不对”的逻辑异常。启动期异常最直观,应用起不来,错误信息往往就在启动日志的前几行。运行期异常藏在请求里,需要复现路径。逻辑异常最坑,Spring不报错,但数据就是不对。
分类之后,再问三个问题:这个异常是第一次出现,还是间歇性发生?是单实例偶发,还是所有节点同时爆?触发的输入是固定场景,还是随机输入?这三个问题的答案,直接决定你要往哪个方向挖。固定场景大概率是业务代码缺陷,间歇性多半跟资源释放或并发有关,全局爆发通常指向外部依赖——比如Redis挂掉、数据库连接池耗尽。
有个小技巧:把异常信息里的类名、方法名、行号复制到IDE里,但不要立刻跳转。先看异常链的根部,也就是Caused by那一行。很多新手盯着最上面的异常看,忘记真正的根因可能埋在三层之下。SpringBoot的异常常常是包装过的——比如BeanCreationException里面藏着UnsatisfiedDependencyException,再往下才是ClassNotFoundException。越深处的异常,离真相越近。
启动失败:最容易被忽略的“最后一根稻草”
启动失败看起来吓人,其实最好排查。SpringBoot的启动过程是分阶段的,每个阶段有名字。EnvironmentPrepared、ApplicationContextInitialized、BeanDefinition加载、BeanFactory初始化……报错发生在哪个阶段,问题就属于哪个范畴。
最常见的启动失败原因是端口被占用。但日志不会直接说“端口被占用”,它会报Web server failed to start,然后是BindException: Address already in use。这时候不要慌,先用lsof -i:8080看谁占了端口,杀掉或改配置即可。
另一个高频坑是配置项缺失。比如@ConfigurationProperties类里有个必填字段没在application.yml中定义,SpringBoot默认是静默处理,只在某些严格校验场景下报错。如果你启用了spring.config.import,且引入的文件不存在,启动会直接失败。别把配置缺失当成逻辑错误,报错信息里通常写得明明白白:Could not resolve placeholder 'xxx'。
启动失败还有一个隐性元凶:Bean循环依赖。SpringBoot 2.6之后默认禁止循环依赖,如果你用了@Lazy或@DependsOn强行解开,有时会得到Requested bean is currently in creation。这个异常最误导人的地方在于,它指向的类看起来毫无关联——其实是因为A依赖B,B依赖A,而Spring因为构建顺序不同,把错误报告在了某个中间层。解决办法是重新设计依赖关系,或者用@Lazy延迟注入。记住,循环依赖不是Spring的错,是你的架构在发出求救信号。
上下文里的“幽灵”:依赖注入失败
UnsatisfiedDependencyException是SpringBoot运行期最常见的异常之一。它翻译成普通话是:有一个Bean我找不到或者没法给你造出来。但真正的原因千奇百怪。
类没有加@Component、@Service、@Repository,或者接口实现类没注册,这是最基础的。进阶一点的是泛型擦除导致的问题,比如你定义了一个BaseRepository<T>,注入时写了BaseRepository<User>,但Spring按类型匹配时可能找到多个候选,于是报NoUniqueBeanDefinitionException。
更隐蔽的是构造器注入的循环依赖。Spring推荐构造器注入,但它完全禁止构造器循环。假设A的构造器需要B,B的构造器需要A,容器启动时就会抛出BeanCurrentlyInCreationException。如果你用的Setter注入,Spring反而能通过提前暴露单例引用来解决,但这只是治标不治本。
还有一类和代理有关。用了@Transactional或@Async的Bean,在注入自己内部方法时,如果直接this.method()而不是通过代理调用,事务不会生效,异常也不会报,只是数据不对。这类问题的排查思路不在异常本身,而在观察调用栈里是否有代理类——如果打印出的类名是$$EnhancerBySpringCGLIB,说明代理生效了;如果是原类,就说明你绕过了代理。
数据库连接与连接池:沉默的杀手
数据库异常是另一个重灾区。CannotGetJdbcConnectionException看起来是连不上数据库,但真正的原因可能是连接池被耗尽。HikariCP默认最大连接数是10,一旦并发超过10个慢查询,后面的请求全部排队,超时后抛异常。日志里会显示Connection is not available, request timed out after 30000ms。
排查这种问题,光看代码没用。先看监控面板里的活跃连接数、等待线程数、慢SQL数。如果活跃连接数恒定打满,问题在SQL效率;如果波动很大,问题可能在连接泄漏——代码里执行完SQL没有关闭Connection,或使用了@Transactional但内部抛出异常没有正确回滚。
还有一种情况是数据库驱动不匹配。SpringBoot2.7默认使用MySQL8.0驱动,但老项目里配了com.mysql.jdbc.Driver,新驱动已经改名为com.mysql.cj.jdbc.Driver。报错可能不是类找不到,而是奇怪的CLIENT_PLUGIN_AUTH认证失败。驱动版本和数据库版本必须是合法夫妻,乱了辈分,轻则告警,重则连接直接断开。
连接池相关还有一个经典坑:连接空闲超时被MySQL杀掉,但HikariCP不知道,以为自己持有的连接还活着。当请求执行时,网络层会返回Communications link failure。这通常不是网络问题,而是maxLifetime配置比数据库的wait_timeout更长导致的。把maxLifetime设为比wait_timeout小120秒左右,问题不治而愈。
内存与线程:高并发下的黑洞
OutOfMemoryError是所有Java开发者的噩梦,SpringBoot也不例外。但异常信息本身往往没用——Java heap space只能告诉你堆满了,不能告诉你谁把它塞满的。排查OOM的正确姿势是GC日志和heap dump,而不是在代码里瞎猜。
启动参数里加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp,再配上-Xlog:gc,下次OOM时就能拿到现场。用jvisualvm或MAT分析dump文件,看哪个类占据的字节数最多,异常就藏在那条类加载路径里。
线程问题更隐蔽。ThreadPoolExecutor默认的AbortPolicy会在队列满时抛RejectedExecutionException。如果你用@Async且没有自定义线程池,Spring会使用默认的SimpleAsyncTaskExecutor——它不限制并发数量,且每次调用都会新建线程。高并发下线程数爆炸,内存先于CPU被消耗殆尽。排查时看线程栈的RUNNABLE状态,如果大量Thread-xxx指向同一个自定义Runnable,你就知道是谁在无休止地造线程。
还有一个容易被忽略的点:非静态内部类持有外部类的隐式引用。你在Spring管理的单例里创建了一个线程,线程里又持有Controller的引用,Controller就无法被垃圾回收。久而久之,Perm Gen或Metaspace缓慢增长,最终爆发OutOfMemoryError: Metaspace。这类异常不会出现在请求日志里,只在内存监控曲线上露出马脚。所以排查异常时,别只盯着报错瞬间,多看几个小时的趋势图。
配置加载:你以为的对不一定是真的对
SpringBoot的配置加载有严格优先级。当application.yml和application.properties同时存在,或者环境变量、启动参数、配置中心都定义了同一个key,最终的生效值可能不是你写在文件里的那个。这种问题不抛异常,但行为诡异。
比如spring.datasource.password被系统环境变量SPRING_DATASOURCE_PASSWORD覆盖了,数据库连接全部失败。怎么发现?开启debug=true日志,启动时SpringBoot会打印每个配置项的来源——但有些版本默认关掉,你需要手动打开spring.config.import或使用ConfigurationPropertySources来查看。
更常见的坑是数组成员覆盖。@ConfigurationProperties绑定List属性时,如果默认值在代码里写死,而配置文件中只改了其中一个项,那整个List会被替换,而不是合并。你期待的是“默认列表+新增一项”,实际得到的是“只有新增一项”。这类问题定位的关键是:先打印出运行时配置对象的完整内容,而不是猜测哪些key被覆盖了。
还有一个和@Value相关的陷阱。@Value注入的是字符串,Spring会自动做类型转换,但如果配了spel表达式,比如@Value("#{${my.map}}"),而map里有个值带特殊字符,解析会失败并抛出SpelParseException。好在这类异常堆栈清晰,看一眼就能定位。真正的难点在于配置值看上去没问题,但空格、换行或BOM字符混在一起,导致匹配失败——这时用十六进制编辑器打开配置文件,真相瞬间大白。
排查工具箱:用直觉,也要用程序
讲了这么多具体案例,最后聊聊方法论。高手的排查思路其实是不断缩小可疑范围的过程。从异常堆栈到调用链,从调用链到输入参数,从输入参数到状态变化,每一步都在排除错误假设。
工具方面,Actuator是SpringBoot的原生诊断利器。/actuator/health和/actuator/metrics能实时看到线程数、连接池状态、堆内存使用。如果开启了/actuator/heapdump,甚至可以直接拉取堆快照。很多时候,远程调试不如一键dump来得快。
日志系统别只配到INFO级别。对于关键业务入口,建议在DEBUG日志里打印请求参数、SQL绑定变量和返回结果。排查问题时,最怕的就是“没日志可看”。但也要注意日志粒度,满屏的DEBUG会淹没真正有价值的错误信息。
还有一个老生常谈但永远有效的原则:先在测试环境用最小复现用例做实验,再在生产环境做验证。用SpringBoot写一个独立的测试类,模拟出异常场景,对比测试环境与生产环境的差异——往往你会发现,导致异常的只是某个环境变量、某个依赖版本、或者某条缓存数据。
收尾:把异常当成朋友
每解决一个异常,你其实是在给系统做一次体检。SpringBoot的异常体系再庞大,也逃不过“根因、触发条件、影响范围”这三个坐标。下次再看到满屏红黑堆栈时,先深吸一口气,问问自己:这个异常想告诉我什么?
是依赖关系没理顺,还是资源瓶颈到极限,还是配置覆盖了预期?技术人最值钱的能力,不是背下所有异常的解决方案,而是建立一套从现象推导根因的思考框架。异常不会消失,但你与它对视的能力会越来越强。
当你把排查变成习惯,你会发现:所谓“不易出错的系统”,其实每一处异常都是被清晰记录、快速定位、且留有预案的。SpringBoot是个好用的框架,但框架替你省下的时间,必须在排查基本功上还回来。愿你每次定位,都比上一次更快三分钟。