11 | 服务发布和引用的实践
服务发布和引用常见的三种方式:Restful API、XML配置以及IDL文件。今天我将以XML配置方式为例,给你讲解服务发布和引用的具体实践以及可能会遇到的问题。
XML配置方式的服务发布和引用流程
1. 服务提供者定义接口
服务提供者发布服务之前首先要定义接口,声明接口名、传递参数以及返回值类型,然后把接口打包成JAR包发布出去。
比如下面这段代码,声明了接口UserLastStatusService,包含两个方法getLastStatusId和getLastStatusIds,传递参数一个是long值、一个是long数组,返回值一个是long值、一个是map。
package com.weibo.api.common.status.service;
public interface UserLastStatusService {
* @param uids
* @return
*/
public long getLastStatusId(long uid);
/**
*
* @param uids
* @return
*/
public Map<Long, Long> getLastStatusIds(long[] uids);
}
2. 服务提供者发布接口
服务提供者发布的接口是通过在服务发布配置文件中定义接口来实现的。
下面我以一个具体的服务发布配置文件user-last-status.xml来给你讲解,它定义了要发布的接口userLastStatusLocalService,对外暴露的协议是Motan协议,端口是8882。并且针对两个方法getLastStatusId和getLastStatusIds,通过requestTimeout=“300”单独定义了超时时间是300ms,通过retries=“0”单独定义了调用失败后重试次数为0,也就是不重试。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd
">
<motan:service ref="userLastStatusLocalService"
requestTimeout="50" retries="2" interface="com.weibo.api.common.status.service.UserLastStatusService"
basicService="serviceBasicConfig" export="motan:8882">
<motan:method name="getLastStatusId" requestTimeout="300"
retries="0" />
<motan:method name="getLastStatusIds" requestTimeout="300"
retries="0" />
</motan:service>
</beans>
然后服务发布者在进程启动的时候,会加载配置文件user-last-status.xml,把接口对外暴露出去。
3. 服务消费者引用接口
服务消费者引用接口是通过在服务引用配置文件中定义要引用的接口,并把包含接口定义的JAR包引入到代码依赖中。
下面我再以一个具体的服务引用配置文件user-last-status-client.xml来给你讲解,它定义服务消费者引用了接口commonUserLastStatusService,接口通信协议是Motan。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd
">
<motan:protocol name="motan" default="true" loadbalance="${service.loadbalance.name}" />
<motan:basicReferer id="userLastStatusServiceClientBasicConfig"
protocol="motan" />
<!-- 导出接口 -->
<motan:referer id="commonUserLastStatusService" interface="com.weibo.api.common.status.service.UserLastStatusService"
basicReferer="userLastStatusServiceClientBasicConfig" />
</beans>
然后服务消费者在进程启动时,会加载配置文件user-last-status-client.xml来完成服务引用。
上面所讲的服务发布和引用流程看似比较简单,但在实际使用过程中,还是有很多坑的,比如在实际项目中经常会遇到这个问题:一个服务包含了多个接口,可能有上行接口也可能有下行接口,每个接口都有超时控制以及是否重试等配置,如果有多个服务消费者引用这个服务,是不是每个服务消费者都必须在服务引用配置文件中定义?
服务发布和引用的那些坑
在一个服务被多个服务消费者引用的情况下,由于业务经验的参差不齐,可能不同的服务消费者对服务的认知水平不一,比如某个服务可能调用超时了,最好可以重试来提供调用成功率。但可能有的服务消费者会忽视这一点,并没有在服务引用配置文件中配置接口调用超时重试的次数,因此最好是可以在服务发布的配置文件中预定义好类似超时重试次数,即使服务消费者没有在服务引用配置文件中定义,也能继承服务提供者的定义。这就是下面要讲的服务发布预定义配置。
1. 服务发布预定义配置
以下面的服务发布配置文件server.xml为例,它提供了一个服务contentSliceRPCService,并且明确了其中三个方法的调用超时时间为500ms以及超时重试次数为3。
<motan:service ref="contentSliceRPCService" interface="cn.sina.api.data.service.ContentSliceRPCService"
basicService="serviceBasicConfig" export="motan:8882" >
<motan:method name="saveContent" requestTimeout="500"
retries="3" />
<motan:method name="deleteContent" requestTimeout="500"
retries="3" />
<motan:method name="updateContent" requestTimeout="500"
retries="3" />
</motan:service>
假设服务引用的配置文件client.xml的内容如下,那么服务消费者就会默认继承服务发布配置文件中设置的方法调用的超时时间以及超时重试次数。
<motan:referer id="contentSliceRPCService" interface="cn.sina.api.data.service.ContentSliceRPCService" basicReferer="contentSliceClientBasicConfig" >
</motan:referer>
通过服务发布预定义配置可以解决多个服务消费者引用服务可能带来的配置复杂的问题,这样是不是最优的解决方案呢?
实际上我还遇到过另外一种极端情况,一个服务提供者发布的服务有上百个方法,并且每个方法都有各自的超时时间、重试次数等信息。服务消费者引用服务时,完全继承了服务发布预定义的各项配置。这种情况下,服务提供者所发布服务的详细配置信息都需要存储在注册中心中,这样服务消费者才能在实际引用时从服务发布预定义配置中继承各种配置。
这里就存在一种风险,当服务提供者发生节点变更,尤其是在网络频繁抖动的情况下,所有的服务消费者都会从注册中心拉取最新的服务节点信息,就包括了服务发布配置中预定的各项接口信息,这个信息不加限制的话可能达到1M以上,如果同时有上百个服务消费者从注册中心拉取服务节点信息,在注册中心机器部署为百兆带宽的情况下,很有可能会导致网络带宽打满的情况发生。
面对这种情况,最好的办法是把服务发布端的详细服务配置信息转移到服务引用端,这样的话注册中心中就不需要存储服务提供者发布的详细服务配置信息了。这就是下面要讲的服务引用定义配置。
2. 服务引用定义配置
以下面的服务发布配置文件为例,它详细定义了服务userInfoService的各个方法的配置信息,比如超时时间和重试次数等。
<motan:service ref="userInfoService" requestTimeout="50" retries="2" interface="cn.sina.api.user.service.UserInfoService" basicService="serviceBasicConfig">
<motan:method name="addUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="modifyUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addUserTags" requestTimeout="300" retries="0"/>
<motan:method name="delUserTags" requestTimeout="300" retries="0"/>
<motan:method name="processUserCacheByNewMyTriggerQ" requestTimeout="300" retries="0"/>
<motan:method name="modifyObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="add" requestTimeout="300" retries="0"/>
<motan:method name="deleteObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="getUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttrList" requestTimeout="300" retries="1" />
<motan:method name="getAllUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttr2" requestTimeout="300" retries="1" />
</motan:service>
可以像下面一样,把服务userInfoService的详细配置信息转移到服务引用配置文件中。
<motan:referer id="userInfoService" interface="cn.sina.api.user.service.UserInfoService" basicReferer="userClientBasicConfig">
<motan:method name="addUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="modifyUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addUserTags" requestTimeout="300" retries="0"/>
<motan:method name="delUserTags" requestTimeout="300" retries="0"/>
<motan:method name="processUserCacheByNewMyTriggerQ" requestTimeout="300" retries="0"/>
<motan:method name="modifyObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="addObjectUserInfo" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectUserPortrait" requestTimeout="300" retries="0"/>
<motan:method name="updateObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="add" requestTimeout="300" retries="0"/>
<motan:method name="deleteObjectManager" requestTimeout="300" retries="0"/>
<motan:method name="getUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttrList" requestTimeout="300" retries="1" />
<motan:method name="getAllUserAttr" requestTimeout="300" retries="1" />
<motan:method name="getUserAttr2" requestTimeout="300" retries="1" />
</motan:referer>
这样的话,服务发布配置文件可以简化为下面这段代码,是不是信息精简了许多。
<motan:service ref="userInfoService" requestTimeout="50" retries="2" interface="cn.sina.api.user.service.UserInfoService" basicService="serviceBasicConfig">
</motan:service>
在进行类似的服务详细信息配置,由服务发布配置文件迁移到服务引用配置文件的过程时,尤其要注意迁移步骤问题,这就是接下来我要给你讲的服务配置升级问题。
3. 服务配置升级
实际项目中,我就经历过一次服务配置升级的过程。由于引用服务的服务消费者众多,并且涉及多个部门,升级步骤就显得异常重要,通常可以按照下面步骤操作。
- 各个服务消费者在服务引用配置文件中添加服务详细信息。
- 服务提供者升级两台服务器,在服务发布配置文件中删除服务详细信息,并观察是否所有的服务消费者引用时都包含服务详细信息。
- 如果都包含,说明所有服务消费者均完成升级,那么服务提供者就可以删除服务发布配置中的服务详细信息。
- 如果有不包含服务详细信息的服务消费者,排查出相应的业务方进行升级,直至所有业务方完成升级。
总结
简单来说就是服务提供者定义好接口,并且在服务发布配置文件中配置要发布的接口名,在进程启动时加载服务发布配置文件就可以对外提供服务了。而服务消费者通过在服务引用配置文件中定义相同的接口名,并且在服务引用配置文件中配置要引用的接口名,在进程启动时加载服务引用配置文件就可以引用服务了。
在业务具体实践过程中可能会遇到引用服务的服务消费者众多,对业务的敏感度参差不齐的问题,所以在服务发布的时候,最好预定义好接口的各种配置。在服务规模不大,业务比较简单的时候,这样做比较合适。但是对于复杂业务,虽然服务发布时预定义好接口的各种配置,但在引用的服务消费者众多且同时访问的时候,可能会引起网络风暴。这种情况下,比较保险的方式是,把接口的各种配置放在服务引用配置文件里。
在进行服务配置升级过程时,要考虑好步骤,在所有服务消费者完成升级之前,服务提供者还不能把服务的详细信息去掉,否则可能会导致没有升级的服务消费者引用异常。
12 | 如何将注册中心落地?
掌握了服务注册和发现的原理之后,我们就需要考虑如何把注册中心落地实现。结合前面所讲的服务注册与发现的流程,在落地注册中心的过程中,我们需要解决一系列的问题,包括如何存储服务信息、如何注册节点、如何反注册、如何查询节点信息以及如何订阅服务变更等。这些问题你都知道如何解决吗?如果还没答案,没关系,下面我来给你一一讲解。
注册中心如何存储服务信息
注册中心既然是用来存储服务信息的,那么服务信息都包含哪些内容呢?
根据我的实践经验,服务信息除了包含节点信息(IP和端口号)以外,还包含其他一些信息,比如请求失败时重试的次数、请求结果是否压缩等信息。因此服务信息通常用JSON字符串来存储,包含多个字段,每个字段代表不同的含义。
除此之外,服务一般会分成多个不同的分组,每个分组的目的不同。一般来说有下面几种分组方式。
- 核心与非核心,从业务的核心程度来分。
- 机房,从机房的维度来分。
- 线上环境与测试环境,从业务场景维度来区分。
所以注册中心存储的服务信息一般包含三部分内容:分组、服务名以及节点信息,节点信息又包括节点地址和节点其他信息。从注册中心中获取的信息结构大致如下图所示。
具体存储的时候,一般是按照“服务-分组-节点信息”三层结构来存储,可以用下图来描述。Service代表服务的具体分组,Cluster代表服务的接口名,节点信息用KV存储。
搞清楚了注册中心存储服务信息的原理后,再来看下注册中心具体是如何工作的,包括四个流程。
- 服务提供者注册流程。
- 服务提供者反注册流程。
- 服务消费者查询流程。
- 服务消费者订阅变更流程。
注册中心是如何工作的
1. 如何注册节点
知道了服务的节点信息如何存储之后,服务注册流程是怎么样的呢?可以用下面这张流程图来描述。
根据我的经验,服务注册流程主要有下面几个步骤:
- 首先查看要注册的节点是否在白名单内?如果不在就抛出异常,在的话继续下一步。
- 其次要查看注册的Cluster(服务的接口名)是否存在?如果不存在就抛出异常,存在的话继续下一步。
- 然后要检查Service(服务的分组)是否存在?如果不存在则抛出异常,存在的话继续下一步。
- 最后将节点信息添加到对应的Service和Cluster下面的存储中。
2. 如何反注册
再来看下服务提供者节点反注册的流程,可以用下面这张流程图来描述。
根据我的经验,节点反注册流程主要包含下面几个步骤:
- 查看Service(服务的分组)是否存在,不存在就抛出异常,存在就继续下一步。
- 查看Cluster(服务的接口名)是否存在,不存在就抛出异常,存在就继续下一步。
- 删除存储中Service和Cluster下对应的节点信息。
- 更新Cluster的sign值。
3. 如何查询节点信息
关于服务消费者是如何从注册中心查询服务提供者的节点信息,可以用下面这张流程图来描述。
服务消费者查询节点信息主要分为下面几个步骤:
- 首先从localcache(本机内存)中查找,如果没有就继续下一步。这里为什么服务消费者要把服务信息存在本机内存呢?主要是因为服务节点信息并不总是时刻变化的,并不需要每一次服务调用都要调用注册中心获取最新的节点信息,只需要在本机内存中保留最新的服务提供者的节点列表就可以。
- 接着从snapshot(本地快照)中查找,如果没有就继续下一步。这里为什么服务消费者要在本地磁盘存储一份服务提供者的节点信息的快照呢?这是因为服务消费者同注册中心之间的网络不一定总是可靠的,服务消费者重启时,本机内存中还不存在服务提供者的节点信息,如果此时调用注册中心失败,那么服务消费者就拿不到服务节点信息了,也就没法调用了。本地快照就是为了防止这种情况的发生,即使服务消费者重启后请求注册中心失败,依然可以读取本地快照,获取到服务节点信息。
4. 如何订阅服务变更
最后看下,服务消费者如何订阅服务提供者的变更信息呢?可以用下面这张流程图来描述。
主要分为下面几个步骤:
- 服务消费者从注册中心获取了服务的信息后,就订阅了服务的变化,会在本地保留Cluster的sign值。
- 服务消费者每隔一段时间,调用getSign()函数,从注册中心获取服务端该Cluster的sign值,并与本地保留的sign值做对比,如果不一致,就从服务端拉取新的节点信息,并更新localcache和snapshot。
注册与发现的几个问题
1. 多注册中心
理论上对于一个服务消费者来说,同一个注册中心交互是最简单的。但是不可避免的是,服务消费者可能订阅了多个服务,多个服务可能是由多个业务部门提供的,而且每个业务部门都有自己的注册中心,提供的服务只在自己的注册中心里有记录。这样的话,就要求服务消费者要具备在启动时,能够从多个注册中心订阅服务的能力。
根据我的经验,还有一种情况是,一个服务提供者提供了某个服务,可能作为静态服务对外提供,有可能又作为动态服务对外提供,这两个服务部署在不同的注册中心,所以要求服务提供者在启动的时候,要能够同时向多个注册中心注册服务。
也就是说,对于服务消费者来说,要能够同时从多个注册中心订阅服务;对于服务提供者来说,要能够同时向多个注册中心注册服务。
2. 并行订阅服务
通常一个服务消费者订阅了不止一个服务,在我经历的一个项目中,一个服务消费者订阅了几十个不同的服务,每个服务都有自己的方法列表以及节点列表。服务消费者在服务启动时,会加载订阅的服务配置,调用注册中心的订阅接口,获取每个服务的节点列表并初始化连接。
最开始我们采用了串行订阅的方式,每订阅一个服务,服务消费者调用一次注册中心的订阅接口,获取这个服务的节点列表并初始化连接,总共需要执行几十次这样的过程。在某些服务节点的初始化连接过程中,出现连接超时的情况,后续所有的服务节点的初始化连接都需要等待它完成,导致服务消费者启动变慢,最后耗费了将近五分钟时间来完成所有服务节点的初始化连接过程。
后来我们改成了并行订阅的方式,每订阅一个服务就单独用一个线程来处理
,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在了30秒以内。
3. 批量反注册服务
通常一个服务提供者节点提供不止一个服务,所以注册和反注册都需要多次调用注册中心。在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”,但服务消费者端还会把它当成“活节点”,继续发起调用,最终导致调用失败。
以前我们的业务中经常遇到这个问题,需要定时去清理注册中心中的“僵尸节点”。后来我们通过优化反注册逻辑,对于下线机器、节点销毁的场景,通过调用注册中心提供的批量反注册接口,一次调用就可以把该节点上提供的所有服务同时反注册掉,从而避免了“僵尸节点”的出现。
4. 服务变更信息增量更新
服务消费者端启动时,除了会查询订阅服务的可用节点列表做初始化连接,还会订阅服务的变更,每隔一段时间从注册中心获取最新的服务节点信息标记sign,并与本地保存的sign值作比对,如果不一样,就会调用注册中心获取最新的服务节点信息。
一般情况下,按照这个过程是没问题的,但是在网络频繁抖动时,服务提供者上报给注册中心的心跳可能会一会儿失败一会儿成功,这时候注册中心就会频繁更新服务的可用节点信息,导致服务消费者频繁从注册中心拉取最新的服务可用节点信息,严重时可能产生网络风暴,导致注册中心带宽被打满。
为了减少服务消费者从注册中心中拉取的服务可用节点信息的数据量,这个时候可以通过增量更新的方式,注册中心只返回变化的那部分节点信息,尤其在只有少数节点信息变更时,此举可以大大减少服务消费者从注册中心拉取的数据量,从而最大程度避免产生网络风暴。
总结
今天我给你讲解了在注册中心实际使用过程中,服务注册、服务反注册、服务订阅和服务变更的实现方式,并列举了几个我在服务注册与发现的过程中遇到的典型问题。
而针对这些异常情况,我都给出了对应的解决方案,这些方案都是经过实际业务验证的,对于大部分中小团队在应用场景面临的问题,应该足以应对。
13 | 开源服务注册中心如何选型?
关于注册中心,如果你的团队有足够的人才和技术储备,可以选择自己研发注册中心。但对于大多数中小规模团队来说,我的建议是最好使用业界开源的、应用比较成熟的注册中心解决方案,把精力投入到业务架构的改造中,不要自己造轮子。
当下主流的服务注册与发现的解决方案,主要有两种:
- 应用内注册与发现:注册中心提供服务端和客户端的SDK,业务应用通过引入注册中心提供的SDK,通过SDK与注册中心交互,来实现服务的注册和发现。
- 应用外注册与发现:业务应用本身不需要通过SDK与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。
下面我会用两个业界使用比较成熟的注册中心开源实现,来讲解下应用内和应用外两种解决方案的不同之处。
两种典型的注册中心实现
1. 应用内
采用应用内注册与发现的方式,最典型的案例要属Netflix开源的Eureka,官方架构图如下。
对着这张图,我来介绍下Eureka的架构,它主要由三个重要的组件组成:
- Eureka Server:注册中心的服务端,实现了服务信息注册、存储以及查询等功能。
- 服务端的Eureka Client:集成在服务端的注册中心SDK,服务提供者通过调用SDK,实现服务注册、反注册等功能。
- 客户端的Eureka Client:集成在客户端的注册中心SDK,服务消费者通过调用SDK,实现服务订阅、服务更新等功能。
2. 应用外
采用应用外方式实现服务注册和发现,最典型的案例是开源注册中心Consul,它的架构图如下。
通过这张架构图,可以看出来使用Consul实现应用外服务注册和发现主要依靠三个重要的组件:
- Consul:注册中心的服务端,实现服务注册信息的存储,并提供注册和发现服务。
- Registrator:一个开源的第三方服务管理器项目,它通过监听服务部署的Docker实例是否存活,来负责服务提供者的注册和销毁。
- Consul Template:定时从注册中心服务端获取最新的服务提供者节点列表并刷新LB配置(比如Nginx的upstream),这样服务消费者就通过访问Nginx就可以获取最新的服务提供者信息。
对比小结一下,这两种解决方案的不同之处在于应用场景,应用内的解决方案一般适用于服务提供者和服务消费者同属于一个技术体系;应用外的解决方案一般适合服务提供者和服务消费者采用了不同技术体系的业务场景,比如服务提供者提供的是C++服务,而服务消费者是一个Java应用,这时候采用应用外的解决方案就不依赖于具体一个技术体系。同时,对于容器化后的云应用来说,一般不适合采用应用内SDK的解决方案,因为这样会侵入业务,而应用外的解决方案正好能够解决这个问题。
注册中心选型要考虑的两个问题
在选择注册中心解决方案的时候,除了要考虑是采用应用内注册还是应用外注册的方式以外,还有两个最值得关注的问题,一个是高可用性,一个是数据一致性,下面我来给你详细解释下为什么。
1. 高可用性
注册中心作为服务提供者和服务消费者之间沟通的纽带,它的高可用性十分重要。试想,如果注册中心不可用了,那么服务提供者就无法对外暴露自己的服务,而服务消费者也无法知道自己想要调用的服务的具体地址,后果将不堪设想。
实现高可用性的方法主要有两种:
- 集群部署,顾名思义就是通过部署多个实例组成集群来保证高可用性,这样的话即使有部分机器宕机,将访问迁移到正常的机器上就可以保证服务的正常访问。
- 多IDC部署,就是部署在不止一个机房,这样能保证即使一个机房因为断电或者光缆被挖断等不可抗力因素不可用时,仍然可以通过把请求迁移到其他机房来保证服务的正常访问。
我们以Consul为例,来看看它是如何通过这两种方法来保证注册中心的高可用性。
从下面的官方架构图中你可以看到,一方面,在每个数据中心(DATACENTER)内都有多个注册中心Server节点可供访问;另一方面还可以部署在多个数据中心来保证多机房高可用性。
2. 数据一致性
为了保证注册中心的高可用性,注册中心的部署往往都采用集群部署,并且还通常部署在不止一个数据中心,这样的话就会引出另一个问题,多个数据中心之间如何保证数据一致?如何确保访问数据中心中任何一台机器都能得到正确的数据?
这里就涉及分布式系统中著名的CAP理论,即同时满足一致性、可用性、分区容错性这三者是不可能的,其中C(Consistency)代表一致性,A(Availability)代表可用性,P(Partition Tolerance)代表分区容错性。
而注册中心一般采用分布式集群部署,也面临着CAP的问题,根据CAP不能同时满足,所以不同的注册中心解决方案选择的方向也就不同,大致可分为两种。
- CP型注册中心,牺牲可用性来保证数据强一致性,最典型的例子就是ZooKeeper,etcd,Consul了。
Zookeeper集群内只有一个Leader,而且在Leader无法使用的时候通过Paxos算法选举出一个新的Leader。这个Leader的目的就是保证写信息的时候只向这个Leader写入,Leader会同步信息到Followers,这个过程就可以保证数据的强一致性。但如果多个ZooKeeper之间网络出现问题,造成出现多个Leader,发生脑裂的话,注册中心就不可用了。而etcd和Consul集群内都是通过raft协议来保证强一致性,如果出现 - AP型注册中心,牺牲一致性来保证可用性,最典型的例子就是Eureka了。对比下Zookeeper,Eureka不用选举一个Leader,每个Eureka服务器单独保存服务注册地址,因此有可能出现数据信息不一致的情况。但是当网络出现问题的时候,每台服务器都可以完成独立的服务。
而对于注册中心来说,最主要的功能是服务的注册和发现,在网络出现问题的时候,可用性的需求要远远高于数据一致性。即使因为数据不一致,注册中心内引入了不可用的服务节点,也可以通过其他措施来避免,比如客户端的快速失败机制等,只要实现最终一致性,对于注册中心来说就足够了。因此,选择AP型注册中心,一般更加合适。
总结
总的来说,在选择开源注册中心解决方案的时候,要看业务的具体场景。
- 如果你的业务体系都采用Java语言的话,Netflix开源的Eureka是一个不错的选择,并且它作为服务注册与发现解决方案,能够最大程度的保证可用性,即使出现了网络问题导致不同节点间数据不一致,你仍然能够访问Eureka获取数据。
- 如果你的业务体系语言比较复杂,Eureka也提供了Sidecar的解决方案;也可以考虑使用Consul,它支持了多种语言接入,包括Go、Python、PHP、Scala、Java,Erlang、Ruby、Node.js、.NET、Perl等。
- 如果你的业务已经是云原生的应用,可以考虑使用Consul,搭配Registrator和Consul Template来实现应用外的服务注册与发现。
14 | 开源RPC框架如何选型?
简单回顾一下一个完整的RPC框架主要有三部分组成:通信框架、通信协议、序列化和反序列化格式。根据我的经验,想要开发一个完整的RPC框架,并且应用到线上生产环境,至少需要投入三个人力半年以上的时间。这对于大部分中小团队来说,人力成本和时间成本都是不可接受的,所以我建议还是选择开源的RPC框架比较合适。
那么业界应用比较广泛的开源RPC框架有哪些呢?
简单划分的话,主要分为两类:一类是跟某种特定语言平台绑定的,另一类是与语言无关即跨语言平台的。
跟语言平台绑定的开源RPC框架主要有下面几种。
- Dubbo:国内最早开源的RPC框架,由阿里巴巴公司开发并于2011年末对外开源,仅支持Java语言。
- Motan:微博内部使用的RPC框架,于2016年对外开源,仅支持Java语言。
- Tars:腾讯内部使用的RPC框架,于2017年对外开源,仅支持C++语言。
- Spring Cloud:国外Pivotal公司2014年对外开源的RPC框架,仅支持Java语言,最近几年生态发展得比较好,是比较火的RPC框架。
而跨语言平台的开源RPC框架主要有以下几种。
- gRPC:Google于2015年对外开源的跨语言RPC框架,支持常用的C++、Java、Python、Go、Ruby、PHP、Android Java、Objective-C等多种语言。
- Thrift:最初是由Facebook开发的内部系统跨语言的RPC框架,2007年贡献给了Apache基金,成为Apache开源项目之一,支持常用的C++、Java、PHP、Python、Ruby、Erlang等多种语言。
如果你的业务场景仅仅局限于一种语言的话,可以选择跟语言绑定的RPC框架中的一种;如果涉及多个语言平台之间的相互调用,就应该选择跨语言平台的RPC框架。
针对每一种RPC框架,它们具体有何区别?该如何选择呢?接下来,我就从每个框架的实现角度来具体给你讲解。当你知道了他们的具体实现,也就能知道他们的优缺点以及适用场景了。
限定语言平台的开源RPC框架
1. Dubbo
先来聊聊Dubbo,Dubbo可以说是国内开源最早的RPC框架了,目前只支持Java语言,它的架构可以用下面这张图展示。
Dubbo的架构主要包含四个角色,其中Consumer是服务消费者,Provider是服务提供者,Registry是注册中心,Monitor是监控系统。
具体的交互流是Consumer一端通过注册中心获取到Provider节点后,通过Dubbo的客户端SDK与Provider建立连接,并发起调用。Provider一端通过Dubbo的服务端SDK接收到Consumer的请求,处理后再把结果返回给Consumer。
可以看到服务消费者和服务提供者都需要引入Dubbo的SDK才来完成RPC调用,因为Dubbo本身是采用Java语言实现的,所以要求服务消费者和服务提供者也都必须采用Java语言实现才可以应用。
我们看下Dubbo的调用框架是如何实现的。
- 通信框架方面,Dubbo默认采用了Netty作为通信框架。
- 通信协议方面,Dubbo除了支持私有的Dubbo协议外,还支持RMI协议、Hession协议、HTTP协议、Thrift协议等。
- 序列化格式方面,Dubbo支持多种序列化格式,比如Dubbo、Hession、JSON、Kryo、FST等。
2. Motan
Motan是国内另外一个比较有名的开源的RPC框架,同样也只支持Java语言实现,它的架构可以用下面这张图描述。
Motan与Dubbo的架构类似,都需要在Client端(服务消费者)和Server端(服务提供者)引入SDK,其中Motan框架主要包含下面几个功能模块。
- register:用来和注册中心交互,包括注册服务、订阅服务、服务变更通知、服务心跳发送等功能。Server端会在系统初始化时通过register模块注册服务,Client端会在系统初始化时通过register模块订阅到具体提供服务的Server列表,当Server列表发生变更时也由register模块通知Client。
- protocol:用来进行RPC服务的描述和RPC服务的配置管理,这一层还可以添加不同功能的filter用来完成统计、并发限制等功能。
- serialize:将RPC请求中的参数、结果等对象进行序列化与反序列化,即进行对象与字节流的互相转换,默认使用对Java更友好的Hessian 2进行序列化。
- transport:用来进行远程通信,默认使用Netty NIO的TCP长链接方式。
- cluster:Client端使用的模块,cluster是一组可用的Server在逻辑上的封装,包含若干可以提供RPC服务的Server,实际请求时会根据不同的高可用与负载均衡策略选择一个可用的Server发起远程调用。
3. Tars
Tars是腾讯根据内部多年使用微服务架构的实践,总结而成的开源项目,仅支持C++语言,它的架构图如下。
Tars的架构交互主要包括以下几个流程:
- 服务发布流程:在web系统上传server的发布包到patch,上传成功后,在web上提交发布server请求,由registry服务传达到node,然后node拉取server的发布包到本地,拉起server服务。
- 管理命令流程:web系统上的可以提交管理server服务命令请求,由registry服务传达到node服务,然后由node向server发送管理命令。
- 心跳上报流程:server服务运行后,会定期上报心跳到node,node然后把服务心跳信息上报到registry服务,由registry进行统一管理。
- 信息上报流程:server服务运行后,会定期上报统计信息到stat,打印远程日志到log,定期上报属性信息到prop、上报异常信息到notify、从config拉取服务配置信息。
- client访问server流程:client可以通过server的对象名Obj间接访问server,client会从registry上拉取server的路由信息(如IP、Port信息),然后根据具体的业务特性(同步或者异步,TCP或者UDP方式)访问server(当然client也可以通过IP/Port直接访问server)。
4. Spring Cloud
Spring Cloud是为了解决微服务架构中服务治理而提供的一系列功能的开发框架,它是完全基于Spring Boot进行开发的,Spring Cloud利用Spring Boot特性整合了开源行业中优秀的组件,整体对外提供了一套在微服务架构中服务治理的解决方案。因为Spring Boot是用Java语言编写的,所以目前Spring Cloud也只支持Java语言平台,它的架构图可以用下面这张图来描述。
由此可见,Spring Cloud微服务架构是由多个组件一起组成的,各个组件的交互流程如下。
- 请求统一通过API网关Zuul来访问内部服务,先经过Token进行安全认证。
- 通过安全认证后,网关Zuul从注册中心Eureka获取可用服务节点列表。
- 从可用服务节点中选取一个可用节点,然后把请求分发到这个节点。
- 整个请求过程中,Hystrix组件负责处理服务超时熔断,Turbine组件负责监控服务间的调用和熔断相关指标,Sleuth组件负责调用链监控,ELK负责日志分析。
5. 对比选型
介绍完这4种限定语言的开源RPC框架后,我们该如何选择呢?
很显然,如果你的语言平台是C++,那么只能选择Tars;而如果是Java的话,可以选择Dubbo、Motan或者Spring Cloud。这时你又要问了,它们三个又该如何抉择呢?
仔细分析,可以看出Spring Cloud不仅提供了基本的RPC框架功能,还提供了服务注册组件、配置中心组件、负载均衡组件、断路器组件、分布式消息追踪组件等一系列组件,也难怪被技术圈的人称之为“Spring Cloud全家桶”。如果你不想自己实现以上这些功能,那么Spring Cloud基本可以满足你的全部需求。而Dubbo、Motan基本上只提供了最基础的RPC框架的功能,其他微服务组件都需要自己去实现。
不过由于Spring Cloud的RPC通信采用了HTTP协议,相比Dubbo和Motan所采用的私有协议来说,在高并发的通信场景下,性能相对要差一些,所以对性能有苛刻要求的情况下,可以考虑Dubbo和Motan。
跨语言平台的开源RPC框架
1. gRPC
先来看下gRPC,它的原理是通过IDL(Interface Definition Language)文件定义服务接口的参数和返回值类型,然后通过代码生成程序生成服务端和客户端的具体实现代码,这样在gRPC里,客户端应用可以像调用本地对象一样调用另一台服务器上对应的方法。
它的主要特征包括三个方面:
- 通信协议采用了HTTP/2,因为HTTP/2提供了连接复用、双向流、服务器推送、请求优先级、首部压缩等机制,所以在通信过程中可以节省带宽、降低TCP连接次数、节省CPU,尤其对于移动端应用来说,可以帮助延长电池寿命。
- IDL使用了ProtoBuf,ProtoBuf是由Google开发的一种数据序列化协议,它的压缩和传输效率极高,语法也简单,所以被广泛应用在数据存储和通信协议上。
- 多语言支持,能够基于多种语言自动生成对应语言的客户端和服务端的代码。
2. Thrift
再来看下Thrift,Thrift是一种轻量级的跨语言RPC通信方案,支持多达25种编程语言。为了支持多种语言,跟gRPC一样,Thrift也有一套自己的接口定义语言IDL,可以通过代码生成器,生成各种编程语言的Client端和Server端的SDK代码,这样就保证了不同语言之间可以相互通信。它的架构图可以用下图来描述。
从这张图上可以看出Thrift RPC框架的特性。
- 支持多种序列化格式:如Binary、Compact、JSON、Multiplexed等。
- 支持多种通信方式:如Socket、Framed、File、Memory、zlib等。
- 服务端支持多种处理方式:如Simple 、Thread Pool、Non-Blocking等。
3. 对比选型
那么涉及跨语言的服务调用场景,到底该选择gRPC还是Thrift呢?
从成熟度上来讲,Thrift因为诞生的时间要早于gRPC,所以使用的范围要高于gRPC,在HBase、Hadoop、Scribe、Cassandra等许多开源组件中都得到了广泛地应用。而且Thrift支持多达25种语言,这要比gRPC支持的语言更多,所以如果遇到gRPC不支持的语言场景下,选择Thrift更合适。
但gRPC作为后起之秀,因为采用了HTTP/2作为通信协议、ProtoBuf作为数据序列化格式,在移动端设备的应用以及对传输带宽比较敏感的场景下具有很大的优势,而且开发文档丰富,根据ProtoBuf文件生成的代码要比Thrift更简洁一些,从使用难易程度上更占优势,所以如果使用的语言平台gRPC支持的话,建议还是采用gRPC比较好。
总结
以上就是我对几种使用最广泛的开源RPC框架的选型建议,也是基于它们目前现状所作出的判断,从长远来看,支持多语言是RPC框架未来的发展趋势。正是基于此判断,各个RPC框架都提供了Sidecar组件来支持多语言平台之间的RPC调用。
- Dubbo在去年年底又重启了维护,并且宣称要引入Sidecar组件来构建Dubbo Mesh提供多语言支持。
- Motan也在去年对外开源了其内部的Sidecar组件:Motan-go,目前支持PHP、Java语言之间的相互调用。
- Spring Cloud也提供了Sidecar组件spring-cloud-netflix-sideca,可以让其他语言也可以使用Spring Cloud的组件。
所以未来语言不会成为使用上面这几种RPC框架的约束,而gRPC和Thrift虽然支持跨语言的RPC调用,但是因为它们只提供了最基本的RPC框架功能,缺乏一系列配套的服务化组件和服务治理功能的支撑,所以使用它们作为跨语言调用的RPC框架,就需要自己考虑注册中心、熔断、限流、监控、分布式追踪等功能的实现,不过好在大多数功能都有开源实现,可以直接采用。
15 | 如何搭建一个可靠的监控系统?
一个监控系统的组成主要涉及四个环节:数据收集、数据传输、数据处理和数据展示。不同的监控系统实现方案,在这四个环节所使用的技术方案不同,适合的业务场景也不一样。
目前,比较流行的开源监控系统实现方案主要有两种:以ELK为代表的集中式日志解决方案,以及Graphite、TICK和Prometheus等为代表的时序数据库解决方案。接下来我就以这几个常见的监控系统实现方案,谈谈它们的实现原理,分别适用于什么场景,以及具体该如何做技术选型。
ELK
ELK是Elasticsearch、Logstash、Kibana三个开源软件产品首字母的缩写,它们三个通常配合使用,所以被称为ELK Stack,它的架构可以用下面的图片来描述。
这三个软件的功能也各不相同。
- Logstash负责数据收集和传输,它支持动态地从各种数据源收集数据,并对数据进行过滤、分析、格式化等,然后存储到指定的位置。
- Elasticsearch负责数据处理,它是一个开源分布式搜索和分析引擎,具有可伸缩、高可靠和易管理等特点,基于Apache Lucene构建,能对大容量的数据进行接近实时的存储、搜索和分析操作,通常被用作基础搜索引擎。
- Kibana负责数据展示,也是一个开源和免费的工具,通常和Elasticsearch搭配使用,对其中的数据进行搜索、分析并且以图表的方式展示。
这种架构因为需要在各个服务器上部署Logstash来从不同的数据源收集数据,所以比较消耗CPU和内存资源,容易造成服务器性能下降,因此后来又在Elasticsearch、Logstash、Kibana之外引入了Beats作为数据收集器。相比于Logstash,Beats所占系统的CPU和内存几乎可以忽略不计,可以安装在每台服务器上做轻量型代理,从成百上千或成千上万台机器向Logstash或者直接向Elasticsearch发送数据。
其中,Beats支持多种数据源,主要包括:
- Packetbeat,用来收集网络流量数据。
- Topbeat,用来收集系统、进程的CPU和内存使用情况等数据。
- Filebeat,用来收集文件数据。
- Winlogbeat,用来收集Windows事件日志收据。
Beats将收集到的数据发送到Logstash,经过Logstash解析、过滤后,再将数据发送到Elasticsearch,最后由Kibana展示,架构就变成下面这张图里描述的了。
Graphite
Graphite的组成主要包括三部分:Carbon、Whisper、Graphite-Web,它的架构可以用下图来描述。
- Carbon:主要作用是接收被监控节点的连接,收集各个指标的数据,将这些数据写入carbon-cache并最终持久化到Whisper存储文件中去。
- Whisper:一个简单的时序数据库,主要作用是存储时间序列数据,可以按照不同的时间粒度来存储数据,比如1分钟1个点、5分钟1个点、15分钟1个点三个精度来存储监控数据。
- Graphite-Web:一个Web App,其主要功能绘制报表与展示,即数据展示。为了保证Graphite-Web能及时绘制出图形,Carbon在将数据写入Whisper存储的同时,会在carbon-cache中同时写入一份数据,Graphite-Web会先查询carbon-cache,如果没有再查询Whisper存储。
也就是说Carbon负责数据处理,Whisper负责数据存储,Graphite-Web负责数据展示,可见Graphite自身并不包含数据采集组件,但可以接入StatsD等开源数据采集组件来采集数据,再传送给Carbon。
其中Carbon对写入的数据格式有一定的要求,比如:
servers.www01.cpuUsage 42 1286269200
products.snake-oil.salesPerMinute 123 1286269200
[one minute passes]
servers.www01.cpuUsageUser 44 1286269260
products.snake-oil.salesPerMinute 119 1286269260
其中“servers.www01.cpuUsage 42 1286269200”是“key” + 空格分隔符 + “value + 时间戳”的数据格式,“servers.www01.cpuUsage”是以“.”分割的key,代表具体的路径信息,“42”是具体的值,“1286269200”是当前的Unix时间戳。
Graphite-Web对外提供了HTTP API可以查询某个key的数据以绘图展示,查询方式如下。
http://graphite.example.com/render?target=servers.www01.cpuUsage&
width=500&height=300&from=-24h
这个HTTP请求意思是查询key“servers.www01.cpuUsage”在过去24小时的数据,并且要求返回500*300大小的数据图。
除此之外,Graphite-Web还支持丰富的函数,比如:
target=sumSeries(products.*.salesPerMinute)
代表了查询匹配规则“products.*.salesPerMinute”的所有key的数据之和。
TICK
TICK是Telegraf、InfluxDB、Chronograf、Kapacitor四个软件首字母的缩写,是由InfluxData开发的一套开源监控工具栈,因此也叫作TICK Stack,它的架构可以看用下面这张图来描述。
从这张图可以看出,其中Telegraf负责数据收集,InfluxDB负责数据存储,Chronograf负责数据展示,Kapacitor负责数据告警。
这里面,InfluxDB对写入的数据格式要求如下。
<measurement>[,<tag-key>=<tag-value>...] <field-key>=<field-value>[,<field2-key>=<field2-value>...] [unix-nano-timestamp]
下面我用一个具体示例来说明它的格式。
cpu,host=serverA,region=us_west value=0.64 1434067467100293230
其中,“cpu,host=serverA,region=us_west value=0.64 1434067467100293230”代表了host为serverA、region为us_west的服务器CPU的值是0.64,时间戳是1434067467100293230,时间精确到nano。
Prometheus
还有一种比较有名的时间序数据库解决方案Prometheus,它是一套开源的系统监控报警框架,受Google的集群监控系统Borgmon启发,由工作在SoundCloud的Google前员工在2012年创建,后来作为社区开源项目进行开发,并于2015年正式发布,2016年正式加入CNCF(Cloud Native Computing Foundation),成为受欢迎程度仅次于Kubernetes的项目,它的架构可以用下图来描述。
从这张图可以看出,Prometheus主要包含下面几个组件:
- Prometheus Server:用于拉取metrics信息并将数据存储在时间序列数据库。
- Jobs/exporters:用于暴露已有的第三方服务的metrics给Prometheus Server,比如StatsD、Graphite等,负责数据收集。
- Pushgateway:主要用于短期jobs,由于这类jobs存在时间短,可能在Prometheus Server来拉取metrics信息之前就消失了,所以这类的jobs可以直接向Prometheus Server推送它们的metrics信息。
- Alertmanager:用于数据报警。
- Prometheus web UI:负责数据展示。
它的工作流程大致是:
- Prometheus Server定期从配置好的jobs或者exporters中拉取metrics信息,或者接收来自Pushgateway发过来的metrics信息。
- Prometheus Server把收集到的metrics信息存储到时间序列数据库中,并运行已经定义好的alert.rules,向Alertmanager推送警报。
- Alertmanager根据配置文件,对接收的警报进行处理,发出告警。
- 通过Prometheus web UI进行可视化展示。
Prometheus存储数据也是用的时间序列数据库,格式如下。
<metric name>{<label name>=<label value>, …}
比如下面这段代码代表了位于集群cluster 1上,节点IP为1.1.1.1,端口为80,访问路径为“/a”的http请求的总数为100。
http_requests_total{instance="1.1.1.1:80",job="cluster1",location="/a"} 100
讲到这里,四种监控系统的解决方案都已经介绍完了,接下来我们对比一下这四种方案,看看如何选型。
选型对比
我们从监控系统的四个环节来分别对比。
1. 数据收集
ELK是通过在每台服务器上部署Beats代理来采集数据;Graphite本身没有收据采集组件,需要配合使用开源收据采集组件,比如StatsD;TICK使用了Telegraf作为数据采集组件;Prometheus通过jobs/exporters组件来获取StatsD等采集过来的metrics信息。
2. 数据传输
ELK是Beats采集的数据传输给Logstash,经过Logstash清洗后再传输给Elasticsearch;Graphite是通过第三方采集组件采集的数据,传输给Carbon;TICK是Telegraf采集的数据,传输给InfluxDB;而Prometheus是Prometheus Server隔一段时间定期去从jobs/exporters拉取数据。可见前三种都是采用“推数据”的方式,而Prometheus是采取拉数据的方式,因此Prometheus的解决方案对服务端的侵入最小,不需要在服务端部署数据采集代理。
3. 数据处理
ELK可以对日志的任意字段索引,适合多维度的数据查询,在存储时间序列数据方面与时间序列数据库相比会有额外的性能和存储开销。除此之外,时间序列数据库的几种解决方案都支持多种功能的数据查询处理,功能也更强大。
- Graphite通过Graphite-Web支持正则表达式匹配、sumSeries求和、alias给监控项重新命名等函数功能,同时还支持这些功能的组合,比如下面这个表达式的意思是,要查询所有匹配路径“stats.open.profile.*.API._comments_flow”的监控项之和,并且把监控项重命名为Total QPS。
alias(sumSeries(stats.openapi.profile.*.API._comments_flow.total_count,“Total QPS”)
- InfluxDB通过类似SQL语言的InfluxQL,能对监控数据进行复杂操作,比如查询一分钟CPU的使用率,用InfluxDB实现的示例是:
SELECT 100 - usage_idel FROM “autogen”.“cpu” WHERE time > now() - 1m and “cpu”=‘cpu0’
- Prometheus通过私有的PromQL查询语言,如果要和上面InfluxDB实现同样的功能,PromQL语句如下,看起来更加简洁。
100 - (node_cpu{job=“node”,mode=“idle”}[1m])
4. 数据展示
Graphite、TICK和Prometheus自带的展示功能都比较弱,界面也不好看,不过好在它们都支持Grafana来做数据展示。Grafana是一个开源的仪表盘工具,它支持多种数据源比如Graphite、InfluxDB、Prometheus以及Elasticsearch等。ELK采用了Kibana做数据展示,Kibana包含的数据展示功能比较强大,但只支持Elasticsearch,而且界面展示UI效果不如Grafana美观。
总结
以上几种监控系统实现方式,所采用的技术均为开源的,其中:
- ELK的技术栈比较成熟,应用范围也比较广,除了可用作监控系统外,还可以用作日志查询和分析。
- Graphite是基于时间序列数据库存储的监控系统,并且提供了功能强大的各种聚合函数比如sum、average、top5等可用于监控分析,而且对外提供了API也可以接入其他图形化监控系统如Grafana。
- TICK的核心在于其时间序列数据库InfluxDB的存储功能强大,且支持类似SQL语言的复杂数据处理操作。
- Prometheus的独特之处在于它采用了拉数据的方式,对业务影响较小,同时也采用了时间序列数据库存储,而且支持独有的PromQL查询语言,功能强大而且简洁。
从对实时性要求角度考虑,时间序列数据库的实时性要好于ELK,通常可以做到10s级别内的延迟,如果对实时性敏感的话,建议选择时间序列数据库解决方案。
从使用的灵活性角度考虑,几种时间序列数据库的监控处理功能都要比ELK更加丰富,使用更灵活也更现代化。
所以如果要搭建一套新的监控系统,我建议可以考虑采用Graphite、TICK或者Prometheus其中之一。不过Graphite还需要搭配数据采集系统比如StatsD或者Collectd使用,而且界面展示建议使用Grafana接入Graphite的数据源,它的效果要比Graphite Web本身提供的界面美观很多。TICK提供了完整的监控系统框架,包括从数据采集、数据传输、数据处理再到数据展示,不过在数据展示方面同样也建议用Grafana替换掉TICK默认的数据展示组件Chronograf,这样展示效果更好。Prometheus因为采用拉数据的方式,所以对业务的侵入性最小,比较适合Docker封装好的云原生应用,比如Kubernetes默认就采用了Prometheus作为监控系统。
16 | 如何搭建一套适合你的服务追踪系统?
服务追踪系统的实现,主要包括三个部分。
- 埋点数据收集,负责在服务端进行埋点,来收集服务调用的上下文数据。
- 实时数据处理,负责对收集到的链路信息,按照traceId和spanId进行串联和存储。
- 数据链路展示,把处理后的服务调用数据,按照调用链的形式展示出来。
如果要自己从0开始实现一个服务追踪系统,针对以上三个部分你都必须有相应的解决方案。首先你需要在业务代码的框架层开发调用拦截程序,在调用的前后收集相关信息,把信息传输给到一个统一的处理中心。然后处理中心需要实时处理收集到链路信息,并按照traceId和spanId进行串联,处理完以后再存到合适的存储中。最后还要能把存储中存储的信息,以调用链路图或者调用拓扑图的形式对外展示。
可以想象这个技术难度以及开发工作量都不小,对于大部分中小业务团队来说,都十分具有挑战。不过幸运的是,业界已经有不少开源的服务追踪系统实现,并且应用范围也已经十分广泛,对大部分的中小业务团队来说,足以满足对服务追踪系统的需求。
业界比较有名的服务追踪系统实现有阿里的鹰眼、Twitter开源的OpenZipkin,还有Naver开源的Pinpoint,它们都是受Google发布的Dapper论文启发而实现的。其中阿里的鹰眼解决方案没有开源,而且由于阿里需要处理数据量比较大,所以鹰眼的定位相对定制化。
下面我主要来介绍下开源实现方案OpenZipkin和Pinpoint,再看看它们有什么区别。
OpenZipkin
OpenZipkin是Twitter开源的服务追踪系统,下面这张图展示了它的架构设计。
从图中看,OpenZipkin主要由四个核心部分组成。
- Collector:负责收集探针Reporter埋点采集的数据,经过验证处理并建立索引。
- Storage:存储服务调用的链路数据,默认使用的是Cassandra,是因为Twitter内部大量使用了Cassandra,你也可以替换成Elasticsearch或者MySQL。
- API:将格式化和建立索引的链路数据以API的方式对外提供服务,比如被UI调用。
- UI:以图形化的方式展示服务调用的链路数据。
它的工作原理可以用下面这张图来描述。
具体流程是,通过在业务的HTTP Client前后引入服务追踪代码,这样在HTTP方法“/foo”调用前,生成trace信息:TraceId:aa、SpanId:6b、annotation:GET /foo,以及当前时刻的timestamp:1483945573944000,然后调用结果返回后,记录下耗时duration,之后再把这些trace信息和duration异步上传给Zipkin Collector。
Pinpoint
Pinpoint是Naver开源的一款深度支持Java语言的服务追踪系统,下面这张图是它的架构设计。
具体来看,就是请求进入TomcatA,然后生成TraceId:TomcatA^ TIME ^ 1、SpanId:10、pSpanId:-1(代表是根请求),接着TomatA调用TomcatB的hello方法,TomcatB生成TraceId:TomcatA^ TIME ^1、新的SpanId:20、pSpanId:10(代表是TomcatA的请求),返回调用结果后将trace信息发给Collector,TomcatA收到调用结果后,将trace信息也发给Collector。Collector把trace信息写入到HBase中,Rowkey就是traceId,SpanId和pSpanId都是列。然后就可以通过UI查询调用链路信息了。
选型对比
根据我的经验,考察服务追踪系统主要从下面这几个方面。
1. 埋点探针支持平台的广泛性
OpenZipkin和Pinpoint都支持哪些语言平台呢?
OpenZipkin提供了不同语言的Library,不同语言实现时需要引入不同版本的Library。
官方提供了C#、Go、Java、JavaScript、Ruby、Scala、PHP等主流语言版本的Library,而且开源社区还提供了更丰富的不同语言版本的Library,详细的可以点击这里查看;而Pinpoint目前只支持Java语言。
所以从探针支持的语言平台广泛性上来看,OpenZipkin比Pinpoint的使用范围要广,而且开源社区很活跃,生命力更强。
2. 系统集成难易程度
再来看下系统集成的难易程度。
以OpenZipkin的Java探针Brave为例,它只提供了基本的操作API,如果系统要想集成Brave,必须在配置里手动里添加相应的配置文件并且增加trace业务代码。具体来讲,就是你需要先修改工程的POM依赖,以引入Brave相关的JAR包。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.zipkin.brave</groupId>
<artifactId>brave-bom</artifactId>
<version>${brave.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
然后假如你想收集每一次HTTP调用的信息,你就可以使用Brave在Apache Httpclient基础上封装的httpClient,它会记录每一次HTTP调用的信息,并上报给OpenZipkin。
httpclient =TracingHttpClientBuilder.create(tracing).build();
而Pinpoint是通过字节码注入的方式来实现拦截服务调用,从而收集trace信息的,所以不需要代码做任何改动。Java字节码注入的大致原理你可以参考下图。
就是JVM在加载class二进制文件时,动态地修改加载的class文件,在方法的前后执行拦截器的before()和after()方法,在before()和after()方法里记录trace()信息。而应用不需要修改业务代码,只需要在JVM启动时,添加类似下面的启动参数就可以了。
-javaagent:$AGENT_PATH/pinpoint-bootstrap-$VERSION.jar
-Dpinpoint.agentId=<Agent's UniqueId>
-Dpinpoint.applicationName=<The name indicating a same service (AgentId collection)
所以从系统集成难易程度上看,Pinpoint要比OpenZipkin简单。
3. 调用链路数据的精确度
从下面这张OpenZipkin的调用链路图可以看出,OpenZipkin收集到的数据只到接口级别,进一步的信息就没有了。
再来看下Pinpoint,因为Pinpoint采用了字节码注入的方式实现trace信息收集,所以它能拿到的信息比OpenZipkin多得多。从下面这张图可以看出,它不仅能够查看接口级别的链路调用信息,还能深入到调用所关联的数据库信息。
同理在绘制链路拓扑图时,OpenZipkin只能绘制服务与服务之间的调用链路拓扑图,比如下面这张示意图。
而Pinpoint不仅能够绘制服务与服务之间,还能绘制与DB之间的调用链路拓扑图,比如下图。
所以,从调用链路数据的精确度上看,Pinpoint要比OpenZipkin精确得多。
总结
讲解了两个开源服务追踪系统OpenZipkin和Pinpoint的具体实现,并从埋点探针支持平台广泛性、系统集成难易程度、调用链路数据精确度三个方面对它们进行了对比。
从选型的角度来讲,如果你的业务采用的是Java语言,那么采用Pinpoint是个不错的选择,因为它不需要业务改动一行代码就可以实现trace信息的收集。除此之外,Pinpoint不仅能看到服务与服务之间的链路调用,还能看到服务内部与资源层的链路调用,功能更为强大,如果你有这方面的需求,Pinpoint正好能满足。
如果你的业务不是Java语言实现,或者采用了多种语言,那毫无疑问应该选择OpenZipkin,并且,由于其开源社区很活跃,基本上各种语言平台都能找到对应的解决方案。不过想要使用OpenZipkin,还需要做一些额外的代码开发工作,以引入OpenZipkin提供的Library到你的系统中。
除了OpenZipkin和Pinpoint,业界还有其他开源追踪系统实现,比如Uber开源的Jaeger,以及国内的一款开源服务追踪系统SkyWalking。
17 | 如何识别服务节点是否存活?
讲解注册中心原理的时候,以开源注册中心ZooKeeper为例,描述了它是如何管理注册到注册中心的节点的存活的。
其实ZooKeeper判断注册中心节点存活的机制其实就是注册中心摘除机制,服务消费者以注册中心中的数据为准,当服务端节点有变更时,注册中心就会把变更通知给服务消费者,服务消费者就会调用注册中心来拉取最新的节点信息。
这种机制在大部分情况下都可以工作得很好,但是在网络频繁抖动时,服务提供者向注册中心汇报心跳信息可能会失败,如果在规定的时间内,注册中心都没有收到服务提供者的心跳信息,就会把这个节点从可用节点列表中移除。更糟糕的是,在服务池拥有上百个节点的的时候,每个节点都可能会被移除,导致注册中心可用节点的状态一直在变化,这个时候应该如何处理呢?
下面就结合我在实践中的经验,给你讲解几种解决方案。
心跳开关保护机制
在网络频繁抖动的情况下,注册中心中可用的节点会不断变化,这时候服务消费者会频繁收到服务提供者节点变更的信息,于是就不断地请求注册中心来拉取最新的可用服务节点信息。当有成百上千个服务消费者,同时请求注册中心获取最新的服务提供者的节点信息时,可能会把注册中心的带宽给占满,尤其是注册中心是百兆网卡的情况下。
所以针对这种情况,需要一种保护机制,即使在网络频繁抖动的时候,服务消费者也不至于同时去请求注册中心获取最新的服务节点信息。
我曾经就遇到过这种情况,一个可行的解决方案就是给注册中心设置一个开关,当开关打开时,即使网络频繁抖动,注册中心也不会通知所有的服务消费者有服务节点信息变更,比如只给10%的服务消费者返回变更,这样的话就能将注册中心的请求量减少到原来的1/10。
当然打开这个开关也是有一定代价的,它会导致服务消费者感知最新的服务节点信息延迟,原先可能在10s内就能感知到服务提供者节点信息的变更,现在可能会延迟到几分钟,所以在网络正常的情况下,开关并不适合打开;可以作为一个紧急措施,在网络频繁抖动的时候,才打开这个开关。
服务节点摘除保护机制
服务提供者在进程启动时,会注册服务到注册中心,并每隔一段时间,汇报心跳给注册中心,以标识自己的存活状态。如果隔了一段固定时间后,服务提供者仍然没有汇报心跳给注册中心,注册中心就会认为该节点已经处于“dead”状态,于是从服务的可用节点信息中移除出去。
如果遇到网络问题,大批服务提供者节点汇报给注册中心的心跳信息都可能会传达失败,注册中心就会把它们都从可用节点列表中移除出去,造成剩下的可用节点难以承受所有的调用,引起“雪崩”。但是这种情况下,可能大部分服务提供者节点是可用的,仅仅因为网络原因无法汇报心跳给注册中心就被“无情”的摘除了。
这个是时候就需要根据实际业务的情况,设定一个阈值比例,即使遇到刚才说的这种情况,注册中心也不能摘除超过这个阈值比例的节点。
这个阈值比例可以根据实际业务的冗余度来确定,我通常会把这个比例设定在20%
,就是说注册中心不能摘除超过20%的节点。因为大部分情况下,节点的变化不会这么频繁,只有在网络抖动或者业务明确要下线大批量节点的情况下才有可能发生。而业务明确要下线大批量节点的情况是可以预知的,这种情况下可以关闭阈值保护;而正常情况下,应该打开阈值保护,以防止网络抖动时,大批量可用的服务节点被摘除。
讲到这里,我们先小结一下。
心跳开关保护机制,是为了防止服务提供者节点频繁变更导致的服务消费者同时去注册中心获取最新服务节点信息;服务节点摘除保护机制,是为了防止服务提供者节点被大量摘除引起服务消费者可以调用的节点不足。
可见,无论是心跳开关保护机制还是服务节点摘除保护机制,都是因为注册中心里的节点信息是随时可能发生变化的,所以也可以把注册中心叫作动态注册中心。
那么是不是可以换个思路,服务消费者并不严格以注册中心中的服务节点信息为准,而是更多的以服务消费者实际调用信息来判断服务提供者节点是否可用。这就是下面我要讲的静态注册中心。
静态注册中心
前面讲过心跳机制能保证在服务提供者出现异常时,注册中心可以及时把不可用的服务提供者从可用节点列表中移除出去,正常情况下这是个很好的机制。
但是仔细思考一下,为什么不把这种心跳机制直接用在服务消费者端呢?
因为服务提供者是向服务消费者提供给服务的,是否可用服务消费者应该比注册中心更清楚,因此可以直接在服务消费者端根据调用服务提供者是否成功来判定服务提供者是否可用。如果服务消费者调用某一个服务提供者节点连续失败超过一定次数,可以在本地内存中将这个节点标记为不可用。并且每隔一段固定时间,服务消费者都要向标记为不可用的节点发起保活探测
,如果探测成功了,就将标记为不可用的节点再恢复为可用状态,重新发起调用。
这样的话,服务提供者节点就不需要向注册中心汇报心跳信息,注册中心中的服务节点信息也不会动态变化,也可以称之为静态注册中心。
从我的实践经历来看,一开始采用了动态注册中心,后来考虑考虑到网络的复杂性,心跳机制不一定是可靠的,而后开始改为采用服务消费者端的保活机制,事实证明这种机制足以应对网络频繁抖动等复杂的场景。
当然静态注册中心中的服务节点信息并不是一直不变,当在业务上线或者运维人工增加或者删除服务节点这种预先感知的情况下,还是有必要去修改注册中心中的服务节点信息。
比如在业务上线过程中,需要把正在部署的服务节点从注册中心中移除,等到服务部署完毕,完全可用的时候,再加入到注册中心。还有就是在业务新增或者下线服务节点的时候,需要调用注册中心提供的接口,添加节点信息或者删除节点。这个时候静态注册中心有点退化到配置中心的意思,只不过这个时候配置中心里存储的不是某一项配置,而是某个服务的可用节点信息。
总结
今天我给你讲解了动态注册中心在实际线上业务运行时,如果遇到网络不可靠等因素,可能会带来的两个问题,一个是服务消费者同时并发访问注册中心获取最新服务信息导致注册中心带宽被打满;另一个是服务提供者节点被大量摘除导致服务消费者没有足够的节点可以调用。
这两个问题都是我在业务实战过程中遇到过的,我给出的两个解决方案:心跳开关保护机制和服务节点摘除保护机制都是在实战中应用过的,并且被证明是行之有效的。
而静态注册中心的思路,是在斟酌注册中心的本质之后,引入的另外一个解决方案,相比于动态注册中心更加简单,并且基于服务消费者本身调用来判断服务节点是否可用,更加直接也更加准确,尤其在注册中心或者网络出现问题的时候,这种方案基本不受影响。
18 | 如何使用负载均衡算法?
假设你订阅了一个别人的服务,从注册中心查询得到了这个服务的可用节点列表,而这个列表里包含了几十个节点,这个时候你该选择哪个节点发起调用呢?这就是今天我要给你讲解的关于客户端负载均衡算法的问题。
为什么要引入负载均衡算法呢?主要有两个原因:一个是要考虑调用的均匀性,也就是要让每个节点都接收到调用,发挥所有节点的作用;另一个是要考虑调用的性能,也就是哪个节点响应最快,优先调用哪个节点。
常见的负载均衡算法
1. 随机算法
从可用的服务节点中,随机挑选一个节点来访问。
在实现时,随机算法通常是通过生成一个随机数来实现,比如服务有10个节点,那么就每一次生成一个1~10之间的随机数,假设生成的是2,那么就访问编号为2的节点。
采用随机算法,在节点数量足够多,并且访问量比较大的情况下,各个节点被访问的概率是基本相同的。
2. 轮询算法
轮询算法,就是按照固定的顺序,把可用的服务节点,挨个访问一次。
在实现时,轮询算法通常是把所有可用节点放到一个数组里,然后按照数组编号,挨个访问。比如服务有10个节点,放到数组里就是一个大小为10的数组,这样的话就可以从序号为0的节点开始访问,访问后序号自动加1,下一次就会访问序号为1的节点,以此类推。
轮询算法能够保证所有节点被访问到的概率是相同的。
3. 加权轮询算法
轮询算法能够保证所有节点被访问的概率相同,而加权轮询算法是在此基础上,给每个节点赋予一个权重,从而使每个节点被访问到的概率不同,权重大的节点被访问的概率就高,权重小的节点被访问的概率就小。
在实现时,加权轮询算法是生成一个节点序列,该序列里有n个节点,n是所有节点的权重之和。在这个序列中,每个节点出现的次数,就是它的权重值。比如有三个节点:a、b、c,权重分别是3、2、1,那么生成的序列就是{a、a、b、c、b、a},这样的话按照这个序列访问,前6次请求就会分别访问节点a三次,节点b两次,节点c一次。从第7个请求开始,又重新按照这个序列的顺序来访问节点。
在应用加权轮询算法的时候,根据我的经验,要尽可能保证生产的序列的均匀,如果生成的不均匀会造成节点访问失衡,比如刚才的例子,如果生成的序列是{a、a、a、b、b、c},就会导致前3次访问的节点都是a。
4. 最少活跃连接算法
最少活跃连接算法,顾名思义就是每一次访问都选择连接数最少的节点。因为不同节点处理请求的速度不同,使得同一个服务消费者同每一个节点的连接数都不相同。连接数大的节点,可以认为是处理请求慢,而连接数小的节点,可以认为是处理请求快。所以在挑选节点时,可以以连接数为依据,选择连接数最少的节点访问。
在实现时,需要记录跟每一个节点的连接数,这样在选择节点时,才能比较出连接数最小的节点。
5. 一致性hash算法
一致性hash算法,是通过某个hash函数,把同一个来源的请求都映射到同一个节点上。一致性hash算法最大的特点就是同一个来源的请求,只会映射到同一个节点上,可以说是具有记忆功能。只有当这个节点不可用时,请求才会被分配到相邻的可用节点上。
负载均衡算法的使用场景
上面这五种负载均衡算法,具体在业务中该如何选择呢?根据我的经验,它们的各自应用场景如下:
- 随机算法:实现比较简单,在请求量远超可用服务节点数量的情况下,各个服务节点被访问的概率基本相同,主要应用在各个服务节点的性能差异不大的情况下。
- 轮询算法:跟随机算法类似,各个服务节点被访问的概率也基本相同,也主要应用在各个服务节点性能差异不大的情况下。
- 加权轮询算法:在轮询算法基础上的改进,可以通过给每个节点设置不同的权重来控制访问的概率,因此主要被用在服务节点性能差异比较大的情况。比如经常会出现一种情况,因为采购时间的不同,新的服务节点的性能往往要高于旧的节点,这个时候可以给新的节点设置更高的权重,让它承担更多的请求,充分发挥新节点的性能优势。
- 最少活跃连接算法:与加权轮询算法预先定义好每个节点的访问权重不同,采用最少活跃连接算法,客户端同服务端节点的连接数是在时刻变化的,理论上连接数越少代表此时服务端节点越空闲,选择最空闲的节点发起请求,能获取更快的响应速度。尤其在服务端节点性能差异较大,而又不好做到预先定义权重时,采用最少活跃连接算法是比较好的选择。
- 一致性hash算法:因为它能够保证同一个客户端的请求始终访问同一个服务节点,所以适合服务端节点处理不同客户端请求差异较大的场景。比如服务端缓存里保存着客户端的请求结果,如果同一客户端一直访问一个服务节点,那么就可以一直从缓存中获取数据。
这五种负载均衡算法是业界最常用的,不光在RPC调用中被广泛采用,在一些负载均衡组件比如Nginx中也有应用,所以说是一种通用的负载均衡算法,但是不是所有的业务场景都能很好解决呢?
我曾经遇到过这种场景:
- 服务节点数量众多,且性能差异比较大;
- 服务节点列表经常发生变化,增加节点或者减少节点时有发生;
- 客户端和服务节点之间的网络情况比较复杂,有些在一个数据中心,有些不在一个数据中心需要跨网访问,而且网络经常延迟或者抖动。
显然无论是随机算法还是轮询算法,第一个情况就不满足,加权轮询算法需要预先配置服务节点的权重,在节点列表经常变化的情况下不好维护,所以也不适合。而最少活跃连接算法是从客户端自身维度去判断的,在实际应用时,并不能直接反映出服务节点的请求量大小,尤其是在网络情况比较复杂的情况下,并不能做到动态的把请求发送给最合适的服务节点。至于一致性hash算法,显然不适合这种场景。
针对上面这种场景,有一种算法更加适合,这种算法就是自适应最优选择算法。
自适应最优选择算法
这种泛的主要思想是在客户端本地维护一份同每一个服务节点的性能统计快照,并且每隔一段时间去更新这个快照。在发起请求时,根据“二八原则”,吧服务节点分为两部分,找出20%的那部分响应最慢的节点,然后降低权重。这样的话,客户端就能够实时的根据自身访问每个节点性能的快慢,动态调整访问最慢的那些节点的权重,来减少访问量,从而可以优化长尾请求。
由此可见,自适应最优选择算法是对加权轮询算法的改良,可以看作是一种动态加权轮询算法
。它的实现关键之处就在于两点:第一点是每隔一段时间获取客户端同每个服务节点之间调用的平均性能统计;第二点是按照这个性能统计对服务节点进行排序,对排在性能倒数20%的那部分节点赋予一个较低的权重,其余的节点赋予正常的权重。
在具体实现时,针对第一点,需要在内存中开辟一块空间记录客户端同每一个服务节点之间调用的平均性能,并每隔一段固定时间去更新。这个更新的时间间隔不能太短,太短的话很容易受瞬时的性能抖动影响,导致统计变化太快,没有参考性;同时也不能太长,太长的话时效性就会大打折扣,效果不佳。根据我的经验,1分钟
的更新时间间隔是个比较合适的值。
针对第二点,关键点是权重值的设定,即使服务节点之间的性能差异较大,也不适合把权重设置得差异太大,这样会导致性能较好的节点与性能较差的节点之间调用量相差太大,这样也不是一种合理的状态。在实际设定时,可以设置20%性能较差的节点权重为3,其余节点权重为5。
总结
讲解了最常用的五种客户端负载均衡算法的原理以及适用场景,在业务实践的过程汇总,究竟采用哪种,需要根据实际情况来决定,并不是算法越复杂越好。
比如在一种简单的业务场景下,有10个服务节点,并且配置基本相同,位于同一个数据中心,此时客户端选择随机算法或者轮询算法既简单又高效,并没有必要选择加权轮询算法或者最少活跃连接算法。
但在遇到前面提到的那种复杂业务场景下,服务节点数量众多,配置差异比较大,而且位于不同的数据中心,客户端与服务节点之间的网络情况也比较复杂,这个时候简单的负载均衡算法通常都难以应对,需要针对实际情况,选择更有针对性的负载均衡算法,比如自适应最优选择算法。
思考题
今天我给你讲的都属于软件层面的负载均衡算法,它与F5这种硬件负载均衡器有什么不同呢?
19 | 如何使用服务路由?
在业务中经常还会遇到这样的场景,比如服务A部署在北京、上海、广州三个数据中心,所有的服务节点按照所在的数据中心被分成了三组,那么服务A的消费者在发起调用时,该如何选择呢?这就是今天我要给你讲解的服务路由的问题。
那么什么是服务路由呢?我的理解是服务路由就是服务消费者在发起服务调用时,必须根据特定的规则来选择服务节点,从而满足某些特定的需求。
那么服务路由都有哪些应用场景?具体都有哪些规则呢?
服务路由的应用场景
服务路由主要有以下几种应用场景:
- 分组调用。一般来讲,为了保证服务的高可用性,实现异地多活的需求,一个服务往往不止部署在一个数据中心,而且出于节省成本等考虑,有些业务可能不仅在私有机房部署,还会采用公有云部署,甚至采用多家公有云部署。服务节点也会按照不同的数据中心分成不同的分组,这时对于服务消费者来说,选择哪一个分组调用,就必须有相应的路由规则。
- 灰度发布。在服务上线发布的过程中,一般需要先在一小部分规模的服务节点上先发布服务,然后验证功能是否正常。如果正常的话就继续扩大发布范围;如果不正常的话,就需要排查问题,解决问题后继续发布。这个过程就叫作灰度发布,也叫金丝雀部署。
- 流量切换。在业务线上运行过程中,经常会遇到一些不可抗力因素导致业务故障,比如某个机房的光缆被挖断,或者发生着火等事故导致整个机房的服务都不可用。这个时候就需要按照某个指令,能够把原来调用这个机房服务的流量切换到其他正常的机房。
- 读写分离。对于大多数互联网业务来说都是读多写少,所以在进行服务部署的时候,可以把读写分开部署,所有写接口可以部署在一起,而读接口部署在另外的节点上。
上面四种应用场景是实际业务中很常见的,服务路由可以通过各种规则来实现,那么服务路由都有哪些规则呢?
服务路由的规则
根据我的实践经验,服务路由主要有两种规则:一种是条件路由,一种是脚本路由。
1. 条件路由
条件路由是基于条件表达式的路由规则,以下面的条件路由为例,我来给你详细讲解下它的用法。
condition://0.0.0.0/dubbo.test.interfaces.TestService?category=routers&dynamic=true&priority=2&enabled=true&rule=" + URL.encode(" host = 10.20.153.10=> host = 10.20.153.11")
这里面“condition://”代表了这是一段用条件表达式编写的路由规则,具体的规则是
host = 10.20.153.10 => host = 10.20.153.11
分隔符“=>”前面是服务消费者的匹配条件,后面是服务提供者的过滤条件。当服务消费者节点满足匹配条件时,就对该服务消费者执行后面的过滤规则。那么上面这段表达式表达的意义就是IP为“10.20.153.10”的服务消费者都调用IP为“10.20.153.11”的服务提供者节点。
如果服务消费者的匹配条件为空,就表示对所有的服务消费者应用,就像下面的表达式一样。
=> host != 10.20.153.11
如果服务提供者的过滤条件为空,就表示禁止服务消费者访问,就像下面的表达式一样。
host = 10.20.153.10=>
下面我举一些Dubbo框架中的条件路由,来给你讲解下条件路由的具体应用场景。
- 排除某个服务节点
=> host != 172.22.3.91
一旦这条路由规则被应用到线上,所有的服务消费者都不会访问IP为172.22.3.91的服务节点,这种路由规则一般应用在线上流量排除预发布机以及摘除某个故障节点的场景。
- 白名单和黑名单功能
host != 10.20.153.10,10.20.153.11 =>
这条路由规则意思是除了IP为10.20.153.10和10.20.153.11的服务消费者可以发起服务调用以外,其他服务消费者都不可以,主要用于白名单访问逻辑,比如某个后台服务只允许特定的几台机器才可以访问,这样的话可以机器控制访问权限。
host = 10.20.153.10,10.20.153.11 =>
同理,这条路由规则意思是除了IP为10.20.153.10和10.20.153.11的服务消费者不能发起服务调用以外,其他服务消费者都可以,也就是实现了黑名单功能,比如线上经常会遇到某些调用方不管是出于有意还是无意的不合理调用,影响了服务的稳定性,这时候可以通过黑名单功能暂时予以封杀。
- 机房隔离
host = 172.22.3.* => host = 172.22.3.*
这条路由规则意思是IP网段为172.22.3.*的服务消费者,才可以访问同网段的服务节点,这种规则一般应用于服务部署在多个IDC,理论上同一个IDC内的调用性能要比跨IDC调用性能要好,应用这个规则是为了实现同IDC就近访问。
- 读写分离
method = find,list,get,is => host =172.22.3.94,172.22.3.95 method != find,list,get,is => host = 172.22.3.97,172.22.3.98
这条路由规则意思是find*、get*、is*等读方法调用IP为172.22.3.94和172.22.3.95的节点,除此以外的写方法调用IP为172.22.3.97和172.22.3.98的节点。对于大部分互联网业务来说,往往读请求要远远大于写请求,而写请求的重要性往往要远远高于读请求,所以需要把读写请求进行分离,以避免读请求异常影响到写请求,这时候就可以应用这种规则。
2. 脚本路由
脚本路由是基于脚本语言的路由规则,常用的脚本语言比如JavaScript、Groovy、JRuby等。以下面的脚本路由规则为例,我来给你详细讲解它的用法。
"script://0.0.0.0/com.foo.BarService?category=routers&dynamic=false&rule=" + URL.encode("(function route(invokers) { ... } (invokers))")
这里面“script://”就代表了这是一段脚本语言编写的路由规则,具体规则定义在脚本语言的route方法实现里,比如下面这段用JavaScript编写的route()方法表达的意思是,只有IP为10.20.153.10的服务消费者可以发起服务调用。
function route(invokers){
var result = new java.util.ArrayList(invokers.size());
for(i =0; i < invokers.size(); i ++){
if("10.20.153.10".equals(invokers.get(i).getUrl().getHost())){
result.add(invokers.get(i));
}
}
return result;
} (invokers));
既然服务路由是通过路由规则来实现的,那么服务消费者该如何获取路由规则呢?
服务路由的获取方式
根据我的实践经验,服务路由的获取方式主要有三种:
- 本地配置
顾名思义就是路由规则存储在服务消费者本地上。服务消费者发起调用时,从本地固定位置读取路由规则,然后按照路由规则选取一个服务节点发起调用。 - 配置中心管理
这种方式下,所有的服务消费者都从配置中心获取路由规则,由配置中心来统一管理。 - 动态下发
这种方式下,一般是运维人员或者开发人员,通过服务治理平台修改路由规则,服务治理平台调用配置中心接口,把修改后的路由规则持久化到配置中心。因为服务消费者订阅了路由规则的变更,于是就会从配置中心获取最新的路由规则,按照最新的路由规则来执行。
上面三种方式实际使用时,还是有一定区别的。
一般来讲,服务路由最好是存储在配置中心中,由配置中心来统一管理。这样的话,所有的服务消费者就不需要在本地管理服务路由,因为大部分的服务消费者并不关心服务路由的问题,或者说也不需要去了解其中的细节。通过配置中心,统一给各个服务消费者下发统一的服务路由,节省了沟通和管理成本。
但也不排除某些服务消费者有特定的需求,需要定制自己的路由规则,这个时候就适合通过本地配置来定制。
而动态下发可以理解为一种高级功能,它能够动态地修改路由规则,在某些业务场景下十分有用。比如某个数据中心存在问题,需要把调用这个数据中心的服务消费者都切换到其他数据中心,这时就可以通过动态下发的方式,向配置中心下发一条路由规则,将所有调用这个数据中心的请求都迁移到别的地方。
当然,这三种方式也可以一起使用,这个时候服务消费者的判断优先级是本地配置>动态下发>配置中心管理。
总结
服务路由的作用就是为了实现某些调用的特殊需求,比如分组调用、灰度发布、流量切换、读写分离等。在业务规模比较小的时候,可能所有的服务节点都部署在一起,也就不需要服务路由。但随着业务规模的扩大、服务节点增多,尤其是涉及多数据中心部署的情况,把服务节点按照数据中心进行分组,或者按照业务的核心程度进行分组,对提高服务的可用性是十分有用的。以微博业务为例,有的服务不仅进行了核心服务和非核心服务分组,还针对私有云和公有云所处的不同数据中心也进行了分组,这样的话就可以将服务之间的调用尽量都限定在同一个数据中心内部,最大限度避免跨数据中心的网络延迟、抖动等影响。
而服务路由具体是在本地配置,还是在配置中心统一管理,也是视具体业务需求而定的。如果没有定制化的需求,建议把路由规则都放到配置中心中统一存储管理。而动态下发路由规则对于服务治理十分有帮助,当数据中心出现故障的时候,可以实现动态切换流量,还可以摘除一些有故障的服务节点。
20 | 服务端出现故障时该如何应对?
微服务系统可能出现故障的种类,主要有三种故障。
- 集群故障。根据我的经验,微服务系统一般都是集群部署的,根据业务量大小而定,集群规模从几台到甚至上万台都有可能。一旦某些代码出现bug,可能整个集群都会发生故障,不能提供对外提供服务。
- 单IDC故障。现在大多数互联网公司为了保证业务的高可用性,往往业务部署在不止一个IDC。然而现实中时常会发生某个IDC的光缆因为道路施工被挖断,导致整个IDC脱网。
- 单机故障。顾名思义就是集群中的个别机器出现故障,这种情况往往对全局没有太大影响,但会导致调用到故障机器上的请求都失败,影响整个系统的成功率。
集群故障
一般而言,集群故障的产生原因不外乎有两种:一种是代码bug所导致,比如说某一段Java代码不断地分配大对象,但没有及时回收导致JVM OOM退出;另一种是突发的流量冲击,超出了系统的最大承载能力,比如“双11”这种购物活动,电商系统会在零点一瞬间涌入大量流量,超出系统的最大承载能力,一下子就把整个系统给压垮了。
应付集群故障的思路,主要有两种:限流和降级。
1. 限流
顾名思义,限流就是限制流量,通常情况下,系统能够承载的流量根据集群规模的大小是固定的,可以称之为系统的最大容量。当真实流量超过了系统的最大容量后,就会导致系统响应变慢,服务调用出现大量超时,反映给用户的感觉就是卡顿、无响应。所以,应该根据系统的最大容量,给系统设置一个阈值,超过这个阈值的请求会被自动抛弃,这样的话可以最大限度地保证系统提供的服务正常。
除此之外,通常一个微服务系统会同时提供多个服务,每个服务在同一时刻的请求量也是不同的,很可能出现的一种情况就是,系统中某个服务的请求量突增,占用了系统中大部分资源,导致其他服务没有资源可用。因此,还要针对系统中每个服务的请求量也设置一个阈值,超过这个阈值的请求也要被自动抛弃,这样的话不至于因为一个服务影响了其他所有服务。
在实际项目中,可以用两个指标来衡量服务的请求量,一个是QPS即每秒请求量,一个是工作线程数。不过QPS因为不同服务的响应快慢不同,所以系统能够承载的QPS相差很大,因此一般选择工作线程数来作为限流的指标,给系统设置一个总的最大工作线程数
以及单个服务的最大工作线程数,这样的话无论是系统的总请求量过大导致整体工作线程数量达到最大工作线程数,还是某个服务的请求量超过单个服务的最大工作线程数,都会被限流,以起到保护整个系统的作用。
2. 降级
在我看来,降级就是通过停止系统中的某些功能,来保证系统整体的可用性。降级可以说是一种被动防御的措施,为什么这么说呢?因为它一般是系统已经出现故障后所采取的一种止损措施。
那么降级一般是如何实现的呢?根据我的实践来看, 一种可行的方案是通过开关来实现。
具体来讲,就是在系统运行的内存中开辟一块区域,专门用于存储开关的状态,也就是开启还是关闭。并且需要监听某个端口,通过这个端口可以向系统下发命令,来改变内存中开关的状态。当开关开启时,业务的某一段逻辑就不再执行,而正常情况下,开关是关闭的状态。
开关一般用在两种地方,一种是新增的业务逻辑,因为新增的业务逻辑相对来说不成熟,往往具备一定的风险,所以需要加开关来控制新业务逻辑是否执行;另一种是依赖的服务或资源,因为依赖的服务或者资源不总是可靠的,所以最好是有开关能够控制是否对依赖服务或资源发起调用,来保证即使依赖出现问题,也能通过降级来避免影响。
在实际业务应用的时候,降级要按照对业务的影响程度进行分级,一般分为三级:
- 一级降级是对业务影响最小的降级,在故障的情况下,首先执行一级降级,所以一级降级也可以设置成自动降级,不需要人为干预;
- 二级降级是对业务有一定影响的降级,在故障的情况下,如果一级降级起不到多大作用的时候,可以人为采取措施,执行二级降级;
- 三级降级是对业务有较大影响的降级,这种降级要么是对商业收入有重大影响,要么是对用户体验有重大影响,所以操作起来要非常谨慎,不在最后时刻一般不予采用。
单IDC故障
在现实情况下,整个IDC脱网的事情时有发生,多半是因为不可抗力比如机房着火、光缆被挖断等,如果业务全部部署在这个IDC,那就完全不可访问了,所以国内大部分的互联网业务多采用多IDC部署。具体来说,有的采用同城双活,也就是在一个城市的两个IDC内部署;有的采用异地多活,一般是在两个城市的两个IDC内部署;当然也有支付宝这种金融级别的应用采用了“三地五中心
”部署,这种部署成本显然高比两个IDC要高得多,但可用性的保障要更高。
采用多IDC部署的最大好处就是当有一个IDC发生故障时,可以把原来访问故障IDC的流量切换到正常的IDC,来保证业务的正常访问。
流量切换的方式一般有两种,一种是基于DNS解析的流量切换,一种是基于RPC分组的流量切换。
1. 基于DNS解析的浏览切换
基于DNS解析流量的切换,一般是通过把请求访问域名解析的VIP从一个IDC切换到另外一个IDC。比如访问“www.weibo.com”,正常情况下北方用户会解析到联通机房的VIP,南方用户会解析到电信机房的VIP,如果联通机房发生故障的话,会把北方用户访问也解析到电信机房的VIP,只不过此时网络延迟可能会变长。
2. 基于RPC分组的流量切换
对于一个服务来说,如果是部署在多个IDC的话,一般每个IDC就是一个分组。假如一个IDC出现故障,那么原先路由到这个分组的流量,就可以通过向配置中心
下发命令,把原先路由到这个分组的流量全部切换到别的分组,这样的话就可以切换故障IDC的流量了。
单机故障
单机故障是发生概率最高的一种故障了,尤其对于业务量大的互联网应用来说,上万台机器的规模也是很常见的。这种情况下,发生单机故障的概率就很高了,这个时候只靠运维人肉处理显然不可行,所以就要求有某种手段来自动处理单机故障。
根据我的经验,处理单机故障一个有效的办法就是自动重启。具体来讲,你可以设置一个阈值,比如以某个接口的平均耗时为准,当监控单机上某个接口的平均耗时超过一定阈值时,就认为这台机器有问题,这个时候就需要把有问题的机器从线上集群中摘除掉,然后在重启服务后,重新加入到集群中。
不过这里要注意的是,需要防止网络抖动
造成的接口超时从而触发自动重启。一种方法是在收集单机接口耗时数据时,多采集几个点,比如每10s采集一个点,采集5个点,当5个点中有超过3个点的数据都超过设定的阈值范围,才认为是真正的单机问题,这时会触发自动重启策略。
除此之外,为了防止某些特殊情况下,短时间内被重启的单机过多,造成整个服务池可用节点数太少,最好是设置一个可重启的单机数量占整个集群的最大比例,一般这个比例不要超过10%,因为正常情况下,不大可能有超过10%的单机都出现故障。
总结
今天我们探讨了微服务系统可能出现的三种故障:集群故障、单IDC故障、单机故障,并且针对这三种故障我给出了分别的解决方案,包括降级、限流、流量切换以及自动重启。
在遇到实际的故障时,往往多个手段是并用的,比如在出现单IDC故障,首先要快速切换流量到正常的IDC,但此时可能正常IDC并不足以支撑两个IDC的流量,所以这个时候首先要降级部分功能,保证正常的IDC顺利支撑切换过来的流量。
而且要尽量让故障处理自动化,这样可以大大减少故障影响的时间。因为一旦需要引入人为干预,往往故障处理的时间都得是10分钟以上,这对大部分用户敏感型业务的影响是巨大的,如果能做到自动化故障处理的话,可以将故障处理的时间降低到1分钟以内甚至秒级别,这样的话对于用户的影响最小。
21 | 服务调用失败时有哪些处理手段?
微服务相比于单体应用最大的不同之处在于,服务的调用从同一台机器内部的本地调用变成了不同机器之间的远程方法调用,但是这个过程也引入了两个不确定的因素。
一个是调用的执行在服务调用者一端,即使服务消费者本身是正常的,服务提供者也可能由于诸如CPU、网络I/O、磁盘、内存、网卡等硬件原因导致调用失败,还有可能由于本身程序执行问题比如GC暂停导致调用失败。
另一个不确定因素是调用发生在两台机器之间,所以要经过网络传输,而网络的复杂性是不可控的,网络丢包、延迟以及随时可能发生的瞬间抖动都有可能造成调用失败。
所以,单体应用改造为微服务架构后,要针对服务调用失败进行特殊处理。那具体来说有哪些处理手段呢?下面我就结合自己的实战经验,一起来聊聊服务调用失败都有哪些处理手段。
超时
单体应用被改造成微服务架构后,一次用户调用可能会被拆分成多个系统之间的服务调用,任何一次服务调用如果发生问题都可能会导致最后用户调用失败。而且在微服务架构下,一个系统的问题会影响所有调用这个系统所提供服务的服务消费者,如果不加以控制,严重的话会引起整个系统雪崩。
所以在实际项目中,针对服务调用都要设置一个超时时间,以避免依赖的服务迟迟没有返回调用结果,把服务消费者拖死。这其中,超时时间的设定也是有讲究的,不是越短越好,因为太短可能会导致有些服务调用还没有来得及执行完就被丢弃了;当然时间也不能太长,太长有可能导致服务消费者被拖垮。根据我的经验,找到比较合适的超时时间需要根据正常情况下,服务提供者的服务水平来决定。具体来说,就是按照服务提供者线上真实的服务水平,取P999或者P9999的值,也就是以99.9%或者99.99%的调用都在多少毫秒内返回为准。
重试
虽然设置超时时间可以起到及时止损的效果,但是服务调用的结果毕竟是失败了,而大部分情况下,调用失败都是因为偶发的网络问题或者个别服务提供者节点有问题导致的,如果能换个节点再次访问说不定就能成功。而且从概率论的角度来讲,假如一次服务调用失败的概率为1%,那么连续两次服务调用失败的概率就是0.01%,失败率降低到原来的1%。
所以,在实际服务调用时,经常还要设置一个服务调用超时后的重试次数。假如某个服务调用的超时时间设置为100ms,重试次数设置为1,那么当服务调用超过100ms后,服务消费者就会立即发起第二次服务调用,而不会再等待第一次调用返回的结果了。
双发
假如一次调用不成功的概率为1%,那么连续两次调用都不成功的概率就是0.01%,根据这个推论,一个简单的提高服务调用成功率的办法就是每次服务消费者要发起服务调用的时候,都同时发起两次服务调用,一方面可以提高调用的成功率,另一方面两次服务调用哪个先返回就采用哪次的返回结果,平均响应时间也要比一次调用更快,这就是双发。
但是这样的话,一次调用会给后端服务两倍的压力,所要消耗的资源也是加倍的,所以一般情况下,这种“鲁莽”的双发是不可取的。我这里讲一个更为聪明的双发,即“备份请求”(Backup Requests),它的大致思想是服务消费者发起一次服务调用后,在给定的时间内如果没有返回请求结果,那么服务消费者就立即发起另一次服务调用。这里需要注意的是,这个设定的时候通常要比超时时间短得多,比如超时时间取的是P999,那么备份请求时间取的可能是P99或者P90,这是因为如果在P99或者P90的时间内调用还没有返回结果,那么大概率可以认为这次请求属于慢请求了,再次发起调用理论上返回要更快一些。
在实际线上服务运行时,P999由于长尾请求时间较长的缘故,可能要远远大于P99和P90。在我经历的一个项目中,一个服务的P999是1s,而P99只有200ms、P90只有50ms,这样的话,如果备份请求时间取的是P90,那么第二次请求等待的时间只有50ms。不过这里需要注意的是,备份请求要设置一个最大重试比例,以避免在服务端出现问题的时,大部分请求响应时间都会超过P90的值,导致请求量几乎翻倍,给服务提供者造成更大的压力。我的经验是这个最大重试比例可以设置成15%,一方面能尽量体现备份请求的优势,另一方面不会给服务提供者额外增加太大的压力。
熔断
假如服务提供者出现故障,短时间内无法恢复时,无论是超时重试还是双发不但不能提高服务调用的成功率,反而会因为重试给服务提供者带来更大的压力,从而加剧故障。
针对这种情况,就需要服务消费者能够探测到服务提供者发生故障,并短时间内停止请求,给服务提供者故障恢复的时间,待服务提供者恢复后,再继续请求。这就好比一条电路,电流负载过高的话,保险丝就会熔断,以防止火灾的发生,所以这种手段就被叫作“熔断”。
首先我们先来简单了解一下熔断的工作原理。
简单来讲,熔断就是把客户端的每每一次服务调用用断路器封装起来,通过断路器来监控每一次服务调用。如果某一段时间内,服务调用失败的次数达到一定阈值,那么断路器就会被触发,后续的服务调用就直接返回,也就不会再向服务提供者发起请求了。
再来看下面这张图,熔断之后,一旦服务提供者恢复之后,服务调用如何恢复呢?这就牵扯到熔断中断路器的几种状态。
- Closed状态:正常情况下,断路器是处于关闭状态的,偶发的调用失败也不影响。
- Open状态:当服务调用失败次数达到一定阈值时,断路器就会处于开启状态,后续的服务调用就直接返回,不会向服务提供者发起请求。
- Half Open状态:当断路器开启后,每隔一段时间,会进入半打开状态,这时候会向服务提供者发起探测调用,以确定服务提供者是否恢复正常。如果调用成功了,断路器就关闭;如果没有成功,断路器就继续保持开启状态,并等待下一个周期重新进入半打开状态。
关于断路器的实现,最经典也是使用最广泛的莫过于Netflix开源的Hystrix了,下面我来给你介绍下Hystrix是如何实现断路器的。
Hystrix的短路器也包含三种状态:关闭、打开、半打开。Hystrix会把每一次服务调用都用HystrixCommand封装起来,它会实时记录每一次服务调用的状态,包括成功、失败、超时还是被线程拒绝。当一段时间内服务调用的失败率高于设定的阈值后,Hystrix的断路器就会进入进入打开状态,新的服务调用就会直接返回,不会向服务提供者发起调用。再等待设定的时间间隔后,Hystrix的断路器又会进入半打开状态,新的服务调用又可以重新发给服务提供者了;如果一段时间内服务调用的失败率依然高于设定的阈值的话,断路器会重新进入打开状态,否则的话,断路器会被重置为关闭状态。
其中决定断路器是否打开的失败率阈值可以通过下面这个参数来设定:
HystrixCommandProperties.circuitBreakerErrorThresholdPercentage()
而决定断路器何时进入半打开的状态的时间间隔可以通过下面这个参数来设定:
HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds()
断路器实现的关键就在于如何计算一段时间内服务调用的失败率,那么Hystrix是如何做的呢?
答案就是下图所示的滑动窗口算法,下面我来解释一下具体原理。
Hystrix通过滑动窗口来对数据进行统计,默认情况下,滑动窗口包含10个桶,每个桶时间宽度为1秒,每个桶内记录了这1秒内所有服务调用中成功的、失败的、超时的以及被线程拒绝的次数。当新的1秒到来时,滑动窗口就会往前滑动,丢弃掉最旧的1个桶,把最新1个桶包含进来。
任意时刻,Hystrix都会取滑动窗口内所有服务调用的失败率作为断路器开关状态的判断依据,这10个桶内记录的所有失败的、超时的、被线程拒绝的调用次数之和除以总的调用次数就是滑动窗口内所有服务的调用的失败率。
总结
微服务架构下服务调用失败的几种常见手段:超时、重试、双发以及熔断,实际使用时,具体选择哪种手段要根据具体业务情况来决定。
根据我的经验,大部分的服务调用都需要设置超时时间以及重试次数,当然对于非幂等的也就是同一个服务调用重复多次返回结果不一样的来说,不可以重试,比如大部分上行请求都是非幂等的。至于双发,它是在重试基础上进行一定程度的优化,减少了超时等待的时间,对于长尾请求的场景十分有效。采用双发策略后,服务调用的P999能大幅减少,经过我的实践证明是提高服务调用成功率非常有效的手段。而熔断能很好地解决依赖服务故障引起的连锁反应,对于线上存在大规模服务调用的情况是必不可少的,尤其是对非关键路径的调用,也就是说即使调用失败也对最终结果影响不大的情况下,更加应该引入熔断。
22 | 如何管理服务配置?
曾经的单体应用只需要管理一套配置;而拆分为微服务后,每一个系统都有自己的配置,并且都各不相同,而且因为服务治理的需要,有些配置还需要能够动态改变,以达到动态降级、切流量、扩缩容等目的,这也是今天我要与你探讨的,在微服务架构下服务配置如何管理的问题。
本地配置
服务配置管理最简单的方案就是把配置当作代码同等看待,随着应用程序代码一起发布。比如下面这段代码用到了开源熔断框架Hystrix,并且在代码里定义了几个配置,一个是线程的超时时间是3000ms,一个是熔断器触发的错误比率是60%。
@HystrixCommand(fallbackMethod = "getDefaultProductInventoryByCode",
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "3000"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value="60")
}
)
public Optional<ProductInventoryResponse> getProductInventoryByCode(String productCode)
{
....
}
还有一种方案就是把配置都抽离到单独的配置文件当中,使配置与代码分离,比如下面这段代码。
@HystrixCommand(commandKey = "inventory-by-productcode", fallbackMethod = "getDefaultProductInventoryByCode")
public Optional<ProductInventoryResponse> getProductInventoryByCode(String productCode)
{
...
}
相应的配置可以抽离到配置文件中,配置文件的内容如下:
hystrix.command.inventory-by-productcode.execution.isolation.thread.timeoutInMilliseconds=2000
hystrix.command.inventory-by-productcode.circuitBreaker.errorThresholdPercentage=60
无论是把配置定义在代码里,还是把配置从代码中抽离出来,都相当于把配置存在了应用程序的本地。这样做的话,如果需要修改配置,就需要重新走一遍代码或者配置的发布流程,在实际的线上业务当中,这是一个很重的操作,往往相当于一次上线发布过程,甚至更繁琐,需要更谨慎。
这时你自然会想,如果能有一个集中管理配置的地方,如果需要修改配置,只需要在这个地方修改一下,线上服务就自动从这个地方同步过去,不需要走代码或者配置的发布流程,不就简单多了吗?没错,这就是下面要讲的配置中心。
配置中心
配置中心的思路就是把服务的各种配置,如代码里配置的各种参数、服务降级的开关甚至依赖的资源等都在一个地方统一进行管理。服务启动时,可以自动从配置中心中拉取所需的配置,并且如果有配置变更的情况,同样可以自动从配置中心拉取最新的配置信息,服务无须重新发布。
具体来讲,配置中心一般包含下面几个功能:
- 配置注册功能
- 配置反注册功能
- 配置查看功能
- 配置变更订阅功能
1. 配置存储结构
如下图所示,一般来讲,配置中心存储配置是按照Group来存储的,同一类配置放在一个Group下,以K, V键值对存储。
2. 配置注册
配置中心对外提供接口/config/service?action=register来完成配置注册功能,需要传递的参数包括配置对应的分组Group,以及对应的Key、Value值。比如调用下面接口请求就会向配置项global.property中添加Key为reload.locations、Value为/data1/confs/system/reload.properties的配置。
curl "http://ip:port/config/service?action=register" -d "group=global.property&key=reload.locations&value=/data1/confs/system/reload.properties"
3. 配置反注册
配置中心对外提供接口config/service?action=unregister来完成配置反注册功能,需要传递的参数包括配置对象的分组Group,以及对应的Key。比如调用下面的接口请求就会从配置项global.property中把Key为reload.locations的配置删除。
curl "http://ip:port/config/service?action=unregister"-d "group=global.property&key=reload.locations"
4. 配置查看
配置中心对外提供接口config/service?action=lookup来完成配置查看功能,需要传递的参数包括配置对象的分组Group,以及对应的Key。比如调用下面的接口请求就会返回配置项global.property中Key为reload.locations的配置值。
curl "http://ip:port/config/service?action=lookup&group=global.property&key=reload.locations"
5. 配置变更订阅
配置中心对外提供接口config/service?action=getSign来完成配置变更订阅接口,客户端本地会保存一个配置对象的分组Group的sign值,同时每隔一段时间去配置中心拉取该Group的sign值,与本地保存的sign值做对比。一旦配置中心中的sign值与本地的sign值不同,客户端就会从配置中心拉取最新的配置信息。比如调用下面的接口请求就会返回配置项global.property中Key为reload.locations的配置值。
curl "http://ip:port/config/service?action=getSign&group=global.property"
实际业务中,有哪些场景应用配置中心比较合适呢?下面我就结合自己的经验,列举几个配置中心的典型应用场景,希望能给你一些启发。
- 资源服务化。对于大部分互联网业务来说,在应用规模不大的时候,所依赖的资源如Memcached缓存或者MCQ消息队列的数量也不多,因此对应的资源的IP可以直接写在配置里。但是当业务规模发展到一定程度后,所依赖的这些资源的数量也开始急剧膨胀。以微博的业务为例,核心缓存Memcached就有上千台机器,经常会遇到个别机器因为硬件故障而不可用,这个时候如果采用的是本地配置的话,就需要去更改本地配置,把不可用的IP改成可用的IP,然后发布新的配置,这样的过程十分不便。但如果采用资源服务化的话,把对应的缓存统统归结为一类配置,然后如果有个别机器不可用的话,只需要在配置中心把对应的IP换成可用的IP即可,应用程序会自动同步到本机,也无须发布。
- 业务动态降级。微服务架构下,拆分的服务越多,出现故障的概率就越大,因此需要有对应的服务治理手段,比如要具备动态降级能力,在依赖的服务出现故障的情况下,可以快速降级对这个服务的调用,从而保证不受影响。为此,服务消费者可以通过订阅依赖服务是否降级的配置,当依赖服务出现故障的时候,通过向配置中心下达指令,修改服务的配置为降级状态,这样服务消费者就可以订阅到配置的变更,从而降级对该服务的调用。
- 分组流量切换。前面我提到过,为了保证异地多活以及本地机房调用,一般服务提供者的部署会按照IDC维度进行部署,每个IDC划分为一个分组,这样的话,如果一个IDC出现故障,可以把故障IDC机房的调用切换到其他正常IDC。为此,服务消费者可以通过订阅依赖服务的分组配置,当依赖服务的分组配置发生变更时,服务消费者就对应的把调用切换到新的分组,从而实现分组流量切换。
开源配置中心与选型
讲到这里,你可以根据我前面对配置中心的讲解自己去实现一个配置中心,但其实对于大部分中小团队来说,目前业界已经开源的配置中心实现可以说功能已经十分完善了,并且经过很多公司实际线上业务的充分论证,能满足大多数业务的需求,所以我建议是尽量选择成熟的开源配置中心实现,那么有哪些开源的配置中心可以使用呢?下面我就简单介绍下三个典型的开源实现:
- Spring Cloud Config。Spring Cloud中使用的配置中心组件,只支持Java语言,配置存储在git中,变更配置也需要通过git操作,如果配置中心有配置变更,需要手动刷新。
- Disconf。百度开源的分布式配置管理平台,只支持Java语言,基于Zookeeper来实现配置变更实时推送给订阅的客户端,并且可以通过统一的管理界面来修改配置中心的配置。
- Apollo。携程开源的分布式配置中心,支持Java和.Net语言,客户端和配置中心通过HTTP长连接实现实时推送,并且有统一的管理界面来实现配置管理。
在实际选择的时候,Spring Cloud Config作为配置中心的功能比较弱,只能通过git命令操作,而且变更配置的话还需要手动刷新,如果不是采用Spring Cloud框架的话不建议选择。而Disconf和Apollo的功能都比较强大,在国内许多互联网公司内部都有大量应用,其中Apollo对Spring Boot的支持比较好,如果应用本身采用的是Spring Boot开发的话,集成Apollo会更容易一些。
总结
关于业务中是否需要用到配置中心,以及选择哪种配置中心,要根据实际情况而定,如果业务比较简单,配置比较少并且不经常变更的话,采用本地配置是最简单的方案,这样的话不需要额外引入配置中心组件;相反,如果业务比较复杂,配置多而且有动态修改配置的需求的话,强烈建议引入配置中心来进行管理,而且最好做到配置变更实时推送给客户端,并且可以通过统一的管理界面来管理配置,这样的话能极大地降低运维的复杂度,减少人为介入,从而提高效率。
23 | 如何搭建微服务治理平台?
单体应用改造为微服务架构后,服务调用从本地调用变成了远程方法调用后,面临的各种不确定因素变多了,一方面你需要能够监控各个服务的实时运行状态、服务调用的链路和拓扑图;另一方面你需要在出现故障时,能够快速定位故障的原因并可以通过诸如降级、限流、切流量、扩容等手段快速干预止损。这个时候就需要我今天要讲的微服务治理平台了。
微服务治理平台的基本功能
微服务治理平台就是与服务打交道的统一入口,无论是开发人员还是运维人员,都能通过这个平台对服务进行各种操作,比如开发人员可以通过这个平台对服务进行降级操作,运维人员可以通过这个平台对服务进行上下线操作,而不需要关心这个操作背后的具体实现。
接下来我就结合下面这张图,给你介绍一下一个微服务治理平台应该具备哪些基本功能。
1. 服务管理
通过微服务治理平台,可以调用注册中心提供的各种管理接口来实现服务的管理。根据我的经验,服务管理一般包括以下几种操作:
- 服务上下线。当上线一个新服务的时候,可以通过调用注册中心的服务添加接口,新添加一个服务,同样要下线一个已有服务的时候,也可以通过调用注册中心的服务注销接口,删除一个服务。
- 节点添加/删除。当需要给服务新添加节点时候,可以通过调用注册中心的节点注册接口,来给服务新增加一个节点。而当有故障节点出现或者想临时下线一些节点时,可以通过调用注册中心的节点反注册接口,来删除节点。
- 服务查询。这个操作会调用注册中心的服务查询接口,可以查询当前注册中心里共注册了多少个服务,每个服务的详细信息。
- 服务节点查询。这个操作会调用注册中心的节点查询接口,来查询某个服务下一共有多少个节点。
2. 服务治理
通过微服务治理平台,可以调用配置中心提供的接口,动态地修改各种配置来实现服务的治理。根据我的经验,常用的服务治理手段包括以下几种:
- 限流。一般是在系统出现故障的时候,比如像微博因为热点突发事件的发生,可能会在短时间内流量翻几倍,超出系统的最大容量。这个时候就需要调用配置中心的接口,去修改非核心服务的限流阈值,从而减少非核心服务的调用,给核心服务留出充足的冗余度。
- 降级。跟限流一样,降级也是系统出现故障时的应对方案。要么是因为突发流量的到来,导致系统的容量不足,这时可以通过降级一些非核心业务,来增加系统的冗余度;要么是因为某些依赖服务的问题,导致系统被拖慢,这时可以降级对依赖服务的调用,避免被拖死。
- 切流量。通常为了服务的异地容灾考虑,服务部署在不止一个IDC内。当某个IDC因为电缆被挖断、机房断电等不可抗力时,需要把故障IDC的流量切换到其他正常IDC,这时候可以调用配置中心的接口,向所有订阅了故障IDC服务的消费者下发指令,将流量统统切换到其他正常IDC,从而避免服务消费者受影响。
3. 服务监控
微服务治理平台一般包括两个层面的监控。一个是整体监控,比如服务依赖拓扑图,将整个系统内服务间的调用关系和依赖关系进行可视化的展示;一个是具体服务监控,比如服务的QPS、AvgTime、P999等监控指标。其中整体监控可以使用服务追踪系统提供的服务依赖拓扑图,而具体服务监控则可以通过Grafana等监控系统UI来展示。
4. 问题定位
微服务治理平台实现问题定位,可以从两个方面来进行。一个是宏观层面,即通过服务监控来发觉异常,比如某个服务的平均耗时异常导致调用失败;一个是微观层面,即通过服务追踪来具体定位一次用户请求失败具体是因为服务调用全链路的哪一层导致的。
5.日志查询
微服务治理平台可以通过接入类似ELK的日志系统,能够实时地查询某个用户的请求的详细信息或者某一类用户请求的数据统计。
6. 服务运维
微服务治理平台可以调用容器管理平台,来实现常见的运维操作。根据我的经验,服务运维主要包括下面几种操作:
- 发布部署。当服务有功能变更,需要重新发布部署的时候,可以调用容器管理平台分批按比例进行重新部署,然后发布到线上。
- 扩缩容。在流量增加或者减少的时候,需要相应地增加或者缩减服务在线上部署的实例,这时候可以调用容器管理平台来扩容或者缩容。
如何搭建微服务治理平台
微服务治理平台之所以能够实现上面所说的功能,关键之处就在于它能够封装对微服务架构内的各个基础设施组件的调用,从而对外提供统一的服务操作API,而且还提供了可视化的界面,以方便开发人员和运维人员操作。
根据我的经验,一个微服务治理平台的组成主要包括三部分:Web Portal层、API层以及数据存储DB层,结合下面这张图我来详细讲解下每一层该如何实现。
第一层:Web Portal。也就是微服务治理平台的前端展示层,一般包含以下几个功能界面:
- 服务管理界面,可以进行节点的操作,比如查询节点、删除节点。
- 服务治理界面,可以进行服务治理操作,比如切流量、降级等,还可以查看操作记录。
- 服务监控界面,可以查看服务的详细信息,比如QPS、AvgTime、耗时分布区间以及P999等。
- 服务运维界面,可以执行服务的扩缩容操作,还可以查看扩缩容的操作历史。
第二层,API。也就是微服务治理平台的后端服务层,这一层对应的需要提供Web Portal接口以调用,对应的一般包含下面几个接口功能:
- 添加服务接口。这个接口会调用注册中心提供的服务添加接口来新发布一个服务。
- 删除服务接口。这个接口会调用注册中心提供的服务注销接口来下线一个服务。
- 服务降级/限流/切流量接口。这几个接口会调用配置中心提供的配置修改接口,来修改对应服务的配置,然后订阅这个服务的消费者就会从配置中心拉取最新的配置,从而实现降级、限流以及流量切换。
- 服务扩缩容接口。这个接口会调用容器平台提供的扩缩容接口,来实现服务的实例添加和删除。
- 服务部署接口。这个接口会调用容器平台提供的上线部署接口,来实现服务的线上部署。
第三层,DB。也就是微服务治理平台的数据存储层,因为微服务治理平台不仅需要调用其他组件提供的接口,还需要存储一些基本信息,主要分为以下几种:
- 用户权限。因为微服务治理平台的功能十分强大,所以要对用户的权限进行管理。一般可以分为可浏览、可更改以及管理员三个权限。而且还需要对可更改的权限进行细分,按照不同服务的负责人进行权限划分,一个人只能对它负责的服务的进行更改操作,而不能修改其他人负责的服务。
- 操作记录。用来记录下用户在平台上所进行的变更操作,比如降级记录、扩缩容记录、切流量记录等。
- 元数据。主要是用来把服务在各个系统中对应的记录映射到微服务治理平台中,统一进行管理。比如某个服务在监控系统里可能有个特殊标识,在注册中心里又使用了另外一个标识,为了统一就需要在微服务治理平台统一进行转换,然后进行数据串联。
总结
可以说一个微服务框架是否成熟,除了要看它是否具备服务治理能力,还要看是否有强大的微服务治理平台。因为微服务治理平台能够将多个系统整合在一起,无论是对开发还是运维来说,都能起到事半功倍的作用,这也是当前大部分开源微服务框架所欠缺的部分,所以对于大部分团队来说,都需要自己搭建微服务治理平台。不过好在微服务治理平台本身的架构并不复杂,你可以根据自己的实际需要,来决定微服务治理平台具备哪些功能。
24 | 微服务架构该如何落地?
在实际项目中,如何让一个团队把我们所学的微服务架构落地呢?
今天我就结合自己的经验,定位在中小规模团队,谈谈微服务架构到底该如何落地。
组建合适的技术团队
微服务架构相比于单体应用来说复杂度提升了很多,这其中涉及很多组件,比如注册中心、配置中心、RPC框架、监控系统、追踪系统、服务治理等,每个组件都需要专门的人甚至专家把控才能hold住,不然微服务架构的落地就相当于空中楼阁,虚无缥缈。
想要落地微服务,首先需要合适的人,也就是组建一支合适的技术团队。你一定很容易想到,是不是只有架构师适合做微服务架构的开发?一定程度上,这是合理的,因为微服务架构所涉及的具体技术,比如CAP理论、底层网络可靠性保证、Netty高并发框架等,都对技术的深度要求比较高,一般有经验的架构师才能掌握,所以这个技术团队必须包含技术能力很强的架构师。但是还要考虑到微服务架构最后还是要落地到业务当中,既要满足业务的需求,也要防止一种情况的发生,那就是全部由架构人员组成技术团队,根据自己的设想,脱离了实际的业务场景,最后开发出来的架构中看不中用,业务无法实际落地,既打击了团队人员积极性,又对业务没有实际价值,劳民伤财。所以这支技术团队,也必须包含做业务懂业务的开发人员,只有他们了解业务的实际痛点以及落地过程中的难点,这样才能保证最后设计出的微服务架构是贴合业务实际的,并且最后是能够实际落地的。
从一个案例入手
当你的团队决定要对业务进行微服务架构改造时,要避免一上来就妄想将整个业务进行服务化拆分、追求完美。这种想法是很危险的,一切的技术改造都应当以给业务创造价值为宗旨
,所以业务的稳定性要放在第一位,切忌好高骛远。
正确的方法是首先从众多业务中找到一个小的业务进行试点,前期的技术方案以满足这个小的业务需求为准,力求先把这个小业务的微服务架构落地实施,从中发现各种问题并予以解决,然后才可以继续考虑更大规模的推广。这样的话,即使微服务架构的改造因为技术方案不成熟,对业务造成了影响,也只是局限在一个小的业务之中,不会对整体业务造成太大影响。否则的话,如果因为微服务架构的改造给业务带来灾难性的后果,在许多技术团队的决策者来看,可能微服务架构的所带来的种种好处也不足以抵消其带来的风险,最后整个微服务架构的改造可能就夭折了。
回想一下微博业务的微服务改造,从2013年开始进行微服务架构的研发,到2014年用户关系服务开始进行微服务改造,再到2015年Feed业务开始进行微服务改造,从几个服务上线后经过春晚流量的考验后,逐步推广到上百个服务的上线,整个过程持续了两年多时间。虽然周期比较长,但是对于大流量的业务系统来说,稳定性永远是在第一位的,业务架构改造追求的是稳步推进,中间可以有小的波折,但对整体架构的演进方向不会产生影响。
做好技术取舍
我在搭建微服务架构的时候,其实做的最多的工作就是技术取舍。比如在开发RPC框架的时候,是选择自研呢还是采用开源RPC框架呢?如果自研的话,目前团队系统的主要语言是Java,那么RPC框架是只支持Java语言就可以了,还是考虑到将来有可能需要支持其他语言呢?
我的经验就是一切以业务的实际情况为准,只要满足当前的需求就好,切忌好高骛远,尤其是对于技术能力很强的开发者来说,很容易陷入对技术的完美追求,投入过多精力在架构的雕花工作上,而忽视了眼下业务最实际的需求。尤其是在团队技术人力紧张,开发周期短的时候,更需要集中力量去满足业务最迫切的需求。而对于架构的完善以及一些附加功能的追求,可以在后面业务落地后逐步进行完善。
以微博的服务化框架Motan为例,因为微博平台的开发语言主要是Java,所以最早Motan只支持Java语言。从2017年开始,有了跨语言服务化调用的需求,才在此基础上,对架构进行了升级,加入了对Go、PHP等语言的支持。而且在早期业务开始使用时,只开发了最基本的几个核心组件如RPC框架、注册中心和配置中心,以及简单的监控系统,而服务追踪系统、服务治理平台这些高级的功能都没有,后来随着重要业务进行微服务改造的越来越多,不断补充技术人力,才开始完善服务追踪系统以及服务治理平台。
除此之外,在做技术选型的时候,还要考虑到团队的实际掌控能力,尤其是对一些新技术方案的引入要尤其慎重。如果没有合适的人能够掌控这些技术,那么贸然引入新技术,一旦业务受影响时,如果没有人能有效干预,这对业务来说是灾难性的后果。
微博在做注册中心选型的时候,没有选取当时很火的Zookeeper的一个重要原因就是,它底层依赖的是HBase存储,当时团队中还没有有经验的运维和开发人员;但团队对Redis十分了解,所以基于Redis存储,自研了一套注册中心,完全能够满足需求,并且又没有引入技术不可控因素。
采用DevOps
微服务架构带来的不光是业务开发模式的改变,对测试和运维的影响也是根本性的。以往在单体应用架构时,开发只需要整体打包成一个服务,交给测试去做自动化测试、交给运维去部署发布就可以了。但是微服务架构下,一个单体应用被拆分成多个细的微服务,并且需要独自开发、测试和上线,如果继续按照之前的单体应用模式运维,那么测试和运维的工作量相当于成倍的增加。因此迫切需要对以往的开发、测试和运维模式进行升级,从我的经验来看,最好的方案就是采用DevOps,对微服务架构进行一站式开发、测试、上线和运维。
在单体应用架构下,开发、测试和运维这三者角色的区分是十分比较明显的,分属于不同的部门。而在微服务架构下,由于服务被拆分得足够细,每个服务都需要完成独立的开发、测试和运维工作,有自己完整的生命周期,所以需要将一个服务从代码开发、单元测试、集成测试以及服务发布都自动化起来。这样的话,测试人员就可以从众多微服务的测试中解放出来,着重进行自动化测试用例的维护;运维人员也可以从众多微服务的上线发布工作中解放出来,着重进行DevOps体系工具的建设。而每个服务的开发负责人,需要对服务的整个生命周期负责,无论是在代码检查阶段出现问题,还是测试阶段和发布阶段出现问题,都需要去解决。
统一微服务治理平台
微服务架构下会衍生出许多新的问题,比如RPC调用超时、注册中心获取失败、服务容量不足等,有些问题需要开发介入去定位分析,而有些问题需要运维介入,十分混乱。
微博在进行微服务改造初期,就面临着诸多问题,比如某一个微服务的容量不足了,需要进行扩容,而它所依赖的服务也需要进行扩容,但这种依赖关系只有业务的开发人员清楚,运维人员其实并不知晓详情。还有就是某个服务依赖的另一个服务出现故障,需要紧急降级,而此时如果运维人员操作的话并不知道哪个开关,虽然开发知晓,但开发实际上又没有线上服务器的操作权限。
所以,这时就迫切需要一个微服务治理平台,能够将微服务的服务治理以及各种运维操作都统一管理起来,并且打破开发和运维之间的隔阂,给予同样的权限,让服务的开发人员真正做到对自己的服务负责,不仅要对自己的服务情况了如指掌,还需要能对自己的服务进行治理和运维。
需要开发和运维深入合作,发挥各自专业的特长,将微服务治理的功能以及之前运维系统的基础功能结合在一起,打造成“一站式”微服务治理平台。
总结
总结来讲就是,首先你必须组建一支合适的技术团队,这其中不仅要包含资深的架构师,还需要包含业务的开发者。在选择业务进行微服务架构改造时,不能追大求全,正确的做法应当是先以一个适当规模的业务进行微服务改造,走完整个微服务架构落地的过程,从而找出问题,不断打磨到成熟可用的状态,再推广到更多更重要的业务当中。在改造的过程中,要做好技术取舍,以团队人员的实际情况以及业务的实际需求为准绳,切忌追新立异,避免给业务引入不可控因素,留下“架构债”。同时,微服务架构的过程,也是团队组织变革的过程,传统意义上的开发、测试和运维明确的分割线会被打破,出现一种DevOps工程师的角色,他需要对服务全生命周期负责。为了做到这一点,就需要一个统一的微服务治理平台,融合服务治理和运维的各种功能。
实际上,每个团队都有各自不同的情况,但只要秉承上面这些基本准则,就可以走出一条适合自己团队的微服务架构路线出来,这其中没有高低之分,适合自己的才是最好的。