Chaves para Alto Desempenho: `shared dict` e Cache `lru`
API7.ai
December 22, 2022
No artigo anterior, apresentei as técnicas de otimização do OpenResty e as ferramentas de ajuste de desempenho, que envolvem string
, table
, Lua API
, LuaJIT
, SystemTap
, gráficos de chama
, etc.
Esses são os pilares da otimização de sistemas, e você precisa dominá-los bem. No entanto, apenas conhecê-los não é suficiente para enfrentar cenários reais de negócios. Em um cenário de negócios mais complexo, manter alto desempenho é um trabalho sistemático, não apenas otimização de código e nível de gateway. Envolverá vários aspectos, como banco de dados, rede, protocolo, cache, disco, etc., que é o significado da existência de um arquiteto.
No artigo de hoje, vamos dar uma olhada no componente que desempenha um papel muito crítico na otimização de desempenho - o cache, e ver como ele é usado e otimizado no OpenResty.
Cache
No nível de hardware, a maioria dos hardwares de computador usa caches para aumentar a velocidade. Por exemplo, CPUs têm caches de vários níveis, e cartões RAID têm caches de leitura e escrita. No nível de software, o banco de dados que usamos é um exemplo muito bom de design de cache. Existem caches na otimização de instruções SQL, design de índices e leitura e escrita em disco.
Aqui, sugiro que você aprenda sobre os vários mecanismos de cache do MySQL antes de projetar seu próprio cache. O material que recomendo é o excelente livro High Performance MySQL: Optimization, Backups, and Replication. Quando eu era responsável pelo banco de dados há muitos anos, me beneficiei muito desse livro, e muitos outros cenários de otimização posteriormente também se inspiraram no design do MySQL.
Voltando ao cache, sabemos que um sistema de cache em um ambiente de produção precisa encontrar a melhor solução com base em seus cenários de negócios e gargalos do sistema. É uma arte de equilíbrio.
Em geral, o cache tem dois princípios.
- Um é que quanto mais próximo da solicitação do usuário, melhor. Por exemplo, não envie solicitações HTTP se puder usar um cache local. Envie para o site de origem se puder usar CDN, e não envie para o banco de dados se puder usar o cache do OpenResty.
- O segundo é tentar usar esse processo e o cache local para resolvê-lo. Porque entre processos, máquinas e até salas de servidores, a sobrecarga de rede do cache será muito grande, o que será muito evidente em cenários de alta concorrência.
No OpenResty, o design e o uso do cache também seguem esses dois princípios. Existem dois componentes de cache no OpenResty: cache shared dict
e cache lru
. O primeiro só pode armazenar objetos de string, e há apenas uma cópia dos dados em cache, que pode ser acessada por cada worker, por isso é frequentemente usado para comunicação de dados entre workers. O último pode armazenar em cache todos os objetos Lua, mas eles só podem ser acessados dentro de um único processo worker. Há tantos dados em cache quanto workers.
As duas tabelas simples a seguir podem ilustrar a diferença entre shared dict
e cache lru
:
Nome do componente de cache | Escopo de acesso | Tipo de dados em cache | Estrutura de dados | Dados obsoletos podem ser obtidos | Número de APIs | Uso de memória |
---|---|---|---|---|---|---|
shared dict | Entre vários workers | objetos string | dict,queue | sim | 20+ | um pedaço de dados |
lru cache | dentro de um único worker | todos os objetos Lua | dict | não | 4 | n cópias de dados (N = número de workers) |
shared dict
e cache lru
não são bons ou ruins. Eles devem ser usados juntos de acordo com o seu cenário.
- Se você não precisar compartilhar dados entre workers, então
lru
pode armazenar em cache tipos de dados complexos, como arrays e funções, e tem o maior desempenho, por isso é a primeira escolha. - Mas se você precisar compartilhar dados entre workers, pode adicionar um cache
shared dict
com base no cachelru
para formar uma arquitetura de cache de dois níveis.
A seguir, vamos dar uma olhada detalhada nessas duas formas de cache.
Cache Shared dict
No artigo sobre Lua, fizemos uma introdução específica ao shared dict
, aqui está uma breve revisão de seu uso:
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
Você precisa declarar a zona de memória dogs
no arquivo de configuração do NGINX com antecedência, e então ela pode ser usada no código Lua. Se você descobrir que o espaço alocado para dogs não é suficiente durante o uso, precisará modificar o arquivo de configuração do NGINX primeiro e, em seguida, recarregar o NGINX para que entre em vigor. Porque não podemos expandir e reduzir em tempo de execução.
A seguir, vamos nos concentrar em várias questões relacionadas ao desempenho no cache de shared dict.
Serialização de dados em cache
O primeiro problema é a serialização de dados em cache. Como apenas objetos string
podem ser armazenados em cache no shared dict
, se você quiser armazenar um array em cache, deve serializar uma vez ao definir e desserializar uma vez ao obter:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", require("cjson").encode({a=111}))
print(require("cjson").decode(dict:get("Tom")).a)'
No entanto, essas operações de serialização e desserialização são muito intensivas em CPU. Se tantas operações forem por solicitação, você pode ver seu consumo no gráfico de chama.
Então, como evitar esse consumo em dicionários compartilhados? Não há uma boa maneira aqui, ou evitar colocar o array no dicionário compartilhado no nível de negócios; ou concatenar manualmente as strings no formato JSON por conta própria. Claro, isso também trará consumo de desempenho de concatenação de strings e pode ocultar mais bugs.
A maior parte da serialização pode ser desmontada no nível de negócios. Você pode desmontar o conteúdo do array e armazená-lo no dicionário compartilhado como strings. Se não funcionar, você também pode armazenar o array em cache no lru
, e usar o espaço de memória em troca da conveniência e desempenho do programa.
Além disso, a chave no cache também deve ser o mais curta e significativa possível, economizando espaço e facilitando a depuração posterior.
Dados obsoletos
Há também um método get_stale
para ler dados no shared dict
. Comparado com o método get
, ele tem um valor de retorno adicional para dados expirados:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56, 0.01)
ngx.sleep(0.02)
local val, flags, stale = dict:get_stale("Tom")
print(val)'
No exemplo acima, os dados são armazenados em cache no shared dict
por apenas 0.01
segundos, e os dados expiraram 0.02
segundos após a definição. Nesse momento, os dados não serão obtidos através da interface get
, mas dados expirados também podem ser obtidos através de get_stale
. A razão pela qual uso a palavra "possível" aqui é porque o espaço ocupado por dados expirados tem uma certa chance de ser reciclado e então usado para outros dados. Esse é o algoritmo LRU
.
Vendo isso, você pode ter dúvidas: qual é a utilidade de obter dados expirados? Não se esqueça de que o que armazenamos no shared dict
são dados em cache. Mesmo que os dados em cache expirem, isso não significa que os dados de origem devem ser atualizados.
Por exemplo, a fonte de dados é armazenada no MySQL. Depois que obtemos os dados do MySQL, definimos um tempo limite de cinco segundos no shared dict
. Então, quando os dados expiram, temos duas opções:
- Quando os dados não existem, vá ao MySQL para consultar novamente e coloque o resultado no cache.
- Determine se os dados do MySQL mudaram. Se não houver mudança, leia os dados expirados no cache, modifique seu tempo de expiração e faça com que continue em vigor.
A última é uma solução mais otimizada que pode interagir com o MySQL o mínimo possível, para que todas as solicitações dos clientes obtenham dados do cache mais rápido.
Nesse momento, como determinar se os dados na fonte de dados mudaram se torna um problema que precisamos considerar e resolver. A seguir, vamos tomar o cache lru
como exemplo para ver como um projeto real resolve esse problema.
Cache lru
Há apenas 5 interfaces para o cache lru
: new
, set
, get
, delete
e flush_all
. Apenas a interface get
está relacionada ao problema acima. Vamos primeiro entender como essa interface é usada:
resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'
Você pode ver que no cache lru
, o segundo valor de retorno da interface get é diretamente stale_data
, em vez de ser dividido em duas APIs diferentes, get
e get_stale
, como no shared dict
. Tal encapsulamento de interface é mais amigável para o uso de dados expirados.
Geralmente recomendamos usar números de versão para distinguir diferentes dados em projetos reais. Dessa forma, seu número de versão também mudará após a mudança dos dados. Por exemplo, um índice modificado no etcd pode ser usado como um número de versão para marcar se os dados mudaram. Com o conceito do número de versão, podemos fazer um encapsulamento secundário simples do cache lru
. Por exemplo, veja o seguinte pseudocódigo, retirado de lrucache
local function (key, version, create_obj_fun, ...)
local obj, stale_obj = lru_obj:get(key)
-- Se os dados não expiraram e a versão não mudou, retorne os dados em cache diretamente
if obj and obj._cache_ver == version then
return obj
end
-- Se os dados expiraram, mas ainda podem ser obtidos, e a versão não mudou, retorne diretamente os dados expirados no cache
if stale_obj and stale_obj._cache_ver == version then
lru_obj:set(key, obj, item_ttl)
return stale_obj
end
-- Se nenhum dado expirado for encontrado, ou o número de versão mudou, obtenha os dados da fonte de dados
local obj, err = create_obj_fun(...)
obj._cache_ver = version
lru_obj:set(key, obj, item_ttl)
return obj, err
end
A partir desse código, você pode ver que, ao introduzir o conceito do número de versão, usamos totalmente dados expirados para reduzir a pressão na fonte de dados e alcançar o desempenho ideal quando o número de versão não muda.
Além disso, na solução acima, há uma grande otimização potencial em que separamos a chave e o número de versão e usamos o número de versão como um atributo do valor.
Sabemos que a abordagem mais convencional é escrever o número de versão na chave. Por exemplo, o valor da chave é key_1234
. Essa prática é muito comum, mas no ambiente OpenResty, isso é um desperdício. Por que você diz isso?
Dê um exemplo, e você entenderá. Se o número de versão mudar a cada minuto, então key_1234
se tornará key_1235
após um minuto, e 60 chaves diferentes e 60 valores serão regenerados em uma hora. Isso também significa que o Lua GC precisa reciclar os objetos Lua por trás de 59 pares de chave-valor. A criação de objetos e o GC consumirão mais recursos se você atualizar com mais frequência.
Claro, esses consumos também podem ser evitados simplesmente movendo o número de versão da chave para o valor. Não importa com que frequência uma chave seja atualizada, apenas dois objetos Lua fixos existirão. Pode-se ver que tais técnicas de otimização são muito engenhosas. No entanto, por trás de técnicas simples e engenhosas, você precisa entender profundamente a API e o mecanismo de cache do OpenResty.
Resumo
Embora a documentação do OpenResty seja relativamente detalhada, você precisa experimentar e compreender como combiná-la com o negócio para produzir o maior efeito de otimização. Em muitos casos, há apenas uma ou duas frases no documento, como dados obsoletos, mas isso fará uma enorme diferença de desempenho.
Então, você já teve uma experiência semelhante ao usar o OpenResty? Sinta-se à vontade para deixar um comentário para compartilhar conosco, e você é bem-vindo para compartilhar este artigo, vamos aprender e progredir juntos.