Ir al contenido
wannacode

ORM y el problema N+1

En resumen: Un ORM asigna filas de tablas a objetos y escribe el SQL por ti, y el problema N+1 aparece cuando lanza una consulta extra por cada fila en lugar de una sola.

Problema

La página lenta de pedidos

La página «Pedidos» del panel de administración muestra el número de pedido, la fecha y el nombre del cliente. La tienda tiene 60 pedidos y la página tarda en abrirse. El registro de la base de datos muestra 61 consultas para una sola visita a la página. ¿De dónde salen, si el código no tiene ningún bucle con SQL?

La idea

ORM: una clase en lugar de la tabla

Un ORM (object-relational mapping, «mapeo objeto-relacional») es una biblioteca que enlaza una clase del programa con una tabla de la base de datos. Describes la clase Order y a qué tabla corresponde, y el ORM escribe el SQL por ti cuando pides los pedidos:

@Entity
@Table(name = "orders")
class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@Column(name = "ordered_at")
private LocalDate orderedAt;
}
List<Order> orders = em.createQuery("SELECT o FROM Order o ORDER BY o.id", Order.class)
.getResultList();

Son Hibernate/JPA en Java, Entity Framework Core en C#, SQLAlchemy en Python y Prisma en JavaScript. Ganas menos código repetido, objetos en lugar de filas y el mismo programa para distintas bases de datos. Pagas con SQL que ya no ves: el ORM decide cuántas consultas envía.

Más precisamente, para curiosos

Los ejemplos de ORM no se ejecutan en las comprobaciones del sitio, porque sus paquetes no están allí. Un programa real necesita además algo de configuración para ellos: la dirección de la base de datos, la clase Customer y el contexto (EntityManager, DbContext, Session, PrismaClient).

Míralo

N+1

Aquí está lo mismo sin ORM, pero con el mismo error: primero los pedidos y luego el cliente de cada uno. El programa cuenta las consultas que envía:

try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
int queries = 0;
List<int[]> orders = new ArrayList<>();
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT id, customer_id FROM orders ORDER BY id")) {
queries++;
while (rs.next()) {
orders.add(new int[] { rs.getInt("id"), rs.getInt("customer_id") });
}
}
for (int[] order : orders) {
try (PreparedStatement ps = conn.prepareStatement("SELECT name FROM customers WHERE id = ?")) {
ps.setInt(1, order[1]);
queries++;
try (ResultSet rs = ps.executeQuery()) {
rs.next();
System.out.println("pedido " + order[0] + ": " + rs.getString("name"));
}
}
}
System.out.println("consultas: " + queries);
}
Salida
pedido 1: Radost Hristova
pedido 2: Georgi Petrov
pedido 3: Maria Ivanova
pedido 4: Ivana Koleva
pedido 5: Radost Hristova
pedido 6: Maria Ivanova
consultas: 7

Seis pedidos, siete consultas: una para los pedidos (1) y una para el cliente de cada uno (N). Con 60 pedidos son 61. Con un ORM ocurre lo mismo, solo que el bucle es invisible: o.getCustomer().getName() parece leer un campo, pero en realidad es una consulta.

for (Order o : orders) {
System.out.println(o.getId() + ": " + o.getCustomer().getName()); // una consulta por cada cliente
}
// La solución: los clientes vienen en la misma consulta.
em.createQuery("SELECT o FROM Order o JOIN FETCH o.customer ORDER BY o.id", Order.class)

La idea

Una sola consulta

La base de datos puede unir ella misma los pedidos y los clientes y devolver todo de una vez:

try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
String sql = """
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.id
""";
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
System.out.println("pedido " + rs.getInt("id") + ": " + rs.getString("name"));
}
}
System.out.println("consultas: 1");
}
Salida
pedido 1: Radost Hristova
pedido 2: Georgi Petrov
pedido 3: Maria Ivanova
pedido 4: Ivana Koleva
pedido 5: Radost Hristova
pedido 6: Maria Ivanova
consultas: 1

Las mismas filas con una consulta. Para la página de la tienda, esto es todo lo que hace falta:

SQLbase de datos: tienda

La diferencia no está en el SQL de cada consulta, sino en el viaje hasta la base de datos. Cada consulta es un viaje de ida y vuelta por la red, normalmente de menos de un milisegundo dentro de un centro de datos y decenas de milisegundos si la base de datos está lejos. 61 de esos se notan, 6001 paralizan la página.

Pruébalo

Los últimos pedidos

El panel muestra los 5 últimos pedidos: el número, la fecha, el nombre y el correo del cliente, el más reciente primero (con la misma fecha, el número más alto arriba). Con una sola consulta.

SQLbase de datos: tienda

Ejercicio

Cuántos pedidos tiene cada cliente

La página «Clientes» muestra a cada cliente con el número de sus pedidos (columna orders), en orden alfabético. Sin N+1, es decir, sin una consulta por cliente, y sin dejar fuera a los clientes que no tienen pedidos.

SQLbase de datos: tienda

Test

Ponte a prueba

1. Una página muestra 50 pedidos con el nombre del cliente. El programa lee los pedidos con una consulta y luego el cliente de cada uno con otra aparte. ¿Cuántas consultas son?
2. ¿Por qué N+1 no se nota mientras escribes el programa?
3. ¿Qué arregla N+1 en un ORM?
4. Un ORM escribe el SQL por ti. ¿Aun así necesitas saber SQL?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·