资源等待与系统吞吐—— 从线程、连接到 TCP 带宽利用率
我们从一个后端服务的抽象开始。
一、线程池为什么成为瓶颈
转发型网关服务
先是一个中间服务,它接收上游调用、然后调用下游服务工作,将结果返回给上游。
通常会有一个线程池处理上述这个任务,任务的核心是网络调用、也就是网络io,获得下游结果,然后返回。
这样有两个瓶颈,调用下游,服务是客户端,需要一个连接池,而接收处理上游请求任务,需要一个线程池。
如果这是一个SpringBoot,那么上述请求客户端对应HttpClient,线程池对应Tomcat线程池,
如果下游返回慢,首先会长时间占用HttpClient连接池的连接,这是第一个可能的瓶颈资源,进而负责处理这个调用任务的线程也会阻塞,那么Tomcat线程池就成为了第二个可能的资源瓶颈。
二、非阻塞IO如何减少线程占用
这比较好解决,我们可以直接用WebClient来替换上述的HttpClient,这是一个非阻塞io调用,调用线程可以返回去干别的事情。可以验证一下:就算我们把Tomcat线程池的线程数设置为1,然后做两个接口分别去调用slow和fast两个下游服务,那么在slow接口返回之前,fast接口的请求可以先返回。类似如下:
先调用后返回: 上游 -> 本服务slow接口 -> 下游slow服务 后调用先返回: 上游 -> 本服务fast接口 -> 下游 fast服务那么既然走到这里了,为了彻底消除 Tomcat 线程模型的心智负担,我们索性直接用WebFlux替换spring-web,也就是底层用Netty替换了Tomcat。
这样彻底不用纠结所谓Tomcat线程池调优问题了,不用拍脑袋去设置线程池的各种参数,WebFlux底层的Netty会自动根据cpu核数*2来固定的reactor线程池大小———这也是这种线程模型下的最优解,然后这些个reactor线程会非阻塞的接收请求以及处理它们的任务、即用WebClient调用下游服务。这样整个应用服务就都是非阻塞的了。
这也是很多高性能网关的原理,因为网关就是这样的转发型中间服务,原理一样,但未必用Java,比如基于OpenResty的kong, Apache APISIX等。
自己做任务的服务
那么如果我们讨论的这个服务,它不是转发型的,而是自己要做一些工作呢?
我们抽象的归类,大部分其实是操作某种数据库,比如MySQL, 或者Redis。
我们仔细想想,其实这也是网络io的一种。
我们接着我们的上面的优化后的服务模型:现在reactor线程不是用WebClient调用下游http服务了,而是需要用jdbc来调用MySQL。
这时候问题来了,jdbc是个同步阻塞Java API,底层通过 MySQL Wire Protocol 与数据库通信,意味着线程要等结果返回之后才能去干别的,假设2核CPU,那么有4个reactor线程,这仅有的4个线程很快会被可能的慢sql任务占满,整个服务就变得不可用了。
于是,有了r2dbc,重新设计的异步响应式api (协议还是原来的,没变),线程在调用r2dbc 进行数据库操作的时候可以非阻塞,向连接池申请连接、向数据库发送sql执行的网络请求、然后不用等sql执行完而可以立即返回去干其他事情。
三、连接池为什么成为新的瓶颈
线程模型讨论完了,那连接呢?
到此,是不是完美了呢?
我们上面讨论一直是围绕线程池,但是连接池部分,比如HttpClient , WebClient (以 HTTP/1.1 为例)、HikariCP这些。线程池我们通过reactor非阻塞线程模型实现了少量线程就可以完美工作,但是在每个网络请求的时候要去连接池申请一个到对端的网络连接,用完放回连接池,而这个连接在被线程使用的时候,是不能被别的线程使用的。
这叫做独占式的连接复用,连接池仍然会成为瓶颈。
四、连接多路复用的原理
而与之相对的是共享式的连接复用,实现连接共享模式的连接池,一般需要客户端与服务端在协议上做出约定,使用类似RequestID这种、服务端在返回Response时带上这个ID,用来识别这个是客户端的哪个请求的响应,后面就可以根据这个ID去做对应的处理了,比如回调对应的handle等等,蚂蚁的Sofa Bolt以及阿里的Dubbo rpc框架就是基于的这样的原理 。
或者还有一种情况,例如基于Netty的Redis Lettuce,默认以单连接多路复用方式工作,它可以采用单连接共享模式的原因在于:它对端的服务端是单线程处理的Redis ,加之TCP协议本身的顺序性保证,这样一来在同一条TCP连接上,响应到达的顺序必然与请求发出的顺序一致,所以就不需要上面所说的客户端与服务端之间约定的ID了。
注:Redis 6.0 之后引入了多线程 I/O(网络读写由多线程处理),但命令的执行仍然是单线程串行的。
ok, 到此,连接池的瓶颈的问题似乎也解决了。我们尽量用连接共享式的连接池。
但是,到底是设置多少连接呢?多连接一定比单连接快? 还是说这是跟“线程多一定比线程少要好”一样的愚蠢的问题。
五、单连接与多连接之争
先说结论:在不存在链路争用、且在低延迟的数据中心内网环境下的情况下,如果连接可以在多线程间共享且非阻塞,单连接是和多连接一样快的。
如果客户端与服务端之间出现了某个瓶颈节点,比如路由器交换机之类的,网络中的公共节点为多个链路公用。由于TCP的拥塞控制机制,经过该公共设备的连接的实际速率会趋于平均化公平分配,所以会出现某个客户端没有充分利用带宽的情况,这样如果在客户端上再开几个连接,由于平均分配机制,那么就会提高该客户端在公共节点的流量占比,从客户端角度看过来就是传输速率变快了。其实可以认为是从其他客户端那儿抢过来的一些公共节点的带宽使用。
如图,客户端1和客户端2如果都是单连接的话,在路由器会被均分带宽,每个是R/2,假设如果客户端带宽也是R的话,那么从客户端的角度来看就没有充分利用带宽;这时候客户端1开两个连接,那么其在路由器相当于会分到2R/3带宽,从其角度来看两连接就比原来的单连接要快了。
而在高延迟网络中,即使没有任何链路争用,单条 TCP 连接也可能因为拥塞窗口(cwnd)或接收窗口(rwnd)的大小限制,无法打满可用带宽。
带宽时延积(BDP = 带宽 × RTT)决定了"管道里能飞多少数据",单连接的窗口如果没有调大,在跨地域高延迟链路上即使独占带宽也跑不满。这时候开多连接确实更快,且不是"抢别人的带宽",而是自己没充分利用。
比如跨机房或公网场景,这时候为了充分利用带宽,确实是可以适当开多连接。
最后,跟风贩卖一下焦虑:)
AI时代来临 2027程序员灭绝倒数的时间点,清理旧电脑时翻出以前的笔记,整理一下发出来,祭奠一下我的开发时代和年轻的日子。
