Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
- [系统设计基础](https://github.com/CyC2018/CS-Notes/blob/master/notes/系统设计基础.md)
- [分布式](https://github.com/CyC2018/CS-Notes/blob/master/notes/分布式.md)
- [集群](https://github.com/CyC2018/CS-Notes/blob/master/notes/集群.md)
- [微服务](https://github.com/CyC2018/CS-Notes/blob/master/notes/微服务.md)
- [攻击技术](https://github.com/CyC2018/CS-Notes/blob/master/notes/攻击技术.md)
- [缓存](https://github.com/CyC2018/CS-Notes/blob/master/notes/缓存.md)
- [消息队列](https://github.com/CyC2018/CS-Notes/blob/master/notes/消息队列.md)
Expand Down
Binary file added notes/pics/instance-down.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added notes/pics/microservice-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
278 changes: 278 additions & 0 deletions notes/微服务.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# 微服务
<!-- GFM-TOC -->
* [微服务](#微服务)
* [一、从单体到微服务](#一从单体到微服务)
* [服务拆分](#服务拆分)
* [二、组件](#二组件)
* [三、注册中心](#三注册中心)
* [四、服务调用](#四服务调用)
* [OpenFeign](#openfeign)
* [实例下线后多久能感知](#实例下线后多久能感知)
* [注册中心宕机](#注册中心宕机)
* [五、网关](#五网关)
* [六、配置中心](#六配置中心)
* [七、服务保护与分布式事务](#七服务保护与分布式事务)
* [八、链路追踪](#八链路追踪)
* [九、灰度发布](#九灰度发布)
* [参考资料](#参考资料)
<!-- GFM-TOC -->


## 一、从单体到微服务

一个商城最初通常是单体应用:用户、商品、库存、订单、支付的代码在同一个工程中,部署成一个进程,共用一个数据库。微服务指的是把系统按业务拆成多个独立部署的服务,每个服务有自己的数据库,服务之间通过网络调用。例如下单时,订单服务要通过网络调用库存服务扣减库存,而不是调用同一个进程中的方法。

拆分带来的好处:

- 独立部署:修改库存服务只需重新发布库存服务;
- 独立扩容:大促时只给订单服务和库存服务增加实例;
- 故障隔离:支付服务出问题时,用户仍然可以浏览商品;
- 团队独立:每个团队负责几个服务,可以各自开发和发布。

代价同样明显:

- 方法调用变成了网络调用,可能超时和失败,需要处理超时、重试和熔断;
- 一个数据库事务变成了跨服务的分布式事务;
- 一个请求经过多个服务,排查问题需要链路追踪;
- 服务和实例的数量增加,部署和监控的成本更高。

因此团队规模小、业务还在快速变化时,单体应用加上清晰的模块划分往往更合适;当各个模块的发布互相阻塞,或者某些模块需要单独扩容时,再考虑拆分。

### 服务拆分

拆分的依据是业务边界,而不是技术上的分层。判断方法是看数据:经常被一起修改、需要在同一个事务中保证一致的数据,应该放在同一个服务中。例如订单和订单明细放在订单服务,库存放在库存服务。

拆分之后每个服务只能访问自己的数据库,订单服务不能再用 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 调用库存服务扣减库存。

<div align="center"> <img src="pics/microservice-overview.png" width="740px"/> </div><br>

## 三、注册中心

注册中心的基本原理见 [服务发现](分布式.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<String, Object> deduct(@RequestParam("skuId") long skuId, @RequestParam("count") int count);
}
```

```java
Map<String, Object> 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:

<div align="center"> <img src="pics/instance-down.png" width="740px"/> </div><br>

| 停止方式 | 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/)
Loading
Loading