- 超大流量分布式系统架构解决方案:人人都是架构师2.0
- 高翔龙
- 7601字
- 2020-08-27 15:53:45
1.2 服务治理需求
随着业务复杂度的上升,服务化能够有效帮助企业解决共享业务被重复建设、业务系统水平伸缩,以及大规模业务开发团队协作等问题,那么接下来笔者就会重点为大家讲解大规模服务化场景下企业应该如何实施服务治理。
1.2.1 服务化与RPC协议
在本章的前面几个小节中,笔者为大家详细介绍了究竟什么是服务化,以及企业为什么需要落地服务化架构。当然,在笔者正式开始为大家演示具体的实施细节之前,不得不提及的就是与服务化息息相关的RPC(Remote Procedure Call,远程过程调用)协议。从严格意义上来说,服务化其实只是一个抽象概念,而RPC协议才是用于实现服务调用的关键。RPC由客户端(服务调用方)和服务端(服务提供方)两部分构成,和在同一个进程空间内执行本地方法调用相比,RPC的实现细节会相对复杂不少。简单来说,服务提供方所提供的方法需要由服务调用方以网络的形式进行远程调用,因此这个过程也称为RPC请求,服务提供方根据服务调用方提供的参数执行指定的服务方法,执行完成后再将执行结果响应给服务调用方,这样一次RPC调用就完成了,如图1-13所示。
图1-13 RPC的请求调用过程
笔者简单阐述下Dubbo消费端Proxy的创建流程。当我们试图从Spring的IoC容器中获取出目标对象实例时,就会触发Dubbo的ReferenceBean类(继承自Spring的BeanFactory)的getObject()方法,由该方法负责调用父类ReferenceConfig的get()方法触发init()调用。在init()方法中,最终会调用createProxy()方法来生成服务接口的Proxy对象。当Proxy成功创建后,一旦发起RPC调用,那么在服务接口的Proxy中将会使用Netty请求调用目标Provider。
服务化框架的核心就是RPC,目前市面上成熟的RPC实现方案有很多,比如Java RMI、Web Service、Hessian及Finagle等。在此需要注意,不同的RPC实现对序列化和反序列化的处理也不尽相同,比如将对象序列化成XML/JSON等文本格式尽管具备良好的可读性、扩展性和通用性,但却过于笨重,不仅报文体积大,解析过程也较为缓慢,因此在一些特别注重性能的场景下,采用二进制协议更合适。看到这里或许有些人已经产生了疑问,实现服务调用无非就是跨进程通信而已,那是否可以使用Socket技术自行实现呢?带着疑问,笔者来为大家梳理一下完成一次RPC调用主要需要经历的三个步骤:
● 底层的网络通信协议处理;
● 解决寻址问题;
● 请求/响应过程中参数的序列化和反序列化工作。
其实,RPC的本质就是屏蔽上述复杂的底层处理细节,让服务提供方和服务调用方都能够以一种极其简单的方式(甚至简单到就像是在实现一个本地方法和调用一个本地方法一样)来实现服务的发布和调用,使开发人员只需要关注自身的业务逻辑即可。
1.2.2 基于服务治理框架Dubbo实现服务化
由于RPC协议屏蔽了底层复杂的细节处理,并且随着后续服务规模的不断扩大需要考虑服务治理等因素,因此笔者在生产环境使用的服务治理框架基于的就是Apache开源的Dubbo。之所以选择Dubbo,主要因为其设计精良、使用简单、性能高效,以及技术文档丰富等特点。并且Dubbo还预留了足够多的接口,以便让开发人员能够以一种非常简单的方式对其进行功能扩展,更好地满足和适配自身业务,所以Dubbo在开源社区拥有众多的技术拥护者和推进者。早期因为一些特殊原因曾导致Dubbo一度停止维护和更新,但好在从2017年的9月份开始,Dubbo又重新回归了开源社区的怀抱,陆陆续续发布了一系列版本,而且目前已经正式成为Apache的顶级项目。
如图1-14所示,Provider作为服务提供方负责对外提供服务,当JVM启动时Provider会被自动加载和启动,由于Provider并不需要依赖任何的Web容器,因此它可以运行在任何一个普通的Java程序中。在Provider成功启动后,会向注册中心注册指定的服务,这样作为服务调用方的Consumer在启动后便可以向注册中心订阅目标服务(服务提供者的地址列表),然后在本地根据负载均衡算法从地址列表中选择其中的某一个可用服务节点进行RPC调用,如果调用失败则自动Failover到其他服务节点上。
图1-14 Dubbo整体架构
在此需要注意,在服务的调用过程中还存在一个不容忽视的问题,即监控系统。试想一下,如果在生产环境中业务系统和外围系统没有部署相对应的监控系统来帮助开发人员分析和定位问题,那么这与脱了缰的野马无异,当问题出现时必然会导致开发人员手足无措,相互之间推卸责任,因此监控系统的重要性不言而喻。值得庆幸的是,Dubbo为开发人员提供了一套完善的监控中心,使我们能够非常清楚地知道指定服务的状态信息(如服务调用成功次数、服务调用失败次数、平均响应时间等),如图1-15所示。这些数据由Provider和Consumer负责统计,再实时提交到监控中心。
图1-15 Dubbo服务状态监控
接下来笔者就为大家演示Dubbo的一些基本使用方式。截至本书成稿之时,Dubbo的最新版本为2.7.0,但由于笔者所在企业使用的版本为2.5.3,因此本书也使用此版本来进行演示。下载Dubbo构件,如下所示:
Dubbo项目的pom.xml文件中依赖有一些其他的第三方构件,如果版本过低或者与当前项目中依赖的构件产生冲突时,那么可以在项目的pom.xml文件中使用Maven提供的<;exclusions/>;标签来排除依赖,自行下载指定版本的相关构件即可。除此之外,大家还需要注意一点,Dubbo的源码是在Java 5版本上进行编译的,因此在项目中使用Dubbo时,JDK版本应该高于或等于Java 5。
成功下载好运行Dubbo所需的相关构件后,首先要做的事情就是定义服务接口和服务实现,如下所示:
在上述程序示例中,服务接口UserService中提供了一个用于模拟用户登录的login()方法,它的服务实现为UserServiceImpl类。如果用户正确输入账号和密码,那么结果将返回“true”,否则返回“false”。服务接口需要同时包含在服务提供方和服务调用方两端,而服务实现对于服务调用方来说是隐藏的,因此它仅仅需要包含在服务提供方即可。
从理论上来说,尽管Dubbo可以不依赖任何的第三方构件,只需有JDK的支撑就可以运行,但是在实际的开发过程中,为了避免Dubbo对业务代码造成侵入,笔者推荐大家将Dubbo集成到Spring中来实现远程服务的发布和调用。在Provider的Spring配置信息中发布服务,如下所示:
成功启动Provider后,便可以在注册中心看见由Provider发布的远程服务。在Consumer的Spring配置信息中引用远程服务,如下所示:
成功启动Consumer后,便可以对目标远程服务进行RPC调用(使用Dubbo进行服务的发布和调用,就像实现一个本地方法和调用一个本地方法一样简单,因此笔者省略了服务调用的相关代码)。关于Dubbo的更多使用方式,本书不再一一进行讲解,大家可以参考Dubbo的用户指南。
1.2.3 警惕因超时和重试引起的系统雪崩
在上一个小节中,笔者为大家演示了Dubbo框架的一些基本使用方法,尽管使用Dubbo来实现RPC调用非常简单,但是刚接触Dubbo框架的开发人员极有可能因为对其不熟悉而跌进一些“陷阱”中。因此,开发人员需要重视看似微不足道的细节,这往往可以非常有效地避免系统在大流量场景下产生雪崩现象。在对数据库、分布式缓存进行读/写访问操作时,我们往往都会设置超时时间,一般笔者不建议在生产环境中将超时时间设置得太长,否则如果应用获取不到会话,又长时间不肯返回,那么一定会对业务产生较大的影响。但将超时时间设置得太短又可能适得其反,因此超时时间究竟应该如何设置需要根据实际的业务场景而定,大家可以在日常的压测过程中仔细评估。
为Dubbo设置超时时间应该是有针对性的,比较简单的业务执行时间较短,可以将超时时间设置得短一点,但对于复杂业务而言,则需要将超时时间适当地设置得长一点。笔者之前曾经提过,Dubbo的Consumer会在本地根据负载均衡算法从地址列表中选择某一个服务节点进行RPC调用,如果调用失败则自动Failover到其他服务节点上,默认重试次数为两次,服务调用超时就意味着调用失败,需要进行重试,如图1-16所示。在此需要注意,如果一些复杂业务本身就需要耗费较长的时间来执行,但超时时间却被不合理地设置为小于服务执行的实际时间,那么在大流量场景下,系统的负载压力将被逐步放大,最终产生蝴蝶效应。假设有1000个并发请求同时对服务A进行RPC调用,但都因超时导致服务调用失败,由于Dubbo默认的Failover机制,共将产生3000次并发请求对服务A进行调用,这是系统正常压力的3倍,若处于峰值流量时情况可能还会更糟糕,大量的并发重试请求很可能直接将Dubbo的容量撑爆,甚至影响到后端存储系统,导致资源连接被耗尽,从而引发系统出现雪崩。
图1-16 Dubbo的Failover机制
还有一点很重要,并不是任何类型的服务都适合Failover的,比如写服务,由于需要考虑幂等性,因此笔者建议调用失败后不应该进行重试,否则将导致数据被重复写入。只有读服务开启Failover才会显得有意义,既然不需要考虑幂等性,就可以通过Failover来提升服务质量。
除了Failover,Dubbo也提供了其他容错方案供开发人员参考,如下所示:
上述相关参数除了可以在服务调用方配置,也适用于服务提供方,如果服务调用方和服务提供方配置有相同的参数,默认以服务调用方的配置信息为主。关信息大家可以参考Dubbo的配置参考手册。
1.2.4 为什么需要实施服务治理
当企业的系统架构逐步演变到服务化阶段时,架构师重点需要考虑的问题是服务如何拆分、粒度如何把控,以及服务之间的RPC调用应该如何实现。这些问题都迎刃而解了,也并不意味着我们就能够一劳永逸,随着服务规模的逐渐扩大,一些棘手的问题终会暴露出来,因此架构师必须具备一定的前瞻性,提前规划和准备充足的预案去应对将来可能发生的种种变故,否则这就等于给自己挖坑,与其花大把时间去填坑,不如花更多的精力去思考企业在大规模服务化前应该如何实施服务治理。
那么究竟什么是服务治理呢?这个问题至今也没有在社区达成一个统一的共识,几乎每个人对服务治理都有一套自己的认知。服务治理所涉及的范围较广,包括但不限于:服务注册/发现、服务限流、服务熔断、负载均衡、服务路由、配置管理、服务监控等。参考Dubbo对服务治理的定义来看,服务治理的主要作用是改变运行时服务的行为和选址逻辑,达到限流,权重配置等目的,当然,采取什么样的治理策略还需要依赖监控数据来进行全方位的分析和指导。
以服务的动态注册/发现为例,当服务变得越来越多时,如果把服务的调用地址(URL)配置在服务调用方,那么URL的配置管理将变得非常麻烦,如图1-17所示。因此引入注册中心的目的就是实现服务的动态注册和发现,让服务的位置更加透明,这样服务调用方将得到解脱,并且在客户端实现负载均衡和Failover将会大大降低对硬件负载均衡器的依赖,从而减少企业的支出成本。
图1-17 不依赖于注册/发现
既然谈到了服务注册/发现机制,那么笔者就顺便为大家讲解下服务发现的2种模式,有助于帮助大家更好地在不同的业务场景下做出合适技术选型。
首先从服务端服务发现模式(Client-side Service Discovery Pattern)开始讲起。在此模式下,除注册中心外,还需引入代理中心(路由器)。当Provider成功启动后,会向注册中心注册指定的服务,由代理中心来处理服务发现;Consumer的职责很简单,只需要连接代理中心并向其发送请求即可,代理中心会根据负载均衡算法从地址列表中选择一个可用的服务节点进行RPC调用,其实,这和部署在接入层的反向代理服务器作用类似。在此模式下,由于Consumer无须处理服务发现等相关逻辑,因此职责相对更加简单和清晰,如图1-18所示。
图1-18 服务端服务发现模式
客户端服务发现模式(Client-side Service Discovery Pattern)相信大家都非常熟悉了,Dubbo整体架构其实也基于的是此模式。基础的服务注册步骤和服务端模式是一致的,只是将服务发现交给了Consumer来负责实现,无须再引入代理中心,由Consumer根据指定的负载均衡算法从地址列表中选择一个可用的服务节点进行RPC调用,如图1-19所示。
图1-19 客户端服务发现模式
在分布式场景下,依赖的外围系统越多,系统存在宕机的风险就越大。在服务端服务发现模式下,我们需要额外引入代理中心来负责服务发现和请求的负载均衡等任务,这就意味着代理中心必须具备容错性和伸缩性;而且,从性能表现来看,客户端模式整体的吞吐量也会优于服务端模式,毕竟在服务端模式下需多增加一层网络开销,不如客户端模式来得直接。因此,在实际的开发过程中,笔者建议应该优先考虑使用基于客户端的服务发现模式。
部署监控中心是为了更好地掌握服务当前的状态信息,因为有了这些指标数据后我们才能够清楚目标服务的负载压力,以便迅速做出调整,采用合理的治理策略。比如在大促场景下,为了让系统的负载处于比较均衡的水位,不会因为峰值流量过大,导致系统产生雪崩而宕机,我们往往都会选择“断臂求生”,采用流控、熔断,或服务降级等治理策略对外提供有损服务,尽可能保证交易系统的稳定和大多数用户能用。关于其他的服务治理问题,大家可以参考Dubbo的用户指南。而关于服务调用跟踪的问题,大家可以直接阅读1.3节。
1.2.5 关于服务化后的分布式事务问题
从单机系统演变到分布式系统并不像书本中描述得那样简单,不同的业务之间必然会存在较大的差异,因此企业在实施服务化改造时肯定会困难重重,即使最终服务成功被拆分出来,架构师还需要提前思考和规划后续的服务治理等问题。当然这一切都还不是终点,我们能够看见的问题其实只是冰山一角,大型网站架构演变过程中等待我们解决的技术难题还有很多。大家思考一下,实施服务化改造后事务的问题应该如何解决?或许很多同学都会毫不犹豫地指出,分布式事务简直让人感到“痛心疾首”。的确,就算是银行业务系统也并不一定都采用强一致性,那么我们是否还有必要去追求强一致性呢?
其实分布式事务一直就是业界没有彻底解决的一个技术难题,没有通用的解决方案,没有高效的实现手段,但是这并不能成为我们不去解决的借口。既然分布式事务实施起来非常困难,那么我们为什么不换个思路,使用其他更优秀的替代方案呢?只要能够保证最终一致性,哪怕数据会出现短暂不一致窗口期又有什么关系?在架构的演变过程中,哪个是主要矛盾就优先解决哪一个,就像我们对JVM进行性能调优一样,吞吐量和低延迟这两个目标本身就是相互矛盾的,如果吞吐量优先,那么GC就必然需要花费更长的暂停时间来执行内存回收;反之,频繁地执行内存回收,又会导致程序吞吐量的下降,因此大家要学会权衡和折中。关于最终一致性的实现方案,大家可以直接阅读5.2.8节和5.4.2节。
1.2.6 注册中心性能瓶颈方案
随着服务拆分的粒度越来越细,服务及服务实例越来越多,整个服务治理体系中注册中心的性能瓶颈会逐渐开始暴露出来,这几乎是任何一家大中型规模的电商企业都会经历和面对的问题。笔者所在企业目前线上环境常态下的服务数量都保持在1W+左右,更不用说大促扩容后的服务数量,由于我们之前使用的注册中心是ZooKeeper,所以面临着如下2个棘手的问题:
● 服务扩容时,应用启动异常缓慢;
● 冗余的服务配置项会增加存储压力和扩大网络开销。
笔者一直认为,ZooKeeper真的不太适合作为大规模服务化场景下的注册中心,因为它是一个典型的CP系统,是基于ZAB(Zookeeper Atomic Broadcast,原子广播)协议的强一致性中间件,它的写操作存在单点问题,无法通过水平扩容来解决。当客户端发送写请求时,集群中的其他节点会优先转发给Leader节点,由Leader节点来负责具体的写入操作,只有当集群中>;=N/2+1个节点都同步成功后,一次写操作才算完成。当服务扩容时,TPS越高,服务注册时的写入效率就越低,这会导致上游产生大量的请求排队,表象就是服务启动变得异常缓慢。
Dubbo服务启动时向注册中心写入的配置项有近30多个,但是其中大部分配置项都不需要传递给消费者,或者说消费者并不需要这些配置项(比如:interface、method、owner等),消费者完全可以通过接口API在生成Proxy时获取到必要的关键信息,那么在服务大规模扩容时,冗余的配置项会导致数据量的急剧膨胀,增加ZooKeeper集群的存储压力,甚至直接导致内存溢出。笔者生产环境中确实发生过一次这样的生产事故,最初我们也没有深究,只是紧急扩大了内存应急,但如果后续服务规模更大,继续盲目扩大内存是无法有效支撑企业未来发展的,最重要的是,数据量的膨胀会直接扩大注册中心的网络开销。
网络开销被扩大会带来什么后果?不急,听笔者徐徐道来。大促结束后,运维同学需要释放掉过剩的服务资源,减少不必要的资源开销,在不考虑服务优雅下线的情况下,通常采用的做法是简单粗暴的“kill-9”操作,但这会导致消费者无法及时更新本地的服务地址列表,在某个单位时间内仍然路由到失效节点上,导致大量的调用超时异常,因此我们的架构团队扩展了Dubbo的优雅下线功能(Dubbo2.5.3版本的优雅停机存在Bug)。简单来说,对那些需要下线的服务,会优先从注册中心内摘除它们注册时写入的相关信息,然后等服务处理完当前任务后再结束其进程。尽管设计上是合理的,但需要释放的服务数量之多,服务节点的任何变化,都会导致消费者每次都全量拉取一个服务接口下的所有服务地址列表信息,笔者线上注册中心的内网带宽是1.5Gbit/s,消费者拉取带来的瞬时流量瞬间就会将ZooKeeper集群的网卡打满,导致大量的消费者没有及时拉取到变化后的服务地址列表而继续路由到失效节点上。
接下来笔者为大家分享下注册中心的改造方案,如图1-20所示。
图1-20 注册中心的3个改造阶段
阶段1的改造成本非常小,仅仅只需要运维同学增加ZooKeeper的ObServer节点即可。由于ObServer节点并不参与投票,只负责同步Leader状态,因此我们可以通过扩展ObServer节点来提升ZooKeeper集群的QPS和分担带宽压力,并且扩展ObServer节点并不会降低集群的写入性能。尽管阶段1可以在短时间内解决我们的燃眉之急,但治标不治本,所以我们引入了阶段2。
阶段2的改造相对复杂。由于我们使用的Dubbo版本是2.5.3,所以修改了Provider的注册逻辑,向注册中心注册时只写入了部分关键信息(比如:ip、port、version,以及timeout等),其他配置项则写入元数据中心,实现了数据分离,所带来的好处就是,注册中心再也不会存在过多的冗余配置项而引起数据膨胀。由于数据量的减少,消费者在拉取时,极大程度地降低了网络开销。在此大家需要注意,目前Dubbo的2.7.0版本已经提供了精简注册配置项和元数据服务等功能,且支持向下兼容,所以笔者建议大家可以直接使用新版本的Dubbo,不必再去重复造轮子。
阶段3是最终目标,同时也是3个阶段中改造成本最大的。写操作对于服务注册/发现场景来说完全没必要保持强一致性,所以笔者选择了自研一个具备最终一致性、增量数据返回,且去中心化架构的分布式Registry中间件corgi-proxy,当注册中心负载较高时,完全可以通过水平扩容来提升其整体的读/写性能。除此之外,大家还可以考虑使用Dubbo生态中的Nacos中间件,截至本书成稿之时,Nacos的当前版本(0.0.9)已经具备了小规模运用于线上环境的条件。最后,关于数据中心的迁移方案,大家可以采用业界通用的双注册中心方案,尽可能降低对原有架构的影响。
1.2.7 分布式多活架构下的服务就近调用方案
在1.1.9小节中,笔者对分布式多活架构的演变进行了详细的介绍,那么接下来请大家仔细思考下,当企业在实施多活建设后,服务之间是否应该继续保持安常习故的调用方式?理论上,尽管多数据中心之间通过内网专线通道可以将网络传输的延迟率控制在几十ms内,但在一些特殊情况下,比如大促带来的峰值流量极有可能瞬间就将延迟率放大数倍,导致大量的服务调用产生超时,因此企业在实施分布式多活架构后,服务调用应该优先在同机房内进行。笔者之前曾经提过,并非所有业务都能够实现分布式多活,只能够通过多种有效手段来保证绝大多数的核心业务实现多活架构,因此部分未实现多活的服务才需要进行跨机房调用。
实际上,这是一个优先级路由的问题,目前笔者所在企业采用的方案是扩展Dubbo的路由功能,加入路由分级策略实现服务的就近调用。简单来说,当Provider注册时,会将当前所在机房的标识信息一同写进注册中心;这样一来,当Consumer进行调用时,便有迹可循,通过检测机房的标识信息来判断路由走向。