一次浏览器崩溃问题的解决过程

2,719 阅读11分钟

背景

我们团队一直在开发网盘类web应用,其中一个核心功能就是文件上传功能。

为了进一步提升其性能,近期对其进行了优化并且加强了测试。

崩溃的浏览器

某日,测试同学在一台用于测试的windows镜像的ECS机器上打开浏览器访问网站进行测试时,发现总是过几秒钟浏览器就自动退出,而访问其它网站不存在这个问题,之前在这台机器上访问网站进行测试时没有出现过这种现象。

带着矛盾的分析

由于出现问题的机器安装的是 Windows 11 操作系统,之前测试的机器都是 Windows 10 和 Mac,所以当时我们怀疑和操作系统有关。同时测试同学又提供了一个新的线索,就是通过查看任务管理器,发现浏览器在访问我们网站时出现了内存不断增长的情况,直到涨到4GB左右的时候浏览器直接闪退。

一般而言,由于浏览器的各个标签页都是独立的进程,如果某个标签访问的网站有问题,比如死循环导致的 CPU 占用率100%,或者内存超出,浏览器都会直接停止该标签对应的进程并提示相关错误。而这个问题却绕过了浏览器的控制,直接让整个浏览器崩溃,从这个层面上似乎可以得出是浏览器出现了问题。但另一个证据似乎又推翻了这个推论,那就是浏览器访问别的网站正常,不会崩溃,只有访问当前网站会引发崩溃。

当时排查起来没头绪,试着用隐身模式访问了下,发现并没有崩溃。于是可以推断是状态数据导致,也就是缓存方面的问题。把浏览器所有缓存清空后浏览器不再崩溃。

问题解决了?

第二天,测试同学反馈,在进行了几次文件(10GB)上传之后问题又复现了,同样的问题出现两次,肯定不是巧合,靠黑盒的方式来推断和解决问题的方式也行不通,毕竟如果用户出现这个问题,总不能要求用户去清除浏览器缓存~

再次分析缓存

上一次定位到和缓存有关系,所以打开浏览器开发者工具,找到对应网站的缓存信息,我们使用的缓存包括 cookie、sessionStorage、localStorage、IndexedDB。

cookie 最大只有4KB,先排除。

再看 sessionStorage,每次浏览器关闭都会清除,但是关闭后重新打开浏览器问题并没有消失,排除。

然后是 localStorage,利用短暂的浏览器未崩溃的几秒钟时间,从开发者管理工具中复制粘贴所有的值,也没有发现超长字符串。

所以利用排除法,应该是 IndexedDB 了。但是通过开发者工具查看 IndexedDB 相关表和数据的时候并没有发现任何数据。而我们的代码在执行的时候是有对数据库进行初始化操作的,建立了一张用于记录上传文件分片信息的表,用作断点续传。

所以真相很可能是:浏览器在初始化 IndexedDB 的时候失败了!

顺着这个线索,于是转头开始研究 Indexed DB 相关的代码。

我们的业务代码只建了一张表,每条数据保存一个文件上传分片信息,每次有文件分片上传成功或失败的时候,更新(update)对应的记录。每条数据纯字符串大小也最多也就在 1MB~2MB 之间,而且在结束的时候会删除对应的记录。

所以仅仅传几个文件并不会占用太多数据库空间,远到不了让浏览器崩溃的地步。但问题的排查根源肯定就在 IndexedDB。

于是打算查看 IndexedDB 的缓存文件来寻找更多线索。

通过访问 chrome://version 地址可以找到浏览器的缓存地址在 C\Users\Admnistrator\AppData\Local\Google\Chrome\UserData\Default 文件夹,在文件夹中找到 IndexedDB 相关子文件夹,发现有个超大文件 MANIFEST-00001 达到了 14GB。

在网上没有找到文件相关说明,从名字推测是个配置文件,尝试删除它后再打开浏览器访问网站,问题消失。同时创建了一个新的 MANIFEST-xxxxx 格式的文件,大小几 KB。

既然没有文件说明,那么直接打开看看,发现里面写了一些类似 SQL 的执行的语句。

update table_xxxxx set (info="{......}")

这个文件似乎记录了所有的update操作。

这样的话似乎找到浏览器的崩溃的原因了:

MANIFEST-xxx 这个文件记录了每一次 update 操作,而每一次 update 操作的数据长度在 1MB2MB 之间,一个大文件分片最多可以达到9000片,如果每次记录都写入这个文件,轻松达到 1GB10GB 的大小。

但是更进一步,为什么浏览器在操作 IndexedDB 的时候会记录操作还不得而知,只在内存数据库中看到有这种设计,用来备份。

由于我们使用的是三方库 JSStore.js 来操作 IndexedDB 数据库,怀疑是不是使用不当导致。

仔细对比后发现确实是按照官方所述来使用的,相关步骤和伪代码如下:

  1. 初始化数据库连接

    var connection = new window.JsStore.Connection(new Worker(URL.createObjectURL(bl)));

    const database = ModelsInit(dbName); await connection.initDb(database);

  2. 创建一个表,声明表结构

    const db = { name: dbName, tables: [{ name: 'upload', columns: { pkey: { primaryKey: true, dataType: 'string' },

      info: { dataType: 'string' },
      last_modified: { dataType: 'number' },
    },
    

    }], }; await connection.initDb(database);

  3. 执行 update 操作

    const result = await connection.update({ in: uploadTable, where: { pkey, }, set: { last_modified, info: JSON.stringify(infoCopy), }, });

虽然没有分析出为什么浏览器会记录 IndexedDB 操作语句,但是从文件内容可以推测出:MANIFEST-xxx 文件的大小和 update 执行次数以及数据内容大小有关。

所以解决这个问题的思路可以从两方面下手:

1 减少 update 操作次数;

2 减少 update 操作数据的大小;

方案一:压缩存储数据,减少操作频次

在压缩存储数据方面,由于之前存储了一个字符串化的 JSON 对象,而这个 JSON 对象字符串后之所以能达到 1MB+,是因为有一个用来记录分片的数组属性,结构如下

part_info_list: [
  {
    "part_number":2,
    "part_size":9677730,
    "parallel_sha1_ctx":{
      "h":[2660585266,4064654707,562757357,2932871323,527124302],
      "part_offset":3649066752
    },
    "from":3649066752,
    "to":3658744482
  },
  ......
]

考虑到浏览器端不适用进行 CPU 密集型计算,压缩思路也比较简单,采用类似压缩 js 代码的思路:

1 优化结构。

这里有两个数组,parrallel_sha1_ctx 和 parrallel_sha1_ctx.h,分别为对象数组和数字数组。

由于数组嵌套层级并不深,我们考虑将对象数组拉平。

而字符串数组可以采取 join 的方式转字符串。得到:

{
    "part_number":2,
    "part_size":9677730,
    "h":"2660585266,4064654707,562757357,2932871323,527124302",
    "part_offset":3649066752,
    "from":3649066752,
    "to":3658744482,
    "start_time":1663308019190,
    "end_time":1663308029190
}

2 简化key。

将语义化的 key 替换为单个字符属性也能减少对象字符串化后的大小,

于是得到:

{
    "n":2,
    "p":9677730,
    "h":"2660585266,4064654707,562757357,2932871323,527124302",
    "o":3649066752,
    "f":3649066752,
    "o":3658744482,
    "s":1663308019190,
    "e":1663308029190
  }

3 数字进制转化。

最后一步就是压缩数字的体积,可以通过进制转化的方式缩短数值长度,这里我们采取36进制进行压缩,得到:

{
    "n":2,
    "p":"5rfdu",
    "h":"1801kqa,1v7znn7,9b1uct,1ci5lzv,8pu3pq",
    "o":"1ock5xc",
    "f":"1ock5xc",
    "o":"1oiblb6",
    "s":"l842ndh2",
    "e":"l842nl6u"
  }

4 减少操作频次

减少操作频次通过节流来实现,比如5秒内只执行一次 update 操作。但是这个优化效果取决于分片大小以及网络带宽,更直白地说,如果每5秒内只有一个分片传输成功,只会执行一次 update 操作,不会触发节流。

优化之后确实起到了一定效果,上传大文件时浏览器不再崩溃。

方案二:改表结构,压缩存储数据

但第二天经过测试同学的多轮测试之后,问题再次出现了。

沿着方案一的优化的思路,减少操作频次的可能性已经不大,只能沿着压缩数据大小的方向继续优化。

考虑到每次更新文件分片上传结果时,都会带上所有分片的数据,这其实是完全没有必要的。所以想到了将分片数据单独拆成一张分片表 table_part ,每次有分片数据更新时,在这个表中插入一行数据。

# 优化前 part1, part2, part3 更新
update table_upload set info=".... part_info_list:[part1, part2, part3, ..., 	partN]"

# 优化后 part1, part2, part3 更新
insert part1 into table_part
insert part2 into table_part
insert part3 into table_part

这个方案确实能显著降低MANIFEST-xxx 文件3个数量级左右的大小增速,但是很快另一个问题出现了。IndexedDB的读写性能非常有限,尤其是在执行上传操作的时候,还要和网络线程抢CPU资源,通常一条数据读写要花几十毫秒时间。所以当网络带宽大的时候,由于分片上传速度非常快,数据库插入速度跟不上上传速度,数据库的更新出现了明显的延迟。而且 JSStore.js 有一个保存在内存中的操作队列,未能执行的操作都放在操作队列中。这种情况下容易造成队列积压,内存占用快速增长导致标签页崩溃。

为了降低操作频率,弥补该方案的不足,需要进一步优化。

即把每个分片转成 key,如果分片数量未知,这个方案不可行,因为key会无限增长。而我们的业务场景中,每个文件上限分片数量在9000,所以只要建立9000个key完全可以覆盖所有场景。

# 分片信息拆成key以后,part1, part2, part3 更新
update table_upload set part1, part2, part3

理想很美好,可在实现过程中通过开发者工具查看数据库表结构的时候发现一个问题,那就是有些key有丢失。

奇怪了,难道 IndexedDB 中表的key数量还有上限?但是并没有找到相关方面的资料。

而且发现那些没有key的value也可以存储。

由此引发了另一个猜想,或许我们通过 JSstore.js 中声明的并不是表结构,或者说在声明表结构的 columns 属性时并不是为了使用某些 key,而是做了一些其它操作,比如建立索引,而索引数据的更新会被记录在 MANIFEST-xxx 文件中。

方案三:删除冗余索引,避免

为了验证这个猜想,我修改了之前的表结构声明,去掉了用于存储分片数据的列以及时间列,只保留主键 pkey。

const db = {
  name: dbName,
  tables: [{
    name: 'upload',
    columns: {
      pkey: { primaryKey: true, dataType: 'string' },
    },
  }],
};
await connection.initDb(database);

同时相关代码回退到方案一之前的版本,也就是说除了删除表结构声明中的 columns,其它代码逻辑不做修改。

效果非常明显,再次使用大文件进行上传时,MANIFEST-xxx 文件几乎没有增长。

问题基本得到解决。