通过 HTTP/2 协议案例学习 Java & Netty 性能调优:工具、技巧与方法论
摘要
Aliware Dubbo3 Triple 协议是参考 gRPC、gRPC-Web、Dubbo2 等协议特点设计而来,它吸取各自协议特点,完全兼容 gRPC、Streaming 通信、且无缝支持 HTTP/1 和浏览器。 当你在 Dubbo 框架中使用 Triple 协议,然后你就可以直接使用 Dubbo 客户端、gRPC 客户端、curl、浏览器等访问你发布的服务,不需要任何额外组件与配置。 除易用性以外,Dubbo3 Triple 在性能调优方面做了大量工作,本文将侧重对 Triple 协议背后的高性能秘密进行深入讲解,涉及一些有价值的性能调优工具、技巧及代码实现;在下一篇文章中,我们将具体展开 Triple 协议在易用性方面的一些具体使用场景。 为什么要优化 Triple 协议的性能?
Aliware 自 2021 年开始 Dubbo3 就已经作为下一代服务框架逐步开始取代阿里内部广泛使用的 HSF 框架,截止目前,阿里以淘宝、天猫等电商为代表的绝大多数核心应用已经成功升级到 Dubbo3。作为过去两年支撑阿里双十一万亿级服务调用的关键框架,Triple 通信协议的性能直接影响整个系统的运行效率。 前置知识
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 体系互调互通。
在具体实现上,Dubbo Triple 支持 Protobuf Buffer 但并不绑定,比如 Dubbo Java 支持以 Java Interface 定义 Triple 服务,这对于关注特定语言易用性的开发者将更容易上手。另外,Dubbo 当前已经提供了 Java、Go、Rust 等语言实现,目前正在推进 Node.js 等语言的协议实现,我们计划通过多语言和 Triple 协议打通移动端、浏览器、后端微服务体系。 在 Triple 的实现中核心的组件有以下几个: TripleInvoker 是 Triple 协议的核心组件之一,用于请求调用 Triple 协议的服务端。其中核心方法为 doInvoke,该方法会根据请求类型如 UNARY、BiStream 等,发起不一样类型的请求。如 UNARY 在 SYNC 下即同步阻塞调用,一个请求对应一个响应。BiStream 则是双向通讯,客户端可以持续发送请求,而服务端同样也可以持续推送消息,他们之间通过回调 StreamObserver 组件的方法实现交互。 TripleClientStream 是 Triple 协议的核心组件之一,该组件与 HTTP/2 中的Stream 概念与之对应,每次发起一个新的请求均会创建一个新的TripleClientStream,同理与之对应的 HTTP/2 的 Stream 也是不相同的。TripleClientStream 提供核心的方法有 sendHeader 用来发送头部帧 Header Frame,以及 sendMessage 用来发送数据帧 Data Frame。 WriteQueue 是 Triple 协议中用于写出消息的缓冲队列,其核心逻辑就是将各种操作命令 QueueCommand 添加到内部维护的队列中,并尝试将这些 QueueCommand 对应的任务提交到 Netty 的 EventLoop 线程中单线程、有序的执行。 QueueCommand 是专门用于提交到 WriteQueue 的任务抽象类,不同的 Command 对应了不同的执行逻辑。 TripleServerStream 是 Triple 协议中服务端的 Stream 抽象,该组件与 HTTP/2 中的 Stream 概念与之对应,客户端每通过一个新的 Stream 发起请求,服务端便会创建一个与之对应的 TripleServerStream,以便处理客户端发来的请求信息。 2. HTTP/2 HTTP/2 是一种新一代的 HTTP 协议,是 HTTP/1.1 的替代品,HTTP/2 相较于 HTTP/1.1 的最大改进在于减少了资源的消耗提高了性能。HTTP/1.1 中,浏览器只能在一个 TCP 连接中发送一个请求。如果浏览器需要加载多个资源,那么浏览器就需要建立多个 TCP 连接。这种方式会导致一些问题,例如 TCP 连接的建立和断开会增加网络延迟,而且浏览器可能会在同一时间内发送多个请求导致网络拥塞。 相反,HTTP/2 允许浏览器在一个 TCP 连接中同时发送多个请求,多个请求对应多个 Stream 流,多个流之间相互独立,并以并行的方式流转。而在每个流中,这些请求会被拆分成多个 Frame 帧,这些帧在同一个流中以串行的方式流转,严格的保证了帧的有序性。因此客户端可以并行发送多个请求,而服务器也可以并行发送多个响应,这有助于减少网络连接数,以及网络延迟和提高性能。 HTTP/2 还支持服务器推送,这意味着服务器可以在浏览器请求之前预加载资源。例如,如果服务器知道浏览器将要请求一个特定的资源,那么服务器可以在浏览器请求之前将该资源推送到浏览器。这有助于提高性能,因为浏览器不需要等待资源的请求和响应。 HTTP/2 还支持头部压缩,这意味着 HTTP 头部中的重复信息可以被压缩。这有助于减少网络带宽的使用。 3. Netty Netty 是一个高性能异步事件驱动的网络框架,主要用于快速开发可维护的高性能协议服务器和客户端。它的主要特点是易于使用、灵活性强、性能高、可扩展性好。Netty 使用 NIO 作为基础,可以轻松地实现异步、非阻塞的网络编程,支持 TCP、UDP、HTTP、SMTP、WebSocket、SSL 等多种协议。 Netty 的核心组件包括Channel、EventLoop、ChannelHandler 和 ChannelPipeline。 Channel 是一个传输数据的双向通道,可以用来处理网络 I/O 操作。Netty 的Channel实现了 Java NIO 的 Channel 接口,并在此基础上添加了一些功能,例如支持异步关闭、绑定多个本地地址、绑定多个事件处理器等。 EventLoop 是 Netty 的核心组件之一,它负责处理所有 I/O 事件和任务。一个 EventLoop 可以管理多个 Channel,每个 Channel 都有一个对应的 EventLoop。EventLoop 使用单线程模型来处理事件,避免了线程之间的竞争和锁的使用,从而提高了性能。 ChannelHandler 是连接到 ChannelPipeline 的处理器,它可以处理入站和出站的数据,例如编码、解码、加密、解密等。一个 Channel 可以有多个 ChannelHandler,ChannelPipeline 会按照添加的顺序依次调用它们来处理数据。 ChannelPipeline 是 Netty 的另一个核心组件,它是一组按顺序连接的ChannelHandler,用于处理入站和出站的数据。每个 Channel 都有自己独占的 ChannelPipeline,当数据进入或离开 Channel 时,会经过所有的 ChannelHandler,由它们来完成处理逻辑。 工具准备curl \--header "Content-Type: application/json"\--data '{"sentence": "Hello Dubbo."}'\https://host:port/org.apache.dubbo.sample.GreetService/sayHello
Aliware 为了对代码进行调优,我们需要借助一些工具来找到 Triple 协议性能瓶颈的位置,例如阻塞、热点方法。而本次调优用到的工具主要有 VisualVM 以及 JFR。 Visual VM Visual VM 是一个可以监视本地和远程的 Java 虚拟机的性能和内存使用情况的图形化工具。它是一个开源项目,可以用于识别和解决 Java 应用程序的性能问题。 Visual VM 可以显示 Java 虚拟机的运行状况,包括 CPU 使用率、线程数、内存使用情况、垃圾回收等。它还可以显示每个线程的 CPU 使用情况和堆栈跟踪,以便识别瓶颈。 Visual VM 还可以分析堆转储文件,以识别内存泄漏和其他内存使用问题。它可以查看对象的大小、引用和类型,以及对象之间的关系。 Visual VM 还可以在运行时监视应用程序的性能,包括方法调用次数、耗时、异常等。它还可以生成 CPU 和内存使用情况的快照,以便进一步分析和优化。 JFR JFR 全称为 Java Flight Recorder,是 JDK 提供的性能分析工具。JFR 是一种轻量级的、低开销的事件记录器,它可以用来记录各种事件,包括线程的生命周期、垃圾回收、类加载、锁竞争等等。JFR 的数据可以用来分析应用程序的性能瓶颈,以及识别内存泄漏等问题。与其他性能分析工具相比,JFR 的特点在于它的开销非常低,可以一直开启记录,而不会对应用程序本身的性能产生影响。 JFR 的使用非常简单,只需要在启动 JVM 时添加启动参数 -XX:+UnlockCommercialFeatures -XX:+FlightRecorder,就可以开启 JFR 的记录功能。当 JVM 运行时,JFR 会自动记录各种事件,并将它们保存到一个文件中。记录结束后,我们可以使用工具 JDK Mission Control 来分析这些数据。例如,我们可以查看 CPU 的使用率、内存的使用情况、线程的数量、锁竞争情况等等。JFR还提供了一些高级的功能,例如事件过滤、自定义事件、事件堆栈跟踪等等。 在本次性能调优中,我们重点关注 Java 中能明显影响性能的事件:Monitor Blocked、Monitor Wait、Thread Park、Thread Sleep。
- Monitor Blocked 事件由 synchronized 块触发,表示有线程进入了同步代码块
- Monitor Wait 事件由 Object.wait 触发,表示有代码调用了该方法
- Thread Park 事件由 LockSupport.park 触发,表示有线程被挂起
- Thread Sleep 事件由 Thread.sleep() 触发,表示代码中存在手动调用该方法的情况
Aliware 1. 非阻塞 高性能的关键点之一是编码时必须是非阻塞的,代码中如果出现了 sleep、await 等类似方法的调用,将会阻塞线程并直接影响到程序的性能,所以在代码中应尽可能避免使用阻塞式的 API,而是使用非阻塞的 API。 2. 异步 在调优思路中,异步是其中一个关键点。在代码中,我们可以使用异步的编程方式,例如使用 Java8 中的 CompletableFuture 等。这样做的好处在于可以避免线程的阻塞,从而提高程序的性能。 3. 分治 在调优过程中,分治也是一个很重要的思路。例如可以将一个大的任务分解成若干个小任务,然后使用多线程并行的方式来处理这些任务。这样做的好处在于可以提高程序的并行度,从而充分利用多核 CPU 的性能,达到优化性能的目的。 4. 批量 在调优思路中,批量也是一个很重要的思路。例如可以将多个小的请求合并成一个大的请求,然后一次性发送给服务器,这样可以减少网络请求的次数,从而降低网络延迟和提高性能。另外,在处理大量数据时,也可以使用批量处理的方式,例如一次性将一批数据读入内存,然后进行处理,这样可以减少 IO 操作的次数,从而提高程序的性能。 高性能的基石:非阻塞
Aliware 不合理的 syncUninterruptibly 通过直接检查代码,我们发现了一处明显明显会阻塞当前线程的方法 syncUninterruptibly。而使用 DEBUG 的方式可以很轻松的得知该代码会在用户线程中进行,其中源码如下所示。
此处代码逻辑如下:private WriteQueue createWriteQueue(Channel parent) {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);}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<Http2StreamChannel>
- 通过调用 syncUninterruptibly 阻塞方法等待 Http2StreamChannel 构建完成
- 得到 Http2StreamChannel 后再构造其对应的 ChannelPipeline
其中 CreateStreamQueueCommand 的核心逻辑如下,通过保证在 EventLoop 中执行以消除不合理的阻塞方法调用。private TripleStreamChannelFuture initHttp2StreamChannel(Channel parent) {TripleStreamChannelFuture streamChannelFuture = new TripleStreamChannelFuture(parent);Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);bootstrap.handler(new ChannelInboundHandlerAdapter() {@Overridepublic 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;}
不恰当的 synchronized 锁竞争 此时简单的看源码已经不能发现明显的性能瓶颈了,接下来我们需要借助 Visual VM 工具来找到性能瓶颈。 打开工具后我们可以选中需要采集的进程,这里我们采集的是 Triple Consumer 的进程,并选中选项卡中的 Sampler,点击 CPU 开始采样 CPU 的耗时热点方法。以下是我们采样 CPU 热点方法的结果,我们展开了耗时最为明显的 EventLoop 线程的调用堆栈。public class CreateStreamQueueCommand extends QueuedCommand {......@Overridepublic void run(Channel channel) {//此处的逻辑可以保证在EventLoop下执行,所以open后可以直接获取结果而不需要阻塞Future<Http2StreamChannel> future = bootstrap.open();if (future.isSuccess()) {streamChannelFuture.complete(future.getNow());} else {streamChannelFuture.completeExceptionally(future.cause());}}}
可以看到这个方法中没有任何逻辑,但是有着关键字眼 synchronized,所以我们可以断定:EventLoop 线程出现了大量的同步锁竞争!那么我们下一步思路便是找到在同一时刻竞争该锁的方法。我们的方法也比较简单粗暴,那就是通过 DEBUG 条件断点的方式找出该方法。如下图所示我们给 isConnected 这个方法里打上条件断点,进入断点的条件是:当前线程不是 EventLoop 线程。public boolean isConnected() {synchronized (stateLock) {return (state == ST_CONNECTED);}}
Aliware 我们继续观察 VisualVM 采样的快照,查看整体线程的耗时情况,如下图:
- 耗时最大的线程为 NettyClientWorker-2-1
- 压测期间有大量非消费者线程即 tri-protocol-214783647-thread-xxx
- 消费者线程的整体耗时较高且线程数多
- 用户线程的耗时非常低
3. Thread Sleep 事件 接下来我们再查看 Java Thread Sleep 事件,该事件表示代码中存在手动调用 Thread.sleep,检查是否存在阻塞工作线程的行为。从下图可以看到,很显然并没有阻塞消费者线程或 benchmark 请求线程,这个主动调用 sleep 的线程主要用于请求超时场景,对整体性能没有影响,同样也可以排除 Java Thread Sleep 事件。public boolean isAvailable() {if (isClosed()) {return false;}Channel channel = getChannel();if (channel != null && channel.isActive()) {return true;}if (init.compareAndSet(false, true)) {connect();}this.createConnectingPromise();//87ms左右的耗时来自这里this.connectingPromise.awaitUninterruptibly(this.connectTimeout, TimeUnit.MILLISECONDS);// destroy connectingPromise after usedsynchronized (this) {this.connectingPromise = null;}channel = getChannel();return channel != null && channel.isActive();}
根据以上的线程交互图,以及 UNARY SYNC “一个请求对应一个响应”的特点, 我们可以大胆推断—— ConsumerThread 不是必要的! 我们可以直接将所有非 I/O 类型的任务都交给用户线程执行,可以有效利用多线程资源并行处理,并且也能大幅度减少不必要的线程上下文的切换。所以此处最佳的线程交互模型应如下图所示。
5. 优化方案 梳理出该线程交互模型后,我们的改动思路就比较简单了。根据 TripleClientStream 的源码得知,每当接收到响应后,I/O 线程均会把任务提交到与TripleClientStream绑定的 Callback Executor 中,该 Callback Executor 默认即消费者线程池,那么我们只需要替换为 ThreadlessExecutor 即可。 其改动如下:
Aliware 我们前面介绍到 triple 协议是一个基于 HTTP/2 协议实现的,并且完全兼容 gRPC,由此可见 gRPC 是一个不错的参照对象。于是我们将 triple 与 gRPC 做对比,环境一致仅协议不同,最终结果发现 triple 与 gRPC 的性能有一定的差距,那么差异点在哪里呢?带着这个问题,我们对这两者继续压测,同时尝试使用 tcpdump 对两者进行抓包,其结果如下。 triple
可以看到 gRPC 的做法是将一个个数据包抽象为 QueueCommand,用户线程发起请求时并非真的直接写出,而是先提交到 WriteQueue 中,并手动调度 EventLoop 执行任务,EventLoop 需要执行的逻辑便是从 QueueCommand 的队列中取出并执行,当写入数据达到 DEQUE_CHUNK_SIZE (默认 128)时,才会调用一次 channel.flush,将缓冲区的内容刷写到对端。当队列的 Command 都消费完毕后,还会按需执行一次兜底的 flush 防止消息丢失。以上便是 gRPC 的批量写入逻辑。 同样的,我们检查了 triple 模块的源码发现也有一个名为 WriteQueue 的类,其目的同样是批量写入消息,减少 I/O 次数。但从 tcpdump 的结果来看,该类的逻辑似乎并没有达到预期,消息仍旧是一个个按序发送并没有批量。 我们可以将断点打在 triple 的 WriteQueue 构造器中,检查 triple 的 WriteQueue 为什么没有达到批量写的预期。如下图所示。private void flush() {PerfMark.startTask("WriteQueue.periodicFlush");try {QueuedCommand cmd;int i = 0;boolean flushedOnce = false;while ((cmd = queue.poll()) != null) {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();}}}
Aliware 最后我们来看一下 triple 本次优化后成果吧。可以看到小报文场景下性能提高明显, 最高提升率达 45%! 而遗憾的是较大报文的场景提升率有限,同时较大报文场景也是 triple 协议未来的优化目标之一。
Aliware 性能解密之外,在下一篇文章中我们将会带来 Triple 易用性、互联互通等方面的设计与使用案例,将主要围绕以下两点展开,敬请期待。
- 在 Dubbo 框架中使用 Triple 协议,可以直接使用 Dubbo 客户端、gRPC 客户端、curl、浏览器等访问你发布的服务,不需要任何额外组件与配置。
- Dubbo 当前已经提供了 Java、Go、Rust 等语言实现,目前正在推进 Node.js 等语言的协议实现,我们计划通过多语言和 Triple 协议打通移动端、浏览器、后端微服务体系。