在正式开始讨论之前,我们先来区分清楚几个容易混淆的概念,分别是前面两讲中我介绍过的服务发现、网关路由,以及这节课要探讨的负载均衡,还有在下一讲中将会介绍的调用容错。这几个技术名词都带有“从服务集群中寻找到一个合适的服务来调用”的含义,那么它们之间的差别都体现在哪呢?下面我就通过一个具体的案例场景来给你说明一下。
假设,你目前身处广东,要上 Fenix's Bookstore 购买一本书。在程序业务逻辑里,购书的其中一个关键步骤是调用商品出库服务来完成货物准备,在代码中该服务的调用请求为:
PATCH https://warehouse:8080/restful/stockpile/3
{amount: -1}
假设 Fenix's Bookstore 是个大书店,在北京、武汉、广州的机房均部署有服务集群,那么此时按顺序会发生以下事件:
1.首先是将 warehouse 这个服务名称转换为了恰当的服务地址。
注意,这里的“恰当”是个宽泛的描述,一种典型的“恰当”就是因为调用请求来自于广东,DNS 层面的负载均衡就会优先分配给传输距离最短的广州机房来应答。
其实按常理来说,这次出库服务的调用应该是集群内的流量,而不是用户浏览器直接发出的请求。所以尽管结果都一样,但更接近实际的情况应该是用户访问首页时,已经被 DNS 服务器分配到了广州机房,请求出库服务时,应优先选择同机房的服务进行调用,此时该服务的调用请求就变为:
PATCH https://guangzhou-ip-wan:8080/restful/stockpile/3
2.然后,广州机房的服务网关将该请求与配置中的特征进行比对,由 URL 中的“/restful/stockpile/**”得知该请求访问的是商品出库服务。因此,将请求的 IP 地址转换为内网中 warehouse 服务集群的入口地址:
PATCH https://warehouse-gz-lan:8080/restful/stockpile/3
3.由于集群中部署有多个 warehouse 服务,所以在收到调用请求后,负载均衡器要在多个服务中根据某种标准或均衡策略(可能是随机挑选,也可能是按顺序轮询,或者是选择此前调用次数最少那个,等等),找出要响应本次调用的服务,我们称其为 warehouse-gz-lan-node1。
PATCH https://warehouse-gz-lan-node1:8080/restful/stockpile/3
4.接着,访问 warehouse-gz-lan-node1 服务,没有返回需要的结果,而是抛出了 500 错。
HTTP/1.1 500 Internal Server Error
5.那么根据预置的故障转移(Failover)策略,负载均衡器会进行重试,把调用分配给能够提供该服务的其他节点,我们称其为 warehouse-gz-lan-node2。
PATCH https://warehouse-gz-lan-node2:8080/restful/stockpile/3
6.最后,warehouse-gz-lan-node2 服务返回商品出库成功。
HTTP/1.1 200 OK
所以,从整体上看,前面步骤中的
1(找到对应的服务)、
2(服务转内网url地址)、
3(找个可以用的节点)、
5(节点挂了换个节点),
就分别对应了服务发现、网关路由、负载均衡和调用容错;
而从细节上看,其中的部分职责又是有交叉的,并不是注册中心就只关心服务发现,网关只关心路由,均衡器只关心负载均衡。
比如,步骤 1 服务发现的过程中,“根据请求来源的物理位置来分配机房”这个操作,本质上是根据请求中的特征(地理位置)来进行流量分发,这其实是一种路由行为。在实际的系统中,DNS 服务器(DNS 智能线路)、服务注册中心(如 Eureka 等框架中的 Region、Zone 概念)或者负载均衡器(可用区负载均衡,如 AWS 的 NLB,或 Envoy 的 Region、Zone、Sub-zone)里都有可能实现路由功能。
而且除此之外,你有没有感觉到这个网络调用的过程好像过于繁琐了,一个从广州机房内网发出的服务请求,绕到了网络边缘的网关、负载均衡器这些设施上,然后再被分配回内网中另外一个服务去响应。这不仅消耗了带宽,降低了性能,也增加了链路上的风险和运维的复杂度。
可是,如果流量不经过这些设施,它们相应的职责就无法发挥作用了:如果不经过负载均衡器的话,甚至连请求应该具体交给哪一个服务去处理都无法确定。
所以既然如此,我们有什么办法可以简化这个调用过程吗?这就需要用到客户端负载均衡器了。
以前,负载均衡器大多只部署在整个服务集群的前端,将用户的请求分流到各个服务进行处理,这种经典的部署形式现在被称为集中式的负载均衡。
而随着微服务的日渐流行,服务集群收到的请求来源就不再局限于外部了,越来越多的访问请求是由集群内部的某个服务发起,由集群内部的另一个服务进行响应的。对于这类流量的负载均衡,既有的方案依然是可行的,但针对内部流量的特点,直接在服务集群内部消化掉,肯定是更合理、更受开发者青睐的办法。
由此,一种全新的、独立位于每个服务前端的、分散式的负载均衡方式正逐渐变得流行起来,这就是本节课我们要讨论的主角:客户端负载均衡器(Client-Side Load Balancer)。
1
客户端负载均衡器的理念提出以后,此前的集中式负载均衡器也有了一个方便与它对比的名字,“服务端负载均衡器”(Server-Side Load Balancer)。
从上图中,你其实能够清晰地看到这两种均衡器的关键差别所在:服务端负载均衡器是集中式的,同时为多个节点提供服务,而客户端负载均衡器是和服务实例一一对应的,而且与服务实例并存于同一个进程之内。这能为它带来很多好处,比如说:
但是你也要清楚,客户端均衡器并不是银弹,它的缺点同样是不少的:
在 Java 领域,客户端负载均衡器中最具代表性的产品,就是 Netflix Ribbon 和 Spring Cloud Load Balancer 了,随着微服务的流行,它们在 Java 微服务中已经积聚了相当可观的使用者。
而到了最近两三年,服务网格(Service Mesh)开始盛行,另一种被称为“代理客户端负载均衡器”(Proxy Client-Side Load Balancer,后面就简称“代理均衡器”)的客户端均衡器变体形式,开始引起不同编程语言的微服务开发者的共同关注,因为它解决了此前客户端均衡器的大部分缺陷。
代理均衡器对此前的客户端负载均衡器的改进,其实就是将原本嵌入在服务进程中的均衡器提取出来,放到边车代理中去实现,它的流量关系如下图所示:
2
这里你可以发现,虽然代理均衡器与服务实例不再是进程内通讯,而是通过虚拟化网络进行数据交换的,数据要经过操作系统的协议栈,要进行打包拆包、计算校验和、维护序列号等网络数据的收发等步骤,流量比起之前的客户端均衡器确实多经历了一次代理过程。
不过,Kubernetes 严格保证了同一个 Pod 中的容器不会跨越不同的节点,相同 Pod 中的容器共享同一个网络和Linux UTS 名称空间,因此代理均衡器与服务实例的交互,仍然要比真正的网络交互高效且稳定得多,代价很小。但它从服务进程中分离出来的收益则是非常明显的:
所以总体而言,边车代理这种通过同一个 Pod 的独立容器实现的负载均衡器,就是目前处理微服务集群内部流量最理想的方式。只是服务网格本身还不够成熟,拭目以待。
这节课,我们从“如何将流量转发到最恰当的服务”开始,讨论了客户端负载均衡器出现的背景、它与服务端负载均衡器之间的差异,以及通过代理来实现客户端负载均衡器的差别。最后,借助本节课建立的上下文场景,我还给你介绍了地域与可用区域的概念。这些概念不仅在购买、使用云计算服务时会用到,还直接影响到了应用程序中路由、负载均衡的请求分发形式。