
凌晨两点生产环境的告警群突然炸了。你打开日志看到一段熟悉的红色堆栈——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依赖BB依赖A而Spring因为构建顺序不同把错误报告在了某个中间层。解决办法是重新设计依赖关系或者用Lazy延迟注入。记住循环依赖不是Spring的错是你的架构在发出求救信号。上下文里的“幽灵”依赖注入失败UnsatisfiedDependencyException是SpringBoot运行期最常见的异常之一。它翻译成普通话是有一个Bean我找不到或者没法给你造出来。但真正的原因千奇百怪。类没有加Component、Service、Repository或者接口实现类没注册这是最基础的。进阶一点的是泛型擦除导致的问题比如你定义了一个BaseRepositoryT注入时写了BaseRepositoryUser但Spring按类型匹配时可能找到多个候选于是报NoUniqueBeanDefinitionException。更隐蔽的是构造器注入的循环依赖。Spring推荐构造器注入但它完全禁止构造器循环。假设A的构造器需要BB的构造器需要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覆盖了数据库连接全部失败。怎么发现开启debugtrue日志启动时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是个好用的框架但框架替你省下的时间必须在排查基本功上还回来。愿你每次定位都比上一次更快三分钟。