大文件上传和下载方案
问题
如何实现一个完善的大文件上传和下载方案?需要考虑哪些问题?
大文件上传/下载要解决哪些问题? 核心痛点四个:
- 内存爆:一次性
readAsArrayBuffer几个 G 的文件直接 OOM。 - 网络断:传 80% 网络抖一下又得从头来。
- 没反馈:用户不知道还要多久、能不能继续。
- 服务端压力:长连接占资源,Nginx 默认
client_max_body_size也兜不住。
完整方案怎么落地? 完整方案是上传会话 + 分片 + 完整性校验 + 可恢复状态 + 有界并发:
- 协议先行:服务端创建 upload session,返回
uploadId、分片规则和过期时间;优先使用对象存储 Multipart Upload、tus 等成熟协议,避免每个项目自造“检查、合并”接口。 - 分片:用
File.slice()按协议切分。5MB 可以作为某些后端的示例起点,但分片大小要结合文件体积、网络 RTT、服务端限制和重试成本调整。 - 完整性:使用服务端或对象存储支持的 checksum;MD5 可以做非安全去重或兼容校验,但不能承担抗碰撞的安全身份。需要安全语义时使用 SHA-256 等算法。
- 续传:服务端是已接收分片的权威来源;浏览器只保存
uploadId等轻量状态,刷新后仍需用户重新授权文件并核对文件指纹。 - 并发与背压:例如从 3 个并发开始,根据吞吐、失败率、网络类型、服务端 429/503 和设备资源动态调整;重试要指数退避并带随机抖动。
- 下载:浏览器原生下载最省内存;只有确实需要暂停、校验或局部读取时才自行处理 Range。超大文件应流式写入文件系统或交给原生下载器,不能先在内存拼成一个 Blob。
答案
大文件传输面临的挑战:
- 内存占用:一次性加载大文件会导致内存溢出
- 网络不稳定:传输中断需要重新开始
- 用户体验:没有进度反馈,用户不知道状态
- 服务器压力:单个大请求占用连接时间长
解决方案的核心思路:有状态的传输协议 + 分片 + 完整性校验 + 断点续传 + 背压与并发控制。
下文自建接口用于解释协议组成,不是可直接上线的文件服务。生产系统还必须处理鉴权、配额、幂等、校验、恶意文件、路径安全、过期分片清理、对象存储一致性、审计和限流。
大文件上传
整体流程
前端实现
1. 文件切片
interface FileChunk {
file: Blob;
index: number;
hash: string;
}
// 5MB 只是演示默认值;生产由服务端上传会话返回分片规则
function createFileChunks(file: File, chunkSize = 5 * 1024 * 1024): FileChunk[] {
const chunks: FileChunk[] = [];
let cur = 0;
let index = 0;
while (cur < file.size) {
chunks.push({
file: file.slice(cur, cur + chunkSize),
index: index++,
hash: '', // 后续计算
});
cur += chunkSize;
}
return chunks;
}
2. 计算文件指纹(用于去重候选和恢复核对)
使用 Web Worker 避免阻塞主线程:
// worker.ts
import SparkMD5 from 'spark-md5';
// 该 MD5 仅作为兼容示例,不具备抗碰撞的安全身份语义。
// 如果后端要求 SHA-256/CRC 等 checksum,应与存储协议使用同一种算法。
self.onmessage = async (e: MessageEvent<{ chunks: Blob[] }>) => {
const { chunks } = e.data;
const spark = new SparkMD5.ArrayBuffer();
let percentage = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const buffer = await chunk.arrayBuffer();
spark.append(buffer);
percentage = Math.floor(((i + 1) / chunks.length) * 100);
self.postMessage({ percentage });
}
self.postMessage({ hash: spark.end() });
};
function calculateHash(chunks: Blob[]): Promise<string> {
return new Promise((resolve) => {
const worker = new Worker(new URL('./hashWorker.ts', import.meta.url));
worker.onmessage = (e) => {
if (e.data.hash) {
resolve(e.data.hash);
worker.terminate();
}
};
worker.postMessage({ chunks });
});
}
3. 检查秒传和已上传分片
interface CheckResult {
shouldUpload: boolean;
uploadedChunks: number[];
}
async function checkFileStatus(
fileHash: string,
fileName: string
): Promise<CheckResult> {
const response = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash, fileName }),
});
return response.json();
}
4. 并发上传分片(带重试)
interface UploadOptions {
fileHash: string;
fileName: string;
chunks: FileChunk[];
uploadedChunks: number[];
concurrency?: number;
onProgress?: (progress: number) => void;
}
async function uploadChunks(options: UploadOptions): Promise<void> {
const {
fileHash,
fileName,
chunks,
uploadedChunks,
concurrency = 3,
onProgress,
} = options;
// 过滤已上传的分片
const pendingChunks = chunks.filter(
(chunk) => !uploadedChunks.includes(chunk.index)
);
let uploadedCount = uploadedChunks.length;
const total = chunks.length;
// 创建上传任务
const uploadTask = async (chunk: FileChunk): Promise<void> => {
const formData = new FormData();
formData.append('file', chunk.file);
formData.append('hash', fileHash);
formData.append('index', String(chunk.index));
formData.append('fileName', fileName);
await fetchWithRetry('/api/upload/chunk', {
method: 'POST',
body: formData,
});
uploadedCount++;
onProgress?.(Math.floor((uploadedCount / total) * 100));
};
// 并发控制
await asyncPool(concurrency, pendingChunks, uploadTask);
}
// 并发池
async function asyncPool<T, R>(
concurrency: number,
items: T[],
fn: (item: T) => Promise<R>
): Promise<R[]> {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error('concurrency must be a positive integer');
}
const results = new Array<R>(items.length);
let cursor = 0;
async function worker(): Promise<void> {
while (true) {
const index = cursor++;
if (index >= items.length) return;
results[index] = await fn(items[index]);
}
}
const workerCount = Math.min(concurrency, items.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
// 带重试的 fetch
async function fetchWithRetry(
url: string,
options: RequestInit,
retries = 3
): Promise<Response> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error('Upload failed');
return response;
} catch (error) {
if (i === retries - 1) throw error;
const backoff = Math.min(1000 * 2 ** i, 30_000);
const jitter = Math.random() * 300;
await new Promise((r) => setTimeout(r, backoff + jitter));
}
}
throw new Error('Max retries exceeded');
}
5. 请求合并分片
async function mergeChunks(
fileHash: string,
fileName: string,
chunkCount: number
): Promise<{ url: string }> {
const response = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash, fileName, chunkCount }),
});
return response.json();
}
6. 完整上传流程
interface UploadResult {
success: boolean;
url?: string;
error?: string;
}
async function uploadFile(
file: File,
onProgress?: (progress: number) => void
): Promise<UploadResult> {
try {
// 1. 切片
const chunks = createFileChunks(file);
// 2. 计算 Hash
const fileHash = await calculateHash(chunks.map((c) => c.file));
// 3. 检查秒传
const { shouldUpload, uploadedChunks } = await checkFileStatus(
fileHash,
file.name
);
if (!shouldUpload) {
onProgress?.(100);
return { success: true, url: `/files/${fileHash}` };
}
// 4. 上传分片
await uploadChunks({
fileHash,
fileName: file.name,
chunks,
uploadedChunks,
onProgress,
});
// 5. 合并分片
const { url } = await mergeChunks(fileHash, file.name, chunks.length);
return { success: true, url };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
后端实现(Node.js + Express)
1. 检查文件状态
import { Request, Response } from 'express';
import fs from 'fs/promises';
import path from 'path';
const UPLOAD_DIR = path.resolve(__dirname, '../uploads');
const CHUNK_DIR = path.resolve(__dirname, '../chunks');
export async function checkFile(req: Request, res: Response): Promise<void> {
const { fileHash, fileName } = req.body;
const ext = typeof fileName === 'string' ? path.extname(fileName).toLowerCase() : '';
if (!/^[a-f0-9]{32,64}$/i.test(fileHash) || !/^\.[a-z0-9]{1,10}$/.test(ext)) {
res.status(400).json({ error: 'Invalid file fingerprint' });
return;
}
// 检查文件是否已存在(秒传)
const filePath = path.join(UPLOAD_DIR, `${fileHash}${ext}`);
try {
await fs.access(filePath);
res.json({ shouldUpload: false, uploadedChunks: [] });
return;
} catch {
// 文件不存在,继续检查分片
}
// 检查已上传的分片
const chunkDir = path.join(CHUNK_DIR, fileHash);
let uploadedChunks: number[] = [];
try {
const files = await fs.readdir(chunkDir);
uploadedChunks = files.map((f) => parseInt(f.split('-')[1], 10));
} catch {
// 没有已上传的分片
}
res.json({ shouldUpload: true, uploadedChunks });
}
2. 接收分片
import { Request, Response } from 'express';
import fs from 'fs/promises';
import path from 'path';
const CHUNK_DIR = path.resolve(__dirname, '../chunks');
export async function uploadChunk(req: Request, res: Response): Promise<void> {
const { hash, index, fileName } = req.body;
const file = req.file;
if (!/^[a-f0-9]{32,64}$/i.test(hash) || !/^\d+$/.test(index)) {
res.status(400).json({ error: 'Invalid upload metadata' });
return;
}
if (!file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const chunkDir = path.join(CHUNK_DIR, hash);
// 确保分片目录存在
await fs.mkdir(chunkDir, { recursive: true });
// 移动分片到目标位置
const chunkPath = path.join(chunkDir, `chunk-${index}`);
await fs.rename(file.path, chunkPath);
res.json({ success: true });
}
3. 合并分片
import { Request, Response } from 'express';
import fs from 'fs/promises';
import { createWriteStream, createReadStream } from 'fs';
import path from 'path';
const UPLOAD_DIR = path.resolve(__dirname, '../uploads');
const CHUNK_DIR = path.resolve(__dirname, '../chunks');
export async function mergeChunks(req: Request, res: Response): Promise<void> {
const { fileHash, fileName, chunkCount } = req.body;
const ext = typeof fileName === 'string' ? path.extname(fileName).toLowerCase() : '';
if (
!/^[a-f0-9]{32,64}$/i.test(fileHash) ||
!/^\.[a-z0-9]{1,10}$/.test(ext) ||
!Number.isInteger(chunkCount) ||
chunkCount < 1
) {
res.status(400).json({ error: 'Invalid merge metadata' });
return;
}
const chunkDir = path.join(CHUNK_DIR, fileHash);
const filePath = path.join(UPLOAD_DIR, `${fileHash}${ext}`);
// 确保上传目录存在
await fs.mkdir(UPLOAD_DIR, { recursive: true });
// 创建写入流
const writeStream = createWriteStream(filePath);
// 按顺序流式合并分片,避免把每个分片完整读入 Node.js 内存
for (let i = 0; i < chunkCount; i++) {
const chunkPath = path.join(chunkDir, `chunk-${i}`);
await new Promise<void>((resolve, reject) => {
const readStream = createReadStream(chunkPath);
readStream.on('error', reject);
readStream.on('end', resolve);
readStream.pipe(writeStream, { end: false });
});
}
writeStream.end();
// 等待写入完成
await new Promise((resolve) => writeStream.on('finish', resolve));
// 删除分片目录
await fs.rm(chunkDir, { recursive: true });
res.json({ success: true, url: `/files/${fileHash}${ext}` });
}
4. 路由配置
import express from 'express';
import multer from 'multer';
import { checkFile } from './checkController';
import { uploadChunk } from './chunkController';
import { mergeChunks } from './mergeController';
const router = express.Router();
const upload = multer({ dest: 'temp/' });
router.post('/upload/check', checkFile);
router.post('/upload/chunk', upload.single('file'), uploadChunk);
router.post('/upload/merge', mergeChunks);
export default router;
大文件下载
整体流程
前端实现
1. 获取文件信息
interface FileInfo {
size: number;
name: string;
contentType: string;
}
async function getFileInfo(url: string): Promise<FileInfo> {
const response = await fetch(url, { method: 'HEAD' });
const size = parseInt(response.headers.get('content-length') || '0', 10);
const contentType = response.headers.get('content-type') || '';
const disposition = response.headers.get('content-disposition') || '';
// 从 Content-Disposition 提取文件名
const nameMatch = disposition.match(/filename="?([^"]+)"?/);
const name = nameMatch ? nameMatch[1] : 'download';
return { size, name, contentType };
}
2. 分片下载
async function downloadChunk(
url: string,
start: number,
end: number
): Promise<ArrayBuffer> {
const response = await fetch(url, {
headers: {
Range: `bytes=${start}-${end}`,
},
});
if (response.status !== 206) {
// 服务器忽略 Range 时可能返回 200 和完整文件,不能把它误当成当前分片。
throw new Error(`Range is not supported: ${response.status}`);
}
const contentRange = response.headers.get('content-range');
if (!contentRange?.startsWith(`bytes ${start}-${end}/`)) {
throw new Error('Unexpected Content-Range');
}
return response.arrayBuffer();
}
3. 并发下载与合并
interface DownloadOptions {
url: string;
chunkSize?: number;
concurrency?: number;
onProgress?: (progress: number) => void;
}
async function downloadFile(options: DownloadOptions): Promise<Blob> {
const {
url,
chunkSize = 5 * 1024 * 1024,
concurrency = 3,
onProgress,
} = options;
// 1. 获取文件信息
const fileInfo = await getFileInfo(url);
const { size, contentType } = fileInfo;
// 2. 计算分片
const chunks: Array<{ start: number; end: number; index: number }> = [];
let cur = 0;
let index = 0;
while (cur < size) {
const end = Math.min(cur + chunkSize - 1, size - 1);
chunks.push({ start: cur, end, index: index++ });
cur = end + 1;
}
// 3. 并发下载
let downloadedCount = 0;
const results: ArrayBuffer[] = new Array(chunks.length);
const downloadTask = async (
chunk: { start: number; end: number; index: number }
): Promise<void> => {
const buffer = await downloadChunk(url, chunk.start, chunk.end);
results[chunk.index] = buffer;
downloadedCount++;
onProgress?.(Math.floor((downloadedCount / chunks.length) * 100));
};
await asyncPool(concurrency, chunks, downloadTask);
// 4. 合并为 Blob
return new Blob(results, { type: contentType });
}
// 触发浏览器下载
function triggerDownload(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
这个 Blob 合并示例只适合内存可承受的文件,因为所有 ArrayBuffer 和最终 Blob 会同时占用大量内存。真正的大文件优先让浏览器直接导航到带鉴权的下载 URL;必须自定义下载时,在支持的浏览器中使用文件选择器获得可写流,或通过桌面客户端/原生下载能力落盘,并为不支持的环境提供降级。
4. 支持断点续传的下载
interface DownloadState {
url: string;
fileName: string;
totalSize: number;
downloadedChunks: number[];
chunks: ArrayBuffer[];
}
class ResumableDownloader {
private state: DownloadState | null = null;
private storageKey = 'download_state';
async download(
url: string,
onProgress?: (progress: number) => void
): Promise<void> {
// 恢复之前的下载状态
this.loadState();
const fileInfo = await getFileInfo(url);
const chunkSize = 5 * 1024 * 1024;
if (!this.state || this.state.url !== url) {
this.state = {
url,
fileName: fileInfo.name,
totalSize: fileInfo.size,
downloadedChunks: [],
chunks: [],
};
}
const totalChunks = Math.ceil(fileInfo.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
if (this.state.downloadedChunks.includes(i)) {
continue;
}
const start = i * chunkSize;
const end = Math.min(start + chunkSize - 1, fileInfo.size - 1);
try {
const buffer = await downloadChunk(url, start, end);
this.state.chunks[i] = buffer;
this.state.downloadedChunks.push(i);
this.saveState();
onProgress?.(
Math.floor((this.state.downloadedChunks.length / totalChunks) * 100)
);
} catch (error) {
console.error(`Chunk ${i} download failed, will retry later`);
throw error;
}
}
// 下载完成,合并并触发下载
const blob = new Blob(this.state.chunks, {
type: fileInfo.contentType,
});
triggerDownload(blob, this.state.fileName);
this.clearState();
}
private saveState(): void {
if (!this.state) return;
// 注意:ArrayBuffer 不能直接序列化,实际项目中需要存储到 IndexedDB
const saveData = {
...this.state,
chunks: [], // 实际项目中应使用 IndexedDB 存储
};
localStorage.setItem(this.storageKey, JSON.stringify(saveData));
}
private loadState(): void {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
this.state = JSON.parse(saved);
}
}
private clearState(): void {
this.state = null;
localStorage.removeItem(this.storageKey);
}
}
上面的类只展示“恢复状态”需要哪些字段,并不能直接持久化分片:ArrayBuffer 不能存进 localStorage,而把几个 GB 的内容集中保存在 IndexedDB 也可能遇到配额、逐出和双份磁盘占用。生产实现必须记录资源版本(ETag 或 Last-Modified),恢复请求时发送 If-Range;如果源文件已变化,服务端会返回 200,此时应丢弃旧分片并重新开始,不能继续拼接。
后端实现
支持 Range 请求
import { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
const UPLOAD_DIR = path.resolve(__dirname, '../uploads');
export function downloadFile(req: Request, res: Response): void {
const { fileHash } = req.params;
if (!/^[a-f0-9]{32,64}(?:\.[a-z0-9]{1,10})?$/i.test(fileHash)) {
res.status(400).json({ error: 'Invalid file id' });
return;
}
const filePath = path.join(UPLOAD_DIR, fileHash);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'File not found' });
return;
}
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
// 处理 Range 请求
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (
!Number.isInteger(start) ||
!Number.isInteger(end) ||
start < 0 ||
end < start ||
end >= fileSize
) {
res.status(416).set('Content-Range', `bytes */${fileSize}`).end();
return;
}
const chunkSize = end - start + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': 'application/octet-stream',
});
const stream = fs.createReadStream(filePath, { start, end });
stream.pipe(res);
} else {
// 普通下载
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
});
fs.createReadStream(filePath).pipe(res);
}
}
完整的上传组件示例
import React, { useState, useCallback } from 'react';
interface UploadState {
status: 'idle' | 'hashing' | 'uploading' | 'success' | 'error';
progress: number;
error?: string;
}
export function FileUploader(): React.ReactElement {
const [state, setState] = useState<UploadState>({
status: 'idle',
progress: 0,
});
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setState({ status: 'hashing', progress: 0 });
const result = await uploadFile(file, (progress) => {
setState((prev) => ({
...prev,
status: 'uploading',
progress,
}));
});
if (result.success) {
setState({ status: 'success', progress: 100 });
} else {
setState({ status: 'error', progress: 0, error: result.error });
}
} catch (error) {
setState({
status: 'error',
progress: 0,
error: (error as Error).message,
});
}
},
[]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={state.status === 'uploading' || state.status === 'hashing'}
/>
{state.status === 'hashing' && <p>计算文件 Hash 中...</p>}
{state.status === 'uploading' && (
<div>
<progress value={state.progress} max={100} />
<span>{state.progress}%</span>
</div>
)}
{state.status === 'success' && <p>上传成功!</p>}
{state.status === 'error' && <p>上传失败:{state.error}</p>}
</div>
);
}
生产级上传协议
推荐把上传设计成一个有生命周期的服务端状态机:
- 创建会话:客户端提交文件名、大小、媒体类型和可选 checksum;服务端鉴权、检查配额,返回不可猜测的
uploadId、分片约束、过期时间和已签名上传地址。 - 上传分片:请求携带
uploadId、part number、长度、幂等键和 checksum。重复上传同一分片要么覆盖为同一结果,要么明确拒绝,不能产生两个未知版本。 - 查询恢复:服务端返回已确认分片及其 checksum/ETag;本地记录只用于快速找到会话,不作为权威结果。
- 完成上传:服务端验证总大小、分片连续性和整体或组合 checksum,再原子地把对象标记为可用。重复调用完成接口应返回同一个结果。
- 取消与过期:用户取消、会话过期或校验失败时,清理未完成分片;对象存储 Multipart Upload 未完成时也可能持续计费。
- 后处理:病毒扫描、内容审核、解压和转码应异步执行,文件在通过校验前处于隔离状态。
对象存储场景通常让浏览器直接上传到短期签名 URL,业务服务只负责创建会话、鉴权和完成确认,避免大流量穿过应用服务器。签名必须限制对象键、方法、大小、媒体类型和有效期,并防止客户端覆盖其他用户的对象。
分片与并发如何自适应
固定 5MB、固定 3~5 并发可以作为实验初值,但不能当规范:
- 小文件直接单请求,避免初始化、hash 和合并的额外往返。
- 高 RTT 网络可适当增大片段减少请求数;失败率高时较小分片能降低重传成本。
- 收到 429/503、超时或失败率升高时降低并发;连续成功且链路未饱和时再谨慎提高。
- 页面切到后台、设备低电量或网络切换时允许暂停;AbortController 取消在途请求,但要保留已确认进度。
- 进度按已发送字节和服务端已确认字节区分,hash、上传、合并/处理也应显示为不同阶段。
安全检查清单
- 文件名只用于展示,存储键由服务端生成;防止路径穿越、覆盖和 Unicode 混淆。
- 不信任扩展名和客户端
Content-Type,服务端检查魔数、大小、压缩炸弹和允许类型。 - 对用户、租户、IP 和会话设置文件数、总量、并发与速率配额。
- 私有文件通过短期授权下载,并记录审计;响应设置安全的
Content-Disposition与X-Content-Type-Options: nosniff。 - “秒传”必须验证当前用户是否有权引用已存在对象,不能因 hash 命中泄露文件存在性或跨租户越权。
优化策略总结
上传优化
| 策略 | 说明 |
|---|---|
| 分片上传 | 大文件切成小块,单个失败只需重传该片 |
| 秒传 | 通过 Hash 检测文件是否已存在 |
| 断点续传 | 记录已上传分片,断网后继续 |
| 并发控制 | 限制同时上传的分片数量 |
| Web Worker | 计算 Hash 不阻塞主线程 |
| 重试机制 | 失败自动重试,指数退避 |
下载优化
| 策略 | 说明 |
|---|---|
| Range 请求 | 支持分段下载 |
| 并发下载 | 多个分片同时下载 |
| 断点续传 | 保存下载进度,中断后继续 |
| 流式下载 | 边下载边写入,减少内存占用 |
常见面试问题
Q1: 如何实现秒传?
先说结论:秒传本质是“服务端已有同内容对象时复用”,不是客户端说一个 hash 就直接成功。
- 客户端可以计算文件指纹,服务端结合大小等信息查询候选对象。
- 命中后仍要检查当前用户或租户是否有权引用,避免泄露文件存在性和跨租户越权。
- MD5 可用于兼容去重,但有碰撞风险;完整性和安全身份应使用存储服务支持的 checksum 或 SHA-256 等方案。
- 去重策略还要考虑加密、数据保留、删除语义和隐私要求,有些业务不允许跨用户去重。
Q2: 如何保证分片的顺序和完整性?
- 顺序:每个分片带有
index标识,合并时按顺序读取 - 完整性:可以对每个分片计算 Hash,服务端验证
更完整的做法是由服务端保存 uploadId -> part number -> checksum/ETag/size,完成时验证分片集合、总大小和整体或组合 checksum。完成接口要幂等,避免网络重试生成重复对象。
Q3: 如果用户刷新页面,如何恢复上传?
- 本地保存
uploadId、文件指纹、大小、最后修改时间和过期时间等轻量元数据。 - 刷新后网页不能凭路径重新读取本地文件,通常要让用户重新选择文件;获得授权后核对指纹。
- 向服务端查询已确认分片,以服务端状态为准,只上传缺失部分。
- 如果会话过期、文件变化或签名失效,创建新会话;不要把本地数组当权威进度。
Q4: 并发数设置多少合适?
没有跨项目通用数字。举例可以从 3 个并发开始,在真实网络和服务端配额下观察吞吐、RTT、错误率、内存和 429/503:成功稳定且链路未饱和时逐步增加,失败或限流时降低。HTTP 版本、分片大小、设备性能和对象存储约束都会改变最优值。
Q5: 为什么大文件下载不建议在浏览器里合并 Blob?
因为分片缓冲区、Blob 和浏览器内部复制可能同时占内存,文件越大越容易 OOM;IndexedDB 还可能受到配额和逐出影响。优先用原生下载。如果必须自定义暂停和校验,应使用 Range + ETag/If-Range,并流式写入文件目标;不支持文件写入 API 时提供原生下载降级。
Q6: Range 续传最容易漏掉什么?
服务端必须返回 206 和正确的 Content-Range。恢复时还要带 ETag 或 Last-Modified 作为 If-Range 条件;如果资源变化,服务端可能返回 200 完整内容,客户端要清空旧分片重新下载,不能把 200 响应误当成当前分片。
Q7: 为什么优先使用对象存储 Multipart Upload 或 tus?
因为成熟协议已经处理了上传会话、分片编号、恢复、完成、取消和校验等边界。自建方案仍可做,但要承担幂等、清理、配额、安全和多实例一致性。对象存储直传还能让大流量绕过业务服务器,业务层只负责授权与状态确认。
Q8: 上传重试怎样避免重复数据?
每个分片请求带稳定的 uploadId、part number 和幂等语义。服务端重复收到同一分片时校验 checksum,返回既有结果或安全覆盖;完成接口也要幂等。重试只针对可重试错误,采用指数退避和抖动,并尊重 Retry-After,鉴权失败和参数错误不应盲目重试。