一、引言:当文件上传遇上金融科技
KMS(Knowledge Management System)是公司内部的知识管理平台,承载着各部门的培训视频、合规文档扫描件、产品手册等核心资产。随着业务扩张,用户上传的文件体积从早期的几 MB 变成了动辄 500MB 的培训视频,甚至出现了 2GB 的高清扫描件合集。
问题随之而来。
2025 年 Q2,我们对 KMS 的文件上传模块做了一次全面的数据分析,结果触目惊心:文件体积超过 50MB 后,单文件上传失败率飙升至 18%。这意味着每 5 个上传大文件的用户中,就有近 1 人遭遇失败。更糟糕的是,失败后用户必须从头重传——一个已经传了 80% 的 1GB 文件,因为网络抖动中断,之前传输的全部作废,重试耗时等于首次上传的 100%。
还有一类隐形成本:同一个培训视频被不同部门的同事分别上传了三次,存储空间白白浪费了 3GB,后端去重完全依赖人工沟通。
问题的本质有且只有三个:
本文的目标,是给出一套经过 KMS 生产环境验证的端到端解决方案,用一个统一的架构同时解决上述三个问题。完成阅读后,你将获得:
- 一份可直接落地的分片上传前端代码(React + TypeScript)
- 一套完整的后端 API 设计(路由、分片合并、状态管理)
- 五个生产环境真实踩坑案例及其解法
- 一个可复用的 useChunkedUpload Hook
我们不只聊前端 slice(),而是从浏览器到 Nginx 到后端存储,覆盖全链路。
二、全链路架构总览
2.1 全链路架构图

2.2 上传状态机
一次完整的上传,从文件选择到合并完成,经历以下状态流转:

2.3 关键技术决策一览表
| 分片大小 | 1MB / 5MB / 10MB / 20MB | 5MB | 平衡请求数(200 个/GB)与失败重传成本(5MB/次) |
| 并发数 | 1 / 3 / 6 / 10 | 3 | 规避浏览器同域 6 连接限制,为其他请求留出空间 |
| 哈希算法 | MD5 / SHA-256 / 抽样哈希 | 全量 MD5(SparkMD5) | 与 OSS 兼容,计算速度可接受,Web Worker 异步不阻塞 |
| 秒传机制 | 无 / 仅文件名 / 哈希匹配 | 哈希匹配 | 文件名不可靠,哈希才能保证内容唯一性 |
| 重试策略 | 固定间隔 / 线性退避 / 指数退避 | 指数退避(1s, 2s, 4s) | 避免雪崩,给服务端恢复留出时间 |
| 状态存储 | 内存 / localStorage / 后端 Redis | 后端 Redis + localStorage 兜底 | Redis 保证并发安全,localStorage 防止页面刷新丢失 uploadId |
| 文件存储 | 本地磁盘 / MinIO / 阿里云 OSS | MinIO(生产),本地(开发) | MinIO 兼容 S3 协议,运维成本低,私有化部署满足合规要求 |
三、前端核心实现
3.1 文件分片:为什么是 5MB?
分片大小的选择,是分片上传方案的第一个关键决策。太小,请求数爆炸;太大,重传代价高。在 KMS 项目中,我们做了一个简单的数学建模:
| 100MB | 100 | 20 | 10 | 5 |
| 500MB | 500 | 100 | 50 | 25 |
| 2GB | 2048 | 410 | 205 | 103 |
1MB 分片在 2GB 文件场景下会产生 2048 个 HTTP 请求。每个请求都有 TCP 握手、TLS 协商、HTTP header 等固定开销,累积延迟非常可观。
20MB 分片虽然请求数少,但单个分片上传失败一次就浪费 20MB 的已传输数据,对移动端弱网用户极不友好。
5MB 是我们的甜点值:2GB 文件仅产生 410 个分片请求,单次失败重传成本 5MB,响应速度在可接受范围内。
分片代码如下:
// utils/createChunks.ts
export interface Chunk {
blob: Blob;
index: number;
start: number;
end: number;
}
export const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
export function createChunks(file: File): Chunk[] {
const chunks: Chunk[] = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push({
blob: file.slice(start, end),
index: chunks.length,
start,
end,
});
start = end;
}
return chunks;
}
3.2 哈希计算与秒传逻辑
哈希计算是大文件上传中一个容易被低估的性能陷阱。
抽样哈希 vs 全量哈希
业界有一种常见做法——抽样哈希:只读取文件头部、中部、尾部的各一段数据计算哈希,以大幅降低计算量。这个方案在视频网站的转码场景中很常见。
但 KMS 没有走这条路。原因有二:(1)KMS 知识库存储的是金融合规材料,文件完整性校验不允许任何妥协;(2)抽样哈希存在理论上的碰撞可能——两个不同文件被判定为同一个,导致某个用户的文件被错误地"秒传"到另一个文件上。这是不可接受的风险。
我们在 Web Worker 中计算全量哈希。配合 SparkMD5 的增量计算能力(append()),Worker 每读取一个分片就将数据追加到哈希计算器中,避免一次性将整个文件加载到内存:
// workers/hash.worker.ts
import SparkMD5 from 'spark-md5';
self.onmessage = (e: MessageEvent<File>) => {
const file = e.data;
const spark = new SparkMD5.ArrayBuffer();
const chunkSize = 5 * 1024 * 1024; // 5MB
let offset = 0;
function readNext() {
const slice = file.slice(offset, offset + chunkSize);
const reader = new FileReader();
reader.onload = (ev) => {
spark.append(ev.target!.result as ArrayBuffer);
offset += chunkSize;
if (offset < file.size) {
self.postMessage({ type: 'progress', progress: offset / file.size });
readNext();
} else {
self.postMessage({ type: 'done', hash: spark.end() });
}
};
reader.readAsArrayBuffer(slice);
}
readNext();
};
秒传逻辑非常直接:前端拿到文件哈希后,调用 POST /api/upload/init。后端查询 Redis / DB 判断该哈希对应的文件是否已经存在于存储中——如果存在,直接返回已有的文件 URL,整个上传在毫秒级完成。
// utils/checkSecretUpload.ts
import axios from 'axios';
export interface InitResult {
uploadId: string;
secret: boolean; // true = 秒传命中
url?: string; // 秒传时直接返回文件 URL
uploadedChunks?: number[]; // 断点续传:已上传的分片序号
}
export async function initUpload(
fileHash: string,
fileName: string,
fileSize: number,
totalChunks: number,
): Promise<InitResult> {
const { data } = await axios.post<InitResult>('/api/upload/init', {
fileHash, fileName, fileSize, totalChunks,
});
return data;
}
3.3 并发上传与进度监控
上传的核心是一个有界并发池(bounded concurrency pool)。我们维护一个大小为 3 的"槽位"数组,每个槽位负责一个分片的上传。当一个分片上传完成(无论成功或最终失败),槽位被释放,立即从待上传队列中取出下一个分片填入。

代码实现使用了一个经典的 Promise 竞态模式:
// utils/concurrentUpload.ts
import axios, { AxiosProgressEvent } from 'axios';
import { Chunk } from './createChunks';
export interface UploadProgress {
uploaded: number; // 已上传字节数
total: number; // 总字节数
percentage: number; // 0-100
}
export async function concurrentUpload(
chunks: Chunk[],
uploadId: string,
onProgress: (p: UploadProgress) => void,
signal: AbortSignal,
): Promise<void> {
const CONCURRENCY = 3;
let completed = 0;
const totalBytes = chunks.reduce((s, c) => s + c.blob.size, 0);
let uploadedBytes = 0;
async function uploadOne(chunk: Chunk): Promise<void> {
if (signal.aborted) throw new Error('Upload canceled');
const formData = new FormData();
formData.append('file', chunk.blob);
formData.append('uploadId', uploadId);
formData.append('chunkIndex', String(chunk.index));
await axios.post('/api/upload/chunk', formData, { signal });
uploadedBytes += chunk.blob.size;
completed += 1;
onProgress({
uploaded: uploadedBytes,
total: totalBytes,
percentage: Math.round((completed / chunks.length) * 100),
});
}
const pool = new Set<Promise<void>>();
for (const chunk of chunks) {
const p = uploadOne(chunk).finally(() => pool.delete(p));
pool.add(p);
if (pool.size >= CONCURRENCY) await Promise.race(pool);
}
await Promise.all(pool);
}
AbortController 被传入每个请求的 signal 参数中。当用户点击"取消"或页面卸载时,调用 controller.abort(),所有进行中的分片上传会被浏览器立即中断,不会在网络层留下悬空连接。
3.4 断点续传与失败重试
断点续传的核心思路是:上传中断后(无论是网络问题还是用户主动暂停),重新发起时先向后端查询已有分片列表,跳过已存在的分片,只传输缺失部分。
// utils/resumeUpload.ts
import axios from 'axios';
export async function getUploadedChunks(
uploadId: string,
): Promise<number[]> {
const { data } = await axios.get<{ chunks: number[] }>(
'/api/upload/status', { params: { uploadId } },
);
return data.chunks;
}
getUploadedChunks 返回已上传分片序号的数组(如 [0, 1, 2, 3, 5] 表示第 4 个分片缺失)。前端据此过滤 chunks 数组,只将缺失的分片推入并发池。
重试策略采用指数退避(exponential backoff)。每次失败后等待时间翻倍,单个分片最多重试 3 次:
// utils/retryWithBackoff.ts
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000,
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries) throw err;
const delay = baseDelay * 2 ** attempt; // 1s, 2s, 4s
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error('unreachable');
}
将 retryWithBackoff 包裹在 uploadOne 函数的外层,就能实现单个分片的自动重试,而不影响其他分片的并发执行。
3.5 完整的上传 Hook:useChunkedUpload
将上述所有逻辑封装到一个 React Hook 中,对外暴露简洁的控制面和状态:
// hooks/useChunkedUpload.ts
import { useState, useRef, useCallback } from 'react';
import { Chunk, createChunks } from '@/utils/createChunks';
import { concurrentUpload, UploadProgress } from '@/utils/concurrentUpload';
import { initUpload, InitResult } from '@/utils/checkSecretUpload';
import { getUploadedChunks } from '@/utils/resumeUpload';
type UploadStatus = 'idle' | 'hashing' | 'checking' | 'uploading'
| 'merging' | 'paused' | 'done' | 'error' | 'canceled';
interface UseChunkedUploadReturn {
start: (file: File) => Promise<void>;
pause: () => void;
resume: () => Promise<void>;
cancel: () => void;
progress: UploadProgress;
status: UploadStatus;
}
export function useChunkedUpload(
onHashProgress?: (p: number) => void,
): UseChunkedUploadReturn {
const [progress, setProgress] = useState<UploadProgress>(
{ uploaded: 0, total: 0, percentage: 0 },
);
const [status, setStatus] = useState<UploadStatus>('idle');
const abortRef = useRef<AbortController | null>(null);
const fileRef = useRef<File | null>(null);
const uploadIdRef = useRef<string>('');
const chunksRef = useRef<Chunk[]>([]);
const uploadedChunksRef = useRef<Set<number>>(new Set());
const start = useCallback(async (file: File) => {
fileRef.current = file;
setStatus('hashing');
// 1. Web Worker 计算哈希
const hash = await computeHashInWorker(file, onHashProgress);
// 2. 初始化上传任务
const chunks = createChunks(file);
chunksRef.current = chunks;
setStatus('checking');
const result: InitResult = await initUpload(
hash, file.name, file.size, chunks.length,
);
uploadIdRef.current = result.uploadId;
localStorage.setItem('lastUploadId', result.uploadId);
if (result.secret) {
setStatus('done');
return;
}
// 3. 断点续传:跳过已上传分片
uploadedChunksRef.current = new Set(result.uploadedChunks ?? []);
const pendingChunks = chunks.filter(
(c) => !uploadedChunksRef.current.has(c.index),
);
// 4. 并发上传
abortRef.current = new AbortController();
setStatus('uploading');
await concurrentUpload(
pendingChunks,
result.uploadId,
setProgress,
abortRef.current.signal,
);
// 5. 请求后端合并
setStatus('merging');
await axios.post('/api/upload/merge', { uploadId: result.uploadId });
setStatus('done');
}, [onHashProgress]);
const pause = useCallback(() => {
abortRef.current?.abort();
setStatus('paused');
}, []);
const resume = useCallback(async () => {
if (!fileRef.current || !uploadIdRef.current) return;
// 重新查询已上传分片,继续上传剩余部分
const uploaded = await getUploadedChunks(uploadIdRef.current);
uploadedChunksRef.current = new Set(uploaded);
const pending = chunksRef.current.filter(
(c) => !uploadedChunksRef.current.has(c.index),
);
abortRef.current = new AbortController();
setStatus('uploading');
await concurrentUpload(
pending, uploadIdRef.current, setProgress, abortRef.current.signal,
);
setStatus('merging');
await axios.post('/api/upload/merge', { uploadId: uploadIdRef.current });
setStatus('done');
}, []);
const cancel = useCallback(() => {
abortRef.current?.abort();
setStatus('canceled');
}, []);
return { start, pause, resume, cancel, progress, status };
}
四、后端协同设计
4.1 API 设计总览
后端提供了四个核心接口,它们共同构成了一次完整的分片上传生命周期:
| /api/upload/init | POST | 接收文件哈希、名称、大小、总分片数,返回 uploadId。若哈希命中已存储文件,返回 secret: true 和已有 URL | 否 |
| /api/upload/chunk | POST | 接收 uploadId + chunkIndex + 分片文件,存入临时目录,Redis 标记该分片已到达 | 是 |
| /api/upload/status | GET | 根据 uploadId 查询 Redis,返回已上传分片序号列表 | 是 |
| /api/upload/merge | POST | 校验所有分片已到齐,按序号合并写入最终文件,校验文件哈希,写入元数据库 | 否 |
4.2 后端分片合并逻辑(伪代码)
这是整个后端链路中最关键的一步。合并不是简单的 cat 拼接——它前面有校验,后面有验证:
# services/upload_service.py (伪代码)
def merge_chunks(upload_id: str) –> str:
# 1. 从 Redis 获取元信息
meta = redis.hgetall(f"upload:{upload_id}:meta")
total_chunks = int(meta["total_chunks"])
expected_hash = meta["file_hash"]
file_name = meta["file_name"]
# 2. 校验分片是否全部到达
uploaded = redis.smembers(f"upload:{upload_id}:chunks")
if len(uploaded) != total_chunks:
raise UploadNotComplete(f"{len(uploaded)}/{total_chunks}")
# 3. 校验每个分片的大小
tmp_dir = f"/tmp/uploads/{upload_id}"
for i in range(total_chunks):
chunk_path = f"{tmp_dir}/{i}"
actual_size = os.path.getsize(chunk_path)
if actual_size > CHUNK_SIZE * 1.1: # 允许 10% 冗余
raise ChunkSizeMismatch(f"chunk {i}: {actual_size}")
# 4. 按序号合并
final_path = f"/data/files/{upload_id}_{file_name}"
with open(final_path, "wb") as out:
for i in range(total_chunks):
with open(f"{tmp_dir}/{i}", "rb") as f:
out.write(f.read())
# 5. 验证最终文件哈希
if sha256_file(final_path) != expected_hash:
os.remove(final_path)
raise HashMismatch("合并后哈希与预期不一致")
# 6. 清理临时目录 & 写入文件元数据
shutil.rmtree(tmp_dir)
redis.delete(f"upload:{upload_id}:chunks")
db.insert_file_record(upload_id, file_name, final_path, expected_hash)
return final_path
4.3 存储策略
KMS 在不同环境采用了不同的存储后端:
- 开发环境:直接写入本地文件系统 /data/files/,简化调试流程,不引入外部依赖
- 生产环境:使用 MinIO(兼容 S3 协议的对象存储)。分片仍然先写入本地临时目录(避免频繁的网络 IO),合并完成后再通过 MinIO SDK 的 put_object 上传最终文件
- OSS 预签名 URL 方案(阿里云 OSS 场景的演进方向):后端生成每个分片的预签名上传 URL,前端直接 PUT 到 OSS,完全绕过应用服务器,大幅节省带宽。但出于金融行业的合规考量,当前阶段 KMS 仍采用"应用服务器中转"的方案,确保所有上传流量经过审计层
4.4 为什么需要一个 uploadId
你可能会问:直接用文件哈希作为标识不行吗?为什么还要引入一个额外的 uploadId?
答案是三个场景需要它:
五、生产环境踩坑与优化
以下五个坑都是在 KMS 生产环境中真实踩过、逐一解决的。每个坑都遵循"现象-根因-解决"的三段式分析。
5.1 Nginx 默认 body size 只有 1MB
现象:分片大小设置为 5MB 后,后端始终无法收到分片请求。浏览器控制台显示 413 Request Entity Too Large。
根因:生产环境的 Nginx 配置中,client_max_body_size 使用了默认值 1m。即使用户拆分成 5MB 的分片,Nginx 在 HTTP 层面就直接拒绝了请求体大于 1MB 的任何 POST。
解决:在 Nginx 的 server/location 块中添加:
client_max_body_size 2g;
值为 2g 而非 5m,一方面考虑了未来可能的更大分片,另一方面也兼容了其他非分片上传的常规文件上传接口。
5.2 SparkMD5 计算 2GB 文件导致浏览器卡死
现象:用户选择了一个 2GB 的文件后,整个浏览器选项卡冻结了约 15 秒,期间无法点击任何按钮,DevTools Performance 面板显示主线程被一个巨大的 JavaScript 任务占满。
根因:最初的实现将 SparkMD5 的 append() 调用直接放在了主线程。即使是增量计算,读取 2GB 文件仍然需要遍历所有字节,这会形成一个长时间运行的主线程任务,阻塞所有 UI 交互。
解决:将哈希计算完全移入 Web Worker(见 3.2 节代码),主线程只负责发送 postMessage 和接收结果。计算过程中,Worker 每完成一个分片的哈希追加就回传 progress 消息,主线程据此更新进度条。
5.3 浏览器关闭/刷新导致上传任务丢失
现象:用户上传了一个 800MB 的文件,已完成 60%。此时不慎关闭了浏览器标签页,重新打开后,上传进度归零,之前传输的 480MB 数据全部作废。
根因:uploadId 仅保存在组件的 React state 中,页面刷新后 state 自然丢失。用户无法向后端查询之前的 uploadId,因为他不知道这个 ID。
解决:在 init 成功后立即将 uploadId 持久化到 localStorage。页面重新加载时,useChunkedUpload 检查 localStorage 中是否有未完成的 uploadId,如果有则自动调用 /api/upload/status 查询进度,展示"继续上传"的提示:
// 页面初始化时检查是否有未完成的上传任务
useEffect(() => {
const lastUploadId = localStorage.getItem('lastUploadId');
if (lastUploadId) {
getUploadedChunks(lastUploadId).then((chunks) => {
if (chunks.length > 0) {
showResumePrompt(lastUploadId, chunks);
}
});
}
}, []);
5.4 同一文件被多次上传浪费存储
现象:某部门的培训视频(1.2GB)被三位同事分别上传了一次,存储中存了三份完全相同的文件,白白浪费了 2.4GB 空间。
根因:早期的上传接口没有去重逻辑,每次上传都创建新的文件记录。
解决:实施基于文件哈希的秒传机制(见 3.2 节)。init 接口在创建上传任务前,先查询哈希映射表。如果发现同一哈希的文件已存在,直接返回 secret: true 和已有文件的 URL,前端跳过所有分片上传步骤,直接展示"秒传成功"。
5.5 并发数太高触发浏览器同域连接限制
现象:将并发数设置为 8 后,发现前 6 个分片可以同时上传,但第 7、8 个分片一直处于 pending 状态,直到前面的分片完成才发出请求。
根因:HTTP/1.1 协议下,浏览器对同一域名的并发连接数限制为 6 个(Chrome)。超过这个数目的请求会排队等待。实际上我们浪费了"创建更多连接"的意图——多余的并发请求只能排队。
解决:将并发池上限从 8 降至 3。这样,(1)分片上传最多占用 3 个连接;(2)剩余 3 个连接留给页面上的其他请求(API 调用、图片加载等);(3)3 个并发的吞吐量对于绝大多数网络环境已经够用(单分片 5MB,3 并发 = 15MB 同时在传)。
5.6 踩坑优化汇总表
| 413 错误 | 5MB 分片被 Nginx 拒绝 | client_max_body_size 默认 1MB | 设为 2GB | 所有分片正常通过 |
| 浏览器卡死 | 2GB 文件哈希计算 15 秒无响应 | 主线程执行 SparkMD5 全量计算 | Web Worker 异步计算 | UI 始终可交互,进度可展示 |
| 上传任务丢失 | 页面刷新后进度归零 | uploadId 仅在 React state | localStorage 持久化 uploadId | 刷新后可恢复上传 |
| 重复存储 | 同一文件 3 个副本 | 无去重机制 | 文件哈希秒传 | 存储占用降至单份 |
| 连接耗尽 | 并发 8 只有 6 个在传 | Chrome 同域 6 连接限制 | 并发池降为 3 | 留出连接给其他请求 |
六、总结与展望
6.1 核心思路回顾
KMS 大文件上传方案可以浓缩为五个关键词:分片、哈希、并发、断点续传、秒传。
分片解决了"大文件无法单次传输"的问题;哈希提供了文件完整性和唯一性的验证手段;并发池在有限的浏览器连接资源下最大化吞吐量;断点续传让中断不再是灾难;秒传则让重复上传变得毫无意义。
这五个技术点并不是孤立的——它们通过 uploadId 和 Redis 状态管理串联在一起,形成了一套完整的状态机。
6.2 适用场景
这套方案在 KMS 知识库的场景(培训视频、合规扫描件、产品手册)中表现稳定。推而广之,凡是涉及 50MB 以上文件上传 的场景——文档管理系统、网盘、视频平台、设计素材库——都可以直接复用这套架构。
对于 50MB 以下的小文件,分片上传的额外开销(多次 HTTP 请求、Web Worker 哈希计算、Redis 状态维护)可能得不偿失。KMS 的做法是:前端判断 file.size > 50 * 1024 * 1024 时走分片上传流程,否则走传统单次 multipart/form-data 上传。
6.3 待探索方向
KMS 上传方案还有几个值得探索的优化方向:
- WebAssembly 加速哈希计算:SparkMD5 是纯 JavaScript 实现。WASM 版的 MD5(如 Rust 编译到 WASM)在 2GB 级别文件上的计算速度可以提升 2-4 倍
- HTTP/3 多路复用:QUIC 协议消除了 TCP 队头阻塞问题,理论上可以将并发数提高而不受连接数限制。这对移动端弱网场景尤其有吸引力
- 客户端边分片边上传:当前流程是先完整计算哈希再开始上传。如果哈希计算和上传交错执行(已算完哈希的分片立即开始上传),可以进一步缩短端到端耗时
这些方向目前还在调研阶段,欢迎有实践经验的朋友交流讨论。


