Skip to content

Spring Boot Web 请求链与商业工程实践 ​

商业 Web 服务不是“Controller 调 Service,再返回 JSON”这么简单。一个创建订单请求还要经历协议解析、身份认证、参数校验、幂等检查、事务、库存或支付下游调用、异常转换、日志追踪和监控;任何一个环节设计错误,都可能产生重复订单、脏数据、超时雪崩或敏感信息泄露。

本章使用 JDK 8 + Spring Boot 2.7 的订单接口贯穿讲解。Spring MVC 组件内部源码见Spring MVC 请求执行链,事务代理和传播规则见Spring 事务全过程。

一、学习目标 ​

学完后应能:

  1. 从网络入口追踪到 Controller、Service、数据库和响应写回。
  2. 区分 DTO、Command、领域对象、Entity、VO,知道为什么不能全部共用一个类。
  3. 解释参数校验发生在哪一层、为什么校验通过不代表业务合法。
  4. 设计统一错误码和全局异常处理,而不是在每个 Controller 写 try-catch。
  5. 正确划分事务边界,理解“数据库提交成功”和“MQ 发送成功”不是一个事务。
  6. 设计幂等、traceId、超时、重试和生产监控。
  7. 分层编写单元测试、MVC 测试和集成测试。
  8. 根据 400、401、403、404、409、415、500、503 和超时现象定位问题。

二、创建订单请求的完整链路 ​

mermaid
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 是否参与一致性,要由专门方案决定。

三、先划清每一层的职责 ​

text
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/ControllerHTTP DTO、状态码、Header协议转换、参数校验、调用用例SQL、长事务、复杂业务规则
Application ServiceCommand、用例结果编排业务步骤、事务边界、幂等入口Servlet API、JSON 细节
Domain业务值和状态不变量、状态机、业务计算Controller 注解、数据库 SQL
Repository/Mapper领域查询和持久化结果数据读写与映射决定支付、库存业务规则
Client/Gateway下游协议超时、错误转换、调用指标将第三方 DTO 泄漏给所有业务层

分层不是为了增加目录数量,而是为了隔离变化:HTTP 协议变化不应迫使领域规则修改,数据库表结构变化不应直接改变外部响应,第三方接口升级不应影响所有 Service。

四、为什么 Request、Entity、Domain 和 Response 不能混用 ​

错误做法:Controller 直接接收数据库 Entity,再原样返回。

后果包括:

  1. 客户端可能提交 id、status、createdTime 等不应控制的字段。
  2. Entity 新增内部敏感字段后可能被意外序列化到公网。
  3. 数据库字段改名会破坏 API 合约。
  4. 懒加载关联可能触发 N+1、序列化递归或会话关闭异常。
  5. 校验规则被数据库结构绑死,创建和修改接口无法表达不同约束。

推荐映射:

mermaid
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 间接带入,项目中显式加入:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
java
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:

java
@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 校验发生在哪里 ​

mermaid
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 非空、金额大于 0DTO Bean Validation
集合内部约束商品数量大于 0嵌套 DTO + @Valid
业务规则商品是否存在、是否可售Service/Domain
并发不变量requestId 唯一、库存不能为负Service + 数据库约束/原子操作
权限规则用户只能创建自己的订单Security + Service 二次校验

仅在 Controller 查询“requestId 是否存在”再插入,会产生并发竞态:两个请求都查不到,然后同时插入。最终不变量必须依靠数据库唯一约束或原子机制兜底。

六、Controller 为什么要保持薄 ​

Controller 应做:

  1. 接收 Path、Query、Header、Body。
  2. 触发格式校验。
  3. 将外部 DTO 转换为用例 Command。
  4. 调用一个清晰的应用服务入口。
  5. 将结果转换为响应 DTO 和状态码。

错误示例:

java
@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 如何编排用例 ​

java
@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 ​

mermaid
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 单方法事务又可能把一个业务用例拆成多次独立提交。

必须知道的限制:

  1. @Transactional 通常由 AOP 代理实现,外部通过代理调用才会拦截。
  2. 同一个对象中 this.create() 自调用可能绕过代理。
  3. 默认情况下,常见运行时异常触发回滚;受检异常规则要显式配置或设计。
  4. 捕获异常后不再抛出,事务拦截器可能认为方法成功。
  5. 本地事务只能直接控制同一事务资源,不能自动回滚远程 HTTP、Redis 和普通 MQ 发送。

完整声明式和编程式事务见Spring 事务全过程。

九、创建接口为什么必须考虑幂等 ​

客户端可能因超时没有收到响应而重试;网关可能重试;用户可能重复点击。第一次请求也许已经提交,只是响应丢失。

mermaid
flowchart TD
    A["客户端发送 requestId=R100"] --> B["服务创建订单并提交"]
    B --> C["响应在网络中丢失"]
    C --> D["客户端使用相同 requestId 重试"]
    D --> E{"是否有幂等保障"}
    E -- "没有" --> F["生成第二张订单"]
    E -- "有" --> G["返回第一次创建结果"]

推荐组合:

  1. 客户端为一次业务操作生成稳定 requestId。
  2. 数据库建立 UNIQUE(request_id),作为并发最终防线。
  3. 插入发生唯一键冲突时查询并返回原结果,或返回明确冲突。
  4. requestId 必须与用户、租户和接口语义绑定,防止跨用户碰撞。
  5. 不要只使用先查后写;它无法阻止并发窗口。

Redis SET NX 可以减少重复请求压力,但如果 Redis 锁释放和数据库提交之间设计不严谨,它不能替代数据库唯一约束。

十、统一响应和错误码怎样设计 ​

成功响应可以统一包装,也可以遵循纯 HTTP 状态码 + 资源表示。关键不是“必须套一层 code”,而是全系统合约一致。

java
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 状态业务错误码示例含义
400REQUEST_INVALIDJSON、类型或格式校验错误
401AUTH_REQUIRED未认证或凭证失效
403ACCESS_DENIED已认证但无权限
404ORDER_NOT_FOUND指定资源不存在
409ORDER_STATE_CONFLICT状态冲突、重复操作
415CONTENT_TYPE_UNSUPPORTED请求体媒体类型不支持
429RATE_LIMITED被限流
503DEPENDENCY_UNAVAILABLE必要下游暂不可用
500INTERNAL_ERROR未分类服务端错误

不要把所有错误都返回 HTTP 200,否则网关、监控、客户端重试和缓存无法准确理解结果;也不要把数据库异常栈直接返回给用户。

十一、全局异常处理全过程 ​

java
@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", "系统繁忙,请稍后重试");
    }
}
mermaid
flowchart TD
    A["Controller 或参数解析抛出异常"] --> B["DispatcherServlet 进入异常解析链"]
    B --> C["匹配 @ExceptionHandler"]
    C --> D["选择 HTTP 状态和业务错误码"]
    D --> E["记录带 traceId 的内部日志"]
    E --> F["返回不泄露内部信息的响应"]

全局兜底异常必须记录堆栈;预期业务异常通常记录关键上下文即可,避免每次“订单不存在”都打印巨大 ERROR 堆栈。日志和客户端消息是两个边界:客户端得到稳定、安全的说明,内部日志保留定位证据。

十二、traceId 和日志如何贯穿请求 ​

Filter 可以读取上游 traceId,没有则生成,再放入 MDC:

java
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 工作线程和连接池:

mermaid
flowchart TD
    A["请求进入 Tomcat 线程池"] --> B["同步调用慢下游"]
    B --> C["线程持续等待"]
    C --> D["新请求继续占用线程"]
    D --> E["线程池和连接池耗尽"]
    E --> F["健康接口也超时,形成级联故障"]

至少分别配置连接超时、读取超时和连接池获取超时。重试只适合可重试、幂等、短暂失败的操作,并应限制次数、退避和总时间预算。

绝不能看到超时就盲目重试:创建订单、扣款、发放优惠券等非幂等请求可能被执行多次。即使调用方超时,也不代表下游没有执行成功。

十四、数据库提交与消息发送的一致性 ​

错误流程:

text
保存订单 -> 提交数据库 -> 发送 MQ

数据库提交后进程崩溃,消息永远没发;如果先发消息再提交数据库,消费者可能读不到订单。

本地消息表/Outbox 思路:

mermaid
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 专栏。

十六、测试怎样覆盖不同风险 ​

mermaid
flowchart TD
    A["领域与 Service 单元测试:快且定位准"] --> B["MVC 切片测试:协议、校验、异常、JSON"]
    B --> C["数据库集成测试:SQL、约束、事务"]
    C --> D["应用集成测试:Bean 和自动配置"]
    D --> E["端到端测试:网络和真实依赖链"]

16.1 MVC 测试应验证什么 ​

java
@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 ​

  1. 按错误码区分 JSON 解析失败、字段校验失败和业务校验失败。
  2. 对比客户端与服务端版本,检查字段名称和类型是否变更。
  3. 检查 Content-Type 和字符编码。
  4. 抽样查看脱敏后的失败请求特征,不能直接全量打印敏感 Body。

18.2 大量 500 ​

  1. 使用 traceId 找到服务端完整异常链。
  2. 先找最早业务根因,不只看全局异常处理器最后一行。
  3. 判断异常发生在参数转换、Service、数据库、下游、事务提交还是响应序列化。
  4. 检查同一错误码、版本、实例和发布时点是否聚集。
  5. 必要时降级、回滚或摘除异常实例,再修复根因。

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。