跳到主要内容

大文件上传和下载方案

问题

如何实现一个完善的大文件上传和下载方案?需要考虑哪些问题?

面试速答版

大文件上传/下载要解决哪些问题? 核心痛点四个:

  • 内存爆:一次性 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. 文件切片

fileChunk.ts
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 避免阻塞主线程:

hashWorker.ts
// 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() });
};
calculateHash.ts
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. 检查秒传和已上传分片

checkFile.ts
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. 并发上传分片(带重试)

uploadChunks.ts
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. 请求合并分片

mergeChunks.ts
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. 完整上传流程

upload.ts
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. 检查文件状态

server/checkController.ts
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. 接收分片

server/chunkController.ts
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. 合并分片

server/mergeController.ts
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. 路由配置

server/routes.ts
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. 获取文件信息

getFileInfo.ts
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. 分片下载

downloadChunk.ts
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. 并发下载与合并

downloadFile.ts
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. 支持断点续传的下载

resumableDownload.ts
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 请求

server/downloadController.ts
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);
}
}

完整的上传组件示例

FileUploader.tsx
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>
);
}

生产级上传协议

推荐把上传设计成一个有生命周期的服务端状态机:

  1. 创建会话:客户端提交文件名、大小、媒体类型和可选 checksum;服务端鉴权、检查配额,返回不可猜测的 uploadId、分片约束、过期时间和已签名上传地址。
  2. 上传分片:请求携带 uploadId、part number、长度、幂等键和 checksum。重复上传同一分片要么覆盖为同一结果,要么明确拒绝,不能产生两个未知版本。
  3. 查询恢复:服务端返回已确认分片及其 checksum/ETag;本地记录只用于快速找到会话,不作为权威结果。
  4. 完成上传:服务端验证总大小、分片连续性和整体或组合 checksum,再原子地把对象标记为可用。重复调用完成接口应返回同一个结果。
  5. 取消与过期:用户取消、会话过期或校验失败时,清理未完成分片;对象存储 Multipart Upload 未完成时也可能持续计费。
  6. 后处理:病毒扫描、内容审核、解压和转码应异步执行,文件在通过校验前处于隔离状态。

对象存储场景通常让浏览器直接上传到短期签名 URL,业务服务只负责创建会话、鉴权和完成确认,避免大流量穿过应用服务器。签名必须限制对象键、方法、大小、媒体类型和有效期,并防止客户端覆盖其他用户的对象。

分片与并发如何自适应

固定 5MB、固定 3~5 并发可以作为实验初值,但不能当规范:

  • 小文件直接单请求,避免初始化、hash 和合并的额外往返。
  • 高 RTT 网络可适当增大片段减少请求数;失败率高时较小分片能降低重传成本。
  • 收到 429/503、超时或失败率升高时降低并发;连续成功且链路未饱和时再谨慎提高。
  • 页面切到后台、设备低电量或网络切换时允许暂停;AbortController 取消在途请求,但要保留已确认进度。
  • 进度按已发送字节和服务端已确认字节区分,hash、上传、合并/处理也应显示为不同阶段。

安全检查清单

  • 文件名只用于展示,存储键由服务端生成;防止路径穿越、覆盖和 Unicode 混淆。
  • 不信任扩展名和客户端 Content-Type,服务端检查魔数、大小、压缩炸弹和允许类型。
  • 对用户、租户、IP 和会话设置文件数、总量、并发与速率配额。
  • 私有文件通过短期授权下载,并记录审计;响应设置安全的 Content-DispositionX-Content-Type-Options: nosniff
  • “秒传”必须验证当前用户是否有权引用已存在对象,不能因 hash 命中泄露文件存在性或跨租户越权。

优化策略总结

上传优化

策略说明
分片上传大文件切成小块,单个失败只需重传该片
秒传通过 Hash 检测文件是否已存在
断点续传记录已上传分片,断网后继续
并发控制限制同时上传的分片数量
Web Worker计算 Hash 不阻塞主线程
重试机制失败自动重试,指数退避

下载优化

策略说明
Range 请求支持分段下载
并发下载多个分片同时下载
断点续传保存下载进度,中断后继续
流式下载边下载边写入,减少内存占用

常见面试问题

Q1: 如何实现秒传?

先说结论:秒传本质是“服务端已有同内容对象时复用”,不是客户端说一个 hash 就直接成功。

  1. 客户端可以计算文件指纹,服务端结合大小等信息查询候选对象。
  2. 命中后仍要检查当前用户或租户是否有权引用,避免泄露文件存在性和跨租户越权。
  3. MD5 可用于兼容去重,但有碰撞风险;完整性和安全身份应使用存储服务支持的 checksum 或 SHA-256 等方案。
  4. 去重策略还要考虑加密、数据保留、删除语义和隐私要求,有些业务不允许跨用户去重。

Q2: 如何保证分片的顺序和完整性?

  1. 顺序:每个分片带有 index 标识,合并时按顺序读取
  2. 完整性:可以对每个分片计算 Hash,服务端验证

更完整的做法是由服务端保存 uploadId -> part number -> checksum/ETag/size,完成时验证分片集合、总大小和整体或组合 checksum。完成接口要幂等,避免网络重试生成重复对象。

Q3: 如果用户刷新页面,如何恢复上传?

  1. 本地保存 uploadId、文件指纹、大小、最后修改时间和过期时间等轻量元数据。
  2. 刷新后网页不能凭路径重新读取本地文件,通常要让用户重新选择文件;获得授权后核对指纹。
  3. 向服务端查询已确认分片,以服务端状态为准,只上传缺失部分。
  4. 如果会话过期、文件变化或签名失效,创建新会话;不要把本地数组当权威进度。

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,鉴权失败和参数错误不应盲目重试。


相关链接