本文是 做远程设计那点儿事儿 系列文章第二篇,讲以下客户端发送过程的性能优化探索。

众所周知,远程设计底层是用的 RPC 进行的远程通信,往底层讲是 http 协议 + jdk 序列化方案组合成的一套 RPC 调用方案。
整个的框架非常简单。简单来说就是
通过代理的方式,将方法调用序列化为二进制数组,然后构造为请求,然后发送到服务器后,反序列化为对应的方法和参数,然后调用即可。
基本等同于下图
500

那么,在不考虑难以预知的卡顿场景,比如网络慢(环境因素),数据量大(比如客户的配置很大之类的,当然这样的场景本身就需要考虑是不是传输的数据不对)的场景下,有没有一些通用方案可以加快远程设计的性能呢? 比如切换协议,但是切换协议本身的成本是比较大的,要考虑客户部署的适配情况,在没有关键阻塞点的情况下,一般不会考虑切换协议的方案。

基于此,综合建议后,找出以下几种方向,进行探索。

1、 减少请求数量
2、 减少 IO

这个就不详细展开了,说白了就是尽量的零拷贝,但和我们目前的远程关系不大,想要了解的,参见 Java 的 MappedByteBuffer 读写文件, 3.3.2 零拷贝 - 磁盘消息文件的读取

3、 减少请求大小

  • 序列化方式
  • 压缩方案

减少请求数量

由于我们目前是使用的 http 协议作为通信协议,每一次通信都需要建立 TCP 连接(3 次握手都讲烂了)。
之前就遇到一个 客户问题 网络因素导致远程设计编辑图表卡死。, 经过测试,场景如下

  • 下载一个大文件 130mb, 耗时 40s,
  • 下载 500 个小文件,每个请求大概 500ms, 总共耗时 250s
    客户本身的网络一般,500 个请求,放大了建立连接的耗时,从而导致该问题。

基于此,当然第一方案是直接减少请求数量。将 500 个请求合并成一个,等价于大文件的方案即可。
另外,要考虑业内有没有通用的减少请求数量的方案, 比如批量请求,将多个请求合并为 1 个这种。

批量请求

经过对比,找到两个比较类似的,1 个是 dubbo, 1 个是 kafka.

先看 dubbo

见其中一个 pr Dubbo 性能调优总结文档 · Issue10915 · apache/dubbo · GitHub
里面讲了做的批量发送优化
简单概括,原先的逻辑为以下这种
|525
用户线程会不断的提交 writeTask 任务到 EventLoop 中。然而每一个 writeTask 任务都是新创建的,导致 EventLoop 线程需要被调度,然而每次调度的时候只能处理一个 writeTask 任务。
基于此问题, 后面改造成了如下的效果
500
即用户线程将任务写入 writeQueue 这个中间件中,当调度 EventLoop 的时候,writeTask 从 writeQueue 这个中间件里面取任务,然后执行,避免线程调度的存在。

总结

总结一下,就是在当前 EventLoop 在执行时,message 进入任务队列 writeQueue, 然后等待下一次 EventLoop 去调度的时候,writeTask 开始不断的从 writeQueue 里面取出 message, 一次性发送,从而避免线程频繁切换的开销。
然而,我们的远程使用的 http 协议,直接通过 httpclient 发送对应的请求,每一个请求都是一个新的连接,并不会要考虑请求阻塞,然后排队类似的事情。所以这个方向 pass

再看 kafka

kafka 本身是有一个 消息发送延迟机制 ,如下图
|500

具体流程是 kafka 生产者使用批处理试图在内存中积累数据,主线程将多条消息通过一个 ProduceRequest 请求批量发送出去,发送的消息暂存在一个队列 (RecordAccumulator) 中,再由 sender 线程去获取一批数据或者不超过某个延迟时间内的数据发送给 broker 进行持久化。

优点:

  • 可以提升 kafka 整体的吞吐量,减少网络 IO 的次数;
  • 提高数据压缩效率 (一般压缩算法都是数据量越大越能接近预期的压缩效果);

缺点:
数据发送有一定 延迟, 但是这个延迟可以由业务因素来自行设置。

总结

缺点是延迟,这是不可以接受的,本身就是为了提高实时性的优化,如果搞成延迟,完全的舍本逐末。

总结

以上两种批量发送思想都是在各自的领域比较重要的存在。
减少线程切换的思想,在 dubbo / grpc 都广泛应用。
延迟后,批量发送,在消息队列上是广泛使用的,
但是以上都不适用于我们当前的远程 rpc 逻辑。

序列化方式

传输的数据,作为网络中重要的组成部分,对序列化的选型,尤为重要。

序列化方案需要考虑

  • 性能
  • 安全性
  • 兼容性
  • 灵活性
  • 可读性
  • 跨平台
  • 稳定性

然后,从耳熟能详的序列化方案中选出以下几个进行相关纬度的考量

  • fury
  • protobuf
  • kryo
  • json/jackjson
  • java-built-in

性能

参考 Home · eishay/jvm-serializers Wiki · GitHub
比较靠谱的 jvm 序列化基准测试,结果如下

createserdesertotalsize
fury56269239508
protobuf1988755271402
kryo5191710441960
json/jackson/databind49134420533397
java-built-in5753572961734974

fury 一骑绝尘,速度和大小都遥遥领先。
protobuf 非常的稳定
json 中规中矩,但也比内置的 java 方式小了大概 10 倍, 快了 10 倍

本机测试中,以以下 Bean 作为基准进行序列化性能如下

public class User implements Serializable {

    private static final long serialVersionUID = 2566816725396650300L;

    private long id;
    private String name;
    private int sex;
    private LocalDate birthday;
    private String email;
    private String mobile;
    private String address;
    private String icon;
    private List<Integer> permissions;
    private int status;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}
框架200w Deserialize(ms)200w Serialize
fury68ms148ms
kyro347ms4061ms
protostuff-runtime307ms5450ms
java-built-in9040ms14506ms

安全性

安全问题描述

详见 Java 中的反序列化漏洞
这里就不赘述了, 在不同情况下, JDKjackson 都有序列化安全问题。
但是 jackson 触发条件。

  • 调用了 ObjectMapper.enableDefaultTyping()函数;
  • 对要进行反序列化的类的属性使用了值为 JsonTypeInfo.Id.CLASS 的@JsonTypeInfo 注解;并且该值为 Object
  • 对要进行反序列化的类的属性使用了值为 JsonTypeInfo.Id.MINIMAL_CLASS 的@JsonTypeInfo 注解;并且该值为 Object

总结

只要是 JDK 序列化可能都不可避免的存在安全问题。曾经想在 JDK9 完全移除序列化,后面被终止,但也可以看到 JDK 序列化存在的问题

fury 协议在完整的 JDK 支持下,100%兼容 JDK 序列化:支持 JDK writeObject/readObject/writeReplace/readResolve/readObjectNoData/Externalizable 序列化 API。 因此存在序列化安全问题。
但是于此同时, fury 支持跨平台协议,本协议下, fury 是不兼容 JDK 序列化的,所以可以保证安全性(可能?源码还没看)

kryo 同理

protobuf 因为是自身实现的一套协议,是需要自己写一套元文件 .proto , 因此安全性较高

稳定性

考虑到 fury 刚刚发布,7.15 号,详见 furyio.org/zh/blog/list, 在试用的时候,还遇到过 BUG, 并且提了个 issues, [JavaScript] If Serialized by JS, Deserialized in Java will failed · Issue703 · alipay/fury · GitHub 因此稳定性欠佳。阿里的东西,大家懂得都懂。

其他方案都比较成熟,因此稳定性较高。

灵活性

protobuf 可以通过 protostuff-runtime 来进行动态创建 schema , 一定程度上提高了灵活性。但性能会有所下降。

跨平台

kryo 只支持 jvm
其他框架支持多种。
json 完美的支持

兼容性

json 不需要考虑,
protobuf 有着良好的兼容性。
kryo 采用自定义和兼容 JDK 的方式保证兼容

  1. Kryo 为了保证序列化的正确性,在遇到定义了 writeObject/readObject/readObjectNoData/writeReplace/ readResolve 的对象时,会调用 JDK 的 ObjectOutputStream 和 ObjectInputStream 进行序列化。
  2. Kryo 提供了一些通用序列化器,它们采用不同的方法来处理兼容性。可以轻松开发其他序列化器以实现向前和向后兼容性,例如使用外部手写模式的序列化器。
    java-built-in 使用 id 来保证兼容性。

可读性

这个主要是考虑到定位问题的角度。
json 无疑是有且仅有的选项。

总结

java-built-in 完全是不可用,安全性差,性能差,样样都差,差点被自己的亲妈抛弃了。
protobuf 是完美的微服务中的协议之一。google 背书,稳定性强,性能高,跨平台,协议简单。
json 是需要可读性,灵活性,并兼具一点点性能的完美选择。更主要的是适用范围广,没有语言会不支持 json

压缩方案

压缩算法,讲究压缩比和压缩速度。
考虑到 gzip 的普适性和高效,比如在浏览器和服务器交互的场景上,原生支持的 gzip 编码,所以无论怎么选型都必定考虑采用兼容 gzip 的算法。gzip 的原理在这里就不详细描述,有兴趣的可以移步 gzip 压缩算法原理

速度与压缩比如何兼得?压缩算法在构建部署中的优化 - 美团技术团队

见上面这篇文章中的性能比较,各种压缩算法,压缩比并没有完全碾压的存在,基本上是依赖于设定的等级,然后会等比例的提升时间。

而在兼容 gzip 的算法中有一个 pigz 算法较为突出。
其最大的作用就是并行策略,可以将 gzip 的性能提升 50% 以上
不过 pigz 本身是 c 实现的命令,不适用于 Java

不过凭借着关键词 并行 gzip java , 同样找到了替代品(Java 的库真是太全了,🥹)
migz, 经过测试,至少相比原生的 gzipxxstream 性能提升 50% 以上
但是与此同时,cpu 占用率相比原生高很多。
在我本机测试,原生平均 20%,使用 migz 至少 50% 以上的占用率。所以需要谨慎使用。

说到谨慎使用,又不得不提另一个话题,什么时候使用 gzip
目前远程是发送 gzip , 返回 gzip 。不考虑数据量大小。
考虑到 gzip 原理,在小文本的情况下,压缩率不一定更好。并且还要加上压缩,解压缩的时间,可能会比原来更加的差。

参考 Nginx 和 Tomcat 的 gzip 压缩配置, 需要设定最小的压缩体积,超过才开启,否则默认关闭。