4 de julio de 2026
Mi backend se tambaleaba con 1000 exámenes simultáneos: de FastAPI a Go
#go #python #fastapi #concurrency
Una plataforma EdTech con la que colaboro corre simuladores de examen. Su carga no es plana — pica, fuerte y a hora fija: cuando se abre un simulacro, del orden de 1000 candidatos golpean los mismos endpoints dentro del mismo minuto. Ese pico es donde el backend original en FastAPI empezó a crujir.
El síntoma
FastAPI está bien — el Python async lleva bien el tráfico I/O-bound. Pero bajo el pico de apertura, la latencia subía y las peticiones empezaban a dar timeout. Añadir procesos worker ayudaba un poco y luego dejaba de ayudar: cada worker cargaba su propia memoria, y la máquina se llenaba antes de que la latencia se aplanara.
El diagnóstico
El desajuste era el modelo de concurrencia, no el framework. El Python async gana cuando esperas I/O y nada bloquea el event loop. Pero los caminos pesados aquí no eran await-and-yield puro:
- el trabajo síncrono (import de Excel, recálculo de percentiles) bloquea el loop mientras corre;
- el paralelismo real es proceso-por-worker, y cada proceso es un intérprete completo con su propia huella de memoria;
- así “atender 1000 a la vez” se convierte en “correr N workers pesados”, y N lo acota la RAM mucho antes que la CPU.
Async no es paralelismo. Para un pico de trabajo mayormente independiente y en parte de CPU, quería concurrencia barata con coste de memoria plano.
La decisión
Reescribí el servicio caliente en Go. El argumento entero cabe en tres líneas:
- las goroutines son baratas — miles en vuelo en un proceso, repartidas en todos los cores;
- los worker pools acotados limitan la concurrencia en las operaciones caras en vez de lanzar trabajo sin límite;
- se entrega como un único binario estático — un contenedor pequeño, un perfil de memoria predecible, sin el impuesto del intérprete por worker.
flowchart LR
S["pico · ~1000 peticiones"] --> Q["cola acotada"]
Q --> P["worker pool · goroutines"]
P --> DB[("PostgreSQL")]
P --> R[("Redis · caché + throttle")]
El patrón que hizo el trabajo: un pool de goroutines de tamaño fijo vaciando un channel, de modo que el pico lo absorbe la cola y se procesa al ritmo que la base de datos aguanta de verdad — backpressure en vez de colapso.
sem := make(chan struct{}, maxWorkers) // acota la concurrencia
for _, job := range jobs {
sem <- struct{}{}
go func(j Job) {
defer func() { <-sem }()
process(j)
}(job)
}
El resultado
Mismo pico, comportamiento opuesto. Órdenes de magnitud en la misma instancia:
- Latencia p95: ~4 s con 504 durante el pico → ~150 ms.
- Throughput sostenido: ~200 req/s antes de degradarse → ~2000 req/s.
- Memoria: ~1,5 GB repartidos entre workers intérprete → ~60 MB para un único binario Go.
(Cifras representativas de la forma de la mejora, no un benchmark formal.)
La victoria no fue “Go es más rápido que Python” en plan microbenchmark — fue que el modelo de concurrencia y despliegue de Go encaja con la forma de esta carga: a ráfagas, en parte CPU, sensible a memoria.
Qué me llevo al siguiente
- Elige el runtime por la forma de concurrencia de la carga, no por el titular del benchmark. I/O async y ráfagas de CPU son problemas distintos.
- Reescribe el camino caliente, no toda la app. Solo el servicio bajo pico pasó a Go; el resto se quedó donde era productivo.
- La historia de despliegue es media victoria. Un binario estático con curva de memoria plana es operativamente más tranquilo que una flota de workers intérprete — y tranquilidad es justo lo que quieres en el momento exacto en que aparecen 1000 personas a la vez.