ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Web请求为何是I/O密集型?CPU低却慢的真相与优化

Web请求为何是I/O密集型?CPU低却慢的真相与优化 1. 从一次吓人的发现说起一个CPU占用只有7%的Web服务大概三年前我给一个内部管理系统做性能排查。系统本身不复杂就是一个标准的三层结构浏览器页面上点按钮后端接口跑业务逻辑MySQL里读写数据。现象是用户经常抱怨转圈圈尤其是每天上午十点的高峰时段一个列表页要等两三秒才出来。我第一反应是服务器撑不住了于是登录机器准备看CPU有没有被打满。结果吓了我一跳CPU整体占用率只有7%内存也很健康机器闲得发慌。可服务就是慢而且并发一高延迟直接往上窜。当时旁边有个刚入行的同事问我是不是要加CPU加两台机器是不是就好了这个问题听起来很自然实际上是个经典的误区。一个Web服务慢CPU却闲着这本身就在告诉你你的瓶颈根本不在计算上而在等待上。而这个等待就是我们常说的I/O。我后来在团队里反复讲一个概念绝大多数Web请求是I/O密集型而不是CPU密集型。很多人听见这句话觉得是废话但真正能把这个判断背后的原理讲清楚的没几个。这篇文章我就用庖丁解牛的方式把一次Web请求从头到尾拆开看看时间到底花在哪然后再回答为什么它必然是I/O密集型这个问题。适合看这篇文章的读者包括正在学后端开发的新人、想搞明白异步模型和线程模型区别的中间层开发者以及准备做性能优化但不知道从哪下手的运维和架构师。看懂这篇文章你对Web服务的设计思想会有一个质的提升。2. 一次HTTP请求的完整链路CPU时间少得可怜等待时间多到离谱要回答为什么是I/O密集型最直接的办法就是算账。我们挑一个典型的后端接口比如从MySQL里查出10条订单数据再渲染成JSON返回给浏览器。把这条链路上的每个环节列出来标注它属于CPU操作还是I/O操作以及各自的耗时量级。2.1 各个阶段的时间账本从你在浏览器里按下回车开始大致经历下面这些阶段。我这里用的是量级估算不同网络环境和硬件有差异但比例关系是非常稳定的阶段操作类型典型耗时说明DNS解析I/O网络10~50ms首次解析可能更慢有缓存时很快TCP三次握手I/O网络0.5~50ms局域网很快跨地域较慢TLS握手HTTPSI/O网络少量CPU20~100ms涉及证书交换和密钥协商请求数据包上行传输I/O网络10~100ms受上传带宽和物理距离影响网关/负载均衡转发I/O 少量CPU1ms内网转发基本可以忽略Web服务器接收完整报文I/O网络与上行传输重叠内核缓冲区到应用缓冲区路由/参数解析CPU0.1~0.5ms极短几乎可以忽略业务逻辑处理CPU0.5~5ms如果不做重型计算往往在1ms内查询MySQLI/O网络磁盘1~20ms走索引很快走全表扫描就慢磁盘数据页读取I/O磁盘0.1~10msSSD快HDD慢且看是否命中Page Cache数据序列化JSONCPU0.2~1ms数据量大时会更久响应数据包下行传输I/O网络10~100ms受下载带宽和物理距离影响浏览器解析渲染CPU I/O20~100ms这是浏览器自己的事不算服务端我们只看服务端真正消耗的时间假设一个接口总耗时120ms其中网络上行40ms、数据库查询15ms、网络下行50ms、应用内部逻辑加起来大概5ms。那么CPU真正干活的时间只有5ms左右剩下的115ms都是在等待网络数据包到达、等待数据库返回、等待磁盘数据就绪。这115ms里CPU在干什么基本在阻塞、空转或者在切换上下文。这就是等待的本质不是没活干而是活儿没到。2.2 有一个隐藏角色叫阻塞线程在传统的同步阻塞模型里每个请求来了服务器就从一个线程池里掏一个线程让它去执行上面的流程。这个线程执行到查询MySQL那一步时就会发起一次网络I/O然后进入阻塞状态傻等数据库结果回来。等结果回来线程被唤醒继续执行几毫秒的CPU逻辑然后又发起一次网络I/O把响应写回去。写的过程如果TCP发送缓冲区满了线程又得阻塞。所以在同步模型下CPU那5ms的活线程用了120ms来干。线程的大部分生命周期是在等待I/O事件而不是执行指令。一个线程在等待期间虽然没有计算负担但它占着内存线程栈、上下文结构占着连接数还在反复地经历阻塞和唤醒。2.3 为什么说必然有人会抬杠如果业务逻辑里做了大量计算比如图像处理、机器学习推理那还算是I/O密集型吗问得好。这种情况确实会变成CPU密集型。但是Web后端服务的主流场景是读写数据库、调用远程API、读写缓存、读写文件、传输数据。这些操作无一例外都是I/O。而且随着微服务架构流行一个Web请求经常需要调用五六个下游服务这五六个调用之间可能还有串行依赖。所以Web请求是I/O密集型不是一个绝对真理而是一个针对绝大多数业务场景的统计性结论。架构设计如果建立在请求是I/O密集这个假设上通常是对的如果建立在请求是CPU密集上通常是要踩坑的。3. 为什么时间差距如此悬殊从纳秒到毫秒的时间维度碾压账号算完了接下来把问题再往下挖一层为什么网络I/O和磁盘I/O这么慢这要从计算机硬件的时间尺度说起。3.1 一条物理定律决定命运计算这件事发生在CPU内部电子在硅片里跑距离以毫米级别计算速度接近光速。所以CPU的一条指令延时在纳秒量级1纳秒等于十亿分之一秒。而网络传输要把数据搬到物理上更远的地方。同一机房内的交换机跳转少则几百米多则几公里跨地域的网络请求光纤要走几百上千公里。即使按光速算地球赤道一圈大约133毫秒跨洲一跳基本就是这个量级。磁盘就更夸张了。机械硬盘要转动盘片、移动磁头磁头定位一个扇区需要好几毫秒这个运动速度受物理机械结构的限制比电子慢了几百万倍。SSD没有机械运动但也要通过控制器、闪存芯片通道延迟依然在微秒到百微秒级别。用一个不精确但好记的比喻CPU算一个加法像是在自己书桌上拿笔写一笔读内存是从自己办公室书架上拿一本文件读SSD是走出办公室到楼下的档案室取一个文件读HDD是坐班车去市中心的档案馆调档走一次广域网是开车跨省拿一份合同。你写一笔只需要0.000001秒跨省开车却要10小时。3.2 具体数字让差异变得真实简单列一张常见的延迟阶梯表大家感受一下数量级操作延迟量级相对CPU周期的倍数CPU寄存器访问约0.3ns1xL1/L2缓存访问1~10ns3~30x主存访问约100ns300xSSD随机读20~100us约100000x机械硬盘随机读5~10ms约10000000x数据中心内网络往返0.5~2ms约百万x跨地域网络往返50~200ms约亿x差距是按数量级算的不是按百分比算的。一次机械硬盘随机读的耗时约等于执行了上千万条CPU指令。3.3 为什么这决定了I/O密集型当一个线程去读数据库它发起一次网络请求要在内核缓冲区、驱动、网卡、远端数据库、数据库磁盘之间兜一圈。这段时间里CPU不是闲着而是真的没事可干——它就等一个结果。返回结果之后它只执行几微秒的活然后又要发起下一次I/O等待。设想一个极端的算术题一个请求需要等待100ms的I/OCPU只执行1ms。如果按同步阻塞模型让这个线程一直占着CPU执行CPU利用率最多也就1%。为了让CPU别闲着就需要大量线程并发地等待不同的请求。于是等待本身不再是一种罪过而是并发模型必须正视的核心矛盾。这也是为什么线程池、异步、协程会变成Web服务器开发的核心话题。如果CPU执行时间和I/O等待时间反过来比如1ms的等待、100ms的计算那就是典型的CPU密集型场景优化思路完全不同你要升级CPU、减少指令数、并行计算。但Web请求普遍是反过来的所以几乎所有主流的Web技术栈都在往让等待不浪费线程、让事件驱动协程化异步化方向演进。4. I/O密集这个判断直接决定了架构模型的生死我经常跟人讲一个架构师如果对负载类型判断错了后面所有技术选型都会跑偏。下面我展开说说为什么Web请求是I/O密集型这个判断会引发一连串连锁反应。4.1 同步阻塞模型的代价一个线程一辆车传统Java Web容器比如早期的Tomcat处理每个请求的方式就是一个请求一个线程。这在请求量小的时候完全够用但一旦QPS到了几百上千问题就出来了。先算线程内存。线程默认栈大小在Linux上是8MB注意这是虚拟内存真实物理内存消耗一般不会全用满但1000个线程你能明显地看到内存压力。更重要的是上下文切换代价线程在阻塞、唤醒之间来回切换每次切换都要保存和恢复寄存器状态、程序计数器、栈指针还有CPU缓存刷新。一次上下文切换大约花费1~10微秒看起来不多可一个线程在等待I/O的120ms里可能会发生多次切换几千个线程时CPU会大量花在切换上而不是业务逻辑上。这就好比一条路上每个送货员都开一辆小车车多到一定程度光堵在红绿灯和收费站的时间就超过了送快递本身的时间。4.2 异步非阻塞模型的出路一辆公交车拉所有乘客既然线程在等待期间啥也不干还要付停车费那不如让事件驱动起来。操作系统提供了非阻塞I/O多路复用机制epollLinux、kqueueBSD/macOS、IOCPWindows。应用层可以通过一个事件循环同时监视成千上万个网络连接哪个连接的数据准备好了就处理哪个处理完继续监视。整个过程没有线程傻等。这就是Node.js的底牌也是Netty、Nginx高性能的原因。Go语言则是另一种思路用轻量级goroutine配合运行时调度器遇到I/O时自动让出调度看起来是同步编程底层已经把阻塞转换成了非阻塞事件驱动。这些技术方案的出现全部都是因为Web请求I/O密集——长等待、短计算。假如Web请求是CPU密集的事件循环反而没意义因为你早晚要消耗掉CPU的算力而单个事件循环又会受单核计算能力限制。4.3 那多核处理器怎么利用这是个衍生问题有人会追问既然I/O密集那是不是不需要多核CPU也不是。事件循环解决了等待问题但它本质上是单线程的只能吃满一个核。Node.js的经典问题就在这里——如果你的代码里混了一段CPU密集操作比如算哈希、加密解密事件循环会卡住所有连接都跟着变慢。所以现代方案普遍是事件驱动 多实例/多Reactor 线程池辅助或者直接选协程运行时自动利用多核。但整体设计依然是围绕I/O密集来组织的大部分线程/协程都在等待少量工作线程专门处理CPU敏感任务。这个洞察很重要你不需要为了Web请求去买几十核的机器来抗并发你更需要的是高吞吐的I/O通道、灵活的事件循环、合理的连接池和能熬住等待的并发模型。5. 别靠嘴说如何用工具证明你的服务是I/O密集型我觉得是I/O密集和铁证如山是I/O密集是两回事。实际排查问题的时候我习惯用下面几套工具让数据来说话。5.1 用压测观察CPU利用率和延迟的关系先不说复杂工具最简单的是压测。用wrk或者ab对某个接口压测同时用top观察机器状态。如果压测到一定并发时CPU先打满了说明你服务里CPU计算占主导如果CPU一直上不去但延迟随并发上升、连接堆积说明线程在等待I/O是典型I/O密集信号。我之前压过一个列表接口开到500并发6核机器CPU才35%但P99延迟已经飙到2秒。这时候我又做了一步把接口里的数据库查询临时换成固定返回的假数据其他逻辑不动重新压。结果同样500并发CPU虽然也只有40%左右但P99降到了10ms以下。这就非常明确地证明了时间黑洞在数据库I/O上不在业务逻辑上。这是一个很简单但极好用的对照实验强烈推荐。5.2 火焰图直接看时间去哪儿火焰图是好东西它能直观告诉你CPU时间分布。但要注意一个陷阱如果服务是I/O密集的火焰图上看CPU时间往往没啥好看因为CPU本来就很闲。这时候应该有另外一个图——Off-CPU火焰图也叫阻塞火焰图它反映线程在等待什么。生产环境用async-profiler可以采集on-CPU和off-CPU数据命令大致是./async-profiler.sh -d 60 -e wall -o flamegraph -f /tmp/offcpu.svg pid-e wall表示按挂钟时间采样而不是纯CPU采样。采集出来的图如果大块时间都在socketRead、epollWait、lock、park这些函数上说明线程大量阻塞在等待外部资源上。这种图比任何讲解都直观。5.3 strace看系统调用也能定位对单枪匹马排查环境strace可以跟踪进程的系统调用。看到进程频繁停在read、recvfrom、futex、epoll_wait上基本就是I/O密集型实锤。strace有较大性能损耗生产环境慎用不过压测环境拿来验证问题再合适不过。strace -f -T -p pid 21 | grep -E recvfrom|read|futex | head -50-T参数会打印每次系统调用的耗时你可以一眼看出时间耗在哪类调用上。5.4 连续观察iostat和网络吞吐磁盘层面的I/O可以用iostat -x 1观察如果util长期很高或者await很大说明磁盘在拖后腿。网络层面统计可以用nicstat或者nload。不过我想强调一点Web服务的I/O瓶颈更多时候是网络等待和数据库往返而不是本地磁盘。把目光盯在网卡、虚拟网络、数据库连接等待上往往收获更大。6. 确认了是I/O密集型之后我的优化思路与实操清单知道类型之后优化就不是乱打了。I/O密集决定了优化的核心方向是减少I/O次数、缩短I/O路径、让等待成本不再吞噬资源。下面这几招是我实际项目中反复使用的按收益和成本排序。6.1 第一优先减少I/O次数最划算的优化是不做那次I/O。举几个实际场景大量重复的数据库查询用Redis或本地缓存加一层但注意缓存穿透和一致性。接口之间串行调用下游能并行的改成并行调用。比如一次请求要分别调用户服务和订单服务等A回来再调B总耗时是AB并发调总耗时是max(A,B)。批量接口替代循环单调不要在for循环里逐条查库要用IN查询批量捞回数据在内存里做组装。举个具体例子一个订单列表原本对每条订单查一次用户信息10条订单就是10次数据库I/O。改成批量查询用户表后1次I/O就搞定数据库压力直接降了一个量级。这类优化对I/O密集系统是立竿见影的。6.2 第二优先缩短I/O路径减少不了次数就缩短时间。手法集中在连接复用和访问就近上使用连接池。这是老生常谈但值得强调连接池的核心价值是省去每次新建TCP连接和鉴权的开销尤其是HTTPS到下游服务时TLS握手很贵。数据库、Redis和上游服务尽量部署在同一个机房或同地域把50ms的网络往返压到1ms以内。用CDN把静态资源推到用户身边。数据库查询走覆盖索引避免回表读磁盘。大文件用对象存储传递别让Web层代理二进制流量。6.3 第三优先让等待不占线程如果I/O已经压到最少、最短了还嫌不够就要改并发模型。从同步阻塞改成异步或者协程。这一步收益很大但改造难度和成本也高。以Java为例可以引入CompletableFuture异步编排外部调用的部分放到独立线程池异步执行不要在Tomcat线程上干等。更彻底是换掉同步Servlet框架用WebFlux或Vert.x。Go开发者和Node开发者在这点上天然有优势goroutine和事件循环已经把等待的代价降得非常低。但我建议改异步前先确认前面的优化做了没有。见过太多团队一上来就推响应式编程结果只是把原本的串行阻塞换成了回调地狱性能没提升可维护性还暴跌。先把I/O次数降下来再审视是否需要异步化。6.4 第四优先控制并发与保护洪峰I/O密集型系统容易出现的怪现象是并发越高单请求越慢直到雪崩。原因往往是高并发击穿了某个低层组件。举个例子数据库连接池设了50结果瞬间涌进来500个请求另外450个都阻塞在等待连接池放连接自然的延迟暴涨。这种情况下不一定是I/O路径太长而是资源争抢过度。合理的做法是给Web接口加上限流和熔断给线程池配一个有界队列给连接池设一个略低于下游极限的阈值实时拒绝或者排队一部分流量。这个优化经常被忽略。因为高并发三个字听起来像是越多越好但I/O密集系统恰恰需要控制住并发才能让平均延迟保持稳定。6.5 第五优先协议层面的隐形优化最后提一个悄无声息但效果显著的让HTTP连接复用起来。开启Keep-Alive、用HTTP/2多路复用可以把一个页面几十个子请求的握手下行成本砍掉大半。具体配置层面Nginx可以用keepalive_timeout控制长连接生命周期上游池配置keepalive 128让后端连接不频繁创建销毁。这些参数监控连续跑几天就能看到连接数显著下降CPU自然跟着下降。7. 一个简化到极致的验证实验前面说的东西全部抽象成一句话就是Web请求里的计算量本来不大大头全在等待。为帮助读者自己验证我提供一个可以5分钟内完成的实验。写两个接口第一个接口模拟纯CPU计算比如原地做一亿次循环累加不碰任何I/O。 第二个接口模拟纯I/O等待直接sleep100ms不碰任何计算。分别用相同并发数压测。你会看到第一个接口的CPU占用稳定接近某个核的100%第二个接口CPU占用极低但响应时间始终在100ms以上。再多开点并发第二个接口的机器CPU依旧很低但你会看到线程数量暴涨延迟进一步升高。这个实验粗糙但结论足够让人印象深刻。我拿这个实验给团队新人讲过好几次比讲一百遍排队论都管用。8. 我在实际项目中的一点体会说句实在话搞清楚Web请求为什么是I/O密集型最大的价值并不是说出这个结论而是让你在做技术决策时不踩坑。有人拿着框架宣传语说异步性能高、同步性能低就盲目从同步转向异步也有人看服务器CPU不高就疯狂加核加机器钱花了性能没提。这两种尴尬我都见过也都经历过。我自己现在的排查习惯很简单先压测、再采样、用数据定位等待发生在哪一层然后按照减少I/O次数、缩短I/O路径、调整并发模型、保护系统资源的顺序来改。流程走完绝大多数Web服务的性能问题都出不了这五个框。最后一句话送给大家理解I/O密集不是让你记住一个标签而是让你在每一次看到CPU很低服务却很慢时能立刻想到时间还在路上还没到家。
返回列表