un

guest
1 / ?
back to lessons

O Padão de Quatro Passos

O defeito vive em quatro etapas sequenciais, não atômicas:

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

Etapa 1: verifique o cache. Etapa 2: falha. Etapa 3: computação. Etapa 4: armazenamento. 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 vêem nulo.

A Armadilha da Idempotência

A razão que protege este padrão: 'é seguro se dois threads computarem e armazenarem o mesmo valor. O resultado é idempotente. Nenhuma corrupção de dados ocorre.'

Esta razão: correta sobre a corretude. Fatal sobre o custo.

A 1.000 threads em uma falha de cache: 1.000 threads executam cada uma expensiveCompute(key). Se expensiveCompute consultar um banco de dados, 1.000 consultas de banco de dados são disparadas simultaneamente. Se chamar um serviço externo, 1.000 solicitações HTTP são disparadas simultaneamente. O sistema que produz resultados corretos colapsa sob o custo de produzi-los.

Três Deslizadores

Um Rebanho Trovoado dispara quando uma chave de cache transita de quente para fria simultaneamente em vários threads:

Início frio: reinício do serviço com um cache vazio. Primeira onda de solicitações: todas as chaves falham. Todos computam ao mesmo tempo.

Reinício do serviço: reinício rolante que reseta o cache em instâncias. O tráfego se redistribui para instâncias frias.

TTL expiração: uma chave de alto tráfego expira. N threads todos verificam, todos falham, todos computam antes que a primeira thread armazene o resultado.

Todos os três deslizadores: correlacionados com picos de tráfego. O rebanho dispara quando o tráfego atinge o auge e o cache está frio. O pior momento possível.

O Exemplo de Elasticsearch EnrichCache

Elasticsearch EnrichCache: o comentário documentado lê 'intencionalmente não bloqueante para simplicidade... OK se regravarmos a mesma chave/valor em uma condição de corrida.' A 10.000 documentos por segundo com um cache de enriquecimento frio: todas as 10.000 solicitações acertam o índice de enriquecimento simultaneamente. O índice de enriquecimento, projetado para consultas ocasionais, enfrenta 10.000 consultas simultâneas. O cluster se destabiliza.

A razão da idempotência: correta no comentário do código. Catastrófica a 10.000 documentos por segundo.

Couplage à MOAD-0001

MOAD-0001 (Defeito Sedimentar) cria uma garganta O(N²) em sistemas de alto throughput. Corrigir MOAD-0001 (O(N²) para O(N)) desbloqueia essa estação de trabalho. O throughput mais rápido envia mais solicitações para baixo. Os caches downstream, anteriormente protegidos pela garganta MOAD-0001, agora recebem picos de tráfego correlacionados. O MOAD-0005 dispara em caches que nunca o acionaram antes. Corrija um MOAD; etape o outro.

O que a Armadilha de Impossibilidade de Idempotência Erra

O comentário do Elasticsearch representa uma razão engenhosa aplicada à questão errada. Idempotência: uma propriedade real digna de raciocínio. A armadilha: parar o análise na corretude sem continuar no custo.

Por que o raciocínio 'é OK se dois threads escreverem o mesmo resultado' leva ao defeito? O que ele tem certo e o que ele perde?

computeIfAbsent & singleflight

A solução: tornar a verificação e computação atômica. Um thread computa. Todos os outros threads esperam por esse resultado.

Java: computeIfAbsent

// DEFECT: quatro etapas não atômicas
Value value = cache.get(key);
if (value == null) {
    value = expensiveCompute(key);
    cache.put(key, value);
}
return value;

// FIX: verificação e computação atômica
return cache.computeIfAbsent(key, k -> expensiveCompute(k));

computeIfAbsent: se a chave estiver ausente, computa exatamente uma vez, armazena e retorna. Todos os outros threads que chamam computeIfAbsent para a mesma chave esperam pela primeira computação. Nenhuma computação N-plica. Nenhum atropelamento.

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 uma computação para a chave já estiver em execução, todos os chamadores para a mesma chave esperam e compartilham o único resultado. Uma computação, N esperando, um resultado compartilhado. A abstração 'flight': desduplicar solicitações em andamento.

Lock vs singleflight

Um lock por chave ingênuo serializa: thread 1 computa, thread 2 espera, thread 3 espera. Depois que o thread 1 termina, o thread 2 entra e verifica a cache (acerta). O thread 3 entra e verifica a cache (acerta). N-1 aquisições de trinco e leituras de cache.

singleflight desduplica: thread 1 computa, threads 2 a N todos esperam no resultado do thread 1. Nenhuma aquisição separada de trinco. Nenhuma leitura de cache separada. Uma computação, um resultado, distribuído para N esperando. Menos operações do que um lock por chave.

Ambos evitam o atropelamento. O singleflight evita o trabalho redundante mais completamente.

Reescreva o Padrao

Aplicar 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 é reiniciado todas as manhãs às 2h. Às 8h, 10.000 usuários solicitam seus perfis simultaneamente.

Identifique o defeito, nomeie quando dispara e reescreva usando computeIfAbsent.