Inicio / Artículos / Conceptos erróneos comunes sobre Node.js y las bases de datos que causan errores en producción

Conceptos erróneos comunes sobre Node.js y las bases de datos que causan errores en producción

Aprenda por qué async/await, el pooling de conexiones y los ORMs no evitan automáticamente las condiciones de carrera, el agotamiento de conexiones o la inyección SQL en las aplicaciones Node.js.

1442 palabras

Node.js y las bases de datos tienen una relación complicada, basada en una trampa común: hacer que algo funcione es tan sencillo que los desarrolladores forman suposiciones a partir de ese primer éxito y nunca vuelven a cuestionarlas. Esas suposiciones funcionan bien hasta que el tráfico real, los usuarios concurrentes o volúmenes de datos más grandes revelan la brecha entre lo que parecía cierto y lo que realmente ocurría. A continuación se presentan cinco de los conceptos erróneos más perjudiciales, junto con una explicación de lo que realmente sucede en cada caso.

La suposición: usar async/await protege automáticamente las operaciones de la base de datos de las condiciones de carrera.

Lo que realmente es cierto: async/await simplemente hace que el código asíncrono parezca secuencial al leerlo. No ofrece garantía de atomicidad para las operaciones en la base de datos, y dos solicitudes separadas aún pueden entrelazarse de manera que produzcan un resultado incorrecto.

// looks sequential, isn't safe under concurrency
async function reserveSeat(eventId, seatNumber) {
  const seat = await db.query(
    "SELECT status FROM seats WHERE event_id = $1 AND number = $2",
    [eventId, seatNumber]
  );
  if (seat.status === "available") {
    await db.query(
      "UPDATE seats SET status = 'reserved' WHERE event_id = $1 AND number = $2",
      [eventId, seatNumber]
    );
  }
}

Dos solicitudes separadas pueden ejecutar cada una la instrucción SELECT, ver que el asiento está marcado como available, e intentar reservar ese mismo asiento. Esto ocurre porque await solo pausa la ejecución de la solicitud que lo inició; no hace nada para evitar que una segunda solicitud, sin relación alguna, se interponga entre una lectura y su correspondiente escritura. La verdadera solución no es algo que pueda resolverse a nivel de JavaScript; debe implementarse en la capa de la base de datos, utilizando ya sea una actualización condicional atómica o una transacción con bloqueo adecuado de filas.

async function reserveSeat(eventId, seatNumber) {
  const result = await db.query(
    `UPDATE seats SET status = 'reserved'
     WHERE event_id = $1 AND number = $2 AND status = 'available'
     RETURNING *`,
    [eventId, seatNumber]
  );
  return result.rowCount > 0; // false means someone beat you to it
}

async/await no es más que un recurso sintáctico para trabajar con promesas. Nunca fue diseñado para garantizar la seguridad en entornos concurrentes, y tratarlo como si lo hiciera es precisamente cómo ocurren los errores de doble reserva.

La suposición: como Node funciona en un único hilo, el pooling de conexiones no es tan crucial como lo sería en un lenguaje multihilo.

Lo que realmente es cierto: la naturaleza de un solo hilo de Node se aplica a cómo se ejecuta JavaScript, no a cómo la base de datos maneja las operaciones de entrada/salida. Un único proceso de Node puede tener fácilmente cientos de consultas a la base de datos en ejecución simultáneamente, y cada una representa un viaje real por red a una base de datos real, la cual debe abrir, mantener activa y finalmente cerrar una conexión real para cada una de ellas.

// a new connection per query, under real traffic, this collapses fast
async function getUser(id) {
  const conn = await mysql.createConnection(config);
  const [rows] = await conn.query("SELECT * FROM users WHERE id = ?", [id]);
  await conn.end();
  return rows[0];
}

Cada llamada a algo como createConnection implica un intercambio de paquetes TCP y un paso de autenticación, y prácticamente todas las bases de datos establecen un límite estricto en la cantidad de conexiones que aceptan al mismo tiempo. El pooling de conexiones distribuye esa carga manteniendo un grupo de conexiones abiertas de antemano y entregándolas según sea necesario:

const pool = mysql.createPool({ ...config, connectionLimit: 10 });
async function getUser(id) {
  const [rows] = await pool.query("SELECT * FROM users WHERE id = ?", [id]);
  return rows[0];
}

El modelo de concurrencia de Node es precisamente la razón por la que el pooling es importante, no una excusa para omitirlo. Un único proceso de Node realmente puede y hace intentos por ejecutar docenas de consultas en paralelo en cualquier momento dado.

La suposición: un ORM elimina por completo la necesidad de preocuparse por inyecciones SQL.

Lo que realmente es cierto: Esa protección solo permanece mientras el código se encuentra dentro de la propia API de construcción de consultas del ORM. Desaparece en el instante en que se escribe una consulta sin procesar o se elabora una cláusula WHERE mediante concatenación de cadenas, algo que ocurre con más frecuencia de la esperada, especialmente cuando una consulta se vuelve lo suficientemente compleja como para que las abstracciones del ORM comiencen a parecer restrictivas.

// still vulnerable, ORM or not
const results = await sequelize.query(
  `SELECT * FROM users WHERE email = '${userInput}'`
);

La seguridad de un ORM proviene específicamente de las consultas parametrizadas que se ejecutan en su interior, y no de algún escudo universal que siga al código dondequiera que vaya. Tan pronto como el SQL se construye como una cadena simple, esa protección ya no existe, independientemente de si hay un ORM encima de él:

const results = await sequelize.query(
  "SELECT * FROM users WHERE email = :email",
  { replacements: { email: userInput }, type: QueryTypes.SELECT }
);

La regla que realmente vale: la entrada proporcionada por el usuario nunca debe concatenarse directamente en una cadena de consulta, sin importar qué capa de abstracción se interponga entre el código y el SQL en bruto.

La suposición: un error no manejado proveniente de una llamada a la base de datos llegará automáticamente al middleware de manejo de errores de Express.

Lo que realmente es cierto: el manejo de errores integrado en Express captura las excepciones síncronas lanzadas dentro de los controladores de ruta, así como los errores pasados explícitamente mediante next(err). No captura automáticamente una promesa rechazada proveniente de un controlador de ruta async, a menos que la configuración utilice una versión de Express que soporte nativamente ese comportamiento o haya sido configurada para manejarlo manualmente.

// on many Express setups, a rejected promise here never reaches your error handler
app.get("/users/:id", async (req, res) => {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
  res.json(user);
});

Si la promesa de esa consulta se rechaza y no hay nada que la capture, el resultado es un rechazo de promesa no manejado; lo cual, en las versiones actuales de Node, puede hacer que se cierre todo el proceso. Eso afecta a todas las demás solicitudes que se están procesando en ese momento, no solo a la que provocó el fallo.

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
    res.json(user);
  } catch (err) {
    next(err); // now Express's error handler actually sees it
  }
});

Envolver manualmente cada ruta asíncrona se vuelve tedioso rápidamente, y por eso mismo vale la pena configurar, desde temprano en un proyecto, ya sea un wrapper de middleware ligero o una versión de Express con soporte nativo para errores asíncronos, en lugar de asumir que los errores se manejarán correctamente por sí solos.

La suposición: una consulta que funciona rápido en el desarrollo local funcionará igual de bien en producción.

Lo que realmente es cierto: Las bases de datos para desarrollo local suelen ser pequeñas, tener un indexado mínimo y ejecutarse en hardware al que nadie somete una carga real. Una consulta que escanea diez mil filas en una laptop y la misma consulta que escanea diez millones de filas en entorno de producción son, en todos los sentidos prácticos, consultas diferentes, aunque el texto SQL sea idéntico.

// fine with 500 test rows, a real problem with 5 million production rows
const orders = await db.query(
  "SELECT * FROM orders WHERE customer_email = $1 ORDER BY created_at DESC"
);

Sin un índice en customer_email, esta consulta provoca un escaneo completo de la tabla, y la diferencia entre “instantáneo” y “tarda varios segundos” depende únicamente del tamaño de la tabla, algo que los entornos de desarrollo local casi nunca reflejan con precisión. La práctica que realmente brinda protección no es escribir el código de manera diferente, sino probarlo con volúmenes de datos similares a los de producción, o al menos ejecutar EXPLAIN en una tabla del tamaño real antes de asumir que algo que funciona localmente indica algo significativo sobre su comportamiento bajo carga real.

Qué une a los cinco elementos

Cada uno de estos conceptos erróneos se remonta a la misma causa raíz: algo pareció funcionar, y ese éxito aparente pasó a considerarse una regla en lugar de ser reconocido como un resultado único que, simplemente, no fracasó. Node y una base de datos son dos sistemas distintos que se comunican a través de una red, cada uno con sus propias garantías y sus propios modos de fallar, y la sintaxis legible de JavaScript no elimina esa distinción solo porque hace que el código sea más fácil de seguir. Las soluciones reales rara vez son complicadas. La verdadera habilidad radica en reconocer qué suposición merece ser cuestionada desde el principio.

Lecturas relacionadas