通过 HTTP/2 协议案例学习 Java & Netty 性能调优:工具、技巧与方法论
作者:梁倍宁 Apache Dubbo Contributor、陈有为 Apache Dubbo PMC
摘要
Aliware
Dubbo3 Triple 协议是参考 gRPC、gRPC-Web、Dubbo2 等协议特点设计而来,它吸取各自协议特点,完全兼容 gRPC、Streaming 通信、且无缝支持 HTTP/1 和浏览器。
为什么要优化 Triple 协议的性能?
Aliware
前置知识
Aliware
1. Triple 协议简介
Triple 协议是参考 gRPC 与 gRPC-Web 两个协议设计而来,它吸取了两个协议各自的特性和优点,将它们整合在一起,成为一个完全兼容 gRPC 且支持 Streaming 通信的协议,同时 Triple 还支持 HTTP/1、HTTP/2。
Triple 协议的设计目标如下:
Triple 设计为对人类友好、开发调试友好的一款基于 HTTP 的协议,尤其是对 unary 类型的 RPC 请求。 完全兼容基于 HTTP/2 的 gRPC 协议,因此 Dubbo Triple 协议实现可以 100% 与 gRPC 体系互调互通。
以下是使用 curl 客户端访问 Dubbo 服务端一个 Triple 协议服务的示例:
curl \
--header "Content-Type: application/json"\
--data '{"sentence": "Hello Dubbo."}'\
https://host:port/org.apache.dubbo.sample.GreetService/sayHello
2. HTTP/2
3. Netty
工具准备
Aliware
为了对代码进行调优,我们需要借助一些工具来找到 Triple 协议性能瓶颈的位置,例如阻塞、热点方法。而本次调优用到的工具主要有 VisualVM 以及 JFR。
Visual VM
JFR
Monitor Blocked 事件由 synchronized 块触发,表示有线程进入了同步代码块
Monitor Wait 事件由 Object.wait 触发,表示有代码调用了该方法
Thread Park 事件由 LockSupport.park 触发,表示有线程被挂起
Thread Sleep 事件由 Thread.sleep() 触发,表示代码中存在手动调用该方法的情况
调优思路
Aliware
1. 非阻塞
2. 异步
3. 分治
4. 批量
高性能的基石:非阻塞
Aliware
不合理的 syncUninterruptibly
private WriteQueue createWriteQueue(Channel parent) {
final Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);
final Future
future = bootstrap.open().syncUninterruptibly(); if (!future.isSuccess()) {
throw new IllegalStateException("Create remote stream failed. channel:" + parent);
}
final Http2StreamChannel channel = future.getNow();
channel.pipeline()
.addLast(new TripleCommandOutBoundHandler())
.addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));
channel.closeFuture()
.addListener(f -> transportException(f.cause()));
return new WriteQueue(channel);
}
此处代码逻辑如下:
通过 TCP Channel 构造出 Http2StreamChannelBootstrap
通过调用 Http2StreamChannelBootstrap 的 open 方法得到 Future
通过调用 syncUninterruptibly 阻塞方法等待 Http2StreamChannel 构建完成
得到 Http2StreamChannel 后再构造其对应的 ChannelPipeline
而在前置知识中我们提到了 Netty 中大部分的任务都是在 EventLoop 线程中以单线程的方式执行的,同样的当用户线程调用 open 时将会把创建 HTTP2 Stream Channel 的任务提交到 EventLoop中,并在调用 syncUninterruptibly 方法时阻塞用户线程直到任务完成。
优化方案
private TripleStreamChannelFuture initHttp2StreamChannel(Channel parent) {
TripleStreamChannelFuture streamChannelFuture = new TripleStreamChannelFuture(parent);
Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);
bootstrap.handler(new ChannelInboundHandlerAdapter() {
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
channel.pipeline().addLast(new TripleCommandOutBoundHandler());
channel.pipeline().addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));
channel.closeFuture().addListener(f -> transportException(f.cause()));
}
});
CreateStreamQueueCommand cmd = CreateStreamQueueCommand.create(bootstrap, streamChannelFuture);
this.writeQueue.enqueue(cmd);
return streamChannelFuture;
}
其中 CreateStreamQueueCommand 的核心逻辑如下,通过保证在 EventLoop 中执行以消除不合理的阻塞方法调用。
public class CreateStreamQueueCommand extends QueuedCommand {
......
@Override
public void run(Channel channel) {
//此处的逻辑可以保证在EventLoop下执行,所以open后可以直接获取结果而不需要阻塞
Future
future = bootstrap.open(); if (future.isSuccess()) {
streamChannelFuture.complete(future.getNow());
} else {
streamChannelFuture.completeExceptionally(future.cause());
}
}
}
不恰当的 synchronized 锁竞争
public boolean isConnected() {
synchronized (stateLock) {
return (state == ST_CONNECTED);
}
}
可以看到这个方法中没有任何逻辑,但是有着关键字眼 synchronized,所以我们可以断定:EventLoop 线程出现了大量的同步锁竞争!那么我们下一步思路便是找到在同一时刻竞争该锁的方法。我们的方法也比较简单粗暴,那就是通过 DEBUG 条件断点的方式找出该方法。如下图所示我们给 isConnected 这个方法里打上条件断点,进入断点的条件是:当前线程不是 EventLoop 线程。
优化方案
不可忽视的开销:线程上下文切换
Aliware
我们继续观察 VisualVM 采样的快照,查看整体线程的耗时情况,如下图:
耗时最大的线程为 NettyClientWorker-2-1
压测期间有大量非消费者线程即
tri-protocol-214783647-thread-xxx消费者线程的整体耗时较高且线程数多
用户线程的耗时非常低
我们任意展开其中一个消费者线程后也能看到消费者线程主要是做反序列化以及交付反序列化结果(DeadlineFuture.received),如下图所示:
1. Monitor Blocked 事件
2. Monitor Wait 事件
public boolean isAvailable() {
if (isClosed()) {
return false;
}
Channel channel = getChannel();
if (channel != && channel.isActive()) {
return true;
}
if (init.compareAndSet(false, true)) {
connect();
}
this.createConnectingPromise();
//87ms左右的耗时来自这里
this.connectingPromise.awaitUninterruptibly(this.connectTimeout, TimeUnit.MILLISECONDS);
// destroy connectingPromise after used
synchronized (this) {
this.connectingPromise = ;
}
channel = getChannel();
return channel != && channel.isActive();
}
4. Thread Park 事件
从以上结果中可以看到已经减少了大量的消费者线程,线程利用率大幅度提高,并且 Java Thread Park 事件也是大幅度减少,性能却提高了约 13%。 由此可见多线程切换对程序性能影响较大,但也带来了另一个问题,我们通过 SerializingExecutor 将大部分的逻辑集中到了少量的消费者线程上是否合理?带着这个疑问我们展开其中一条消费者线程的调用堆栈进行分析。通过展开方法调用堆栈可以看到 deserialize 的字样(如下图所示)。 很显然我们虽然提高了性能,但却把不同请求的响应体反序列化行为都集中在了少量的消费者线程上处理,会导致反序列化被”串行”执行了,当反序列化大报文时耗时会明显上涨。 所以能不能想办法把反序列化的逻辑再次派发到多个线程上并行处理呢?带着这个疑问我们首先梳理出当前的线程交互模型,如下图所示。 根据以上的线程交互图,以及 UNARY SYNC “一个请求对应一个响应”的特点,我们可以大胆推断—— ConsumerThread 不是必要的!我们可以直接将所有非 I/O 类型的任务都交给用户线程执行,可以有效利用多线程资源并行处理,并且也能大幅度减少不必要的线程上下文的切换。所以此处最佳的线程交互模型应如下图所示。 5. 优化方案
梳理出该线程交互模型后,我们的改动思路就比较简单了。根据 TripleClientStream 的源码得知,每当接收到响应后,I/O 线程均会把任务提交到与TripleClientStream绑定的 Callback Executor 中,该 Callback Executor 默认即消费者线程池,那么我们只需要替换为 ThreadlessExecutor 即可。其改动如下: 减少 I/O 的利器:批量
Aliware
我们前面介绍到 triple 协议是一个基于 HTTP/2 协议实现的,并且完全兼容 gRPC,由此可见 gRPC 是一个不错的参照对象。于是我们将 triple 与 gRPC 做对比,环境一致仅协议不同,最终结果发现 triple 与 gRPC 的性能有一定的差距,那么差异点在哪里呢?带着这个问题,我们对这两者继续压测,同时尝试使用 tcpdump 对两者进行抓包,其结果如下。
triple
gRPC
从以上的结果我们可以看到 gRPC 与 triple 的抓包差异非常大,gRPC 中一个时间点发送了一大批不同 Stream 的数据,而 triple 则是非常规矩的请求“一来一回”。所以我们可以大胆猜测 gRPC 的代码实现中一定会有批量发送的行为,一组数据包被当作一个整体进行发送,大幅度的减少了 I/O 次数。为了验证我们的猜想,我们需要对 gRPC 的源码深入了解。最终发现 gRPC 中批量的实现位于 WriteQueue 中,其核心源码片段如下: private void flush() {
PerfMark.startTask("WriteQueue.periodicFlush");
try {
QueuedCommand cmd;
int i = 0;
boolean flushedOnce = false;
while ((cmd = queue.poll()) != ) {
cmd.run(channel);
if (++i == DEQUE_CHUNK_SIZE) {
i = 0;
// Flush each chunk so we are releasing buffers periodically. In theory this loop
// might never end as new events are continuously added to the queue, if we never
// flushed in that case we would be guaranteed to OOM.
PerfMark.startTask("WriteQueue.flush0");
try {
channel.flush();
} finally {
PerfMark.stopTask("WriteQueue.flush0");
}
flushedOnce = true;
}
}
// Must flush at least once, even if there were no writes.
if (i != 0 || !flushedOnce) {
PerfMark.startTask("WriteQueue.flush1");
try {
channel.flush();
} finally {
PerfMark.stopTask("WriteQueue.flush1");
}
}
} finally {
PerfMark.stopTask("WriteQueue.periodicFlush");
// Mark the write as done, if the queue is non-empty after marking trigger a new write.
scheduled.set(false);
if (!queue.isEmpty()) {
scheduleFlush();
}
}
}
可以看到 gRPC 的做法是将一个个数据包抽象为 QueueCommand,用户线程发起请求时并非真的直接写出,而是先提交到 WriteQueue 中,并手动调度 EventLoop 执行任务,EventLoop 需要执行的逻辑便是从 QueueCommand 的队列中取出并执行,当写入数据达到 DEQUE_CHUNK_SIZE (默认 128)时,才会调用一次 channel.flush,将缓冲区的内容刷写到对端。当队列的 Command 都消费完毕后,还会按需执行一次兜底的 flush 防止消息丢失。以上便是 gRPC 的批量写入逻辑。
同样的,我们检查了 triple 模块的源码发现也有一个名为 WriteQueue 的类,其目的同样是批量写入消息,减少 I/O 次数。但从 tcpdump 的结果来看,该类的逻辑似乎并没有达到预期,消息仍旧是一个个按序发送并没有批量。 我们可以将断点打在 triple 的 WriteQueue 构造器中,检查 triple 的 WriteQueue 为什么没有达到批量写的预期。如下图所示。 可以看到 WriteQueue 会在 TripleClientStream 构造器中实例化,而 TripleClientStream 则是与 HTTP/2 中的 Stream 对应,每次发起一个新的请求都需要构建一个新的 Stream,也就意味着每个 Stream 都使用了不同的 WriteQueue 实例,多个 Stream 提交 Command 时并没有提交到一块去,使得不同的 Stream 发起请求在结束时都会直接 flush,导致 I/O 过高,严重的影响了 triple 协议的性能。 分析出原因后,优化改动就比较清晰了,那便是将 WriteQueue 作为连接级共享,而不是一个连接下不同的 Stream 各自持有一个 WriteQueue 实例。当 WriteQueue 连接级别单例后,可以充分利用其持有的 ConcurrentLinkedQueue 队列作为缓冲,实现一次 flush 即可将多个不同 Stream 的数据刷写到对端,大幅度 triple 协议的性能。
调优成果
Aliware
最后我们来看一下 triple 本次优化后成果吧。可以看到小报文场景下性能提高明显,最高提升率达 45%!而遗憾的是较大报文的场景提升率有限,同时较大报文场景也是 triple 协议未来的优化目标之一。
总结
Aliware
性能解密之外,在下一篇文章中我们将会带来 Triple 易用性、互联互通等方面的设计与使用案例,将主要围绕以下两点展开,敬请期待。
在 Dubbo 框架中使用 Triple 协议,可以直接使用 Dubbo 客户端、gRPC 客户端、curl、浏览器等访问你发布的服务,不需要任何额外组件与配置。
Dubbo 当前已经提供了 Java、Go、Rust 等语言实现,目前正在推进 Node.js 等语言的协议实现,我们计划通过多语言和 Triple 协议打通移动端、浏览器、后端微服务体系。