在分布式与容器中实践服务调用

 随着越来越多的公司进行SOA和微服务化建设,系统架构也从单体应用架构过渡到了多体应用架构,再到分布式应用架构。服务之间的调用,也由少到多,所处环境也由简单变得复杂。

一、前言

1.1 服务调用是什么

 简单而言,服务A暴露接口,服务B调用了该接口,这就是一次完整的服务调用。调用的方式一般是HTTP,服务调用对应了一次完整的HTTP连接。如下图所示:


服务调用简单理解.png

 看起来蛮简单,不过服务B需要知道服务A的地址,这个地址可能需要被域名解析代理,被网关代理,并且该地址对应的服务A节点是有效的、可靠的,才能保证服务调用总是成功的。为了提升服务A的可用性,将服务A部署多个节点,服务B需要知道所有服务A的节点,并在服务调用时选择出一个有效的节点。


服务调用.png

二、数禾遇到的问题

数禾科技在已实现系统上云的比例达到了100%,管理上百个微服务。系统上云降低了系统成本,不过也带来更多的复杂度,对服务调用带来了很多挑战。


容器编排结构与系统结构的对应关系.png

 如上图所示,展示了基础设施与系统结构的对应关系。集群对应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 多集群部署

 出于系统可用性建设的需求,数禾进行了异地多活建设,也就产生多集群部署的现状。服务调用就产生了跨集群的问题,一般有两种场景:

  1. 某个集群节点都down了,路由到另一个集群,也就是灾备机制。此类场景只适用于BOOT服务。
  2. 某个应用只部署了一个集群,MVC配置的地址就是另一个集群的地址。(一般几乎没有这种场景)
2.1.4 混合云建设

 数禾基于健壮性、经济性和适配性的考虑,系统建设基于混合云建设,从而出现了多VPC共存。必然存在同一个服务既存在于AWS也存在于阿里云,或者一部分服务存在于AWS一部分服务存在于阿里云的现象。这也对服务调用带来了挑战。

2.2 服务发布

 应用发布时,新版本平滑发布,逐步替换旧版本,并在异常状况下,回滚或中止后续发布的发布过程称为灰度发布。灰度发布机制也对服务调用产生了影响,发布时,发布组的节点需要被屏蔽,流量降到0;发布完成之后,组的流量分配恢复,解除发布组的节点的屏蔽功能。

2.3 动态路由

 在应用运行时,更换应用中的负载均衡策略,称为动态路由。动态路由降低了负载均衡策略变更的成本,而负载均衡策略是服务调用时选择的服务节点方法。

三、基础架构建设中的服务调用实践

数禾选择Spring Cloud作为微服务系统建设的技术栈,并基于Spring Cloud的OpenFeign和Ribbon,结合了企业级微服务系统建设的需要,对服务调用与负载均衡器的功能进行扩展。扩展的核心点在于自定义的负载均衡器策略,主要实现了如下的功能:

  1. 服务调用跨各种“障碍“
  2. 支持灰度发布
  3. 动态路由

3.1 遇水架桥逢山开路

服务调用深度理解.png

 Pod启动之后,服务节点将Pod的Pod IP注册到注册中心Consul。由于Pod IP是虚拟的IP,所以只能被同一个Kubernetes集群中的服务调用者调用。调用方通过负载均衡策略选择出的节点地址,会因为服务提供者的类型、所在平台、所在集群而不同。一般有如下几种情况:

  1. 域名地址:跨集群访问或者跨平台,地址会首先被DNS服务器转发,然后通过Ingress Controller进行转发。
  2. IP地址:同集群同平台同一个注册中心,则是直接到Service,然后到Pod中Container中。由于Kubernetes的网络是同一水平的,所以同一个集群中服务发现可以直接使用二层虚拟机地址-Pod IP。
  3. 集群内域名地址:服务提供者为MVC类型,由CoreDNS进行路由。

 MVC类型的应用由于缺少服务注册的组件,采取了通过固定格式的域名(appName+固定后缀),BOOT类型通过服务注册与服务发现的方式进行路由,所以使用IP地址(Pod IP)。

 应用类型的差异在部署架构中也有不同。MVC类型需要搭配HAProxy,服务调用时,首先经过HAProxy,然后到达CoreDNS,然后再映射到Kubernetes Service里的Pod中;BOOT类型需要搭配注册中心,服务调用时,首先从注册中心拉取应用的注册信息(服务列表信息+metadata信息),从服务列表中选择一个节点地址,然后完成调用。

3.1.1 同集群内

 服务调用方与服务提供方同在一个集群中时,需要考虑的“障碍”,如下所示:

  1. 服务是否在同一个平台,跨平台的服务调用需要经过平台网关
  2. 服务的类型是否都是BOOT类型,BOOT类型支持服务发现,MVC类型支持HAProxy
同集群访问.png

 跨平台访问通过通过域名访问平台网关B,再由平台网关通过服务发现的方式访问服务提供方,如果服务提供方是MVC的类型,则先通过域名访问到内网HAProxy,再通过内网HAProxy访问服务提供者。
 同平台访问,由于同在一个注册中心中,可以通过服务发现的方式访问服务提供方;如果服务提供方是MVC的类型,缺乏服务注册功能,导致无法直接通过服务发现的方式,所以请求会先通过域名访问到内网HAProxy,再通过内网HAProxy根据路由,访问服务提供者。
 对MVC应用节点的兼容方案,如下所示:

  1. 服务注册时,增加域名地址。由于域名为固定格式,所以可以拼接出来。
  2. 服务调用时,负载均衡策略对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进行转发,如下图所示:


跨集群.png
  • 跨集群调用通过域名访问集群B中的同平台A的HAProxy,再由HAProxy路由到服务提供方。
  • 跨集群调用通过域名访问集群B中的同平台B的HAProxy,再平台网关B通过服务发现访问服务提供者,如果服务提供者是MVC类型的应用,则直接由HAProxy直接路由到服务提供者。
3.1.3 跨VPC

 不同VPC的服务之间的通信通过域名,域名为入口HAProxy地址,再由HAProxy进行转发。和跨集群的差异,在于跨VPC通信延时更高,同一个服务共存于不同VPC的情况很少。
 解决方案是服务注册信息中增加VPC信息,负载均衡器的负载均衡策略增加对VPC的支持,如下所示:

  1. 在原有的负载均衡器的流量分配机制中增加对多VPC的支持,允许设置不同的VPC的流量占比。
  2. 服务注册时,服务节点将VPC信息上报添加到元数据信息中。
  3. 服务调用方在更新ServerList时,就能获取到带有VPC信息的元数据。
  4. 服务调用方在进行接口调用,负载均衡器进行服务节点选择,会根据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 灰度发布

权重分配.png

 当我们在数禾发布平台中,进行应用新版本发布时, OpenShift会按组进行滚动发布,发布组的流量将会降到0,等到滚动发布结束,并完成健康检查之后,恢复之前设定的流量分配,从而完成整个发布动作。发布失败自动回滚的情况,有如下两种:

  1. 如果发布组的新版本发布时,节点健康检查失败了,那么该发布组将会回滚;
  2. 发布组健康检查虽然成功,但是流量进入之后,错误日志达到了阈值,那么该发布组将会回滚。
灰度发布-负载均衡器.png

 如上图所示, 服务调用时,调用方的负载均衡器将会根据服务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加载接收负载均衡策略的字节码数据,然后将新的负载均衡策略替换旧的负载均衡策略,这就完成了一次负载均衡策略的变更。


负载均衡策略管理界面.png

 图中可以看到,数禾自研了负载均衡器的管理界面,让负载均衡器可视化,使其便于管理、维护与更新。负载均衡策略可以选择java文件方式,也可以选择class文件的方式。


负载均衡策略查看.png
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方式。如下图所示:


classLoader测试.png

groovy慢的原因

 我们可以对groovy文件编译后的class,再反编译成java文件,从而观察groovy的实现原理。如下所示:

$ vim GroovyTest.groovy
println("Hello World");
$groovyc GroovyTest.groovy

 groovyc编译之后可以得到GroovyTest.class文件,再对其进行反编译javap -c GroovyTest.class,可以得到下图所示信息:


groovy文件反编译.png

 从图中我们可以看到groovy做了很多工作,使用了大量的静态方法,说明了越高级的语言,封装的越多,这就是groovy执行效率低的原因。

四、 本文小结

 本文首先简述服务调用的概念,然后简述了数禾在系统建设过程中遇到到一些服务调用的场景问题,然后介绍数禾的针对这些场景问题提供的解决方案,最后补充了动态路由的实现机制。

本文已在本人所在公司公众号《数禾技术》发布
如果需要給我修改意见的发送邮箱:erghjmncq6643981@163.com

资料参考:无
转发博客,请注明,谢谢。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容