Cuando la concurrencia y el legado se vuelven un dolor de cabeza

Cesar A. Fernandez B.
Sr. Software Engineer

Bueno muchachones, aca les cuento esta experiencia, hace dias me contactan para realizar una consultoria, me indican: 'Tenemos un problema!!!' la app de ordenes... funciona como se debe el 99% del tiempo, pero basta que lancemos una promo y suba la gente, y empiezan a pasar cosas extranas, se vuelve loca!!! ordenes que no corresponden, montos que no cuadran, etc. Y no tenemos soporte ya que la empresa que nos realizo la app ya no existe.
Coño, aqui es donde me dije, mier.... esa uno de esos desarrollos donde no quiero tocar mucho, porque es legacy ummm delicado!!. Sabes cómo son esos escenarios: sistemas que nadie entiende del todo, miedo a romper algo más grande. Pero bueno, me gustan los retos, así que acepté el llamado a la batalla.
El Misterio del Comportamiento Errático bajo Presión
Llego y lo primero, como siempre, es ver los síntomas. Me enseñan tickets de clientes: "pedí X, me llegó Y", "el total era 50, me cobraron 150", "me duplicaron la orden". Cosas raras, inconsistentes.
Lo clásico: me meto en los logs. Errores? Excepciones? Nada. Limpiecitos. Reviso métricas de infraestructura: CPU, memoria, red... todo dentro de rangos normales, ni siquiera saturados. Coño, un fantasma!!! jaja jaja jaja
Aquí viene la primera lección que vale oro, y se la comento muchachones: los problemas más cabrones no siempre gritan con una 'java.lang.NullPointerException' o te tiran el servidor. A veces son silenciosos, sutiles, y solo aparecen bajo ciertas condiciones. Como este, que solo salía a la luz cuando había mucha gente usando el sistema a la vez, con las promos.
Después de darle mil vueltas, revisar flujos, y no encontrar nada obvio, se me prende el bombillo: "Podría ser un problema de concurrencia?". Nojoda!!! Eso encajaba perfecto con el patrón: solo falla cuando hay muchas peticiones procesándose al mismo tiempo.
Desenterrando al Culpable Silencioso: El Estado Compartido
Coño alli estuvo la clave. Empece a mirar el código con otros ojos, buscando puntos donde varios hilos pudieran estar interactuando con la misma pieza de memoria. Y listo, encontre el foco de este comportamiento loco...
Era un 'Service' de Spring Boot, de esos que por defecto son 'singleton'. Y dentro, horror!!!, tenía variables de instancia que guardaban información del proceso de la orden actual. Algo como esto (y aclaro, pana, que este es un ejemplo súper simplificado para que se entienda, el código real era mucho más enredado y feo, pero la esencia del problema era esta!):
@Service // Esto lo hace Singleton por defecto
public class OrderProcessorService {
private BigDecimal currentOrderTotal; // Mal mis bros!!! Estado mutable compartido
private List<String> currentOrderItems; // ¡Más peligro! Estado mutable compartido
// ... otras dependencias inyectadas ...
public Order processOrder(OrderRequest request) {
// Aquí se inicializaban... pero para *todos* los hilos!
this.currentOrderTotal = BigDecimal.ZERO;
this.currentOrderItems = new ArrayList<>();
// ... lógica que usaba y modificaba currentOrderTotal y currentOrderItems ...
for (ItemRequest itemReq : request.getItems()) {
// ... obtener precio, cantidad ...
this.currentOrderTotal = this.currentOrderTotal.add(/* cálculo */);
this.currentOrderItems.add(/* item */);
// ... actualizar inventario ...
}
// ... crear y retornar la orden usando los campos de instancia ...
Order order = new Order();
// ... setear campos ...
order.setTotalAmount(this.currentOrderTotal); // Aquí se usaba el valor pisado!!! :S
// ...
return order;
}
}
Ves el problema? Cuando llegaban 10, 50, 100 peticiones de orden a la vez, todos los hilos de ejecución intentaban usar y modificar las mismas variables currentOrderTotal y currentOrderItems que pertenecían a la única instancia del OrderProcessorService. Un hilo empezaba a calcular una orden, otro llegaba y le reiniciaba el total a cero, otro le borraba la lista de ítems... Un desastre total y absoluto!!! Los datos se mezclaban, se perdían, se pisaban.
La Solución "Low-Impact" para el Legacy Delicado
Aquí la restricción de "no tocar mucho" era clave. No podía rediseñar toda la arquitectura ni meter cambios masivos. La solución tenía que ser quirúrgica, precisa y mínima.
La respuesta? Hacer el método processOrder stateless respecto a las variables que le pertenecían a la instancia del bean. En lugar de usar campos de la clase para guardar el estado temporal de una orden específica, usamos variables locales dentro del método. Cada hilo tiene su propia pila de ejecución y sus propias variables locales, así que no se pisan!!! y todo calidad!!!
El cambio fue sorprendentemente simple a nivel de código (de nuevo, un ejemplo simplificado):
@Service
public class OrderProcessorService {
// Ya no hay estado mutable aquí a nivel de instancia!!! Relajado!!!
// ... otras dependencias inyectadas ...
public Order processOrder(OrderRequest request) {
// Ahora usamos variables locales dentro del método
BigDecimal orderTotal = BigDecimal.ZERO; // Cada hilo tiene su propia 'orderTotal'
List<String> orderItems = new ArrayList<>(); // Cada hilo tiene su propia 'orderItems'
// ... la misma lógica, pero usando las variables locales ...
for (ItemRequest itemReq : request.getItems()) {
// ... obtener precio, cantidad ...
orderTotal = orderTotal.add(/* cálculo */); // Modificando la variable local
orderItems.add(/* item */); // Modificando la variable local
// ... actualizar inventario ...
}
// ... crear y retornar la orden usando las variables locales ...
Order order = new Order();
// ... setear campos ...
order.setTotalAmount(orderTotal); // Usando el valor local correcto
// ...
return order;
}
}
Este cambio tan pequeño, local, que apenas tocaba la lógica del negocio, resolvió el problema de concurrencia. Lo desplegue y a esperar la siguiente promo... y funcionó!!! Mas nada jaja jaja Ni una sola orden rara. Eso nos enseña otra cosa: a veces, en sistemas legacy, el ajuste mínimo y preciso es más efectivo (y seguro) que intentar reescribir grandes partes, por eso hay que invertir horas en analizar bien el tema seguir los flujos y hacer los diagramitas muchachones
Y de Regalo, Domando al Monstruo del Docker
Mientras estaba en eso, me di cuenta de otro problemita que, aunque no causaba los errores de orden, sí era un dolor de cabeza: la imagen Docker del microservicio pesaba casi 2GB. Una locura esa mier...!!! Desplegar eso era lento, consumía mucho espacio y, francamente, una imagen tan grande suele esconder cosas innecesarias que pueden ser agujeros de seguridad :S
Aquí de nuevo a ponerse en modo PRO de Docker para apps Java. La clave fue la construcción multi-etapa (multi-stage build) y usar una imagen base optimizada.
Te explico el truco:
-
Primera etapa ('builder'): se usa una imagen completa de Java ('eclipse-temurin:17-jre-alpine' es una excelente opción, ligera y oficial). En esta etapa, se copio solo el JAR de la aplicación y se usan las 'layertools' de Spring Boot. Esto es magia pura: las 'layertools' permiten separar el JAR en capas lógicas (dependencias, código de la app, etc.). Esto es brutal porque las dependencias rara vez cambian, y al estar en su propia capa, Docker las cachea. Si solo cambias tu código, Docker solo reconstruye y sube la capa pequeña de la aplicación, mucho más rápido!!!
-
Segunda etapa (runtime): Aquí se usa una imagen base minimalista, idealmente 'distroless'. 'gcr.io/distroless/java17-debian11' es perfecta. Estas imágenes solo tienen lo mínimo indispensable para correr una app (la JVM, certificados SSL, etc.), sin shell, sin gestor de paquetes, sin nada extra!!! Esto reduce drásticamente el tamaño y la superficie de ataque (menos cosas = menos posibles vulnerabilidades). En esta etapa, solo se copia las capas separadas por 'layertools' desde la etapa 'builder'.
El Dockerfile final quedó algo así de elegante:
# Etapa 1: El Constructor - Prepara las capas del JAR FROM eclipse-temurin:17-jre-alpine as builder WORKDIR application ARG JAR_FILE=target/*.jar # Copiamos el JAR COPY ${JAR_FILE} app.jar # Usamos layertools para extraer las capas RUN java -Djarmode=layertools -jar app.jar extract # Etapa 2: El Runtime - La imagen final mínima # Usamos una base distroless optimizada para Java FROM gcr.io/distroless/java17-debian11 WORKDIR application # Copiamos las capas optimizadas desde la etapa builder COPY --from=builder application/dependencies/ ./ COPY --from=builder application/snapshot-dependencies/ ./ COPY --from=builder application/spring-boot-loader/ ./ COPY --from=builder application/application/ ./ # El punto de entrada que lanza la app ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
El resultado papa? Pasamos de una imagen de casi 2GB a una de apenas 200MB! Esto hizo los despliegues mucho más rápidos, consumió menos recursos y, como dije, mejoró la seguridad.
Las Grandes Enseñanzas de este trabajito
Si algo te puedo contar pana, es lo siguiente:
-
Huye del Estado Mutable en Singletons!!! En entornos concurrentes como los microservicios en Spring (donde los beans son singletons por defecto), guardar estado que cambia ('BigDecimal', 'List', etc.) en variables de instancia es una receta para el desastre, una soberana locura!!!. Haz tus servicios lo más stateless posible, pasando datos como argumentos y manejando el estado dentro de los métodos con variables locales.
-
Los Problemas Intermitentes Bajo Carga, Suelen Ser de Concurrencia: Si algo falla solo cuando el sistema está ocupado, casi seguro que estás lidiando con hilos pisándose. Las herramientas de profiling y, a veces, solo sentarse a pensar en qué partes del código pueden ser accedidas por varios hilos a la vez, son tus mejores amigos.
-
En Código Legacy, el Cambio Mínimo es Tu Amigo: A veces, no necesitas reescribir todo. Identificar el punto exacto del problema y aplicar una corrección localizada (como pasar variables de instancia a locales) puede salvarte la patria sin introducir nuevos riesgos.
-
Optimizar Docker Es Clave (y No Tan Difícil): Una imagen Docker bien construida con multi-stage builds y bases ligeras ('distroless') no es solo por fregar. Mejora la seguridad, acelera los despliegues y te hace la vida más fácil, especialmente si usas orquestadores como Kubernetes. Es nivel PRO que vale la pena dominar!!!
Al final, quedo bello, bello esa app para sus promos, los clientes dejaron de quejarse de órdenes raras y respire tranquilo, llevandome otra historia para la colección y la certeza de que, a veces, los problemas más esquivos son los que más te enseñan.
Así que ya sabes, muchachon!!! Si te toca lidiar con un bicho raro que solo sale bajo presión en un sistema viejo, acuérdate de esta historia. La concurrencia y el estado compartido son enemigos silenciosos, asi que activitos muchachones!!!