English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

ospite
1 / ?
torna alle lezioni

Il pattern a quattro passi

Il difetto risiede in quattro passi sequenziali, non atomici:

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

Passo 1: controlla la cache. Passo 2: miss. Passo 3: calcola. Passo 4: memorizza. Tutti e quattro i passi: non atomici. Tra il passo 1 e il passo 4, un numero qualsiasi di thread può eseguire il passo 1 e tutti vedono null.

La trappola dell'idempotenza

Il ragionamento che protegge questo pattern: 'va bene se due thread calcolano e memorizzano lo stesso valore. Il risultato è idempotente. Non si verifica alcuna corruzione dei dati.'

Questo ragionamento: corretto sulla correttezza. Fatale sul costo.

A 1.000 thread in caso di cache miss: 1.000 thread eseguono ciascuno expensiveCompute(key). Se expensiveCompute interroga un database, vengono eseguite simultaneamente 1.000 query al database. Se chiama un servizio esterno, vengono effettuate simultaneamente 1.000 richieste HTTP. Il sistema che produce risultati corretti collassa sotto il costo di produrli.

Tre trigger

Un Thundering Herd si verifica quando una chiave della cache passa da warm a cold simultaneamente su molti thread:

Cold start: il servizio si riavvia con una cache vuota. Prima ondata di richieste: ogni chiave va in miss. Tutti calcolano simultaneamente.

Service restart: un rolling restart azzera la cache su tutte le istanze. Il traffico viene ridistribuito su istanze cold.

TTL expiry: una chiave ad alto traffico scade. N thread controllano tutti, tutti vanno in miss, tutti calcolano prima che il primo thread memorizzi il risultato.

Tutti e tre i trigger: correlati con picchi di traffico. Il gregge si attiva quando il traffico raggiunge il picco e la cache è fredda. Il momento peggiore possibile.

L'esempio di Elasticsearch EnrichCache

Elasticsearch EnrichCache: il commento nella documentazione recita 'intenzionalmente non bloccante per semplicità... va bene se riscriviamo la stessa chiave/valore in una race condition.' A 10.000 documenti al secondo con una cache di arricchimento fredda: tutte le 10.000 richieste colpiscono l'indice di arricchimento simultaneamente. L'indice di arricchimento, progettato per ricerche occasionali, si trova a gestire 10.000 query concorrenti. Il cluster diventa instabile.

Il ragionamento sull'idempotenza: corretto nel commento del codice. Catastrofico a 10.000 documenti al secondo.

Collegamento a MOAD-0001

MOAD-0001 (Difetto Sedimentario) crea un collo di bottiglia O(N²) nei sistemi ad alto throughput. Risolvere MOAD-0001 (da O(N²) a O(N)) sblocca quella workstation. Il throughput più veloce invia più richieste a valle. Le cache a valle, precedentemente protette dal collo di bottiglia MOAD-0001, ora ricevono picchi di traffico correlati. MOAD-0005 si attiva nelle cache che non l'avevano mai attivato prima. Correggi un MOAD; prepara l'altro.

Cosa sbaglia la trappola dell'idempotenza

Il commento di Elasticsearch rappresenta un ragionamento ingegneristico attento applicato alla domanda sbagliata. L'idempotenza: una proprietà reale su cui vale la pena ragionare. La trappola: fermare l'analisi alla correttezza senza proseguire con il costo.

Perché il ragionamento 'è OK se due thread scrivono lo stesso risultato' porta al difetto? Cosa coglie correttamente e cosa trascura?

computeIfAbsent & singleflight

La correzione: rendere atomico il controllo e il calcolo. Un thread calcola. Tutti gli altri thread attendono quel risultato.

Java: computeIfAbsent

// DIFETTO: quattro passi non atomici
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

// FIX: atomic check-and-compute
return cache.computeIfAbsent(key, k -> expensiveCompute(k));

computeIfAbsent: se la chiave è assente, calcola esattamente una volta, memorizza e restituisce. Tutti gli altri thread che chiamano computeIfAbsent per la stessa chiave attendono il primo calcolo. Nessun calcolo N-volte. Nessuna stampede.

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: se per una chiave è già in esecuzione un calcolo, tutti i chiamanti per la stessa chiave attendono e condividono il singolo risultato. Un solo calcolo, N chiamanti in attesa, un risultato condiviso. L'astrazione 'flight': deduplica le richieste in-flight.

Lock vs singleflight

Un lock ingenuo per chiave serializza: thread 1 calcola, thread 2 attende, thread 3 attende. Dopo che thread 1 termina, thread 2 entra e controlla la cache (hit). Thread 3 entra e controlla la cache (hit). N-1 acquisizioni di lock e letture della cache.

singleflight deduplica: thread 1 calcola, i thread da 2 a N attendono tutti il risultato di thread 1. Nessuna acquisizione separata di lock. Nessuna lettura separata della cache. Un solo calcolo, un solo risultato, distribuito a N chiamanti in attesa. Meno operazioni rispetto a un lock per chiave.

Entrambi prevengono lo stampede. singleflight previene il lavoro ridondante in modo più completo.

Riscrivi il Pattern

Applica la correzione a uno scenario concreto.

// Una cache del profilo utente in un servizio Java ad alto traffico
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId);  // costoso: query DB da 50ms
profileCache.put(userId, profile);
}
return profile;
}

Il servizio si riavvia ogni mattina alle 2:00. Alle 8:00, 10.000 utenti richiedono simultaneamente i propri profili.

Identifica il difetto, indica quando si manifesta e riscrivi il codice usando computeIfAbsent.