perf(backend): ノートのリアクション情報をキャッシュすることでDBへのクエリを削減

This commit is contained in:
syuilo 2023-10-19 09:20:19 +09:00
parent 4d1d25e02f
commit 1671575d5d
13 changed files with 103 additions and 23 deletions

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NoteReactionAndUserPairCache1697673894459 {
name = 'NoteReactionAndUserPairCache1697673894459'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD "reactionAndUserPairCache" character varying(1024) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAndUserPairCache"`);
}
}

View file

@ -584,7 +584,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
// Pack the note // Pack the note
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true }); const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
this.globalEventService.publishNotesStream(noteObj); this.globalEventService.publishNotesStream(noteObj);

View file

@ -187,6 +187,9 @@ export class ReactionService {
await this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
reactions: () => sql, reactions: () => sql,
...(note.reactionAndUserPairCache.length < 10 ? {
reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}:${reaction}')`,
} : {}),
}) })
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();
@ -293,6 +296,7 @@ export class ReactionService {
await this.notesRepository.createQueryBuilder().update() await this.notesRepository.createQueryBuilder().update()
.set({ .set({
reactions: () => sql, reactions: () => sql,
reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}:${exist.reaction}')`,
}) })
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();

View file

@ -170,26 +170,37 @@ export class NoteEntityService implements OnModuleInit {
} }
@bindThis @bindThis
public async populateMyReaction(noteId: MiNote['id'], meId: MiUser['id'], _hint_?: { public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
myReactions: Map<MiNote['id'], MiNoteReaction | null>; myReactions: Map<MiNote['id'], string | null>;
}) { }) {
if (_hint_?.myReactions) { if (_hint_?.myReactions) {
const reaction = _hint_.myReactions.get(noteId); const reaction = _hint_.myReactions.get(note.id);
if (reaction) { if (reaction) {
return this.reactionService.convertLegacyReaction(reaction.reaction); return this.reactionService.convertLegacyReaction(reaction);
} else {
return undefined;
}
}
const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) return undefined;
if (note.reactionAndUserPairCache && reactionsCount <= note.reactionAndUserPairCache.length) {
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
if (pair) {
return this.reactionService.convertLegacyReaction(pair.split(':')[1]);
} else { } else {
return undefined; return undefined;
} }
} }
// パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない // パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない
if (this.idService.parse(noteId).date.getTime() + 2000 > Date.now()) { if (this.idService.parse(note.id).date.getTime() + 2000 > Date.now()) {
return undefined; return undefined;
} }
const reaction = await this.noteReactionsRepository.findOneBy({ const reaction = await this.noteReactionsRepository.findOneBy({
userId: meId, userId: meId,
noteId: noteId, noteId: note.id,
}); });
if (reaction) { if (reaction) {
@ -275,8 +286,9 @@ export class NoteEntityService implements OnModuleInit {
options?: { options?: {
detail?: boolean; detail?: boolean;
skipHide?: boolean; skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
_hint_?: { _hint_?: {
myReactions: Map<MiNote['id'], MiNoteReaction | null>; myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>; packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
}; };
}, },
@ -284,6 +296,7 @@ export class NoteEntityService implements OnModuleInit {
const opts = Object.assign({ const opts = Object.assign({
detail: true, detail: true,
skipHide: false, skipHide: false,
withReactionAndUserPairCache: false,
}, options); }, options);
const meId = me ? me.id : null; const meId = me ? me.id : null;
@ -324,6 +337,7 @@ export class NoteEntityService implements OnModuleInit {
repliesCount: note.repliesCount, repliesCount: note.repliesCount,
reactions: this.reactionService.convertLegacyReactions(note.reactions), reactions: this.reactionService.convertLegacyReactions(note.reactions),
reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host),
reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined,
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined, tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds, fileIds: note.fileIds,
@ -346,18 +360,20 @@ export class NoteEntityService implements OnModuleInit {
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
detail: false, detail: false,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_, _hint_: options?._hint_,
}) : undefined, }) : undefined,
renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, { renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, {
detail: true, detail: true,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_, _hint_: options?._hint_,
}) : undefined, }) : undefined,
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
...(meId && Object.keys(note.reactions).length > 0 ? { ...(meId && Object.keys(note.reactions).length > 0 ? {
myReaction: this.populateMyReaction(note.id, meId, options?._hint_), myReaction: this.populateMyReaction(note, meId, options?._hint_),
} : {}), } : {}),
} : {}), } : {}),
}); });
@ -381,19 +397,48 @@ export class NoteEntityService implements OnModuleInit {
if (notes.length === 0) return []; if (notes.length === 0) return [];
const meId = me ? me.id : null; const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], MiNoteReaction | null>(); const myReactionsMap = new Map<MiNote['id'], string | null>();
if (meId) { if (meId) {
const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); const idsNeedFetchMyReaction = new Set<MiNote['id']>();
// パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない // パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない
const oldId = this.idService.gen(Date.now() - 2000); const oldId = this.idService.gen(Date.now() - 2000);
const targets = [...notes.filter(n => (n.id < oldId) && (Object.keys(n.reactions).length > 0)).map(n => n.id), ...renoteIds];
const myReactions = targets.length > 0 ? await this.noteReactionsRepository.findBy({ for (const note of notes) {
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
const reactionsCount = Object.values(note.renote.reactions).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
myReactionsMap.set(note.renote.id, null);
} else if (reactionsCount <= note.renote.reactionAndUserPairCache.length) {
const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId));
myReactionsMap.set(note.renote.id, pair ? pair.split(':')[1] : null);
} else {
idsNeedFetchMyReaction.add(note.renote.id);
}
} else {
if (note.id < oldId) {
const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
myReactionsMap.set(note.id, null);
} else if (reactionsCount <= note.reactionAndUserPairCache.length) {
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
myReactionsMap.set(note.id, pair ? pair.split(':')[1] : null);
} else {
idsNeedFetchMyReaction.add(note.id);
}
} else {
myReactionsMap.set(note.id, null);
}
}
}
const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({
userId: meId, userId: meId,
noteId: In(targets), noteId: In(Array.from(idsNeedFetchMyReaction)),
}) : []; }) : [];
for (const target of targets) { for (const id of idsNeedFetchMyReaction) {
myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); myReactionsMap.set(id, myReactions.find(reaction => reaction.noteId === id)?.reaction ?? null);
} }
} }

View file

@ -164,6 +164,11 @@ export class MiNote {
}) })
public mentionedRemoteUsers: string; public mentionedRemoteUsers: string;
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public reactionAndUserPairCache: string[];
@Column('varchar', { @Column('varchar', {
length: 128, array: true, default: '{}', length: 128, array: true, default: '{}',
}) })

View file

@ -174,6 +174,14 @@ export const packedNoteSchema = {
type: 'string', type: 'string',
optional: true, nullable: false, optional: true, nullable: false,
}, },
reactionAndUserPairCache: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
myReaction: { myReaction: {
type: 'object', type: 'object',

View file

@ -47,7 +47,7 @@ class ChannelChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }

View file

@ -73,7 +73,7 @@ class GlobalTimelineChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }

View file

@ -52,7 +52,7 @@ class HashtagChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }

View file

@ -75,7 +75,7 @@ class HomeTimelineChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }

View file

@ -89,7 +89,8 @@ class HybridTimelineChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); console.log(note.renote.reactionAndUserPairCache);
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }

View file

@ -72,7 +72,7 @@ class LocalTimelineChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }

View file

@ -104,7 +104,7 @@ class UserListChannel extends Channel {
if (this.user && note.renoteId && !note.text) { if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) { if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renoteId, this.user.id); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction; note.renote.myReaction = myRenoteReaction;
} }
} }