踩坑,不是坏事,但重复踩同一个坑,就是浪费生命。我见过太多团队在SpringBoot项目刚起步时,用一周时间搭骨架,然后用三个月时间给当初的草率决定还债。今天这篇,不谈理论,直接把我自己反复踩过、也看别人反复踩过的五个坑摊开来讲。每个坑都配了具体的避坑姿势,希望你能绕开它们。
第一坑:目录结构按“层”分包,而不是按“域”分包
很多新手甚至老手,一上来就建四个包:controller、service、dao、entity。看着清爽,项目一大就完蛋。订单相关的逻辑散落在二十个目录里,改一个需求要跨五个包找代码。分层是逻辑概念,分包是物理组织,两者强行一一对应,只会让代码腐烂速度翻倍。
正确的做法是按业务域分包。比如order域下面放OrderController、OrderService、OrderRepository、OrderEntity,整个订单相关的东西内聚在一起。内聚性是代码可维护性的第一指标,不是包名多漂亮。如果担心跨域调用,用接口隔离,而不是用物理位置强行拆分。
另外,包名不要用common之类的大杂烩。我见过某个项目的common包里塞了工具类、常量、异常定义、配置类、还有几个过时的Service。这不是公共模块,这是垃圾场。真要有共享逻辑,按领域命名拆分,比如order-api、user-api,或者干脆通过独立jar管理。
避坑建议:新建项目时,先花半小时画出业务域地图,然后按域建包。如果项目已经分层了,也别急着大重构,可以逐步把新需求写到新域包里,老代码慢慢迁移。迁移不是一次性的工程量,而是一个持续性的卫生习惯。
第二坑:配置文件一把梭,环境切换全靠注释
SpringBoot的application.properties,有人一辈子只用这一个文件。开发环境、测试环境、生产环境的数据库地址、Redis密码、日志级别,全挤在一起。要切换环境?手工注释掉一段,再取消注释另一段。用注释管理环境配置,等于在悬崖边走钢丝,早晚掉下去。
正确姿势是Spring Profile。弄三个文件:application-dev.yml、application-test.yml、application-prod.yml,然后通过spring.profiles.active激活。这还不够,敏感信息绝不能明文提交到Git仓库。用环境变量或配置中心(比如Nacos、Apollo)来注入生产密码,把application-prod.yml里的密码写成${DB_PASSWORD},让运维在部署时注入。配置和代码分离,是专业和业余的分水岭。
还有个大坑:把配置值直接塞进注解里。比如@Value("${order.timeout:30}")散落在各处,改一个配置要全局搜索。建议把所有配置项集中在一个Properties类里,用@ConfigurationProperties绑定,统一管理,顺便还能做类型校验。配置不是代码的边角料,它是系统的显式边界,边界必须整洁。
避坑建议:从第一天起就启用Profile,每个环境独立文件。生产环境的敏感值一律用占位符+外部注入。加一个application-local.yml用于本地快速调试,别动公共配置。
第三坑:事务只加在方法上,连数据库都笑了
有同学写@Transactional,直接标在Controller方法上。结果一个请求里查了个列表、调了远程接口、再写库,整个流程被一个巨大事务包着,数据库连接被长时间占用,高并发一来,连接池直接打爆。事务不是金钟罩,它是锁,锁的范围越大,系统吞吐越差。
更隐蔽的坑是:在同一个类内,一个方法调用另一个带@Transactional的方法,事务注解失效。因为Spring的事务通过代理实现,内部方法自调用绕过了代理,就像你请了个保安,但自己从后门溜进去了。很多人排查半天,最后发现是这个原因,哭笑不得。
还有,事务里别做远程IO。比如在事务里调RPC、发消息、写文件,一旦远程服务慢,事务就长时间持锁,其他请求全卡住。事务只应该包含必须原子化的数据库操作,其他动作一律移到事务外。
避坑建议:事务加在Service层的公开方法上,且方法粒度要小。如果多个写操作必须原子化,就拆分Service方法,把事务边界放在最外层调用处。在需要自调用的场景,要么拆分到不同类,要么用TransactionTemplate手动控制。不要迷信注解,要理解代理的脾性。
第四坑:异常处理全靠try-catch,到处都是吞异常
打开一个项目,满屏try { ... } catch (Exception e) { e.printStackTrace(); }。然后呢?日志里一片红,业务数据却已经错乱了。吞异常是最隐蔽的bug工厂,它让失败变得无声无息,直到用户投诉才发现。
另一种极端是:每个方法都throws Exception。Controller方法上写着throws Exception,Service接口也throws Exception,结果全局异常处理器根本接不住,因为异常签名在编译期就声明出去了,到了Controller层没人处理,直接返回500。这等于把错误处理的责任甩给最上层,而最上层根本不知道底层发生了什么。
正确的做法是:定义统一的业务异常(如BizException),带错误码和错误消息。在Service层发现业务规则不满足时,抛出BizException。再写一个@RestControllerAdvice全局异常处理器,针对BizException返回对应错误码HTTP响应,针对参数校验异常返回400,针对其他未捕获异常统一记录日志并返回500。前端拿到的错误信息是结构化的JSON,而不是一堆堆栈。
还有一条狠点的原则:只在能处理异常的地方捕获异常,否则就抛出去。你catch它,就是为了打印一行日志?那不如不catch,让全局处理器去兜底。每一次catch,都必须有业务含义:要么降级,要么重试,要么转成业务错误。否则就是代码里的口香糖,黏糊糊还恶心。
避坑建议:建一个GlobalExceptionHandler,把异常分类处理。业务异常直接转JSON错误码,技术异常记录完整堆栈后返回通用错误。在Service层,校验失败早抛异常,别返回null或空对象,让调用方去猜。
第五坑:依赖注入用@Autowired成瘾,也没管循环依赖
SpringBoot太香了,往字段上写个@Autowired,对象就来了。但随之而来的是:字段注入无法让你意识到类之间的耦合,直到某天启动报The dependencies of some of the beans form a cycle。你一看,A依赖B,B依赖C,C又依赖A,三人转圈圈。Spring可以处理单例的循环依赖,但处理的方式是缓存半成品对象,这东西在构造函数注入时完全没戏。
更好的选择是构造器注入,它强制你在创建对象时把依赖说清楚,类需要什么,一眼可见。而且构造器注入天然避免循环依赖——如果有循环,编译期或启动期直接报错,逼你重构,而不是默默用加缓存逻辑兜底。依赖应该是显式的,而不是像魔术一样从字段里冒出来。
另一个坑:在构造函数里做了太多事。比如在构造器里调用远程服务、加载大量数据,这会让Bean初始化变慢,而且遇到代理失效等诡异问题。构造器只做简单的赋值和合法性检查,其他事放到@PostConstruct或者ApplicationRunner里做。
避坑建议:新代码一律用构造器注入。也可以使用@RequiredArgsConstructor配合final字段,省掉手写构造器。如果发现循环依赖,停下来思考一下设计,把被循环的那个依赖拆出去。依赖图应该是DAG(有向无环图),不是蜘蛛网。
上面这五个坑,每一个我都亲眼见过它们如何把一个新项目拖入泥潭。但更有价值的不是坑本身,而是坑背后的思维模式:配置不是临时凑合的,结构不是顺手堆的,事务不是越宽越好,异常不是越吞越安全,依赖不是越隐式越优雅。
SpringBoot是一个极其宽容的框架,你做什么它都能让你跑起来,但等到流量大了、团队大了、交付节奏快了,那些当初的偷懒和妥协都会变成定时炸弹。好的架构不是一步到位的,而是从第一天起就坚持正确的小事。踩坑不可怕,怕的是不知道自己踩了坑,还觉得是SpringBoot的锅。
如果你现在正准备从零搭项目,希望你把目录结构、配置管理、事务边界、异常处理和依赖注入这五件事当成项目的第一块地基。地基稳了,上面的每一行代码都有意义。地基歪了,后面的所有优化都是给危房刷漆。
最后送你一句话:优秀程序员不是不踩坑,而是每个坑只踩一次,然后把坑填平,立个牌子告诉后来人——这里有过坑,绕道。希望这篇文,就是你前面的牌子。