阅读视图

发现新文章,点击刷新页面。

彻底搞懂大文件上传:从幼儿园到博士阶段的全栈方案

第一层:幼儿园阶段 —— 为什么要搞复杂上传?

想象一下:你要把家里的**一万本书(10GB 文件)**搬到新家。

普通方案(简单上传):你试图一次性把一万本书塞进一个小三轮车。结果:车爆胎了(浏览器内存溢出),或者路上堵车太久,新家管理员等得不耐烦把门关了(请求超时)。

全栈方案(分片上传):你把书装成 100 个小箱子,一箱一箱运。即便路上有一箱丢了,你只需要补发那一箱,而不是重新搬一万本书。

为什么这样做?

  • 降低单次传输的数据量,避免内存溢出
  • 减少单个请求的处理时间,降低超时风险
  • 支持断点续传,提高上传成功率

第二层:小学阶段 —— 简单上传的极限

对于小于 10MB 的图片或文档,我们用 FormData。

前端 (Vue):input type="file" 获取文件,封入 FormData,通过 axios 发送。

后端 (Node):使用 multer 或 formidable 中间件接收。

数据库 (MySQL):切记! 数据库不存文件二进制流,只存文件的访问路径(URL)、文件名、大小和上传时间。

为什么数据库不存文件?

  • 文件体积太大,影响数据库性能
  • 数据库备份和迁移变得困难
  • 磁盘空间浪费,难以清理

简单上传的代码示例

// 前端
const formData = new FormData();
formData.append('file', fileInput.files[0]);
axios.post('/upload', formData);

// 后端
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
  // 处理上传
});

第三层:中学阶段 —— 分片上传 (Chunking) 的逻辑公式

这是面试的核心起点。请背诵流程:

切片:利用 File 对象的 slice 方法(底层是 Blob),将大文件切成 N 份。 标识:给每个片起个名字,通常是 文件名 + 下标。 并发发送:同时发送多个 HTTP 请求。 合并:前端发个"指令",后端把所有碎片按顺序合成一个完整文件。

为什么要用slice方法?

  • 它不会复制整个文件到内存,而是创建一个指向原文件部分的引用
  • 内存占用极小,适合处理大文件
  • 可以精确控制每个分片的大小

分片上传的实现逻辑

// 分片函数
function createFileChunks(file, chunkSize = 1024 * 1024 * 2) { // 2MB per chunk
  const chunks = [];
  let start = 0;
  
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end); // 核心API
    chunks.push({
      blob: chunk,
      index: chunks.length,
      start,
      end
    });
    start = end;
  }
  
  return chunks;
}

第四层:大学阶段 —— 秒传与唯一标识 (MD5)

面试官问:"如果用户上传一个服务器已经有的文件,怎么实现秒传?"

核心:文件的"身份证"。不能用文件名,要用文件的内容生成 MD5 哈希值。

流程

  1. 前端用 spark-md5 计算文件哈希。
  2. 上传前先问后端:"这个 MD5 对应的文件你有没有?"
  3. 后端查 MySQL,如果有,直接返回成功——这就是秒传。

为什么用MD5而不是文件名?

  • 文件名可以重复,但内容不同的文件
  • 文件名可以被修改,但内容不变
  • MD5是内容的数字指纹,相同内容必定有相同的MD5

MD5计算示例

import SparkMD5 from 'spark-md5';

function calculateFileHash(file) {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    const spark = new SparkMD5.ArrayBuffer();
    let chunkIndex = 0;
    const chunkSize = 2097152; // 2MB
    
    const loadNext = () => {
      const start = chunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      
      fileReader.readAsArrayBuffer(file.slice(start, end));
    };
    
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      chunkIndex++;
      
      if (chunkIndex * chunkSize < file.size) {
        loadNext(); // 继续读取下一个分片
      } else {
        resolve(spark.end()); // 返回最终哈希值
      }
    };
    
    loadNext();
  });
}

第五层:博士阶段 —— 断点续传 (Resumable)

如果传到一半断网了,剩下的怎么办?

方案 A:前端记录(不可靠) 方案 B(推荐):后端 MySQL 记录已收到的分片序号。

流程

  1. 重新上传前,调用 checkChunks 接口
  2. 后端查库返回:{ uploadedList: [1, 2, 5] }
  3. 前端过滤掉已存在的序号,只发剩下的

为什么选择后端记录?

  • 前端存储不可靠(localStorage可能被清除)
  • 支持跨设备续传(从手机上传一半,从电脑继续)
  • 数据一致性更好,不容易出现脏数据

断点续传实现

// 检查已上传分片
async function checkUploadedChunks(fileHash) {
  const response = await api.checkChunks({ fileHash });
  return response.uploadedList || []; // 已上传的分片索引数组
}

// 过滤未上传的分片
const uploadedList = await checkUploadedChunks(fileHash);
const needUploadChunks = allChunks.filter(chunk => 
  !uploadedList.includes(chunk.index)
);

第六层:性能巅峰 —— 只有 1% 的人知道的 Worker 计算

浏览器是"单线程"的,JavaScript 引擎和页面渲染(DOM 树构建、布局、绘制)共用一个线程,如果你在主线程执行一个耗时 10 秒的循环(比如计算大文件的 MD5),浏览器会直接卡死。用户点不动按钮、动画停止、甚至浏览器弹出"页面无响应"。

屏幕刷新率通常是 60Hz,意味着浏览器每 16.7ms 就要渲染一帧。如果你的计算任务占据了这 16.7ms,页面就会掉帧、卡顿。

二、 什么是 Web Worker?(定义)

Web Worker 是 HTML5 标准引入的一项技术,它允许 JavaScript 脚本在后台线程中运行,不占用主线程。

它的地位: 它是主线程的"打工仔"。

它的环境: 它运行在另一个完全独立的环境中,拥有自己的全局对象(self 而不是 window)。

三、 Web Worker 能干什么?(职责与局限)

能干什么:

CPU 密集型计算: MD5 计算、加密解密、图像/视频处理、大数据排序。

网络请求: 可以在后台轮询接口。

不能干什么(面试必考点):

不能操作 DOM: 它拿不到 window、document、parent 对象。

不能弹窗: 无法使用 alert()。

受限通信: 它和主线程之间只能通过 "消息传递"(PostMessage)沟通。

四、 怎么干?(核心 API 实战)

我们要实现的目标是:在不卡顿页面的情况下,计算一个 1GB 文件的 MD5。

步骤 1:主线程逻辑(Vue/JS 环境)

主线程负责雇佣 Worker,并给它派活。

// 1. 创建 Worker 实例 (路径指向 worker 脚本)
const myWorker = new Worker('hash-worker.js');

// 2. 发送任务 (把文件对象传给 Worker)
myWorker.postMessage({ file: fileObject });

// 3. 接收结果 (监听 Worker 回传的消息)
myWorker.onmessage = (e) => {
    const { hash, percentage } = e.data;
    if (hash) {
        console.log("计算完成!MD5 为:", hash);
    } else {
        console.log("当前进度:", percentage);
    }
};

// 4. 异常处理
myWorker.onerror = (err) => {
    console.error("Worker 报错了:", err);
};

步骤 2:Worker 线程逻辑(hash-worker.js)

Worker 负责埋头苦干。

// 引入计算 MD5 的库 (Worker 内部引用脚本的方式)
importScripts('spark-md5.min.js');

self.onmessage = function(e) {
    const { file } = e.data;
    const spark = new SparkMD5.ArrayBuffer(); // 增量计算实例
    const reader = new FileReader();
    const chunkSize = 2 * 1024 * 1024; // 每次读 2MB
    let currentChunk = 0;

    // 分块读取的核心逻辑
    reader.onload = function(event) {
        spark.append(event.target.result); // 将 2MB 数据喂给 spark
        currentChunk++;

        if (currentChunk < Math.ceil(file.size / chunkSize)) {
            loadNext(); // 继续读下一块
            // 反馈进度
            self.postMessage({ 
                percentage: (currentChunk / Math.ceil(file.size / chunkSize) * 100).toFixed(2) 
            });
        } else {
            // 全部读完,生成最终 MD5
            const md5 = spark.end();
            self.postMessage({ hash: md5 }); // 完工,回传结果
        }
    };

    function loadNext() {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        // 关键点:使用 slice 切片读取,避免一次性读入内存
        reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext(); // 开始任务
};

五、 关键思路过程(给面试官讲出深度)

当你向面试官复述这个过程时,要按照这个逻辑链条:

实例化(New Worker):

"首先,我通过 new Worker 启动一个独立线程,将耗时的计算逻辑从主线程剥离。"

数据传输(PostMessage):

"主线程将 File 对象发送给 Worker。这里要注意,File 对象是 File 句柄,发送它并不会瞬间占据大量内存,因为它是基于 Blob 的,是惰性的。"

分块读取与增量计算(Chunked Hashing):

"这是最核心的一步。即便在 Worker 内部,我也不能直接读取整个文件(比如 5GB 读进内存会直接让 Worker 进程挂掉)。

我使用了 file.slice 配合 FileReader,每次只读取 2MB 数据。

配合 spark-md5 的 append 方法,将数据'喂'给计算引擎,处理完后,之前的内存块会被释放。"

异步通信(Messaging):

"在计算过程中,我不断通过 self.postMessage 向主线程发送计算进度,以便用户在界面上能看到动态的百分比。

最后计算完成,通过消息回传最终 MD5。"

六、 总结:核心 API 记事本

new Worker('path'): 开启招聘。

postMessage(data): 互发短信。

onmessage: 接收短信。

importScripts(): Worker 内部加载插件。

file.slice(): 物理上的"切片",MD5 不崩溃的秘诀。

FileReader.readAsArrayBuffer(): 将二进制内容读入内存进行计算。

七. 谈 内存管理 (Memory Management)

面试官可能会问: "在Worker内部如何避免内存溢出?"

回答: "在 Worker 内部,我没有使用 fileReader.readAsArrayBuffer(file) 直接读取整个文件。因为 4GB 的文件如果直接读入内存,V8 引擎会直接 OOM (Out of Memory) 崩溃。我采用了 '分块读取 -> 增量哈希' 的策略。利用 spark.append() 每次只处理 2MB 的数据,处理完后 V8 的垃圾回收机制会自动释放这块内存,从而实现用极小的内存开销处理极大的文件。"

八. 谈 抽样哈希 (性能杀手锏) —— 只有 1% 的人知道的黑科技

面试官: "如果文件 100GB,Worker 计算也要好几分钟,用户等不及怎么办?"

你的杀手锏: "如果对完整性校验要求不是 100% 严苛,我会采用 '抽样哈希' 方案:

  • 文件头 2MB 全部取样
  • 文件尾 2MB 全部取样
  • 中间部分:每隔一段距离取样几个字节

这样 10GB 的文件我也只需要计算 10MB 左右的数据,MD5 计算会在 1秒内 完成,配合后端校验,能实现'秒级'预判,极大提升用户体验。"

D. 总结:面试加分关键词

  • 增量计算 (Incremental Hashing):不是一次性算,是攒着算
  • Blob.slice:文件切片的核心底层 API
  • 非阻塞 (Non-blocking):Worker 的核心价值
  • OOM 预防:通过分块读取控制内存峰值
  • 抽样哈希:大文件快速识别的有效手段

"其实 Web Worker 也有开销,创建它需要时间和内存。对于几百 KB 的小文件,直接在主线程算可能更快;但对于大文件上传,Worker 是保证 UI 响应性 的唯一正确解。"

第七层:Node.js 后端压测 —— 碎片合并的艺术

当 1000 个切片传上来,Node 如何高效合并?

初级:fs.readFileSync。 瞬间撑爆内存,Node.js(V8)默认内存限制通常在 1GB~2GB 左右。执行 readFile 合并大文件,服务器会瞬间 Crash。

高级:fs.createReadStream 和 fs.createWriteStream。

利用"流(Stream)"的管道模式,边读边写,内存占用极低。

一、 什么是 Stream?(本质定义)

流(Stream)是 Node.js 提供的处理 流式数据 的抽象接口。它将数据分成一小块一小块(Buffer),像流水一样从一头流向另一头。

Readable(可读流): 数据的源头(如:分片文件)。

Writable(可写流): 数据的终点(如:最终合并的大文件)。

Pipe(管道): 连接两者的水管,数据通过管道自动流过去。

二、 能干什么?

在文件上传场景中,它能:

低内存合并: 无论文件多大(1G 或 100G),内存占用始终稳定在几十 MB。

边读边写: 读入一小块分片,立即写入目标文件,不需要等待整个分片读完。

自动背压处理(Backpressure): 如果写的速度慢,读的速度快,管道会自动让读取慢下来,防止内存积压。

三、 怎么干?(核心 API 与实战)

1. 核心 API 记事本

fs.createReadStream(path):创建一个指向分片文件的水龙头。

fs.createWriteStream(path, { flags: 'a' }):创建一个指向目标文件的接收桶。flags: 'a' 表示追加写入(Append)。

reader.pipe(writer):把水龙头接到桶上。

const fs = require('fs');
const path = require('path');

/**
 * @param {string} targetFile 最终文件存放路径
 * @param {string} chunkDir 分片临时存放目录
 * @param {number} chunkCount 分片总数
 */
async function mergeFileChunks(targetFile, chunkDir, chunkCount) {
    // 1. 创建一个可写流,准备写入最终文件
    // flags: 'a' 表示追加模式,如果文件已存在,就在末尾接着写
    const writeStream = fs.createWriteStream(targetFile, { flags: 'a' });

    for (let i = 0; i < chunkCount; i++) {
        const chunkPath = path.join(chunkDir, `${i}.part`);
        
        // 2. 依次读取每个分片
        const readStream = fs.createReadStream(chunkPath);

        // 3. 核心:通过 Promise 封装,确保"按顺序"合并
        // 必须等第 0 片合完了,才能合第 1 片,否则文件内容会错乱
        await new Promise((resolve, reject) => {
            // 将读取流的内容导向写入流
            // 注意:end: false 表示读完这一个分片后,不要关闭目标文件写入流
            readStream.pipe(writeStream, { end: false });
            
            readStream.on('end', () => {
                // 读取完后,删除这个临时分片(节省空间)
                fs.unlinkSync(chunkPath); 
                resolve();
            });
            
            readStream.on('error', (err) => {
                reject(err);
            });
        });
    }

    // 4. 所有分片读完了,手动关闭写入流
    writeStream.end();
    console.log("合并完成!");
}

为什么用Stream而不是readFileSync?

  • readFileSync会将整个文件加载到内存,大文件会爆内存
  • Stream是流式处理,内存占用固定
  • 性能更好,适合处理大文件

吊打面试官:

"在大文件合并的处理上,我绝对不会使用 fs.readFileSync。我会使用 Node.js 的 Stream API。

具体的实现思路是:

首先,创建一个 WriteStream 指向最终文件。

然后,遍历所有分片,通过 createReadStream 逐个读取。

关键点在于利用 pipe 管道将读流导向写流。为了保证文件的正确性,我会通过 Promise 包装实现串行合并。同时,设置 pipe 的 end 参数为 false,确保写入流在合并过程中不被提前关闭。

这种做法的优势在于:利用了 Stream 的背压机制,内存占用极低(通常只有几十 KB),即便是在低配置的服务器上,也能稳定合并几十 GB 的大文件。"

第八层:MySQL 表结构设计 (实战架构)

你需要两张表来支撑这个系统:

文件表 (Files):id, file_md5, file_name, file_url, status(0:上传中, 1:已完成)。 切片记录表 (Chunks):id, file_md5, chunk_index, chunk_name。

查询优化:给 file_md5 加唯一索引,极大提升查询速度。

-- 文件主表
CREATE TABLE files (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) UNIQUE NOT NULL COMMENT '文件MD5',
  file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
  file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
  file_url VARCHAR(500) COMMENT '存储路径',
  status TINYINT DEFAULT 0 COMMENT '状态:0-上传中,1-已完成',
  upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_file_md5 (file_md5)
);

-- 分片记录表
CREATE TABLE chunks (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) NOT NULL,
  chunk_index INT NOT NULL COMMENT '分片索引',
  chunk_name VARCHAR(100) NOT NULL COMMENT '分片文件名',
  upload_status TINYINT DEFAULT 0 COMMENT '上传状态',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (file_md5) REFERENCES files(file_md5),
  INDEX idx_file_chunk (file_md5, chunk_index)
);

为什么file_md5要加索引?

  • 秒传查询时需要快速定位文件是否存在
  • 断点续传时需要快速获取某个文件的所有分片
  • 提升查询性能,避免全表扫描

一、 为什么要这么建表?(核心痛点)

如果不存数据库,只存本地文件系统,你会面临三个死穴:

断点续传没依据: 用户刷新网页,前端内存丢了,怎么知道哪些片传过?必须查库。

秒传无法实现: 10GB 的文件,怎么瞬间判断服务器有没有?全盘扫描物理文件?太慢!必须查索引。

并发合并风险: 多个请求同时触发合并逻辑怎么办?需要数据库的状态锁(Status)来控制。

二、 表结构详解:它们各司其职

1. 文件元数据表 (files) —— "身份证"

file_md5 (核心): 这是文件的唯一物理标识。不管文件名叫"高清.mp4"还是"学习资料.avi",只要内容一样,MD5 就一样。

status: 标记文件状态。

0 (Uploading): 还没传完或正在合并。

1 (Completed): 已经合并成功,可以直接访问。

file_url: 最终合并后的访问路径。

2. 切片记录表 (chunks) —— "进度表"

chunk_index: 记录这是第几个分片。

关系: 通过 file_md5 关联。一个 files 记录对应多个 chunks 记录。

三、 怎么建立联系?(逻辑关联图)

一对多关系:

files.file_md5 (1) <————> chunks.file_md5 (N)

秒传逻辑:

前端传 MD5 给后端。

SQL: SELECT file_url FROM files WHERE file_md5 = 'xxx' AND status = 1;

结果: 有记录?直接返回 URL(秒传成功)。没记录?进入下一步。

续传逻辑:

前端问:"这个 MD5 我传了多少了?"

SQL: SELECT chunk_index FROM chunks WHERE file_md5 = 'xxx';

结果: 后端返回 [0, 1, 2, 5],前端发现少了 3 和 4,于是只补传 3 和 4。

四、 关键思路与实战 SQL

1. 为什么加唯一索引(Unique Index)?

file_md5 必须是 UNIQUE。

面试点: "我给 file_md5 加了唯一索引,这不仅是为了查询快,更是一种业务兜底。在高并发下,如果两个用户同时上传同一个文件,数据库的唯一约束能防止产生两条重复的文件记录。"

2. 复合索引优化

在 chunks 表,我建议建立复合索引:INDEX idx_md5_index (file_md5, chunk_index)。

原因: 续传查询时,我们经常要查"某个 MD5 下的索引情况",复合索引能让这种搜索达到毫秒级。

五、 全流程演练(怎么干)

第一步:初始化 (Pre-check)

用户选好文件,计算 MD5,发给后端。

-- 尝试插入主表,如果 MD5 已存在则忽略(或返回已存在记录)
INSERT IGNORE INTO files (file_md5, file_name, file_size, status) 
VALUES ('abc123hash', 'video.mp4', 102400, 0);

第二步:分片上传 (Chunk Upload)

每收到一个片,存入 chunks 表。

-- 记录已收到的分片
INSERT INTO chunks (file_md5, chunk_index, chunk_name) 
VALUES ('abc123hash', 0, 'abc123hash_0.part');

第三步:合并触发 (Merge)

前端发合并指令,后端校验分片数量。

-- 检查分片是否齐全
SELECT COUNT(*) FROM chunks WHERE file_md5 = 'abc123hash';
-- 如果 Count == 总片数,开始 Stream 合并

第四步:完工 (Finish)

合并成功,更新主表。

-- 更新状态和最终路径
UPDATE files SET status = 1, file_url = '/uploads/2023/video.mp4' 
WHERE file_md5 = 'abc123hash';

六、 总结话术(吊打面试官版)

"我的数据库设计核心思路是 '内容标识胜于文件标识'。

我通过 files 表存储文件的 MD5 和全局状态,配合 UNIQUE 索引实现秒传的快速检索和并发控制。

通过 chunks 表记录每一个分片的到达状态,实现断点续传。

值得注意的细节是:

我使用了 BIGINT 来存储 file_size,因为 4GB 以上的文件 INT 会溢出。

我给 file_md5 做了索引优化,确保在百万级文件记录中,校验文件状态依然是 O(1) 的复杂度。

合并逻辑完成后,我会通过事务或状态锁更新 status 字段,确保数据的一致性。"

第九层:上帝视角 —— 并发控制 (Concurrency Control)

这一层是面试中的高光时刻。如果说 MD5 是为了"准确",Stream 是为了"稳健",那么并发控制就是为了"平衡"。

如果一个文件切了 1000 片,浏览器瞬间发出 1000 个请求,会导致浏览器崩溃或服务器宕机。

面试加分项:异步并发限制队列。

限制同时只有 6 个请求在跑(Chrome 的默认限制)。

async function sendRequest(tasks, limit = 6) {
    const pool = new Set();
    for (const task of tasks) {
        const promise = task();
        pool.add(promise);
        promise.then(() => pool.delete(promise));
        if (pool.size >= limit) await Promise.race(pool);
    }
}

为什么限制6个并发?

  • 浏览器对同一域名有最大连接数限制(通常为6)
  • 避免过多HTTP请求导致网络拥堵
  • 平衡上传速度和系统稳定性

一、 为什么要搞并发控制?(痛点)

  1. 浏览器"自保"机制: Chrome 浏览器对同一个域名的 HTTP 连接数有限制(通常是 6 个)。 如果你瞬间发起 1000 个请求,剩下的 994 个会处于 Pending(排队)状态。虽然不会直接崩溃,但会阻塞该域名的其他所有请求(比如你同时想加载一张图片,都要排在 900 多个切片后面)。

  2. 内存与性能压力: 前端: 1000 个 Promise 对象被创建,会瞬间吃掉大量内存。 后端(Node): 服务器瞬间接收 1000 个并发连接,磁盘 IO 会被占满,CPU 可能会飙升,甚至触发服务器的拒绝服务保护。

二、 什么是并发控制?(本质定义)

并发控制(Concurrency Control) 就像是一个"十字路口的红绿灯"或者"银行的排号机"。

它不改变任务总量。

它控制同一时刻正在运行的任务数量。

三、 怎么干?(核心逻辑公式)

我们要实现一个"工作池(Pool)",逻辑如下:

填满: 先一次性发出 limit(比如 6 个)个请求。

接替: 只要这 6 个请求中任何一个完成了,空出一个位子,就立刻补上第 7 个。

循环: 始终保持有 6 个请求在跑,直到 1000 个全部发完。

四、 代码详解:这 10 行代码值 5k 薪资

这是利用 Promise.race 实现的极其精妙的方案。

/**
 * @param {Array} tasks - 所有的上传任务(函数数组,执行函数才发请求)
 * @param {number} limit - 最大并发数
 */
async function sendRequest(tasks, limit = 6) {
    const pool = new Set(); // 正在执行的任务池
    const results = [];     // 存储所有请求结果

    for (const task of tasks) {
        // 1. 开始执行任务 (task 是一个返回 Promise 的函数)
        const promise = task();
        results.push(promise);
        pool.add(promise);

        // 2. 任务执行完后,从池子里删掉自己
        promise.then(() => pool.delete(promise));

        // 3. 核心:如果池子满了,就等最快的一个完成
        if (pool.size >= limit) {
            // Promise.race 会在 pool 中任何一个 promise 完成时 resolve
            await Promise.race(pool);
        }
    }

    // 4. 等最后剩下的几个也跑完
    return Promise.all(results);
}

五、 关键思路拆解(给面试官讲透)

  1. 为什么要传入 tasks 函数数组,而不是 Promise 数组? 回答: "因为 Promise 一旦创建就会立即执行。如果我传 [axios(), axios()],那并发控制就没意义了。我必须传 [() => axios(), () => axios()],这样我才能在循环里手动控制什么时候执行它。"

  2. Promise.race 起到了什么作用? 回答: "它充当了'阻塞器'。当池子满了,await Promise.race(pool) 会让 for 循环停下来。只有当池子里最快的一个请求完成了,race 才会解除阻塞,循环继续,从而发起下一个请求。"

  3. 为什么是 6 个? 回答: "这是基于 RFC 2616 标准建议和主流浏览器(Chrome/Firefox)的默认限制。超过 6 个,浏览器也会让剩下的排队。所以我们将并发数设为 6,既能榨干带宽,又能保持浏览器的响应顺畅。"

六、 进阶:如果请求失败了怎么办?(断点续传的结合)

在实战中,我们还需要加上重试机制。

// 伪代码:带重试的 task
const createTask = (chunk) => {
    return async () => {
        let retries = 3;
        while (retries > 0) {
            try {
                return await axios.post('/upload', chunk);
            } catch (err) {
                retries--;
                if (retries === 0) throw err;
            }
        }
    };
};

七、 总结

"在大文件上传场景下,盲目发起成百上千个切片请求会导致浏览器网络层阻塞和服务器压力过大。

我的解决方案是实现一个 '异步并发控制队列'。

核心思想是利用 Promise.race。我将并发数限制在 6 个。在 for 循环中,我维护一个 Set 结构的执行池。每当一个切片请求开始,就加入池子;完成后移出。当池子达到限制数时,利用 await Promise.race 阻塞循环,实现**'走一个,补一个'**的动态平衡。

这样做不仅遵守了浏览器的连接限制,更重要的是保证了前端页面的流畅度和后端 IO 的稳定性。如果遇到失败的请求,我还会配合重试逻辑和断点续传记录,确保整个上传过程的强壮性。"

第十层:终极回答策略 (架构师收网版)

如果面试官让你总结一套"完美的文件上传方案",请按照五个维度深度收网:

用户体验层 (Performance):

采用 Web Worker 开启后台线程,配合 Spark-MD5 实现增量哈希计算。

核心亮点:通过"分块读取 -> 增量累加"策略避免 4GB+ 大文件导致的浏览器 OOM(内存溢出),并利用 Worker 的非阻塞特性确保 UI 响应始终保持 60fps。对于超大文件,可选抽样哈希方案,秒级生成指纹。

传输策略层 (Strategy):

秒传:上传前预检 MD5,实现"内容即路径"的瞬间完成。

断点续传:以后端存储为准,通过接口查询 MySQL 中已存在的 chunk_index,前端执行 filter 增量上传。

并发控制:手写异步并发池,利用 Promise.race 实现"走一个,补一个"的槽位控制(限制 6 个并发),既榨干带宽又防止 TCP 阻塞及服务器 IO 爆表。

后端处理层 (Processing):

流式合并:放弃 fs.readFile,坚持使用 Node.js Stream (pipe)。

核心亮点:利用 WriteStream 的追加模式与读流对接,通过 Promise 串行化 保证切片顺序,并依靠 Stream 的背压(Backpressure)机制自动平衡读写速度,将内存占用稳定在 30MB 左右。

持久化设计层 (Database):

文件元数据管理:MySQL 记录文件 MD5、状态与最终存储 URL。

查询优化:给 file_md5 加 Unique Index(唯一索引),不仅提升秒传查询效率,更在数据库层面兜底高并发下的重复写入风险。

安全防护层 (Security):

二进制校验:不信任前端后缀名,后端读取文件流前 8 字节的 Magic Number(魔数) 校验二进制头,防止伪造后缀的木马攻击。

总结话术:一分钟"吊打"面试官

"在处理大文件上传时,我的核心思路是 '分而治之' 与 '状态持久化'。

在前端层面,我通过 Web Worker 配合增量哈希 解决了计算大文件 MD5 时的 UI 阻塞和内存溢出问题。利用 Blob.slice 实现逻辑切片后,我没有盲目发起请求,而是设计了一个基于 Promise.race 的并发控制队列,在遵守浏览器 TCP 限制的同时,保证了传输的平稳性。

在状态管理上,我采用 '后端驱动'的断点续传方案。前端在上传前会通过接口查询 MySQL 获取已上传分片列表,这种方案比 localStorage 更可靠,且天然支持跨设备续传。

在后端处理上,我深度使用了 Node.js 的 Stream 流 进行分片合并。通过管道模式与背压处理,我确保了服务器在处理几十 GB 数据时,内存水位依然保持在极低范围。

在安全性与严谨性上,我通过 MySQL 唯一索引 处理并发写冲突,通过 文件头二进制校验 过滤恶意文件。

这套方案不仅是功能的堆砌,更是对浏览器渲染机制、网络拥塞控制、内存管理以及服务器 IO 瓶颈的综合优化方案。"

💡 面试官可能追问的"补丁"

追问:如果合并过程中服务器断电了怎么办?

回答: 由于我们是通过数据库记录 status 的,合并未完成前 status 始终为 0。服务器重启后,可以根据 status=0 且切片已齐全的记录,重新触发合并任务,或者由前端下次触发预检时重新合并。

追问:切片大小设为多少合适?

回答: 通常建议 2MB - 5MB。太小会导致请求碎片过多(HTTP 头部开销大),太大容易触发网络波动导致的单个请求失败。

追问:上传过程中进度条如何实现?

回答: 两个维度。一是 MD5 进度(Worker 返回),二是上传进度(使用 axios 的 onUploadProgress 监控每个分片,结合已上传分片数量计算加权平均进度)。

❌