first commit
This commit is contained in:
77
专栏/RPC实战与核心原理/00开篇词别老想着怎么用好RPC框架,你得多花时间琢磨原理.md
Normal file
77
专栏/RPC实战与核心原理/00开篇词别老想着怎么用好RPC框架,你得多花时间琢磨原理.md
Normal file
@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 别老想着怎么用好RPC框架,你得多花时间琢磨原理
|
||||
你好,我是何小锋。欢迎你和我一起学习RPC。
|
||||
|
||||
在专栏开始之前,我先简单介绍下自己。我是1998年从北航毕业的,毕业以后我就一直在一线编程写代码。2011年,我正式加入京东,刚好赶上了京东的快速发展期,一路做到了现在的技术架构部首席架构师。盘点下在京东的这9年时间,我参加过17次大促备战,和我的技术团队一起见证了京东的技术演进过程。我也曾带领团队攻克过很多技术领域难题,包括自主研发微服务框架、高性能消息中间件、智能监控以及容器平台等等。
|
||||
|
||||
近几年,我主攻分布式系统架构与设计,这也是我的专长所在。而在搭建分布式系统的过程中,我发现 RPC 总能充当较为关键的角色,它对整个分布式系统性能的提升起到了非常重要的作用。
|
||||
|
||||
我期待通过这个专栏,能把我这些年积攒的一些有关RPC的实战经验分享给你。
|
||||
|
||||
为什么要学习RPC?
|
||||
|
||||
做任何事情都应该 Start with Why,那我们就先来说说为什么要学习RPC。要回答这个问题,我们就得先考虑下RPC的实际应用场景。
|
||||
|
||||
说到RPC,可能你的第一反应就是“微服务”。RPC最大的特点就是可以让我们像调用本地一样发起远程调用,这一特点常常会让人感觉RPC就是为“微服务”或SOA而生的。现在的大多数应用系统发展到一定规模之后,都会向“微服务化”演进,演进后的大型应用系统也的确是由一个个“微服务”组成的。
|
||||
|
||||
我们可以说 RPC 是“微服务”的基础,这一点是毋庸置疑的。现在我们就可以反过来想这样一个问题——RPC是不是只应用在“微服务”中呢?
|
||||
|
||||
当然不是,只要涉及到网络通信,我们就可能用到RPC。一起看这样两个例子。
|
||||
|
||||
例1:大型分布式应用系统可能会依赖消息队列、分布式缓存、分布式数据库以及统一配置中心等,应用程序与依赖的这些中间件之间都可以通过RPC进行通信。比如 etcd,它作为一个统一的配置服务,客户端就是通过gRPC框架与服务端进行通信的。
|
||||
|
||||
例2:我们经常会谈到的容器编排引擎 Kubernetes,它本身就是分布式的,Kubernetes的 kube-apiserver 与整个分布式集群中的每个组件间的通讯,都是通过gRPC框架进行的。
|
||||
|
||||
所以说,RPC的应用场景还是非常广泛的。既然应用如此广泛,那它的核心价值又在哪里呢?
|
||||
|
||||
在我看来,RPC是解决分布式系统通信问题的一大利器。
|
||||
|
||||
分布式系统中的网络通信一般都会采用四层的TCP协议或七层的HTTP协议,在我的了解中,前者占大多数,这主要得益于TCP协议的稳定性和高效性。网络通信说起来简单,但实际上是一个非常复杂的过程,这个过程主要包括:对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等,每一项都很复杂。
|
||||
|
||||
你可以想象一下,在搭建一个复杂的分布式系统过程中,如果开发人员在编码时要对每个涉及到网络通信的逻辑都进行一系列的复杂编码,这将是件多么恐怖的事儿。所以说,网络通信是搭建分布式系统的一个大难题,是一点不为过的,我们必须给予足够的重视。
|
||||
|
||||
而RPC对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠。
|
||||
|
||||
现在你是不是感觉到学好RPC是很有必要的?
|
||||
|
||||
如何学习RPC?
|
||||
|
||||
那我们应该怎么去学习RPC呢?
|
||||
|
||||
其实,深刻了解了为什么之后,怎么学这个问题并不难找到答案。就我自己的经验来看,我觉得可以用“逐步深入”这四个字来概括我的学习方式。
|
||||
|
||||
说起来也特别简单。当我们认识到,使用RPC就可以像调用本地一样发起远程调用,用它可以解决通信问题,这时候我们肯定要去学序列化、编解码以及网络传输这些内容。
|
||||
|
||||
把这些内容掌握后,你就会发现,原来这些只是RPC的基础,RPC还有更吸引人的点,它真正强大的地方是它的治理功能,比如连接管理、健康检测、负载均衡、优雅启停机、异常重试、业务分组以及熔断限流等等。突然间,你会感觉自己走进了一个新世界,这些内容会成为你今后学习RPC的重点和难点。
|
||||
|
||||
这个逐步深入的过程,一定离不开真实的实践场景。学习知识,解决问题,遇到新问题,继续学习,不断解决问题,最后你会发现自己的学习曲线大概是这样的。
|
||||
|
||||
|
||||
|
||||
总结一下,学习RPC时,我们先要了解其基本原理以及关键的网络通信部分,不要一味依赖现成的框架;之后我们再学习RPC的重点和难点,了解RPC框架中的治理功能以及集群管理功能等;这个时候你已经很厉害了,但这还不是终点,我们要对RPC活学活用,学会提升RPC的性能以及它在分布式环境下如何定位问题等等。
|
||||
|
||||
整个专栏能让你学到什么?
|
||||
|
||||
上面提到的这些内容,就是我想通过这个专栏和你分享的。下面我来讲下本专栏的设计思路。
|
||||
|
||||
我把整个专栏的内容分为了三大部分,分别是基础篇、进阶篇和高级篇。
|
||||
|
||||
基础篇:重点讲解RPC的基础知识,包括RPC的基本原理以及它的基本功能模块,夯实基础之后,我们会以一场实战,通过剖析一款RPC框架来将知识点串联起来。
|
||||
|
||||
进阶篇:重点讲解RPC框架的架构设计,以及RPC框架集群、治理相关的知识。这部分我会列举很多我在运营RPC框架中遇到的实际问题,以及这些问题的解决方案。
|
||||
|
||||
高级篇:通过对上述两部分的学习,你已经对RPC有了较高层次的理解了。在这部分,我主要会从性能优化、线上问题排查以及一些比较有特色的功能设计上讲解RPC的应用。
|
||||
|
||||
|
||||
|
||||
整个专栏跟下来,虽然主要讲解的都是RPC相关的知识,但你会接触到很多的案例和解决方案,它们首先会使你对RPC的理解到达一个较高的层次;其次就是这些知识和解决方案会有相通性,只要你能举一反三,对你今后的工作就会有很大的帮助。
|
||||
|
||||
最后,我也很想听听你的想法。我们可以在留言区认识一下,期待你和我讲讲你的工作经历,你对RPC的认识,以及学习它的痛点、难点,我也好有针对性地为你讲解。现在,就让我们共同开启这段学习之旅吧!
|
||||
|
||||
|
||||
|
||||
|
107
专栏/RPC实战与核心原理/01核心原理:能否画张图解释下RPC的通信流程?.md
Normal file
107
专栏/RPC实战与核心原理/01核心原理:能否画张图解释下RPC的通信流程?.md
Normal file
@ -0,0 +1,107 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 核心原理:能否画张图解释下RPC的通信流程?
|
||||
你好,我是何小锋。只要你做过几年开发,那我相信RPC这个词你肯定是不陌生了。写专栏之前,我还特意查了下RPC的百度指数,发现这些年RPC的搜索趋势都是稳步上升的,这也侧面说明了这项技术正在逐步渗透到我们的日常开发中。作为专栏的第一讲,我想只围绕“RPC”这个词,和你聊聊它的定义,它要解决的问题,以及工作原理。
|
||||
|
||||
在前些年,我面试工程师的时候,最喜欢问候选人一个问题,“你能否给我解释下RPC的通信流程”。这问题其实并不难,不过因为很多工程师平时都在用各种框架,他们可能并未停下来思考过框架的原理,所以,问完这问题,有的人就犹豫了,吱唔了半天也没说出所以然来。
|
||||
|
||||
紧接着,我会引导他说,“你想想,如果没有RPC框架,那你要怎么调用另外一台服务器上的接口呢”。你看,这问题可深可浅,也特别考验候选人的基本功。如果你是候选人,你会怎么回答呢?今天我就来试着回答你这个问题。
|
||||
|
||||
什么是RPC?
|
||||
|
||||
我知道你肯定不喜欢听概念,我也是这样,看书的时候一看到概念就直接略过。不过,到后来,我才发现,“定义”是一件多么伟大的事情。当我们能够用一句话把一个东西给定义出来的时候,侧面也说明你已经彻底理解这事了,不仅知道它要解决什么问题,还要知道它的边界。所以,你可以先停下来想想,什么是RPC。
|
||||
|
||||
RPC的全称是Remote Procedure Call,即远程过程调用。简单解读字面上的意思,远程肯定是指要跨机器而非本机,所以需要用到网络编程才能实现,但是不是只要通过网络通信访问到另一台机器的应用程序,就可以称之为RPC调用了?显然并不够。
|
||||
|
||||
我理解的RPC是帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样的体验,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。
|
||||
|
||||
这就好比建在小河上的桥一样连接着河的两岸,如果没有小桥,我们需要通过划船、绕道等其他方式才能到达对面,但是有了小桥之后,我们就能像在路面上一样行走到达对面,并且跟在路面上行走的体验没有区别。所以我认为,RPC的作用就是体现在这样两个方面:
|
||||
|
||||
|
||||
屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;
|
||||
隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。
|
||||
|
||||
|
||||
RPC通信流程
|
||||
|
||||
理解了什么是RPC,接下来我们讲下RPC框架的通信流程,方便我们进一步理解RPC。
|
||||
|
||||
如前面所讲,RPC能帮助我们的应用透明地完成远程调用,发起调用请求的那一方叫做调用方,被调用的一方叫做服务提供方。为了实现这个目标,我们就需要在RPC框架里面对整个通信细节进行封装,那一个完整的RPC会涉及到哪些步骤呢?
|
||||
|
||||
我们已经知道RPC是一个远程调用,那肯定就需要通过网络来传输数据,并且RPC常用于业务系统之间的数据交互,需要保证其可靠性,所以RPC一般默认采用TCP来传输。我们常用的HTTP协议也是建立在TCP之上的。
|
||||
|
||||
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。
|
||||
|
||||
调用方持续地把请求参数序列化成二进制后,经过TCP传输给了服务提供方。服务提供方从TCP通道里面收到二进制数据,那如何知道一个请求的数据到哪里结束,是一个什么类型的请求呢?
|
||||
|
||||
在这里我们可以想想高速公路,它上面有很多出口,为了让司机清楚地知道从哪里出去,管理部门会在路上建立很多指示牌,并在指示牌上标明下一个出口是哪里、还有多远。那回到数据包识别这个场景,我们是不是也可以建立一些“指示牌”,并在上面标明数据包的类型和长度,这样就可以正确的解析数据了。确实可以,并且我们把数据格式的约定内容叫做“协议”。大多数的协议会分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。
|
||||
|
||||
根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。
|
||||
|
||||
服务提供方再根据反序列化出来的请求对象找到对应的实现类,完成真正的方法调用,然后把执行结果序列化后,回写到对应的TCP通道里面。调用方获取到应答的数据包后,再反序列化成应答对象,这样调用方就完成了一次RPC调用。
|
||||
|
||||
那上述几个流程就组成了一个完整的RPC吗?
|
||||
|
||||
在我看来,还缺点东西。因为对于研发人员来说,这样做要掌握太多的RPC底层细节,需要手动写代码去构造请求、调用序列化,并进行网络调用,整个API非常不友好。
|
||||
|
||||
那我们有什么办法来简化API,屏蔽掉RPC细节,让使用方只需要关注业务接口,像调用本地一样来调用远程呢?
|
||||
|
||||
如果你了解Spring,一定对其AOP技术很佩服,其核心是采用动态代理的技术,通过字节码增强对方法进行拦截增强,以便于增加需要的额外处理逻辑。其实这个技术也可以应用到RPC场景来解决我们刚才面临的问题。
|
||||
|
||||
由服务提供者给出业务接口声明,在调用方的程序里面,RPC框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。
|
||||
|
||||
到这里,一个简单版本的RPC框架就实现了。我把整个流程都画出来了,供你参考:
|
||||
|
||||
|
||||
|
||||
RPC在架构中的位置
|
||||
|
||||
围绕RPC我们讲了这么多,那RPC在架构中究竟处于什么位置呢?
|
||||
|
||||
如刚才所讲,RPC是解决应用间通信的一种方式,而无论是在一个大型的分布式应用系统还是中小型系统中,应用架构最终都会从“单体”演进成“微服务化”,整个应用系统会被拆分为多个不同功能的应用,并将它们部署在不同的服务器中,而应用之间会通过RPC进行通信,可以说RPC对应的是整个分布式应用系统,就像是“经络”一样的存在。
|
||||
|
||||
那么如果没有RPC,我们现实中的开发过程是怎样的一个体验呢?
|
||||
|
||||
所有的功能代码都会被我们堆砌在一个大项目中,开发过程中你可能要改一行代码,但改完后编译会花掉你2分钟,编译完想运行起来验证下结果可能要5分钟,是不是很酸爽?更难受的是在人数比较多的团队里面,多人协同开发的时候,如果团队其他人把接口定义改了,你连编译通过的机会都没有,系统直接报错,从而导致整个团队的开发效率都会非常低下。而且当我们准备要上线发版本的时候,QA也很难评估这次的测试范围,为了保险起见我们只能把所有的功能进行回归测试,这样会导致我们上线新功能的整体周期都特别长。
|
||||
|
||||
无论你是研发还是架构师,我相信这种系统架构我们肯定都不能接受,那怎么才能解决这个问题呢?
|
||||
|
||||
我们首先都会想到可以采用“分而治之”的思想来进行拆分,但是拆分完的系统怎么保持跟未拆分前的调用方式一样呢?我们总不能因为架构升级,就把所有的代码都推倒重写一遍吧。
|
||||
|
||||
RPC框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远程方法。利用RPC我们不仅可以很方便地将应用架构从“单体”演进成“微服务化”,而且还能解决实际开发过程中的效率低下、系统耦合等问题,这样可以使得我们的系统架构整体清晰、健壮,应用可运维度增强。
|
||||
|
||||
当然RPC不仅可以用来解决通信问题,它还被用在了很多其他场景,比如:发MQ、分布式缓存、数据库等。下图是我之前开发的一个应用架构图:
|
||||
|
||||
|
||||
|
||||
在这个应用中,我使用了MQ来处理异步流程、Redis缓存热点数据、MySQL持久化数据,还有就是在系统中调用另外一个业务系统的接口,对我的应用来说这些都是属于RPC调用,而MQ、MySQL持久化的数据也会存在于一个分布式文件系统中,他们之间的调用也是需要用RPC来完成数据交互的。
|
||||
|
||||
由此可见,RPC确实是我们日常开发中经常接触的东西,只是被包装成了各种框架,导致我们很少意识到这就是RPC,让RPC变成了我们最“熟悉的陌生人”。现在,回过头想想,我说RPC是整个应用系统的“经络”,这不为过吧?我们真的很有必要学好RPC,不仅因为RPC是构建复杂系统的基石,还是提升自身认知的利器。
|
||||
|
||||
总结
|
||||
|
||||
本讲我主要讲了下RPC的原理,RPC就是提供一种透明调用机制,让使用者不必显式地区分本地调用和远程调用。RPC虽然可以帮助开发者屏蔽远程调用跟本地调用的区别,但毕竟涉及到远程网络通信,所以这里还是有很多使用上的区别,比如:
|
||||
|
||||
|
||||
调用过程中超时了怎么处理业务?
|
||||
什么场景下最适合使用RPC?
|
||||
什么时候才需要考虑开启压缩?
|
||||
|
||||
|
||||
无论你是一个初级开发者还是高级开发者,RPC都应该是你日常开发过程中绕不开的一个话题,所以作为软件开发者的我们,真的很有必要详细地了解RPC实现细节。只有这样,才能帮助我们更好地在日常工作中使用RPC。
|
||||
|
||||
课后思考
|
||||
|
||||
|
||||
你应用中有哪些地方用到了RPC?
|
||||
你认为,RPC使用过程中需要注意哪些问题?
|
||||
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
85
专栏/RPC实战与核心原理/02协议:怎么设计可扩展且向后兼容的协议?.md
Normal file
85
专栏/RPC实战与核心原理/02协议:怎么设计可扩展且向后兼容的协议?.md
Normal file
@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 协议:怎么设计可扩展且向后兼容的协议?
|
||||
你好,我是何小锋。上一讲我分享了RPC原理,其核心是让我们像调用本地一样调用远程,帮助我们的应用层屏蔽远程调用的复杂性,使得我们可以更加方便地构建分布式系统。总结起来,其实就一个关键字:透明化。
|
||||
|
||||
接着上一讲的内容,我们再来聊聊RPC协议。
|
||||
|
||||
一提到协议,你最先想到的可能是 TCP协议、UDP协议等等,这些网络传输协议的实现在我看来有点晦涩难懂。虽然在RPC中我们也会用到这些协议,但这些协议更多的是对我们上层应用是透明的,我们RPC在使用过程中并不太需要关注他们的细节。那我今天要讲的RPC协议到底是什么呢?
|
||||
|
||||
可能我举个例子,你立马就明白了。HTTP协议是不是很熟悉(本讲里面所说的HTTP默认都是1.X)? 这应该是我们日常工作中用得最频繁的协议了,每天打开浏览器浏览的网页就是使用的HTTP协议。那HTTP协议跟RPC协议又有什么关系呢?看起来他俩好像不搭边,但他们有一个共性就是都属于应用层协议。
|
||||
|
||||
所以我们今天要讲的RPC协议就是围绕应用层协议展开的。我们可以先了解下HTTP协议,我们先看看它的协议格式是什么样子的。回想一下我们在浏览器里面输入一个URL会发生什么?抛开DNS解析暂且不谈,浏览器收到命令后会封装一个请求,并把请求发送到DNS解析出来的IP上,通过抓包工具我们可以抓到请求的数据包,如下图所示:
|
||||
|
||||
|
||||
|
||||
协议的作用
|
||||
|
||||
看完HTTP协议之后,你可能会有一个疑问,我们为什么需要协议这个东西呢?没有协议就不能通信吗?
|
||||
|
||||
我们知道只有二进制才能在网络中传输,所以RPC请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地Socket中,然后被网卡发送到网络设备中。
|
||||
|
||||
但在传输过程中,RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个TCP连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和TCP窗口大小。对于服务提供方应用来说,他会从TCP通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?
|
||||
|
||||
这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。
|
||||
|
||||
同理在RPC传输数据的时候,为了能准确地“断句”,我们也必须在应用发送请求的数据包里面加入“句号”,这样才能帮我们的接收方应用从数据流里面分割出正确的数据。这个数据包里面的句号就是消息的边界,用于标示请求数据的结束位置。举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到ABCDEF或者ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。
|
||||
|
||||
所以呢,为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。
|
||||
|
||||
如何设计协议?
|
||||
|
||||
理解了协议的作用,我们再来看看在RPC里面是怎么设计协议的。可能你会问:“前面你不是说了HTTP协议跟RPC都属于应用层协议,那有了现成的HTTP协议,为啥不直接用,还要为RPC设计私有协议呢?”
|
||||
|
||||
这还要从RPC的作用说起,相对于HTTP的用处,RPC更多的是负责应用间的通信,所以性能要求相对更高。但HTTP协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;还有一个更重要的原因是,HTTP协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的RPC来说,HTTP协议基本很难满足需求,所以RPC会选择设计更紧凑的私有协议。
|
||||
|
||||
那怎么设计一个私有RPC协议呢?
|
||||
|
||||
在设计协议前,我们先梳理下要完成RPC通信的时候,在协议里面需要放哪些内容。
|
||||
|
||||
首先要想到的就是我们前面说的消息边界了,但RPC每次发请求发的大小都是不固定的,所以我们的协议必须能让接收方正确地读出不定长的内容。我们可以先固定一个长度(比如4个字节)用来保存整个请求数据大小,这样收到数据的时候,我们先读取固定长度的位置里面的值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议可以设计成这样:
|
||||
|
||||
|
||||
|
||||
但上面这种协议,只实现了正确的断句效果,在RPC里面还行不通。因为对于服务提供方来说,他是不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数我们可以统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。
|
||||
|
||||
在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标示、消息ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。这样一个完整的RPC协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示:
|
||||
|
||||
|
||||
|
||||
可扩展的协议
|
||||
|
||||
刚才讲的协议属于定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参数就会导致线上兼容问题。举个具体例子,假设你设计了一个88Bit的协议头,其中协议长度占用32bit,然后你为了加入新功能,在协议头里面加了2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照88bit读取协议头,新加的2个bit会当作协议体前2个bit数据读出来,但原本的协议体最后2个bit会被丢弃了,这样就会导致协议体的数据是错的。
|
||||
|
||||
可能你会想:“那我把参数加在不定长的协议体里面行不行?而且刚才你也说了,协议体里面会放一些扩展属性。”
|
||||
|
||||
没错,协议体里面是可以加新的参数,但这里有一个关键点,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。但在某些场景下,这样做的代价有点高啊!
|
||||
|
||||
比如说,服务提供方收到一个过期请求,这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间,既然已经过期,就没有必要接着处理,直接返回一个超时就好了。那要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间参数的话,我们现在把这个超时时间加到协议体里面是不是就有点重了呢?显然,会加重CPU的消耗。
|
||||
|
||||
所以为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分我们还是可以统称为“协议头”,具体协议如下:
|
||||
|
||||
|
||||
|
||||
最后,我想说,设计一个简单的RPC协议并不难,难的就是怎么去设计一个可“升级”的协议。不仅要让我们在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以我们协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。上述这种设计方法来源于我多年的线上经验,可以说做好扩展性是至关重要的,期待这个协议模版能帮你避掉一些坑。
|
||||
|
||||
总结
|
||||
|
||||
我们人类区别于其他动物的一个很大原因,就是我们能够通过语言去沟通,用文字去沉淀文明,从而让我们能站在巨人的肩膀上成长,但为了保证我们记录的文字能够被其他人理解,我们必须通过符号去实现断句,否则就可能导致文字的意义被曲解,甚至闹出笑话。
|
||||
|
||||
在RPC里面,协议的作用就类似于文字中的符号,作为应用拆解请求消息的边界,保证二进制数据经过网络传输后,还能被正确地还原语义,避免调用方跟被调用方之间的“鸡同鸭讲”。
|
||||
|
||||
但我们在设计协议的时候,也不能只单纯考虑满足目前功能,还应该从更高的层次出发。就好比我们设计系统架构一样,我们需要保证设计出来的系统能够能很好地扩展,支持新增功能。
|
||||
|
||||
课后思考
|
||||
|
||||
好了,今天的内容就到这里,最后留一道思考题。今天我们讨论过RPC不直接用HTTP协议的一个原因是无法实现请求跟响应关联,每次请求都需要重新建立连接,响应完成后再关闭连接,所以我们要设计私有协议。那么在RPC里面,我们是怎么实现请求跟响应关联的呢?
|
||||
|
||||
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
285
专栏/RPC实战与核心原理/03序列化:对象怎么在网络中传输?.md
Normal file
285
专栏/RPC实战与核心原理/03序列化:对象怎么在网络中传输?.md
Normal file
@ -0,0 +1,285 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 序列化:对象怎么在网络中传输?
|
||||
你好,我是何小锋。上一讲我讲解了在RPC框架中,如何设计可扩展的、向后兼容的协议,其关键点就是利用好Header中的扩展字段以及Payload中的扩展字段,通过扩展字段向后兼容。
|
||||
|
||||
那么承接上一讲的一个重点,今天我会讲解下RPC框架中的序列化。要知道,在不同的场景下合理地选择序列化方式,对提升RPC框架整体的稳定性和性能是至关重要的。
|
||||
|
||||
为什么需要序列化?
|
||||
|
||||
首先,我们得知道什么是序列化与反序列化。
|
||||
|
||||
我们先回顾下[第 01 讲] 介绍过的RPC原理的内容,在描述RPC通信流程的时候我说过:
|
||||
|
||||
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。 这时,服务提供方就可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程我们称之为“反序列化”。
|
||||
|
||||
这两个过程如下图所示:
|
||||
|
||||
|
||||
|
||||
总结来说,序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。
|
||||
|
||||
那么RPC框架为什么需要序列化呢?还是请你回想下RPC的通信流程:
|
||||
|
||||
|
||||
|
||||
不妨借用个例子帮助你理解,比如发快递,我们要发一个需要自行组装的物件。发件人发之前,会把物件拆开装箱,这就好比序列化;这时候快递员来了,不能磕碰呀,那就要打包,这就好比将序列化后的数据进行编码,封装成一个固定格式的协议;过了两天,收件人收到包裹了,就会拆箱将物件拼接好,这就好比是协议解码和反序列化。
|
||||
|
||||
所以现在你清楚了吗?因为网络传输的数据必须是二进制数据,所以在RPC调用中,对入参对象与返回值对象进行序列化与反序列化是一个必须的过程。
|
||||
|
||||
有哪些常用的序列化?
|
||||
|
||||
那这么看来,你会不会觉得这个过程很简单呢?实则不然,很复杂。我们可以先看看都有哪些常用的序列化,下面我来简单地介绍下几种常用的序列化方式。
|
||||
|
||||
JDK原生序列化
|
||||
|
||||
如果你会使用Java语言开发,那么你一定知道JDK原生的序列化,下面是JDK序列化的一个例子:
|
||||
|
||||
import java.io.*;
|
||||
|
||||
public class Student implements Serializable {
|
||||
//学号
|
||||
private int no;
|
||||
//姓名
|
||||
private String name;
|
||||
|
||||
public int getNo() {
|
||||
return no;
|
||||
}
|
||||
|
||||
public void setNo(int no) {
|
||||
this.no = no;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Student{" +
|
||||
"no=" + no +
|
||||
", name='" + name + '\'' +
|
||||
'}';
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException, ClassNotFoundException {
|
||||
String home = System.getProperty("user.home");
|
||||
String basePath = home + "/Desktop";
|
||||
FileOutputStream fos = new FileOutputStream(basePath + "student.dat");
|
||||
Student student = new Student();
|
||||
student.setNo(100);
|
||||
student.setName("TEST_STUDENT");
|
||||
ObjectOutputStream oos = new ObjectOutputStream(fos);
|
||||
oos.writeObject(student);
|
||||
oos.flush();
|
||||
oos.close();
|
||||
|
||||
FileInputStream fis = new FileInputStream(basePath + "student.dat");
|
||||
ObjectInputStream ois = new ObjectInputStream(fis);
|
||||
Student deStudent = (Student) ois.readObject();
|
||||
ois.close();
|
||||
|
||||
System.out.println(deStudent);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,JDK自带的序列化机制对使用者而言是非常简单的。序列化具体的实现是由ObjectOutputStream完成的,而反序列化的具体实现是由ObjectInputStream完成的。
|
||||
|
||||
那么JDK的序列化过程是怎样完成的呢?我们看下下面这张图:
|
||||
|
||||
|
||||
|
||||
序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。
|
||||
|
||||
|
||||
头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
|
||||
对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
|
||||
存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑
|
||||
|
||||
|
||||
实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。
|
||||
|
||||
JSON
|
||||
|
||||
JSON可能是我们最熟悉的一种序列化格式了,JSON是典型的Key-Value方式,没有数据类型,是一种文本型序列化框架,JSON的具体格式和特性,网上相关的资料非常多,这里就不再介绍了。
|
||||
|
||||
他在应用上还是很广泛的,无论是前台Web用Ajax调用、用磁盘存储文本类型的数据,还是基于HTTP协议的RPC框架通信,都会选择JSON格式。
|
||||
|
||||
但用JSON进行序列化有这样两个问题,你需要格外注意:
|
||||
|
||||
|
||||
JSON进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;
|
||||
JSON没有类型,但像Java这种强类型语言,需要通过反射统一解决,所以性能不会太好。
|
||||
|
||||
|
||||
所以如果RPC框架选用JSON序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。
|
||||
|
||||
Hessian
|
||||
|
||||
Hessian是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian协议要比JDK、JSON更加紧凑,性能上要比JDK、JSON序列化高效很多,而且生成的字节数也更小。
|
||||
|
||||
使用代码示例如下:
|
||||
|
||||
Student student = new Student();
|
||||
student.setNo(101);
|
||||
student.setName("HESSIAN");
|
||||
|
||||
//把student对象转化为byte数组
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
Hessian2Output output = new Hessian2Output(bos);
|
||||
output.writeObject(student);
|
||||
output.flushBuffer();
|
||||
byte[] data = bos.toByteArray();
|
||||
bos.close();
|
||||
|
||||
//把刚才序列化出来的byte数组转化为student对象
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data);
|
||||
Hessian2Input input = new Hessian2Input(bis);
|
||||
Student deStudent = (Student) input.readObject();
|
||||
input.close();
|
||||
|
||||
System.out.println(deStudent);
|
||||
|
||||
|
||||
相对于JDK、JSON,由于Hessian更加高效,生成的字节数更小,有非常好的兼容性和稳定性,所以Hessian更加适合作为RPC框架远程通信的序列化协议。
|
||||
|
||||
但Hessian本身也有问题,官方版本对Java里面一些常见对象的类型不支持,比如:
|
||||
|
||||
|
||||
Linked系列,LinkedHashMap、LinkedHashSet等,但是可以通过扩展CollectionDeserializer类修复;
|
||||
Locale类,可以通过扩展ContextSerializerFactory类修复;
|
||||
Byte/Short反序列化的时候变成Integer。
|
||||
|
||||
|
||||
以上这些情况,你在实践时需要格外注意。
|
||||
|
||||
Protobuf
|
||||
|
||||
Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持Java、Python、C++、Go等语言。Protobuf使用的时候需要定义IDL(Interface description language),然后使用不同语言的IDL编译器,生成序列化工具类,它的优点是:
|
||||
|
||||
|
||||
序列化后体积相比 JSON、Hessian小很多;
|
||||
IDL能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;
|
||||
序列化反序列化速度很快,不需要通过反射获取类型;
|
||||
消息格式升级和兼容性不错,可以做到向后兼容。
|
||||
|
||||
|
||||
使用代码示例如下:
|
||||
|
||||
/**
|
||||
*
|
||||
* // IDl 文件格式
|
||||
* synax = "proto3";
|
||||
* option java_package = "com.test";
|
||||
* option java_outer_classname = "StudentProtobuf";
|
||||
*
|
||||
* message StudentMsg {
|
||||
* //序号
|
||||
* int32 no = 1;
|
||||
* //姓名
|
||||
* string name = 2;
|
||||
* }
|
||||
*
|
||||
*/
|
||||
|
||||
StudentProtobuf.StudentMsg.Builder builder = StudentProtobuf.StudentMsg.newBuilder();
|
||||
builder.setNo(103);
|
||||
builder.setName("protobuf");
|
||||
|
||||
//把student对象转化为byte数组
|
||||
StudentProtobuf.StudentMsg msg = builder.build();
|
||||
byte[] data = msg.toByteArray();
|
||||
|
||||
//把刚才序列化出来的byte数组转化为student对象
|
||||
StudentProtobuf.StudentMsg deStudent = StudentProtobuf.StudentMsg.parseFrom(data);
|
||||
|
||||
System.out.println(deStudent);
|
||||
|
||||
|
||||
Protobuf 非常高效,但是对于具有反射和动态能力的语言来说,这样用起来很费劲,这一点就不如Hessian,比如用Java的话,这个预编译过程不是必须的,可以考虑使用Protostuff。
|
||||
|
||||
Protostuff不需要依赖IDL文件,可以直接对Java领域对象进行反/序列化操作,在效率上跟Protobuf差不多,生成的二进制格式和Protobuf是完全相同的,可以说是一个Java版本的Protobuf序列化框架。但在使用过程中,我遇到过一些不支持的情况,也同步给你:
|
||||
|
||||
|
||||
不支持null;
|
||||
ProtoStuff不支持单纯的Map、List集合对象,需要包在对象里面。
|
||||
|
||||
|
||||
RPC框架中如何选择序列化?
|
||||
|
||||
我刚刚简单地介绍了几种最常见的序列化协议,其实远不止这几种,还有 Message pack、kryo等。那么面对这么多的序列化协议,在RPC框架中我们该如何选择呢?
|
||||
|
||||
首先你可能想到的是性能和效率,不错,这的确是一个非常值得参考的因素。我刚才讲过,序列化与反序列化过程是RPC调用的一个必须过程,那么序列化与反序列化的性能和效率势必将直接关系到RPC框架整体的性能和效率。
|
||||
|
||||
那除了这点,你还想到了什么?
|
||||
|
||||
对,还有空间开销,也就是序列化之后的二进制数据的体积大小。序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,由于RPC是远程调用,那么网络传输的速度将直接关系到请求响应的耗时。
|
||||
|
||||
现在请你再想想,还有什么因素可以影响到我们的选择?
|
||||
|
||||
没错,就是序列化协议的通用性和兼容性。在RPC的运营中,序列化问题恐怕是我碰到的和解答过的最多的问题了,经常有业务会向我反馈这个问题,比如某个类型为集合类的入参服务调用者不能解析了,服务提供方将入参类加一个属性之后服务调用方不能正常调用,升级了RPC版本后发起调用时报序列化异常了…
|
||||
|
||||
在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。我们更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次我们才会去考虑性能、效率和空间开销。
|
||||
|
||||
还有一点我要特别强调。除了序列化协议的通用性和兼容性,序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。以JDK原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵。
|
||||
|
||||
|
||||
|
||||
综合上面几个参考因素,现在我们再来总结一下这几个序列化协议。
|
||||
|
||||
我们首选的还是Hessian与Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中Hessian在使用上更加方便,在对象的兼容性上更好;Protobuf则更加高效,通用性上更有优势。
|
||||
|
||||
RPC框架在使用时要注意哪些问题?
|
||||
|
||||
了解了在RPC框架中如何选择序列化,那么我们在使用过程中需要注意哪些序列化上的问题呢?
|
||||
|
||||
我刚才讲过,在RPC的运营中,我遇到的最多的问题就是序列化问题了,除了早期RPC框架本身出现的问题以外,大多数问题都是使用方使用不正确导致的,接下来我们就盘点下这些高频出现的人为问题。
|
||||
|
||||
对象构造得过于复杂:属性很多,并且存在多层的嵌套,比如A对象关联B对象,B对象又聚合C对象,C对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗CPU,这会严重影响RPC框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
|
||||
|
||||
对象过于庞大:我经常遇到业务过来咨询,为啥他们的RPC请求经常超时,排查后发现他们的入参对象非常得大,比如为一个大List或者大Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
|
||||
|
||||
使用序列化框架不支持的类作为入参类:比如Hessian框架,他天然是不支持LinkedHashMap、LinkedHashSet等,而且大多数情况下最好不要使用第三方集合类,如Guava中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如HashMap、ArrayList。
|
||||
|
||||
对象有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题1一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。
|
||||
|
||||
在RPC框架的使用过程中,我们要尽量构建简单的对象作为入参和返回值对象,避免上述问题。
|
||||
|
||||
总结
|
||||
|
||||
今天我们深入学习了什么是序列化,并介绍了如JDK原生序列化、JSON、Hessian以及Protobuf等几种常见的序列化方式。
|
||||
|
||||
除了这些基础知识之外,我们重点讲解了在RPC框架中如何去选择序列化协议,我们有这样几个很重要的参考因素,优先级从高到低依次是安全性、通用性和兼容性,之后我们会再考虑序列化框架的性能、效率和空间开销。
|
||||
|
||||
这归根结底还是因为服务调用的稳定性与可靠性,要比服务的性能与响应耗时更加重要。另外对于RPC调用来说,整体调用上,最为耗时、最消耗性能的操作大多都是服务提供者执行业务逻辑的操作,这时序列化的开销对于服务整体的开销来说影响相对较小。
|
||||
|
||||
在使用RPC框架的过程中,我们构造入参、返回值对象,主要记住以下几点:
|
||||
|
||||
|
||||
对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
|
||||
入参对象与返回值对象体积不要太大,更不要传太大的集合;
|
||||
尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
|
||||
对象不要有复杂的继承关系,最好不要有父子类的情况。
|
||||
|
||||
|
||||
实际上,虽然RPC框架可以让我们发起远程调用就像调用本地一样,但在RPC框架的传输过程中,入参与返回值的根本作用就是用来传递信息的,为了提高RPC调用整体的性能和稳定性,我们的入参与返回值对象要构造得尽量简单,这很重要。
|
||||
|
||||
课后思考
|
||||
|
||||
RPC框架在序列化框架的选型上,你认为还需要考虑哪些因素?你还知道哪些优秀的序列化框架,它们又是否适合在RPC调用中使用?
|
||||
|
||||
欢迎留言和我分享你的答案和经验,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
139
专栏/RPC实战与核心原理/04网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?.md
Normal file
139
专栏/RPC实战与核心原理/04网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?.md
Normal file
@ -0,0 +1,139 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?
|
||||
你好,我是何小锋。在上一讲我讲解了RPC框架中的序列化,通过上一讲,我们知道由于网络传输的数据都是二进制数据,所以我们要传递对象,就必须将对象进行序列化,而RPC框架在序列化的选择上,我们更关注序列化协议的安全性、通用性、兼容性,其次才关注序列化协议的性能、效率、空间开销。承接上一讲,这一讲,我要专门讲解下RPC框架中的网络通信,这也是我们在开篇词中就强调过的重要内容。
|
||||
|
||||
那么网络通信在RPC调用中起到什么作用呢?
|
||||
|
||||
我在[第 01 讲] 中讲过,RPC是解决进程间通信的一种方式。一次RPC调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络IO发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次RPC调用便结束了。可以说,网络通信是整个RPC调用流程的基础。
|
||||
|
||||
常见的网络IO模型
|
||||
|
||||
那说到网络通信,就不得不提一下网络IO模型。为什么要讲网络IO模型呢?因为所谓的两台PC机之间的网络通信,实际上就是两台PC机对网络IO的操作。
|
||||
|
||||
常见的网络IO模型分为四种:同步阻塞IO(BIO)、同步非阻塞IO(NIO)、IO多路复用和异步非阻塞IO(AIO)。在这四种IO模型中,只有AIO为异步IO,其他都是同步IO。
|
||||
|
||||
其中,最常用的就是同步阻塞IO和IO多路复用,这一点通过了解它们的机制,你会get到。至于其他两种IO模型,因为不常用,则不作为本讲的重点,有兴趣的话我们可以在留言区中讨论。
|
||||
|
||||
阻塞IO(blocking IO)
|
||||
|
||||
同步阻塞IO是最简单、最常见的IO模型,在Linux中,默认情况下所有的socket都是blocking的,先看下操作流程。
|
||||
|
||||
首先,应用进程发起IO系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个IO处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。
|
||||
|
||||
这里我们可以看到,系统内核处理IO操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中IO操作的线程会一直都处于阻塞状态,如果是基于Java多线程开发,那么每一个IO操作都要占用线程,直至IO操作结束。
|
||||
|
||||
这个流程就好比我们去餐厅吃饭,我们到达餐厅,向服务员点餐,之后要一直在餐厅等待后厨将菜做好,然后服务员会将菜端给我们,我们才能享用。
|
||||
|
||||
IO多路复用(IO multiplexing)
|
||||
|
||||
多路复用IO是在高并发场景中使用最为广泛的一种IO模型,如Java的NIO、Redis、Nginx的底层实现就是此类IO模型的应用,经典的Reactor模式也是基于此类IO模型。
|
||||
|
||||
那么什么是IO多路复用呢?通过字面上的理解,多路就是指多个通道,也就是多个网络连接的IO,而复用就是指多个通道复用在一个复用器上。
|
||||
|
||||
多个网络连接的IO可以注册到一个复用器(select)上,当用户进程调用了select,那么整个进程会被阻塞。同时,内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核中拷贝到用户进程。
|
||||
|
||||
这里我们可以看到,当用户进程发起了select调用,进程会被阻塞,当发现该select负责的socket有准备好的数据时才返回,之后才发起一次read,整个流程要比阻塞IO要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
|
||||
|
||||
同样好比我们去餐厅吃饭,这次我们是几个人一起去的,我们专门留了一个人在餐厅排号等位,其他人就去逛街了,等排号的朋友通知我们可以吃饭了,我们就直接去享用了。
|
||||
|
||||
为什么说阻塞IO和IO多路复用最为常用?
|
||||
|
||||
了解完二者的机制,我们就可以回到起初的问题了——我为什么说阻塞IO和IO多路复用最为常用。对比这四种网络IO模型:阻塞IO、非阻塞IO、IO多路复用、异步IO。实际在网络IO的应用上,需要的是系统内核的支持以及编程语言的支持。
|
||||
|
||||
在系统内核的支持上,现在大多数系统内核都会支持阻塞IO、非阻塞IO和IO多路复用,但像信号驱动IO、异步IO,只有高版本的Linux系统内核才会支持。
|
||||
|
||||
在编程语言上,无论C++还是Java,在高性能的网络编程框架的编写上,大多数都是基于Reactor模式,其中最为典型的便是Java的Netty框架,而Reactor模式是基于IO多路复用的。当然,在非高并发场景下,同步阻塞IO是最为常见的。
|
||||
|
||||
综合来讲,在这四种常用的IO模型中,应用最多的、系统内核与编程语言支持最为完善的,便是阻塞IO和IO多路复用。这两种IO模型,已经可以满足绝大多数网络IO的应用场景。
|
||||
|
||||
RPC框架在网络通信上倾向选择哪种网络IO模型?
|
||||
|
||||
讲完了这两种最常用的网络IO模型,我们可以看看它们都适合什么样的场景。
|
||||
|
||||
IO多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的socket的IO请求,但使用难度比较高。当然高级的编程语言支持得还是比较好的,比如Java语言有很多的开源框架对Java原生API做了封装,如Netty框架,使用非常简便;而GO语言,语言本身对IO多路复用的封装就已经很简洁了。
|
||||
|
||||
而阻塞IO与IO多路复用相比,阻塞IO每处理一个socket的IO请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行IO操作的场景下,阻塞IO已经满足了需求,并且不需要发起select调用,开销上还要比IO多路复用低。
|
||||
|
||||
RPC调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及IO模型本身的特点,在RPC框架的实现中,在网络通信的处理上,我们会选择IO多路复用的方式。开发语言的网络通信框架的选型上,我们最优的选择是基于Reactor模式实现的框架,如Java语言,首选的框架便是Netty框架(Java还有很多其他NIO框架,但目前Netty应用得最为广泛),并且在Linux环境下,也要开启epoll来提升系统性能(Windows环境下是无法开启epoll的,因为系统内核不支持)。
|
||||
|
||||
了解完以上内容,我们可以继续看这样一个关键问题——零拷贝。在我们应用的过程中,他是非常重要的。
|
||||
|
||||
什么是零拷贝?
|
||||
|
||||
刚才讲阻塞IO的时候我讲到,系统内核处理IO操作分为两个阶段——等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。以下是具体流程:
|
||||
|
||||
|
||||
|
||||
应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由CPU将数据拷贝到系统内核的缓冲区中,之后再由DMA将这份数据拷贝到网卡中,最后由网卡发送出去。这里我们可以看到,一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据。
|
||||
|
||||
应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要CPU进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样是不是很浪费CPU和性能呢?那有没有什么方式,可以减少进程间的数据拷贝,提高数据传输的效率呢?
|
||||
|
||||
这时我们就需要零拷贝(Zero-copy)技术。
|
||||
|
||||
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过DMA将内核中的数据拷贝到网卡,或将网卡中的数据copy到内核。
|
||||
|
||||
那怎么做到零拷贝?你想一下是不是用户空间与内核空间都将数据写到一个地方,就不需要拷贝了?此时你有没有想到虚拟内存?
|
||||
|
||||
|
||||
|
||||
零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,mmap+write方式的核心原理就是通过虚拟内存来解决的。这两种实现方式都不难,市面上可查阅的资料也很多,在此就不详述了,有问题,可以在留言区中解决。
|
||||
|
||||
Netty中的零拷贝
|
||||
|
||||
了解完零拷贝,我们再看看Netty中的零拷贝。
|
||||
|
||||
我刚才讲到,RPC框架在网络通信框架的选型上,我们最优的选择是基于Reactor模式实现的框架,如Java语言,首选的便是Netty框架。那么Netty框架是否也有零拷贝机制呢?Netty框架中的零拷贝和我之前讲的零拷贝又有什么不同呢?
|
||||
|
||||
刚才我讲的零拷贝是操作系统层面上的零拷贝,主要目标是避免用户空间与内核空间之间的数据拷贝操作,可以提升CPU的利用率。
|
||||
|
||||
而Netty的零拷贝则不大一样,他完全站在了用户空间上,也就是JVM上,它的零拷贝主要是偏向于数据操作的优化上。
|
||||
|
||||
那么Netty这么做的意义是什么呢?
|
||||
|
||||
回想下[第 02 讲],在这一讲中我讲解了RPC框架如何去设计协议,其中我讲到:在传输过程中,RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包,所以消息都需要有边界。那么一端的机器收到消息之后,就需要对数据包进行处理,根据边界对数据包进行分割和合并,最终获得一条完整的消息。
|
||||
|
||||
那收到消息后,对数据包的分割和合并,是在用户空间完成,还是在内核空间完成的呢?
|
||||
|
||||
当然是在用户空间,因为对数据包的处理工作都是由应用程序来处理的,那么这里有没有可能存在数据的拷贝操作?可能会存在,当然不是在用户空间与内核空间之间的拷贝,是用户空间内部内存中的拷贝处理操作。Netty的零拷贝就是为了解决这个问题,在用户空间对数据操作进行优化。
|
||||
|
||||
那么Netty是怎么对数据操作进行优化的呢?
|
||||
|
||||
|
||||
Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。
|
||||
ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。
|
||||
通过 wrap 操作,我们可以将 byte[] 数组、ByteBuf、ByteBuffer 等包装成一个 Netty ByteBuf 对象, 进而避免拷贝操作。
|
||||
|
||||
|
||||
Netty框架中很多内部的ChannelHandler实现类,都是通过CompositeByteBuf、slice、wrap操作来处理TCP传输中的拆包与粘包问题的。
|
||||
|
||||
那么Netty有没有解决用户空间与内核空间之间的数据拷贝问题的方法呢?
|
||||
|
||||
Netty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接内存进行Socket的读写操作,最终的效果与我刚才讲解的虚拟内存所实现的效果是一样的。
|
||||
|
||||
Netty 还提供 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法实现了零拷贝,这与Linux 中的 sendfile 方式在原理上也是一样的。
|
||||
|
||||
总结
|
||||
|
||||
今天我们详细地介绍了阻塞IO与IO多路复用,拓展了零拷贝相关的知识以及Netty框架中的零拷贝。
|
||||
|
||||
考虑到系统内核的支持、编程语言的支持以及IO模型本身的特点,RPC框架在网络通信的处理上,我们更倾向选择IO多路复用的方式。
|
||||
|
||||
零拷贝带来的好处就是避免没必要的CPU拷贝,让CPU解脱出来去做其他的事,同时也减少了CPU在用户空间与内核空间之间的上下文切换,从而提升了网络通信效率与应用程序的整体性能。
|
||||
|
||||
而Netty的零拷贝与操作系统的零拷贝是有些区别的,Netty的零拷贝偏向于用户空间中对数据操作的优化,这对处理TCP传输中的拆包粘包问题有着重要的意义,对应用程序处理请求数据与返回数据也有重要的意义。
|
||||
|
||||
在 RPC框架的开发与使用过程中,我们要深入了解网络通信相关的原理知识,尽量做到零拷贝,如使用Netty框架;我们要合理使用ByteBuf子类,做到完全零拷贝,提升RPC框架的整体性能。
|
||||
|
||||
课后思考
|
||||
|
||||
回想一下,你所接触的开源中间件框架有哪些框架在网络通信上做到了零拷贝?都是使用哪种方式实现的零拷贝?
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
215
专栏/RPC实战与核心原理/05动态代理:面向接口编程,屏蔽RPC处理流程.md
Normal file
215
专栏/RPC实战与核心原理/05动态代理:面向接口编程,屏蔽RPC处理流程.md
Normal file
@ -0,0 +1,215 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 动态代理:面向接口编程,屏蔽RPC处理流程
|
||||
你好,我是何小锋。上一讲我分享了网络通信,其实要理解起来也很简单,RPC 是用来解决两个应用之间的通信,而网络则是两台机器之间的“桥梁”,只有架好了桥梁,我们才能把请求数据从一端传输另外一端。其实关于网络通信,你只要记住一个关键字就行了——可靠的传输。
|
||||
|
||||
那么接着上一讲的内容,我们再来聊聊动态代理在 RPC 里面的应用。
|
||||
|
||||
如果我问你,你知道动态代理吗? 你可能会如数家珍般地告诉我动态代理的作用以及好处。那我现在接着问你,你在项目中用过动态代理吗?这时候可能有些人就会犹豫了。那我再换一个方式问你,你在项目中有实现过统一拦截的功能吗?比如授权认证、性能统计等等。你可能立马就会想到,我实现过呀,并且我知道可以用 Spring 的 AOP 功能来实现。
|
||||
|
||||
没错,进一步再想,在 Spring AOP 里面我们是怎么实现统一拦截的效果呢?并且是在我们不需要改动原有代码的前提下,还能实现非业务逻辑跟业务逻辑的解耦。这里的核心就是采用动态代理技术,通过对字节码进行增强,在方法调用的时候进行拦截,以便于在方法调用前后,增加我们需要的额外处理逻辑。
|
||||
|
||||
那话说回来,动态代理跟 RPC 又有什么关系呢?
|
||||
|
||||
远程调用的魔法
|
||||
|
||||
我说个具体的场景,你可能就明白了。
|
||||
|
||||
在项目中,当我们要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,通过 Maven 或者其他的工具把接口依赖到我们项目中。我们在编写业务逻辑的时候,如果要调用提供方的接口,我们就只需要通过依赖注入的方式把接口注入到项目中就行了,然后在代码里面直接调用接口的方法 。
|
||||
|
||||
我们都知道,接口里并不会包含真实的业务逻辑,业务逻辑都在服务提供方应用里,但我们通过调用接口方法,确实拿到了想要的结果,是不是感觉有点神奇呢?想一下,在 RPC 里面,我们是怎么完成这个魔术的。
|
||||
|
||||
这里面用到的核心技术就是前面说的动态代理。RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
|
||||
|
||||
通过这种“偷梁换柱”的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验,整体流程如下图所示:
|
||||
|
||||
|
||||
|
||||
实现原理
|
||||
|
||||
动态代理在 RPC 里面的作用,就像是个魔术。现在我不妨给你揭秘一下,我们一起看看这是怎么实现的。之后,学以致用自然就不难了。
|
||||
|
||||
我们以 Java 为例,看一个具体例子,代码如下所示:
|
||||
|
||||
/**
|
||||
* 要代理的接口
|
||||
*/
|
||||
public interface Hello {
|
||||
String say();
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实调用对象
|
||||
*/
|
||||
public class RealHello {
|
||||
|
||||
public String invoke(){
|
||||
return "i'm proxy";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JDK代理类生成
|
||||
*/
|
||||
public class JDKProxy implements InvocationHandler {
|
||||
private Object target;
|
||||
|
||||
JDKProxy(Object target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] paramValues) {
|
||||
return ((RealHello)target).invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试例子
|
||||
*/
|
||||
public class TestProxy {
|
||||
|
||||
public static void main(String[] args){
|
||||
// 构建代理器
|
||||
JDKProxy proxy = new JDKProxy(new RealHello());
|
||||
ClassLoader classLoader = ClassLoaderUtils.getCurrentClassLoader();
|
||||
// 把生成的代理类保存到文件
|
||||
System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles","true");
|
||||
// 生成代理类
|
||||
Hello test = (Hello) Proxy.newProxyInstance(classLoader, new Class[]{Hello.class}, proxy);
|
||||
// 方法调用
|
||||
System.out.println(test.say());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这段代码想表达的意思就是:给 Hello 接口生成一个动态代理类,并调用接口 say() 方法,但真实返回的值居然是来自 RealHello 里面的 invoke() 方法返回值。你看,短短50行的代码,就完成了这个功能,是不是还挺有意思的?
|
||||
|
||||
那既然重点是代理类的生成,那我们就去看下 Proxy.newProxyInstance 里面究竟发生了什么?
|
||||
|
||||
一起看下下面的流程图,具体代码细节你可以对照着 JDK 的源码看(上文中有类和方法,可以直接定位),我是按照 1.7.X 版本梳理的。
|
||||
|
||||
|
||||
|
||||
在生成字节码的那个地方,也就是 ProxyGenerator.generateProxyClass() 方法里面,通过代码我们可以看到,里面是用参数 saveGeneratedFiles 来控制是否把生成的字节码保存到本地磁盘。同时为了更直观地了解代理的本质,我们需要把参数 saveGeneratedFiles 设置成true,但这个参数的值是由key为“sun.misc.ProxyGenerator.saveGeneratedFiles”的Property来控制的,动态生成的类会保存在工程根目录下的 com/sun/proxy 目录里面。现在我们找到刚才生成的 $Proxy0.class,通过反编译工具打开class文件,你会看到这样的代码:
|
||||
|
||||
package com.sun.proxy;
|
||||
|
||||
import com.proxy.Hello;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.lang.reflect.UndeclaredThrowableException;
|
||||
|
||||
public final class $Proxy0 extends Proxy implements Hello {
|
||||
private static Method m3;
|
||||
|
||||
private static Method m1;
|
||||
|
||||
private static Method m0;
|
||||
|
||||
private static Method m2;
|
||||
|
||||
public $Proxy0(InvocationHandler paramInvocationHandler) {
|
||||
super(paramInvocationHandler);
|
||||
}
|
||||
|
||||
public final String say() {
|
||||
try {
|
||||
return (String)this.h.invoke(this, m3, null);
|
||||
} catch (Error|RuntimeException error) {
|
||||
throw null;
|
||||
} catch (Throwable throwable) {
|
||||
throw new UndeclaredThrowableException(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
public final boolean equals(Object paramObject) {
|
||||
try {
|
||||
return ((Boolean)this.h.invoke(this, m1, new Object[] { paramObject })).booleanValue();
|
||||
} catch (Error|RuntimeException error) {
|
||||
throw null;
|
||||
} catch (Throwable throwable) {
|
||||
throw new UndeclaredThrowableException(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
public final int hashCode() {
|
||||
try {
|
||||
return ((Integer)this.h.invoke(this, m0, null)).intValue();
|
||||
} catch (Error|RuntimeException error) {
|
||||
throw null;
|
||||
} catch (Throwable throwable) {
|
||||
throw new UndeclaredThrowableException(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
public final String toString() {
|
||||
try {
|
||||
return (String)this.h.invoke(this, m2, null);
|
||||
} catch (Error|RuntimeException error) {
|
||||
throw null;
|
||||
} catch (Throwable throwable) {
|
||||
throw new UndeclaredThrowableException(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
try {
|
||||
m3 = Class.forName("com.proxy.Hello").getMethod("say", new Class[0]);
|
||||
m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[] { Class.forName("java.lang.Object") });
|
||||
m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
|
||||
m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
|
||||
return;
|
||||
} catch (NoSuchMethodException noSuchMethodException) {
|
||||
throw new NoSuchMethodError(noSuchMethodException.getMessage());
|
||||
} catch (ClassNotFoundException classNotFoundException) {
|
||||
throw new NoClassDefFoundError(classNotFoundException.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
我们可以看到 $Proxy0 类里面有一个跟 Hello 一样签名的 say() 方法,其中 this.h 绑定的是刚才传入的 JDKProxy 对象,所以当我们调用 Hello.say() 的时候,其实它是被转发到了JDKProxy.invoke()。到这儿,整个魔术过程就透明了。
|
||||
|
||||
实现方法
|
||||
|
||||
其实在 Java 领域,除了JDK 默认的nvocationHandler能完成代理功能,我们还有很多其他的第三方框架也可以,比如像 Javassist、Byte Buddy 这样的框架。
|
||||
|
||||
单纯从代理功能上来看,JDK 默认的代理功能是有一定的局限性的,它要求被代理的类只能是接口。原因是因为生成的代理类会继承 Proxy 类,但Java 是不支持多重继承的。
|
||||
|
||||
这个限制在RPC应用场景里面还是挺要紧的,因为对于服务调用方来说,在使用RPC的时候本来就是面向接口来编程的,这个我们刚才在前面已经讨论过了。使用JDK默认的代理功能,最大的问题就是性能问题。它生成后的代理类是使用反射来完成方法调用的,而这种方式相对直接用编码调用来说,性能会降低,但好在JDK8及以上版本对反射调用的性能有很大的提升,所以还是可以期待一下的。
|
||||
|
||||
相对 JDK 自带的代理功能,Javassist的定位是能够操纵底层字节码,所以使用起来并不简单,要生成动态代理类恐怕是有点复杂了。但好的方面是,通过Javassist生成字节码,不需要通过反射完成方法调用,所以性能肯定是更胜一筹的。在使用中,我们要注意一个问题,通过Javassist生成一个代理类后,此 CtClass 对象会被冻结起来,不允许再修改;否则,再次生成时会报错。
|
||||
|
||||
Byte Buddy 则属于后起之秀,在很多优秀的项目中,像Spring、Jackson都用到了Byte Buddy来完成底层代理。相比Javassist,Byte Buddy提供了更容易操作的API,编写的代码可读性更高。更重要的是,生成的代理类执行速度比Javassist更快。
|
||||
|
||||
虽然以上这三种框架使用的方式相差很大,但核心原理却是差不多的,区别就只是通过什么方式生成的代理类以及在生成的代理类里面是怎么完成的方法调用。同时呢,也正是因为这些细小的差异,才导致了不同的代理框架在性能方面的表现不同。因此,我们在设计RPC框架的时候,还是需要进行一些比较的,具体你可以综合它们的优劣以及你的场景需求进行选择。
|
||||
|
||||
总结
|
||||
|
||||
今天我们介绍了动态代理在RPC里面的应用,虽然它只是一种具体实现的技术,但我觉得只有理解了方法调用是怎么被拦截的,才能厘清在RPC里面我们是怎么做到面向接口编程,帮助用户屏蔽RPC调用细节的,最终呈现给用户一个像调用本地一样去调用远程的编程体验。
|
||||
|
||||
既然动态代理是一种具体的技术框架,那就会涉及到选型。我们可以从这样三个角度去考虑:
|
||||
|
||||
|
||||
因为代理类是在运行中生成的,那么代理框架生成代理类的速度、生成代理类的字节码大小等等,都会影响到其性能——生成的字节码越小,运行所占资源就越小。
|
||||
还有就是我们生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。
|
||||
最后一个是从我们的使用角度出发的,我们肯定希望选择一个使用起来很方便的代理类框架,比如我们可以考虑:API设计是否好理解、社区活跃度、还有就是依赖复杂度等等。
|
||||
|
||||
|
||||
最后,我想再强调一下。动态代理在RPC里面,虽然看起来只是一个很小的技术点,但就是这个创新使得用户可以不用关注细节了。其实,我们在日常设计接口的时候也是一样的,我们会想尽一切办法把细节对调用方屏蔽,让调用方的接入尽可能的简单。这就好比,让你去设计一个商品发布的接口,你并不需要暴露给用户一些细节,比如,告诉他们商品数据是怎么存储的。
|
||||
|
||||
课后思考
|
||||
|
||||
请你设想一下,如果没有动态代理帮我们完成方法调用拦截,用户该怎么完成RPC调用?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
255
专栏/RPC实战与核心原理/06RPC实战:剖析gRPC源码,动手实现一个完整的RPC.md
Normal file
255
专栏/RPC实战与核心原理/06RPC实战:剖析gRPC源码,动手实现一个完整的RPC.md
Normal file
@ -0,0 +1,255 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 RPC实战:剖析gRPC源码,动手实现一个完整的RPC
|
||||
你好,我是何小锋。上一讲我分享了动态代理,其作用总结起来就是一句话:“我们可以通过动态代理技术,屏蔽 RPC 调用的细节,从而让使用者能够面向接口编程。”
|
||||
|
||||
到今天为止,我们已经把 RPC 通信过程中要用到的所有基础知识都讲了一遍,但这些内容多属于理论。这一讲我们就来实战一下,看看具体落实到代码上,我们应该怎么实现一个 RPC 框架?
|
||||
|
||||
为了能让咱们快速达成共识,我选择剖析 gRPC 源码(源码地址:https://github.com/grpc/grpc-java)。通过分析 gRPC 的通信过程,我们可以清楚地知道在 gRPC 里面这些知识点是怎么落地到具体代码上的。
|
||||
|
||||
gRPC 是由 Google 开发并且开源的一款高性能、跨语言的 RPC 框架,当前支持 C、Java 和 Go 等语言,当前 Java 版本最新 Release 版为 1.27.0。gRPC 有很多特点,比如跨语言,通信协议是基于标准的 HTTP/2 设计的,序列化支持 PB(Protocol Buffer)和 JSON,整个调用示例如下图所示:
|
||||
|
||||
|
||||
|
||||
如果你想快速地了解一个全新框架的工作原理,我个人认为最快的方式就是从使用示例开始,所以现在我们就以最简单的 HelloWord 为例开始了解。
|
||||
|
||||
在这个例子里面,我们会定义一个 say 方法,调用方通过 gRPC 调用服务提供方,然后服务提供方会返回一个字符串给调用方。
|
||||
|
||||
为了保证调用方和服务提供方能够正常通信,我们需要先约定一个通信过程中的契约,也就是我们在 Java 里面说的定义一个接口,这个接口里面只会包含一个 say 方法。在 gRPC 里面定义接口是通过写 Protocol Buffer 代码,从而把接口的定义信息通过 Protocol Buffer 语义表达出来。HelloWord 的 Protocol Buffer 代码如下所示:
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "io.grpc.hello";
|
||||
option java_outer_classname = "HelloProto";
|
||||
option objc_class_prefix = "HLW";
|
||||
|
||||
package hello;
|
||||
|
||||
service HelloService{
|
||||
rpc Say(HelloRequest) returns (HelloReply) {}
|
||||
}
|
||||
|
||||
message HelloRequest {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message HelloReply {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
|
||||
有了这段代码,我们就可以为客户端和服务器端生成消息对象和 RPC 基础代码。我们可以利用 Protocol Buffer 的编译器 protoc,再配合 gRPC Java 插件(protoc-gen-grpc-java),通过命令行 protoc3 加上 plugin 和 proto 目录地址参数,我们就可以生成消息对象和 gRPC 通信所需要的基础代码。如果你的项目是 Maven 工程的话,你还可以直接选择使用 Maven 插件来生成同样的代码。
|
||||
|
||||
发送原理
|
||||
|
||||
生成完基础代码以后,我们就可以基于生成的代码写下调用端代码,具体如下:
|
||||
|
||||
package io.grpc.hello;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class HelloWorldClient {
|
||||
|
||||
private final ManagedChannel channel;
|
||||
private final HelloServiceGrpc.HelloServiceBlockingStub blockingStub;
|
||||
/**
|
||||
* 构建Channel连接
|
||||
**/
|
||||
public HelloWorldClient(String host, int port) {
|
||||
this(ManagedChannelBuilder.forAddress(host, port)
|
||||
.usePlaintext()
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Stub用于发请求
|
||||
**/
|
||||
HelloWorldClient(ManagedChannel channel) {
|
||||
this.channel = channel;
|
||||
blockingStub = HelloServiceGrpc.newBlockingStub(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用完手动关闭
|
||||
**/
|
||||
public void shutdown() throws InterruptedException {
|
||||
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送rpc请求
|
||||
**/
|
||||
public void say(String name) {
|
||||
// 构建入参对象
|
||||
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
|
||||
HelloReply response;
|
||||
try {
|
||||
// 发送请求
|
||||
response = blockingStub.say(request);
|
||||
} catch (StatusRuntimeException e) {
|
||||
return;
|
||||
}
|
||||
System.out.println(response);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
HelloWorldClient client = new HelloWorldClient("127.0.0.1", 50051);
|
||||
try {
|
||||
client.say("world");
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
调用端代码大致分成三个步骤:
|
||||
|
||||
|
||||
首先用 host 和 port 生成 channel 连接;
|
||||
然后用前面生成的 HelloService gRPC 创建 Stub 类;
|
||||
最后我们可以用生成的这个 Stub 调用 say 方法发起真正的 RPC 调用,后续其它的 RPC 通信细节就对我们使用者透明了。
|
||||
|
||||
|
||||
为了能看清楚里面具体发生了什么,我们需要进入到 ClientCalls.blockingUnaryCall 方法里面看下逻辑细节。但是为了避免太多的细节影响你理解整体流程,我在下面这张图中只画下了最重要的部分。
|
||||
|
||||
|
||||
|
||||
我们可以看到,在调用端代码里面,我们只需要一行(第48行)代码就可以发起一个 RPC 调用,而具体这个请求是怎么发送到服务提供者那端的呢?这对于我们 gRPC 使用者来说是完全透明的,我们只要关注是怎么创建出 stub 对象的就可以了。
|
||||
|
||||
比如入参是一个字符对象,gRPC 是怎么把这个对象传输到服务提供方的呢?因为在[第 03 讲] 中我们说过,只有二进制才能在网络中传输,但是目前调用端代码的入参是一个字符对象,那在 gRPC 里面我们是怎么把对象转成二进制数据的呢?
|
||||
|
||||
回到上面流程图的第3步,在 writePayload 之前,ClientCallImpl 里面有一行代码就是 method.streamRequest(message),看方法签名我们大概就知道它是用来把对象转成一个 InputStream,有了 InputStream 我们就很容易获得入参对象的二进制数据。这个方法返回值很有意思,就是为啥不直接返回我们想要的二进制数组,而是返回一个 InputStream 对象呢?你可以先停下来想下原因,我们会在最后继续讨论这个问题。
|
||||
|
||||
我们接着看 streamRequest 方法的拥有者 method 是个什么对象?我们可以看到 method 是 MethodDescriptor 对象关联的一个实例,而 MethodDescriptor 是用来存放要调用 RPC 服务的接口名、方法名、服务调用的方式以及请求和响应的序列化和反序列化实现类。
|
||||
|
||||
大白话说就是,MethodDescriptor 是用来存储一些 RPC 调用过程中的元数据,而在 MethodDescriptor 里面 requestMarshaller 是在绑定请求的时候用来序列化方式对象的,所以当我们调用 method.streamRequest(message) 的时候,实际是调用 requestMarshaller.stream(requestMessage) 方法,而 requestMarshaller 里面会绑定一个 Parser,这个 Parser 才真正地把对象转成了 InputStream 对象。
|
||||
|
||||
讲完序列化在 gRPC 里面的应用后,我们再来看下在 gRPC 里面是怎么完成请求数据“断句”的,就是我们在[第 02 讲] 中说的那个问题——二进制流经过网络传输后,怎么正确地还原请求前语义?
|
||||
|
||||
我们在 gRPC 文档中可以看到,gRPC 的通信协议是基于标准的 HTTP/2 设计的,而 HTTP/2 相对于常用的 HTTP/1.X 来说,它最大的特点就是多路复用、双向流,该怎么理解这个特点呢?这就好比我们生活中的单行道和双行道,HTTP/1.X 就是单行道,HTTP/2 就是双行道。
|
||||
|
||||
那既然在请求收到后需要进行请求“断句”,那肯定就需要在发送的时候把断句的符号加上,我们看下在 gRPC 里面是怎么加的?
|
||||
|
||||
因为 gRPC 是基于 HTTP/2 协议,而 HTTP/2 传输基本单位是 Frame,Frame 格式是以固定 9 字节长度的 header,后面加上不定长的 payload 组成,协议格式如下图所示:
|
||||
|
||||
|
||||
|
||||
那在 gRPC 里面就变成怎么构造一个 HTTP/2 的 Frame 了。
|
||||
|
||||
现在回看我们上面那个流程图的第 4 步,在 write 到 Netty 里面之前,我们看到在 MessageFramer.writePayload 方法里面会间接调用 writeKnownLengthUncompressed 方法,该方法要做的两件事情就是构造 Frame Header 和 Frame Body,然后再把构造的 Frame 发送到 NettyClientHandler,最后将 Frame 写入到 HTTP/2 Stream 中,完成请求消息的发送。
|
||||
|
||||
接收原理
|
||||
|
||||
讲完 gRPC 的请求发送原理,我们再来看下服务提供方收到请求后会怎么处理?我们还是接着前面的那个例子,先看下服务提供方代码,具体如下:
|
||||
|
||||
static class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
|
||||
|
||||
@Override
|
||||
public void say(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
|
||||
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
|
||||
responseObserver.onNext(reply);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
上面 HelloServiceImpl 类是按照 gRPC 使用方式实现了 HelloService 接口逻辑,但是对于调用者来说并不能把它调用过来,因为我们没有把这个接口对外暴露,在 gRPC 里面我们是采用 Build 模式对底层服务进行绑定,具体代码如下:
|
||||
|
||||
package io.grpc.hello;
|
||||
|
||||
import io.grpc.Server;
|
||||
import io.grpc.ServerBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
public class HelloWorldServer {
|
||||
|
||||
private Server server;
|
||||
|
||||
/**
|
||||
* 对外暴露服务
|
||||
**/
|
||||
private void start() throws IOException {
|
||||
int port = 50051;
|
||||
server = ServerBuilder.forPort(port)
|
||||
.addService(new HelloServiceImpl())
|
||||
.build()
|
||||
.start();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
HelloWorldServer.this.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭端口
|
||||
**/
|
||||
private void stop() {
|
||||
if (server != null) {
|
||||
server.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭
|
||||
**/
|
||||
private void blockUntilShutdown() throws InterruptedException {
|
||||
if (server != null) {
|
||||
server.awaitTermination();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static void main(String[] args) throws IOException, InterruptedException {
|
||||
final HelloWorldServer server = new HelloWorldServer();
|
||||
server.start();
|
||||
server.blockUntilShutdown();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
服务对外暴露的目的是让过来的请求在被还原成信息后,能找到对应接口的实现。在这之前,我们需要先保证能正常接收请求,通俗地讲就是要先开启一个 TCP 端口,让调用方可以建立连接,并把二进制数据发送到这个连接通道里面,这里依然只展示最重要的部分。
|
||||
|
||||
|
||||
|
||||
这四个步骤是用来开启一个 Netty Server,并绑定编解码逻辑的,如果你暂时看不懂,没关系的,我们可以先忽略细节。我们重点看下 NettyServerHandler 就行了,在这个 Handler 里面会绑定一个 FrameListener,gRPC 会在这个 Listener 里面处理收到数据请求的 Header 和 Body,并且也会处理 Ping、RST 命令等,具体流程如下图所示:
|
||||
|
||||
|
||||
|
||||
在收到 Header 或者 Body 二进制数据后,NettyServerHandler 上绑定的FrameListener 会把这些二进制数据转到 MessageDeframer 里面,从而实现 gRPC 协议消息的解析 。
|
||||
|
||||
那你可能会问,这些 Header 和 Body 数据是怎么分离出来的呢?按照我们前面说的,调用方发过来的是一串二进制数据,这就是我们前面开启 Netty Server 的时候绑定 Default HTTP/2FrameReader 的作用,它能帮助我们按照 HTTP/2 协议的格式自动切分出 Header 和 Body 数据来,而对我们上层应用 gRPC 来说,它可以直接拿拆分后的数据来用。
|
||||
|
||||
总结
|
||||
|
||||
这是我们基础篇的最后一讲,我们采用剖析 gRPC 源码的方式来学习如何实现一个完整的 RPC。当然整个 gRPC 的代码量可比这多得多,但今天的主要目就是想让你把前面所学的序列化、协议等方面的知识落实到具体代码上,所以我们这儿只分析了 gRPC 收发请求两个过程。
|
||||
|
||||
实现了这两个过程,我们就可以完成一个点对点的 RPC 功能,但在实际使用的时候,我们的服务提供方通常都是以一个集群的方式对外提供服务的,所以在 gRPC 里面你还可以看到负载均衡、服务发现等功能。而且 gRPC 采用的是 HTTP/2 协议,我们还可以通过 Stream 方式来调用服务,以提升调用性能。
|
||||
|
||||
总的来说,其实我们可以简单地认为gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。
|
||||
|
||||
课后思考
|
||||
|
||||
我们讲到,在 gRPC 调用的时候,我们有一个关键步骤就是把对象转成可传输的二进制,但是在 gRPC 里面,我们并没有直接转成二进制数组,而是返回一个 InputStream,你知道这样做的好处是什么吗?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
73
专栏/RPC实战与核心原理/07架构设计:设计一个灵活的RPC框架.md
Normal file
73
专栏/RPC实战与核心原理/07架构设计:设计一个灵活的RPC框架.md
Normal file
@ -0,0 +1,73 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 架构设计:设计一个灵活的RPC框架
|
||||
你好,我是何小锋。到今天为止,基础篇的知识我们就全部学习完了,接下来我们进入进阶篇。
|
||||
|
||||
在基础篇里面,我们讲了RPC的通信原理以及RPC里各个功能组件的作用,不妨用一段话再次回顾下:“其实RPC就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的。”你记住了吗?
|
||||
|
||||
那学到这儿,距离实现一个灵活的RPC框架其实还是有距离的。知道了各个功能组件只是迈出了第一步,接下来你必须要清楚各个组件之间是怎么完成数据交互的,这也是今天这讲的重点,我们一起搞清楚RPC的架构设计。
|
||||
|
||||
RPC架构
|
||||
|
||||
说起架构设计,我相信你一定不陌生。我理解的架构设计呢,就是从顶层角度出发,厘清各模块组件之间数据交互的流程,让我们对系统有一个整体的宏观认识。我们先看看RPC里面都有哪些功能模块。
|
||||
|
||||
我们讲过,RPC本质上就是一个远程调用,那肯定就需要通过网络来传输数据。虽然传输协议可以有多种选择,但考虑到可靠性的话,我们一般默认采用TCP协议。为了屏蔽网络传输的复杂性,我们需要封装一个单独的数据传输模块用来收发二进制数据,这个单独模块我们可以叫做传输模块。
|
||||
|
||||
用户请求的时候是基于方法调用,方法出入参数都是对象数据,对象是肯定没法直接在网络中传输的,我们需要提前把它转成可传输的二进制,这就是我们说的序列化过程。但只是把方法调用参数的二进制数据传输到服务提供方是不够的,我们需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是我们请求的二进制数据,这个过程我们叫做协议封装。
|
||||
|
||||
虽然这是两个不同的过程,但其目的都是一样的,都是为了保证数据在网络中可以正确传输。这里我说的正确,可不仅指数据能够传输,还需要保证传输后能正确还原出传输前的语义。所以我们可以把这两个处理过程放在架构中的同一个模块,统称为协议模块。
|
||||
|
||||
除此之外,我们还可以在协议模块中加入压缩功能,这是因为压缩过程也是对传输的二进制数据进行操作。在实际的网络传输过程中,我们的请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题,我们可以在RPC调用的时候这样操作:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,我们可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原。
|
||||
|
||||
传输和协议这两个模块是RPC里面最基础的功能,它们使对象可以正确地传输到服务提供方。但距离RPC的目标——实现像调用本地一样地调用远程,还缺少点东西。因为这两个模块所提供的都是一些基础能力,要让这两个模块同时工作的话,我们需要手写一些黏合的代码,但这些代码对我们使用RPC的研发人员来说是没有意义的,而且属于一个重复的工作,会导致使用过程的体验非常不友好。
|
||||
|
||||
这就需要我们在RPC里面把这些细节对研发人员进行屏蔽,让他们感觉不到本地调用和远程调用的区别。假设有用到Spring的话,我们希望RPC能让我们把一个RPC接口定义成一个Spring Bean,并且这个Bean也会统一被Spring Bean Factory管理,可以在项目中通过Spring依赖注入到方式引用。这是RPC调用的入口,我们一般叫做Bootstrap模块。
|
||||
|
||||
学到这儿,一个点对点(Point to Point)版本的RPC框架就完成了。我一般称这种模式的RPC框架为单机版本,因为它没有集群能力。所谓集群能力,就是针对同一个接口有着多个服务提供者,但这多个服务提供者对于我们的调用方来说是透明的,所以在RPC里面我们还需要给调用方找到所有的服务提供方,并需要在RPC里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,这就是我们常说的“服务发现”。
|
||||
|
||||
但服务发现只是解决了接口和服务提供方地址映射关系的查找问题,这更多是一种“静态数据”。说它是静态数据是因为,对于我们的RPC来说,我们每次发送请求的时候都是需要用TCP连接的,相对服务提供方IP地址,TCP连接状态是瞬息万变的,所以我们的RPC框架里面要有连接管理器去维护TCP连接的状态。
|
||||
|
||||
有了集群之后,提供方可能就需要管理好这些服务了,那我们的RPC就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。而服务调用方需要额外做哪些事情呢?每次调用前,我们都需要根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。
|
||||
|
||||
那到这儿,一个比较完善的RPC框架基本就完成了,功能也差不多就是这些了。按照分层设计的原则,我将这些功能模块分为了四层,具体内容见图示:
|
||||
|
||||
|
||||
|
||||
可扩展的架构
|
||||
|
||||
那RPC架构设计出来就完事了吗?当然不,技术迭代谁都躲不过。
|
||||
|
||||
不知道你有没有这样的经历,你设计的一个系统它看上去很完善,也能很好地运行,然后你成功地把它交付给了业务方。有一天业务方有了新的需求,要加入很多新的功能,这时候你就会发现当前架构面临的可就是大挑战了,要修改很多地方才能实现。
|
||||
|
||||
举个例子,假如你设计了一个商品发布系统,早些年我们只能在网上购买电脑、衣服等实物商品,但现在发展成可以在网上购买电话充值卡、游戏点卡等虚拟商品,实物商品的发布流程是需要选择购买区域的,但虚拟商品并没有这一限制。如果你想要在一套发布系统里面同时完成实物和虚拟商品发布的话,你就只能在代码里面加入很多的if else判断逻辑,这样是能行,可整个代码就臃肿、杂乱了,后期也极难维护。
|
||||
|
||||
其实,我们设计RPC框架也是一样的,我们不可能在开始时就面面俱到。那有没有更好的方式来解决这些问题呢?这就是我们接下来要讲的插件化架构。
|
||||
|
||||
在RPC框架里面,我们是怎么支持插件化架构的呢?我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。在Java里面,JDK有自带的SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现。使用SPI机制需要在Classpath下的META-INF/services目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
|
||||
|
||||
但在实际项目中,我们其实很少使用到JDK自带的SPI机制,首先它不能按需加载,ServiceLoader加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个Spring Bean,原生的Java SPI就不支持。
|
||||
|
||||
加上了插件功能之后,我们的RPC框架就包含了两大核心体系——核心功能体系与插件体系,如下图所示:
|
||||
|
||||
|
||||
|
||||
这时,整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入RPC导致的包版本冲突问题。
|
||||
|
||||
总结
|
||||
|
||||
我们都知道软件开发的过程很复杂,不仅是因为业务需求经常变化,更难的是在开发过程中要保证团队成员的目标统一。我们需要用一种可沟通的话语、可“触摸”的愿景达成目标,我认为这就是软件架构设计的意义。
|
||||
|
||||
但仅从功能角度设计出的软件架构并不够健壮,系统不仅要能正确地运行,还要以最低的成本进行可持续的维护,因此我们十分有必要关注系统的可扩展性。只有这样,才能满足业务变化的需求,让系统的生命力不断延伸。
|
||||
|
||||
课后思考
|
||||
|
||||
你能分享一下,在日常工作中,你都有哪些地方是用到了插件思想来解决扩展性问题的吗?
|
||||
|
||||
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
130
专栏/RPC实战与核心原理/08服务发现:到底是要CP还是AP?.md
Normal file
130
专栏/RPC实战与核心原理/08服务发现:到底是要CP还是AP?.md
Normal file
@ -0,0 +1,130 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 服务发现:到底是要CP还是AP?
|
||||
你好,我是何小锋。在上一讲中,我讲了“怎么设计一个灵活的RPC框架”,总结起来,就是怎么在RPC框架中应用插件,用插件方式构造一个基于微内核的RPC框架,其关键点就是“插件化”。
|
||||
|
||||
今天,我要和你聊聊RPC里面的“服务发现”在超大规模集群的场景下所面临的挑战。
|
||||
|
||||
为什么需要服务发现?
|
||||
|
||||
先举个例子,假如你要给一位以前从未合作过的同事发邮件请求帮助,但你却没有他的邮箱地址。这个时候你会怎么办呢?如果是我,我会选择去看公司的企业“通信录”。
|
||||
|
||||
同理,为了高可用,在生产环境中服务提供方都是以集群的方式对外提供服务,集群里面的这些IP随时可能变化,我们也需要用一本“通信录”及时获取到对应的服务节点,这个获取的过程我们一般叫作“服务发现”。
|
||||
|
||||
对于服务调用方和服务提供方来说,其契约就是接口,相当于“通信录”中的姓名,服务节点就是提供该契约的一个具体实例。服务IP集合作为“通信录”中的地址,从而可以通过接口获取服务IP的集合来完成服务的发现。这就是我要说的RPC框架的服务发现机制,如下图所示:
|
||||
|
||||
|
||||
|
||||
|
||||
服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的IP和接口保存下来。
|
||||
服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的IP,然后缓存到本地,并用于后续的远程调用。
|
||||
|
||||
|
||||
为什么不使用DNS?
|
||||
|
||||
既然服务发现这么“厉害”,那是不是很难实现啊?其实类似机制一直在我们身边,我们回想下服务发现的本质,就是完成了接口跟服务提供者IP的映射。那我们能不能把服务提供者IP统一换成一个域名啊,利用已经成熟的DNS机制来实现?
|
||||
|
||||
好,先带着这个问题,简单地看下DNS的流程:
|
||||
|
||||
|
||||
|
||||
如果我们用DNS来实现服务发现,所有的服务提供者节点都配置在了同一个域名下,调用方的确可以通过DNS拿到随机的一个服务提供者的IP,并与之建立长连接,这看上去并没有太大问题,但在我们业界为什么很少用到这种方案呢?不知道你想过这个问题没有,如果没有,现在可以停下来想想这样两个问题:
|
||||
|
||||
|
||||
如果这个IP端口下线了,服务调用者能否及时摘除服务节点呢?
|
||||
如果在之前已经上线了一部分服务节点,这时我突然对这个服务进行扩容,那么新上线的服务节点能否及时接收到流量呢?
|
||||
|
||||
|
||||
这两个问题的答案都是:“不能”。这是因为为了提升性能和减少DNS服务的压力,DNS采取了多级缓存机制,一般配置的缓存时间较长,特别是JVM的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化。
|
||||
|
||||
这时你可能会想,我是不是可以加一个负载均衡设备呢?将域名绑定到这台负载均衡设备上,通过DNS拿到负载均衡的IP。这样服务调用的时候,服务调用方就可以直接跟VIP建立连接,然后由VIP机器完成TCP转发,如下图所示:
|
||||
|
||||
|
||||
|
||||
这个方案确实能解决DNS遇到的一些问题,但在RPC场景里面也并不是很合适,原因有以下几点:
|
||||
|
||||
|
||||
搭建负载均衡设备或TCP/IP四层代理,需求额外成本;
|
||||
请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费些性能;
|
||||
负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟;
|
||||
我们在服务治理的时候,需要更灵活的负载均衡策略,目前的负载均衡设备的算法还满足不了灵活的需求。
|
||||
|
||||
|
||||
由此可见,DNS或者VIP方案虽然可以充当服务发现的角色,但在RPC场景里面直接用还是很难的。
|
||||
|
||||
基于ZooKeeper的服务发现
|
||||
|
||||
那么在RPC里面我们该如何实现呢?我们还是要回到服务发现的本质,就是完成接口跟服务提供者IP之间的映射。这个映射是不是就是一种命名服务?当然,我们还希望注册中心能完成实时变更推送,是不是像开源的ZooKeeper、etcd就可以实现?我很肯定地说“确实可以”。下面我就来介绍下一种基于ZooKeeper的服务发现方式。
|
||||
|
||||
整体的思路很简单,就是搭建一个ZooKeeper集群作为注册中心集群,服务注册的时候只需要服务节点向ZooKeeper节点写入注册信息即可,利用ZooKeeper的Watcher机制完成服务订阅与服务下发功能,整体流程如下图:
|
||||
|
||||
|
||||
|
||||
|
||||
服务平台管理端先在ZooKeeper中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。
|
||||
当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
|
||||
当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方watch该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
|
||||
当服务提供方目录下有节点数据发生变更时,ZooKeeper就会通知给发起订阅的服务调用方。
|
||||
|
||||
|
||||
我所在的技术团队早期使用的RPC框架服务发现就是基于ZooKeeper实现的,并且还平稳运行了一年多,但后续团队的微服务化程度越来越高之后,ZooKeeper集群整体压力也越来越高,尤其在集中上线的时候越发明显。“集中爆发”是在一次大规模上线的时候,当时有超大批量的服务节点在同时发起注册操作,ZooKeeper集群的CPU突然飙升,导致ZooKeeper集群不能工作了,而且我们当时也无法立马将ZooKeeper集群重新启动,一直到ZooKeeper集群恢复后业务才能继续上线。
|
||||
|
||||
经过我们的排查,引发这次问题的根本原因就是ZooKeeper本身的性能问题,当连接到ZooKeeper的节点数量特别多,对ZooKeeper读写特别频繁,且ZooKeeper存储的目录达到一定数量的时候,ZooKeeper将不再稳定,CPU持续升高,最终宕机。而宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZooKeeper就因无法承受瞬间的读写压力,马上宕机。
|
||||
|
||||
这次“意外”让我们意识到,ZooKeeper集群性能显然已经无法支撑我们现有规模的服务集群了,我们需要重新考虑服务发现方案。
|
||||
|
||||
基于消息总线的最终一致性的注册中心
|
||||
|
||||
我们知道,ZooKeeper的一大特点就是强一致性,ZooKeeper集群的每个节点的数据每次发生更新操作,都会通知其它ZooKeeper节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这也就直接导致了ZooKeeper集群性能上的下降。这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。
|
||||
|
||||
而RPC框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以我们可以牺牲掉CP(强制一致性),而选择AP(最终一致),来换取整个注册中心集群的性能和稳定性。
|
||||
|
||||
那么是否有一种简单、高效,并且最终一致的更新机制,能代替ZooKeeper那种数据强一致的数据更新机制呢?
|
||||
|
||||
因为要求最终一致性,我们可以考虑采用消息总线机制。注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性,具体流程如下图所示:
|
||||
|
||||
|
||||
|
||||
|
||||
当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。
|
||||
消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
|
||||
消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。
|
||||
采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
|
||||
|
||||
|
||||
为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。
|
||||
|
||||
另外,你也可能会想到,服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?这个问题我们放到了RPC框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点。
|
||||
|
||||
通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。在RPC领域精耕细作后,你会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。
|
||||
|
||||
总结
|
||||
|
||||
今天我分享了RPC框架服务发现机制,以及如何用ZooKeeper完成“服务发现”,还有ZooKeeper在超大规模集群下作为注册中心所存在的问题。
|
||||
|
||||
通常我们可以使用ZooKeeper、etcd或者分布式缓存(如Hazelcast)来解决事件通知问题,但当集群达到一定规模之后,依赖的ZooKeeper集群、etcd集群可能就不稳定了,无法满足我们的需求。
|
||||
|
||||
在超大规模的服务集群下,注册中心所面临的挑战就是超大批量服务节点同时上下线,注册中心集群接受到大量服务变更请求,集群间各节点间需要同步大量服务节点数据,最终导致如下问题:
|
||||
|
||||
|
||||
注册中心负载过高;
|
||||
各节点数据不一致;
|
||||
服务下发不及时或下发错误的服务节点列表。
|
||||
|
||||
|
||||
RPC框架依赖的注册中心的服务数据的一致性其实并不需要满足CP,只要满足AP即可。我们就是采用“消息总线”的通知机制,来保证注册中心数据的最终一致性,来解决这些问题的。
|
||||
|
||||
另外,在今天的内容中,很多知识点不只可以应用到RPC框架的“服务发现”中。例如服务节点数据的推送采用增量更新的方式,这种方式提高了注册中心“服务下发”的效率,而这种方式,你还可以利用在其它地方,比如统一配置中心,用此方式可以提升统一配置中心下发配置的效率。
|
||||
|
||||
课后思考
|
||||
|
||||
目前服务提供者上线后会自动注册到注册中心,服务调用方会自动感知到新增的实例,并且流量会很快打到该新增的实例。如果我想把某些服务提供者实例的流量切走,除了下线实例,你有没有想到其它更便捷的办法呢?
|
||||
|
||||
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
113
专栏/RPC实战与核心原理/09健康检测:这个节点都挂了,为啥还要疯狂发请求?.md
Normal file
113
专栏/RPC实战与核心原理/09健康检测:这个节点都挂了,为啥还要疯狂发请求?.md
Normal file
@ -0,0 +1,113 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 健康检测:这个节点都挂了,为啥还要疯狂发请求?
|
||||
你好,我是何小锋。上一讲我们介绍了超大规模集群“服务发现”的挑战,服务发现的作用就是实时感知集群IP的变化,实现接口跟服务集群节点IP的映射。在超大规模集群实战中,我们更多需要考虑的是保证最终一致性。其实总结来说,就一关键词,你要记住“推拉结合,以拉为准”。接着昨天的内容,我们再来聊聊RPC中的健康检测。
|
||||
|
||||
因为有了集群,所以每次发请求前,RPC框架会根据路由和负载均衡算法选择一个具体的IP地址。为了保证请求成功,我们就需要确保每次选择出来的IP对应的连接是健康的,这个逻辑你应该理解。
|
||||
|
||||
但你也知道,调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况,那怎么保证选择出来的连接一定是可用的呢?
|
||||
|
||||
从我的角度看,终极的解决方案是让调用方实时感知到节点的状态变化,这样他们才能做出正确的选择。这个道理像我们开车一样,车有各种各样的零件,我们不可能在开车之前先去挨个检查下他们的健康情况,转而是应该有一套反馈机制,比如今天我的大灯坏了,那中控台就可以给我提示;明天我的胎压不够了,中控台也能够收到提示。汽车中大部分关键零件的状态变化,我作为调用方,都能够第一时间了解。
|
||||
|
||||
那回到RPC框架里,我们应该怎么设计这套机制呢?你可以先停下来想想汽车的例子,看看他们是怎么做的。当然,回到我们RPC的框架里,这事用专业一点的词来说就是服务的健康检测。今天我们就来详细聊聊这个话题。
|
||||
|
||||
遇到的问题
|
||||
|
||||
在进一步讲解服务健康检测之前,我想先和你分享一个我曾经遇到过的线上问题。
|
||||
|
||||
有一天,我们公司某个业务研发团队的负责人急匆匆跑过来,让我帮他解决个问题。仔细听完他的描述后,我才明白,原来是他们发现线上业务的某个接口可用性并不高,基本上十次调用里总会有几次失败。
|
||||
|
||||
查看了具体的监控数据之后,我们发现只有请求具体打到某台机器的时候才会有这个问题,也就是说,集群中有某台机器出了问题。于是快刀斩乱麻,我建议他们先把这台“问题机器”下线,以快速解决目前的问题。
|
||||
|
||||
|
||||
|
||||
但对于我来说,问题并没有结束,我开始进一步琢磨:“接口调用某台机器的时候已经出现不能及时响应了,那为什么RPC框架还会继续把请求发到这台有问题的机器上呢?RPC框架还会把请求发到这台机器上,也就是说从调用方的角度看,它没有觉得这台服务器有问题。”
|
||||
|
||||
就像警察破案一样,为了进一步了解事情的真相,我查看了问题时间点的监控和日志,在案发现场发现了这样几个线索:
|
||||
|
||||
|
||||
通过日志发现请求确实会一直打到这台有问题的机器上,因为我看到日志里有很多超时的异常信息。
|
||||
从监控上看,这台机器还是有一些成功的请求,这说明当时调用方跟服务之间的网络连接没有断开。因为如果连接断开之后,RPC框架会把这个节点标识为“不健康”,不会被选出来用于发业务请求。
|
||||
深入进去看异常日志,我发现调用方到目标机器的定时心跳会有间歇性失败。
|
||||
从目标机器的监控上可以看到该机器的网络指标有异常,出问题时间点TCP重传数比正常高10倍以上。
|
||||
|
||||
|
||||
有了对这四个线索的分析,我基本上可以得出这样的结论:那台问题服务器在某些时间段出现了网络故障,但也还能处理部分请求。换句话说,它处于半死不活的状态。但是(是转折,也是关键点),它还没彻底“死”,还有心跳,这样,调用方就觉得它还正常,所以就没有把它及时挪出健康状态列表。
|
||||
|
||||
到这里,你应该也明白了,一开始,我们为了快速解决问题,手动把那台问题机器下线了。刨根问底之后,我们发现,其实更大的问题是我们的服务检测机制有问题,有的服务本来都已经病危了,但我们还以为人家只是个感冒。
|
||||
|
||||
接下来,我们就来看看服务检测的核心逻辑。
|
||||
|
||||
健康检测的逻辑
|
||||
|
||||
刚刚我们提到了心跳机制,我估计你会想,搞什么心跳,是不是我们把问题复杂化了。当服务方下线,正常情况下我们肯定会收到连接断开的通知事件,在这个事件里面直接加处理逻辑不就可以了?是的,我们前面汽车的例子里检测都是这样做的。但咱们这里不行,因为应用健康状况不仅包括TCP连接状况,还包括应用本身是否存活,很多情况下TCP连接没有断开,但应用可能已经“僵死了”。
|
||||
|
||||
所以,业内常用的检测方法就是用心跳机制。心跳机制说起来也不复杂,其实就是服务调用方每隔一段时间就问一下服务提供方,“兄弟,你还好吧?”,然后服务提供方很诚实地告诉调用方它目前的状态。
|
||||
|
||||
结合前面的文章,你也不难想出来,服务方的状态一般会有三种情况,一个是我很好,一个是我生病了,一个是没回复。用专业的词来对应这三个状态就是:
|
||||
|
||||
|
||||
健康状态:建立连接成功,并且心跳探活也一直成功;
|
||||
亚健康状态:建立连接成功,但是心跳请求连续失败;
|
||||
死亡状态:建立连接失败。
|
||||
|
||||
|
||||
节点的状态并不是固定不变的,它会根据心跳或者重连的结果来动态变化,具体状态间转换图如下:
|
||||
|
||||
|
||||
|
||||
这里你可以关注下几个状态之间的转换箭头,我再给你解释下。首先,一开始初始化的时候,如果建立连接成功,那就是健康状态,否则就是死亡状态。这里没有亚健康这样的中间态。紧接着,如果健康状态的节点连续出现几次不能响应心跳请求的情况,那就会被标记为亚健康状态,也就是说,服务调用方会觉得它生病了。
|
||||
|
||||
生病之后(亚健康状态),如果连续几次都能正常响应心跳请求,那就可以转回健康状态,证明病好了。如果病一直好不了,那就会被断定为是死亡节点,死亡之后还需要善后,比如关闭连接。
|
||||
|
||||
当然,死亡并不是真正死亡,它还有复活的机会。如果某个时间点里,死亡的节点能够重连成功,那它就可以重新被标记为健康状态。
|
||||
|
||||
这就是整个节点的状态转换思路,你不用死记,它很简单,除了不能复活,其他都和我们人的状态一样。当服务调用方通过心跳机制了解了节点的状态之后,每次发请求的时候,就可以优先从健康列表里面选择一个节点。当然,如果健康列表为空,为了提高可用性,也可以尝试从亚健康列表里面选择一个,这就是具体的策略了。
|
||||
|
||||
具体的解决方案
|
||||
|
||||
理解了服务健康检测的逻辑,我们再回到开头我描述的场景里,看看怎么优化。现在你理解了,一个节点从健康状态过渡到亚健康状态的前提是“连续”心跳失败次数必须到达某一个阈值,比如3次(具体看你怎么配置了)。
|
||||
|
||||
而我们的场景里,节点的心跳日志只是间歇性失败,也就是时好时坏,这样,失败次数根本没到阈值,调用方会觉得它只是“生病”了,并且很快就好了。那怎么解决呢?我还是建议你先停下来想想。
|
||||
|
||||
你是不是会脱口而出,说改下配置,调低阈值呗。是的,这是最快的解决方法,但是我想说,它治标不治本。第一,像前面说的那样,调用方跟服务节点之间网络状况瞬息万变,出现网络波动的时候会导致误判。第二,在负载高情况,服务端来不及处理心跳请求,由于心跳时间很短,会导致调用方很快触发连续心跳失败而造成断开连接。
|
||||
|
||||
我们回到问题的本源,核心是服务节点网络有问题,心跳间歇性失败。我们现在判断节点状态只有一个维度,那就是心跳检测,那是不是可以再加上业务请求的维度呢?
|
||||
|
||||
起码我当时是顺着这个方向解决问题的。但紧接着,我又发现了新的麻烦:
|
||||
|
||||
|
||||
调用方每个接口的调用频次不一样,有的接口可能1秒内调用上百次,有的接口可能半个小时才会调用一次,所以我们不能把简单的把总失败的次数当作判断条件。
|
||||
服务的接口响应时间也是不一样的,有的接口可能1ms,有的接口可能是10s,所以我们也不能把TPS至来当作判断条件。
|
||||
|
||||
|
||||
和同事讨论之后,我们找到了可用率这个突破口,应该相对完美了。可用率的计算方式是某一个时间窗口内接口调用成功次数的百分比(成功次数/总调用次数)。当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
|
||||
|
||||
总结
|
||||
|
||||
这一讲我给你分享了RPC框架里面的一个核心的功能——健康检测,它能帮助我们从连接列表里面过滤掉一些存在问题的节点,避免在发请求的时候选择出有问题的节点而影响业务。但是在设计健康检测方案的时候,我们不能简单地从TCP连接是否健康、心跳是否正常等简单维度考虑,因为健康检测的目的就是要保证“业务无损”,所以在设计方案的时候,我们可以加入业务请求可用率因素,这样能最大化地提升RPC接口可用率。
|
||||
|
||||
正常情况下,我们大概30S会发一次心跳请求,这个间隔一般不会太短,如果太短会给服务节点造成很大的压力。但是如果太长的话,又不能及时摘除有问题的节点。
|
||||
|
||||
除了在RPC框架里面我们会有采用定时“健康检测”,其实在其它分布式系统设计的时候也会用到“心跳探活”机制。
|
||||
|
||||
比如在应用监控系统设计的时候,需要对不健康的应用实例进行报警,好让运维人员及时处理。和咱们RPC的例子一样,在这个场景里,你也不能简单地依赖端口的连通性来判断应用是否存活,因为在端口连通正常的情况下,应用也可能僵死了。
|
||||
|
||||
那有啥其他办法能处理应用僵死的情况吗?我们可以让每个应用实例提供一个“健康检测”的URL,检测程序定时通过构造HTTP请求访问该URL,然后根据响应结果来进行存活判断,这样就可以防止僵死状态的误判。你想想,这不就是咱们前面讲到的心跳机制吗?
|
||||
|
||||
不过,这个案例里,我还要卖个关子。加完心跳机制,是不是就没有问题了呢?当然不是,因为检测程序所在的机器和目标机器之间的网络可能还会出现故障,如果真出现了故障,不就会误判吗?你以为人家已经生病或者挂了,其实是心跳仪器坏了…
|
||||
|
||||
根据我的经验,有一个办法可以减少误判的几率,那就是把检测程序部署在多个机器里面,分布在不同的机架,甚至不同的机房。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。
|
||||
|
||||
课后思考
|
||||
|
||||
不知道看完今天的分享之后你有何感触,你在工作中会接触到健康检测的场景吗?你可以在留言区给我分享下你是怎么做的,或者给我的方案挑挑毛病,我会第一时间给你反馈。
|
||||
|
||||
当然,也欢迎你留言和我分享你的思考和疑惑,期待你能把今天的所学分享给身边的朋友,邀请他一同交流。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
77
专栏/RPC实战与核心原理/10路由策略:怎么让请求按照设定的规则发到不同的节点上?.md
Normal file
77
专栏/RPC实战与核心原理/10路由策略:怎么让请求按照设定的规则发到不同的节点上?.md
Normal file
@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 路由策略:怎么让请求按照设定的规则发到不同的节点上?
|
||||
你好,我是何小锋。上一讲我们介绍了健康检测在RPC中的作用,简单来讲就是帮助调用方应用来管理所有服务提供方的连接,并动态维护每个连接的状态,方便服务调用方在每次发起请求的时候都可以拿到一个可用的连接。回顾完上一讲的重点,我们就切入今天的主题——RPC中的路由策略。
|
||||
|
||||
为什么选择路由策略?
|
||||
|
||||
在前面我们提到过,在真实环境中我们的服务提供方是以一个集群的方式提供服务,这对于服务调用方来说,就是一个接口会有多个服务提供方同时提供服务,所以我们的RPC在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个用于发请求的节点。
|
||||
|
||||
既然这些节点都可以用来完成这次请求,那么我们就可以简单地认为这些节点是同质的。这里的同质怎么理解呢?就是这次请求无论发送到集合中的哪个节点上,返回的结果都是一样的。
|
||||
|
||||
既然服务提供方是以集群的方式对外提供服务,那就要考虑一些实际问题。要知道我们每次上线应用的时候都不止一台服务器会运行实例,那上线就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候,导致我们应用不稳定的因素就变得很多。
|
||||
|
||||
为了减少这种风险,我们一般会选择灰度发布我们的应用实例,比如我们可以先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例。
|
||||
|
||||
但这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。因为对于我们的服务提供方来说,我们的服务会同时提供给很多调用方来调用,尤其是像一些基础服务的调用方会更复杂,比如商品、价格等等,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。
|
||||
|
||||
那对于我们的RPC框架来说,有什么的办法可以减少上线变更导致的风险吗?这就不得不提路由在RPC中的应用。具体好在哪里,怎么实现,我们接着往下看。
|
||||
|
||||
如何实现路由策略?
|
||||
|
||||
可能你会说,我们可以在上线前把所有的场景都重新测试一遍啊?这也是一种方法,而且测试肯定是上线前的一个重要环节。但以我个人的经验来看,由于线上环境太复杂了,单纯从测试角度出发只能降低风险出现的概率,想要彻底验证所有场景基本是不可能的。
|
||||
|
||||
那如果没法100%规避风险,我们还能怎么办?我认为只有一条路可以尝试了,就是尽量减小上线出问题导致业务受损的范围。基于这个思路,我们是不是可以在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没问题后再接入其他调用方,从而实现流量隔离的效果。那在RPC框架里面我们具体该怎么实现呢?
|
||||
|
||||
我们在服务发现那讲讲过,在RPC里面服务调用方是通过服务发现的方式拿到了所有服务提供方的IP地址,那我们是不是就可以利用这个特点?当我们选择要灰度验证功能的时候,是不是就可以让注册中心在推送的时候区别对待,而不是一股脑的把服务提供方的IP地址推送到所有调用方。换句话说就是,注册中心只会把刚上线的服务IP地址推送到选择指定的调用方,而其他调用方是不能通过服务发现拿到这个IP地址的。
|
||||
|
||||
通过服务发现的方式来隔离调用方请求,从逻辑上来看确实可行,但注册中心在RPC里面的定位是用来存储数据并保证数据一致性的。如果把这种复杂的计算逻辑放到注册中心里面,当集群节点变多之后,就会导致注册中心压力很大,而且大部分情况下我们一般都是采用开源软件来搭建注册中心,要满足这种需求还需要进行二次开发。所以从实际的角度出发,通过影响服务发现来实现请求隔离并不划算。
|
||||
|
||||
那还有其他更合适的解决方案吗?在我给出方案前,你可以停下来思考下你的解决方案。
|
||||
|
||||
我们可以重新回到调用方发起RPC调用的流程。在RPC发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(就是我们常说的负载均衡),那我们是不是可以在选择节点前加上“筛选逻辑”,把符合我们要求的节点筛选出来。那这个筛选的规则是什么呢?就是我们前面说的灰度过程中要验证的规则。
|
||||
|
||||
举个具体例子你可能就明白了,比如我们要求新上线的节点只允许某个IP可以调用,那我们的注册中心会把这条规则下发到服务调用方。在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合,按照这个例子的逻辑,最后会过滤出一个节点,这个节点就是我们刚才新上线的节点。通过这样的改造,RPC调用流程就变成了这样:
|
||||
|
||||
|
||||
|
||||
这个筛选过程在我们的RPC里面有一个专业名词,就是“路由策略”,而上面例子里面的路由策略是我们常见的IP路由策略,用于限制可以调用服务提供方的IP。使用了IP路由策略后,整个集群的调用拓扑如下图所示:
|
||||
|
||||
|
||||
|
||||
参数路由
|
||||
|
||||
有了IP路由之后,上线过程中我们就可以做到只让部分调用方请求调用到新上线的实例,相对传统的灰度发布功能来说,这样做我们可以把试错成本降到最低。
|
||||
|
||||
但在有些场景下,我们可能还需要更细粒度的路由方式。比如,在升级改造应用的时候,为了保证调用方能平滑地切调用我们的新应用逻辑,在升级过程中我们常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了100%且运行一段时间后才能去下线老应用。
|
||||
|
||||
在流量切换的过程中,为了保证整个流程的完整性,我们必须保证某个主题对象的所有请求都使用同一种应用来承接。假设我们改造的是商品应用,那主题对象肯定是商品ID,在切流量的过程中,我们必须保证某个商品的所有操作都是用新应用(或者老应用)来完成所有请求的响应。
|
||||
|
||||
很显然,上面的IP路由并不能满足我们这个需求,因为IP路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。
|
||||
|
||||
那我们怎么利用路由策略实现这个需求呢?其实你只要明白路由策略的本质,就不难明白这种参数路由的实现。
|
||||
|
||||
我们可以给所有的服务提供方节点都打上标签,用来区分新老应用节点。在服务调用方发生请求的时候,我们可以很容易地拿到请求参数,也就是我们例子中的商品ID,我们可以根据注册中心下发的规则来判断当前商品ID的请求是过滤掉新应用还是老应用的节点。因为规则对所有的调用方都是一样的,从而保证对应同一个商品ID的请求要么是新应用的节点,要么是老应用的节点。使用了参数路由策略后,整个集群的调用拓扑如下图所示:
|
||||
|
||||
|
||||
|
||||
相比IP路由,参数路由支持的灰度粒度更小,他为服务提供方应用提供了另外一个服务治理的手段。灰度发布功能是RPC路由功能的一个典型应用场景,通过RPC路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。
|
||||
|
||||
总结
|
||||
|
||||
在日常工作中,我们几乎每天都在做线上变更,每次变更都有可能带来一次事故,为了降低事故发生的概率,我们不光要从流程上优化操作步骤,还要使我们的基础设施能支持更低的试错成本。
|
||||
|
||||
灰度发布功能作为RPC路由功能的一个典型应用场景,我们可以通过路由功能完成像定点调用、黑白名单等一些高级服务治理功能。在RPC里面,不管是哪种路由策略,其核心思想都是一样的,就是让请求按照我们设定的规则发送到目标节点上,从而实现流量隔离的效果。
|
||||
|
||||
课后思考
|
||||
|
||||
你在使用RPC的过程中,除了用路由策略实现过灰度发布、定点调用等功能,还用它完成过其他功能吗?
|
||||
|
||||
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
118
专栏/RPC实战与核心原理/11负载均衡:节点负载差距这么大,为什么收到的流量还一样?.md
Normal file
118
专栏/RPC实战与核心原理/11负载均衡:节点负载差距这么大,为什么收到的流量还一样?.md
Normal file
@ -0,0 +1,118 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 负载均衡:节点负载差距这么大,为什么收到的流量还一样?
|
||||
你好,我是何小锋。上一讲我讲解了“多场景的路由选择”,其核心就是“如何根据不同的场景控制选择合适的目标机器”。今天我们来聊一个新的话题,看看在RPC中如何实现负载均衡。
|
||||
|
||||
一个需求
|
||||
|
||||
在进入主题之前,我想先和你分享一个需求,这是我们公司的业务部门给我们提的。
|
||||
|
||||
他们反馈的问题是这样的:有一次碰上流量高峰,他们突然发现线上服务的可用率降低了,经过排查发现,是因为其中有几台机器比较旧了。当时最早申请的一批容器配置比较低,缩容的时候留下了几台,当流量达到高峰时,这几台容器由于负载太高,就扛不住压力了。业务问我们有没有好的服务治理策略?
|
||||
|
||||
|
||||
|
||||
这个问题其实挺好解决的,我们当时给出的方案是:在治理平台上调低这几台机器的权重,这样的话,访问的流量自然就减少了。
|
||||
|
||||
但业务接着反馈了,说:当他们发现服务可用率降低的时候,业务请求已经受到影响了,这时再如此解决,需要时间啊,那这段时间里业务可能已经有损失了。紧接着他们就提出了需求,问:RPC框架有没有什么智能负载的机制?能否及时地自动控制服务节点接收到的访问量?
|
||||
|
||||
这个需求其实很合理,这也是一个比较普遍的问题。确实,虽说我们的服务治理平台能够动态地控制线上服务节点接收的访问量,但当业务方发现部分机器负载过高或者响应变慢的时候再去调整节点权重,真的很可能已经影响到线上服务的可用率了。
|
||||
|
||||
看到这儿,你有没有想到什么好的处理方案呢?接下来,我们就以这个问题为背景,一起看看RPC框架的负载均衡。
|
||||
|
||||
什么是负载均衡?
|
||||
|
||||
我先来简单地介绍下负载均衡。当我们的一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共同分担请求压力的目的。
|
||||
|
||||
|
||||
|
||||
负载均衡主要分为软负载和硬负载,软负载就是在一台或多台服务器上安装负载均衡的软件,如LVS、Nginx等,硬负载就是通过硬件设备来实现的负载均衡,如F5服务器等。负载均衡的算法主要有随机法、轮询法、最小连接法等。
|
||||
|
||||
我刚才介绍的负载均衡主要还是应用在Web服务上,Web服务的域名绑定负载均衡的地址,通过负载均衡将用户的请求分发到一个个后端服务上。
|
||||
|
||||
RPC框架中的负载均衡
|
||||
|
||||
那RPC框架中的负载均衡是不是也是如此呢?和我上面讲的负载均衡,你觉得会有区别吗?
|
||||
|
||||
我们可以回想下[第 08 讲] 的开头:我讲到为什么不通过DNS来实现“服务发现”,之后我又讲解了为什么不采用添加负载均衡设备或者TCP/IP四层代理,域名绑定负载均衡设备的IP或者四层代理IP的方式。
|
||||
|
||||
我的回答是这种方式会面临这样几个问题:
|
||||
|
||||
|
||||
搭建负载均衡设备或TCP/IP四层代理,需要额外成本;
|
||||
请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费一些性能;
|
||||
负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作,“服务发现”在操作上是个问题;
|
||||
我们在服务治理的时候,针对不同接口服务、服务的不同分组,我们的负载均衡策略是需要可配的,如果大家都经过这一个负载均衡设备,就不容易根据不同的场景来配置不同的负载均衡策略了。
|
||||
|
||||
|
||||
我相信看到这儿,你应该已经知道了RPC实现的负载均衡所采用的策略与传统的Web服务实现负载均衡所采用策略的不同之处了。
|
||||
|
||||
RPC的负载均衡完全由RPC框架自身实现,RPC的服务调用者会与“注册中心”下发的所有服务节点建立长连接,在每次发起RPC调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起RPC调用请求。
|
||||
|
||||
|
||||
|
||||
RPC负载均衡策略一般包括随机权重、Hash、轮询。当然,这还是主要看RPC框架自身的实现。其中的随机权重策略应该是我们最常用的一种了,通过随机算法,我们基本可以保证每个节点接收到的请求流量是均匀的;同时我们还可以通过控制节点权重的方式,来进行流量控制。比如我们默认每个节点的权重都是100,但当我们把其中的一个节点的权重设置成50时,它接收到的流量就是其他节点的1/2。
|
||||
|
||||
这几种负载均衡算法的实现还是很简单的,网上资料也非常多,在这我就不过多介绍了。有什么问题,咱们可以在留言区交流。
|
||||
|
||||
由于负载均衡机制完全是由RPC框架自身实现的,所以它不再需要依赖任何负载均衡设备,自然也不会发生负载均衡设备的单点问题,服务调用方的负载均衡策略也完全可配,同时我们可以通过控制权重的方式,对负载均衡进行治理。
|
||||
|
||||
了解完RPC框架的负载均衡,现在我们就可以回到这讲最开头业务提的那个需求:有没有什么办法可以动态地、智能地控制线上服务节点所接收到的请求流量?
|
||||
|
||||
现在答案是不是就显而易见了,解决问题的关键就在于RPC框架的负载均衡上。对于这个问题,我们当时的方案就是,设计一种自适应的负载均衡策略。
|
||||
|
||||
如何设计自适应的负载均衡?
|
||||
|
||||
我刚才讲过,RPC的负载均衡完全由RPC框架自身实现,服务调用者发起请求时,会通过配置的负载均衡插件,自主地选择服务节点。那是不是只要调用者知道每个服务节点处理请求的能力,再根据服务处理节点处理请求的能力来判断要打给它多少流量就可以了?当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。
|
||||
|
||||
这就有点像日常工作中的分配任务,要多考虑实际情况。当一位下属身体欠佳,就少给他些工作;若刚好另一位下属状态很好,手头工作又不是很多,就多分给他一点。
|
||||
|
||||
那服务调用者节点又该如何判定一个服务节点的处理能力呢?
|
||||
|
||||
这里我们可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU核数、内存大小、请求处理的耗时指标(如请求平均耗时、TP99、TP999)、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数,比如总分10分,如果CPU负载达到70%,就减它3分,当然了,减3分只是个类比,需要减多少分是需要一个计算策略的。
|
||||
|
||||
我们又该如果根据这些指标来打分呢?
|
||||
|
||||
这就有点像公司对员工进行年终考核。假设我是老板,我要考核专业能力、沟通能力和工作态度,这三项的占比分别是30%、30%、40%,我给一个员工的评分是10、8、8,那他的综合分数就是这样计算的:10*30%+8*30%+8*40%=8.6分。
|
||||
|
||||
给服务节点打分也一样,我们可以为每个指标都设置一个指标权重占比,然后再根据这些指标数据,计算分数。
|
||||
|
||||
服务调用者给每个服务节点都打完分之后,会发送请求,那这时候我们又该如何根据分数去控制给每个服务节点发送多少流量呢?
|
||||
|
||||
我们可以配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重。例如给一个服务节点综合打分是8分(满分10分),服务节点的权重是100,那么计算后最终权重就是80(100*80%)。服务调用者发送请求时,会通过随机权重的策略来选择服务节点,那么这个节点接收到的流量就是其他正常节点的80%(这里假设其他节点默认权重都是100,且指标正常,打分为10分的情况)。
|
||||
|
||||
到这儿,一个自适应的负载均衡我们就完成了,整体的设计方案如下图所示:
|
||||
|
||||
|
||||
|
||||
关键步骤我来解释下:
|
||||
|
||||
|
||||
添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
|
||||
运行时状态指标收集器收集服务节点CPU核数、CPU负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取。
|
||||
请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999等。
|
||||
可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
|
||||
通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天我们详细讲解了RPC框架的负载均衡,它与Web服务的负载均衡的不同之处在于:RPC框架并不是依赖一个负载均衡设备或者负载均衡服务器来实现负载均衡的,而是由RPC框架本身实现的,服务调用者可以自主选择服务节点,发起服务调用。
|
||||
|
||||
这样的好处是,RPC框架不再需要依赖专门的负载均衡设备,可以节约成本;还减少了与负载均衡设备间额外的网络传输,提升了传输效率;并且均衡策略可配,便于服务治理。
|
||||
|
||||
除此之外,我们今天的重点还涉及到“如何设计一个自适应的负载均衡”,通过它,我们可以就能根据服务调用者依赖的服务集群中每个节点的自身状态,智能地控制发送给每个服务节点的请求流量,防止因某个服务节点负载过高、请求处理过慢而影响到整个服务集群的可用率。
|
||||
|
||||
这个自适应负载均衡的实现方案,其实不只是应用于RPC框架中的负载均衡,它本身便是一个智能负载的解决方案,如果你在工作中需要设计一个智能的负载均衡服务,那么完全可以参考。
|
||||
|
||||
课后思考
|
||||
|
||||
你知道RPC框架中还有哪些负载均衡策略吗?它们的优缺点是什么?期待你能在留言区中分享实现方法,与我共同探讨。
|
||||
|
||||
也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
97
专栏/RPC实战与核心原理/12异常重试:在约定时间内安全可靠地重试.md
Normal file
97
专栏/RPC实战与核心原理/12异常重试:在约定时间内安全可靠地重试.md
Normal file
@ -0,0 +1,97 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 异常重试:在约定时间内安全可靠地重试
|
||||
你好,我是何小锋。上一讲我讲解了在RPC框架中如何设计自适应的负载均衡,其关键点就是调用端收集服务端每个节点的指标数据,再根据各方面的指标数据进行计算打分,最后根据每个节点的分数,将更多的流量打到分数较高的节点上。
|
||||
|
||||
今天我们就继续下一个话题,讲讲RPC框架中的异常重试机制。
|
||||
|
||||
为什么需要异常重试?
|
||||
|
||||
我们可以考虑这样一个场景。我们发起一次RPC调用,去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,那这时我们要怎么做呢?
|
||||
|
||||
我们需要重新发起一次RPC调用,那我们在代码中该如何处理呢?是在代码逻辑里catch一下,失败了就再发起一次调用吗?这样做显然不够优雅吧。这时我们就可以考虑使用RPC框架的重试机制。
|
||||
|
||||
RPC框架的重试机制
|
||||
|
||||
那什么是RPC框架的重试机制呢?
|
||||
|
||||
这其实很好理解,就是当调用端发起的请求失败时,RPC框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。
|
||||
|
||||
那这个机制是如何实现的呢?
|
||||
|
||||
|
||||
|
||||
还是挺简单的。我们可以回想下[第 11 讲],通过这一讲我们了解到,调用端在发起RPC调用时,会经过负载均衡,选择一个节点,之后它会向这个节点发送请求信息。当消息发送失败或收到异常消息时,我们就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求消息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数的时候,就返回给调用端动态代理一个失败异常,否则就一直重试下去。
|
||||
|
||||
RPC框架的重试机制就是调用端发现请求失败时捕获异常,之后触发重试,那是不是所有的异常都要触发重试呢?
|
||||
|
||||
当然不是了,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给动态代理的,所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
|
||||
|
||||
了解了RPC框架的重试机制,那用户在使用异常重试时需要注意哪些问题呢?
|
||||
|
||||
比如我刚才提的那个调用场景,当网络突然抖动了一下导致请求超时了,但这个时候调用方的请求信息可能已经发送到服务提供方的节点上,也可能已经发送到服务提供方的服务节点上,那如果请求信息成功地发送到了服务节点上,那这个节点是不是就要执行业务逻辑了呢?是的。
|
||||
|
||||
那如果这个时候发起了重试,业务逻辑是否会被执行呢?会的。
|
||||
|
||||
那如果这个服务业务逻辑不是幂等的,比如插入数据操作,那触发重试的话会不会引发问题呢?会的。
|
||||
|
||||
综上,我们可以总结出:在使用RPC框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启RPC框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了。
|
||||
|
||||
通过上述讲解,我相信你已经非常清楚RPC框架的重试机制了,这也是现在大多数RPC框架所采用的重试机制。
|
||||
|
||||
那看到这儿,你觉得这个机制完善了吗?有没有想到连续重试对请求超时时间的影响?继续考虑这样一个场景:我把调用端的请求超时时间设置为5s,结果连续重试3次,每次都耗时2s,那最终这个请求的耗时是6s,那这样的话,调用端设置的超时时间是不是就不准确了呢?
|
||||
|
||||
如何在约定时间内安全可靠地重试?
|
||||
|
||||
我刚才讲到,连续的异常重试可能会出现一种不可靠的情况,那就是连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。
|
||||
|
||||
解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间。
|
||||
|
||||
当调用端发起RPC请求时,如果发送请求发生异常并触发了异常重试,我们可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。
|
||||
|
||||
那么解决了因多次异常重试引发的超时时间失效的问题,这个重试机制是不是就完全可靠了呢?
|
||||
|
||||
我们接着考虑,当调用端设置了异常重试策略,发起了一次RPC调用,通过负载均衡选择了节点,将请求消息发送到这个节点,这时这个节点由于负载压力较大,导致这个请求处理失败了,调用端触发了重试,再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点,那么在这种情况下,重试的效果是否受影响了呢?
|
||||
|
||||
当然有影响。因此,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。
|
||||
|
||||
那我们现在再完整地回顾一下,考虑了业务逻辑必须是幂等的、超时时间需要重置以及去掉有问题的服务节点后,这样的异常重试机制,还有没有可优化的地方呢?
|
||||
|
||||
我刚才讲过,RPC框架的异常重试机制,是调用端发送请求之后,如果发送失败会捕获异常,触发重试,但并不是所有的异常都会触发重试的,只有RPC框架中特定的异常才会如此,比如连接异常、超时异常。
|
||||
|
||||
而像服务端业务逻辑中抛回给调用端的异常是不能重试的。那么请你想一下这种情况:服务端的业务逻辑抛给调用端一个异常信息,而服务端抛出这个异常是允许调用端重新发起一次调用的。
|
||||
|
||||
比如这个场景:服务端的业务逻辑是对数据库某个数据的更新操作,更新失败则抛出个更新失败的异常,调用端可以再次调用,来触发服务端重新执行更新操作。那这个时候对于调用端来说,它接收到了更新失败异常,虽然是服务端抛回来的业务异常,但也是可以进行重试的。
|
||||
|
||||
那么在这种情况下,RPC框架的重试机制需要怎么优化呢?
|
||||
|
||||
RPC框架是不会知道哪些业务异常能够去进行异常重试的,我们可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,我们就可以采用这样的异常处理策略。如果这个异常是RPC框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,我们就允许对这个请求进行重试。
|
||||
|
||||
所有可能出现的问题,我们排查了一圈下来之后,一个可靠的重试机制就诞生了,如下图所示:
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天我们讲解了RPC框架的重试机制,还有如何在约定时间内进行安全可靠地重试。
|
||||
|
||||
这个机制是当调用端发起的请求失败时,如果配置了异常重试策略,RPC框架会捕捉异常,对异常进行判定,符合条件则进行重试,重新发送请求。
|
||||
|
||||
在重试的过程中,为了能够在约定的时间内进行安全可靠地重试,在每次触发重试之前,我们需要先判定下这个请求是否已经超时,如果超时了会直接返回超时异常,否则我们需要重置下这个请求的超时时间,防止因多次重试导致这个请求的处理时间超过用户配置的超时时间,从而影响到业务处理的耗时。
|
||||
|
||||
在发起重试、负载均衡选择节点的时候,我们应该去掉重试之前出现过问题的那个节点,这样可以提高重试的成功率,并且我们允许用户配置可重试异常的白名单,这样可以让RPC框架的异常重试功能变得更加友好。
|
||||
|
||||
另外,在使用RPC框架的重试机制时,我们要确保被调用的服务的业务逻辑是幂等的,这样才能考虑是否使用重试,这一点至关重要。
|
||||
|
||||
课后思考
|
||||
|
||||
请你思考一下,在整个RPC调用的流程中,异常重试发生在哪个环节?
|
||||
|
||||
欢迎留言分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
86
专栏/RPC实战与核心原理/13优雅关闭:如何避免服务停机带来的业务损失?.md
Normal file
86
专栏/RPC实战与核心原理/13优雅关闭:如何避免服务停机带来的业务损失?.md
Normal file
@ -0,0 +1,86 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 优雅关闭:如何避免服务停机带来的业务损失?
|
||||
你好,我是何小锋。上一讲我们讲了“异常重试”,总结来说,异常重试就是为了尽最大可能保证接口可用率的一种手段,但这种策略只能用在幂等接口上,否则就会因为重试导致应用系统数据“写花”。
|
||||
|
||||
接着昨天的内容,今天我们再来聊聊RPC中的关闭流程。
|
||||
|
||||
关闭为什么有问题?
|
||||
|
||||
我们知道,在“单体应用”复杂到一定程度后,我们一般会进行系统拆分,也就是时下流行的微服务架构。服务拆分之后,自然就需要协同,于是RPC框架就出来了,它用来解决各个子系统之间的通信问题。
|
||||
|
||||
我再倒回来问你一个非常基础的问题?你觉得系统为啥非要拆分呢?从我的角度,如果只说一个原因,我觉得拆分之后我们可以更方便、更快速地迭代业务。那么问题来了,更快速地迭代业务,说人话不就是我会经常更新应用系统,时不时还老要重启服务器吗?
|
||||
|
||||
那具体到我们的RPC体系里,你就要考虑,在重启服务的过程中,RPC怎么做到让调用方系统不出问题呢?
|
||||
|
||||
要想说明白这事,我们先要简述下上线的大概流程:当服务提供方要上线的时候,一般是通过部署系统完成实例重启。在这个过程中,服务提供方的团队并不会事先告诉调用方他们需要操作哪些机器,从而让调用方去事先切走流量。而对调用方来说,它也无法预测到服务提供方要对哪些机器重启上线,因此负载均衡就有可能把要正在重启的机器选出来,这样就会导致把请求发送到正在重启中的机器里面,从而导致调用方不能拿到正确的响应结果。
|
||||
|
||||
|
||||
|
||||
在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:
|
||||
|
||||
|
||||
调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。
|
||||
调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。
|
||||
|
||||
|
||||
关闭流程
|
||||
|
||||
当然还存在目标服务正在启动的情况,如何优雅地启动我会在下一讲详细地讲,这也是重点。今天我们要聚焦讨论的就是当出现第二种情况的时候,在RPC里面怎么避免调用方业务受损。
|
||||
|
||||
这时候你可能会想到,我是不是在重启服务机器前,先通过“某种方式”把要下线的机器从调用方维护的“健康列表”里面删除就可以了,这样负载均衡就选不到这个节点了?你说得一点都没错,但这个具体的“某种方式”是怎么完成呢?
|
||||
|
||||
最没有效率的办法就是人工通知调用方,让他们手动摘除要下线的机器,这种方式很原始也很直接。但这样对于提供方上线的过程来说太繁琐了,每次上线都要通知到所有调用我接口的团队,整个过程既浪费时间又没有意义,显然不能被正常接受。
|
||||
|
||||
这时候,可能你还会想到,RPC里面不是有服务发现吗?它的作用不就是用来“实时”感知服务提供方的状态吗?当服务提供方关闭前,是不是可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除?关闭流程如下图所示:
|
||||
|
||||
|
||||
|
||||
这样不就可以实现不通过“人肉”的方式,从而达到一种自动化方式,但这么做就能完全保证实现无损上下线吗?
|
||||
|
||||
如上图所示,整个关闭过程中依赖了两次RPC调用,一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,我们在“服务发现”一讲中讲过在大规模集群里面,服务发现只保证最终一致性,并不保证实时性,所以注册中心在收到服务提供方下线的时候,并不能成功保证把这次要下线的节点推送到所有的调用方。所以这么来看,通过服务发现并不能做到应用无损关闭。
|
||||
|
||||
不能强依赖“服务发现”来通知调用方要下线的机器,那服务提供方自己来通知行不行?因为在RPC里面调用方跟服务提供方之间是长连接,我们可以在提供方应用内存里面维护一份调用方连接集合,当服务要关闭的时候,挨个去通知调用方去下线这台机器。这样整个调用链路就变短了,对于每个调用方来说就一次RPC,可以确保调用的成功率很高。大部分场景下,这么做确实没有问题,我们之前也是这么实现的,但是我们发现线上还是会偶尔会出现,因为服务提供方上线而导致调用失败的问题。
|
||||
|
||||
那到底哪里出问题了呢?我后面分析了调用方请求日志跟收到关闭通知的日志,并且发现了一个线索如下:出问题请求的时间点跟收到服务提供方关闭通知的时间点很接近,只比关闭通知的时间早不到1ms,如果再加上网络传输时间的话,那服务提供方收到请求的时候,它应该正在处理关闭逻辑。这就说明服务提供方关闭的时候,并没有正确处理关闭后接收到的新请求。
|
||||
|
||||
优雅关闭
|
||||
|
||||
知道了根本原因,问题就很好解决了。因为服务提供方已经开始进入关闭流程,那么很多对象就可能已经被销毁了,关闭后再收到的请求按照正常业务请求来处理,肯定是没法保证能处理的。所以我们可以在关闭的时候,设置一个请求“挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。
|
||||
|
||||
如果大家经常去银行办理业务,就会很熟悉这个流程。在交接班或者有其他要事情处理的时候,银行柜台工作人员会拿出一个纸板,放在窗口前,上面写到“该窗口已关闭”。在该窗口排队的人虽然有一万个不愿意,也只能换到其它窗口办理业务,因为柜台工作人员会把当前正在办理的业务处理完后正式关闭窗口。
|
||||
|
||||
基于这个思路,我们可以这么处理:当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(比如ShutdownException)。这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,并没有处理这个请求”,然后调用方收到这个异常响应后,RPC框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。
|
||||
|
||||
但如果只是靠等待被动调用,就会让这个关闭过程整体有点漫长。因为有的调用方那个时刻没有业务请求,就不能及时地通知调用方了,所以我们可以加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。
|
||||
|
||||
说到这里,我知道你肯定会问,那要怎么捕获到关闭事件呢?
|
||||
|
||||
在我的经验里,可以通过捕获操作系统的进程信号来获取,在Java语言里面,对应的是Runtime.addShutdownHook方法,可以注册关闭的钩子。在RPC启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。
|
||||
|
||||
看到这里,感觉问题已经比较好地被解决了。但细心的同学可能还会提出问题,关闭过程中已经在处理的请求会不会受到影响呢?
|
||||
|
||||
如果进程结束过快会造成这些请求还没有来得及应答,同时调用方会也会抛出异常。为了尽可能地完成正在处理的请求,首先我们要把这些请求识别出来。这就好比日常生活中,我们经常看见停车场指示牌上提示还有多少剩余车位,这个是如何做到的呢?如果仔细观察一下,你就会发现它是每进入一辆车,剩余车位就减一,每出来一辆车,剩余车位就加一。我们也可以利用这个原理在服务对象加上引用计数器,每开始处理请求之前加一,完成请求处理减一,通过该计数器我们就可以快速判断是否有正在处理的请求。
|
||||
|
||||
服务对象在关闭过程中,会拒绝新的请求,同时根据引用计数器等待正在处理的请求全部结束之后才会真正关闭。但考虑到有些业务请求可能处理时间长,或者存在被挂住的情况,为了避免一直等待造成应用无法正常退出,我们可以在整个ShutdownHook里面,加上超时时间控制,当超过了指定时间没有结束,则强制退出应用。超时时间我建议可以设定成10s,基本可以确保请求都处理完了。整个流程如下图所示。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
在RPC里面,关闭虽然看似不属于RPC主流程,但如果我们不能处理得很好的话,可能就会导致调用方业务异常,从而需要我们加入很多额外的运维工作。一个好的关闭流程,可以确保使用我们框架的业务实现平滑的上下线,而不用担心重启导致的问题。
|
||||
|
||||
其实“优雅关闭”这个概念除了在RPC里面有,在很多框架里面也都挺常见的,比如像我们经常用的应用容器框架Tomcat。Tomcat关闭的时候也是先从外层到里层逐层进行关闭,先保证不接收新请求,然后再处理关闭前收到的请求。
|
||||
|
||||
课后思考
|
||||
|
||||
今天我只讲了优雅关闭,其实应用重启上下线的时候,还涉及到应用启动流程。那么如何做到优雅启动,避免请求分发到没有就绪的服务节点呢?请你先自行考虑一下,我会在下一讲给你细说。
|
||||
|
||||
当然,也欢迎你留言先和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
83
专栏/RPC实战与核心原理/14优雅启动:如何避免流量打到没有启动完成的节点?.md
Normal file
83
专栏/RPC实战与核心原理/14优雅启动:如何避免流量打到没有启动完成的节点?.md
Normal file
@ -0,0 +1,83 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 优雅启动:如何避免流量打到没有启动完成的节点?
|
||||
你好,我是何小锋。上一讲我们介绍了优雅停机,就是为了让服务提供方在停机应用的时候,保证所有调用方都能“安全”地切走流量,不再调用自己,从而做到对业务无损。其中实现的关键点就在于,让正在停机的服务提供方应用有状态,让调用方感知到服务提供方正在停机。
|
||||
|
||||
接着上一讲的内容,今天我们来聊聊优雅启动。
|
||||
|
||||
是不是很诧异?应用启动居然也要这么“讲究”吗?这就好比我们日常生活中的热车,行驶之前让发动机空跑一会,可以让汽车的各个部件都“热”起来,减小磨损。
|
||||
|
||||
换到应用上来看,原理也是一样的。运行了一段时间后的应用,执行速度会比刚启动的应用更快。这是因为在Java里面,在运行过程中,JVM虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到JVM缓存中,再次使用的时候不会触发临时加载,这样就使得“热点”代码的执行不用每次都通过解释,从而提升执行速度。
|
||||
|
||||
但是这些“临时数据”,都在我们应用重启后就消失了。重启后的这些“红利”没有了之后,如果让我们刚启动的应用就承担像停机前一样的流量,这会使应用在启动之初就处于高负载状态,从而导致调用方过来的请求可能出现大面积超时,进而对线上业务产生损害行为。
|
||||
|
||||
在上一讲我们说过,在微服务架构里面,上线肯定是频繁发生的,那我们总不能因为上线,就让过来的请求出现大面积超时吧?所以我们得想点办法。既然问题的关键是在于“刚重启的服务提供方因为没有预跑就承担了大流量”,那我们是不是可以通过某些方法,让应用一开始只接少许流量呢?这样低功率运行一段时间后,再逐渐提升至最佳状态。
|
||||
|
||||
这其实就是我今天要和你分享的重点,RPC里面的一个实用功能——启动预热。
|
||||
|
||||
启动预热
|
||||
|
||||
那什么叫启动预热呢?
|
||||
|
||||
简单来说,就是让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。
|
||||
|
||||
那在RPC里面,我们该怎么实现这个功能呢?
|
||||
|
||||
我们现在是要控制调用方发送到服务提供方的流量。我们可以先简单地回顾下调用方发起的RPC调用流程是怎样的,调用方应用通过服务发现能够获取到服务提供方的IP地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接。那这样的话,我们是不是就可以让负载均衡在选择连接的时候,区分一下是否是刚启动不久的应用?对于刚启动的应用,我们可以让它被选择到的概率特别低,但这个概率会随着时间的推移慢慢变大,从而实现一个动态增加流量的过程。
|
||||
|
||||
现在方案有了,我们就可以考虑具体实现了。
|
||||
|
||||
首先对于调用方来说,我们要知道服务提供方启动的时间,这个怎么获取呢?我这里给出两种方法,一种是服务提供方在启动的时候,把自己启动的时间告诉注册中心;另外一种就是注册中心收到的服务提供方的请求注册时间。这两个时间我认为都可以,不过可能你会犹豫我们该怎么确保所有机器的日期时间是一样的?这其实不用太关心,因为整个预热过程的时间是一个粗略值,即使机器之间的日期时间存在1分钟的误差也不影响,并且在真实环境中机器都会默认开启NTP时间同步功能,来保证所有机器时间的一致性。
|
||||
|
||||
不管你是选择哪个时间,最终的结果就是,调用方通过服务发现,除了可以拿到IP列表,还可以拿到对应的启动时间。我们需要把这个时间作用在负载均衡上,在[第 11 讲] 我们介绍过一种基于权重的负载均衡,但是这个权重是由服务提供方设置的,属于一个固定状态。现在我们要让这个权重变成动态的,并且是随着时间的推移慢慢增加到服务提供方设定的固定值,整个过程如下图所示:
|
||||
|
||||
|
||||
|
||||
通过这个小逻辑的改动,我们就可以保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处于高负载状态,从而实现服务提供方在启动后有一个预热的过程。
|
||||
|
||||
看到这儿,你可能还会有另外一个疑问,就是当我在大批量重启服务提供方的时候,会不会导致没有重启的机器因为扛的流量太大而出现问题?
|
||||
|
||||
关于这个问题,我是这么考虑的。当你大批量重启服务提供方的时候,对于调用方来说,这些刚重启的机器权重基本是一样的,也就是说这些机器被选中的概率是一样的,大家都是一样得低,也就不存在权重区分的问题了。但是对于那些没有重启过的应用提供方来说,它们被负载均衡选中的概率是相对较高的,但是我们可以通过[第 11 讲] 学到的自适应负载的方法平缓地切换,所以也是没有问题的。
|
||||
|
||||
启动预热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常水平,从而实现平滑上线。但对于服务提供方本身来说,有没有相关方案可以实现这种效果呢?
|
||||
|
||||
当然有,这也是我今天要分享的另一个重点,和热启动息息相关,那就是延迟暴露。
|
||||
|
||||
延迟暴露
|
||||
|
||||
我们应用启动的时候都是通过main入口,然后顺序加载各种相关依赖的类。以Spring应用启动为例,在加载的过程中,Spring容器会顺序加载Spring Bean,如果某个Bean是RPC服务的话,我们不光要把它注册到Spring-BeanFactory里面去,还要把这个Bean对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址的时候,会把这个地址推送到调用方应用内存中;当调用方收到这个服务提供方地址的时候,就会去建立连接发请求。
|
||||
|
||||
但这时候是不是存在服务提供方可能并没有启动完成的情况?因为服务提供方应用可能还在加载其它的Bean。对于调用方来说,只要获取到了服务提供方的IP,就有可能发起RPC调用,但如果这时候服务提供方没有启动完成的话,就会导致调用失败,从而使业务受损。
|
||||
|
||||
那有什么办法可以避免这种情况吗?
|
||||
|
||||
在解决问题前,我们先看下出现上述问题的根本原因。这是因为服务提供方应用在没有启动完成的时候,调用方的请求就过来了,而调用方请求过来的原因是,服务提供方应用在启动过程中把解析到的RPC服务注册到了注册中心,这就导致在后续加载没有完成的情况下服务提供方的地址就被服务调用方感知到了。
|
||||
|
||||
这样的话,其实我们就可以把接口注册到注册中心的时间挪到应用启动完成后。具体的做法就是在应用启动加载、解析Bean的时候,如果遇到了RPC服务的Bean,只先把这个Bean注册到Spring-BeanFactory里面去,而并不把这个Bean对应的接口注册到注册中心,只有等应用启动完成后,才把接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址。
|
||||
|
||||
这样是可以保证应用在启动完后才开始接入流量的,但其实这样做,我们还是没有实现最开始的目标。因为这时候应用虽然启动完成了,但并没有执行相关的业务代码,所以JVM内存里面还是冷的。如果这时候大量请求过来,还是会导致整个应用在高负载模式下运行,从而导致不能及时地返回请求结果。而且在实际业务中,一个服务的内部业务逻辑一般会依赖其它资源的,比如缓存数据。如果我们能在服务正式提供服务前,先完成缓存的初始化操作,而不是等请求来了之后才去加载,我们就可以降低重启后第一次请求出错的概率。
|
||||
|
||||
那具体怎么实现呢?
|
||||
|
||||
我们还是需要利用服务提供方把接口注册到注册中心的那段时间。我们可以在服务提供方应用启动后,接口注册到注册中心前,预留一个Hook过程,让用户可以实现可扩展的Hook逻辑。用户可以在Hook里面模拟调用逻辑,从而使JVM指令能够预热起来,并且用户也可以在Hook里面事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。整个应用启动过程如下图所示:
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
包括[第 11 讲] 在内,到今天为止,我们就已经把整个RPC里面的启停机流程都讲完了。就像前面说过的那样,虽然启停机流程看起来不属于RPC主流程,但是如果你能在RPC里面把这些“微小”的工作做好,就可以让你的技术团队感受到更多的微服务带来的好处。
|
||||
|
||||
另外,我们今天的两大重点——启动预热与延迟暴露,它们并不是RPC的专属功能,我们在开发其它系统时,也可以利用这两点来减少冷启动对业务的影响。
|
||||
|
||||
课后思考
|
||||
|
||||
在启动预热那部分,我们特意提到过一个问题,就是“当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住”,不知道你是怎么看待这个问题的,是否有好的解决方案呢?
|
||||
|
||||
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
99
专栏/RPC实战与核心原理/15熔断限流:业务如何实现自我保护_.md
Normal file
99
专栏/RPC实战与核心原理/15熔断限流:业务如何实现自我保护_.md
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 熔断限流:业务如何实现自我保护_
|
||||
你好,我是何小锋。上一讲我讲解了RPC框架中的优雅启动,这块的重点就是启动预热与延迟暴露。今天,我们换一个新的话题,看看在使用RPC时,业务是如何实现自我保护的。
|
||||
|
||||
为什么需要自我保护?
|
||||
|
||||
我在开篇词中说过,RPC是解决分布式系统通信问题的一大利器,而分布式系统的一大特点就是高并发,所以说RPC也会面临高并发的场景。在这样的情况下,我们提供服务的每个服务节点就都可能由于访问量过大而引起一系列的问题,比如业务处理耗时过长、CPU飘高、频繁Full GC以及服务进程直接宕机等等。但是在生产环境中,我们要保证服务的稳定性和高可用性,这时我们就需要业务进行自我保护,从而保证在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。
|
||||
|
||||
那么在使用RPC时,业务又如何实现自我保护呢?
|
||||
|
||||
最常见的方式就是限流了,简单有效,但RPC框架的自我保护方式可不只有限流,并且RPC框架的限流方式可以是多种多样的。
|
||||
|
||||
我们可以将RPC框架拆开来分析,RPC调用包括服务端和调用端,调用端向服务端发起调用。下面我就分享一下服务端与调用端分别是如何进行自我保护的。
|
||||
|
||||
服务端的自我保护
|
||||
|
||||
我们先看服务端,举个例子,假如我们要发布一个RPC服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,我们该如何保护这个节点?
|
||||
|
||||
|
||||
|
||||
这个问题还是很好解决的,既然负载压力高,那就不让它再接收太多的请求就好了,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了。
|
||||
|
||||
那么就是限流吧?是的,在RPC调用中服务端的自我保护策略就是限流,那你有没有想过我们是如何实现限流的呢?是在服务端的业务逻辑中做限流吗?有没有更优雅的方式?
|
||||
|
||||
限流是一个比较通用的功能,我们可以在RPC框架中集成限流的功能,让使用方自己去配置限流阈值;我们还可以在服务端添加限流逻辑,当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑。
|
||||
|
||||
|
||||
|
||||
那服务端的限流逻辑又该如何实现呢?
|
||||
|
||||
方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。上述这几种限流算法我就不一一讲解了,资料很多,不太清楚的话自行查阅下就可以了。
|
||||
|
||||
我们可以假设下这样一个场景:我发布了一个服务,提供给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其它的应用大很多,这时我们就应该对这个应用下的调用端发送过来的请求流量进行限流。所以说我们在做限流的时候要考虑应用级别的维度,甚至是IP级别的维度,这样做不仅可以让我们对一个应用下的调用端发送过来的请求流量做限流,还可以对一个IP发送过来的请求流量做限流。
|
||||
|
||||
这时你可能会想,使用方该如何配置应用维度以及IP维度的限流呢?在代码中配置是不是不大方便?我之前说过,RPC框架真正强大的地方在于它的治理功能,而治理功能大多都需要依赖一个注册中心或者配置中心,我们可以通过RPC治理的管理端进行配置,再通过注册中心或者配置中心将限流阈值的配置下发到服务提供方的每个节点上,实现动态配置。
|
||||
|
||||
看到这儿,你有没有发现,在服务端实现限流,配置的限流阈值是作用在每个服务节点上的。比如说我配置的阈值是每秒1000次请求,那么就是指一台机器每秒处理1000次请求;如果我的服务集群拥有10个服务节点,那么我提供的服务限流阈值在最理想的情况下就是每秒10000次。
|
||||
|
||||
接着看这样一个场景:我提供了一个服务,而这个服务的业务逻辑依赖的是MySQL数据库,由于MySQL数据库的性能限制,我们是需要对其进行保护。假如在MySQL处理业务逻辑中,SQL语句的能力是每秒10000次,那么我们提供的服务处理的访问量就不能超过每秒10000次,而我们的服务有10个节点,这时我们配置的限流阈值应该是每秒1000次。那如果之后因为某种需求我们对这个服务扩容了呢?扩容到20个节点,我们是不是就要把限流阈值调整到每秒500次呢?这样操作每次都要自己去计算,重新配置,显然太麻烦了。
|
||||
|
||||
我们可以让RPC框架自己去计算,当注册中心或配置中心将限流阈值配置下发的时候,我们可以将总服务节点数也下发给服务节点,之后由服务节点自己计算限流阈值,这样就解决问题了吧?
|
||||
|
||||
解决了一部分,还有一个问题存在,那就是在实际情况下,一个服务节点所接收到的访问量并不是绝对均匀的,比如有20个节点,而每个节点限流的阈值是500,其中有的节点访问量已经达到阈值了,但有的节点可能在这一秒内的访问量是450,这时调用端发送过来的总调用量还没有达到10000次,但可能也会被限流,这样是不是就不精确了?那有没有比较精确的限流方式呢?
|
||||
|
||||
我刚才讲解的限流方式之所以不精确,是因为限流逻辑是服务集群下的每个节点独立去执行的,是一种单机的限流方式,而且每个服务节点所接收到的流量并不是绝对均匀的。
|
||||
|
||||
我们可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。
|
||||
|
||||
这种限流方式可以让整个服务集群的限流变得更加精确,但也由于依赖了一个限流服务,它在性能和耗时上与单机的限流方式相比是有很大劣势的。至于要选择哪种限流方式,就要结合具体的应用场景进行选择了。
|
||||
|
||||
调用端的自我保护
|
||||
|
||||
刚才我讲解了服务端如何进行自我保护,最简单有效的方式就是限流。那么调用端呢?调用端是否需要自我保护呢?
|
||||
|
||||
举个例子,假如我要发布一个服务B,而服务B又依赖服务C,当一个服务A来调用服务B时,服务B的业务逻辑调用服务C,而这时服务C响应超时了,由于服务B依赖服务C,C超时直接导致B的业务逻辑一直等待,而这个时候服务A在频繁地调用服务B,服务B就可能会因为堆积大量的请求而导致服务宕机。
|
||||
|
||||
|
||||
|
||||
由此可见,服务B调用服务C,服务C执行业务逻辑出现异常时,会影响到服务B,甚至可能会引起服务B宕机。这还只是A->B->C的情况,试想一下A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。
|
||||
|
||||
所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断。
|
||||
|
||||
|
||||
|
||||
我们可以先了解下熔断机制。
|
||||
|
||||
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。
|
||||
|
||||
了解完熔断机制,你就会发现,在业务逻辑中加入熔断器其实是不够优雅的。那么在RPC框架中,我们该如何整合熔断器呢?
|
||||
|
||||
熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。我们可以回想下RPC的调用流程:
|
||||
|
||||
|
||||
|
||||
你看图的话,有没有想到在哪个步骤整合熔断器会比较合适呢?
|
||||
|
||||
我的建议是动态代理,因为在RPC调用的流程中,动态代理是RPC调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。
|
||||
|
||||
总结
|
||||
|
||||
今天我们主要讲解了RPC框架是如何实现业务的自我保护。
|
||||
|
||||
服务端主要是通过限流来进行自我保护,我们在实现限流时要考虑到应用和IP级别,方便我们在服务治理的时候,对部分访问量特别大的应用进行合理的限流;服务端的限流阈值配置都是作用于单机的,而在有些场景下,例如对整个服务设置限流阈值,服务进行扩容时,限流的配置并不方便,我们可以在注册中心或配置中心下发限流阈值配置的时候,将总服务节点数也下发给服务节点,让RPC框架自己去计算限流阈值;我们还可以让RPC框架的限流模块依赖一个专门的限流服务,对服务设置限流阈值进行精准地控制,但是这种方式依赖了限流服务,相比单机的限流方式,在性能和耗时上有劣势。
|
||||
|
||||
调用端可以通过熔断机制进行自我保护,防止调用下游服务出现异常,或者耗时过长影响调用端的业务逻辑,RPC框架可以在动态代理的逻辑中去整合熔断器,实现RPC框架的熔断功能。
|
||||
|
||||
课后思考
|
||||
|
||||
在使用RPC的过程中业务要实现自我保护,针对这个问题你是否还有其他的解决方案?
|
||||
|
||||
欢迎留言分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
73
专栏/RPC实战与核心原理/16业务分组:如何隔离流量?.md
Normal file
73
专栏/RPC实战与核心原理/16业务分组:如何隔离流量?.md
Normal file
@ -0,0 +1,73 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 业务分组:如何隔离流量?
|
||||
你好,我是何小锋。上一讲我们介绍了RPC中常用的保护手段“熔断限流”,熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。虽然这两种手段作用的对象不同,但出发点都是为了实现自我保护,所以一旦发生这种行为,业务都是有损的。
|
||||
|
||||
那说起突发流量,限流固然是一种手段,但其实面对复杂的业务以及高并发场景时,我们还有别的手段,可以最大限度地保障业务无损,那就是隔离流量。这也是我今天重点要和你分享的内容,接下来我们就一起看看分组在RPC中的应用。
|
||||
|
||||
为什么需要分组?
|
||||
|
||||
在我们的日常开发中,我们不都提倡让用户使用起来越简单越好吗?如果在接口上再加一个分组维度去管理,不就让事情变复杂了吗?
|
||||
|
||||
实则不然,举个例子。在没有汽车的年代,我们的道路很简单,就一条,行人、洋车都在上边走。那随着汽车的普及以及猛增,我们的道路越来越宽,慢慢地有了高速、辅路、人行道等等。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。
|
||||
|
||||
同样的道理,我们用在RPC治理上也是一样的。假设你是一个服务提供方应用的负责人,在早期业务量不大的情况下,应用之间的调用关系并不会复杂,请求量也不会很大,我们的应用有足够的能力扛住日常的所有流量。我们并不需要花太多的时间去治理调用请求过来的流量,我们通常会选择最简单的方法,就是把服务实例统一管理,把所有的请求都用一个共享的“大池子”来处理。这就类似于“简单道路时期”,服务调用方跟服务提供方之间的调用拓扑如下图所示:
|
||||
|
||||
|
||||
|
||||
后期因为业务发展丰富了,调用你接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降。而这时候作为应用负责人的你,那就得变身“救火队长”了,要想尽各种办法来保证应用的稳定。
|
||||
|
||||
在经过一系列的救火操作后,我们肯定要去想更好的应对办法。那回到问题的根本去看,关键就在于,早期为了管理方便,我们把接口都放到了同一个分组下面,所有的服务实例是以一个整体对外提供能力的。
|
||||
|
||||
但后期因为业务发展,这种粗暴的管理模式已经不适用了,这就好比“汽车来了,我们的交通网也得抓紧建设”一样,让人车分流。此时,道路上的人和车就好比我们应用的调用方,我们可以尝试把应用提供方这个大池子划分出不同规格的小池子,再分配给不同的调用方,而不同小池子之间的隔离带,就是我们在RPC里面所说的分组,它可以实现流量隔离。
|
||||
|
||||
怎么实现分组?
|
||||
|
||||
现在分组是怎么回事我们搞清楚了,那放到RPC里我们该怎么实现呢?
|
||||
|
||||
既然是要求不同的调用方应用能拿到的池子内容不同,那我们就要回想下服务发现了,因为在RPC流程里,能影响到调用方获取服务节点的逻辑就是它了。
|
||||
|
||||
在[第 08 讲] 我们说过,服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。
|
||||
|
||||
通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?
|
||||
|
||||
坦白讲,这个分组并没有一个可衡量的标准,但我自己总结了一个规则可以供你参考,就是按照应用重要级别划分。
|
||||
|
||||
非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。比如提供给电商下单过程中用的商品信息接口,我们肯定是需要独立出一个单独分组,避免受其它调用方污染的。有了分组之后,我们的服务调用方跟服务提供方之间的调用拓扑就如下图所示:
|
||||
|
||||
|
||||
|
||||
通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是我们日常治理服务过程中一个高频使用的手段,那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?
|
||||
|
||||
如何实现高可用?
|
||||
|
||||
分组隔离后,单个调用方在发RPC请求的时候可选择的服务节点数相比没有分组前减少了,那对于单个调用方来说,出错的概率就升高了。比如一个集中交换机设备突然坏了,而这个调用方的所有服务节点都在这个交换机下面,在这种情况下对于服务调用方来说,它的请求无论如何也到达不了服务提供方,从而导致这个调用方业务受损。
|
||||
|
||||
那有没有更高可用一点的方案呢?回到我们前面说的那个马路例子上,正常情况下我们是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,我们一般都是允许对方借道行驶一段时间,直到道路完全恢复。
|
||||
|
||||
我们同样可以把这个特性用到我们的RPC中,要怎么实现呢?
|
||||
|
||||
在前面我们也说了,调用方应用服务发现的时候,除了带上对应的接口名,还需要带上一个特定分组名,所以对于调用方来说,它是拿不到其它分组的服务节点的,那这样的话调用方就没法建立起连接发请求了。
|
||||
|
||||
因此问题的核心就变成了调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了。一个最简单的办法就是,允许调用方可以配置多个分组。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现我们想要的“借道”的效果。
|
||||
|
||||
所以我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。
|
||||
|
||||
总结
|
||||
|
||||
今天我们通过一个道路划分的案例,引出了在RPC里面我们可以通过分组的方式人为地给不同的调用方划分出不同的小集群,从而实现调用方流量隔离的效果,保障我们的核心业务不受非核心业务的干扰。但我们在考虑问题的时候,不能顾此失彼,不能因为新加一个的功能而影响到原有系统的稳定性。
|
||||
|
||||
其实我们不仅可以通过分组把服务提供方划分成不同规模的小集群,我们还可以利用分组完成一个接口多种实现的功能。正常情况下,为了方便我们自己管理服务,我一般都会建议每个接口完成的功能尽量保证唯一。但在有些特殊场景下,两个接口也会完全一样,只是具体实现上有那么一点不同,那么我们就可以在服务提供方应用里面同时暴露两个相同接口,但只是接口分组不一样罢了。
|
||||
|
||||
课后思考
|
||||
|
||||
在我们的实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,你有什么好办法吗?
|
||||
|
||||
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
121
专栏/RPC实战与核心原理/17异步RPC:压榨单机吞吐量.md
Normal file
121
专栏/RPC实战与核心原理/17异步RPC:压榨单机吞吐量.md
Normal file
@ -0,0 +1,121 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 异步RPC:压榨单机吞吐量
|
||||
你好,我是何小锋。从今天开始,我们就正式进入高级篇了。
|
||||
|
||||
在上个篇章,我们学习了RPC框架的基础架构和一系列治理功能,以及一些与集群管理相关的高级功能,如服务发现、健康检查、路由策略、负载均衡、优雅启停机等等。
|
||||
|
||||
有了这些知识储备,你就已经对RPC框架有了较为充分的认识。但如果你想要更深入地了解RPC,更好地使用RPC,你就必须从RPC框架的整体性能上去考虑问题了。你得知道如何去提升RPC框架的性能、稳定性、安全性、吞吐量,以及如何在分布式的场景下快速定位问题等等,这些都是我们在高级篇中重点要讲解的内容。难度有一定提升,希望你能坚持学习呀!
|
||||
|
||||
那么今天我们就先来讲讲,RPC框架是如何压榨单机吞吐量的。
|
||||
|
||||
如何提升单机吞吐量?
|
||||
|
||||
在我运营RPC的过程中,“如何提升吞吐量”是我与业务团队经常讨论的问题。
|
||||
|
||||
记得之前业务团队反馈过这样一个问题:我们的TPS始终上不去,压测的时候CPU压到40%~50%就再也压不上去了,TPS也不会提高,问我们这里有没有什么解决方案可以提升业务的吞吐量?
|
||||
|
||||
之后我是看了下他们服务的业务逻辑,发现他们的业务逻辑在执行较为耗时的业务逻辑的基础上,又同步调用了好几个其它的服务。由于这几个服务的耗时较长,才导致这个服务的业务逻辑耗时也长,CPU大部分的时间都在等待,并没有得到充分地利用,因此CPU的利用率和服务的吞吐量当然上不去了。
|
||||
|
||||
那是什么影响到了RPC调用的吞吐量呢?
|
||||
|
||||
在使用RPC的过程中,谈到性能和吞吐量,我们的第一反应就是选择一款高性能、高吞吐量的RPC框架,那影响到RPC调用的吞吐量的根本原因是什么呢?
|
||||
|
||||
其实根本原因就是由于处理RPC请求比较耗时,并且CPU大部分的时间都在等待而没有去计算,从而导致CPU的利用率不够。这就好比一个人在干活,但他没有规划好时间,并且有很长一段时间都在闲着,当然也就完不成太多工作了。
|
||||
|
||||
那么导致RPC请求比较耗时的原因主要是在于RPC框架本身吗?事实上除非在网络比较慢或者使用方使用不当的情况下,否则,在大多数情况下,刨除业务逻辑处理的耗时时间,RPC本身处理请求的效率就算在比较差的情况下也不过是毫秒级的。可以说RPC请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢SQL的操作。所以说,在大多数情况下,影响到RPC调用的吞吐量的原因也就是业务逻辑处理慢了,CPU大部分时间都在等待资源。
|
||||
|
||||
弄明白了原因,咱们就可以解决问题了,该如何去提升单机吞吐量?
|
||||
|
||||
这并不是一个新话题,比如现在我们经常提到的响应式开发,就是为了能够提升业务处理的吞吐量。要提升吞吐量,其实关键就两个字:“异步”。我们的RPC框架要做到完全异步化,实现全异步RPC。试想一下,如果我们每次发送一个异步请求,发送请求过后请求即刻就结束了,之后业务逻辑全部异步执行,结果异步通知,这样可以增加多么可观的吞吐量?
|
||||
|
||||
效果不用我说我想你也清楚了。那RPC框架都有哪些异步策略呢?
|
||||
|
||||
调用端如何异步?
|
||||
|
||||
说到异步,我们最常用的方式就是返回Future对象的Future方式,或者入参为Callback对象的回调方式,而Future方式可以说是最简单的一种异步方式了。我们发起一次异步请求并且从请求上下文中拿到一个Future,之后我们就可以调用Future的get方法获取结果。
|
||||
|
||||
就比如刚才我提到的业务团队的那个问题,他们的业务逻辑中调用了好几个其它的服务,这时如果是同步调用,假设调用了4个服务,每个服务耗时10毫秒,那么业务逻辑执行完至少要耗时40毫秒。
|
||||
|
||||
那如果采用Future方式呢?
|
||||
|
||||
连续发送4次异步请求并且拿到4个Future,由于是异步调用,这段时间的耗时几乎可以忽略不计,之后我们统一调用这几个Future的get方法。这样一来的话,业务逻辑执行完的时间在理想的情况下是多少毫秒呢?没错,10毫秒,耗时整整缩短到了原来的四分之一,也就是说,我们的吞吐量有可能提升4倍!
|
||||
|
||||
|
||||
|
||||
那RPC框架的Future方式异步又该如何实现呢?
|
||||
|
||||
通过基础篇的学习,我们了解到,一次RPC调用的本质就是调用端向服务端发送一条请求消息,服务端收到消息后进行处理,处理之后响应给调用端一条响应消息,调用端收到响应消息之后再进行处理,最后将最终的返回值返回给动态代理。
|
||||
|
||||
这里我们可以看到,对于调用端来说,向服务端发送请求消息与接收服务端发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行。那么是不是说RPC框架的调用端,对于RPC调用的处理逻辑,内部实现就是异步的呢?
|
||||
|
||||
不错,对于RPC框架,无论是同步调用还是异步调用,调用端的内部实现都是异步的。
|
||||
|
||||
通过[第 02 讲] 我们知道,调用端发送的每条消息都一个唯一的消息标识,实际上调用端向服务端发送请求消息之前会先创建一个Future,并会存储这个消息标识与这个Future的映射,动态代理所获得的返回值最终就是从这个Future中获取的;当收到服务端响应的消息时,调用端会根据响应消息的唯一标识,通过之前存储的映射找到对应的Future,将结果注入给那个Future,再进行一系列的处理逻辑,最后动态代理从Future中获得到正确的返回值。
|
||||
|
||||
所谓的同步调用,不过是RPC框架在调用端的处理逻辑中主动执行了这个Future的get方法,让动态代理等待返回值;而异步调用则是RPC框架没有主动执行这个Future的get方法,用户可以从请求上下文中得到这个Future,自己决定什么时候执行这个Future的get方法。
|
||||
|
||||
|
||||
|
||||
现在你应该很清楚RPC框架是如何实现Future方式的异步了。
|
||||
|
||||
如何做到RPC调用全异步?
|
||||
|
||||
刚才我讲解了Future方式的异步,Future方式异步可以说是调用端异步的一种方式,那么服务端呢?服务端是否需要异步,有什么实现方式?
|
||||
|
||||
通过基础篇的学习,我们了解到RPC服务端接收到请求的二进制消息之后会根据协议进行拆包解包,之后将完整的消息进行解码并反序列化,获得到入参参数之后再通过反射执行业务逻辑。那你有没有想过,在生产环境中这些操作都在哪个线程中执行呢?是在一个线程中执行吗?
|
||||
|
||||
当然不会在一个,对二进制消息数据包拆解包的处理是一定要在处理网络IO的线程中,如果网络通信框架使用的是Netty框架,那么对二进制包的处理是在IO线程中,而解码与反序列化的过程也往往在IO线程中处理,那服务端的业务逻辑呢?也应该在IO线程中处理吗?原则上是不应该的,业务逻辑应该交给专门的业务线程池处理,以防止由于业务逻辑处理得过慢而影响到网络IO的处理。
|
||||
|
||||
这时问题就来了,我们配置的业务线程池的线程数都是有限制的,在我运营RPC的经验中,业务线程池的线程数一般只会配置到200,因为在大多数情况下线程数配置到200还不够用就说明业务逻辑该优化了。那么如果碰到特殊的业务场景呢?让配置的业务线程池完全打满了,比如这样一个场景。
|
||||
|
||||
我这里启动一个服务,业务逻辑处理得就是比较慢,当访问量逐渐变大时,业务线程池很容易就被打满了,吞吐量很不理想,并且这时CPU的利用率也很低。
|
||||
|
||||
对于这个问题,你有没有想到什么解决办法呢?是不是会马上想到调大业务线程池的线程数?那这样可以吗?有没有更好的解决方式呢?
|
||||
|
||||
我想服务端业务处理逻辑异步是个好方法。
|
||||
|
||||
调大业务线程池的线程数,的确勉强可以解决这个问题,但是对于RPC框架来说,往往都会有多个服务共用一个线程池的情况,即使调大业务线程池,比较耗时的服务很可能还会影响到其它的服务。所以最佳的解决办法是能够让业务线程池尽快地释放,那么我们就需要RPC框架能够支持服务端业务逻辑异步处理,这对提高服务的吞吐量有很重要的意义。
|
||||
|
||||
那服务端如何支持业务逻辑异步呢?
|
||||
|
||||
这是个比较难处理的问题,因为服务端执行完业务逻辑之后,要对返回值进行序列化并且编码,将消息响应给调用端,但如果是异步处理,业务逻辑触发异步之后方法就执行完了,来不及将真正的结果进行序列化并编码之后响应给调用端。
|
||||
|
||||
这时我们就需要RPC框架提供一种回调方式,让业务逻辑可以异步处理,处理完之后调用RPC框架的回调接口,将最终的结果通过回调的方式响应给调用端。
|
||||
|
||||
说到服务端支持业务逻辑异步处理,结合我刚才讲解的Future方式异步,你有没有想到更好的处理方式呢?其实我们可以让RPC框架支持CompletableFuture,实现RPC调用在调用端与服务端之间完全异步。
|
||||
|
||||
CompletableFuture是Java8原生支持的。试想一下,假如RPC框架能够支持CompletableFuture,我现在发布一个RPC服务,服务接口定义的返回值是CompletableFuture对象,整个调用过程会分为这样几步:
|
||||
|
||||
|
||||
服务调用方发起RPC调用,直接拿到返回值CompletableFuture对象,之后就不需要任何额外的与RPC框架相关的操作了(如我刚才讲解Future方式时需要通过请求上下文获取Future的操作),直接就可以进行异步处理;
|
||||
在服务端的业务逻辑中创建一个返回值CompletableFuture对象,之后服务端真正的业务逻辑完全可以在一个线程池中异步处理,业务逻辑完成之后再调用这个CompletableFuture对象的complete方法,完成异步通知;
|
||||
调用端在收到服务端发送过来的响应之后,RPC框架再自动地调用调用端拿到的那个返回值CompletableFuture对象的complete方法,这样一次异步调用就完成了。
|
||||
|
||||
|
||||
通过对CompletableFuture的支持,RPC框架可以真正地做到在调用端与服务端之间完全异步,同时提升了调用端与服务端的两端的单机吞吐量,并且CompletableFuture是Java8原生支持,业务逻辑中没有任何代码入侵性,这是不是很酷炫了?
|
||||
|
||||
总结
|
||||
|
||||
今天我们主要讲解了如果通过RPC的异步去压榨单机的吞吐量。
|
||||
|
||||
影响到RPC调用的吞吐量的主要原因就是服务端的业务逻辑比较耗时,并且CPU大部分时间都在等待而没有去计算,导致CPU利用率不够,而提升单机吞吐量的最好办法就是使用异步RPC。
|
||||
|
||||
RPC框架的异步策略主要是调用端异步与服务端异步。调用端的异步就是通过Future方式实现异步,调用端发起一次异步请求并且从请求上下文中拿到一个Future,之后通过Future的get方法获取结果,如果业务逻辑中同时调用多个其它的服务,则可以通过Future的方式减少业务逻辑的耗时,提升吞吐量。服务端异步则需要一种回调方式,让业务逻辑可以异步处理,之后调用RPC框架提供的回调接口,将最终结果异步通知给调用端。
|
||||
|
||||
另外,我们可以通过对CompletableFuture的支持,实现RPC调用在调用端与服务端之间的完全异步,同时提升两端的单机吞吐量。
|
||||
|
||||
其实,RPC框架也可以有其它的异步策略,比如集成RxJava,再比如gRPC的StreamObserver入参对象,但CompletableFuture是Java8原生提供的,无代码入侵性,并且在使用上更加方便。如果是Java开发,让RPC框架支持CompletableFuture可以说是最佳的异步解决方案。
|
||||
|
||||
课后思考
|
||||
|
||||
对于RPC调用提升吞吐量这个问题,你是否还有其它的解决方案?你还能想到哪些RPC框架的异步策略?
|
||||
|
||||
欢迎留言分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
87
专栏/RPC实战与核心原理/18安全体系:如何建立可靠的安全体系?.md
Normal file
87
专栏/RPC实战与核心原理/18安全体系:如何建立可靠的安全体系?.md
Normal file
@ -0,0 +1,87 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 安全体系:如何建立可靠的安全体系?
|
||||
你好,我是何小锋。上一讲我们学习了在RPC里面该如何提升单机资源的利用率,你要记住的关键点就一个,那就是“异步化”。调用方利用异步化机制实现并行调用多个服务,以缩短整个调用时间;而服务提供方则可以利用异步化把业务逻辑放到自定义线程池里面去执行,以提升单机的OPS。
|
||||
|
||||
回顾完上一讲的重点,我们就切入今天的主题,一起来看看RPC里面的安全问题。
|
||||
|
||||
为什么需要考虑安全问题?
|
||||
|
||||
说起安全问题,你可能会想到像SQL注入、XSS攻击等恶意攻击行为,还有就是相对更广义的安全,像网络安全、信息安全等,那在RPC里面我们说的安全一般指什么呢?
|
||||
|
||||
我们知道RPC是解决应用间互相通信的框架,而应用之间的远程调用过程一般不会暴露在公网,换句话讲就是说RPC一般用于解决内部应用之间的通信,而这个“内部”是指应用都部署在同一个大局域网内。相对于公网环境,局域网的隔离性更好,也就相对更安全,所以在RPC里面我们很少考虑像数据包篡改、请求伪造等恶意行为。
|
||||
|
||||
那在RPC里面我们应该关心什么样的安全问题呢?要搞清楚这个问题,我们可以先看一个完整的RPC应用流程。
|
||||
|
||||
我们一般是先由服务提供方定义好一个接口,并把这个接口的Jar包发布到私服上去,然后在项目中去实现这个接口,最后通过RPC提供的API把这个接口和其对应的实现类完成对外暴露,如果是Spring应用的话直接定义成一个Bean就好了。到这儿,服务提供方就完成了一个接口的对外发布了。
|
||||
|
||||
对于服务调用方来说就更简单了,只要拿到刚才上传到私服上的Jar的坐标,就可以把发布到私服的Jar引入到项目中来,然后借助RPC提供的动态代理功能,服务调用方直接就可以在项目完成RPC调用了。
|
||||
|
||||
这里面其实存在一个安全隐患问题,因为私服上所有的Jar坐标我们所有人都可以看到,只要拿到了Jar的坐标,我们就可以把发布到私服的Jar引入到项目中完成RPC调用了吗?
|
||||
|
||||
理论上确实是这样,当然我相信在公司内部这种不向服务提供方咨询就直接调用的行为很少发生,而且一般真实业务的接口出入参数都不会太简单,这样不经过咨询只靠调用方自己猜测完成调用的工作效率实在太低了。
|
||||
|
||||
虽然这种靠猜测调用的概率很小,但是当调用方在其它新业务场景里面要用之前项目中使用过的接口,就很有可能真的不跟服务提供方打招呼就直接调用了。这种行为对于服务提供方来说就很危险了,因为接入了新的调用方就意味着承担的调用量会变大,有时候很有可能新增加的调用量会成为压倒服务提供方的“最后一根稻草”,从而导致服务提供方无法正常提供服务,关键是服务提供方还不知道是被谁给压倒的。
|
||||
|
||||
当然你可能会说,这是一个流程问题,我们只要在公司内部规范好调用流程,就可以避免这种问题发生了。
|
||||
|
||||
确实是这样,我们可以通过流程宣贯让我们所有的研发人员达成一个“君子约定”,就是在应用里面每次要用一个接口的时候必须先向服务提供方进行报备,这样确实能在很大程度上避免这种情况的发生。但就RPC本身来说,我们是不是可以提供某种功能来解决这种问题呢?毕竟对于人数众多的团队来说,光靠口头约定的流程并不能彻底杜绝这类问题,依然存在隐患,且不可控。
|
||||
|
||||
调用方之间的安全保证
|
||||
|
||||
那在RPC里面,我们该怎么解决这种问题呢?
|
||||
|
||||
我们先总结下刚才的问题,根本原因就是服务提供方收到请求后,不知道这次请求是哪个调用方发起的,没法判断这次请求是属于之前打过招呼的调用方还是没有打过招呼的调用方,所以也就没法选择拒绝这次请求还是继续执行。
|
||||
|
||||
问题说明白了就好解决了,我们只需要给每个调用方设定一个唯一的身份,每个调用方在调用之前都先来服务提供方这登记下身份,只有登记过的调用方才能继续放行,没有登记过的调用方一律拒绝。
|
||||
|
||||
这就好比我们平时坐火车,我们拿着身份证去购买火车票,买票成功就类似服务调用方去服务提供方这儿进行登记。当你进站准备上火车的时候,你必须同时出示你的身份证和火车票,这两个就是代表你能上这趟火车的“唯一身份”,只有验证了身份,负责检票的工作人员才会让你上车,否则会直接拒绝你乘车。
|
||||
|
||||
现在方案有了,那在RPC里面我们该怎么实现呢?
|
||||
|
||||
首先我们要有一个可以供调用方进行调用接口登记的地方,我们姑且称这个地方为“授权平台”,调用方可以在授权平台上申请自己应用里面要调用的接口,而服务提供方则可以在授权平台上进行审批,只有服务提供方审批后调用方才能调用。但这只是解决了调用数据收集的问题,并没有完成真正的授权认证功能,缺少一个检票的环节。
|
||||
|
||||
既然有了刚搭建的授权平台,而且接口的授权数据也在这个平台上,我们自然就很容易想到是不是可以把这个检票的环节放到这个授权平台上呢?调用方每次发起业务请求的时候先去发一条认证请求到授权平台上,就说:“哥们儿,我能调用这个接口吗?”只有授权平台返回“没问题”后才继续把业务请求发送到服务提供方那去。整个流程如下图所示:
|
||||
|
||||
|
||||
|
||||
从使用功能的角度来说,目前这种设计是没有问题的,而且整个认证过程对RPC使用者来说也是透明的。但有一个问题就是这个授权平台承担了公司内所有RPC请求的次数总和,当公司内部RPC使用程度高了之后,这个授权平台就会成为一个瓶颈点,而且必须保证超高可用,一旦这个授权平台出现问题,影响的可就是全公司的RPC请求了。
|
||||
|
||||
可能你会说我们可以改进下,我们是不是不需要把这个认证的逻辑放到业务请求过程中,而是可以把这个认证过程挪到初始化过程中呢?这样确实可以在很大程度上减少授权平台的压力,但本质并没有发生变化,还是一个集中式的授权平台。
|
||||
|
||||
我们可以想一个更优雅一点的方案。
|
||||
|
||||
其实调用方能不能调用相关接口,是由服务提供方说了算,我服务提供方认为你是可以的,你就肯定能调,那我们是不是就可以把这个检票过程放到服务提供方里面呢?在调用方启动初始化接口的时候,带上授权平台上颁发的身份去服务提供方认证下,当认证通过后就认为这个接口可以调用。
|
||||
|
||||
现在新的问题又来了,服务提供方验票的时候对照的数据来自哪儿,我总不能又去请求授权平台吧?否则就又会遇到和前面方案一样的问题。
|
||||
|
||||
你还记得我们加密算法里面有一种叫做不可逆加密算法吗?HMAC就是其中一种具体实现。服务提供方应用里面放一个用于HMAC签名的私钥,在授权平台上用这个私钥为申请调用的调用方应用进行签名,这个签名生成的串就变成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,我们只要需要验证下这个签名跟调用方应用信息是否对应得上就行了,这样集中式授权的瓶颈也就不存在了。
|
||||
|
||||
服务发现也有安全问题?
|
||||
|
||||
好,现在我们已经解决了调用方之间的安全认证问题。那在RPC里面,我们还有其它的安全问题吗?
|
||||
|
||||
回到我们上面说的那个完整的RPC应用流程里面,服务提供方会把接口Jar发布到私服上,以方便调用方能引入到项目中快速完成RPC调用,那有没有可能有人拿到你这个Jar后,发布出来一个服务提供方呢?这样的后果就是导致调用方通过服务发现拿到的服务提供方IP地址集合里面会有那个伪造的提供方。
|
||||
|
||||
当然,这种情况相对上面说的调用方未经过咨询就直接调用的概率会小很多,但为了让我们的系统整体更安全,我们也需要在RPC里面考虑这种情况。要解决这个问题的根本就是需要把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口。
|
||||
|
||||
那怎么实现呢?在[第 08 讲] 我们提到过,服务提供方启动的时候,需要把接口实例在注册中心进行注册登记。我们就可以利用这个流程,注册中心可以在收到服务提供方注册请求的时候,验证下请求过来的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。
|
||||
|
||||
总结
|
||||
|
||||
安全问题在任何一个领域都很重要,但又经常被我们忽视,只有每次出安全事故后,我们才会意识到安全防护的重要性。所以在日常写代码的过程中,我们一定要保持一个严谨的态度,防止细小错误引入线上安全问题。
|
||||
|
||||
虽然RPC经常用于解决内网应用之间的调用,内网环境相对公网也没有那么恶劣,但我们也有必要去建立一套可控的安全体系,去防止一些错误行为。对于RPC来说,我们所关心的安全问题不会有公网应用那么复杂,我们只要保证让服务调用方能拿到真实的服务提供方IP地址集合,且服务提供方可以管控调用自己的应用就够了。
|
||||
|
||||
课后思考
|
||||
|
||||
前面讲的调用方之间的安全问题,我们更多只是解决认证问题,并没有解决权限问题。在现实开发过程中,一个RPC接口定义里面一般会包含多个方法,但我们目前只是解决了你能不能调用接口的问题,并没有解决你能调用我接口里面的哪些方法。像这种问题,你有什么好方案吗?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
111
专栏/RPC实战与核心原理/19分布式环境下如何快速定位问题?.md
Normal file
111
专栏/RPC实战与核心原理/19分布式环境下如何快速定位问题?.md
Normal file
@ -0,0 +1,111 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 分布式环境下如何快速定位问题?
|
||||
你好,我是何小锋。上一讲我们学习了如何建立可靠的安全体系,关键点就是“鉴权”,我们可以通过统一的鉴权服务动态生成秘钥,提高RPC调用的安全性。
|
||||
|
||||
回顾完上一讲的重点,我们就切入今天的主题,一起看看RPC在分布式环境下如何快速定位问题。重要性看字面也是不言而喻了,只有准确地定位问题,我们才能更好地解决问题。
|
||||
|
||||
分布式环境下定位问题有哪些困难?
|
||||
|
||||
在此之前,我想先请你想想,在开发以及生产环境运行的过程中,如果遇见问题,我们是如何定位的?
|
||||
|
||||
在开发过程中遇见问题其实很好排查,我们可以用IDE在自己本地的开发环境中运行一遍代码,进行debug,在这个过程中是很容易找到问题的。
|
||||
|
||||
那换到生产环境,代码在线上运行业务,我们是不能进行debug的,这时我们就可以通过打印日志来查看当前的异常日志,这也是最简单有效的一种方式了。事实上,大部分问题的定位我们也是这样做的。
|
||||
|
||||
那么如果是在分布式的生产环境中呢?比如下面这个场景:
|
||||
|
||||
我们搭建了一个分布式的应用系统,在这个应用系统中,我启动了4个子服务,分别是服务A、服务B、服务C与服务D,而这4个服务的依赖关系是A->B->C->D,而这些服务又都部署在不同的机器上。在RPC调用中,如果服务端的业务逻辑出现了异常,就会把异常抛回给调用端,那么如果现在这个调用链中有一个服务出现了异常,我们该如何定位问题呢?
|
||||
|
||||
|
||||
|
||||
可能你的第一反应仍然是打印日志,好,那就打印日志吧。
|
||||
|
||||
假如这时我们发现服务A出现了异常,那这个异常有没有可能是因为B或C或D出现了异常抛回来的呢?当然很有可能。那我们怎么确定在整个应用系统中,是哪一个调用步骤出现的问题,以及是在这个步骤中的哪台机器出现的问题呢?我们该在哪台机器上打印日志?而且为了排查问题,如果要打印日志,我们就必须要修改代码,这样的话我们就得重新对服务进行上线。如果这几个服务又恰好是跨团队跨部门的呢?想想我们要面临的沟通成本吧。
|
||||
|
||||
所以你看,分布式环境下定位问题的难点就在于,各子应用、子服务间有着复杂的依赖关系,我们有时很难确定是哪个服务的哪个环节出现的问题。简单地通过日志排查问题,就要对每个子应用、子服务逐一进行排查,很难一步到位;若恰好再赶上跨团队跨部门,那不死也得去半条命了。
|
||||
|
||||
如何做到快速定位问题?
|
||||
|
||||
明白了难点,我们其实就可以有针对性地去攻克它了。有关RPC在分布式环境下如何快速定位问题,我给出两个方法,很实用。
|
||||
|
||||
方法1:借助合理封装的异常信息
|
||||
|
||||
我们前面说是因为各子应用、子服务间复杂的依赖关系,所以通过日志难定位问题。那我们就想办法通过日志定位到是哪个子应用的子服务出现问题就行了。
|
||||
|
||||
其实,在RPC框架打印的异常信息中,是包括定位异常所需要的异常信息的,比如是哪类异常引起的问题(如序列化问题或网络超时问题),是调用端还是服务端出现的异常,调用端与服务端的IP是什么,以及服务接口与服务分组都是什么等等。具体如下图所示:
|
||||
|
||||
|
||||
|
||||
这样的话,在A->B->C->D这个过程中,我们就可以很快地定位到是C服务出现了问题,服务接口是com.demo.CSerivce,调用端IP是192.168.1.2,服务端IP是192.168.1.3,而出现问题的原因就是业务线程池满了。
|
||||
|
||||
由此可见,一款优秀的RPC框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档。使用方可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因;并且异常信息中要包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端与服务端的IP,以及产生异常的原因。总之就是,要让使用方在复杂的分布式应用系统中,根据异常信息快速地定位到问题。
|
||||
|
||||
以上是对于RPC框架本身的异常来说的,比如序列化异常、响应超时异常、连接异常等等。那服务端业务逻辑的异常呢?服务提供方提供的服务的业务逻辑也要封装自己的业务异常信息,从而让服务调用方也可以通过异常信息快速地定位到问题。
|
||||
|
||||
方法2:借助分布式链路跟踪
|
||||
|
||||
无论是RPC框架本身,还是服务提供方提供的服务,只要对异常信息进行合理地封装,就可以让我们在分布式环境下定位问题变得更加容易。那这样是不是就满足我们定位问题的需求了呢?
|
||||
|
||||
我们还是回到前面提过的那个分布式场景:我们搭建了一个分布式的应用系统,它由4个子服务组成,4个服务的依赖关系为A->B->C->D。
|
||||
|
||||
假设这4个服务分别由来自不同部门的4个同事维护,在A调用B的时候,维护服务A的同事可能是不知道存在服务C和服务D的,对于服务A来说,它的下游服务只有B服务,那这时如果服务C或服务D出现异常,最终在整个链路中将异常抛给A了呢?
|
||||
|
||||
在这种情况下维护服务A的同事该如何定位问题呢?
|
||||
|
||||
因为对于A来说,它可能是不知道下游存在服务C和服务D的,所以维护服务A的同事会直接联系维护服务B的同事,之后维护服务B的同事会继续联系下游服务的服务提供方,直到找到问题。可这样做成本很高啊!
|
||||
|
||||
现在我们换个思路,其实我们只要知道整个请求的调用链路就可以了。服务A调用下游服务B,服务B又调用了B依赖的下游服务,如果维护服务A的同事能清楚地知道整个调用链路,并且能准确地发现在整个调用链路中是哪个环节出现了问题,那就好了。
|
||||
|
||||
这就好比我们收发快递,我们可以在平台上看到快递配送的轨迹,实时获知快递在何时到达了哪个站点,这样当我们没有准时地收到快递时,我们马上就能知道快递是在哪里延误了。
|
||||
|
||||
在分布式环境下,要想知道服务调用的整个链路,我们可以用“分布式链路跟踪”。
|
||||
|
||||
先介绍下分布式链路跟踪系统。从字面上理解,分布式链路跟踪就是将一次分布式请求还原为一个完整的调用链路,我们可以在整个调用链路中跟踪到这一次分布式请求的每一个环节的调用情况,比如调用是否成功,返回什么异常,调用的哪个服务节点以及请求耗时等等。
|
||||
|
||||
这样如果我们发现服务调用出现问题,通过这个方法,我们就能快速定位问题,哪怕是多个部门合作,也可以一步到位。
|
||||
|
||||
紧接着,我们再看看在RPC框架中是如何整合分布式链路跟踪的?
|
||||
|
||||
分布式链路跟踪有Trace与Span的概念,什么意思呢,我逐一解释。
|
||||
|
||||
Trace就是代表整个链路,每次分布式都会产生一个Trace,每个Trace都有它的唯一标识即TraceId,在分布式链路跟踪系统中,就是通过TraceId来区分每个Trace的。
|
||||
|
||||
Span就是代表了整个链路中的一段链路,也就是说Trace是由多个Span组成的。在一个Trace下,每个Span也都有它的唯一标识SpanId,而Span是存在父子关系的。还是以讲过的例子为例子,在A->B->C->D的情况下,在整个调用链中,正常情况下会产生3个Span,分别是Span1(A->B)、Span2(B->C)、Span3(C->D),这时Span3的父Span就是Span2,而Span2的父Span就是Span1。
|
||||
|
||||
Trace与Span的关系如下图所示:
|
||||
|
||||
|
||||
|
||||
分布式链路跟踪系统的实现方式有很多,但它们都脱离不开我刚才说的Trace和Span,这两点可以说非常重要,掌握了这两个概念,其实你就掌握了大部分实现方式的原理。接着我们看看在RPC框架中如何利用这两个概念去整合分布式链路跟踪。
|
||||
|
||||
RPC在整合分布式链路跟踪需要做的最核心的两件事就是“埋点”和“传递”。
|
||||
|
||||
所谓“埋点”就是说,分布式链路跟踪系统要想获得一次分布式调用的完整的链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过RPC框架对分布式链路跟踪进行埋点。
|
||||
|
||||
RPC调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的Span,而这个链路的源头会记录一个完整的Trace,最终Trace信息会被上报给分布式链路跟踪系统。
|
||||
|
||||
那所谓“传递”就是指,上游调用端将Trace信息与父Span信息传递给下游服务的服务端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统中,每个子Span都存有父Span的相关信息以及Trace的相关信息。
|
||||
|
||||
总结
|
||||
|
||||
今天我们讲解了在分布式环境下如何快速定位问题。这里面的难点就是分布式系统有着较为复杂的依赖关系,我们很难判断出是哪个环节出现的问题,而且在大型的分布式系统中,往往会有跨部门、跨团队合作的情况,在排查问题的时候会面临非常高的沟通成本。
|
||||
|
||||
为了在分布式环境下能够快速地定位问题,RPC框架应该对框架自身的异常进行详细地封装,每类异常都要有明确的异常标识码,并将其整理成一份简明的文档,异常信息中要尽量包含服务接口名、服务分组、调用端与服务端的IP,以及产生异常的原因等信息,这样对于使用方来说就非常便捷了。
|
||||
|
||||
另外,服务提供方在提供服务时也要对异常进行封装,以方便上游排查问题。
|
||||
|
||||
在分布式环境下,我们可以通过分布式链路跟踪来快速定位问题,尤其是在多个部门的合作中,这样做可以一步到位,减少排查问题的时间,降低沟通成本,以最高的效率解决实际问题。
|
||||
|
||||
课后思考
|
||||
|
||||
在分布式环境下,你还知道哪些快速定位问题的方法?
|
||||
|
||||
期待你能在留言区中和我分享,也欢迎你把文章分享给你的朋友,邀请他加入学习,共同探讨。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
112
专栏/RPC实战与核心原理/20详解时钟轮在RPC中的应用.md
Normal file
112
专栏/RPC实战与核心原理/20详解时钟轮在RPC中的应用.md
Normal file
@ -0,0 +1,112 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 详解时钟轮在RPC中的应用
|
||||
你好,我是何小锋。上一讲我们学习了在分布式环境下如何快速定位问题,简单回顾下重点。在分布式环境下,RPC框架自身以及服务提供方的业务逻辑实现,都应该对异常进行合理地封装,让使用方可以根据异常快速地定位问题;而在依赖关系复杂且涉及多个部门合作的分布式系统中,我们也可以借助分布式链路跟踪系统,快速定位问题。
|
||||
|
||||
现在,切换到咱们今天的主题,一起看看时钟轮在RPC中的应用。
|
||||
|
||||
定时任务带来了什么问题?
|
||||
|
||||
在讲解时钟轮之前,我们先来聊聊定时任务。相信你在开发的过程中,很多场景都会使用到定时任务,在RPC框架中也有很多地方会使用到它。就以调用端请求超时的处理逻辑为例,下面我们看一下RPC框架是如果处理超时请求的。
|
||||
|
||||
回顾下[第 17 讲],我讲解Future的时候说过:无论是同步调用还是异步调用,调用端内部实行的都是异步,而调用端在向服务端发送消息之前会创建一个Future,并存储这个消息标识与这个Future的映射,当服务端收到消息并且处理完毕后向调用端发送响应消息,调用端在接收到消息后会根据消息的唯一标识找到这个Future,并将结果注入给这个Future。
|
||||
|
||||
那在这个过程中,如果服务端没有及时响应消息给调用端呢?调用端该如何处理超时的请求?
|
||||
|
||||
没错,就是可以利用定时任务。每次创建一个Future,我们都记录这个Future的创建时间与这个Future的超时时间,并且有一个定时任务进行检测,当这个Future到达超时时间并且没有被处理时,我们就对这个Future执行超时逻辑。
|
||||
|
||||
那定时任务该如何实现呢?
|
||||
|
||||
有种实现方式是这样的,也是最简单的一种。每创建一个Future我们都启动一个线程,之后sleep,到达超时时间就触发请求超时的处理逻辑。
|
||||
|
||||
这种方式吧,确实简单,在某些场景下也是可以使用的,但弊端也是显而易见的。就像刚才我讲的那个Future超时处理的例子,如果我们面临的是高并发的请求,单机每秒发送数万次请求,请求超时时间设置的是5秒,那我们要创建多少个线程用来执行超时任务呢?超过10万个线程,这个数字真的够吓人了。
|
||||
|
||||
别急,我们还有另一种实现方式。我们可以用一个线程来处理所有的定时任务,还以刚才那个Future超时处理的例子为例。假设我们要启动一个线程,这个线程每隔100毫秒会扫描一遍所有的处理Future超时的任务,当发现一个Future超时了,我们就执行这个任务,对这个Future执行超时逻辑。
|
||||
|
||||
这种方式我们用得最多,它也解决了第一种方式线程过多的问题,但其实它也有明显的弊端。
|
||||
|
||||
同样是高并发的请求,那么扫描任务的线程每隔100毫秒要扫描多少个定时任务呢?如果调用端刚好在1秒内发送了1万次请求,这1万次请求要在5秒后才会超时,那么那个扫描的线程在这个5秒内就会不停地对这1万个任务进行扫描遍历,要额外扫描40多次(每100毫秒扫描一次,5秒内要扫描近50次),很浪费CPU。
|
||||
|
||||
在我们使用定时任务时,它所带来的问题,就是让CPU做了很多额外的轮询遍历操作,浪费了CPU,这种现象在定时任务非常多的情况下,尤其明显。
|
||||
|
||||
什么是时钟轮?
|
||||
|
||||
这个问题也不难解决,我们只要找到一种方式,减少额外的扫描操作就行了。比如我的一批定时任务是5秒之后执行,我在4.9秒之后才开始扫描这批定时任务,这样就大大地节省了CPU。这时我们就可以利用时钟轮的机制了。
|
||||
|
||||
我们先来看下我们生活中用到的时钟。
|
||||
|
||||
|
||||
|
||||
很熟悉了吧,时钟有时针、分针和秒针,秒针跳动一周之后,也就是跳动60个刻度之后,分针跳动1次,分针跳动60个刻度,时针走动一步。
|
||||
|
||||
而时钟轮的实现原理就是参考了生活中的时钟跳动的原理。
|
||||
|
||||
|
||||
|
||||
在时钟轮机制中,有时间槽和时钟轮的概念,时间槽就相当于时钟的刻度,而时钟轮就相当于秒针与分针等跳动的一个周期,我们会将每个任务放到对应的时间槽位上。
|
||||
|
||||
时钟轮的运行机制和生活中的时钟也是一样的,每隔固定的单位时间,就会从一个时间槽位跳到下一个时间槽位,这就相当于我们的秒针跳动了一次;时钟轮可以分为多层,下一层时钟轮中每个槽位的单位时间是当前时间轮整个周期的时间,这就相当于1分钟等于60秒钟;当时钟轮将一个周期的所有槽位都跳动完之后,就会从下一层时钟轮中取出一个槽位的任务,重新分布到当前的时钟轮中,当前时钟轮则从第0槽位从新开始跳动,这就相当于下一分钟的第1秒。
|
||||
|
||||
为了方便你了解时钟轮的运行机制,我们用一个场景例子来模拟下,一起看下这个场景。
|
||||
|
||||
假设我们的时钟轮有10个槽位,而时钟轮一轮的周期是1秒,那么我们每个槽位的单位时间就是100毫秒,而下一层时间轮的周期就是10秒,每个槽位的单位时间也就是1秒,并且当前的时钟轮刚初始化完成,也就是第0跳,当前在第0个槽位。
|
||||
|
||||
|
||||
|
||||
好,现在我们有3个任务,分别是任务A(90毫秒之后执行)、任务B(610毫秒之后执行)与任务C(1秒610毫秒之后执行),我们将这3个任务添加到时钟轮中,任务A被放到第0槽位,任务B被放到第6槽位,任务C被放到下一层时间轮的第1槽位,如下面这张图所示。
|
||||
|
||||
|
||||
|
||||
当任务A刚被放到时钟轮,就被即刻执行了,因为它被放到了第0槽位,而当前时间轮正好跳到第0槽位(实际上还没开始跳动,状态为第0跳);600毫秒之后,时间轮已经进行了6跳,当前槽位是第6槽位,第6槽位所有的任务都被取出执行;1秒钟之后,当前时钟轮的第9跳已经跳完,从新开始了第0跳,这时下一层时钟轮从第0跳跳到了第1跳,将第1槽位的任务取出,分布到当前的时钟轮中,这时任务C从下一层时钟轮中取出并放到当前时钟轮的第6槽位;1秒600毫秒之后,任务C被执行。
|
||||
|
||||
|
||||
|
||||
看完了这个场景,相信你对时钟轮的机制已经有所了解了。在这个例子中,时钟轮的扫描周期仍是100毫秒,但是其中的任务并没有被过多的重复扫描,它完美地解决了CPU浪费的问题。
|
||||
|
||||
这个机制其实不难理解,但实现起来还是很有难度的,其中要注意的问题也很多。具体的代码实现我们这里不展示,这又是另外一个比较大的话题了。有兴趣的话你可以自行查阅下相关源码,动手实现一下。到哪里卡住了,我们可以在留言区交流。
|
||||
|
||||
时钟轮在RPC中的应用
|
||||
|
||||
通过刚才对时钟轮的讲解,相信你可以看出,它就是用来执行定时任务的,可以说在RPC框架中只要涉及到定时相关的操作,我们就可以使用时钟轮。
|
||||
|
||||
那么RPC框架在哪些功能实现中会用到它呢?
|
||||
|
||||
刚才我举例讲到的调用端请求超时处理,这里我们就可以应用到时钟轮,我们每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的CPU。
|
||||
|
||||
调用端与服务端启动超时也可以应用到时钟轮,以调用端为例,假设我们想要让应用可以快速地部署,例如1分钟内启动,如果超过1分钟则启动失败。我们可以在调用端启动时创建一个处理启动超时的定时任务,放到时钟轮里。
|
||||
|
||||
除此之外,你还能想到RPC框架在哪些地方可以应用到时钟轮吗?还有定时心跳。RPC框架调用端定时向服务端发送心跳,来维护连接状态,我们可以将心跳的逻辑封装为一个心跳任务,放到时钟轮里。
|
||||
|
||||
这时你可能会有一个疑问,心跳是要定时重复执行的,而时钟轮中的任务执行一遍就被移除了,对于这种需要重复执行的定时任务我们该如何处理呢?在定时任务的执行逻辑的最后,我们可以重设这个任务的执行时间,把它重新丢回到时钟轮里。
|
||||
|
||||
总结
|
||||
|
||||
今天我们主要讲解了时钟轮的机制,以及时钟轮在RPC框架中的应用。
|
||||
|
||||
这个机制很好地解决了定时任务中,因每个任务都创建一个线程,导致的创建过多线程的问题,以及一个线程扫描所有的定时任务,让CPU做了很多额外的轮询遍历操作而浪费CPU的问题。
|
||||
|
||||
时钟轮的实现机制就是模拟现实生活中的时钟,将每个定时任务放到对应的时间槽位上,这样可以减少扫描任务时对其它时间槽位定时任务的额外遍历操作。
|
||||
|
||||
在时间轮的使用中,有些问题需要你额外注意:
|
||||
|
||||
|
||||
时间槽位的单位时间越短,时间轮触发任务的时间就越精确。例如时间槽位的单位时间是10毫秒,那么执行定时任务的时间误差就在10毫秒内,如果是100毫秒,那么误差就在100毫秒内。
|
||||
时间轮的槽位越多,那么一个任务被重复扫描的概率就越小,因为只有在多层时钟轮中的任务才会被重复扫描。比如一个时间轮的槽位有1000个,一个槽位的单位时间是10毫秒,那么下一层时间轮的一个槽位的单位时间就是10秒,超过10秒的定时任务会被放到下一层时间轮中,也就是只有超过10秒的定时任务会被扫描遍历两次,但如果槽位是10个,那么超过100毫秒的任务,就会被扫描遍历两次。
|
||||
|
||||
|
||||
结合这些特点,我们就可以视具体的业务场景而定,对时钟轮的周期和时间槽数进行设置。
|
||||
|
||||
在RPC框架中,只要涉及到定时任务,我们都可以应用时钟轮,比较典型的就是调用端的超时处理、调用端与服务端的启动超时以及定时心跳等等。
|
||||
|
||||
课后思考
|
||||
|
||||
在RPC框架中,除了我说过的那几个例子,你还知道有哪些功能的实现可以应用到时钟轮?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
65
专栏/RPC实战与核心原理/21流量回放:保障业务技术升级的神器.md
Normal file
65
专栏/RPC实战与核心原理/21流量回放:保障业务技术升级的神器.md
Normal file
@ -0,0 +1,65 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 流量回放:保障业务技术升级的神器
|
||||
你好,我是何小锋。上一讲我们学习了时钟轮在RPC中的应用,核心原理就一个关键字“分而治之”,我们可以把它用在任何需要高效处理大量定时任务的场景中,最具有代表性的就是在高并发场景下的请求超时检测。
|
||||
|
||||
回顾完上一讲的重点,我们就进入咱们今天的主题,一起看看流量回放在RPC里面的应用。
|
||||
|
||||
如果你经常翻阅一些技术文章的话,可能你会不止一次看到过“流量回放”这个词。我简单地介绍一下,所谓的流量就是某个时间段内的所有请求,我们通过某种手段把发送到A应用的所有请求录制下来,然后把这些请求统一转发到B应用,让B应用接收到的请求参数跟A应用保持一致,从而实现A接收到的请求在B应用里面重新请求了一遍。整个过程我们称之为“流量回放”。
|
||||
|
||||
这就好比今晚有场球赛,但我没空看,但我可以利用视频录播技术把球赛录下来,我随时想看都可以拿出来看,画面是一模一样的。
|
||||
|
||||
那在系统开发的过程中,回放功能可以用来做什么呢?
|
||||
|
||||
流量回放可以做什么?
|
||||
|
||||
我个人感觉,在我们日常开发过程中,可以专心致志地写代码、完成业务功能,是件很幸福的事儿,让我比较头疼的是代码开发完成后的测试环节。
|
||||
|
||||
在团队中,我们经常是多个需求并行开发的,在开发新需求的过程中,我们还可能夹杂着应用的重构和拆分。每到这个时候,我们基本很难做到不改动老逻辑,那只要有改动就有可能会存在考虑不周全的情况。如果你比较严谨的话,那可能在开发完成后,你会把项目里面的TestCase都跑一遍,并同时补充新功能的TestCase,只有所有的TestCase都跑通后才能安心。
|
||||
|
||||
在代码里面,算小改动的业务需求,这种做法一般不会出问题。但对于大改动的应用,比如应用中很多基础逻辑都被改动过,这时候如果你还是通过已有的Case去验证功能的正确性,就很难保证应用上线后不出故障了,毕竟我们靠自己维护的Case相对线上运行的真实环境来说还是少了很多。
|
||||
|
||||
这时候我们会向更专业的QA测试人员求助,希望他们能从QA角度多加入一些Case。但因为我们改动代码逻辑影响范围比较大,想要圈定一个比较确定的测试范围又很难,坦白讲这时候相对保险的方式就是QA把整个项目都回归测试一遍。这种方式已经是在最大程度上避免上线出问题了,但从概率角度上来讲也不是万无一失的,因为线上不仅环境复杂,而且使用场景也并不好评估,还有就是这种方式耗时也很长。
|
||||
|
||||
这就是我认为最让人头疼的原因,靠传统QA测试的方式,不仅过程费时,结果也不是完全可靠。那有没有更可靠、更廉价的方案呢?
|
||||
|
||||
传统QA测试出问题的根本原因就是,因为改造后的应用在上线后出现跟应用上线前不一致的行为。而我们测试的目的就是为了保证改造后的应用跟改造前应用的行为一致,我们测试Case也都是在尽力模拟应用在线上的运行行为,但仅通过我们自己的枚举方式维护的Case并不能代表线上应用的所有行为。因此最好的方式就是用线上流量来验证,但是直接把新应用上线肯定是不行的,因为一旦新改造的应用存在问题就可能会导致线上调用方业务受损。
|
||||
|
||||
我们可以换一种思路,我可以先把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里重新请求一遍,最后比对一下改造前后的响应结果是否一致,这就间接达到了使用线上流量测试的效果。有了线上的请求参数和响应结果后,我们再结合持续集成过程,就可以让我们改动后的代码随时用线上流量进行验证,这就跟我录制球赛视频一样,只要我想看,我随时都可以拿出来重新看一遍。
|
||||
|
||||
RPC怎么支持流量回放?
|
||||
|
||||
那在实际工作中,我们该怎么实现流量回放呢?
|
||||
|
||||
我们常见的方案有很多,比如像TcpCopy、Nginx等。但在线上环境要使用这些工具的时候,我们还得需要找运维团队帮我们把应用安装到应用实例里面,然后再按照你的需求给配置好才能使用,整个过程繁琐而且总数重复做无用功,那有没有更好的办法呢?尤其是在应用使用了RPC的情况下。
|
||||
|
||||
在前面我们不止一次说过,RPC是用来完成应用之间通信的,换句话就是说应用之间的所有请求响应都会经过RPC。
|
||||
|
||||
既然所有的请求都会经过RPC,那么我们在RPC里面是不是就可以很方便地拿到每次请求的出入参数?拿到这些出入参数后,我们只要把这些出入参数旁录下来,并把这些旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量回放里面的录制功能。
|
||||
|
||||
有了真实的请求入参之后,剩下的就是怎么把这些请求参数转发到我们要回归测试的应用里面。在RPC中,我们把能够接收请求的应用叫做服务提供方,那就是说我们只需要模拟一个应用调用方,把刚才收到的请求参数重新发送一遍到要回归测试的应用里面,然后比对录制拿到的请求结果和新请求的结果,就可以完成请求回放的效果。整个过程如下图所示:
|
||||
|
||||
|
||||
|
||||
相对其它现成的流量回放方案,我们在RPC里面内置流量回放功能,使用起来会更加方便,并且我们还可以做更多定制,比如在线启停、方法级别录制等个性化需求。
|
||||
|
||||
总结
|
||||
|
||||
保障线上应用的稳定,是我们研发同学每天都在努力耕耘的一件事,不管是通过应用架构升级,还是修复现有问题的方式。实际情况就是我们不仅要保障已有业务的稳定,还需要快速去完成各种新业务的需求,这期间我们的应用代码就会经常发生变化,而发生变化后就可能会引入新的不稳定因素,而且这个过程会一直持续不断发生。
|
||||
|
||||
为了保障应用升级后,我们的业务行为还能保持和升级前一样,我们在大多数情况下都是依靠已有的TestCase去验证,但这种方式在一定程度上并不是完全可靠的。最可靠的方式就是引入线上Case去验证改造后的应用,把线上的真实流量在改造后的应用里面进行回放,这样不仅节省整个上线时间,还能弥补手动维护Case存在的缺陷。
|
||||
|
||||
应用引入了RPC后,所有的请求流量都会被RPC接管,所以我们可以很自然地在RPC里面支持流量回放功能。虽然这个功能本身并不是RPC的核心功能,但对于使用RPC的人来说,他们有了这个功能之后,就可以更放心地升级自己的应用了。
|
||||
|
||||
课后思考
|
||||
|
||||
除了上面我提到的可以使用流量回放功能来验证改造后的应用逻辑,我们还可以用流量回放来做哪些有意义的事儿?
|
||||
|
||||
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
71
专栏/RPC实战与核心原理/22动态分组:超高效实现秒级扩缩容.md
Normal file
71
专栏/RPC实战与核心原理/22动态分组:超高效实现秒级扩缩容.md
Normal file
@ -0,0 +1,71 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 动态分组:超高效实现秒级扩缩容
|
||||
你好,我是何小锋。上一讲我们介绍了在RPC里面怎么支持流量回放,应用在引入RPC后,所有的请求都会被RPC接管,而我们在RPC里面引入回放的原因也很简单,就是想通过线上流量来验证改造后应用的正确性,而线上流量相比手动维护TestCase的场景更丰富,所以用线上流量进行测试的覆盖率会更广。
|
||||
|
||||
回顾完上一讲的重点,我们就切入今天的主题,一起看看动态分组在RPC里面的应用。
|
||||
|
||||
在[第 16 讲] 我们讲过,在调用方复杂的情况下,如果还是让所有调用方都调用同一个集群的话,很有可能会因为非核心业务的调用量突然增长,而让整个集群变得不可用了,进而让核心业务的调用方受到影响。为了避免这种情况发生,我们需要把整个大集群根据不同的调用方划分出不同的小集群来,从而实现调用方流量隔离的效果,进而保障业务之间不会互相影响。
|
||||
|
||||
分组后容量评估
|
||||
|
||||
通过人为分组的方式确实能帮服务提供方硬隔离调用方的流量,让不同的调用方拥有自己独享的集群,从而保障各个调用方之间互不影响。但这对于我们服务提供方来说,又带来了一个新的问题,就是我们该给调用方分配多大的集群才合适呢?
|
||||
|
||||
在[第 16 讲] 我们也有聊到过这样的问题,就是该怎么划分集群的分组?当然,最理想的情况就是给每个调用方都分配一个独立的分组,但是如果在服务提供方的调用方相对比较多的情况下,对于服务提供方来说要维护这些关系还是比较困难的。因此实际在给集群划分分组的时候,我们一般会选择性地合并一些调用方到同一个分组里。这就需要我们服务提供方考虑该怎么合并,且合并哪些调用方?
|
||||
|
||||
因为这个问题并没有统一的标准,所以我当时给的建议就是我们可以按照应用的重要级别来划分,让非核心业务应用跟核心业务应用不要公用一个分组,核心应用之间也最好别用同一个分组。但这只是一个划分集群分组的建议,并没有具体告诉你该如何划分集群大小。换句话就是,你可以按照这个原则去规划设计自己的集群要分多少个组。
|
||||
|
||||
按照上面的原则,我们把整个集群从逻辑上分为不同的分组之后,接下来我们要做的事情就是给每个分组分配相应的机器数量。那每个分组对应的机器数量,我们该怎么计算呢?我相信这个问题肯定难不倒你。在这儿我先分享下我们团队常用的做法,我们一般会先通过压测去评估下服务提供方单台机器所能承受的QPS,然后再计算出每个分组里面的所有调用方的调用总量。有了这两个值之后,我们就能很容易地计算出这个分组所需要的机器数。
|
||||
|
||||
通过计算分组内所有调用方QPS的方式来算出单个分组内所需的机器数,整体而言还是比较客观准确的。但因为每个调用方的调用量并不是一成不变的,比如商家找个网红做个直播卖货,那就很有可能会导致今天的下单量相对昨天有小幅度的上涨。就是因为这些不确定性因素的存在,所以服务提供方在给调用方做容量评估的时候,通常都会在现有调用量的基础上加一个百分比,而这个百分比多半来自历史经验总结。
|
||||
|
||||
总之,就是在我们算每个分组所需要的机器数的时候,需要额外给每个分组增加一些机器,从而让每个小集群有一定的抗压能力,而这个抗压能力取决于给这个集群预留的机器数量。作为服务提供方来说,肯定希望给每个集群预留的机器数越多越好,但现实情况又不允许预留太多,因为这样会增加团队的整体成本。
|
||||
|
||||
分组带来的问题
|
||||
|
||||
通过给分组预留少量机器的方式,以增加单个集群的抗压能力。一般情况下,这种机制能够运行得很好,但在应对大的突发流量时,就会显得有点捉襟见肘了。因为机器成本的原因,我们给每个分组预留的机器数量都不会太多,所以当突发流量超过预留机器的能力的时候,就会让这个分组的集群处于一个危险状态了。
|
||||
|
||||
这时候我们唯一能做的就是给这个分组去扩容新的机器,但临时扩容新机器通常需要一个比较长的时间,而且花的时间越长,业务受影响的范围就越大。
|
||||
|
||||
那有没有更便捷一点的方案呢?前面我们说过,我们在给分组做容量评估的时候,通常都会增加了一些富余。换句话就是,除了当前出问题的分组,其它分组的服务提供方在保障自己调用方质量的同时,还是可以额外承担一些流量的。我们可以想办法快速利用这部分已有的能力。
|
||||
|
||||
但因为我们实现了流量隔离功能,整个集群被我们划分成了不同的分组,所以当前出问题的调用方并不能把请求发送到其它分组的机器上。那可能你会说,既然临时去申请机器进行扩容时间长,那我能不能把上面说的那些富余的机器直接拿过来,把部署在机器上的应用改成出问题的分组,然后进行重启啊?这样出问题的那个分组的服务提供方机器数就会变多了。
|
||||
|
||||
从结果上来看,这样处理确实能够解决问题,但有一个问题就是这样处理的时间还是相对较长的,而且当这个分组的流量恢复后,你还得把临时借过来的机器还回原来的分组。
|
||||
|
||||
问题分析到这儿,我想说,动态分组就可以派上用场了。
|
||||
|
||||
动态分组的应用
|
||||
|
||||
上面的问题,其根本原因就是某个分组的调用方流量突增,而这个分组所预留的空间也不能满足当前流量的需求,但是其它分组的服务提供方有足够的富余能力。但这些富余的能力,又被我们的分组进行了强制的隔离,我们又不能抛弃分组功能,否则老问题就要循环起来了。
|
||||
|
||||
那这样的话,我们就只能在出问题的时候临时去借用其它分组的部分能力,但通过改分组进行重启应用的方式,不仅操作过程慢,事后还得恢复。因此这种生硬的方式显然并不是很合适。
|
||||
|
||||
想一下啊,我们改应用分组然后进行重启的目的,就是让出问题的服务调用方能通过服务发现找到更多的服务提供方机器,而服务发现的数据来自注册中心,那我们是不是可以通过修改注册中心的数据来解决呢?
|
||||
|
||||
我们只要把注册中心里面的部分实例的别名改成我们想要的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合。
|
||||
|
||||
举个例子,服务提供方有3个服务实例,其中A分组有2个实例,B分组有1个实例,调用方1调用A分组,调用方2调用B分组。我们把A分组里面的一个实例分组在注册中心由A分组改为B分组,经过服务发现影响后,整个调用拓扑就变成了这样:
|
||||
|
||||
|
||||
|
||||
通过直接修改注册中心数据,我们可以让任何一个分组瞬间拥有不同规模的集群能力。我们不仅可以实现把某个实例的分组名改成另外一个分组名,还可以让某个实例分组名变成多个分组名,这就是我们在动态分组里面最常见的两种动作——追加和替换。
|
||||
|
||||
总结
|
||||
|
||||
在[第 16 讲],我们讲了分组后带来的收益,它可以帮助服务提供方实现调用方的隔离。但是因为调用方流量并不是一成不变的,而且还可能会因为突发事件导致某个分组的流量溢出,而在整个大集群还有富余能力的时候,又因为分组隔离不能为出问题的集群提供帮助。
|
||||
|
||||
为了解决这种突发流量的问题,我们提供了一种更高效的方案,可以实现分组的快速扩缩容。事实上我们还可以利用动态分组解决分组后给每个分组预留机器冗余的问题,我们没有必要把所有冗余的机器都分配到分组里面,我们可以把这些预留的机器做成一个共享的池子,从而减少整体预留的实例数量。
|
||||
|
||||
课后思考
|
||||
|
||||
在服务治理的过程中,我们通常会给服务进行逻辑分组,但之后某个分组可能会遇到突发流量调用的问题,在本讲我给出了一个动态分组的方案。但是动态分组的过程中,我们只是把注册中心的数据改了,而服务提供方提供真实的分组名并没有改变,这时候用动态分组名的调用方调用过来的请求可能就会报错,因为服务提供方会验证调用方过来的分组名跟自身的是否一样。针对这个问题,你能想到什么解决方案?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
93
专栏/RPC实战与核心原理/23如何在没有接口的情况下进行RPC调用?.md
Normal file
93
专栏/RPC实战与核心原理/23如何在没有接口的情况下进行RPC调用?.md
Normal file
@ -0,0 +1,93 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 如何在没有接口的情况下进行RPC调用?
|
||||
你好,我是何小锋。上一讲我们学习了RPC如何通过动态分组来实现秒级扩缩容,其关键点就是“动态”与“隔离”。今天我们来聊聊如何在没有接口的情况下进行RPC调用。
|
||||
|
||||
应用场景有哪些?
|
||||
|
||||
在RPC运营的过程中,让调用端在没有接口API的情况下发起RPC调用的需求,不只是一个业务方和我提过,这里我列举两个非常典型的场景例子。
|
||||
|
||||
场景一:我们要搭建一个统一的测试平台,可以让各个业务方在测试平台中通过输入接口、分组名、方法名以及参数值,在线测试自己发布的RPC服务。这时我们就有一个问题要解决,我们搭建统一的测试平台实际上是作为各个RPC服务的调用端,而在RPC框架的使用中,调用端是需要依赖服务提供方提供的接口API的,而统一测试平台不可能依赖所有服务提供方的接口API。我们不能因为每有一个新的服务发布,就去修改平台的代码以及重新上线。这时我们就需要让调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起RPC调用。
|
||||
|
||||
|
||||
|
||||
场景二:我们要搭建一个轻量级的服务网关,可以让各个业务方用HTTP的方式,通过服务网关调用其它服务。这时就有与场景一相同的问题,服务网关要作为所有RPC服务的调用端,是不能依赖所有服务提供方的接口API的,也需要调用端在没有服务提供方提供接口的情况下,仍然可以正常地发起RPC调用。
|
||||
|
||||
|
||||
|
||||
这两个场景都是我们经常会碰到的,而让调用端在没有服务提供方提供接口API的情况下仍然可以发起RPC调用的功能,在RPC框架中也是非常有价值的。
|
||||
|
||||
怎么做?
|
||||
|
||||
RPC框架要实现这个功能,我们可以使用泛化调用。那什么是泛化调用呢?我们带着这个问题,先学习下如何在没有接口的情况下进行RPC调用。
|
||||
|
||||
我们先回想下我在基础篇讲过的内容,通过前面的学习我们了解到,在RPC调用的过程中,调用端向服务端发起请求,首先要通过动态代理,正如[第 05 讲] 中我说过的,动态代理可以帮助我们屏蔽RPC处理流程,真正地让我们发起远程调用就像调用本地一样。
|
||||
|
||||
那么在RPC调用的过程中,既然调用端是通过动态代理向服务端发起远程调用的,那么在调用端的程序中就一定要依赖服务提供方提供的接口API,因为调用端是通过这个接口API自动生成动态代理的。那如果没有接口API呢?我们该如何让调用端仍然能够发起RPC调用呢?
|
||||
|
||||
所谓的RPC调用,本质上就是调用端向服务端发送一条请求消息,服务端接收并处理,之后向调用端发送一条响应消息,调用端处理完响应消息之后,一次RPC调用就完成了。那是不是说我们只要能够让调用端在没有服务提供方提供接口的情况下,仍然能够向服务端发送正确的请求消息,就能够解决这个问题了呢?
|
||||
|
||||
没错,只要调用端将服务端需要知道的信息,如接口名、业务分组名、方法名以及参数信息等封装成请求消息发送给服务端,服务端就能够解析并处理这条请求消息,这样问题就解决了。过程如下图所示:
|
||||
|
||||
|
||||
|
||||
现在我们已经清楚了解决问题的关键,但RPC的调用端向服务端发送消息是需要以动态代理作为入口的,我们现在得继续想办法让调用端发送我刚才讲过的那条请求消息。
|
||||
|
||||
我们可以定义一个统一的接口(GenericService),调用端在创建GenericService代理时指定真正需要调用的接口的接口名以及分组名,而GenericService接口的$invoke方法的入参就是方法名以及参数信息。
|
||||
|
||||
这样我们传递给服务端所需要的所有信息,包括接口名、业务分组名、方法名以及参数信息等都可以通过调用GenericService代理的$invoke方法来传递。具体的接口定义如下:
|
||||
|
||||
class GenericService {
|
||||
|
||||
Object $invoke(String methodName, String[] paramTypes, Object[] params);
|
||||
|
||||
}
|
||||
|
||||
|
||||
这个通过统一的GenericService接口类生成的动态代理,来实现在没有接口的情况下进行RPC调用的功能,我们就称之为泛化调用。
|
||||
|
||||
通过泛化调用功能,我们可以解决在没有服务提供方提供接口API的情况下进行RPC调用,那么这个功能是否就完美了呢?
|
||||
|
||||
回顾下[第 17 讲] 我过的内容,RPC框架可以通过异步的方式提升吞吐量,还有如何实现全异步的RPC框架,其关键点就是RPC框架对CompletableFuture的支持,那么我们的泛化调用是否也可以支持异步呢?
|
||||
|
||||
当然可以。我们可以给GenericService接口再添加一个异步方法$asyncInvoke,方法的返回值就是CompletableFuture,GenericService接口的具体定义如下:
|
||||
|
||||
class GenericService {
|
||||
|
||||
Object $invoke(String methodName, String[] paramTypes, Object[] params);
|
||||
|
||||
CompletableFuture<Object> $asyncInvoke(String methodName, String[] paramTypes, Object[] params);
|
||||
|
||||
}
|
||||
|
||||
|
||||
学到这里相信你已经对泛化调用的功能有一定的了解了,那你有没有想过这样一个问题?在没有服务提供方提供接口API的情况下,我们可以用泛化调用的方式实现RPC调用,但是如果没有服务提供方提供接口API,我们就没法得到入参以及返回值的Class类,也就不能对入参对象进行正常的序列化。这时我们会面临两个问题:
|
||||
|
||||
问题1:调用端不能对入参对象进行正常的序列化,那调用端、服务端在接收到请求消息后,入参对象又该如何序列化与反序列化呢?
|
||||
|
||||
回想下[第 07 讲],在这一讲中我讲解了如何设计可扩展的RPC框架,我们通过插件体系来提高RPC框架的可扩展性,在RPC框架的整体架构中就包括了序列化插件,我们可以为泛化调用提供专属的序列化插件,通过这个插件,解决泛化调用中的序列化与反序列化问题。
|
||||
|
||||
问题2:调用端的入参对象(params)与返回值应该是什么类型呢?
|
||||
|
||||
在服务提供方提供的接口API中,被调用的方法的入参类型是一个对象,那么使用泛化调用功能的调用端,可以使用Map类型的对象,之后通过泛化调用专属的序列化方式对这个Map对象进行序列化,服务端收到消息后,再通过泛化调用专属的序列化方式将其反序列成对象。
|
||||
|
||||
总结
|
||||
|
||||
今天我们主要讲解了如何在没有接口的情况下进行RPC调用,泛化调用的功能可以实现这一目的。
|
||||
|
||||
这个功能的实现原理,就是RPC框架提供统一的泛化调用接口(GenericService),调用端在创建GenericService代理时指定真正需要调用的接口的接口名以及分组名,通过调用GenericService代理的$invoke方法将服务端所需要的所有信息,包括接口名、业务分组名、方法名以及参数信息等封装成请求消息,发送给服务端,实现在没有接口的情况下进行RPC调用的功能。
|
||||
|
||||
而通过泛化调用的方式发起调用,由于调用端没有服务端提供方提供的接口API,不能正常地进行序列化与反序列化,我们可以为泛化调用提供专属的序列化插件,来解决实际问题。
|
||||
|
||||
课后思考
|
||||
|
||||
在讲解泛化调用时,我讲到服务端在收到调用端通过泛化调用的方式发送过来的请求时,会使用泛化调用专属的序列化插件实现对其进行反序列化,那么服务端是如何判定这个请求消息是通过泛化调用的方式发送过来的消息呢?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
65
专栏/RPC实战与核心原理/24如何在线上环境里兼容多种RPC协议?.md
Normal file
65
专栏/RPC实战与核心原理/24如何在线上环境里兼容多种RPC协议?.md
Normal file
@ -0,0 +1,65 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 如何在线上环境里兼容多种RPC协议?
|
||||
你好,我是何小锋。上一讲我们学习了如何在没有接口的情况下完成RPC调用,其关键在于你要理解接口定义在RPC里面的作用。除了我们前面说的,动态代理生成的过程中需要用到接口定义,剩余的其它过程中接口的定义只是被当作元数据来使用,而动态代理在RPC中并不是一个必须的环节,所以在没有接口定义的情况下我们同样也是可以完成RPC调用的。
|
||||
|
||||
回顾完上一讲的重点,咱们就言归正传,切入今天的主题,一起看看如何在线上环境里兼容多种RPC协议。
|
||||
|
||||
看到这个问题后,可能你的第一反应就是,在真实环境中为什么会存在多个协议呢?我们说过,RPC是能够帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地一样的体验。大白话说就是,RPC是能够帮助我们在开发过程中完成应用之间的通信,而又不需要我们关心具体通信细节的工具。
|
||||
|
||||
为什么要支持多协议?
|
||||
|
||||
既然应用之间的通信都是通过RPC来完成的,而能够完成RPC通信的工具有很多,比如像Web Service、Hessian、gRPC等都可以用来充当RPC使用。这些不同的RPC框架都是随着互联网技术的发展而慢慢涌现出来的,而这些RPC框架可能在不同时期会被我们引入到不同的项目中解决当时应用之间的通信问题,这样就导致我们线上的生成环境中存在各种各样的RPC框架。
|
||||
|
||||
很显然,这种混乱使用RPC框架的方式肯定不利于公司技术栈的管理,最明显的一个特点就是我们维护RPC框架的成本越来越高,因为每种RPC框架都需要有专人去负责升级维护。
|
||||
|
||||
为了解决早期遗留的一些技术负债,我们通常会去选择更高级的、更好用的工具来解决,治理RPC框架混乱的问题也是一样。为了解决同时维护多个RPC框架的困难,我们肯定希望能够用统一用一种RPC框架来替代线上所有的RPC框架,这样不仅能降低我们的维护成本,而且还可以让我们在一种RPC上面去精进。
|
||||
|
||||
既然目标明确后,我们该如何实施呢?
|
||||
|
||||
可能你会说这很简单啊,我们只要把所有的应用都改造成新RPC的使用方式,然后同时上线所有改造后的应用就可以了。如果在团队比较小的情况下,这种断崖式的更新可能确实是最快的方法,但如果是在团队比较大的情况下,要想做到同时上线所有改造后的应用,暂且不讨论这种方式是否存在风险,光从多个团队同一时间上线所有应用来看,这也几乎是一件不可能做到的事儿。
|
||||
|
||||
那对于多人团队来说,有什么办法可以让其把多个RPC框架统一到一个工具上呢?我们先看下多人团队在升级过程中所要面临的困难,人数多就意味着要维护的应用会比较多,应用多了之后线上应用之间的调用关系就会相对比较复杂。那这时候如果单纯地把任意一个应用目前使用的RPC框架换成新的RPC框架的话,就需要让所有调用这个应用的调用方去改成新的调用方式。
|
||||
|
||||
通过这种自下而上的滚动升级方式,最终是可以让所有的应用都切换到统一的RPC框架上,但是这种升级方式存在一定的局限性,首先要求我们能够清楚地梳理出各个应用之间的调用关系,只有这样,我们才能按部就班地把所有应用都升级到新的RPC框架上;其次要求应用之间的关系不能存在互相调用的情况,最好的情况就是应用之间的调用关系像一颗树,有一定的层次关系。但实际上我们应用的调用关系可能已经变成了网状结构,这时候想再按照这种方式去推进升级的话,就可能寸步难行了。
|
||||
|
||||
为了解决上面升级过程中遇到的问题,你可能还会想到另外一个方案,那就是在应用升级的过程中,先不移除原有的RPC框架,但同时接入新的RPC框架,让两种RPC同时提供服务,然后等所有的应用都接入完新的RPC以后,再让所有的应用逐步接入到新的RPC上。这样既解决了上面存在的问题,同时也可以让所有的应用都能无序地升级到统一的RPC框架上。
|
||||
|
||||
在保持原有RPC使用方式不变的情况下,同时引入新的RPC框架的思路,是可以让所有的应用最终都能升级到我们想要升级的RPC上,但对于开发人员来说,这样切换成本还是有点儿高,整个过程最少需要两次上线才能彻底地把应用里面的旧RPC都切换成新RPC。
|
||||
|
||||
那有没有更好的方式可以让应用上线一次就可以完成新老RPC的切换呢?关键就在于要让新的RPC能同时支持多种RPC调用,当一个调用方切换到新的RPC之后,调用方和服务提供方之间就可以用新的协议完成调用;当调用方还是用老的RPC进行调用的话,调用方和服务提供方之间就继续沿用老的协议完成调用。对于服务提供方来说,所要处理的请求关系如下图所示:
|
||||
|
||||
|
||||
|
||||
怎么优雅处理多协议?
|
||||
|
||||
要让新的RPC同时支持多种RPC调用,关键就在于要让新的RPC能够原地支持多种协议的请求。怎么才能做到?在[第 02 讲] 我们说过,协议的作用就是用于分割二进制数据流。每种协议约定的数据包格式是不一样的,而且每种协议开头都有一个协议编码,我们一般叫做magic number。
|
||||
|
||||
当RPC收到了数据包后,我们可以先解析出magic number来。获取到magic number后,我们就很容易地找到对应协议的数据格式,然后用对应协议的数据格式去解析收到的二进制数据包。
|
||||
|
||||
协议解析过程就是把一连串的二进制数据变成一个RPC内部对象,但这个对象一般是跟协议相关的,所以为了能让RPC内部处理起来更加方便,我们一般都会把这个协议相关的对象转成一个跟协议无关的RPC对象。这是因为在RPC流程中,当服务提供方收到反序列化后的请求的时候,我们需要根据当前请求的参数找到对应接口的实现类去完成真正的方法调用。如果这个请求参数是跟协议相关的话,那后续RPC的整个处理逻辑就会变得很复杂。
|
||||
|
||||
当完成了真正的方法调用以后,RPC返回的也是一个跟协议无关的通用对象,所以在真正往调用方写回数据的时候,我们同样需要完成一个对象转换的逻辑,只不过这时候是把通用对象转成协议相关的对象。
|
||||
|
||||
在收发数据包的时候,我们通过两次转换实现RPC内部的处理逻辑跟协议无关,同时保证调用方收到的数据格式跟调用请求过来的数据格式是一样的。整个流程如下图所示:
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
在我们日常开发的过程中,最难的环节不是从0到1完成一个新应用的开发,而是把一个老应用通过架构升级完成从70分到80分的跳跃。因为在老应用升级的过程中,我们不仅需要考虑既有的功能逻辑,也需要考虑切换到新架构上的成本,这就要求我们在设计新架构的时候要考虑如何让老应用能够平滑地升级,就像在RPC里面支持多协议一样。
|
||||
|
||||
在RPC里面支持多协议,不仅能让我们更从容地推进应用RPC的升级,还能为未来在RPC里面扩展新协议奠定一个良好的基础。所以我们平时在设计应用架构的时候,不仅要考虑应用自身功能的完整性,还需要考虑应用的可运维性,以及是否能平滑升级等一些软性能力。
|
||||
|
||||
课后思考
|
||||
|
||||
在RPC里面支持多协议的时候,有一个关键点就是能够识别出不同的协议,并且根据不同的magic number找到不同协议的解析逻辑。如果线上协议存在很多种的话,就需要我们事先在RPC里面内置各种协议,但通过枚举的方式可能会遗漏,不知道针对这种问题你有什么好的办法吗?
|
||||
|
||||
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
261
专栏/RPC实战与核心原理/加餐RPC框架代码实例详解.md
Normal file
261
专栏/RPC实战与核心原理/加餐RPC框架代码实例详解.md
Normal file
@ -0,0 +1,261 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 RPC框架代码实例详解
|
||||
你好,我是何小锋,好久不见!咱们专栏结课有段时间了,这期间我和编辑冬青一起对整个课程做了复盘,也认真挨个逐字看了结课问卷中的反馈,其中呼声最高的是“想看RPC代码实例”,今天我就带着你的期待来了。
|
||||
|
||||
还记得我在[结束语]提到过,我在写这个专栏之前,把公司内部我负责的RPC框架重新写了一遍。口说无凭,现在这个RPC框架已经开源,接受你的检阅。
|
||||
|
||||
下面我就针对这套代码做一个详细的解析,希望能帮你串联已学的知识点,实战演练,有所收获。
|
||||
|
||||
RPC框架整体结构
|
||||
|
||||
首先说我们RPC框架的整体架构,这里请你回想下[第 07 讲],在这一讲中我讲解了如何设计一个灵活的RPC框架,其关键点就是插件化,我们可以利用插件体系来提高RPC的扩展性,使其成为一个微内核架构,如下图所示:
|
||||
|
||||
|
||||
|
||||
这里我们可以看到,我们将RPC框架大体分为了四层,分别是入口层、集群层、协议层和传输层,而这四层中分别包含了一系列的插件,而在实际的RPC框架中插件会更多。在我所开源的RPC框架中就超过了50个插件,其中涉及到的代码量也是相当大的,下面我就通过服务端启动流程、调用端启动流程、RPC调用流程这三大流程来将RPC框架的核心模块以及核心类串联起来,理解了这三大流程会对你阅读代码有非常大的帮助。
|
||||
|
||||
服务端启动流程
|
||||
|
||||
在讲解服务启动流程之前,我们先看下服务端启动的代码示例,如下:
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
|
||||
DemoService demoService = new DemoServiceImpl(); //服务提供者设置
|
||||
ProviderConfig<DemoService> providerConfig = new ProviderConfig<>();
|
||||
providerConfig.setServerConfig(new ServerConfig());
|
||||
providerConfig.setInterfaceClazz(DemoService.class.getName());
|
||||
providerConfig.setRef(demoService);
|
||||
providerConfig.setAlias("joyrpc-demo");
|
||||
providerConfig.setRegistry(new RegistryConfig("broadcast"));
|
||||
|
||||
providerConfig.exportAndOpen().whenComplete((v, t) -> {
|
||||
if (t != null) {
|
||||
logger.error(t.getMessage(), t);
|
||||
System.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
System.in.read();
|
||||
|
||||
}
|
||||
|
||||
|
||||
我们可以看出,providerConfig是通过调用exportAndOpen()方法来启动服务端的,那么为何这个方法要如此命名呢?
|
||||
|
||||
我们可以看下 exportAndOpen 方法的代码实现:
|
||||
|
||||
public CompletableFuture<Void> exportAndOpen() {
|
||||
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
export().whenComplete((v, t) -> {
|
||||
if (t != null) {
|
||||
future.completeExceptionally(t);
|
||||
} else {
|
||||
Futures.chain(open(), future);
|
||||
}
|
||||
});
|
||||
|
||||
return future;
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里服务的启动流程被分为了两个部分,export(创建Export对象)以及open(打开服务)。而服务端的启动流程也被分为了两部分:服务端的创建流程与服务端的开启流程。
|
||||
|
||||
服务端创建流程
|
||||
|
||||
|
||||
|
||||
这里的ProviderConfig是服务端的配置对象,其中接口、分组、注册中心配置等等的相关信息都在这个配置类中配置,流程的入口是调用ProviderConfig的export方法,整个流程如下:
|
||||
|
||||
|
||||
根据ProviderConfig的配置信息生成registryUrl(注册中心URL对象)与serviceUrl(服务URL对象);
|
||||
根据registryUrl,调用Registry插件,创建Registry对象,Registry对象为注册中心对象,与注册中心进行交互;
|
||||
调用Registry对象的open方法,开启注册中心对象,也就是与注册中心建立连接;
|
||||
调用Registry对象的subscribe方法,订阅接口的配置信息与全局配置信息;
|
||||
调用InvokerManager,创建Exporter对象;
|
||||
InvokerManager返回Exporter对象。
|
||||
|
||||
|
||||
服务端的创建流程实际上就是Exporter对象,Exporter对象是调用器Invoker接口的子类,Invoker接口有两个子类,分别是Exporter与Refer,Exporter用来处理服务端接收的请求,而Refer用来向服务端发送请求,这两个类可以说是入口层最为核心的两个类。
|
||||
|
||||
在InvokerManager创建Exporter对象时实际上会有一系列的操作,而初始化Exporter也会有一系列的操作,如创建Filter链、创建认证信息等等。这里不再详细叙述,你可以阅读下源码。
|
||||
|
||||
服务端开启流程
|
||||
|
||||
|
||||
|
||||
创建完服务端的Exporter对象之后,我们就要开启Exporter对象,开启Exporter对象最重要的两个操作就是开启传输层中Server的端口,用来接收调用端发送过来的请求,以及将服务端节点注册到注册中心上,让调用端可以发现到这个服务节点,整个流程如下:
|
||||
|
||||
|
||||
调用Exporter对象的open方法,开启服务端;
|
||||
Exporter对象调用接口预热插件,进行接口预热;
|
||||
Exporter对象调用传输层中的EndpointFactroy插件,创建一个Server对象,一个Server对象就代表一个端口了;
|
||||
调用Server对象的open方法,开启端口,端口开启之后,服务端就可以提供远程服务了;
|
||||
Exporter对象调用Registry对象的register方法,将这个调用端节点注册到注册中心中。
|
||||
|
||||
|
||||
这里无论是Exporter的open方法、Server的open还是Registry的register方法,都是异步方法,返回值为CompletableFuture对象,这个流程的每个环节也都是异步的。
|
||||
|
||||
Server的open操作实际上是一个比较复杂的操作,要绑定协议适配器、初始化session管理器、添加eventbus事件监听等等的操作,而且整个流程完全异步,并且是插件化的。
|
||||
|
||||
调用端启动流程
|
||||
|
||||
在讲解调用端启动流程之前,我们还是先看下代码示例,调用端启动代码示例如下:
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
ConsumerConfig<DemoService> consumerConfig = new ConsumerConfig<>(); //consumer设置
|
||||
consumerConfig.setInterfaceClazz(DemoService.class.getName());
|
||||
consumerConfig.setAlias("joyrpc-demo");
|
||||
consumerConfig.setRegistry(new RegistryConfig("broadcast"));
|
||||
|
||||
try {
|
||||
CompletableFuture<DemoService> future = consumerConfig.refer();
|
||||
DemoService service = future.get();
|
||||
|
||||
String echo = service.sayHello("hello"); //发起服务调用
|
||||
logger.info("Get msg: {} ", echo);
|
||||
} catch (Throwable e) {
|
||||
logger.error(e.getMessage(), e);
|
||||
}
|
||||
|
||||
System.in.read();
|
||||
|
||||
}
|
||||
|
||||
|
||||
调用端流程的启动入口就是ConsumerConfig对象的refer方法,ConsumerConfig对象就是调用端的配置对象,这里可以看到refer方法的返回值是CompletableFuture,与服务端相同,调用端的启动流程也完全是异步的,下面我们来看下调用端的启动流程。
|
||||
|
||||
|
||||
|
||||
调用端具体流程如下:
|
||||
|
||||
|
||||
根据ConsumerConfig的配置信息生成registryUrl(注册中心URL对象)与serviceUrl(服务URL对象);
|
||||
根据registryUrl,调用Registry插件,创建Registry对象,Registry对象为注册中心对象,与注册中心进行交互;
|
||||
创建动态代理对象;
|
||||
调用Registry对象的Open方法,开启注册中心对象;
|
||||
调用Registry对象subscribe方法,订阅接口的配置信息与全局配置信息;
|
||||
调用InvokeManager的refer方法,用来创建Refer对象;
|
||||
InvokeManager在创建Refer对象之前会先创建Cluster对象,Cluser对象是集群层的核心对象,Cluster会维护该调用端与服务端节点的连接状态;
|
||||
InvokeManager创建Refer对象;
|
||||
Refer对象初始化,其中主要包括创建路由策略、消息分发策略、创建负载均衡、调用链、添加eventbus事件监听等等;
|
||||
ConsumerConfig调用Refer的open方法,开启调用端;
|
||||
Refer对象调用Cluster对象的open方法,开启集群;
|
||||
Cluster对象调用Registry对象的subcribe方法,订阅服务端节点变化,收到服务端节点变化时,Cluster会调用传输层EndpointFactroy插件,创建Client对象,与这些服务节点建立连接,Cluster会维护这些连接;
|
||||
ConsumerConfig调用Refer对象封装到ConsumerInvokerHandler中,将ConsumerInvokerHandler对象注入给动态代理对象。
|
||||
|
||||
|
||||
在调用端的开启流程中,最复杂的操作就是Cluster对象的open操作以及Client对象的open操作。
|
||||
|
||||
Cluster对象是集群层的核心对象,也是这个RPC框架中处理逻辑最为复杂的对象,Cluster对象负责维护该调用端节点集群信息,监听注册中心推送的服务节点更新事件,调用传输层中的EndpointFactroy插件,创建Client对象,并且会通过Client与服务端节点建立连接,发送协商信息、安全验证信息、心跳信息,通过心跳机制维护与服务节点的连接状态。
|
||||
|
||||
Client对象的open操作也是有着一系列的操作,比如创建Transport对象,创建Channel对象,生成并记录session信息等等。
|
||||
|
||||
Refer对象在构造调用链的时候,其最后一个调用链就是Refer对象的distribute方法,用来发送远程请求。
|
||||
|
||||
动态代理对象内部的核心逻辑就是调用ConsumerInvokerHandler对象的Invoke方法,最终就是调用Refer对象,我会在下面的RPC调用流程中详细讲下。
|
||||
|
||||
RPC调用流程
|
||||
|
||||
讲解完了服务端的启动流程与调用端的启动流程,下面我开始讲解RPC的调用流程。RPC的整个调用流程就是调用端发送请求消息以及服务端接收请求消息并处理,之后响应给调用端的流程。
|
||||
|
||||
下面我就讲解下调用端的发送流程与服务端的接收流程。
|
||||
|
||||
调用端发送流程
|
||||
|
||||
|
||||
|
||||
调用端发送流程如下:
|
||||
|
||||
|
||||
动态代理对象调用ConsumerInvokerHandler对象的Invoke方法;
|
||||
ConsumerInvokerHandler对象生成请求消息对象;
|
||||
ConsumerInvokerHandler对象调用Refer对象的Invoke方法;
|
||||
Refer对象对请求消息对象进行处理,如设置接口信息、分组信息等等;
|
||||
Refer对象调用消息透传插件,处理透传信息,其中就包括隐式参数信息;
|
||||
Refer对象调用FilterChain对象的Invoker方法,执行调用链;
|
||||
FilterChain对象调用每个Filter;
|
||||
Refer对象的distribute方法作为最后一个Filter,被调用链最后一个执行。
|
||||
调用NodeSelecter对象的select方法,NodeSelecter是集群层的路由规则节点选择器,其select方法用来选择出符合路由规则的服务节点;
|
||||
调用Route对象的route方法,Route对象为路由分发器,也是集群层中的对象,默认为路由分发策略为Failover,即请求失败后可以重试请求,这里你可以回顾下[第 12 讲],在这一讲的思考题中我就问过异常重试发送在RPC调用中的哪个环节,其实就在此环节;
|
||||
Route对象调用LoadBalance对象的select方法,通过负载均衡选择一个节点;
|
||||
Route对象回调Refer对象的invokeRemote方法;
|
||||
Refer对象的invokeRemote方法调用传输层中Client对象,向服务端节点发送消息。
|
||||
|
||||
|
||||
在调用端发送流程中,最终会通过传输层将消息发送给服务端,这里对传输层的操作没有详细的讲解,其实传输层内部的流程还是比较复杂的,也会有一系列的操作,比如创建Future对象、调用FutureManager管理Future对象、请求消息协议转换处理、编解码、超时处理等等的操作。
|
||||
|
||||
当调用端发送完请求消息之后,服务端就会接收到请求消息并对请求消息进行处理。接下来我们看服务端的接收流程。
|
||||
|
||||
服务端接收流程
|
||||
|
||||
|
||||
|
||||
服务端的传输层会接收到请求消息,并对请求消息进行编解码以及反序列化,之后调用Exporter对象的invoke方法,具体流程如下:
|
||||
|
||||
|
||||
传输层接收到请求,触发协议适配器ProtocolAdapter;
|
||||
ProtocolAdapter对象遍历Protocol插件的实现类,匹配协议;
|
||||
匹配协议之后,根据Protocol对象,传输层的Server对象绑定该协议的编解码器(Codec对象)、Channel处理链(ChainChannelHandler对象);
|
||||
对接收的消息进行解码与反序列化;
|
||||
执行Channel处理链;
|
||||
在业务线程池中调用消息处理链(MessageHandle插件);
|
||||
调用BizReqHandle对象的handle方法,处理请求消息;
|
||||
BizReqHandle对象调用restore方法,根据连接Session信息,处理请求消息数据,并根据请求的接口名、分组名与方法名,获取Exporter对象;
|
||||
调用Exporter对象的invoke方法,Exporter对象返回CompletableFuture对象;
|
||||
Exporter对象调用FilterChain的invoke方法;
|
||||
FilterChain执行所有Filter对象;
|
||||
Exporter对象的invokeMethod方法作为最后一个Filter,最后被调用;
|
||||
Exporter对象的invokeMethod方法处理请求上下文,执行反射;
|
||||
Exporter对象将执行反射之后得到的请求结果异步通知给BizReqHandle对象;
|
||||
BizReqHandle调用传输层的Channel对象,发送响应结果;
|
||||
传输层对响应消息进行协议转换、序列化、编码,最后通过网络传输响应给调用端。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天我们剖析了一款开源的RPC框架的代码,主要通过服务端启动流程、调用端启动流程、RPC调用流程这三大流程来将RPC框架的核心模块以及核心类串联起来。
|
||||
|
||||
在服务端的启动流程中,核心工作就是创建和开启Exporter对象。ProviderConfig在创建Exporter对象之前会先创建Registry对象,从注册中心中订阅接口配置与全局配置,之后才会创建Exporter对象,在Exporter开启时,会启动一个Server对象来开启一个端口,Exporter开启成功之后,才会通过Registry对象向注册中心发起注册。
|
||||
|
||||
在调用端的启动流程中,核心工作就是创建和开启Refer对象,开启Refer对象中处理逻辑最为复杂的就是对Cluster的open操作,Cluster负责了调用端的集群管理操作,其中有注册中心服务节点变更事件的监听、与服务端节点建立连接以及服务端节点连接状态的管理等等。
|
||||
|
||||
调用端向服务端发起调用时,会先经过动态代理,之后会调用Refer对象的invoke方法,Refer对象会先对要透传的消息进行处理,再执行Filter链,调用端最后一个Filter会根据配置的路由规则选择出符合条件的一组服务端节点,之后调用Route对象的route方法,route方法的内部逻辑会根据配置的负载均衡策略选择一个服务端节点,最后向这个服务端节点发送请求消息。
|
||||
|
||||
服务端的传输层收到调用端发送过来的请求消息,在对请求消息进行一系列处理之后(如解码、反序列化、协议转换等等),会在业务线程池中处理消息,关键的逻辑就是调用Exporter对象的invoke方法,Exporter对象的invoke方法会执行服务端配置的Filter链,最终通过反射或预编译对象执行业务逻辑,再将最终结果封装成响应消息,通过传输层响应给调用端。
|
||||
|
||||
本讲在调用端向服务端发起调用时,没有讲到异步调用,实际上Refer对象的invoke方法的实现逻辑完全是异步的,同样Exporter对象的invoke方法也是异步的,Refer类与Exporter类都是调用端Invoker接口的实现类,可以看下Invoker接口中invoke方法的定义:
|
||||
|
||||
/**
|
||||
* 调用
|
||||
*
|
||||
* @param request 请求
|
||||
* @return
|
||||
*/
|
||||
|
||||
CompletableFuture<Result> invoke(RequestMessage<Invocation> request);
|
||||
|
||||
|
||||
JoyRPC框架是一个纯异步的RPC框架,所谓的同步只不过是对异步进行了等待。
|
||||
|
||||
入口层的核心对象就是Exporter对象与Refer对象,这两个类承担了入口层的大多数核心逻辑。
|
||||
|
||||
集群层的核心对象就是Cluster对象与Registry对象,Cluser对象的内部逻辑还是非常复杂的,核心逻辑就是与Registry交互,订阅服务端节点变更事件,以及对与服务端节点建立的连接的管理,这里我们对Cluser对象没有进行过多介绍,你可以去查看代码。
|
||||
|
||||
协议层的核心对象就是Protocol接口的各个子类了。
|
||||
|
||||
接下来就是传输层了,传输层的具体实现我们在本讲也没有过多介绍,因为很难通过有限的内容把它讲解完整,还是建议你去查看下源码,一目了然。传输层是纯异步的并且是完全插件化的,其入口就是EndpointFactroy插件,通过EndpointFactroy插件获取一个EndpointFactroy对象,EndpointFactroy对象是一个工厂类,用来创建Client对象与Server对象。
|
||||
|
||||
对于一个完善的RPC框架,今天我们仅是针对服务端启动流程、调用端启动流程、RPC调用流程这三个主流程做了一个大致的讲解,真正实现起来还是要复杂许多,因为涉及到了很多细节上的问题,但主要脉络出来以后,相信也会对你有很大帮助,更多的细节就还是要靠你自己去阅读源码啦!
|
||||
|
||||
今天的加餐分享就到这里,有任何问题,欢迎你在留言区与我交流!
|
||||
|
||||
|
||||
|
||||
|
77
专栏/RPC实战与核心原理/加餐谈谈我所经历过的RPC.md
Normal file
77
专栏/RPC实战与核心原理/加餐谈谈我所经历过的RPC.md
Normal file
@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 谈谈我所经历过的RPC
|
||||
你好,我是何小锋。上一讲我们学习了如何在线上环境里兼容多种RPC协议,目的就是为了能够平滑地升级线上环境中已经存在的RPC框架,同时我们也可以利用多协议的特点来支持不同的使用场景。
|
||||
|
||||
以上就是我们整个专栏关于技术内容的最后一讲了,很幸运能够和你一起携手并肩走过这些日子,这段时间我们跨了新年,也经历了让人猝不及防的新冠肺炎。现在我才发现原来能够自由地呼吸新鲜空气也是一种幸福,祝你平平安安度过这段艰难的日子,期待我们能早日摘下口罩。为了感谢你这些日子的陪伴,今天我们来换换脑子,聊一些轻松的话题。我就和你分享分享我所经历过的RPC框架吧。
|
||||
|
||||
与RPC结缘
|
||||
|
||||
我1998从大学毕业的时候,互联网并不是像今天这样如火如荼地进行着。那时候大部分IT公司都在耕耘数字化办公相关领域,所以大学毕业的时候我也就随了大流,进入了一个从事办公软件开发的公司,也就是我们今天经常说的“传统软件行业”。
|
||||
|
||||
后来随着互联网的快速普及,各个行业都想借着这个机会跟互联网发生点关系,因为大家都知道互联网代表着未来,在咱国内借助互联网技术发展最快的行业代表就是电商和游戏。
|
||||
|
||||
得益于互联网的普及,各个互联网公司有关技术的文章也是扑面而来,比如A公司因为流量激增而导致整站长时间瘫痪,B公司因为订单持续上涨而导致数据库压力太大等等。每当我看到类似技术文章的时候,我都在想自己什么时候也可以遇到能用技术优化的方式来解决的问题呢。后来发现这些“设想”在我当时所在的行业里很难体会到,所以2011年我毅然选择了加入京东。
|
||||
|
||||
那时候我们公司刚好处于一个业务高速发展期,但系统稳定性相比今天来讲的话,还处于一个比较稚嫩的阶段。在日常工作中,我们的研发人员不仅要面对来自业务方需求进度的压力,还要面对因为线上业务增长而导致的系统压力。这样的双重压力对我来说很新鲜,这种体验是我之前从未有过的,这让我感到很兴奋。
|
||||
|
||||
之前我自己也做了很长一段时间的业务系统开发,虽然积累了不少实用的编程技巧,也认真阅读过国内外很多优秀的开源代码,包括像Spring、Netty等等这样优秀的框架,但因为从来没有经历过这么多系统之间复杂调用的场景,所以在入职后的一段时间里,我的内心一直处于忐忑状态。
|
||||
|
||||
得幸于同事的热情帮助,我很快地就融入了团队氛围。因为我们大团队是负责整个公司基础架构的,可以简单理解成我们就是负责公司PaaS平台的建设,既然是P,aaS平台那肯定少不了中间件,而中间件里面最核心的应该就是RPC了,因为RPC相对其它中间件来说,使用频率是最高的,也是我们构建分布式系统的基石。
|
||||
|
||||
从此我就踏上了RPC这条“不归路”了,当然并不是因为这是条绝路,而是这条路一直充满着挑战,并没有让我觉得有停下来的想法。
|
||||
|
||||
使用过的RPC
|
||||
|
||||
因为我工作年限比较长,所以编程经历相对也比较丰富,在转Java之前我做过一段时间的.Net。早期因为主要做数字化办公软件,所以那时候用.Net编程的机会比较多,主要是因为很多应用采用.Net相对使用Java来说,开发效率会高很多。但后面应用复杂之后,.Net在性能、可维护性上都不如Java有优势,而且.Net在很长一段时间内并不支持在Linux环境下部署,所以我们就把应用都逐渐改成Java了。
|
||||
|
||||
ICE
|
||||
|
||||
这就存在一个问题,我们是怎么把.Net应用平滑地切换到Java上呢?原本是.Net应用之间的互相调用,需要改成.Net跟Java之间互相通信。为了解决这个问题,我们当时选择了一个比较古老的RPC框架ICE(https://zeroc.com),可能现在很多人都没有听说过了。
|
||||
|
||||
Hessian
|
||||
|
||||
后面使用Java开发应用的机会越来越多,而且随着Spring开发方式的大流行,在Java应用里面使用ICE来完成应用之间的RPC调用就变得比较鸡肋了。所以当时我们用了一种新的RPC框架Hessian(http://hessian.caucho.com/),这时我们就把Java应用之间的RPC调用方式改成了Hessian方式。
|
||||
|
||||
用Hessian的原因就是因为它可以很好地跟Spring进行集成,对Spring项目的开发人员来说,开发一个RPC接口就变得很容易了,我们可以直接把Java类对外进行暴露,用作RPC接口的定义,而不需要像ICE一样先定义Stub。
|
||||
|
||||
单纯的从RPC框架角度出发,Hessian是一款很优秀的产品,即使放到今天,它的性能和鲁棒性都有着很强的参考意义。但当业务发展壮大到一定程度后,应用之间的调用就不仅仅需要考虑用什么RPC框架了,更多的是需要考虑怎么去完成服务治理。
|
||||
|
||||
另外一个原因就是因为Hessian是没有服务发现功能的,我们只能通过VIP暴露的方式完成调用,我们需要给每个应用分配一个VIP,把同一接口的所有服务提供方实例挂载到同一个VIP上。这种集中式流量转发架构就会使得提供VIP服务的LVS存在很大的压力,而且集中式流量的转发会让调用方响应时间相对变长。
|
||||
|
||||
Dubbo
|
||||
|
||||
为了解决类似Hessian这种集中式问题,实现大规模应用服务化的落地,国内的RPC框架Dubbo的做法就显得比较先进了。随着业务越来越复杂,应用之间的调用关系也就变得更加错综复杂,所以后面我们也是选择基于Dubbo进行扩展,以完成RPC通信,而服务发现则通过接入ZooKeeper集群来完成。通过这种现有框架的搭配,我们完成了应用服务化的快速落地,也同时完成了统一公司内部所有应用RPC框架的目标。
|
||||
|
||||
但随着微服务理念越来越流行,很多应用的接口也是越拆越细,导致我们ZooKeeper集群需要接入的接口数量越来越多;还有就是因为我们每年的业务量是成倍增长,为了让应用能够抗足够的调用量应用,我们也需要经常扩容,从而导致ZooKeeper集群接入的IP实例数也是呈数量级增长的,这使得我们的ZooKeeper集群负荷特别重。
|
||||
|
||||
再有就是Dubbo相对有点复杂,而且性能还有提高空间,这使得我们不得不考虑新的方案。
|
||||
|
||||
自研RPC
|
||||
|
||||
在这种背景下,我们决定自行研发一套适合自己业务场景的微服务解决方案,包括RPC框架、服务治理以及多语言解决方案。
|
||||
|
||||
至此我们自研的RPC就一直平稳地支持着公司内的各种业务。
|
||||
|
||||
这几年,在以Kubernetes为代表的基础设施演进过程中,一个重要的关键词就是应用基础设施能力的下沉。在过去我们给应用提供RPC能力的时候,都是需要应用引入Jar包方式来解决的,在RPC里面,我们要把服务发现、路由等一整套RPC解决方案都融入到这个Jar里面去。
|
||||
|
||||
未来
|
||||
|
||||
目前,Kubernetes已成为基础设施的事实标准。而原先通过Jar包的方式封装的各种基础设施能力,现在全都被Kubernetes项目从应用层拽到了基础设施中。那对于我们RPC来说也是一样,我们需要把非业务功能从传统的RPC框架中剥离出来,下沉到基础设施并且融入基础设施,然后通过Mesh去连接应用和基础设施。
|
||||
|
||||
这也是RPC发展的下一个阶段,完成所有应用的Mesh化。所以说,RPC这条路没有尽头,只有不断的挑战和乐趣。希望你也能爱上它!
|
||||
|
||||
那最后我还想和你谈谈我对RPC的看法。
|
||||
|
||||
可能大家在谈论RPC时候,都想着RPC只是解决应用之间调用的工具。从本质上来讲,这没有什么问题,但在现实中,我们需要RPC解决更多的实际问题,比如服务治理,这些东西都是在使用RPC的过程中需要考虑的问题,所以我个人认为RPC应该是一个比较泛的概念。
|
||||
|
||||
当然,可能我们中大多数人现在是没有机会去完整实现一个新的RPC的,这不仅是精力的问题,更多是实际需求的问题,那为什么我们还需要学好RPC呢?我的想法很简单也非常实在,就是因为RPC是我们构建分布式系统的基石,就好比我们每次都是从“Hello World”开始学习一门新的编程语言。期待你能打牢这个基础,总有一天你会体验到它的能量!
|
||||
|
||||
今天的特别放送就到这里,也非常期待听到你和RPC的故事。
|
||||
|
||||
|
||||
|
||||
|
77
专栏/RPC实战与核心原理/答疑课堂基础篇与进阶篇思考题答案合集.md
Normal file
77
专栏/RPC实战与核心原理/答疑课堂基础篇与进阶篇思考题答案合集.md
Normal file
@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
答疑课堂 基础篇与进阶篇思考题答案合集
|
||||
你好,我是何小锋。到今天为止,基础篇和进阶篇我们就都学习完了,在这个过程中我一直在看大家的留言,知道你可能还有很多地方存在着疑问,今天这一讲我整理了一些关注度比较高的课后思考题答案,希望能给你带来帮助。
|
||||
|
||||
第二讲
|
||||
|
||||
思考题:在RPC里面,我们是怎么实现请求跟响应关联的?
|
||||
|
||||
首先我们要弄清楚为什么要把请求与响应关联。这是因为在RPC调用过程中,调用端会向服务端发送请求消息,之后它还会收到服务端发送回来的响应消息,但这两个操作并不是同步进行的。在高并发的情况下,调用端可能会在某一时刻向服务端连续发送很多条消息之后,才会陆续收到服务端发送回来的各个响应消息,这时调用端需要一种手段来区分这些响应消息分别对应的是之前的哪条请求消息,所以我们说RPC在发送消息时要请求跟响应关联。
|
||||
|
||||
解决这个问题不难,只要调用端在收到响应消息之后,从响应消息中读取到一个标识,告诉调用端,这是哪条请求消息的响应消息就可以了。在这一讲中,你会发现我们设计的私有协议都会有消息ID,这个消息ID的作用就是起到请求跟响应关联的作用。调用端为每一个消息生成一个唯一的消息ID,它收到服务端发送回来的响应消息如果是同一消息ID,那么调用端就可以认为,这条响应消息是之前那条请求消息的响应消息。
|
||||
|
||||
第五讲
|
||||
|
||||
思考题:如果没有动态代理帮我们完成方法调用拦截,用户该怎么完成RPC调用?
|
||||
|
||||
这个问题我们可以参考下gRPC框架。gRPC框架中就没有使用动态代理,它是通过代码生成的方式生成Service存根,当然这个Service存根起到的作用和RPC框架中的动态代理是一样的。
|
||||
|
||||
gRPC框架用代码生成的Service存根来代替动态代理主要是为了实现多语言的客户端,因为有些语言是不支持动态代理的,比如C++、go等,但缺点也是显而易见的。如果你使用过gRPC,你会发现这种代码生成Service存根的方式与动态代理相比还是很麻烦的,并不如动态代理的方式使用起来方便、透明。
|
||||
|
||||
第六讲
|
||||
|
||||
思考题:在 gRPC 调用的时候,我们有一个关键步骤就是把对象转成可传输的二进制,但是在 gRPC 里面,我们并没有直接转成二进制数组,而是返回一个 InputStream,你知道这样做的好处是什么吗?
|
||||
|
||||
RPC调用在底层传输过程中也是需要使用Stream的,直接返回一个InputStream而不是二进制数组,可以避免数据的拷贝。
|
||||
|
||||
第八讲
|
||||
|
||||
思考题:目前服务提供者上线后会自动注册到注册中心,服务调用方会自动感知到新增的实例,并且流量会很快打到该新增的实例。如果我想把某些服务提供者实例的流量切走,除了下线实例,你有没有想到其它更便捷的办法呢?
|
||||
|
||||
解决这个问题的方法还是有很多的,比如留言中提到的改变服务提供者实例的权重,将权重调整为0,或者通过路由的方式也可以。
|
||||
|
||||
但解决这个问题最便捷的方式还是使用动态分组,在[第 16 讲] 中我讲解了业务分组的概念,通过业务分组来实现流量隔离。如果业务分组是动态的,我们就可以在管理平台动态地自由调整,那是不是就可以实现动态地流量切换了呢?这个问题我们还会在高级篇中详解,期待一下。
|
||||
|
||||
第十二讲
|
||||
|
||||
思考题:在整个RPC调用的流程中,异常重试发生在哪个环节?
|
||||
|
||||
在回答这个问题之前,我们先回想下这一讲中讲过的内容。我在讲RPC为什么需要异常重试时我说过,如果在发出请求时恰好网络出现问题了,导致我们的请求失败,我们可能需要进行异常重试。从这一点我们可以看出,异常重试的操作是要在调用端进行的。因为如果在调用端发出请求时恰好网络出现问题导致请求失败,那么这个请求很可能还没到达服务端,服务端当然就没办法去处理重试了。
|
||||
|
||||
另外,我还讲过,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。由此可见异常重试的操作应该发生在负载均衡之前,在发起重试的时候,会调用负载均衡插件来选择一个服务节点,在调用负载均衡插件时我们要告诉负载均衡需要刨除哪些有问题的服务节点。
|
||||
|
||||
在整个RPC调用的过程中,从动态代理到负载均衡之间还有一系列的操作,如果你研究过开源的RPC框架,你会发现在调用端发送请求消息之前还会经过过滤链,对请求消息进行层层的过滤处理,之后才会通过负载均衡选择服务节点,发送请求消息,而异常重试操作就发生在过滤链处理之后,调用负载均衡选择服务节点之前,这样的重试是可以减少很多重复操作的。
|
||||
|
||||
第十四讲
|
||||
|
||||
思考题:在启动预热那部分,我们特意提到过一个问题,就是“当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住”,不知道你是怎么看待这个问题的,是否有好的解决方案呢?
|
||||
|
||||
我们可以考虑在非流量高峰的时候重启服务,将影响降到最低;也可以考虑分批次重启,控制好每批重启的服务节点的数量,当一批服务节点的权重与访问量都到正常水平时,再去重启下一批服务节点。
|
||||
|
||||
第十五讲
|
||||
|
||||
思考题:在使用RPC的过程中业务要实现自我保护,针对这个问题你是否还有其他的解决方案?
|
||||
|
||||
通过这一讲我们知道,在RPC调用中无论服务端还是调用端都需要自我保护,服务端自我保护的最简单有效的方式是“限流”,调用端则可以通过“熔断”机制来进行自我保护。
|
||||
|
||||
除了“熔断”和“限流”外,相信你一定听过“降级”这个词。简单来说就是当一个服务处理大量的请求达到一定压力的时候,我们可以让这个服务在处理请求时减少些非必要的功能,从而降低这个服务的压力。
|
||||
|
||||
还有就是我们可以通过服务治理,降低一个服务节点的权重来减轻某一方服务节点的请求压力,达到保护这个服务节点的目的。
|
||||
|
||||
第十六讲
|
||||
|
||||
思考题:在我们的实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,你有什么好办法吗?
|
||||
|
||||
我们可以考虑配置不同的注册中心,开发人员将自己的服务注册到注册中心A上,而测试人员可以将自己的服务注册到测试专属的注册中心B上,这样测试人员在验证功能的时候,调用端会从注册中心B上拉取服务节点,开发人员重启自己的服务是影响不到测试人员的。
|
||||
|
||||
如果你使用过或者了解k8s的话,你一定知道“命名空间”的概念,RPC框架如果支持“命名空间”,也是可以解决这一问题的。
|
||||
|
||||
今天的答疑就到这里,如果你有更多问题,欢迎继续在留言区中告知,我们共同讨论。下节课再见!
|
||||
|
||||
|
||||
|
||||
|
39
专栏/RPC实战与核心原理/结束语学会从优秀项目的源代码中挖掘知识.md
Normal file
39
专栏/RPC实战与核心原理/结束语学会从优秀项目的源代码中挖掘知识.md
Normal file
@ -0,0 +1,39 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 学会从优秀项目的源代码中挖掘知识
|
||||
你好,我是何小锋。
|
||||
|
||||
今天是咱们专栏的最后一课。首先,我的读者,非常感谢你对这个专栏的支持。当你看到这儿,恭喜你“毕业”了,我也“毕业”了!从专栏筹备到结课,转眼已经过去了半年的时间。这段经历令我很难忘,可以说是痛苦与快乐并存吧,今天结课我还真想和你说道说道。
|
||||
|
||||
我其实不太善言辞,挺典型的一类技术人,做这件事的出发点还是对技术的热爱,我希望能把自己这些年的经验去沉淀一下,分享出去供更多的人参考,与更多的人探讨和交流,很开心我做到了,有一种成就感和满足感在。但写作的过程确实还挺难的,有点出乎我的意料,讲课和写技术文档的差别不小,我觉得这其中最大的区别就是讲课我需要去设计内容,怎么让内容“既浅又深”,让不同阶段的人都能“看得懂、学得会、用得着”,就是我的标准。
|
||||
|
||||
我们应该学会分享与交流,这是我在写作过程中最大的一个感触。
|
||||
|
||||
在写的过程中,我会不停地调动自己积累的知识和经验,每一节课,每一个问题,都需要我在脑子里一遍遍梳理好才能成文,对我自己的知识体系是一次很好的加固和升级的机会。当然,我也会碰到新的疑惑和问题,甚至是自我否定,但这不重要,重要的是我解决了它,我的知识面得到了拓展。还有就是,我会感觉到一种快乐,别人会因为我的分享而有收获,这是一种肯定。
|
||||
|
||||
当然,分享和兴趣虽是初衷,但我打的也是“有准备之仗”。在写这个专栏之前,我曾对我所运营的RPC框架进行了重定义,整体架构完全重新设计,代码也完全重写,这里面的主要原因还是在于旧版本的RPC框架由于一次次的代码迭代,已经有了太多的补丁,代码维护起来已经十分困难了,它很难再满足业务新增的需求。
|
||||
|
||||
在设计与编写RPC框架的过程中,我从业界的RPC框架中吸取经验,我先对自己的认知做了一次全面的升级。正如我在[开篇词] 里说的,RPC是解决分布式系统通信问题的一大利器,在我所接触的分布式系统中,基本都离不开RPC。这一点令我自己都是兴奋的。
|
||||
|
||||
决定了重定义RPC框架,我就坚持对每一行代码,甚至是每一行注释进行review,对代码的扩展性、可阅读性以及性能都尽量做到完美。但是在写专栏的时候,我并没有写到太多的代码,因为在我看来,在设计整体框架、实现每个功能、解决某一个难题时,我们首先要有一个或多个完整的并且可行的思路和想法,而编码的过程不过是将解决问题的思路和想法以代码的形式翻译出来。当然编码也需要很扎实的基本功,这一点我们不能否认。
|
||||
|
||||
那讲到这儿,我还是想再强调一下咱们专栏的重点,以免你迷路。我把重点放在了RPC框架的一些基础功能与高级功能的实现思路上,以及某一类问题的解决办法上。
|
||||
|
||||
从内容结构上来说,为了能让你更好地学习RPC框架,整个专栏的内容设计还是比较系统的,我们从RPC框架的基础功能,讲到集群、治理等相关的高级功能,再到RPC框架的性能优化以及问题排查等等,整个学习过程由浅入深。
|
||||
|
||||
希望看到最后一课的你,已经完全理解了我的用意和用心!
|
||||
|
||||
那最后,我还想给你一个小建议,那就是你一定要学会从优秀项目的源代码中挖掘知识,结合自己的见解与经验,去解决一个又一个的难题,形成自己的知识体系,而我前面所说的分享就是检验成果的一个好办法。
|
||||
|
||||
最后的最后,还是要感谢你,我的读者,多谢你的支持,你的鼓励和批评是我前进的最大动力。如果你有什么意见或建议,欢迎你通过结课问卷告知我,我会正视大家的声音。
|
||||
|
||||
我是何小锋,我们后会有期!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user