使用 hash 优化用户信息获取速度

This commit is contained in:
shinya
2026-02-27 19:57:14 +08:00
parent 3a201c7546
commit 13f1fb7166
6 changed files with 252 additions and 105 deletions

View File

@@ -179,13 +179,7 @@ export async function DELETE(request: NextRequest) {
await db.deleteFavorite(username, source, id); await db.deleteFavorite(username, source, id);
} else { } else {
// 清空全部 // 清空全部
const all = await db.getAllFavorites(username); await db.deleteAllFavorites(username);
await Promise.all(
Object.keys(all).map(async (k) => {
const [s, i] = k.split('+');
if (s && i) await db.deleteFavorite(username, s, i);
})
);
} }
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });

View File

@@ -147,14 +147,7 @@ export async function DELETE(request: NextRequest) {
await db.deletePlayRecord(username, source, id); await db.deletePlayRecord(username, source, id);
} else { } else {
// 未提供 key则清空全部播放记录 // 未提供 key则清空全部播放记录
// 目前 DbManager 没有对应方法,这里直接遍历删除 await db.deleteAllPlayRecords(username);
const all = await db.getAllPlayRecords(username);
await Promise.all(
Object.keys(all).map(async (k) => {
const [s, i] = k.split('+');
if (s && i) await db.deletePlayRecord(username, s, i);
})
);
} }
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });

View File

@@ -48,9 +48,24 @@ export function generateStorageKey(source: string, id: string): string {
// 导出便捷方法 // 导出便捷方法
export class DbManager { export class DbManager {
private storage: IStorage; private storage: IStorage;
private migrationPromise: Promise<void> | null = null;
constructor() { constructor() {
this.storage = getStorage(); this.storage = getStorage();
// 启动时自动触发数据迁移(异步,不阻塞构造)
if (this.storage && typeof this.storage.migrateData === 'function') {
this.migrationPromise = this.storage.migrateData().catch((err) => {
console.error('数据迁移异常:', err);
});
}
}
/** 等待迁移完成(内部方法,首次调用后 migrationPromise 会被置空) */
private async ensureMigrated(): Promise<void> {
if (this.migrationPromise) {
await this.migrationPromise;
this.migrationPromise = null;
}
} }
// 播放记录相关方法 // 播放记录相关方法
@@ -76,6 +91,7 @@ export class DbManager {
async getAllPlayRecords(userName: string): Promise<{ async getAllPlayRecords(userName: string): Promise<{
[key: string]: PlayRecord; [key: string]: PlayRecord;
}> { }> {
await this.ensureMigrated();
return this.storage.getAllPlayRecords(userName); return this.storage.getAllPlayRecords(userName);
} }
@@ -88,6 +104,10 @@ export class DbManager {
await this.storage.deletePlayRecord(userName, key); await this.storage.deletePlayRecord(userName, key);
} }
async deleteAllPlayRecords(userName: string): Promise<void> {
await this.storage.deleteAllPlayRecords(userName);
}
// 收藏相关方法 // 收藏相关方法
async getFavorite( async getFavorite(
userName: string, userName: string,
@@ -111,6 +131,7 @@ export class DbManager {
async getAllFavorites( async getAllFavorites(
userName: string userName: string
): Promise<{ [key: string]: Favorite }> { ): Promise<{ [key: string]: Favorite }> {
await this.ensureMigrated();
return this.storage.getAllFavorites(userName); return this.storage.getAllFavorites(userName);
} }
@@ -123,6 +144,10 @@ export class DbManager {
await this.storage.deleteFavorite(userName, key); await this.storage.deleteFavorite(userName, key);
} }
async deleteAllFavorites(userName: string): Promise<void> {
await this.storage.deleteAllFavorites(userName);
}
async isFavorited( async isFavorited(
userName: string, userName: string,
source: string, source: string,

View File

@@ -151,8 +151,8 @@ export abstract class BaseRedisStorage implements IStorage {
} }
// ---------- 播放记录 ---------- // ---------- 播放记录 ----------
private prKey(user: string, key: string) { private prHashKey(user: string) {
return `u:${user}:pr:${key}`; // u:username:pr:source+id return `u:${user}:pr`; // 一个用户的所有播放记录存在一个 Hash 中
} }
async getPlayRecord( async getPlayRecord(
@@ -160,7 +160,7 @@ export abstract class BaseRedisStorage implements IStorage {
key: string key: string
): Promise<PlayRecord | null> { ): Promise<PlayRecord | null> {
const val = await this.withRetry(() => const val = await this.withRetry(() =>
this.client.get(this.prKey(userName, key)) this.client.hGet(this.prHashKey(userName), key)
); );
return val ? (JSON.parse(val) as PlayRecord) : null; return val ? (JSON.parse(val) as PlayRecord) : null;
} }
@@ -171,42 +171,43 @@ export abstract class BaseRedisStorage implements IStorage {
record: PlayRecord record: PlayRecord
): Promise<void> { ): Promise<void> {
await this.withRetry(() => await this.withRetry(() =>
this.client.set(this.prKey(userName, key), JSON.stringify(record)) this.client.hSet(this.prHashKey(userName), key, JSON.stringify(record))
); );
} }
async getAllPlayRecords( async getAllPlayRecords(
userName: string userName: string
): Promise<Record<string, PlayRecord>> { ): Promise<Record<string, PlayRecord>> {
const pattern = `u:${userName}:pr:*`; const all = await this.withRetry(() =>
const keys: string[] = await this.withRetry(() => this.client.keys(pattern)); this.client.hGetAll(this.prHashKey(userName))
if (keys.length === 0) return {}; );
const values = await this.withRetry(() => this.client.mGet(keys));
const result: Record<string, PlayRecord> = {}; const result: Record<string, PlayRecord> = {};
keys.forEach((fullKey: string, idx: number) => { for (const [field, raw] of Object.entries(all)) {
const raw = values[idx];
if (raw) { if (raw) {
const rec = JSON.parse(raw) as PlayRecord; result[field] = JSON.parse(raw) as PlayRecord;
// 截取 source+id 部分 }
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
result[keyPart] = rec;
} }
});
return result; return result;
} }
async deletePlayRecord(userName: string, key: string): Promise<void> { async deletePlayRecord(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.client.del(this.prKey(userName, key))); await this.withRetry(() =>
this.client.hDel(this.prHashKey(userName), key)
);
}
async deleteAllPlayRecords(userName: string): Promise<void> {
await this.withRetry(() => this.client.del(this.prHashKey(userName)));
} }
// ---------- 收藏 ---------- // ---------- 收藏 ----------
private favKey(user: string, key: string) { private favHashKey(user: string) {
return `u:${user}:fav:${key}`; return `u:${user}:fav`; // 一个用户的所有收藏存在一个 Hash 中
} }
async getFavorite(userName: string, key: string): Promise<Favorite | null> { async getFavorite(userName: string, key: string): Promise<Favorite | null> {
const val = await this.withRetry(() => const val = await this.withRetry(() =>
this.client.get(this.favKey(userName, key)) this.client.hGet(this.favHashKey(userName), key)
); );
return val ? (JSON.parse(val) as Favorite) : null; return val ? (JSON.parse(val) as Favorite) : null;
} }
@@ -217,29 +218,31 @@ export abstract class BaseRedisStorage implements IStorage {
favorite: Favorite favorite: Favorite
): Promise<void> { ): Promise<void> {
await this.withRetry(() => await this.withRetry(() =>
this.client.set(this.favKey(userName, key), JSON.stringify(favorite)) this.client.hSet(this.favHashKey(userName), key, JSON.stringify(favorite))
); );
} }
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> { async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
const pattern = `u:${userName}:fav:*`; const all = await this.withRetry(() =>
const keys: string[] = await this.withRetry(() => this.client.keys(pattern)); this.client.hGetAll(this.favHashKey(userName))
if (keys.length === 0) return {}; );
const values = await this.withRetry(() => this.client.mGet(keys));
const result: Record<string, Favorite> = {}; const result: Record<string, Favorite> = {};
keys.forEach((fullKey: string, idx: number) => { for (const [field, raw] of Object.entries(all)) {
const raw = values[idx];
if (raw) { if (raw) {
const fav = JSON.parse(raw) as Favorite; result[field] = JSON.parse(raw) as Favorite;
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); }
result[keyPart] = fav;
} }
});
return result; return result;
} }
async deleteFavorite(userName: string, key: string): Promise<void> { async deleteFavorite(userName: string, key: string): Promise<void> {
await this.withRetry(() => this.client.del(this.favKey(userName, key))); await this.withRetry(() =>
this.client.hDel(this.favHashKey(userName), key)
);
}
async deleteAllFavorites(userName: string): Promise<void> {
await this.withRetry(() => this.client.del(this.favHashKey(userName)));
} }
// ---------- 用户注册 / 登录 ---------- // ---------- 用户注册 / 登录 ----------
@@ -286,23 +289,11 @@ export abstract class BaseRedisStorage implements IStorage {
// 删除搜索历史 // 删除搜索历史
await this.withRetry(() => this.client.del(this.shKey(userName))); await this.withRetry(() => this.client.del(this.shKey(userName)));
// 删除播放记录 // 删除播放记录Hash key 直接删除)
const playRecordPattern = `u:${userName}:pr:*`; await this.withRetry(() => this.client.del(this.prHashKey(userName)));
const playRecordKeys = await this.withRetry(() =>
this.client.keys(playRecordPattern)
);
if (playRecordKeys.length > 0) {
await this.withRetry(() => this.client.del(playRecordKeys));
}
// 删除收藏夹 // 删除收藏夹Hash key 直接删除)
const favoritePattern = `u:${userName}:fav:*`; await this.withRetry(() => this.client.del(this.favHashKey(userName)));
const favoriteKeys = await this.withRetry(() =>
this.client.keys(favoritePattern)
);
if (favoriteKeys.length > 0) {
await this.withRetry(() => this.client.del(favoriteKeys));
}
// 删除跳过片头片尾配置 // 删除跳过片头片尾配置
const skipConfigPattern = `u:${userName}:skip:*`; const skipConfigPattern = `u:${userName}:skip:*`;
@@ -443,6 +434,81 @@ export abstract class BaseRedisStorage implements IStorage {
return configs; return configs;
} }
// ---------- 数据迁移:旧扁平 key → Hash 结构 ----------
private migrationKey() {
return 'sys:migration:hash_v1';
}
async migrateData(): Promise<void> {
// 检查是否已迁移
const migrated = await this.withRetry(() => this.client.get(this.migrationKey()));
if (migrated === 'done') return;
console.log('开始数据迁移:扁平 key → Hash 结构...');
try {
// 迁移播放记录u:*:pr:* → u:username:pr (Hash)
const prKeys = await this.withRetry(() => this.client.keys('u:*:pr:*'));
if (prKeys.length > 0) {
// 过滤掉新 Hash key没有第四段的就是 Hash key 本身)
const oldPrKeys = prKeys.filter((k) => {
const parts = k.split(':');
return parts.length >= 4 && parts[2] === 'pr' && parts[3] !== '';
});
if (oldPrKeys.length > 0) {
const values = await this.withRetry(() => this.client.mGet(oldPrKeys));
for (let i = 0; i < oldPrKeys.length; i++) {
const raw = values[i];
if (!raw) continue;
const match = oldPrKeys[i].match(/^u:(.+?):pr:(.+)$/);
if (!match) continue;
const [, userName, field] = match;
await this.withRetry(() =>
this.client.hSet(this.prHashKey(userName), field, raw)
);
}
// 删除旧 key
await this.withRetry(() => this.client.del(oldPrKeys));
console.log(`迁移了 ${oldPrKeys.length} 条播放记录`);
}
}
// 迁移收藏u:*:fav:* → u:username:fav (Hash)
const favKeys = await this.withRetry(() => this.client.keys('u:*:fav:*'));
if (favKeys.length > 0) {
const oldFavKeys = favKeys.filter((k) => {
const parts = k.split(':');
return parts.length >= 4 && parts[2] === 'fav' && parts[3] !== '';
});
if (oldFavKeys.length > 0) {
const values = await this.withRetry(() => this.client.mGet(oldFavKeys));
for (let i = 0; i < oldFavKeys.length; i++) {
const raw = values[i];
if (!raw) continue;
const match = oldFavKeys[i].match(/^u:(.+?):fav:(.+)$/);
if (!match) continue;
const [, userName, field] = match;
await this.withRetry(() =>
this.client.hSet(this.favHashKey(userName), field, raw)
);
}
// 删除旧 key
await this.withRetry(() => this.client.del(oldFavKeys));
console.log(`迁移了 ${oldFavKeys.length} 条收藏`);
}
}
// 标记迁移完成
await this.withRetry(() => this.client.set(this.migrationKey(), 'done'));
console.log('数据迁移完成');
} catch (error) {
console.error('数据迁移失败:', error);
// 不抛出异常,允许服务继续运行
}
}
// 清空所有数据 // 清空所有数据
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
try { try {

View File

@@ -37,12 +37,14 @@ export interface IStorage {
): Promise<void>; ): Promise<void>;
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>; getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
deletePlayRecord(userName: string, key: string): Promise<void>; deletePlayRecord(userName: string, key: string): Promise<void>;
deleteAllPlayRecords(userName: string): Promise<void>;
// 收藏相关 // 收藏相关
getFavorite(userName: string, key: string): Promise<Favorite | null>; getFavorite(userName: string, key: string): Promise<Favorite | null>;
setFavorite(userName: string, key: string, favorite: Favorite): Promise<void>; setFavorite(userName: string, key: string, favorite: Favorite): Promise<void>;
getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>; getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>;
deleteFavorite(userName: string, key: string): Promise<void>; deleteFavorite(userName: string, key: string): Promise<void>;
deleteAllFavorites(userName: string): Promise<void>;
// 用户相关 // 用户相关
registerUser(userName: string, password: string): Promise<void>; registerUser(userName: string, password: string): Promise<void>;
@@ -81,6 +83,9 @@ export interface IStorage {
deleteSkipConfig(userName: string, source: string, id: string): Promise<void>; deleteSkipConfig(userName: string, source: string, id: string): Promise<void>;
getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>; getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>;
// 数据迁移(旧扁平 key → Hash 结构)
migrateData?(): Promise<void>;
// 数据清理相关 // 数据清理相关
clearAllData(): Promise<void>; clearAllData(): Promise<void>;
} }

View File

@@ -61,8 +61,8 @@ export class UpstashRedisStorage implements IStorage {
} }
// ---------- 播放记录 ---------- // ---------- 播放记录 ----------
private prKey(user: string, key: string) { private prHashKey(user: string) {
return `u:${user}:pr:${key}`; // u:username:pr:source+id return `u:${user}:pr`; // 一个用户的所有播放记录存在一个 Hash 中
} }
async getPlayRecord( async getPlayRecord(
@@ -70,7 +70,7 @@ export class UpstashRedisStorage implements IStorage {
key: string key: string
): Promise<PlayRecord | null> { ): Promise<PlayRecord | null> {
const val = await withRetry(() => const val = await withRetry(() =>
this.client.get(this.prKey(userName, key)) this.client.hget(this.prHashKey(userName), key)
); );
return val ? (val as PlayRecord) : null; return val ? (val as PlayRecord) : null;
} }
@@ -80,40 +80,43 @@ export class UpstashRedisStorage implements IStorage {
key: string, key: string,
record: PlayRecord record: PlayRecord
): Promise<void> { ): Promise<void> {
await withRetry(() => this.client.set(this.prKey(userName, key), record)); await withRetry(() =>
this.client.hset(this.prHashKey(userName), { [key]: record })
);
} }
async getAllPlayRecords( async getAllPlayRecords(
userName: string userName: string
): Promise<Record<string, PlayRecord>> { ): Promise<Record<string, PlayRecord>> {
const pattern = `u:${userName}:pr:*`; const all = await withRetry(() =>
const keys: string[] = await withRetry(() => this.client.keys(pattern)); this.client.hgetall(this.prHashKey(userName))
if (keys.length === 0) return {}; );
if (!all || Object.keys(all).length === 0) return {};
const result: Record<string, PlayRecord> = {}; const result: Record<string, PlayRecord> = {};
for (const fullKey of keys) { for (const [field, value] of Object.entries(all)) {
const value = await withRetry(() => this.client.get(fullKey));
if (value) { if (value) {
// 截取 source+id 部分 result[field] = value as PlayRecord;
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
result[keyPart] = value as PlayRecord;
} }
} }
return result; return result;
} }
async deletePlayRecord(userName: string, key: string): Promise<void> { async deletePlayRecord(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.prKey(userName, key))); await withRetry(() => this.client.hdel(this.prHashKey(userName), key));
}
async deleteAllPlayRecords(userName: string): Promise<void> {
await withRetry(() => this.client.del(this.prHashKey(userName)));
} }
// ---------- 收藏 ---------- // ---------- 收藏 ----------
private favKey(user: string, key: string) { private favHashKey(user: string) {
return `u:${user}:fav:${key}`; return `u:${user}:fav`; // 一个用户的所有收藏存在一个 Hash 中
} }
async getFavorite(userName: string, key: string): Promise<Favorite | null> { async getFavorite(userName: string, key: string): Promise<Favorite | null> {
const val = await withRetry(() => const val = await withRetry(() =>
this.client.get(this.favKey(userName, key)) this.client.hget(this.favHashKey(userName), key)
); );
return val ? (val as Favorite) : null; return val ? (val as Favorite) : null;
} }
@@ -124,28 +127,30 @@ export class UpstashRedisStorage implements IStorage {
favorite: Favorite favorite: Favorite
): Promise<void> { ): Promise<void> {
await withRetry(() => await withRetry(() =>
this.client.set(this.favKey(userName, key), favorite) this.client.hset(this.favHashKey(userName), { [key]: favorite })
); );
} }
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> { async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
const pattern = `u:${userName}:fav:*`; const all = await withRetry(() =>
const keys: string[] = await withRetry(() => this.client.keys(pattern)); this.client.hgetall(this.favHashKey(userName))
if (keys.length === 0) return {}; );
if (!all || Object.keys(all).length === 0) return {};
const result: Record<string, Favorite> = {}; const result: Record<string, Favorite> = {};
for (const fullKey of keys) { for (const [field, value] of Object.entries(all)) {
const value = await withRetry(() => this.client.get(fullKey));
if (value) { if (value) {
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); result[field] = value as Favorite;
result[keyPart] = value as Favorite;
} }
} }
return result; return result;
} }
async deleteFavorite(userName: string, key: string): Promise<void> { async deleteFavorite(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.favKey(userName, key))); await withRetry(() => this.client.hdel(this.favHashKey(userName), key));
}
async deleteAllFavorites(userName: string): Promise<void> {
await withRetry(() => this.client.del(this.favHashKey(userName)));
} }
// ---------- 用户注册 / 登录 ---------- // ---------- 用户注册 / 登录 ----------
@@ -192,23 +197,11 @@ export class UpstashRedisStorage implements IStorage {
// 删除搜索历史 // 删除搜索历史
await withRetry(() => this.client.del(this.shKey(userName))); await withRetry(() => this.client.del(this.shKey(userName)));
// 删除播放记录 // 删除播放记录Hash key 直接删除)
const playRecordPattern = `u:${userName}:pr:*`; await withRetry(() => this.client.del(this.prHashKey(userName)));
const playRecordKeys = await withRetry(() =>
this.client.keys(playRecordPattern)
);
if (playRecordKeys.length > 0) {
await withRetry(() => this.client.del(...playRecordKeys));
}
// 删除收藏夹 // 删除收藏夹Hash key 直接删除)
const favoritePattern = `u:${userName}:fav:*`; await withRetry(() => this.client.del(this.favHashKey(userName)));
const favoriteKeys = await withRetry(() =>
this.client.keys(favoritePattern)
);
if (favoriteKeys.length > 0) {
await withRetry(() => this.client.del(...favoriteKeys));
}
// 删除跳过片头片尾配置 // 删除跳过片头片尾配置
const skipConfigPattern = `u:${userName}:skip:*`; const skipConfigPattern = `u:${userName}:skip:*`;
@@ -344,6 +337,77 @@ export class UpstashRedisStorage implements IStorage {
return configs; return configs;
} }
// ---------- 数据迁移:旧扁平 key → Hash 结构 ----------
private migrationKey() {
return 'sys:migration:hash_v1';
}
async migrateData(): Promise<void> {
// 检查是否已迁移
const migrated = await withRetry(() => this.client.get(this.migrationKey()));
if (migrated === 'done') return;
console.log('开始数据迁移:扁平 key → Hash 结构...');
try {
// 迁移播放记录u:*:pr:* → u:username:pr (Hash)
const prKeys: string[] = await withRetry(() => this.client.keys('u:*:pr:*'));
if (prKeys.length > 0) {
const oldPrKeys = prKeys.filter((k) => {
const parts = k.split(':');
return parts.length >= 4 && parts[2] === 'pr' && parts[3] !== '';
});
for (const oldKey of oldPrKeys) {
const match = oldKey.match(/^u:(.+?):pr:(.+)$/);
if (!match) continue;
const [, userName, field] = match;
const value = await withRetry(() => this.client.get(oldKey));
if (value) {
await withRetry(() =>
this.client.hset(this.prHashKey(userName), { [field]: value })
);
await withRetry(() => this.client.del(oldKey));
}
}
if (oldPrKeys.length > 0) {
console.log(`迁移了 ${oldPrKeys.length} 条播放记录`);
}
}
// 迁移收藏u:*:fav:* → u:username:fav (Hash)
const favKeys: string[] = await withRetry(() => this.client.keys('u:*:fav:*'));
if (favKeys.length > 0) {
const oldFavKeys = favKeys.filter((k) => {
const parts = k.split(':');
return parts.length >= 4 && parts[2] === 'fav' && parts[3] !== '';
});
for (const oldKey of oldFavKeys) {
const match = oldKey.match(/^u:(.+?):fav:(.+)$/);
if (!match) continue;
const [, userName, field] = match;
const value = await withRetry(() => this.client.get(oldKey));
if (value) {
await withRetry(() =>
this.client.hset(this.favHashKey(userName), { [field]: value })
);
await withRetry(() => this.client.del(oldKey));
}
}
if (oldFavKeys.length > 0) {
console.log(`迁移了 ${oldFavKeys.length} 条收藏`);
}
}
// 标记迁移完成
await withRetry(() => this.client.set(this.migrationKey(), 'done'));
console.log('数据迁移完成');
} catch (error) {
console.error('数据迁移失败:', error);
}
}
// 清空所有数据 // 清空所有数据
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
try { try {