Tus programas no son de un solo hilo
Otros idiomas: English Français Deutsch 日本語 한국어 Português 中文
Al escribir código, tendemos a pensar de forma síncrona. Escribimos una instrucción tras otra, y el runtime las ejecuta en orden. Este modelo mental funciona muy bien para scripts y herramientas de línea de comandos, pero puede ser peligrosamente engañoso cuando se trabaja con frameworks y aplicaciones de servidor.
Incluso si nunca creas hilos explícitamente o usas primitivas de concurrencia, tu aplicación web casi con certeza está ejecutando código en paralelo. El framework lo maneja por ti, lo cual es conveniente, siempre y cuando lo tengas en cuenta y uses correctamente el modelo de hilos y los bloques de construcción que el framework proporciona.
En este artículo, veremos exactamente el tipo de bug que puede ocurrir cuando uno sigue erróneamente un modelo mental de un solo hilo en código aparentemente síncrono. Encontraremos la causa, la reproduciremos usando el depurador y veremos varias soluciones posibles.
El problema
Imagina que tienes una aplicación Spring Boot que ha estado funcionando bien en desarrollo y producción. Durante mucho tiempo, todo ha funcionado sin problemas, pero entonces el equipo de contabilidad reporta un problema serio: varias facturas tienen el mismo número. Esto es malo. Los números de factura deben ser únicos por razones legales y contables. Los duplicados solo aparecieron una vez, y no puedes reproducir el problema localmente.
Para hacer este ejercicio más interesante, puedes intentar encontrar el problema tú mismo antes de seguir leyendo. El proyecto está disponible en GitHub.
Verificas la base de datos, y muestra registros distintos con números de factura idénticos. La configuración parece correcta, pero claramente algo está mal. Al rastrear el flujo de creación de facturas, encuentras el servicio responsable de generar números de factura.
Así es como se ve el código:
@Service
public class InvoiceService {
private InvoiceNumberGenerator generator;
public Invoice createInvoice(Order order) {
if (generator == null) {
generator = createGenerator();
}
String invoiceNumber = generator.nextNumber();
return new Invoice(invoiceNumber, order);
}
private InvoiceNumberGenerator createGenerator() {
// Reads last used number from database, initializes counter
int lastNumber = invoiceRepository.findMaxInvoiceNumber();
return new InvoiceNumberGenerator(lastNumber);
}
}
Así no es como escribirías ese código. ¿Puedes detectar el problema?
¿Qué salió mal?
El modelo mental del autor probablemente era: “Spring gestiona mis beans, así que estoy a salvo.”
Y eso es cierto: Spring efectivamente maneja el ciclo de vida de los beans y asegura
que InvoiceService se cree una vez y se comparta correctamente.
Pero Spring no sabe sobre los campos que inicializas tú mismo.
El campo generator es tu responsabilidad, y aquí es donde acecha el problema.
La secuencia de eventos
Los beans de Spring son singletons por defecto.
Esto significa que solo hay una instancia de InvoiceService compartida entre todas las solicitudes.
Cuando múltiples solicitudes HTTP llegan simultáneamente,
son manejadas por diferentes hilos, todos accediendo a la misma instancia del servicio.
Ahora, tracemos lo que sucede si el generador aún no ha sido inicializado,
y dos hilos llaman a createInvoice() aproximadamente al mismo tiempo:
- El hilo A verifica
generator == null, que estrue - El hilo A entra en el bloque
ify comienza a crear el generador - El hilo B verifica
generator == null, que todavía estrue(A no ha creado el generador aún) - El hilo B también entra en el bloque
if - Ambos hilos consultan la base de datos para
findMaxInvoiceNumber()y obtienen el mismo valor, por ejemplo,1000 - Ambos hilos crean su propio
InvoiceNumberGeneratorcomenzando en1000 - Ambos llaman a
nextNumber()y obtienen1001 - Se crean dos facturas con el mismo número
La inicialización perezosa no es thread-safe, y bajo acceso concurrente, ambos hilos pueden leer el mismo “último número de factura” de la base de datos y generar el mismo siguiente número.
Reproduciendo con el depurador
El código problemático que acabamos de encontrar es simple, así que podrías entender la secuencia de eventos solo mirándolo. En casos más complejos, podrías querer reproducir el bug paso a paso para entender exactamente qué está sucediendo. Otras veces, podrías simplemente querer simular el timing desafortunado y probar si el programa no se rompe bajo carga.
Usaremos el depurador de IntelliJ IDEA para esto. Puede controlar hilos individuales – exactamente lo que necesitamos.
Primero, establece un punto de interrupción en la línea generator = createGenerator(); ,
haz clic derecho en él, y configura Suspend
a Thread.
Esto le dice al depurador que solo suspenda el hilo que alcanzó el punto de interrupción en lugar de todos.
Ejecuta la aplicación en modo depuración y envía dos solicitudes desde diferentes terminales usando el siguiente comando curl:
curl -X POST http://localhost:8080/api/diagnostics/create-order
Una de las solicitudes alcanzará el punto de interrupción: esto es esperado. Cuando la segunda solicitud también alcance el punto de interrupción, acabas de probar que múltiples hilos pueden entrar en la sección crítica simultáneamente – la definición del bug en cuestión.
Puedes inspeccionar la pestaña Threads para ver ambos hilos esperando en la misma ubicación:
Ambos hilos han pasado la verificación null ,
y cuando los liberes, crearán sus propias instancias del generador.
Alternativamente, puedes usar un punto de interrupción de registro sin suspensión con un pequeño retraso para simular una ejecución más lenta:
Este tipo de punto de interrupción está diseñado para registro de depuración, pero como ya sabemos, puede usarse para introducir prácticamente cualquier efecto secundario. Con este retraso artificial, no tenemos que suspender la aplicación porque es probable que las solicitudes colisionen de todos modos.
Solucionando el problema
Hay varias formas de solucionar el problema, dependiendo de tus requisitos:
Opción 1: Usar la base de datos
Para números de factura específicamente, la solución correcta es dejar que la base de datos maneje la unicidad:
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public Invoice createInvoice(Order order) {
// Database generates the number via sequence or auto-increment
return invoiceRepository.save(new Invoice(order));
}
}
Esto elimina el contador en memoria por completo. La base de datos garantiza unicidad incluso a través de múltiples instancias de la aplicación.
Opción 2: Inicialización eager
Si necesitas un generador en memoria, hazlo un bean de Spring:
@Component
public class InvoiceNumberGenerator {
private final AtomicInteger counter;
public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
int startingNumber = (maxNumber != null) ? maxNumber : 0;
this.counter = new AtomicInteger(startingNumber);
}
public String nextNumber() {
int next = counter.incrementAndGet();
return String.format("INV-%05d", next);
}
}
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberGenerator generator;
public InvoiceService(InvoiceRepository invoiceRepository,
InvoiceNumberGenerator generator) {
this.invoiceRepository = invoiceRepository;
this.generator = generator;
}
public Invoice createInvoice(Order order) {
String invoiceNumber = generator.nextNumber();
Invoice invoice = new Invoice(invoiceNumber, order);
return invoiceRepository.save(invoice);
}
}
Este es el enfoque estándar en Spring. Los beans singleton se crean al inicio y se comparten de forma segura entre todas las solicitudes.
Opción 3: Usar @Lazy
Si realmente necesitas inicialización perezosa
(por ejemplo, cuando la creación de un bean es costosa porque carga datos o establece conexiones),
puedes usar @Lazy para que Spring lo maneje de forma segura:
@Component @Lazy
public class InvoiceNumberGenerator {
private final AtomicInteger counter;
public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
int startingNumber = (maxNumber != null) ? maxNumber : 0;
this.counter = new AtomicInteger(startingNumber);
}
public String nextNumber() {
int next = counter.incrementAndGet();
return String.format("INV-%05d", next);
}
}
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberGenerator generator;
public InvoiceService(InvoiceRepository invoiceRepository,
@Lazy InvoiceNumberGenerator generator) {
this.invoiceRepository = invoiceRepository;
this.generator = generator;
}
public Invoice createInvoice(Order order) {
String invoiceNumber = generator.nextNumber();
Invoice invoice = new Invoice(invoiceNumber, order);
return invoiceRepository.save(invoice);
}
}
Como regla general, si te encuentras implementando manualmente inicialización perezosa, caché, sincronización, o patrones similares – verifica si el framework ya lo proporciona. Hacerlo tú mismo a menudo significa luchar contra el framework e introducir sobrecarga de mantenimiento y bugs sutiles.
Resumen
Incluso cuando no creamos hilos explícitamente, nuestras aplicaciones a menudo se ejecutan concurrentemente. Este patrón no es único de Spring o incluso del desarrollo web. Donde sea que tengas un modelo de hilos implícito, estás en riesgo: los frameworks web, toolkits de UI e incluso las APIs de la biblioteca estándar gestionan hilos en segundo plano.
La próxima vez que veas comportamiento intermitente e inexplicable en tu aplicación, pregúntate: ¿podría ser esto un problema de hilos? La mayoría de las veces, la respuesta es sí.
¡Feliz depuración!