ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

网络请求全链路:从DNS到HTTP/2的性能优化与排查实战

网络请求全链路:从DNS到HTTP/2的性能优化与排查实战

1. 一个网络请求从发出到返回,中间到底发生了什么

先说一个我常用来考团队新人的场景:你在手机App里刷到一条内容,点了个赞,屏幕上的红心立刻亮了。这个过程中,你的手机到底做了哪些事?很多人会回答“发了个请求给服务器,服务器返回成功”。这话没错,但离“真正理解网络请求”还差着一整条链路。

拆开来看,一次典型的HTTP请求,至少经历了这么几个阶段:

触摸反馈之后,客户端先做的是“找路”——DNS解析。你写代码时用的是https://api.example.com这种域名,但网络传输层只认IP地址。手机需要先向DNS服务器询问:“api.example.com的IP是多少?”这个过程本身又是一次网络请求。如果App是第一次启动,这条DNS解析的耗时常常在几十到几百毫秒不等,慢的时候甚至能超过1秒。很多人排查接口慢,只盯着服务器响应时间,却忽略了DNS这一层。

找到IP之后,TCP连接开始建立。这个阶段就是大家常说的“三次握手”:客户端发一个SYN包,服务器回SYN+ACK,客户端再回ACK。三次握手本身要消耗一个RTT(往返时间)。如果网络环境不好,比如丢包重传,这个阶段就可能卡住。很多接口超时问题,不是服务器没响应,而是TCP根本握不上手。

连接建立后,如果走的是HTTPS,还有TLS握手。这一步往往比很多人想象中更耗时。TLS握手要协商加密套件、交换证书、生成会话密钥,通常也得消耗一到两个RTT。我见过很多性能优化项目,最后发现耗时大头根本不在业务代码里,而是在TLS协商上——尤其是一台服务器部署了多张证书、或者客户端的CA证书链不完整的时候。

然后是真正的HTTP请求发送与响应。客户端把请求行(方法+路径+协议版本)、请求头、请求体发出去,服务器解析后路由到对应的处理函数,业务代码执行完,再按状态码、响应头、响应体返回。这一步才是大家眼里“接口调通了”的那一步。

最后,连接不会立刻断掉。除非服务器或客户端明确关了连接,否则这个TCP连接会被放进连接池,供后续请求复用。这也就是“Keep-Alive”机制的底层原理。

我说这些不是为了掉书袋。想说明的是:一次网络请求是整套链路协同的结果,任何一个环节出问题,表现都是“接口慢”或“接口失败”,但排查方向完全不一样。这也是为什么我一直建议,遇到网络问题先把问题分层——是DNS层、连接层、TLS层、传输层还是应用层?分清楚再动手,能省下大把无用功。

2. 三个最容易被忽视的细节:连接管理、超时配置与DNS缓存

很多人在项目初期“调通接口”之后就再也不管网络层了,直到线上报障才回来补课。根据我的经验,线上网络类问题里,有相当大比例根本不是服务器逻辑出错,而是下面这三个细节没处理好。

2.1 连接复用:Keep-Alive才是高并发的基础

先说一个直觉很容易骗人的点。很多刚入行的开发者以为“并发高就是多开连接”,实际恰恰相反——高并发场景下,连接复用才是保命的关键。

HTTP/1.1在设计时就支持了Keep-Alive,也就是在一个TCP连接上连续发送多个请求,避免每个请求都重新走三次握手和TLS握手。我在优化过一个模拟项目X的网关层时做过实测:短连接模式下,单机吞吐只有不到2000 QPS,连接建立的开销占了快一半的CPU时间;改成连接池复用后,同样一台机器,吞吐直接翻了将近四倍,P99延迟也从300毫秒降到了80毫秒左右。

但连接复用不是“配了就完了”,它有几个隐藏问题。比如连接池的最大连接数和空闲超时需要根据业务特征设置:如果设置的空闲超时太短,连接很快被回收,复利率就低;太长又可能占用大量文件描述符。另外一个更隐蔽的问题是,连接复用容易把错误扩散——比如某个请求超时了,但TCP连接本身还活着,下一个请求如果继续复用这个半关闭的连接,就可能出现“请求发出去了但收不到响应”的诡异现象。所以成熟的客户端库都会在检测到连接异常时主动废弃这条连接,而不是继续复用。

2.2 超时配置:只设一个总超时是新手最容易犯的错

我见过太多线上事故,根因就是“超时时间设置不合理”。最典型的问题有两个:超时设得太短,导致本该成功的请求在慢网络下被误杀;超时设得太长,用户在弱网环境下卡到怀疑人生,最终自己杀掉了App。

正确的做法是分层设超时:

连接超时(Connect Timeout):只覆盖TCP握手和TLS握手阶段,建议3秒左右。这个阶段失败通常是网络根本不通或服务器宕机,不需要等太久。

读取超时(Read Timeout):覆盖“请求发出后等待响应”的阶段,建议可以根据业务特性调整。比如普通的查询接口,10秒是底线;如果是导出报表这类长任务,可能要放到60秒甚至更长。

写超时(Write Timeout):这是很多人忽略的一项。它保护的是请求体本身发送失败的情况,比如弱网环境下上传大文件,连接半开导致发送卡死。

还有一个建议:千万不要在业务代码里把超时写死成同一个值。在模拟项目X里我接手过一套老代码,所有接口统一设置30秒超时,结果就是:本该快速失败的请求占了大量线程资源,线程池被拖垮,最终整个服务雪崩。后来按接口分级设置超时,核心接口15秒、非核心接口30秒、长任务接口单独放宽,才把稳定性拉回来。

2.3 DNS缓存:你代码里最隐蔽的“定时炸弹”

DNS解析听起来是基础设施的事,但它在客户端侧的坑特别多。最常见的问题是:系统DNS缓存时间过长,导致域名解析结果长期不变。一旦服务器IP发生了迁移或故障切换,客户端还在拼命连接旧IP,表现就是“App突然大面积请求失败,但服务器明明好好的”。

我在某个图像处理Demo里就踩过这个坑。当时把图片上传域名迁移到了新机房,CDN和源站都切换完成后,还是有大约10%的用户上传失败,排查了一天,最后发现是这些用户的本地DNS缓存还指着旧IP,而旧IP的网关已经在迁移当天做了下线处理。

处理方式有几个方向:一是用HTTPDNS方案,在应用层直接绕过本地DNS,自己做解析和缓存管理;二是在客户端缓存DNS结果,并配合TTL做主动刷新;三是至少要做到“请求失败时尝试重新解析一次”,而不是死磕同一个IP。别指望用户会乖乖清缓存,客户端层面的容错必须自己做。

3. 幂等、重试与缓存:决定线上稳定性的关键设计

如果说上一节讲的连接、超时、DNS是网络请求的“地基”,那这一节要讲的就是“骨架”——真正决定一个接口在真实场景下能不能扛住乱局的,是幂等设计、重试策略和缓存机制。

3.1 幂等性:为什么“重试”会带来灾难

很多人对“幂等”的理解停留在“GET请求是幂等的,POST不是”,这个说法太粗糙了。严格来说,幂等指的是:同一个请求执行多次,和只执行一次,产生的效果完全一致。

为什么网络层特别需要关注这个?因为网络是不可靠的,超时会频繁发生。客户端超时了,服务器到底有没有处理这个请求?没人知道。于是客户端只能重试。这一重试就有讲究了:如果接口不幂等,用户点了一次支付按钮,后端可能收到两次扣款指令,结果就是扣了两次钱。

我见过一个很典型的案例:某跨平台系统的订单回调接口,业务方没做幂等处理,某次网络抖动导致消息队列重复投递,一天之内生成了几百条重复订单,财务对账对到怀疑人生。后来加的方案很朴素但也很有代表性:每条请求带上全局唯一的请求ID,服务端在Redis或数据库里按请求ID做去重,重复请求直接返回第一次的结果。别看方案简单,它能挡住绝大多数重试带来的脏数据。

3.2 重试策略:不是“失败就再来一次”

重试本身是个好机制,但盲目的重试比不重试更可怕。我先列几个基本判断:

什么情况下值得重试?连接超时、TLS握手失败、5xx类错误、DNS解析失败——这些属于“服务器可能没处理成功”的情况,可以重试。什么情况下不该重试?请求体已经发送成功但没收到响应、4xx类业务错误、超时时间已经到了但服务器可能正在处理——这些情况重试很可能造成重复业务或无效请求。

重试的次数和间隔怎么定?我的建议是:总重试次数不超过3次,间隔用递增的退避策略(比如1秒、2秒、4秒),并且要加入随机抖动。这里有个数学上的道理:如果大量客户端在同一时刻失败,又用完全相同的间隔重试,它们会形成一个“重试雪崩”,把本来就被打垮的服务再压上一根稻草。退避+抖动,就是为了打破这种同步性。

还有一个经常被忽略的细节:重试要限制在同一个逻辑请求里,不能无限扩散。比如A调用B,B调用C,C超时了,A重试,B也重试,这个重试放大效应会把下游直接打挂。成熟的架构会在RPC链路上传递“重试预算”(比如通过请求头传递剩余重试次数),每一层消耗一次预算,预算用完了就不允许再重试。

3.3 缓存:网络请求性能优化的第一手段

我在性能调优时有一句口头禅:“能不走网络的请求,尽量别走。”这不只是节省带宽,更是为了省掉整条链路的不确定性和延迟。

HTTP协议自带了一套缓存语义,只要后端配合,客户端可以极大地减少重复请求。核心是这几个字段:

  • Cache-Control:服务端告诉客户端这个响应能不能缓存、缓存多久。比如max-age=60表示60秒内可以直接用缓存。
  • ETag / If-None-Match:服务端给资源一个版本标识,客户端带上上次的ETag去请求,如果资源没变,服务端返回304,不下载实际内容。
  • Last-Modified / If-Modified-Since:类似的逻辑,基于时间戳判断。

这里有个容易踩的坑:Cache-Control设了no-cache不等于不缓存,它只是要求“使用前必须先验证”。很多开发者搞混了,导致明明想禁用缓存,却反而让客户端每次都要发请求去服务器确认,延迟没降反升。

对于App端的网络请求,更常用的做法是应用层自己维护缓存。比如我在优化某跨平台系统的接口时,把所有“读多写少”的数据按用户维度做了本地+内存双重缓存,命中率从20%提到70%多,整体接口平均耗时降了一半。缓存的设计原则就一条:先明确数据的更新频率和一致性要求,再选择缓存层级和过期策略。不是所有数据都适合缓存,硬塞只会带来数据不一致的问题。

4. 从“能通”到“好用”:性能优化是我最推荐的动手方向

网络请求能做到“稳定不出错”,已经超过了相当多项目;但要真正把体验做上去,就得进入性能优化阶段。这也是我觉得每个人都可以亲手做、做完立刻能感知到成效的部分。

4.1 先看清你的请求到底慢在哪

优化的第一步不是“优化”,是测量。我建议所有人在动手前先抓一份现状基线:接口平均耗时、P95耗时、P99耗时、失败率、超时率,以及——最关键的一环——耗时拆解。网络耗时可以拆成DNS解析时间、TCP连接时间、TLS握手时间、首字节时间(TTFB,服务器处理时间)、内容下载时间这几段。

拿到了拆解数据,优化方向就很清晰了:

  • DNS解析慢:考虑HTTPDNS、客户端缓存、减少域名数量。
  • TCP连接慢:开启连接池复用。
  • TLS握手慢:检查证书链完整性、考虑TLS会话复用。
  • TTFB慢:这是服务器端的业务处理时间,网络层帮不了忙,得去看后端逻辑。
  • 内容下载慢:检查响应体大小、压缩情况、网络带宽。

我遇到过很多“优化了半天没效果”的项目,最后发现是把时间和资源用错了环节——比如后端逻辑明明要2秒,却在拼命调连接池参数,当然没有意义。

4.2 HTTP/2:当前性价比最高的网络升级项

如果只让我选一个“性价比最高”的网络优化手段,我会选升级到HTTP/2。它带来的核心能力有三个:

多路复用:一个TCP连接里可以同时跑多个请求,不再受HTTP/1.1“队头阻塞”的限制。以前浏览器对同一个域名最多开6个连接,每个连接里请求还得排队;HTTP/2把多个请求交错传输,性能提升立竿见影。

头部压缩:HTTP头是很啰嗦的,每次请求都要带一堆Cookie、User-Agent、Accept之类的字段。HTTP/2用HPACK对头部做压缩,重复字段只传一次差异部分,能省下不少字节。

服务端推送(Server Push):服务器可以在客户端请求某个页面时,主动把页面需要的配套资源(CSS、JS)一起推过来,减少一次往返。不过这个特性实际落地效果有争议,很多CDN在默认配置里反而会关掉它,具体还是要看场景。

从HTTP/1.1切到HTTP/2,改动成本远比很多人想象的低。服务端开个TLS证书再加一行配置,客户端只要换用支持HTTP/2的网络库,连接层的事情就不用管了。我在优化某跨平台系统时做过一次全量升级,仅此一项,静态资源的加载时间平均下降了35%,接口并发能力也涨了一截。

4.3 弱网优化:把“网络不好”变成“体验尚可”

移动端场景下,用户可能在电梯里、地铁上、地下车库,网络质量差是常态而不是异常。弱网优化的思路不是“让网络变好”——这谁也做不到——而是让应用在网络差的时候不要那么糟糕。

常见的手段包括:

请求合并(Batching):把多个小请求合并成一个大请求发出。比如用户进入一个页面,传统做法是三个接口分别拉数据,弱网下每个都要经历完整的握手和首字节等待。合并成一个接口后,三次往返变成一次,体验提升会很明显。

数据压缩:在带宽有限时,减少传输字节数是硬道理。Gzip/Brotli压缩是基础项,控制响应体大小、去掉冗余字段、精简JSON结构,也能省下可观的流量。

超时动态调整:前面提到过分层设超时,弱网环境下这种分层设置尤其重要。如果网络状态检测到当前信号差,可以适当放宽读取超时;信号恢复后,再把超时收紧。很多成熟的App会用网络质量监控模块来做这个动态策略。

离线优先(Offline First):把上一次成功拉取的数据缓存下来,网络请求失败时先展示缓存,让页面不至于白屏。这个策略在信息流、列表页场景里效果特别好,我从实践中得到的心得是:用户对“数据稍微旧一点”的容忍度,远远高于对“页面空白”的容忍度。

5. 网络请求排查的思路与常用工具:遇到问题不乱翻代码

网络请求的问题有个特点:报错信息往往极其简略——“请求超时”“连接失败”“网络异常”,就这五个字,背后可能藏着十几种不同的原因。如果拿到报错就一头扎进业务代码里翻,大概率是浪费时间。我自己在面对网络问题时,遵循的是一套固定的排查思路。

5.1 先复现,再分层定位

复现这个环节很多人会跳过,但我建议一定不要省。因为稳定复现是定位问题的前提。你至少要搞清楚:这个报错是偶发的还是必现的?是特定网络环境(Wi-Fi、4G/5G)下才出现?是特定设备、特定系统版本才有?

复现之后,按我前面说的链路逐层排查:

  1. DNS层:dig或nslookup看解析是否正常,解析出来的IP是否还活着。
  2. 连接层:用ping -c 4看基本连通性,再检查目标端口是否开放(可用telnet ip 端口或在客户端配置里临时指向测试IP验证)。
  3. TLS层:用带详细输出的方式看证书链(很多客户端工具本身就能打印证书校验细节),确认证书是否过期、域名是否匹配、是否缺少中间证书。
  4. 应用层:抓包看请求头、请求体、响应状态码。重点关注状态码——4xx是客户端问题,5xx是服务端问题,超时则要看是连接超时还是读取超时。

5.2 三个我认为最顺手的排查工具

首先是curl。别小看这个命令行工具,它几乎是网络排查的“瑞士军刀”。我常用的几个参数:

# 完整输出请求和响应头部,并加上耗时统计 curl -v -w "DNS解析: %{time_namelookup}s\n连接建立: %{time_connect}s\nTLS握手: %{time_appconnect}s\n首字节: %{time_starttransfer}s\n总耗时: %{time_total}s\n" https://example.com # 指定HTTP版本测试 curl --http2 -I https://example.com # 模拟慢速网络 curl --limit-rate 10k https://example.com

这一套下来,响应慢的问题基本能定位出是哪一层了。

然后是抓包工具。Wireshark能做到全链路分析,但它的学习曲线很陡峭;日常调试我更常用的其实是Charles这类的图形化HTTP代理工具,因为它能看到每个请求的详细信息、耗时,还能直接断点修改请求和响应。移动端调试时,把证书装进手机,就能抓到真实的App网络流量,非常适合排查“真机上的问题”和“模拟器上的问题”表现不一致的情况。

还有一个是客户端日志。很多网络库都支持把网络层的内部日志打出来,比如OkHttp的日志拦截器、Flutter的Dio日志拦截器。这类日志的颗粒度已经足够精细,能直接看到每次请求的耗时和失败原因。在测试环境打开日志抓一轮,比对着屏幕猜要有效得多。

5.3 常见状态码到底在告诉你什么

这个表格我经常在排查问题时当速查表用:

状态码含义常见排查方向
200请求成功直接看响应体是否符合预期
301/302重定向检查Location头是否正确,客户端是否允许跟随重定向
304未修改,可直接用缓存检查缓存策略是否生效
400客户端请求语法错误检查请求参数、请求头格式
401未认证检查Token是否过期、认证头格式
403无权限检查权限配置、IP白名单、防盗链
404资源不存在检查URL路径是否写错、服务端路由是否匹配
429请求过于频繁检查限流配置,客户端是否触发了频率控制
500服务器内部错误服务端日志是唯一突破口
502/504网关错误/超时检查反向代理配置、上游服务是否存活

特别要提醒一点:很多人在状态码上会犯一个直觉性错误——看到4xx就觉得是服务端的锅。实际上4xx的定义就是“客户端发的东西有问题”,责任方在调用方,不要搞反了。

6. 安全与合规视角下的网络请求:别等出事才补课

网络请求是数据进出应用的“大门”,安全和合规问题也最容易在这一层暴露。这部分的优先级常常被低估,但真要出问题,往往就是大问题。

6.1 HTTPS与证书校验是底线,不是选配

我见过不少项目为了省事,直接把证书校验关掉,或者干脆用HTTP。说句不好听的,这是把自己家的钥匙挂在门口。中间人攻击在公共Wi-Fi环境下非常常见,攻击者可以悄无声息地篡改你App流向服务器的数据——你在App里输入的账号密码,等于直接送给了别人。

正确的做法是:

  • 生产环境一律使用HTTPS,并且开启证书校验,不要盲目信任所有证书。
  • 证书固定(Certificate Pinning)可以加,但要慎重。它确实能提升安全性,但也会带来证书更新时的运维成本。我建议至少对核心接口启用,并且做好证书轮换预案。
  • 内部测试环境可以用自签名证书,但一定要在代码里明确区分环境,绝不能把测试用的证书校验逻辑带到生产环境。

6.2 敏感数据的网络传输:字段级别的红线

现在主流合规要求里,对敏感信息的保护是重点审查项。在技术层面,有几个非常具体的注意事项:

不要用GET请求传敏感参数。GET的参数会出现在URL里,而URL会被记入服务器访问日志、代理日志、浏览器历史记录等多处地方。密码、Token这类数据一旦进了URL,就等于在多个地方留了副本。

Token的存放位置很关键。放在本地存储里本身就容易被其他应用读取;放在内存里则要考虑持久化。推荐的折中方案是用系统提供的安全存储能力(如Android的EncryptedSharedPreferences、iOS的Keychain),加上合理的过期时间。

请求体加密可以,但要谨慎。很多团队喜欢自己在应用层再加一层加密,这本身没问题,但加密方案若是自己拍脑袋发明的,往往比不加密更危险。如果要做应用层加密,请使用公开、成熟的标准算法(比如AES-GCM),并且密钥管理要独立于代码之外。

6.3 请求头里的隐私痕迹要主动清理

这一节想提醒一个很多团队完全没注意过的地方:请求头会暴露很多设备、用户甚至行为特征。User-Agent里带了完整的系统版本、机型;Accept-Language暴露语言和地区;自定义Header里可能有内部账号体系信息。

这些信息单个看问题不大,但汇聚在一起就会形成设备指纹,从合规上看属于“间接识别用户”的范畴。建议做一件很简单的事:每个接口上线前,先看一眼它发出的请求头,把不必要的信息全部去掉。只保留服务端真正需要的最小集合。我参与的几个对外项目里,都会强制要求开发在联调阶段用抓包工具检查一遍完整的请求头,这项习惯后来帮我们规避了好几次合规审查风险。

7. 从一次线上故障看网络请求的链路价值

讲了这么多理论,最后分享一个我印象很深的实战案例。这个案例几乎把前面几个章节的问题都串在了一起,理解了它,你对网络请求的认知就基本成形了。

那是某跨平台系统的一次线上事故,现象是:用户端大面积报错,提示“网络异常,请稍后重试”,但服务器监控显示各项指标都正常,CPU、内存、带宽都不高。当时很多人第一反应是“客户端的问题”,甚至怀疑是某个版本发版引入的Bug。

排查过程是这样的:

第一步复现。从用户反馈里筛选出几个机器,发现报错比例大约占8%,且集中出现在移动网络环境、特定地区。

第二步看客户端日志。发现有大量请求卡在“连接建立”阶段,也就是TCP握手一直没成功。这推翻了“服务器没处理”的猜测——服务器根本没收到多少请求。

第三步顺着连接层往下查。发现DNS解析结果全都指向一个IP,但这个IP在几个小时前刚做过一次机房割接。问题浮出水面了:服务端IP变更后,客户端的本地DNS缓存还在使用旧地址,而旧地址的防火墙规则已经移除,导致连接直接被丢弃。

第四步是验证和修复。我们把问题域切到HTTPDNS服务,在客户端代码里做了“连接失败自动重解析一次”的容错逻辑,同时调整了DNS TTL策略。上线后,报错比例直接从8%降到接近0。

这个case的核心价值在于:它让我们意识到,网络请求的稳定性从来不是某一层单独能保障的。DNS层、连接层、应用层、客户端与服务器之间的任何一处细小疏忽,都可能以“网络异常”四个字呈现在用户面前。而你能做的,就是把每一层都当成一块独立的、可以被观测和控制的积木——出了问题可以快速定位是哪一块歪了,而不是整栋楼一起垮。

这也是我这篇文章想传达的最后一个观点:别把“网络请求”这四个字看成一个API调用那么简单。花点时间把这条链路上的每一环都吃透,日后能帮你省下的排障时间,会远超你当初学习这些内容所花的工夫。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进