news 2026/8/19 9:43:30

Spring Boot 写得快不等于写得好:10 个让资深开发者也翻车的坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 写得快不等于写得好:10 个让资深开发者也翻车的坑

Spring Boot 有多快?几分钟搭个 REST API,几分钟连上数据库,安全、缓存、校验、 测试——别的框架还在配环境,你已经 demo 给老板看了。

但快,是有代价的。

应用能跑,接口能通,测试能过,演示很干净。然后生产流量一来——API 风格乱成一锅粥,事务行为莫名其妙,测试跑一次喝杯咖啡,日志看了等于没看,SQL 炸了,配置没人敢动,安全规则散落在各个角落,Controller 胖得像个穿了 HTTP 外套的 Service。

这篇文章讲的就是这些坑。

不是那种“忘了加@RestController”的新手错误。是真正的、有经验的开发者在赶进度、抄老代码、迷信 Spring 魔法时照样会踩的坑。

我用一个咖啡店点单系统贯穿所有例子。来,上咖啡。


1. Controller 里写业务逻辑:最常见也最致命

一开始,Controller 看起来人畜无害:

@RestController@RequestMapping("/coffees")publicclassCoffeeController{privatefinalCoffeeRepositorycoffeeRepo;privatefinalInventoryClientinventoryClient;@PostMapping("/order")publicOrderResponseorder(@RequestBodyOrderRequestreq){Coffeecoffee=coffeeRepo.findByName(req.coffeeName());if(coffee==null){thrownewCoffeeNotFoundException();}booleanavailable=inventoryClient.checkStock(coffee.getId(),req.quantity());if(!available){thrownewOutOfStockException();}Orderorder=newOrder();order.setCoffee(coffee);order.setQuantity(req.quantity());order.setStatus(OrderStatus.PREPARING);Ordersaved=coffeeRepo.saveOrder(order);returnnewOrderResponse(saved.getId(),saved.getStatus());}}

能跑。这就是问题所在。

这个 Controller 现在干了:HTTP 处理、库存校验、实体创建、状态判断、持久化、响应映射。六个月后它会涨到 500 行,所有人都不敢碰它——怕改坏了。

Controller 只该做一件事:把 HTTP 请求翻译成应用行为,然后把结果翻译回 HTTP。

瘦身后:

@RestController@RequestMapping("/coffees")publicclassCoffeeController{privatefinalOrderCoffeeUseCaseorderCoffee;@PostMapping("/order")@ResponseStatus(CREATED)publicOrderResponseorder(@Valid@RequestBodyOrderRequestreq){returnorderCoffee.execute(req);}}

业务逻辑去该去的地方:

@ServicepublicclassOrderCoffeeUseCase{privatefinalCoffeeRepositorycoffeeRepo;privatefinalInventoryClientinventoryClient;@TransactionalpublicOrderResponseexecute(OrderRequestreq){Coffeecoffee=coffeeRepo.findByNameOrThrow(req.coffeeName());inventoryClient.reserveOrThrow(coffee.getId(),req.quantity());Orderorder=Order.prepare(coffee,req.quantity());returnOrderResponse.from(coffeeRepo.saveOrder(order));}}

差别看着小,生产环境里是天壤之别。瘦 Controller 好测、好改、好理解。如果你的 Controller 里有业务规则、有数据库操作、有外部调用、有 DTO 转换——它已经不是 Controller 了,是个穿了 HTTP 衣服的胖 Service。


2. 直接把 Entity 当 API 返回:图省事,埋大雷

你已经有一个 Entity 了,接口要返回数据,直接 return 它多省事:

@GetMapping("/{id}")publicCustomergetCustomer(@PathVariableLongid){returncustomerRepo.findById(id).orElseThrow();}

看着干净,其实是在裸奔。

你的 JPA Entity 是持久化模型,API 响应是对外契约。这俩不是一回事。

@EntitypublicclassCustomer{@IdprivateLongid;privateStringname;privateStringphone;privateStringpasswordHash;// ← 这个能返回给前端吗?privatebooleanvip;// ← 这个内部标记能暴露吗?privateLocalDateTimecreatedAt;}

直接返回 Entity,passwordHash和内部标记可能就泄露了。就算用 Jackson 注解藏字段,你的持久化模型也被 API concerns 污染了。

更坑的是懒加载——序列化时触发额外查询,或者直接报LazyInitializationException

用 DTO,把边界立起来:

publicrecordCustomerResponse(Longid,Stringname,booleanvip,LocalDateTimecreatedAt){publicstaticCustomerResponsefrom(Customerc){returnnewCustomerResponse(c.getId(),c.getName(),c.isVip(),c.getCreatedAt());}}

请求体也一样,别拿 Entity 当入参:

// 坏@PostMappingpublicCustomercreate(@RequestBodyCustomerc){returncustomerRepo.save(c);}// 好publicrecordCreateCustomerRequest(@NotBlankStringname,@Pattern(regexp="^1\\d{10}$")Stringphone){}@PostMapping@ResponseStatus(CREATED)publicCustomerResponsecreate(@Valid@RequestBodyCreateCustomerRequestreq){returncustomerService.create(req);}

DTO 不是多余的样板代码,是边界。边界就是防止生产系统变成意外的混沌。


3. 把 @Transactional 当护身符乱贴

很多人用@Transactional像挂平安符——碰数据库了?贴一个。报错了?再贴一个。求心安?全贴上。

这不是事务设计,这是注解迷信。

事务应该包裹一个有意义的业务操作。

@TransactionalpublicvoidbrewCoffee(BrewCommandcmd){Orderorder=orderRepo.save(Order.create(cmd));paymentClient.charge(cmd.paymentToken());// ← 外部网络调用!baristaService.assign(order);}

看到问题了吗?paymentClient.charge()调外部支付接口的时候,数据库事务是开着的——你拿着数据库连接和锁,在等网络响应。高并发下这就是定时炸弹。

还有一个经典坑:同类内自调用不生效

@ServicepublicclassMemberService{publicvoidregister(RegisterRequestreq){saveMember(req);// ← 同类调用,@Transactional 不生效!}@TransactionalpublicvoidsaveMember(RegisterRequestreq){memberRepo.save(...);}}

Spring 的事务是基于代理的,同类内直接调用绕过了代理,事务注解等于白写。

另外记住:默认只对 RuntimeException 回滚。受检异常(比如 IOException)默认不回滚,要手动指定:

@Transactional(rollbackFor=IOException.class)publicvoidimportMembers()throwsIOException{...}

高级开发者问的是:事务从哪开始?到哪结束?什么异常回滚?这个方法是走代理调用的吗?事务里有没有外部网络调用?锁是不是持有太久了?

这才叫用事务,不是赌运气。


4. API 入口不校验:垃圾进,垃圾出

@PostMapping("/brew")publicBrewResponsebrew(@RequestBodyBrewRequestreq){returnbrewService.brew(req);}

amount是负数怎么办?coffeeType是空字符串怎么办?customerId是 null 怎么办?

你应该在请求到达业务逻辑之前就把垃圾挡在门外:

publicrecordBrewRequest(@NotNullLongcustomerId,@NotBlankStringcoffeeType,@Positive@Max(10)Integerquantity,@Size(max=200)Stringnote){}@PostMapping("/brew")publicBrewResponsebrew(@Valid@RequestBodyBrewRequestreq){returnbrewService.brew(req);}

常用校验注解就那几个:@NotNull@NotBlank@NotEmpty@Size@Min@Max@Positive@Email@Pattern

结构性校验放 API 层,业务规则放 Service 层:

if(!supportedCoffeeTypes.contains(req.coffeeType())){thrownewUnsupportedCoffeeException(req.coffeeType());}

如果脏数据能深入到业务逻辑里,说明你的 API 边界已经漏了。


5. 错误返回格式五花八门:前端的噩梦

一个接口返回:

{"message":"咖啡卖完了"}

另一个返回:

{"error":"INVALID_ORDER","detail":"数量必须大于0"}

第三个因为异常没处理,直接返回了一个 HTML 错误页。

前端开发者哭了。移动端开发者哭了。三个月后的你也哭了。

用全局异常处理器 + 统一错误格式:

@RestControllerAdvicepublicclassApiExceptionHandler{@ExceptionHandler(CoffeeNotFoundException.class)@ResponseStatus(NOT_FOUND)publicErrorResponsehandleNotFound(CoffeeNotFoundExceptione){returnnewErrorResponse("COFFEE_NOT_FOUND",e.getMessage());}@ExceptionHandler(MethodArgumentNotValidException.class)@ResponseStatus(BAD_REQUEST)publicErrorResponsehandleValidation(MethodArgumentNotValidExceptione){returnnewErrorResponse("VALIDATION_FAILED","请求参数校验失败");}}publicrecordErrorResponse(Stringcode,Stringmessage){}

好的 API 不只是成功时返回得漂亮,失败时也要可预测、可解析、可排查


6. 到处撒 @Value:配置变成捉迷藏

@Value("${brew.timeout}")privateDurationtimeout;@Value("${brew.retry}")privateintretry;@Value("${brew.machine-url}")privateStringmachineUrl;

一开始看着没事。然后类越来越多,同一个配置在不同地方各读一份,默认值散落在各处,测试的时候你得一个个 mock。配置去哪了?没人知道。

相关的配置,给它一个家:

@ConfigurationProperties(prefix="brew")publicrecordBrewProperties(Durationtimeout,intretry,URImachineUrl){}

启动类开启扫描:

@SpringBootApplication@ConfigurationPropertiesScanpublicclassCoffeeApplication{}

注入使用:

@ServicepublicclassBrewMachineClient{privatefinalBrewPropertiesprops;publicBrewMachineClient(BrewPropertiesprops){this.props=props;}}

现在配置是类型安全的、分组的、可测试的、一目了然的。三个以上相关属性,就该建个 Properties 类了。


7. 所有测试都 @SpringBootTest:慢到不想跑

@SpringBootTestclassMemberServiceTest{...}

能跑。但它加载了整个应用上下文——数据库配置、安全配置、Web 配置、自动装配、你根本用不到的 Bean,全加载了。

一个纯 Service 的单元测试,根本不需要 Spring:

classMemberServiceTest{privatefinalMemberRepositoryrepo=mock(MemberRepository.class);privatefinalMemberServiceservice=newMemberService(repo);@TestvoidshouldCreateMember(){...}}

该用切片测试用切片:

@WebMvcTest(CoffeeController.class)// 只测 ControllerclassCoffeeControllerTest{}@DataJpaTest// 只测 RepositoryclassCoffeeRepositoryTest{}@JsonTest// 只测 JSON 序列化classCoffeeJsonTest{}

@SpringBootTest留给真正需要全上下文的场景:集成测试、完整链路验证、启动冒烟测试。

测试快,开发者就愿意常跑;测试慢,开发者就开始逃避——bug 就是这么活下来的。


8. 字段注入:把依赖藏起来

@ServicepublicclassInvoiceService{@AutowiredprivateInvoiceRepositoryinvoiceRepo;@AutowiredprivatePaymentClientpaymentClient;@AutowiredprivateEmailServiceemailService;@AutowiredprivateAuditServiceauditService;// 还在加……}

能跑。但它隐藏了类的真实依赖,让测试变麻烦,允许对象处于无效状态,还鼓励类无限收集依赖——反正构造函数不会变长来提醒你。

用构造器注入,让依赖暴露在阳光下:

@ServicepublicclassInvoiceService{privatefinalInvoiceRepositoryinvoiceRepo;privatefinalPaymentClientpaymentClient;publicInvoiceService(InvoiceRepositoryinvoiceRepo,PaymentClientpaymentClient){this.invoiceRepo=invoiceRepo;this.paymentClient=paymentClient;}}

构造函数参数太多?那不是字段注入的理由,那是设计臭味——这个类可能干了太多事,该拆了。

构造器注入让设计问题看得见。字段注入把它们藏到生产环境才爆发。


9. 不懂懒加载和 N+1:SQL 在你看不见的地方爆炸

JPA 让数据库访问像操作对象一样丝滑。丝滑的另一面是危险。

List<Order>orders=orderRepo.findAll();for(Orderorder:orders){System.out.println(order.getCustomer().getName());// ← 每条都查一次!}

查订单 1 条 SQL,然后每个 Customer 各查 1 条——10 条订单 11 条 SQL,10000 条订单 10001 条 SQL。这就是经典 N+1。

更坑的是 Controller 直接返回 Entity,JSON 序列化时触发懒加载,要么多查一堆 SQL,要么直接报错。

解决方案:fetch join 或 DTO 投影。

// fetch join:一次查出来@Query("select o from Order o join fetch o.customer where o.status = :status")List<Order>findByStatusWithCustomer(@Param("status")OrderStatusstatus);
// DTO 投影:只查需要的字段publicrecordOrderSummary(Longid,StringcustomerName,BigDecimalamount){}@Query("select new com.example.OrderSummary(o.id, c.name, o.amount) "+"from Order o join o.customer c where o.status = :status")List<OrderSummary>findSummaries(@Param("status")OrderStatusstatus);

别把 JPA 当魔法。打开 SQL 日志看看你的代码到底产生了什么查询。高级开发者不靠猜 ORM 在干嘛,他们去验证。


10. 没有可观测性就上线:出事了两眼一抹黑

本地能跑,测试通过,部署成功。然后生产挂了,没人知道为什么。

一个生产级的 Spring Boot 应用,应该能回答这些问题:

  • 应用活着吗?
  • 能接流量了吗?
  • 多少请求在失败?
  • 哪个接口慢?
  • 哪个依赖超时了?
  • 数据库连接池满了吗?
  • 定时任务在跑吗?

加 Actuator,暴露健康检查和指标。用结构化日志,带上 traceId:

// 坏日志log.info("制作咖啡失败");// 好日志log.warn("制作咖啡失败 orderId={}, customerId={}, reason={}",orderId,customerId,reason);

别记敏感数据——密码、token、完整卡号、客户隐私信息,一个都别进日志。

Actuator 的/env/beans端点会暴露敏感信息,该保护保护,该关关。

可观测性不是生产挂了之后才补的,是生产就绪的一部分。没有它,你就是在蒙眼开车。


十个坑背后的同一个病根

这 10 个坑看起来各不相同:

  • Controller 太胖
  • Entity 泄露到 API
  • 事务乱用
  • 校验缺失
  • 错误格式乱
  • 配置散落
  • 测试太慢
  • 依赖隐藏
  • JPA 查询失控
  • 可观测性缺失

但根子上是同一个问题:代码能跑,就以为设计没问题。

能跑的代码不等于健康的代码。一个 Spring Boot 应用可以完美运行,同时悄悄变得越来越难改、难测、难调、难运维。

初级和高级的区别,不是你认识多少注解,而是你知不知道边界在哪

  • HTTP 边界
  • 校验边界
  • 事务边界
  • 持久化边界
  • 配置边界
  • 测试边界
  • 运维边界

Spring Boot 给了你强大的工具,但它不会逼你好好用。那部分,还是你的活。


上线前的自检清单

检查项是/否
Controller 是不是瘦的?
业务逻辑是不是在 Service/UseCase 里?
Entity 是不是藏在 DTO 后面?
请求有没有加 @Valid?
错误返回格式是不是统一的?
事务是不是有意为之的?
长事务里有没有外部调用?
配置是不是分组且类型安全的?
测试是不是用了最小必要的上下文?
依赖是不是走构造器注入?
JPA 查询是不是理解并优化过的?
日志、指标、健康检查是不是就绪了?

全打勾?那你的应用不只是能跑——它是可维护的、可调试的、能扛真实流量的。

去冲杯咖啡庆祝一下。☕

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/19 9:43:20

网络编程知识点记录

IPV4的知识讲解IPV4是32位的xxx.xxx.xxx.xxx前8位&#xff1a;网络号后24位&#xff1a;主机号xxx .xxx.xxx.xxx网络号 主机号其中工具网络号的分布来划分ABCDE类A类&#xff1a;0~127&#xff08;其中 0 和 127 被保留&#xff09;-->A类只有…

作者头像 李华
网站建设 2026/8/19 9:43:01

为TI RSLK MAX机器人添加CC3100 Wi-Fi模块的嵌入式物联网实践

1. 项目概述与核心价值 最近在折腾TI的机器人系统学习套件RSLK MAX&#xff0c;这确实是个学习嵌入式系统的好平台&#xff0c;但玩久了总觉得缺了点什么。它的核心是MSP432P401R微控制器&#xff0c;性能不错&#xff0c;外设也丰富&#xff0c;但原生缺少一个关键能力——网络…

作者头像 李华
网站建设 2026/8/19 9:42:06

数字万用表使用教程

文章目录&#xff1a; 一&#xff1a;扩展 1.单位相关 2.核心公式 二&#xff1a;了解万用表 三&#xff1a;测量通断&#xff08;校表&#xff09; 四&#xff1a;测量 1.测电流 2.测电压 3.测电阻/电容 4.测二极管 五&#xff1a;如何判断漏电 六&#xff1a;注意…

作者头像 李华
网站建设 2026/8/19 9:39:18

多智能体协作中的记忆诅咒:完美记忆如何破坏AI团队合作

1. 引言&#xff1a;当AI拥有“完美记忆”&#xff0c;合作反而变难了&#xff1f; 最近在折腾多智能体系统时&#xff0c;我遇到了一个反直觉的现象&#xff1a;我们总以为给AI智能体&#xff08;Agent&#xff09;的记忆力越强&#xff0c;它们之间的协作就应该越顺畅。毕竟&…

作者头像 李华
网站建设 2026/8/19 9:36:21

从零打造DIY智能显示屏:树莓派+网页技术构建个性化信息中枢

1. 从“电子相框”到“智能中枢”&#xff1a;为什么我们需要一个DIY智能显示屏&#xff1f; 几年前&#xff0c;我买过一个所谓的“智能相框”&#xff0c;功能就是轮播照片&#xff0c;偶尔显示一下天气。新鲜感过去后&#xff0c;它就沦为了一个吃灰的电子垃圾。直到后来&am…

作者头像 李华