Deduplicación de peticiones con singleflight

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:

  1. La primera goroutine que llega a g.Do encuentra que no hay ninguna ejecución activa para "user_123". Crea un objeto call y comienza la ejecución de db.GetUser.
  2. Las otras 9 goroutines llegan mientras db.GetUser aún está en su time.Sleep. Al ver que ya existe una ejecución para esa clave, no ejecutan la función, sino que se bloquean esperando que el WaitGroup interno del primer call se libere.
  3. Cuando la primera goroutine termina, singleflight recupera el valor y el error, y los despierta a todas las demás.
  4. El valor de retorno shared es 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án shared = 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.

218

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio