Spring Boot Web 请求链与商业工程实践
商业 Web 服务不是“Controller 调 Service,再返回 JSON”这么简单。一个创建订单请求还要经历协议解析、身份认证、参数校验、幂等检查、事务、库存或支付下游调用、异常转换、日志追踪和监控;任何一个环节设计错误,都可能产生重复订单、脏数据、超时雪崩或敏感信息泄露。
本章使用 JDK 8 + Spring Boot 2.7 的订单接口贯穿讲解。Spring MVC 组件内部源码见Spring MVC 请求执行链,事务代理和传播规则见Spring 事务全过程。
一、学习目标
学完后应能:
- 从网络入口追踪到 Controller、Service、数据库和响应写回。
- 区分 DTO、Command、领域对象、Entity、VO,知道为什么不能全部共用一个类。
- 解释参数校验发生在哪一层、为什么校验通过不代表业务合法。
- 设计统一错误码和全局异常处理,而不是在每个 Controller 写
try-catch。 - 正确划分事务边界,理解“数据库提交成功”和“MQ 发送成功”不是一个事务。
- 设计幂等、traceId、超时、重试和生产监控。
- 分层编写单元测试、MVC 测试和集成测试。
- 根据 400、401、403、404、409、415、500、503 和超时现象定位问题。
二、创建订单请求的完整链路
flowchart TD
A["客户端发送 POST /api/orders"] --> B["Nginx 或网关"]
B --> C["Tomcat 解析 HTTP"]
C --> D["Filter:traceId、编码、安全"]
D --> E["Spring Security 认证授权"]
E --> F["DispatcherServlet"]
F --> G["参数解析、JSON 反序列化、校验"]
G --> H["Controller 转换请求并调用 Service"]
H --> I["Service:幂等、业务规则、事务"]
I --> J["Repository 或 Mapper 写数据库"]
J --> K["事务提交或回滚"]
K --> L["返回值处理与 JSON 序列化"]
L --> M["记录指标、访问日志并返回响应"]不要把上图理解成所有步骤都属于同一个数据库事务:Filter、认证、参数校验通常发生在事务之前;响应序列化常发生在 Service 事务完成之后;外部 HTTP 和 MQ 是否参与一致性,要由专门方案决定。
三、先划清每一层的职责
com.example.order
├─ web
│ ├─ OrderController.java
│ ├─ CreateOrderRequest.java
│ ├─ OrderResponse.java
│ └─ GlobalExceptionHandler.java
├─ application
│ ├─ OrderApplicationService.java
│ └─ CreateOrderCommand.java
├─ domain
│ ├─ Order.java
│ ├─ OrderRepository.java
│ └─ OrderStatus.java
├─ infrastructure
│ ├─ persistence
│ ├─ client
│ └─ messaging
├─ config
└─ OrderApplication.java| 层 | 输入输出 | 核心职责 | 禁止堆放 |
|---|---|---|---|
| Web/Controller | HTTP DTO、状态码、Header | 协议转换、参数校验、调用用例 | SQL、长事务、复杂业务规则 |
| Application Service | Command、用例结果 | 编排业务步骤、事务边界、幂等入口 | Servlet API、JSON 细节 |
| Domain | 业务值和状态 | 不变量、状态机、业务计算 | Controller 注解、数据库 SQL |
| Repository/Mapper | 领域查询和持久化结果 | 数据读写与映射 | 决定支付、库存业务规则 |
| Client/Gateway | 下游协议 | 超时、错误转换、调用指标 | 将第三方 DTO 泄漏给所有业务层 |
分层不是为了增加目录数量,而是为了隔离变化:HTTP 协议变化不应迫使领域规则修改,数据库表结构变化不应直接改变外部响应,第三方接口升级不应影响所有 Service。
四、为什么 Request、Entity、Domain 和 Response 不能混用
错误做法:Controller 直接接收数据库 Entity,再原样返回。
后果包括:
- 客户端可能提交
id、status、createdTime等不应控制的字段。 - Entity 新增内部敏感字段后可能被意外序列化到公网。
- 数据库字段改名会破坏 API 合约。
- 懒加载关联可能触发 N+1、序列化递归或会话关闭异常。
- 校验规则被数据库结构绑死,创建和修改接口无法表达不同约束。
推荐映射:
flowchart TD
A["JSON 请求"] --> B["CreateOrderRequest"]
B --> C["CreateOrderCommand"]
C --> D["Order 领域对象"]
D --> E["OrderEntity 持久化模型"]
D --> F["OrderResponse"]
F --> G["JSON 响应"]小系统可以适当合并 Command 和简单 DTO,但外部请求模型与持久化 Entity 仍应谨慎隔离。避免为了“分层”机械复制几十个完全相同的类,也避免一个类贯穿所有边界。
五、请求 DTO 与参数校验
Boot 2.7 使用 javax.validation:
Boot 2.3 以后,校验能力不应假设一定由 starter-web 间接带入,项目中显式加入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>package com.example.order.web;
import javax.validation.Valid;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
public class CreateOrderRequest {
@NotBlank
@Size(max = 64)
private String customerId;
@NotBlank
@Size(max = 64)
private String requestId;
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal totalAmount;
@Valid
@NotEmpty
@Size(max = 100)
private List<OrderItemRequest> items;
// getter、setter 省略
}Controller:
@PostMapping
public ApiResponse<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request) {
CreateOrderCommand command = converter.toCommand(request);
Order result = orderApplicationService.create(command);
return ApiResponse.success(converter.toResponse(result));
}5.1 校验发生在哪里
flowchart TD
A["HttpMessageConverter 读取 JSON"] --> B["创建 CreateOrderRequest"]
B --> C["参数解析器发现 @Valid"]
C --> D["Bean Validation 执行约束"]
D --> E{"是否有校验错误"}
E -- "有" --> F["抛出 MethodArgumentNotValidException"]
E -- "无" --> G["调用 Controller 方法"]JSON 语法错误、字段类型错误通常在反序列化阶段失败;@NotBlank、@DecimalMin 等是在对象创建后校验。二者异常类型不同,但都应转换为稳定的客户端错误响应。
5.2 参数校验不等于业务校验
| 校验 | 示例 | 应放位置 |
|---|---|---|
| 格式约束 | customerId 非空、金额大于 0 | DTO Bean Validation |
| 集合内部约束 | 商品数量大于 0 | 嵌套 DTO + @Valid |
| 业务规则 | 商品是否存在、是否可售 | Service/Domain |
| 并发不变量 | requestId 唯一、库存不能为负 | Service + 数据库约束/原子操作 |
| 权限规则 | 用户只能创建自己的订单 | Security + Service 二次校验 |
仅在 Controller 查询“requestId 是否存在”再插入,会产生并发竞态:两个请求都查不到,然后同时插入。最终不变量必须依靠数据库唯一约束或原子机制兜底。
六、Controller 为什么要保持薄
Controller 应做:
- 接收 Path、Query、Header、Body。
- 触发格式校验。
- 将外部 DTO 转换为用例 Command。
- 调用一个清晰的应用服务入口。
- 将结果转换为响应 DTO 和状态码。
错误示例:
@PostMapping
public Object create(@RequestBody CreateOrderRequest request) {
if (orderMapper.exists(request.getRequestId())) {
return "duplicate";
}
inventoryClient.deduct(request.getItems());
orderMapper.insert(request);
mqTemplate.send(request);
return "ok";
}这里混入了幂等、库存、数据库、消息和协议,无法建立清晰事务边界;任何一步失败都不知道前面步骤是否需要补偿。Controller 单元也难以复用到 MQ 消费、定时任务或 RPC 入口。
七、Application Service 如何编排用例
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final InventoryGateway inventoryGateway;
private final OutboxRepository outboxRepository;
public OrderApplicationService(OrderRepository orderRepository,
InventoryGateway inventoryGateway,
OutboxRepository outboxRepository) {
this.orderRepository = orderRepository;
this.inventoryGateway = inventoryGateway;
this.outboxRepository = outboxRepository;
}
@Transactional
public Order create(CreateOrderCommand command) {
Order existing = orderRepository.findByRequestId(command.getRequestId());
if (existing != null) {
return existing;
}
inventoryGateway.checkAvailable(command.getItems());
Order order = Order.create(command);
orderRepository.save(order);
outboxRepository.save(OrderCreatedEvent.from(order));
return order;
}
}这段代码表达了用例顺序,但仍必须继续分析:
findByRequestId不是并发幂等的最终保障,数据库还要有唯一索引。inventoryGateway.checkAvailable如果是远程调用,它不受本地数据库事务回滚控制。- 事务中执行慢远程调用会延长连接和锁占用。
- Outbox 与订单写入同一本地数据库事务,提交后再由独立任务可靠投递消息。
八、事务边界为什么通常在 Service
flowchart TD
A["Controller 调用代理后的 Service"] --> B["事务拦截器开启事务"]
B --> C["Service 执行业务编排"]
C --> D["Repository 使用事务绑定连接"]
D --> E{"方法是否正常完成"}
E -- "是" --> F["提交事务"]
E -- "否且满足回滚规则" --> G["回滚事务"]
F --> H["返回 Controller"]
G --> I["异常继续向外传播"]Service 表达一个完整业务用例,因此适合作为事务边界。Controller 事务通常过大,会把 JSON 处理、下游等待和响应转换也包进事务;Repository 单方法事务又可能把一个业务用例拆成多次独立提交。
必须知道的限制:
@Transactional通常由 AOP 代理实现,外部通过代理调用才会拦截。- 同一个对象中
this.create()自调用可能绕过代理。 - 默认情况下,常见运行时异常触发回滚;受检异常规则要显式配置或设计。
- 捕获异常后不再抛出,事务拦截器可能认为方法成功。
- 本地事务只能直接控制同一事务资源,不能自动回滚远程 HTTP、Redis 和普通 MQ 发送。
完整声明式和编程式事务见Spring 事务全过程。
九、创建接口为什么必须考虑幂等
客户端可能因超时没有收到响应而重试;网关可能重试;用户可能重复点击。第一次请求也许已经提交,只是响应丢失。
flowchart TD
A["客户端发送 requestId=R100"] --> B["服务创建订单并提交"]
B --> C["响应在网络中丢失"]
C --> D["客户端使用相同 requestId 重试"]
D --> E{"是否有幂等保障"}
E -- "没有" --> F["生成第二张订单"]
E -- "有" --> G["返回第一次创建结果"]推荐组合:
- 客户端为一次业务操作生成稳定
requestId。 - 数据库建立
UNIQUE(request_id),作为并发最终防线。 - 插入发生唯一键冲突时查询并返回原结果,或返回明确冲突。
- requestId 必须与用户、租户和接口语义绑定,防止跨用户碰撞。
- 不要只使用先查后写;它无法阻止并发窗口。
Redis SET NX 可以减少重复请求压力,但如果 Redis 锁释放和数据库提交之间设计不严谨,它不能替代数据库唯一约束。
十、统一响应和错误码怎样设计
成功响应可以统一包装,也可以遵循纯 HTTP 状态码 + 资源表示。关键不是“必须套一层 code”,而是全系统合约一致。
public class ApiResponse<T> {
private String code;
private String message;
private String traceId;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<T>();
response.code = "SUCCESS";
response.message = "success";
response.data = data;
return response;
}
// getter、setter 省略
}错误码应稳定、可搜索、可监控:
| HTTP 状态 | 业务错误码示例 | 含义 |
|---|---|---|
| 400 | REQUEST_INVALID | JSON、类型或格式校验错误 |
| 401 | AUTH_REQUIRED | 未认证或凭证失效 |
| 403 | ACCESS_DENIED | 已认证但无权限 |
| 404 | ORDER_NOT_FOUND | 指定资源不存在 |
| 409 | ORDER_STATE_CONFLICT | 状态冲突、重复操作 |
| 415 | CONTENT_TYPE_UNSUPPORTED | 请求体媒体类型不支持 |
| 429 | RATE_LIMITED | 被限流 |
| 503 | DEPENDENCY_UNAVAILABLE | 必要下游暂不可用 |
| 500 | INTERNAL_ERROR | 未分类服务端错误 |
不要把所有错误都返回 HTTP 200,否则网关、监控、客户端重试和缓存无法准确理解结果;也不要把数据库异常栈直接返回给用户。
十一、全局异常处理全过程
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidation(
MethodArgumentNotValidException exception) {
String message = exception.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ":" + error.getDefaultMessage())
.collect(Collectors.joining(","));
return error("REQUEST_INVALID", message);
}
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleNotFound(OrderNotFoundException exception) {
return error("ORDER_NOT_FOUND", exception.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleUnknown(Exception exception) {
log.error("unexpected request failure", exception);
return error("INTERNAL_ERROR", "系统繁忙,请稍后重试");
}
}flowchart TD
A["Controller 或参数解析抛出异常"] --> B["DispatcherServlet 进入异常解析链"]
B --> C["匹配 @ExceptionHandler"]
C --> D["选择 HTTP 状态和业务错误码"]
D --> E["记录带 traceId 的内部日志"]
E --> F["返回不泄露内部信息的响应"]全局兜底异常必须记录堆栈;预期业务异常通常记录关键上下文即可,避免每次“订单不存在”都打印巨大 ERROR 堆栈。日志和客户端消息是两个边界:客户端得到稳定、安全的说明,内部日志保留定位证据。
十二、traceId 和日志如何贯穿请求
Filter 可以读取上游 traceId,没有则生成,再放入 MDC:
public class TraceIdFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.trim().isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}为什么 finally 必须清理:Tomcat 线程会被线程池复用;如果 MDC 不删除,下一个请求可能继承前一个请求的 traceId,造成日志串号甚至信息混淆。
日志至少包含:traceId、接口、耗时、结果码、必要业务主键和下游结果。禁止记录密码、完整 Token、身份证、手机号、患者原文、银行卡和密钥。订单号也要评估是否属于敏感业务标识。
十三、调用下游为什么必须设置超时
没有超时时,下游连接或读取可能长期占用 Tomcat 工作线程和连接池:
flowchart TD
A["请求进入 Tomcat 线程池"] --> B["同步调用慢下游"]
B --> C["线程持续等待"]
C --> D["新请求继续占用线程"]
D --> E["线程池和连接池耗尽"]
E --> F["健康接口也超时,形成级联故障"]至少分别配置连接超时、读取超时和连接池获取超时。重试只适合可重试、幂等、短暂失败的操作,并应限制次数、退避和总时间预算。
绝不能看到超时就盲目重试:创建订单、扣款、发放优惠券等非幂等请求可能被执行多次。即使调用方超时,也不代表下游没有执行成功。
十四、数据库提交与消息发送的一致性
错误流程:
保存订单 -> 提交数据库 -> 发送 MQ数据库提交后进程崩溃,消息永远没发;如果先发消息再提交数据库,消费者可能读不到订单。
本地消息表/Outbox 思路:
flowchart TD
A["开启本地数据库事务"] --> B["写订单表"]
B --> C["写 Outbox 事件表"]
C --> D["提交同一本地事务"]
D --> E["独立投递器扫描未发送事件"]
E --> F["发送 MQ"]
F --> G["记录已发送或等待重试"]
G --> H["消费者按事件 ID 幂等"]Outbox 解决“订单和待发送事件同生共死”,但不等于消息只消费一次。投递器在“MQ 已接收但状态未更新”时会重复发送,消费者仍须幂等。更多方案见分布式事务专栏。
十五、认证、授权、CSRF 和 CORS 不要混淆
| 概念 | 回答的问题 |
|---|---|
| 认证 Authentication | 你是谁 |
| 授权 Authorization | 你能做什么 |
| CORS | 浏览器是否允许前端页面跨源读取响应 |
| CSRF | 浏览器是否被诱导携带现有凭证发起非预期请求 |
| 参数校验 | 输入格式是否满足约束 |
CORS 放开不等于接口无权限;JWT 存在不等于已经校验签名、过期、issuer 和 audience;Controller 传入 customerId 也不能直接信任,应以认证上下文中的主体为准并做资源归属校验。完整内容见Spring Security 专栏。
十六、测试怎样覆盖不同风险
flowchart TD
A["领域与 Service 单元测试:快且定位准"] --> B["MVC 切片测试:协议、校验、异常、JSON"]
B --> C["数据库集成测试:SQL、约束、事务"]
C --> D["应用集成测试:Bean 和自动配置"]
D --> E["端到端测试:网络和真实依赖链"]16.1 MVC 测试应验证什么
@WebMvcTest(OrderController.class)
class OrderControllerWebTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderApplicationService orderApplicationService;
@Test
void rejectsInvalidAmount() throws Exception {
String body = "{\"customerId\":\"C1\","
+ "\"requestId\":\"R1\","
+ "\"totalAmount\":0,\"items\":[]}";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
}要覆盖:正确请求、JSON 语法错误、字段类型错误、嵌套校验、Content-Type 错误、业务异常映射、未知异常脱敏、认证和权限。
16.2 数据库集成测试应验证什么
重点验证唯一索引、事务回滚、锁、SQL 映射、时区和真实数据库方言。只使用 H2 可能掩盖 MySQL/PostgreSQL 差异,关键 SQL 更适合 Testcontainers 或测试数据库。
十七、生产环境要观测哪些指标
| 指标 | 价值 | 异常含义示例 |
|---|---|---|
| 请求量、成功率 | 判断流量和整体质量 | 流量突增或成功率下降 |
| P50/P95/P99 耗时 | 发现尾延迟 | 平均值正常但少量请求极慢 |
| HTTP 状态和业务错误码 | 区分客户端、权限、冲突、服务端错误 | 400 激增可能是发布不兼容 |
| Tomcat 活跃线程与队列 | 判断入口是否饱和 | 活跃线程长期打满 |
| 数据库连接池 | 判断连接等待和泄漏 | pending 增长、active 达上限 |
| 下游调用耗时与错误 | 定位依赖故障 | 某医院接口超时率上升 |
| JVM GC、Heap、线程 | 判断运行时压力 | Full GC、线程暴增 |
| 幂等冲突和 Outbox 积压 | 发现重复请求与消息投递问题 | 待投递事件持续增加 |
指标 tag 不能使用 orderNo、requestId、userId 等高基数字段,否则监控时间序列数量会爆炸。主键应进入日志和 trace,不应成为指标标签。
十八、线上问题排查 Runbook
18.1 大量 400
- 按错误码区分 JSON 解析失败、字段校验失败和业务校验失败。
- 对比客户端与服务端版本,检查字段名称和类型是否变更。
- 检查
Content-Type和字符编码。 - 抽样查看脱敏后的失败请求特征,不能直接全量打印敏感 Body。
18.2 大量 500
- 使用 traceId 找到服务端完整异常链。
- 先找最早业务根因,不只看全局异常处理器最后一行。
- 判断异常发生在参数转换、Service、数据库、下游、事务提交还是响应序列化。
- 检查同一错误码、版本、实例和发布时点是否聚集。
- 必要时降级、回滚或摘除异常实例,再修复根因。
18.3 接口耗时上升但 CPU 不高
这常见于等待而非计算:检查线程栈、数据库连接池、慢 SQL、锁等待、HTTP 连接池、DNS、下游耗时和 MQ 同步确认。低 CPU 不能证明应用健康。
18.4 客户端超时但服务端订单已创建
不要让客户端换 requestId 重新创建。应使用相同幂等键查询或重试,服务端返回第一次结果。检查响应写回、网关超时、网络和下游链路,区分“业务未执行”和“执行成功但响应丢失”。
18.5 数据库有订单但 MQ 没消息
确认采用的是直接发送、事务消息还是 Outbox。Outbox 场景检查事件记录是否存在、投递状态、重试次数、投递器存活、MQ ACK 和死信;如果订单表和 Outbox 都没有同事务写入,则设计本身存在一致性缺口。
十九、常见错误与后果
| 错误 | 后果 | 修复方向 |
|---|---|---|
| Controller 直接操作多个资源 | 无清晰事务和补偿边界 | 用 Application Service 编排 |
| Entity 直接作为请求和响应 | 越权赋值、字段泄露、API 与库表耦合 | 使用边界 DTO 并转换 |
| 所有异常都返回 200 | 监控、网关和客户端无法理解失败 | 正确 HTTP 状态 + 稳定业务码 |
| 捕获 Exception 后返回成功 | 事务可能提交,故障被掩盖 | 只捕获可处理异常,失败继续传播 |
| 先查后插实现幂等 | 并发下生成重复数据 | 唯一约束 + 冲突处理 |
| 事务中执行无超时远程调用 | 长时间占用连接和锁 | 缩短事务、设置预算、重构一致性 |
| 对创建请求自动无限重试 | 重复扣款、重复订单 | 先保证幂等,再有限重试 |
| 日志打印整个请求对象 | 密码、Token、患者数据泄露 | 字段白名单、脱敏和审计 |
| MDC 不清理 | 线程复用后 trace 串号 | 在 finally 中 remove/clear |
二十、面试标准回答
一次 Spring Boot HTTP 请求怎么走
请求先由 Nginx 或网关转发到内嵌 Tomcat,Tomcat 解析 HTTP 并进入 Filter 链;Spring Security 完成认证授权后,请求到 DispatcherServlet。HandlerMapping 查找 Controller 方法,HandlerAdapter 使用参数解析器和 HttpMessageConverter 完成参数解析、JSON 反序列化与校验。Controller 将请求 DTO 转成 Command 并调用 Service;Service 通过事务代理执行业务编排和数据访问。正常完成后事务提交,返回值处理器再通过 Jackson 序列化响应;异常则进入 HandlerExceptionResolver 和
@RestControllerAdvice转换成统一错误。
为什么事务通常放 Service
Service 通常表达一个完整业务用例,能覆盖多个 Repository 操作,又不会把 HTTP 解析和响应序列化包含进事务。
@Transactional由 AOP 代理开启、提交或回滚事务;要注意自调用、异常回滚规则和本地事务不能自动控制远程 HTTP、Redis 或普通 MQ。
怎样保证创建订单幂等
客户端为同一次业务操作提供稳定 requestId,服务端将它与用户或租户绑定,并在数据库建立唯一约束作为并发最终防线。相同 requestId 重试时返回第一次结果;只做先查后写存在并发窗口,Redis 锁也不能替代数据库唯一约束。调用下游还必须传递幂等语义。
数据库成功但 MQ 失败怎么办
不能假设两个独立资源处于同一本地事务。常见方案是订单和 Outbox 事件在同一数据库事务写入,再由投递器重试发送;或使用消息中间件事务消息。由于确认与状态更新之间仍可能失败,生产端和消费端都要按事件 ID 幂等,并监控积压和重试。
更多标准回答统一放在Spring Boot 面试题,原理以本页和对应专栏为准。
二十一、关联知识点
本章小结
生产级 Web 接口是一条有边界的执行链:入口层处理协议和身份,参数解析与校验阻止无效输入,Application Service 编排用例和事务,Domain 保证业务规则,Repository 持久化,Client 隔离外部协议,唯一约束保障并发幂等,Outbox 或事务消息处理跨资源一致性,全局异常、traceId、指标和分层测试保证系统可诊断、可验证。
只有能说明每一步由谁触发、在事务内还是事务外、失败会留下什么状态、如何重试或补偿,才算真正掌握 Spring Boot Web 工程,而不只是会写 Controller。
