diff --git a/notes/pics/instance-down.png b/notes/pics/instance-down.png new file mode 100644 index 00000000..c4c75f0e Binary files /dev/null and b/notes/pics/instance-down.png differ diff --git a/notes/pics/microservice-overview.png b/notes/pics/microservice-overview.png new file mode 100644 index 00000000..b61e6093 Binary files /dev/null and b/notes/pics/microservice-overview.png differ diff --git a/notes/微服务.md b/notes/微服务.md new file mode 100644 index 00000000..a26d10c9 --- /dev/null +++ b/notes/微服务.md @@ -0,0 +1,278 @@ +# 微服务 + +* [微服务](#微服务) + * [一、从单体到微服务](#一从单体到微服务) + * [服务拆分](#服务拆分) + * [二、组件](#二组件) + * [三、注册中心](#三注册中心) + * [四、服务调用](#四服务调用) + * [OpenFeign](#openfeign) + * [实例下线后多久能感知](#实例下线后多久能感知) + * [注册中心宕机](#注册中心宕机) + * [五、网关](#五网关) + * [六、配置中心](#六配置中心) + * [七、服务保护与分布式事务](#七服务保护与分布式事务) + * [八、链路追踪](#八链路追踪) + * [九、灰度发布](#九灰度发布) + * [参考资料](#参考资料) + + + +## 一、从单体到微服务 + +一个商城最初通常是单体应用:用户、商品、库存、订单、支付的代码在同一个工程中,部署成一个进程,共用一个数据库。微服务指的是把系统按业务拆成多个独立部署的服务,每个服务有自己的数据库,服务之间通过网络调用。例如下单时,订单服务要通过网络调用库存服务扣减库存,而不是调用同一个进程中的方法。 + +拆分带来的好处: + +- 独立部署:修改库存服务只需重新发布库存服务; +- 独立扩容:大促时只给订单服务和库存服务增加实例; +- 故障隔离:支付服务出问题时,用户仍然可以浏览商品; +- 团队独立:每个团队负责几个服务,可以各自开发和发布。 + +代价同样明显: + +- 方法调用变成了网络调用,可能超时和失败,需要处理超时、重试和熔断; +- 一个数据库事务变成了跨服务的分布式事务; +- 一个请求经过多个服务,排查问题需要链路追踪; +- 服务和实例的数量增加,部署和监控的成本更高。 + +因此团队规模小、业务还在快速变化时,单体应用加上清晰的模块划分往往更合适;当各个模块的发布互相阻塞,或者某些模块需要单独扩容时,再考虑拆分。 + +### 服务拆分 + +拆分的依据是业务边界,而不是技术上的分层。判断方法是看数据:经常被一起修改、需要在同一个事务中保证一致的数据,应该放在同一个服务中。例如订单和订单明细放在订单服务,库存放在库存服务。 + +拆分之后每个服务只能访问自己的数据库,订单服务不能再用 JOIN 查询用户表中的用户名。常见的做法有两种:调用用户服务的接口查询;或者在下单时把用户名、商品名称和价格作为快照保存到订单表中,商品之后改名、改价,订单中记录的仍然是下单时的信息。 + +拆分已有的单体时通常逐步进行:先拆变化快、压力大的模块,新功能直接写在新服务中,网关把对应的请求转发给新服务,旧功能再一点点迁出。数据库也是先理清每个模块用到哪些表,消除跨模块的 JOIN 和事务,最后再分库。 + +## 二、组件 + +服务拆分之后,要解决服务在哪里、怎么调用、配置怎么管理等问题。Spring Cloud 为这些问题定义了统一的接口,Spring Cloud Alibaba 等项目提供具体的实现: + +| 要解决的问题 | 组件 | 其它常见选择 | +| --- | --- | --- | +| 服务的实例在哪里 | 注册中心:Nacos | Eureka、ZooKeeper、Consul | +| 配置怎么集中管理和动态修改 | 配置中心:Nacos | Apollo | +| 怎么调用其它服务 | OpenFeign + Spring Cloud LoadBalancer | Dubbo、gRPC | +| 外部请求从哪里进入 | 网关:Spring Cloud Gateway | Kong | +| 下游故障时怎么保护自己 | Sentinel | Resilience4j | +| 跨服务的事务 | Seata | 本地消息表、事务消息 | +| 一个请求经过了哪些服务 | 链路追踪:Micrometer Tracing | SkyWalking、Zipkin | + +下面的例子都在本机实际运行:网关、订单服务各一个实例,库存服务两个实例,订单服务通过 OpenFeign 调用库存服务扣减库存。 + +

+ +## 三、注册中心 + +注册中心的基本原理见 [服务发现](分布式.md#服务发现)。库存服务启动后,Nacos 中记录的实例信息如下(省略了部分字段): + +```json +{ + "ip": "127.0.0.1", "port": 8081, + "weight": 1.0, "healthy": true, "enabled": true, "ephemeral": true, + "metadata": {"preserved.register.source": "SPRING_CLOUD"} +} +``` + +weight 用于按权重分配流量;metadata 可以存放版本号等自定义信息,灰度发布时会用到;ephemeral 表示临时实例,Spring Cloud 应用默认注册为临时实例。 + +Nacos 2.x 的客户端与服务端之间保持一个 gRPC 长连接,端口为主端口加 1000(默认 9848)。临时实例依附于这个连接,连接断开,服务端就删除这个实例。持久化实例保存在服务端,客户端断开后不会被删除,只会被标记为不健康,通常由运维通过控制台或者接口维护。 + +Nacos 与 ZooKeeper 的区别:Nacos 的临时实例使用自研的 Distro 协议,每个节点都保存全部数据,网络分区时仍然可以返回实例列表,属于 AP;ZooKeeper 是 CP 的,只有多数节点可用时才能提供服务。服务发现可以容忍短暂的数据不一致,却不能接受注册中心不可用,所以更适合 AP。 + +## 四、服务调用 + +### OpenFeign + +订单服务调用库存服务时,只需声明一个接口: + +```java +@FeignClient(name = "inventory-service") +interface InventoryClient { + @PostMapping("/inventory/deduct") + Map deduct(@RequestParam("skuId") long skuId, @RequestParam("count") int count); +} +``` + +```java +Map result = inventoryClient.deduct(10086, 2); +``` + +注入的 inventoryClient 是 JDK 动态代理生成的对象,运行时打印它的类名为 $Proxy88。调用 deduct() 时: + +1. 代理根据注解拼出 HTTP 请求 POST /inventory/deduct?skuId=10086&count=2; +2. Spring Cloud LoadBalancer 从 inventory-service 的实例列表中选出一个实例,默认轮询,连续下单 6 次依次落在 8082、8081、8082、8081、8082、8081; +3. 把请求发给选中的实例,再把响应的 JSON 转换为返回值。 + +原理与 [RPC 的调用过程](分布式.md#调用过程) 相同,只是 OpenFeign 使用 HTTP + JSON,而 Dubbo 默认使用 TCP 上的二进制协议。Spring Cloud 项目常用 OpenFeign,因为它的注解与 Spring MVC 一致,被调用的就是普通的 HTTP 接口;调用量大、对延迟敏感的内部服务可以选择 Dubbo 或者 gRPC,原因见 [RPC 与 HTTP](分布式.md#rpc-与-http)。 + +服务之间不通过 Nginx 转发,是因为 Nginx 是集中式的代理:每次调用都要多经过一跳,Nginx 本身也要做高可用,实例变化时还要更新它的配置。客户端负载均衡由调用方直接从注册中心获取实例列表并选择实例,没有中间这一跳。 + +需要注意的是,OpenFeign 默认的连接超时为 10 秒、读取超时为 60 秒。下游实例卡住时,实测订单服务要等 60 秒才失败,期间一直占用一个线程,所以要按接口的实际耗时设置超时: + +```yaml +spring: + cloud: + openfeign: + client: + config: + default: + connect-timeout: 500 + read-timeout: 2000 +``` + +### 实例下线后多久能感知 + +注册中心删除实例之后,调用方并不会立刻停止调用它。下面让订单服务每 0.2 秒调用一次库存服务,再用三种方式停掉 8082: + +

+ +| 停止方式 | Nacos 删除实例 | 默认配置 | 关闭负载均衡缓存,读取超时 2 秒 | +| --- | --- | --- | --- | +| kill -9,进程退出 | 0.6 s | 35 s 内约一半调用失败,共 83 次 | 失败 1 次 | +| 正常关闭 | 0.6 s | 33 s 内失败 77 次 | 失败 1 次 | +| kill -STOP,进程卡住 | 23 s | 发往它的调用要等 60 s 才失败 | 每次 2 s 后失败,约 23 s 后不再调用 | + +原因有两个: + +- 负载均衡缓存:Spring Cloud LoadBalancer 默认把实例列表缓存 35 秒,Nacos 已经删除了实例,调用方在缓存过期前仍然会选中它。Nacos 客户端会接收服务端推送的实例变化并保存在本地,所以可以关闭这层缓存(spring.cloud.loadbalancer.cache.enabled 设为 false),Spring Cloud 的文档也建议在服务发现客户端已有缓存时关闭,避免重复缓存; +- 连接没有断开:进程退出或者正常关闭时连接随之断开,Nacos 立刻删除实例;进程卡住、机器宕机或者网络中断时连接并没有断开,Nacos 要等健康检查超时才能发现,本机实测为 23 秒。这段时间只能靠调用方的超时和熔断尽快放弃这个实例。 + +### 注册中心宕机 + +停掉 Nacos 后: + +- 已经运行的订单服务仍然可以调用库存服务,因为实例列表已经缓存在本地; +- 新启动的订单服务默认无法启动:spring.cloud.nacos.discovery.fail-fast 默认为 true,注册失败时直接报错退出。把它设为 false 后可以启动,配置也能从本地快照读取,但调用库存服务时报错 No servers available for service: inventory-service,因为新进程从没有拿到过实例列表。 + +所以注册中心宕机不会立刻影响线上的调用,但扩容和发布都无法进行,生产环境中 Nacos 要以集群方式部署。 + +## 五、网关 + +外部请求不应该直接访问各个服务:客户端要知道每个服务的地址,每个服务也都要各自做鉴权、限流。网关是所有外部请求的统一入口,负责: + +- 路由:按路径把请求转发给对应的服务,例如把 /api/orders/** 转发给订单服务; +- 鉴权:校验令牌,拒绝未登录的请求; +- 限流、日志、跨域等每个服务都需要的处理。 + +Spring Cloud Gateway 的路由配置: + +```yaml +spring: + cloud: + gateway: + routes: + - id: order + uri: lb://order-service # 从注册中心获取 order-service 的实例并负载均衡 + predicates: + - Path=/api/orders/** + filters: + - StripPrefix=1 # 去掉 /api,转发为 /orders/** +``` + +鉴权通常放在网关:网关校验令牌(例如 [JWT](HTTP.md#11-jwt)),取出用户 ID 放进请求头,下游服务直接使用这个请求头,不再重复校验: + +```java +@Bean +GlobalFilter auth() { + return (exchange, chain) -> { + String userId = verify(exchange.getRequest().getHeaders().getFirst("Authorization")); + if (userId == null) { + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + return chain.filter(exchange.mutate() + .request(r -> r.headers(h -> h.set("X-User-Id", userId))).build()); + }; +} +``` + +实测不带令牌或者令牌错误时,网关返回 401;带上正确的令牌时,订单服务收到 X-User-Id: 1024。需要注意两点: + +- 用 set 而不是 add:客户端自己带上 X-User-Id: 1 时会被覆盖,实测订单服务收到的仍然是 1024; +- 下游服务信任网关传来的请求头,所以这些服务本身不能对外暴露,只能通过网关访问。 + +网关与 Nginx 并不冲突:Nginx 通常在最外层负责 TLS 和多个网关实例之间的负载均衡,网关在它后面按服务路由,并处理鉴权这类与业务相关的逻辑。 + +## 六、配置中心 + +每个服务有多个实例,如果配置写在各自的配置文件中,修改一个参数就要改所有实例并重启。配置中心集中保存配置,修改后推送给运行中的实例。 + +订单服务从 Nacos 读取名为 order-service.yaml 的配置: + +```yaml +spring: + config: + import: optional:nacos:order-service.yaml +``` + +```java +@RestController +@RefreshScope +class OrderController { + @Value("${order.max-items:10}") + int maxItems; +} +``` + +在 Nacos 中把 order.max-items 从 5 改为 8,306 ms 后订单服务就读到了新值,不需要重启。配置变化后 Nacos 主动通知客户端,而不是等客户端定时来查询;@RefreshScope 的 Bean 是一个代理,配置刷新时缓存的实例被丢弃,下次调用时用新的配置重新创建。 + +客户端还会在本地保存配置的快照(~/nacos/config 目录下)。Nacos 不可用时,实测新启动的订单服务从快照中读到了 max-items: 8。 + +## 七、服务保护与分布式事务 + +服务之间调用越多,一个服务变慢就越容易拖垮调用它的服务:上面卡住的库存实例让每次调用等待 60 秒,订单服务的线程会被逐渐占满。限流、降级和熔断的原理见 [限流、降级与熔断](系统设计基础.md#限流降级与熔断),Spring Cloud Alibaba 中常用 Sentinel 实现:把接口或者一段代码定义为资源,再为资源配置规则。熔断规则可以按慢调用比例、异常比例或者异常数触发,熔断时长过后进入半开状态,放行一个请求试探,成功就结束熔断,失败就继续熔断。 + +跨服务的数据一致性见 [分布式事务](分布式.md#二分布式事务)。Seata 的 AT 模式对业务代码的改动最少,以下单时订单服务创建订单、库存服务扣减库存为例: + +1. 第一阶段:每个服务执行自己的 SQL,并在同一个本地事务中写入 undo_log,记录被修改的行修改前后的值,然后提交本地事务。提交前要先获得这些行的全局锁,防止其它全局事务同时修改它们; +2. 全局事务成功时,第二阶段异步删除 undo_log; +3. 全局事务失败时,第二阶段按 undo_log 中修改前的值生成反向的 SQL,例如库存服务把扣掉的库存加回去。 + +## 八、链路追踪 + +一个下单请求依次经过网关、订单服务和库存服务,出错时需要知道是哪一步出了错、每一步耗时多少。链路追踪为每个请求分配一个 traceId,调用下游时通过请求头传递,各个服务打印日志和上报耗时的时候都带上它。 + +在上面的服务中加入 Micrometer Tracing 后,同一次下单在订单服务和库存服务中打印的日志如下,方括号中依次是 traceId 和 spanId: + +```text +[order-service] [6ade5947250103822ff3f13a5baab74b-2763eeac981ce4a4] 创建订单 userId=1024 +[inventory-service] [6ade5947250103822ff3f13a5baab74b-92a95370047818fb] 扣减库存 skuId=10086 +``` + +库存服务收到的请求头为: + +```text +traceparent: 00-6ade5947250103822ff3f13a5baab74b-64990a2774676e11-01 +``` + +traceparent 是 W3C Trace Context 规定的格式,四段依次是版本、traceId、调用方这次请求的 spanId(这里是订单服务发起 HTTP 调用的 span)和采样标记。每个服务处理请求时生成自己的 spanId,并记下父 spanId,这些 span 上报到 Zipkin、SkyWalking 等系统后,就能按 traceId 还原出完整的调用树和每一段的耗时。用 traceId 在日志系统中搜索,也能找到这个请求在所有服务中的日志。 + +## 九、灰度发布 + +新版本上线时,常用的发布方式: + +- 滚动发布:逐个替换旧的实例,占用的资源少,但一段时间内新旧版本同时运行; +- 蓝绿发布:准备一整套新环境,验证后把流量一次切过去,回滚时切回即可,但需要双倍的资源; +- 灰度发布(金丝雀发布):先让一小部分流量进入新版本,观察错误率和耗时,没有问题再逐步扩大比例。 + +灰度发布可以借助注册中心和网关实现:新版本的实例注册时带上元数据 version=v2,或者注册为单独的服务名,网关按比例(Spring Cloud Gateway 的 Weight 断言)或者按用户、请求头把请求转发给新版本。发现问题时把新版本的比例调回 0 即可回滚。 + +需要注意的是,新旧版本会同时访问同一个数据库,表结构的修改必须同时兼容两个版本。例如先只增加新列,等旧版本全部下线后,再删除不再使用的旧列。 + +## 参考资料 + +- [Nacos: Instance Lifecycle](https://nacos.io/en/docs/latest/manual/user/naming/instance-lifecycle/) +- [Nacos 2.0 兼容性文档](https://nacos.io/docs/latest/upgrading/200-compatibility/) +- [Nacos 的 Distro 一致性协议](https://nacos.io/en/news/article-nacos-distro-mechanism/) +- [Nacos Java SDK](https://nacos.io/docs/latest/manual/user/java-sdk/usage/) +- [ZooKeeper: Overview](https://zookeeper.apache.org/doc/current/zookeeperOver.html) +- [Spring Cloud Commons: Spring Cloud LoadBalancer](https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/loadbalancer.html) +- [Spring Cloud Commons: Refresh Scope](https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/application-context-services.html) +- [Spring Cloud OpenFeign](https://docs.spring.io/spring-cloud-openfeign/reference/) +- [Spring Cloud Gateway: Route Predicate Factories](https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway-server-webflux/request-predicates-factories.html) +- [Sentinel 熔断降级](https://sentinelguard.io/zh-cn/docs/circuit-breaking.html) +- [Seata AT 模式](https://seata.apache.org/docs/dev/mode/at-mode/) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) diff --git a/tools/diagram/src/instance-down.svg b/tools/diagram/src/instance-down.svg new file mode 100644 index 00000000..dbb813ed --- /dev/null +++ b/tools/diagram/src/instance-down.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + 停掉库存实例 8082 后,订单服务调用它的情况 + + 0 s + + 10 s + + 20 s + + 30 s + + 40 s + kill -9,默认配置 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 35 s 内约一半调用失败,共 83 次 + kill -9,关闭负载均衡缓存 + + + 只失败 1 次 + 进程卡住,默认配置 + + + 发往它的调用要等 60 s 才失败 + 进程卡住,读取超时 2 s + + + + + + + + + + + + + 每次 2 s 后失败,约 23 s 后不再调用 + + Nacos 删除实例 0.6 s + + 进程卡住时 Nacos 23 s 后才删除 + + 负载均衡缓存 35 s 过期 + 订单服务每 0.2 s 调用一次库存服务,请求在 8081 和 8082 之间轮询;红色为失败的调用 + diff --git a/tools/diagram/src/microservice-overview.svg b/tools/diagram/src/microservice-overview.svg new file mode 100644 index 00000000..8dc217da --- /dev/null +++ b/tools/diagram/src/microservice-overview.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + 一次下单请求经过的组件 + + + App + 带上令牌 + + + 网关 :8080 + 校验令牌,加上 X-User-Id + /api/orders/** → 订单服务 + + + 订单服务 :8090 + OpenFeign 调用库存服务 + 读取 order.max-items + + + 库存服务 :8081 + + + 库存服务 :8082 + + + + + 轮询 + + + Nacos :8848 + 注册中心:服务名 → 实例列表;配置中心:order-service.yaml + + 查询实例 + + 实例列表、配置推送 + + 注册 + + traceId 6ade5947…74b 随请求头 traceparent 传递,各服务的日志中都带上它 + 订单服务调用库存服务时加上超时和熔断(Sentinel),跨服务的事务用 Seata 或消息保证一致 + 本机实际运行:Nacos 2.5.4,Spring Boot 3.3,Spring Cloud 2023.0,Spring Cloud Alibaba 2023.0.3.4 +