Recortar un 90% el tiempo de respuesta sin reescribir el servicio
· 5 min de lectura
Hay una tentación muy común cuando un servicio va lento: reescribirlo. Otro lenguaje, otro framework, otra arquitectura. Casi siempre es una mala idea. Lo lento rara vez es todo el servicio; suele ser una parte concreta que nadie ha medido todavía.
Este es el caso de cómo un equipo de cuatro ingenieros redujo un 90% el tiempo de respuesta del servicio principal de una plataforma e-commerce sin reescribirlo. No hubo bala de plata: hubo método.
Contexto
La plataforma atiende más de dos millones de peticiones al mes. El backend está repartido en varios servicios en TypeScript con NestJS, con MongoDB como base de datos y Redis disponible como caché.
Uno de esos servicios es el centro de todo: casi cualquier página o flujo importante pasa por él. Cuando ese servicio va lento, va lento todo lo demás. Y lo iba.
El equipo éramos cuatro personas, responsables del backend y de parte del frontend. Con ese tamaño no hay margen para un proyecto de meses que congele el resto del trabajo.
Restricciones
Antes de tocar nada, dejamos claras las reglas del juego:
| Restricción | Consecuencia |
|---|---|
| Sin parar el negocio | Nada de ventanas de mantenimiento ni migraciones big bang. |
| Sin reescritura completa | Cambios incrementales, cada uno desplegable por separado. |
| Equipo pequeño | Priorizar por impacto: primero lo que más pesa. |
| Otros equipos dependen del servicio | El contrato de la API no puede cambiar. |
Estas restricciones no eran un obstáculo, sino la mejor guía. Obligan a hacer lo que hay que hacer de todas formas: medir, atacar lo que más duele y desplegar en pasos pequeños.
Diagnóstico: medir antes de opinar
En un equipo, todo el mundo tiene una teoría sobre por qué algo va lento. “Es la base de datos.” “Es el ORM.” “Es Node.” Las teorías no sirven de nada hasta que se miden.
El primer paso fue tener visibilidad real de dónde se va el tiempo de cada petición. No el tiempo medio del servicio, que esconde casi todo, sino el desglose: cuánto es lógica propia, cuánto consultas a MongoDB y cuánto llamadas a otros servicios.
Petición al servicio principal
│
├─ lógica propia ............ poco
├─ consultas a MongoDB ...... ¿cuántas? ¿qué índices usan?
└─ llamadas a otros servicios
├─ servicio A .......... ¿en serie o en paralelo?
├─ servicio B .......... ¿se repiten en cada petición?
└─ servicio C .......... ¿qué pasa si tarda?
Con ese desglose aparecen las sospechosas habituales en un servicio que ha crecido durante años:
- Llamadas síncronas encadenadas a otros servicios: la latencia de cada una se suma a la siguiente.
- Datos que cambian poco pero se piden en cada petición, a la base de datos o a otro servicio.
- Consultas sin el índice adecuado o que traen mucho más de lo que se usa.
- Trabajo repetido dentro de la misma petición.
Lo importante del diagnóstico no es encontrar un culpable, sino ordenar los culpables por peso. Eso decide el plan.
Diseño: atacar por orden de impacto
Con las causas ordenadas, el plan se escribe casi solo. Cada cambio debía cumplir dos condiciones: atacar una causa medida y poder desplegarse solo.
1. Dejar de preguntar lo que ya sabemos
Uno de los patrones más caros en un servicio así es pedir, una y otra vez, datos que pertenecen a otros servicios y que cambian poco. La respuesta clásica es una caché en Redis delante de esas llamadas. Funciona, pero tiene un límite: el primer acceso sigue siendo lento, y si el otro servicio cae, la caché acaba expirando.
Por eso fuimos un paso más allá y montamos un readstore interno: una copia local de los datos de otros servicios que necesitamos para responder, mantenida al día cuando esos datos cambian. El servicio principal ya no pregunta a nadie en el camino crítico: lee de lo suyo.
Antes: cliente ─▶ servicio principal ─▶ servicio A ─▶ servicio B ─▶ ...
Después: cliente ─▶ servicio principal ─▶ readstore local
▲
servicios A, B ──┘ (actualizan el readstore cuando cambian sus datos)
Esto tuvo un efecto doble: menos latencia y más resiliencia. Si otro servicio tiene un mal día, el principal sigue respondiendo con los últimos datos que conoce.
El precio es la consistencia eventual: durante un instante, el readstore puede ir por detrás del original. Hay que decidir conscientemente qué datos lo toleran y cuáles no. De ese dilema hablaré en otro post.
2. Mejorar la comunicación entre servicios
Las llamadas que siguieron siendo necesarias se revisaron una a una: cuáles podían ir en paralelo en lugar de en serie, cuáles tenían timeouts razonables y cuáles podían degradarse con elegancia si el otro lado fallaba.
3. Lo básico, bien hecho
El resto fue trabajo poco vistoso pero necesario: índices de MongoDB alineados con las consultas reales, proyecciones que solo traen los campos que se usan y eliminar trabajo duplicado dentro de una misma petición.
Resultado
El tiempo de respuesta del servicio principal bajó un 90%. Sin reescribirlo, sin parar el negocio y sin cambiar su contrato con el resto de equipos.
No todo fue gratis:
- Más piezas que mantener. El readstore es otro componente que vigilar: hay que saber si se queda atrás y cómo reconstruirlo.
- Consistencia eventual. Algunos datos pueden tardar un momento en reflejar un cambio. Es aceptable donde lo es, y hay que documentarlo.
- Disciplina de medición. Mantener la mejora exige seguir midiendo; si no, la latencia vuelve poco a poco con cada funcionalidad nueva.
Lo que aprendí
Medir antes de opinar. La intuición del equipo sobre la causa rara vez coincide del todo con lo que dicen los datos. El desglose por petición cambió el orden de prioridades.
La latencia de un servicio es la suma de la de sus dependencias. En una arquitectura distribuida, el servicio más rápido del mundo es lento si en el camino crítico espera a otros tres. Sacar dependencias de ese camino vale más que optimizar código.
Caché y readstore no son lo mismo. Una caché acelera lo que ya se ha pedido. Un readstore cambia quién tiene los datos. El segundo es más trabajo, pero aporta resiliencia además de velocidad.
Las restricciones ayudan. “Sin reescribir y sin parar” nos obligó a hacer cambios pequeños, medibles y reversibles. Es exactamente como debería hacerse siempre.
Reescribir es tentador porque parece un nuevo comienzo. Pero casi siempre lo que hace falta no es un servicio nuevo, sino saber exactamente dónde se pierde el tiempo en el que ya existe.