12 KiB
12 KiB
上传会话存储:内存Map vs 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
必须迁移的情况:
- ✅ 多实例部署 - 使用负载均衡,多个容器实例
- ✅ 高可用性要求 - 不能容忍数据丢失
- ✅ 大量并发上传 - 需要更好的内存管理
- ✅ 跨服务共享 - 上传服务和API服务分离
- ✅ 生产环境 - 正式上线,用户使用
可以继续使用内存Map的情况:
- ✅ 单实例部署 - 只有一个容器
- ✅ 开发/测试环境 - 数据丢失影响小
- ✅ 小规模使用 - 并发上传少
- ✅ 简单部署 - 不想增加复杂度
💡 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):
- ✅ 适合:多实例部署、生产环境、高可用性
- ✅ 优势:数据持久化、跨实例共享、自动过期
- ❌ 问题:需要额外服务、部署复杂
建议:
- 当前阶段:继续使用内存Map(单实例部署,可以工作)
- 未来升级:当需要多实例或高可用时,迁移到Redis
- 平滑迁移:实现环境适配层,通过环境变量切换