`lua-resty-*` Encapsulação Liberta Desenvolvedores de Cache Multinível

API7.ai

December 30, 2022

OpenResty (NGINX + Lua)

Nos dois artigos anteriores, aprendemos sobre o cache no OpenResty e o problema de "cache stampede", que são aspectos básicos. No desenvolvimento real de projetos, os desenvolvedores preferem uma biblioteca pronta para uso, com todos os detalhes tratados e ocultos, que possa ser usada diretamente para desenvolver o código de negócios.

Essa é uma vantagem da divisão de trabalho: os desenvolvedores de componentes básicos se concentram em arquitetura flexível, bom desempenho e estabilidade do código, sem se preocupar com a lógica de negócios de nível superior; enquanto os engenheiros de aplicação se preocupam mais com a implementação do negócio e iteração rápida, esperando não se distrair com vários detalhes técnicos da camada inferior. A lacuna entre esses dois grupos pode ser preenchida por bibliotecas de encapsulamento.

O cache no OpenResty enfrenta o mesmo problema. O shared dict e os lru caches são estáveis e eficientes o suficiente, mas há muitos detalhes para lidar. A "última milha" para os engenheiros de desenvolvimento de aplicativos pode ser árdua sem algum encapsulamento útil. É aqui que a importância da comunidade entra em jogo. Uma comunidade ativa tomará a iniciativa de encontrar as lacunas e preenchê-las rapidamente.

lua-resty-memcached-shdict

Vamos voltar ao encapsulamento de cache. O lua-resty-memcached-shdict é um projeto oficial do OpenResty que usa o shared dict para fazer uma camada de encapsulamento para o memcached, lidando com detalhes como o problema de "cache stampede" e dados expirados. Se os seus dados em cache estiverem armazenados no memcached no backend, você pode tentar usar esta biblioteca.

É uma biblioteca desenvolvida oficialmente pelo OpenResty, mas não está incluída no pacote do OpenResty por padrão. Se você quiser testá-la localmente, precisa baixar o código-fonte para o caminho de pesquisa local do OpenResty primeiro.

Esta biblioteca de encapsulamento é a mesma solução que mencionamos no artigo anterior. Ela usa o lua-resty-lock para ser mutuamente exclusiva. Em caso de falha no cache, apenas uma solicitação vai ao memcached buscar os dados e evita tempestades de cache. Os dados antigos são retornados ao endpoint se os dados mais recentes não forem obtidos.

No entanto, esta biblioteca lua-resty, embora seja um projeto oficial do OpenResty, não é perfeita:

  1. Primeiro, ela não tem cobertura de casos de teste, o que significa que a qualidade do código não pode ser consistentemente garantida.
  2. Segundo, ela expõe muitos parâmetros de interface, com 11 parâmetros obrigatórios e 7 opcionais.
local memc_fetch, memc_store =
    shdict_memc.gen_memc_methods{
        tag = "my memcached server tag",
        debug_logger = dlog,
        warn_logger = warn,
        error_logger = error_log,

        locks_shdict_name = "some_lua_shared_dict_name",

        shdict_set = meta_shdict_set,  
        shdict_get = meta_shdict_get,  

        disable_shdict = false,  -- optional, default false

        memc_host = "127.0.0.1",
        memc_port = 11211,
        memc_timeout = 200,  -- in ms
        memc_conn_pool_size = 5,
        memc_fetch_retries = 2,  -- optional, default 1
        memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)

        memc_conn_max_idle_time = 10 * 1000,  -- in ms, for in-pool connections,optional, default to nil

        memc_store_retries = 2,  -- optional, default to 1
        memc_store_retry_delay = 100,  -- in ms, optional, default to 100 (ms)

        store_ttl = 1,  -- in seconds, optional, default to 0 (i.e., never expires)
    }

A maioria dos parâmetros expostos pode ser simplificada por "criar um novo manipulador de memcached". A maneira atual de encapsular todos os parâmetros jogando-os para o usuário não é amigável, então eu daria as boas-vindas a desenvolvedores interessados em contribuir com PRs para otimizar isso.

Além disso, otimizações adicionais são mencionadas nas seguintes direções na documentação desta biblioteca de encapsulamento.

  1. Use lua-resty-lrucache para aumentar o cache no nível do Worker, em vez de apenas o cache no nível do server com shared dict.
  2. Use ngx.timer para fazer operações assíncronas de atualização de cache.

A primeira direção é uma sugestão muito boa, pois o desempenho do cache dentro do worker é melhor; a segunda sugestão é algo que você precisa considerar com base no seu cenário real. No entanto, eu geralmente não recomendo a segunda, não apenas porque há um limite para o número de timers, mas também porque, se a lógica de atualização aqui falhar, o cache nunca será atualizado novamente, o que tem um grande impacto.

lua-resty-mlcache

A seguir, vamos apresentar um encapsulamento de cache comumente usado no OpenResty: o lua-resty-mlcache, que usa shared dict e lua-resty-lrucache para implementar um mecanismo de cache de várias camadas. Vamos ver como esta biblioteca é usada nos dois exemplos de código a seguir.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("cache_name", "cache_dict", {
    lru_size = 500,    -- tamanho do cache L1 (Lua VM)
    ttl = 3600,   -- 1h de ttl para acertos
    neg_ttl  = 30,     -- 30s de ttl para falhas
})
if not cache then
    error("failed to create mlcache: " .. err)
end

Vamos olhar para o primeiro trecho de código. O início deste código introduz a biblioteca mlcache e define os parâmetros para inicialização. Normalmente, colocaríamos este código na fase init e só precisaríamos fazê-lo uma vez.

Além dos dois parâmetros obrigatórios, nome do cache e nome do dicionário, um terceiro parâmetro é um dicionário com 12 opções que são opcionais e usam valores padrão se não forem preenchidos. Isso é muito mais elegante do que o lua-resty-memcached-shdict. Se fôssemos projetar a interface nós mesmos, seria melhor adotar a abordagem do mlcache - manter a interface o mais simples possível, mantendo flexibilidade suficiente.

Aqui está o segundo trecho de código, que é o código lógico quando a solicitação é processada.

local function fetch_user(id)
    return db:query_user(id)
end

local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
    ngx.log(ngx.ERR , "failed to fetch user: ", err)
    return
end

if user then
    print(user.id) -- 123
end

Como você pode ver, o cache de várias camadas está oculto, então você precisa usar o objeto mlcache para buscar o cache e definir a função de retorno quando o cache expirar. A lógica complexa por trás disso pode ser completamente ocultada.

Você pode estar curioso sobre como esta biblioteca é implementada internamente. A seguir, vamos dar uma olhada na arquitetura e implementação desta biblioteca. A imagem a seguir é um slide de uma palestra dada por Thibault Charbonnier, o autor do mlcache, na OpenResty Con 2018.

Arquitetura do mlcache

Como você pode ver no diagrama, o mlcache divide os dados em três camadas, ou seja, L1, L2 e L3.

O cache L1 é o lua-resty-lrucache, onde cada Worker tem sua cópia, e com N Workers, há N cópias dos dados, então há redundância de dados. Como operar o lrucache dentro de um único Worker não aciona bloqueios, ele tem um desempenho superior e é adequado como um cache de primeiro nível.

O cache L2 é um shared dict. Todos os Workers compartilham uma única cópia dos dados em cache e consultarão o cache L2 se o cache L1 não for atingido. O ngx.shared.DICT fornece uma API que usa spinlocks para garantir a atomicidade das operações, então não precisamos nos preocupar com condições de corrida aqui.

O L3 é o caso em que o cache L2 também não é atingido, e a função de retorno precisa ser executada para consultar a fonte de dados, como um banco de dados externo, e então armazená-la em cache no L2. Aqui, para evitar tempestades de cache, ele usa lua-resty-lock para garantir que apenas um Worker vá à fonte de dados buscar os dados.

Do ponto de vista de uma solicitação:

  • Primeiro, ele consultará o cache L1 dentro do Worker e retornará diretamente se o L1 for atingido.
  • Se o L1 não for atingido ou o cache falhar, ele consultará o cache L2 entre os Workers. Se o L2 for atingido, ele retornará e armazenará o resultado em cache no L1.
  • Se o L2 também não for atingido ou o cache for invalidado, uma função de retorno será chamada para buscar os dados da fonte de dados e gravá-los no cache L2, que é a função da camada de dados L3.

Você também pode ver a partir deste processo que as atualizações de cache são acionadas passivamente por solicitações de endpoint. Mesmo que uma solicitação falhe ao buscar o cache, solicitações subsequentes ainda podem acionar a lógica de atualização para maximizar a segurança do cache.

No entanto, embora o mlcache tenha sido implementado de forma perfeita, ainda há um ponto de dor - a serialização e desserialização de dados. Isso não é um problema do mlcache, mas a diferença entre lrucache e shared dict, que mencionamos repetidamente. No lrucache, podemos armazenar vários tipos de dados Lua, incluindo table; mas no shared dict, só podemos armazenar strings.

O cache L1, o lrucache, é a camada de dados que os usuários tocam, e queremos armazenar em cache todos os tipos de dados nele, incluindo string, table, cdata, e assim por diante. O problema é que o L2 só pode armazenar strings, e quando os dados são elevados do L2 para o L1, precisamos fazer uma camada de conversão de strings para tipos de dados que possamos entregar diretamente ao usuário.

Felizmente, o mlcache levou essa situação em consideração e fornece funções opcionais l1_serializer nas interfaces new e get, especificamente projetadas para lidar com o processamento de dados quando o L2 é elevado para o L1. Podemos ver o seguinte código de exemplo, que extraí do meu conjunto de casos de teste.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
    return i + 2
end,
})

local function callback()
    return 123456
end

local data = assert(cache:get("number", nil, callback))
assert(data == 123458)

Deixe-me explicar rapidamente. Neste caso, a função de retorno retorna o número 123456; no new, a função l1_serializer que definimos adicionará 2 ao número recebido antes de definir o cache L1, que se torna 123458. Com uma função de serialização como essa, os dados podem ser mais flexíveis ao converter entre L1 e L2.

Resumo

Com várias camadas de cache, o desempenho do lado do servidor pode ser maximizado, e muitos detalhes estão ocultos entre elas. Neste ponto, uma biblioteca de encapsulamento estável e eficiente nos poupa muito esforço. Eu também espero que essas duas bibliotecas de encapsulamento apresentadas hoje ajudem você a entender melhor o cache.

Por fim, pense nesta questão: A camada de dicionário compartilhado do cache é necessária? É possível usar apenas o lrucache? Sinta-se à vontade para deixar um comentário e compartilhar sua opinião comigo, e você também é bem-vindo a compartilhar este artigo com mais pessoas para se comunicar e progredir juntos.