Triple协议性能调优报告
- Dominant language
- Java
- Stars
- 41.6k
- Forks
- 26.4k
- Avg merge
- 15h 13m
- Merged PRs (30d)
- 4
Description
issue: #10558
PR:#10587
# 前言
Triple 是 Dubbo3 提出的基于`HTTP2` 的开放协议。HTTP2基本特性可参考: [一文读懂 HTTP/2 特性](https://zhuanlan.zhihu.com/p/26559480)
# 结论
先说结论,本次PR的调优性能提高约`25%`(ClientPb.listUser),与grpc还有一定的差距,仍需持续优化。理论上该改动会大幅度提高一些小报文场景!相关数据后续补充
# 基准测试
由于Triple协议是基于HTTP2来实现的,而grpc同样也是基于HTTP2实现的,那么把grpc作为一个参照对象再好不过。
这里直接使用 [dubbo-benchmark](https://github.com/apache/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`做一个简单的压测抽样。(这里需要适当调整基准测试次数,否则抓出来的包极大不方便分析)
````bash
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`的代码是不是有问题,其源码核心部分如下:
````java
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`核心源码如下:
````java
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`,如下:
````java
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 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:https://github.com/apache/dubbo/pull/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不是那么优雅,此处待定。
Contributor guide
Research direction
Start with the WriteQueue and QueuedCommand implementations described in the report, then inspect its construction in TripleClientStream. Run the dubbo-benchmark ClientPb.listUser case and use tcpdump to compare packet batching with gRPC. Done would mean validating connection-level batching across streams and documenting the resulting performance, including the unresolved response-data batching limitation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- grpc, java
- Domain
- backend-api-design, distributed-systems, performance
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 20/100