Mágica de comunicação entre workers do NGINX: uma das estruturas de dados mais importantes, o `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

Como dissemos no artigo anterior, a table é a única estrutura de dados em Lua. Isso corresponde ao shared dict, que é a estrutura de dados mais importante que você pode usar na programação OpenResty. Ele suporta armazenamento de dados, leitura, contagem atômica e operações de fila.

Com base no shared dict, você pode implementar cache e comunicação entre múltiplos Workers, limitação de taxa, estatísticas de tráfego e outras funcionalidades. Você pode usar o shared dict como um Redis simples, exceto que os dados no shared dict não são persistentes, então você deve considerar a perda de dados armazenados.

Várias formas de compartilhamento de dados

Ao escrever o código Lua do OpenResty, você inevitavelmente encontrará o compartilhamento de dados entre diferentes Workers em diferentes fases da requisição. Você também pode precisar compartilhar dados entre código Lua e C.

Portanto, antes de introduzirmos formalmente as APIs do shared dict, vamos primeiro entender os métodos comuns de compartilhamento de dados no OpenResty e aprender como escolher um método de compartilhamento de dados mais apropriado de acordo com a situação atual.

O primeiro são as variáveis no NGINX. Elas podem compartilhar dados entre módulos C do NGINX. Naturalmente, também podem compartilhar dados entre módulos C e o lua-nginx-module fornecido pelo OpenResty, como no código abaixo.

location /foo {
     set $my_var ''; # esta linha é necessária para criar $my_var no tempo de configuração
     content_by_lua_block {
         ngx.var.my_var = 123;
         ...
     }
 }

No entanto, usar variáveis do NGINX para compartilhar dados é lento porque envolve pesquisas de hash e alocação de memória. Além disso, essa abordagem tem a limitação de que só pode ser usada para armazenar strings e não suporta tipos complexos de Lua.

O segundo é o ngx.ctx, que pode compartilhar dados entre diferentes fases da mesma requisição. É uma table Lua normal, então é rápida e pode armazenar vários objetos Lua. Seu ciclo de vida é de nível de requisição; quando a requisição termina, o ngx.ctx é destruído.

A seguir está um cenário de uso típico onde usamos o ngx.ctx para cache caro de chamadas como variáveis do NGINX e o usamos em várias etapas.

location /test {
     rewrite_by_lua_block {
         ngx.ctx.host = ngx.var.host
     }
     access_by_lua_block {
        if (ngx.ctx.host == 'api7.ai') then
            ngx.ctx.host = 'test.com'
        end
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.host)
     }
 }

Neste caso, se você usar curl para acessá-lo.

curl -i 127.0.0.1:8080/test -H 'host:api7.ai'

Ele então imprimirá test.com, mostrando que o ngx.ctx está compartilhando dados em diferentes estágios. Claro, você também pode modificar o exemplo acima salvando objetos mais complexos como tables em vez de strings simples para ver se atende às suas expectativas.

No entanto, uma observação especial aqui é que, como o ciclo de vida do ngx.ctx é de nível de requisição, ele não faz cache no nível do módulo. Por exemplo, eu cometi o erro de usar isso no meu arquivo foo.lua.

local ngx_ctx = ngx.ctx

local function bar()
    ngx_ctx.host =  'test.com'
end

Devemos chamar e fazer cache no nível da função.

local ngx = ngx

local function bar()
    ngx_ctx.host =  'test.com'
end

Há muitos mais detalhes sobre o ngx.ctx, que continuaremos a explorar mais tarde na seção de otimização de desempenho.

A terceira abordagem usa variáveis de nível de módulo para compartilhar dados entre todas as requisições dentro do mesmo Worker. Diferente das variáveis do NGINX e do ngx.ctx anteriores, essa abordagem é um pouco menos compreensível. Mas não se preocupe, o conceito é abstrato, e o código vem primeiro, então vamos ver um exemplo para entender uma variável de nível de módulo.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.get_age(name)
    return data[name]
end

return _M

A configuração no nginx.conf é a seguinte.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata.get_age("dog"))
     }
 }

Neste exemplo, mydata é um módulo que é carregado apenas uma vez pelo processo Worker, e todas as requisições processadas pelo Worker depois disso compartilham o código e os dados do módulo mydata.

Naturalmente, a variável data no módulo mydata é uma variável de nível de módulo localizada no topo do módulo, ou seja, no início do módulo, e é acessível a todas as funções.

Portanto, você pode colocar dados que precisam ser compartilhados entre requisições na variável de topo do módulo. No entanto, é essencial notar que geralmente só usamos essa maneira para armazenar dados somente leitura. Se operações de escrita estiverem envolvidas, você deve ter muito cuidado porque pode haver uma condição de corrida, que é um bug complicado de localizar.

Podemos experimentar isso com o seguinte exemplo mais simplificado.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.incr_age(name)
    data[name]  = data[name] + 1
    return data[name]
end

return _M

No módulo, adicionamos a função incr_age, que modifica os dados na tabela data.

Então, no código de chamada, adicionamos a linha mais crítica ngx.sleep(5), onde sleep é uma operação de yield.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata. incr_age("dog"))
         ngx.sleep(5) -- yield API
         ngx.say(mydata. incr_age("dog"))
     }
 }

Sem esta linha de código sleep (ou outras operações de IO não bloqueantes, como acessar Redis, etc.), não haveria operação de yield, não haveria competição, e a saída final seria sequencial.

Mas quando adicionamos esta linha de código, mesmo que seja apenas dentro de 5 segundos de sleep, outra requisição provavelmente chamará a função mydata.incr_age e modificará o valor da variável, causando assim que os números finais de saída sejam descontínuos. A lógica não é tão simples no código real, e o bug é muito mais difícil de localizar.

Portanto, a menos que você tenha certeza de que não há operação de yield no meio que dará controle ao loop de eventos do NGINX, recomendo manter suas variáveis de nível de módulo somente leitura.

A quarta e última abordagem usa o shared dict para compartilhar dados que podem ser compartilhados entre vários workers.

Essa abordagem é baseada em uma implementação de árvore rubro-negra, que tem um bom desempenho. No entanto, tem suas limitações: você deve declarar o tamanho da memória compartilhada no arquivo de configuração do NGINX antecipadamente, e isso não pode ser alterado em tempo de execução:

lua_shared_dict dogs 10m;

O shared dict também só armazena dados do tipo string e não suporta tipos de dados complexos do Lua. Isso significa que, quando eu precisar armazenar tipos de dados complexos como tables, terei que usar JSON ou outros métodos para serializar e desserializar, o que naturalmente causará uma grande perda de desempenho.

De qualquer forma, não há uma solução perfeita aqui, e não há uma maneira perfeita de compartilhar dados. Você deve combinar vários métodos de acordo com suas necessidades e cenários.

Shared dict

Passamos muito tempo aprendendo sobre a parte de compartilhamento de dados acima, e alguns de vocês podem se perguntar: parece que eles não estão diretamente relacionados ao shared dict. Isso não está fora do tópico?

Na verdade, não. Por favor, pense nisso: por que existe um shared dict no OpenResty? Lembre-se de que os três primeiros métodos de compartilhamento de dados são todos no nível de requisição ou no nível individual do Worker. Portanto, na implementação atual do OpenResty, apenas o shared dict pode realizar o compartilhamento de dados entre Workers, permitindo a comunicação entre Workers, que é o valor de sua existência.

Na minha opinião, entender por que a tecnologia existe e descobrir suas diferenças e vantagens em comparação com outras tecnologias semelhantes é muito mais importante do que apenas ser proficiente em chamar as APIs que ela fornece. Essa visão técnica lhe dá um grau de previsão e insight e é, sem dúvida, uma diferença importante entre engenheiros e arquitetos.

Voltando ao shared dict, que fornece mais de 20 APIs Lua ao público, todas atômicas, então você não precisa se preocupar com competição no caso de múltiplos Workers e alta concorrência.

Essas APIs têm documentação oficial detalhada, então não vou entrar em todas elas. Quero enfatizar novamente que nenhum curso técnico pode substituir uma leitura cuidadosa da documentação oficial. Ninguém pode pular esses procedimentos demorados e estúpidos.

A seguir, vamos continuar a olhar para as APIs do shared dict, que podem ser divididas em três categorias: leitura/escrita de dict, operação de fila e gerenciamento.

Leitura/escrita de dict

Vamos primeiro olhar para as classes de leitura e escrita de dict. Na versão original, havia apenas APIs para classes de leitura e escrita de dict, os recursos mais comuns de dicionários compartilhados. Aqui está o exemplo mais simples.

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                               dict:set("Tom", 56)
                               print(dict:get("Tom"))'

Além do set, o OpenResty também fornece quatro métodos de escrita: safe_set, add, safe_add e replace. O significado do prefixo safe aqui é que, se a memória estiver cheia, em vez de eliminar os dados antigos de acordo com o LRU, a escrita falhará e retornará o erro no memory.

Além do get, o OpenResty também fornece o método get_stale para leitura de dados, que tem um valor de retorno adicional para dados expirados em comparação com o método get.

value, flags, stale = ngx.shared.DICT:get_stale(key)

Você também pode chamar o método delete para excluir a chave especificada, o que equivale a set(key, nil).

Operação de fila

Passando para as operações de fila, é uma adição posterior ao OpenResty que fornece uma interface semelhante ao Redis. Cada elemento em uma fila é descrito por ngx_http_lua_shdict_list_node_t.

typedef struct {
    ngx_queue_t queue;
    uint32_t value_len;
    uint8_t value_type;
    u_char data[1];
} ngx_http_lua_shdict_list_node_t;

Eu postei o PR dessas APIs de fila no artigo. Se você estiver interessado nisso, pode seguir a documentação, casos de teste e código-fonte para analisar a implementação específica.

No entanto, não há exemplos de código correspondentes para as cinco APIs de fila seguintes na documentação, então vou apresentá-las brevemente aqui.

  • lpush``/``rpush significa adicionar elementos em ambas as extremidades da fila.
  • lpop``/``rpop, que remove elementos em ambas as extremidades da fila.
  • llen, que indica o número de elementos retornados para a fila.

Não vamos esquecer outra ferramenta útil que discutimos no último artigo: casos de teste. Geralmente podemos encontrar o código correspondente em um caso de teste se não estiver na documentação. Os testes relacionados à fila estão precisamente no arquivo 145-shdict-list.t.

=== TEST 1: lpush & lpop
--- http_config
    lua_shared_dict dogs 1m;
--- config
    location = /test {
        content_by_lua_block {
            local dogs = ngx.shared.dogs

            local len, err = dogs:lpush("foo", "bar")
            if len then
                ngx.say("push success")
            else
                ngx.say("push err: ", err)
            end

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
        }
    }
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]

Gerenciamento

A API final de gerenciamento também é uma adição posterior e é uma demanda popular na comunidade. Um dos exemplos mais típicos é o uso de memória compartilhada. Por exemplo, se um usuário solicitar 100M de espaço como um shared dict, esse 100M é suficiente? Quantas chaves estão armazenadas nele, e quais são elas? Essas são todas perguntas autênticas.

Para esse tipo de problema, o OpenResty oficial espera que os usuários usem gráficos de chama para resolvê-los, ou seja, de uma maneira não invasiva, mantendo a base de código eficiente e limpa, em vez de fornecer uma API invasiva para retornar os resultados diretamente.

Mas, de uma perspectiva amigável ao usuário, essas APIs de gerenciamento ainda são essenciais. Afinal, projetos de código aberto são projetados para resolver requisitos de produto, não para mostrar a tecnologia em si. Então, vamos olhar para as seguintes APIs de gerenciamento que serão adicionadas posteriormente.

Primeiro é get_keys(max_count?), que por padrão só retorna as primeiras 1024 chaves; se você definir max_count como 0, ele retornará todas as chaves. Depois vêm capacity e free_space, ambos parte do repositório lua-resty-core, então você precisa require antes de usá-los.

require "resty.core.shdict"

local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()

Eles retornam o tamanho da memória compartilhada (o tamanho configurado em lua_shared_dict) e o número de bytes de páginas livres. Como o shared dict é alocado por página, mesmo que free_space retorne 0, pode haver espaço nas páginas alocadas. Portanto, seu valor de retorno não representa quanto da memória compartilhada está ocupada.

Resumo

Na prática, frequentemente usamos cache de vários níveis, e o projeto oficial do OpenResty também tem um pacote de cache. Você consegue descobrir quais projetos são esses? Ou você conhece algumas outras bibliotecas lua-resty que encapsulam cache?

Você é bem-vindo a compartilhar este artigo com seus colegas e amigos para que possamos nos comunicar e melhorar juntos.