gongxiangp2/docs/upload-sessions-redis-explanation.md

12 KiB
Raw Blame History

上传会话存储:内存Map vs Redis 详细说明

📋 目录

  1. 与断点续传的关系
  2. 当前实现(内存Map)的问题
  3. Redis的优势
  4. 为什么非生产环境可以用内存Map
  5. 何时需要迁移到Redis
  6. Redis实现方案

🔗 与断点续传的关系

核心关系

断点续传功能 = 前端IndexedDB(客户端恢复) + 后端uploadSessions(服务器验证)

前端IndexedDB的作用

  • ✅ 记录上传进度 - 客户端知道哪些块已上传
  • ✅ 页面刷新恢复 - 刷新页面后可以从断点继续
  • ✅ 跳过已上传块 - 只上传未完成的块,节省时间

后端uploadSessions的作用

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

两者配合实现断点续传

┌─────────────────────────────────────────────────────────┐
│              断点续传数据流                               │
└─────────────────────────────────────────────────────────┘

前端(IndexedDB)                   后端(内存Map/Redis)
┌─────────────────┐                ┌─────────────────┐
│ uploadedChunks  │ ←── 对应 ──→   │ chunks          │
│   [0, 1, 2]     │                │   {0: path1,    │
│                 │                │    1: path2,    │
│ uploadId        │ ←── 关联 ──→   │    2: path3}    │
└─────────────────┘                └─────────────────┘
     │                                   │
     │ 客户端知道哪些块已上传              │ 服务器知道哪些块已存储
     │ (基于网络请求成功)                │ (基于实际文件写入)

服务器重启的影响

使用内存Map(当前):

  • 服务器重启 → 后端数据丢失 → ❌ 无法恢复
  • 前端可以恢复,但后端无法验证
  • 需要重新初始化上传

使用Redis(生产环境):

  • 服务器重启 → 后端数据保留 → ✅ 可以恢复
  • 前端和后端都可以恢复
  • 完美的断点续传体验

详细说明

更多关于断点续传的详细说明,请查看:


🔴 当前实现(内存Map)的问题

1. 数据丢失风险

// 当前实现:数据存储在进程内存中
export const uploadSessions = new Map<string, {...}>()

// 问题场景:
// - 服务器重启 → 所有上传会话丢失
// - 容器重启 → 所有上传会话丢失
// - 应用崩溃 → 所有上传会话丢失
// - 部署更新 → 所有上传会话丢失

实际影响:

  • 用户正在上传大文件(比如50%进度)
  • 服务器重启或部署更新
  • 上传会话丢失,用户需要重新开始上传
  • 断点续传功能失效

2. 多实例部署问题

当前架构:单实例部署(可以工作)
          ┌─────────────┐
          │  Container  │
          │  (App)      │
          │  Memory Map │
          └─────────────┘

未来可能:多实例部署(内存Map无法工作)
          ┌─────────────┐     ┌─────────────┐
          │  Container1 │     │  Container2 │
          │  Memory Map │     │  Memory Map │
          └─────────────┘     └─────────────┘
                │                    │
                └────────┬───────────┘
                         │
                   用户请求可能被
                   路由到不同实例
                   导致会话丢失

问题场景:

  • 用户请求1:初始化上传 → 路由到Container1 → 会话存储在Container1内存
  • 用户请求2:上传块 → 路由到Container2 → Container2找不到会话 → 上传失败

3. 内存限制

// 内存Map会无限增长(直到手动清理)
// 如果有很多大文件上传:
// - 每个会话:~1KB
// - 1000个并发上传:~1MB
// - 10000个并发上传:~10MB
// - 如果清理失败,内存会持续增长

4. 无法跨服务共享

如果未来需要:
- 上传服务(独立服务)
- API服务(独立服务)
- 它们需要共享上传会话数据
- 内存Map无法跨进程共享

✅ Redis的优势

1. 数据持久化

// Redis可以配置持久化
// - RDB快照:定期保存数据
// - AOF日志:记录每个写操作
// 即使Redis重启,数据也不会丢失

2. 跨实例共享

多实例部署(Redis可以工作)
          ┌─────────────┐     ┌─────────────┐
          │  Container1 │     │  Container2 │
          └──────┬──────┘     └──────┬──────┘
                 │                   │
                 └─────────┬─────────┘
                           │
                    ┌──────▼──────┐
                    │    Redis    │
                    │  (共享存储)  │
                    └─────────────┘

所有实例共享同一个Redis,会话数据全局可见

3. 自动过期(TTL)

// Redis支持自动过期
await redis.setex(`upload:${uploadId}`, 24 * 60 * 60, sessionData)
// 24小时后自动删除,无需手动清理

// 内存Map需要手动清理
setInterval(() => {
  // 手动遍历和删除
}, 60 * 60 * 1000)

4. 高性能

内存Map:O(1)查找,但受限于单进程内存
Redis:   O(1)查找,独立内存,支持高并发

5. 高可用性

Redis可以配置:
- 主从复制(Master-Slave)
- 哨兵模式(Sentinel)
- 集群模式(Cluster)
- 数据自动备份和故障转移

🟢 为什么非生产环境可以用内存Map

1. 单实例部署

# 当前部署方式
docker compose up -d
# 只有一个容器实例,不会有跨实例问题

2. 数据丢失影响小

开发/测试环境:
- 数据丢失影响不大
- 可以重新测试
- 不需要高可用性

3. 部署简单

内存Map:
- 无需额外服务
- 无需配置
- 开箱即用

Redis:
- 需要安装Redis服务
- 需要配置连接
- 增加部署复杂度

4. 资源消耗小

内存Map:
- 无额外进程
- 无网络开销
- 内存占用小

Redis:
- 额外进程(~10-50MB内存)
- 网络开销
- 对小型应用可能过度设计

📊 对比表

特性 内存Map Redis
数据持久化 ❌ 进程重启丢失 ✅ 可配置持久化
多实例支持 ❌ 无法共享 ✅ 全局共享
自动过期 ❌ 需手动清理 ✅ 支持TTL
部署复杂度 ✅ 简单 ❌ 需要额外服务
性能 ✅ 极快(本地内存) ✅ 很快(网络内存)
内存占用 ✅ 小(应用内存) ❌ 额外进程内存
高可用性 ❌ 单点故障 ✅ 支持集群
适用场景 单实例、开发测试 生产环境、多实例

🚀 何时需要迁移到Redis

必须迁移的情况:

  1. ✅ 多实例部署 - 使用负载均衡,多个容器实例
  2. ✅ 高可用性要求 - 不能容忍数据丢失
  3. ✅ 大量并发上传 - 需要更好的内存管理
  4. ✅ 跨服务共享 - 上传服务和API服务分离
  5. ✅ 生产环境 - 正式上线,用户使用

可以继续使用内存Map的情况:

  1. ✅ 单实例部署 - 只有一个容器
  2. ✅ 开发/测试环境 - 数据丢失影响小
  3. ✅ 小规模使用 - 并发上传少
  4. ✅ 简单部署 - 不想增加复杂度

💡 Redis实现方案

方案1:使用ioredis(推荐)

// lib/upload-sessions-redis.ts
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379')

interface UploadSession {
  fileName: string
  fileSize: number
  totalChunks: number
  chunks: Map<number, string>
  createdAt: number
  userId: string
}

export async function saveUploadSession(
  uploadId: string,
  session: UploadSession
): Promise<void> {
  const key = `upload:${uploadId}`
  const data = JSON.stringify({
    ...session,
    chunks: Array.from(session.chunks.entries())
  })
  // 24小时过期
  await redis.setex(key, 24 * 60 * 60, data)
}

export async function getUploadSession(
  uploadId: string
): Promise<UploadSession | null> {
  const key = `upload:${uploadId}`
  const data = await redis.get(key)
  if (!data) return null
  
  const session = JSON.parse(data)
  return {
    ...session,
    chunks: new Map(session.chunks)
  }
}

export async function deleteUploadSession(uploadId: string): Promise<void> {
  const key = `upload:${uploadId}`
  await redis.del(key)
}

export async function updateChunk(
  uploadId: string,
  chunkIndex: number,
  chunkPath: string
): Promise<void> {
  const session = await getUploadSession(uploadId)
  if (!session) throw new Error('Session not found')
  
  session.chunks.set(chunkIndex, chunkPath)
  await saveUploadSession(uploadId, session)
}

方案2:环境适配(自动选择)

// lib/upload-sessions.ts
import { saveUploadSession as saveRedis, getUploadSession as getRedis } from './upload-sessions-redis'

const USE_REDIS = process.env.USE_REDIS === 'true'
const memorySessions = new Map()

export async function saveUploadSession(uploadId: string, session: any) {
  if (USE_REDIS) {
    return saveRedis(uploadId, session)
  } else {
    memorySessions.set(uploadId, session)
  }
}

export async function getUploadSession(uploadId: string) {
  if (USE_REDIS) {
    return getRedis(uploadId)
  } else {
    return memorySessions.get(uploadId) || null
  }
}

🎯 总结

当前情况(内存Map):

  • ✅ 适合:单实例部署、开发测试环境
  • ✅ 优势:简单、快速、无需额外服务
  • ❌ 问题:数据不持久、不支持多实例

生产环境(Redis):

  • ✅ 适合:多实例部署、生产环境、高可用性
  • ✅ 优势:数据持久化、跨实例共享、自动过期
  • ❌ 问题:需要额外服务、部署复杂

建议:

  1. 当前阶段:继续使用内存Map(单实例部署,可以工作)
  2. 未来升级:当需要多实例或高可用时,迁移到Redis
  3. 平滑迁移:实现环境适配层,通过环境变量切换

📚 参考资料