微服务

关于微服务的讨论,可以参考: Martin Fowler - MircroServices

站在工程师的角度看,微服务涉及三种角色:

  • Service SPI 1 服务契约,一般称之为:SPI
  • Service Provider 服务实现方,一般称之为Server
  • Client 服务调用方,即 SPI 依赖方,一般称之为UI/Client

服务提供方公布一系列接口(SPI),使用Maven构建成jar包,同时,另起一个项目实现SPI(Service Provider)。

对于依赖某个服务的工程师来说,TA只需引入特定版本的SPI jar,比如: loupan-spi-1.1.3.jar,代码中引入Service 和调用方式并没有什么变化,Spring项目代码如下:

  @Service
  public class MyService{
      // CitySpi 为SPI
    @Autowired
    CitySpi citySpi;

    public List<MyObject> findById(int id){
       City city= citySpi.findById(id);
       if(city == null){
        return Collections.emptyList();
       }
       // other logic
     }
  }

但是在底层,Service调用已由 同一进程内调用 透明的转换为 跨JVM进程间调用

之前,当我们某个项目线提供一组公共服务时,通常将服务封装成Jar包发布,但随之而来的,如果Jar包出现Bug、配置文件变更、功能调整、或者安全性问题时,所有依赖方(客户端)必须强制升级,真可谓,牵一发而动全身,我们称这种服务依赖方式为:“硬引用“。

微服务是如何避免这种尴尬的服务维护呢?

对客户端来说,如果我要萝卜,服务提供方给它萝卜即可,至于萝卜是从何而来,客户端是不关心的。反观我们采用“硬引用”的方式暴露服务时,将实现方式直接驻留在了客户端,从而变相地和客户端耦合在了一起。

基于微服务的架构开发时,项目线(Service Provider)只向客户端(Client)提供 Service SPI。SPI是一系列声明(接口和Model),没有任何实现细节,驻留在客户端的只是一个空壳,真身仍在项目线。

如果某服务声明说我有(返回)萝卜,那客户端就能获取萝卜,绝无可能冒出白菜(所谓的契约)。至于萝卜的制造工艺,各项目线可随时变更,只要不违背契约即可(通常服务都有版本号)。

我们称这种服务依赖方式为: ”软引用“,类似文件的快捷方式。

微服务还有其他诸多特点或挑战,但在此书中不会过多讨论。

微服务、Spring Cloud、Netflix OSS2

上文说到,微服务的调用是一种跨JVM进程的调用。也就意味着,微服务离不开分布式系统之间的交互。

在一个分布式的环境中,我们通常会部署多个节点(实例),而且可能动态地增加一些节点来应对紧急网络流量。传统的基于Nginx的静态路由(由人工手动配置),显然是不合适的。

一次典型的微服务调用,其背后的基本步骤如下:

 1. 服务发现 - 根据所调用的服务查找Service Provider的所有可用节点;
 2. 服务路由 - 从众多节点以某种规则选择一个节点。
 3. RPC请求 - 向选定的节点发起远程网络调用(RPC),解析返回数据。

由于网络调用的复杂性,我们还必须处理RPC3请求异常时可能导致的雪崩效应4,所以网络流量整形5和监控是必要的。

另外,由于部署多个节点,如何管理应用程序的运行时配置也是必须考虑的。

针对以上需求,Spring团队整合了一种解决方案:Spring Cloud + Netflix OSS。

服务发现 - Eureka

Netflix Eureka 1.X.X 使用了一种简单机制实现了节点的自动注册和发现。

Eureka 有两种角色:Eureka ClientEureka Server。通常我们的Spring Cloud 程序都是作为Eureka Client启动的。

Eureka Client启动时,会根据配置的Eureka Server的url,主动向Eureka Server汇报自身以下信息:

  • 虚拟主机地址(VIPAddress)
    服务的标识,极其重要,只有根据VIPAddress才能在Eureka Server中查找服务的节点。
  • 主机名或IP地址
    默认是主机名,但通常都配置成节点的IP地址。
  • 端口
    节点对外提供服务的端口
  • 节点实例ID
    全局唯一,如果重复,会覆盖之前注册相同实例ID的节点信息。
  • status url
    程序运行状态的监控url.
  • health url
    程序健康状况的监控url
  • metadata 和其他
    自定义的元数据信息,可被其他Client获取。

与此同时,Eureka Client会开启一些定时任务,按固定频率向Eureka Server轮询其他客户端的注册信息(注册信息会缓存在本地一段时间),当然也少不了发送心跳包。

Eureka Server(1.X.X)目前不支持向所有节点广播节点变更,节点的自动发现主要依赖Eureka Client定时Poll数据。

默认情况下,Eureka Client的实例ID为主机名,这样同一个台机器只能运行一个Client。通过Spring,我们可以让Eureka Client启动时生成随机的Instance Id。

SE团队目前部署了两个Eureka Server节点(集成环境):
http://discovery1.se.dooioo.orghttp://discovery2.se.dooioo.org,正式环境将顶级域名.org 改为.com,访问即可查看所有已注册的Eureka Client

服务路由 - Ribbon

当Eureka Client通过待调用服务的VIPAddress获得该服务的多个节点时,如何路由到一个合适的节点?这时轮到Netflix的开源组件 Ribbon 出场了。

Ribbon 有以下特性:

  • 插件式的路由规则,内置Round RobinResponse time weighted。既可以向节点随机分发请求,也支持根据响应时间来分发。另外,我们也可以方便地扩展一些符合自己应用场景的路由规则。
  • 集成Eureka服务发现(Ribbon-Eureka模块)。
  • 弹性容错。Ribbon通过IPing接口可以动态感知节点是否存活,以过滤一些失去响应的节点。它可以进一步基于断路器6模式过滤节点。关于断路器模式,请参考 Circuit Breaker
  • 支持分布式云环境。假如,我们将服务部署到阿里云不同的数据中心,比如,北京,杭州,上海,节点路由时,可以优先选择位于同一数据中心的节点,也可以主动避开网络拥堵的数据中心。

默认情况下,Ribbon 随机转发请求到各个节点。

RPC调用 - Feign

当Ribbon选定节点后,接下来客户端便要发起RPC调用了。

常见的RPC调用,有二进制流序列化+TCP协议 或 二进制流序列化+Http协议(比如Hessian),序列化/反序列化协议既可以是原生JAVA,也可以是其他序列化协议(比如,KryoFst ,Thrift,ProtoBuf);有文本序列化+ Http协议,使用JSON或XML反序列化。

最终,我们的RPC调用决定采用Http协议+JSON文本序列化的方式,这样即可以直接将服务作为REST接口暴露出去,也更容易对众多服务进行自动化测试。

而完成这一RPC调用的组件,就是 Netflix Feign

Feign 根据 Service SPI 接口的标注信息,构造符合Http协议的请求参数。你可以把Feign看成一个HttpClient,只不过它构造Http请求是通过接口的元数据-标注。比如,我们以后的Service SPI类似以下代码:

@FeignClient("city")
//@FeignClient(name="city",url="http://localhost:8080")
public interface CitySpi{
    @RequestMapping(value="/v1/citys/{id}",method=RequestMethod.GET)
    City findByIdV1(@PathVariable(value="id")int id );
 }

Feign 有一个接口 feign.Contract,用于完成Http请求的构造。Spring提供了 SpringMvcContract以支持Spring MVC标注的解析,解析在程序启动时就完成了。

Service SPI的每个接口都将被Feign代理(JAVA 动态代理),当方法调用时,模板参数将被动态地替换为方法实参。

上文中CityService的findById将会被Feign理解为:向 URL = http://city/v1/citys/{id}的主机发起一个Http GET请求,id为模板参数,运行时替换。

大家也看到了,URL的主机为city,也就是@FeignClient("city")中的city,city被称为虚拟主机地址,是服务的标识。一个服务通常是一个Eureka Client,服务会部署多个节点。

此时,发起RPC请求之前需向Eureka Server查询该虚拟主机的所有节点,再由Ribbon选定一个节点。

但我们也可以直接指定请求的url:@FeignClient(name="city",url="http://localhost:8080"),此时会被Feign理解为:向 URL = http://localhost:8080/v1/citys/{id}的主机发起一个Http GET请求。此时已无需Ribbon路由,通常在测试时才指定节点。

Url的主机地址被动态解析和替换之后,Feign开始发送请求,最后对响应信息反序列化。

网络流量整形以及断路器

正常情况下,一次完整的RPC请求如上文所言。

俗话说,风平浪静时,人人都可以是舵手。

但当 Service Provider 无法及时响应时?有多种原因会导致此类情况。

  • 客户端业务逻辑调整,导致某个时间点访问量暴增,大量请求被分发至Service Provider,服务提供方过载,数据库服务器CPU 100%,导致其他服务访问数据库时也超时。
  • 服务提供方业务调整,原先一个50ms就能响应的请求,现在需要300ms,从而导致客户端大量线程阻塞,进而失去响应。
  • …..

通常服务不可用时,终端用户可能不停地刷新页面,从而加剧问题。

以上情况我们统称为:服务过载

为了应对分布式环境中服务过载可能引起的"雪崩反应”,我们启用了Netflix公司的开源组件 Hystrix

下面我简要介绍一下Hystrix的工作流程:

1,将所有对外部系统的调用封装成HystrixCommandHystrixObservableCommand ,并运行在一个隔离的线程中。
比如,当我们发起远程调用时,如果启用Hystrix,则实际的调用如下:

  new HystrixCommand(){
      Object run(){
        //我们的RPC调用
      }
  }.execute();

2, 每个执行的HystrixCommand都有一个超时时间,默认是一秒。客户端可以配置。 如果 HystrixCommand执行超时,那么将抛出类型为超时的HystrixRuntimeException。(资源隔离)

3,为每个外部依赖维护一个小型的线程池或信号量,如果线程池满了,新的请求会立即被拒绝。线程池或信号量的大小即为转发到后端请求的最大并发数。(流量整形)

4,收集请求成功、失败、超时以及池满被拒绝的请求数。

5,根据收集到的统计数据,周期性地触发断路器。当断路器开启时,接下来的所有请求都会被直接拒绝。断路器可以手动触发或者当请求的错误率(errorThresholdPercentage,可配置,默认%50)达到一定阈值时自动触发。 断路器开启一段时间之后(sleepWindowInMilliseconds,可配置,默认5秒),会自动关闭。也就是说如果断路器开启,那么默认5秒内不会向外部依赖发送请求。

6,当请求失败、池满被拒绝、超时以及短路(断路器开启)时,HystrixCommand将执行一个默认逻辑。HystrixCommand有个getFallback方法,当失败时,你可以返回默认数据。

对服务过载的预防,Hystrix只是做了它能做的,但更重要的是,服务实现方、调用方必须透彻地理解自己的业务逻辑,明白自己服务的边界(QPS,TPS,依赖,服务可用率),从而调整好相关参数。

无论是前端Web或后台系统,请求发起方有义务为预防服务过载做一些应对措施。

如何保障服务的可用性,我们以后再详加讨论。

API网关:智能代理 Zuul

对于企业内部的服务间调用来说,以上的 Eureka & Ribbon & Feign & Hystrix基本就满足应用了。

但如果前端Web页面想Ajax访问内部服务呢?或者我们要暴露一些基础服务给移动端呢?

之前,各个项目线有单独的API模块。部署之后,Nginx配好指向,Web/App就可以通过域名访问接口了。

当改为微服务架构后,节点只能通过Eureka Server来发现了。而且,服务提供方就是标准的REST实现,只需要暴露出去即可,已无需单独的API模块了。

也就是说,我们需要个智能代理,它既可以动态发现新注册的Eureka节点,并把请求分发到合适的节点;又可以做一些权限控制,安全校验等通用的逻辑。

Netflix Zuul 正是解决这一类问题的不二之选。

Zuul是由一系列过滤器组成的,过滤器内置四种类型:

  • pre” 请求预处理,可以做权限校验、CORS支持、OAuth认证等。
  • route” 将前端请求路由到后端节点。
  • "post" 将后端节点的响应信息写入客户端。
  • "error" 代理执行过程中,如果发生异常,则会调用此种类型的Filter处理异常。

我们知道,虚拟主机地址(VIPAddress)代表了一个服务,如果要从Eureka Server中获取特定服务的节点,则必须告诉Eureka Server 虚拟主机地址。

那Zuul是如何知道 Request Path 关联的虚拟主机地址呢?

我们的Zuul代理项目(以下统称为API网关),采用了一种命名约定,比如对以下的请求url:

GET http://api.route.dooioo.org/loupan/v1/building/3

API网关默认会将”loupan“作为虚拟主机地址,并从请求路径中移除,此时,转发到后端节点的请求路径变为:

GET http://node1:port/v1/building/3

也就是说,访问API网关的request path必须以虚拟主机地址开始

对于以下SPI代码:

@FeignClient("loupan-server")
public interface CitySpi{
    @RequestMapping(value="/v1/citys/{id}",method=RequestMethod.GET)
    City findByIdV1(@PathVariable(value="id")int id );
  }

如果通过API网关访问,则request path必须为:/loupan-server/v1/citys/{id}。

我们的API网关会将以-连起来的虚拟主机地址转为/分隔的路径,对于: /loupan-server/v1/citys/{id},客户端也可以访问:/loupan/server/v1/citys/{id}。

我们称“loupan/server”为虚拟主机地址 “loupan-server”的别名

强烈推荐前端Web以别名Path请求API网关。

SE团队的API网关域名为(集成环境):http://api.route.dooioo.org,正式环境,请将.org改为.com。

最后让我们粗略过一下前端Web对示例SPI的请求流程:

1, Web 发起Ajax请求:

GET http://api.route.dooioo.org/loupan/server/v1/citys/32

2,请求到达API网关,首先类型为 “pre” 的Zuul Filter找到别名"/loupan/server"对应的虚拟主机地址:"loupan-server",并标识请求为Eureka,将虚拟主机地址放在 RequestContext中。

3,接下来类型为 "route" 的Zuul Filter发现RequestContext有虚拟主机地址,开始使用Ribbon以某种路由规则选定服务的一个节点,移除虚拟主机地址或别名后发送请求。此时后端接受到的请求如下:

GET http://selected-node:port/v1/citys/32

4,后端节点响应后,类型为 “post”的Zuul Filter将响应写入客户端。

5,以上任何一步发生异常,都会调用类型为 “error” 的Zuul Filter。

附带说明一点:我们的API网关已支持CORS,Web Ajax请求已无需考虑跨域的问题了。

配置中心 Spring Cloud Config

对应用配置的管理,Spring Cloud目前默认的实现是使用Git集中存储,这样多节点也只有一份副本,管理和维护起来比较容易,相关模块为:Spring-Cloud-Config

当Spring Cloud项目启动时,首先会使用PropertySourceLocator自动加载远程Git仓库里的配置文件。

对开发人员来说,原先放在/src/main/resources目录下的配置文件,放在了Git里维护了而已。

具体细节,在此不多说。



1. SPI = Service Provider Interface,服务提供方接口
2. OSS = Open Source Software 开源软件
3. RPC = Remote Procedure Call 远程过程调用
4. “雪崩效应”是指信息在沿供应链传递中其波动会被依次放大的现象,这种现象导致信息在传递过程之中有如滚雪球一般越滚越大。
5. 流量整形(traffic shaping)典型作用是限制流出某一网络的某一连接的流量与突发,使这类报文以比较均匀的速度向外发送。
6. 断路器(Circuit Breaker)是指能够关合、承载和开断正常回路条件下的电流并能关合、在规定的时间内承载和开断异常回路条件下的电流的开关装置。
© RD@上海链家 all right reserved,powered by Gitbook文件修订时间: 2016-11-01 01:48

results matching ""

    No results matching ""