Chrome 端到端网络排查战记
又拖更了很久!最近要打的游戏有点多……
在一个多月以前开始倒霉的我加入了我们的性能排查专项,主要解决我们平台站点网络反应慢的问题。(再不更新就要忘光了)。
为了以防各位难以理解文章的脑回路,首先先来简单描述一下我们遇到的场景:
- 内部平台,因此没有办法使用全球 CDN 像 C 端那样加速
- 全球化 team,使用者遍布全球各地
- 数据合规,因此在美国数据只能在美国,欧洲数据只能在欧洲
也因此处理内部网络问题更加困难,如果说在 C 端可以用「部分用户网络问题」来搪塞的话,内部系统一个老板 Case 就要一查到底了。
首先作为服务端我们先内部自查了一波,排除了服务本身的问题,剩下的可能性还有 Nginx 造成的问题、端到端网络问题、和前端的不当使用几个点可能会导致问题。
首先我们来看一个极端的 Case:

Chrome 中有针对不同的阶段进行解释说明:
- Queueing. The browser queues requests before connection start and when:
- There are higher priority requests. Request priority is determined by factors such as the type of a resource, as well as its location within the document. For more information, read the resource priority section of the
fetchpriorityguide. - There are already six TCP connections open for this origin, which is the limit. (Applies to HTTP/1.0 and HTTP/1.1 only.)
- The browser is briefly allocating space in the disk cache.
- There are higher priority requests. Request priority is determined by factors such as the type of a resource, as well as its location within the document. For more information, read the resource priority section of the
- Stalled. The request could be stalled after connection start for any of the reasons described in Queueing.
- DNS Lookup. The browser is resolving the request's IP address.
- Initial connection. The browser is establishing a connection, including TCP handshakes or retries and negotiating an SSL.
- Proxy negotiation. The browser is negotiating the request with a proxy server.
- Request sent. The request is being sent.
- ServiceWorker Preparation. The browser is starting up the service worker.
- Request to ServiceWorker. The request is being sent to the service worker.
- Waiting (TTFB). The browser is waiting for the first byte of a response. TTFB stands for Time To First Byte. This timing includes 1 round trip of latency and the time the server took to prepare the response.
- Content Download. The browser is receiving the response, either directly from the network or from a service worker. This value is the total amount of time spent reading the response body. Larger than expected values could indicate a slow network, or that the browser is busy performing other work which delays the response from being read.
因此对于 Waiting For Server Response 来说,最简单的可能性当然还是 Server 的问题,但是我们打了 Server 的点,非常短的耗时。
然后回到我们的背景中,我们的访问位置是中国大陆,而我们的服务(页面)都是部署在美国的,中美跨洋算 300ms 一个 RTT,服务内部耗时 4ms(特地挑了一个没有业务逻辑的接口),那么剩下的时间在哪里呢。
结合之前的可能性,首先得先排除 Nginx 带来的影响,因为我们是双七层架构,链路本身就比较复杂,不过后续我们姑且排除了这个的可能性。
剩下的就是排查这一现象可能的原因了,当然,首先我们有个思考方向,那就是,如果差值在 300ms,那么有没有可能是因为多了一次 RTT 呢?
为此我们要用到一个祖传工具:chrome://net-export/,在 2017 年的时候,我们就有文章对此做过介绍:https://www.codesky.me/archives/something-about-chrome-cache-lock.wind
它的优点是,你可以得到一些 Chrome 网络通信过程中的细节,去协助你排查和理解对应请求到底发生了什么。
这是我们抓包得到的结果:

其中:
t=表示事件发生的时间,st=表示相对于请求开始的时间偏移量(以毫秒为单位),dt=表示该事件持续的时间(以毫秒为单位)。HTTP2_STREAM_UPDATE_RECV_WINDOW:更新接收窗口
与之相对的还有一个事件,让我们来看下面一个 Case:

这里有一个和 RECV_WINDOW 相对的事件:HTTP2_STREAM_UPDATE_SEND_WINDOW
HTTP/2 使用流控制来避免发送方发送超过接收方处理能力的流量。每个流和整个连接都有一个发送窗口和接收窗口:
- 发送窗口 (Send Window): 发送方允许发送的数据量。每发送一个字节,发送窗口就会减小。
- 接收窗口 (Receive Window): 接收方愿意接收的数据量。
关于两者的差别我们可以看本表格:
| 特性 | HTTP2_STREAM_UPDATE_SEND_WINDOW | HTTP2_STREAM_UPDATE_RECV_WINDOW |
|---|---|---|
| 方向 | 本地发送能力的变化 | 本地接收能力的变化 |
| Negative Delta | 本地发送数据 | 远端发送数据,本地接收 |
| Positive Delta | 远端增加本地发送窗口 | 本地增加远端发送窗口 (本地接收窗口增加) |
| 目的 | 变小:控制本地发送速率,避免远端过载 变大:本地发送的更快 |
变小:控制远端发送速率,避免本地过载 变大:本地下载的更快 |
对于上一张截图来说,窗口调整带来的正好是一次 RTT,其实是符合预期的。
也就是说,理论情况下,我们可以简化成:
Client Server
| |
|------- HEADERS (GET) ----------->| (Stream 1)
| |
|<------ HEADERS (200) ------------| (Stream 1)
|<------ DATA (64KB) --------------| (Stream 1, 耗尽初始窗口)
| |
|------- WINDOW_UPDATE (64KB) ---->| (Stream 1)
| |
|<------ DATA (剩余数据) -----------| (Stream 1)
| |
在跨洋链路中,窗口调整耗时是不可避免却无法被忽视的。
那么下一个问题,这么小的接口,为什么也会涉及到窗口调整呢?因为 HTTP2 多路复用,本质上复用了同一个链接,那么即使你自己是一个小接口,但是由于同一个 TCP 连接内的其他链接耗尽了资源,还是会有相同的问题。
常见的更新窗口时机有:
- 当 已发送未确认的数据量 ≥ 当前窗口剩余空间 时,发送方会暂停传输,等待接收方的
WINDOW_UPDATE。- 极端情况下,服务端返回 32 KB 数据,客户端均未确认
- 接收方通常在 消费完部分数据后(如窗口的 50%~80%) 主动发送
WINDOW_UPDATE以避免阻塞。- 服务端返回了 62 KB 数据,消费端消费确认了 32 KB,也会主动去更新 Window.
- 服务端主动进行窗口管理:服务器在发送响应之前,可能会主动将客户端的接收窗口开到最大。
- 最大化吞吐量: 确保客户端在接收响应体时,不会因为窗口限制而导致数据传输中断或减速。服务器假定客户端有能力处理大量数据,并希望一次性清空流控限制。
- 减少
WINDOW_UPDATE帧的往返: 如果服务器知道即将发送大量数据,提前将窗口开大可以减少客户端后续发送WINDOW_UPDATE帧的次数,从而减少了这些控制帧的网络往返延迟
- 由于 HTTP2 会进行多路复用会使得多个请求共享窗口,可能会因为其他请求消耗窗口导致窗口调整大小。
此外,HTTP2 虽然进行了多路复用,但这也并不意味着可以同时发起无限个请求,一个连接中分配的流是有限个,在网络分析工具的 HTTP2 面板中,我们可以看到最大流数以及一些其他的信息:

另外,下一个问题是,我们发现 HTTP2 中 Content Download 的时间有时候非常短,短到你会觉得同机房调用也没有这么快。对此我们可以看最开始的截图中URL_REQUEST_DELEGATE_RESPONSE_STARTED dt=0,URL_REQUEST_DELEGATE_RESPONSE_STARTED 意味着 URL 请求的代理(delegate)开始处理接收到的 HTTP 响应。换成人话就是「响应头已经收到了,数据要来了,你准备一下接收和处理吧!」
如果是串行传输的,那么很明显不可能有这种效果,这也是得益于多路复用,因此不必等待,响应体的数据可能已经在响应头发送的过程中或紧随其后就开始传输了。
在 Chromium 源代码中有详细记录了每个打点的事件和对应的位置,如果有需要欢迎大家翻找:https://source.chromium.org/chromium/chromium/src/+/main:net/url_request/url_request.cc?q=URL_REQUEST_DELEGATE_RESPONSE_STARTED

排查了第一轮过后,我们发现在加载时还有不可名状的解释空间,后来发现是因为 Service worker 异步获取 HTML 带来的问题:
- HTML 本身比较大,下载耗时较久
- Service worker 异步下载 HTML 调度优先级为 Highest,影响其他资源下载(顺便我试验了一下,普通的 fetch HTML 其实优先级是 high)
导致很小的接口也需要很长时间才能下载完成。
首先我们上面提到 HTTP2 中同一个连接最多可以有 N 条流,而这 N 条流也不是同一优先级的,依旧要服从优先级算法。
在 Netlog Viewer 中,我们也能寻找到 Priority 分配的影子,比如:
t=70537 [st= 0] +REQUEST_ALIVE [dt=704]
--> priority = "MEDIUM"
--> traffic_annotation = 101845102
Chrome 会根据不同的资源分配不同的权重,不同权重间拿到的带宽资源是不等的,网络质量优、包体积普遍较小的情况下这一现象可能并不明显,而包体大、网络差的情况就变得很明显。
对此,让我们来看下 Chromium 是怎么处理的:
1enum ResourceLoadPriority {
2 // The unresolved priority is here for the convenience of the clients. It
3 // should not be passed to the ResourceLoadScheduler.
4 ResourceLoadPriorityUnresolved = -1,
5 ResourceLoadPriorityVeryLow = 0,
6 ResourceLoadPriorityLow,
7 ResourceLoadPriorityMedium,
8 ResourceLoadPriorityHigh,
9 ResourceLoadPriorityVeryHigh,
10 ResourceLoadPriorityLowest = ResourceLoadPriorityVeryLow,
11 ResourceLoadPriorityHighest = ResourceLoadPriorityVeryHigh,
12};
13
首先先定义了优先级,其次在根据类型分配优先级:
1static ResourceLoadPriority typeToPriority(Resource::Type type)
2{
3 switch (type) {
4 case Resource::MainResource:
5 return ResourceLoadPriorityVeryHigh;
6 case Resource::XSLStyleSheet:
7 ASSERT(RuntimeEnabledFeatures::xsltEnabled());
8 case Resource::CSSStyleSheet:
9 return ResourceLoadPriorityHigh;
10 case Resource::Raw:
11 case Resource::Script:
12 case Resource::Font:
13 case Resource::ImportResource:
14 return ResourceLoadPriorityMedium;
15 case Resource::LinkSubresource:
16 case Resource::TextTrack:
17 case Resource::Media:
18 case Resource::SVGDocument:
19 return ResourceLoadPriorityLow;
20 case Resource::Image:
21 case Resource::LinkPrefetch:
22 case Resource::LinkPreload:
23 return ResourceLoadPriorityVeryLow;
24 }
25 ASSERT_NOT_REACHED();
26 return ResourceLoadPriorityUnresolved;
27}
28
还有一些耳熟能详的八股调度策略:
1// If enabled, drop the priority of all resources in a subframe.
2if (frame()->settings()->lowPriorityIframes() && !frame()->isMainFrame())
3 return ResourceLoadPriorityVeryLow;
4// Async/Defer scripts.
5if (type == Resource::Script && FetchRequest::LazyLoad == request.defer())
6 return frame()->settings()->fetchIncreaseAsyncScriptPriority() ? ResourceLoadPriorityMedium : ResourceLoadPriorityLow;
7
现在,和前端的八股文就高度一致了,只是以后人家问你优先级的时候,你甚至可以告诉他怎么算的
1spdy::SpdyPriority ConvertRequestPriorityToSpdyPriority(
2 const RequestPriority priority) {
3 DCHECK_GE(priority, MINIMUM_PRIORITY);
4 DCHECK_LE(priority, MAXIMUM_PRIORITY);
5 return static_cast<spdy::SpdyPriority>(MAXIMUM_PRIORITY - priority +
6 spdy::kV3HighestPriority);
7}
8
9int Spdy3PriorityToHttp2Weight(SpdyPriority priority) {
10 priority = ClampSpdy3Priority(priority);
11 const float kSteps = 255.9f / 7.f;
12 return static_cast<int>(kSteps * (7.f - priority)) + 1;
13}
14
将 HTTP/2 的最大权重值 256 除以 7,并乘以 SpdyPriority 作为系数。
最终我们得到的优先级调度结论为:

当然,还有一些原因也可能会导致问题,我们都知道如果在一个 HTTP/2 连接中发生丢包,整个 TCP 连接上的所有流都会受到影响,直到丢失的包被重传并按顺序到达。这可能会抵消多路复用带来的好处,尤其是在高丢包率的网络环境下(例如,不稳定的移动网络或远距离连接)。跨洋链路稳定性差也是一个很重要的问题,尤其是国内的出口网络。
以及我们早在 2017 年就写过的 Chrome 缓存锁导致 Stall 的问题,这些都是大家可能不注意就会触发的问题。
此外,在 300ms RTT 下,我们有必要注意跨域请求带来的影响。
本文只从原理的角度做一些排查方法和排查思路的总结,剩下的优化思路其实还是各位熟悉的八股文时间,能优化优化,不能优化拉倒,毕竟很大困难是由跨洋链路带来的物理耗时,我们只能避免自己程序所带来的问题。
评论 (0)