TCP 三次握手与 HTTP:第一次跟运维抓包,在 Wireshark 里亲眼看到 SYN 和 ACK

页面慢,不一定慢在你的代码开始执行之后。Network 面板里如果 Stalled、Initial connection、SSL 这些阶段先拉长了,说明浏览器还没把请求真正发出去,瓶颈就已经出现在线路上了。

这次后台页面偶发性转好几秒,真正的线索就藏在 Timing 里:JS 执行没异常,接口处理也不慢,拖时间的是连接建立本身。到这一步,再盯着 Vue 组件和接口封装已经没有意义,问题得往 TCP、TLS 和连接复用那条链路上走。

下面会从一次慢请求的 Timing 面板开始,顺着 TCP 三次握手、四次挥手、Keep-Alive 和 HTTPS 建连往下拆,把这些平时容易背成名词的东西重新挂回真实请求上。

开抓之前:一个请求到底经历什么

等 Wireshark 装抓包过滤器的间隙,运维同事先在白板上给我画了条链路。用户打开页面,浏览器要做的事依次是:DNS 解析域名、建立 TCP 连接、如果是 HTTPS 再做 TLS 握手、发送 HTTP 请求、服务端处理并返回响应、浏览器接收响应并渲染。任何一步慢,页面都会慢。

他特意强调这是一条串行的链路——前面几步是后面几步的前提,DNS 没解析完拿不到 IP 就没法建连,连没建好就没法握手 TLS,TLS 没握完就发不了加密的 HTTP 请求。所以这几段耗时是叠加的,不是并行的,链路上任何一个环节卡住,后面全得跟着等。这也解释了为什么"提前做 DNS、提前建连"这类优化有意义:它是把串行链路里靠前的环节,挪到浏览器还闲着的时候先做掉,等真正要发请求时,前面这几段已经就绪,省掉的是实打实的串行等待。

这几步在 Chrome 的 Network 面板里是能一一对上号的,把请求点开看 Timing:

1Queued / Stalled        ->  排队、等连接复用
2DNS Lookup              ->  DNS 解析
3Initial connection      ->  TCP 握手
4SSL                     ->  TLS 握手
5Request sent            ->  发请求
6Waiting (TTFB)          ->  服务端处理,到第一个字节
7Content Download        ->  下载响应体

运维同事说他排查性能第一件事就是看 TTFB(Time To First Byte)高不高:TTFB 高,问题在后端或网络链路,前端代码优化到天上去也没用;TTFB 低但 Content Download 长,那是响应体太大,该考虑压缩、分页、懒加载。

他强调这张图最大的用处是"分层定位"——它把一个笼统的"这个请求慢"拆成了排队、DNS、建连、TLS、等后端、下载这么几段,慢在哪一段一目了然,你就能立刻判断该找谁:慢在 DNS 找运维查解析,慢在建连或 TLS 查网络和证书配置,慢在 Waiting 找后端查接口,慢在 Download 才回头看自己的响应体大不大。学会看这张 Timing 图,比背任何协议都实用——把锅分对地方,能省下大量瞎忙的时间。我们这次的现象是 Initial connection 偶发地长,所以矛头指向建连。

三次握手,在 Wireshark 里长这样

运维同事起了个过滤器 tcp.port == 443,让我在测试环境刷新页面。抓包窗口哗哗滚了一屏,他按时间排好序,指着最前面三行:

1No.  Source          Destination     Info
21    10.8.3.21       10.0.5.107      [SYN]  Seq=0
32    10.0.5.107      10.8.3.21       [SYN, ACK]  Seq=0 Ack=1
43    10.8.3.21       10.0.5.107      [ACK]  Seq=1 Ack=1

书上那三行抽象描述,此刻就摆在屏幕上:

1客户端 -> 服务端:SYN
2服务端 -> 客户端:SYN + ACK
3客户端 -> 服务端:ACK

简单理解:客户端说"我要建立连接";服务端说"我收到了,也准备好了";客户端再确认"我也知道你准备好了"。这样双方都确认了对方的发送和接收能力都正常。

我趁机把书里那个经典问题抛给运维同事:为什么是三次不是两次?他反问我:"要是两次就建连,一个早就超时的 SYN 在网络里兜了一圈才到,服务端咋办?"——这正是关键。两次握手的话,服务端无法确认客户端的接收能力,也防不住历史延迟的重复连接请求:服务端如果收到 SYN 就直接建连,会白白维护一个客户端根本不想要的连接。第三次的 ACK,就是客户端在说"这次连接确实是我现在想要的"。

他还让我盯着那三行里的 SeqAck 数字看,说握手不只是"打个招呼",还顺手把一件正事办了——同步双方的初始序列号。TCP 靠序列号给每个字节编号来保证有序和可靠,两端各自要有一个起始序列号(ISN),而且这个 ISN 不是从 0 开始、是随机的。

第一个 SYN 里 Seq=0 是 Wireshark 帮我们相对化显示的,真实值是个随机数。为什么要随机?还是为了防前面说的"幽灵旧连接"——如果每次都从固定值开始,旧连接的残留报文序列号很容易和新连接撞上、被误当成合法数据。第二行 SYN, ACK 里的 Ack=1,是服务端在说"你那个 Seq=0 我收到了,期待你下一个字节从 1 开始";第三行客户端的 Ack=1 同理。

三次握手表面是握手,实质是两端交换并确认了各自的起始序列号,后面所有数据的有序可靠都建立在这次交换上。这层我以前完全没注意,只当那些数字是摆设——原来那三个包不是形式,是在为整条连接的可靠传输打地基。

顺带他还指出握手包里带的选项也有讲究:SYN 包里通常会捎上 MSS(最大报文段长度)、窗口缩放因子这些参数,两端在建连这一刻就把"我一次最多能收多大的段""我的接收窗口能放大到多少"协商好。所以握手不光定序列号,还顺手把后续传输的一些基本规格谈妥了,是一次信息量不小的开场。

Wireshark 里还有个东西是书上给不了的:每个包的时间戳。运维同事选中第一个 SYN 和第三个 ACK,底部状态栏直接显示出这一次握手花掉的时间。握手是要花时间的,而且这个时间和物理距离成正比——一次握手至少一个 RTT(往返时延)。

机房内网这一趟不到一毫秒;但运维同事说,之前公司有个面向东南亚用户的活动页,用户到源站一个 RTT 两三百毫秒,光建连就先耗掉这么多,请求还没发呢,后来接了 CDN,把节点铺到离用户近的地方,首屏快了将近一秒——CDN 的本质就是缩短 RTT,让握手和传输都快起来。这也让我对"RTT 是很多网络开销的计价单位"有了体感:握手要一个 RTT、TLS 又要一两个、慢启动阶段每涨一次窗口也要一个 RTT,凡是牵扯到来回确认的地方,账都是按 RTT 记的。RTT 一大,这些开销全跟着放大,这就是为什么面向远距离用户时,减少往返次数(复用连接、会话恢复、就近接入)比压缩几 KB 数据往往更划算。

至于我们那个偶发慢的问题,运维同事在抓包里找到了实锤:慢的那几次,第一个 SYN 发出去没等到 SYN+ACK,客户端超时重传了一次 SYN,而 SYN 的重传超时是秒级的——一两秒的 Initial connection 就是这么来的。

这个"SYN 重传超时是秒级"的点特别值得记,因为它解释了一类很反常的现象:平时接口几十毫秒,偶尔一次却卡了整整一两秒,中间没有任何缓慢过渡,就是干脆利落地跳到秒级。这种"要么很快、要么正好卡一两秒"的分布,八成就是握手阶段丢了个 SYN、在等那个固定的重传超时,而不是链路整体变慢了。链路变慢通常是渐进的,而重传超时是个定值,卡出来的时间也就带着那个定值的特征。再往下查是一台中间设备偶发丢包,那是运维同事的战场了。但我总算亲眼看见了"慢"长什么样。

四次挥手,和 TIME_WAIT 的堆积

既然抓都抓了,运维同事说索性把连接关闭也看一眼。他把某条连接结束的几个包过滤出来:

1客户端 -> 服务端:FIN
2服务端 -> 客户端:ACK
3服务端 -> 客户端:FIN
4客户端 -> 服务端:ACK

为什么关闭要四次?因为 TCP 是全双工的,一方说"我不发了"(FIN),另一方可能还有数据没发完,所以不能立刻跟着关,得先 ACK 确认,等自己也发完了再单独发 FIN。比握手多一步,凑成四次。

四次挥手比握手多的那一步,其实藏着一个"半关闭"的状态。TCP 是全双工,两个方向的数据通道是独立的,一方发 FIN 只是说"我这个方向没数据要发了",但它还能继续收对方发来的数据——这段时间连接就是"半关闭"的,一半通道关了、另一半还开着。所以中间那两个包(服务端先 ACK、稍后才 FIN)不能合并,因为服务端 ACK 完之后可能手上还有没发完的数据要接着发,发完了才轮到它说"我这边也关了"。理解了半关闭,四次挥手为什么不能像握手那样把中间两步并成一步,就顺理成章了。

挥手里有个细节会实实在在影响服务端:主动关闭的一方会进入 TIME_WAIT 状态,要等两倍报文最大生存时间(2MSL,通常几十秒到两分钟)才真正释放。这个设计是为了确保最后那个 ACK 对方能收到,也让旧连接的残留报文在网络里自然消亡。

我追问了下为什么非得等 2MSL、直接关不行吗,运维同事拆成两半讲。

一半是为最后那个 ACK 兜底:万一主动方发的最后一个 ACK 在路上丢了,被动方等不到会重发它的 FIN,主动方得还在 TIME_WAIT 里待着、能接住这个重发的 FIN 再回一次 ACK;要是立刻把连接销毁了,重发的 FIN 过来就没人应答,被动方那头就一直卡在等待里。

另一半是防"串味":等满 2MSL,能保证这条连接残留在网络里的所有旧报文都彻底过期消失,这样万一马上又建了一条四元组一模一样的新连接,也不会收到上一条连接迟到的幽灵报文,把新连接的数据搞乱。一个 MSL 保证去程的旧报文消亡、来回两程正好 2MSL,这个数就是这么来的。两个理由其实指向同一个主题:TCP 为了"可靠",连关闭都要留个尾巴处理各种丢包和延迟的边角情况,这份谨慎正是它和不管这些的 UDP 最大的分野。

运维同事当场在网关机上敲了一句给我看:

1netstat -ant | awk '{print $6}' | sort | uniq -c

TIME_WAIT 的计数好几千。他说这还算健康的,之前有台高并发网关因为短连接太多,TIME_WAIT 堆到几万个,把本地端口耗尽,新连接建不出来,最后是调内核参数(tcp_tw_reuse 之类)加改用长连接才压下去。那一刻我对一句话有了体感:连接不是免费的,建和拆都有代价。

他还特意提醒别把 TIME_WAITCLOSE_WAIT 搞混,这俩堆起来是完全不同的病。TIME_WAIT 堆在主动关闭方,是"正常关闭流程的正常产物",只是短连接太多时量大,属于配置和架构问题。CLOSE_WAIT 堆在被动关闭方,往往是"代码 bug"——对方已经发了 FIN、你的程序却迟迟不调用 close 把连接关掉,连接就卡在 CLOSE_WAIT 出不去,越堆越多最后耗尽文件描述符。所以在 netstat 里看到一大片 CLOSE_WAIT,第一反应应该是查自己的代码是不是漏了关连接、或者某个下游响应处理完没释放资源,而不是去调内核参数。TIME_WAIT 多是量的问题,CLOSE_WAIT 多是 bug 的问题,这个区分他说排障时能少走很多弯路。

Keep-Alive:面板里消失的 Initial connection

如果每个请求都重新走一遍"三次握手加四次挥手",成本高得离谱,尤其 RTT 大的时候。所以 HTTP/1.1 默认开了 Keep-Alive,一条 TCP 连接发完一个请求不立刻关,留着给后续请求复用:

1Connection: keep-alive
2Keep-Alive: timeout=60, max=100

timeout 是空闲多久就关,max 是这条连接最多服务多少个请求。抓包里能看得很清楚:同一个四元组(源 IP、源端口、目标 IP、目标端口)上,一个响应结束紧接着又是一个新的 GET,中间没有任何 SYN。对应到 Network 面板,复用到已有连接的请求,Timing 里的 Initial connection 和 SSL 是空的——这就是 Keep-Alive 在帮你省钱。

这里有个双方超时不一致的坑,运维同事专门点了一下。timeout 是两端各自设的,如果服务端设的空闲超时比客户端短,可能出现这种尴尬:服务端那边觉得连接空闲太久、悄悄把它关了,客户端却以为连接还活着,拿它去发下一个请求,结果一发就撞上一个已经被关掉的连接,报 Connection reset。这类偶发的连接重置,根因往往就是两端 keep-alive 超时没对齐。所以调这个参数不能只顾自己一头,得两端一起看,一般让客户端的空闲超时略短于服务端,主动权握在自己手里更稳。

服务端到服务端那种场景还会更进一步,用"连接池"把这套复用管理起来——预先建好一批 TCP 连接放在池子里,谁要发请求就借一条、用完还回去,避免每次现建现拆。

连接池要操心的事不少:池子开多大(太小并发不够、太大占资源)、借出去的连接怎么做健康检查(别把一条已经被对端关掉的坏连接借出去)、空闲多久回收。这些其实都是"连接不免费、所以要复用、复用又带来管理成本"这条线的延伸。前端在浏览器里享的是浏览器帮你管好的连接复用,但知道底下是这么一套池化逻辑,遇到"接口偶发连接错误"这类问题时才不会只往业务代码上找。

但 HTTP/1.1 的复用有个硬伤:队头阻塞(head-of-line blocking)。同一条连接上的请求是排队的,前一个响应没回来,后面的得等着。所以浏览器对同一个域名会开多条并行连接(一般 6 条)来缓解。这也催生了两类相反的优化套路:HTTP/1.1 时代靠合并资源(雪碧图、打包 JS/CSS)、域名分片(把静态资源拆到多个域名,绕开 6 条连接的上限)来减少排队;而 HTTP/2 在一条连接上做多路复用,多个请求真正并行,队头阻塞在 HTTP 层被解决了,这时域名分片、过度合并反而是反优化。

运维同事说双十一前全站要切 HTTP/2,预发环境的 Nginx 已经开了,正在验证,等切完我再单独记一篇。眼下先记住结论:"资源要不要合并"这种问题没有标准答案,得看你跑在 HTTP/1.1 还是 HTTP/2 上,这正是理解底层带来的判断力。

Follow TCP Stream:HTTP 报文的素颜

Wireshark 有个功能叫 Follow TCP Stream,右键一条 HTTP(非加密)连接,整个请求响应的原文直接铺在眼前。我第一次看到自己天天打交道的接口请求的"素颜":

1GET /api/users HTTP/1.1
2Host: example.com
3Accept: application/json

响应:

1HTTP/1.1 200 OK
2Content-Type: application/json

请求就是请求行、请求头、请求体三段,响应就是状态行、响应头、响应体三段,Network 面板里看到的那些信息,本质上就是这几行文本。祛魅了。

看到这个"素颜"我才真正意识到,平时在 DevTools 里点开一个请求看到的那些分门别类、折叠好的 Headers、Payload、Response,只是浏览器把这几行纯文本解析、美化后的展示。底下在网线上跑的就是这么朴素的几行 ASCII——一个动词加一个路径开头、接一堆 键: 值 的头、空一行、再跟上 body。HTTP 之所以能流行这么多年、什么语言都能几十行代码实现一个简单客户端,很大程度就赢在这份"文本化的简单"上。也正因为如此,前面 HTTP/2 把它改成二进制才显得是个不小的取舍——省了解析效率,丢了这份一眼可读的直观。

状态码是前端每天打交道的东西,趁热我把自己排错用的分类记了下来:

12xx 成功      200 OK / 201 Created / 204 No Content(删除成功常返回它,没 body)
23xx 重定向    301 永久 / 302 临时 / 304 Not Modified(命中协商缓存,最爱看到的)
34xx 客户端错  400 参数错 / 401 没登录 / 403 没权限 / 404 找不到 / 429 被限流
45xx 服务端错  500 后端炸了 / 502 网关拿不到上游 / 503 服务不可用 / 504 网关超时

几个容易混的,我和后端同事专门掰扯过。401 和 403 不一样:401 是"你没证明你是谁",该去登录或刷 token;403 是"我知道你是谁,但你没这权限"。前端拦截器对这俩的处理逻辑完全不同,401 跳登录、403 提示无权限,写反了用户体验就很怪。

还有 502 和 504:502 通常是后端服务挂了或返回了非法响应,504 是后端太慢、网关等不及超时了。找后端同事报障时说清楚是哪个,他定位能快一半。304 也值得单独记一笔——它不是错误,是协商缓存命中的信号,浏览器带着 If-None-MatchIf-Modified-Since 去问"我这份还新鲜吗",服务端答 304 表示"没变,用你本地的",省掉了整个响应体的下载。排性能问题时看到一片 304 反而是好事,说明缓存在生效。

另外有个新人常踩的坑:HTTP 状态码 200 不代表业务成功。我们后端把业务错误也包在 200 响应里,body 里再带 { code: -1, msg: '余额不足' }。所以前端的错误处理得分两层,先看 HTTP 状态,再看业务 code,两层都得判。

这背后其实是两派做法的分歧:一派主张"业务错误也该用 HTTP 状态码表达",余额不足就返 4xx,让协议语义和业务语义对齐,好处是标准、监控和网关能直接按状态码统计错误率;另一派就是我们这种,HTTP 层永远 200、错误全塞进 body 的 code,好处是前后端约定简单、不用纠结每种业务错误该映射到哪个 HTTP 码。两种都能用,关键是团队内统一、前端拦截器按约定处理,最怕的是同一个后端有的接口用状态码、有的用 body code,前端两套逻辑都得防,那才是真难受。

切到 HTTPS:抓包窗口突然看不懂了

看完明文的 HTTP,运维同事把过滤器切到线上的 HTTPS 流量,再 Follow 一条——满屏乱码。他笑:"这就是 HTTPS 的意义,我这个抓包的都看不见内容,中间人更看不见。"

HTTPS 是在 HTTP 和 TCP 之间加了一层 TLS,它解决三件事:加密传输,链路上抓包看到的是密文;身份认证,通过证书确认你连的确实是这个域名的服务器,不是冒充的;防篡改,传输内容带校验,中间人改了你能发现。

握手过程简单说:TCP 三次握手建好连接后,再做 TLS 握手——服务端把证书发给客户端,客户端校验证书是不是可信 CA 签发的、域名对不对、有没有过期,通过后双方协商出一个对称密钥,之后的通信都用这个密钥加密。抓包里能看到 Client Hello、Server Hello、Certificate 这几个明文的握手帧,之后就全是 Application Data 密文了。代价是 TLS 握手又多了一到两个 RTT,这就是"HTTPS 第一次连接更贵"的来源,对应 Timing 里的 SSL 段。

这里有个我以前没细究的点:为什么用了证书里的非对称加密,最后却协商出一个对称密钥来加密数据?运维同事说这是性能和安全的合谋。非对称加密(公钥私钥那套)安全但慢,用它来传大量业务数据成本太高;对称加密快,但难点在于"双方怎么安全地拿到同一把密钥"。TLS 的巧思就是各取所长——握手阶段用非对称加密安全地协商出一把临时的对称密钥,之后的海量数据传输全用这把对称密钥来加解密。非对称加密只用在开头交换密钥这一下,真正跑数据的是对称加密,既解决了密钥分发的安全问题,又没牺牲传输性能。

校验证书那一步也不是只看一张证书那么简单,它是顺着一条"证书链"往上验的:服务器的证书由中间 CA 签发、中间 CA 又由根 CA 签发,客户端得一级级验到自己信任列表里那个根 CA 才算通过。所以有时候证书本身没过期、域名也对,却还是报不受信任,往往是服务器少配了中间证书、链断了——这类"链不完整"的坑,比证书过期更难一眼看出来。

不过这个代价可以摊薄。一是连接复用,握手只在建连时做一次,后续请求走同一条 TLS 连接;二是会话恢复(Session Resumption / Session Ticket),断开后重连可以带上之前的会话信息跳过完整握手,省掉一个 RTT;三是 HTTP/2 几乎都跑在 HTTPS 上,多路复用加一次握手,整体反而比 HTTP/1.1 多条明文连接还快。所以"HTTPS 慢"这个刻板印象,做了复用和会话恢复之后基本不成立。

会话恢复这两种方式的差别运维同事也点了一下:Session ID 是服务端记住会话状态、客户端下次带上 ID 来认领,缺点是服务端得存一堆会话、多机部署时还得考虑会话在机器间同步;Session Ticket 是把会话信息加密成一张票据交给客户端自己保管、下次原样带回来,服务端不用存,对负载均衡后面挂一排机器的架构更友好。这类"状态放服务端还是放客户端"的取舍,看多了就会发现分布式系统里到处都是这个母题——凡是后面挂了多台机器的场景,让状态别赖在某一台上,往往就能省掉一堆同步的麻烦。

实际配置里我们踩过两个坑。一是混合内容(mixed content):HTTPS 页面里引了一个 http:// 的图片或脚本,浏览器会拦截或警告,地址栏的安全锁直接没了,得把所有外链资源换成 https 或协议相对的 //。这里还有个区别要知道:混合内容分"主动"和"被动",脚本、iframe、fetch 这类能改变页面行为的主动内容,浏览器直接拦掉不给加载;图片这类被动内容一般只是警告、还会加载。所以有时候明明有 mixed content,脚本却神不知鬼不觉地不执行了,排查半天才发现是被浏览器拦了。

二是证书过期没人管,某天凌晨证书到期,全站 NET::ERR_CERT_DATE_INVALID,用户一片白屏——后来上了证书到期监控告警,再没出过。这种事故最坑的地方在于它跟代码一点关系没有、纯粹是运维日程的疏忽,但砸下来是全站白屏,影响面比大多数代码 bug 都大,属于那种"平时完全想不起、爆发就是致命"的隐患,值得单独拉个监控盯着。

涉及登录、支付、后台系统,HTTPS 是底线,Chrome 现在已经把纯 HTTP 页面标"不安全"了,绝对不要在 HTTP 页面里传密码、token 这类敏感信息——今天这个下午让我彻底信了这句话,明文链路上真的谁都能抓到。

抓包之外:前端能做的事

回工位的路上我一直在想,链路的大头在运维同事和后端同事手里,前端不能控制所有网络细节,但顺着今天看到的每一段时间,前端能省钱的地方其实不少。

这些手段其实可以按今天抓包看到的链路顺序来归类,一段一段地省。

DNS、建连、TLS 这几段的开销,前端能做的是"提前做"和"少做几次"两招。提前做,是用资源提示把解析和建连挪到浏览器空闲时(下面就是);少做几次,靠的是连接复用和会话恢复这些底层机制,前端别做出"频繁短连接"这种反复建连拆连的行为,把好不容易建起来的连接用足——这也是前面 Keep-Alive 那节讲的价值在前端侧的落地。

减少不必要请求:能合并的接口合并,能懒加载的延后,首屏不需要的别在加载时就拉。静态资源用好缓存:强缓存(Cache-Control: max-age)让浏览器压根不发请求,协商缓存(ETag / Last-Modified)命中时只回个 304,省掉响应体下载。合理用 CDN,本质是缩短 RTT。具体到 DNS 这一段:

1<link rel="dns-prefetch" href="//static.example.com">
2<link rel="preconnect" href="https://api.example.com">

dns-prefetch 只提前做 DNS,preconnect 连 TCP 和 TLS 握手都提前做好,正是今天在 Wireshark 里看到的那几个 RTT,被挪到了浏览器空闲的时候。接口层面,没有依赖关系的请求别 await a(); await b(); 一个个等,Promise.all([a(), b()]) 并发,省掉串行的等待。资源体积上,开 gzip 或 br 压缩、图片选对格式(WebP 今年已经能在大部分场景用了)、按需加载。最后是超时和错误统一处理:fetch 默认不带超时,得自己用 AbortControllerPromise.race 包一层,否则后端卡死前端就一直转圈。

错误处理这块我在请求层里做了分类,把失败分成几类区别对待:

1async function request(url, options) {
2  const controller = new AbortController()
3  const timer = setTimeout(() => controller.abort(), 10000) // 10s 超时
4
5  try {
6    const res = await fetch(url, { ...options, signal: controller.signal })
7
8    if (!res.ok) {
9      // HTTP 错误:4xx/5xx,按状态码分流
10      if (res.status === 401) redirectToLogin()
11      throw new HttpError(res.status)
12    }
13
14    const data = await res.json()
15    if (data.code !== 0) {
16      // 业务错误:HTTP 200 但业务失败
17      throw new BizError(data.code, data.msg)
18    }
19    return data.result
20  } catch (err) {
21    if (err.name === 'AbortError') {
22      // 超时:和"后端返回错误"是两回事,提示用户"网络较慢,请重试"
23      throw new TimeoutError()
24    }
25    throw err // 网络层错误(断网、DNS 失败等)走到这
26  } finally {
27    clearTimeout(timer)
28  }
29}

把网络错误、超时、HTTP 错误、业务错误分清楚,前端的提示和重试策略才做得对:超时可以自动重试,业务错误(余额不足)重试没意义、该直接提示,401 该跳登录而不是弹个红框。

顺带记一个运维同事教的轻量工具,不用开 Wireshark 也能量出各段耗时:

1curl -o /dev/null -s -w 'dns: %{time_namelookup}s | connect: %{time_connect}s | tls: %{time_appconnect}s | ttfb: %{time_starttransfer}s | total: %{time_total}s\n' https://api.example.com/health

一行命令,DNS、建连、TLS、TTFB 各花多少一目了然,写进备战巡检脚本里每天跑一遍,异常一眼就能看出来。

收工

那个下午结束时,偶发慢的问题有了明确去向:中间设备偶发丢包导致 SYN 重传,运维同事去处理链路。而我带走的东西比结论多得多。

TCP 负责可靠连接,HTTP 负责应用层的请求响应,HTTPS 在中间塞了一层 TLS 负责安全传输。这几层叠起来,就是浏览器地址栏敲下回车到页面出来之间发生的事。

以前这套东西对我是背诵材料,现在它是 Wireshark 窗口里一行行带时间戳的包:SYN 等不到回应会重传,握手要花掉实打实的 RTT,Keep-Alive 让 Initial connection 从面板里消失,TLS 把 Follow Stream 变成乱码。这些细节单看零碎,但顺着"一个请求从建连到关闭"这条时间轴串起来,它们各自卡在哪个环节、影响哪一段耗时,就都对上号了。分层不是为了背名词,是为了出问题时知道该往哪一层看。

对我自己来说,这个下午最大的收获是:再遇到"页面偶尔很慢""接口时好时坏"这类模糊问题,我不再瞎猜,而是能顺着 DNS、握手、TTFB、下载这条链路一段段往下查,把问题精准定位到该负责的那一层。这种排错的笃定感,才是补这块基础最直接的回报。双十一之前,我打算再缠着运维同事抓一次 HTTP/2 的包——听说那里面连报文都是二进制的帧了。