⚖️ Caso 02 — Comparativa multi-stack: N+1 y cuellos de botella en base de datos (PHP · Python · Node.js · Java · .NET · Go · Rust)#
TL;DR —
db_hitspasa de21a2conlimit=20, sin cambiar el resultado. Los 7 stacks ejecutan N+1 real contra un motor: el contador mideprepare()+execute(), no iteraciones de un bucle.
🐘 PHP · 🐍 Python · 🟢 Node.js · ☕ Java 21 · 🔵 .NET 8 · 🐹 Go 1.23 · 🦀 Rust 1.83
Estructura: 🎯 el problema → una seccion por stack → ⚖️ tabla de decision → 📊 primitiva por stack → 🏁 veredicto y ranking
🎯 El problema que los siete resuelven#
Un feed de pedidos que necesita devolver, por cada pedido, el cliente, los items, y por cada item el producto y su categoria. La variante legacy construye ese grafo con queries anidadas dentro de bucles. La variante optimizada lo construye con joins consolidados y ensamblado en memoria.
🔬 Fidelidad del substrato — los 7 stacks corren sobre SQL real#
Caso 02 ejecuta N+1 real sobre una base relacional embebida en los siete stacks — igual que el caso 01 desde que se cerro su deuda de fidelidad. El contraste deja de ser "DB vs memoria" y pasa a ser N+1 sobre el mismo problema, primitivas idiomaticas distintas por lenguaje:
| Stack | Motor | Primitiva idiomatica |
|---|---|---|
| PHP | PostgreSQL 16 (externo) | PDO + prepared statements |
| Python | SQLite (stdlib) | sqlite3 + cursor compartido |
| Node.js | SQLite (node:sqlite built-in en Node 22.5+) | Database + db.prepare() |
| Java 21 | SQLite (sqlite-jdbc) | Connection + PreparedStatement |
| .NET 8 | SQLite (Microsoft.Data.Sqlite) | SqliteConnection + SqliteCommand |
db_hits ahora es un contador real, no una metrica derivada. La diferencia entre orders-legacy y orders-optimized es observable a nivel motor en los siete runtimes. El contrato JSON externo no cambio — lo que cambio es que el numero ahora corresponde a prepare() + execute() reales contra una DB.
🐘 PHP: PostgreSQL, cursores PDO, agrupacion con array_column#
Runtime: PHP-FPM. Cada request es un proceso efimero. No hay estructuras de datos que sobrevivan entre requests.
El fallo legacy en PHP:
foreach ($orders as &$order) {
$order['customer'] = $db->timedQuery(
"SELECT * FROM customers WHERE id = ?", [$order['customer_id']]
)->fetch();
$items = $db->timedQuery(
"SELECT * FROM order_items WHERE order_id = ?", [$order['id']]
)->fetchAll();
foreach ($items as &$item) {
$item['product'] = $db->timedQuery(
"SELECT p.*, c.name FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.id = ?", [$item['product_id']]
)->fetch();
}
$order['items'] = $items;
}Patron: 1 + N + N*M + N*M. Para 20 pedidos con 3 items promedio: mas de 120 llamadas PDO. PostgreSQL abre un cursor transaccional por cada prepare(). El pool de conexiones se agota.
La correccion en PHP:
$orderIds = array_map(fn($o) => $o['id'], $orders);
$ph = implode(',', array_fill(0, count($orderIds), '?'));
$items = $db->timedQuery(
"SELECT oi.*, p.name product_name, p.list_price,
c.name category_name
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE oi.order_id IN ($ph)", $orderIds
)->fetchAll();
$grouped = [];
foreach ($items as $item) {
$grouped[$item['order_id']][] = $item;
}
foreach ($orders as &$order) {
$order['items'] = $grouped[$order['id']] ?? [];
}PHP usa array_column() e iteracion asociativa. El FPM recupera su rol: procesamiento CPU sin cruzar la frontera de red.
Observabilidad adicional PHP: pg_stat_statements via postgres-exporter. Se pueden ver las queries reales ejecutadas contra PostgreSQL desde Grafana.
🐍 Python: SQLite, sqlite3 stdlib, dict comprehensions#
Runtime: ThreadingHTTPServer. Los hilos comparten la conexion SQLite protegida por threading.RLock.
Motor de datos: SQLite via sqlite3 de stdlib. Conexion unica, file I/O real, prepared statements via ? placeholders.
El fallo legacy en Python:
for order in orders:
cur.execute("SELECT * FROM customers WHERE id = ?", (order["customer_id"],))
order["customer"] = dict(cur.fetchone())
cur.execute("SELECT * FROM order_items WHERE order_id = ?", (order["id"],))
items = [dict(r) for r in cur.fetchall()]
for item in items:
cur.execute(
"SELECT p.*, c.name cat FROM products p "
"JOIN categories c ON c.id = p.category_id WHERE p.id = ?",
(item["product_id"],)
)
item["product"] = dict(cur.fetchone())
order["items"] = itemsMismo patron 1 + N + N*M. SQLite bloquea el hilo en cada execute(). El GIL se libera durante el I/O pero el acceso secuencial acumula tiempo de todas formas.
La correccion en Python:
order_ids = [o["id"] for o in orders]
ph = ",".join("?" * len(order_ids))
cur.execute(
f"SELECT oi.*, p.name product_name, p.list_price, c.name category_name "
f"FROM order_items oi "
f"JOIN products p ON p.id = oi.product_id "
f"JOIN categories c ON c.id = p.category_id "
f"WHERE oi.order_id IN ({ph})", order_ids
)
items_all = [dict(r) for r in cur.fetchall()]
grouped = {}
for item in items_all:
grouped.setdefault(item["order_id"], []).append(item)
for order in orders:
order["items"] = grouped.get(order["id"], [])Python usa dict.setdefault() y list comprehensions. El resultado es funcionalmente identico al PHP con array_column().
🟢 Node.js: SQLite via node:sqlite built-in, db.prepare(), single-thread#
Runtime: Node.js 22 single-thread con event loop. El N+1 anidado se traduce en for (const order of baseOrders) { const c = stmt.get(order.customer_id); ... } — el costo es 1 + N + sum(items_por_order * 2) prepare()/get() reales contra SQLite.
Motor de datos: node:sqlite (modulo built-in desde Node 22.5, sin npm install, sin bindings nativos a compilar). DB en :memory: por instancia o /tmp/case02.db. Database + db.prepare(sql).get(...) / db.prepare(sql).all(...) con prepared statements verdaderos.
El fallo legacy en Node.js:
const getCustomer = db.prepare("SELECT * FROM customers WHERE id = ?");
const getItems = db.prepare("SELECT * FROM order_items WHERE order_id = ?");
const getProduct = db.prepare(`
SELECT p.*, c.name AS category_name FROM products p
JOIN categories c ON c.id = p.category_id WHERE p.id = ?`);
for (const order of baseOrders) {
order.customer = timedQuery(() => getCustomer.get(order.customer_id), stats);
const items = timedQuery(() => getItems.all(order.id), stats);
for (const item of items) {
item.product = timedQuery(() => getProduct.get(item.product_id), stats);
}
order.items = items;
}Con limit=20 y ~3.7 items/order: ~190 Statement.get() secuenciales contra SQLite. Cada uno es file I/O sincronico — bloquea el event loop mientras dura. Bajo concurrencia, event_loop_lag_ms se dispara y el throughput del proceso cae.
La correccion en Node.js:
const ids = baseOrders.map(o => o.id);
const ph = ids.map(() => "?").join(",");
const rows = timedQuery(() =>
db.prepare(`
SELECT oi.*, p.name AS product_name, p.list_price,
c.name AS category_name
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE oi.order_id IN (${ph})
`).all(...ids), stats);
const grouped = new Map();
for (const row of rows) {
const list = grouped.get(row.order_id) || [];
list.push(row);
grouped.set(row.order_id, list);
}
for (const order of baseOrders) order.items = grouped.get(order.id) || [];Dos prepare().all() reales. Joins resueltos por SQLite, agrupacion en JS con Map.get() O(1). La ausencia de un ORM hace explicita la decision — no hay magia que la oculte.
☕ Java 21: sqlite-jdbc, PreparedStatement, batch IN(...)#
Runtime: JVM con thread pool. Cada handler corre en thread propio. La Connection SQLite es compartida y serializada por SQLite mismo (la libreria sqlite-jdbc empaqueta el motor nativo).
Motor de datos: SQLite via sqlite-jdbc (single jar, sin Maven — se descarga en build-time y se agrega al classpath). DB en :memory: por instancia o /tmp/case02.db. Connection + PreparedStatement con ? parametrizado.
El fallo legacy en Java:
try (PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM order_items WHERE order_id = ?")) {
for (int i = 0; i < take; i++) {
Order o = orders.get(i);
ps.setInt(1, o.id);
try (ResultSet rs = ps.executeQuery()) { // N+1 clasico
while (rs.next()) ...
}
}
}N+1 clasico contra el motor real. Cada executeQuery() cruza la frontera JNI hasta el motor nativo de SQLite, lee del file, retorna. db_hits incrementa una vez por iteracion — el contador refleja el numero verdadero de queries.
La correccion en Java:
String ph = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql = """
SELECT oi.*, p.name AS product_name, p.list_price,
c.name AS category_name
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE oi.order_id IN (%s)
""".formatted(ph);
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 0; i < ids.size(); i++) ps.setInt(i + 1, ids.get(i));
try (ResultSet rs = ps.executeQuery()) {
Map<Integer, List<Item>> grouped = new HashMap<>();
while (rs.next()) {
grouped.computeIfAbsent(rs.getInt("order_id"), k -> new ArrayList<>())
.add(mapItem(rs));
}
return grouped;
}
}Un solo executeQuery() con IN(?, ?, ?, ...) construido dinamicamente — el patron canonico cuando JDBC no expone setArray() para SQLite. try-with-resources garantiza el cleanup del ResultSet y PreparedStatement incluso bajo excepcion.
🔵 .NET 8: Microsoft.Data.Sqlite, SqliteCommand, parameter binding#
Runtime: .NET 8 sobre HttpListener. El CLR despacha al ThreadPool. Cada request puede tomar una SqliteConnection (la libreria es thread-safe a nivel de connection, no de command compartido).
Motor de datos: SQLite via Microsoft.Data.Sqlite (paquete oficial Microsoft, ADO.NET-style). DB en :memory: por instancia o /tmp/case02.db. SqliteConnection + SqliteCommand con @parametro named bindings.
El fallo legacy en C#:
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM order_items WHERE order_id = @id";
var idParam = cmd.Parameters.Add("@id", SqliteType.Integer);
for (int i = 0; i < take; i++) {
var o = orders[i];
idParam.Value = o.Id;
using var rdr = cmd.ExecuteReader(); // N+1 clasico
while (rdr.Read()) ...
}Mismo patron 1 + N contra el motor real. Cada ExecuteReader() invoca el motor nativo de SQLite. El contador db_hits cuenta cada ejecucion — la metrica es honesta.
La correccion en C#:
var ph = string.Join(",", ids.Select((_, i) => $"@id{i}"));
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
SELECT oi.*, p.name AS product_name, p.list_price,
c.name AS category_name
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE oi.order_id IN ({ph})";
for (int i = 0; i < ids.Count; i++)
cmd.Parameters.AddWithValue($"@id{i}", ids[i]);
var grouped = new Dictionary<int, List<Item>>();
using var rdr = cmd.ExecuteReader();
while (rdr.Read()) {
var orderId = rdr.GetInt32(rdr.GetOrdinal("order_id"));
if (!grouped.TryGetValue(orderId, out var list))
grouped[orderId] = list = new();
list.Add(MapItem(rdr));
}Un solo ExecuteReader() con IN(@id0, @id1, ...) parametrizado. using garantiza el Dispose del SqliteCommand y del SqliteDataReader. Con EF Core real esto seria .Where(x => ids.Contains(x.OrderId)).ToList() traducido a IN(...) automaticamente — aqui se ve el SQL crudo para que el patron quede explicito.
Notas idiomaticas vs los otros stacks:
Microsoft.Data.Sqlitecumple el rol desqlite-jdbcJava onode:sqliteNode — el motor empaquetado en libreria nativa, accesible via API ADO.NET.Interlocked.Increment(ref counter)reemplaza elLongAdderJava oAtomicInteger.- A diferencia de PHP, no hay pool de conexiones externo — la
SqliteConnectiones del proceso.
🐹 Go 1.23: database/sql + modernc.org/sqlite, sin ORM que culpar#
Motor: SQLite en memoria compartida (file:case02?mode=memory&cache=shared). Sin cache=shared, cada conexion del pool abriria su propia base vacia — es el detalle que hace que el caso funcione con database/sql.
Legacy vs optimized:
orders, _ := selectOrders(limit) // 1
for _, o := range orders { // N
db.Query("SELECT sku, qty FROM order_items WHERE order_id = ? ORDER BY id ASC", o.id)
}
// vs
placeholders := strings.TrimRight(strings.Repeat("?,", len(ids)), ",")
db.Query(fmt.Sprintf("SELECT order_id, sku, qty FROM order_items WHERE order_id IN (%s) ORDER BY id ASC", placeholders), ids...)Lo que este stack enseña y los otros no: Go no tiene ORM en la biblioteca estandar. En Java o .NET, el N+1 de este caso es el que genera Hibernate o Entity Framework al iterar una coleccion lazy sin JOIN FETCH ni Include() — aparece sin que nadie lo escriba, y por eso cuesta detectarlo. Aca hay que teclearlo. El caso lo teclea a proposito para medirlo, y el diagnostico empieza leyendo el codigo en vez del log del ORM.
🦀 Rust 1.83: rusqlite bundled y el error de cursor que no se puede ignorar#
Motor: SQLite embebido via rusqlite feature bundled, archivo con journal_mode=WAL.
Donde Rust se separa de Go —que comparte con el la ausencia de ORM— es en el manejo del cursor:
// query_map devuelve Iterator<Item = Result<T>>.
// Este collect obliga a decidir que pasa si una fila falla a mitad del recorrido.
mapped.collect::<rusqlite::Result<Vec<_>>>()?En Go el equivalente es recorrer rows.Next() y acordarse de chequear rows.Err() despues del bucle. Olvidarlo compila y silencia fallos parciales del cursor: la query devuelve menos filas de las que debia y nadie se entera. En Rust ese olvido no tiene forma de expresarse, porque el Result esta en el tipo del iterador.
Verificacion cruzada: Go y Rust generan el dataset con el mismo LCG. /orders-legacy?limit=5 devuelve order_id 1, customer_id 276 con items SKU-2369 qty 2 y SKU-2863 qty 8 en ambos.
⚖️ Diferencias de decision, no de correccion#
Los siete stacks implementan el mismo algoritmo. Esta tabla contrasta como lo expresa cada uno.
| Aspecto | PHP | Python | Node.js | Java | .NET | Go | Rust | Razon |
|---|---|---|---|---|---|---|---|---|
| Driver | PDO | sqlite3 stdlib | node:sqlite | sqlite-jdbc | Microsoft.Data.Sqlite | database/sql | rusqlite | Siete APIs idiomaticas sobre el mismo patron. |
Batch IN(...) | str_repeat('?,') | ','.join('?') | ids.map(()=>'?') | StringBuilder de ? | $id0..$idN nombrados | strings.Repeat("?,") | vec!["?"; n].join(",") | Misma tecnica; solo .NET usa binding nombrado por convencion ADO.NET. |
| Riesgo de N+1 accidental | medio (ORMs opcionales) | medio | medio | alto — Hibernate lo genera solo | alto — EF Core lo genera solo | bajo — no hay ORM en stdlib | bajo — no hay ORM en stdlib | Donde hay ORM, el N+1 aparece sin que nadie lo escriba. Ese es el peligro real. |
| Error de cursor | excepcion PDO | excepcion | excepcion | SQLException | SqliteException | hay que recordar rows.Err() | collect::<Result<..>> obliga a decidir | Solo Rust hace imposible ignorar un fallo parcial del cursor. |
El patron que los siete demuestran es identico: N+1 sobre SQL escala con N*M independientemente del lenguaje o motor. La correccion — batch loading con IN(...) + agrupacion en memoria — tambien es identica en concepto. La diferencia observable bajo carga concurrente es donde duele y con que primitiva idiomatica se expresa la solucion.
📊 Primitiva central por stack#
Los siete stacks resuelven el mismo problema. Lo que cambia es la primitiva y donde duele.
| Stack | Primitiva central en este caso |
|---|---|
| PHP | PostgreSQL real via PDO |
| Python | sqlite3 stdlib |
| Node.js | node:sqlite db.prepare() |
| Java 21 | sqlite-jdbc PreparedStatement + batch IN(...) |
| .NET 8 | Microsoft.Data.Sqlite SqliteCommand + parametros |
| Go 1.23 | database/sql — sin ORM que genere el N+1 por accidente |
| Rust 1.83 | rusqlite; collect::<Result<Vec<_>>>() impide ignorar el error de cursor |
🏁 Veredicto: que stack resuelve mejor este problema#
⚠️ Ranking de fit, no de calidad de lenguaje. Mide que tan directamente las primitivas nativas del runtime expresan la solucion de este caso concreto. El orden cambia — a veces se invierte — de un caso a otro: leer varios rankings juntos dice mas que cualquiera por separado.
| Stack | Por que | |
|---|---|---|
| 🥇 | Rust 1.83 | collect::<Result<Vec<_>>>() hace imposible ignorar un fallo parcial del cursor, y sin ORM el N+1 no puede colarse solo. |
| 🥈 | Go 1.23 | Mismo argumento —database/sql obliga a escribir el SQL— pero olvidarse de rows.Err() compila. |
| 🥉 | PHP 8.3 | PDO contra PostgreSQL real: es el unico donde el N+1 cruza un socket y el coste es de red, no de CPU. |
| 4º | Python 3.12 | sqlite3 stdlib, directo y legible. |
| 5º | Node.js 22 | node:sqlite built-in, sin npm install. |
| 6º | Java 21 / .NET 8 | Las APIs son correctas, pero son los dos ecosistemas donde este bug nace solo: Hibernate y EF Core generan el N+1 al iterar una coleccion lazy sin que nadie lo escriba. |
Lectura honesta: El ranking se invierte respecto de la intuicion: los stacks con mejor tooling de datos son los mas expuestos, porque el ORM que te ahorra escribir SQL es el mismo que te esconde 41 queries.