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 查询是不是理解并优化过的? | |
| 日志、指标、健康检查是不是就绪了? |
全打勾?那你的应用不只是能跑——它是可维护的、可调试的、能扛真实流量的。
去冲杯咖啡庆祝一下。☕