HU

Humano

← Blog

17 de marzo de 2026

La serialización JSON que consumía el 65% del CPU en un servicio Python

Un servicio FastAPI con 18 instancias gastaba $12,420/mes. El 65% del CPU se iba en serializar JSON. Un módulo Rust de 300 líneas lo bajó a 6 instancias.

pythonserializaciónrustcpufastapi

Un servicio FastAPI sirve un catálogo de productos. A 600 RPS, el equipo escala a 18 instancias c5.xlarge para mantener la latencia bajo control. El billing de AWS marca $12,420/mes solo en compute. La latencia p99 ronda los 890ms — demasiado cerca del timeout de los clientes que consumen la API.

El equipo sospecha que necesitan instancias más grandes o una reescritura parcial. Pero antes de escalar, alguien corre py-spy en producción durante 60 segundos. El flame graph muestra que el 65% del tiempo de CPU se concentra en dos funciones: la serialización de modelos Pydantic y json.dumps. No en queries a la base de datos. No en lógica de negocio. En convertir objetos Python a texto JSON.

Por qué la serialización consume tanto CPU

Cada respuesta del servicio es una lista de 50 a 200 productos con objetos anidados: variantes, niveles de precio, inventario por ubicación. El payload promedio es 85KB.

La serialización de un objeto Python a JSON requiere recorrer recursivamente toda la estructura de datos, convertir cada valor a su representación en string, y concatenar el resultado. En CPython, cada paso involucra overhead del intérprete: lookup de tipos, dispatch de métodos, allocación de strings intermedios.

Con Pydantic V2 — que ya usa un core en Rust para validación — la serialización del modelo a dict sigue pasando por Python para la conversión final a JSON. El método .model_dump() produce un diccionario Python nativo que después se serializa con json.dumps del módulo estándar. Cada llamada a json.dumps sobre un dict de 85KB toma ~8ms de CPU puro.

Pero el problema real no es solo la velocidad de json.dumps. Es el GIL.

El GIL como multiplicador del costo

Python tiene un Global Interpreter Lock que impide que múltiples threads ejecuten bytecode simultáneamente. En un servicio async con uvicorn y FastAPI, el event loop procesa requests de forma concurrente pero no paralela: cuando una coroutine hace I/O (query a la DB, llamada HTTP), cede el control y otra coroutine avanza. Pero cuando una coroutine ejecuta código CPU-bound como serialización JSON, el event loop se bloquea hasta que termina.

Con 600 RPS distribuidos en 18 instancias, cada instancia maneja ~33 RPS. A 8ms de serialización por response:

33 requests/s × 8ms = 264ms de CPU por segundo en serialización

El event loop de cada instancia está bloqueado el 26% del tiempo solo en serialización. Mientras serializa una respuesta, todas las demás requests en cola esperan. Esto produce un efecto cascada: una request que llega durante la serialización de otra acumula latencia de espera, lo que explica el p99 de 890ms a pesar de que el p50 es 45ms.

Lo que el equipo probó antes

IntentoResultadoPor qué no fue suficiente
Reemplazar json.dumps por ujson15% más rápido (~6.8ms por response)Mejora real pero no cambia la magnitud. El event loop sigue bloqueado 22% del tiempo
Paginar respuestas (máximo 20 productos)Latencia por request mejoróPero los clientes hacen más requests para obtener el catálogo completo. El throughput total sigue limitado por serialización
Cache de respuestas completas en RedisFunciona para endpoints estáticosEl catálogo cambia cada pocos minutos. La tasa de cache miss es demasiado alta para los endpoints más consultados

Cada intento mejora un aspecto pero no ataca la raíz: la serialización de payloads grandes en Python es inherentemente costosa porque cada operación pasa por el intérprete y el GIL.

El diagnóstico con py-spy

El profiling con py-spy es no invasivo — se conecta al proceso sin reiniciarlo ni modificar el código:

py-spy record -d 30 --pid <pid> -o profile.svg

El flame graph de 30 segundos muestra la distribución del tiempo de CPU:

Función% del CPU
pydantic.BaseModel.model_dump + json.dumps65%
SQLAlchemy query execution18%
FastAPI routing + middleware9%
Lógica de negocio (filtros, cálculos)8%

El 65% concentrado en serialización confirma que el bottleneck no es la base de datos ni la lógica de negocio. Es la conversión de objetos Python a bytes JSON.

La intervención: un módulo nativo en Rust vía PyO3

Si el 65% del CPU se va en serializar Python dicts a JSON, y esa operación es pura (recibe datos, devuelve bytes, sin side effects), se puede mover fuera del intérprete de Python a código nativo que no pase por el GIL.

PyO3 permite escribir módulos nativos de Python en Rust. El módulo recibe los objetos Python (la lista de productos ya como dicts), serializa a JSON usando serde_json, y devuelve bytes directamente al response ASGI. Mientras el código Rust ejecuta, libera el GIL — lo que permite que el event loop siga procesando otras requests.

Qué hace el módulo

Input:  list[dict] (productos como dicts Python, post model_dump)
Output: bytes (JSON serializado, listo para el response body)

El flujo del request queda:

  1. FastAPI recibe el request, ejecuta la query a la DB → no cambia
  2. Pydantic valida y estructura los datos → no cambia
  3. Módulo Rust recibe los dicts, serializa a JSON, devuelve bytes → reemplaza json.dumps
  4. ASGI envía los bytes al cliente → no cambia

La lógica de negocio, validación, queries, y manejo de errores siguen en Python. Solo el hot path de serialización se mueve a Rust.

El módulo es ~300 líneas de Rust. Se construye con maturin, que genera un wheel compatible con el Python de producción:

maturin build --release --interpreter python3.11
pip install target/wheels/catalog_serializer-*.whl

Por qué Rust y no otra opción

AlternativaProblema
C con Python C APIFunciona, pero sin safety de memoria. Un buffer overflow en un serializer que procesa input variable en producción es un riesgo de seguridad real
Go como microservicio separadoAgrega latencia de red (~1-3ms por hop), complejidad operativa (otro deploy, otro servicio que monitorear), y no resuelve el problema — lo mueve
CythonAcelera ~2-3x pero sigue ejecutando en el intérprete para operaciones sobre dicts. No puede competir con serde_json para serialización pura
orjson (biblioteca existente en Rust)Alternativa viable — es un drop-in para json.dumps escrito en Rust. Serializa ~4x más rápido que json.dumps. Pero para payloads de 85KB con estructuras anidadas conocidas, un serializer tipado con serde_json es aún más rápido porque evita el dispatch dinámico de tipos
Rust vía PyO3Sin GC, memory safety garantizada por el compilador, serde_json es uno de los serializers JSON más rápidos disponibles, y libera el GIL durante la ejecución

La decisión no es "Rust es mejor que Python" — es que este componente específico ejecuta trabajo CPU-bound puro que bloquea el event loop, y moverlo a código nativo que libera el GIL elimina el bloqueo sin cambiar la arquitectura del servicio.

Por qué no usar orjson directamente

orjson es una alternativa seria. Está escrito en Rust, es un drop-in para json.dumps, y para muchos casos es suficiente. Si el payload promedio fuera <20KB o el throughput fuera <200 RPS, orjson sería la respuesta correcta con cero esfuerzo de desarrollo.

En este caso, el módulo custom gana por dos razones:

  1. Serialización tipada: serde_json con structs definidos evita el overhead de inspeccionar el tipo de cada valor en runtime. Para un dict Python con 200+ nested objects, el dispatch dinámico de orjson suma ~30% más de tiempo que un serializer que ya conoce la estructura
  2. Conversión directa: el módulo recibe la lista de dicts y convierte a structs Rust internamente. orjson necesita recorrer la estructura Python dict → JSON. El módulo custom hace Python dict → Rust struct → JSON, lo que permite optimizaciones de layout en memoria

Para un servicio con payloads más simples o menor throughput, orjson es la recomendación.

El resultado

MétricaAntesDespués
Serialización por response (85KB)~8ms (Python json/Pydantic)~0.9ms (Rust serde_json vía PyO3)
CPU en serialización65%12%
Instancias necesarias a 600 RPS186
Costo mensual infra (c5.xlarge)$12,420/mes$4,140/mes
Latencia p5045ms18ms
Latencia p99890ms95ms

La mejora en p99 (de 890ms a 95ms) es desproporcionada respecto a la mejora en tiempo de serialización (8ms → 0.9ms) porque el efecto principal no es serializar más rápido — es desbloquear el event loop. Con el GIL liberado durante la serialización, las requests concurrentes no se apilan esperando.

La reducción de 18 a 6 instancias representa $8,280/mes de ahorro — $99,360/año en una sola línea del billing.

Cuándo este patrón aplica

La extracción del hot path de serialización a código nativo tiene sentido cuando:

Cuándo no


Si tu servicio Python gasta más CPU en serializar que en resolver lógica de negocio y quieres saber si el hot path es candidato a extracción nativa, podemos revisarlo en una conversación de 15 minutos sin costo.

Referencias