阶段 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 个人时,单体应用的三个致命问题会集中爆发:
- 改一行代码要整体重新部署:下单功能要发版,登录功能被迫跟着一起停服;
- 一处内存泄漏拖垮全站:秒杀接口
OOM,整个应用连带登录、支付一起挂掉; - 无法按模块扩缩容:真正吃 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
模块被所有服务依赖,用来共享
ApiResult、ErrorCode、BizException
这些公共类。
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
微服务不是银弹:拆分的代价与边界原则
在动手拆分前,你必须清楚微服务的代价,否则很容易「为了微服务而微服务」:
- 网络开销与不确定性:本地方法调用是纳秒级,网络调用是毫秒级,还叠加了超时、重试、丢包这些不确定性。原来一个事务里的两步操作,拆开后变成了两次可能失败的远程调用;
- 分布式一致性问题:本地
@Transactional
失效了,跨服务一致性要引入 Seata 或消息最终一致性,复杂度陡增(第 7
章会展开); - 运维复杂度爆炸:从「部署 1 个服务」变成「部署 N
个服务 + 注册中心 + 网关 + 配置中心 + 链路追踪 +
分布式事务协调器」,DevOps 能力跟不上就是灾难; - 调试与排错成本:一个 Bug 可能跨 4
个服务,没有链路追踪(第 7 章)根本没法定位。
判断「要不要拆」的三个信号:
- 团队规模:单个服务超过 2
个团队在同时改,合并冲突频繁 → 该拆; - 扩缩容诉求:只有某个模块是性能瓶颈,但被迫整机扩容
→ 该拆; - 故障隔离诉求:某个模块一出问题就全站宕机,无法隔离
→ 该拆。
拆分的第一原则是按业务边界(限界上下文)拆分,而不是按技术层次拆(比如「所有
Controller 一个服务、所有 Service
一个服务」这种就是错的)。「用户、订单、库存」就是三个清晰的业务边界,它们各自有独立的数据库、独立的团队、独立的生命周期。本篇的
Demo 也严格按这个边界来拆。
1.5 一次请求的完整旅程
理解了组件矩阵,再看一次「下单」请求的完整链路,你会对整个微服务协作方式豁然开朗:
浏览器 ──> Gateway(8080) ──鉴权、路由、限流──> order-service(8082)
│
Feign 远程调用 ├──> user-service(8081) 查用户
└──> stock-service(8083) 扣库存
- 请求先到 Gateway,网关做统一鉴权(校验 JWT),并把
X-User-Id透传到下游; - Gateway 按
Path=/api/orders/**路由到
order-service,负载均衡选一个实例; order-service处理下单逻辑,通过 Feign 调用
user-service查用户、stock-service
扣库存;- 若
stock-service挂了,Feign
的降级工厂兜底返回友好错误,不会拖垮订单服务; - 全程每个服务从 Nacos 拉取实例列表与配置,Sentinel 在入口做 QPS
限流,TraceId 在服务间传递。
本章小结:CAP 告诉我们分布式必须取舍,BASE
告诉我们怎么取舍——用最终一致性换可用性;组件全景图告诉我们微服务不是拆服务,而是一整套治理体系的组合。
第 2 章 注册中心
Nacos(服务注册与发现)
2.1 服务注册与发现的原理
单体时代,A 调用 B 直接写死 IP 就行。微服务时代,B
可能随时扩容、缩容、重启,IP
一直在变。注册中心解决的就是「调用方怎么动态知道被调用方在哪」这个问题,核心是三件事:
- 注册:服务启动时,把自己的
服务名 + IP + 端口 + 健康状态
上报给注册中心(Nacos); - 发现:调用方启动时,从注册中心拉取目标服务的实例列表,并订阅变更通知;
- 健康检查:注册中心通过心跳/探活,把不健康的实例及时从列表剔除。
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 表示用默认线程池
}
});
}
}
@RefreshScope 和 addListener
的分工:前者是「被动刷新」——配置变了,下次读 Bean
自动是新值;后者是「主动响应」——配置变了,立刻执行一段自定义逻辑。大多数场景
@RefreshScope 就够,需要「重建资源」时用
addListener。
3.4 多环境隔离:namespace +
group
配置中心最怕的就是「串环境」。Nacos 用两级来隔离:
- namespace(命名空间):物理级隔离,每个 namespace
有独立 ID,不同 namespace
的配置和注册实例完全隔离。典型用法:dev/test
/prod三个 namespace; - group(分组):同一 namespace
内的逻辑隔离,比如同一环境里「交易组」「营销组」各自维护自己的配置。
生产规范是「namespace 隔离环境 + group
隔离业务线」。服务通过
spring.cloud.nacos.config.namespace 和 group
定位到自己的配置。还要特别注意: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-service 和 stock-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 放进自定义的
UserContext(ThreadLocal 或
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;
}
}
这里有两个生产级决策,值得展开:
- 为什么关闭 Feign 重试:Feign 默认的
Retryer.Default会对「连接异常」自动重试最多 5
次。对查询类接口这没问题,但对「下单」「扣库存」这种非幂等写操作,重试会导致重复扣减。所以要么关闭重试,要么保证下游接口幂等(用唯一订单号去重)。 - 超时和重试要配合 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 # 按时间匹配(可做活动页面定时上下线)
常用 Filter(filters:
下,可叠加多个):
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/1。lb://
前缀是关键:它告诉网关「按服务名去 Nacos
查实例列表,再负载均衡转发」。
5.4 统一鉴权 +
用户信息透传(生产关键)
这是本章的重点,也是生产上最容易做错的地方。核心诉求有两步:
- 鉴权:在网关校验请求头里的 JWT,无效直接返回
401,不把无效请求放行到下游; - 透传:鉴权通过后,把解析出的
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 可视化控制台更高效。完整操作步骤:
- 启动 Dashboard(见 3.2 节),并确保服务已配置
spring.cloud.sentinel.transport.dashboard指向它; - 先访问一次被
@SentinelResource
标注的接口(如扣库存),让资源出现在控制台; - 打开
http://localhost:8858,左侧「簇点链路」里能看到
deductStock资源; - 点击资源右侧的「流控」按钮,选择「QPS 模式」,阈值填
50,流控效果选「快速失败」,点「新增」; - 用压测工具(如
wrk -t4 -c50 -d30s http://localhost:8083/api/stocks/deduct)打接口,观察实时
QPS 与「通过/拒绝」的比例; - 在「实时监控」里能看到限流生效的曲线。
注意: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));
注意:@SentinelResource 的 value
必须与热点规则的资源名一致,且热点参数限流的参数索引
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 模式原理(最常用,必须理解):它基于「本地事务 +
全局锁 + 回滚日志」三步——
- 一阶段:各服务正常执行本地事务,同时把「变更前数据」和「变更后数据」记录成
undo log,一并提交; - 若全局提交:异步删除 undo log,结束;
- 若全局回滚: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
理论的落地)成本更低、可用性更高。思路是:
- 下单服务本地事务里,同时写入「订单表」和「本地消息表」(同库同事务,强一致);
- 后台定时任务扫描「本地消息表」中未发送的消息,投递到 MQ;
- 库存服务消费消息扣库存,成功后回执;失败则重试,直到成功。
这套方案在第 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
依赖:common、nacos-discovery、nacos-config、web、mybatis-plus-spring-boot3-starter、mysql-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,额外引入
sentinel、sentinel-annotation-aspectj、sentinel-datasource-nacos、seata。
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
额外引入:openfeign、loadbalancer、sentinel、sentinel-datasource-nacos、seata。
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":"未登录或登录已过期"}
验证要点:
- 注册发现:三个服务启动后都能在 Nacos
控制台看到; - 配置热更新:改
user-service.yaml里的
app.name,访问/api/config/app-name
看是否刷新; - 降级:停掉
stock-service,再次下单,应返回
code=50301 服务暂不可用(而非 500 或超时); - 限流:用压测工具(如
wrk或
ab)打扣库存接口,QPS 超过 50 后应看到降级返回; - 分布式事务:把库存改成 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 延伸阅读
- Spring Cloud Alibaba 官方文档:https://sca.aliyun.com ——
各组件版本兼容矩阵与最佳实践的第一手资料; - Nacos 官方文档:https://nacos.io ——
注册中心与配置中心的完整配置项说明; - Sentinel 官方文档:https://sentinelguard.io ——
限流/熔断/降级的规则详解与热点参数限流; - Seata 官方文档:https://seata.io —— AT/TCC/Saga
三种模式的完整原理与示例; - 《凤凰架构》(周志明):https://icyfenix.cn ——
分布式架构、可靠性工程与可观测性的系统性读物。
本教程为「Spring Boot 教程系列」阶段
7,遵循全系列统一写作规范。下一阶段(阶段 8)将进入容器化与部署(Docker
/ Kubernetes),把这套微服务集群真正跑上生产。