Volver al blog

ProCat Solutions

Cientos de peticiones por segundo: lo que nos enseñaron nuestras primeras API de alta carga

API sobre Node.js y NestJS bajo carga real: índices en PostgreSQL, connection pooling, caché con Redis, rate limiting, idempotencia y observabilidad.

ProCat Solutions rest-apinodejsnestjspostgresqlredisprueba-de-carga
Compartir
Cientos de peticiones por segundo: lo que nos enseñaron nuestras primeras API de alta carga

Varios cientos de peticiones por segundo, y un tiempo de respuesta que repercute directamente en el negocio del cliente. En el último medio año hemos entregado varios backends de ese tipo, y en todos asomaron los mismos problemas. Ninguna de las soluciones es revolucionaria. Lo que sigue se parece más a la lista de comprobación que nos habría convenido escribir antes.

Un desarrollador revisando la salida del terminal de noche

La base de datos se agota primero

El bucle de eventos de Node.js casi nunca se agota. Bajo NestJS, un endpoint bien escrito atiende del orden de miles de peticiones por segundo en un único proceso, siempre que no esté esperando a la base de datos. En la práctica, casi siempre la está esperando.

Por eso, en todos los proyectos hacemos tres cosas:

  • Índices a partir de las consultas reales. En producción, el primer paso es activar pg_stat_statements. Revisamos las consultas más lentas y más frecuentes con EXPLAIN (ANALYZE, BUFFERS) y solo indexamos las combinaciones de columnas en las que el plan muestra de verdad una lectura secuencial. La regla de “un índice en cada clave foránea” sirve para empezar. La medición sigue siendo imprescindible.
  • Connection pooling con cabeza. El número de procesos Node.js multiplicado por el tamaño del pool no puede superar el max_connections de PostgreSQL, y hay que reservar margen para el mantenimiento. Con varias instancias, PgBouncer se coloca entre los procesos y la base de datos en modo transacción. A cambio, hay que renunciar a los ajustes a nivel de sesión y a los prepared statements, algo que conviene saber de antemano.
  • Transacciones cortas. Dentro de una transacción no llamamos a ningún servicio externo. Si es inevitable, la cerramos antes de la llamada y escribimos el resultado por separado.

Redis, en tres papeles

Redis nos sirve de caché, de limitador de peticiones y de bloqueo, y cada papel se configura de forma distinta.

Caché. En los endpoints intensivos en lectura (listados, configuraciones, datos maestros que cambian poco) guardamos la respuesta por clave, con un TTL corto. No buscamos una invalidación de caché perfecta. Unos segundos de desfase suelen ser aceptables, y eso es mucho más simple que un borrado dirigido por eventos y atado a las escrituras. Donde el desfase no es aceptable, no cacheamos.

Rate limiting. Un contador de ventana deslizante por cliente (según clave de API o IP), con las cabeceras X-RateLimit-* en la respuesta. La comprobación vive en un guard global de NestJS, fuera de cada endpoint, para que nadie pueda olvidarla.

Bloqueo distribuido. Cuando varias instancias pueden modificar el mismo recurso, por ejemplo al procesar el callback de un pago externo, usamos un bloqueo SET NX de expiración corta. Una única instancia de Redis, sin Redlock. La tolerancia a fallos la aporta el procesamiento idempotente; el bloqueo no interviene en ella.

Racks de servidores en un pequeño centro de datos

Idempotencia y paginación

Las dos son fáciles de descuidar y difíciles de encajar a posteriori.

La clave de idempotencia es obligatoria en todos los endpoints de modificación que pueda invocar un sistema externo o una aplicación móvil. El cliente envía una cabecera Idempotency-Key, el servidor guarda durante un tiempo la respuesta asociada a esa clave y, ante una llamada repetida, devuelve la misma. Es lo que protege al sistema de los pedidos y mensajes duplicados que provocan los reintentos de red.

En la paginación, OFFSET se ralentiza de forma perceptible por encima de las decenas de miles de filas y, con datos en movimiento, se salta o repite elementos. Hemos pasado a la paginación por cursor: el cliente recibe la clave de ordenación del último elemento visto y con ella pide la página siguiente. Resulta menos cómoda para interfaces del tipo “ir a la página 47”. En una API, casi siempre es mejor.

Lo que dice el p99

La lección principal de las primeras semanas en producción: los ficheros de log, por sí solos, no bastan. En todos los sistemas incorporamos:

  • log estructurado (JSON) con identificador de petición, trazable desde la petición entrante hasta la consulta a base de datos,
  • métricas de Prometheus por endpoint: número de peticiones, tasa de error, percentiles de tiempo de respuesta (p50, p95, p99),
  • el estado del pool de base de datos (esperas, conexiones ocupadas) como métrica,
  • un endpoint de health check que comprueba también la base de datos y Redis, y que vigilan tanto Docker como Nginx.

La cifra que vigilamos es el p99. La media casi siempre luce bien; el p99 muestra lo que vive el uno por ciento de los usuarios.

Prueba de carga antes del estreno

No nos fiamos de nuestras propias estimaciones. Antes de cada puesta en marcha ejecutamos con k6 o una herramienta similar un escenario que simula varias veces el pico de carga esperado, sobre una base de datos con un volumen de datos realista. La prueba se lanza en staging, que corre en Docker con la misma configuración que producción.

Lo que suele aparecer: un índice que falta, un pool demasiado pequeño, una consulta N+1 que el ORM escondía o un endpoint que devuelve varios megabytes de JSON en una sola petición. Encontrar cualquiera de ellos en staging sale más barato que el primer día de campaña.

La próxima entrada trata de las arquitecturas SaaS multi-tenant, donde estos problemas suman una dimensión más: unos inquilinos afectan a otros.

También construimos backends de alta carga dentro de proyectos SaaS: desarrollo y operación SaaS.

Compartir
Széchenyi Terv Plusz kedvezményezetti infoblokk
QR Code