微服务网关——设计篇

在《微服务网关——需求篇》中,我们讨论了微服务网关的需求,本文将对微服务网关进行设计。考虑到实际情况的差异,这里实际给出的是设计选项,最终设计基于实际场景来确定。

网关功能性设计

路由

一般情况下,服务对外提供的是RESTful接口,所以一般路由模块根据请求的host, url等规则转发到指定的服务。

考虑到路由规则需要频繁的修改发布,为了发布的便利性,考虑针对规则实现热发布。有几种实现方式:

基于数据库

即将路由规则配置到数据库中,当网关收到请求后,从数据库中查询规则进行规则匹配。根据匹配到的规则进行路由。

考虑到性能,可以缓存规则,例如缓存到redis中。当修改配置后,需要将修改的数据刷到缓存中。

此方式需要实现数据库与缓存的同步逻辑,提供操作界面,需要一定的开发量。

基于配置文件

即将路由规则配置到配置文件中,网关启动时直接加载即可。普通的配置文件方式无法动态处理配置,每次修改后都需要启动网关,比较麻烦。对于微服务架构来说,一般会有配置服务器,可以基于配置服务器来实现配置的实时生效。

相对于前一种方法,可以基于微服务基础设施来实现,降低了一定的开发量。

负载均衡

一般负载均衡算法有:

  • 随机算法:从多个服务中,随机选择一个服务来处理请求。此算法的问题是,实际无法做到负载均衡,极端情况下可能会导致所有请求都由同一个服务进行处理。且对于有状态的服务,对状态的管理会比较麻烦。
  • 加权随机:同随机算法,不同之处是每个服务的权重不同。比如有的服务器性能较好,则可以提高权重,能够处理较多的请求;有的服务器性能较差,则可以降低权重,处理较少的请求。
  • 轮询算法:对服务进行排序,将请求按顺序发送给对应的服务来处理。假设有两个服务A,B,第一个请求由A处理,第二个请求则由B处理,第三个请求还由A处理,以次类推。对于有状态的服务,轮询算法对状态处理也比较麻烦。
  • 加权轮询:同加权轮询,不同之处是每个服务的权重不同。比如还以上面的例子,A,B权重2:1,则第一个请求A处理,第二个请求还是A处理,第三个请求B处理,第四个请求A处理,以次类推。
  • 最小连接算法:根据服务的连接数来判断请求由哪个服务来处理,选择当前连接数最少的服务来处理请求。此算法需要维护每个服务的连接数,比较复杂,不推荐使用。
  • 源地址hash:根据请求地址取hash,然后对服务数量取模,由对应的服务来处理对应的请求。此算法可以保证相同用户的请求由同一个服务来处理,可以保障服务端状态。

对于微服务场景来说,优先选择源地址hash:

  • 首先,不需要处理随机、轮询这种算法需要处理的服务端Session共享的问题
  • 其次,实现简单
  • 最后,考虑服务的变动不会太频繁,前期用户量也不会很大,使用源地址hash的性价比最高

聚合服务

聚合服务有两种方案:

  • GraphQL:一种用于 API 的查询语言。使用GraphQL有三种可选方案
    • 在网关前增加一个聚合服务Server,基于GraphQL来实现服务聚合(也可以使用编码的形式来处理,此服务主要是IO密集型操作,故可以使用擅长IO密集型操作的技术,比如nodeJs,golang)
    • 直接在网关中使用GraphQL来进行服务聚合,此方式需要重启网关
    • 网关后增加聚合服务层,用于组装聚合请求
  • 编码:在网关层进行服务请求的处理,针对需要聚合的服务构建微服务请求,将获得的结果构建为最终结果返回。此方案需要编码,发布。对于需要频繁发布的聚合服务,也可以考虑独立「聚合服务」,避免频繁的发布网关,影响系统稳定性。

考虑到GraphQL的学习成本,以及聚合服务的量不是很多,优先考虑在网关中直接进行编码的方式。

认证授权

目前大部分系统采用的都是基于RBAC的认证授权。RBAC模型是目前主流权限控制的理论基础。

RBAC(Role-Based Access Control)即:基于角色的权限控制。通过角色关联用户,用户关联权限的方式间接赋予用户权限。如下图:

微服务网关——设计篇

RBAC模型可分为:RBAC0、RBAC1、RBAC2、RBAC3四种。其中RBAC0是基础,也是最简单的,相当于底层逻辑,RBAC1、RBAC2、RBAC3都是以RBAC0为基础的升级。具体内容请自行Google。

考虑互联网项目对用户角色的区分没有特别的严格(相对后台管理系统),RBAC0模型就可以满足常规的权限管理系统的需求,所以选择基于RBAC0来实现认证与鉴权。

对于Java来说,主流的认证与鉴权框架是SpringSecurity和Shiro,考虑集成的便利性,选择SpringSecurity作为认证鉴权框架。

过载保护

流量控制

一般的流量控制模式有:

  • 控制并发,即限制并发的总数量(比如数据库连接池、线程池)
  • 控制速率,即限制并发访问的速率(如nginx的limitconn模块,用来限制瞬时并发连接数)
  • 控制单位时间窗口内的请求数量(如Guava的RateLimiter、nginx的limitreq模块,限制每秒的平均速率)
  • 控制远程接口调用速率
  • 控制MQ的消费速率
  • 根据网络连接数、网络流量、CPU或内存负载等来限流。

对于微服务场景来说,控制速率是比较合适的流量控制方案。通常情况下,使用令牌桶算法来实现访问速率的控制,常用的令牌桶算法有两种:

  • 漏桶算法:水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出。可以看出漏桶算法能强行限制数据的传输速率,但是某些情况下,系统可能需要允许某种程度的突发访问量,此时可以使用令牌桶算法。
  • 令牌桶算法:系统会以一个恒定的速度向桶里放入令牌。如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。令牌桶算法通过发放令牌,根据令牌的rate频率做请求频率限制,容量限制等。

流量控制算法在确定后也是基本不需要变化的,所以对于热部署的需求不是必要的。

另外流量控制可以前置,放到接入层来处理,一般的网络接入服务,如nginx是支持流量控制的。如果前期对流量控制没有太多的定制化需求,可以考虑基于nginx来进行处理。

熔断

服务熔断的实现思路:

  • 调用失败次数累积达到了阈值(或一定比例)则启动熔断机制
  • 此时对调用直接返回错误。待达到设置的时间后(这个时间一般设置成平均故障处理时间,也就是MTTR),进入半熔断状态
  • 此时允许定量的服务请求,如果调用都成功(或一定比例)则认为恢复了,关闭熔断;否则认为还没好,继续熔断

考虑到有较成熟的开源项目,推荐直接使用开源项目来处理。

服务升降级

一种服务升降级的方案可以基于阻塞队列来实现:

  • 网关接收到请求后,进入定长的阻塞队列
  • 消费线程从消费队列中获取请求来进行处理
  • 当生产速率大于消费速率,会导致队列中请求不断增加,当请求数量超过设定的阈值时,根据配置的服务升降级规则判定当前请求是否属于可降级的服务(或者基于队列来判定),如果属于可降级的服务,则根据配置的降级逻辑对该请求进行处理(比如直接拒绝);如果请求属于不可降级的服务,则依然添加到请求队列中

考虑到有较成熟的开源项目,推荐直接使用开源项目来处理。

缓存

考虑到网关是集群化部署,所以优先使用集中式缓存方式,即网关中所有需要缓存的数据都集中进行缓存。使用常用的分布式缓存中间件即可,例如redis。

基于缓存的网关工作步骤:

  • 网关通过加载缓存模块,根据请求URL和参数解析,从缓存中查询数据
  • 如果缓存命中(缓存有效期内),那么直接返回结果
  • 如果缓存未命中(缓存失效或者未缓存),那么请求目标服务
  • 请求结果返回网关
  • 网关缓存请求结果

此处需要注意缓存常见问题:缓存雪崩、缓存击穿、缓存穿透,需要针对性的做好处理。

服务重试

对于服务重试至少需要提供两个功能:

  • 配置:即需要配置哪些接口需要进行重试,重试几次
  • 执行:针对配置进行重试

对于配置来说,需要配置请求的超时时间、单次请求的超时时间、重试次数,注意单次请求的超时时间*重试次数要小于请求的超时时间,否则会影响服务重试逻辑。同时,也需要考虑配置的动态生效,以保障网关的稳定性。

对于执行来说,根据配置的次数来进行处理即可。

逻辑实现并不复杂,不过考虑到有较成熟的开源项目,推荐直接使用开源项目来处理。

日志

应用日志记录遵循项目日志规范。对于访问日志来说,前期可以考虑在接入层实现,例如通过nginx的访问日志来实现对访问请求的记录。待后期有特定需求后再进行定制化。

管理

对于管理功能,由于是非核心需求,前期可以暂不考虑。

网关非功能性设计

高性能

传统的基于线程的并发模型(Thread-based concurrency),为每一个请求分配一个线程或进程。这种模型编程简单,可以将处理一个完整请求的代码编写在一个代码路径中。这种模型的弊端是,随着线程(进程)数的上升,操作系统在这些线程(进程)之间的频繁切换,将急剧降低系统的性能。

网关作为整个系统的入口,需要处理大量的请求,故基于线程的并发模型并不适用。需要使用Reactor模型来进行处理。

关于Reactor模型请参考《EDA风格与Reactor模式

目前常用的IO框架Netty可通过配置实现上述Reactor模型,如自行开发网关,可基于Netty进行开发。

高可用设计

高可用包含了前面所说的流量控制、熔断和服务升降级。除了这些功能外,还需要提供服务的优雅上下线功能以及自身的优雅下线功能。

对于使用Java开发的项目来说,由于JVM的特性,一般需要一个预热的过程,即服务启动后,需要访问一段时间后,服务才会达到最佳状态。如果服务刚启动就接收高强度的请求,可能会导致响应时间过长、服务负载过高的问题,严重时可能导致服务被瞬间压垮。为了避免这种情况,网关可以考虑支持Slow Start特性。即经过一段时间,逐渐把请求压力增加到预设的值。

另外,当一个服务下线时,不能直接关闭服务,需要先关闭该服务的对外接口,当该服务处理完所有正在处理的请求并返回后,方可关闭服务。

对于网关自身也类似,当网关需要关闭时,不是直接结束网关进程,而是先关闭监听套接字,但是继续为当前连接的客户提供服务,当所有客户端的服务都完成后,再把进程关闭。

扩展性

网关对请求的处理,可以分为:

  • 接受请求
  • 路由并转发请求
    • 如果是直接路由转发,则将请求直接转发给目标服务
    • 如果是聚合服务,则可能分发多个请求到各个目标服务
  • 接受服务的返回数据并返回给请求者
    • 如果是直接路由转发,则直接将结果进行返回
    • 如果是聚合服务,则等待所有服务返回结果后,组装结果数据后再返回
  • 错误处理
    • 统一的错误处理,例如服务请求错误返回统一的错误
    • 对于聚合服务,如果部分请求错误,根据业务需求决定是返回统一请求错误还是组合部分结果返回

对于此类请求的扩展,主要是基于过滤器/拦截器来实现。

一般拦截器可以分为两大类:

  • 全局拦截器,即对所有请求都进行拦截处理,例如安全校验、日志记录等
  • 业务拦截器,即为了某些业务逻辑,针对符合特定规则的请求进行拦截处理。

一般来说,先执行全局拦截器,再执行为了业务逻辑编写的拦截器。不过,为了灵活性,网关最好能提供一种机制,可以较容易地调整拦截器的执行顺序。最简单的一种方法,就是给每个拦截器定义一个优先级,网关按优先级顺序依次调用各拦截器。

同时,网关也需要能方便的动态配置拦截器,即动态配置拦截器的开启与关闭、以及配置哪些拦截器针对哪些请求生效。可以通过两种方式来处理:

  • 通过接口调用的方式来处理
  • 基于配置服务器的方式来处理

伸缩性

网关层为保证高可用,易于伸缩,快速启动,需要设计成无状态的(微服务里的绝大部分服务都需要设计为无状态的)。但是,由于网关需要处理用户的认证与鉴权,势必与用户状态有关系,此处需要解耦用户状态关系。目前一般做法是基于token来进行处理:

  • 用户在登录页完成登录操作后,服务端会生成一个登录用户信息,缓存起来,同时设置失效时间。返回给前端对应的key作为登录token凭证。
  • 用户后续的每次请求里会带着这个token信息,服务端根据token从缓存中获取登录用户信息,进行校验,校验通过就认为是合法用户,执行请求操作。否则就拒绝操作。
  • 对于访问鉴权流程类似,服务端根据token从缓存中获取登录用户信息,根据用户角色、当前访问的接口,判定当前用户是否有权限访问该接口,如果有权限则执行请求操作,否则就拒绝操作。

通过此方式,保证了网关的无状态,继而保证网关的快速扩容。

服务监控

对于微服务监控目前市面上有较完善的项目,例如SkyWalking,Pinpoint。可以基于这些项目快速搭建一个服务监控系统。对于定制化需求,可以进行二次开发。

同时可以基于ELK对日志进行收集分析,方便快速的定位问题。

您可能还会对下面的文章感兴趣: