普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月12日首页

👋 手搓 gzip 实现的文件分块压缩上传

作者 源心锁
2026年1月12日 23:13

👋 手搓 GZIP 实现的文件分块压缩上传

1 前言

已经半年多的时间没有闲下来写文章了。一方面是重新迷上了玩游戏,另一方面是 AI 时代的到来,让我对普通技术类文章的阅读频率减少了很多,相应的,自己动笔的动力也减缓了不少。

但经过这段时间的摸索,有一点是可以确定的:具有一定技术深度、带有强烈个人风格或独特创意的文章,在 AI 时代仍具有不可替代的价值。

所以,本篇来了。

在上一篇文章中,我们实现了在浏览器中记录结构化日志,现在,我们需要将这部分日志上传到云端,方便工程师调试。

我们面临的首要问题就是,文件太大了,必须分片上传。

我们将从零构建一套大文件上传系统。和普通的大文件上传系统(如阿里 OSS、七牛云常见的方案)相似,我们具备分片上传、断点续传的基础能力。但不同的是,我们为此引入了两个高阶特性:

  1. AWS S3 预签名直传(Presigned URL) :降低服务端带宽压力。
  2. 独立分片 Gzip 压缩:在客户端对分片进行独立压缩,但最终在服务端合并成一个合法的 Gzip 文件。

阅读本篇,你将收获:

  • Gzip (RFC 1952) 与 Deflate (RFC 1951) 协议的底层实现原理。
  • 基于 AWS S3 实现大文件分片直传的完整架构。
  • 一个生产级前端上传 SDK 的设计思路。

2 基础方案设计

在正式开始设计之前,我们需要先了解以下知识:AWS 提供服务端的大文件上传或下载能力,但不直接提供直传场景(presign url)的大文件分片上传能力。

基于 AWS 实现的常规流程的大文件上传 flow 为:

  • 后端先启用 CreateMultipartUpload,得到 uploadId,返回前端

    • 在启用时,需遵循以下规则:

      • ✅ 分段上传的最大文件大小为 5TB
      • ⚠️ 最大分段数为 10000
      • ⚠️ 分段大小单次限制为 5MB-5GB,最后一段无限制
    • 需提前定义 x-amz-acl

    • 需提前定义使用的校验和算法 x-amz-checksum-algorithm

    • 需提前定义校验和类型 x-amz-checksum-type

  • 在上传时,可以通过 presign url 上传

    • 每一段都必须在 header 中包含 uploadId
    • 每一段都建议计算校验和,并携带到 header 中(声明时如定义了 **x-amz-checksum-algorithm 则必传)**
    • 每一段上传时,都必须携带分段的序号 partNumber
    • 上传后,返回每一段的 ETag 和 PartNumber,如果使用了校验和算法,则也返回;该返回数据需要记录下来
  • 上传完成后,调用 CompleteMultipartUpload

    • 必须包含参数 part,使用类似于:
    • ⚠️ 除了最后一段外,单次最小 5MB,否则 complete 阶段会报错

好在这并不意味着我们要在「直传」和「分片上传」中间二选一。

来看到我们的架构图,我们在 BFF 总共只需要三个接口,分别负责「创建上传任务」「获取分片上传 URL」「完成分片上传」的任务,而实际上传时,调用预授权的 AWS URL。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092658.png

更细节的部分,可以参考这份时序图。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092540.png

2.1 关键接口

📤 创建上传任务

  • 接口地址POST /createSliceUpload

  • 功能

    • 检查文件是否已存在
    • 检查是否存在未完成的上传任务
    • 创建新的分片上传任务
  • 返回示例

    • ✅ 文件已存在:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "url": "https://..."
      }
      
    • 🔄 任务进行中:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": [1, 2, 3]
      }
      
    • 🆕 新建任务:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": []
      }
      

🔗 获取分片上传 URL

  • 接口地址POST /getSlicePresignedUrl

  • 功能:获取指定分片的预签名上传 URL

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "partNumber": 1,
      "uploadId": "abc123"
    }
    
  • 返回示例

    {
      "uploadUrl": "https://..."
    }
    

/getSlicePresignedUrl 接口中,我们通过 AWS SDK 可以预签一个直传 URL

import { UploadPartCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

  const uploadUrl = await getSignedUrl(
    s3client,
    new UploadPartCommand({
      Bucket: AWS_BUCKET,
      Key: fileKey,
      PartNumber: partNumber,
      UploadId: uploadId,
    }),
    { expiresIn: 3600 },
  );

✅ 完成分片上传

  • 接口地址POST /completeSliceUpload

  • 功能:合并所有已上传的分片

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "uploadId": "abc123",
      "parts": [
        { "ETag": "etag1", "PartNumber": 1 },
        { "ETag": "etag2", "PartNumber": 2 }
      ]
    }
    
  • 返回示例

    {
      "id": "xxx",
      "location": "https://..."
    }
    

2.2 前端设计

为了方便使用,我们尝试构建一套方便使用的 SDK,设计的 Options 如下

interface UploadSliceOptions {
  fileName: string;
  id: string;
  getContent: (
    uploadSlice: (params: { content: ArrayBufferLike; partNumber: number; isLast?: boolean }) => Promise<void>,
  ) => Promise<void>;
  acl?: 'public-read' | 'authenticated-read';
  contentType?: string;
  contentEncoding?: 'gzip';
}

这些参数的设计意图是:

  • fileName: 分片最终合并时呈现的名字

  • id :同名文件可能实际并不同,可以使用 hash 值来区分

  • 核心上传逻辑的抽象(getContent 函数):

    • 职责:负责异步地生成或获取每一个文件分片(比如从本地文件中读取一块数据)

    • 不直接接收文件内容,而是接收一个回调函数 uploadSlice 作为参数。

      • uploadSlice 的职责是:负责异步地将这一个分片的数据(content)和它的序号(partNumber)发送到服务器。
  • 可选的文件属性(HTTP 头部相关):

  • contentType?: string: 可选。指定文件的 MIME 类型(例如 'image/jpeg''application/pdf')。这在云存储中很重要,它会影响文件被访问时的 Content-Type 响应头。

  • contentEncoding?: 'gzip': 可选。指明文件内容是否(或如何)被压缩的。在这里,它明确只支持 'gzip',意味着如果提供了这个选项,上传的内容会被进行独立分片压缩

2.2.1 核心功能实现

📤 单个分片上传

uploadSlice 函数实现逻辑如下:

  1. 通过 FileClient 获取预签名 URL
  2. 使用 fetch API 将分片内容上传到该 URL
  3. 获取 ETag,并返回上传结果
export const uploadSlice = async ({ id, fileName, partNumber, content, uploadId }: UploadSliceParams) => {
  const { uploadUrl: presignedUrl } = await FileClient.getSlicePresignedUrl({
    id,
    fileName,
    partNumber,
    uploadId,
  });

  const uploadRes = await fetch(presignedUrl, {
    method: 'PUT',
    body: content,
  });
  const etag = uploadRes.headers.get('etag');
  if (!etag) throw new Error('Upload failed');
  return {
    ETag: etag,
    PartNumber: partNumber,
  };
};

🔁 分片上传流程控制

uploadSliceFile 实现完整上传逻辑:

  1. 创建上传任务,获取 uploadId
  2. 若返回完整 URL(如小文件无需分片),则直接返回
  3. 调用 getContent 回调,获取各分片内容并上传
  4. 对失败的分片进行重试
  5. 所有分片上传完成后,调用接口合并分片
  const uploadTask = await FileClient.createSliceUpload({
    fileName,
    id,
    acl,
    contentEncoding,
    contentType,
  });
  
  if (uploadTask.url) {
    return uploadTask.url; // 代表这个 id 的文件实际上已经上传过了
  }
  
  const { uploadedParts = [] } = uploadTask;
  const uploadId = uploadTask.uploadId as string;

  const parts: { PartNumber: number; ETag: string }[] = [...(uploadedParts as { PartNumber: number; ETag: string }[])];
  
  await getContent(async ({content,isLast})=>{
     ...
     const part = await uploadSlice({
         content: new Blob([content]),
         partNumber: currentPartNumber,
         uploadId,
         id,
         fileName,
     });
     parts.push(part);
  })
  
  
  return FileClient.completeSliceUpload(...)

❗ 错误处理与重试机制

  • 最大重试次数:MAX_RETRY_TIMES = 3
  • 重试延迟时间:RETRY_DELAY = 1000ms
  • 若分片上传失败,则按策略重试
  • 合并上传前需校验所有分片是否上传成功

🔄 分片去重处理

合并前对已上传分片进行去重:

  1. 按分片序号排序
  2. 使用 Set 记录已处理的分片编号
  3. 构建唯一的分片列表

2.2.2 使用示例

虽然咋一看有些奇怪,但这种方式对于流式上传支持度更好,且在普通场景也同样适用。如下边这份代码是普通文件的上传 demo

// 示例:上传一个大文件
const fileId = 'unique-file-id';
const fileName = 'large-file.mp4';
const file = /* 获取文件对象 */;
const chunkSize = 5 * 1024 * 1024; // 每片5MB
const chunks = Math.ceil(file.size / chunkSize);

const fileUrl = await uploadSliceFile({
  fileName,
  id: fileId,
  getContent: async (uploadSlice) => {
    for (let i = 0; i < chunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(file.size, start + chunkSize);
      const chunk = file.slice(start, end);

      await uploadSlice({
        content: chunk,
        partNumber: i + 1, // 分片编号从1开始
      });
    }
  },
});

console.log('文件上传成功,访问地址:', fileUrl);

3 进阶:分块 GZIP 压缩

我们的日志,其以字符串的形式保存,上传时,最终也是要上传成一份文本型文件。

所以,我们可以考虑在上传前进行压缩,以进一步减少上传时的体积——这个过程中,我们可以考虑使用 gzip、brotli、zstd 等算法。

从兼容性考虑 💭,现在 Web 浏览器支持率最高的算法是 gzip 和 brotli 算法。但 brotli 的原理决定了我们可能很难完整发挥出 brotli 算法的效果。

原因有几个。

第一个致命的原因是,brotli(RFC 7932) 是一种 raw stream 格式,它的数据流由一个或多个“元块”(Meta-Block) 组成。流中的最后一个元块会包含一个特殊的 ISLAST 标志位,它相当于一个「文件结束符」

当我们单独压缩每一个分散的文本片段时:

  • 文本A -> 片段A.br (最后一个元块包含 ISLAST=true)
  • 文本B -> 片段B.br (最后一个元块包含 ISLAST=true)
  • 文本C -> 片段C.br (最后一个元块包含 ISLAST=true)
  • ...

当我们把它们合并在一起时(例如通过 cat A.br B.br C.br > final.br),我们得到的文件结构是: [A的数据... ISLAST=true] [B的数据... ISLAST=true] [C的数据... ISLAST=true]

当一个标准的 Brotli 解码器(比如浏览器)读取这个 final.br 文件时:

  1. 解码器开始读取 [A的数据...]
  2. 解码器读取到 A 的最后一个元块,看到了 ISLAST=true 标志。
  3. 解码器立即停止解码,因为它认为流已经结束了。
  4. [B的数据...][C的数据...] 会被完全忽略,当成文件末尾的“垃圾数据”。

最终结果 我们只能成功解压出 文本A,所有后续的文本内容都会丢失。

——即便我们手动将 IS_LAST 修改正确,但「独立压缩」会导致另一个严重问题——压缩率的极大损失。

因为 br 的压缩过程中,需要先建立一个滑动窗口字典。而如果我们对每一个分片都进行压缩,br 实际上需要为每一个分片建立一个字典。

这意味着这个过程中,最核心的字典不断被重置,br 压缩器丢失了用于判断内部重复的关键工具, 进而会导致压缩率极大的下降。

而对于 gzip 来讲,虽然 gzip body 采用的 deflate 算法同样需要字段,但其窗口大小只有 32KB(br 则是 4-16MB),而我们单个分片单最小大小即是 %MB,所以对于 gzip 来说,分成 5MB 再压缩还是 500MB 直接压缩区别并不大。

所以,我们选择 gzip 来做分块压缩。

Gzip 协议是一种文件格式,它充当一个“容器”。这个容器包裹了使用 DEFLATE (RFC 1951) 算法压缩的数据块,并为其添加了元信息和校验和,以确保文件的完整性和可识别性。

一个 gzip 文件由三个核心部分组成:

  1. Header (头部) :识别文件并提供元信息。
  2. Body (主体) :包含 DEFLATE 压缩的数据流。
  3. Footer (尾部) :提供数据完整性校验。

这意味着,我们进行分块压缩时,可以通过手动创建 header + body + footer 的方式进行分块压缩。

3.1 HEADER & FOOTER

头部至少有 10 个字节。

偏移量 (字节) 长度 (字节) 字段名 固定值 / 描述
0 1 ID1 0x1f (或 31)。这是识别 gzip 文件的“魔术数字”第一部分。
1 1 ID2 0x8b (或 139)。“魔术数字”第二部分。
2 1 CM 0x08 (或 8)。表示压缩方法 (Compression Method) 为 DEFLATE
3 1 FLG 标志位 (Flags)。这是一个极其重要的字节,它的每一位都代表一个布尔值,用于控制是否存在“可选头部”。
4 4 MTIME 文件的最后修改时间 (Modification Time),以 4 字节的 Unix 时间戳格式存储。
8 1 XFL 额外标志 (Extra Flags)。通常用于指示 DEFLATE 压缩器使用的压缩级别(例如 0x02 = 最高压缩率,0x04 = 最快压缩率)。
9 1 OS 操作系统 (Operating System)。0x03 = Unix, 0x00 = Windows/FAT, 0xFF = 未知。

其中的核心部分是 FLG,即标志位。这是头部第 4 个字节 (偏移量 3),我们需要按位 (bit) 来解析它:

Bit (位) 掩码 (Hex) 字段名 描述
0 (最低位) 0x01 FTEXT 如果置 1,表示文件可能是 ASCII 文本文件(这只是一个提示)。
1 0x02 FHCRC 如果置 1,表示头部包含一个 2 字节的头部校验和 (CRC-16)
2 0x04 FEXTRA 如果置 1,表示头部包含一个扩展字段 (extra field)
3 0x08 FNAME 如果置 1,表示头部包含原始文件名
4 0x10 FCOMMENT 如果置 1,表示头部包含注释
5 0x20 RESERVED 保留位,必须为 0。
6 0x40 RESERVED 保留位,必须为 0。
7 0x80 RESERVED 保留位,必须为 0。

然后,根据 FLG 标志位的设置,紧跟在 10 字节固定头部后面的,可能会按顺序出现以下字段:

  • FEXTRA (如果 FLG & 0x04 为真):

    • XLEN (2 字节): 扩展字段的总长度 N。
    • EXTRA (N 字节): N 字节的扩展数据。
  • FNAME (如果 FLG & 0x08 为真):

    • 原始文件名,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FCOMMENT (如果 FLG & 0x10 为真):

    • 注释,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FHCRC (如果 FLG & 0x02 为真):

    • 一个 2 字节的 CRC-16 校验和,用于校验整个头部(包括所有可选部分)的完整性。

我们的话,我们需要写入 filename,所以转换成代码,就是如下的实现:

/**
 * 生成标准 GZIP Header(10 字节)
 * 符合 RFC 1952 规范。
 * 可用于拼接 deflate raw 数据生成完整 .gz 文件。
 */
/**
 * 生成包含文件名的标准 GZIP Header
 * @param {string} filename - 要嵌入头部的原始文件名
 */
export function createGzipHeader(filename: string): Uint8Array {
  // 1. 创建基础的10字节头部,并将Flags位设置为8 (FNAME)
  const header = new Uint8Array([
    0x1f,
    0x8b, // ID1 + ID2: magic number
    0x08, // Compression method: deflate (8)
    0x08, // Flags: 设置FNAME位 (bit 3)
    0x00,
    0x00,
    0x00,
    0x00, // MTIME: 0
    0x00, // Extra flags: 0
    0x03, // OS: 3 (Unix)
  ]);

  // 动态设置 MTIME
  const mtime = Math.floor(Date.now() / 1000);
  header[4] = mtime & 0xff;
  header[5] = (mtime >> 8) & 0xff;
  header[6] = (mtime >> 16) & 0xff;
  header[7] = (mtime >> 24) & 0xff;

  // 2. 将文件名字符串编码为字节
  const encoder = new TextEncoder(); // 默认使用 UTF-8
  const filenameBytes = encoder.encode(filename);

  // 3. 拼接最终的头部
  // 最终头部 = 10字节基础头 + 文件名字节 + 1字节的null结束符
  const finalHeader = new Uint8Array(10 + filenameBytes.length + 1);

  finalHeader.set(header, 0);
  finalHeader.set(filenameBytes, 10);
  // 最后一个字节默认为0,作为null结束符

  return finalHeader;
}

footer 则相对简单一些,尾部是固定 8 字节的块,由 CRC32 和 ISIZE 组成:

偏移量 长度 (字节) 字段名 描述
0 4 CRC-32 原始未压缩数据的 CRC-32 校验和。
4 4 ISIZE 原始未压缩数据的大小 (字节数)。由于它只有 4 字节,gzip 文件无法正确表示大于 4GB 的文件(解压后的大小)。

这两个值是 gzip 压缩过程中需要从整个文件角度计算的信息,由于两者均可以增量计算,问题不大。(crc32 本身计算量不大,推荐直接使用 sheetjs 库就行)

这样的话,我们就得到了这样的代码:

export function createGzipFooter(crc32: number, size: number): Uint8Array {
  const footer = new Uint8Array(8);
  const view = new DataView(footer.buffer);
  view.setUint32(0, crc32, true);
  view.setUint32(4, size % 0x100000000, true);
  return footer;
}

3.2 BODY

对我们来说,中间的 raw 流是最麻烦的。

gzip body 中的 DEFLATE 流 (RFC 1951) 并不是一个单一的、连续的东西,它本身就有一套非常重要的“特殊规则”。

DEFLATE 流的真正结构是由一个或多个数据“块” (Block) 拼接而成的。

gzip压缩器在工作时,会根据数据的情况,智能地将原始数据分割成不同类型的“块”来处理。它可能会先用一种块,然后再换另一种,以达到最佳的压缩效果。

DEFLATE 流中的每一个“块”,都必须以一个 3-bit (比特) 的头部开始。这个 3-bit 的头部定义了这个块的所有规则。

这 3 个 bit (比特) 分为两部分:

  1. BFINAL (1-bit): “最后一块”标记

    • 1: 这是整个 DEFLATE 流的最后一个块。解压器在处理完这个块后,就应该停止,并去寻找 gzip 的 Footer (CRC-32 和 ISIZE)。
    • 0: 后面还有更多的块,请继续。
  2. BTYPE (2-bits): “块类型”

    • 这 2 个 bit 决定了紧随其后的整个块的数据要如何被解析。

BTYPE 字段有三种可能的值,每一种都代表一套完全不同的压缩规则:

****规则 1:BTYPE = 00 (无压缩块) 压缩器在分析数据时,如果发现数据是完全随机的(比如已经压缩过的图片、或加密数据),它会发现压缩后的体积反而变大了。

  • 此时,它会切换到 00 模式,意思是:“我放弃压缩,直接原文存储。”

  • 结构:

    1. (BFINAL, 00) 这 3-bit 头部。
    2. 跳到下一个字节边界 (Byte-alignment)。
    3. LEN (2 字节): 声明这个块里有多少字节的未压缩数据(长度 N)。
    4. NLEN (2 字节): LEN 的“反码”(NOT LEN),用于校验 LEN 是否正确。
    5. N 字节的原始数据(原文照搬)。

规则 2:BTYPE = 01 (静态霍夫曼压缩)

  • 这是“标准”规则。 压缩器使用一套固定的、在 RFC-1951 规范中预先定义好的霍夫曼树(Huffman Tree)来进行压缩。

  • 这套“静态树”是基于对大量英语文本统计分析后得出的最佳通用编码表(例如,'e'、'a'、' ' 的编码非常短)。

  • 优点: 压缩器不需要在数据流中包含霍夫曼树本身,解压器直接使用它内置的这套标准树即可。这节省了头部空间。

  • 缺点: 如果你的数据不是英语文本(比如是中文或代码),这套树的效率可能不高。

  • 结构:

    1. (BFINAL, 01) 这 3-bit 头部。
    2. 紧接着就是使用“静态树”编码的 LZ77 + 霍夫曼编码 的数据流。
    3. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(静态树中的 256 号符号)结尾。

规则 3:BTYPE = 10 (动态霍夫曼压缩)

  • 这是“定制”规则,也是压缩率最高的规则。

  • 压缩器会先分析这个块的数据,统计出所有字符的准确频率,然后为这个块“量身定做”一套最优的霍夫曼树。

  • 优点: 压缩率最高,因为它完美贴合了当前数据块的特征(比如在压缩 JS 时,{ } ( ) . 的编码会变得极短)。

  • 缺点: 压缩器必须把这套“定制树”本身也压缩后,放到这个块的开头,以便解压器知道该如何解码。这会占用一些头部空间。

  • 结构:

    1. (BFINAL, 10) 这 3-bit 头部。
    2. 一个“定制霍夫曼树”的描述信息(这部分本身也是被压缩的)。
    3. 紧接着是使用这套“定制树”编码的 LZ77 + 霍夫曼编码 的数据流。
    4. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(定制树中的 256 号符号)结尾。

——不过,于我们而言,我们先通过静态霍夫曼压缩即可。

这个过程中,我们需要借助三方库,目前浏览器虽然支持 CompressionStream API,但并不支持我们进行精确流控制。

import pako from 'pako';

export async function compressBufferRaw(buf: ArrayBufferLike, isLast?: boolean): Promise<ArrayBufferLike> {
  const originalData = new Uint8Array(buf);

  const deflater = new pako.Deflate({ raw: true });
  deflater.push(originalData, isLast ? pako.constants.Z_FINISH : pako.constants.Z_SYNC_FLUSH);
  if (!isLast) {
    deflater.onEnd(pako.constants.Z_OK);
  }
  const compressedData = deflater.result;
  return compressedData.buffer;
}

我们用一个示例来表示一个完整 gzip 文件的话,方便理解。假设我们压缩一个叫 test.txt 的文件,它的 Gzip 文件 test.txt.gz 在十六进制编辑器中可能如下所示:

Offset  Data
------  -------------------------------------------------------------
0000    1F 8B         (ID1, ID2: Gzip 魔术数字)
0002    08            (CM: DEFLATE)
0003    08            (FLG: 0x08 = FNAME 标志位置 1)
0004    XX XX XX XX   (MTIME: 4 字节时间戳)
0008    04            (XFL: 最快压缩)
0009    03            (OS: Unix)

(可选头部开始)
000A    74 65 73 74   (t e s t)
000E    2E 74 78 74   (. t x t)
0012    00            (FNAME: NULL 终结符)

(Body 开始)
0013    ED C0 ...     (DEFLATE 压缩流开始...)
...
...     ...           (...此块数据流的末尾包含一个 EOB 符号...)
                      (... DEFLATE 压缩流结束)

(Footer 开始)
XXXX    YY YY YY YY   (CRC-32: 原始 test.txt 文件的校验和)
XXXX+4  ZZ ZZ ZZ ZZ   (ISIZE: 原始 test.txt 文件的大小)

至此,我们完成了一套社区前列的分片上传方案。S3 将所有上传的部分按序合并后,在S3上形成的文件结构是:[Gzip Header][Deflate_Chunk_1][Deflate_Chunk_2]...[Deflate_Last_Chunk][Gzip Footer] 这个拼接起来的文件是一个完全合法、可流式解压的 .gz 文件。

4 性能 & 对比

为了验证该方案(Smart S3 Gzip)的实际效果,我们构建了一个基准测试环境,将本文方案与「普通直传」及「传统前端压缩上传」进行全方位对比。

4.1 测试环境

  • 测试文件:1GB Nginx Access Log (纯文本)
  • 网络环境:模拟家用宽带上行 50Mbps (约 6.25MB/s)
  • 测试设备:MacBook Pro (M1 Pro), 32GB RAM
  • 浏览器:Chrome 143

4.2 核心指标对比

核心指标 方案 A:普通直传 方案 B:前端整体压缩 方案 C:本文方案 (分片 Gzip 流)
上传总耗时 ~165 秒 ~45 秒 (但等待压缩很久) ~38 秒 (边压边传)
首字节发送时间 0 秒 (立即开始) 30 秒+ (需等待压缩完成) 0.5 秒 (首个分片压缩完即发)
峰值内存占用(计算值) 50MB (流式) 2GB+ (需读入全量文件) 100MB (仅缓存并发分片)
网络流量消耗 1GB ~120MB ~121MB (略多出的 Header 开销可忽略)
客户端 CPU 负载 极低 (<5%) 单核 100% (持续一段时间,可能 OOM) 多核均衡 (并发压缩,利用率高)

4.3 深度解析

🚀 1. 速度提升的秘密:流水线效应

在方案 B(整体压缩)中,用户必须等待整个 1GB 文件在本地压缩完成,才能开始上传第 1 个字节。这是一种「串行阻断」模型。 而本文方案 C 采用了「流水线(Pipeline)」模型:压缩第 N 个分片的同时,正在上传第 N-1 个分片。 对于高压缩率的文本文件(通常压缩比 5:1 到 10:1),网络传输往往比本地 CPU 压缩要慢。这意味着 CPU 的压缩几乎是“免费”的,因为它掩盖在了网络传输的时间里。

💰 2. 成本分析:不仅是快,还省钱

AWS S3 的计费主要包含存储费和流量费。

  • 存储成本:1GB 的日志存入 S3,如果未压缩,每月存储费是压缩后的 5-10 倍。虽然 S3 本身很便宜,但对于 PB 级日志归档,这笔费用惊人。
  • 传输加速成本:如果使用了 S3 Transfer Acceleration,费用是按流量计算的。压缩后上传意味着流量费用直接打一折。

🛡️ 3. 内存安全性

方案 B 是前端的大忌。试图将 1GB 文件读入 ArrayBuffer 进行整体 gzip 压缩,极其容易导致浏览器 Tab 崩溃(OOM)。本文方案将内存控制在 分片大小 * 并发数 (例如 5MB * 5 = 25MB) 的安全范围内,即使上传 100GB 文件也不会爆内存。

4.4 适用场景与局限性

✅ 强烈推荐场景:

  • 日志归档 / 数据备份:CSV, JSON, SQL Dump, Log 文件。压缩率极高,收益巨大。
  • 弱网环境:上传带宽受限时,压缩能显著减少等待时间。

❌ 不推荐场景:

  • 已经压缩的文件:MP4, JPG, ZIP, PNG。再次 Gzip 几乎无压缩效果,反而浪费 CPU。
  • 超低端设备:如果用户的设备是性能极差的老旧手机,CPU 压缩速度可能低于网络上传速度,反而成为瓶颈。建议在 SDK 增加 navigator.hardwareConcurrency 检测,自动降级。

5 结语

通过深入理解 HTTP、AWS S3 协议以及 Gzip 的二进制结构,我们打破了“压缩”与“分片”不可兼得的魔咒。这套系统目前已在我们内部的日志回放平台稳定运行,有效减少文件上传时长。

有时候,技术的突破口往往就藏在那些看似枯燥的 RFC 文档里。希望这篇“硬核”的实战总结,能给你带来一些启发。

丧心病狂!在浏览器全天候记录用户行为排障

作者 源心锁
2026年1月12日 22:40

1 前言

QA:“bug, 你把这个 bug 处理一下。”

我:“这个 bug 复现不了,你先复现一下。”

QA:“我也复现不了。”

(PS: 面面相觑脸 x 2)

众所周知,每个公司每个项目都可能存在偶现的缺陷,毋庸置疑,这为问题的定位和修复带来了严重的阻碍。

要解决这个问题,社区方案中常常依赖 datadog、sentry 等问题记录工具,但这些工具存在采样率限制或依赖错误做信息收集,很难做到 100% 的日志记录。

emoji_002.png

偶然间,我看到了 pagespy,它符合需求,但又不完全符合,好在调研下来,我们只要魔改一番,保留其基础的日志能力,修改其存储方式,就能得到一个能做全天候日志采集的工具。

那么,目标明确:

  • 实现全时段用户行为录制与回放
  • 最小化对用户体验的影响
  • 确保数据安全与隐私保护
  • 与现有系统(如 intercom )无缝集成

2 SDK 设计

目前 pagespy 设计目标和我们预期并不一致,并不能开箱即用。pagespy 的方案不满足我们需求的点在于:

  1. 没有持久化能力,内存存储,单次录制不对数据做导出则数据清空。
  2. pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

经过对 pagespy 的源码解析以及文档阅读,整理出来其中分支的 OSpy(离线版 pagespy 的数据走向如下):

image.png

我们可以通过 inject 的形式,把这两个能力代理到我们的逻辑中。

image.png

样式上,则通过插入一段 style 强制将 dom 样式隐藏。

  document.head.insertAdjacentHTML(
    'beforeend',
    `<style>
    #o-spy {
      display: none;
    }
    </style>`,
  );

至此,我们已经基本脱离了 pagespy 的数据 in & out 逻辑,所有数据都由我们来处理,包括数据存储也需要我们重新设计。

2.1 日志存储方案

✅ 确定日志存储方案。需要注意避免大量日志将用户的电脑卡死。

✅ pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

✅ pagespy 为了防止爆内存引入了时间上限等因素,会时不时清除数据(rrweb 存在非常重要的首屏帧,缺少该帧后续都无法渲染成功),这会导致以单个浏览器标签作为切片的设计逻辑被迫中断,会对我们的逻辑带来负面影响。

为了实现全时段存储的目标,经评估除了 indexDB 之外没有其他很好的存储方案可以满足我们的大容量需求。在此,决定引入 dexie 进行数据库管理。

import type { EntityTable } from 'dexie';
import Dexie from 'dexie';

const DB_NAME = 'SpyDataHarborDB';

export class DataHarborClient {
  db: DBType;
  constructor() {
    this.db = new Dexie(DB_NAME) as DBType;
    this.db.version(1).stores({
      logs: '++id,[tabId+timestamp],tabId, timestamp',
      metas: '++id,tabId,startTime,endTime',
    });
  }
}

export const { db } = new DataHarborClient();

我们将日志以浏览器标签页为维度进行拆分,引入了 tabId 的概念。并设计了两个表,一个用于存储日志,一个用于存在 tab 的基本信息。

type DBType = Dexie & {
  logs: EntityTable<{
    id?: number;
    tabId: string;
    timestamp: number;
    data: string;
  }>;
  metas: EntityTable<{
    id?: number;
    tabId: string;
    size: number;
    startTime: number;
    endTime: number;
  }>;
};

这意味着,从 pagespy 得到的数据只需要直接入库,我们在每次入库后做一次日志清理,即可实现一个基本的存储系统。

  async addLog(data: CacheMessageItem) {
    const now = new Date();

    const dataStr = JSON.stringify(data);

    await db.logs.add({
      tabId: this.tabId,
      timestamp: now.getTime(),
      data: dataStr,
    });

    await db.transaction('rw', ['metas'], async (tx) => {
      const meta = await tx.metas.get({
        tabId: this.tabId,
      });
      if (meta) {
        meta.size += dataStr.length;
        meta.endTime = now.getTime();
        await db.metas.put(meta);
        return meta;
      } else {
        await db.metas.add({
          tabId: this.tabId,
          size: dataStr.length,
          startTime: now.getTime(),
          endTime: now.getTime(),
        });
      }
    });
  }

在我们完成日志入库之后,额外需要考虑的是持续直接入库的性能损耗。 经测试,通过 worker 进行操作与直接在主线程进行操作,对主线程的耗时影响对比表格如下(基于 performance.now()):

操作方式 峰值 最低值 中位数 平均值
worker + insert 5.3 ms 0ms 0.1ms 0.31ms
直接 insert 149.5 ms 0.4ms 3.6ms 55.29ms

所以最终决策将数据库操作转移到 worker 中实现——但这又反应了一点问题,目前 pagespy 的入库数据是序列化后的字符串,并不能很好地享受主线程和 worker 线程之间通过 transfer 传输的性能优势。

2.2 安全和合规问题

目前可知,我们的方案先天就存在较严重的合规问题 🙋,这体现在:

  1. pagespy 会保存一些隐秘的 storage、cookie 数据到 indexedDB 中,有一定安全风险。
  2. pagespy 基于 rrweb ⏺️ 录制页面,用户在电脑上的行为和信息可能被记录。(如 PII 数据)

第一个问题,我们可以考虑直接基于 Pagespy 来记录,其实际上提供了 API 允许我们自行决定要抛弃哪些信息。

使用时,类似于:

    network: (data) => {
      if (['fetch', 'xhr'].includes(data.requestType)) {
        data.responseHeader?.forEach((item) => {
          if (item[0] === 'set-cookie') {
            item[1] = obfuscate(item[1]);
          }
        });
        return true;
      }
      return true;
    },

image.png 第二个问题,我们应考虑基于 rrweb 的默认隐私策略来做处理,rrweb 在 sentry、posthog 中都有使用,都是基于默认屏蔽规则来允许,所以我们使用默认屏蔽规则,其他库的隐私合规也相当于一起做了。

所以,我们需遵循以下规则(rrweb 默认屏蔽规则)修改 Web 端,而不是 SDK:

  • 具有该类名的元素.rr-block不会被记录。它将被替换为具有相同尺寸的占位符。
  • 具有该类名的元素.rr-ignore将不会记录其输入事件。
  • 具有类名的元素.rr-mask及其子元素的所有文本都将被屏蔽。和 block 的区别是,只会屏蔽文本,不会直接替换 dom 结构(也就是背景颜色之类的会保留)
  • input[type="password"]将被默认屏蔽。

根据元素是否包含“用户输入能力”,分为 3 种处理方式:

  • 1️⃣ 包含输入能力(如 input, textarea,canvas 可编辑区域)

    • 目的:既屏蔽用户的输入行为,也屏蔽输入内容
    • 处理方式:添加 rr-ignorerr-block 两个类
    • 效果:

image.png

  • 2️⃣ 不包含输入能力(如纯展示类的文本)

    • 目的:保留结构,隐藏文本内容,避免泄露隐私
    • 处理方式:添加 rr-mask 类,将文本进行混淆显示
    • 效果:

image.png

  • 3️⃣ 图片、只读 canvas 包含隐私信息(如签名)

    • 目的:隐藏内容
    • 处理方式:添加 rr-block

2.3 日志获取和处理

在上述流程中,我们设计了基于浏览器标签页的存储系统,但由于 rrweb 和 ospy 的设计,我们仍有两个问题待解决:

  1. ospy 中的 meta 帧只在 download 时获取,并需要是 logs 的最后一帧。
  2. rrweb 存在特殊限制,即必须存在首 2 帧,否则提取出来的日志无法显示页面。

这两个问题我们需要特殊处理,针对 meta 帧的情况,首先要知道,meta 帧包含了客户端信息等数据:

image.png

image.png

这部分信息虽然相比之下不是那么重要,但在特定场景中非常有用,nice to have。在此前提下,由于 ospy 未提供对外函数,我们需要自行添加该帧。目前,meta 帧会在 spy 初始化时自动插入,然后在读取时排序到尾部。

// 这个其实是 spy 的源码
export const minifyData = (d: any) => {
  return strFromU8(zlibSync(strToU8(JSON.stringify(d)), { level: 9 }), true);
};

export const getMetaLog = () => {
  return minifyData({
    ua: navigator.userAgent,
    title: document.title,
    url: window.location.href,
    startTime: 0,
    endTime: 0,
    remark: '',
  });
};

第二个问题相比之下更加致命,但解决起来又异常简单。rrweb 的机制决定了我们在导出的时候必定要查询出第一二帧,我们在获取日志时需要特殊处理:

  1. 获取用户指定日期范围内的日志的 tabId。
  2. 基于 tabId 筛查出所有日志,筛查出 < endTime 的所有日志。
async getTabLogs({ tabId, end }: { tabId: string; end: number }) {
    // 日志获取逻辑
}

(如你所见,获取日志阶段 start 直接 gank 没了)

此外,由于持续存储特性,读取日志时会面临数据量过大的问题。例如,8 分钟连续操作导出的日志约 17MB,一小时约 120MB。按照平均每小时录制数据量估算,静态浏览约 2 - 5MB,普通交互约 50MB,高频交互约 100MB。以单个用户每日使用 8 小时计算,平均用户约 400MB / 天,重度用户约 800MB / 天。基于 14 天保留策略,单用户最大存储空间约为 12GB。

这意味着如果用户选择的时间范围较大,传统读取流程可能读取 10GB+ 日志到内存,这显然会导致浏览器内存溢出。

为避免读取大量日志导致浏览器内存溢出,我们采用分片式读取。核心思想是将指定 tab 的日志数据按需 “分片提取”,通过回调逐步传输给调用方,确保高效、稳定地处理大体积日志的读取与传输:

  1. 读取元信息 (meta):

    • 通过 tabIddb.metas 获取对应日志的元信息(如日志总大小)。
  2. 判断是否需要分片:

    • 如果日志总大小小于阈值 MIN_SLICE_CHUNK_SIZE一次性读取所有日志,拼接成完整 JSON,再调用 callback 发送。
  3. 大文件分片处理逻辑:

    • 根据日志总大小计算合适的 chunkSize,从而决定分片数量 chunkCount
    • 每次读取一部分日志数据(受限于计算出的 limit),拼接为 JSON 片段,通过 callback 逐步传出。
    • 每片都使用 Comlink.transfer() 进行内存零拷贝传输,提高性能。
  4. 合并与补充 meta 信息:

    • 如果日志数据中有 meta 类型数据(携带一些压缩信息),在最后一片中进行处理与拼接,保持语义完整。
  5. 进度追踪与标记:

    • 每一片传输都附带 progresspartNumber,便于前端追踪处理进度。
  async getTabLogs(
    {
      tabId,
      end,
    }: {
      tabId: string;
      end: number;
    },
    callback: (log: { content: Uint8Array; progress: number; partNumber: number }) => void | Promise<void>,
  ) {
  
    ...

    const totalSize = meta.size + BUFFER_SIZE;
    // 根据 totalSize、MAX_SLICE_CHUNK、MIN_SLICE_CHUNK_SIZE 计算出最佳分片大小
    const chunkSize = Math.max(Math.min(totalSize / MAX_SLICE_CHUNK, MIN_SLICE_CHUNK_SIZE), MIN_SLICE_CHUNK_SIZE);

    const chunkCount = Math.ceil(totalSize / chunkSize);

    let offset = 0;
    const count = await db.logs
      .where('tabId')
      .equals(tabId)
      .and((log) => log.timestamp <= end)
      .count();

    const limit = Math.max(1, Math.ceil(count / chunkCount / 3));

    let metaData: string | null = null;

    let startTime = 0;
    let endTime = 0;

    let preLogStr = '';
    let progressContentSize = 0;
    let partNumber = 1;
    while (offset <= count) {
      try {
        const logs = await db.logs
          .where('tabId')
          .equals(tabId)
          .and((log) => log.timestamp <= end)
          .offset(offset)
          .limit(limit)
          .toArray();

        let baseStr = preLogStr;
        if (offset > 0) {
          baseStr += ',';
        } else if (offset === 0) {
          baseStr += '[';
        }

        endTime = logs?.[logs.length - 1]?.timestamp ?? endTime;
        if (offset === 0) {
          startTime = logs?.[0].timestamp ?? 0;
        }

        offset += logs.length;

        const logData = logs.map((log) => log.data).filter((log) => log !== '"PERIOD_DIVIDE_IDENTIFIER"');
        ...

        const logsStr = logData.join(',');
        baseStr += logsStr;

        if (offset === count) {
          if (!metaData) {
            await callback({
              content: transfer(baseStr + ']'),
              progress: 1,
              partNumber,
            });
          } else {
            const metaJson = JSON.parse(metaData);
            const parseMetaData = parseMinifiedData(metaJson.data);
            const metaMinifyData = minifyData({
              ...parseMetaData,
              startTime,
              endTime,
            });
            const metaStr = JSON.stringify({
              type: 'meta',
              timestamp: endTime,
              data: metaMinifyData,
            });
            await callback({
              content: transfer(baseStr + ',' + metaStr + ']'),
              progress: 1,
              partNumber,
            });
          }
          break;
        }

        progressContentSize += baseStr.length;
        const progress = Math.min(0.99, progressContentSize / totalSize);

        // 如果 size < minSize,那么就继续获取
        if (baseStr.length < MIN_SLICE_CHUNK_SIZE) {
          preLogStr = baseStr;
          continue;
        }

        preLogStr = '';
        await callback({
          content: transfer(baseStr),
          progress,
          partNumber,
        });
        partNumber++;
      } catch (error) {
        console.log(error);
        break;
      }
    }
  }

3 工作流设计

3.1 👼 基础工作流

我们公司采用 intercom 和外部客户沟通,用户可以在网页右下角的 intercom iframe 中和客服沟通。

image.png

所以,如果有办法将整个日志流程合并到目前的 intercom 流程中,不仅贴合目前的业务情况,而且不改变用户习惯。

通过调研,可以确定以下方案:

  1. CS 侧配置默认时间范围,需要 POST /configure-card 进行表单填写,填写后表单会在下一步被携带到 payload 中。
  2. CS 侧在发送时,会 POST /initialize接口(由自有后端提供),接口需返回 canvas json 数据。如:
{
  canvas: {
    content: {
      components: [
        {
          type: "text",
          text: "*Log Submission*",
          style: "header",
        },
        {
          type: "button",
          label: "Select logs",
          style: "primary",
          id: "submit_button",
          action: {
            type: "sheet",
            url: "xxxxxx",
          },
        },
      ],
    },
  },
}
  1. 发送后,用户点击 sheet 按钮可以跳转到前端,但需注意,该请求为 POST 请求。
  2. 用户填写完表单,提交时可以直接请求后端接口,也可以由 intercom 服务端向后端发起 POST 请求。
  3. 如期望在提交后修改消息状态,则必须在上一步执行【由 intercom 服务端向后端发起 POST 请求】(推荐,最完整的 flow),此时后端需返回 canvas json,后端同步触发逻辑,添加 note 到 intercom 页面,方便 CS 创建 jira 单时携带复现链接

Editor _ Mermaid Chart-2025-05-09-062511.png

3.2 ⚠️ 增强工作流

在我们上述 flow 中,需要获取用户授权,由用户操作触发下载和上传日志的过程,但实际上有比较刑的方案。

具体 flow 如图:

image.png

该方案的整体优势是:

  1. 无需 CS 介入,无需修改 CS 流程。
  2. 用户对日志上传感知力度小

换句话说,隐私合规风险较大。

4 工作流技术要点

4.1 😈 iframe 实现

Iframe 指的是 【日志上传 iframe】,对应这一步骤:

image.png

由于 intercom 将基于 POST 请求去调用服务希望得到 html 的限制,这里存在两个问题:

  1. Intercom 使用 POST 请求,则我们的服务需要支持 POST 请求返回 html,目前是不支持的,所以需要解决方案。
  2. 由于我们的 iframe 网页要读取日志,那么 iframe 地址必须和 Web 端同源,但生产的 API 地址和 Web 端不同源。

基本方向上,我们可以通过反向代理的方式实现:

image.png

iframe 的同源限制比预想的还要麻烦一些,由于 intercom 的接入方式是 iframe 嵌套,类似于:A(<https://samesite.com/>)->B(<https://xxxx.com/>)->A(<https://samesite.com>)

这个过程会导致两个跨域限制:

  1. Cookie 的跨域限制,具体表现为用于登录态的 Cookie 由于未显式设置 Samesite: None ,无法被携带进内层网页,进而丢失登录态。
  2. indexedDB 的跨域限制,由于中间多了一层外域,浏览器限制了最里边的网页读取 indexedDB,具体表现为读取到的数据为🈳。

Cookie 的跨域限制通过显式设置 Samesite 可以解决,但进一步地,为了确保安全性,我们需要给网页其他路径添加X-Frame-Options SAMEORIGIN; 防止外域嵌套我们的其他网页。

后者卡了一阵子,最后的解决思路是通过 postMessage 通信的方式变相读取——反正能读取到就行。

  window.top.postMessage(
    {
      type: 'uploadLogs',
      id: topUUID,
      params: {
        start,
        end,
      },
    },
    '*',
  );

(有趣的是,排查过程中发现了 chrome devtools 的缺陷,devtools 里的 document 都指不到最外层,但是实际上 window.top 和 window.parent.parent.parent 都是最外层,具体不细说了)

4.2 🥹 日志安全与上传

日志的格式是 JSON 格式,将其拖拽到 ospy 中即可复原用户浏览器操作记录,一旦泄漏会有极高的安全风险。在此,提出加密方案用于解决该问题。

思路其实很简单:在文件上传前对文件内容进行 AES 加密,对 AES 密钥做 RSA 非对称加密,通过公钥加密,然后将加密后的密钥附加到文件尾。

image.png

其实还可以进一步,我们在写入日志的时候就加密,但这样读取的时候压力会比较大,因为日志是一段一段的,或许我们还需要定制分隔符。

5 总结

好,那么理所当然的,我们应该不会遇到其他卡点卡,方案落地应该是没问题了。但——

Leader: “有个问题,我们没有分片上传”

我: ”Woc? 又要自己写?”

欲知后事如何,且听下回分解。

❌
❌