这是我参与「第三届青训营 -后端场」笔记创作活动的第3篇笔记。
本次笔记主要介绍的是在第一个版本中使用Nacos作为配置中心以及微服务的服务发现组件。而Nacos自带的负载均衡是做在L7上的,与gRPC留出来的负载均衡的接口是不相匹配的,因此想要实现gRPC的L4上的负载均衡的重难点是在实现gRPC的负载均衡需要实现的resolver(解析器)。
Nacos的部署与使用
Nacos是阿里巴巴推出的一个开源的项目,主要做服务发现、配置和管理微服务。在这一版本中我选择了在docker中部署单机版本的Nacos。
在nacos启动之后,浏览器输入http://127.0.0.1:8848/nacos/进入nacos的控制台(初始账户和密码都是nacos)。
在命名空间中给项目新建一个命名空间,这时候在配置管理和服务管理中就会出现新建的命名空间与Id,如图:
接下来你就可以使用这个
命名空间ID通过官方SDK来使用Nacos的配置管理和服务管理功能了。如下示例代码中:
sc := []constant.ServerConfig{
{
IpAddr: NacosConfig.Host,
Port: NacosConfig.Port,
},
}
cc := constant.ClientConfig{
NamespaceId: NacosConfig.Namespace,
TimeoutMs: 5000,
NotLoadCacheAtStart: true,
LogDir: "nacos/log",
CacheDir: "nacos/cache",
LogLevel: "warn",
}
ConfigClient, err = clients.NewConfigClient(
vo.NacosClientParam{
ClientConfig: &cc,
ServerConfigs: sc,
},
)
NamingClient, err = clients.NewNamingClient(
vo.NacosClientParam{
ClientConfig: &cc,
ServerConfigs: sc,
},
)
其中的ConfigClient就是配置中心的客户端,通过读取在配置中心的统一配置,可以使微服务的配置达到统一,并且支持配置的热更新。
而服务管理功能中,使用RegisterInstance接口来注册服务,DeregisterInstance接口来注销服务。如果存在同名服务,则可以使用SelectAllInstances接口来获得所有服务实例的地址(ip:port)。还可以使用SelectOneHealthyInstance来得到一个健康实例的地址(ip:port),而这个接口本身是通过加权轮训实现负载均衡的。
Nacos上的gRPC服务的负载均衡
虽然通过这样的负载均衡方式每次获得一个健康实例的地址(ip:port)可以实现L7上的负载均衡,但是使用这样的方式会每次都建立一个新的连接,而建立新连接的消耗也是不能忽视的;而且在这种情况下,过多的连接建立会导致tcp端口占用过多,也没有实现连接的复用,降低了服务的并发度。
由于 gRPC client 和 server 建立的长连接, 因而基于连接的负载均衡没有太大意义, 所以 gRPC 负载均衡是基于每次调用. 也就是你在同一个 client 发的请求也希望它被负载均衡到所有服务端.
gRPC本身没有提供服务发现的服务的,但是gRPC从设计上留下了负载均衡和服务发现的接口的。并且与Nacos本身的接口实现L7负载均衡相比,gRPC设计留下的方案是使用连接池的,这样就避免了每次使用服务发现与负载均衡的时候需要重新建立一个新的连接,达到了连接的复用。
原理
gRPC 采取的客户端负载均衡, 主要由两个客户端组件来完成:
- 维护目标服务名称和真实地址列表的映射 (resolver)
- 控制该和哪些真实地址建立连接, 该将请求发送给哪个服务实例 (balancer)
这种方式是客户端直接请求服务端, 所以没有额外性能开销。这种模式客户端可能会和多个服务端建立连接(balancer 部分详细介绍),gRPC的client connection背后其实维护了一组subConnections,每个subConnection会与一个服务端建立连接。详情见文档 Load Balance in gRPC。
其中核心的部分就是resolver的实现。
gRPC 客户端在建立连接时, 地址解析部分大致会有以下几个步骤:
- 根据传入地址的
Scheme在全局 resolver map (上面代码中的 m) 中找到与之对应的 resolver (Builder) - 将地址解析为
Target作为参数调用resolver.Build方法实例化出Resolver - 使用用户实现
Resolver中调用cc.UpdateState传入的State.Addrs中的地址建立连接
例如: 注册一个 nacos resolver, m 值会变为 {nacos: nacosResolver}, 当连接地址为 nacos:///xxx 时, 会被匹配到 nacosResolver, 并且地址会被解析为 &Target{Scheme: "nacos", Authority: "", Endpoint: "xxx"}, 之后作为调用 testResolver.Build 方法的参数。
我为实现的具体思路如下:
- 传入服务管理过程所需的各种参数
c := &nacosResolver{
namingClient: global.NamingClient,
cc: cc,
mode: mode,
close: make(chan bool),
interval: interval,
serviceName: serviceName,
groupName: groupName,
nameSpaceId: nameSpaceId,
clusters: clusterName,
}
- 使用在init()阶段的
NamingClient,通过调用接口getInstances()开始时候将所有地址注册进resolver的连接中
addrs, err := c.getInstances()
if err != nil {
c.cc.ReportError(err)
} else {
c.cc.UpdateState(resolver.State{Addresses: addrs})
}
- 使用轮训或者监听来实现服务的动态扩缩容
if c.mode == modeHeartBeat {
tick := time.NewTicker(c.interval)
for {
select {
case <-tick.C:
addrs, err := c.getInstances()
if err != nil {
c.cc.ReportError(err)
} else {
c.cc.UpdateState(resolver.State{Addresses: addrs})
}
case <-c.close:
return
}
}
} else {
err := c.namingClient.Subscribe(&vo.SubscribeParam{
ServiceName: c.serviceName,
GroupName: c.groupName, // 默认值DEFAULT_GROUP
Clusters: []string{c.clusters}, // 默认值DEFAULT
SubscribeCallback: func(services []model.SubscribeService, e error) {
addrs, err := c.getInstances()
if err != nil {
c.cc.ReportError(err)
} else {
c.cc.UpdateState(resolver.State{Addresses: addrs})
}
},
})
if err != nil {
c.cc.ReportError(err)
}
}
整个过程是在一个生命周期在全局的一个协程中进行的。
我认为在整个项目过程中gRPC的的负载均衡和扩缩容的检测是基于gRPC微服务的重点,在不同部署环境和不同技术栈中都是非常关键的