# lgrpc **Repository Path**: longyunfeigu/lgrpc ## Basic Information - **Project Name**: lgrpc - **Description**: No description available - **Primary Language**: Go - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-06-18 - **Last Updated**: 2024-07-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # lgrpc # 什么是微服务框架 微服务框架是服务于微服务架构的,微服务架构简单来说就是整个系统由多个组件组成,每个组件独立部署和管理,组件之间通过网络进行通信,所以网络通信是微服务架构的很多问题的本质。 微服务框架是为了解决在这种架构下,组件之间的发现、通信、容错等问题。比如客户端怎么知道要访问的服务有哪些实例,我应该发送请求给哪里实例,如果失败了我应该怎么办等等。 # 微服务框架核心要解决的两个问题 微服务框架的核心在于 通信 + 服务治理。 * 通信:服务之间如何发起调用,是通过rpc还是HTTP * 服务治理:涵盖从服务注册、服务发现到服务可观测性等全部内容 目前市场上主要的微服务框架可以细分为如下三种: * 纯粹的RPC框架:这一类框架的代表是早期的gRPC, gRPC 发展到现在已经非常庞大了,定义了一下服务治理相关的接口并提供了默认实现 * 服务治理框架:这类框架没有设计自己的微服务通信协议,底层依赖gRPC或者HTTP, 专注于处理服务治理的问题,甚至部分框架只解决服务治理的某一方面 * 大而全的微服务框架:这类框架既有自己的通信协议,又有服务治理功能,典型的例子比如Dubbo. 接下来我们将一步步去设计自己的RPC框架,在这个过程中我们可以学习到如下内容: 1. RPC协议设计 2. RPC的调用语义 3. 超时控制 4. 服务注册和发现 5. 负载均衡 6. 路由和分组 7. 广播和组播 8. 可用性 9. 可观测性 值得一提的是,虽然我们自己要自己实现RPC框架,但是我们的目的不是在生产中使用我们自定义的RPC框架,而是用来学习微服务框架的原理,在实际工作中还是建议使用gRPC或HTTP这些已经非常成熟的框架。 在动手实现RPC框架之前,我们要理清RPC框架要解决的核心问题是什么,核心就是如同本地调用一样调用服务器上的方法。举个例子,假如我们客户端想要调用服务端userService.GetByID方法, 传的入参是int类型的123,那么服务端怎么知道客户端想要调用的是userService.GetByID(123)呢?你可能会说,我们把这些信息都传过去不就行了,没错!这些要传过去的信息我们统称为调用信息, 调用信息包括: 1. 服务名:这个例子中就是userService 2. 方法名:这个例子中就是GetByID 3. 参数值:这个例子中就是123 问题又来了,使用框架的用户写了一段代码: userService.GetByID(123), 底层的RPC框架怎么提取到这些调用信息呢,换句话说RPC框架怎么知道userService、 GetByID、123 这3个信息呢?总不能通过字符串或者正则之类的匹配吧。一般来说,本地的RPC客户端捕捉用户调用的方式有2种: ## IDL生成代码 典型代表是gRPC:用户定义一个.proto文件包含需要调用的服务端的服务和消息类型,然后利用gRPC的工具生成对应的实现代码,代码中硬编码了服务名、方法名、参数值等调用信息,用户利用生成的代码发送请求,生成的代码会把调用信息发送给服务端。 ```go type UserServiceClient interface { GetById(ctx context.Context, in *GetByIdReq, opts ...grpc.CallOption) (*GetByIdResp, error) } type userServiceClient struct { cc grpc.ClientConnInterface } ...... func (c *userServiceClient) GetById(ctx context.Context, in *GetByIdReq, opts ...grpc.CallOption) (*GetByIdResp, error) { out := new(GetByIdResp) err := c.cc.Invoke(ctx, "/users.UserService/GetByID", in, out, opts...) if err != nil { return nil, err } return out, nil } ``` 上面的代码是基于.proto生成的代码,我们发现在用户调用GetById的时候,硬编码了调用信息"/users.UserService/GetByID",gRPC就是通过这种方式来捕捉用户调用信息的。 ## 代理模式 因为在Go中没办法直接修改方法实现,所以我们只能"曲线救国":定义一个结构体,和普通结构体不同的是我们在这个结构体中定义的字段类似是方法类型,如: ```go type UserService struct { GetByID func(ctx context.Context, req *Request) (*Response, error) } ``` GetByID 是一个字段,我们可以对其赋值,那么我们就可以把我们RPC框架底层的function赋值给这个字段,在这个function中我们可以写获取调用信息的逻辑,通过这样一种方式我们捕捉到了调用信息。 # RPC协议设计 实际场景下,大多数的RPC框架需支持不同的序列化协议、支持数据压缩、版本升级、加密等功能,所以我们需要精心设计我们的RPC消息协议。我们先来看看其他开源项目的RPC通信协议大体是怎么实现的: * gRPC协议分成头部和body两个部分,而gRPC是基于HTTP2.0实现的,所以gRPC的头部放在HTTP的请求头,gRPC的body放在HTTP的请求体中 在 gRPC 中,服务和方法定义如下: ```protobuf syntax = "proto3"; package example; service MyService { rpc MyMethod (MyRequest) returns (MyResponse); } message MyRequest { string param = 1; } message MyResponse { string result = 1; } ``` 假设调用 MyMethod 方法并传递参数 { "param": "hello" }。 此 gRPC 调用将通过 HTTP/2 进行编码,请求看起来像这样: ```protobuf POST /example.MyService/MyMethod HTTP/2.0 Host: localhost Content-Type: application/grpc+proto User-Agent: grpc-go/1.0 Te: trailers ``` 其中 是序列化后的 MyRequest protobuf 消息,因为 gRPC 默认使用 protobuf 格式,并以二进制形式在网络上传输。 对于响应,假设返回 { "result": "world" },HTTP/2 响应可能看起来像这样: ``` HTTP/2.0 200 OK Content-Type: application/grpc+proto grpc-status: 0 grpc-message: OK ``` 同样, 是序列化后的 MyResponse protobuf 消息。 注意 grpc-status 和 grpc-message 分别代表 gRPC 的状态代码和消息,它们在 HTTP/2 的尾部头字段(trailers)中发送。 * Dubbo协议把消息分成了两部分: 定长部分(协议头)和非定长部分(协议体), ![协议图](https://cdn.nlark.com/yuque/0/2024/png/3004213/1719198131625-131f72a1-8b09-4089-815e-4acc9dcf14f8.png?x-oss-process=image%2Fformat%2Cwebp%2Fresize%2Cw_1031%2Climit_0) gRPC协议和Dubbo协议都是应用层协议,其他协议设计也是类似的,比如TCP协议。 ## 请求 对于请求我们设计成不定长字段,具体分为两部分:固定字段 & 不固定字段 1. 固定字段: * 长度字段:用于分割消息 * 版本字段:描述协议版本,用于后续的消息版本协议升级 * 序列化协议:用于标记采用的序列化协议 * 压缩算法:用于标记协议体是如何被压缩的 * 消息ID: 用于后续支持多路复用 * 服务名 * 方法名 2. 不固定字段:这部分主要是链路元数据,比如trace id, a/b测试,全链路压测的标记位等等 3. 最后的协议体就只存放请求参数的真实数据 ![请求](https://cdn.nlark.com/yuque/0/2024/png/3004213/1719198709604-ddadcbad-3f40-41e9-8488-d54232122aa5.png?x-oss-process=image%2Fformat%2Cwebp) 我们把服务名、方法名和元数据放在请求头部而非请求体中,是基于如下考虑:如果我们的微服务请求要经过网关等组件,这些中间件就可以只解析头部字段而不必解析整个请求,比如解析到服务名就可以找到可用节点做负载均衡。 ## 响应 对于响应我们也设计成不定长字段,具体分为两部分:固定字段 & 不固定字段 1. 固定字段: * 长度字段:用于分割消息 * 版本字段:描述协议版本,用于后续的消息版本协议升级 * 序列化协议:用于标记采用的序列化协议 * 压缩算法:用于标记协议体是如何被压缩的 * 消息ID: 用于后续支持多路复用 * 错误 2. 最后的协议体就只存放请求参数的真实数据 ![响应](https://cdn.nlark.com/yuque/0/2024/png/3004213/1719199484748-802cbf46-ab51-4005-81e4-658ce05d8f08.png?x-oss-process=image%2Fformat%2Cwebp) 我们把错误放在头部而非协议体中,主要是不想增加实现难度,虽然error放在响应体更符合直觉,但是我们需要在响应体中区分哪部分是error哪部分是数据。另外我们也没有用一个字段去区分消息体到底是error还是真实数据, 因为有时候error和数据都是要有的,而不是互斥的关系,比如用户需要根据error来判断要不要显示我们返回的数据。 # RPC调用语义 RPC(Remote Procedure Call)调用语义主要有以下三种: * 异步调用:客户端向服务端发送请求后,不需要立即等待结果返回,可以继续执行其他感兴趣的操作,过一段时间之后在来等待结果。 * 单向调用:客户端只发送请求,不需要接收服务端的回复。这意味着该调用具有“fire and forget”特性,即发送请求后即可做其他事情,无需关心结果。 * 回调:用户在发起调用的时候注册一个回调,当结果返回之后再执行回调 Go语言的框架一般不会提供异步调用和回调,因为在Go语言中开协程是一件非常简便且低成本的操作,完成可以开启协程来执行异步调用和回调操作。 **异步调用示例:** 假设有一个RPC函数`DoSomething`需要异步调用: ```go func DoSomething(args *Args, reply *Reply) error { // 假设这是一个RPC调用 return nil } ``` 在Go中,可以通过启动一个新的协程来实现异步调用: ```go go func() { // 创建RPC调用所需的参数和接收结果的变量 args := &Args{} reply := &Reply{} // 执行RPC调用 if err := DoSomething(args, reply); err != nil { log.Fatalf("RPC call failed: %v", err) } // 处理RPC调用的结果 fmt.Println("RPC call succeeded:", reply) }() ``` **回调示例:** 如果你想在RPC调用完成后执行某些操作,可以定义一个回调函数,并在协程中调用它: ```go func DoSomethingWithCallback(args *Args, reply *Reply, callback func(*Reply, error)) { // 执行RPC调用 err := DoSomething(args, reply) // 调用回调函数 callback(reply, err) } // 定义一个回调函数 callback := func(reply *Reply, err error) { if err != nil { log.Fatalf("RPC call failed: %v", err) } fmt.Println("RPC call succeeded:", reply) } // 使用协程和回调 go DoSomethingWithCallback(&Args{}, &Reply{}, callback) ``` 这里我们重点关注一下单向调用,单向调用其实也分成两种: * 虚假的单向调用:用户的请求发过去之后,服务端还是会把响应发回来,但是客户端在收到响应之后会直接丢弃 * 真实的单向调用:用户的请求发过去之后,服务端发现这是一个单向调用,就不会发送响应,此时就可以提前释放掉连接等资源了 一般单向调用的目的就是尽快地释放两端的资源,因此虚假的单向调用毫无意义. 为了支持真实的单向调用,我们需要显式地告诉服务端,这是一个单向调用。那么我们怎么 告诉设计协议来支持单向调用呢,这里大致有2种方式: 1. 在元数据中携带一个标记位过去 2. 在头部设计一个oneway的字段 > 在Go语言里,虚假的单向调用实现非常简单,用户完全可以自己开一个goroutine,只不过在goroutine中不处理响应而已 那么问题又来了,用户该怎么告诉RPC客户端这是一个单向调用呢,这里也有两种做法: 1. 初始化客户端的时候,告诉RPC的客户端这是一个只能单向调用的客户端,这样的话我们就需要初始化两个客户端:支持单向调用客户端和支持双向调用客户端 2. 在context.Context 中携带一个标记位 # 超时控制 在微服务里面,超时控制一般分为两种: * 单一服务的超时控制 * 链路超时控制 拿 A -> B -> C 这种调用关系举例, 假设 A -> B 设置的超时时间是1s, 那么: * 如果是链路超时控制,那么 B -> C 也不能超过1s,也就是说A调用B,不管B怎么调用,反正最终你给我的响应不能超过1s * 在单一服务的超时控制模式下,我们通常只设置每个直接的服务调用关系的超时时间,比如A调用B,B调用C。这些超时时间是独立考虑的,A对B的超时时间并不会因为B对C的超时时间而改变。 值得一提的是,绝大多数的微服务框架的超时时间,都只支持单一服务超时控制。 ## 链路超时控制 ![链路超时控制](https://cdn.nlark.com/yuque/0/2024/png/3004213/1719457608891-96257bae-fb1b-4781-8880-fd23a62218bb.png?x-oss-process=image%2Fformat%2Cwebp) 从上图我们可以看出: 1. RPC客户端需要监听ctx, 并且把剩余超时时间传递给RPC服务端(进程内通过ctx, 进程外通过网络) 2. RPC服务端收到请求之后,要检查元数据有没有携带超时时间,然后重建context.Context 3. 如果用户的业务里用到了其他中间件,那么用户可能需要自己手动管理超时数据,继续传递给其他中间件 4. 任何一个环节的超时时间,都不能超过总的链路超时时间 ## 如何监听超时 从理论上来说,超时监听有两种情况 1. 只在客户端监听 2. 客户端和服务端同时监听 但是不能只在服务端进行监听,例如当服务端监听超时后发现超时了,返回超时响应的时候,如果网络发现故障,客户端收不到超时响应,但是客户端有没有监听超时,那么客户端就会一直在那等。 如果我们暂时只在客户端进行监听,那么问题来了,我们在哪个地方检测超时呢,可以检测超时的位置有以下几种: 1. 从连接池拿连接之前 2. 拿到连接之后发送请求之前 3. 等待响应的过程中就超时,那么就不需要再读取响应了 4. 读取响应之后超时,那么就没必要再解析结果了 但是在实际中,不会在每个点都做检测,我们这里只在 "1" 的时候检查一下,然后发起调用,同时监听一下超时。 ## 超时时间应该传什么 超时时间可以传的内容如下: * 传递剩余超时时间,类似1s、2s这种,传递的时间一般是毫秒为单位,这个方案缺点是难以估计网络的传输时间,比如RPC客户端计算剩余时间还剩2000ms, 请求到达服务端的时候需要考虑扣除网络传输时间,比如20ms,那么RPC服务端实际剩下的超时时间就只有1980ms * 传递超时时间戳, 表明什么时间点过期, 这个方案的缺点是时钟同步问题, 即某个特定的时刻,时钟读数在两台服务器上是不同的,尤其是不同云厂商的服务器之间这种情况可能会出现 这里我们简单一点,直接采用传递超时时间戳的方式。 # 服务注册和发现 刚开始我们把RPC的服务端地址写死了,RPC的客户端直连固定的地址,但是在实际生产中这种情况比较少见,因为RPC的服务端地址有可能是动态变化的。 ## 演进 1. 第一阶段:直接IP和端口访问,这种情况下客户端需要提前配置好服务端的ip&port, 但是ip&port可能会动态变化 2. 第二阶段:域名解析DNS, 这种情况下客户端保持了DNS的域名,也可以缓存DNS解析的结果,缺点是客户端不缓存解析结果就要多一次请求,缓存结果就会存在不一致性。基于HTTP的微服务架构还在大规模应用这种模式,gRPC也可以直接使用这种方式 3. 第三阶段:引入分布式协调——注册中心,服务端发起注册,客户端向注册中心询问地址,在大规模集群下注册中心可能会成为瓶颈 除了以上三个阶段,还有一个变种情况:引入一个中间件(通常是网关),请求和响应都经过网关,这种方式的性能相对来说更差。 ## 服务注册中心模式 对于服务注册中心模式,核心依赖于一个第三方组件,基本模型如下: 1. 服务启动成功之后主动注册到注册中心 2. 服务端和注册中心保持心跳 3. 客户端启动的时候需主动订阅服务的数据 4. 注册中心要通知客户端服务端的变更 对于服务注册中心模式,常常会有如下问题需要考虑: 1. 运行过程中,客户端连不上注册中心怎么办 2. 运行过程中,客户端拿到了注册数据,但是连不上对应的服务端怎么办 3. 运行过程中,注册中心没有收到服务端心跳怎么办 4. 注册中心崩溃了,客户端和服务端怎么办 # gRPC的服务注册与发现 gRPC的服务注册和发现的核心在resolver包,核心接口有: * Target: 被解析的目标,是对服务的抽象 * Builder: Builder模式用于构建一个Resolver * Resolver: 负责服务发现,找到对应的service 还有一些抽象的类型,都在resolver包中,比如代表一个地址的抽象Address. resolver 包根据提供的目标 URI (Uniform Resource Identifier) 来解析出物理地址,然后 gRPC 的客户端可以用这些地址去连接到对应的服务,我们可以实现自定义的 resolver 来满足你的特定需求。 值得说明的是,gRPC没有实现服务注册相关的代码,只有服务发现。 Resolver包中的核心方式是watch方法,它的功能主要是监听服务的变化,包括服务地址的新增、删除和修改。 当你通过Resolver的Builder创建一个新的Resolver实例之后,客户端会调用这个Resolver的watch方法来开始监听服务的变化。 具体的工作原理如下: 1. 当watch方法被调用时,Resolver会向服务注册中心(例如Consul, Etcd等)或DNS服务器发送一个请求,请求获取目标服务的所有当前可用地址。 2. Resolver将收到的服务地址信息传递给gRPC客户端,客户端可以根据这些地址创建连接。 3. 同时,Resolver还会在后台启动一个循环,定期向服务注册中心或DNS服务器发送请求,检查服务地址是否有更新。 4. 如果发现服务地址有更新(例如有新的服务实例添加,或者某个服务实例不再可用),Resolver就会将更新的服务地址信息传递给gRPC客户端,客户端可以根据新的地址信息更新连接。 # 自定义服务注册和发现 我们的目标是在gRPC内部接入我们自定义的服务注册和发现, 我们知道gRPC只提供了服务解析的接口(也就是Resolver和Builder两个接口),gRPC本身没有提供服务注册接口,需要我们自己实现。 ![gRPC服务注册和发现](https://cdn.nlark.com/yuque/0/2024/png/3004213/1719553619351-e45424c5-82cd-4f8b-bfb3-0a15824a1534.png?x-oss-process=image%2Fformat%2Cwebp) ![Resolver流程](https://cdn.nlark.com/yuque/0/2024/png/3004213/1719554166004-6954f223-e97d-49be-b6e7-1b2284524c16.png?x-oss-process=image%2Fformat%2Cwebp) gRPC服务发现的核心接口如下: * ClientConn: 对于服务的抽象,而不是对于连接的抽象, 将它理解为对某个服务的连接(在一个ClientConn内部,可能会有多个真实的网络连接,这些连接可以指向服务的不同节点) * resolver.Builder: Builder 用于创建Resolver, gRPC会维护一个scheme到Builder的映射 * Resolver: 和服务进行绑定,一个服务一个Resolver, 负责与注册中心交互,监听注册数据的变化 ## 注册中心 我们考虑使用etcd来实现一个服务注册中心,往etcd注册服务,说白了就是往etcd写入数据,那么我们应该写入什么样的数据、以及写入什么格式的数据呢? * 写入服务实例的信息 * 可以考虑采用json、xml 甚至 protobuf, 只有用户可以解析就行 接着我们要定义注册中心的接口,表明注册中心有哪些能力, 注册中心的接口定义如下: ```go type Registry interface { Register(ctx context.Context, instance ServiceInstance) error UnRegister(ctx context.Context, instance ServiceInstance) error ListServices(ctx context.Context, name string) ([]ServiceInstance, error) Subscribe(ctx context.Context, name string) <-Event } type ServiceInstance struct { Name string Address string } ``` 服务注册的关键是写入一个"不稳定"的数据,所谓的"不稳定"是指万一节点崩溃了,写入的数据能被注册中心自动删除。 正常来说,服务节点可以正常退出的时候,只需要退出之前让服务节点主动删除注册的数据就行。但实际情况可能不会这么理想,比如服务节点宕机或者网络有问题等等,服务节点 没机会去完成删除数据的任务,所以我们只能期望注册中心自己发现服务节点崩溃了, 也就是说注册中心自己删除数据。我们可以利用etcd的租约API来解决这种问题: 1. 每一个写入的数据都有一个存活时间,可以称之为"租约" 2. 服务注册方需要不断刷新存活时间,也叫"续租",否则数据过期了就被删除了 ### 服务注册时机 大多数的情况下,我们在服务启动完成的时候 进行服务注册,那么什么时候服务才算成功启动了呢? 1. 端口监听成功? 2. 服务能接收请求了? 3. 服务已经预热好了,比如加载好了缓存? 我这里选择的方案是把时机 **抛给用户去选择**,用户在初始化工作完成之后执行注册的动作。 ## 发现流程 gRPC发现的流程如下: 1. 用户在初始化gRPC的时候指定grpc.WithResolver选项,传入自定义的Resolver 2. 在Dial的时候传入服务标识符,一般形式是 scheme:///service_name, scheme代表的是如何通信,大多数的时候代表的就是注册中心 3. gRPC 会根据scheme来找注册的Resolver,我们在Resolver更新可用的连接 如果我们使用etcd作为注册中心的话,gRPC的发现流程就是: 1. 客户端启动的时候从etcd把服务信息拉出来,对应Registry接口的ListServices 2. 监听etcd对应的服务信息的变化,对应Registry接口的Subscribe # 负载均衡 服务注册和发现解决的是"有哪些可用的服务实例",那么有这么多可用实例,客户端应该把请求发给谁?这就是负载均衡要解决的问题。 客户端的目标肯定是尽量把请求发给响应最快的实例,所以一切的负载均衡算法都是回答这个问题:怎么找出这个实例? 目前主流的算法有: * 完全不实时计算负载的算法:轮询、加权轮询、随机、加权随机、哈希、一致性哈希等 * 尝试实时计算负载的算法:最快响应时间、最少连接数、最少请求数等 在gRPC里要自定义负载均衡算法,和注册中心类似,也是实现两个接口: * balancer.PickerBuilder * balancer.Picker 不同的是,负载均衡是通过ServiceConfig来指定的。 ## 为什么要有balancer.PickerBuilder和balancer.Picker两个接口 balancer.PickerBuilder负责构建一个新的 Picker。当负载均衡器的状态发生改变时(例如新的子连接可用,现有的子连接关闭等),gRPC 会调用 PickerBuilder 来生成一个全新的 Picker。这样做的主要原因是让 Picker 保持无状态,从而可以并发使用。每次 Picker 的构建都基于当前的负载均衡器状态,所以它总是反映最新的状态。 balancer.Picker则是用来选择具体的子连接(SubConn)进行 RPC 调用。每次 RPC 调用都会使用 Picker 的 Pick() 方法来确定应该使用的 SubConn。 总结一下,PickerBuilder 提供了每当负载均衡器状态变化时动态生成新的 Picker 的机制,而 Picker 则提供了具体的路由决策。 如果只有一个 Picker,那么你需要某种方式来处理状态的改变,这可能会引入复杂性和同步问题。通过分离为 PickerBuilder 和 Picker,gRPC 达到了简化代码并提高性能的效果。 这个设计体现了"分离关注点"(Separation of Concerns, SoC)的设计思想。SoC是一种设计原则,建议将一个应用程序分解为不同的部分,每个部分解决一个单一的关注点。 在我们讨论的gRPC的负载均衡器中,balancer.PickerBuilder 和 balancer.Picker 分别处理两个不同的关注点: * balancer.PickerBuilder 关注于如何基于当前的状态信息构建一个新的 Picker。 * balancer.Picker 则专注于如何从可用的连接中选择一个来处理请求。 通过这种方式分离关注点,代码的组织和维护变得更容易。当需要改变创建 Picker 的方式时,只需要修改 PickerBuilder;而当改变连接选择策略时,则只需修改 Picker。每个部分都可以独立于其他部分进行理解、测试和修改,提高了代码的可读性和可维护性。 ## 轮询 普通的轮询算法有一些假设: * 所有的服务器的处理能力是一样的 * 所有请求所需的资源也是一样的 在一般情况下,轮询策略的执行效果良好。尤其在高并发的场景中,从数学概率模型的角度考虑,每个节点都能基本均匀地接收到请求。 ## 加权轮询 普通的轮询算法也有一些假设: * 用权重来代表服务器的处理能力 * 所有请求所需的资源也是一样的 所谓平滑的加权轮询就是考虑动态调整权重来实现平滑的效果,基本原理如下: 1. 每个服务实例有三个权重值:weight(预设权重),currentweight(当前权重),efficientweight(有效权重)。其中,有效权重会根据服务的调用结果动态调整。 2. 当需要选择一个实例来处理新的请求时,首先计算所有可用实例的effective weight,然后汇总为total weight。 3. 然后对每一个可用实例,更新其current weight为它自己的current weight加上它的effective weight。 4. 从所有的实例中选择current weight最大的那个,作为被选中来处理当前请求的实例。 5. 最后,更新被选中实例的current weight,使其等于它的current weight减去total weight。 这种方式可以确保系统在各个实例间实现更平滑、更公正的负载分配,特别是当实例之间的性能或者处理能力存在差异时。 ## 最少连接数 这种策略假设: * 服务器的负载可以通过连接数来判断 * 所有服务器的处理能力相同 * 每个请求所需的资源量都相同 然而,这种策略可能会在短时间内将所有的请求都派发给同一台服务器,因为它只考虑了当前的连接数,而没有考虑已存在连接的负载状态。 在使用连接复用技术的情况下,连接数可能无法准确反映服务器的实际负载。 另外,在如gRPC这样的系统中,我们并不能直接管理连接,因此,这种"最少连接数"的负载均衡策略可能无法有效实现。 ## 最少活跃数 我们假设: * 服务器上的请求数量可以作为负载的代表 * 所有服务器的处理能力都相同 * 每个请求所需的资源量都一样 然而,这种策略可能会在短时间内将所有的请求集中派发给同一台服务器,因为它只考虑了当前的请求数量,而没有分散到不同的服务器上平衡负载。 ## 最快响应 这个算法假设: * 用服务器上的响应时间来代表其负载 * 所有请求所需的资源都相同 然而,这种策略也存在潜在问题。例如,如果一个节点偶尔处理了几个响应时间较长的请求,那么这个节点在接下来的轮次中可能很难再被选中。在极端情况下,如果一个节点一直处理的是响应时间较长的请求,它可能甚至永远不会再被选中。 ## 权重的效果 大多数情况下我们都是使用权重来表达服务器的处理能力,但是使用权重算法的时候需要考虑: * 某个实例的权重特别大,可能连续几次都选中它,那么要考虑平滑效果 * 结合实际的调用结果来调整权重,例如实例如果返回了错误,那么就降低权重,否则就增高权重 * 如果一个实例的权重是动态调整的,那么就需要考虑上限和下限的问题,尤其是要考虑调整权重的过程中会不会导致权重变成0、最大值或者最小值,这三个值会导致实例会一直被选中或一直不被选中 ## 如何设计自己的负载均衡算法 核心是根据自己的业务特征来选取一些指标,来表达服务实例的负载, 比如: * 错误率等其他服务指标 * CPU、IO、网络负载等硬件指标 而要知道这些指标,除了客户端自己统计之外,还有一些其他方法,比如: * 服务端每次将指标的值写到注册中心,注册中心通知客户端 * 服务端每次响应的时候,额外带上自己的指标 * 利用可观测性平台,从可观测性平台获得数据 # 路由策略 虽然负载均衡能找出负载最轻的节点,但仅此并不足够,因为它没有考虑到特定的业务需求。例如: * 在A/B测试中,A的请求必须发送到A的节点; * 在全链路压测中,压测流量只能发送到指定的测试节点; * 对于VIP服务,VIP的请求应发送到高性能的服务器上; * 在联调或者debug过程中,请求需要发送到特定的机器 我们通常将满足这些需求的策略成为路由策略。 微服务领域中,路由策略的本质是微服务框架根据用户设定的流量转发规则,将满足条件的RPC请求转发到特定的服务实例。常见的有以下几种: 1. 标签路由:为服务端实例添加不同的标签(可以由服务器自身或客户端进行标记),然后将特定请求路由到具有某个标签的服务实例。 2. 健康路由:根据实时情况,把服务实例分为健康和不健康两类,每次发送请求仅向标记为健康的实例发送(这与标签路由相似,也有些像负载均衡)。 其实,不管是什么路由,本质上就是为了在负载之前过滤掉一些节点,然后在可用的节点上做负载均衡。 拿简单的A/B流量分组举例, 思路如下: ![A/B流量分组](https://cdn.nlark.com/yuque/0/2024/png/3004213/1720511432524-8caa4820-ad89-43d2-b8c7-80e219189869.png?x-oss-process=image%2Fformat%2Cwebp%2Fresize%2Cw_1010%2Climit_0) 1. 在整个链路里面带上一个a/b标记 2. 进程内整个a/b标记位放在context.context里 3. 在服务端重建ctx(gRPC会帮我们重建context), 在负载均衡之前,先根据 a/b接口筛选节点 4. 发送请求到具体的服务端节点上 # 失败策略 RPC调用失败后有两种决策: * Failover:当RPC调用失败后,使用这个策略会再次尝试调用,但这次会选择一个新的节点进行通讯。这样可以在原节点出现问题时,通过切换到另一个健康节点来继续服务。 * Failfast:这种策略在遇到RPC调用失败时,不会进行重试,而是立即返回错误。这对于一些对时间敏感或者不能重复执行的操作非常有用,因为这样可以快速地向用户反馈问题,并避免不必要的等待和资源消耗。 failover的要点在于: * 重试的时候需要考虑重试间隔和重试次数 * 去除失败节点,选用新节点: * 新节点可能是随便挑选一个 * 新节点可能在不同机房的节点 * 新节点可能在不同的城市 大多数的failover没有那么精致,无非是在所有节点里面,去除已经失败的节点,剩下的随便挑一个。gRPC本身就支持重试,每一次重试,都要再经过 一次负载均衡,所以我们只需要设置重试并且选择**合适**(能选择其他节点)的负载均衡策略。 gRPC的重试策略可以通过服务配置(service config)来进行设置。在服务配置中,可以定义"retryPolicy"字段来描述重试行为。以下是一个例子: ```yaml { "methodConfig": [ { "name": [ { "service": "example.grpc.service.ServiceName" } ], "retryPolicy": { "maxAttempts": 5, "initialBackoff": "0.2s", "maxBackoff": "1s", "backoffMultiplier": 2, "retryableStatusCodes": [ "UNAVAILABLE" ] } } ] } ``` 这个服务配置指定了对于服务"example.grpc.service.ServiceName"的调用应该尝试最多5次,在每次重试时,等待时间会从0.2秒开始,并在每次重试后增加两倍,直到达到最大的1秒。仅当返回的状态码为"UNAVAILABLE"时,才会触发重试。 注意,因为在gRPC的负载均衡接口里,我们无法判断请求是不是重试请求,所以我们只能选择那些每次都选出不同节点的负载均衡算法。而像哈希之类的算法, 同一个请求选中的都是同一个节点,所以就没办法达成failover的效果。 # 广播broadcast 广播在RPC里指的是将请求发送到所有节点,它在gRPC里比较难实现,只能借助于gRPC的拦截器。gRPC的拦截器(Interceptors)可以根据调用类型进行分类,主要分为拦截一元(Unary)调用和拦截流(Stream)调用: * 一元(Unary)拦截器:这类拦截器处理的是简单的请求-响应类型的方法调用。客户端发送一个请求到服务器,服务器返回一个响应。 * 流(Stream)拦截器:这类拦截器处理的是流类型的方法调用 使用拦截器实现广播的思路如下: 1. 利用拦截器捕获调用 2. 利用注册中心获得所有可用的服务端实例 3. 在拦截器内部遍历服务端实例