gongxiangp2/lib/upload-sessions-redis.ts
chenpei 4cf06c0463 优化:分片上传性能和稳定性 - 50MB分片+4小时TTL
主要改动:
1. 统一使用50MB分片(内网公网一致)
   - 请求数减少80%(1GB文件从100个减少到20个)
   - TCP利用率更高,网络开销更低
2. 延长会话TTL:5分钟 → 4小时
   - 支持220GB大文件上传
   - 解决大文件上传中途失败问题
3. 优化超时时间:支持50MB分片(5-10分钟)
4. 新增会话刷新机制(不清空进度)
   - 新增 /api/materials/upload/refresh API
   - 会话过期后保留已上传进度
5. 新增TUS协议迁移方案文档
   - docs/TUS协议迁移方案.md

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 10:24:53 +08:00

280 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Redis实现 - 生产环境推荐
*
* 功能:
* - 数据持久化(RDB + AOF)
* - 支持多实例部署
* - 自动过期(TTL)
* - 高可用性
* - 跨服务共享
*/
import Redis from 'ioredis'
import type { UploadSession } from './upload-sessions'
// 初始化Redis连接
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'
const redis = new Redis(redisUrl, {
retryStrategy: (times) => {
// 重试策略:指数退避,最大延迟2秒,最多重试10次
if (times > 10) {
console.error('[Redis] 重试次数超过10次,停止重试')
return null // 停止重试
}
const delay = Math.min(times * 50, 2000)
return delay
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
enableOfflineQueue: true, // 启用离线队列,允许在连接恢复前缓存命令
lazyConnect: false, // 立即连接
connectTimeout: 5000, // 连接超时5秒
commandTimeout: 3000, // 命令超时3秒
// 自动重连配置
autoResubscribe: true,
autoResendUnfulfilledCommands: true,
})
// Redis键前缀
const KEY_PREFIX = 'upload:session:'
const EXPIRE_TIME = 4 * 60 * 60 // 4小时(秒),支持大文件上传(17.5分钟×13≈220GB)
/**
* 保存上传会话
*/
export async function saveUploadSession(
uploadId: string,
session: UploadSession
): Promise<void> {
try {
const key = `${KEY_PREFIX}${uploadId}`
// 将Map转换为数组以便序列化
const data = {
...session,
chunks: Array.from(session.chunks.entries()),
}
// 使用SETEX设置键值并指定过期时间
await redis.setex(key, EXPIRE_TIME, JSON.stringify(data))
console.log(`[Redis] 保存上传会话: ${uploadId}, 过期时间: ${EXPIRE_TIME}秒`)
} catch (error) {
console.error(`[Redis] 保存上传会话失败: ${uploadId}`, error)
throw error
}
}
/**
* 获取上传会话
*/
export async function getUploadSession(
uploadId: string
): Promise<UploadSession | null> {
try {
const key = `${KEY_PREFIX}${uploadId}`
const data = await redis.get(key)
if (!data) {
return null
}
const session = JSON.parse(data)
// 将数组转换回Map
return {
...session,
chunks: new Map(session.chunks || []),
}
} catch (error) {
console.error(`[Redis] 获取上传会话失败: ${uploadId}`, error)
return null
}
}
/**
* 删除上传会话
*/
export async function deleteUploadSession(uploadId: string): Promise<void> {
try {
const key = `${KEY_PREFIX}${uploadId}`
await redis.del(key)
console.log(`[Redis] 删除上传会话: ${uploadId}`)
} catch (error) {
console.error(`[Redis] 删除上传会话失败: ${uploadId}`, error)
throw error
}
}
/**
* 更新块信息
*/
export async function updateChunk(
uploadId: string,
chunkIndex: number,
chunkPath: string
): Promise<void> {
try {
const session = await getUploadSession(uploadId)
if (!session) {
throw new Error(`上传会话不存在: ${uploadId}`)
}
session.chunks.set(chunkIndex, chunkPath)
await saveUploadSession(uploadId, session)
console.log(`[Redis] 更新块信息: ${uploadId}, 块 ${chunkIndex}`)
} catch (error) {
console.error(`[Redis] 更新块信息失败: ${uploadId}, 块 ${chunkIndex}`, error)
throw error
}
}
/**
* 检查会话是否存在
*/
export async function hasUploadSession(uploadId: string): Promise<boolean> {
try {
const key = `${KEY_PREFIX}${uploadId}`
const exists = await redis.exists(key)
return exists === 1
} catch (error) {
console.error(`[Redis] 检查会话是否存在失败: ${uploadId}`, error)
return false
}
}
/**
* 获取会话的剩余过期时间
*/
export async function getSessionTTL(uploadId: string): Promise<number> {
try {
const key = `${KEY_PREFIX}${uploadId}`
const ttl = await redis.ttl(key)
return ttl
} catch (error) {
console.error(`[Redis] 获取会话TTL失败: ${uploadId}`, error)
return -2 // -2表示键不存在
}
}
/**
* 获取所有活跃会话数量
*/
export async function getActiveSessionsCount(): Promise<number> {
try {
const keys = await redis.keys(`${KEY_PREFIX}*`)
return keys.length
} catch (error) {
console.error('[Redis] 获取活跃会话数量失败', error)
return 0
}
}
/**
* 刷新会话过期时间
*/
export async function refreshSessionTTL(uploadId: string): Promise<void> {
try {
const key = `${KEY_PREFIX}${uploadId}`
await redis.expire(key, EXPIRE_TIME)
console.log(`[Redis] 刷新会话过期时间: ${uploadId}`)
} catch (error) {
console.error(`[Redis] 刷新会话过期时间失败: ${uploadId}`, error)
throw error
}
}
/**
* 刷新上传会话(保留已上传进度)
* 用于会话过期后恢复上传,不清空已上传的分片记录
*/
export async function refreshUploadSession(uploadId: string): Promise<boolean> {
try {
const key = `${KEY_PREFIX}${uploadId}`
const exists = await redis.expire(key, EXPIRE_TIME)
if (exists === 1) {
console.log(`[Redis] 刷新上传会话成功: ${uploadId}, TTL: ${EXPIRE_TIME}秒`)
return true
} else {
console.warn(`[Redis] 刷新上传会话失败,会话不存在: ${uploadId}`)
return false
}
} catch (error) {
console.error(`[Redis] 刷新上传会话失败: ${uploadId}`, error)
return false
}
}
/**
* 设置已完成会话的过期时间为 1 分钟(立即清理)
*/
export async function setCompletedSessionTTL(uploadId: string): Promise<void> {
try {
const key = `${KEY_PREFIX}${uploadId}`
const COMPLETED_TTL = 60 // 1分钟
await redis.expire(key, COMPLETED_TTL)
console.log(`[Redis] 已完成会话,TTL 缩短为 1 分钟: ${uploadId}`)
} catch (error) {
console.error(`[Redis] 设置已完成会话 TTL 失败: ${uploadId}`, error)
throw error
}
}
/**
* 获取所有上传会话
*/
export async function getAllSessions(): Promise<Map<string, UploadSession>> {
try {
const keys = await redis.keys(`${KEY_PREFIX}*`)
const sessions = new Map<string, UploadSession>()
for (const key of keys) {
try {
const data = await redis.get(key)
if (data) {
const uploadId = key.replace(KEY_PREFIX, '')
const session = JSON.parse(data)
sessions.set(uploadId, {
...session,
chunks: new Map(session.chunks || []),
})
}
} catch (error) {
console.error(`[Redis] 解析会话失败: ${key}`, error)
}
}
return sessions
} catch (error) {
console.error('[Redis] 获取所有会话失败', error)
return new Map()
}
}
// 错误处理
redis.on('error', (err) => {
console.error('[Redis] 连接错误:', err)
// 不抛出错误,允许应用继续运行(降级到内存Map)
})
redis.on('connect', () => {
console.log('[Redis] 连接成功')
})
redis.on('ready', () => {
console.log('[Redis] 准备就绪')
})
redis.on('close', () => {
console.warn('[Redis] 连接关闭')
})
redis.on('reconnecting', () => {
console.log('[Redis] 重新连接中...')
})
// 导出Redis实例(用于其他操作)
export { redis }