`lua-resty-*` Encapsulação Liberta Desenvolvedores de Cache Multinível
API7.ai
December 30, 2022
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:
- 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.
- 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.
- Use
lua-resty-lrucache
para aumentar o cache no nível doWorker
, em vez de apenas o cache no nível doserver
comshared dict
. - 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.
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
Worker
s, 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 Worker
s 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 doWorker
e retornará diretamente se oL1
for atingido. - Se o
L1
não for atingido ou o cache falhar, ele consultará o cacheL2
entre osWorker
s. Se oL2
for atingido, ele retornará e armazenará o resultado em cache noL1
. - 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 cacheL2
, que é a função da camada de dadosL3
.
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.