Como Evitar o Cache Stampede?

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

No artigo anterior, aprendemos algumas técnicas de otimização de alto desempenho com shared dict e lru cache. No entanto, deixamos de lado um problema significativo que merece seu próprio artigo hoje, o "Cache Stampede".

O que é um Cache Stampede?

Vamos imaginar um cenário.

A fonte de dados está em um banco de dados MySQL, os dados em cache estão em um shared dict, e o tempo de expiração é de 60 segundos. Durante os 60 segundos em que os dados estão no cache, todas as solicitações estão buscando os dados do cache em vez do MySQL. Mas, após 60 segundos, os dados em cache expiram. Se houver um grande número de solicitações simultâneas, nenhum dado no cache poderá ser consultado. Então, a função de consulta da fonte de dados será acionada, e todas essas solicitações irão para o banco de dados MySQL, o que causará diretamente o bloqueio ou até mesmo a queda do servidor de banco de dados.

Esse fenômeno pode ser chamado de "Cache Stampede", e às vezes é referido como Dog-Piling. Nenhum dos códigos relacionados a cache que apareceram nas seções anteriores tem um tratamento correspondente. Abaixo está um exemplo de pseudo-código que tem o potencial de causar um cache stampede.

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

O pseudo-código parece que a lógica está correta, e você não irá acionar um cache stampede usando testes unitários ou testes de ponta a ponta. Apenas um teste de estresse prolongado revelará o problema. A cada 60 segundos, o banco de dados terá um pico regular de consultas. Mas se você definir um tempo de expiração de cache mais longo aqui, as chances de o problema de cache stampede ser detectado são reduzidas.

Como evitar isso?

Vamos dividir a discussão em vários casos diferentes.

1. Atualização proativa do cache

No pseudo-código acima, o cache é atualizado passivamente e só vai ao banco de dados para consultar novos dados quando é solicitado, mas uma falha de cache é encontrada. Portanto, mudar a forma como o cache é atualizado de passivo para ativo pode contornar o problema de cache stampede.

No OpenResty, podemos implementar isso da seguinte forma.

Primeiro, usamos ngx.timer.every para criar uma tarefa de timer que é executada a cada minuto para buscar os dados mais recentes do banco de dados MySQL e colocá-los no shared dict:

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

Então, na lógica do código que processa a solicitação, precisamos remover a parte que consulta o MySQL e manter apenas a parte do código que obtém o cache do shared dict.

local value = get_from_cache(key)
return value

Os dois trechos de pseudo-código acima podem nos ajudar a contornar o problema de cache stampede. Mas essa abordagem não é perfeita, cada cache tem que corresponder a uma tarefa periódica (há um limite superior para o número de timers no OpenResty), e o tempo de expiração do cache e o tempo de ciclo da tarefa agendada têm que corresponder bem. Se houver algum erro durante esse período, a solicitação pode continuar obtendo dados vazios.

Portanto, em projetos reais, geralmente usamos bloqueios para resolver o problema de cache stampede. Aqui estão alguns métodos diferentes de bloqueio, você pode escolher o seu de acordo com suas necessidades.

2. lua-resty-lock

Quando se trata de adicionar bloqueios, você pode sentir dificuldade, pensando que é uma operação pesada, e o que acontece se ocorrer um deadlock e você tiver que lidar com várias exceções.

Podemos aliviar essa preocupação usando a biblioteca lua-resty-lock no OpenResty para adicionar bloqueios. lua-resty-lock é uma biblioteca resty do OpenResty, que é baseada em um shared dict e fornece uma API de bloqueio não bloqueante. Vamos ver um exemplo simples.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

Como lua-resty-lock é implementado usando um shared dict, precisamos declarar o nome e o tamanho do shdict primeiro e, em seguida, usar o método new para criar um novo objeto lock. No trecho de código acima, passamos apenas o primeiro parâmetro, o nome do shdict. O método new tem um segundo parâmetro, que pode ser usado para especificar o tempo de expiração, o tempo limite para o bloqueio e muitos outros parâmetros. Aqui mantemos os valores padrão. Esses parâmetros são usados para evitar deadlocks e outras exceções.

Então podemos chamar o método lock para tentar obter um bloqueio. Se conseguirmos adquirir o bloqueio, podemos garantir que apenas uma solicitação vá à fonte de dados para atualizar os dados no mesmo momento. Mas se o bloqueio falhar devido a preempção, tempo limite, etc., então os dados são obtidos do cache obsoleto e retornados ao solicitante. Isso nos leva à API get_stale introduzida na lição anterior.

local elapsed, err = lock:lock("my_key")
# elapsed to nil significa que o bloqueio falhou. O valor de retorno de err é um de timeout, locked
if not elapsed and err then
    dict:get_stale("my_key")
end

Se lock for bem-sucedido, então é seguro consultar o banco de dados e atualizar os resultados no cache, e finalmente chamamos a interface unlock para liberar o bloqueio.

Combinando lua-resty-lock e get_stale, temos a solução perfeita para o problema de cache stampede. A documentação de lua-resty-lock fornece um código muito completo para lidar com isso. Se estiver interessado, você pode conferir aqui.

Vamos nos aprofundar e ver como a interface lock implementa o bloqueio. Quando encontramos alguma implementação interessante, sempre queremos ver como ela é implementada no código-fonte, o que é uma das vantagens do código aberto.

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

Como mencionado no artigo sobre shared dict, todas as APIs de shared dict são operações atômicas e não há necessidade de se preocupar com contenção. Portanto, é uma boa ideia usar shared dict para marcar o estado dos bloqueios.

A implementação de lock acima usa dict:add para tentar definir a chave: se a chave não existir na memória compartilhada, add retornará sucesso, indicando que o bloqueio foi bem-sucedido; outras solicitações concorrentes retornarão falha quando atingirem a lógica da linha de código dict:add, e então o código pode escolher se retorna diretamente ou se tenta novamente várias vezes com base nas informações de err retornadas.

3. lua-resty-shcache

Na implementação acima de lua-resty-lock, você precisa lidar com bloqueio, desbloqueio, obtenção de dados expirados, novas tentativas, tratamento de exceções e outros problemas, o que ainda é bastante tedioso.

Aqui está um wrapper simples para você: lua-resty-shcache, que é uma biblioteca lua-resty da Cloudflare, ela faz uma camada de encapsulamento em cima de dicionários compartilhados e armazenamento externo e fornece funções adicionais para serialização e desserialização, para que você não precise se preocupar com os detalhes acima:

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- cache good data for 10s
          negative_ttl = 3,            -- cache failed lookup for 3s
          name = 'my_cache',     -- "named" cache, useful for debug / report
        }
    )

local my_table, from_cache = my_cache_table:load(key)

Este código de exemplo foi extraído do exemplo oficial e ocultou todos os detalhes. Esta biblioteca de encapsulamento de cache não é a melhor escolha, mas é um bom material de aprendizado para iniciantes. O próximo artigo apresentará algumas outras encapsulações melhores e mais comumente usadas.

4. Diretivas do NGINX

Se você não estiver usando a biblioteca lua-resty do OpenResty, também pode usar as diretivas de configuração do NGINX para bloqueio e obtenção de dados expirados: proxy_cache_lock e proxy_cache_use_stale. No entanto, não recomendamos o uso da diretiva NGINX aqui, pois ela não é flexível o suficiente, e seu desempenho não é tão bom quanto o código Lua.

Resumo

Cache stampedes, como o problema de corrida que mencionamos repetidamente antes, são difíceis de detectar por meio de revisões de código e testes. A melhor maneira de resolvê-los é melhorar sua codificação ou usar uma biblioteca de encapsulamento.

Uma última pergunta: Como você lida com cache stampede e similares nas linguagens e plataformas que você conhece? Existe uma maneira melhor do que o OpenResty? Sinta-se à vontade para compartilhar comigo nos comentários.