SOFA:Channel,有趣实用的分布式架构频道。
本次是 SOFAChannel 第三期,SOFARPC 性能优化(下),进一步分享 SOFARPC 在性能上做的一些优化。
本期你将收获:
- 如何控制序列化和反序列化的时机;
- 如何通过线程池隔离,避免部分接口对整体性能的影响;
- 如何进行客户端权重调节,优化启动期和故障时的性能;
- 服务端 Server Fail Fast 支持,减少无效操作;
- 在 Netty 内存操作中,如何优化内存使用。
欢迎加入直播互动钉钉群:23127468,不错过每场直播。
大家好,今天是 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、写分隔符。有兴趣的同学可以直接看源码。
源码链接:
https://github.com/alipay/sofa-bolt/blob/531d1c0d872553d92fc55775565b3f7be8661afa/src/main/java/com/alipay/remoting/rpc/protocol/RpcRequestCommand.java#L66
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地址: https://github.com/leizhiyuan/rpcchannel
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。所以:
- 使用 Bytebuf.forEachByte() ,传入 Processor 来代替循环 ByteBuf.readByte() 的遍历操作,避免rangeCheck() 。因为每次 readByte() 都不是读一个字节这么简单,首先要判断 refCnt() 是否大于0,然后再做范围检查防止越界。getByte(i=int) 又有一些检查函数,JVM 没有内连的时候,性能就有一定的损耗。
- 使用 CompositeByteBuf 来避免不必要的内存拷贝。在操作一些协议包数据拼接时会比较有用,比如在 Service Mesh 的场景,如果我们需要改变 Header 中的 RequestId,然后和原始的 Body 数据拼接。
- 如果要读1个 int , 用 Bytebuf.readInt() , 不要使用 Bytebuf.readBytes(buf, 0, 4) 。这样能避免一次内存拷贝,其他 long 等同理,毕竟还要转换回来,性能也更好。在 Demo 4 中有体现。
- RecyclableArrayList ,在出现频繁 new ArrayList 的场景可考虑 。例如:SOFABolt 在批量解包的时候使用了 RecyClableList ,可以让 Netty 来回收。上期分享中有介绍到这个功能,详情可以见文末上期回顾链接。
- 避免拷贝,为了失败时重试,假设要保留内容稍后使用。不想 Netty 在发送完毕后把 buffer 就直接释放了,可以用 copy() 复制一个新的 ByteBuf。但是下面这样更高效,Bytebuf newBuf=oldBuf.duplicate().retain(); 只是复制出独立的读写索引, 底下的 ByteBuffer 是共享的,同时将 ByteBuffer 的计数器+1,这样可以避免释放,而不是通过拷贝来阻止释放。
- 最后可能出现问题,使用 PooledBytebuf 时要善于利用 -Dio.netty.leakDetection.level 参数,可以定位内存泄漏出现的信息。
客户端权重调节
下面,我们说一下权重。在路由阶段的权重调节,我们通常能够拿到很多可以调用的服务端。这时候通常情况下,最好的负载均衡算法应该是随机算法。当然如果有一些特殊的需求,比如希望同样的参数落到固定的机器组,一致性 Hash 也是可以选择的。
不过,在系统规模到达很高的情况下,需要对启动期间和单机故障发生期间的调用有一定调整。
启动期权重调节
如果应用刚刚启动完成,此时 JIT 的优化以及其他相关组件还未充分预热完成。此时,如果立刻收到正常的流量调用可能会导致当前机器处理非常缓慢,甚至直接当机无法正常启动。这时需要的操作:先关闭流量,然后重启,之后开放流量。
为此,SOFARPC 允许用户在发布服务时,设置当前服务在启动后的一段时间内接受的权重数值,默认是100。
权重负载均衡图
如上图所示,假设用户设置了某个服务 A 的启动预热时间为 60s,期间权重是10,则 SOFARPC 在调用的时候会进行如图所示的权重调节。
这里我们假设有三个服务端,两个过了启动期间,另一个还在启动期间。在负载均衡的时候,三个服务器会根据各自的权重占总权重的比例来进行负载均衡。这样,在启动期间的服务方就会收到比较少的调用,防止打垮服务端。当过了启动期间之后,会使用默认的 100 权重进行负载均衡。这个在 Demo 5 中有示例。
运行时单机故障权重调节
除了启动期间保护服务端之外,还有个情况,是服务端在运行期间假死,或者其他故障。现象会是:服务发现中心认为机器存活,仍然会给客户端推送这个地址,但是调用一直超时,或者一直有其他非业务异常。这种情况下,如果还是调用,一方面会影响链路的性能,因为线程占用等;另一方面会有持续的报错。因此,这种情况下还需要通过单机故障剔除的功能,对异常机器的权重进行调整,最终可以在负载均衡的时候生效。
对于单机故障剔除,本次我们不做为重点讲解,有兴趣的同学可以看下相关文章介绍。
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 有想要交流的话题可以在文末留言或者在公众号留言告知我们。
本期视频回顾
https://tech.antfin.com/activities/245
往期直播精彩回顾
- SOFAChannel#2 SOFARPC 性能优化实践(上):https://tech.antfin.com/activities/244
- SOFA Channel#1 从蚂蚁金服微服务实践谈起:https://tech.antfin.com/activities/148
相关参考链接
- Demo 链接:https://github.com/leizhiyuan/rpcchannel
- 【剖析 | SOFARPC 框架】系列之 SOFARPC 单机故障剔除剖析:https://mp.weixin.qq.com/s/WusXmhMnsvQ1tQh5wiCyDw
- bolt enable Pooled:https://github.com/alipay/sofa-bolt/issues/78
- Netty pooled release note:https://netty.io/wiki/new-and-noteworthy-in-4.1.html#pooledbytebufallocator-as-the-default-allocator