first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View 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的认识以及学习它的痛点、难点我也好有针对性地为你讲解。现在就让我们共同开启这段学习之旅吧

View 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使用过程中需要注意哪些问题
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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里面我们是怎么实现请求跟响应关联的呢
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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使用的时候需要定义IDLInterface 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调用中使用
欢迎留言和我分享你的答案和经验,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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模型分为四种同步阻塞IOBIO、同步非阻塞IONIO、IO多路复用和异步非阻塞IOAIO。在这四种IO模型中只有AIO为异步IO其他都是同步IO。
其中最常用的就是同步阻塞IO和IO多路复用这一点通过了解它们的机制你会get到。至于其他两种IO模型因为不常用则不作为本讲的重点有兴趣的话我们可以在留言区中讨论。
阻塞IOblocking 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框架的整体性能。
课后思考
回想一下,你所接触的开源中间件框架有哪些框架在网络通信上做到了零拷贝?都是使用哪种方式实现的零拷贝?
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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来完成底层代理。相比JavassistByte Buddy提供了更容易操作的API编写的代码可读性更高。更重要的是生成的代理类执行速度比Javassist更快。
虽然以上这三种框架使用的方式相差很大但核心原理却是差不多的区别就只是通过什么方式生成的代理类以及在生成的代理类里面是怎么完成的方法调用。同时呢也正是因为这些细小的差异才导致了不同的代理框架在性能方面的表现不同。因此我们在设计RPC框架的时候还是需要进行一些比较的具体你可以综合它们的优劣以及你的场景需求进行选择。
总结
今天我们介绍了动态代理在RPC里面的应用虽然它只是一种具体实现的技术但我觉得只有理解了方法调用是怎么被拦截的才能厘清在RPC里面我们是怎么做到面向接口编程帮助用户屏蔽RPC调用细节的最终呈现给用户一个像调用本地一样去调用远程的编程体验。
既然动态代理是一种具体的技术框架,那就会涉及到选型。我们可以从这样三个角度去考虑:
因为代理类是在运行中生成的,那么代理框架生成代理类的速度、生成代理类的字节码大小等等,都会影响到其性能——生成的字节码越小,运行所占资源就越小。
还有就是我们生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。
最后一个是从我们的使用角度出发的我们肯定希望选择一个使用起来很方便的代理类框架比如我们可以考虑API设计是否好理解、社区活跃度、还有就是依赖复杂度等等。
最后我想再强调一下。动态代理在RPC里面虽然看起来只是一个很小的技术点但就是这个创新使得用户可以不用关注细节了。其实我们在日常设计接口的时候也是一样的我们会想尽一切办法把细节对调用方屏蔽让调用方的接入尽可能的简单。这就好比让你去设计一个商品发布的接口你并不需要暴露给用户一些细节比如告诉他们商品数据是怎么存储的。
课后思考
请你设想一下如果没有动态代理帮我们完成方法调用拦截用户该怎么完成RPC调用
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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 设计的,序列化支持 PBProtocol 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 传输基本单位是 FrameFrame 格式是以固定 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 里面会绑定一个 FrameListenergRPC 会在这个 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你知道这样做的好处是什么吗
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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有自带的SPIService Provider Interface服务发现机制它可以动态地为某个接口寻找服务实现。使用SPI机制需要在Classpath下的META-INF/services目录里创建一个以服务接口命名的文件这个文件里的内容就是这个接口的具体实现类。
但在实际项目中我们其实很少使用到JDK自带的SPI机制首先它不能按需加载ServiceLoader加载某个接口实现类的时候会遍历全部获取也就是接口的实现类得全部载入并实例化一遍会造成不必要的浪费。另外就是扩展如果依赖其它的扩展那就做不到自动注入和装配这就很难和其他框架集成比如扩展里面依赖了一个Spring Bean原生的Java SPI就不支持。
加上了插件功能之后我们的RPC框架就包含了两大核心体系——核心功能体系与插件体系如下图所示
这时整个架构就变成了一个微内核架构我们将每个功能点抽象成一个接口将这个接口作为插件的契约然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构有很多优势。首先它的可扩展性很好实现了开闭原则用户可以非常方便地通过插件扩展实现自己的功能而且不需要修改核心功能的本身其次就是保持了核心包的精简依赖外部包少这样可以有效减少开发人员引入RPC导致的包版本冲突问题。
总结
我们都知道软件开发的过程很复杂,不仅是因为业务需求经常变化,更难的是在开发过程中要保证团队成员的目标统一。我们需要用一种可沟通的话语、可“触摸”的愿景达成目标,我认为这就是软件架构设计的意义。
但仅从功能角度设计出的软件架构并不够健壮,系统不仅要能正确地运行,还要以最低的成本进行可持续的维护,因此我们十分有必要关注系统的可扩展性。只有这样,才能满足业务变化的需求,让系统的生命力不断延伸。
课后思考
你能分享一下,在日常工作中,你都有哪些地方是用到了插件思想来解决扩展性问题的吗?
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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框架的“服务发现”中。例如服务节点数据的推送采用增量更新的方式这种方式提高了注册中心“服务下发”的效率而这种方式你还可以利用在其它地方比如统一配置中心用此方式可以提升统一配置中心下发配置的效率。
课后思考
目前服务提供者上线后会自动注册到注册中心,服务调用方会自动感知到新增的实例,并且流量会很快打到该新增的实例。如果我想把某些服务提供者实例的流量切走,除了下线实例,你有没有想到其它更便捷的办法呢?
欢迎留言和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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然后根据响应结果来进行存活判断这样就可以防止僵死状态的误判。你想想这不就是咱们前面讲到的心跳机制吗
不过,这个案例里,我还要卖个关子。加完心跳机制,是不是就没有问题了呢?当然不是,因为检测程序所在的机器和目标机器之间的网络可能还会出现故障,如果真出现了故障,不就会误判吗?你以为人家已经生病或者挂了,其实是心跳仪器坏了…
根据我的经验,有一个办法可以减少误判的几率,那就是把检测程序部署在多个机器里面,分布在不同的机架,甚至不同的机房。因为网络同时故障的概率非常低,所以只要任意一个检测程序实例访问目标机器正常,就可以说明该目标机器正常。
课后思考
不知道看完今天的分享之后你有何感触,你在工作中会接触到健康检测的场景吗?你可以在留言区给我分享下你是怎么做的,或者给我的方案挑挑毛病,我会第一时间给你反馈。
当然,也欢迎你留言和我分享你的思考和疑惑,期待你能把今天的所学分享给身边的朋友,邀请他一同交流。我们下节课再见!

View 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的过程中除了用路由策略实现过灰度发布、定点调用等功能还用它完成过其他功能吗
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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那么计算后最终权重就是80100*80%。服务调用者发送请求时会通过随机权重的策略来选择服务节点那么这个节点接收到的流量就是其他正常节点的80%这里假设其他节点默认权重都是100且指标正常打分为10分的情况
到这儿,一个自适应的负载均衡我们就完成了,整体的设计方案如下图所示:
关键步骤我来解释下:
添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
运行时状态指标收集器收集服务节点CPU核数、CPU负载以及内存等指标在服务调用者与服务提供者的心跳数据中获取。
请求耗时指标收集器收集请求耗时数据如平均耗时、TP99、TP999等。
可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。
总结
今天我们详细讲解了RPC框架的负载均衡它与Web服务的负载均衡的不同之处在于RPC框架并不是依赖一个负载均衡设备或者负载均衡服务器来实现负载均衡的而是由RPC框架本身实现的服务调用者可以自主选择服务节点发起服务调用。
这样的好处是RPC框架不再需要依赖专门的负载均衡设备可以节约成本还减少了与负载均衡设备间额外的网络传输提升了传输效率并且均衡策略可配便于服务治理。
除此之外,我们今天的重点还涉及到“如何设计一个自适应的负载均衡”,通过它,我们可以就能根据服务调用者依赖的服务集群中每个节点的自身状态,智能地控制发送给每个服务节点的请求流量,防止因某个服务节点负载过高、请求处理过慢而影响到整个服务集群的可用率。
这个自适应负载均衡的实现方案其实不只是应用于RPC框架中的负载均衡它本身便是一个智能负载的解决方案如果你在工作中需要设计一个智能的负载均衡服务那么完全可以参考。
课后思考
你知道RPC框架中还有哪些负载均衡策略吗它们的优缺点是什么期待你能在留言区中分享实现方法与我共同探讨。
也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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调用的流程中异常重试发生在哪个环节
欢迎留言分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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关闭的时候也是先从外层到里层逐层进行关闭先保证不接收新请求然后再处理关闭前收到的请求。
课后思考
今天我只讲了优雅关闭,其实应用重启上下线的时候,还涉及到应用启动流程。那么如何做到优雅启动,避免请求分发到没有就绪的服务节点呢?请你先自行考虑一下,我会在下一讲给你细说。
当然,也欢迎你留言先和我分享你的思考和疑惑,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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的专属功能我们在开发其它系统时也可以利用这两点来减少冷启动对业务的影响。
课后思考
在启动预热那部分,我们特意提到过一个问题,就是“当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住”,不知道你是怎么看待这个问题的,是否有好的解决方案呢?
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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依赖服务CC超时直接导致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的过程中业务要实现自我保护针对这个问题你是否还有其他的解决方案
欢迎留言分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View File

@ -0,0 +1,73 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 业务分组:如何隔离流量?
你好我是何小锋。上一讲我们介绍了RPC中常用的保护手段“熔断限流”熔断是调用方为了避免在调用过程中服务提供方出现问题的时候自身资源被耗尽的一种保护行为而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。虽然这两种手段作用的对象不同但出发点都是为了实现自我保护所以一旦发生这种行为业务都是有损的。
那说起突发流量限流固然是一种手段但其实面对复杂的业务以及高并发场景时我们还有别的手段可以最大限度地保障业务无损那就是隔离流量。这也是我今天重点要和你分享的内容接下来我们就一起看看分组在RPC中的应用。
为什么需要分组?
在我们的日常开发中,我们不都提倡让用户使用起来越简单越好吗?如果在接口上再加一个分组维度去管理,不就让事情变复杂了吗?
实则不然,举个例子。在没有汽车的年代,我们的道路很简单,就一条,行人、洋车都在上边走。那随着汽车的普及以及猛增,我们的道路越来越宽,慢慢地有了高速、辅路、人行道等等。很显然,交通网的建设与完善不仅提高了我们的出行效率,而且还更好地保障了我们行人的安全。
同样的道理我们用在RPC治理上也是一样的。假设你是一个服务提供方应用的负责人在早期业务量不大的情况下应用之间的调用关系并不会复杂请求量也不会很大我们的应用有足够的能力扛住日常的所有流量。我们并不需要花太多的时间去治理调用请求过来的流量我们通常会选择最简单的方法就是把服务实例统一管理把所有的请求都用一个共享的“大池子”来处理。这就类似于“简单道路时期”服务调用方跟服务提供方之间的调用拓扑如下图所示
后期因为业务发展丰富了,调用你接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致它们的整体可用率下降。而这时候作为应用负责人的你,那就得变身“救火队长”了,要想尽各种办法来保证应用的稳定。
在经过一系列的救火操作后,我们肯定要去想更好的应对办法。那回到问题的根本去看,关键就在于,早期为了管理方便,我们把接口都放到了同一个分组下面,所有的服务实例是以一个整体对外提供能力的。
但后期因为业务发展这种粗暴的管理模式已经不适用了这就好比“汽车来了我们的交通网也得抓紧建设”一样让人车分流。此时道路上的人和车就好比我们应用的调用方我们可以尝试把应用提供方这个大池子划分出不同规格的小池子再分配给不同的调用方而不同小池子之间的隔离带就是我们在RPC里面所说的分组它可以实现流量隔离。
怎么实现分组?
现在分组是怎么回事我们搞清楚了那放到RPC里我们该怎么实现呢
既然是要求不同的调用方应用能拿到的池子内容不同那我们就要回想下服务发现了因为在RPC流程里能影响到调用方获取服务节点的逻辑就是它了。
在[第 08 讲] 我们说过,服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。
通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?
坦白讲,这个分组并没有一个可衡量的标准,但我自己总结了一个规则可以供你参考,就是按照应用重要级别划分。
非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。比如提供给电商下单过程中用的商品信息接口,我们肯定是需要独立出一个单独分组,避免受其它调用方污染的。有了分组之后,我们的服务调用方跟服务提供方之间的调用拓扑就如下图所示:
通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是我们日常治理服务过程中一个高频使用的手段,那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?
如何实现高可用?
分组隔离后单个调用方在发RPC请求的时候可选择的服务节点数相比没有分组前减少了那对于单个调用方来说出错的概率就升高了。比如一个集中交换机设备突然坏了而这个调用方的所有服务节点都在这个交换机下面在这种情况下对于服务调用方来说它的请求无论如何也到达不了服务提供方从而导致这个调用方业务受损。
那有没有更高可用一点的方案呢?回到我们前面说的那个马路例子上,正常情况下我们是必须让车在车道行驶,人在人行道上行走。但当人行道或者车道出现抢修的时候,在条件允许的情况下,我们一般都是允许对方借道行驶一段时间,直到道路完全恢复。
我们同样可以把这个特性用到我们的RPC中要怎么实现呢
在前面我们也说了,调用方应用服务发现的时候,除了带上对应的接口名,还需要带上一个特定分组名,所以对于调用方来说,它是拿不到其它分组的服务节点的,那这样的话调用方就没法建立起连接发请求了。
因此问题的核心就变成了调用方要拿到其它分组的服务节点,但是又不能拿到所有的服务节点,否则分组就没有意义了。一个最简单的办法就是,允许调用方可以配置多个分组。但这样的话,这些节点对于调用方来说就都是一样的了,调用方可以随意选择获取到的所有节点发送请求,这样就又失去了分组隔离的意义,并且还没有实现我们想要的“借道”的效果。
所以我们还需要把配置的分组区分下主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点;只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上,整个切换过程对于应用层完全透明,从而在一定程度上保障调用方应用的高可用。
总结
今天我们通过一个道路划分的案例引出了在RPC里面我们可以通过分组的方式人为地给不同的调用方划分出不同的小集群从而实现调用方流量隔离的效果保障我们的核心业务不受非核心业务的干扰。但我们在考虑问题的时候不能顾此失彼不能因为新加一个的功能而影响到原有系统的稳定性。
其实我们不仅可以通过分组把服务提供方划分成不同规模的小集群,我们还可以利用分组完成一个接口多种实现的功能。正常情况下,为了方便我们自己管理服务,我一般都会建议每个接口完成的功能尽量保证唯一。但在有些特殊场景下,两个接口也会完全一样,只是具体实现上有那么一点不同,那么我们就可以在服务提供方应用里面同时暴露两个相同接口,但只是接口分组不一样罢了。
课后思考
在我们的实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,你有什么好办法吗?
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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框架的异步策略
欢迎留言分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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接口定义里面一般会包含多个方法但我们目前只是解决了你能不能调用接口的问题并没有解决你能调用我接口里面的哪些方法。像这种问题你有什么好方案吗
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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分别是Span1A->B、Span2B->C、Span3C->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以及产生异常的原因等信息这样对于使用方来说就非常便捷了。
另外,服务提供方在提供服务时也要对异常进行封装,以方便上游排查问题。
在分布式环境下,我们可以通过分布式链路跟踪来快速定位问题,尤其是在多个部门的合作中,这样做可以一步到位,减少排查问题的时间,降低沟通成本,以最高的效率解决实际问题。
课后思考
在分布式环境下,你还知道哪些快速定位问题的方法?
期待你能在留言区中和我分享,也欢迎你把文章分享给你的朋友,邀请他加入学习,共同探讨。我们下节课再见!

View 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个任务分别是任务A90毫秒之后执行、任务B610毫秒之后执行与任务C1秒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框架中除了我说过的那几个例子你还知道有哪些功能的实现可以应用到时钟轮
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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的人来说他们有了这个功能之后就可以更放心地升级自己的应用了。
课后思考
除了上面我提到的可以使用流量回放功能来验证改造后的应用逻辑,我们还可以用流量回放来做哪些有意义的事儿?
欢迎留言和我分享你的思考,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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 讲],我们讲了分组后带来的收益,它可以帮助服务提供方实现调用方的隔离。但是因为调用方流量并不是一成不变的,而且还可能会因为突发事件导致某个分组的流量溢出,而在整个大集群还有富余能力的时候,又因为分组隔离不能为出问题的集群提供帮助。
为了解决这种突发流量的问题,我们提供了一种更高效的方案,可以实现分组的快速扩缩容。事实上我们还可以利用动态分组解决分组后给每个分组预留机器冗余的问题,我们没有必要把所有冗余的机器都分配到分组里面,我们可以把这些预留的机器做成一个共享的池子,从而减少整体预留的实例数量。
课后思考
在服务治理的过程中,我们通常会给服务进行逻辑分组,但之后某个分组可能会遇到突发流量调用的问题,在本讲我给出了一个动态分组的方案。但是动态分组的过程中,我们只是把注册中心的数据改了,而服务提供方提供真实的分组名并没有改变,这时候用动态分组名的调用方调用过来的请求可能就会报错,因为服务提供方会验证调用方过来的分组名跟自身的是否一样。针对这个问题,你能想到什么解决方案?
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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方法的返回值就是CompletableFutureGenericService接口的具体定义如下
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不能正常地进行序列化与反序列化我们可以为泛化调用提供专属的序列化插件来解决实际问题。
课后思考
在讲解泛化调用时,我讲到服务端在收到调用端通过泛化调用的方式发送过来的请求时,会使用泛化调用专属的序列化插件实现对其进行反序列化,那么服务端是如何判定这个请求消息是通过泛化调用的方式发送过来的消息呢?
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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里面内置各种协议但通过枚举的方式可能会遗漏不知道针对这种问题你有什么好的办法吗
欢迎留言和我分享你的答案,也欢迎你把文章分享给你的朋友,邀请他加入学习。我们下节课再见!

View 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与ReferExporter用来处理服务端接收的请求而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调用流程这三个主流程做了一个大致的讲解真正实现起来还是要复杂许多因为涉及到了很多细节上的问题但主要脉络出来以后相信也会对你有很大帮助更多的细节就还是要靠你自己去阅读源码啦
今天的加餐分享就到这里,有任何问题,欢迎你在留言区与我交流!

View 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框架ICEhttps://zeroc.com可能现在很多人都没有听说过了。
Hessian
后面使用Java开发应用的机会越来越多而且随着Spring开发方式的大流行在Java应用里面使用ICE来完成应用之间的RPC调用就变得比较鸡肋了。所以当时我们用了一种新的RPC框架Hessianhttp://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的故事。

View 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框架如果支持“命名空间”也是可以解决这一问题的。
今天的答疑就到这里,如果你有更多问题,欢迎继续在留言区中告知,我们共同讨论。下节课再见!

View File

@ -0,0 +1,39 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 学会从优秀项目的源代码中挖掘知识
你好,我是何小锋。
今天是咱们专栏的最后一课。首先,我的读者,非常感谢你对这个专栏的支持。当你看到这儿,恭喜你“毕业”了,我也“毕业”了!从专栏筹备到结课,转眼已经过去了半年的时间。这段经历令我很难忘,可以说是痛苦与快乐并存吧,今天结课我还真想和你说道说道。
我其实不太善言辞,挺典型的一类技术人,做这件事的出发点还是对技术的热爱,我希望能把自己这些年的经验去沉淀一下,分享出去供更多的人参考,与更多的人探讨和交流,很开心我做到了,有一种成就感和满足感在。但写作的过程确实还挺难的,有点出乎我的意料,讲课和写技术文档的差别不小,我觉得这其中最大的区别就是讲课我需要去设计内容,怎么让内容“既浅又深”,让不同阶段的人都能“看得懂、学得会、用得着”,就是我的标准。
我们应该学会分享与交流,这是我在写作过程中最大的一个感触。
在写的过程中,我会不停地调动自己积累的知识和经验,每一节课,每一个问题,都需要我在脑子里一遍遍梳理好才能成文,对我自己的知识体系是一次很好的加固和升级的机会。当然,我也会碰到新的疑惑和问题,甚至是自我否定,但这不重要,重要的是我解决了它,我的知识面得到了拓展。还有就是,我会感觉到一种快乐,别人会因为我的分享而有收获,这是一种肯定。
当然分享和兴趣虽是初衷但我打的也是“有准备之仗”。在写这个专栏之前我曾对我所运营的RPC框架进行了重定义整体架构完全重新设计代码也完全重写这里面的主要原因还是在于旧版本的RPC框架由于一次次的代码迭代已经有了太多的补丁代码维护起来已经十分困难了它很难再满足业务新增的需求。
在设计与编写RPC框架的过程中我从业界的RPC框架中吸取经验我先对自己的认知做了一次全面的升级。正如我在[开篇词] 里说的RPC是解决分布式系统通信问题的一大利器在我所接触的分布式系统中基本都离不开RPC。这一点令我自己都是兴奋的。
决定了重定义RPC框架我就坚持对每一行代码甚至是每一行注释进行review对代码的扩展性、可阅读性以及性能都尽量做到完美。但是在写专栏的时候我并没有写到太多的代码因为在我看来在设计整体框架、实现每个功能、解决某一个难题时我们首先要有一个或多个完整的并且可行的思路和想法而编码的过程不过是将解决问题的思路和想法以代码的形式翻译出来。当然编码也需要很扎实的基本功这一点我们不能否认。
那讲到这儿我还是想再强调一下咱们专栏的重点以免你迷路。我把重点放在了RPC框架的一些基础功能与高级功能的实现思路上以及某一类问题的解决办法上。
从内容结构上来说为了能让你更好地学习RPC框架整个专栏的内容设计还是比较系统的我们从RPC框架的基础功能讲到集群、治理等相关的高级功能再到RPC框架的性能优化以及问题排查等等整个学习过程由浅入深。
希望看到最后一课的你,已经完全理解了我的用意和用心!
那最后,我还想给你一个小建议,那就是你一定要学会从优秀项目的源代码中挖掘知识,结合自己的见解与经验,去解决一个又一个的难题,形成自己的知识体系,而我前面所说的分享就是检验成果的一个好办法。
最后的最后,还是要感谢你,我的读者,多谢你的支持,你的鼓励和批评是我前进的最大动力。如果你有什么意见或建议,欢迎你通过结课问卷告知我,我会正视大家的声音。
我是何小锋,我们后会有期!