解决使用OSS的multipartUpload方法上传大文件导致内存溢出的问题
这里写目录标题
- 问题背景
- 问题原因分析
- 解决方案与代码优化
- 动态调整分片大小
- 清理断点续传缓存
- 内存泄漏防护
- 使用Web Worker计算MD5
- 完整代码
- 总结
问题背景
在使用阿里云OSS(对象存储服务)的multipartUpload方法上传大文件时,可能会遇到内存溢出(OOM)的问题。
这一问题通常出现在处理超大文件(如数GB)时,由于一次性加载整个文件到内存中,导致内存占用飙升,甚至进程崩溃。本文将结合实际代码,分析问题原因并提供解决方案。
问题原因分析
-
文件分片策略不合理
如果分片大小(partSize)设置过小,会导致分片数量过多,增加内存压力;反之,分片过大可能导致单次上传任务占用过高内存。 -
断点续传机制缺陷
断点续传需要保存上传进度(checkpoint),如果未及时清理已完成任务的checkpoint,可能导致内存泄漏。 -
并发请求过多
并行上传分片时,若未合理控制并发数(parallel参数),可能导致大量请求堆积,占用内存。
解决方案与代码优化
以下结合代码中的关键逻辑,逐步优化内存问题。
动态调整分片大小
根据文件大小动态计算分片大小,避免固定值导致的资源浪费。例如:
// 分片大小配置
const SHARD_SIZE_CONFIG = {SMALL: 1, // < 200MBMEDIUM: 2, // < 1024MBLARGE: 5, // < 2048MBXLARGE_RATIO: 500 // >= 2048MB
};
function calcShardSize(fileSize: number): number {if (fileSize < 200) return SHARD_SIZE_CONFIG.SMALL; // 1MBelse if (fileSize < 1024) return SHARD_SIZE_CONFIG.MEDIUM; // 2MBelse if (fileSize < 2048) return SHARD_SIZE_CONFIG.LARGE; // 5MBelse return Math.ceil(fileSize / SHARD_SIZE_CONFIG.XLARGE_RATIO); // 动态分片
}
设置合理的parallel参数(如4~8),平衡上传速度和内存占用。
// 示例:分片上传逻辑
let option = {partSize: 1024 * 1024 * shardSize,parallel: 8,timeout: 1000 * 60 * 60,checkpoint: null,progress: progressHandle
};
if (abortCheckpointMap[md5]) {abortCheckpointMap[md5].progress = progressHandle;option = abortCheckpointMap[md5];
}
// 文件上传
const result = await client.multipartUpload(uploadFileName, file, option);
清理断点续传缓存
上传完成后及时清理checkpoint,避免内存泄漏。
// 上传成功后清理断点信息
delete abortCheckpointMap[md5];
saveAbortMap();
内存泄漏防护
在上传失败或取消时,主动释放资源:
try {const result = await client.multipartUpload(/* ... */);
} catch (e) {if (client) client.cancel(); // 取消未完成的请求if (md5 && abortCheckpointMap[md5]) {delete abortCheckpointMap[md5]; // 清理断点信息saveAbortMap();}
}
使用Web Worker计算MD5
避免主线程阻塞,同时防止大文件MD5计算过程中的内存问题。
async function toMD5(file: File): Promise<string> {return new Promise((resolve, reject) => {const mdWorker = new Worker(new URL("./toMD5", import.meta.url), {type: "module"});mdWorker.postMessage(file); // 分片传输数据mdWorker.onmessage = (res) => {resolve(res.data);mdWorker.terminate(); // 及时终止Worker};});
}
toMD5.ts的内容
import cryptojs from "crypto-js";
const MD5_LENGTH = 5;
self.onmessage = function (res) {const file = res.data;const reader = new FileReader();let temp: any = null;// 文件太大会卡死if (file.size > 1024 * 1024 * MD5_LENGTH + 1) {temp = file.slice(0, 1024 * 1024 * MD5_LENGTH);}reader.readAsDataURL(temp || file);// 开始转base64reader.onload = async () => {const md5 = cryptojs.MD5(reader.result.toString()).toString();self.postMessage(md5);};
};
完整代码
import OSS from "ali-oss";
import dayjs from "dayjs"; // 引入时间组件import { buildUUID } from "@pureadmin/utils";
import { getStsToken, getFilePath, saveFilePath } from "@/api/common/oss";
import { storageLocal } from "@pureadmin/utils";
// 获取不同文件类型bucket
const OSS_BUCKET_OBJECT = {image: "X-image",audio: "X-audio",video: "X-video",application: "X-file",other: "X-upload"
};
// 文件大小阈值,主要区分直接上传和分片上传
const FILE_SIZE_THRESHOLD = 50;
// 当文件大小限制的时候,将重新获取StsToken,避免Token过期
const REFRESH_FILE_MAX = 1024;
// 分片大小配置
const SHARD_SIZE_CONFIG = {SMALL: 1, // < 200MBMEDIUM: 2, // < 1024MBLARGE: 5, // < 2048MBXLARGE_RATIO: 500 // >= 2048MB
};type configType = {accessKeyId: string;accessKeySecret: string;bucket: string;expiration: string;region: string;token: string;stsToken: string;
};let config: configType = undefined;
let configPromise: Promise<configType> = null; // 用于避免并发请求
// 获取存到缓存的断点续传信息
const abortCheckpointMap = storageLocal().getItem("abortMap") || {};
// 对文件进行上传
export async function uploadFile(file: File, progressCallback?: Function) {// 实例化OSS对象if (!file || Object.prototype.toString.call(file) !== "[object File]") {throw new Error("参数不正确");}// 文件大小Mconst fileSize = file.size / 1024 / 1024;// 根据约定和相关规则进行参数配置const ossConfig: configType = await getOssConfig(fileSize > REFRESH_FILE_MAX ? true : false);ossConfig.bucket = getBucket(file);ossConfig.stsToken = ossConfig.token;const client = new OSS(ossConfig);let timeID = null; // 保存定时器ID,用于可能的清理let md5: string = null;try {// progressCallback 三个参数 (进度, 断点信息, 返回值)// 校验传入的对象是否正确md5 = await toMD5(file);const fileName = getFileName(file);// 进行秒传的判定const loadPath = await judgmentExistence(md5);// debugger;if (loadPath) {exeCallback(progressCallback, 1);return loadPath;}const environment = `/${import.meta.env.VITE_LOGOGRAM}`;const pathName = `${environment}/${dayjs().format("YYYY-MM-DD")}/`;const uploadFileName = pathName + buildUUID() + fileName;// 根据文件大小选择上传的方式if (fileSize > FILE_SIZE_THRESHOLD) {const shardSize = calcShardSize(fileSize);const progressHandle = (p, cpt, _res) => {// 为中断点赋值。abortCheckpointMap[md5] = {...option,checkpoint: cpt};saveAbortMap();// 获取上传进度。exeCallback(progressCallback, p);};let option = {partSize: 1024 * 1024 * shardSize,parallel: 8,timeout: 1000 * 60 * 60,checkpoint: null,progress: progressHandle};if (abortCheckpointMap[md5]) {abortCheckpointMap[md5].progress = progressHandle;option = abortCheckpointMap[md5];}// 文件上传const result = await client.multipartUpload(uploadFileName, file, option);// 保存MD5和文件路径await saveFilePath({fileMd5: md5,path: `${result.name}`});delete abortCheckpointMap[md5];saveAbortMap();return `${result.name}`;} else {timeID = simulateUpload(progressCallback);// 文件上传const result = await client.put(uploadFileName, file);// 保存MD5和文件路径await saveFilePath({fileMd5: md5,path: `/${result.name}`});clearInterval(timeID);exeCallback(progressCallback, 1);return `/${result.name}`;}} catch (e) {// 确保在出错时清理资源if (client) client.cancel();if (timeID) clearInterval(timeID); // 清理定时器// 如果有md5值且在断点映射中,则删除它if (md5 && abortCheckpointMap[md5]) {delete abortCheckpointMap[md5];saveAbortMap();}return Promise.reject(e);}
}
function simulateUpload(progressCallback) {let num = 0.1;const timeID = setInterval(() => {if (num >= 0.8) return;num += 0.1;exeCallback(progressCallback, num);}, 200);return timeID;
}
function exeCallback(progressCallback, p) {if (Object.prototype.toString.call(progressCallback) === "[object Function]")progressCallback(p);
}
// 获取后端返回的临时凭证,并根据时间判断凭证是否过期
export async function getOssConfig(isRefresh = false) {// 避免并发请求if (configPromise) {return configPromise;}if (config?.expiration &&dayjs().isBefore(dayjs(config.expiration)) &&!isRefresh) {return Promise.resolve(config);} else {configPromise = getStsToken().then(({ data }) => {config = data as configType;configPromise = null; // 重置Promisereturn data;}).catch(error => {configPromise = null; // 重置Promisereturn Promise.reject(error);});return configPromise;}
}
/*** 根据文件类型和属性生成处理后的文件名称* @param file 文件对象,需包含 name、type、width、height 等属性* @returns 处理后的文件名称字符串,若解析失败则返回原始文件名*/
// 缓存正则表达式以提高性能
const FILE_NAME_REGEX = /.*(?=\.\w+$)/g;
const FILE_SUFFIX_REGEX = /(?<=\.)\w*$/g;function getFileName(file: any) {try {// 提取文件扩展名(如.txt)const suffix = file.name.match(FILE_SUFFIX_REGEX)?.[0] || "";// 提取文件名(不含扩展名)let oldFileName = file.name.match(FILE_NAME_REGEX)?.[0] || "";let other = "";// 若为图片文件且包含尺寸信息,添加尺寸后缀if (file.type.split("/")[0] == "image" && file?.width) {other = `-${file.width}x${file.height}`;}// 若非音频文件则清空旧文件名,音频文件对旧文件名进行URI编码if (file.type.split("/")[0] != "audio") oldFileName = "";else oldFileName = "-" + oldFileName;const fileName = `${oldFileName}${other}.${suffix}`;return fileName;} catch (e) {// 异常处理,返回原始文件名console.error("序列化文件名失败", e);return file.name;}
}
// 根据文件类型获取不同的bucket
function getBucket(file: File) {const fileType = file.type.split("/")[0];const bucket = OSS_BUCKET_OBJECT[fileType];if (bucket) return bucket;else return OSS_BUCKET_OBJECT["other"];
}
// 校验是都可以秒传
async function judgmentExistence(md5: string) {try {const { data } = await getFilePath({ fileMd5: md5 });return data;} catch (e) {console.error(e);return "";}
}
// 获取文件md5
async function toMD5(file: File): Promise<string> {return new Promise((resolve, reject) => {try {const mdWorker = new Worker(new URL("./toMD5", import.meta.url), {type: "module"});mdWorker.postMessage(file);mdWorker.onmessage = res => {resolve(res.data);mdWorker.terminate();};mdWorker.onerror = err => {reject(err);mdWorker.terminate();};} catch (e) {console.error(e);reject(e);}});
}
function calcShardSize(fileSize: number): number {if (fileSize < 200) return SHARD_SIZE_CONFIG.SMALL;else if (fileSize < 1024) return SHARD_SIZE_CONFIG.MEDIUM;else if (fileSize < 2048) return SHARD_SIZE_CONFIG.LARGE;else return Math.ceil(fileSize / SHARD_SIZE_CONFIG.XLARGE_RATIO);
}
function saveAbortMap() {storageLocal().setItem("abortMap", abortCheckpointMap);
}
总结
通过动态分片、流式处理、并发控制、断点续传清理等策略,可以有效避免内存溢出问题。核心原则是:
- 避免一次性加载大文件到内存。
- 合理分配资源(分片大小、并发数)。
- 及时释放无用资源(如取消请求、清理缓存)。
完整代码已在实际项目中验证,成功上传10GB+文件.