Chrome fetch bug

461 阅读17分钟

项目中有一个跟随用户的附件预览列表页,在这个页面上可以通过弹窗预览与该用户相关的各种文件,包含图片、pdf、音视频等。

但最近收到反馈,在该页面预览附件时可能导致页面卡死,反馈的现象是:

随机在几个附件之间来回切换预览,尝试多次之后会不定期出现页面卡死的情况。

听完这个描述,我脑子一惊!第一反应是:该不会有什么内存泄漏吧?!

alt text

但业务方追得紧,这问题得赶紧修复,于是我就:

  1. 联系相关同事现场查看情况
  2. 尝试本地复现该问题
  3. 分析问题原因
  4. 寻找解决方案

接下来请各位看官听我娓娓道来,如果心急的朋友可以直接跳过中间过程,享受总结吧 ...

联系相关同事现场查看情况

点击多次预览,当问题出现的时候,从业务方的角度来说就是预览弹窗白屏,无实际内容,案发现场如下:

预览弹窗白屏

但页面没有完全卡死,弹窗可以正常关闭,页面上按钮也都可以点击,且有点击动画,但大多数按钮点击后没有任何反应,整个页面感觉像是静止了,定格在那里!

死了,但,又没死透!(活见鬼了)

泪奔

从技术的角度来分析,我首先想到的是请求量达到并发上限,导致后续请求被阻塞,但从控制台上来看,到目前为止,除了最后一个请求是 pending 状态,其它请求都是成功的:

这么看来不太像是达到并发上限,因为自始至终我都一直紧盯 network 面板,确定只有这最后一个请求是 pending 状态,其它请求确定一定以及肯定都是成功的,就像上面截图那样。

但为了成为一名合格且有素养的开发,我还是下意识地看了一下 initiator 中的调用栈,并扒拉扒拉了一下发起请求那里的源码看看:

async function resolveMime(source: string): Promise<SourceType> {
  const { headers } = await fetch(source); //  <- 这行代码发起的请求
  const contentType = headers.get("content-type");
  const disposition = headers.get("content-disposition");
  // ... 根据 HTTP 头对文件类型进行解析并分类的一些无伤大雅的业务逻辑,此处省略
}

单从这几行代码来看,我并没有发现任何问题。

随着我继续操作页面,发现从该 pending 请求之后的所有请求都被阻塞了!

pending-after-download

页面也没办法 F5 刷新!

但通过仔细对比发现,如果我一开始就预览本页面其它图片,则不会出现这个问题!

如果是代码有问题吧,其它图片又能正常预览(相同类型的文件预览走的代码逻辑完全相同),如果是达到并发上限吧,出现问题时又只有一个请求在 pending,好像是那么一回事,又不太像那么一回事!

咋滴?难道这个图片还能有什么问题?!

一脸无奈

是不是图片在作妖啊?!

说来可笑,按道理就不应该怀疑这种情况,不过,当时我的确是这么想的,我想了一堆莫须有的理由来给这张图片定罪,抑或是为了说服自己(是不是服务器上图片静态资源的问题?是不是该文件所属存储桶的问题?是不是服务器网络问题引起图片资源加载异常从而触发了某种灵异性 bug 等)

基于上面的考量,于是我打算将这张有罪的图片下载到本地,然后在本地启动一个服务,看看通过 fetch 获取这个图片会不会出现相同的问题。

使用 nodejs 写了一个简单的静态文件服务器:

const fs = require("fs");
const url = require("url");
const http = require("http");
​
const server = http.createServer((req, res) => {
  const pathname = req.url === "/" ? "/index.html" : req.url;
​
  if (fs.existsSync(__dirname + pathname)) {
    res.writeHead(200);
    fs.createReadStream(__dirname + pathname).pipe(res);
    return;
  }
​
  res.writeHead(404);
  res.end("404 Not Found");
});
​
server.listen(8000, () =>
  console.log("\nserver started: http://localhost:8000")
);

然后把相同的图片文件(本地命名为 big-pic.jpg)丢进去,像前面源码中发起请求那样的在浏览器控制台通过 fetch 获取图片文件,结果依旧是:没有任何问题!(而且由于是在本地,显得格外顺畅!)

fetch本地图片

同样是用浏览器 fetch 方法,我本地搭建的服务器就没问题,线上环境就出现了卡死。这不就更加印证了我上面的猜想:恐怕是线上服务器相关的问题导致的?

似乎到这里就已经有个大概结论了。

我把这个情况反馈给了后端同事,让他帮忙排查一下服务端,然后继续看看能不能再发现一些更加有用的线索。

初见端倪:大文件多次预览后必定会导致卡死

但随着我再仔细多次尝试观察之后,得出一个看似有点帮助的结论:如果每次都只点击出现问题的图片对应的预览功能,则在第 6 次会出现卡死。

这个 6 很关键,我第一反应又回到了浏览器对相同域名并发请求限制为 6 次的问题上。

另外,其它没有问题的图片 size 相对较小,而这个出现问题的图片大小有 2.9M,会不会和文件大小还有关系?

于是再在线上找了一些比较大一点的图片进行预览,发现也有相同的问题!看来好像还真和图片大小有点关系呢!

但还有一点问题不能说服我,那就是前面请求的 network status 都已经从 pending 变成了 200 状态码。再大的文件,200 不都表示请求成功了吗?

会不会从请求时间上面看出点端倪呢?几 M 的文件肯定比几十 KB 的文件慢不少。于是我打开了 network 面板中的 time 列。

这真是不看不知道,一看吓一跳!

浏览器 network time pending

打开 network time 才发现,里面的 pending 是真的多啊!

请求time列存在pending

虽然请求中 status 已经是 200,但 time 仍然在 pending ...

感觉像是接口响应的数据没有发送完整,或者是服务端出于某些原因没有关闭 TCP 连接。

但查看 HTTP Header 部分,发现 connectionkeep-alive,也就是说当前 TCP 连接本身就可以复用,并不一定会在当前请求结束后关闭。

Connection keep alive

单独开一个页面发送 fetch 请求,依旧阻塞

虽然目前这些情况看起来都和项目代码没有任何关系,但是为了彻底排除可能因为项目代码带来的影响而干扰判断,我还是新开了一个指向静态资源的 tab 页(因为静态资源如 xxx.css 只会展示文件内容,并不会在控制台执行任何项目代码,但又能保证域名相同,不至于跨域),然后打开控制台,通过直接调用 fetch 对出现问题的图片资源进行请求。

依然有问题!—— network 面板中 status 为 200,time 为 pending。(和前面差不多,这里就不放图占位置了)

既然都与项目页面功能代码无关,浏览器 fetch 方法又已经非常普及了,大家都在用,一直都挺稳,它应该也不会有什么问题吧。

此时我的心里更加稳了 —— 多半是后端或静态文件服务器的问题。

虽然我心里稳了,但案发现场在前端,直接给后端同事讲解上面的猜测也不一定说得清楚,还不如花点时间找找更多证据。

抓个包看看

对于前端来说,浏览器 fetch 内部实现是个黑盒,发送请求的底层细节就更黑了 ...

能从浏览器获取到的信息基本上都已经展示在 network 面板中了。

如果继续耗在浏览器面板上,那就没啥意义了。

不如抓个包看看吧。

得出结论 —— 没有收到 HTTP 回包

还真是不抓不知道,一抓一个准儿!

正常情况下,一个 HTTP 请求至少应该包含请求和响应,就像下面这样:

request-and-respons

其中,7190 包和 7210 包成双成对,分别对应客户端发出的请求和服务端作出的回应。

但出现问题的那个包,只有从客户端发出的请求,而迟迟收不到与之对应的响应包 ...

no-response

这更加印证了我前面的猜想 —— 服务端出了问题!

我迫不及待的把这一现象拿给后端同事看,并请求他们协助排查是否是服务端相关的配置引起的。

后端同事也抓了几个包来分析 —— 嘿,还真是,确实没收到回包!

于是乎,拉了相关的运维大佬、SRE 来讨论。

在这一铁证面前,他们几乎哑口无言 ...

但他们的意见保持高度一致 —— 线上环境链路较长,还需要多个其它部门的配合,且需要走流程申请,一时半会儿肯定搞不定!

(这似乎也反映出一个公司流程上的问题,遇到紧急情况优先卡脖子的不是技术,而是流程 ...)

得到这一结论,我紧张的心也立马舒缓了不少,那就先等他们走流程吧!

灵光乍现:使用 XMLHttpRequest 试试?

同样是发送请求,既然 fetch 有问题,试试更加老牌儿的 XHR 呢,它应该也会有这个问题吧?

于是我尝试在控制台通过 XMLHttpRequest 来发起请求:

fetch-and-xhr

从这张控制台截图可以看到,相同的 URL,上面是通过 fetch 发送的请求,下面是通过 XMLHttpRequest 来发起的,通过 fetch 发起的请求明显被挂起了,而通过 XMLHttpRequest 发起的请求,即便是在 fetch 之后才发起,也已经完成了。

XMLHttpRequest 没问题,而 fetch 就会被挂起?

悬着的心刚放下,这又立马抬到了嗓子眼儿 ...

我对比了两者的 HTTP header,发现并没有什么不同。

alt text alt text

既然如此,那较大概率问题还是出在前端,而问题的根本,就在于这个 fetch ...

尝试本地复现该问题

事已至此,不得不尽可能在本地复现这个问题。

难道和头部有关?

之前只是在本地简单写了个静态资源服务器,并没有处理很多细节,现在需要复刻每个"环境变量",首当其冲的就是对齐每一个 HTTP Header。

再接再厉,尽量还原案发现场

尽管每一个 Header 项看起来都是清纯可爱,人畜无害的样子!但我还是把它们挨个添加到了我的本地静态服务器中 ...

情况非常乐观,当我加完和服务器上一模一样的 Header 之后,问题立马浮现 ...

local-pending

本地也卡住啦!

cry-with-joy

喜极而泣!

分析问题原因

既然本地能复现,那就好办多了。

既然问题是由于添加了和线上一样的 HTTP Header 导致的,那我就挨着删除每一项 Header,当我删到 Cache-Control: no-store 这一项的时候,问题竟然奇迹般的消失了!!!

换成 Cache-Control: no-cache 或者其它的,也不会有问题!

那最终问题的关键就来到了 Cache-Control: no-store 这里。

由于问题是 Cache-Control: no-storefetch 打组合拳产生的,缺一不可,我尝试将它们俩关联起来进行解释,但思考良久,查阅了不少资料,依然无法将二者成功的关联起来。

到浏览器这一层能获取到的信息真的非常有限,目前看能不能再从抓包里面发现一些端倪。

继续抓包,TCP Win=0

单从 HTTP 协议上看,表现还是和之前一样,只有入没有出 —— 即只有浏览器发出的包,没有服务端响应的包。

但随着跟踪 TCP 流,发现客户端出现了 TCP ZeroWindow

alt text

客户端的接收窗口满了!

然后就一直在重复零窗口探测:

  • 服务端:喂,客户端,你那边窗口好了没
  • 客户端:没呢没呢,还是 0
  • 服务端:喂,客户端,你那边窗口好了没
  • 客户端:没呢没呢,还是 0
  • alt text
  • 服务端:喂,客户端,你那边窗口好了没
  • 客户端:没呢没呢,还是 0
  • ...

怪不得,Chrome network 里面接口一直在 pending,而抓包也迟迟没有看到 HTTP 回应。

根据经验,客户端的窗口满了,无非就是消费数据的速度太慢,导致数据堆积,把窗口硬生生堆满了。

但只要有数据消费,窗口就不应该一直是 0

所以从目前的情况看来,肯定是数据没有消费导致的。

Response.blob() 解套

既然没有数据消费,那就回到代码,尝试消费一下数据。

fetch 返回的是 Response 对象,由于它的 body 是一个 ReadableStream,因此可以直接消费它的 body,或者调用对应的方法消费数据,如 blobtextjsonarrayBufferbytesformData

当我调用 blob 方法的那一刻,神奇的事情发生了!

network time 从 pending 瞬间变成了网络请求耗时,即请求完成。

after-call-blob

并且如果此时正在抓包,也能看到当对数据进行消费之后,客户端立即通知服务端窗口更新(上图蓝色框),从而可以继续发送后面的数据(上图绿色框)。

data-consumed

这么一来,所有问题就解释的通了。稍后总结一下。

解决方案

结合我们前面的源码,我们只需要获取 HTTP 响应头中的特定数据,并不关心响应的其它数据。要如何快速解决业务方反馈的这个 BUG 呢?

其实在前面排查问题的过程中,已经出现了两种解决方案:

  • 使用 XMLHttpRequest:它不会出现因为大文件导致请求挂起的情况
  • 消费 Response 中的数据,如在代码中调用 .blob().json().text() 等:即消费数据,腾出更多空间接收后续的数据

这两种都不太优雅,要么是使用过时的 API,要么是多一行代码多做一步无用功,还有没有更好的方案呢?

更好的方案

别忘了,我们还有一种方式,它最符合我们的需求,那就是只发送 HEAD 请求:

fetch(url, { method: "HEAD" });

这样做,既避免了让后端发送不必要的数据浪费资源,又成功获得我们想要的 Header 信息,一举两得。

总结

排查问题的整个过程离奇又曲折,但最终还是成功击毙了这个线上 BUG!

下面来总结一下。

问题原因

首先看看出现这个彩蛋的前提条件。

根据前面的排查过程,总结下来,需要同时满足以下条件才会触发这个彩蛋(BUG):

  • Chrome 浏览器(其他浏览器如 Safari、Firefox 不会出现该问题)
  • 使用 fetch API
  • 后端返回的响应头中 Cache-Control 包含 no-store
  • 请求的资源比较大(例如最初出现问题的文件大概 2.9MB)

也就是说上面的条件缺一不可!Cache-Control 不包含 no-store不行,资源 size 太小不够塞牙缝也不行(例如我尝试过 20 KB左右的图片不会导致这个问题),不使用 fetch API 也不行(我尝试过 fetchXMLHttpRequest 以及直接用 img 标签加载图片),不是 Chrome (或者基于 Chromium 的其它浏览器,如 Edge)也不行。

理解起来也很简单,下面是我个人的看法:

在 Chrome 浏览器中,fetch 得到的 ReadableStream 自身的缓冲区也被当作缓存中间件,如果后端返回的响应头中 Cache-Control 含有 no-store,则会使得 fetch 无法缓存超出自身缓冲区的内容。当自身缓冲区满了之后,就无法继续读取 TCP 缓冲区中的内容,从而导致 TCP 窗口一直为 0,服务端一直无法向客户端发送更多数据,直到消费该笔数据来腾出缓冲区以便接收更多数据。

而其它浏览器并未将这个缓存也看作是 Cache-Control: no-store 控制的中间缓存 ... (个人愚见,希望有大佬能够一针见血地指出问题真正所在)

  这真是一个集齐了天时地利人和的 BUG 啊!

更多思考

fetch 未抛出异常,是否就表示数据获取成功?

fetch 在获取到 HTTP Header 后就会返回,因此它只能表示 HTTP 连接成功并且获取到 Header,并不能表示数据获取成功。

fetch resolve 之后再读取,如果网络错误或者服务端关闭连接会怎样?

如果是使用 Chrome 浏览器或者基于 Chromium 内核的浏览器(例如新版的 Edge),在 fetch 返回 Promise resolve 之后得到 Response 对象,如果此时再出现网络错误或者服务端关闭连接,当调用对应的方法读取数据时仍然会报错,例如 blob() 方法返回的 Promise 依旧会 Reject。

image-20241011132700585

但其它浏览器已经将数据全部从服务端读取完成并缓存在本地,因此不会出现,例如相同的操作,FireFox 并不会出现该问题:

image-20241011133014092

timeout 的重要性

由于直接使用的 fetch API,并没有处理超时的情况,因此出现了这些问题。为了编写出更加健壮的代码,添加接口超时的处理必不可少。

写在最后

fetch 大文件时,如果响应头包含 no-store,则 Chrome 中的表现更像是一个 服务端 -> 客户端 的管道流,而 Safari 和 Firefox 则表现得更加透明,像是本地有一个无限大的蓄水池,先把数据放在本地缓存,等待数据被消费。

虽然标题为 Chrome fetch bug,但我个人觉得两种方式都没有问题,设计理念不同。

在某些情况下,Chrome 的这种做法能够节省带宽和本地资源,用多少取多少,这也是管道的好处,尤其是在一些本身存储和带宽都受限的小型设备上更有优势。而 Safari 和 FireFox 的这种做法,能够做到尽快获取数据,先拿到手再说,毕竟夜长梦多,一直拖着的话指不定啥时候网络就出了问题,但拿到数据后如果不消费,会占用太多本地存储资源,看如何取舍了。

但说到底这都是一些极端情况下的特例,大多数情况下我们还是会真正的消费数据。如果只需要 HTTP Header,那就单独发 HEAD 请求就好了。

(感谢各位老板看到这里,为了演示这个问题的各种情况我单独写了个 demo:https://github.com/Cinux-Chosan/demo-chrome-fetch-bug,感兴趣的老板可以看看)

打完,收工!