SOFARPC 性能优化实践(下)| SOFAChannel#3 直播整理

<SOFA:Channel/>,有趣实用的分布式架构频道。html


本次是 SOFAChannel 第三期,SOFARPC 性能优化(下),进一步分享 SOFARPC 在性能上作的一些优化。java


本期你将收获:git

- 如何控制序列化和反序列化的时机;github

- 如何经过线程池隔离,避免部分接口对总体性能的影响;算法

- 如何进行客户端权重调节,优化启动期和故障时的性能;json

- 服务端 Server Fail Fast 支持,减小无效操做;数组

- 在 Netty 内存操做中,如何优化内存使用。性能优化


欢迎加入直播互动钉钉群:23127468,不错过每场直播。bash

你们好,今天是 SOFAChannel 第三期,欢迎你们观看。服务器

我是来自蚂蚁金服中间件的雷志远,花名碧远,目前负责 SOFARPC 框架的相关工做。在上一期直播中,给你们介绍了 SOFARPC 性能优化方面的关于自定义协议、Netty 参数优化、动态代理等的优化。

往期的直播回顾,能够在文末获取。
本期互动中奖名单:
@司马懿 @邓从宝 @雾渊,请文章下方回复进行礼品领取

今天咱们会从序列化控制、内存操做优化、线程池隔离等方面来介绍剩余的部分。


序列化优化

上次介绍了序列化方式的选择,此次主要介绍序列化和反序列化的时机、处理的位置以及这样的好处,如避免占用 IO 线程,影响 IO 性能等。

上一节,咱们介绍的 BOLT 协议的设计,回顾一下:


能够看到有这三个地方不是经过原生类型直接写的:ClassName,Header,Content 。其他的,例如 RequestId 是直接写的,或者说跟具体请求对象无关的。因此在选择序列化和反序列化时机的时候,咱们根据本身的需求,也精确的控制了协议以上三个部分的时机。

对于序列化

serializeClazz 是最简单的:

byte[] clz = this.requestClass.getBytes(Configs.DEFAULT_CHARSET);复制代码

直接将字符串转换成 Byte 数组便可,跟具体的任何序列化方式,好比跟采用 Hessian 仍是 Pb 都是无关的。

serializeHeader 则是序列化 HeaderMap。这时候由于有了前面的 requestClass,就能够根据这个名字拿到SOFARPC 层或者用户本身注册的序列化器。而后进行序列化 Header,这个对应 SOFARPC 框架中的 SofaRpcSerialization 类。在这个类里,咱们能够自由使用本次传输的对象,将一些必要信息提取到Header 中,并进行对应的编码。这里也不跟具体的序列化方式有关,是一个简单 Map 的序列化,写 key、写 value、写分隔符。有兴趣的同窗能够直接看源码。

源码连接:

github.com/alipay/sofa…

serializeContent 序列化业务对象的信息,这里 RPC 框架会根据本次用户配置的信息决定如何操做序列化对象,是调用 Hessian 仍是调用 Pb 来序列化。

至此,完成了序列化过程。能够看到,这些操做实际上都是在业务发起的线程里面的,在请求发送阶段,也就是在调用 Netty 的写接口以前,跟 IO 线程池还没什么关系,因此都会在业务线程里先作好序列化。

对于反序列化

介绍完序列化,反序列化的时机就有一些差别,须要重点考虑。在服务端的请求接收阶段,咱们有 IO 线程、业务线程两种线程池。为了最大程度的配合业务特性、保证总体吞吐,SOFABolt 设计了精细的开关来控制反序列化时机。

具体选择逻辑以下:

用户请求处理器图

体如今代码的这个类中。

com.alipay.remoting.rpc.protocol.RpcRequestProcessor#process复制代码

从上图能够看到 反序列化 大体分红如下三种状况,适用于不一样的场景。

IO 线程池动做

业务线程池

使用场景

反序列化 ClassName

反序列化 Header 和 Content 处理业务

通常 RPC 默认场景。IO 线程池识别出来当前是哪一个类,调用用户注册的对应处理器

反序列化 ClassName 和 Header

仅反序列化 Content 和业务处理

但愿根据 Header 中的信息,选择线程池,而不是直接注册的线程池

一次性反序列化 ClassName、Header 和 Content,并直接处理

没有逻辑

IO 密集型的业务


线程池隔离

通过前面的介绍,能够了解到,因为业务逻辑一般状况下在 SOFARPC 设置的一个默认线程池里面处理,这个线程池是公用的。也就是说, 对于一个应用,当他做为服务端时,全部的调用请求都会在这个线程池中处理。

举个例子:若是应用 A 对外提供两个接口,S1 和 S2,因为 S2 接口的性能不足,多是下游系统的拖累,会致使这个默认线程池一直被占用,没法空闲出来被其余请求使用。这会致使 S1 的处理能力受到影响,对外报错,线程池已满,致使整个业务链路不稳定,有时候 S1 的重要性可能比 S2 更高。

线程池隔离图

所以,基于上面的设计,SOFARPC 框架容许在序列化的时候,根据用户对当前接口的线程池配置将接口和服务信息放到 Header 中,反序列化的时候,根据这个 Header 信息选择到用户自定义的线程池。这样,用户能够针对不一样的服务接口配置不一样的业务线程池,能够避免部分接口对整个性能的影响。在系统接口较多的时候,能够有效的提升总体的性能。


内存操做优化

介绍完线程池隔离以后,咱们介绍一下 Netty 内存操做的一些注意事项。在 Netty 内存操做中,如何尽可能少的使用内存和避免垃圾回收,来优化性能。先看一些基础概念。

内存基础

在 JVM 中内存可分为两大块,一个是堆内存,一个是直接内存。

堆内存是 JVM 所管理的内存。全部的对象实例都要在堆上分配,垃圾收集器能够在堆上回收垃圾,有不一样的运行条件和回收区域。

JVM 使用 Native 函数在堆外分配内存。为何要在堆外分配内存?主要由于在堆上的话, IO 操做会涉及到频繁的内存分配和销毁,这会致使 GC 频繁,对性能会有比较大的影响。

注意:直接分配自己也并不见得性能有多好,因此还要有池的概念,减小频繁的分配。

所以 JVM 中的直接内存,存在堆内存中的其实就是 DirectByteBuffer 类,它自己其实很小,真的内存是在堆外,经过 JVM 堆中的 DirectByteBuffer 对象做为这块内存的引用进行操做。直接内存不会受到 Java 堆的限制,只受本机内存影响。固然能够设置最大大小。也并非 Direct 就彻底跟 Heap 没什么关系了,由于堆中的这个对象持有了堆外的地址,只有这个对象被回收了,直接内存才能释放。

其中 DirectByteBuffer 通过几回 young gc 以后,会进入老年代。当老年代满了以后,会触发 Full GC。

由于自己很小,很难占满老年代,所以基本不会触发 Full GC,带来的后果是大量堆外内存一直占着不放,没法进行内存回收,因此这里要注意 -XX:+DisableExplicitGC 不要关闭。

Pool 仍是 UnPool

Netty 从 4.1.x 开始,非 Android 平台默认使用池化(PooledByteBufAllocator)实现,能最大程度的减小内存碎片。另一种方式是非池化(UnpooledByteBufAllocator),每次返回一个新实例。能够查看 io.netty.buffer.ByteBufUtil 这个工具类。

在 4.1.x 以前,因为 Netty 没法确认 Pool 是否存在内存泄漏,因此并无打开。目前,SOFARPC 的 SOFABolt 中目前对于 Pool 和 Upool 是经过参数决定的,默认是 Unpool。使用 Pool 会有更好的性能数据。在 SOFABolt 1.5.0 中进行了打开,若是新开发 RPC 框架,能够进行默认打开。SOFARPC 下个版本会进行打开。

可能你们对这个的感觉不是很直观,所以咱们提供了一个测试 Demo。

注意:

  • 若是 DirectMemory 设置太小,是不会启用 Pooled 的。
  • 另外须要注意 PooledByteBufAllocator 的 MaxDirectMemorySize 设置。本机验证的话,大概须要 96M 以上,在 Demo中有说明。
  • Demo地址: github.com/leizhiyuan/…
DEFAULT_NUM_DIRECT_ARENA = Math.max(0,
                SystemPropertyUtil.getInt(
                        "io.netty.allocator.numDirectArenas",
                        (int) Math.min(
                                defaultMinNumArena,
                                PlatformDependent.maxDirectMemory() / defaultChunkSize / 2 / 3)));复制代码

Direct 仍是 Heap

目前 Netty 在 write 的时候默认是 Direct ,而在 read 到字节流时会进行选择。能够查看以下代码,`io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe#read`。框架所采起的策略是:若是所运行的平台提供了Unsafe 相关的操做,则调用 Unsafe 在 Direct 区域进行内存分配,不然在 Heap 上进行分配。

有兴趣的同窗能够经过 Demo 3 中的示例来 debug,断点打在以下位置,就能够看到 Netty 选择的过程。

io.netty.buffer.AbstractByteBufAllocator#ioBuffer(int)复制代码

正常 RPC 的开发中,基本上都会在 Direct 区域进行内存分配,在 Heap 中进行内存分配自己也不符合 RPC 的性能要求。由于 GC 有比较大的性能影响,而 GC 在运行中,业务的代码影响比较大,可控性不强。

其余注意事项

通常来讲,咱们不会主动去分配 ByteBuf ,只要去操做读写 ByteBuf。因此:

  1. 使用 Bytebuf.forEachByte() ,传入 Processor 来代替循环 ByteBuf.readByte() 的遍历操做,避免rangeCheck() 。由于每次 readByte() 都不是读一个字节这么简单,首先要判断 refCnt() 是否大于0,而后再作范围检查防止越界。getByte(i=int) 又有一些检查函数,JVM 没有内连的时候,性能就有必定的损耗。
  2. 使用 CompositeByteBuf 来避免没必要要的内存拷贝。在操做一些协议包数据拼接时会比较有用,好比在 Service Mesh 的场景,若是咱们须要改变 Header 中的 RequestId,而后和原始的 Body 数据拼接。
  3. 若是要读1个 int , 用 Bytebuf.readInt() , 不要使用 Bytebuf.readBytes(buf, 0, 4) 。这样能避免一次内存拷贝,其余 long 等同理,毕竟还要转换回来,性能也更好。在 Demo 4 中有体现。
  4. RecyclableArrayList ,在出现频繁 new ArrayList 的场景可考虑 。例如:SOFABolt 在批量解包的时候使用了 RecyClableList ,可让 Netty 来回收。上期分享中有介绍到这个功能,详情能够见文末上期回顾连接。
  5. 避免拷贝,为了失败时重试,假设要保留内容稍后使用。不想 Netty 在发送完毕后把 buffer 就直接释放了,能够用 copy() 复制一个新的 ByteBuf。可是下面这样更高效,Bytebuf newBuf=oldBuf.duplicate().retain(); 只是复制出独立的读写索引, 底下的 ByteBuffer 是共享的,同时将 ByteBuffer 的计数器+1,这样能够避免释放,而不是经过拷贝来阻止释放。
  6. 最后可能出现问题,使用 PooledBytebuf 时要善于利用 -Dio.netty.leakDetection.level 参数,能够定位内存泄漏出现的信息。


客户端权重调节

下面,咱们说一下权重。在路由阶段的权重调节,咱们一般可以拿到不少能够调用的服务端。这时候一般状况下,最好的负载均衡算法应该是随机算法。固然若是有一些特殊的需求,好比但愿一样的参数落到固定的机器组,一致性 Hash 也是能够选择的。

不过,在系统规模到达很高的状况下,须要对启动期间和单机故障发生期间的调用有必定调整。

启动期权重调节

若是应用刚刚启动完成,此时 JIT 的优化以及其余相关组件还未充分预热完成。此时,若是马上收到正常的流量调用可能会致使当前机器处理很是缓慢,甚至直接当机没法正常启动。这时须要的操做:先关闭流量,而后重启,以后开放流量。

为此,SOFARPC 容许用户在发布服务时,设置当前服务在启动后的一段时间内接受的权重数值,默认是100。

权重负载均衡图

如上图所示,假设用户设置了某个服务 A 的启动预热时间为 60s,期间权重是10,则 SOFARPC 在调用的时候会进行如图所示的权重调节。

这里咱们假设有三个服务端,两个过了启动期间,另外一个还在启动期间。在负载均衡的时候,三个服务器会根据各自的权重占总权重的比例来进行负载均衡。这样,在启动期间的服务方就会收到比较少的调用,防止打垮服务端。当过了启动期间以后,会使用默认的 100 权重进行负载均衡。这个在 Demo 5 中有示例。

运行时单机故障权重调节

除了启动期间保护服务端以外,还有个状况,是服务端在运行期间假死,或者其余故障。现象会是:服务发现中心认为机器存活,仍然会给客户端推送这个地址,可是调用一直超时,或者一直有其余非业务异常。这种状况下,若是仍是调用,一方面会影响链路的性能,由于线程占用等;另外一方面会有持续的报错。所以,这种状况下还须要经过单机故障剔除的功能,对异常机器的权重进行调整,最终能够在负载均衡的时候生效。

对于单机故障剔除,本次咱们不作为重点讲解,有兴趣的同窗能够看下相关文章介绍。

附:【剖析 | SOFARPC 框架】系列之 SOFARPC 单机故障剔除剖析


Server Fail Fast 支持

服务端根据客户端的超时时间来决定是否丢弃已经超时的结果,而且不返回,以减小网络数据以及减小没必要要的处理,带来性能提高。

这里面分两种。

第一种是 SOFABolt 在网络层的 Server Fail Fast

对于 SOFABolt 层面, SOFABolt 会在 Decode 完字节流以后,记录一个开始时间,而后在准备分发给 RPC 的业务线程池以前,比较一下当前时间,是否已经超过了用户的超时时间。若是超过了,直接丢弃,不分发给 RPC,也不会给客户端响应。

第二种是 SOFARPC 在业务层的 Server Fail Fast

若是 SOFABolt 分发给 SOFARPC 的时候,尚未超时,可是 SOFARPC 走完了服务端业务逻辑以后,发现已经超时了。这时候,能够不返回业务结果,直接构造异常超时结果,数据更少,但结果是同样的。

注意:这里会有个反作用,虽然服务端处理已经完成,可是日志里可能会打印一个错误码,须要根据实际状况开启。

以后咱们也会开放参数,容许用户设置。


用户可调节参数

对用户的配置,你们均可以经过 com.alipay.sofa.rpc.boot.config.SofaBootRpcProperties 这个类来查看。

使用方式和标准的 SpringBoot 工程一致,开箱便可。

若是是特别特殊的需求,或者并不使用 Spring 做为开发框架,咱们也容许用户经过定制 rpc-config.json 文件来进行调整,包括动态代理生成方式、默认的 tracer、超时时间的控制、时机序列化黑名单是否开启等等。这些参数在有特殊需求的状况下能够优化性能。

线程池调节

以业务线程数为例,目前默认线程池,20核心线程数,200最大线程数,0队列。能够经过如下配置项来调整:

com.alipay.sofa.rpc.bolt.thread.pool.core.size # bolt 核心线程数
com.alipay.sofa.rpc.bolt.thread.pool.max.size # bolt 最大线程数
com.alipay.sofa.rpc.bolt.thread.pool.queue.size # bolt 线程池队列复制代码

这里在线程池的设置上,主要关注队列大小这个设置项。若是队列数比较大,会致使若是上游系统处理能力不足的时候,请求积压在队列中,等真正处理的时候已通过了比较长的时间,并且若是请求量很是大,会致使以后的请求都至少等待整个队列前面的数据。

因此若是业务是一个延迟敏感的系统, 建议不要设置队列大小;若是业务能够接受必定程度的线程池等待,能够设置。这样,能够避太短暂的流量高峰。


总结

SOFARPC 和 SOFABolt 在性能优化上作了一些工做,包括一些比较实际的业务需求产生的性能优化方式。两篇文章不足以介绍更多的代码实现细节和方式。错过上期直播的能够点击文末连接进行回顾。

相信你们在 RPC 或者其余中间件的开发中,也有本身独到的性能优化方式,若是你们对 RPC 的性能和需求有本身的想法,欢迎你们在钉钉群(搜索群号便可加入:23127468)或者 Github 上与咱们讨论交流。

到此,咱们 SOFAChannel 的 SOFARPC 系列主题关于性能优化相关的两期分享就介绍完了,感谢你们。

关于 SOFAChannel 有想要交流的话题能够在文末留言或者在公众号留言告知咱们。


本期视频回顾

tech.antfin.com/activities/…

往期直播精彩回顾

相关参考连接

讲师观点



公众号:金融级分布式架构(Antfin_SOFA)

相关文章
相关标签/搜索