4. 服务分支模式
服务分支模式是服务代理模式、服务聚合模式和服务串联模式相结合的产物。
分支服务可以拥有自己的数据库存储,调用多个后端的服务或者服务串联链,然后将结果进行组合处理再返回给客户端。分支服务也可以使用代理模式,简单地调用后端的某个服务或者服务链,然后将返回的数据直接返回给使用方。
服务分支模式的架构如图1-20所示。
在实际的业务平台建设中,由于业务的复杂性,抽象的微服务可能有多层的依赖关系,依赖关系并不会太简单,经常呈现树形的分支结构。
以电商平台的支付服务架构为例,如图1-21所示。
支付服务对接两个外部的支付网关,都要经过各自的支付渠道网关,同时支持账户余额支付,这个支付服务其实就是一个分支模式,在实际项目中这种服务分支模式很多。
笔者在构建支付平台时,由于大量地使用了服务分支模式,所以发现了一个比较有趣的现象,如下所述。
假设有一个基础服务,在服务分支模式的多个层次中对基础服务都有依赖,那么当基础服务的一台机器宕机时,假设基础服务有8台机器,则最后受影响的流量并不是1/8。假设基础服务6共有8台机器,服务1、服务3和服务5组成某服务的一个调用链,则调用链过程中会多次调用基础服务6。
具体服务的调用链示意图如图1-22所示。
某天,基础服务6的8台机器中的1台宕机,按照常理,大家都认为只影响其中1/8的流量,而统计结果显示影响的业务结果竟然大于1/8。
仔细思考,造成这个结果的原因是调用链上有多个层次重复调用了基础服务,导致基础服务挂掉时影响的流量有累加效果,具体计算如下。
假设进入系统的流量为n,调用链从服务3开始调用服务6,服务3有1/8的流量失败,这时剩下的成功的流量为7/8 ×n,剩下的成功的流量继续走到服务5,服务5再次调用服务6,又有1/8的流量失败,剩下7/8 × 7/8× n。
假设基础服务资源池中的机器个数为i,一次挂掉的机器个数为j,一个调用链中调用x次基础服务,那么正确处理的流量的计算公式为:
假设允许的可用性波动率为a,求出底层服务一次宕机1台时最少应该配置的机器数为:
对公式进行转换:
由于一次只允许一台机器宕机:
所以得出需要设置的机器数量i为:
对于上面的案例,每次最多允许基础服务6宕机1台,在这种情况下需要保持可用性的波动率小于25%,一共有两层服务依赖基础服务6,通过上述公式计算得出:
结果,至少为服务6部署9台机器,这样在1台机器宕机时,对可用性的波动性影响控制在25%以内。
由于分支模式放大了服务的依赖关系,因此在现实的微服务设计中尽量保持服务调用级别的简单,在使用服务组合和服务代理模式时,不要使用服务串联模式和服务分支模式,以保持服务依赖关系的清晰明了,这也减少了日后维护的工作量。
5. 服务异步消息模式
前面的所有服务组合模式都使用同步的RESTful风格的同步调用来实现,同步调用模式在调用的过程中会阻塞线程,如果服务提供方迟迟没有返回,则服务消费方会一直阻塞,在严重情况下会撑满服务的线程池,出现雪崩效应。
因此,在构建微服务架构系统时,通常会梳理核心系统的最小化服务集合,这些核心的系统服务使用同步调用,而其他核心链路以外的服务可以使用异步消息队列进行异步化。
服务异步消息模式的架构如图1-23所示。
在图1-23中,聚合服务同步调用服务1和服务2,而服务2通过消息队列将异步消息传递给服务3和服务4。
典型的案例就是在电商系统中,交易完成后向物流系统发起消息通知,通知物流系统发货,如图1-24所示。
6. 服务共享数据模式
服务共享数据模式其实是反模式,在1.3.3节中提出了去数据共享模式,由于去掉了数据共享,所以仅仅通过服务之间良好定义的接口进行交互和通信,使得每个服务都是自治的,服务本身和服务的团队包含全角色栈的技术和运营人员,这些人都是专业的人做专业的事,使沟通在团队内部解决,因此可以使效率最大化。
服务共享数据模式的架构如图1-25所示。
然而,在下面两种场景下,我们仍然需要数据共享模式。
单元化架构
一些平台由于对性能有较高的要求,所以采用微服务化将服务进行拆分,通过网络服务进行通信,尽管网络通信的带宽已经很宽,但是还会有性能方面的损耗,在这种场景下,可以让不同的微服务共享一些资源,例如:缓存、数据库等,甚至可以将缓存和数据在物理拓扑上与微服务部署在一个物理机中,最大限度地减少网络通信带来的性能损耗,我们将这种方法称为“单元化架构”。
单元化架构的示意图如图1-26所示。
遗留的整体服务
对于历史遗留的传统单体服务,我们在重构微服务的过程中,发现单体服务依赖的数据库表耦合在一起,对其拆分需要进行反规范化的处理,可能会造成数据一致性问题,在没有对其完全理解和有把握的前提下,会选择保持现状,让不同的微服务暂时共享数据存储。
除了上面提到的两个场景,任何场景都不能使用服务数据共享模式。
在使用了微服务架构以后,整体的业务流程被拆分成小的微服务,并组合在一起对外提供服务,微服务之间使用轻量级的网络协议通信,通常是RESTful风格的远程调用。由于服务与服务的调用不再是进程内的调用,而是通过网络进行的远程调用,众所周知,网络通信是不稳定、不可靠的,一个服务依赖的服务可能出错、超时或者宕机,如果没有及时发现和隔离问题,或者在设计中没有考虑如何应对这样的问题,那么很可能在短时间内服务的线程池中的线程被用满、资源耗尽,导致出现雪崩效应。本节针对微服务架构中可能遇到的这些问题,讲解应该采取哪些措施和方案来解决。
1. 舱壁隔离模式
这里用航船的设计比喻舱壁隔离模式,若一艘航船遇到了意外事故,其中一个船舱进了水,则我们希望这个船舱和其他船舱是隔离的,希望其他船舱可以不进水,不受影响。在微服务架构中,这主要体现在如下两个方面。
1)微服务容器分组
笔者所在的支付平台应用了微服务,将微服务的每个节点的服务池分为三组:准生产环境、灰度环境和生产环境。准生产环境供内侧使用;灰度环境会跑一些普通商户的流量;大部分生产流量和VIP商户的流量则跑在生产环境中。这样,在一次比较大的重构过程中,我们就可以充分利用灰度环境的隔离性进行预验证,用普通商户的流量验证重构没有问题后,再上生产环境。
另外一个案例是一些社交平台将名人的自媒体流量全部路由到服务的核心池子中,而将普通用户的流量路由到另外一个服务池子中,有效隔离了普通用户和重要用户的负载。
其服务分组如图1-27所示。
2)线程池隔离
在微服务架构实施的过程中,我们不一定将每个服务拆分到微小的力度,这取决于职能团队和财务的状况,我们一般会将同一类功能划分在一个微服务中,尽量避免微服务过细而导致成本增加,适可而止。
这样就会导致多个功能混合部署在一个微服务实例中,这些微服务的不同功能通常使用同一个线程池,导致一个功能流量增加时耗尽线程池的线程,而阻塞其他功能的服务。
线程池隔离如图1-28所示。
图1-28
2. 熔断模式
可以用家里的电路保险开关来比喻熔断模式,如果家里的用电量过大,则电路保险开关就会自动跳闸,这时需要人工找到用电量过大的电器来解决问题,然后打开电路保险开关。在这个过程中,电路保险开关起到保护整个家庭电路系统的作用。
对于微服务系统也一样,当服务的输入负载迅速增加时,如果没有有效的措施对负载进行熔断,则会使服务迅速被压垮,服务被压垮会导致依赖的服务都被压垮,出现雪崩效应,因此,可通过模拟家庭的电路保险开关,在微服务架构中实现熔断模式。
微服务化的熔断模式的状态流转如图1-29所示。
3. 限流模式
服务的容量和性能是有限的,在第3章中会介绍如何在架构设计过程中评估服务的最大性能和容量,然而,即使我们在设计阶段考虑到了性能压力的问题,并从设计和部署上解决了这些问题,但是业务量是随着时间的推移而增长的,突然上量对于一个飞速发展的平台来说是很常见的事情。
针对服务突然上量,我们必须有限流机制,限流机制一般会控制访问的并发量,例如每秒允许处理的并发用户数及查询量、请求量等。
有如下几种主流的方法实现限流。
1)计数器
通过原子变量计算单位时间内的访问次数,如果超出某个阈值,则拒绝后续的请求,等到下一个单位时间再重新计数。
在计数器的实现方法中通常定义了一个循环数组(见图1-30),例如:定义5个元素的环形数组,计数周期为1s,可以记录4s内的访问量,其中有1个元素为当前时间点的标志,通常来说每秒程序都会将前面3s的访问量打印到日志,供统计分析。
我们将时间的秒数除以数组元素的个数5,然后取模,映射到环形数组里的数据元素,假如当前时间是1 000 000 002s,那么对应当前时间的环形数组里的第3个元素,下标为2。
此时的数组元素的数据如图1-31所示。
在图1-31中,当前时间为1 000 000 002s,对应的计数器在第3个元素,下标为2,当前请求是在这个时间周期内的第1个访问请求,程序首先需要对后一个元素即第4个元素,也就是下标为3的元素清零;在1 000 000 002s内,任何一个请求如果发现下标为3的元素不为0,则都会将原子变量3清零,并记录清零的时间。
这时程序可以对第3个元素即下标为2的元素,进行累加并判断是否达到阈值,如果达到阈值,则拒绝请求,否则请求通过;同时,打印本次及之前3秒的数据访问量,打印结果如下。
当前:1次,前1s:302次,前2s:201次,前3s:518次
然而,如果当前秒一直没有请求量,下一秒的计数器始终不能清零,则下一秒的请求到达后要首先清零再使用,并更新清零时间。
在下一秒的请求到达后,若检查到当前秒对应的原子变量计数器不为0,而且最后的清零时间不是上一秒,则先对当前秒的计数器清零,再进行累加操作,这避免发生上一秒无请求的场景,或者上一秒的请求由于线程调度延迟而没有清零下一秒的场景,后面这种场景发生的概率较小。
另外一种实现计数器的简单方法是单独启动一个线程,每隔一定的时间间隔执行对下一秒的原子变量计数器清零操作,这个时间间隔必须小于计数时间间隔。
2)令牌筒
令牌筒是一个流行的实现限流的技术方案,它通过一个线程在单位时间内生产固定数量的令牌,然后把令牌放入队列,每次请求调用需要从桶中拿取一个令牌,拿到令牌后才有资格执行请求调用,否则只能等待拿到令牌再执行,或者直接丢弃。
令牌筒的结构如图1-32所示。
3)信号量
限流类似于生活中的漏洞,无论倒入多少油,下面有漏管的流量是有限的,实际上我们在应用层使用的信号量也可以实现限流。
使用信号量的示例如下:
public class SemaphoreExample { private ExecutorService exec = Executors.newCachedThreadPool(); public static void main(String[] args) { final Semaphore sem = new Semaphore(5); for (int index = 0; index <>20; index++) { Runnable run = new Runnable() { public void run() { try { // 获得许可 sem.acquire(); // 同时只有5个请求可以到达这里Thread.sleep((long) (Math.random())); // 释放许可 sem.release(); System.out.println('剩余许可:' + sem.availablePermits()); } catch (InterruptedException e) { e.printStackTrace(); } } }; exec.execute(run); } exec.shutdown();}}
4. 失效转移模式
若微服务架构中发生了熔断和限流,则该如何处理被拒绝的请求呢?解决这个问题的模式叫作失效转移模式,通常分为下面几种。
采用快速失败的策略,直接返回使用方错误,让使用方知道发生了问题并自行决定后续处理。
是否有备份服务,如果有备份服务,则迅速切换到备份服务。
失败的服务有可能是某台机器有问题,而不是所有机器有问题,例如OOM问题,在这种情况下适合使用failover策略,采用重试的方法来解决,但是这种方法要求服务提供者的服务实现了幂等性。
在服务化系统或者微服务架构中,我们如何拆分服务才是最合理的?服务拆分到什么样的粒度最合适?
按照微服务的初衷,服务要按照业务的功能进行拆分,直到每个服务的功能和职责单一,甚至不可再拆分为止,以至于每个服务都能独立部署,扩容和缩容方便,能够有效地提高利用率。拆得越细,服务的耦合度越小,内聚性越好,越适合敏捷发布和上线。
然而,拆得太细会导致系统的服务数量较多,相互依赖的关系较复杂,更重要的是根据康威定律,团队要响应系统的架构,每个微服务都要有相应的独立、自治的团队来维护,这也是一个不切实际的想法。
因此,这里倡导对微服务的拆分适可而止,原则是拆分到可以让使用方自由地编排底层的子服务来获得相应的组合服务即可,同时要考虑团队的建设及人员的数量和分配等。
有的公司把每个接口包装成一个工程,或者把每一次JDBC调用包装成一个工程,然后号称是“微服务”,最后有成百上千的微服务项目,这是不合理的。当然,有的公司把一套接口完成的一个粗粒度的流程耦合在一个项目中,导致上层服务想要使用这套接口中某个单独的服务时,由于这个服务与其他逻辑耦合在一起,所以需要在流程中做定制化才能实现使用方使用部分服务的需求,这也是不合理的,原因是服务粒度太粗。
总之,拆分的粒度太细和太粗都是不合理的,根据业务需要,能够满足上层服务对底层服务自由编排并获得更多的业务功能即可,并需要适合团队的建设和布局。
本文作者:
李艳鹏,现任易宝支付产品中心首席架构师,著有《分布式服务架构:原理、设计与实战》一书,曾经在花旗银行、甲骨文、路透社、新浪微博等大型IT互联网公司担任技术负责人和架构师的工作。
杨彪:现任某创业公司技术总监及合伙人,在互联网和游戏行业有近10年工作经验,曾在酷我音乐盒、人人游戏和掌趣科技等上市公司担任核心研发职位,在互联网公司做过日活跃用户量达千万的项目,也在游戏公司做过多款月流水千万以上的游戏。
联系客服