Update 微服务.md

新增附录实测章节与 Seata AT 配图,重新绘制实例下线配图;补充 OpenFeign 重试与降级说明,调整注册中心对比细节并进行文字校对。
This commit is contained in:
CyC2018
2026-10-30 20:19:11 +08:00
parent 1be35f1ecd
commit eb6686b22f
3 changed files with 121 additions and 14 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+121 -14
View File
@@ -18,6 +18,12 @@
* [七、服务保护与分布式事务](#七服务保护与分布式事务)
* [八、链路追踪](#八链路追踪)
* [九、灰度发布](#九灰度发布)
* [附录:实测](#附录实测)
* [A1 实验环境](#a1-实验环境)
* [A2 实例下线](#a2-实例下线)
* [A3 注册中心宕机](#a3-注册中心宕机)
* [A4 链路追踪](#a4-链路追踪)
* [A5 调用失败时的重试与降级](#a5-调用失败时的重试与降级)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -26,6 +32,8 @@
一个商城最初通常是单体应用:用户、商品、库存、订单、支付的代码在同一个工程中,部署成一个进程,共用一个数据库。微服务指的是把系统按业务拆成多个独立部署的服务,每个服务有自己的数据库,服务之间通过网络调用。例如下单时,订单服务要通过网络调用库存服务扣减库存,而不是调用同一个进程中的方法。
微服务与分布式的关系:分布式只说明系统由多台机器上的进程协作完成,例如一个应用部署多个实例;微服务是分布式系统的一种架构方式,进一步要求按业务拆分、独立部署、各自维护数据。
拆分带来的好处:
- 独立部署:修改库存服务只需重新发布库存服务;
@@ -127,11 +135,14 @@ Spring Cloud 以发布列车(release train)的方式发版,版本号由年
| Spring Cloud | Spring Boot |
| --- | --- |
| 2025.1(Oakwood) | 4.0、4.1 |
| 2025.0(Northfields) | 3.5 |
| 2024.0(Moorgate) | 3.4 |
| 2023.0(Leyton) | 3.2、3.3 |
Spring Cloud Alibaba 的版本号前缀与 Spring Cloud 一致,例如 2023.0.3.4 对应 Spring Cloud 2023.0。为了避免组件之间的版本不匹配,通常在父工程中导入 Spring Cloud 和 Spring Cloud Alibaba 的 BOM,子模块引入依赖时不再写版本号。本文的例子使用的组合如下,版本的对应关系以官方最新的说明为准:
截至 2026 年 10 月,2025.0 及更早的版本已经结束开源支持。
Spring Cloud Alibaba 的版本号前缀与 Spring Cloud 一致,例如 2023.0.3.4 对应 Spring Cloud 2023.0。为了避免组件之间的版本不匹配,通常在父工程中导入 Spring Cloud 和 Spring Cloud Alibaba 的 BOM,子模块引入依赖时不再写版本号。本文的例子使用的组合如下(实验环境见[附录 A1](#a1-实验环境)),版本的对应关系以官方最新的说明为准:
```xml
<parent>
@@ -176,9 +187,13 @@ weight 用于按权重分配流量;metadata 可以存放版本号等自定义
Nacos 2.x 的客户端与服务端之间保持一个 gRPC 长连接,端口为主端口加 1000(默认 9848)。临时实例依附于这个连接,连接断开,服务端就删除这个实例。持久化实例保存在服务端,客户端断开后不会被删除,只会被标记为不健康,通常由运维通过控制台或者接口维护。
Nacos 与 Eureka 的区别:Eureka 的客户端每 30 秒发送一次心跳,服务端 90 秒没有收到心跳才删除实例,客户端也是每 30 秒拉取一次实例列表的变化;Nacos 2.x 依靠长连接发现客户端断开,并把实例的变化推送给订阅者,本文实测进程退出后 0.6 秒就删除了实例。另外 Eureka 只是注册中心,Nacos 同时也是配置中心。
Nacos 与 Eureka 的区别:
Nacos 与 ZooKeeper 的区别:Nacos 的临时实例使用自研的 Distro 协议,每个节点都保存全部数据,网络分区时仍然可以返回实例列表,属于 AP;ZooKeeper 是 CP 的,只有多数节点可用时才能提供服务。服务发现可以容忍短暂的数据不一致,却不能接受注册中心不可用,所以更适合 AP。
- 发现实例下线:Eureka 的客户端每 30 秒发送一次心跳,租约默认 90 秒过期(实现上实际约 180 秒),短时间内大量实例续约失败时还会进入自我保护,暂停删除实例。Nacos 2.x 依靠长连接发现客户端断开,本文实测进程退出后 0.6 秒就删除了实例(见[附录 A2](#a2-实例下线));
- 通知调用方:Eureka 的客户端每 30 秒拉取一次实例列表的变化,Nacos 把实例的变化推送给订阅者;
- 功能:Eureka 只是注册中心,Nacos 同时也是配置中心。
Nacos 与 ZooKeeper 的区别:Nacos 的临时实例使用自研的 Distro 协议,每个节点都保存全部数据,网络分区时仍然可以返回实例列表,属于 [AP](分布式.md#权衡)(持久化实例使用 Raft,属于 CP);ZooKeeper 是 CP 的,只有多数节点可用时才能提供服务。服务发现可以容忍短暂的数据不一致,却不能接受注册中心不可用,所以更适合 AP。
## 四、服务调用
@@ -231,6 +246,13 @@ spring:
read-timeout: 2000
```
OpenFeign 默认不重试,调用失败时异常直接抛给业务代码,[实例下线](#实例下线后多久能感知)后、负载均衡缓存过期前,选中这个实例的调用都会失败。常见的做法是再加两层(见[附录 A5](#a5-调用失败时的重试与降级)):
- 重试:引入 spring-retry 后,GET 请求遇到连接失败、读取超时这类异常时,Spring Cloud LoadBalancer 默认换一个实例再试一次,POST 不重试。扣库存这类写操作要带上请求 ID、由服务方去重后才能重试,见 [超时与重试](分布式.md#超时与重试);
- 降级:开启 spring.cloud.openfeign.circuitbreaker.enabled 并引入熔断器的实现(例如 Resilience4j),在 @FeignClient 的 fallback 中指定一个实现同一接口的 Bean,调用失败或者熔断时返回它给出的默认值,例如“库存查询失败时先显示有货”。
Spring Cloud OpenFeign 已经只做维护、不再增加新功能。新项目也可以用 Spring Framework 6 的 HTTP 接口客户端(@HttpExchange),用标注了 @LoadBalanced 的 RestClient.Builder 构建 RestClient,再由 HttpServiceProxyFactory 生成接口的代理,同样按服务名调用。
### 负载均衡策略
Spring Cloud LoadBalancer 默认使用 RoundRobinLoadBalancer 轮询,还提供 RandomLoadBalancer 随机选择。早期的 Ribbon 通过 IRule 接口配置策略,已经在 2020.0 版本中移除。
@@ -268,7 +290,7 @@ public class RandomConfiguration {
### 实例下线后多久能感知
注册中心删除实例之后,调用方并不会立刻停止调用它。下面让订单服务每 0.2 秒调用一次库存服务,再用三种方式停掉 8082:
注册中心删除实例之后,调用方并不会立刻停止调用它。下面让订单服务每 0.2 秒调用一次库存服务,再用三种方式停掉 8082(见[附录 A2](#a2-实例下线)):
<div align="center"> <img src="pics/instance-down.png" width="740px"/> </div><br>
@@ -288,7 +310,7 @@ public class RandomConfiguration {
停掉 Nacos 后:
- 已经运行的订单服务仍然可以调用库存服务,因为实例列表已经缓存在本地;
- 新启动的订单服务默认无法启动:spring.cloud.nacos.discovery.fail-fast 默认为 true,注册失败时直接报错退出。把它设为 false 后可以启动,配置也能从本地快照读取,但调用库存服务时报错 No servers available for service: inventory-service,因为新进程从没有拿到过实例列表。
- 新启动的订单服务默认无法启动:spring.cloud.nacos.discovery.fail-fast 默认为 true,注册失败时直接报错退出。把它设为 false 后可以启动,配置也能从本地快照读取,但调用库存服务时报错 No servers available for service: inventory-service,因为新进程从没有拿到过实例列表(见[附录 A3](#a3-注册中心宕机))。
所以注册中心宕机不会立刻影响线上的调用,但扩容和发布都无法进行,生产环境中 Nacos 要以集群方式部署。
@@ -315,7 +337,9 @@ spring:
- StripPrefix=1 # 去掉 /api,转发为 /orders/**
```
鉴权通常放在网关:网关校验令牌(例如 [JWT](HTTP.md#11-jwt)),取出用户 ID 放进请求头,下游服务直接使用这个请求头,不再重复校验:
这是 2023.0 的写法,2025.0 起 starter 改名为 spring-cloud-starter-gateway-server-webflux,配置前缀改为 spring.cloud.gateway.server.webflux。
鉴权通常放在网关:网关校验令牌(例如 [JWT](HTTP.md#11-jwt)),取出用户 ID 放进请求首部,下游服务直接使用这个首部,不再重复校验:
```java
@Bean
@@ -335,13 +359,13 @@ GlobalFilter auth() {
实测不带令牌或者令牌错误时,网关返回 401;带上正确的令牌时,订单服务收到 X-User-Id: 1024。需要注意两点:
- 用 set 而不是 add:客户端自己带上 X-User-Id: 1 时会被覆盖,实测订单服务收到的仍然是 1024;
- 下游服务信任网关传来的请求头,所以这些服务本身不能对外暴露,只能通过网关访问。
- 下游服务信任网关传来的首部,所以这些服务本身不能对外暴露,只能通过网关访问。
网关与 Nginx 并不冲突:Nginx 通常在最外层负责 TLS 和多个网关实例之间的负载均衡,网关在它后面按服务路由,并处理鉴权这类与业务相关的逻辑。
## 六、配置中心
每个服务有多个实例,如果配置写在各自的配置文件中,修改一个参数就要改所有实例并重启。配置中心集中保存配置,修改后推送给运行中的实例。
每个服务有多个实例,如果配置写在各自的配置文件中,修改一个参数就要改所有实例并重启。配置中心集中保存配置,修改后推送给运行中的实例。放在配置中心的通常是各环境不同或需要运行时调整的配置,例如数据库地址、功能开关、限流阈值。
订单服务从 Nacos 读取名为 order-service.yaml 的配置:
@@ -360,7 +384,9 @@ class OrderController {
}
```
在 Nacos 中把 order.max-items 从 5 改为 8,306 ms 后订单服务就读到了新值,不需要重启。配置变化后 Nacos 主动通知客户端,而不是等客户端定时来查询;@RefreshScope 的 Bean 是一个代理,配置刷新时缓存的实例被丢弃,下次调用时用新的配置重新创建。
在 Nacos 中把 order.max-items 从 5 改为 8,306 ms 后订单服务就读到了新值,不需要重启。
这是因为配置变化后,Nacos 2.x 通过 gRPC 长连接推送一条变更通知(只说明哪个配置变了),客户端再查询新的内容;@RefreshScope 的 Bean 是一个代理,配置刷新时缓存的实例被丢弃,下次调用时用新的配置重新创建。
Spring Cloud 2020.0 起默认不再加载 bootstrap.yml,配置中心的配置改为通过上面的 spring.config.import 导入。
@@ -368,7 +394,9 @@ Spring Cloud 2020.0 起默认不再加载 bootstrap.yml,配置中心的配置
## 七、服务保护与分布式事务
服务之间调用越多,一个服务变慢就越容易拖垮调用它的服务:上面卡住的库存实例让每次调用等待 60 秒,订单服务的线程会被逐渐占满。限流、降级和熔断的原理见 [限流、降级与熔断](系统设计基础.md#限流降级与熔断),Spring Cloud Alibaba 中常用 Sentinel 实现:把接口或者一段代码定义为资源,再为资源配置规则。熔断规则可以按慢调用比例、异常比例或者异常数触发,熔断时长过后进入半开状态,放行一个请求试探,成功就结束熔断,失败就继续熔断。
服务之间调用越多,一个服务变慢就越容易拖垮调用它的服务:上面卡住的库存实例让每次调用等待 60 秒,订单服务的线程会被逐渐占满。限流、降级和熔断的原理见 [限流、降级与熔断](系统设计基础.md#限流降级与熔断)。
Spring Cloud Alibaba 中常用 Sentinel 实现:把接口或者一段代码定义为资源,再为资源配置规则。熔断规则可以按慢调用比例、异常比例或者异常数触发,熔断时长过后进入半开状态,放行一个请求试探,成功就结束熔断,失败就继续熔断。
跨服务的数据一致性见 [分布式事务](分布式.md#二分布式事务)。Seata 的 AT 模式对业务代码的改动最少,以下单时订单服务创建订单、库存服务扣减库存为例:
@@ -376,18 +404,27 @@ Spring Cloud 2020.0 起默认不再加载 bootstrap.yml,配置中心的配置
2. 全局事务成功时,第二阶段异步删除 undo_log;
3. 全局事务失败时,第二阶段按 undo_log 中修改前的值生成反向的 SQL,例如库存服务把扣掉的库存加回去。
库存服务中的一个分支事务如下图,商品 10086 的库存从 100 扣成 98:
<div align="center"> <img src="pics/seata-at.png" width="760px"/> </div><br>
AT 与 XA 两阶段提交的区别在于锁持有的时间:XA 的第一阶段只准备、不提交,行锁一直持有到第二阶段;AT 的第一阶段就提交了本地事务、释放行锁,只靠全局锁阻止其它全局事务修改这些行,所以并发更高。代价有两个:
- 其它事务在第二阶段之前就能读到 98,即默认的全局隔离级别是读未提交,需要读已提交时用 SELECT ... FOR UPDATE 等待全局锁;
- 全局锁只对 Seata 管理的事务有效,其它代码直接改了这些行时,回滚前发现当前值与后镜像不一致,只能人工处理。
## 八、链路追踪
一个下单请求依次经过网关、订单服务和库存服务,出错时需要知道是哪一步出了错、每一步耗时多少。链路追踪为每个请求分配一个 traceId,调用下游时通过请求头传递,各个服务打印日志和上报耗时的时候都带上它。
一个下单请求依次经过网关、订单服务和库存服务,出错时需要知道是哪一步出了错、每一步耗时多少。链路追踪为每个请求分配一个 traceId,调用下游时通过 HTTP 首部传递,各个服务打印日志和上报耗时的时候都带上它。
在上面的服务中加入 Micrometer Tracing 后,同一次下单在订单服务和库存服务中打印的日志如下,方括号中依次是 traceId 和 spanId:
在上面的服务中加入 Micrometer Tracing 后,同一次下单在订单服务和库存服务中打印的日志如下,方括号中依次是 traceId 和 spanId(见[附录 A4](#a4-链路追踪)):
```text
[order-service] [6ade5947250103822ff3f13a5baab74b-2763eeac981ce4a4] 创建订单 userId=1024
[inventory-service] [6ade5947250103822ff3f13a5baab74b-92a95370047818fb] 扣减库存 skuId=10086
```
库存服务收到的请求头为:
库存服务收到的请求首部为:
```text
traceparent: 00-6ade5947250103822ff3f13a5baab74b-64990a2774676e11-01
@@ -403,10 +440,80 @@ traceparent 是 W3C Trace Context 规定的格式,四段依次是版本、trac
- 蓝绿发布:准备一整套新环境,验证后把流量一次切过去,回滚时切回即可,但需要双倍的资源;
- 灰度发布(金丝雀发布):先让一小部分流量进入新版本,观察错误率和耗时,没有问题再逐步扩大比例。
灰度发布可以借助注册中心和网关实现:新版本的实例注册时带上元数据 version=v2,或者注册为单独的服务名,网关按比例(Spring Cloud Gateway 的 Weight 断言)或者按用户、请求头把请求转发给新版本。发现问题时把新版本的比例调回 0 即可回滚。
灰度发布可以借助注册中心和网关实现。新版本注册为单独的服务名,例如 order-service-v2,网关为新旧服务各配一条路由,用 Spring Cloud Gateway 的 Weight 断言按 90%、10% 分配,或者按用户、请求首部选择路由;发现问题时把新版本的比例调回 0 即可回滚。新旧实例共用服务名、只在元数据中带上 version=v2 时,还要[自定义负载均衡](#负载均衡策略)按版本筛选实例。
需要注意的是,新旧版本会同时访问同一个数据库,表结构的修改必须同时兼容两个版本。例如先只增加新列,等旧版本全部下线后,再删除不再使用的旧列。
## 附录:实测
### A1 实验环境
Apple M1 Pro,macOS 15,JDK 21,Spring Boot 3.3.5、Spring Cloud 2023.0.3、Spring Cloud Alibaba 2023.0.3.4,Nacos 2.5.4 单机模式。所有服务运行在本机:网关 8080、订单服务 8090、库存服务 8081 和 8082,启动时用 --spring.cloud.nacos.discovery.ip=127.0.0.1 注册本机地址。正文中的调用次数、耗时都来自这套环境,每次运行的数字会略有差异。
### A2 实例下线
脚本先启动 8082,确认订单服务能调用到它之后,用指定的方式停止 8082;随后 60 秒内每 0.2 秒调用一次订单服务的下单接口(超时 3 秒),记下每次落在哪个端口、是否失败,同时另一个线程每 0.2 秒查询一次 Nacos,记下 8082 从实例列表中消失的时间:
```python
os.kill(pid, {"kill -9": signal.SIGKILL, "kill -STOP": signal.SIGSTOP, "SIGTERM": signal.SIGTERM}[mode])
while time.time() - t0 < 60:
s = time.time()
try:
r = json.loads(urlopen(Request("http://127.0.0.1:8090/orders", method="POST"), timeout=3).read())
out = r["inventoryPort"]
except Exception as e:
out = "超时" if "timed out" in str(e) else "失败"
events.append((round(s - t0, 1), out))
time.sleep(0.2)
# 查询 Nacos 的线程
hosts = json.loads(urlopen("http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=inventory-service").read())["hosts"]
```
“关闭负载均衡缓存,读取超时 2 秒”一列在订单服务启动时加上 spring.cloud.loadbalancer.cache.enabled=false 和 OpenFeign 的 read-timeout: 2000。统计结果见[实例下线后多久能感知](#实例下线后多久能感知)中的表格。
### A3 注册中心宕机
停掉 Nacos 后启动新的订单服务。默认配置下注册失败,启动中止:
```text
ERROR ... NacosServiceRegistry : nacos registry, order-service register failed...
com.alibaba.nacos.api.exception.NacosException: Client not connected, current status:STARTING
WARN ... Exception encountered during context initialization - cancelling refresh attempt
org.springframework.context.ApplicationContextException: Failed to start bean 'webServerStartStop'
```
加上 spring.cloud.nacos.discovery.fail-fast=false 后可以启动,调用库存服务时失败:
```text
WARN ... NacosServiceRegistry : Failfast is false. order-service register failed...
INFO ... Started OrderApp in 2.53 seconds (process running for 2.777)
WARN ... RoundRobinLoadBalancer : No servers available for service: inventory-service
WARN ... FeignBlockingLoadBalancerClient : Load balancer does not contain an instance for the service inventory-service
```
### A4 链路追踪
库存服务中打印 traceparent 首部,某一次运行的日志如下(traceId 与正文的例子不是同一次请求):
```text
[inventory-service] [6ade5f4e849a3ac47ccc3cdb7dd195a0-01b064dce8abb5e0] 扣减库存 skuId=10086,traceparent=00-6ade5f4e849a3ac47ccc3cdb7dd195a0-5f83fda61e8bee64-01
```
traceparent 中的 traceId 与日志中的相同,第三段 5f83fda61e8bee64 是订单服务发起调用的 span,库存服务的 spanId 01b064dce8abb5e0 是它自己生成的。
### A5 调用失败时的重试与降级
环境同 A1,不启动 Nacos:订单服务用静态实例列表配置 inventory-service 的两个实例,一个正常运行,另一个端口没有进程监听(连接被拒绝),并关闭负载均衡缓存。@FeignClient 加上 fallback = InventoryFallback.class,InventoryFallback 实现同一接口、返回默认值,并用 @Component 注册为 Bean。分别用 GET 查询库存和 POST 扣减库存连续调用 10 次:
```text
默认依赖 GET:成功 5,失败 5(RetryableException) POST:成功 5,失败 5
加上 spring-retry GET:成功 10 POST:成功 5,失败 5
再开启 openfeign.circuitbreaker GET:成功 10 POST:成功 5,降级 5
```
加上 spring-retry 后 POST 仍有 5 次失败,说明失败的 POST 没有换到正常的实例重试;库存服务共执行 15 次扣减,没有重复扣减。
## 参考资料
- [Spring Cloud](https://spring.io/projects/spring-cloud)