gongxiangp2/docs/resume-upload-architecture.md

13 KiB
Raw Blame History

断点续传架构详解:前端与后端的配合

📋 目录

  1. 整体架构
  2. 前端存储(IndexedDB)
  3. 后端存储(内存Map/Redis)
  4. 两者关系
  5. 断点续传流程
  6. 数据丢失场景分析

🏗️ 整体架构

┌─────────────────────────────────────────────────────────┐
│                   断点续传系统架构                        │
└─────────────────────────────────────────────────────────┘

┌──────────────┐                    ┌──────────────┐
│   前端浏览器   │                    │   后端服务器   │
│              │                    │              │
│ ┌──────────┐ │                    │ ┌──────────┐ │
│ │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
}

作用

  1. ✅ 记录上传进度 - 客户端知道哪些块已上传
  2. ✅ 页面刷新恢复 - 刷新页面后可以从断点继续
  3. ✅ 浏览器关闭恢复 - 关闭浏览器后重新打开可以继续
  4. ✅ 跳过已上传块 - 只上传未完成的块,节省时间

存储位置

  • IndexedDB(浏览器本地数据库)
  • 持久化存储,即使关闭浏览器也不会丢失
  • 每个域名独立,不会跨站点访问

🗄️ 后端存储(内存Map/Redis)

存储内容

interface UploadSession {
  fileName: string                // 文件名
  fileSize: number                // 文件大小
  totalChunks: number             // 总块数
  chunks: Map<number, string>     // 块索引 → 块文件路径(服务器实际存储)
  createdAt: number               // 创建时间
  userId: string                  // 用户ID
}

作用

  1. ✅ 验证上传会话 - 验证上传请求是否合法
  2. ✅ 记录已接收块 - 服务器知道哪些块已收到
  3. ✅ 存储块文件路径 - 知道每个块文件存储在哪里
  4. ✅ 用户权限验证 - 确保用户只能访问自己的上传
  5. ✅ 合并文件 - 完成上传时,知道如何合并所有块

存储位置

  • 内存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 过期,但临时文件还在
// 需要清理这些文件,释放磁盘空间

📚 相关文档