13 KiB
13 KiB
断点续传架构详解:前端与后端的配合
📋 目录
🏗️ 整体架构
┌─────────────────────────────────────────────────────────┐
│ 断点续传系统架构 │
└─────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐
│ 前端浏览器 │ │ 后端服务器 │
│ │ │ │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │IndexedDB │ │ │ │内存Map/ │ │
│ │ │ │ │ │Redis │ │
│ │保存状态 │ │ │ │保存会话 │ │
│ └────┬─────┘ │ │ └────┬─────┘ │
│ │ │ │ │ │
│ │ 1.初始化上传 │ │ │
│ │───────────────────────────>│ │ │
│ │ │ │ │
│ │ 2.上传块1 │ │ │
│ │───────────────────────────>│ │ │
│ │ │ │ │
│ │ 3.上传块2 │ │ │
│ │───────────────────────────>│ │ │
│ │ │ │ │
│ │ ... │ │ │
│ │ │ │ │
│ │ 4.完成上传 │ │ │
│ │───────────────────────────>│ │ │
└──────┴────────────────────────────┴──────┴───────┘
💾 前端存储(IndexedDB)
存储内容
interface ChunkUploadState {
fileId: string // 文件唯一ID(基于文件名、大小、修改时间)
fileName: string // 文件名
fileSize: number // 文件大小
chunkSize: number // 每块大小
totalChunks: number // 总块数
uploadedChunks: Set<number> // 已上传的块索引(客户端记录)
uploadId?: string // 服务器返回的上传ID
}
作用
- ✅ 记录上传进度 - 客户端知道哪些块已上传
- ✅ 页面刷新恢复 - 刷新页面后可以从断点继续
- ✅ 浏览器关闭恢复 - 关闭浏览器后重新打开可以继续
- ✅ 跳过已上传块 - 只上传未完成的块,节省时间
存储位置
- IndexedDB(浏览器本地数据库)
- 持久化存储,即使关闭浏览器也不会丢失
- 每个域名独立,不会跨站点访问
🗄️ 后端存储(内存Map/Redis)
存储内容
interface UploadSession {
fileName: string // 文件名
fileSize: number // 文件大小
totalChunks: number // 总块数
chunks: Map<number, string> // 块索引 → 块文件路径(服务器实际存储)
createdAt: number // 创建时间
userId: string // 用户ID
}
作用
- ✅ 验证上传会话 - 验证上传请求是否合法
- ✅ 记录已接收块 - 服务器知道哪些块已收到
- ✅ 存储块文件路径 - 知道每个块文件存储在哪里
- ✅ 用户权限验证 - 确保用户只能访问自己的上传
- ✅ 合并文件 - 完成上传时,知道如何合并所有块
存储位置
- 内存Map(当前实现)- 进程内存
- Redis(生产环境推荐)- 独立内存数据库
🔗 两者关系
关系图
┌─────────────────────────────────────────────────────────┐
│ 断点续传数据流 │
└─────────────────────────────────────────────────────────┘
前端(IndexedDB) 后端(内存Map/Redis)
┌─────────────────┐ ┌─────────────────┐
│ fileId │ │ uploadId │
│ fileName │ │ fileName │
│ fileSize │ │ fileSize │
│ totalChunks │ │ totalChunks │
│ uploadedChunks │ ←── 对应 ──→ │ chunks │
│ [0, 1, 2] │ │ {0: path1, │
│ │ │ 1: path2, │
│ uploadId │ ←── 关联 ──→ │ 2: path3} │
└─────────────────┘ └─────────────────┘
│ │
│ 客户端知道哪些块已上传 │ 服务器知道哪些块已存储
│ (基于网络请求成功) │ (基于实际文件写入)
└───────────────────────────────────┘
关键区别
| 特性 | 前端(IndexedDB) | 后端(内存Map/Redis) |
|---|---|---|
| 存储内容 | 上传进度(块索引) | 上传会话(块文件路径) |
| 数据来源 | 网络请求成功 | 实际文件写入 |
| 作用范围 | 单客户端 | 全局(所有客户端) |
| 持久化 | ✅ 浏览器持久化 | 内存Map: ❌ / Redis: ✅ |
| 恢复能力 | 页面刷新可恢复 | 内存Map: 重启丢失 / Redis: 重启可恢复 |
🔄 断点续传流程
场景1:正常上传流程
1. 用户选择文件
└─> 前端生成 fileId
2. 前端检查 IndexedDB
└─> 没有记录 → 新上传
3. 调用 /api/materials/upload/init
└─> 后端创建 uploadSession(内存Map)
└─> 返回 uploadId
4. 前端保存到 IndexedDB
└─> fileId, uploadId, uploadedChunks: []
5. 上传块1
└─> 前端:uploadChunk(uploadId, 0, chunk1)
└─> 后端:保存块文件,记录到 uploadSession.chunks
└─> 前端:更新 IndexedDB,uploadedChunks: [0]
6. 上传块2
└─> 前端:uploadChunk(uploadId, 1, chunk2)
└─> 后端:保存块文件,记录到 uploadSession.chunks
└─> 前端:更新 IndexedDB,uploadedChunks: [0, 1]
7. 完成上传
└─> 前端:completeUpload(uploadId)
└─> 后端:合并所有块,创建数据库记录
└─> 前端:删除 IndexedDB 记录
场景2:页面刷新恢复
1. 用户刷新页面
└─> 前端重新加载文件
2. 前端检查 IndexedDB
└─> 找到记录:uploadedChunks: [0, 1, 2]
└─> uploadId: "upload-xxx"
3. 前端跳过已上传块
└─> 块0, 1, 2 已上传,跳过
└─> 从块3开始上传
4. 调用 /api/materials/upload/chunk
└─> 后端检查 uploadSession(内存Map)
└─> 找到会话,验证用户权限
└─> 保存块3,更新 uploadSession.chunks
5. 继续上传剩余块
└─> 块4, 5, 6...
场景3:服务器重启(内存Map)
1. 用户上传到50%
└─> 前端 IndexedDB: uploadedChunks: [0-9]
└─> 后端 内存Map: chunks: {0-9: paths}
2. 服务器重启
└─> 后端 内存Map: ❌ 数据丢失
3. 用户继续上传块10
└─> 前端:uploadChunk(uploadId, 10, chunk10)
└─> 后端:❌ uploadSession 不存在
└─> 返回错误:上传会话不存在或已过期
4. 前端处理
└─> 检测到会话丢失
└─> 重新初始化上传(/api/materials/upload/init)
└─> 获得新的 uploadId
└─> 但之前的块文件还在临时目录
└─> ❌ 无法恢复,需要重新上传
场景4:服务器重启(Redis)
1. 用户上传到50%
└─> 前端 IndexedDB: uploadedChunks: [0-9]
└─> 后端 Redis: chunks: {0-9: paths}
2. 服务器重启
└─> 后端 Redis: ✅ 数据还在(如果配置了持久化)
3. 用户继续上传块10
└─> 前端:uploadChunk(uploadId, 10, chunk10)
└─> 后端:✅ 从Redis读取 uploadSession
└─> 找到会话,验证用户权限
└─> 保存块10,更新 Redis
4. 继续上传
└─> ✅ 完美恢复,从50%继续
⚠️ 数据丢失场景分析
场景1:前端数据丢失(IndexedDB被清除)
情况:
- 用户清除了浏览器数据
- IndexedDB被删除
- 但后端会话还在
结果:
- ❌ 前端不知道哪些块已上传
- ✅ 后端知道哪些块已接收
- 前端需要重新初始化上传
- 但后端可以检测到重复的块(通过uploadId)
- 可以优化:后端返回已上传的块列表
场景2:后端数据丢失(内存Map,服务器重启)
情况:
- 服务器重启
- 内存Map数据丢失
- 但前端IndexedDB还在
结果:
- ✅ 前端知道哪些块已上传
- ❌ 后端不知道哪些块已接收
- 前端可以跳过已上传块,但后端无法验证
- 需要重新初始化上传会话
- ❌ 无法恢复,需要重新上传
场景3:后端数据丢失(Redis,但未配置持久化)
情况:
- Redis重启
- 未配置持久化
- 数据丢失
结果:
- ✅ 前端知道哪些块已上传
- ❌ 后端不知道哪些块已接收
- 与内存Map相同,无法恢复
场景4:后端数据持久化(Redis,配置了持久化)
情况:
- Redis重启
- 配置了RDB/AOF持久化
- 数据恢复
结果:
- ✅ 前端知道哪些块已上传
- ✅ 后端知道哪些块已接收
- ✅ 完美恢复,继续上传
🎯 关键点总结
1. 前端IndexedDB的作用
- ✅ 客户端恢复 - 页面刷新、浏览器关闭后恢复
- ✅ 跳过已上传块 - 节省时间和带宽
- ✅ 用户体验 - 显示上传进度
2. 后端uploadSessions的作用
- ✅ 服务器验证 - 验证上传请求合法性
- ✅ 文件管理 - 知道每个块文件存储位置
- ✅ 权限控制 - 确保用户只能访问自己的上传
- ✅ 数据完整性 - 确保所有块都上传完成
3. 两者配合
- ✅ 前端记录进度 - 客户端知道上传进度
- ✅ 后端记录会话 - 服务器知道上传状态
- ✅ 双重保障 - 前端和后端都有记录
- ✅ 断点续传 - 两者配合实现完整的断点续传
4. 内存Map vs Redis
-
内存Map:
- ❌ 服务器重启 → 数据丢失 → 无法恢复
- ✅ 单实例部署可以使用
- ✅ 部署简单
-
Redis:
- ✅ 服务器重启 → 数据保留 → 可以恢复
- ✅ 多实例部署可以使用
- ✅ 高可用性
💡 优化建议
1. 后端返回已上传块列表
// 前端初始化上传时,后端可以返回已上传的块
GET /api/materials/upload/status?uploadId=xxx
Response:
{
uploadId: "xxx",
uploadedChunks: [0, 1, 2], // 服务器已知的已上传块
totalChunks: 10
}
// 前端可以对比 IndexedDB 和服务器返回的数据
// 确保数据一致性
2. 前端检测会话丢失
// 如果后端返回"会话不存在",前端可以:
// 1. 检查 IndexedDB 中的 uploadedChunks
// 2. 询问用户是否重新上传
// 3. 或者尝试从已上传块继续(如果后端支持)
3. 后端清理临时文件
// 定期清理过期的临时文件
// 如果 uploadSession 过期,但临时文件还在
// 需要清理这些文件,释放磁盘空间