Saltar al contenido

Volver a proyectos

FastAPI

Escalando una aplicación para 6.000 usuarios con pocos recursos

· 8 min

Recibimos la aplicación en vísperas de un pico de tráfico esperado: de algunos cientos a cerca de 6.000 usuarios simultáneos, sin presupuesto para escalar horizontalmente y sin tiempo para una reescritura. El plazo era una semana, y el objetivo era simple de enunciar y difícil de cumplir: mantener la latencia estable y el sistema en pie bajo carga real.

Ilustración de barras en orden creciente

Mapeando el cuello de botella real

Antes de tocar cualquier línea de código, medimos. Un profiling rápido del backend en FastAPI mostró que la aplicación en sí no era el problema, las rutas asíncronas respondían bien de forma aislada. El cuello de botella estaba río abajo: PostgreSQL saturaba las conexiones disponibles en pocos minutos de carga simulada, y un puñado de queries sin índice adecuado disparaba sequential scans en tablas que ya superaban varios millones de filas.

PostgreSQL: pool de conexiones e índices

El primer cambio fue separar el pool de conexiones de la aplicación del pool de la base de datos: configuramos PgBouncer en modo transaction pooling delante de Postgres, reduciendo el número de conexiones físicas que la base necesitaba sostener incluso con cientos de workers de FastAPI abriendo conexiones simultáneas. Del lado de la aplicación, ajustamos el tamaño del pool de SQLAlchemy (pool_size y max_overflow) para no competir con el límite configurado en PgBouncer.

En paralelo, revisamos los planes de ejecución de las queries más llamadas con EXPLAIN ANALYZE y agregamos índices compuestos en las columnas usadas en los filtros y joins más frecuentes. Dos queries que hacían sequential scan en tablas grandes pasaron a usar índice, recortando el tiempo de respuesta de cientos de milisegundos a pocos milisegundos bajo carga.

Validando cada cambio con k6

Cada ajuste se validó con pruebas de carga en Grafana k6, simulando el patrón de acceso esperado: rampa de usuarios hasta 6.000 VUs simultáneos, sosteniendo el pico durante algunos minutos antes de reducir la carga. Definimos umbrales explícitos, p95 por debajo de un límite objetivo y tasa de error cercana a cero, para que la propia prueba fallara automáticamente si la estabilidad retrocedía.

Las pruebas se ejecutaron con cada nuevo cambio, lo que convirtió la semana en un ciclo corto de hipótesis, cambio y medición, en lugar de una apuesta única al final del plazo. Esto permitió aislar el efecto real de PgBouncer, de los índices y del ajuste del pool, uno a la vez.

Race conditions bajo alta concurrencia

Las propias pruebas de carga en k6 revelaron un problema que una carga baja nunca habría mostrado: con miles de solicitudes simultáneas golpeando el mismo registro (el mismo pedido, el mismo saldo de usuario), empezaron a aparecer actualizaciones perdidas y, en algunos casos, filas duplicadas, síntoma clásico de race condition en un patrón leer-y-luego-escribir sin ningún tipo de bloqueo.

La corrección quedó en la base de datos, no en la aplicación: las transacciones críticas pasaron a usar SELECT ... FOR UPDATE para bloquear la fila durante el ciclo de lectura y escritura, agregamos restricciones UNIQUE como última línea de defensa contra la duplicidad, e introdujimos claves de idempotencia en los endpoints de escritura más sensibles, para que un reintento del cliente ante un timeout no generara un efecto colateral duplicado. Recreamos el escenario en k6 con múltiples VUs disparando a propósito contra el mismo recurso, hasta que la prueba dejó de detectar cualquier inconsistencia.

El resultado

Al final de la semana, la aplicación sostenía los 6.000 usuarios simultáneos con un p95 estable y sin agotar conexiones de la base de datos, usando la misma infraestructura que antes apenas soportaba una fracción de ese tráfico. Ningún hardware nuevo, ninguna reescritura, solo medir el cuello de botella correcto, resolver el problema de conexiones y comprobar cada cambio con carga real antes de confiar en él.