本文共 31770 字,大约阅读时间需要 105 分钟。
请求过程会有3次握手4次挥手:
1:浏览器请求服务器(订单服务),请求建立链接 1次握手 2:服务器(订单服务)响应浏览器,可以建立链接,并询问浏览器是否可以建立链接 2次握手 3:浏览器响应服务器(订单服务),可以建立链接 3次握手 ------开始传输数据------ 1:浏览器向服务端(订单服务)发起请求,要求断开链接 1次挥手 2:服务器(订单服务)回应浏览器,数据还在传输中 2次挥手 3:服务器(订单服务)接收完数据后,向浏览器发消息要求断开链接 3次挥手 4:浏览器收到服务器消息后,回复服务器(订单服务)同意断开链接 4次挥手
TCP 是面向连接的、可靠的流协议。流就是指不间断的数据结构,当应用程序采用 TCP 发送消息时,虽然可以保证发送的顺序,但还是犹如没有任何间隔的数据流发送给接收端。TCP 为提供可靠性传输,实行“顺序控制”或“重发控制”机制。此外还具备“流控制(流量控制)”、“拥塞控制”、提高网络利用率等众多功能。
UDP 是不具有可靠性的数据报协议。细微的处理它会交给上层的应用去完成。在 UDP 的情况下,虽然可以确保发送消息的大小,却不能保证消息一定会到达。因此,应用有时会根据自己的需要进行重发处理。
HTTP协议(HyperText Transfer Protocol,超文本传输协议)是用于从WWW服务器传输超文本到本地浏览器的传送协议。
HTTP是一个应用层协议,由请求和响应构成,是一个标准的客户端服务器模型,HTTP协议永远都是客户端发起请求,服务器回送响应。HTTP是一个无状态的协议。
1.客户端连接到Web服务器
一个HTTP客户端,通常是浏览器,与Web服务器的HTTP端口(默认为80)建立一个TCP套接字连接。 2.发送HTTP请求 通过TCP套接字,客户端向Web服务器发送一个文本的请求报文,一个请求报文由请求行、请求头部、空行和请求数据4部分组成。 3.服务器接受请求并返回HTTP响应 Web服务器解析请求,定位请求资源。服务器将资源复本写到TCP套接字,由客户端读取。一个响应由状态行、响应头部、空行和响应数据4部分组成。 4. 释放连接TCP连接 若connection 模式为close,则服务器主动关闭TCP连接,客户端被动关闭连接,释放TCP连接;若connection 模式为keepalive,则该连接会保持一段时间,在该时间内可以继续接收请求; 5.客户端浏览器解析HTML内容 客户端浏览器首先解析状态行,查看表明请求是否成功的状态代码。然后解析每一个响应头,响应头告知以下为若干字节的HTML文档和文档的字符集。客户端浏览器读取响应数据HTML,根据HTML的语法对其进行格式化,并在浏览器窗口中显示。优点
1.简单,灵活,易于扩展:因为无太多限制,因为简单可以叫用户自己扩展。 2.应用广泛,环境成熟:因为过于简单,普及,因此应用很广泛。因为本身不属于一种语言,因此,就无平台,语言界限,因此跨平台性很强。 3.无状态,因为没有任何记录。可以减轻服务器的负担,能够更多的cpu和内存用来对外提供服务。因为无状态,对服务器无要求,因此可以组成集群。缺点
1.明文不安全。 2.因为无状态,因此无法做连续多个步骤的操作。例如:加入购物出,结算,支付。每次都需要验证身份信息,但是无状态所以无法连续。解决办法,就是cookie技术。 3.性能:“请求 - 应答”模式则加剧了 HTTP 的性能问题,这就是著名的“队头阻塞”(Head-of-line blocking),当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一并被阻塞,会导致客户端迟迟收不到数据。为了解决这个问题,就诞生出了一个专门的研究课题“Web 性能优化”,HTTP 官方标准里就有“缓存”一章(RFC7234),非官方的“花招”就更多了,例如切图、数据内嵌与合并,域名分片、JavaScript“黑科技”等等RPC 的全称是 Remote Procedure Call ,是一种进程间通信方式,可以理解成远程过程调用或者远程程序调用。
它允许程序调用另一个地址空间的过程或函数,而不用程序员显式编码这个远程调用的细节,程序员无论是调用本地的还是远程的,本质上编写的调用代码基本相同。
比如说两台服务器A、B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。
优点
1.分布式设计:比如分布式应用中个节点相互调用协调工作。 2.部署灵活:能实现服务间远程通信,即便部署不在同一个机器上也能实现通信。 3.解耦服务:服务间方法调用无需相互依赖,可以直接实现远程通信。 4.扩展性强 缺点 1.RPC框架开发难度大 2.RPC框架调用成功率受限于网络状况 3.调用远程方法对初学者来说难度大Http vs RPC
传输协议 RPC,可以基于TCP协议,也可以基于HTTP协议 HTTP,基于HTTP协议 传输效率 RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减少报文的体积,提高传输效率 HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么简单的封装以下是可以作为一个RPC来使用的,这是标准RPC框架更多的是服务治理 性能消耗 RPC,可以基于thrift实现高效的二进制传输 HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能 负载均衡 RPC,基本都自带了负载均衡策略 HTTP,需要配置Nginx,HAProxy来实现 服务治理 RPC,能做到自动通知,不影响上游 HTTP,需要事先通知,修改Nginx/HAProxy配置应用例举:
具体调用过程:
所涉及的技术:
java -Dserver.port=8090 -Dcsp.sentinel.dashboard.server=localhost:8090 -jar D:/TestCode/sources/sentinel-dashboard-1.6.3.jar
[ { "resource": "placeOrder", "count": 0.2, "grade": 1, "timeWindow": 4 } ]
resource: 为资源名称。
count: 为百分比[0-1], 这里代表20% grade: 为降级策略, 0: 代表响应时间, 1: 代表异常比例, 2: 代表异常数量, 这里采用的是异常比。 timeWindow:为时间窗, 单位为秒。 sentinel-user-flow为限流配置策略: 内容:[ { "resource": "placeOrder", "controlBehavior": 0, "count": 2, "grade": 1, "limitApp": "default", "strategy": 0 } ]
resource: 为资源名称。
controlBehavior:流量整形的控制效果,目前支持快速失败和匀速排队两种模式,默认是0, 快速失败。 count: 线程数量。 grade:限流配置策略, 0:代表线程数量, 1:代表QPS并发数。 limitApp: 限流针对的来源, 填写default即可在网络传输中,数据必须采用二进制形式, 所以在RPC调用过程中, 需要采用序列化技术,对入参对象和返回值对象进行序列化与反序列化。
User对象:
package com.itcast;public class User { /*** 用户编号 */ private String userNo = "0001"; /*** 用户名称 */private String name = "zhangsan"; }
包体的数据组成:
业务指令为0x00000001占1个字节,类的包名com.itcast占10个字节, 类名User占4个字节; 属性UserNo名称占6个字节,属性类型string占2个字节表示,属性值为0001占4个字节; 属性name名称占4个字节,属性类型string占2个字节表示,属性值为zhangsan占8个字节; 包体共计占有1+10+4+6+2+4+4+2+8 = 41字节。 包头的数据组成: 版本号v1.0占4个字节,消息包体实际长度为41占4个字节表示,序列号0001占4个字节,校验码32位表示占4个字节。 包头共计占有4+4+4+4 = 16字节。 包尾的数据组成: 通过回车符标记结束\r\n,占用1个字节。 整个包的序列化二进制字节流共41+16+1 = 58字节。这里讲解的是整个序列化的处理思路, 在实际的序列化处理中还要考虑更多细节,比如说方法和属性的区分,方法权限的标记,嵌套类型的处理等等。序列化的处理要素
常用的序列化技术
public static void serialize() throws Exception { //将序列化后的数据存入到D:/TestCode/tradeUser.clazz中 String basePath = "E:\\study\\第2章 RPC通信原理实战\\第二章 RPC 通信原理实战(最新版)"; FileOutputStream fos = new FileOutputStream(basePath + "tradeUser.clazz"); //创建tradeUser对象 TradeUser tradeUser = new TradeUser(); tradeUser.setName("Mirson"); //将tradeUser写入到tradeUser.clazz中 ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(tradeUser); oos.flush(); oos.close(); //读取D:/TestCode/tradeUser.clazz FileInputStream fis = new FileInputStream(basePath + "tradeUser.clazz"); ObjectInputStream ois = new ObjectInputStream(fis); //将读取的数据反序列化到对象中 TradeUser deTradeUser = (TradeUser) ois.readObject(); ois.close(); System.out.println("=== 反序列化结果 ==== "); System.out.println(deTradeUser); } public static void main(String[] args) throws Exception { serialize(); }
(1) 在Java中,序列化必须要实现java.io.Serializable接口。
(2) 通过ObjectOutputStream和ObjectInputStream对象进行序列化及反序列化操作。 (3) 虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致 (也就是在代码中定义的序列ID private static final long serialVersionUID) (4) 序列化并不会保存静态变量。 (5) 要想将父类对象也序列化,就需要让父类也实现Serializable 接口。 (6) Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该 变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如基本类型 int为 0,封装对象型Integer则为null。 (7) 服务器端给客户端发送序列化对象数据并非加密的,如果对象中有一些敏感数据比如密码等,那么在对密码字段序列化之前,最好做加密处理, 这样可以一定程度保证序列化对象的数据安全。2. JSON序列化
一般在HTTP协议的RPC框架通信中,会选择JSON方式。 优势:JSON具有较好的扩展性、可读性和通用性。 缺陷:JSON序列化占用空间开销较大,没有JAVA的强类型区分,需要通过反射解决,解析效率和压缩率都较差。 如果对并发和性能要求较高,或者是传输数据量较大的场景,不建议采用JSON序列化方式。3. Hessian2序列化
Hessian 是一个动态类型,二进制序列化,并且支持跨语言特性的序列化框架。 Hessian 性能上要比 JDK、JSON 序列化高效很多,并且生成的字节数也更小。有非常好的兼容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。public static void main(String[] args) throws Exception { serialize(); } public static void serialize() throws Exception { TradeUser tradeUser = new TradeUser(); tradeUser.setName("Mirson"); tradeUser.setUserNo("100001"); //tradeUser对象序列化处理 ByteArrayOutputStream bos = new ByteArrayOutputStream(); Hessian2Output output = new Hessian2Output(bos); output.writeObject(tradeUser); output.flushBuffer(); byte[] data = bos.toByteArray(); System.out.println("=== 序列化结果 ==== "); System.out.println(data); bos.close(); //tradeUser对象反序列化处理 ByteArrayInputStream bis = new ByteArrayInputStream(data); Hessian2Input input = new Hessian2Input(bis); TradeUser deTradeUser = (TradeUser) input.readObject(); input.close(); System.out.println("=== 反序列化结果 ==== "); System.out.println(deTradeUser); }
Dubbo2.7.3通讯序列化源码实现分析:
@Override public void encode(Channel channel, ChannelBuffer buffer, Object msg) throws IOException{ if (msg instanceof Request) { encodeRequest(channel, buffer, (Request) msg); } else if (msg instanceof Response) { encodeResponse(channel, buffer, (Response) msg); } else { super.encode(channel, buffer, msg); } }
@Override public Object decode(Channel channel, ChannelBuffer buffer) throws IOException { int readable = buffer.readableBytes(); byte[] header = new byte[Math.min(readable, HEADER_LENGTH)]; buffer.readBytes(header); return decode(channel, buffer, readable, header); }
ExchangeCodec的decodeBody方法:
protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException { ... } else { // decode request. Request req = new Request(id); req.setVersion(Version.getProtocolVersion()); req.setTwoWay((flag & FLAG_TWOWAY) != 0); if ((flag & FLAG_EVENT) != 0) { req.setEvent(true); } try { ObjectInput in = CodecSupport.deserialize(channel.getUrl(), is, proto); } ... }
// 定义Proto版本 syntax = "proto3"; // 是否允许生成多个JAVA文件 option java_multiple_files = false; // 生成的包路径 option java_package = "com.itcast.bulls.stock.struct.netty.trade"; // 生成的JAVA类名 option java_outer_classname = "TradeUserProto"; // 预警通知消息体 message TradeUser { /*** 用户ID */ int64 userId = 1 ; /*** 用户名称string userName = 2 ; }
public static void serialize() throws Exception{ // 创建TradeUser的Protobuf对象 TradeUserProto.TradeUser.Builder builder = TradeUserProto.TradeUser.newBuilder(); builder.setUserId(100001); builder.setUserName("Mirson"); //将TradeUser做序列化处理 TradeUserProto.TradeUser msg = builder.build(); byte[] data = msg.toByteArray(); System.out.println("=== 序列化结果 ==== "); System.out.println(data); //反序列化处理, 将刚才序列化的byte数组转化为TradeUser对象 TradeUserProto.TradeUser deTradeUser = TradeUserProto.TradeUser.parseFrom(data); System.out.println("=== 反序列化结果 ==== "); System.out.println(deTradeUser); }
被代理对象必须实现1个接口
JDK动态代理的如何实现?
实例代码:public class Invocation { public interface User{ String job(); } public static class Teacher{ public String invoke(){ return "I AM teacher"; } } public static class InvocationProxy implements InvocationHandler{ private Object targer; InvocationProxy(Object targer){ this.targer=targer; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return ((Teacher)targer).invoke(); } } public static void main(String[] args) { InvocationProxy invocationProxy = new InvocationProxy(new Teacher());ClassLoader classLoader = ClassLoaderUtils.getClassLoader(); // 生成代理类 User user = (User) Proxy.newProxyInstance(classLoader, new Class[]{ User.class}, proxy); // 接口调用 System.out.println(user.job()); }}
JDK动态代理的实现原理:
为什么要加入动态代理?
第一, 如果没有动态代理, 服务端大量的接口将不便于管理,需要大量的if判断,如果扩展了新的接 口,需要更改调用逻辑, 不利于扩展维护。 第二, 是可以拦截,添加其他额外功能, 比如连接负载管理,日志记录等等。 动态代理开源技术 (1) Cglib 动态代理 Cglib是一个强大的、高性能的代码生成包,它广泛被许多AOP框架使用,支持方法级别的拦截。 它是高级的字节码生成库,位于ASM之上,ASM是低级的字节码生成工具,ASM的使用对开发人员要求较高,相比较来讲, ASM性能更好。几种动态代理性能比较:
单位是纳秒。大括号内代表的是样本标准差,综合结果源码剖析
在高可用的生产环境中,一般都以集群方式提供服务,集群里面的IP可能随时变化,也可能会随着维护扩充或减少节点,客户端需要能够及时感知服务端的变化,获取集群最新服务节点的连接信息。
服务注册发现功能
服务注册发现的具体流程
主流服务注册工具有Nacos、Consul、Zookeeper等, 基于 ZooKeeper 的服务发现: ZooKeeper 集群作为注册中心集群,服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册 信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能。privateInvoker doRefer(Cluster cluster, Registry registry, Class type, URL url) { RegistryDirectory directory = new RegistryDirectory(type, url); directory.setRegistry(registry); directory.setProtocol(this.protocol); Map parameters = new HashMap(directory.getConsumerUrl().getParameters()); URL subscribeUrl = new URL("consumer", (String)parameters.remove("register.ip"), 0, type.getName(), parameters); if (directory.isShouldRegister()) { directory.setRegisteredConsumerUrl(subscribeUrl); registry.register(directory.getRegisteredConsumerUrl()); } directory.buildRouterChain(subscribeUrl); directory.subscribe(toSubscribeUrl(subscribeUrl)); Invoker invoker = cluster.join(directory); List listeners = this.findRegistryProtocolListeners(url); if (CollectionUtils.isEmpty(listeners)) { return invoker; } else { RegistryInvokerWrapper registryInvokerWrapper = new RegistryInvokerWrapper(directory, cluster, invoker, subscribeUrl); Iterator var11 = listeners.iterator(); while(var11.hasNext()) { RegistryProtocolListener listener = (RegistryProtocolListener)var11.next(); listener.onRefer(this, registryInvokerWrapper); } return registryInvokerWrapper; } }
Dubbo Spring Cloud 注册发现的源码(服务端):
publicExporter export(final Invoker originInvoker) throws RpcException { // 获取注册信息 URL registryUrl = getRegistryUrl(originInvoker); // 获取服务提供方信息 URL providerUrl = getProviderUrl(originInvoker); // Subscribe the override data // FIXME When the provider subscribes, it will affect the scene : a certain JVM exposes the service and call // the same service. Because the subscribed is cached key with the name of the service, it causes the // subscription information to cover.final URL overrideSubscribeUrl = getSubscribedOverrideUrl(providerUrl); final OverrideListener overrideSubscribeListener = new OverrideListener(overrideSubscribeUrl, originInvoker); overrideListeners.put(overrideSubscribeUrl, overrideSubscribeListener); providerUrl = overrideUrlWithConfig(providerUrl, overrideSubscribeListener); //export invoker final ExporterChangeableWrapper exporter = doLocalExport(originInvoker, providerUrl); // 获取订阅注册器 final Registry registry = getRegistry(originInvoker); final URL registeredProviderUrl = getRegisteredProviderUrl(providerUrl, registryUrl); ProviderInvokerWrapper providerInvokerWrapper = ProviderConsumerRegTable.registerProvider(originInvoker, registryUrl, registeredProviderUrl); //to judge if we need to delay publish boolean register = registeredProviderUrl.getParameter("register", true); if (register) { // 进入服务端信息注册处理 register(registryUrl, registeredProviderUrl); providerInvokerWrapper.setReg(true); } // Deprecated! Subscribe to override rules in 2.6.x or before. // 服务端信息订阅处理 registry.subscribe(overrideSubscribeUrl, overrideSubscribeListener); exporter.setRegisterUrl(registeredProviderUrl); exporter.setSubscribeUrl(overrideSubscribeUrl); //Ensure that a new exporter instance is returned every time export return new DestroyableExporter<>(exporter); }
什么是阻塞IO模型
IO多路复用
概念: 服务端采用单线程过select/epoll机制,获取fd列表, 遍历fd中的所有事件, 可以关注多个 文件描述符,使其能够支持更多的并发连接。 IO多路复用的实现主要有select,poll和epoll模式。 文件描述符: 在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文 件。 文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,用来指向被打 开的文件。文件描述符的值是一个非负整数。 下图说明(左边是进程、中间是内核、右边是文件系统): 1) A的文件描述符1和30都指向了同一个打开的文件句柄, 代表进程多次执行打开操作。 2) A的文件描述符2和B的文件描述符2都指向文件句柄(#73),代表A和程B可能是父子进程或 者A和进程B打开了同一个文件(低概率)。 3) A的描述符0和B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表的相同条目(#1936),这种情况是因为每个进程各自对同一个文件发起了打开请求。select 模型
while (1) { // 阻塞获取 // 每次需要把fd从用户态拷贝到内核态 nfds = select(max + 1, & read_fd, &write_fd, NULL, &timeout); // 每次需要遍历所有fd,判断有无读写事件发生 for (int i = 0; i <= max && nfds; ++i) { if (i == listenfd) { --nfds; // 这里处理accept事件 FD_SET(i, & read_fd);//将客户端socket加入到集合中 } if (FD_ISSET(i, & read_fd)){ --nfds;// 这里处理read事件 } if (FD_ISSET(i, & write_fd)){ --nfds; // 这里处理write事件 } } } } } }
缺点:
POLL模型
int max = 0; // 队列的实际长度 while (1) { // 阻塞获取 // 每次需要把fd从用户态拷贝到内核态 nfds = poll(fds, max + 1, timeout); if (fds[0].revents & POLLRDNORM) { // 这里处理accept事件 connfd = accept(listenfd); //将新的描述符添加到读描述符集合中 } // 每次需要遍历所有fd,判断有无读写事件发生 for (int i = 1; i < max; ++i) { if (fds[i].revents & POLLRDNORM) { sockfd = fds[i].fd if ((n = read(sockfd, buf, MAXLINE)) <= 0) { // 这里处理read事件 if (n == 0) { close(sockfd); fds[i].fd = -1; } } else { // 这里处理write事件 } if (--nfds <= 0) { break; } } } }
poll与select相比,只是没有fd的限制,都存在相同的缺陷。
EPOLL模型
// 需要监听的socket放到ep中 epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, & ev); while (1) { // 阻塞获取 nfds = epoll_wait(epfd, events, 20, 0); for (i = 0; i < nfds; ++i) { if (events[i].data.fd == listenfd) { // 这里处理accept事件 connfd = accept(listenfd); // 接收新连接写到内核对象中 epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, & ev); } else if (events[i].events & EPOLLIN) { // 这里处理read事件 read(sockfd, BUF, MAXLINE); //读完后准备写 epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, & ev); } else if (events[i].events & EPOLLOUT) { // 这里处理write事件 write(sockfd, BUF, n); //写完后准备读 epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, & ev); } } }
缺点:
目前只能工作在linux环境下 数据量很小的时候没有性能优势epoll下的两种模式(拓展了解):
EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。 LT(水平触发)模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提 醒用户程序去操作 ET(边缘触发)模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误(kernel会根据select/epoll等机制, 监听所有select接入的socket,当任何一个socket中的
数据准备好了,select就会返回, 使得一个进程能同时等待多个文件描述符。)RPC 框架采用哪种网络 IO 模型?
什么是零拷贝
系统内核处理 IO 操作分为两个阶段:等待数据和拷贝数据。 等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中。 拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。 具体流程:Netty 框架是否也有零拷贝机制?
Netty 的零拷贝则有些不一样,他完全站在了用户空间上,也就是基于 JVM 之上。 Netty当中的零拷贝是如何实现的? RPC 并不会把请求参数作为一个整体数据包发送到对端机器上,中间可能会拆分,也可能会合并其他请求,所以消息都需要有边界。接收到消息之后,需要对数据包进行处理,根据边界对数据包进行分割和合并,最终获得完整的消息。 Netty零拷贝主要体现在三个方面: Netty的接收和发送ByteBuffer是采用DIRECT BUFFERS,使用堆外的直接内存(内存对象分配在JVM中堆以外的内存)进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果采用传统堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后写入Socket中。 Netty提供了组合Buffer对象,也就是CompositeByteBuf 类,可以将 ByteBuf 分解为多个共 享同一个存储区域的 ByteBuf,避免了内存的拷贝。零拷贝带来的作用就是避免没必要的 CPU 拷贝,减少了 CPU 在用户空间与内核空间之间的上下文切换,从而提升了网络通信效率与应用程序的整体性能。
而 Netty 的零拷贝与操作系统的零拷贝是有些区别的,Netty 的零拷贝实质上是对用户空间中数据操作的优化,这对处理 TCP 传输中的拆包粘包问题有着重要的意义,通过 CompositeByteBuf可以有效解决这些问题。 在 RPC 框架的开发和应用过程中,我们要深入了解网络通信相关的原理知识,尽量做到零拷贝, 比如采用Netty 框架作为RPC通信;我们要合理使用 ByteBuf 子类,做到完全零拷贝,提升 RPC 框架的整体性能。为什么需要时间轮?
在Dubbo中,为增强系统的容错能力,会有相应的监听判断处理机制。比如RPC调用的超时机制的实现,消费者判断RPC调用是否超时,如果超时会将超时结果返回给应用层。在Dubbo最开始的实现中,是将所有的返回结果(DefaultFuture)都放入一个集合中,并且通过一个定时任务,每隔一定时间间隔就扫描所有的future,逐个判断是否超时。 这样的实现方式虽然比较简单,但是存在一个问题就是会有很多无意义的遍历操作开销。比如一个RPC调用的超时时间是10秒,而设置的超时判定的定时任务是2秒执行一次,那么可能会有4次左右无意义的循环检测判断操作。 为了解决上述场景中的类似问题,Dubbo借鉴Netty,引入了时间轮算法,减少无意义的轮询判断操作。 时间轮原理 对于以上问题, 目的是要减少额外的扫描操作就可以了。比如说一个定时任务是在5 秒之后执行,那么在 4.9 秒之后才扫描这个定时任务,这样就可以极大减少 CPU开销。这时我们就可以利用时钟轮的机制了。假设现在我们有 3 个任务,分别是任务 A(0.9秒之后执行)、任务 B(2.1秒后执行)与任务C(12.1秒之后执行),我们将这 3 个任务添加到时钟轮中,任务 A 被放到第 0 槽位,任务 B 被放到第 2槽位,任务 C 被放到下一层时间轮的第2个槽位,如下图所示
Dubbo源码剖析
时间轮核心类HashedWheelTimer结构:调用超时与重试处理: 上面所讲的客户端调用超时的处理,就可以应用到时钟轮,我们每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的 CPU。
源码: FailbackRegistry, 代码片段:public FailbackRegistry(URL url) { super(url); this.retryPeriod = url.getParameter("retry.period", 5000); // 重试器的时间槽数量, 设定为128 this.retryTimer = new HashedWheelTimer(new NamedThreadFactory("DubboRegistryRetryTimer", true), (long)this.retryPeriod, TimeUnit.MILLISECONDS, 128); }// 失败时间任务注册器 private void addFailedRegistered(URL url) { FailedRegisteredTask oldOne = (FailedRegisteredTask)this.failedRegistered.get(url); if (oldOne == null) { FailedRegisteredTask newTask = new FailedRegisteredTask(url, this); oldOne = (FailedRegisteredTask)this.failedRegistered.putIfAbsent(url, newTask); if (oldOne == null) { // 旧任务不存在, 则放置时间轮,开启新一个任务 this.retryTimer.newTimeout(newTask, (long)this.retryPeriod, TimeUnit.MILLISECONDS); } } }
定时心跳检测: RPC 框架调用端定时向服务端发送的心跳检测,来维护连接状态,我们可以将心跳的逻辑封装为一个心跳任务,放到时钟轮里。心跳是要定时重复执行的,而时钟轮中的任务执行一遍就被移除了,对于这种需要重复执行的定时任务我们该如何处理呢?我们在定时任务逻辑结束的最后,再加上一段逻辑, 重设这个任务的执行时间,把它重新丢回到时钟轮里。这样就可以实现循环执行。
源码: HeaderExchangeServer代码片段:// 启动心跳任务检测 private void startIdleCheckTask(URL url) { if (!this.server.canHandleIdle()) { ChannelProvider cp = () -> { return Collections.unmodifiableCollection(this.getChannels()); }; int idleTimeout = UrlUtils.getIdleTimeout(url); long idleTimeoutTick = this.calculateLeastDuration(idleTimeout); CloseTimerTask closeTimerTask = new CloseTimerTask(cp, idleTimeoutTick, idleTimeout); this.closeTimerTask = closeTimerTask; IDLE_CHECK_TIMER.newTimeout(closeTimerTask, idleTimeoutTick, TimeUnit.MILLISECONDS); } }
连接检测, 会不断执行, 加入时间轮中。
AbstractTimerTask源码:public void run(Timeout timeout) throws Exception { Collectionc = this.channelProvider.getChannels(); Iterator var3 = c.iterator(); while(var3.hasNext()) { Channel channel = (Channel)var3.next(); if (!channel.isClosed()) { // 调用心跳检测任务 this.doTask(channel); } } // 重新放入时间轮中 this.reput(timeout, this.tick); }
为什么要采用异步?
如果采用同步调用, CPU 大部分的时间都在等待而没有去计算,从而导致 CPU 的利用率不够。RPC 请求比较耗时的原因主要是在哪里? 在大多数情况下,RPC 本身处理请求的效率是在毫秒级的。RPC 请求的耗时大部分都是业务耗时,比如业务逻辑中有访问数据库执行慢 SQL 的操作,核心是在I/O瓶颈。所以说,在大多数情况下,影响到 RPC 调用的吞吐量的原因也就是业务逻辑处理慢了,CPU 大部分时间都在等待资源。 调用端如何实现异步? 常用的方式就是Future 方式,它是返回 Future 对象,通过GET方式获取结果;或者采用入参为Callback 对象的回调方式,处理结果。 从DUBBO框架, 来看具体是如何实现异步调用?为什么要采用路由?
真实的环境中一般是以集群的方式提供服务,对于服务调用方来说,一个接口会有多个服务提供方同时提供服务,所以 RPC 在每次发起请求的时候,都需要从多个服务节点里面选取一个用于处理请求的服务节点。这就需要在RPC应用中增加路由功能。 如何实现路由? 服务注册发现方式: 通过服务发现的方式从逻辑上看是可行,但注册中心是用来保证数据的一致性。通过服务发现方式来实现请求隔离并不理想。 RPC路由策略: 从服务提供方节点集合里面选择一个合适的节点(负载均衡),把符合我们要求的节点筛选出来。 这个就是路由策略: 接收请求–>请求校验–>路由策略–>负载均衡–> 使用了 IP 路由策略后,整个集群的调用拓扑如下图所示:RPC 的负载均衡完全由 RPC 框架自身实现,服务调用方发起请求时,会通过所配置的负载均衡组件,自主地选择合适服务节点。调用方如果能知道每个服务节点处理请求的能力,再根据服务节点处理请求的能力来判断分配相应的流量,集群资源就能够得到充分的利用, 当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。这个就是自适应的负载均衡策略。
具体如何实现? 这就需要判定服务节点的处理能力。为什么要进行限流?
在实际生产环境中,每个服务节点都可能由于访问量过大而引起一系列问题,就需要业务提供方能够进行自我保护,从而保证在高访问量、高并发的场景下,系统依然能够稳定,高效运行。 服务端的自我保护实现一个服务 A 调用服务 B 时,服务 B 的业务逻辑又调用了服务 C,这时服务 C 响应超时,服务 B 就可能会因为堆积大量请求而导致服务宕机,由此产生服务雪崩的问题。
熔断处理流程:转载地址:http://gssz.baihongyu.com/