La constatazione
EnglishUp mostra quiz di lezioni di inglese, ogni domanda ha le sue risposte possibili e la sua risposta corretta. In locale con pochi dati, tutto sembrava veloce. In produzione, con qualche centinaio di domande, l'endpoint GET /api/lessons/:id/quiz impiegava 2,3 secondi a rispondere per 20 domande. Inaccettabile per un'esperienza di quiz che dovrebbe essere fluida.
Il codice colpevole
Il repository recuperava la lezione, poi iterava sulle sue domande per andare a prendere le risposte una per una:
// AVANT : une requête par question = N+1
async function getQuizForLesson(lessonId) {
const lesson = await prisma.lesson.findUnique({ where: { id: lessonId } });
const questions = await prisma.question.findMany({ where: { lessonId } });
for (const q of questions) {
q.answers = await prisma.answer.findMany({ where: { questionId: q.id } }); // N requêtes !
}
return { lesson, questions };
}
Per 20 domande, sono 22 query SQL sequenziali (1 lezione + 1 elenco di domande + 20 volte le risposte), ognuna con la sua latenza di rete verso MySQL. Il classico problema N+1, ma con Prisma che rende il codice così leggibile che non lo si vede arrivare.
La diagnosi
Prisma espone un logger di query molto utile per questo tipo di casi:
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
});
prisma.$on('query', (e) => {
console.log(`${e.query} — ${e.duration}ms`);
});
La console ha mostrato 22 righe quasi identiche per una sola chiamata API. Il pattern era evidente fin dai primi log.
La correzione: include invece di iterare
Prisma permette di caricare le relazioni in una sola query tramite include, generando un JOIN (o più query ottimizzate in parallelo a seconda del motore, ma sempre in un solo round-trip logico):
// APRÈS : une seule requête avec include imbriqué
async function getQuizForLesson(lessonId) {
return prisma.lesson.findUnique({
where: { id: lessonId },
include: {
questions: {
include: { answers: true },
orderBy: { position: 'asc' },
},
},
});
}
Risultato: da 22 query a 1 sola, e il tempo di risposta è passato da 2,3s a 90ms.
Cosa ne ricavo
- L'N+1 si nasconde facilmente dietro codice Prisma pulito e leggibile — la leggibilità non è una garanzia di prestazioni.
- Attivare il logger di query in sviluppo, anche temporaneamente, rivela istantaneamente i pattern N+1.
includeannidato sostituisce elegantemente i cicli conawaitall'interno — un riflesso da avere non appena si vede unforattorno a una chiamata Prisma.- Su una relazione 1-N-N come lezione → domande → risposte, un solo
includeben strutturato basta a caricare tutto in un colpo.