dubbo Triple协议性能调优报告

xriantvc  于 4个月前  发布在  其他
关注(0)|答案(3)|浏览(111)

issue: #10558
PR: #10587

前言

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不是那么优雅,此处待定。

htrmnn0y

htrmnn0y1#

最新提交使用 Http2StreamChannel 的write后,性能相比之前直接使用连接级Channel有少许降低,但解决了 Http2StreamChannel 不方便管理的问题,其中之一为: client side出现异常时并不会被标记为close

优化后

Benchmark             Mode  Cnt      Score       Error  Units
ClientPb.createUser  thrpt    3  31172.920 ± 25789.182  ops/s
ClientPb.existUser   thrpt    3  32726.997 ± 14906.890  ops/s
ClientPb.getUser     thrpt    3  30563.383 ±  6528.415  ops/s
ClientPb.listUser    thrpt    3  23953.497 ±  3215.888  ops/s

优化前(3.1.0)

Benchmark             Mode  Cnt      Score       Error  Units
ClientPb.createUser  thrpt    3  24640.820 ± 12601.192  ops/s
ClientPb.existUser   thrpt    3  24047.713 ±  6466.463  ops/s
ClientPb.getUser     thrpt    3  23001.375 ± 15389.702  ops/s
ClientPb.listUser    thrpt    3  19871.524 ±  4566.229  ops/s
tp5buhyn

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
Http2StreamChannel channel = bootstrap.open().syncUninterruptibly().getNow();
channel.pipeline().addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));
channel.closeFuture().addListener(f -> transportException(f.cause()));
vtwuwzda

vtwuwzda3#

benchmark数据:

triple优化前(3.1.0)

Benchmark             Mode  Cnt      Score       Error  Units
ClientPb.createUser  thrpt    3  23311.271 ± 17479.312  ops/s
ClientPb.existUser   thrpt    3  24204.740 ± 11288.850  ops/s
ClientPb.getUser     thrpt    3  23418.718 ±  3614.677  ops/s
ClientPb.listUser    thrpt    3  20484.369 ± 12999.594  ops/s

triple优化后

Benchmark             Mode  Cnt      Score      Error  Units
ClientPb.createUser  thrpt    3  34581.955 ± 7218.063  ops/s
ClientPb.existUser   thrpt    3  34699.273 ± 5034.128  ops/s
ClientPb.getUser     thrpt    3  32347.277 ± 3946.979  ops/s
ClientPb.listUser    thrpt    3  26766.718 ± 6755.026  ops/s

比较对象GRPC

Benchmark               Mode  Cnt      Score       Error  Units
ClientGrpc.createUser  thrpt    3  42687.751 ±  8276.734  ops/s
ClientGrpc.existUser   thrpt    3  47803.786 ± 77246.113  ops/s
ClientGrpc.getUser     thrpt    3  43274.952 ± 37666.058  ops/s
ClientGrpc.listUser    thrpt    3  35624.875 ± 32683.337  ops/s

相关问题