SpringBoot生产环境七大坑:线上跑得欢一上线就炸?

线上跑得挺欢,一上线就炸:SpringBoot生产环境血泪清单

有些代码在本地跑得丝般顺滑,推到生产就原地爆炸。直接返回实体类、开着自动改表、在事务里调外部接口、吞了异常不抛、异步不配线程池、同类调用事务注解失效、还开着Open-in-View,这七件事,每一件都是线上故障的定时炸弹。本文结合真实血泪案例,手把手拆解这些坑怎么埋人,以及怎么填。

直接从控制器往外扔实体类,Jackson一序列化就触发N+1查询

你把User实体类直接从Controller返回,想着省事。Spring Boot用Jackson把对象变成JSON,Jackson调用getter方法。如果你的实体类有懒加载关联,比如orders,每次调用getOrders(),Hibernate就偷偷跑到数据库查一遍。返回一个User,顺带查了十次订单,再来十条评论,再来十条点赞。一百个用户请求过来,数据库连接池直接见底。

这还不算完。你以为查一次就够了,结果每个关联都触发额外查询。N+1问题在响应层发生,日志里全是SQL,慢得跟蜗牛爬一样。你盯着监控面板,响应时间从50毫秒飙到两秒,CPU飙到80%,重启也没用。

根治办法就是上DTO。建个UserResponse类,只塞你真正需要的字段。用构造函数或者MapStruct拷贝数据,别让实体类露脸。虽然多了点代码,但每个接口返回什么清清楚楚,再也不会因为getter调用触发奇怪查询。这是写Java的基操,别偷懒。

开着ddl-auto.update上生产,Hibernate半夜偷偷改了你字段类型

开发环境图省事,spring.jpa.hibernate.ddl-auto=update。实体类加个字段,表结构自动同步,别提多爽。这玩意儿到了生产就是一颗定时炸弹。Hibernate启动时比对实体和表结构,发现不一致直接改。你加个@Column(length=500),它把字段改成VARCHAR(500)。原来存着的数据长度超了,直接截断,或者干脆报错。

更阴的是它不会告诉你改了啥。某次在预发布环境,一个Integer字段改成Long,Hibernate悄咪咪改了列类型。数据还在,但应用里查询结果全变成了乱码。没人发现,直到报表系统跑出来全是错的,熬了三个通宵才定位到是Hibernate自动改表搞的鬼。

正确姿势是validate只在本地用,生产全关掉。用Flyway管理版本化SQL脚本,每次变更手写迁移文件。改表结构前先写V1.2__alter_user_table.sql,上线跑一遍,回滚也有记录。虽然麻烦,但你知道每个改动干了什么,出了事还能查日志。

外部API调用放在@Transactional里面,数据库连接池三秒就被薅光

你写了个下单方法,标了@Transactional。里面先查库存,再调支付网关,最后更新订单状态。支付网关响应慢,三秒才回来。这期间数据库连接一直被你攥着,别的线程想查数据只能排队。十个并发请求过来,三十个连接全被占满,连接池直接爆炸。

关键是你还觉得代码没问题。事务注解只管数据库操作,它不知道你中间调了REST接口。你以为只有查数据库时才用连接,实际上整个方法执行期间连接都不释放。支付接口超时重试,每个重试又续了三秒,连接越积越多,最后应用直接挂掉。

拆分吧。调外部接口之前把数据查好,调完之后再开新事务更新状态。把网络IO和数据库事务隔开,连接只用于真正需要回滚的操作。可以用@Transactional(propagation = Propagation.REQUIRES_NEW)拆开,或者干脆把外部调用挪到事务外面。原则就一条:别让事务沾任何可能等待的IO。

@Transactional里面catch了异常但不往外抛,数据提交一半你都不知道

你写了段代码,觉得可能出错,就加了个try-catch。捕获异常后记了个日志,程序继续往下跑。方法结束,Spring事务管理器看没抛异常,欢天喜地提交了事务。前半段插入成功,后半段更新失败,数据库里一半数据变了,一半没变。一致性?不存在的。

更坑的是你翻日志,只看到一行INFO,没有任何错误堆栈。用户反馈订单状态不对,你怀疑缓存、怀疑网络、怀疑人生,最后把整个事务日志翻烂了才发现那个被吞掉的异常。原来catch块才是真正的bug。

正确做法是捕获异常后要么直接抛出去,要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。让Spring知道事务要回滚。如果只是想记录日志,记完再抛。别偷偷把异常吃了,数据库不会帮你猜哪步该回滚。

异步任务没配线程池,默认执行器分分钟创建几千个线程把你内存撑爆

你在方法上标了@Async,本地跑了几次,响应挺快,觉得自己很酷。Spring默认的SimpleAsyncTaskExecutor不复用线程,每次调用都新建一个。并发小的时候看不出来,一旦流量上来,一千个请求就创建一千个线程。每个线程默认占1MB栈内存,一千个就是1GB,直接OutOfMemoryError。

线上半夜两点突然挂了,重启也撑不过五分钟。GC日志里全是线程创建和销毁,CPU都在忙活管理线程,业务逻辑一点没跑。你看着java.lang.OutOfMemoryError: unable to create new native thread,才想起来异步线程池根本没配。

去写个ThreadPoolTaskExecutor的Bean,核心线程数、最大线程数、队列容量、拒绝策略全定好。核心设10,最大设50,队列塞200,满了就抛异常或者主线程同步跑。这样线程数量可控,内存稳定,出问题也能快速定位。永远别把默认执行器用在生产环境。

同类里方法互相调用,里面的@Transactional注解形同虚设

你在Service里写了两个方法,都标了@Transactional。方法A调用方法B,美滋滋以为各自都在事务里。Spring的事务靠代理对象生效,你直接从this.methodB()调,跳过了代理,B的事务注解完全被无视。A开了一个事务,B的隔离级别、传播行为、超时设置全没用。

线上出了个诡异bug,B方法里抛了异常,但数据居然没回滚。因为你从A里直接调,异常被A捕获了,B根本没独立事务。你以为B应该单独回滚,结果它用的是A的事务,整个逻辑乱成一锅粥。

要么把方法B挪到另一个Service里,通过注入的Bean调,让代理生效。要么用@Autowired注入自己,通过代理对象调用。更彻底的是在同一个类里就别分开标事务,要么整个方法一个事务,要么拆成不同类。自调用是代理模式经典陷阱,写代码时多想想谁在调用谁。

Open-in-View默认开着,Hibernate Session贯穿整个请求,懒加载问题全藏起来了

Spring Boot默认spring.jpa.open-in-view=true,意思是从请求进来直到JSON返回,Hibernate Session一直开着。你在Controller里访问懒加载关联,没报错,因为Session还在。你觉得自己写得没问题,上线后响应慢得要死,因为每个懒加载都在序列化阶段触发查询。

更坑的是你改了个字段,把懒加载改成急加载,性能又崩了。你开始加@EntityGraph、加JOIN FETCH,改来改去全是打补丁。根本原因是你依赖了Open-in-View帮你兜底,真正的数据访问模式一塌糊涂。

关掉它:把spring.jpa.open-in-view设成false。然后所有需要的数据在Service层显式查好,用DTO返回。Controller只负责接收和转发,不做任何懒加载。一开始会疯狂报LazyInitializationException,但这是好事,逼你把每个接口的数据依赖理清楚。宁可启动就报错,也别上线半夜被叫醒。

你发现没,这些坑都有一个共同点:都是让你“少写点代码”的偷懒设计。直接返回实体少写DTO,自动建表少写SQL,开事务少管连接,吞异常少写处理,默认线程池少写配置,同类调用少写注入,Open-in-View少写查询。每个偷懒决策在本地都显得特别合理,一到生产就把你卖了。

真正稳的系统,代码量从来不省,每行多出来的代码都是用来防那个半夜三点把你叫醒的报警电话的。