O Padrão de Quatro Passos
O defeito reside em quatro passos sequenciais, não atômicos:
// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
Etapa 1: verificar cache. Etapa 2: falha. Etapa 3: computar. Etapa 4: armazenar. Todas as quatro etapas: não atômicas. Entre a etapa 1 e a etapa 4, qualquer número de threads pode executar a etapa 1 e todos veem null.
A Armadilha da Idempotência
O raciocínio que protege este padrão: 'é OK se duas threads calcularem e armazenarem o mesmo valor. O resultado é idempotente. Não ocorre corrupção de dados.'
Este raciocínio: correto quanto à correção. Fatal quanto ao custo.
Com 1.000 threads em um cache miss: 1.000 threads executam expensiveCompute(key). Se expensiveCompute consulta um banco de dados, 1.000 consultas ao banco de dados são disparadas simultaneamente. Se chama um serviço externo, 1.000 requisições HTTP são disparadas simultaneamente. O sistema que produz resultados corretos colapsa sob o custo de produzi-los.
Três Gatilhos
Um Thundering Herd ocorre quando uma chave de cache transita de quente para fria simultaneamente em muitas threads:
Cold start: o serviço reinicia com um cache vazio. Primeira onda de requisições: todas as chaves resultam em miss. Todos os cálculos ocorrem simultaneamente.
Service restart: reinício gradual reseta o cache em todas as instâncias. O tráfego é redistribuído para instâncias frias.
TTL expiry: uma chave de alto tráfego expira. N threads verificam, todas resultam em miss, todas calculam antes que a primeira thread armazene o resultado.
Todos os três gatilhos: correlacionados com picos de tráfego. O rebanho dispara quando o tráfego atinge o pico e o cache está frio. O pior momento possível.
O Exemplo do EnrichCache do Elasticsearch
Elasticsearch EnrichCache: o comentário documentado diz 'intencionalmente sem bloqueio por simplicidade... OK se recolocarmos a mesma chave/valor em uma condição de corrida.' A 10.000 documentos por segundo com um enrich cache frio: todas as 10.000 requisições atingem o enrich index simultaneamente. O enrich index, projetado para consultas ocasionais, enfrenta 10.000 consultas concorrentes. O cluster desestabiliza.
O raciocínio de idempotência: correto no comentário do código. Catastrófico a 10.000 documentos por segundo.
Acoplamento ao MOAD-0001
MOAD-0001 (Sedimentary Defect) cria um gargalo O(N²) em sistemas de alta vazão. Corrigir MOAD-0001 (de O(N²) para O(N)) desbloqueia aquela estação de trabalho. A vazão mais rápida envia mais requisições downstream. Caches downstream, anteriormente protegidos pelo gargalo MOAD-0001, agora recebem picos de tráfego correlacionados. MOAD-0005 dispara em caches que nunca o haviam disparado antes. Corrija um MOAD; prepare o outro.
O Que a Armadilha da Idempotência Entende Errado
O comentário do Elasticsearch representa um raciocínio de engenharia cuidadoso aplicado à pergunta errada. Idempotência: uma propriedade real que vale a pena analisar. A armadilha: parar a análise na correção sem continuar até o custo.
computeIfAbsent & singleflight
A correção: tornar a verificação e o cálculo atômicos. Uma thread calcula. Todas as outras threads aguardam esse resultado.
Java: computeIfAbsent
// DEFECT: quatro passos não atômicos
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// CORREÇÃO: verificação e cálculo atômicos
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent: se a chave estiver ausente, calcula exatamente uma vez, armazena e retorna. Todas as outras threads que chamam computeIfAbsent para a mesma chave aguardam o primeiro cálculo. Sem computação N vezes. Sem 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 um cálculo para uma chave já estiver em execução, todos os chamadores para a mesma chave aguardam e compartilham o resultado único. Um cálculo, N aguardadores, um resultado compartilhado. A abstração 'flight': deduplica requisições em andamento.
Lock vs singleflight
Um lock ingênuo por chave serializa: thread 1 calcula, thread 2 espera, thread 3 espera. Após a thread 1 terminar, a thread 2 entra e verifica o cache (acerto). A thread 3 entra e verifica o cache (acerto). N-1 aquisições de lock e leituras de cache.
singleflight deduplica: thread 1 calcula, threads 2 até N aguardam o resultado da thread 1. Sem aquisições separadas de lock. Sem leituras separadas de cache. Um cálculo, um resultado, distribuído para N aguardadores. Menos operações que um lock por chave.
Ambos previnem a stampede. singleflight previne trabalho redundante de forma mais completa.
Reescreva o Padrão
Aplique a correção a um cenário concreto.
// Um cache de perfil de usuário em um serviço Java de alto tráfego
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // caro: consulta DB de 50ms
profileCache.put(userId, profile);
}
return profile;
}
O serviço reinicia todas as manhãs às 2h. Às 8h, 10.000 usuários solicitam seus perfis simultaneamente.