概述
对于大文件上传,这是一个非常普遍的需求。分片上传就是一个实现路径,也叫分块上传。
下面直接给出MinIO和腾讯云COS的实现逻辑代码。
不过这种几乎快要烂大街的代码根本不值一分钱:
- 是的,我就是在吐槽,最近两家公司的CTO拿着屎山代码当成传家宝;
- 仅两年来,Coding Agent能力完全可以代替5-8年经验的开发者。
重点在后面记录的几个问题。
MinIO
根据官方文档,分片上传有3个步骤:
- 初始化
- 上传分片
- 合并分片
初始化
直接上代码,Controller层接口定义如下:
java
@PostMapping("/init")
@ApiOperation(value = "初始化分片上传", notes = "创建分片上传任务,返回上传ID和分片信息")
public MultipartUploadInitResponse initMultipartUpload(@Valid @RequestBody MultipartUploadInitRequest request) {
return service.initMultipartUpload(request);
}
MultipartUploadInitRequest:
java
@Data
@ApiModel("分片上传初始化请求")
public class MultipartUploadInitRequest {
@ApiModelProperty(value = "文件名", required = true)
@NotBlank(message = "文件名不能为空")
private String fileName;
@ApiModelProperty(value = "文件大小(字节)", required = true)
@NotNull(message = "文件大小不能为空")
@Min(value = 1, message = "文件大小必须大于0")
private Long fileSize;
@ApiModelProperty(value = "文件哈希值", required = true)
@NotBlank(message = "文件哈希值不能为空")
private String fileHash;
@ApiModelProperty(value = "哈希算法", example = MD5)
private String hashAlgorithm = MD5;
@ApiModelProperty(value = "存储桶名称", required = true)
@NotBlank(message = "存储桶名称不能为空")
private String bucketName;
@ApiModelProperty(value = "业务场景", required = true)
@NotBlank(message = "业务场景不能为空")
private String bizScene;
@ApiModelProperty(value = "存储平台", example = "MINIO")
private String platform = "MINIO";
// 默认 5MB
@ApiModelProperty(value = "分片大小(字节)", example = "5242880")
private Long chunkSize = 5242880L;
}
MultipartUploadInitResponse:
java
@Data
@ApiModel("分片上传初始化响应")
public class MultipartUploadInitResponse {
@ApiModelProperty("上传任务ID")
private String uploadId;
@ApiModelProperty("文件存储ID")
private Long fileId;
@ApiModelProperty("总分片数")
private Integer totalParts;
@ApiModelProperty("分片大小(字节)")
private Long chunkSize;
@ApiModelProperty("已上传的分片列表(用于断点续传)")
private List<PartInfo> uploadedParts;
@Data
@ApiModel("分片信息")
public static class PartInfo {
@ApiModelProperty("分片序号")
private Integer partNumber;
@ApiModelProperty("分片大小")
private Long partSize;
@ApiModelProperty("分片ETag")
private String etag;
@ApiModelProperty("是否已上传")
private Boolean uploaded;
@ApiModelProperty("预签名上传URL")
private String uploadUrl;
@ApiModelProperty("URL过期时间(毫秒时间戳)")
private Long expireTime;
}
}
核心Service方法:
java
public MultipartUploadInitResponse initMultipartUpload(MultipartUploadInitRequest request) {
log.info("初始化分片上传,文件名: {}, 大小: {}", request.getFileName(), request.getFileSize());
String uploadId = CmsUtil.getUuid();
// 计算分片信息
long chunkSize = request.getChunkSize() != null ? request.getChunkSize() : DEFAULT_CHUNK_SIZE;
int totalParts = (int) Math.ceil((double) request.getFileSize() / chunkSize);
// 生成文件路径
String filePath = commonService.generateFilePath(minioProperties.getBasePath(), request.getFileName(), request.getBizScene());
// 创建主表记录
ObjectStorageDO storage = commonService.buildObjectStorage(request, filePath, uploadId, totalParts);
Long id = objectStorageService.addAndReturnId(storage);
// 创建分片记录(分片编号从0开始,与前端保持一致)
List<MultipartUploadDO> parts = new ArrayList<>();
OffsetDateTime expireTime = OffsetDateTime.now().plusHours(URL_EXPIRE_HOURS);
for (int i = 0; i < totalParts; i++) {
// 最后一片大小 = fileSize - 前面所有分片占用的大小
long partSize = (i == totalParts - 1) ? request.getFileSize() - i * chunkSize : chunkSize;
MultipartUploadDO part = new MultipartUploadDO();
part.setUploadId(uploadId);
part.setPartNumber(i);
part.setPartSize(partSize);
part.setUploadStatus(false);
part.setExpireTime(expireTime);
part.setCreateTime(OffsetDateTime.now());
if (request.getEnableDirectUpload()) {
// 生成预签名上传URL
String partObjectName = filePath + PART + i;
try {
String url = minioTemplate.getPresignedObjectUrl(request.getBucketName(), partObjectName, URL_EXPIRE_HOURS, TimeUnit.HOURS, Method.PUT);
part.setUploadUrl(url);
} catch (Exception e) {
log.error("生成分片上传URL失败:", e);
throw new BackendBizException(GENERATE_MULTIPART_UPLOAD_URL_FAIL);
}
}
parts.add(part);
}
multipartUploadService.batchInsert(parts);
// 构建响应
MultipartUploadInitResponse resp = new MultipartUploadInitResponse();
resp.setUploadId(uploadId);
resp.setFileId(id);
resp.setTotalParts(totalParts);
resp.setChunkSize(chunkSize);
List<MultipartUploadInitResponse.PartInfo> partInfos = parts.stream()
.map(item -> {
MultipartUploadInitResponse.PartInfo info = new MultipartUploadInitResponse.PartInfo();
info.setPartNumber(item.getPartNumber());
info.setPartSize(item.getPartSize());
info.setUploaded(false);
info.setUploadUrl(item.getUploadUrl());
info.setExpireTime(item.getExpireTime().toInstant().toEpochMilli());
return info;
})
.collect(Collectors.toList());
resp.setUploadedParts(partInfos);
log.info("分片上传初始化完成,uploadId: {}, 总分片数: {}", uploadId, totalParts);
return resp;
}
初始化核心逻辑:
- 前后端协商一致分开大小,默认5M,这也是MinIO推荐的分块阈值;
- 前后端协商一致分片起始索引。不能各玩各的,前端如果从0开始,后端从1开始,则后续的5M分片文件上传校验无法通过
- 返回uploadId给前端,uploadId可以使用自定义UUID,也可直接使用平台返回的Id。
分片上传
controller方法定义:
java
@PostMapping("/upload")
@ApiOperation(value = "上传分片", notes = "上传单个分片文件")
public String uploadPart(
@ApiParam(value = "分片文件", required = true) @NotNull @RequestPart("file") MultipartFile file,
@ApiParam(value = "上传任务ID", required = true) @NotBlank @RequestParam String uploadId,
@ApiParam(value = "分片序号", required = true) @NotNull @Min(value = 0, message = "分片序号不能为负数") @RequestParam Integer partNumber,
@ApiParam(value = "分片哈希值", required = true) @RequestParam @NotBlank String partHash,
@ApiParam(value = "分片大小") @NotNull @RequestParam Long partSize,
@ApiParam(value = "存储平台", example = "MINIO") @RequestParam(defaultValue = "MINIO") String platform) {
return service.uploadPart(file, uploadId, partNumber, partHash, partSize, platform);
}
其中partSize可有可无,属于冗余传参,对于前N-1个分片文件,其大小一般固定为5M,即5242880,最后一个分片文件小于5242880。调用接口时,需要上传part文件,后端可根据MultipartFile.getSize()方法获取分片大小。
核心Service方法如下:
java
public String uploadPart(MultipartFile file, MultipartUploadDTO req) {
log.info("上传分片,uploadId: {}, partNumber: {}", req.getUploadId(), req.getPartNumber());
try {
MutableTriple<Boolean, MultipartUploadDO, ObjectStorageDO> result = commonService.checkDbInfo(req.getUploadId(), req.getPartNumber(), StorageTypeEnum.MINIO.name());
if (!result.getLeft()) {
return "";
}
Boolean check = commonService.checkPartAndHash(file, req);
if (!check) {
return "";
}
ObjectStorageDO storage = result.right;
// 上传分片到MinIO
String partObjectName = storage.getFilePath() + ".part." + req.getPartNumber();
ObjectWriteResponse resp = minioTemplate.putObject(storage.getBucketName(), file, partObjectName);
// 更新分片状态
boolean updated = multipartUploadService.updatePartStatus(req.getUploadId(), req.getPartNumber(), resp.etag(), req.getPartHash());
if (updated) {
// 更新主表的已完成分片数
Integer completedParts = multipartUploadService.countUploadedParts(req.getUploadId());
storage.setCompletedParts(completedParts);
objectStorageService.update(storage);
log.info("分片上传成功,uploadId: {}, partNumber: {}, 已完成: {}/{}", req.getUploadId(), req.getPartNumber(), completedParts, storage.getTotalParts());
}
return resp.etag();
} catch (Exception e) {
log.error("分片上传失败: uploadId={}, partNumber={}", req.getUploadId(), req.getPartNumber(), e);
return "";
}
}
CommonService.java两个校验方法:
java
public MutableTriple<Boolean, MultipartUploadDO, ObjectStorageDO> checkDbInfo(String uploadId, Integer partNumber, String platform) {
MutableTriple<Boolean, MultipartUploadDO, ObjectStorageDO> result = new MutableTriple<>(false, null, null);
// 获取主表信息
ObjectStorageDO storage = objectStorageService.findByUploadIdAndPlatform(uploadId, platform);
if (storage == null) {
log.error("上传任务不存在: uploadId={}", uploadId);
return result;
}
// 验证分片信息
MultipartUploadDO part = multipartUploadService.findByUploadIdAndPartNumber(uploadId, partNumber);
if (part == null) {
log.error("分片信息不存在: uploadId={}, partNumber={}", uploadId, partNumber);
return result;
}
result.setLeft(true);
result.setMiddle(part);
result.setRight(storage);
return result;
}
public Boolean checkPartAndHash(MultipartFile file, MultipartUploadDTO req) throws IOException {
// 验证分片大小,可有可无
if (!req.getPartSize().equals(file.getSize())) {
log.warn("分片大小不匹配: expected={}, actual={}", req.getPartSize(), file.getSize());
return false;
}
// 验证分片哈希
String actualHash;
try (InputStream inputStream = file.getInputStream()) {
actualHash = DigestUtils.md5Hex(inputStream);
}
if (!req.getPartHash().equals(actualHash)) {
log.error("分片哈希不匹配: expected={}, actual={}", req.getPartHash(), actualHash);
return false;
}
return true;
}
核心业务逻辑:
- 校验DB记录是否存在
- 校验接口传参的fileHash是否与文件真实hash相同。后端代码计算方式是读取文件流,然后使用Apache
Commons-Codec工具库,当然支持使用其他类库 - 更新分片上传状态
分片合并
controller接口:
java
@PostMapping("/complete")
@ApiOperation(value = "完成分片上传", notes = "合并所有分片,完成文件上传")
public FileDTO completeMultipartUpload(
@RequestParam String uploadId,
@ApiParam(value = "存储平台", example = "MINIO") @RequestParam(defaultValue = "MINIO") String platform) {
return service.completeMultipartUpload(uploadId, platform);
}
Service核心代码:
java
public FileDTO completeMultipartUpload(String uploadId) {
log.info("完成分片上传,uploadId: {}", uploadId);
try {
// 获取上传任务信息
ObjectStorageDO storage = objectStorageService.findByUploadIdAndPlatform(uploadId, StorageTypeEnum.MINIO.name());
if (storage == null) {
throw new BackendBizException(UPLOAD_TASK_NOT_EXIST);
}
// 检查所有分片是否都已上传
Integer completedParts = multipartUploadService.countUploadedParts(uploadId);
if (!completedParts.equals(storage.getTotalParts())) {
throw new BackendBizException(CHUNK_NOT_UPLOAD_COMPLETE);
}
// 获取所有分片信息(按分片编号升序排列,确保合并顺序正确)
List<MultipartUploadDO> parts = multipartUploadService.findByUploadId(uploadId);
List<String> filePaths = parts.stream()
.sorted(Comparator.comparingInt(MultipartUploadDO::getPartNumber))
.map(i -> storage.getFilePath() + PART + i.getPartNumber())
.collect(Collectors.toList());
minioTemplate.composeObject(storage.getBucketName(), storage.getFilePath(), filePaths);
// 验证最终文件哈希
try {
// 获取合并后的文件信息
StatObjectResponse finalFileStats = minioTemplate.statObject(storage.getBucketName(), storage.getFilePath());
// 计算合并后文件的哈希值
List<String> list = parts.stream().map(MultipartUploadDO::getPartHash).collect(Collectors.toList());
String actualFileHash = commonService.getFileHash(list);
if (!actualFileHash.equals(finalFileStats.etag())) {
log.error("文件哈希验证失败: uploadId={}, expected={}, actual={}", uploadId, storage.getFileHash(), actualFileHash);
// 删除不完整的文件
minioTemplate.removeFile(storage.getBucketName(), storage.getFilePath(), false);
throw new BackendBizException(FILE_HASH_VERIFY_FAIL);
}
log.info("文件哈希验证通过: uploadId={}, hash={}", uploadId, actualFileHash);
// 验证文件大小
if (!storage.getFileSize().equals(finalFileStats.size())) {
log.error("文件大小验证失败: uploadId={}, expected={}, actual={}", uploadId, storage.getFileSize(), finalFileStats.size());
// 删除不完整的文件
minioTemplate.removeFile(storage.getBucketName(), storage.getFilePath(), false);
throw new BackendBizException(FILE_SIZE_VERIFY_FAIL);
}
// 更新文件的最终ETag
storage.setFileHash(finalFileStats.etag().contains(DASH) ? finalFileStats.etag().substring(0, finalFileStats.etag().indexOf(DASH)) : finalFileStats.etag());
} catch (Exception e) {
log.error("文件验证失败: uploadId={} ", uploadId, e);
throw new BackendBizException(FILE_VERIFY_FAIL);
}
// 清理分片文件
for (MultipartUploadDO part : parts) {
try {
minioTemplate.removeFile(storage.getBucketName(), storage.getFilePath() + PART + part.getPartNumber(), false);
} catch (Exception e) {
log.warn("清理分片文件失败: partNumber={}, error={}", part.getPartNumber(), e.getMessage());
}
}
// 更新主表状态
storage.setUploadStatus(UploadStatusEnum.COMPLETED.getCode());
storage.setModifyTime(LocalDateTime.now().atOffset(ZoneOffset.UTC));
objectStorageService.update(storage);
// 删除分片记录
multipartUploadService.deleteByUploadId(uploadId);
// 构建响应
FileDTO fileDTO = new FileDTO();
fileDTO.setId(storage.getId());
fileDTO.setName(storage.getFileName());
fileDTO.setUrl(minioTemplate.getPresignedObjectUrl(storage.getBucketName(), storage.getFilePath()));
log.info("分片上传完成,uploadId: {}, fileId: {}", uploadId, storage.getId());
return fileDTO;
} catch (Exception e) {
log.error("完成分片上传失败: uploadId={}", uploadId, e);
throw new BackendBizException(COMPLETE_MULTIPART_UPLOAD_FAIL);
}
}
自测
为了通过Postman模拟自测,需要借助一些工具。分片阈值是5M,那就选择一个(5M, 10M]区间内的文件,如何切割文件呢?
对于macOS,使用系统自带的命令行即可实现:
bash
split -b 5242880 e99f6ba278654d588baa171968ceac57.mp4 part_1
执行成功后,查看文件

然后使用在线网站计算文件Hash,当然这类网站有很多,按需使用。
COS
限于篇幅过长,腾讯云COS分片上传实现请参考腾讯云COS分片上传完整实现。
另外,关于表设计,也请移步COS。
问题
size must be greater than 5242880
3个分片都成功上传,在MinIO Server端也能查看到,但是合并分片时遇到的报错

报错信息:java.lang.IllegalArgumentException: source xxx/yyy/2c3a762704754aeaaabc1a00af94324c.mp4.part.3: size 1286209 must be greater than 5242880。
注意看红色框标记的部分,稍加分析,不难得出:合并时没有按照partNumber升序排列,错把part.3当做第一个分片。
分片哈希不匹配
遇到的问题,分片上传时,日志提示分片哈希不匹配。
稍加分析,再看看代码片段:
java
String actualHash;
try (InputStream inputStream = file.getInputStream()) {
actualHash = DigestUtils.md5Hex(inputStream);
}
服务端在每一个分片文件上传时,使用读取文件流的方式,计算分片的Hash;
与此同时,使用Postman自测时,使用spilt命令行切割文件,使用在线网站工具计算文件Hash,这两者是一样的,问题只能是前端:

分片起始索引
其实这根本不值一提,根本算不上什么问题。但是,工作上工程上的事情,不是一个人能完成的,也就是必须依赖于团队协作。团队协作,自然离不开沟通;沟通成本高的团队,就很浪费时间,影响效率。
上面的代码片段有分片校验。也就是说,前端定义的分片起始索引和后端定义的起始索引,必须要一致。否则报错:分片信息不存在。
初始化分片时,会将分片信息存储于DB:
java
for (int i = 0; i < totalParts; i++) {
// 最后一片大小 = fileSize - 前面所有分片占用的大小
long partSize = (i == totalParts - 1) ? request.getFileSize() - i * chunkSize : chunkSize;
MultipartUploadDO part = new MultipartUploadDO();
part.setUploadId(uploadId);
part.setPartNumber(i);
part.setPartSize(partSize);
}
假如前端定义的partNumber从0开始,后端从1开始,怎么调试对接,肯定都不能联调成功的。
相关地,最后一片大小也与起始索引息息相关,仅仅只是对齐起始索引,没有调整最后一个分片大小,最后的合并分片还是会失败。
合并分片时Hash校验
初始化上传时,前端需要传参Hash;分片上传若干个5M大小分片文件,前端也会传参Hash;最后合并分片,调用第三方SDK。作为服务端,逻辑校验非常有必要。
问题:如何确认MinIO Server合并文件是真的合并成功,而不仅仅只是判断client没有抛出异常。
有了校验的意识,仅仅只是第一步。如何校验才是关键,很多开发者第一直觉是读取文件流,这其中就包括我接手的屎山代码。

继续看看私有方法

这是在干啥??
读取一个超大文件,读到内存流里,然后使用JDK自带的API:MessageDigest.getInstance("MD5");,虽然有小批量处理md5.update,但是性能损坏和时间计算成本,不能不考虑。
优化思路:既然能拿到初始文件Hash,以及N个分片文件的Hash,即1+N个Hash。那是否存在一种算法,根据N个分片文件Hash,通过计算等价于初始文件Hash?如果相等,说明文件合并真的成功。
GPT答复:存在,即私有方法getFileHash:
java
/**
* MinIO/S3 分片合并后文件的Hash/Etag计算逻辑
*/
public String getFileHash(List<String> list) throws Exception {
// 将 Hex 字符串转回 16 字节的二进制数组并拼接
byte[] allBytes = new byte[list.size() * 16];
for (int i = 0; i < list.size(); i++) {
byte[] partBytes = Hex.decodeHex(list.get(i));
System.arraycopy(partBytes, 0, allBytes, i * 16, 16);
}
// 对拼接后的二进制流再做一次 MD5
return DigestUtils.md5Hex(allBytes) + DASH + list.size();
}
分片大小阈值
当分片上传的文件小于5M时,MinIO Server端,支持合并为正常文件。也就是说,MinIO支持通过分片上传的方式,上传小于5M的文件。

但是,不管是在MinIO Server端,还是断点调试,都会发现合并后的文件的ETag并没有-n后缀:

而且,刚好和原始文件的MD5算法得到的Hash值一样。

作为对比,看看大于5MB的文件,合并后的ETag值带有-n后缀,n表示由多少个分片文件组成:

针对小于5M的文件的分片上传的场景,既然MinIO支持,那代码层也应该支持,不修改,怎合并文件比较Hash值的地方会有问题:
java
public String getFileHash(List<String> list) throws Exception {
if (list.size() == 1) {
return list.get(0);
}
// 省略后续代码
}
来看看AI是怎么解读的?

初始化分片上传Hash
写到最后,秃然发现一个"致命"问题。本地使用Postman自测时,主要是在验证代码逻辑正确性,选择大于5M小于10M的文件即可。因为文件非常小,使用在线工具网站,能快速拿到文件Hash。后端Java代码里获取文件Hash,一般情况都是读取文件流,使用Commons Codec类库DigestUtils.md5Hex(inputStream);方法。前端在初始化分片文件上传时,必须得带上Hash!!
问题:前端是如何快速计算几G大文件的Hash值的?
GPT解读:
前端计算超大文件的Hash,确实不能像后端那样一次性读取整个文件流------几个G的文件直接读到内存里,浏览器直接崩。
核心思路是:分块读取+增量计算,把大文件切成一个个小片,边读边喂给Hash算法,最后再得出完整的结果。
目前主要有这几种方案,从简单到复杂给你排个序:
方案一:spark-md5(最常用,推荐)
这是最主流的方案,专门为增量计算设计的。
安装:npm install spark-md5
js
import SparkMD5 from 'spark-md5';
function calculateFileMD5(file) {
return new Promise((resolve, reject) => {
const chunkSize = 5 * 1024 * 1024; // 5MB 分片
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result); // 增量追加
currentChunk++;
if (currentChunk < chunks) {
loadNext(); // 继续读下一片
} else {
resolve(spark.end()); // 最终 MD5
}
};
fileReader.onerror = () => reject(fileReader.error);
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
fileReader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
// 使用
const file = fileInput.files[0];
const md5 = await calculateFileMD5(file);
console.log(md5);
优点:成熟稳定,简单可靠,兼容性好。
缺点:纯JS实现,大文件计算时间较长(几个G可能要几十秒)。
方案二:WebAssembly + Web Worker(最快)
追求极致性能的话,这个方案是天花板。用Rust/C++写Hash算法编译成Wasm,再加上多线程Worker并行计算。
js
// 以 fast-md5-web 为例
import { Md5CalculatorPool, WasmInit } from 'fast-md5-web';
// 初始化
await WasmInit();
const pool = new Md5CalculatorPool(4); // 4个Worker线程
// 计算
const hash = await pool.calculateMd5(file, 32, 60000, (progress) => {
console.log(`进度: ${progress}%`);
});
console.log('MD5:', hash);
pool.destroy(); // 用完销毁
优点:速度极快,官方声称比spark-md5快8-16倍,且不阻塞UI。
缺点:依赖WebAssembly和SharedArrayBuffer,需要配置COOP/COEP响应头才能启用多线程(单线程模式则不需要)。
方案三:Web Crypto API(原生支持)
浏览器内置的加密API,不需要额外库。
js
async function hashWithWebCrypto(file) {
const chunkSize = 2 * 1024 * 1024; // 2MB 分片
let position = 0;
let hashBuffer = await crypto.subtle.digest('SHA-256', new ArrayBuffer(0));
while (position < file.size) {
const chunk = file.slice(position, position + chunkSize);
const arrayBuffer = await chunk.arrayBuffer();
// 注意:Web Crypto 的 digest 方法不能增量更新,需要自己拼接
// 实际上更简单的方式是一次性读取(小文件)或用 spark-md5
position += chunkSize;
}
// crypto.subtle.digest不支持真正的增量MD5,它每次digest都是重新计算整个数据块,所以大文件场景不如spark-md5的增量模式
}
注意:Web Crypto API的digest方法是一次性计算,不是真正的增量,所以对大文件不太友好。真正的增量计算还是得用spark-md5。
其他开箱即用的封装库
file-chunk-worker:基于Web Worker+spark-md5,支持进度回调,开箱即用。fluxforge:企业级分片处理库,内置重试、并发控制、完整性验证。