Reduzca la carga de Prisma y PostgreSQL antes de pagar por una base de datos más grande
Quince técnicas prácticas, desde EXPLAIN ANALYZE e índices compuestos hasta soluciones para el problema N+1, contadores, agrupamiento y vaciado, para reducir las operaciones de la base de datos Prisma.
Fuentes: I Reduced Prisma Database Operations by 60% Without Upgrading My Database
En una aplicación de Prisma y PostgreSQL, la base de datos tiende a convertirse en el primer cuello de botella real. Una consulta que tarda 5 ms en responder con unos pocos cientos de filas puede demorar segundos cuando la tabla contiene millones, y la reacción inmediata es adquirir una instancia más grande o un plan de mayor costo. Considere una aplicación de mercado cuyo tráfico en la base de datos asciende a 5,000 o 6,000 operaciones al día, cantidad suficiente para agotar la versión gratuita basada en el uso antes incluso de que termine el mes. Un fin de semana dedicado al análisis y a optimizar las consultas en un caso como este puede reducir las operaciones en más del 60 por ciento, acelerar las respuestas de la API y disminuir la factura, sin necesidad de modificar el hardware.
Las técnicas que se describen a continuación, ordenadas desde el diagnóstico hasta el diseño del esquema y la mantenimiento, son las que suelen marcar esa diferencia.
Mida antes de cambiar algo
No asuma que una consulta es lenta; mézcala. Hay dos herramientas que cubren la mayoría de las necesidades:
EXPLAIN ANALYZEmuestra el plan de ejecución de PostgreSQL junto con las filas escaneadas, si se utilizaron índices y el tiempo real empleado.- El registro de consultas de Prisma revela el SQL exacto que genera el cliente.
Ejecutar EXPLAIN ANALYZE con un filtro por vendedor se realiza de esta manera. Tenga en cuenta que ANALYZE ejecuta realmente la instrucción, por lo que las consultas que modifican datos deben envolverse en una transacción que luego se revierte.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE seller_id = 123;
Incluso una llamada a Prisma de apariencia limpia puede generar un escaneo secuencial de toda la tabla; solo el plan de ejecución lo indica. pg_stat_statements (que clasifica las consultas por tiempo total), las estadísticas y paneles de Prisma, así como herramientas como las de Vercel, le ayudan a decidir qué corregir primero. Para un enfoque estructurado, consulte cómo encontrar el verdadero cuello de botella en un endpoint lento de Node.js.
Indexa las consultas que realmente ejecutas
Los índices suelen ser el cambio con mayor impacto. Sin ellos, una consulta como esta podría obligar a PostgreSQL a inspeccionar cada fila en orders:
SELECT *
FROM orders
WHERE seller_id = 123;
Un índice simple de tipo B-tree en la columna filtrada permite al planificador ir directamente a las filas correspondientes:
CREATE INDEX idx_orders_seller
ON orders(seller_id);
Cuando la aplicación filtra rutinariamente varias columnas al mismo tiempo, un índice compuesto sirve directamente para esa combinación:
CREATE INDEX idx_orders_seller_status
ON orders(seller_id, status);
Con él, una condición tanto en el vendedor como en el estado se convierte en una búsqueda de índice más eficiente:
WHERE seller_id = 123
AND status = 'PAID'
El orden de las columnas es importante en un índice compuesto. (seller_id, status) también ayuda a las consultas que filtran únicamente por seller_id, pero no a las que filtran solo por status. La regla general es indexar las columnas que realmente se filtran, se ordenan o con las que se realizan uniones, y no todo; cada índice ocupa espacio en disco y ralentiza todas las escrituras en la tabla.
Eliminar consultas N+1
El patrón N+1 es un problema clásico de los ORM. Se carga una lista de 100 pedidos:
const orders = await prisma.order.findMany();
y luego se obtiene el usuario de cada pedido uno por uno dentro de un bucle:
for (const order of orders) {
await prisma.user.findUnique({
where: {
id: order.userId
}
});
}
Eso equivale a 101 consultas por cada página de datos. Pedirle a Prisma que cargue la relación junto con los pedidos elimina la necesidad de ese bucle:
const orders = await prisma.order.findMany({
include: {
user: true
}
});
Una sola llamada reemplaza el bucle, con mucha menos latencia y carga. Dependiendo de la versión y configuración, Prisma ejecuta include como una unión o como una consulta adicional agrupada; de cualquier manera, desaparecen los viajes ida y vuelta por fila.
Seleccione solo las columnas que necesite
Reducir los datos transferidos es una de las soluciones más económicas. Un simple findMany devuelve todas las columnas de cada fila:
await prisma.user.findMany();
Un select limita el resultado a lo que realmente utiliza quien llama la función:
await prisma.user.findMany({
select: {
id: true,
name: true
}
});
Con bloques JSON, Markdown, imágenes o texto largo en la tabla, esto por sí solo puede reducir notablemente los tiempos de respuesta, además de mantener las columnas sensibles fuera de las respuestas de la API.
Sustituya las agregaciones repetidas por contadores almacenados
Los paneles de análisis suelen volver a calcular los agregados en cada carga de página:
COUNT(*)
SUM(amount)
AVG(...)
Eso está bien con 100 filas, pero resulta problemático con millones. Por ejemplo, calcular las ventas totales en cada solicitud implica sumar toda la tabla:
SELECT SUM(amount)
FROM orders;
La alternativa es mantener los totales actualizados en el momento de la escritura, almacenando valores como las sumas directamente en el registro del usuario:
User
-------------
totalSales
totalOrders
totalRevenue
Actualice esos campos cada vez que se complete una compra, de modo que las lecturas sean búsquedas O(1) en lugar de escaneos a través de millones de filas; en el escenario anterior, esta fue una de las mayores reducciones posibles. Actualice los contadores de forma atómica (por ejemplo, con increment de Prisma en la misma transacción que el ingreso del pedido), y concilíelos ocasionalmente, ya que los totales denormalizados pueden desviarse.
Paginar cada lista
Las tablas no permanecen pequeñas, así que nunca devuelva una lista ilimitada como esta:
await prisma.product.findMany();
Utilice take y skip para obtener una página limitada; este ejemplo devuelve la segunda de un total de 20:
await prisma.product.findMany({
take: 20,
skip: 20
});
Para conjuntos de datos grandes o desplazamiento infinito, prefiera la paginación con cursor: un offset hace que PostgreSQL pase por cada fila omitida, mientras que un cursor en una columna indexada reanuda exactamente donde terminó la última página.
Lecturas en caché que rara vez cambian
La consulta más rápida es aquella que nunca se ejecuta. Los datos que cambian con poca frecuencia son candidatos ideales para el caché: páginas de productos, tiendas públicas, configuraciones de la aplicación y listas de categorías. Las opciones comunes incluyen Upstash Redis, el caché de Next.js y un caché LRU en proceso. Sacar las lecturas frecuentes de PostgreSQL alivia gran parte de la carga; el costo es la invalidación, por lo que debe decidir qué tan obsoletos pueden estar los datos y volver a validarlos cuando las filas cambien.
Escribir por lotes en lugar de usar bucles
Cada llamada en espera representa un viaje de ida y vuelta por la red, y estos se acumulan rápidamente. Crear registros uno por uno en un bucle es un ejemplo clásico:
for (const item of items) {
await prisma.item.create({
data: item
});
}
createMany inserta todo el array en una sola instrucción:
await prisma.item.createMany({
data: items
});
Un viaje de ida y vuelta es mejor que cientos. Tenga en cuenta que createMany no crea relaciones anidadas y devuelve un conteo en lugar de las filas; consulte la documentación de su versión de Prisma si necesita recuperar los registros.
Grupar escrituras relacionadas en una transacción
Crear un pedido suele implicar otras escrituras, como ajustar el inventario, actualizar los saldos y generar un recibo. Esos cambios forman un conjunto, y $transaction garantiza que o todos tengan éxito o se rechacen todos:
await prisma.$transaction([
prisma.order.create(...),
prisma.user.update(...),
prisma.balance.update(...)
]);
La verdadera ventaja es la consistencia y no la velocidad: no se puede tener un orden sin actualizar su equilibrio si algo falla a mitad de proceso. Mantenga las transacciones cortas, ya que mantienen bloqueos y una conexión.
Reutilice un único Prisma Client
Crear un nuevo PrismaClient por solicitud es costoso y puede agotar las conexiones; además, en el desarrollo el recargue en tiempo real puede generar uno por cada cambio de archivo. Un singleton evita ambos problemas. Importe el cliente:
import { PrismaClient } from "@prisma/client";
Luego, almacene la instancia en globalThis fuera del entorno de producción para que los módulos recargados utilicen el cliente existente en lugar de crear otro nuevo:
const globalForPrisma =
globalThis as unknown as {
prisma: PrismaClient | undefined;
};export const prisma =
globalForPrisma.prisma ??
new PrismaClient();if (process.env.NODE_ENV !== "production")
globalForPrisma.prisma = prisma;
El casteo as unknown as le otorga a globalThis una propiedad prisma de tipo definido; en producción el módulo se carga una sola vez, por lo que no se necesita un caché global.
Utilice un pool de conexiones, especialmente en entornos serverless
Las plataformas serverless pueden iniciar miles de instancias de funciones concurrentes, cada una con su propia conexión a la base de datos. Sin pooling, PostgreSQL puede quedarse sin conexiones mucho antes de que el CPU se convierta en un problema. Tanto Prisma Accelerate como PgBouncer se interponen entre la aplicación y la base de datos, permitiendo que muchas solicitudes compartan un conjunto mucho más pequeño de conexiones reales a PostgreSQL.
Indexa los campos JSONB que filtras
El tipo JSONB de PostgreSQL es muy potente, pero filtrar campos anidados sin un índice suele significar un escaneo completo de la tabla. Si consultas regularmente dentro de documentos JSON, agrega un índice GIN a la columna (o un índice de expresión en una clave específica que filtres con frecuencia), convirtiendo búsquedas lentas de documentos en búsquedas indexadas.
Preferir findUnique para búsquedas únicas
Cuando realices consultas por clave primaria u otro campo único, utiliza este método:
findUnique()
Indica a Prisma que la búsqueda apunta a una sola fila, lo que permite a Prisma agrupar varias llamadas findUnique del mismo momento en una sola consulta, útil en los resolvers de GraphQL. Por ejemplo:
await prisma.user.findUnique({
where: {
id
}
});
Utilícelo en lugar de lo siguiente siempre que el campo sea único:
findFirst()
findFirst sigue siendo la opción adecuada para filtros no únicos, cuando lo que realmente se desea es “la primera fila que coincida”.
Archivee las filas antiguas en lugar de borrarlas de forma temporal
Las borradas temporales son convenientes; simplemente se marca la fila:
isDeleted = true
No obstante, con el paso de los años, cada consulta debe filtrar las filas eliminadas y evitar que las tablas e índices se vuelvan excesivamente grandes. En sistemas de alto volumen, mueva periódicamente los registros eliminados a tablas de archivo: las tablas más pequeñas implican índices menores y consultas más rápidas. Un índice parcial sobre las filas no eliminadas representa una solución intermedia.
Mantener el autovacuum en buen estado
PostgreSQL utiliza MVCC (control de concurrencia multiversión), por lo que las filas actualizadas y eliminadas no se eliminan de inmediato; permanecen como tuplas muertas. El autovacuum es el proceso en segundo plano que se encarga de eliminarlas. Un autovacuum bien ajustado:
- libera el espacio ocupado por las tuplas muertas;
- actualiza las estadísticas del planificador;
- mantiene los índices eficientes;
- garantiza que los planes de consulta sean precisos.
Ignorarlo provoca sobrecarga y planes de consulta deficientes, especialmente en tablas con muchas actualizaciones como los contadores mencionados anteriormente.
Puntos clave
La mayoría de los problemas de rendimiento de las bases de datos no se resuelven con servidores más grandes; se solucionan mediante patrones de acceso a datos mejores. En el escenario anterior, el proveedor de hosting y el tamaño de la base de datos permanecieron iguales. Las mejoras se lograron gracias a:
- el análisis de las consultas costosas en lugar de hacer conjeturas;
Juntos permitieron reducir las operaciones en más del 60 por ciento, acelerar las APIs y dar cabida a mucho más tráfico. Antes de escalar, averigüe cómo se utiliza realmente su base de datos; las consultas existentes a menudo ocultan la mayor parte del rendimiento necesario.