Prisma in produzione: query log, transazioni e i dettagli che l'ORM non mostra da solo

Per chi ha già uno schema in produzione e vuole sapere esattamente cosa fa il client prima di fidarsene su scala.

Nei due articoli precedenti (Prisma con Next.js: setup, schema e query in un progetto reale e Prisma e PostgreSQL: ottimizzare query e indici) abbiamo coperto il setup e le prime ottimizzazioni: indici, N+1, paginazione. Restano alcune zone d'ombra che emergono solo quando un progetto cresce sul serio: cosa manda davvero Prisma al database, come garantire che più operazioni avvengano insieme o non avvengano affatto, e dove tracciare la linea tra query builder e SQL scritto a mano. Sono i dettagli che separano chi usa Prisma e chi lo capisce.

Vedere le query SQL che Prisma genera davvero

Prisma traduce ogni chiamata del client in SQL, ma di default quella traduzione resta invisibile. Il modo più diretto per vederla è attivare il query logging in fase di istanziazione del client:
const prisma = new PrismaClient({
  log: [
    { emit: 'event', level: 'query' },
    { emit: 'stdout', level: 'error' },
  ],
});

prisma.$on('query', (e) => {
  console.log('Query:', e.query);
  console.log('Params:', e.params);
  console.log('Duration:', e.duration, 'ms');
});

Questo approccio, a differenza del semplice log: ['query'] che stampa su stdout senza struttura, espone anche la durata di esecuzione, il dato più utile per isolare la query lenta in mezzo a decine di chiamate per ogni richiesta HTTP.

In sviluppo serve soprattutto a capire quante query effettive genera un include a due livelli, e qui vale una precisazione che elimina un equivoco diffuso. Con PostgreSQL la strategia di default di Prisma è query: un include produce sempre query separate, una per relazione, mai un JOIN unico. Il JOIN (in forma di LEFT JOIN LATERAL) si ottiene solo impostando esplicitamente relationLoadStrategy: 'join'. Il log non serve quindi a indovinare quale delle due strade abbia preso il client, ma a vedere quante query partono davvero e con quali parametri.

Va distinto tutto questo da $queryRawUnsafe, che a volte viene confuso con uno strumento di ispezione: non lo è. È una funzione per eseguire SQL costruito dinamicamente, e il nome non è casuale. A differenza di $queryRaw, non parametrizza automaticamente gli input, quindi l'interpolazione di valori provenienti dall'utente va evitata o gestita a mano. Va riservato a query realmente dinamiche (es. nomi di tabella variabili), mai come scorciatoia per query normali.

Indici e unicità dichiarati, non scritti

Uno dei vantaggi meno pubblicizzati di Prisma è che indici e vincoli di unicità diventano parte dello schema versionato, non comandi SQL sparsi tra migration manuali. Nello schema.prisma:

model User {
  id       String @id @default(cuid())
  email    String
  tenantId String

  @@unique([tenantId, email], name: "unique_email_per_tenant")
}

È il punto in cui è facile sbagliare. Mettere @unique sul singolo campo (email String @unique) impone l'unicità dell'email a livello globale: renderebbe impossibile avere lo stesso indirizzo su due tenant diversi. In uno scenario multi-tenant è quasi sempre l'opposto di quello che serve, dove la stessa persona può essere utente di più organizzazioni con la medesima email. La forma corretta è il vincolo composto @@unique([tenantId, email]) a livello di modello, senza @unique sul campo: l'unicità viene garantita per tenant, non sull'intera tabella.

Un dettaglio che fa risparmiare un indice: @@unique crea già un indice sulle colonne indicate, nell'ordine in cui compaiono. Poiché tenantId è la prima colonna del vincolo, le query che filtrano solo su tenantId sono già coperte, per la regola del leftmost prefix. Aggiungere un @@index([tenantId]) separato sarebbe ridondante. L'indice esplicito, come visto nell'articolo precedente, va aggiunto per le colonne filtrate di frequente che non compaiono già come prefisso di un altro indice: Prisma non lo deduce dalle query che si scrivono nel codice applicativo.

Un dettaglio spesso ignorato: prisma migrate dev genera SQL leggibile in prisma/migrations/. Vale la pena aprirlo prima di applicarlo su un database di produzione, ed è qui che serve un'accortezza che va detta con chiarezza. Prisma non genera mai CREATE INDEX CONCURRENTLY: la migration contiene un semplice CREATE INDEX, che su una tabella grande e trafficata acquisisce un lock sulle scritture per tutta la durata della creazione. Non potrebbe essere altrimenti, perché CONCURRENTLY in PostgreSQL non può girare all'interno di un blocco di transazione, e Prisma avvolge ogni migration in una transazione. Se l'indice va creato su un dataset grande in produzione, la migration va modificata a mano, estraendo il comando CONCURRENTLY dalla transazione gestita da Prisma. Aprire il file .sql prima di applicarlo serve proprio a rendersene conto, invece di scoprire il lock a tabella già bloccata.

N+1: come si manifesta davvero in un progetto reale

Il problema N+1 raramente si presenta come nel manuale, cioè un loop esplicito con findUnique dentro un for. Più spesso si nasconde in un resolver GraphQL, in una funzione di formattazione chiamata su ogni elemento di un array, o in un componente server-side che richiama una utility per ogni riga di una tabella renderizzata.

// Il pattern è nascosto in una funzione di utilità, non in un loop visibile
async function formatOrderWithCustomer(order: Order) {
  const customer = await prisma.customer.findUnique({
    where: { id: order.customerId },
  });
  return { ...order, customerName: customer?.name };
}

const orders = await prisma.order.findMany();
const formatted = await Promise.all(orders.map(formatOrderWithCustomer));

Promise.all fa sembrare il codice performante, perché le query partono in parallelo, ma restano comunque N query verso il database invece di una. La soluzione resta l'include mirato, spostando la relazione dentro la query originale:

const orders = await prisma.order.findMany({
  include: { customer: { select: { name: true } } },
});

Il query log descritto sopra è lo strumento più affidabile per scovare questi casi: se una singola richiesta HTTP genera decine di righe identiche nel log, salvo il valore del parametro, il pattern è quello.

Cursor vs skip/take: la differenza si vede solo a scala

skip/take è la scelta più intuitiva per la paginazione, ed è anche quella che degrada peggio. PostgreSQL, per eseguire OFFSET 50000, scandisce comunque le prime 50.000 righe per scartarle prima di restituire la pagina richiesta: il costo cresce linearmente con il numero di pagina, non resta costante.

// skip/take: costo crescente a ogni pagina
await prisma.order.findMany({ skip: 50000, take: 20, orderBy: { id: 'asc' } });

// cursor: costo costante, si appoggia all'indice
await prisma.order.findMany({
  take: 20,
  skip: 1,
  cursor: { id: lastSeenId },
  orderBy: { id: 'asc' },
});

La paginazione a cursore usa l'indice sulla colonna di ordinamento per posizionarsi direttamente, indipendentemente da quante pagine la precedono. Il compromesso è l'impossibilità di saltare direttamente alla pagina 40: va bene per scroll infinito e feed, meno per un'interfaccia con numeri di pagina cliccabili. In quel caso conviene valutare un ibrido, con skip/take sulle prime pagine e cursore oltre una certa soglia.


Query raw, con le regole di sicurezza che contano

Quando un'aggregazione o una window function rendono il query builder più complesso del SQL equivalente, Prisma espone due funzioni raw distinte per scopi diversi: $queryRaw per le letture, $executeRaw per le scritture.

// Lettura: $queryRaw restituisce righe
const stats = await prisma.$queryRaw<{ month: Date; total: bigint }[]>`
  SELECT DATE_TRUNC('month', "createdAt") as month, COUNT(*) as total
  FROM "Order"
  GROUP BY month
  ORDER BY month;
`;

// Scrittura: $executeRaw restituisce il numero di righe modificate
const updated = await prisma.$executeRaw`
  UPDATE "Order" SET status = 'expired'
  WHERE status = 'pending' AND "createdAt" < NOW() - INTERVAL '7 days';
`;

C'è un'insidia sui tipi che l'annotazione manuale non protegge, anzi maschera. $queryRaw non converte i tipi come farebbe il query builder: restituisce quello che manda il driver. COUNT(*) in PostgreSQL è un bigint, che arriva in JavaScript come BigInt, non come number, e un JSON.stringify su un BigInt lancia un'eccezione a runtime. DATE_TRUNC restituisce un timestamp, che arriva come oggetto Date, non come stringa. Annotare il risultato come { month: string; total: number } è una promessa che TypeScript accetta e che il runtime smentisce. Se servono numeri normali, si casta lato SQL (COUNT(*)::int) oppure lato JavaScript (Number(row.total)).

La sicurezza, invece, dipende interamente dalla sintassi usata. Con i template literal ($queryRaw...``), Prisma parametrizza automaticamente ogni valore interpolato, prevenendo la SQL injection. Con le varianti Unsafe ($queryRawUnsafe, $executeRawUnsafe), che accettano stringhe costruite a runtime, quella protezione non esiste: se un valore proveniente dall'utente finisce in quella stringa senza essere parametrizzato esplicitamente, l'applicazione è vulnerabile. La regola pratica è semplice: $queryRaw come default, Unsafe solo quando serve costruire dinamicamente identificatori (nomi di tabella o colonna), mai valori.


$transaction: atomicità sì, ma non è isolamento

Molte operazioni applicative coinvolgono più scritture che devono avvenire insieme o non avvenire affatto: scalare un magazzino e creare un ordine, trasferire un saldo tra due conti. Prisma offre due forme di $transaction.

La forma sequenziale accetta un array di operazioni Prisma, eseguite in un'unica transazione SQL:

const [order, updatedStock] = await prisma.$transaction([
  prisma.order.create({ data: orderData }),
  prisma.product.update({
    where: { id: productId },
    data: { stock: { decrement: quantity } },
  }),
]);

La forma interattiva passa una funzione che riceve un client transazionale, utile quando la seconda operazione dipende dal risultato della prima. Un primo tentativo, quello che viene naturale scrivere, è questo:

await prisma.$transaction(async (tx) => {
  const product = await tx.product.findUnique({ where: { id: productId } });

  if (!product || product.stock < quantity) {
    throw new Error('Stock insufficiente');
  }

  await tx.product.update({
    where: { id: productId },
    data: { stock: { decrement: quantity } },
  });

  await tx.order.create({ data: orderData });
});

Il rollback qui è automatico: se una qualsiasi operazione all'interno della funzione lancia un'eccezione, inclusa quella esplicita sullo stock insufficiente, Prisma annulla l'intera transazione senza lasciare scritture parziali. Non serve gestire il rollback manualmente.

C'è però un equivoco da chiarire, ed è la trappola più comune. Atomicità significa "tutto o niente", non "isolamento dalla concorrenza". La transazione garantisce che le due scritture avvengano insieme, ma non protegge dal pattern read-then-write dell'esempio sopra. Sotto Read Committed, il livello di isolamento di default in PostgreSQL, due richieste concorrenti possono leggere entrambe stock = 1, superare entrambe il controllo stock < quantity e poi decrementare: il risultato è stock = -1, l'oversell classico. Il decrement è atomico a livello di riga, ma il guard si basa su una lettura ormai stantia, e l'atomicità della transazione non lo salva.

La versione a prova di concorrenza sposta il controllo dentro l'UPDATE, così la condizione viene rivalutata sotto il lock di riga:

await prisma.$transaction(async (tx) => {
  const res = await tx.product.updateMany({
    where: { id: productId, stock: { gte: quantity } },
    data: { stock: { decrement: quantity } },
  });

  if (res.count === 0) {
    throw new Error('Stock insufficiente');
  }

  await tx.order.create({ data: orderData });
});

Qui updateMany decrementa solo se la riga soddisfa ancora stock >= quantity nel momento in cui acquisisce il lock. Se una transazione concorrente ha già ridotto lo stock, res.count vale 0 e la seconda richiesta fallisce invece di sovravendere. L'alternativa è alzare il livello di isolamento a Serializable, passandolo nel secondo argomento (prisma.$transaction(fn, { isolationLevel: 'Serializable' })): risolve il problema in modo più generale, ma con un costo maggiore in throughput e con la necessità di gestire i retry sui conflitti di serializzazione.

Un ultimo dettaglio operativo dietro l'avvertimento sulle chiamate lente. La transazione interattiva ha un timeout di default di 5 secondi (e un maxWait di 2 secondi per acquisire una connessione dal pool). Inserire al suo interno una richiesta HTTP esterna o un invio email non solo allunga il tempo di blocco delle righe coinvolte, ma rischia di far scadere la transazione, che Prisma aborta con l'errore P2028. Entrambi i limiti sono configurabili nel secondo argomento di $transaction, ma il valore di default è un buon promemoria: una transazione va tenuta corta.

Nessuno di questi strumenti è complesso in sé. Quello che li rende avanzati è il momento in cui servono: quando un progetto smette di essere un prototipo e inizia a portare traffico reale, e il costo di una scelta sbagliata (un N+1 non visto, una transazione troppo lunga, un updateMany mancato, un Unsafe di troppo) smette di essere teorico.

In Unitiva costruiamo e portiamo in produzione applicazioni che devono reggere traffico vero, e sappiamo quanto pesano questi dettagli quando un sistema cresce. Se stai scalando un progetto e vuoi un confronto sull'architettura dati, o capire dove l'intelligenza artificiale può entrare nei tuoi processi senza forzature, prenota un appuntamento gratuito: ne parliamo insieme.

Autoreadmin
Potrebbero interessarti...
back to top icon