【编者按】在构建一个高性能Java版的服务框架时,哪些技术是最核心的要素?服务化过程中有哪些最容易出现的问题,该如何解决?服务架构的演进方向又是什么样的?华为分布式服务框架首席设计师李林锋为大家一一解答了这些问题。
以下是他的演讲实录:
今天我的演讲内容分为三个方面,首先看一下传统应用开发面临的挑战。我2008年到华为至今,我个人的体会和整个华为的Java的发展,包括很多互联网的公司其实都在按照这样的一个方向。第二点是服务化的实践。最后一点是服务架构的演进分析。
传统应用开发面临的挑战
挑战一:研发成本高
2008年我来北京参与当时华为的一个TOP3的项目,甲方是中国移动。那时公司也刚做Java类的软件,还没有服务框架和中间件,唯一有的是外部的框架。我们要做的是后台消息类系统,前端界面非常少。分工和协同完全靠人工来进行,并且没有考虑交互,当时面临着代码重复率高、需求变更困难和无法满足新业务快速上线和敏捷交付这些难题。
挑战二:运维效率低
对于运维感受比较深的是07年我在东软做一个ERP的系统。主要问题包括:
测试、部署成本高:业务运行在一个进程中,因此系统中任何程序的改变,都需要对整个系统重新测试并部署。
可伸缩性差:水平扩展只能基于整个系统进行扩展,无法针对某一个功能模块按需扩展。
可靠性差:某个应用BUG,例如死循环、OOM等,会导致整个进程宕机,影响其它合设的应用。
代码维护成本高:本地代码在不断的迭代和变更,最后形成了一个个垂直的功能孤岛,只有原来的开发者才理解接口调用关系和功能需求,新加入人员或者团队其它人员很难理解和维护这些代码。
依赖关系无法有效管理:服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。
解决对策
要解决上面的问题,我们采用的主要的第一个措施是拆分,包括水平拆分和垂直拆分。拆完以后我们会发现我们按业务的领域形成各种各样的一个独立的集群的中心,一个资源的池子。我们在开发应用的时候都会发现无论需求怎么去变化,其实它容易发生变化就是20%左右的功能,其实最终我们要把变化给隔离出来,控制这个变化。做法就是要抽取和识别我们的核心,我们的公共的要沉淀到下层形成一个公共的独立能力层,然后逐渐形成稳定的一个服务能力中心再下沉。前端包括中间编排层的变化给它抽象出来,做一个消费者去做。这样前后端分离以后,我们就可以有效的去管理。能管控住变化以后的需求变更,包括测试和整个的成本其实都是可以得到一个有效的控制。
服务化实践
服务的订阅发布
图1
如图1所示,我们无论是用过MQ或者中间件有一个流行的做法,第一个就是说服务或者位置的一个透明,但随着规模的扩大,人工应用管理这些地址的时候成本会非常高。第二点是很多东西现在都上到云,云的很大特点是它的弹性跟伸缩。弹性跟伸缩意味着它随着有可能会跳入,但是它的IP都是动态的,如果我们没有一套动态发现机制的话,软件的敏捷性会非常差。
现在一种非常流行的做法是服务提供者会把它的服务注册到注册中心,消费者会去订阅拉取这些消息,然后在本地缓存一个提供者的信息。这样每一次交互的时候,只查本地内存就可以了,不需要强依赖这个注册中心,实际上对服务中心依赖就是一个弱依赖。
当然其他做法也有很多,管理的服务节点数在万级以内用ZK比较成熟,规模再大一点时延和性能包括可靠性会有一些问题,这时可以考虑一些其他的技术手段。
第二点其实就是服务的发布和消费,以前我们用中间件或者ESB,包括在用Netty的时候,都会担心版本的兼容性。版本的变化可能使其实整个目录都发生重构,切到独立的IO点的话,这个变化是很麻烦的,所有的东西都需要过,不要说底层的限制模型和其他的一些用法的修改,这个就得用很多时间。
零侵入
所以在我们实际设计过程中,很希望这个服务框架或者服务化对上层应用的侵入尽可能小,但百分百零侵入是做不到的。通过配置化的方式——即Spring的方式,通过配置和扩展、消费、发布,其实配置本身是一个侵入,是代码的一部分。但是这个有的好处是我们改一个代表总比去编译它强。就是说我们尽量少的开放服务框架的API给上层,而开放的都是一些特性和配置化的方式,或者是用注解也可以。
这样的话,未来我们的服务框架无论是底层做什么样的整合和重构,其实我们都不用再担心。当然,这个也是比较流行的做法,有一些做的比较好的,可能是硬编码、基于配置和注解都支持,像亚马逊AWS做的只支持注解。
容错和路由
首先讲路由。在同一个机房以内的路由会提各种各样的策略。比如说按照服务的负载做路由或做自定义的路由。其他的跨数据中心和跨地域的,要考虑容灾和跨机房的路由。我们的策略是常用的路由肯定要提供的,但只有基础路由肯定是不够的,最终要把路由策略扩展出去。当然这个策略有很多种,比如说有一些做法是把路由作为一个接口由它来实现,还有一个是让它自定义我们的策略配置一下就可以直接执行用户的一个路由策略。
路由策略是透明的,大多数时候用户只需要提需求就可以了。而容错是服务框架自身要考虑的,用户选择一个路由,选了一个比较空闲的服务,对他选择而言是没有错的。但是我们都知道即使我们在可用的决策里面选择一个可用的提供者,也有可能会出问题。比如说我选择的时候没有问题,服务状态是OK的,但是我在给他发消息的过程中它挂了,网络断了,或者处理消息的过程中超时了。。这一块是我们有几类比较通用的方法,第一个是重试,因为服务化大家经常提起的最佳状态就是我们要做无状态,其实应用肯定是要状态的。但是我们做应用的时候,把它的状态信息不要扔到本地,最终要放出去,不管是缓存还是放到统一集中式的会话管理中心。最终是不会扔到本地的,因为扔到本地有几个问题,第一个是幂等性肯定没法状态,第二个是有了状态以后,重试就会有一些问题。到云的时候更是这样,比如说我们过程中虚拟机更个宕掉了,虚拟机的物理性和和整机肯定是没法比较的。我们会发现会遇到一些新的挑战,实际上从软件层我们考虑到它的架构的话,我们就可以通过服务的无状态加上我的集群容错,去真正的把底层这种异常整个给屏蔽掉,只要有一个集群有整个服务的使用,我这个应用就能成功了,而且我这个应用也不关心到底哪个服务提供者能用,哪个不能用。
不过在做集群容错的时候可能会注意一些细节,假如说某个服务提供者不可用,我们会重试下一个,假如说下一个也不能用呢?比如说应用层的超时就没法应答,怎么办呢?会到下一个,因为一个应用掉下来会掉很多服务,前面肯定有一个3秒或者几秒的超时。有可能重试的过程中最终突破了前端的超时时间,那你再继续下一个的时候就没有意义了。这个时候,应用层上对重试的次数和整个时间还是有一定的约束,我们设计的时候也是考虑的,不能做无限的重试。
本地短路策略
在整个路由里面有一个短路的策略,就是说有三点。第一点就是说首先如果我本地就有提供者,指的是JVM。当我的消费者应用不是很大的时候,我的消费者和提供者都是运行在同一个JVM之内,就可以优先调我本地提供的。首先不走网络,网络的整个可靠性可以得到提升。第二点的话看框架怎么做,做的好的连序列化多不用做,直接短路做本地的接口调用,整个性能包括可靠性会提升很多。第三点是本VM或者服务器,走本地网卡的短路,不走网络,最后一点是走远端的网络。最终实践中我们发现运用的客户更喜欢这样,因为我们知道很多的时候我们去推崇微服务还是服务的独立部署,其实在很多时候如果当你部署的VM或者这个机器比较好的话,肯定会面临应用多进程的核设,一个VM就是一个应用也有,但是有很多场景是核设的,它会有一些性能上的诉求。
多样化调用方式
最常用的是这样一个同步的调用方式,如图2所示。在做服务化之前,我们都是本地Java的接口调用,这肯定是一个同步的调用。其实跟这种场景一样,它发起一个调用,最终消息通过我们的通信框架发给对方,这个时候肯定没有应答,我现在怎么办呢?常用的做法就是在这里等,等到接收到以后把应答解码唤醒,这个时候就可以走了。实际上来说业务是在这里挂起的,模拟的就是本地的调用,这种是非常常用的一种方式。
图2
因为这样的话,成本是最低的,而且跟本地使用习惯相关。但这种模式是弊端的,无论你的IO模型是不是异步,最终你的整个应用层处理的效率都取决于对方的一个处理速度。如果对方处理很慢,假如说我这边用Netty,做异步IO和非阻塞,没有问题,但是它挂住了你的应用线程。这也有一个问题,就是说实际上我们知道应用的线程个数也是有限的,道理是一样的。以前我们IO能挂住,应用也能挂住我们,现在IO挂不住,应用还是能挂住。这种模式我现在接触了很多客户还是喜欢用这种模式,但是另外一些客户的场景和量非常大的话,他会选择第二种异步的方式。如图3所示。
图3
我们可以看到它的特点在这里不挂,怎么办呢?我们都知道在Java和get里面其实就有一个Future和一个其他的内容,它这个就是说我不想再执行,我想先返回,但是你可以给我一个回执,给我一个上下文或者Future预知的东西,如果我想等的话,我就给我一个get。如果这个时候没有做完你就挂住我,如果我不想等,我就想返回,程序继续往下走,继续处理其他消息,你处理完了回调通知我,这样处理完了以后相当于做一个监听器,最终解码以后会回调,它会调这些接口,在这些接口里面可以拿到应答消息的上下文,流程是被挂起了,你就继续处理其他消息了。
这种方式是对我们整个的吞吐量和可靠性有非常大的改善,但它的使用其实是有一定约束,包括跟我们编程的习惯有关系,以前我们习惯一条线处理完。现在你要考虑到你这个点可能会拿不到结果,这个时候怎么办?流程怎么走?所以对应用层的设计包括开发有一定约束,所以说坦率来讲很多应用开发并不喜欢用这种方式。第三种方式是并行的调用,如图4所示。它的理念有点像我们做数据库、批量操作。就是说我单条做,有可能在哪一条就堵住我了。如果ABC串行的话,肯定是A+B+C。如果说批量绑定在一起调用,它跟我返回一个Future的数组,实际上我在每一个点上都get,这种模式下它的阻塞时间多长呢?取决于最长的那个。实际上来说就把以前串行改成了并行,但是这不是真实意义上的并行,因为底下还是一个线程在那里串行做。这种模式其实是没有问题的,为什么呢?底层其实还是一个线程串行做并行,原因是我们大多数服务框架底层都IO都是异步的。意味着我写N条信息下去,都不会阻塞我写这个线程本身,本身我写完了以后就在这里等待可以了。这种方式其实它有一个约束,我三个服务调用没有强的前后关系和逻辑,第一个不需要等,第二个需要等,第三个也不用等。比如说我到营业厅开户,它这个会提醒我开户成功,这个提醒会充值成功,那个可能是我粉丝值,或者积分增加了几分,这几个都可以并行,他们之间的先后谁失败不会影响另外一个,这个就是天生的操作,很适合使用并行的调用。
图4
高性能、低时延
很长一段时间以来大家对性能都非常看重,对我们服务框架一样的。们强调两点性能,第一个是吞吐量,包含两个指标:一个是我单个服务节点到底能并发处理多少个消费者来连接处理;二是我每秒能处理多少消息。第二个是时延。我们做服务化以后,一个大的应用会拆十几个或者几十个服务调用。对于大多数业务,用户使用时就是外部前端处理的结果或者查询的结果。如果我们的时延做的非常差的话,用户会认为服务化最终解决不了他的问题。
影响性能的因素还是三点。第一个就是IO,这个其实现在没有太大争议了,因为现在无论大家是用什么通信框架,或者是自研都是这样的模式。第二点是序列化,其实现在还有很大的优化空间。很多人他不喜欢用二进制或者PB这些,但实际上我们如果做压力测试或者对比的时候,会发现它的整个吞吐量包括一个性能的差异是非常大的。在大多数情况下,我们认为二进制的东西不好堵,其实也可以做很多工具,或者做一些小插件,没有必要非得用肉眼去做。
高效线程模型
线程模型的影响是非常大的。因为我现在发现两类用的特别多,第一类是Java的线程池,原生的,性能不足就调线程,这点Netty做的比较好。第二类是自己写个线程去调度。在这里推荐一个方式,大家有经历的话还是看一下Netty自身的线程模型怎么做的,如图5所示。
图5
它的核心理念就是尽量的无锁,就是一个串行。以前大家认为串行了就并行不起来了,就会喜欢在后端架N个线程池,这边有一个队列,不管前端多少个消息都丢到一个队列里,去抢着征用。其实根本的问题第一个是队列里面我们能不能去无锁,尽量不要用锁。这就有一个前提,就是你的线程征用尽可能的少。如果这边你做成这样的,在某一个时刻或者任何一个时刻,有可能最多只有两个线程,一个去放,一个去写,去征用。
我们可以这样想,假如说Netty这边的线程是一个,因为这是不可能跨线程的。这个线程只对应到后端的一个业务线程,一比一这样的对应。在任何一个时刻,最起码在这个业务上已经做到了一比一的这样一个征用。假如说我们后端用JDK一个线程池的话,我们不可能只配一个线程。配为N的话,就意味着在任何一个链路的消费者进来,都会被N个线程征用去读写。但在真正的项目中不可能只有一个消费者,那就成为一个N比M了,每一个线程都是这样的征用,性能肯定是比较差的。
所以说在Netty的处理里面很简单,就是说第一个一定是在一个线程里面,线程和它是一比N的关系,直到它被消灭掉,否则一直会在上面。后端也是这样的同样,应该是一个一个独立的管,每一个水管里面都有线程在这里调度。在这条链上尽量不要跨线程去做。
所以说在实际服务框架研发过程中,我们一个核心的理念第一个后端不是线程池,一定是N组,每一组线程数尽量的少,通过增加组数进行并行调动,尽量在一条链上做成串行,这是第一点。第二点是深度优化,我在前端可能会设计虚拟分组,保证通信线程和它的配比尽量做到一比一,当然这一块线程非常复杂,这一块需要你对通信框架和这两块非常熟,因为我们目前来讲,有后端优化,但是前端和后端联合起来优化还是没有见到过。
故障隔离
下面来看一下可靠性,一个故障的隔离。在服务框架里面做服务机故障隔离的时候,服务发布的时候会指定不同线程池,包括订单和用户注册核心的服务,有独立的线程池在这里跑。对于一些其他非核心的,我可以共用一个大的池子,即使出了故障,我这一块还是正常跑,这种隔离是调度曾的隔离。比如说CPU百分之百,或者内存OVM了,它还做不到。但是我们看一下服务治理体系,强调了线上和线下这两点。我们知道我们做运维的过程中,很多数据不可能提前制定,没有哪个架构师说这个线程池配成20个,超时时间1秒一定是最优的,其实还会根据业务和线上的实际情况做调整。在调整的过程中我们有一个基础的要求,就是说第一个你要去改,肯定不能重新打个包,停了再上去,我们肯定是在线的改。
服务治理
我们肯定要有一个界面对需要治理的服务做修改,然后在里面集群生效。当然,改了很多,超时时间和留空的阈值和路由的策略、权重的调整和黑白名单。我发现某个消费者恶意消费,我想把它限流,想把它加入到黑名单里,不让他再调度进来,这都是一些临时的措施。
然后是线下地当我们应用规模大的时候,我们会发现我们的服务上线包括服务的下线,甚至服务的打包和部署和下面的测试,都需要有一套完整的流程管理起来。因为有一些人服务不太完善的情况,他就敢扔到预发布的环境里面,这时导致其他所有依赖这个服务的都不能用,影响一大片。实际上线上的话,服务整个生命周期都需要维护。比如说开发,一个人可能不需要,但是二十个、三十个呢?当整个规模大了以后,我们会不会做统一的工具来提升我服务的开发效率。
高可靠性
我们可以看一下服务框架本身的可靠性,第一个就是无状态,就是说把本地迁移到原端。当然我们可以用分布式缓存或者其他的管理框架。第二个是做集群,第三个是要做它的健康状态的检测。如果只做服务能否可用的检测远远不够,在实际项目中我们也需要对他做什么呢?性能KPI的一个检测,机遇KPI数据检测评估它的可用性。
还有一个是治理,比如说我们服务治理的时候,如果服务治理中心宕机了,应该不影响我业务的一个正常的运行,接着是我的服务级的故障隔离,包括我的容灾。
未来演进方向
微服务架构
第一个重大演进是微服务,谈了两年。我的体会是首先它是一个架构的分隔,一定在可预见的未来不会有标准。根本的就是在演进的过程中,我们会发现服务完了以后还会遇到一些新的挑战,包括要敏捷交付、实现更细化的隔离和交互。如果我们没有一套好的工具和流程,能够把微服务治理好的话,去做微服务的话还是会面临很多问题。包括一些团队的分工,就像亚马逊的团队,构建全栈小分队,实际上就是大概四到五个人的规模。这个人负责他所属的微服务从开发、测试、打包、部署、上线、运行、修复都由这个团队统一负责。跟传统的研发是研发,测试是测试,运维是运维,分工还是有差异的。其实团队能不能去接受,或者团队整个实践的效果,也能决定你整个实施的结果。
所以对微服务我的一个判断就是说水到渠成,如果你的服务化实施了很多年,你已经遇到需要向微服务演进的时候,自然而然就能演进。如果你不具备这样一个基础设施去强推的话,效果肯定会非常差。
基于Docker部署微服务
今年很流行一句话,就是说微服务天生是部署在Docker里面。为什么呢?因为拆完这么多东西以后,首先我们一定要考虑部署要快,以前我一次性打包部署,跟我去部署十台虚拟机的速度不一样,所以说它的成本会让应用感觉我没法真正拆开独立部署。有了Docker以后,比如Docker的秒级和创建以及资源的隔离、使用、消费,这些特性都决定了一个微服务或者若干个微服务运行在一个Docker容器里,是非常适合的一种部署策略。
我们看一下Docker的服务特点,除了高密度、快速部署微服务以外,其实还有一个优势,比如说一致的环境、避免对特定云基础设施提供商的依赖,包括对它的依赖性等等的特性,我们都可以享受。
云上的微服务
云化以后有一个问题,我原有的微服务包括我原有的服务也会迁移到云上,所以它会享受到另外一个特性,比如说云上的弹性伸缩和资源的灵活快速调度。迁移到云上以后,我的服务本来能够自动发现。有了这个弹性伸缩以后,我结合服务性能的KPI数据加上我的阈值值,我就可以实现自动化的运维,到时候扩容的时候就不需要人工干预,我需要自己做。
还有一个是虚拟机,有了虚拟机以后我们发现对资源的隔离就可以做的更好了,VM可以跨的非常小。这个时候我就可以利用它的故障隔离实现更细力度的隔离。比如说我们以前担心某个微服务,其实做了故障隔离以后,其实还是会宕机,这个时候可以通过VM把它隔离起来,做VM级的故障隔离。有了云以后,提供了很多的特性,如果能和我们已有的服务框架结合起来,会能带来很多的价值,对运维的价值其实就可以帮助我们以前很多手工的运维,就可以通过上云以后的服务实现自动化运维,当然还有很多和云的结合,这是需要缓慢的一个演进过程。
总结
最后对整个服务化架构演进的历史路标,首先是MVC,我们最常见的。我们从2002、2003年就开始的架构,到了RPC我们很多都在用,最终我们要连接很多模块要调用。但是那个时候我们很多地址是写死的,包括它有很多的缺陷。随后IBM提出SOA的结构,实施了这么多年以后,服务华固有的缺陷和问题,包括更便理论化的问题,我们发现已经不适应当前的整个业务的发展,最终会朝着微服务架构的理念去演进。当然,它其实更多的是一种风格和理念。其实还是我们说的,这个过程是自然而然,水到渠成的一个过程。所以,我相信未来基于微服务架构的应用会越来越多,包括这样的一些案例。当然有可能是微服务,有可能是混着的一个架构。
本文是华为技术专家、分布式服务框架首席设计师李林锋日前在「七牛云主办的架构师实践日——高性能服务端开发实践专场沙龙」的演讲内容整理。PPT、速记和现场演讲视频等参见“七牛架构师实践日”官网。