OpenResty FAQ | Carga Dinâmica, NYI e Cache de Shared Dict
API7.ai
January 19, 2023
A série de artigos sobre OpenResty foi atualizada até agora, e a parte sobre otimização de desempenho é tudo o que aprendemos. Parabéns por não ficar para trás, continuar aprendendo e praticando ativamente, e por deixar seus pensamentos com entusiasmo.
Coletamos muitas perguntas mais típicas e interessantes, e aqui estão cinco delas.
Pergunta 1: Como faço para realizar o carregamento dinâmico de módulos Lua?
Descrição: Tenho uma dúvida sobre o carregamento dinâmico implementado no OpenResty. Como posso usar a função
loadstring
para terminar o carregamento de um novo arquivo após ele ter sido substituído? Entendo queloadstring
só pode carregar strings, então, se eu quiser recarregar um arquivo/módulo Lua, como posso fazer isso no OpenResty?
Como sabemos, loadstring
é usado para carregar uma string, enquanto loadfile
pode carregar um arquivo especificado, por exemplo: loadfile("foo.lua")
. Esses dois comandos alcançam o mesmo resultado. Quanto ao carregamento de módulos Lua, aqui está um exemplo:
resty -e 'local s = [[
local ngx = ngx
local _M = {}
function _M.f()
ngx.say("hello world")
end
return _M
]]
local lua = loadstring(s)
local ret, func = pcall(lua)
func.f()'
O conteúdo da string s
é um módulo Lua completo. Então, quando você encontrar uma mudança no código desse módulo, pode reiniciar o carregamento com loadstring
ou loadfile
. Dessa forma, as funções e variáveis nele serão atualizadas junto.
Para ir um passo além, você também pode envolver a obtenção de mudanças e o recarregamento com uma camada chamada função code_loader
.
local func = code_loader(name)
Isso torna as atualizações de código muito mais concisas. Ao mesmo tempo, code_loader
geralmente usa lru cache
para armazenar s
em cache, evitando chamar loadstring
toda vez.
Pergunta 2: Por que o OpenResty não proíbe operações bloqueantes?
Descrição: Ao longo dos anos, sempre me perguntei, já que essas chamadas bloqueantes são desencorajadas oficialmente, por que não simplesmente desativá-las? Ou adicionar uma flag para permitir que o usuário escolha desativá-las?
Aqui está minha opinião pessoal. Primeiro, porque o ecossistema em torno do OpenResty não é perfeito, às vezes temos que chamar bibliotecas bloqueantes para implementar algumas funcionalidades. Por exemplo, antes da versão 1.15.8, você tinha que usar a biblioteca Lua os.execute
em vez de lua-resty-shell
para chamar comandos externos. Por exemplo, no OpenResty, a leitura e escrita de arquivos ainda só é possível com a biblioteca de I/O do Lua, e não há uma alternativa não bloqueante.
Em segundo lugar, o OpenResty é muito cauteloso com tais otimizações. Por exemplo, lua-resty-core
foi desenvolvido por muito tempo, mas nunca foi ativado por padrão, exigindo que você chamasse manualmente require 'resty.core'
. Ele só foi ativado até o lançamento mais recente, o 1.15.8.
Finalmente, os mantenedores do OpenResty preferem padronizar chamadas bloqueantes gerando automaticamente código Lua altamente otimizado através do compilador e DSL. Portanto, não há esforço para fazer algo como opções de flag na própria plataforma OpenResty. Claro, não tenho certeza se essa direção pode resolver o problema.
Do ponto de vista de um desenvolvedor externo, o problema mais prático é como evitar tais bloqueios. Podemos estender ferramentas de detecção de código Lua, como luacheck
, para encontrar e alertar sobre operações bloqueantes comuns, ou podemos desativar ou reescrever intrusivamente certas funções diretamente reescrevendo _G
, por exemplo:
resty -e '_G.ngx.print = function()
ngx.say("hello")
end
ngx.print()'
# hello
Com este código de exemplo, você pode reescrever diretamente a função ngx.print
.
Pergunta 3: A operação de NYI do LuaJIT tem um grande impacto no desempenho?
Descrição:
loadstring
mostranever
na lista NYI do LuaJIT. Isso terá um grande impacto no desempenho?
Em relação ao NYI do LuaJIT, não precisamos ser muito rigorosos. Para operações que podem ser JIT, a abordagem JIT é naturalmente a melhor; mas para operações que ainda não podem ser JIT, podemos continuar a usá-las.
Para otimização de desempenho, precisamos adotar uma abordagem científica baseada em estatísticas, que é o que o gráfico de chamas faz. A otimização prematura é a raiz de todo mal. Só precisamos otimizar o código quente que faz muitas chamadas e consome muita CPU.
Voltando ao loadstring
, só o chamaremos para recarregar quando o código mudar, não a cada requisição, então não é uma operação frequente. Nesse ponto, não precisamos nos preocupar com seu impacto no desempenho geral do sistema.
Em conjunto com o segundo problema de bloqueio, no OpenResty, às vezes também invocamos operações de I/O de arquivo bloqueantes durante as fases init
e init worker
. Essa operação é mais prejudicial ao desempenho do que o NYI, mas como é realizada apenas uma vez quando o serviço é iniciado, é aceitável.
Como sempre, a otimização de desempenho deve ser vista de uma perspectiva macro, um ponto que você precisa prestar atenção. Caso contrário, ao se preocupar excessivamente com um detalhe específico, você provavelmente passará muito tempo otimizando sem obter um bom efeito.
Pergunta 4: Posso implementar o upstream dinâmico por conta própria?
Descrição: Para o upstream dinâmico, minha abordagem é configurar 2 upstreams para um serviço, selecionar diferentes upstreams de acordo com as condições de roteamento e modificar diretamente o IP no upstream quando o IP da máquina mudar. Há alguma desvantagem ou armadilha nessa abordagem em comparação com o uso direto de
balancer_by_lua
?
A vantagem do balancer_by_lua
é que ele permite que o usuário escolha o algoritmo de balanceamento de carga, por exemplo, se deve usar roundrobin
ou chash
, ou qualquer outro algoritmo que o usuário implementar, o que é flexível e de alto desempenho.
Se você fizer isso da maneira das regras de roteamento, o resultado é o mesmo. Mas a verificação de saúde do upstream precisa ser implementada por você, adicionando muito trabalho extra.
Podemos expandir essa pergunta perguntando como devemos implementar esse cenário para abtest
, que requer um upstream diferente.
Você pode decidir qual upstream usar na fase balancer_by_lua
com base em uri
, host
, parâmetros
, etc. Você também pode usar gateways de API para transformar essas decisões em regras de roteamento, decidindo qual rota usar na fase inicial access
e, em seguida, encontrar o upstream especificado através da relação de vinculação entre a rota e o upstream. Essa é uma abordagem comum de gateways de API, e falaremos mais especificamente sobre isso mais tarde na seção prática.
Pergunta 5: O cache de shared dict
é obrigatório?
Descrição:
Em aplicações de produção reais, acho que a camada de cache do
shared dict
é obrigatória. Parece que todos só se lembram das vantagens dolru cache
, sem restrições de formato de dados, sem necessidade de desserialização, sem necessidade de calcular espaço de memória com base no volume de k/v, sem contenção entre workers, sem bloqueios de leitura/escrita e alto desempenho.No entanto, não ignore que uma de suas fraquezas mais fatais é que o ciclo de vida do
lru cache
segue oWorker
. Sempre que o NGINX recarrega, essa parte do cache será completamente perdida, e, nesse momento, se não houvershared dict
, a fonte de dadosL3
será sobrecarregada em minutos.Claro, esse é o caso de maior concorrência, mas, como o cache é usado, o volume de negócios certamente não é pequeno, o que significa que a análise mencionada ainda se aplica. Se eu estiver certo nessa visão?
Em alguns casos, é verdade que, como você disse, o shared dict
não é perdido durante o recarregamento, então é necessário. Mas há um caso particular onde apenas o lru cache
é aceitável se todos os dados estiverem disponíveis ativamente da fonte de dados L3
na fase init
ou init_worker
.
Por exemplo, se o gateway de API de código aberto APISIX tiver sua fonte de dados em etcd
, ele só buscará dados do etcd
. Ele armazena em cache no lru cache
durante a fase init_worker
, e as atualizações de cache posteriores são buscadas ativamente através do mecanismo watch
do etcd
. Dessa forma, mesmo que o NGINX recarregue, não haverá corrida de cache.
Portanto, podemos ter preferências na escolha de tecnologia, mas não generalize absolutamente porque não há uma solução única que se encaixe em todos os cenários de cache. É uma excelente maneira de construir uma solução mínima viável de acordo com as necessidades do cenário real e, em seguida, aumentá-la gradualmente.