【阶段 7】微服务:Spring Cloud Alibaba 实战

105次阅读
没有评论

阶段 7 · 从单体走向分布式,掌握服务治理核心组件 版本:Spring Boot
3.2.x · Spring Cloud Alibaba 2023.0.1.0 · JDK 17


1. 导语

前面六个阶段,你写的都是一个「单体应用」:一个
com.example.demo 工程,一个 JVM 进程,一个数据库,所有
Controller、Service、Mapper
都堆在一起。这种写法在项目早期又快又省事,但当你负责的系统用户量涨到百万级、业务团队从
3 个人涨到 30 个人时,单体应用的三个致命问题会集中爆发:

  1. 改一行代码要整体重新部署:下单功能要发版,登录功能被迫跟着一起停服;
  2. 一处内存泄漏拖垮全站:秒杀接口
    OOM,整个应用连带登录、支付一起挂掉;
  3. 无法按模块扩缩容:真正吃 CPU
    的只有订单计算,但你只能整机加机器。

微服务就是用来拆解这些问题的:把一个大系统按业务边界拆成若干个能独立开发、独立部署、独立扩缩容的小服务,服务之间通过网络互相调用。但拆开之后,新的难题随之而来——服务怎么找到彼此(注册中心)、配置怎么统一管理(配置中心)、调用失败了怎么办(熔断降级)、流量洪峰怎么扛(限流)、出问题怎么定位(链路追踪)、跨服务数据怎么保证一致(分布式事务)。

本阶段你将用 Spring Cloud Alibaba 这一套阿里开源、Spring
官方认证的技术栈,把这些问题逐个击破。学完这一篇,你能亲手搭出一个「用户
+ 订单 + 库存」三服务 +
网关的完整微服务集群:服务自动注册发现、配置热更新、远程调用失败自动降级、网关统一鉴权、接口
QPS 限流、全链路 TraceId 贯穿。这是你从「会写
CRUD」走向「会做架构」的分水岭。


2. 学习目标与前置要求

学完本阶段,你能:

  • 用 CAP 定理与 BASE 理论解释「为什么分布式系统必须取舍」,并能判断
    Nacos / Eureka / Zookeeper 各自属于 CP 还是 AP;
  • 独立部署 Nacos Server,并让两个 Spring Boot 服务通过 Nacos
    完成服务注册与发现,用 Feign 完成服务间调用;
  • 用 Nacos Config 集中管理配置,通过 @RefreshScope
    实现不重启应用的热更新,并用 namespace
    实现开发/测试/生产环境隔离;
  • 用 OpenFeign 的 fallbackFactory
    为远程调用编写可捕获异常的降级逻辑,并正确配置连接/读取超时;
  • 用 Spring Cloud Gateway
    实现统一路由、全局鉴权过滤器,并将登录用户信息通过 Header
    透传给下游服务;
  • 用 Sentinel 实现接口 QPS 限流、熔断与降级,并把限流规则持久化到
    Nacos 防止重启丢失;
  • 讲清 TraceId 如何贯穿整个调用链,以及 Seata 的 AT / TCC / Saga
    三种分布式事务模式的区别与选型。

前置依赖

  • 阶段 3(Web 开发):Controller / Service / Mapper
    分层、ApiResult 统一响应体;
  • 阶段 4(数据访问):MyBatis-Plus 的实体与 CRUD;
  • 阶段 5(安全认证):JWT 签发与校验、@Slf4j
    日志习惯;
  • 阶段 6(消息队列):异步解耦与最终一致性的思想(第 7
    章分布式事务会用到)。

如果你还没掌握「构造器注入(@RequiredArgsConstructor +
final 字段)」和「统一响应体
ApiResult<T>」这两个贯穿全系列的习惯,建议先回看阶段
3,因为本篇所有代码都建立在这两个约定之上。


3. 环境准备

3.1 版本清单

组件 版本 说明
JDK 17 编译与运行环境
Spring Boot 3.2.5 基础框架
Spring Cloud 2023.0.1 微服务基础版本(与 Boot 3.2.x 配套)
Spring Cloud Alibaba 2023.0.1.0 Nacos / Sentinel / Seata 的整合版本
Nacos Server 2.3.2 注册中心 + 配置中心,单机模式即可
Sentinel Dashboard 1.8.7 限流/熔断的可视化控制台
MySQL 8.0 三个服务各自的业务库
Redis 7.x 网关限流计数 + 可选缓存

3.2 初始化命令

先启动基础设施。Nacos 与 Sentinel Dashboard 用官方压缩包即可(JDK 17
环境直接解压运行):

# 1. 下载并启动 Nacos(单机模式,用于注册中心 + 配置中心)
wget https://github.com/alibaba/nacos/releases/download/2.3.2/nacos-server-2.3.2.tar.gz
tar -zxvf nacos-server-2.3.2.tar.gz
cd nacos/bin
# Linux/Unix 单机模式启动
sh startup.sh -m standalone
# 默认控制台地址:http://localhost:8848/nacos ,账号/密码均为 nacos

# 2. 下载并启动 Sentinel Dashboard(可视化限流规则控制台)
wget https://github.com/alibaba/Sentinel/releases/download/1.8.7/sentinel-dashboard-1.8.7.jar
java -Dserver.port=8858 -Dcsp.sentinel.dashboard.server=localhost:8858 
     -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.7.jar
# 默认控制台地址:http://localhost:8858 ,账号/密码均为 sentinel

# 3. 初始化三个业务库
mysql -uroot -p <<'EOF'
CREATE DATABASE IF NOT EXISTS db_user  DEFAULT CHARACTER SET utf8mb4;
CREATE DATABASE IF NOT EXISTS db_order DEFAULT CHARACTER SET utf8mb4;
CREATE DATABASE IF NOT EXISTS db_stock DEFAULT CHARACTER SET utf8mb4;
EOF

3.3 多服务项目结构

本篇贯穿全程的 Demo 是一个 Maven 多模块工程,共 4 个可运行服务 + 1
个公共模块:

microservice-demo/
├── pom.xml                        # 父 POM:统一管理依赖版本
├── common/                        # 公共模块:ApiResult / ErrorCode / BizException 等
│   └── src/main/java/com/example/common/...
├── user-service/                  # 用户服务 :8081  库 db_user
├── order-service/                  # 订单服务 :8082  库 db_order(调用 user + stock)
├── stock-service/                  # 库存服务 :8083  库 db_stock
└── gateway/                        # 网关      :8080  统一入口 + 鉴权

依赖关系很关键,它直接体现了微服务的边界:order-service 依赖
user-service 和 stock-service(通过 Feign
远程调用),但它们三个之间没有代码级的依赖,只有网络调用
common
模块被所有服务依赖,用来共享
ApiResultErrorCodeBizException
这些公共类。


4. 正文章节

第 1 章 微服务架构基础

1.1 为什么需要微服务

单体架构(Monolith)在业务早期有巨大优势:部署简单、调用本地方法无网络开销、事务天然
ACID。但当规模变大,三个问题会同时出现,也就是导语里说的部署耦合、故障耦合、扩缩容耦合

微服务的本质,是用「网络调用」和「分布式复杂度」换取「独立部署、独立扩缩容、故障隔离、技术栈解耦」四个能力。注意:微服务不是银弹,如果你的团队只有
5
个人、业务还没定型,拆微服务只会让你把时间花在解决网络抖动和分布式一致性上,而不是业务上。拆分的第一原则是「按业务边界拆分,而不是拆得越细越好」

1.2 CAP
定理:分布式系统最多同时满足两个

CAP 定理由 Eric Brewer
提出,是分布式系统的第一性原理。三个字母分别代表:

  • C(Consistency,一致性):任意时刻,所有节点读到的数据都相同。你刚写入一个值,立刻去另一个节点读,一定能读到新值。
  • A(Availability,可用性):每个请求都能在合理时间内得到响应(不一定是成功结果),系统不因部分节点故障而拒绝服务。
  • P(Partition
    tolerance,分区容错性)
    :网络分区(节点之间网络断开、消息丢失、延迟超时)发生时,系统仍能继续运行。

核心结论:一个分布式系统最多只能同时满足其中的两个,不可能三者兼得。

原因很好理解:分布式系统里,节点之间靠网络通信,而网络分区(P)是必然会发生的物理事实——交换机故障、机房割接、光缆被挖断都会导致节点之间无法通信。既然
P 无法避免,那么当网络分区发生时,你就只剩两个选择:

  • 选择 CP(一致性 +
    分区容错)
    :分区发生时,为了保证数据一致,宁可拒绝请求、放弃可用性。典型代表:Zookeeper、Consul、Nacos
    的持久化实例(AP 不可时)。
  • 选择 AP(可用性 +
    分区容错)
    :分区发生时,为了保证系统可用,允许各分区返回可能不一致的临时数据。典型代表:Eureka、Nacos
    的临时实例(默认)。
            P(网络分区,无法避免,必须满足)
                     │
        ┌────────────┴────────────┐
        │                         │
   C(一致性)  ──────────  A(可用性)
        │                         │
   选 CP:宁可拒绝            选 AP:宁可短暂不一致
   Zookeeper / Consul        Eureka / Nacos 临时实例

Nacos
的巧妙之处
:它同时支持两种模式。注册中心里,临时实例(ephemeral)走
AP 模式,靠客户端心跳上报,宕机了很快摘除;持久化实例(persistent)走 CP
模式,基于 Raft
协议保证一致性。生产环境的服务实例几乎都用临时实例(AP),因为「短时间内拉到一台已经下线的实例」比「整个注册中心不可用」的代价小得多。

1.2.1 用「下单」场景理解 CP
与 AP 的取舍

抽象地讲 CP/AP
很容易,落到业务上才直观。假设你的电商系统有两个机房,用户数据被复制到两个机房的数据库,现在两个机房之间的网络断了(分区发生,P
出现):

  • 如果选 CP(一致优先):网络断开后,A
    机房的写操作无法同步到 B
    机房,为了保证「两边读到的用户数据一定一致」,系统会拒绝写请求。结果:用户还能浏览,但下单、改密码全部失败,可用性受损,但数据绝对不乱。
  • 如果选 AP(可用优先):网络断开后,A、B
    机房各自继续接受写请求,两边暂时看到的数据不一样(比如用户在 A
    机房下单了,B
    机房还看不到这个订单)。结果:系统始终可用,但短时间数据不一致,需要事后通过「对账/同步」修复。

这个例子里的「下单」对应一致性(C),「系统始终能响应」对应可用性(A)。你选哪个,取决于业务:资金账户类业务选
CP(宁可拒绝也不能错账),商品浏览/下单类业务选
AP(宁可短暂不一致也不能不可用)
。这也是为什么注册中心里,Eureka
和 Nacos 临时实例走
AP——短暂的「拉到一台已下线实例」换来的是「注册中心永远可用」,而注册中心挂了才是整个微服务体系的灾难。

1.3 BASE 理论:对 CAP
的工程妥协

既然 AP 模式允许短暂不一致,那「最终能不能一致、怎么一致」就是 BASE
理论回答的问题:

  • BA(Basically
    Available,基本可用)
    :系统出现故障时,允许损失部分可用性,但保证核心功能可用。比如大促时下单页的「商品详情」允许降级为静态数据,但「下单」必须可用。
  • S(Soft
    state,软状态)
    :允许系统存在中间状态,这个中间状态不会影响系统整体可用性。比如订单支付后处于「支付中」,等待回调确认。
  • E(Eventually
    consistent,最终一致性)
    :系统中的数据经过一段时间后,最终能达到一致,而不要求实时一致。比如库存扣减异步同步,最终各方看到的库存一致。

CAP 是「理论上的硬约束」,BASE
是「工程上的软着陆」:既然强一致(C)在分区场景下代价太大,就用最终一致性换可用性。你在第
7 章看到的 Seata、消息队列最终一致性,本质上都是 BASE 理论的落地。

1.4 微服务组件全景图

一个完整的微服务体系,不是「拆了几个服务」就完事了,而是需要下面这套组件矩阵支撑。这也是本篇
7 章的地图:

组件 解决什么问题 主流选型 本篇对应章节
注册中心 服务实例的动态注册与发现 Nacos / Eureka / Consul 第 2 章
配置中心 配置集中管理 + 热更新 + 环境隔离 Nacos / Apollo 第 3 章
服务调用 远程服务间像本地方法一样调用 OpenFeign / Dubbo 第 4 章
负载均衡 把请求分发到多个实例 Spring Cloud LoadBalancer 第 4 章
服务网关 统一入口、鉴权、路由、限流 Spring Cloud Gateway 第 5 章
熔断/降级/限流 保护服务不被流量或故障打垮 Sentinel / Resilience4j 第 6 章
链路追踪 全链路调用关系与耗时观测 SkyWalking / Zipkin / Micrometer 第 7 章
分布式事务 跨服务的数据一致性 Seata / 消息最终一致性 第 7 章

1.4.1
微服务不是银弹:拆分的代价与边界原则

在动手拆分前,你必须清楚微服务的代价,否则很容易「为了微服务而微服务」:

  1. 网络开销与不确定性:本地方法调用是纳秒级,网络调用是毫秒级,还叠加了超时、重试、丢包这些不确定性。原来一个事务里的两步操作,拆开后变成了两次可能失败的远程调用;
  2. 分布式一致性问题:本地 @Transactional
    失效了,跨服务一致性要引入 Seata 或消息最终一致性,复杂度陡增(第 7
    章会展开);
  3. 运维复杂度爆炸:从「部署 1 个服务」变成「部署 N
    个服务 + 注册中心 + 网关 + 配置中心 + 链路追踪 +
    分布式事务协调器」,DevOps 能力跟不上就是灾难;
  4. 调试与排错成本:一个 Bug 可能跨 4
    个服务,没有链路追踪(第 7 章)根本没法定位。

判断「要不要拆」的三个信号:

  • 团队规模:单个服务超过 2
    个团队在同时改,合并冲突频繁 → 该拆;
  • 扩缩容诉求:只有某个模块是性能瓶颈,但被迫整机扩容
    → 该拆;
  • 故障隔离诉求:某个模块一出问题就全站宕机,无法隔离
    → 该拆。

拆分的第一原则是按业务边界(限界上下文)拆分,而不是按技术层次拆(比如「所有
Controller 一个服务、所有 Service
一个服务」这种就是错的)。「用户、订单、库存」就是三个清晰的业务边界,它们各自有独立的数据库、独立的团队、独立的生命周期。本篇的
Demo 也严格按这个边界来拆。

1.5 一次请求的完整旅程

理解了组件矩阵,再看一次「下单」请求的完整链路,你会对整个微服务协作方式豁然开朗:

浏览器 ──> Gateway(8080) ──鉴权、路由、限流──> order-service(8082)
                                                    │
                                     Feign 远程调用  ├──> user-service(8081) 查用户
                                                    └──> stock-service(8083) 扣库存
  1. 请求先到 Gateway,网关做统一鉴权(校验 JWT),并把
    X-User-Id 透传到下游;
  2. Gateway 按 Path=/api/orders/** 路由到
    order-service,负载均衡选一个实例;
  3. order-service 处理下单逻辑,通过 Feign 调用
    user-service 查用户、stock-service
    扣库存;
  4. stock-service 挂了,Feign
    的降级工厂兜底返回友好错误,不会拖垮订单服务;
  5. 全程每个服务从 Nacos 拉取实例列表与配置,Sentinel 在入口做 QPS
    限流,TraceId 在服务间传递。

本章小结:CAP 告诉我们分布式必须取舍,BASE
告诉我们怎么取舍——用最终一致性换可用性;组件全景图告诉我们微服务不是拆服务,而是一整套治理体系的组合。


第 2 章 注册中心
Nacos(服务注册与发现)

2.1 服务注册与发现的原理

单体时代,A 调用 B 直接写死 IP 就行。微服务时代,B
可能随时扩容、缩容、重启,IP
一直在变。注册中心解决的就是「调用方怎么动态知道被调用方在哪」这个问题,核心是三件事:

  1. 注册:服务启动时,把自己的
    服务名 + IP + 端口 + 健康状态
    上报给注册中心(Nacos);
  2. 发现:调用方启动时,从注册中心拉取目标服务的实例列表,并订阅变更通知;
  3. 健康检查:注册中心通过心跳/探活,把不健康的实例及时从列表剔除。
user-service 启动 ──注册──>  Nacos(8848)  <──订阅── order-service
 (8081)                        │                     │
                       维护实例列表            缓存实例列表
                        ip1:8081              每 10s 刷新
                        ip2:8081

Nacos 健康检查分两种:临时实例靠客户端每 5
秒上报一次心跳,15 秒没收到就标记不健康、30 秒剔除(AP
模式,适合常规业务服务);持久化实例由 Nacos
服务端主动探测(CP 模式,适合数据库这种不主动上报心跳的中间件)。

2.2 依赖与最小配置

引入 Spring Cloud Alibaba 的 Nacos Discovery 依赖(父 POM
已统一管理版本,子模块无需写版本号):

<!-- user-service/pom.xml 的依赖片段 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

启动类加 @EnableDiscoveryClient(Spring Cloud 2023.x
里,只要 classpath 有 discovery
依赖,这一步其实可以省略,但显式标注更清晰、便于读者理解):

package com.example.user;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient   // 显式开启服务注册与发现,语义清晰
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

application.yml 配置注册中心地址:

spring:
  application:
    name: user-service          # 服务名,注册中心和 Feign 调用都以它为准
  cloud:
    nacos:
      discovery:
        server-addr: ${NACOS_ADDR:localhost:8848}   # 环境变量可覆盖
        namespace: dev          # 命名空间:环境隔离(见第 3 章)
        group: DEFAULT_GROUP    # 分组:默认 DEFAULT_GROUP
server:
  port: 8081

启动后,登录 Nacos 控制台
服务管理 -> 服务列表,就能看到 user-service
及其 8081 实例。namespace 先记着概念,第 3
章会详细讲环境隔离。

2.2.1 Nacos 控制台与健康检查

启动服务后登录 Nacos
控制台(http://localhost:8848/nacos,默认账号密码
nacos/nacos),在「服务管理 → 服务列表」里能看到
user-service
和它的实例详情:IP、端口、健康状态、权重。这里有几个生产必懂的配置点:

健康检查的精细化。默认健康检查比较「粗」,推荐接入
Actuator,让 Nacos 感知服务的真实健康状态:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health          # 暴露 /actuator/health
  endpoint:
    health:
      show-details: always
spring:
  cloud:
    nacos:
      discovery:
        # 临时实例(默认):靠客户端心跳上报,15s 没心跳标记不健康,30s 剔除
        ephemeral: true

权重与元数据。发布时你可以给实例配权重(做灰度/蓝绿),也可以带自定义元数据:

spring:
  cloud:
    nacos:
      discovery:
        metadata:
          version: v1.2.0       # 版本号,配合灰度路由使用
          zone: hangzhou        # 机房/可用区,配合就近路由使用

为什么临时实例是默认且推荐的:微服务实例会频繁启停、扩缩容,临时实例的心跳机制能秒级感知下线;而持久化实例(ephemeral: false)由服务端主动探测,摘除慢,适合数据库、缓存这类「不会主动上报心跳」的中间件。绝大多数业务服务都应该用默认的临时实例。

2.2.2 服务发现的完整时序

把「注册 → 发现 → 调用」的时序捋一遍,你对 Nacos 的理解才算闭环:

1. user-service 启动 → 调用 Nacos 注册接口,上报 (user-service, 192.168.1.10:8081, UP)
2. Nacos 维护实例列表,并每 5s 收一次心跳,超过 15s 无心跳标记 UNHEALTHY
3. order-service 启动 → 向 Nacos 订阅 user-service,拉取实例列表并本地缓存
4. Nacos 发现实例列表变化(扩缩容/下线)→ 推送变更给所有订阅者
5. order-service 每次调用 user-service → 从本地缓存的实例列表里轮询选一个 → 发起 HTTP

第 5
步里「轮询选一个」就是客户端负载均衡(LoadBalancer)在做的事。注意:调用方缓存的是「实例列表」,而不是「某个固定
IP」
,所以 user-service 扩容后,order-service
不需要任何改动就能感知新实例——这就是「服务发现」带来的弹性。

2.3
让服务被调用:一个最小可用的用户接口

注册只是第一步,服务还得对外提供接口。这里给
user-service
写一个查询用户的最小接口(生产级版本在实战项目里展开,这里聚焦「注册 +
调用」这条链路):

package com.example.user.controller;

import com.example.common.ApiResult;
import com.example.common.ErrorCode;
import com.example.user.entity.User;
import com.example.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {

    private final UserService userService;   // 构造器注入,依赖不可变

    @GetMapping("/{id}")
    public ApiResult<User> getById(@PathVariable("id") Long id) {
        log.info("收到查询用户请求,userId={}", id);
        User user = userService.getById(id);
        if (user == null) {
            // 查不到返回业务错误码,而不是 200 + null,下游能明确区分
            return ApiResult.fail(ErrorCode.USER_NOT_FOUND);
        }
        return ApiResult.ok(user);
    }
}

2.4
调用方如何发现并调用:LoadBalancer + RestTemplate

有了提供方,再看调用方怎么「按服务名」找到它。Spring Cloud 引入
LoadBalancer
做客户端负载均衡:调用方本地缓存一份实例列表,每次请求轮询(默认)选一个实例发出。最小可用示例用
RestTemplate 演示,第 4 章再用更优雅的 Feign:

package com.example.order.config;

import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {

    @Bean
    @LoadBalanced   // 关键:让 RestTemplate 支持按服务名 + 负载均衡调用
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
// order-service 里调用 user-service 的片段
// 注意 URL 里写的是服务名 user-service,而非 IP:port
String url = "http://user-service/api/users/" + userId;
ApiResult<User> result = restTemplate.getForObject(url, ApiResult.class);

@LoadBalanced 的原理:它给 RestTemplate
加了一个拦截器,遇到 http://服务名/... 这种
URL,就把「服务名」交给 LoadBalancer,替换成一个真实实例的
ip:port 再发出去。这就是「注册发现 +
负载均衡」的完整闭环。

2.5 生产级注意点

  • 服务名规范spring.application.name
    就是服务在注册中心的唯一标识,一旦上线不要随意改,否则所有调用方都要跟着改;
  • namespace 环境隔离:开发、测试、生产必须用不同
    namespace,否则生产服务可能拉到测试环境的实例列表,这是线上事故的高发点;
  • 优雅下线:服务发布时要先从注册中心摘除实例再停进程,否则正在路由的请求会打到已停机的实例上。Spring
    Boot 配合 management.endpoint.health
    和优雅停机(server.shutdown=graceful)能显著减少这类「闪断」;
  • 健康检查与探针:配合
    spring-boot-starter-actuator
    /actuator/health,把真实健康状态上报,避免「进程活着但接口已经死了」的假健康。

本章小结:注册中心是微服务的「通讯录」——服务把自己登记上去,调用方按名字查到真实地址再负载均衡调用;Nacos
临时实例走 AP、持久化实例走 CP,理解这点才能选对模式。


第 3 章
配置中心 Nacos Config(集中配置与动态刷新)

3.1 为什么需要配置中心

单体时代,配置文件就一个
application.yml,改完重启即可。微服务时代问题来了:

  • 配置散落:20 个服务就有 20 份
    application.yml,改一个数据库地址要改 20 处;
  • 无法热更新:改个开关(比如「大促降级开关」)要重启所有实例,几分钟的停服在高峰期是灾难;
  • 环境混乱:开发/测试/生产的配置混在一起,稍有不慎就把测试库的地址发到生产。

配置中心把配置集中到 Nacos
统一管理
,服务启动时拉取、运行时监听变更。改配置不用重新部署,配合
@RefreshScope 还能让 Bean 自动刷新。

3.2 依赖与 DataId 规则

引入依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

Spring Boot 3.x 用 spring.config.import
引入外部配置(不再依赖旧版的 bootstrap.yml):

spring:
  application:
    name: user-service
  cloud:
    nacos:
      config:
        server-addr: ${NACOS_ADDR:localhost:8848}
        file-extension: yaml          # 配置内容格式
        namespace: dev                # 与 discovery 的 namespace 对应
        group: DEFAULT_GROUP
  config:
    import:
      - optional:nacos:user-service.yaml   # 拉取 dataId=user-service.yaml 的配置

Nacos Config 的定位规则是 DataId =
spring.application.name + . +
file-extension
,即上面的
user-service.yaml。你在 Nacos 控制台「配置管理 ->
配置列表」里新建一条 DataId 为 user-service.yaml
的配置:

# 内容示例:把业务参数集中到 Nacos
app:
  name: 用户中心服务
  swagger-enabled: true
order:
  timeout-ms: 3000

optional: 前缀表示「即使 Nacos
上暂时没有这条配置,应用也能正常启动」,生产上强烈建议加上,避免配置中心抖动导致服务起不来。

3.2.1
配置的加载顺序与共享配置

当配置散落在多个来源时,「谁覆盖谁」直接决定了线上行为,必须搞清楚。Spring
Boot 3.x 的配置优先级从高到低是:命令行参数 > 环境变量 >
Nacos Config(远程) > 本地
application.yml
。也就是说,本地
application.yml 里的默认值会被 Nacos
上的同名配置覆盖,而环境变量又能覆盖 Nacos——这正是「远程集中管理 +
本地兜底 + 环境变量灵活覆盖」三层结构的由来。

共享配置:很多配置是多个服务共用的(比如公共的数据库连接、日志级别、短信开关),如果每个服务的
DataId 里都复制一份,改起来又是 20 处。Nacos
支持「扩展配置」,让服务在加载自己配置的同时,额外加载一份公共配置:

spring:
  cloud:
    nacos:
      config:
        server-addr: ${NACOS_ADDR:localhost:8848}
        file-extension: yaml
        namespace: dev
        group: DEFAULT_GROUP
        extension-configs:
          - data-id: common-config.yaml      # 公共配置,所有服务共享
            group: DEFAULT_GROUP
            refresh: true                    # 公共配置变更也参与热更新
  config:
    import:
      - optional:nacos:user-service.yaml

这样「服务独有配置」放 user-service.yaml,「公共配置」放
common-config.yaml,改公共配置一处生效全集群。注意
extension-configs 的优先级高于服务自身的
DataId 配置,若两边有同名 key,以 extension-configs
为准。

共享配置 vs
服务配置的取舍
:公共配置里只放「真正所有服务都一致」的东西(日志格式、公共开关),不要图省事把什么都塞进去,否则一个服务的特殊需求会污染全局。

3.3 动态刷新:@RefreshScope

配置拉到本地后,默认不会自动更新到已注入的
Bean。要让某个 Bean 在配置变更时刷新,给它加
@RefreshScope

package com.example.user.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/config")
@RefreshScope   // 关键:配置变更时,该 Bean 会被销毁重建,@Value 重新注入
@Slf4j
public class ConfigController {

    @Value("${app.name:默认名称}")   // 加了默认值,配置缺失也不报错
    private String appName;

    @Value("${app.swagger-enabled:false}")
    private boolean swaggerEnabled;

    @GetMapping("/app-name")
    public String appName() {
        return appName + ",swagger=" + swaggerEnabled;
    }
}

验证方式:启动服务后访问 /api/config/app-name
拿到当前值,然后在 Nacos 控制台把 app.name
改成别的值并「发布」,不用重启服务,再次访问就能看到新值。

@RefreshScope 的底层原理:它把 Bean
包装成代理,配置变更事件触发时,把代理指向的旧实例销毁、重新创建一个新实例并重新执行
@Value 注入。代价是每次刷新都会重建
Bean,所以只对「读配置的轻量 Bean」用
@RefreshScope
,不要滥用在整个 Service
上,否则刷新时会带来额外的对象创建开销。

3.3.1
更灵活的做法:配置变更监听

@RefreshScope 适合「读配置的 Bean
自动刷新」,但有些场景你需要「配置一变就主动做点事」,比如「数据库连接串变了要重建连接池」「限流阈值变了要重新加载规则」。这时可以用
Nacos 的监听 API:

package com.example.user.config;

import com.alibaba.cloud.nacos.NacosConfigManager;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class ConfigChangeListener {

    private final NacosConfigManager nacosConfigManager;

    @PostConstruct
    public void init() throws Exception {
        // 监听 dataId=user-service.yaml 的变更,配置一变就回调
        nacosConfigManager.getConfigService().addListener("user-service.yaml", "DEFAULT_GROUP",
                new com.alibaba.nacos.api.config.listener.Listener() {
                    @Override
                    public void receiveConfigInfo(String configInfo) {
                        // configInfo 是变更后的完整配置内容,这里可以做自定义处理
                        log.info("检测到配置变更:{}", configInfo);
                        // 例如:重建连接池、重新加载限流规则等
                    }

                    @Override
                    public com.alibaba.nacos.api.config.listener.Executor getExecutor() {
                        return null;   // null 表示用默认线程池
                    }
                });
    }
}

@RefreshScopeaddListener
的分工:前者是「被动刷新」——配置变了,下次读 Bean
自动是新值;后者是「主动响应」——配置变了,立刻执行一段自定义逻辑
。大多数场景
@RefreshScope 就够,需要「重建资源」时用
addListener

3.4 多环境隔离:namespace +
group

配置中心最怕的就是「串环境」。Nacos 用两级来隔离:

  • namespace(命名空间):物理级隔离,每个 namespace
    有独立 ID,不同 namespace
    的配置和注册实例完全隔离。典型用法:dev / test
    / prod 三个 namespace;
  • group(分组):同一 namespace
    内的逻辑隔离,比如同一环境里「交易组」「营销组」各自维护自己的配置。

生产规范是「namespace 隔离环境 + group
隔离业务线
」。服务通过
spring.cloud.nacos.config.namespacegroup
定位到自己的配置。还要特别注意:namespace 填的是 Nacos
控制台里生成的「命名空间
ID」,不是显示名称
,填错了会静默读到空配置,这是极难排查的坑。

3.5 生产级注意点

  • 敏感信息加密:数据库密码这类敏感配置,不要在 Nacos
    明文存放,结合 Nacos 的 AES 加密插件或外接 KMS 处理;
  • 配置变更审计:Nacos
    自带配置历史版本,变更前留好备注,出问题能一键回滚到历史版本;
  • 启动拉取失败兜底:用 optional: 前缀 +
    本地 application.yml
    保留关键默认值,避免配置中心不可用时服务集体起不来。

本章小结:配置中心把「改配置」从「改 20 份文件 +
重启 20 个服务」变成「改一处 + 自动热更新」;namespace 隔离环境、group
隔离业务,@RefreshScope 负责让 Bean 感知变更。


第 4 章 服务调用
OpenFeign(声明式调用与降级)

4.1
声明式调用:像调本地方法一样调远程服务

第 2 章用 RestTemplate 手动拼 URL
调用,写起来啰嗦:要自己拼接路径、自己处理响应体、自己写降级。OpenFeign
把这些都封装了——你只需定义一个接口并加注解,Feign
在运行时为它生成实现类,你就能像调本地方法一样调远程服务。

引入依赖(order-service 需要调用
user-servicestock-service):

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

启动类加 @EnableFeignClients

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.example.order.client")  // 扫描 Feign 客户端接口
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

4.2 最小可用:一个 Feign
客户端

package com.example.order.client;

import com.example.common.ApiResult;
import com.example.common.vo.UserVO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

// name 指向注册中心里的服务名,Feign 会自动通过 Nacos 发现实例并负载均衡
@FeignClient(name = "user-service")
public interface UserClient {

    @GetMapping("/api/users/{id}")
    ApiResult<UserVO> getById(@PathVariable("id") Long id);
}

然后在业务层直接注入调用:

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {

    private final UserClient userClient;   // Feign 生成的代理,像本地 Bean 一样注入

    @Override
    public OrderVO getOrderDetail(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            throw new BizException(ErrorCode.ORDER_NOT_FOUND);
        }
        // 远程调用用户服务:声明式调用,代码像在调本地方法
        ApiResult<UserVO> result = userClient.getById(order.getUserId());
        if (result.getCode() != 0 || result.getData() == null) {
            throw new BizException(result.getCode(), result.getMessage());
        }
        return OrderVO.of(order, result.getData());
    }
}

注意一个关键点:远程调用返回的是
ApiResult<UserVO> 包装体
,而不是裸的
UserVO。因为网络调用可能出现「HTTP 200
但业务失败」的情况(对方返回了
code=40401 用户不存在),包装体能让你区分「网络成功」和「业务成功」,这是微服务里最容易踩的坑之一——很多人看到
getData()
非空就以为调用成功了,实际上业务早就返回了错误码。

4.2.1 更多 Feign
用法:POST、参数传递与请求头透传

真实业务里,远程调用远不止「GET 一个
id」这么简单。下面是几种高频用法:

POST + 请求体。下单服务调用库存服务扣减库存(完整的
StockClient 已在 4.4/5.5
给出,这里看参数传递的三种姿势):

@FeignClient(name = "stock-service", fallbackFactory = StockClientFallbackFactory.class)
public interface StockClient {

    // 1. @RequestBody 传 JSON 对象:参数会被序列化成 JSON 放到请求体
    @PostMapping("/api/stocks/deduct")
    ApiResult<Boolean> deduct(@RequestBody DeductStockDTO dto);

    // 2. @PathVariable 拼到 URL 路径上
    @GetMapping("/api/stocks/{productId}")
    ApiResult<StockVO> getStock(@PathVariable("productId") Long productId);

    // 3. @SpringQueryMap 把对象的字段展开成查询参数 ?a=1&b=2(适合 GET 传多参数)
    @GetMapping("/api/stocks/page")
    ApiResult<List<StockVO>> list(@SpringQueryMap StockQuery query);
}

请求头透传:Feign
拦截器
。这是和「用户信息透传」直接相关的一个关键能力。你在第 5
章会看到网关把 X-User-Id
透传给订单服务;而订单服务再调库存服务时,这个头默认不会自动带过去,需要
Feign 拦截器手动转发:

package com.example.order.config;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Configuration
public class FeignHeaderConfig {

    // Feign 拦截器:在发出远程调用前,把当前请求的 X-User-Id 转发到下游
    // 这样「身份」就能穿透多层服务调用,下游无需重新鉴权
    @Bean
    public RequestInterceptor userHeaderInterceptor() {
        return template -> {
            ServletRequestAttributes attrs =
                    (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attrs == null) {
                return;
            }
            String userId = attrs.getRequest().getHeader("X-User-Id");
            if (userId != null) {
                template.header("X-User-Id", userId);
            }
        };
    }
}

注意一个陷阱:Feign 拦截器里取 X-User-Id 依赖
RequestContextHolder,而它基于
ThreadLocal
。如果用线程池异步调用 Feign,或 Feign
内部切换到其他线程,RequestContextHolder
可能取不到值。更稳妥的做法是参照第 5 章,把 userId 放进自定义的
UserContextThreadLocal
TransmittableThreadLocal)里,拦截器从
UserContext 取,再配合 TTL 线程池保证跨线程传递。

4.3 超时配置:必须显式配置

这是一个绕不开的坑。历史版本里,Feign 搭配 Ribbon
时,默认读取超时只有 1 秒,稍微慢一点的接口就会
Read timed out。当前 Spring Cloud 2023.x 已经用
LoadBalancer 替代 Ribbon,Feign 走自己的默认值(连接超时 10s、读取超时
60s),但仍然不能依赖默认值:10
秒的连接超时对「快速失败」来说太长了,60
秒的读取超时又可能让线程池被慢接口占满。

生产级做法是给 Feign 显式配置超时:

package com.example.order.config;

import feign.Request;
import feign.Retryer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class FeignConfig {

    @Bean
    public Request.Options requestOptions() {
        // 连接超时 3s,读取超时 5s:比默认值更贴合「快速失败」的微服务诉求
        return new Request.Options(3, TimeUnit.SECONDS, 5, TimeUnit.SECONDS, true);
    }

    @Bean
    public Retryer retryer() {
        // Feign 默认会重试(Retryer.Default 重试 5 次),
        // 对「写操作」很危险:可能重复下单/重复扣库存,生产建议关闭重试
        return Retryer.NEVER_RETRY;
    }
}

这里有两个生产级决策,值得展开:

  1. 为什么关闭 Feign 重试:Feign 默认的
    Retryer.Default 会对「连接异常」自动重试最多 5
    次。对查询类接口这没问题,但对「下单」「扣库存」这种非幂等写操作,重试会导致重复扣减。所以要么关闭重试,要么保证下游接口幂等(用唯一订单号去重)。
  2. 超时和重试要配合 Sentinel
    一起想
    :超时不是越短越好,太短会把正常的慢接口误杀。真正的「快速失败」应该交给第
    6 章的 Sentinel 熔断来做,Feign 超时只兜底网络级卡死。

4.4 降级:fallback 与
fallbackFactory 的区别

远程调用一定会失败——下游宕机、超时、网络抖动都是常态。如果
user-service 挂了,order-service
不做任何处理,就会抛出异常、线程堆积,最终拖垮整个订单服务(这就是「服务雪崩」的起点)。降级就是给远程调用一个「Plan
B」:失败时返回一个兜底结果,而不是把异常抛给调用方。

OpenFeign 提供两种降级方式,生产上必须选
fallbackFactory

方式 能否拿到异常原因 适用场景
fallback 不能,只能返回固定兜底值 简单场景,不关心失败原因
fallbackFactory 能,可以拿到 Throwable 并记录日志 生产场景,需要定位失败原因

fallbackFactory 的完整实现:

package com.example.order.client;

import com.example.common.ApiResult;
import com.example.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;

// 降级工厂:下游 user-service 不可用时触发,返回兜底结果而不是抛异常
@Component
@Slf4j
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {

    @Override
    public UserClient create(Throwable cause) {
        // cause 是真正的失败原因:超时、连接拒绝、500 等,必须记录日志便于排查
        log.error("调用 user-service 失败,触发降级", cause);
        return new UserClient() {
            @Override
            public ApiResult<UserVO> getById(Long id) {
                // 兜底:返回一个「服务不可用」的统一错误,而不是 null 或抛异常
                return ApiResult.fail(ErrorCode.SERVICE_UNAVAILABLE);
            }
        };
    }
}

Feign 客户端接口挂上降级工厂:

@FeignClient(
    name = "user-service",
    fallbackFactory = UserClientFallbackFactory.class,   // 降级工厂
    configuration = FeignConfig.class                    // 超时、重试配置
)
public interface UserClient {

    @GetMapping("/api/users/{id}")
    ApiResult<UserVO> getById(@PathVariable("id") Long id);
}

配合降级,在公共模块的 ErrorCode(定义见 5.2
节,全系列统一)里新增一个「服务不可用」错误码:

// 在 ErrorCode 枚举中新增(基础码保持数值不变,见 5.2 节完整定义)
SERVICE_UNAVAILABLE(50301, "服务暂不可用,请稍后重试"),

这样,调用方(订单服务)拿到 code=50301
就知道是下游挂了,可以给用户展示「系统繁忙」的友好提示,同时日志里已经记录了真实的
cause 堆栈,方便运维定位是哪台实例、什么原因失败。

4.5 Feign 日志

生产排查时,你想看到 Feign 实际发出的 URL、参数、响应。Feign
的日志默认是 NONE,需要显式开启:

logging:
  level:
    com.example.order.client.UserClient: DEBUG   # 只对某个 Feign 客户端开 DEBUG

feign:
  client:
    config:
      default:
        logger-level: FULL     # NONE/BASIC/HEADERS/FULL,FULL 最详细但性能开销大

生产建议FULL
会打印完整请求体和响应体,如果里面含敏感信息(手机号、密码)会有泄露风险,所以线上一般用
BASIC(只打印 URL、方法、状态码、耗时),配合 TraceId
定位问题后再临时开 FULL 排查。

本章小结:OpenFeign 把远程调用变成「定义接口 +
注解」,但生产上必须三件套配齐——显式超时、关闭重试(防重复写)、fallbackFactory
降级(能拿异常、有日志),否则下游一挂就是雪崩。


第 5
章 服务网关 Spring Cloud Gateway(路由 + 统一鉴权 + 用户透传)

5.1 为什么需要网关

微服务里,user-service 暴露
/api/users/**order-service 暴露
/api/orders/**,前端难道要记 10
个服务的地址吗?更致命的是:鉴权逻辑如果散落在每个服务里,漏一个就是安全漏洞;限流、日志、跨域这些横切关注点也会在每个服务重复实现。

网关(Gateway)就是所有流量的统一入口,把所有横切关注点收口到一处:

  • 统一路由:前端只访问网关一个地址,由网关按路径转发到对应服务;
  • 统一鉴权:在网关校验
    JWT,下游服务不再重复校验;
  • 统一限流、日志、跨域:一处配置,全局生效。

Spring Cloud Gateway 基于 WebFlux(响应式),非阻塞
I/O,性能远高于传统的 Zuul 1.x。

5.2 三大核心概念:Route
/ Predicate / Filter

  • Route(路由):一条路由 = 一个目标服务 +
    一组匹配规则 + 一组过滤器,是网关转发的基本单元;
  • Predicate(断言):匹配条件,判断「这个请求该走哪条路由」。基于路径、方法、Header、时间等;
  • Filter(过滤器):对请求/响应做处理,比如加请求头、限流、改写响应。

引入依赖(注意:网关是响应式的,不能
spring-boot-starter-web 一起用,否则启动报错):

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

5.2.1 常用 Predicate 与
Filter 清单

Predicate(断言)决定「请求匹配哪条路由」,Filter(过滤器)决定「匹配后怎么处理」。掌握常用清单,你就能组合出绝大多数路由需求:

常用 Predicate

predicates:
  - Path=/api/orders/**              # 按路径匹配
  - Method=GET,POST                  # 按 HTTP 方法匹配
  - Header=X-Requested-With, XMLHttpRequest   # 按请求头匹配(值是正则)
  - Query=brand, baidu               # 按查询参数匹配
  - After=2026-08-01T00:00:00+08:00  # 按时间匹配(可做活动页面定时上下线)

常用 Filterfilters:
下,可叠加多个):

filters:
  - StripPrefix=1                    # 去掉第一段路径再转发(/api/orders/x -> /orders/x)
  - AddRequestHeader=X-From, gateway # 给下游加一个请求头
  - AddResponseHeader=X-Powered-By, gateway
  - RedirectTo=302, https://example.com   # 重定向
  - name: RequestRateLimiter         # 限流过滤器(见 5.5)
    args: ...

Predicate 与 Filter
的执行顺序
:一个请求进来,Gateway 先按顺序匹配各路由的
Predicate,命中后按顺序执行该路由的 Filter(含全局 Filter)。全局 Filter
Ordered
值越小越先执行,比如鉴权过滤器要最先跑(-100),日志过滤器最后跑。

5.3 最小可用:路由配置

server:
  port: 8080
spring:
  application:
    name: gateway
  cloud:
    nacos:
      discovery:
        server-addr: ${NACOS_ADDR:localhost:8848}
    gateway:
      routes:
        - id: user-route
          uri: lb://user-service       # lb:// 走 Nacos 发现 + 负载均衡
          predicates:
            - Path=/api/users/**       # 路径断言:以 /api/users/ 开头的请求走这条
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
        - id: stock-route
          uri: lb://stock-service
          predicates:
            - Path=/api/stocks/**

启动网关后,访问
http://localhost:8080/api/users/1,网关会把它转发到
user-service
/api/users/1lb://
前缀是关键:它告诉网关「按服务名去 Nacos
查实例列表,再负载均衡转发」。

5.4 统一鉴权 +
用户信息透传(生产关键)

这是本章的重点,也是生产上最容易做错的地方。核心诉求有两步:

  1. 鉴权:在网关校验请求头里的 JWT,无效直接返回
    401,不把无效请求放行到下游
  2. 透传:鉴权通过后,把解析出的 userId
    放到一个新的请求头(如
    X-User-Id)里传给下游,下游直接用,不再重复解析
    JWT

这里有个必须知道的坑:下游服务绝对不能信任「客户端自己传上来的
X-User-Id」
。因为客户端可以直接伪造这个
Header。正确的做法是——网关用 JWT 解析出可信的
userId
,然后覆盖(而非透传原样)这个
Header:

package com.example.gateway.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

// 全局鉴权过滤器:所有请求都会经过这里
@Component
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    // 白名单:这些路径不需要鉴权(登录接口、健康检查、Swagger 等)
    private static final String[] WHITE_LIST = {"/api/auth/login", "/actuator", "/api/users/register"};

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        // 1. 白名单直接放行
        for (String white : WHITE_LIST) {
            if (path.startsWith(white)) {
                return chain.filter(exchange);
            }
        }

        // 2. 校验 JWT,解析出可信的 userId(这里用简化版 JWT 工具,生产用阶段 5 的 JwtUtil)
        String token = request.getHeaders().getFirst("Authorization");
        Long userId = null;
        if (token != null && token.startsWith("Bearer ")) {
            userId = JwtUtil.parseUserId(token.substring(7));   // 解析失败返回 null
        }
        if (userId == null) {
            // 未登录或 token 无效:直接返回 401,不进入下游
            log.warn("鉴权失败,拒绝访问,path={}", path);
            return unauthorized(exchange);
        }

        // 3. 关键:用 JWT 解析出的 userId 覆盖请求头,防止客户端伪造 X-User-Id
        ServerHttpRequest mutated = request.mutate()
                .header("X-User-Id", String.valueOf(userId))   // 覆盖,而非追加
                .build();

        // 4. 把改写后的请求传给下游
        return chain.filter(exchange.mutate().request(mutated).build());
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        String body = "{"code":40101,"message":"未登录或登录已过期"}";
        DataBuffer buffer = response.bufferFactory()
                .wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    // Ordered 越小优先级越高,鉴权要最先执行
    @Override
    public int getOrder() {
        return -100;
    }
}

下游服务(如 order-service)通过一个拦截器读取
X-User-Id,装进
ThreadLocal,业务代码直接取用:

package com.example.order.context;

// 用户上下文:从网关透传的 Header 里取 userId,存到 ThreadLocal
public class UserContext {
    private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();

    public static void setUserId(Long userId) { USER_ID.set(userId); }
    public static Long getUserId() { return USER_ID.get(); }
    public static void clear() { USER_ID.remove(); }   // 必须 remove,防止线程池复用导致串号
}
package com.example.order.interceptor;

import com.example.order.context.UserContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

// 下游拦截器:读取网关透传的 X-User-Id,写入 ThreadLocal
@Component
public class UserContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) {
        String userId = request.getHeader("X-User-Id");
        if (userId != null) {
            UserContext.setUserId(Long.parseLong(userId));
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        UserContext.clear();   // 请求结束必须清理,否则线程池复用会串用户
    }
}

这一整套「网关解析 JWT → 覆盖 X-User-Id → 下游拦截器读 Header 进
ThreadLocal」的链路,是生产环境的标准姿势。它的价值在于:鉴权只做一次、身份只信网关、下游只读透传头

5.5
网关层限流(RequestRateLimiter)

网关是全站入口,第一道限流应该在这里做。Spring Cloud Gateway 内置
RequestRateLimiter 过滤器,基于 Redis 令牌桶实现:

spring:
  cloud:
    gateway:
      routes:
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10    # 每秒补充 10 个令牌(即 QPS=10)
                redis-rate-limiter.burstCapacity: 20     # 桶容量 20,允许突发
                key-resolver: "#{@userKeyResolver}"      # 按用户维度限流的 Key 解析器
package com.example.gateway.config;

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

@Configuration
public class RateLimitConfig {

    // 按用户限流:每个 userId 独立限流,而非全局限流(防止单个用户刷爆)
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
            return Mono.just(userId == null ? "anonymous" : userId);
        };
    }
}

网关限流依赖 Redis,需引入
spring-boot-starter-data-redis-reactive。注意网关限流和
Sentinel
限流的分工:网关限流做「粗粒度」的第一道拦截(按用户、按
IP),Sentinel
做「细粒度」的服务内限流(按接口、按热点参数)
,两层配合。

5.5.1 跨域与自定义日志过滤器

跨域
CORS
。前端页面和网关往往不同源,跨域要在网关统一处理,而不是每个服务各配一份:

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowed-origins: "https://www.example.com"   # 生产填具体域名,不要用 *
            allowed-methods: "GET,POST,PUT,DELETE"
            allowed-headers: "*"
            allow-credentials: true

自定义日志过滤器。生产上,你需要在网关记录「谁在什么时间访问了什么接口、花了多久、返回了什么状态码」,这是审计和排错的基础数据。用
GlobalFilter 实现:

package com.example.gateway.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
@Slf4j
public class AccessLogFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        long start = System.currentTimeMillis();
        String path = request.getURI().getPath();
        String userId = request.getHeaders().getFirst("X-User-Id");

        // 请求完成后记录耗时(doFinally 无论成功失败都会执行)
        return chain.filter(exchange).doFinally(signalType -> {
            long cost = System.currentTimeMillis() - start;
            log.info("访问日志:path={}, userId={}, status={}, cost={}ms",
                    path, userId, exchange.getResponse().getStatusCode(), cost);
        });
    }

    // 日志过滤器要在鉴权之后执行(此时 X-User-Id 已就位),Ordered 值比鉴权大
    @Override
    public int getOrder() {
        return 0;
    }
}

这里有个细节:日志里用 doFinally 而不是
then,因为 then
只在正常完成时执行,doFinally
在异常、取消、超时等所有结束情况下都会执行,能保证每条请求都有日志。

本章小结:网关是统一入口,把路由、鉴权、限流收口到一处;鉴权必须在网关用
JWT 解析出可信 userId 再「覆盖」透传头,下游绝不能信任客户端自带的
Header。


第 6 章 Sentinel
熔断降级限流

6.1 三大能力分别解决什么问题

很多人分不清「限流、熔断、降级」这三个词,其实它们解决的是三个不同层次的问题:

能力 解决的问题 触发场景 手段
限流(Flow) 流量洪峰打垮服务 QPS 超过阈值 直接拒绝 / 排队等待
熔断(Degrade) 下游持续故障拖垮上游 慢调用比例 / 异常比例超阈值 打开熔断,快速失败
降级(Fallback) 非核心功能在故障时的兜底 限流或熔断触发后 返回兜底结果

用一句话区分:限流管「来的太多」,熔断管「坏得太久」,降级管「坏了给个兜底」。熔断触发后往往会走降级逻辑,所以二者经常一起出现。

6.2
依赖与最小可用:@SentinelResource

引入依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!-- 让 @SentinelResource 的注解生效(AOP 切面) -->
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-annotation-aspectj</artifactId>
</dependency>
spring:
  cloud:
    sentinel:
      transport:
        dashboard: ${SENTINEL_DASHBOARD:localhost:8858}   # 控制台地址
        port: 8719          # 客户端与控制台通信的本地端口
      eager: true           # 启动即注册到控制台,否则首次访问接口才出现

最小可用示例,用 @SentinelResource
标注资源,并区分两种兜底方法:

package com.example.stock.service;

import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.example.common.ErrorCode;
import com.example.common.BizException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class StockServiceImpl implements StockService {

    // value 是资源名(限流/熔断规则的锚点),必须全局唯一
    // blockHandler 处理「限流/熔断触发」的情况,fallback 处理「业务异常」的情况
    @SentinelResource(
            value = "deductStock",
            blockHandler = "deductStockBlock",
            fallback = "deductStockFallback"
    )
    @Override
    public boolean deductStock(Long productId, Integer quantity) {
        // 模拟扣库存:库存不足抛业务异常
        int affected = stockMapper.deduct(productId, quantity);
        if (affected == 0) {
            throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
        }
        return true;
    }

    // 限流/熔断触发时走这里:参数必须与原方法一致,末尾多一个 BlockException
    public boolean deductStockBlock(Long productId, Integer quantity, BlockException e) {
        log.warn("扣库存被限流/熔断,productId={}, quantity={}", productId, quantity);
        // 降级:返回 false 表示本次扣减未执行,让上游走「库存紧张」的提示
        return false;
    }

    // 业务异常(如库存不足抛 BizException)走这里:参数一致,末尾多一个 Throwable
    public boolean deductStockFallback(Long productId, Integer quantity, Throwable t) {
        log.error("扣库存业务异常", t);
        return false;
    }
}

这里要特别讲清 blockHandler 和 fallback
的区别
,这是自测题必考:

  • blockHandler:只在「限流规则命中 /
    熔断打开」时触发,参数签名 = 原方法参数 +
    BlockException
  • fallback:在「方法内部抛出任意异常」时触发,参数签名
    = 原方法参数 + Throwable
  • 两者可以同时存在,触发优先级是:先判断限流/熔断 → 命中走
    blockHandler;未命中进入方法 → 方法抛异常走 fallback

6.2.1 Feign
与 Sentinel 整合:给远程调用统一加限流降级

前面第 4 章用 fallbackFactory 给 Feign
做了降级,但那只处理「下游挂掉」的情况。如果下游没挂、只是流量太大,你想给「这个远程调用」限流,就需要
Feign 整合 Sentinel——开启后,每个 Feign 客户端会自动变成一个 Sentinel
资源,可以直接在控制台对它配置限流/熔断规则:

feign:
  sentinel:
    enabled: true   # 开启 Feign 的 Sentinel 整合

开启后,Feign 客户端生成的资源名默认是
接口类名#方法名(如
UserClient#getById(Long)),在 Sentinel
控制台就能看到并配置规则。这个整合的价值在于:限流和降级统一由
Sentinel 管理
,而不是降级走
fallbackFactory、限流另起一套,避免两套机制打架。

一个重要的注意点:Feign 整合 Sentinel
后,触发限流/熔断时会先走 fallbackFactory
的降级逻辑
(如果配置了的话),所以降级和限流的兜底可以统一。如果没配
fallbackFactory,才会抛出 BlockException
相关异常。

6.3 QPS 限流规则

规则可以在 Sentinel Dashboard
控制台配置,也可以用代码定义。生产推荐控制台配置 + Nacos
持久化
(见 6.5),但理解规则结构要从代码开始:

package com.example.stock.config;

import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

@Configuration
public class SentinelRuleConfig {

    @PostConstruct
    public void initFlowRules() {
        List<FlowRule> rules = new ArrayList<>();

        FlowRule rule = new FlowRule();
        rule.setResource("deductStock");        // 资源名,与 @SentinelResource 的 value 一致
        rule.setGrade(RuleConstant.FLOW_GRADE_QPS);   // 按 QPS 限流(而非线程数)
        rule.setCount(50);                      // 阈值:每秒最多 50 个请求
        rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT); // 超出直接拒绝
        rules.add(rule);

        FlowRuleManager.loadRules(rules);
    }
}

三种流控模式:直接(默认,单资源限流)、关联(A
资源超阈值时也限
B)、链路(只限某个入口链路)。三种流控效果:快速失败(直接拒绝,默认)、Warm
Up
(预热,防止冷启动被打爆)、排队等待(匀速排队,适合突发流量削峰)。

6.4 熔断规则(DegradeRule)

限流管「来得多」,熔断管「坏得久」。比如 user-service
的接口最近 50%
的请求都超时,与其让每个请求都等超时,不如「熔断」:直接快速失败,等它恢复。

DegradeRule degradeRule = new DegradeRule();
degradeRule.setResource("getUser");
degradeRule.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType()); // 慢调用比例
degradeRule.setCount(0.3);              // 慢调用比例阈值 30%
degradeRule.setSlowRatioThreshold(0.5); // 调用时间超过 500ms 视为慢调用
degradeRule.setMinRequestAmount(10);    // 最少 10 个请求才开始统计
degradeRule.setStatIntervalMs(10000);   // 统计窗口 10s
degradeRule.setTimeWindow(30);          // 熔断打开 30s 后进入半开状态试探
DegradeRuleManager.loadRules(List.of(degradeRule));

熔断三种策略:慢调用比例(SLOW_REQUEST_RATIO)、异常比例(ERROR_RATIO)、异常数(ERROR_COUNT)。熔断状态机是「关闭
→ 打开 → 半开 → 关闭」:打开期间所有请求直接失败;经过
timeWindow
后进入「半开」,放行少量请求试探,若成功则关闭,若仍失败则继续打开。

6.4.1 Sentinel Dashboard
控制台操作演示

代码能定义规则,但生产上规则经常需要运行时动态调整(大促前临时调低阈值),这时用
Sentinel Dashboard 可视化控制台更高效。完整操作步骤:

  1. 启动 Dashboard(见 3.2 节),并确保服务已配置
    spring.cloud.sentinel.transport.dashboard 指向它;
  2. 先访问一次被 @SentinelResource
    标注的接口(如扣库存),让资源出现在控制台;
  3. 打开 http://localhost:8858,左侧「簇点链路」里能看到
    deductStock 资源;
  4. 点击资源右侧的「流控」按钮,选择「QPS 模式」,阈值填
    50,流控效果选「快速失败」,点「新增」;
  5. 用压测工具(如
    wrk -t4 -c50 -d30s http://localhost:8083/api/stocks/deduct)打接口,观察实时
    QPS 与「通过/拒绝」的比例;
  6. 在「实时监控」里能看到限流生效的曲线。

注意:Dashboard
里配的规则默认存在内存里,服务重启就丢(这就是 6.5
要持久化的原因)。Dashboard
的定位是「运行时调试和临时调整」,持久化的权威规则仍以 Nacos 为准。

6.5 规则持久化到
Nacos(生产必知)

这是 Sentinel 最大的坑:Dashboard
里配的规则默认只存在内存里,服务一重启规则就全没了。生产必须把规则持久化到
Nacos,服务启动时从 Nacos 拉取规则。

引入 Sentinel 的 Nacos 数据源依赖:

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
spring:
  cloud:
    sentinel:
      datasource:
        flow:                             # 数据源名称,可自定义
          nacos:
            server-addr: ${NACOS_ADDR:localhost:8848}
            namespace: dev
            group-id: DEFAULT_GROUP
            data-id: ${spring.application.name}-flow-rules   # 规则文件 DataId
            data-type: json
            rule-type: flow               # 规则类型:flow/degrade/system/authority/param-flow
        degrade:
          nacos:
            server-addr: ${NACOS_ADDR:localhost:8848}
            namespace: dev
            group-id: DEFAULT_GROUP
            data-id: ${spring.application.name}-degrade-rules
            data-type: json
            rule-type: degrade

然后在 Nacos 控制台新建 DataId 为
stock-service-flow-rules 的配置,内容是 JSON
格式的限流规则:

[
  {
    "resource": "deductStock",
    "grade": 1,
    "count": 50,
    "controlBehavior": 0,
    "clusterMode": false
  }
]

这样,重启服务后规则会从 Nacos
自动加载,再也不会丢。这条链路的意义在于:规则的「定义」在
Nacos(持久、可版本管理、可回滚),「执行」在
Sentinel(实时、高效)

6.6
热点参数限流与系统规则(进阶)

热点参数限流:普通 QPS
限流针对「整个资源」,热点参数限流能针对「某个具体参数值」单独限流。典型场景:productId=1001
是爆款商品,秒杀时它的流量远超其他商品,你只想限制它,而不想限制整个扣库存接口。

import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRuleManager;

// 热点参数限流规则
ParamFlowRule rule = new ParamFlowRule("deductStock")
        .setParamIdx(0)             // 针对第 0 个参数(productId)
        .setCount(10);              // 该参数值的 QPS 阈值
// 针对特定值单独设阈值:productId=1001 只允许 5 QPS
rule.setParamFlowItemList(java.util.List.of(
        new ParamFlowItem().setObject("1001").setClassType(long.class.getName()).setCount(5)
));
ParamFlowRuleManager.loadRules(java.util.List.of(rule));

注意:@SentinelResourcevalue
必须与热点规则的资源名一致,且热点参数限流的参数索引
paramIdx 从 0 开始,对应方法签名里的参数顺序。

系统保护规则(SystemRule):从「单个资源」上升到「整机」维度,是保护
JVM 的最后一道防线。当整机负载过高时,自动拒绝入口流量,防止雪崩:

import com.alibaba.csp.sentinel.slots.system.SystemRule;
import com.alibaba.csp.sentinel.slots.system.SystemRuleManager;

SystemRule rule = new SystemRule();
rule.setHighestSystemLoad(4.0);      // 系统 load 超过 4.0 时触发保护
rule.setHighestCpuUsage(0.8);        // CPU 使用率超过 80% 时触发
rule.setAvgRt(500);                  // 平均 RT 超过 500ms 时触发
rule.setMaxThread(200);              // 并发线程超过 200 时触发
SystemRuleManager.loadRules(java.util.List.of(rule));

系统规则的保护维度可以多个同时生效,任一命中即触发限流。它的意义在于:单资源限流防不住「整体过载」——比如
100 个接口每个都卡在各自阈值内,但加起来已经把机器 CPU
打满了,这时只有系统规则能从全局兜底。

本章小结:限流管流量、熔断管故障、降级管兜底;@SentinelResource
用 blockHandler 接限流熔断、用 fallback 接业务异常;规则必须持久化到
Nacos,否则重启即丢。


第 7 章 链路追踪与分布式事务

7.1 为什么需要链路追踪

单体时代,一次请求在一个进程里,查日志按时间戳就能定位。微服务时代,一次下单请求要经过「网关
→ 订单服务 → 用户服务 + 库存服务」,可能横跨 4 个进程、3
台机器。当用户反馈「下单很慢」,你根本不知道卡在哪一环——这就是「分布式调试的地狱」。

链路追踪(Tracing)解决这个问题,核心是两个概念:

  • TraceId:标识一次完整请求,跨所有服务保持不变;
  • SpanId:标识调用链上的一个片段(一次本地调用或远程调用)。

只要每个服务把「TraceId + SpanId + 耗时 +
结果」上报到同一个追踪系统,你就能还原出整条调用链,一眼看出「哪一跳最慢、哪一跳报错」。

7.2
方案选型:SkyWalking vs Micrometer + Zipkin

维度 SkyWalking Micrometer + Zipkin
侵入性 无侵入(Java Agent 探针,不改代码) 有侵入(引入依赖 + 埋点配置)
采集内容 调用链 + 指标 + 日志关联,自动采集 主要采集调用链,指标需另配
部署复杂度 较重(OAP + UI + 存储) 较轻(Zipkin Server)
适用场景 生产级全链路观测,推荐 轻量级、快速验证

生产推荐 SkyWalking:因为它用 Java Agent
挂载,业务代码零改动,且自动采集、自动埋点,对调用链的还原度极高。下面两种都讲,但以
SkyWalking 为主。

7.3 SkyWalking:零侵入接入

下载 SkyWalking,配置 Agent,然后给服务 JVM
加启动参数即可,一行业务代码都不用改

# 1. 下载并启动 SkyWalking(OAP + UI 一体,默认 8080 是 UI,11800 是 gRPC 采集端口)
wget https://archive.apache.org/dist/skywalking/9.7.0/apache-skywalking-apm-9.7.0.tar.gz
tar -zxvf apache-skywalking-apm-9.7.0.tar.gz
cd apache-skywalking-apm-bin/bin
sh oapService.sh        # 启动 OAP 采集服务
sh webappService.sh     # 启动 UI,访问 http://localhost:8080

# 2. 启动业务服务时挂 Agent
java -javaagent:/path/to/skywalking-agent/skywalking-agent.jar 
     -Dskywalking.agent.service_name=order-service 
     -Dskywalking.collector.backend_service=localhost:11800 
     -jar order-service.jar

每个服务用不同的
service_name
、指向同一个 OAP 后端,SkyWalking
就能自动把跨服务的调用串成一条链。之后在 UI 上输入一个
TraceId,就能看到完整调用链和每一跳的耗时。

SkyWalking 的 UI 使用要点:启动后访问
http://localhost:8080,在「追踪」页面按 TraceId 或按服务名
+
时间范围查询,能看到一次请求的完整调用链拓扑图和每一跳的耗时、状态。点击任意一跳还能下钻到该服务在该时刻的日志。生产上排错的标准姿势是:用户报障
→ 拿到订单号/用户 ID 关联到 TraceId → 在 SkyWalking 里搜 TraceId →
定位最慢或报错的那一跳 → 下钻到对应服务日志

7.3.1 Micrometer +
Zipkin:轻量可选的方案

如果不想部署 SkyWalking 那么重的体系,可以用 Spring Boot 3.x
原生整合的 Micrometer Tracing 把调用链上报到 Zipkin。引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management:
  tracing:
    sampling:
      probability: 1.0        # 采样率,生产建议 0.1~0.5,避免海量数据
  zipkin:
    tracing:
      endpoint: http://localhost:9411/api/v2/spans   # Zipkin 上报地址

Micrometer Tracing 会自动生成 TraceId 并在服务间通过
X-B3-TraceId 等 Header 传播。它的优势是轻量、和 Spring Boot
原生集成,劣势是埋点粒度不如 SkyWalking 丰富,且需要自己部署 Zipkin
Server。选型建议:快速验证用
Micrometer+Zipkin,上生产用 SkyWalking。

7.4 TraceId 的贯穿与传播

SkyWalking 无侵入,TraceId 由 Agent 自动生成和传递(通过 HTTP Header
sw8
在服务间传播)。但如果你想在业务日志里也打印
TraceId(把日志和调用链关联起来),需要一点配置。这正好呼应本篇公共类
ApiResult 里的 traceId 字段:

package com.example.common.trace;

import org.slf4j.MDC;

// TraceId 工具:从 MDC 取,取不到就生成一个,保证每处日志都能关联
public class TraceContext {

    public static final String TRACE_ID_KEY = "traceId";

    public static String getTraceId() {
        String traceId = MDC.get(TRACE_ID_KEY);
        return traceId == null ? "N/A" : traceId;
    }

    public static void setTraceId(String traceId) {
        MDC.put(TRACE_ID_KEY, traceId);
    }

    public static void clear() {
        MDC.remove(TRACE_ID_KEY);
    }
}

在日志格式里加上 %X{traceId},所有日志就自动带上
TraceId:

logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{50} - %msg%n"

配合 Micrometer Tracing(Brave/Zipkin 桥接),可以用一个 Filter
在请求入口统一把上游透传的 TraceId 写入 MDC,实现「日志 +
调用链」双向关联。完整的透传姿势与第 5
章的用户信息透传是同一个套路:入口接收 → 写 ThreadLocal/MDC →
出口清理

7.5
分布式事务:为什么本地事务失效了

单体里,@Transactional 保证「下单 +
扣库存」在同一个数据库事务里,要么都成功要么都回滚。微服务里,下单在
order-service 的库,扣库存在 stock-service
的库,两个事务跨越两个服务、两个数据库,本地
@Transactional
管不到对方——这就是分布式事务要解决的问题。

order-service:  BEGIN -> 插入订单 -> 调用 stock-service -> COMMIT
                              │
stock-service:              BEGIN -> 扣库存 -> COMMIT

如果「插入订单」成功、「扣库存」失败,本地事务只能回滚订单,但扣库存那边的状态已经独立提交了,两边数据就「脏」了。

7.6 Seata:AT / TCC / Saga
三种模式

Seata
是阿里开源的分布式事务解决方案,核心思路是引入一个事务协调器(TC),让参与事务的各个服务(TM/RM)统一听它指挥。三种模式对比如下:

模式 侵入性 性能 适用场景
AT 无侵入(自动生成回滚日志) 基于关系型数据库、不需要改业务的通用场景,首选
TCC 强侵入(业务要写 Try/Confirm/Cancel) 对性能要求极高、资源能预留的场景(资金、库存)
Saga 中侵入(业务要写正向 + 补偿) 长流程、跨多个异构系统的场景

AT 模式原理(最常用,必须理解):它基于「本地事务 +
全局锁 + 回滚日志」三步——

  1. 一阶段:各服务正常执行本地事务,同时把「变更前数据」和「变更后数据」记录成
    undo log,一并提交;
  2. 若全局提交:异步删除 undo log,结束;
  3. 若全局回滚:TC 通知各服务,根据 undo log 生成反向
    SQL
    (把删除的数据插回去、把插入的数据删掉),实现自动回滚。

AT
的优点是业务代码零侵入,缺点是依赖数据库、有全局锁带来的性能开销。

TCC
模式
:业务自己实现三个方法——Try(预留资源,如冻结库存)、Confirm(确认提交,真正扣减)、Cancel(取消,释放预留)。性能好但业务侵入强,适合资金、库存这种对一致性要求极高的场景。

Saga
模式
:把长事务拆成多个本地事务,每个本地事务配一个「补偿操作」,正向失败时逆序执行补偿。适合流程长、跨多个异构系统的场景(如跨境支付)。

Saga 和 TCC 的区别在于:TCC
是「先预留再确认」的两阶段,资源在 Try
阶段就被冻结,一致性更强;Saga
是「直接执行,失败再补偿」的正向+补偿,没有预留阶段,性能更好但中间状态会短暂暴露(比如库存已经扣了,后续步骤失败再补回来)。所以
Saga 适合「补偿容易、对中间状态不敏感」的长流程。

7.7 Seata AT 模式接入

引入依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

全局事务的发起方(这里是下单逻辑所在的
order-service)在方法上加
@GlobalTransactional

package com.example.order.service;

import java.util.Map;

import io.seata.spring.annotation.GlobalTransactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {

    private final StockClient stockClient;

    // @GlobalTransactional:把「本地事务」升级为「全局事务」
    // 该方法内所有远程调用参与的服务,都纳入同一个分布式事务
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    @Override
    public Long createOrder(CreateOrderDTO dto) {
        // 1. 本地事务:插入订单
        Order order = insertOrder(dto);
        // 2. 远程调用:扣库存(stock-service 内部执行自己的本地事务,受 TC 协调)
        ApiResult<Boolean> result = stockClient.deductStock(Map.of("productId", dto.getProductId(), "quantity", dto.getQuantity()));
        if (result.getCode() != 0 || !Boolean.TRUE.equals(result.getData())) {
            // 抛异常会触发全局回滚:订单插入会被回滚,库存扣减也会被回滚
            throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
        }
        return order.getId();
    }
}

关键点:参与方(stock-service)的业务代码完全不用改,只要它的事务由
Seata 的 DataSourceProxy 代理(配置好 Seata
后自动完成),TC 就能协调它的提交与回滚。这就是 AT
模式「无侵入」的含义。

配置 Seata 连接 Nacos
注册中心和配置中心(application.yml):

seata:
  registry:
    type: nacos
    nacos:
      server-addr: ${NACOS_ADDR:localhost:8848}
      namespace: dev
      group: SEATA_GROUP        # 事务分组,与服务端 tc 配置对应
  config:
    type: nacos
    nacos:
      server-addr: ${NACOS_ADDR:localhost:8848}
      namespace: dev
      group: SEATA_GROUP

Seata Server(TC)需要单独部署,用 Nacos
作为它的注册中心和配置中心,各服务通过 SEATA_GROUP 找到
TC。

7.7.1 AT
模式的回滚日志原理(看懂它才算真懂 Seata)

AT 模式「无侵入」的关键,是它在执行本地事务时,自动生成一份
undo log(回滚日志),记录每条 SQL
的「变更前镜像」和「变更后镜像」。以上面的「扣库存」为例:

-- 业务执行的 SQL
UPDATE t_stock SET available = available - 2 WHERE product_id = 1001;

Seata 代理了数据源,在执行这条 SQL 前先查一次变更前数据(比如
available=100),执行后再查一次变更后数据(available=98),然后把两个镜像存进
undo log:

-- Seata 自动写入的回滚日志(示意)
INSERT INTO undo_log (branch_id, xid, before_image, after_image, ...) VALUES (
  123, '192.168.1.10:8091:202608141200001',
  '{"available":100}',      -- before_image:变更前
  '{"available":98}'        -- after_image:变更后
);

如果全局事务需要回滚,TC 通知各分支根据 before_image 生成反向
SQL
恢复数据:

-- 反向 SQL:把 available 改回 100
UPDATE t_stock SET available = 100 WHERE product_id = 1001;

这就是 AT 模式的精髓:业务代码不用写任何回滚逻辑,Seata
通过「记录镜像 + 反向 SQL」自动回滚
。它的代价是需要一张
undo_log 表(Seata
建表脚本提供)和全局锁带来的性能开销。理解了「镜像 + 反向
SQL」,你就理解了一半的分布式事务。

7.7.2 TCC
模式:业务自己写 Try / Confirm / Cancel

TCC
模式不依赖数据库回滚日志,而是让业务自己实现三个方法,性能更好但侵入性强。以「冻结库存」为例:

public interface StockTccService {

    // Try:预留资源(冻结库存,不真正扣减)
    boolean tryFreeze(Long productId, Integer quantity);

    // Confirm:确认提交(真正扣减之前冻结的量)
    boolean confirmFreeze(Long productId, Integer quantity);

    // Cancel:取消(释放之前冻结的量)
    boolean cancelFreeze(Long productId, Integer quantity);
}

整个 TCC 的流程是:全局事务发起方先调用所有参与方的 Try
预留资源 → 全部 Try 成功则依次 Confirm 提交 → 任一失败则对已 Try
成功的参与方逆序 Cancel
释放资源
。它的适用场景是「资源可以预留」的业务,比如库存冻结、资金冻结。TCC
的难点在于三个方法都要幂等(网络重试可能重复调用)和允许空回滚/悬挂(处理并发和异常时序),所以一般只在资金、库存这种核心高价值场景使用。

7.8
分布式事务的兜底:本地消息表 + 最终一致性

Seata
解决「强一致性」诉求,但生产上很多场景并不需要那么强的一致性,用消息队列
+ 本地消息表
实现最终一致性(BASE
理论的落地)成本更低、可用性更高。思路是:

  1. 下单服务本地事务里,同时写入「订单表」和「本地消息表」(同库同事务,强一致);
  2. 后台定时任务扫描「本地消息表」中未发送的消息,投递到 MQ;
  3. 库存服务消费消息扣库存,成功后回执;失败则重试,直到成功。

这套方案在第 6
阶段(消息队列)已经铺垫过,本质是「用最终一致性换可用性」,和 Seata
的「强一致性」形成互补:核心链路用
Seata,非核心链路用消息最终一致性

本地消息表的核心代码片段(下单时「订单表 +
消息表」同库同事务写入):

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl {

    private final OrderMapper orderMapper;
    private final LocalMsgMapper localMsgMapper;

    // 本地事务:订单和消息同库写入,保证「有订单必有消息」
    @Transactional
    public Long createOrder(CreateOrderDTO dto) {
        Order order = buildOrder(dto);
        orderMapper.insert(order);

        // 写入本地消息表,状态=待发送
        LocalMsg msg = new LocalMsg();
        msg.setMsgId(UUID.randomUUID().toString());
        msg.setTopic("stock-deduct");
        msg.setPayload(JSON.toJSONString(dto));   // 扣库存的消息体
        msg.setStatus(0);                          // 0=待发送
        localMsgMapper.insert(msg);

        return order.getId();
    }
}
// 定时任务:扫描「待发送」消息投递到 MQ,投递成功改状态
@Scheduled(fixedDelay = 5000)
public void sendLocalMsg() {
    List<LocalMsg> msgs = localMsgMapper.selectByStatus(0);   // 查待发送
    for (LocalMsg msg : msgs) {
        try {
            rocketMQTemplate.syncSend(msg.getTopic(), msg.getPayload());
            msg.setStatus(1);                       // 1=已发送
            localMsgMapper.updateById(msg);
        } catch (Exception e) {
            log.error("消息投递失败,msgId={},稍后重试", msg.getMsgId(), e);
            // 失败不更新状态,下次定时任务继续重试
        }
    }
}

这套方案的可靠性来自三个点:订单和消息同库同事务(不会出现「有订单没消息」)、定时扫描
+
失败重试
(消息不丢)、消费端幂等(重复消费靠唯一
msgId 去重)。它不需要 Seata
那种全局锁,可用性更高,代价是「扣库存」是异步的、有延迟,所以只适合「能接受短暂不一致」的非核心链路。

本章小结:链路追踪靠 TraceId 贯穿全链路,SkyWalking
零侵入、生产首选;分布式事务中 Seata AT 模式无侵入、首选,TCC
高性能但侵入强,Saga 适合长流程,而非核心链路用消息最终一致性兜底。


5.
生产级实战项目:用户 + 订单 + 库存微服务集群

本实战项目把前面 7
章的知识点全部串起来,构成一个可运行的完整集群。完整链路:

POST http://localhost:8080/api/orders            (网关入口)
        │
        │ 1. AuthGlobalFilter 校验 JWT,覆盖透传 X-User-Id
        │ 2. RequestRateLimiter 按用户限流
        ▼
   order-service:8082
        │  @GlobalTransactional 全局事务
        ├── Feign 调用 user-service 查用户(fallbackFactory 降级)
        └── Feign 调用 stock-service 扣库存(@SentinelResource 限流 + 降级)

5.1 父 POM(统一版本管理)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>microservice-demo</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>common</module>
        <module>user-service</module>
        <module>order-service</module>
        <module>stock-service</module>
        <module>gateway</module>
    </modules>

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2023.0.1</spring-cloud.version>
        <spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version>
        <mybatis-plus.version>3.5.7</mybatis-plus.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
                <version>${mybatis-plus.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

5.2 公共模块
common:统一响应体与异常体系

公共类定义与全系列逐字一致,微服务场景下补充了
SERVICE_UNAVAILABLE 错误码和
TraceContext

com.example.common.ApiResult

import lombok.Data;
import org.slf4j.MDC;

@Data
public class ApiResult<T> {
    private int code;        // 0=成功,非 0=错误码
    private String message;  // 提示信息
    private T data;          // 业务数据
    private String traceId;  // 链路追踪 id

    public static <T> ApiResult<T> ok(T data) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(ErrorCode.SUCCESS.getCode());
        r.setMessage(ErrorCode.SUCCESS.getMessage());
        r.setData(data);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> ok() {
        return ok(null);
    }
    public static <T> ApiResult<T> fail(int code, String message) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(code);
        r.setMessage(message);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> fail(ErrorCode ec) {
        return fail(ec.getCode(), ec.getMessage());
    }
}

com.example.common.ErrorCode

package com.example.common;

public enum ErrorCode {
    SUCCESS(0, "success"),
    PARAM_ERROR(40001, "参数错误"),
    UNAUTHORIZED(40101, "未登录或登录已过期"),
    FORBIDDEN(40301, "无权限访问"),
    USER_NOT_FOUND(40401, "用户不存在"),
    ORDER_NOT_FOUND(40403, "订单不存在"),
    STOCK_NOT_ENOUGH(50002, "库存不足"),
    SERVICE_UNAVAILABLE(50301, "服务暂不可用,请稍后重试"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
    ;

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }
}

com.example.common.BizException

package com.example.common;

// 统一业务异常:业务代码里主动抛它,全局异常处理器统一转成 ApiResult
public class BizException extends RuntimeException {
    private final int code;

    public BizException(ErrorCode ec) {
        super(ec.getMessage());
        this.code = ec.getCode();
    }

    public BizException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() { return code; }
}

com.example.common.GlobalExceptionHandler

package com.example.common;

import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// 全局异常处理器:所有服务复用,兜底异常只返回模糊提示,详细堆栈只进日志
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 业务异常:返回业务错误码
    @ExceptionHandler(BizException.class)
    public ApiResult<Void> handleBizException(BizException e) {
        log.warn("业务异常:code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常:返回具体字段错误
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Void> handleValidException(MethodArgumentNotValidException e) {
        FieldError fieldError = e.getBindingResult().getFieldError();
        String msg = fieldError == null ? "参数错误" : fieldError.getDefaultMessage();
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
    }

    // 兜底异常:对外模糊,堆栈只进日志
    @ExceptionHandler(Exception.class)
    public ApiResult<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
    }
}

com.example.common.trace.TraceContext

package com.example.common.trace;

import org.slf4j.MDC;

// TraceId 工具:写日志时从 MDC 取,实现「日志 + 调用链」关联
public class TraceContext {

    public static final String TRACE_ID_KEY = "traceId";

    public static String getTraceId() {
        String traceId = MDC.get(TRACE_ID_KEY);
        return traceId == null ? "N/A" : traceId;
    }

    public static void setTraceId(String traceId) {
        MDC.put(TRACE_ID_KEY, traceId);
    }

    public static void clear() {
        MDC.remove(TRACE_ID_KEY);
    }
}

com.example.common.vo.UserVO(跨服务传输的用户视图对象,只含必要字段):

package com.example.common.vo;

import lombok.Data;

// 用户视图对象:跨服务传输时裁剪字段,不暴露敏感信息(密码等)
@Data
public class UserVO {
    private Long id;
    private String username;
    private String nickname;
    private String phone;
}

5.3
user-service(用户服务,端口 8081)

pom.xml
依赖:commonnacos-discoverynacos-configwebmybatis-plus-spring-boot3-startermysql-connector-j

application.yml

spring:
  application:
    name: user-service
  datasource:
    url: jdbc:mysql://localhost:3306/db_user?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  cloud:
    nacos:
      discovery:
        server-addr: ${NACOS_ADDR:localhost:8848}
        namespace: dev
      config:
        server-addr: ${NACOS_ADDR:localhost:8848}
        file-extension: yaml
        namespace: dev
  config:
    import:
      - optional:nacos:user-service.yaml
server:
  port: 8081

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true

User 实体与 UserMapper

package com.example.user.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("t_user")
public class User {
    @TableId
    private Long id;
    private String username;
    private String nickname;
    private String phone;
    private String email;
    private String password;   // 密文存储,出参时用 UserVO 裁剪掉
}
package com.example.user.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.user.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

UserService 与实现:

package com.example.user.service;

import com.example.user.entity.User;

public interface UserService {
    User getById(Long id);
}
package com.example.user.service.impl;

import com.example.user.entity.User;
import com.example.user.mapper.UserMapper;
import com.example.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

    private final UserMapper userMapper;

    @Override
    public User getById(Long id) {
        // 实际生产中这里应接 Redis 缓存,避免每次远程调用都打数据库
        User user = userMapper.selectById(id);
        log.info("查询用户,id={}, 命中={}", id, user != null);
        return user;
    }
}

UserController

package com.example.user.controller;

import com.example.common.ApiResult;
import com.example.common.ErrorCode;
import com.example.common.vo.UserVO;
import com.example.user.entity.User;
import com.example.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {

    private final UserService userService;

    @GetMapping("/{id}")
    public ApiResult<UserVO> getById(@PathVariable("id") Long id) {
        User user = userService.getById(id);
        if (user == null) {
            return ApiResult.fail(ErrorCode.USER_NOT_FOUND);
        }
        // Entity -> VO,裁剪掉 password 等敏感字段
        UserVO vo = new UserVO();
        BeanUtils.copyProperties(user, vo);
        return ApiResult.ok(vo);
    }
}

5.4
stock-service(库存服务,端口 8083)

application.yml 与 user-service
类似,spring.application.name=stock-service,端口
8083,数据库 db_stock,额外引入
sentinelsentinel-annotation-aspectjsentinel-datasource-nacosseata

Stock 实体与 DeductStockDTO

package com.example.stock.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("t_stock")
public class Stock {
    @TableId
    private Long id;
    private Long productId;      // 商品 ID
    private Integer total;       // 总库存
    private Integer available;   // 可用库存(扣减对象)
}
package com.example.stock.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

// 扣库存入参:带参数校验,防止负数/空值
@Data
public class DeductStockDTO {
    @NotNull(message = "productId 不能为空")
    private Long productId;

    @NotNull(message = "quantity 不能为空")
    @Min(value = 1, message = "quantity 至少为 1")
    private Integer quantity;
}

StockController 提供扣库存接口:

package com.example.stock.controller;

import com.example.common.ApiResult;
import com.example.stock.dto.DeductStockDTO;
import com.example.stock.service.StockService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/stocks")
@RequiredArgsConstructor
@Slf4j
public class StockController {

    private final StockService stockService;

    @PostMapping("/deduct")
    public ApiResult<Boolean> deduct(@Valid @RequestBody DeductStockDTO dto) {
        boolean ok = stockService.deductStock(dto.getProductId(), dto.getQuantity());
        return ApiResult.ok(ok);
    }
}

StockService 实现(含 Sentinel 限流 + 降级,完整版见第 6
章,此处复用):

package com.example.stock.service.impl;

import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.example.common.BizException;
import com.example.common.ErrorCode;
import com.example.stock.entity.Stock;
import com.example.stock.mapper.StockMapper;
import com.example.stock.service.StockService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
public class StockServiceImpl implements StockService {

    private final StockMapper stockMapper;

    @SentinelResource(
            value = "deductStock",
            blockHandler = "deductStockBlock",
            fallback = "deductStockFallback"
    )
    @Transactional
    @Override
    public boolean deductStock(Long productId, Integer quantity) {
        // 扣库存用条件更新,防止并发超卖(乐观锁思想)
        int affected = stockMapper.deduct(productId, quantity);
        if (affected == 0) {
            throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
        }
        return true;
    }

    public boolean deductStockBlock(Long productId, Integer quantity, BlockException e) {
        log.warn("扣库存被限流/熔断,productId={}", productId);
        return false;
    }

    public boolean deductStockFallback(Long productId, Integer quantity, Throwable t) {
        log.error("扣库存业务异常", t);
        return false;
    }
}

StockMapper 的条件扣减 SQL(防超卖的关键):

package com.example.stock.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.stock.entity.Stock;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

@Mapper
public interface StockMapper extends BaseMapper<Stock> {

    // 条件更新:仅当 available >= quantity 时才扣减,返回影响行数
    // 影响行数为 0 说明库存不足,天然防超卖
    @Update("UPDATE t_stock SET available = available - #{quantity} " +
            "WHERE product_id = #{productId} AND available >= #{quantity}")
    int deduct(@Param("productId") Long productId, @Param("quantity") Integer quantity);
}

5.5
order-service(订单服务,端口 8082)

这是集群的核心,负责「下单」这一跨服务事务的编排。

pom.xml
额外引入:openfeignloadbalancersentinelsentinel-datasource-nacosseata

application.yml

spring:
  application:
    name: order-service
  datasource:
    url: jdbc:mysql://localhost:3306/db_order?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  cloud:
    nacos:
      discovery:
        server-addr: ${NACOS_ADDR:localhost:8848}
        namespace: dev
      config:
        server-addr: ${NACOS_ADDR:localhost:8848}
        file-extension: yaml
        namespace: dev
  config:
    import:
      - optional:nacos:order-service.yaml
server:
  port: 8082

seata:
  registry:
    type: nacos
    nacos:
      server-addr: ${NACOS_ADDR:localhost:8848}
      namespace: dev
      group: SEATA_GROUP
  config:
    type: nacos
    nacos:
      server-addr: ${NACOS_ADDR:localhost:8848}
      namespace: dev
      group: SEATA_GROUP

Feign 客户端(UserClient
StockClient,含降级工厂):

package com.example.order.client;

import com.example.common.ApiResult;
import com.example.common.vo.UserVO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(
        name = "user-service",
        fallbackFactory = UserClientFallbackFactory.class,
        configuration = FeignConfig.class
)
public interface UserClient {

    @GetMapping("/api/users/{id}")
    ApiResult<UserVO> getById(@PathVariable("id") Long id);
}
package com.example.order.client;

import com.example.common.ApiResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import java.util.Map;

@FeignClient(
        name = "stock-service",
        fallbackFactory = StockClientFallbackFactory.class,
        configuration = FeignConfig.class
)
public interface StockClient {

    @PostMapping("/api/stocks/deduct")
    ApiResult<Boolean> deductStock(@RequestBody Map<String, Object> body);
}

UserClientFallbackFactory(第 4
章已给完整版,此处复用),StockClientFallbackFactory
同理:

package com.example.order.client;

import com.example.common.ApiResult;
import com.example.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
@Slf4j
public class StockClientFallbackFactory implements FallbackFactory<StockClient> {

    @Override
    public StockClient create(Throwable cause) {
        log.error("调用 stock-service 失败,触发降级", cause);
        return body -> ApiResult.fail(ErrorCode.SERVICE_UNAVAILABLE);
    }
}

核心的下单服务(含 @GlobalTransactional):

package com.example.order.service.impl;

import com.example.common.ApiResult;
import com.example.common.BizException;
import com.example.common.ErrorCode;
import com.example.common.vo.UserVO;
import com.example.order.client.StockClient;
import com.example.order.client.UserClient;
import com.example.order.dto.CreateOrderDTO;
import com.example.order.entity.Order;
import com.example.order.mapper.OrderMapper;
import com.example.order.service.OrderService;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {

    private final OrderMapper orderMapper;
    private final UserClient userClient;
    private final StockClient stockClient;

    // 全局事务:下单 + 扣库存跨服务,任何一个失败都整体回滚
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    @Override
    public Long createOrder(CreateOrderDTO dto) {
        // 1. 远程调用用户服务校验用户存在(失败会走 fallbackFactory 降级)
        ApiResult<UserVO> userResult = userClient.getById(dto.getUserId());
        if (userResult.getCode() != 0 || userResult.getData() == null) {
            throw new BizException(userResult.getCode(), userResult.getMessage());
        }

        // 2. 本地事务:插入订单
        Order order = new Order();
        order.setOrderNo(generateOrderNo());
        order.setUserId(dto.getUserId());
        order.setProductId(dto.getProductId());
        order.setQuantity(dto.getQuantity());
        order.setAmount(dto.getAmount());
        order.setStatus(0);   // 0=待支付
        order.setCreateTime(LocalDateTime.now());
        orderMapper.insert(order);

        // 3. 远程调用库存服务扣库存
        Map<String, Object> body = new HashMap<>();
        body.put("productId", dto.getProductId());
        body.put("quantity", dto.getQuantity());
        ApiResult<Boolean> stockResult = stockClient.deductStock(body);
        if (stockResult.getCode() != 0 || !Boolean.TRUE.equals(stockResult.getData())) {
            // 抛异常触发全局回滚:订单和库存一起回滚
            throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
        }

        log.info("下单成功,orderNo={}", order.getOrderNo());
        return order.getId();
    }

    private String generateOrderNo() {
        // 订单号:时间戳 + 随机数,全局唯一,用于幂等去重
        return "ORD" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 6);
    }
}

OrderController

package com.example.order.controller;

import com.example.common.ApiResult;
import com.example.order.dto.CreateOrderDTO;
import com.example.order.service.OrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
@Slf4j
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ApiResult<Long> create(@Valid @RequestBody CreateOrderDTO dto) {
        return ApiResult.ok(orderService.createOrder(dto));
    }
}

CreateOrderDTO(带参数校验):

package com.example.order.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class CreateOrderDTO {

    @NotNull(message = "userId 不能为空")
    private Long userId;

    @NotNull(message = "productId 不能为空")
    private Long productId;

    @NotNull(message = "quantity 不能为空")
    @Min(value = 1, message = "quantity 至少为 1")
    private Integer quantity;

    @NotNull(message = "amount 不能为空")
    @Min(value = 0, message = "amount 不能为负数")
    private java.math.BigDecimal amount;
}

Order 实体与 OrderMapper

package com.example.order.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@TableName("t_order")
public class Order {
    @TableId
    private Long id;
    private String orderNo;        // 订单号,全局唯一,用于幂等
    private Long userId;           // 用户 ID(来自网关透传的 X-User-Id)
    private Long productId;        // 商品 ID
    private Integer quantity;      // 数量
    private BigDecimal amount;     // 金额
    private Integer status;        // 0=待支付 1=已支付 2=已取消
    private LocalDateTime createTime;
}
package com.example.order.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.order.entity.Order;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}

5.6 gateway(网关,端口 8080)

application.yml(路由 + 限流,第 5
章已给完整配置),核心代码是 AuthGlobalFilter(鉴权 +
透传,第 5 章完整版)和
RateLimitConfig(按用户限流)。网关还需要一个简化版
JwtUtil

package com.example.gateway.filter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;
import java.util.Date;

// 简化版 JWT 工具:生产环境应复用阶段 5 的完整 JwtUtil(含过期刷新、黑名单等)
public class JwtUtil {

    private static final String SECRET = "change-me-to-a-long-random-secret-key-please";
    private static final long EXPIRE_MS = 24 * 60 * 60 * 1000L;   // 24 小时
    private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes());

    public static String createToken(Long userId) {
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .expiration(new Date(System.currentTimeMillis() + EXPIRE_MS))
                .signWith(KEY)
                .compact();
    }

    public static Long parseUserId(String token) {
        try {
            Claims claims = Jwts.parser()
                    .verifyWith(KEY)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
            return Long.parseLong(claims.getSubject());
        } catch (Exception e) {
            // 解析失败(过期/篡改/格式错误)返回 null,调用方判断为未登录
            return null;
        }
    }
}

5.6.1 数据库初始化 SQL

三个服务各自独立建库建表(db_user /
db_order /
db_stock),这是「数据库按服务拆分」的体现——服务之间不共享表,只能通过网络调用交换数据:

-- db_user 库
CREATE TABLE t_user (
    id       BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64)  NOT NULL,
    nickname VARCHAR(64),
    phone    VARCHAR(20),
    email    VARCHAR(128),
    password VARCHAR(128) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- db_order 库
CREATE TABLE t_order (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no    VARCHAR(64)    NOT NULL,
    user_id     BIGINT         NOT NULL,
    product_id  BIGINT         NOT NULL,
    quantity    INT            NOT NULL,
    amount      DECIMAL(10,2)  NOT NULL,
    status      INT            NOT NULL DEFAULT 0,
    create_time DATETIME       NOT NULL,
    UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- db_stock 库
CREATE TABLE t_stock (
    id         BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    total      INT    NOT NULL,
    available  INT    NOT NULL,
    UNIQUE KEY uk_product_id (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- db_stock 库:Seata AT 模式的 undo_log 表(用 Seata 官方建表脚本)
-- 参与全局事务的每个业务库都要建这张表,否则全局回滚会失败
CREATE TABLE undo_log (
    branch_id     BIGINT       NOT NULL,
    xid           VARCHAR(128) NOT NULL,
    context       VARCHAR(128) NOT NULL,
    rollback_info LONGBLOB     NOT NULL,
    log_status    INT          NOT NULL,
    log_created   DATETIME     NOT NULL,
    log_modified  DATETIME     NOT NULL,
    PRIMARY KEY (branch_id),
    KEY idx_undo_log_xid (xid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化数据
INSERT INTO t_user (id, username, nickname, phone, email, password) VALUES
  (1, 'zhangsan', '张三', '13800000001', 'zhangsan@example.com', '$2a$10$xxx');
INSERT INTO t_stock (product_id, total, available) VALUES (1001, 1000, 1000);

注意 t_order 上的唯一索引
uk_order_no:它是幂等的关键,配合 Feign
关闭重试、消息去重,能防止重复下单。

5.7 运行步骤

# 1. 启动基础设施(Nacos、Sentinel Dashboard、MySQL、Redis、Seata Server、SkyWalking)
#    见 3.2 节与第 7 章

# 2. 在 Nacos 控制台创建 namespace=dev,并新建配置:
#    user-service.yaml / order-service.yaml / stock-service.yaml
#    以及 Sentinel 规则文件 stock-service-flow-rules(见 6.5 节)

# 3. 按顺序启动服务(先在父工程 mvn install 装 common)
mvn -pl common install
mvn -pl user-service spring-boot:run     # 8081
mvn -pl stock-service spring-boot:run    # 8083
mvn -pl order-service spring-boot:run    # 8082
mvn -pl gateway spring-boot:run          # 8080

# 4. 验证链路
# 4.1 先拿一个 token(生产用阶段 5 的登录接口,这里用 JwtUtil 生成)
# 4.2 通过网关下单
curl -X POST http://localhost:8080/api/orders 
  -H "Authorization: Bearer <token>" 
  -H "Content-Type: application/json" 
  -d '{"userId":1,"productId":1001,"quantity":2,"amount":199.00}'
# 预期返回:{"code":0,"message":"success","data":<订单id>,"traceId":"..."}

# 4.3 直连下游(无 token)应被网关拦截返回 401
curl -X POST http://localhost:8080/api/orders -H "Content-Type: application/json" -d '{}'
# 预期返回:{"code":40101,"message":"未登录或登录已过期"}

验证要点:

  1. 注册发现:三个服务启动后都能在 Nacos
    控制台看到;
  2. 配置热更新:改 user-service.yaml 里的
    app.name,访问 /api/config/app-name
    看是否刷新;
  3. 降级:停掉
    stock-service,再次下单,应返回
    code=50301 服务暂不可用(而非 500 或超时);
  4. 限流:用压测工具(如 wrk
    ab)打扣库存接口,QPS 超过 50 后应看到降级返回;
  5. 分布式事务:把库存改成 0 再下单,应返回
    库存不足,且订单表里不应残留脏数据(全局回滚成功)。

6. 常见坑与排错指南

坑/现象 原因 解决方案
Feign 调用报 Read timed out 历史版本默认 1s 超时;当前版本默认 60s
但未显式配置,慢接口被误杀或线程池占满
显式配置 Request.Options,连接 3s、读取 5s,并配合
Sentinel 熔断做真正的快速失败
Feign 默认重试导致重复下单/重复扣库存 Retryer.Default 对连接异常自动重试 5
次,写操作不幂等
写操作关闭重试
Retryer.NEVER_RETRY,或让下游接口幂等(唯一订单号去重)
下游挂了,上游线程堆积、服务雪崩 远程调用无降级,异常直接抛出、线程池被打满 fallbackFactory 降级返回兜底结果,并记录
Throwable 日志
Sentinel 规则重启后丢失 规则默认只存内存,服务重启即清空 sentinel-datasource-nacos 把规则持久化到 Nacos
网关透传的用户 ID 被客户端伪造 下游直接信任客户端自带的 X-User-Id Header 网关用 JWT 解析出可信 userId 后「覆盖」Header,下游只信网关
网关启动报 Spring MVC found on classpath 网关基于 WebFlux,与 spring-boot-starter-web 冲突 移除网关模块的 spring-boot-starter-web 依赖
Nacos 配置改了不生效 未加 @RefreshScope,或
spring.config.import 的 DataId 写错
给读配置的 Bean 加 @RefreshScope,核对 DataId =
应用名.后缀
服务读到「空配置」或拉不到实例 namespace 填的是「显示名称」而非「命名空间 ID」 在 Nacos 控制台复制「命名空间 ID」填入 namespace
多实例定时任务重复执行 每个实例都跑同一份定时任务,数据被重复处理 用分布式锁(Redis)或 XXL-Job 这类分布式调度框架
拆分过细导致运维复杂度爆炸 微服务边界划分不合理,网络调用链过长 按业务边界拆分,先单体后演进,避免「为微服务而微服务」

8. 总结与延伸阅读

8.1 本篇总结

这一篇你完成了一次完整的「架构跃迁」:从单体走向了微服务。你理解了
CAP 定理这个分布式系统的第一性原理——它告诉你「分布式必须取舍」,而 BASE
理论告诉你「用最终一致性换可用性」;你用 Nacos
解决了「服务怎么找到彼此」和「配置怎么统一管理」两个基础问题;用
OpenFeign 把远程调用变得像本地调用,并用 fallbackFactory
给调用加上了「失败兜底」;用 Gateway
把鉴权、路由、限流收口到统一入口,并学会了「网关解析身份、下游只信透传头」的生产级鉴权姿势;用
Sentinel
的限流、熔断、降级三层防护扛住了流量洪峰和下游故障;最后用链路追踪和
Seata
解决了「出问题怎么定位」和「跨服务怎么保持一致」这两个最难的问题。

记住一个贯穿全篇的认知:微服务不是银弹。它用「分布式复杂度」换「独立部署和弹性」,拆分的边界、降级的完备、规则的持久化、鉴权的收口,每一个细节都决定了这套架构在生产上是「救命」还是「添乱」。

8.2 延伸阅读

  1. Spring Cloud Alibaba 官方文档https://sca.aliyun.com ——
    各组件版本兼容矩阵与最佳实践的第一手资料;
  2. Nacos 官方文档https://nacos.io ——
    注册中心与配置中心的完整配置项说明;
  3. Sentinel 官方文档https://sentinelguard.io ——
    限流/熔断/降级的规则详解与热点参数限流;
  4. Seata 官方文档https://seata.io —— AT/TCC/Saga
    三种模式的完整原理与示例;
  5. 《凤凰架构》(周志明):https://icyfenix.cn ——
    分布式架构、可靠性工程与可观测性的系统性读物。

本教程为「Spring Boot 教程系列」阶段
7,遵循全系列统一写作规范。下一阶段(阶段 8)将进入容器化与部署(Docker
/ Kubernetes),把这套微服务集群真正跑上生产。

正文完
 0
评论(没有评论)