Cuando tienes un sistema con alta concurrencia, te enfrentarás al fenómeno del thundering herd (o cache stampede). Esto ocurre cuando una clave de caché crítica expira y, en ese preciso instante, decenas o cientos de peticiones simultáneas detectan la ausencia del dato y todas intentan regenerarlo, disparando una carga masiva y destructiva hacia tu base de datos o servicio dependiente. singleflight.Group [disponible en la librería golang.org/x/sync/singleflight] es la herramienta diseñada para mitigar este escenario mediante la deduplicación de peticiones “en vuelo” (in-flight).
El mecanismo interno de singleflight es una estructura que mantiene un mapa de claves hacia objetos call. Cada call contiene un sync.WaitGroup. Cuando llamas a g.Do(key, fn), la librería verifica si ya existe un call activo para esa key. Si existe, la goroutine simplemente se queda esperando en el WaitGroup de esa llamada ya existente. Si no existe, se crea un nuevo call, se ejecuta fn y, al finalizar, se cierra el WaitGroup para liberar a todos los waiting goroutines, quienes recibirán el mismo resultado y el mismo error. Es vital entender que singleflight no es un sistema de caché; su única función es asegurar que, para una misma clave, solo una ejecución de la función fn esté activa en un momento dado. Si intentas usarlo como sustituto de una caché (sin persistencia), estarás introduciendo errores de diseño, ya que singleflight no guarda el resultado para la siguiente petición que llegue cuando la actual haya terminado.
package main
import (
"fmt"
"log"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// Database simula una base de datos con latencia.
type Database struct {
sync.Mutex
calls int
}
func (db *Database) GetUser(id string) (string, error) {
db.Lock()
db.calls++
currentCalls := db.calls
db.Unlock()
// Simulamos una operación costosa de 500ms
time.Sleep(500 * time.Millisecond)
log.Printf("[DB] Ejecutando consulta para usuario: %s (Total llamadas a DB: %d)\n", id, currentCalls)
return fmt.Sprintf("Perfil de %s", id), nil
}
func main() {
db := &Database{}
var g singleflight.Group
var wg sync.WaitGroup
userID := "user_123"
numRequests := 10
// Lanzamos 10 peticiones simultáneas para el mismo usuario.
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func(requestID int) {
defer wg.Done()
// g.Do asegura que solo una ejecución de la función sea activa por key.
val, err, shared := g.Do(userID, func() (interface{}, error) {
return db.GetUser(userID)
})
if err != nil {
log.Printf("[Req %d] Error: %v\n", requestID, err)
return
}
// val es el resultado devuelto por la función.
// shared es true si la goroutine recibió el resultado de una ejecución en curso.
status := "privada"
if shared {
status = "compartida"
}
fmt.Printf("[Req %d] Resultado: %v | Tipo: %s\n", requestID, val, status)
}(i)
}
wg.Wait()
}
Análisis del flujo
En el ejemplo anterior, lanzamos 10 goroutines que compiten por el mismo userID. Aunque todas llaman a g.Do casi al mismo tiempo, el comportamiento es el siguiente:
- La primera goroutine que llega a
g.Doencuentra que no hay ninguna ejecución activa para"user_123". Crea un objetocally comienza la ejecución dedb.GetUser. - Las otras 9 goroutines llegan mientras
db.GetUseraún está en sutime.Sleep. Al ver que ya existe una ejecución para esa clave, no ejecutan la función, sino que se bloquean esperando que elWaitGroupinterno del primercallse libere. - Cuando la primera goroutine termina,
singleflightrecupera el valor y el error, y los despierta a todas las demás. - El valor de retorno
sharedes la clave para la observabilidad: la primera goroutine recibiráshared = false(ella es la dueña de la ejecución), mientras que las otras 9 recibiránshared = true. Esto es extremadamente útil para métricas; te permite saber cuántas peticiones fueron realmente únicas y cuántas fueron deduplicadas.
El error frecuente
Un error crítico al usar singleflight es la propagación de errores transitorios. Si la función fn devuelve un error (por ejemplo, un timeout temporal de la base de datos), singleflight distribuirá ese mismo error a todas las goroutines que estén esperando.
Si tu sistema tiene mucha carga y una llamada falla por un error de red de milisegundos, habrás fallado a todos los usuarios concurrentes en lugar de solo a uno. Si necesitas que las peticiones sean más resilientes, podrías considerar usar g.Forget(key) dentro de un mecanismo de reintento o envolver la llamada en un patrón de Circuit Breaker para evitar que una falla masiva se propague a través del mecanismo de deduplicación.
N° 218