前言
Triple 是 Dubbo3 提出的基于 HTTP2
的开放协议。HTTP2基本特性可参考: 一文读懂 HTTP/2 特性
结论
先说结论,本次PR的调优性能提高约 25%
(ClientPb.listUser),与grpc还有一定的差距,仍需持续优化。理论上该改动会大幅度提高一些小报文场景!相关数据后续补充
基准测试
由于Triple协议是基于HTTP2来实现的,而grpc同样也是基于HTTP2实现的,那么把grpc作为一个参照对象再好不过。
这里直接使用 dubbo-benchmark 工程做基准测试,了解大致的性能差异。
序列化方式均为 protobuf
Triple (3.1.0)
# Warmup Iteration 1: 12412.531 ops/s
# Warmup Iteration 2: 21042.671 ops/s
# Warmup Iteration 3: 20705.143 ops/s
Iteration 1: 20884.380 ops/s
Iteration 2: 19628.467 ops/s
Iteration 3: 20108.126 ops/s
Benchmark Mode Cnt Score Error Units
ClientPb.listUser thrpt 3 20206.991 ± 11562.258 ops/s
GRPC
# Warmup Iteration 1: 25136.019 ops/s
# Warmup Iteration 2: 36998.606 ops/s
# Warmup Iteration 3: 35293.949 ops/s
Iteration 1: 34507.696 ops/s
Iteration 2: 34550.355 ops/s
Iteration 3: 34823.409 ops/s
Benchmark Mode Cnt Score Error Units
ClientGrpc.listUser thrpt 3 34627.153 ± 3125.063 ops/s
从以上的结果可以看到同样基于HTTP2的grpc性能远高于triple!
分析步骤
程序性能差通常的问题点为:网络IO消耗大、阻塞、GC停顿等。
而本次案例我们首先抓取triple与grpc的网络消耗做对比,这里可以使用 tcpdump
做一个简单的压测抽样。(这里需要适当调整基准测试次数,否则抓出来的包极大不方便分析)
tcpdump -w benchmark-grpc.pcap -i lo0 port 8080
grpc结果:
tcpdump: listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
1737323 packets captured
1739700 packets received by filter
1607 packets dropped by kernel
triple结果:
tcpdump: listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
13198398 packets captured
13199262 packets received by filter
0 packets dropped by kernel
通过两次结果的对比可以明显的看出,同样的测试条件下grpc发送的数据包 远远小于
triple,我们有理由怀疑性能差异一大部分来自于此。
我们使用Wireshark打开triple的tcpdump抓出来的文件,结果如图
接着打开grpc的tcpdump文件,结果如图
从图中我们可以明显的看出两者的差异:triple的数据包总是零碎的且非常规矩的“一来一回”,grpc的数据包总是“一次一批”,说明grpc在发送数据包之前一定有一个缓冲区用来批量发送,解决大量零碎数据包交互的问题。
根据查阅grpc的代码得知,grpc中使用了一个叫 WriteQueue
的对象做缓冲批量发送,同样的dubbo3的triple中也有一个 WriteQueue
用来缓冲批量发送,那么为什么dubbo3的 WriteQueue
看起来就像是无效的呢?带着这个疑惑继续深入dubbo的源码实现。
首先检查这个 WriteQueue
的代码是不是有问题,其源码核心部分如下:
public void scheduleFlush() {
if (scheduled.compareAndSet(false, true)) {
channel.eventLoop().execute(this::flush);
}
}
private void flush() {
try {
QueuedCommand cmd;
int i = 0;
boolean flushedOnce = false;
while ((cmd = queue.poll()) != null) {
cmd.run(channel);
i++;
if (i == DEQUE_CHUNK_SIZE) {
i = 0;
channel.flush();
flushedOnce = true;
}
}
if (i != 0 || !flushedOnce) {
channel.flush();
}
} finally {
scheduled.set(false);
if (!queue.isEmpty()) {
scheduleFlush();
}
}
}
通过以上源码可以得知triple在flush时并不是立即flush,而是把flush任务提交到 EventLoop
线程组上执行,这样便可以在高并发的情况下,CPU调度到flush任务之前积累出大量的Command,并逐个执行Command,如果执行了128个Command还没结束,则会一次性把他们flush,如果队列的Command都执行完毕但数量并未达到阈值128,也会兜底一次flush。
接下来我们检查Command的实现是否有问题,其 QueuedCommand
核心源码如下:
public final void send(ChannelHandlerContext ctx, ChannelPromise promise) {
if (ctx.channel().isActive()) {
doSend(ctx, promise);
ctx.flush();
}
}
这里居然每次doSend后都立即调用了一次flush,那么 WriteQueue
的批量flush还有什么意义呢。
这里将 ctx.flush();
这一行代码移除后并install到本地仓库,把benchmark的依赖换成本地的再次进行测试,同时使用tcpdump继续抓包,结果如下:
# Warmup Iteration 1: 18503.163 ops/s
# Warmup Iteration 2: 24623.833 ops/s
# Warmup Iteration 3: 24234.191 ops/s
Iteration 1: 23619.526 ops/s
Iteration 2: 22872.961 ops/s
Benchmark Mode Cnt Score Error Units
ClientPb.listUser thrpt 3 23182.998 ± 7097.240 ops/s
这个结果对比之前好了不少,但还是远低于grpc的。我们再次打开调整后的tcpdump文件观察,结果如下:
粗略看来与之前的结果差不多,但性能是有所提升的,毕竟将多次flush变为了一次flush。接下来我们检查构造 WriteQueue
的源码,为什么这个批量的结果与grpc的批量结果差异这么大,依旧还是“一来一回”。
构造 WriteQueue
的代码位于 TripleClientStream
,如下:
public TripleClientStream(FrameworkModel frameworkModel,
Executor executor,
Channel parent,
ClientStream.Listener listener) {
super(executor, frameworkModel);
this.parent = parent;
this.listener = listener;
this.writeQueue = createWriteQueue(parent);
}
private WriteQueue createWriteQueue(Channel parent) {
//1.通过连接打开一个HTTP2 stream
final Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);
final Future<Http2StreamChannel> future = bootstrap.open().syncUninterruptibly();
if (!future.isSuccess()) {
throw new IllegalStateException("Create remote stream failed. channel:" + parent);
}
//2.为这个stream channel流水线添加相应的处理器
final Http2StreamChannel channel = future.getNow();
channel.pipeline()
.addLast(new TripleCommandOutBoundHandler())
.addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));
channel.closeFuture()
.addListener(f -> transportException(f.cause()));
//3.把这个stream channel与WriteQueue绑定并返回
return new WriteQueue(channel);
}
可以看到在构造 WriteQueue
时首先是需要通过一个Channel打开一个HTTP2StreamChannel的,并将这个StreamChannel作为WriteQueue的构造参数传入,那也就说明 一个Stream对应一个WriteQueue
。而一个HTTP2连接会有多个Stream,也就是说会有多个WriteQueue,那么批量flush的时候实际上只是对一个Stream里的内容进行批量flush。在Unary模式下,一个Stream中也就一个Header、一个Data、一个End,说明一次批量flush顶多也就3个Command。
那么我们可以尝试把WriteQueue的Channel变为连接级别的Channel,并将该WriteQueue对象修改为 同一连接下单例
,这样就可以把一个连接下的其他Stream也加入到同一个WriteQueue中,批量flush的时候便可以flush多个Stream的内容,以达到 多请求并行
提高性能的目的。这样的改动细节较多,这里不做展示,详情参考pr: #10587
至于为什么多个请求并行可以被正常处理,相关细节可了解 HTTP2帧
、 HTTP2多路复用
等知识,这里也不做展开。
将改动的后的内容install,再次进行基准测试,其tcpdump抓包与benchmark结果如下:
# Warmup Iteration 1: 14027.495 ops/s
# Warmup Iteration 2: 25809.170 ops/s
# Warmup Iteration 3: 26318.787 ops/s
Iteration 1: 25659.561 ops/s
Iteration 2: 25854.664 ops/s
Iteration 3: 24830.314 ops/s
Benchmark Mode Cnt Score Error Units
ClientPb.listUser thrpt 3 25448.179 ± 9922.891 ops/s
可以看到,调整后的Header已经是批量发送了,其结果表象比较接近了grpc。但本次pr中没涉及到批量响应data,主要原因是server端想要构造一个同连接的WriteQueue不是那么优雅,此处待定。
3条答案
按热度按时间htrmnn0y1#
最新提交使用
Http2StreamChannel
的write后,性能相比之前直接使用连接级Channel有少许降低,但解决了Http2StreamChannel
不方便管理的问题,其中之一为:client side出现异常时并不会被标记为close
。优化后
优化前(3.1.0)
tp5buhyn2#
改造问题记录:
headers not received before payload
构造
StreamChannel
时调用了直接syncUninterruptibly
取出channel,然后再addLast
会导致高并发时偶现headers not received before payload
。其本质原因是addLast
会判断是否为eventloop
线程组,如果不是则会提交任务到eventloop
中,那么持续高并发的情况下就有可能出现:请求发送成功并且收到了响应,但pipeline实际上并未组装完成,导致header丢失,从而报错 headers not received before payload
。具体源码参见io.netty.channel.AbstractChannelHandlerContext#invokeHandler
vtwuwzda3#
benchmark数据:
triple优化前(3.1.0)
triple优化后
比较对象GRPC