Singleflight con hilos virtuales de Java: una llamada por cada clave caliente
Singleflight evita que miles de hilos virtuales golpeen a la vez el mismo servicio interno cuando expira una clave de caché. Se implementa sin locks, con ConcurrentHashMap y CompletableFuture.

Un hilo virtual por petición sale barato hasta que diez mil de esos hilos piden exactamente lo mismo al mismo tiempo. Cuando una clave caliente expira en Redis o en Caffeine, cada petición concurrente lanza su propia consulta y el servicio interno de destino recibe una tormenta que, en la práctica, se ha autoinfligido. La propuesta es aplicar singleflight dentro de la propia JVM: que una sola llamada baje por la red y que el resto espere su resultado.
El patrón no es nuevo. Go lo lleva años en su paquete singleflight con la misma idea: cuando muchas goroutines piden la misma clave, una ejecuta y las demás se cuelgan de su resultado. Lo que cambia aquí es el envoltorio, porque aprovecha que los hilos virtuales se desmontan del hilo portador mientras esperan.
Un lock distribuido no arregla esto
Tirar de Redisson o de locks en Redis para un problema que es de duplicación en proceso añade saltos de red y latencia operativa a algo que se resuelve en memoria local. El segundo error es blindar la sección crítica con synchronized o un ReentrantLock grueso: en las primeras versiones de Loom eso fijaba el hilo portador y se llevaba por delante la escalabilidad. El tercero es confiar solo en el TTL de la caché, porque con 20.000 peticiones por segundo una clave caliente que expira dispara una estampida inmediata contra los endpoints gRPC o REST de detrás.
Cómo se implementa
La coordinación es sin locks. Un ConcurrentHashMap guarda CompletableFuture en vuelo indexados por la identidad de la consulta. El primer hilo virtual que llega registra su future con computeIfAbsent y arranca la llamada al servicio de detrás; los siguientes caen en el mismo future y se limitan a llamar a join(), que los deja esperando sin ocupar un hilo del sistema operativo. Cuando la tarea termina, el future se completa y la clave se borra del mapa desde un bloque finally o desde un callback de finalización, de modo que la siguiente petición vuelva a disparar una ejecución nueva. Ese borrado importa: si una excepción transitoria deja la entrada viva, todos los que lleguen después se comen el error.
No hay mediciones que respalden la comparación con las alternativas que se descartan, así que la ventaja sobre un lock distribuido hay que tomarla como argumento de diseño, no como dato. Tampoco se entra en los detalles finos: qué pasa si el número de claves distintas en vuelo crece sin control, ni cómo se comporta el patrón bajo reintentos o timeouts del servicio remoto.
Aun así, merece la pena tenerlo a mano. Cualquier servicio con fan-in alto sobre una caché compartida acaba topándose con este problema, y resolverlo con dos estructuras de java.util.concurrent es bastante más barato que escalar la base de datos de detrás. Antes de darlo por hecho, conviene medir el fan-in real y el coste de mantener esas entradas en memoria.


