
配置文件的坑往往不是配置本身写错了而是环境切换时你的配置像一滩烂泥——你以为换了个环境其实是换了个寂寞。第一个坑多环境配置文件写着写着就“串味”了很多团队从单体时代就习惯了application.properties一把梭。等到SpringBoot里需要区分dev/test/prod就随手复制出application-dev.properties、application-prod.properties。刚开始还相安无事直到有一天有人在application-prod.properties里写了个数据库密码想着“反正只有生产环境能加载”结果因为spring.profiles.active忘了设或者设成了dev生产密码直接暴露在代码仓库里甚至被拉到了测试环境。更隐蔽的是配置项的“幽灵覆盖”。SpringBoot的配置加载顺序优先级从命令行参数、Java系统属性、环境变量一路到jar包外的application.properties再到jar包内的。很多人不知道spring.config.activate.on-profile和spring.profiles的区别结果出现同一个配置项在不同文件里定义了三遍最终生效哪个全看运气。比如server.port在application.yml里写8080在application-dev.yml里写9090但通过环境变量SERVER_PORT8088注入——系统变量优先级更高测试同学连端口都猜不对。规避的思路不是“小心一点”而是从根上建立配置边界。把可变配置数据库地址、密钥、限流阈值与不可变配置框架开关、日志级别分离可变配置统一放在配置中心如Nacos/Spring Cloud Config本地配置文件只保留默认值和兜底逻辑。至少在你还没上配置中心的时候禁止在profile文件中写任何密钥密钥必须来源于环境变量或外部挂载的secret文件。密钥泄露一次比配置写错一百次都致命。另一个实用技巧是使用spring.config.import显式声明配置文件依赖而不是靠spring.profiles.active层层覆盖。比如spring.config.importoptional:configserver:http://config-server这样配置来源是线性叠加的哪里覆盖哪里一目了然。记住一个铁律环境的差异应该靠配置源切换而不是靠配置文件内容互相挤兑。你的dev文件只放dev独有的端口和调试开关不要重复定义prod也有的公共项。第二个坑自动配置的“好心”与Bean覆盖的“恩怨”SpringBoot的自动配置确实香但自动配置的魔法在你自定义Bean时常常变成一场灾难。最常见的场景你要自己定义RestTemplate或ObjectMapper于是写了个Configuration类里面Bean返回了一个配置好的RestTemplate。结果启动后某些地方用的还是SpringBoot默认的RestTemplate——原因在于你没搞清楚条件注解的生效顺序。SpringBoot自动配置通过ConditionalOnMissingBean来“让位”给用户自定义Bean。但如果你自定义的Bean并没有在自动配置之前被扫描到或者你的Configuration类被ConditionalOnClass挡掉了那么自动配置的默认Bean会悄悄顶上你的自定义逻辑全程没参与。更气人的是如果你用了RefreshScope配置刷新在某些版本下刷新会销毁原Bean然后重建但重建时自动配置的优先级可能发生变化导致覆盖关系在运行期动态反转——表面上代码没问题一刷新功能就诡异。还有一个经典案例DataSource的循环依赖。你为了多数据源配置手动创建了一个DataSourceBean但SpringBoot的自动配置也会尝试创建DataSource。如果你既保留了spring.datasource.url等属性又自定义了Bean就会出现两个数据源同时存在事务管理器不知道绑哪个。很多新手还会踩FeignClient或者MyBatis-Plus的Mapper扫描冲突结果抛BeanDefinitionStoreException报错信息含含糊糊查半天才发现是自动配置和手动配置重复定义。规避思路分三步第一自定义Bean时给配置类加上ConditionalOnProperty或ConditionalOnClass明确你的Bean在什么条件下才存在而不是无条件覆盖。第二如果你想彻底拒绝某个自动配置使用SpringBootApplication(exclude DataSourceAutoConfiguration.class)显式排除别留到运行期凑合。第三也是最重要的用Primary注解标明你要用的那个Bean尤其在多数据源、多RestTemplate、多ObjectMapper场景下Primary是给注入容器看的“裁判”。道理很简单自动配置是一个乐于助人的陌生人你得学会礼貌地关门。第三个坑事务注解突然“失灵”服务内部自调用是元凶在SpringBoot里用Transactional大家都以为是银弹。但有个场景让无数人抓狂UserService里的方法A调用同类中的方法BB上有Transactional(rollbackFor Exception.class)结果B抛异常后数据照样提交了事务完全没生效。原因不复杂Spring的事务是基于AOP动态代理实现的。当你通过userService.b()调用时调用的是代理对象的方法但当你在一个类内部用this.b()来调用时this是原始对象不是代理于是Transactional上的通知逻辑根本不执行——相当于你穿了一件防弹衣却把子弹打在防弹衣的缝上。这个坑在Async、Cacheable、Retryable等所有基于代理的注解上都会犯只是事务失效的后果最严重。规避思路不复杂第一把需要事务的方法拆分到另一个Service类中通过注入代理对象来调用。第二如果执意要同类内调用可以通过Lazy注入自己或者从ApplicationContext里取出代理对象再调用。第三更硬核的做法是把事务逻辑移到外层让入口方法持有事务边界内部私有方法只做业务操作——但这样粒度变粗大事务容易锁表。最好的实践是将事务注解加在对外暴露的公共方法上内部辅助方法不加事务让入口方法决定事务边界。记住一个心智模型代理管的是边界不是函数体。只要调用链走到了裸代码上注解就变成注释。还有一点容易被忽略Transactional默认只在RuntimeException和Error时回滚而受检异常比如IOException不会触发回滚。所以如果你方法里抛了受检异常记得在注解上写明rollbackFor Exception.class。这不是Spring的bug是设计但设计默认值并不适合所有业务。与其抱怨不如每次写事务注解时心里默念“我的异常我做主”别指望默认值猜中你的心意。第四个坑Async的异步方法调着调着又变成同步了异步化是提升接口响应的常用招数SpringBoot的Async注解看着简单用起来却自带三个连环雷。第一雷是同类自调用和事务一样Async通过代理实现同类内this.asyncMethod()直接裸调异步变成了同步接口耗时原样拖慢。第二雷是线程池被沉默你配置了ThreadPoolTaskExecutor作为异步线程池但没注意Bean名字。SpringBoot默认会找名为applicationTaskExecutor的Executor如果你自定义的线程池Bean名是myExecutor并且没有用Async(myExecutor)指定那么你以为的高性能线程池压根没上阵用的是默认的SimpleAsyncTaskExecutor它每个任务新建一个线程高并发下直接把资源打爆。第三雷是异步方法内的事务和异常偷偷跑丢。异步方法抛出的异常不会传递到调用方如果你没有在异步方法内部try-catch并记录日志或者配置自定义AsyncUncaughtExceptionHandler那么异常就像吞进了黑洞线上查无此错。更麻烦的是异步方法中Transactional事务也是独立的它不在调用方的事务上下文里所以两个方法共同操作同一条记录时可能产生竞态——你原本想通过异步优化结果把一致性问题也异步了。规避思路首先同步方法里不要直接贴Async把异步方法独立到一个Service类中确保外部调用走代理。其次明确定义线程池Bean并起好名字比如Bean(mailExecutor)然后在异步方法上用Async(mailExecutor)指名道姓。给线程池配置合理的核心线程数、队列大小、拒绝策略千万别用无界队列。第三异步方法的异常处理必须显式落地要么方法内部自己try-catch包装成日志和告警要么实现AsyncConfigurer并注册全局AsyncUncaughtExceptionHandler。异步的本质是“把过程委托出去”但委托不等于甩锅你还是要对结果和异常负责。每写一个Async都问自己一句如果这个任务失败了我怎么知道如果不知道就别异步。第五个坑热部署与类加载器让“应该被加载”的Bean成了幽灵SpringBoot开发者偏爱devtools热部署改完代码自动重启键盘敲得飞起。但热部署的类加载机制隐藏着一个“两个我”的陷阱。devtools默认会用一个RestartClassLoader来加载你的代码而一些外部的依赖比如JPA的EntityManagerFactory、MyBatis的Mapper代理、Hibernate的SessionFactory则是在Application启动时用系统类加载器创建的。当你的代码被重启后新类由新的RestartClassLoader加载但某些缓存住的单例比如Cacheable的缓存、Spring容器里的代理对象还持有旧类加载器产生的类于是你在代码里看到明明改对了运行起来却总是旧行为。更诡异的场景是你在热部署后修改了一个实体类的字段保存时Hibernate报IllegalArgumentException: Unknown entity或者Shiro/Spring Security报ClassCastException: Cannot cast ... to ...——都是因为同一个类被两个类加载器分别加载了一次JVM认为它们是两个完全不同的类。这还不是最狠的最狠的是你在static变量里存了某些状态热部署之后static变量还在但类已经换血于是新旧代码混杂行为彻底不可预测。规避思路不是不用热部署而是分清哪些场景适合devtools。在纯业务代码改个if条件、加个日志热部署很爽但你一旦动了依赖库的版本、Spring配置的注解方式、或者实体类映射请直接重启完整应用别指望热部署能体面接管。另一个更稳的做法是关闭devtools的自动重启改用spring-boot-devtools只做浏览器端的CSS/JS缓存禁用真正的大改动交给SpringApplication的restart。或者干脆抛弃devtools使用JVM自带的热替换如-XX:Hotswap但只支持方法体修改不适合结构变化——没有银弹只有明确的边界。更推荐的是用现代的开发容器化把应用跑在Docker里代码变动用docker volume挂载启动加spring-boot.run.jvmArguments做-Dspring.devtools.restart.enabledfalse要重启就重启容器干净利落。你要明白热部署的初衷是“省时间”但排查类加载器带来的诡异问题所花的时间往往超出省下的时间。权衡之下宁愿用接口测试脚本配合几秒级的冷启动也比被幽灵类坑一整晚强。坑底拾遗所有坑背后的“元规律”回看这五个坑其实共享着同一条底层逻辑SpringBoot的“约定优于配置”既是蜜糖也是砒霜。约定帮你省了写配置的时间但你一旦打破约定比如自定义Bean、同类自调用、自定义线程池就必须清楚约定是如何被覆盖的。很多人踩坑是因为“以为”框架懂你的心思而框架只按固定的条件注解和代理顺序执行。另一条规律是注解很多都是“纸面承诺”。Transactional、Async、Cacheable这些声明式能力都依赖AOP代理你只要不经过代理承诺就自动失效。写代码时多问一句我的调用链上断点会不会落在“this”上如果会那就是在裸奔。养成把需要代理的方法放进独立类的习惯能避开80%的偶发灵异。第三条规律关乎排查思路。遇到“配置了但没生效”的怪问题先别怀疑框架先检查Bean的加载情况。在启动日志里看CONDITIONS EVALUATION REPORT用/actuator/conditions查看自动配置的匹配条件——这比看千行配置文件都有用。SpringBoot已经把每一步决策都记录在案只是你很少去读那些打印出来的小字。框架的透明日志是你拨开迷雾的手电筒。实战中没有一个坑是孤立的。配置串味可能导致数据源重复数据源重复又诱发事务失效事务失效让你误改调用链改出来的同类自调用又把异步方法拖成同步。这些坑环环相扣只有从根本上理解SpringBoot的代理机制、自动配置条件、类加载边界才能在写代码的那一刻就避开。最后送上一句自嘲的总结SpringBoot让你幸福地掉坑也能让你痛苦地爬坑——爬出来的经验才真正属于你。