ProCat Solutions
Diseño de API REST de alta carga: lo que nos enseñaron los primeros proyectos en producción
API sobre Node.js y NestJS bajo carga real: índices en PostgreSQL, connection pooling, caché con Redis, rate limiting, idempotencia y observabilidad.
En el último medio año han salido de nuestras manos varios backends que tienen que atender varios cientos de peticiones por segundo y en los que el tiempo de respuesta influye directamente en el negocio del cliente. En esta entrada recogemos lo que apareció una y otra vez en esos proyectos. No hay nada revolucionario; es más bien una lista de comprobación que nos habría venido bien escribir antes.
El cuello de botella es la base de datos, no Node.js
La primera lección: el bucle de eventos de Node.js casi nunca es lo que se agota. Bajo NestJS, un endpoint bien escrito gestiona 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, sin embargo, casi siempre la está esperando.
Lo que hacemos en consecuencia en todos los proyectos:
- Índices basados en las consultas reales. Activar
pg_stat_statementses el primer paso en producción. Revisamos las consultas más lentas y más frecuentes conEXPLAIN (ANALYZE, BUFFERS)y solo creamos índices para aquellas combinaciones de columnas en las que el plan muestra realmente una lectura secuencial. La regla de “un índice en cada clave foránea” es un buen punto de partida, pero no sustituye a la medición. - Connection pooling consciente. El número de procesos Node.js multiplicado por el tamaño del pool no puede superar el valor de
max_connectionsde PostgreSQL, y hay que dejar además un margen para el mantenimiento. Con varias instancias colocamos PgBouncer entre los procesos y la base de datos en modo transacción; en ese caso, eso sí, hay que renunciar a los ajustes a nivel de sesión y a los prepared statements, y conviene saberlo de antemano. - Transacciones cortas. Dentro de una transacción no llamamos a ningún servicio externo. Si aun así hace falta, cerramos antes de la llamada y escribimos el resultado por separado.
Redis: caché, rate limit y bloqueo en un mismo sitio
Redis cumple tres papeles en nuestros sistemas, y los tres se configuran de forma distinta.
Caché. Almacenamos por clave, con un TTL corto, la respuesta de los endpoints intensivos en lectura (listados, configuraciones, datos maestros que cambian poco). No intentamos diseñar una invalidación de caché perfecta: en la mayoría de los casos, unos segundos de desfase son aceptables y resulta mucho más simple que un borrado dirigido por eventos y atado a las escrituras. Donde eso no es aceptable, preferimos no cachear.
Rate limiting. Mantenemos un contador de ventana deslizante por cliente (según clave de API o IP) y devolvemos en la respuesta las cabeceras X-RateLimit-*. El límite no se comprueba dentro del endpoint, sino en un guard global de NestJS, para que no se pueda olvidar.
Bloqueo distribuido. Si un mismo recurso puede ser modificado por varias instancias (por ejemplo, al procesar el callback de un pago externo), usamos un bloqueo SET NX de expiración corta. No es Redlock, sino una única instancia; la tolerancia a fallos no la resolvemos con el bloqueo, sino con un procesamiento idempotente.
Idempotencia y paginación
Dos cosas que es fácil descuidar y difícil encajar después.
La clave de idempotencia es obligatoria en todos los endpoints de modificación que puedan ser invocados por un sistema externo o por 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. Esto es lo que salva al sistema de los pedidos y mensajes duplicados que provocan los reintentos de red.
En cuanto a la paginación, la solución basada en OFFSET se ralentiza de forma perceptible por encima de las decenas de miles de filas y, con un conjunto de 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. Es menos cómodo para interfaces del tipo “salta a la página 47”, pero en una API casi siempre es mejor.
Observabilidad: lo que no medimos no lo podemos arreglar
La lección más importante de las primeras semanas en producción es que los ficheros de log, por sí solos, no bastan. Lo que incorporamos a todos los sistemas:
- 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 (número de 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.
El tiempo de respuesta que merece atención es el p99. La media casi siempre es bonita; el p99 muestra lo que vive el uno por ciento de los usuarios.
Prueba de carga, antes del arranque en producción
Por último, pero no menos importante: no nos creemos 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 cargada 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 estas pruebas suelen sacar a la luz: 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. Todo eso sale más barato encontrarlo en una prueba de staging que el primer día de campaña.
En la siguiente entrada escribiremos sobre las arquitecturas SaaS multi-tenant, donde estos problemas se amplían con una dimensión más: la influencia de unos inquilinos sobre otros.