在当今数字化办公环境中,大文件传输已成为企业日常运营的刚需。我们团队最近完成了一个支持单文件100GB传输的企业级解决方案,这个系统需要同时满足文件夹结构保持、断点续传、IE8兼容等严苛需求。下面我将从技术架构层面拆解这个系统的设计思路。
这个项目最核心的挑战来自六个方面:
经过对开源社区的全面调研,我们发现现有方案存在以下局限:
因此我们决定自研整套解决方案,关键技术栈包括:
系统采用分层架构设计,各组件职责明确:
code复制[客户端] ←HTTPS加密→ [Nginx] ←→ [Spring Boot] ←→ [MySQL]
↓
[阿里云OSS]
关键设计要点:
前端采用Vue2实现上传组件,主要处理以下逻辑:
javascript复制// 文件夹递归扫描
async scanFolder(directory, path = '') {
const entries = []
const dirReader = directory.createReader()
const dirEntries = await new Promise(resolve => {
dirReader.readEntries(resolve)
})
for (const entry of dirEntries) {
const relativePath = path ? `${path}/${entry.name}` : entry.name
if (entry.isFile) {
entries.push({
isFile: true,
file: entry,
relativePath
})
} else if (entry.isDirectory) {
entries.push(...await this.scanFolder(entry, relativePath))
}
}
return entries
}
// 分片上传控制
async uploadFile(file, relativePath = '') {
const fileSize = file.size
const chunks = Math.ceil(fileSize / this.chunkSize)
const fileId = this.generateFileId(file)
// 断点续传检查
const checkpoint = await this.getUploadCheckpoint(fileId)
let uploadedChunks = checkpoint ? checkpoint.uploadedChunks : 0
for (let i = uploadedChunks; i < chunks; i++) {
const start = i * this.chunkSize
const end = Math.min(fileSize, start + this.chunkSize)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('fileId', fileId)
formData.append('chunkIndex', i)
formData.append('chunks', chunks)
formData.append('chunk', chunk)
formData.append('relativePath', relativePath)
await axios.post('/api/upload/chunk', formData, {
onUploadProgress: (progressEvent) => {
this.updateProgress(fileId, i, chunks,
progressEvent.loaded / progressEvent.total)
}
})
await this.saveUploadCheckpoint(fileId, {
uploadedChunks: i + 1
})
}
// 通知合并
await axios.post('/api/upload/merge', {
fileId,
fileName: file.name,
fileSize,
relativePath
})
}
后端采用Spring Boot框架,主要处理分片上传和合并逻辑:
java复制@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
@PostMapping("/chunk")
public ResponseEntity uploadChunk(
@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("relativePath") String relativePath) {
try {
// 加密分片
byte[] encrypted = encryptChunk(chunk.getBytes())
// 存储到OSS
String chunkKey = "chunks/" + fileId + "/" + chunkIndex
ossClient.putObject(bucketName, chunkKey,
new ByteArrayInputStream(encrypted))
// 更新进度
redisTemplate.opsForHash().increment(
"upload:" + fileId, "uploaded", 1)
return ResponseEntity.ok().build()
} catch (Exception e) {
return ResponseEntity.status(500)
.body("上传失败: " + e.getMessage())
}
}
@PostMapping("/merge")
public ResponseEntity mergeChunks(@RequestBody MergeRequest request) {
// 从Redis获取分片信息
List<PartETag> partETags = getPartETags(request.getFileId())
// 执行OSS合并
CompleteMultipartUploadRequest completeRequest =
new CompleteMultipartUploadRequest(
bucketName,
"files/" + request.getRelativePath(),
uploadId,
partETags)
ossClient.completeMultipartUpload(completeRequest)
// 清理临时分片
cleanTempChunks(request.getFileId())
return ResponseEntity.ok().build()
}
}
断点续传是保证大文件传输可靠性的关键,我们采用双存储机制:
Redis:存储实时上传进度
upload:{fileId}uploaded(已上传分片数)、total(总分片数)MySQL:持久化文件元信息
java复制// 断点信息获取示例
public UploadProgress getProgress(String fileId) {
// 优先从Redis获取
Map<String, Object> progress = redisTemplate.opsForHash()
.entries("upload:" + fileId)
if (progress.isEmpty()) {
// 从数据库恢复
progress = jdbcTemplate.queryForMap(
"SELECT * FROM upload_progress WHERE file_id = ?",
fileId)
if (!progress.isEmpty()) {
redisTemplate.opsForHash().putAll(
"upload:" + fileId, progress)
}
}
return new UploadProgress(
Integer.parseInt(progress.get("uploaded").toString()),
Integer.parseInt(progress.get("total").toString()))
}
系统支持SM4和AES两种加密算法,可在配置中心动态切换:
java复制public class EncryptionUtil {
private static final String AES = "AES"
private static final String SM4 = "SM4"
public static byte[] encrypt(byte[] data, String algorithm) {
switch (algorithm) {
case AES:
return AESEncryptor.encrypt(data)
case SM4:
return SM4Encryptor.encrypt(data)
default:
throw new IllegalArgumentException("不支持的算法")
}
}
// AES实现
private static class AESEncryptor {
static byte[] encrypt(byte[] data) {
// AES-CBC模式实现
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
// ...初始化密钥和IV
return cipher.doFinal(data)
}
}
// SM4实现
private static class SM4Encryptor {
static byte[] encrypt(byte[] data) {
// SM4-ECB模式实现
Cipher cipher = Cipher.getInstance("SM4/ECB/PKCS5Padding")
// ...初始化密钥
return cipher.doFinal(data)
}
}
}
针对IE8的特殊处理包括:
javascript复制// IE8兼容代码示例
(function() {
// FormData模拟
if (!window.FormData) {
window.FormData = function() {
this.boundary = '----FormData' + Math.random()
this.parts = []
}
FormData.prototype.append = function(name, value) {
this.parts.push([name, value])
}
FormData.prototype._serialize = function() {
var body = ''
for (var i = 0; i < this.parts.length; i++) {
var part = this.parts[i]
body += '--' + this.boundary + '\r\n'
body += 'Content-Disposition: form-data; name="' + part[0] + '"'
if (typeof part[1] === 'object') {
body += '; filename="' + part[1].name + '"\r\n'
body += 'Content-Type: application/octet-stream\r\n\r\n'
body += part[1].getAsBinary() + '\r\n'
} else {
body += '\r\n\r\n' + part[1] + '\r\n'
}
}
body += '--' + this.boundary + '--'
return body
}
}
})()
传统的大文件下载通常需要在服务端打包成ZIP,这会导致:
我们的解决方案是:
java复制@RestController
@RequestMapping("/api/download")
public class FileDownloadController {
@GetMapping("/init")
public ResponseEntity initDownload(@RequestParam String path) {
// 验证权限
if (!checkPermission(path)) {
return ResponseEntity.status(403).build()
}
// 生成清单
DownloadManifest manifest = generateManifest(path)
String manifestId = saveManifest(manifest)
return ResponseEntity.ok(Map.of(
"manifestId", manifestId,
"fileCount", manifest.getFiles().size(),
"totalSize", manifest.getTotalSize()
))
}
@GetMapping("/file")
public void downloadFile(
@RequestParam String manifestId,
@RequestParam int fileIndex,
HttpServletResponse response) {
// 获取文件信息
ManifestFile file = getManifestFile(manifestId, fileIndex)
OSSObject ossObject = ossClient.getObject(
bucketName, file.getStoragePath())
// 设置响应头
response.setContentType("application/octet-stream")
response.setHeader("Content-Disposition",
"attachment; filename=" + file.getFileName())
// 流式解密输出
try (InputStream in = ossObject.getObjectContent();
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192]
int bytesRead
while ((bytesRead = in.read(buffer)) != -1) {
byte[] decrypted = decrypt(buffer, bytesRead)
out.write(decrypted)
}
}
}
}
javascript复制class DownloadManager {
constructor(manifestId, fileCount) {
this.manifestId = manifestId
this.fileCount = fileCount
this.progress = Array(fileCount).fill(0)
this.status = Array(fileCount).fill('pending') // 'done'|'failed'|'pending'
}
async start() {
while (true) {
const nextIndex = this.findNextFile()
if (nextIndex === -1) break
try {
await this.downloadFile(nextIndex)
this.markDone(nextIndex)
} catch (error) {
this.markFailed(nextIndex)
}
this.saveProgress()
}
}
async downloadFile(index) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', `/api/download/file?manifestId=${this.manifestId}&fileIndex=${index}`)
xhr.responseType = 'blob'
xhr.onprogress = (event) => {
if (event.lengthComputable) {
this.progress[index] = event.loaded / event.total
}
}
xhr.onload = () => {
if (xhr.status === 200) {
saveBlobToFile(xhr.response)
resolve()
} else {
reject(new Error('下载失败'))
}
}
xhr.send()
})
}
}
动态分片大小:根据网络状况调整分片大小(1MB-10MB)
并行上传:支持3个分片同时上传
javascript复制// 并行上传控制
const MAX_PARALLEL = 3
let activeUploads = 0
async uploadChunks() {
while (hasMoreChunks()) {
if (activeUploads >= MAX_PARALLEL) {
await wait(1000)
continue
}
activeUploads++
uploadNextChunk().finally(() => {
activeUploads--
})
}
}
内存优化:流式处理避免大内存占用
java复制// 流式加密示例
public void encryptStream(InputStream in, OutputStream out) {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
// ...初始化cipher
byte[] buffer = new byte[8192]
int bytesRead
while ((bytesRead = in.read(buffer)) != -1) {
byte[] encrypted = cipher.update(buffer, 0, bytesRead)
out.write(encrypted)
}
byte[] finalBytes = cipher.doFinal()
out.write(finalBytes)
}
分片重试机制:
心跳检测:
javascript复制// 上传心跳
setInterval(() => {
if (uploading) {
axios.post('/api/upload/heartbeat', {
fileId: currentFileId
})
}
}, 30000)
离线恢复:
| 组件 | 最低配置 | 推荐配置 |
|---|---|---|
| 应用服务器 | 4核8G | 8核16G |
| MySQL | 4核8G, 100G存储 | 8核16G, 500G存储 |
| Redis | 2核4G | 4核8G |
| OSS | 无特殊要求 | 开启传输加速 |
基础监控:
业务监控:
sql复制-- 上传成功率
SELECT
(SELECT COUNT(*) FROM upload_log WHERE status = 'success') * 100.0 /
(SELECT COUNT(*) FROM upload_log) AS success_rate
-- 平均上传速度
SELECT AVG(file_size / TIMESTAMPDIFF(SECOND, start_time, end_time))
FROM upload_log
WHERE status = 'success'
告警设置:
多可用区部署:
灾备方案:
症状:分片上传失败,进度卡住
排查步骤:
bash复制# 查看最近错误日志
tail -n 100 /var/log/upload-service/error.log | grep '上传失败'
bash复制# OSS剩余空间
df -h /oss_mount_point
bash复制redis-cli ping
可能原因:
优化建议:
java复制// 创建加速端点
String endpoint = "https://accelerate.aliyuncs.com"
OSSClient ossClient = new OSSClient(endpoint, credentials)
javascript复制// 增加并行下载数
const downloader = new DownloadManager({
parallel: 3 // 默认1
})
常见问题:
解决方案:
javascript复制if (isIE8()) {
initFlashUploader()
} else {
initNativeUploader()
}
html复制<!--[if lt IE 9]>
<script src="polyfill/formdata.js"></script>
<script src="polyfill/xhr-progress.js"></script>
<![endif]-->
nginx复制server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
nginx复制ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
java复制@PreAuthorize("hasPermission(#folderId, 'UPLOAD')")
@PostMapping("/upload")
public ResponseEntity uploadFile(@PathVariable String folderId) {
// ...
}
java复制public boolean checkDownloadPermission(String fileId, User user) {
FileRecord file = fileRepository.findById(fileId)
return aclService.checkPermission(user, file, 'READ')
}
java复制@Aspect
@Component
public class UploadLogAspect {
@AfterReturning("execution(* com.example..upload*(..))")
public void logUpload(JoinPoint jp) {
String userId = SecurityContext.getCurrentUser()
Object[] args = jp.getArgs()
log.info("用户{}上传文件,参数:{}", userId, Arrays.toString(args))
}
}
sql复制-- 监控异常下载行为
SELECT * FROM download_log
WHERE file_size > 1073741824 /* 1GB */
AND download_time < 60 /* 1分钟内下完 */
ORDER BY create_time DESC
LIMIT 10
| 项目 | 配置 |
|---|---|
| 服务器 | 阿里云 ecs.g7ne.4xlarge |
| 网络 | 5Gbps带宽 |
| 测试工具 | JMeter 5.4.1 |
上传性能:
| 分片大小 | 并发数 | 平均速度 | CPU使用率 |
|---|---|---|---|
| 1MB | 3 | 28MB/s | 65% |
| 2MB | 3 | 32MB/s | 58% |
| 5MB | 3 | 38MB/s | 52% |
下载性能:
| 并发数 | 平均速度 | OPS带宽使用率 |
|---|---|---|
| 1 | 42MB/s | 35% |
| 3 | 118MB/s | 92% |
| 5 | 125MB/s | 100% |
72小时连续运行:
故障注入测试:
在这个大文件传输系统的开发过程中,我们积累了以下几点重要经验:
分片大小需要动态调整:固定分片大小无法适应各种网络环境,后期我们实现了根据网络状况动态调整分片大小的算法,上传速度提升了30%
IE8兼容要尽早测试:现代前端开发很少考虑IE8兼容,我们项目中期才发现多个Polyfill的冲突问题,导致不得不重构部分前端代码
加密算法要可插拔:初期硬编码AES算法导致后期支持国密SM4时改动量很大,抽象出加密接口后系统扩展性明显提升
进度存储要冗余设计:仅使用Redis存储进度在实例重启时会导致数据丢失,引入MySQL持久化后解决了这个问题
客户端限流很重要:初期没有限制客户端并行上传数,导致某些客户端占用过多服务器资源,增加限流后系统稳定性显著提高
这个系统目前已经稳定运行了6个月,日均处理上传请求1.2万次,累计传输数据超过500TB。在实际运维中,我们发现文件夹结构保持功能是最受用户欢迎的特性,而断点续传功能则大大减少了客服工单数量