un

guest
1 / ?
back to lessons

El Patrón de los Cuatro Pasos

El defecto está en cuatro pasos secuenciales, no atómicos:

// DEFECTO
Value value = cache.get(key);
if (value == null) {
    value = expensiveCompute(key);
    cache.put(key, value);
}
return value;

Paso 1: comprobar la caché. Paso 2: fallo. Paso 3: computar. Paso 4: almacenar. Los cuatro pasos: no atómicos. Entre el paso 1 y el paso 4, cualquier número de hilos puede ejecutar el paso 1 y verán todos un valor nulo.

La Trampa de la Idempotencia

La razón que protege este patrón: 'es correcto que dos hilos realicen el cálculo y la almacenación del mismo valor. El resultado es idempotente. No se produce corrupción de datos.'

Esta razón: correcta en cuanto a la corrección. Fatal en cuanto al costo.

A 1.000 hilos en un fallo de caché: 1.000 hilos que ejecutan expensiveCompute(key). Si expensiveCompute consulta una base de datos, se dispararán 1.000 consultas de base de datos simultáneamente. Si llama a un servicio externo, se dispararán 1.000 solicitudes HTTP simultáneamente. El sistema que produce resultados correctos se derrumba bajo el costo de producirlos.

Tres Desencadenantes

Una Manada Trueno se dispara cuando una clave de caché pasa de cálida a fría simultáneamente en muchos hilos:

Inicio frío: reinicio del servicio con una caché vacía. Primera ola de solicitudes: todas las claves fallan. Todos computan simultáneamente.

Reinicio de servicio: reinicio en rollo que resetea la caché en las instancias. El tráfico se redistribuye a instancias frías.

Vencimiento TTL: una clave de alto tráfico expira. N hilos todos revisan, todos fallan, todos computan antes de que el primer hilo almacene el resultado.

Los tres desencadenantes: correlacionados con picos de tráfico. La manada dispara cuando el tráfico alcanza su punto máximo y la caché está fría. El peor momento posible.

El Ejemplo de Elasticsearch EnrichCache

Elasticsearch EnrichCache: el comentario documentado dice 'intencionadamente no bloqueante por simplicidad... está bien si volvemos a poner la misma clave/valor en una condición de carrera'. A 10.000 documentos por segundo con una caché de enriquecimiento fría: todas las 10.000 solicitudes consultan el índice de enriquecimiento simultáneamente. El índice de enriquecimiento, diseñado para consultas ocasionales, se enfrenta a 10.000 consultas concurrentes. El clúster se desestabiliza.

La razón de la idempotencia: correcta en el comentario del código. Catastrófica a 10.000 documentos por segundo.

Relación con MOAD-0001

MOAD-0001 (Defecto Sedimentario) crea una botella de cuello de botella O(N²) en sistemas de alto rendimiento. Solucionar MOAD-0001 (O(N²) a O(N)) desbloquea esa estación de trabajo. El rendimiento más rápido envía más solicitudes aguas abajo. Los cachés aguas abajo, previamente protegidos por la botella de cuello de botella MOAD-0001, ahora reciben picos de tráfico correlacionados. MOAD-0005 dispara en cachés que nunca lo habían disparado antes. Corrije uno MOAD; etapa al otro.

¿Qué trampa de idempotencia tiene razón?

El comentario de Elasticsearch representa un razonamiento ingenieril cuidadoso aplicado a la pregunta equivocada. Idempotencia: una propiedad real digna de razonamiento. La trampa: detener el análisis en la corrección sin continuar con el costo.

¿Por qué la razón 'está bien que dos hilos escriban el mismo resultado' lleva al defecto? ¿Qué tiene razón y qué se pierde?

computeIfAbsent & singleflight

La solución: hacer el chequeo y el cálculo atómico. Un hilo calcula. Los otros hilos esperan el resultado de ese hilo.

Java: computeIfAbsent

// DEFECTO: cuatro pasos no atómicos
Valor valor = caché.get(clave);
if (valor == null) {
    valor = computación costosa(clave);
    caché.put(clave, valor);
}
return valor;

// SOLUCIÓN: verificación y cálculo atómico
return caché.computeIfAbsent(clave, k -> computación costosa(k));

computeIfAbsent: si la clave está ausente, se computa exactamente una vez, se almacena y se devuelve. Los demás hilos que llaman a computeIfAbsent con la misma clave esperan la primera computación. No se computa N veces. No hay estampida.

Go: singleflight.Group

var g singleflight.Group

func getOrCompute(key string) (Value, error) {
    v, err, _ := g.Do(key, func() (interface{}, error) {
        return expensiveCompute(key)
    })
    return v.(Value), err
}

singleflight: si ya se ejecuta una computación para la clave, todos los llamadores para la misma clave esperan y compartan el resultado único. Una computación, N esperando, un resultado compartido. La abstracción 'flight': desduplicar solicitudes en curso.

Bloque vs singleflight

Un bloque por clave ingenuo serializa: hilo 1 computa, hilo 2 espera, hilo 3 espera. Después de que hilo 1 termine, hilo 2 entra y verifica la caché (acierta). Hilo 3 entra y verifica la caché (acierta). N-1 adquisiciones de bloque y lecturas de caché.

singleflight desduplica: hilo 1 computa, hilos 2 a N todos esperan el resultado de hilo 1. Sin adquisiciones de bloque separadas. Sin lecturas de caché separadas. Una computación, un resultado, distribuido a N esperando. Menos operaciones que un bloque por clave.

Ambos previenen la estampida. singleflight previene el trabajo redundante más completamente.

Reescribir el Patrón

Aplica la solución a un escenario concreto.

// Una caché de perfiles de usuario en un servicio Java de alta traffic
public UserProfile getProfile(String userId) {
    UserProfile profile = profileCache.get(userId);
    if (profile == null) {
        profile = database.loadProfile(userId);  // caro: consulta de DB de 50ms
        profileCache.put(userId, profile);
    }
    return profile;
}

El servicio se reinicia todos los días a las 2 AM. A las 8 AM, 10,000 usuarios solicitan sus perfiles de manera simultánea.

Identifica el defecto, nombra cuándo dispara y reescribe usando computeIfAbsent.