在上一课时中,我们已经介绍了负载均衡的相关概念以及在服务高可用架构中的重要性,也了解了几种主流负载均衡算法的实现。在本课时中,我们将在 Go 微服务实例中具体使用负载均衡技术,并详细说明如何基于服务发现来实现负载均衡的微服务间 HTTP 调用。

基于服务发现和注册的负载均衡

我们仍然以之前课时提到的电商商品系统为例,商品详情页面需要展示下游评论系统的具体评论信息,所以商品系统通过 HTTP 请求调用评论系统获取商品评论。下图展示了两个系统的交互情况:

商品和评论系统交互示意图

评论系统单实例性能欠佳,需要多实例部署,平均请求压力;而商品系统则需要在发起请求前,从所有可用的评论系统实例中挑选一个,再发起请求。查看商品详情的外部请求数量往往在促销时增加,因此评论系统实例的数量并不是固定的,而是动态变化的,请求量大时增加服务实例,请求量少时,则减少服务实例。

所以,商品系统的负载均衡机制需要基于服务注册与发现机制,动态获取评论系统的可用实例列表,而不是将其固化在代码或者配置文件中。

下面,我们就来具体看一下如何在商品系统项目中实现负载均衡。本课时的相关代码在课程代码库中的 section28 文件夹下,地址为https://github.com/longjoy/micro-go-course。

服务初始化

首先,我们需要使用前面 14 课时讲解的基于 Consul 的负载均衡客户端(若是记不太清了,你可以回过头再温故一下)。下面代码展示了商品系统在启动时的初始化过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 传入consul的地址和端口初始化服务注册和发现客户端
client := discovery.NewDiscoveryClient(*consulAddr, *consulPort)
// 使用uuid生成客户端实例ID
instanceId := *serviceName + "-" + uuid.New().String()
// 将实例自己注册到 consul 上,包括服务名称,实例ID,健康检查地址,host和端口等
err := client.Register(context.Background(), *serviceName, instanceId, "/health", *serviceAddr, *servicePort, nil, nil)
// 初始化负载均衡器,可以初始化携带不同负载均衡策略的负载均衡器
loadbalancer := loadbalancer.NewRandomLoadBalancer()
// 使用服务注册与发现客户端和负载均衡器初始化service
srv := service.NewGoodsServiceImpl(client, loadbalancer)

其中,loadbalancer是本课时的重点,它是定义负载均衡策略的接口,只有一个 SelectService 方法,接受 ServiceInstance 也就是可用服务列表作为参数,根据一定负载均衡策略从服务实例列表中选择一个服务实例返回。而可用服务列表则可以通过服务注册和发现客户端从 Consul 等服务注册和发现中心获取。

1
2
3
4
5
6
7
8
// 负载均衡器
type LoadBalance interface {
// 基于可用服务列表的负载均衡接口
SelectService(service []common.ServiceInstance) (common.Service Instance, error)
// 基于可用服务列表和键值辅助的负载均衡接口
SelectServiceByKey(service []*discovery.InstanceInfo, key string) (*discovery.InstanceInfo, error)
}

具体的负载均衡器都要实现该接口,并给出具体不同负载均衡策略的 SelectService 方法的实现,比如上一课时中介绍的随机负载均衡策略和权重平滑负载均衡策略。

我们下面再讲解一下项目中使用的一致性负载均衡策略,根据商品 ID 将不同的获取商品评价的 HTTP 请求分发到某一个固定的评级服务实例上,这样有利于使用本地缓存等缓存机制,提高系统的性能。

一致性哈希负载均衡的核心思想是首先将服务器 key 进行 Hash 运算,将其映射到一个圆形的哈希环上,key 计算出来的整数值则为该服务实例在哈希环上的位置,然后再将请求的 key 值,用同样的方法计算出哈希环上的位置,按顺时针方向,找到第一个大于或等于该哈希环位置的服务实例 key,从而得到本次请求需要分配的服务实例。

一致性哈希负载均衡示意图

如上图所示,服务实例 node1~4 都计算出 Hash 值并映射到哈希环上,而请求的 key 值也能计算出 Hash 值并映射到环上,如图右侧的键值,然后按照顺时针方向找到了服务实例 node2,则该请求就被负载转发到服务实例上。

一致性哈希负载均衡策略能够很好地应对服务实例上线或者下线的场景,以防止大量请求被负载转发到不同的服务实例,减少其对整体系统带来的影响,而一般的哈希负载均衡策略就很难满足这点。比如说服务实例 node2 突然宕机下线,按照该算法,只有 Hash 值落在在服务实例 node1 和 node2 之间的请求受到了影响,被负载转发到了服务实例 node4 上,其他的大部分请求不受影响。

一致性哈希负载均衡策略的具体实现,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type HashLoadBalancer struct {
}

func (loadBalance *HashLoadBalancer) SelectServiceByKey(services []*discovery.InstanceInfo, key string) (*discovery.InstanceInfo, error) {
// 检查可用服务实例列表不为空
lens := len(services)
if services == nil || lens == 0 {
return nil, errors.New("service instances are not exist")
}
// 使用crc32将key值算出hash值
crcTable := crc32.MakeTable(crc32.IEEE)
hashVal := crc32.Checksum([]byte(key), crcTable)
// 根据hash值和列表长度取余获得服务实例
index := int(hashVal) % lens
return services[index],nil
}

在使用该负载均衡策略时,我们就将商品 ID 作为 key 值传递进来,该算法会使用 crc32 计算该商品 ID 对应的 Hash 值,然后根据取余结果从可用服务列表选出本次负载均衡的目标实例。

发起网络请求

商品系统服务和评论系统初始化启动好之后,对外建立 HTTP 服务,当有用户查看详情时,商品系统会向评论系统发起网络请求,具体代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func (service *GoodsDetailServiceImpl) GetGoodsComments(ctx context.Context, id string) (common.CommentResult, error) {
var result common.CommentResult
// 使用服务注册和发现客户端从consul中获取名为comment的可用服务实例列表
serviceName := "comment"
instances, err := service.discoveryClient.DiscoverServices(ctx, serviceName)
.... // 省略,异常检查
// 使用负载均衡器根据商品id和可用服务实例列表获取本次网络调用的目标comment服务实例
selectedInstance, err2 := service.loadbalancer.SelectService(instances,id)

if err2 != nil {
log.Printf("loadbalancer get selected instance  err: %s", err2)
return result, ErrLoadBalancer
}
call_err := hystrix.Do(serviceName, func() error {
// 使用选中comment服务实例的信息来拼接HTTP请求
requestUrl := url.URL{
Scheme:   "http",
Host:     selectedInstance.Address + ":" + strconv.Itoa(selectedInstance.Port),
Path:     "/comments/detail",
RawQuery: "id=" + id,
}
resp, err := http.Get(requestUrl.String())
.... // 省略
}, func(e error) error {
// 断路器打开时的处理逻辑,本示例是直接返回错误提示
return errors.New("Http errors!")
})
.... //省略
}

所以,每次发起查询商品评论信息的网络请求前,都会先调用服务注册和发现客户端的 DiscoverServices 方法来获取当前 comment 可用的服务实例列表,然后调用负载均衡器的 SelectService 方法,根据商品的 ID 从可用列表中选中一个服务实例,最后根据该服务实例的信息构建网络请求,比如 host 和 port 信息等。整个过程如下图所示:

基于服务发现和注册的负载均衡示意图

以上就是在 Go 微服务中实现客户端负载均衡的主流实现原理,很多开源负载均衡器(比如,Ribbon 等)都是以这套原理实现的,不过这个过程还是有许多可以优化的细节,比如负载均衡客户端可以使用缓存可用服务列表数据等方式,来避免每次都从 Consul 处获取可用服务列表数据,以此提高效率。

运行展示

下面,我们就来具体运行和展示一下本课时的案例项目。

首先,我们启动一个商品系统服务(good)和三个评论系统服务(comment),它们都会将自己注册到服务注册和发现中心 Consul 上。如下是 Consul 相关的截图:

Consul 所有服务列表截图

我们可以从 Consul 的服务列表页面发现有三个 comment 服务实例和一个商品服务实例,这三个 comment 服务实例的具体信息如下图所示,从图中可以看出,它们的 host 信息都是127.0.0.1,但是端口号不同,你也可以将这三个 comment 服务实例部署在不同 IP 地址的服务器上,这样它们的 host 信息就不一样了。

Comment 服务实例具体信息截图

接着,我们使用 postman 或者 curl 向 good 发起查看多个商品详情的网络请求,请求多次,具体命令如下所示:

1
2
3
curl http://127.0.0.1:12313/goods/detail?id=1
curl http://127.0.0.1:12313/goods/detail?id=2
curl http://127.0.0.1:12313/goods/detail?id=3

然后,我们到 good 服务实例的日志中进行查看,可以看到如下日志信息:

1
2
3
4
5
6
get good 1 comment from comment service host:127.0.0.1 port 13312
get good 2 comment from comment service host:127.0.0.1 port 11312
get good 3 comment from comment service host:127.0.0.1 port 12312
get good 1 comment from comment service host:127.0.0.1 port 13312
get good 2 comment from comment service host:127.0.0.1 port 11312
get good 3 comment from comment service host:127.0.0.1 port 12312

从日志中可以看出,不同 ID 的商品会请求不同的 comment 服务实例,并且不会改变请求的实例,这正是使用一致性哈希负载均衡策略想要得到的效果。

接下来,我们将端口号为11312的 comment 服务下线,此时就只有两个 comment 服务实例,我们再次发起上述的查询商品详情的请求,可以看到如下日志:

1
2
3
4
5
6
get good 1 comment from comment service host:127.0.0.1 port 13312
get good 2 comment from comment service host:127.0.0.1 port 13312
get good 3 comment from comment service host:127.0.0.1 port 12312
get good 1 comment from comment service host:127.0.0.1 port 13312
get good 2 comment from comment service host:127.0.0.1 port 13312
get good 3 comment from comment service host:127.0.0.1 port 12312

从这段日志我们可以看出:原来 ID 为 1 的商品详情会向端口为 13312 的 comment 服务实例进行请求,现在也是如此;而且 ID 为 3 的商品详情也跟原来一样,都是向端口为 12312 的服务实例进行请求,二者没有发生变化,这也是一致性哈希负载均衡策略的功效。

小结

在本课时,我为你讲述了在 Go 微服务中使用基于服务注册和发现的负载均衡机制,通过该机制,可以很方便地为下游集群增加和删除服务实例,上游服务也可以对其进行自动适配和负载均衡。除此之外,我们还以商品详情为例,给出了 Go 微服务负载均衡机制的具体实现,向你展示了使用一致性哈希负载均衡策略将请求发送给不同评论服务实例的场景。

文中讲解的基于服务注册和发现机制的负载均衡过程还有大量可以优化的细节,你还知道哪些呢?欢迎你留言,我们一起讨论。

-– ### 精选评论 ##### **源: > 在serviceMesh结构下,是否可以把获取负载均衡调度结果的逻辑从业务代码中解耦出来呢 比如结合k8s的service申明 ##### **波: > 可以讲一下grpc在istio下面的负载均衡实现方式吗? ######     讲师回复: >     istio支持grpc负载均衡,详情可以参考:https://cloud.google.com/solutions/using-istio-for-internal-load-balancing-of-grpc-services?hl=zh-cn