diff --git a/notes/pics/instance-down.png b/notes/pics/instance-down.png index 7a7567f9..eec91712 100644 Binary files a/notes/pics/instance-down.png and b/notes/pics/instance-down.png differ diff --git a/notes/pics/seata-at.png b/notes/pics/seata-at.png new file mode 100644 index 00000000..1b281756 Binary files /dev/null and b/notes/pics/seata-at.png differ diff --git a/notes/微服务.md b/notes/微服务.md index 9f5c4f8c..b9f1ce98 100644 --- a/notes/微服务.md +++ b/notes/微服务.md @@ -18,6 +18,12 @@ * [七、服务保护与分布式事务](#七服务保护与分布式事务) * [八、链路追踪](#八链路追踪) * [九、灰度发布](#九灰度发布) + * [附录:实测](#附录实测) + * [A1 实验环境](#a1-实验环境) + * [A2 实例下线](#a2-实例下线) + * [A3 注册中心宕机](#a3-注册中心宕机) + * [A4 链路追踪](#a4-链路追踪) + * [A5 调用失败时的重试与降级](#a5-调用失败时的重试与降级) * [参考资料](#参考资料) @@ -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 @@ -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-实例下线)):

@@ -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: + +

+ +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)