随着越来越多的公司进行SOA和微服务化建设,系统架构也从单体应用架构过渡到了多体应用架构,再到分布式应用架构。服务之间的调用,也由少到多,所处环境也由简单变得复杂。
一、前言
1.1 服务调用是什么
简单而言,服务A暴露接口,服务B调用了该接口,这就是一次完整的服务调用。调用的方式一般是HTTP,服务调用对应了一次完整的HTTP连接。如下图所示:
看起来蛮简单,不过服务B需要知道服务A的地址,这个地址可能需要被域名解析代理,被网关代理,并且该地址对应的服务A节点是有效的、可靠的,才能保证服务调用总是成功的。为了提升服务A的可用性,将服务A部署多个节点,服务B需要知道所有服务A的节点,并在服务调用时选择出一个有效的节点。
二、数禾遇到的问题
数禾科技在已实现系统上云的比例达到了100%,管理上百个微服务。系统上云降低了系统成本,不过也带来更多的复杂度,对服务调用带来了很多挑战。
如上图所示,展示了基础设施与系统结构的对应关系。集群对应Kubernetes集群,平台对应命名空间,服务的组对应Pod,而服务节点对应Pod中Container。当服务B想要调用服务A的接口时,服务B需要知道服务A在哪里,是BOOT类型还是MVC类型,服务A的节点是否可用,服务调用随着系统复杂的提升变得愈加困难。基础架构团队将服务调用遇到的一个个问题梳理起来,然后一个个解决,并做到无感化,透明化。接下来我们一起了解一下,数禾遇到了在什么场景遇到了哪些与服务调用相关的问题。
2.1 服务调用的障碍
2.1.1 应用升级
数禾的系统架构的升级不是一蹴而就的,系统中的应用陆续由MVC类型转为BOOT类型,MVC类型与BOOT类型在系统中将会共存很长一段时间。MVC类型与BOOT类型的类型差异对于服务调用的影响,MVC类型无法与注册中心组合实现服务注册与服务发现的功能。
2.1.2 多平台建设
数禾系统支撑了多个产品,产品属于的平台不一样,导致多平台共存建设的现状。由于系统建设规划和合规的要求,不同平台间会有一定的隔离性,导致不同平台的应用间的服务调用无法直接通过服务发现的方式。
2.1.3 多集群部署
出于系统可用性建设的需求,数禾进行了异地多活建设,也就产生多集群部署的现状。服务调用就产生了跨集群的问题,一般有两种场景:
- 某个集群节点都down了,路由到另一个集群,也就是灾备机制。此类场景只适用于BOOT服务。
- 某个应用只部署了一个集群,MVC配置的地址就是另一个集群的地址。(一般几乎没有这种场景)
2.1.4 混合云建设
数禾基于健壮性、经济性和适配性的考虑,系统建设基于混合云建设,从而出现了多VPC共存。必然存在同一个服务既存在于AWS也存在于阿里云,或者一部分服务存在于AWS一部分服务存在于阿里云的现象。这也对服务调用带来了挑战。
2.2 服务发布
应用发布时,新版本平滑发布,逐步替换旧版本,并在异常状况下,回滚或中止后续发布的发布过程称为灰度发布。灰度发布机制也对服务调用产生了影响,发布时,发布组的节点需要被屏蔽,流量降到0;发布完成之后,组的流量分配恢复,解除发布组的节点的屏蔽功能。
2.3 动态路由
在应用运行时,更换应用中的负载均衡策略,称为动态路由。动态路由降低了负载均衡策略变更的成本,而负载均衡策略是服务调用时选择的服务节点方法。
三、基础架构建设中的服务调用实践
数禾选择Spring Cloud作为微服务系统建设的技术栈,并基于Spring Cloud的OpenFeign和Ribbon,结合了企业级微服务系统建设的需要,对服务调用与负载均衡器的功能进行扩展。扩展的核心点在于自定义的负载均衡器策略,主要实现了如下的功能:
- 服务调用跨各种“障碍“
- 支持灰度发布
- 动态路由
3.1 遇水架桥逢山开路
Pod启动之后,服务节点将Pod的Pod IP注册到注册中心Consul。由于Pod IP是虚拟的IP,所以只能被同一个Kubernetes集群中的服务调用者调用。调用方通过负载均衡策略选择出的节点地址,会因为服务提供者的类型、所在平台、所在集群而不同。一般有如下几种情况:
- 域名地址:跨集群访问或者跨平台,地址会首先被DNS服务器转发,然后通过Ingress Controller进行转发。
- IP地址:同集群同平台同一个注册中心,则是直接到Service,然后到Pod中Container中。由于Kubernetes的网络是同一水平的,所以同一个集群中服务发现可以直接使用二层虚拟机地址-Pod IP。
- 集群内域名地址:服务提供者为MVC类型,由CoreDNS进行路由。
MVC类型的应用由于缺少服务注册的组件,采取了通过固定格式的域名(appName+固定后缀),BOOT类型通过服务注册与服务发现的方式进行路由,所以使用IP地址(Pod IP)。
应用类型的差异在部署架构中也有不同。MVC类型需要搭配HAProxy,服务调用时,首先经过HAProxy,然后到达CoreDNS,然后再映射到Kubernetes Service里的Pod中;BOOT类型需要搭配注册中心,服务调用时,首先从注册中心拉取应用的注册信息(服务列表信息+metadata信息),从服务列表中选择一个节点地址,然后完成调用。
3.1.1 同集群内
服务调用方与服务提供方同在一个集群中时,需要考虑的“障碍”,如下所示:
- 服务是否在同一个平台,跨平台的服务调用需要经过平台网关
- 服务的类型是否都是BOOT类型,BOOT类型支持服务发现,MVC类型支持HAProxy
跨平台访问通过通过域名访问平台网关B,再由平台网关通过服务发现的方式访问服务提供方,如果服务提供方是MVC的类型,则先通过域名访问到内网HAProxy,再通过内网HAProxy访问服务提供者。
同平台访问,由于同在一个注册中心中,可以通过服务发现的方式访问服务提供方;如果服务提供方是MVC的类型,缺乏服务注册功能,导致无法直接通过服务发现的方式,所以请求会先通过域名访问到内网HAProxy,再通过内网HAProxy根据路由,访问服务提供者。
对MVC应用节点的兼容方案,如下所示:
- 服务注册时,增加域名地址。由于域名为固定格式,所以可以拼接出来。
- 服务调用时,负载均衡策略对ServerList进行修正。
// springmvc升级到springboot时,只会部署一个组(例如b组),并且设置低权重。
// a组是mvc没有在consul中注册,导致调用会路由到b组,与原意"小量测试b组"违背。此处为修正代码,路由到ha
int sumweight = 0;
for (Map.Entry<String, Integer> o : weightMap.entrySet()) {
sumweight += o.getValue();
}
if (sumweight < 100 && sumweight > 0 && localClusterHostServers.size() > 0) {
int differWeight = 100 - sumweight;
int tempWeight = differWeight / localClusterHostServers.size();
for (Server server : localClusterHostServers) {
localClusterIpServers.add(new WeightElement(server, tempWeight));
}
}
// 结束修正
3.1.2 跨集群
不同集群的服务之间的通信通过域名,域名为入口HAProxy地址,再由HAProxy进行转发,如下图所示:
- 跨集群调用通过域名访问集群B中的同平台A的HAProxy,再由HAProxy路由到服务提供方。
- 跨集群调用通过域名访问集群B中的同平台B的HAProxy,再平台网关B通过服务发现访问服务提供者,如果服务提供者是MVC类型的应用,则直接由HAProxy直接路由到服务提供者。
3.1.3 跨VPC
不同VPC的服务之间的通信通过域名,域名为入口HAProxy地址,再由HAProxy进行转发。和跨集群的差异,在于跨VPC通信延时更高,同一个服务共存于不同VPC的情况很少。
解决方案是服务注册信息中增加VPC信息,负载均衡器的负载均衡策略增加对VPC的支持,如下所示:
- 在原有的负载均衡器的流量分配机制中增加对多VPC的支持,允许设置不同的VPC的流量占比。
- 服务注册时,服务节点将VPC信息上报添加到元数据信息中。
- 服务调用方在更新ServerList时,就能获取到带有VPC信息的元数据。
- 服务调用方在进行接口调用,负载均衡器进行服务节点选择,会根据VPC的流量占比选择出目标VPC,再根据最近优先的原则从该VPC的ServerList中选择出目标Server。
代码实现:
// internal consul普通服务,external consul外部服务
String access = s.getMeta().get("access");
// 寻找vpc 权重信息
if (lookupVpcWeight && "external".equalsIgnoreCase(access)) {
// vpc 权重
String currVpcWeightStr = s.getMeta().get("vpcWeight");
// 只要有一个vpcweight 不相同,就放弃vpc weight的策略路由
if (vpcWeightStr != null && !vpcWeightStr.equalsIgnoreCase(currVpcWeightStr)) {
lookupVpcWeight = false;
vpcWeightStr = null;
vpcWeight = null;
}
// 第一次找到vpcWeight, 设置权重
else if (vpcWeightStr == null && !StringUtils.isBlank(currVpcWeightStr)) {
vpcWeightStr = currVpcWeightStr;
// 解析vpc权重, 拼装出 vpcWeight
try {
JSONObject vpcWeightJson = JSON.parseObject(vpcWeightStr);
// 转换vpc名称, latteVpc -> aws, aliVpc -> ali
for (Map.Entry<String, Object> o : vpcWeightJson.entrySet()) {
Integer weight = (Integer) o.getValue();
if (weight < 0) {
weight = 0;
}
if ("latteVpc".equalsIgnoreCase(o.getKey())) {
vpcWeight.put("aws", weight);
} else if ("aliLatteVpc".equalsIgnoreCase(o.getKey())) {
vpcWeight.put("ali", weight);
}
}
} catch (Exception e) {
if (log.isDebugEnabled()) {
log.debug("Consul-starter: Parse vpc weight error, vpcWeight={}", vpcWeightStr, e);
}
lookupVpcWeight = false;
vpcWeightStr = null;
vpcWeight = null;
}
}
}
3.2 灰度发布
当我们在数禾发布平台中,进行应用新版本发布时, OpenShift会按组进行滚动发布,发布组的流量将会降到0,等到滚动发布结束,并完成健康检查之后,恢复之前设定的流量分配,从而完成整个发布动作。发布失败自动回滚的情况,有如下两种:
- 如果发布组的新版本发布时,节点健康检查失败了,那么该发布组将会回滚;
- 发布组健康检查虽然成功,但是流量进入之后,错误日志达到了阈值,那么该发布组将会回滚。
如上图所示, 服务调用时,调用方的负载均衡器将会根据服务A的ab组节点权重进行流量分配,权重比例是0-100的数值,一个服务的权重总合为100。比如a组权重是100,那么流量将100%的分配到a组,在从a组的服务A的节点集合中轮询选出一个节点,OpenFeign再封装好HTTP连接,完成一次服务调用。
3.3 动态路由
3.3.1 意义
虽然Spring Cloud支持负载均衡策略变更,而变更方式是代码变更(将需要的策略注册到Spring容器中),并进行应用发布。当应用很多时,策略修改的成本将变的很高,服务的发布也会来系统风险,特别是很久没有发布过的应用。如果可以在应用运行时,修改负载均衡器的负载均衡策略,再配合默认策略进行兜底,就能将成本降到最低,风险控制到最小。
3.3.2 实现原理
如下图所示,数禾服务管理平台将新的负载均衡策略文件,通过HTTP下发到服务节点中。服务节点使用URLClassLoader加载接收负载均衡策略的字节码数据,然后将新的负载均衡策略替换旧的负载均衡策略,这就完成了一次负载均衡策略的变更。
图中可以看到,数禾自研了负载均衡器的管理界面,让负载均衡器可视化,使其便于管理、维护与更新。负载均衡策略可以选择java文件方式,也可以选择class文件的方式。
3.3.3 方案取舍
远程加载Java对象的方式有很多种,经过调研发现,适合数禾有两种,分别如下所示:
- URLClassLoader:加载class文件,实例化成负载均衡策略。
- GroovyClassLoader:加载Groovy脚本,实例化负载均衡策略。
然后我们需要对两种对象,进行性能测试,测试方式为分别实例化Groovy对象和Java对象,然后分别执行相同的方法10000次。
定义接口IRoute
public interface IRoute {
/**
* 路由测试
* @param serviceId
* @return
*/
String route(String serviceId);
String routeList(String serviceId, List<String> nodes);
}
定义groovy实现类脚本文件
import com.chandler.feign.client.example.groovy.IRoute
class groovyRoute implements IRoute {
@Override
String route(String serviceId) {
return "hello:"+serviceId
}
@Override
String routeList(String serviceId,List<String> nodes) {
int len=0
for (node in nodes) {
len += node.length()
}
return "hello:"+serviceId + len
}
}
定义java实现类
public class JavaRoute implements IRoute {
@Override
public String route(String serviceId) {
return "hello:"+serviceId;
}
@Override
public String routeList(String serviceId, List<String> nodes) {
int len=0;
for (int i = 0; i <nodes.size() ; i++) {
len += nodes.get(i).length();
}
return "hello:"+serviceId + len;
}
}
测试类GroovyRunner
@Slf4j
public class GroovyRunner {
public static void main(String[] args) {
try {
//从classpath下加载groovy文件
String path = "classpath:groovy_route.groovy";
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource resource=resolver.getResource(path);
InputStream input = resource.getInputStream();
InputStreamReader reader = new InputStreamReader(input);
BufferedReader br = new BufferedReader(reader);
StringBuilder template = new StringBuilder();
for (String line; (line = br.readLine()) != null; ) {
template.append(line).append("\n");
}
//加载GroovyRoute
GroovyClassLoader classLoader = new GroovyClassLoader();
Class<IRoute> groovyRouteClass = classLoader.parseClass(template.toString());
IRoute groovyRoute = groovyRouteClass.newInstance();
List<String> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
list.add("你好");
list.add("chandler");
}
int runTimes = 10000;
long st = System.currentTimeMillis();
for (int j = 0; j < runTimes; j++) {
groovyRoute.routeList("chandler", list);
}
log.info("groovy run :" + (System.currentTimeMillis() - st));
//使用反射方式调用GroovyRoute的方法
GroovyObject obj = (GroovyObject) groovyRouteClass.newInstance();
st = System.currentTimeMillis();
for (int j = 0; j < runTimes; j++) {
obj.invokeMethod("routeList", new Object[]{"chandler", list});
}
log.info("groovy invokeMethod :" + (System.currentTimeMillis() - st));
//加载JavaRoute
JavaRoute javaRoute = new JavaRoute();
st = System.currentTimeMillis();
for (int j = 0; j < runTimes; j++) {
javaRoute.routeList("chandler", list);
}
log.info("javaRoute run :" + (System.currentTimeMillis() - st));
} catch (Exception e) {
e.printStackTrace();
}
}
}
测试结果显示,goovy类的方法执行耗时大约是java类的20倍,故选择Java方式。如下图所示:
groovy慢的原因
我们可以对groovy文件编译后的class,再反编译成java文件,从而观察groovy的实现原理。如下所示:
$ vim GroovyTest.groovy
println("Hello World");
$groovyc GroovyTest.groovy
groovyc编译之后可以得到GroovyTest.class文件,再对其进行反编译javap -c GroovyTest.class,可以得到下图所示信息:
从图中我们可以看到groovy做了很多工作,使用了大量的静态方法,说明了越高级的语言,封装的越多,这就是groovy执行效率低的原因。
四、 本文小结
本文首先简述服务调用的概念,然后简述了数禾在系统建设过程中遇到到一些服务调用的场景问题,然后介绍数禾的针对这些场景问题提供的解决方案,最后补充了动态路由的实现机制。
本文已在本人所在公司公众号《数禾技术》发布
如果需要給我修改意见的发送邮箱:erghjmncq6643981@163.com
资料参考:无
转发博客,请注明,谢谢。