Pular para conteúdo

mlcache: Biblioteca de cache em camadas para nginx-module-lua

Instalação

Se você ainda não configurou a assinatura do repositório RPM, inscreva-se. Depois, você pode prosseguir com os seguintes passos.

CentOS/RHEL 7 ou Amazon Linux 2

yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install lua-resty-mlcache

CentOS/RHEL 8+, Fedora Linux, Amazon Linux 2023

dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install lua5.1-resty-mlcache

Para usar esta biblioteca Lua com NGINX, certifique-se de que o nginx-module-lua está instalado.

Este documento descreve lua-resty-mlcache v2.7.0 lançado em 14 de fevereiro de 2024.


CI

Cache em camadas rápido e automatizado para OpenResty.

Esta biblioteca pode ser manipulada como um armazenamento de cache de chave/valor para tipos escalares Lua e tabelas, combinando o poder da API [lua_shared_dict] e [lua-resty-lrucache], resultando em uma solução de cache extremamente performática e flexível.

Características:

  • Cache e cache negativo com TTLs.
  • Mutex embutido via [lua-resty-lock] para prevenir efeitos de pilha em seu banco de dados/backend em casos de falhas de cache.
  • Comunicação inter-trabalhadores embutida para propagar invalidações de cache e permitir que os trabalhadores atualizem seus caches L1 (lua-resty-lrucache) em caso de mudanças (set(), delete()).
  • Suporte para filas de cache de acertos e erros divididos.
  • Múltiplas instâncias isoladas podem ser criadas para armazenar vários tipos de dados enquanto dependem do mesmo cache L2 lua_shared_dict.

Ilustração dos vários níveis de cache incorporados nesta biblioteca:

┌─────────────────────────────────────────────────┐
│ Nginx                                           │
│       ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│       │worker     │ │worker     │ │worker     │ │
│ L1    │           │ │           │ │           │ │
│       │ Lua cache │ │ Lua cache │ │ Lua cache │ │
│       └───────────┘ └───────────┘ └───────────┘ │
│             │             │             │       │
│             ▼             ▼             ▼       │
│       ┌───────────────────────────────────────┐ │
│       │                                       │ │
│ L2    │           lua_shared_dict             │ │
│       │                                       │ │
│       └───────────────────────────────────────┘ │
│                           │ mutex               │
│                           ▼                     │
│                  ┌──────────────────┐           │
│                  │     callback     │           │
│                  └────────┬─────────┘           │
└───────────────────────────┼─────────────────────┘
                            │
  L3                        │   I/O fetch
                            ▼

                   Banco de dados, API, DNS, Disco, qualquer I/O...

A hierarquia dos níveis de cache é: - L1: Cache Lua VM de Menos Recentemente Usado usando [lua-resty-lrucache]. Proporciona a busca mais rápida se populado e evita esgotar a memória do VM Lua dos trabalhadores. - L2: Zona de memória lua_shared_dict compartilhada por todos os trabalhadores. Este nível é acessado apenas se L1 for um erro, e impede que os trabalhadores solicitem o cache L3. - L3: uma função personalizada que será executada apenas por um único trabalhador para evitar o efeito de pilha em seu banco de dados/backend (via [lua-resty-lock]). Valores buscados via L3 serão definidos no cache L2 para que outros trabalhadores possam recuperar.

Esta biblioteca foi apresentada na OpenResty Con 2018. Veja a seção Recursos para uma gravação da palestra.

Sinopse

## nginx.conf

http {
    # você não precisa configurar a linha a seguir quando você
    # usa LuaRocks ou opm.
    # 'on' já é o padrão para esta diretiva. Se 'off', o cache L1
    # será ineficaz, pois o VM Lua será recriado para cada
    # requisição. Isso é aceitável durante o desenvolvimento, mas
    # assegure-se de que em produção esteja 'on'.
    lua_code_cache on;

    lua_shared_dict cache_dict 1m;

    init_by_lua_block {
        local mlcache = require "resty.mlcache"

        local cache, err = mlcache.new("my_cache", "cache_dict", {
            lru_size = 500,    -- tamanho do cache L1 (VM Lua)
            ttl      = 3600,   -- 1h ttl para acertos
            neg_ttl  = 30,     -- 30s ttl para erros
        })
        if err then

        end

        -- colocamos nossa instância na tabela global para brevidade neste
        -- exemplo, mas prefira um upvalue para um de seus módulos
        -- conforme recomendado pelo ngx_lua
        _G.cache = cache
    }

    server {
        listen 8080;

        location / {
            content_by_lua_block {
                local function callback(username)
                    -- isso só roda *uma vez* até a chave expirar, então
                    -- faça operações caras como conectar a um backend remoto aqui.
                    -- i.e: chame um servidor MySQL neste callback
                    return db:get_user(username) -- { name = "John Doe", email = "[email protected]" }
                end

                -- esta chamada tentará L1 e L2 antes de executar o callback (L3)
                -- o valor retornado será então armazenado em L2 e L1
                -- para a próxima requisição.
                local user, err = cache:get("my_key", nil, callback, "jdoe")

                ngx.say(user.name) -- "John Doe"
            }
        }
    }
}

Métodos

new

sintaxe: cache, err = mlcache.new(name, shm, opts?)

Cria uma nova instância de mlcache. Se falhar, retorna nil e uma string descrevendo o erro.

O primeiro argumento name é um nome arbitrário de sua escolha para este cache, e deve ser uma string. Cada instância de mlcache namespace os valores que contém de acordo com seu nome, então várias instâncias com o mesmo nome compartilharão os mesmos dados.

O segundo argumento shm é o nome da zona de memória compartilhada lua_shared_dict. Várias instâncias de mlcache podem usar o mesmo shm (os valores serão namespaced).

O terceiro argumento opts é opcional. Se fornecido, deve ser uma tabela contendo as opções desejadas para esta instância. As opções possíveis são:

  • lru_size: um número definindo o tamanho do cache L1 subjacente (instância lua-resty-lrucache). Este tamanho é o número máximo de itens que o cache L1 pode conter. Padrão: 100.
  • ttl: um número especificando o período de tempo de expiração dos valores em cache. A unidade é segundos, mas aceita partes fracionárias, como 0.3. Um ttl de 0 significa que os valores em cache nunca expirarão. Padrão: 30.
  • neg_ttl: um número especificando o período de tempo de expiração dos erros em cache (quando o callback L3 retorna nil). A unidade é segundos, mas aceita partes fracionárias, como 0.3. Um neg_ttl de 0 significa que os erros em cache nunca expirarão. Padrão: 5.
  • resurrect_ttl: opcional número. Quando especificado, a instância de mlcache tentará ressuscitar valores obsoletos quando o callback L3 retornar nil, err (erros suaves). Mais detalhes estão disponíveis para esta opção na seção get(). A unidade é segundos, mas aceita partes fracionárias, como 0.3.
  • lru: opcional. Uma instância lua-resty-lrucache de sua escolha. Se especificado, mlcache não instanciará um LRU. Pode-se usar este valor para utilizar a implementação resty.lrucache.pureffi do lua-resty-lrucache, se desejado.
  • shm_set_tries: o número de tentativas para a operação set() do lua_shared_dict. Quando o lua_shared_dict está cheio, ele tenta liberar até 30 itens de sua fila. Quando o valor a ser definido é muito maior do que o espaço liberado, esta opção permite que mlcache tente novamente a operação (e libere mais slots) até que o número máximo de tentativas seja alcançado ou memória suficiente tenha sido liberada para que o valor se encaixe. Padrão: 3.
  • shm_miss: opcional string. O nome de um lua_shared_dict. Quando especificado, erros (callbacks retornando nil) serão armazenados neste lua_shared_dict separado. Isso é útil para garantir que um grande número de erros de cache (por exemplo, acionados por clientes maliciosos) não expulse muitos itens em cache (acertos) do lua_shared_dict especificado em shm.
  • shm_locks: opcional string. O nome de um lua_shared_dict. Quando especificado, lua-resty-lock usará este dicionário compartilhado para armazenar seus locks. Esta opção pode ajudar a reduzir a volatilidade do cache: quando o cache L2 (shm) está cheio, cada inserção (como locks criados por acessos concorrentes acionando callbacks L3) purga os 30 itens acessados mais antigos. Esses itens purgados são mais propensos a serem valores previamente (e valiosos) em cache. Ao isolar locks em um dicionário compartilhado separado, cargas de trabalho que experimentam volatilidade de cache podem mitigar esse efeito.
  • resty_lock_opts: opcional tabela. Opções para instâncias [lua-resty-lock]. Quando mlcache executa o callback L3, ele usa lua-resty-lock para garantir que um único trabalhador execute o callback fornecido.
  • ipc_shm: opcional string. Se você deseja usar set(), delete() ou purge(), deve fornecer um mecanismo IPC (Comunicação Inter-Processos) para que os trabalhadores sincronizem e invalidem seus caches L1. Este módulo agrupa uma biblioteca IPC "pronta para uso", e você pode habilitá-la especificando um lua_shared_dict dedicado nesta opção. Várias instâncias de mlcache podem usar o mesmo dicionário compartilhado (os eventos serão namespaced), mas nenhum outro ator além do mlcache deve interferir nele.
  • ipc: opcional tabela. Semelhante à opção ipc_shm acima, mas permite que você use a biblioteca IPC de sua escolha para propagar eventos inter-trabalhadores.
  • l1_serializer: opcional função. Sua assinatura e valores aceitos estão documentados sob o método get(), juntamente com um exemplo. Se especificado, esta função será chamada cada vez que um valor for promovido do cache L2 para o L1 (VM Lua do trabalhador). Esta função pode realizar serialização arbitrária do item em cache para transformá-lo em qualquer objeto Lua antes de armazená-lo no cache L1. Assim, pode evitar que sua aplicação tenha que repetir tais transformações em cada requisição, como criar tabelas, objetos cdata, carregar novo código Lua, etc...

Exemplo:

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict", {
    lru_size = 1000, -- manter até 1000 itens no cache L1 (VM Lua)
    ttl      = 3600, -- armazena tipos escalares e tabelas por 1h
    neg_ttl  = 60    -- armazena valores nil por 60s
})
if not cache then
    error("não foi possível criar mlcache: " .. err)
end

Você pode criar várias instâncias de mlcache dependendo da mesma zona de memória compartilhada lua_shared_dict:

local mlcache = require "mlcache"

local cache_1 = mlcache.new("cache_1", "cache_shared_dict", { lru_size = 100 })
local cache_2 = mlcache.new("cache_2", "cache_shared_dict", { lru_size = 1e5 })

No exemplo acima, cache_1 é ideal para armazenar alguns valores muito grandes. cache_2 pode ser usado para armazenar um grande número de pequenos valores. Ambas as instâncias dependerão do mesmo shm: lua_shared_dict cache_shared_dict 2048m;. Mesmo que você use chaves idênticas em ambos os caches, elas não entrarão em conflito entre si, pois cada uma tem um namespace diferente.

Este outro exemplo instancia um mlcache usando o módulo IPC agrupado para eventos de invalidação inter-trabalhadores (para que possamos usar set(), delete() e purge()):

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_cache_with_ipc", "cache_shared_dict", {
    lru_size = 1000,
    ipc_shm = "ipc_shared_dict"
})

Nota: para que o cache L1 seja eficaz, certifique-se de que lua_code_cache está habilitado (o que é o padrão). Se você desativar esta diretiva durante o desenvolvimento, o mlcache funcionará, mas o cache L1 será ineficaz, pois um novo VM Lua será criado para cada requisição.

get

sintaxe: value, err, hit_level = cache:get(key, opts?, callback?, ...)

Realiza uma busca no cache. Este é o método primário e mais eficiente deste módulo. Um padrão típico é não chamar set() e deixar get() realizar todo o trabalho.

Quando este método tem sucesso, ele retorna value e err é definido como nil. Como valores nil do callback L3 podem ser armazenados em cache (ou seja, "cache negativo"), value pode ser nil, embora já esteja em cache. Portanto, deve-se observar que é necessário verificar o segundo valor de retorno err para determinar se este método teve sucesso ou não.

O terceiro valor de retorno é um número que é definido se nenhum erro foi encontrado. Indica o nível em que o valor foi buscado: 1 para L1, 2 para L2 e 3 para L3.

Se, no entanto, um erro for encontrado, então este método retorna nil em value e uma string descrevendo o erro em err.

O primeiro argumento key é uma string. Cada valor deve ser armazenado sob uma chave única.

O segundo argumento opts é opcional. Se fornecido, deve ser uma tabela contendo as opções desejadas para esta chave. Essas opções substituirão as opções da instância:

  • ttl: um número especificando o período de tempo de expiração dos valores em cache. A unidade é segundos, mas aceita partes fracionárias, como 0.3. Um ttl de 0 significa que os valores em cache nunca expirarão. Padrão: herdado da instância.
  • neg_ttl: um número especificando o período de tempo de expiração dos erros em cache (quando o callback L3 retorna nil). A unidade é segundos, mas aceita partes fracionárias, como 0.3. Um neg_ttl de 0 significa que os erros em cache nunca expirarão. Padrão: herdado da instância.
  • resurrect_ttl: opcional número. Quando especificado, get() tentará ressuscitar valores obsoletos quando erros forem encontrados. Erros retornados pelo callback L3 (nil, err) são considerados falhas ao buscar/atualizar um valor. Quando tais valores de retorno do callback são vistos por get(), e se o valor obsoleto ainda estiver na memória, então get() ressuscitará o valor obsoleto por resurrect_ttl segundos. O erro retornado por get() será registrado no nível WARN, mas não retornado ao chamador. Finalmente, o valor de retorno hit_level será 4 para indicar que o item servido está obsoleto. Quando resurrect_ttl for alcançado, get() tentará novamente executar o callback. Se até lá, o callback retornar um erro novamente, o valor será ressuscitado mais uma vez, e assim por diante. Se o callback tiver sucesso, o valor é atualizado e não é mais marcado como obsoleto. Devido a limitações atuais dentro do módulo de cache LRU, hit_level será 1 quando valores obsoletos forem promovidos para o cache L1 e recuperados a partir daí. Erros Lua lançados pelo callback não acionam uma ressuscitação e são retornados por get() como de costume (nil, err). Quando vários trabalhadores expiram enquanto aguardam o trabalhador que executa o callback (por exemplo, porque o datastore está expirando), então os usuários desta opção verão uma leve diferença em comparação ao comportamento tradicional de get(). Em vez de retornar nil, err (indicando um timeout de lock), get() retornará o valor obsoleto (se disponível), sem erro, e hit_level será 4. No entanto, o valor não será ressuscitado (uma vez que outro trabalhador ainda está executando o callback). A unidade para esta opção é segundos, mas aceita partes fracionárias, como 0.3. Esta opção deve ser maior que 0, para evitar que valores obsoletos sejam armazenados em cache indefinidamente. Padrão: herdado da instância.
  • shm_set_tries: o número de tentativas para a operação set() do lua_shared_dict. Quando o lua_shared_dict está cheio, ele tenta liberar até 30 itens de sua fila. Quando o valor a ser definido é muito maior do que o espaço liberado, esta opção permite que mlcache tente novamente a operação (e libere mais slots) até que o número máximo de tentativas seja alcançado ou memória suficiente tenha sido liberada para que o valor se encaixe. Padrão: herdado da instância.
  • l1_serializer: opcional função. Sua assinatura e valores aceitos estão documentados sob o método get(), juntamente com um exemplo. Se especificado, esta função será chamada cada vez que um valor for promovido do cache L2 para o L1 (VM Lua do trabalhador). Esta função pode realizar serialização arbitrária do item em cache para transformá-lo em qualquer objeto Lua antes de armazená-lo no cache L1. Assim, pode evitar que sua aplicação tenha que repetir tais transformações em cada requisição, como criar tabelas, objetos cdata, carregar novo código Lua, etc... Padrão: herdado da instância.
  • resty_lock_opts: opcional tabela. Se especificado, substitui as opções resty_lock_opts da instância para a busca atual get(). Padrão: herdado da instância.

O terceiro argumento callback é opcional. Se fornecido, deve ser uma função cuja assinatura e valores de retorno estão documentados no exemplo a seguir:

-- arg1, arg2 e arg3 são argumentos encaminhados para o callback a partir dos
-- argumentos variádicos de `get()`, assim:
-- cache:get(key, opts, callback, arg1, arg2, arg3)

local function callback(arg1, arg2, arg3)
    -- lógica de busca I/O
    -- ...

    -- value: o valor a ser armazenado em cache (escalar Lua ou tabela)
    -- err: se não for `nil`, abortará get(), que retornará `value` e `err`
    -- ttl: sobrescrever ttl para este valor
    --      Se retornado como `ttl >= 0`, sobrescreverá a instância
    --      (ou opção) `ttl` ou `neg_ttl`.
    --      Se retornado como `ttl < 0`, `value` será retornado por get(),
    --      mas não será armazenado em cache. Este valor de retorno será ignorado se não for um número.
    return value, err, ttl
end

A função callback fornecida pode lançar erros Lua, pois é executada em modo protegido. Tais erros lançados pelo callback serão retornados como strings no segundo valor de retorno err.

Se callback não for fornecido, get() ainda buscará a chave solicitada nos caches L1 e L2 e a retornará se encontrada. No caso em que nenhum valor é encontrado no cache e nenhum callback é fornecido, get() retornará nil, nil, -1, onde -1 significa um erro de cache (sem valor). Isso não deve ser confundido com valores de retorno como nil, nil, 1, onde 1 significa um item em cache negativo encontrado em L1 (cached nil).

Não fornecer uma função callback permite implementar padrões de busca em cache que são garantidos para estar na CPU para um final de latência mais constante e suave (por exemplo, com valores atualizados em timers de fundo via set()).

local value, err, hit_lvl = cache:get("key")
if value == nil then
    if err ~= nil then
        -- erro
    elseif hit_lvl == -1 then
        -- erro (sem valor)
    else
        -- acerto negativo (valor `nil` em cache)
    end
end

Quando fornecido um callback, get() segue a lógica abaixo:

  1. consulta o cache L1 (instância lua-resty-lrucache). Este cache vive na VM Lua e, como tal, é o mais eficiente para consultar.
    1. se o cache L1 tem o valor, retorne-o.
    2. se o cache L1 não tem o valor (erro L1), continue.
  2. consulta o cache L2 (zona de memória lua_shared_dict). Este cache é compartilhado por todos os trabalhadores e é quase tão eficiente quanto o cache L1. No entanto, requer serialização de tabelas Lua armazenadas.
    1. se o cache L2 tem o valor, retorne-o.
      1. se l1_serializer estiver definido, execute-o e promova o valor resultante no cache L1.
      2. se não, promova diretamente o valor como está no cache L1.
    2. se o cache L2 não tem o valor (erro L2), continue.
  3. cria um [lua-resty-lock] e garante que um único trabalhador executará o callback (outros trabalhadores que tentam acessar o mesmo valor esperarão).
  4. um único trabalhador executa o callback L3 (por exemplo, realiza uma consulta ao banco de dados)
  5. o callback tem sucesso e retorna um valor: o valor é definido no cache L2 e, em seguida, no cache L1 (como está por padrão, ou como retornado por l1_serializer se especificado).
  6. o callback falhou e retornou nil, err: a. se resurrect_ttl estiver especificado, e se o valor obsoleto ainda estiver disponível, ressuscite-o no cache L2 e promova-o para o L1. b. caso contrário, get() retorna nil, err.
  7. outros trabalhadores que estavam tentando acessar o mesmo valor, mas estavam esperando, são desbloqueados e leem o valor do cache L2 (eles não executam o callback L3) e o retornam.

Quando não fornecido um callback, get() executará apenas os passos 1. e 2.

Aqui está um exemplo completo de uso:

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict", {
    lru_size = 1000,
    ttl      = 3600,
    neg_ttl  = 60
})

local function fetch_user(user_id)
    local user, err = db:query_user(user_id)
    if err then
        -- neste caso, get() retornará `nil` + `err`
        return nil, err
    end

    return user -- tabela ou nil
end

local user_id = 3

local user, err = cache:get("users:" .. user_id, nil, fetch_user, user_id)
if err then
    ngx.log(ngx.ERR, "não foi possível recuperar o usuário: ", err)
    return
end

-- `user` pode ser uma tabela, mas também pode ser `nil` (não existe)
-- independentemente, será armazenado em cache e chamadas subsequentes para get()
-- retornarão o valor em cache, por até `ttl` ou `neg_ttl`.
if user then
    ngx.say("usuário existe: ", user.name)
else
    ngx.say("usuário não existe")
end

Este segundo exemplo é semelhante ao anterior, mas aqui aplicamos alguma transformação ao registro user recuperado antes de armazená-lo em cache via callback l1_serializer:

-- Nosso l1_serializer, chamado quando um valor é promovido de L2 para L1
--
-- Sua assinatura recebe um único argumento: o item conforme retornado de
-- um acerto L2. Portanto, este argumento nunca pode ser `nil`. O resultado será
-- mantido no cache L1, mas não pode ser `nil`.
--
-- Esta função pode retornar `nil` e uma string descrevendo um erro, que
-- será propagada para o chamador de `get()`. Ela também é executada em modo protegido
-- e relatará qualquer erro Lua.
local function load_code(user_row)
    if user_row.custom_code ~= nil then
        local f, err = loadstring(user_row.raw_lua_code)
        if not f then
            -- neste caso, nada será armazenado em cache (como se o L3
            -- callback falhasse)
            return nil, "falha ao compilar código personalizado: " .. err
        end

        user_row.f = f
    end

    return user_row
end

local user, err = cache:get("users:" .. user_id,
                            { l1_serializer = load_code },
                            fetch_user, user_id)
if err then
     ngx.log(ngx.ERR, "não foi possível recuperar o usuário: ", err)
     return
end

-- agora podemos chamar uma função que já foi carregada uma vez, ao entrar
-- no cache L1 (VM Lua)
user.f()

get_bulk

sintaxe: res, err = cache:get_bulk(bulk, opts?)

Realiza várias buscas get() de uma só vez (em massa). Qualquer uma dessas buscas que requerer uma chamada de callback L3 será executada de forma concorrente, em um pool de ngx.thread.

O primeiro argumento bulk é uma tabela contendo n operações.

O segundo argumento opts é opcional. Se fornecido, deve ser uma tabela contendo as opções para esta busca em massa. As opções possíveis são:

  • concurrency: um número maior que 0. Especifica o número de threads que executarão concorrente as callbacks L3 para esta busca em massa. Uma concorrência de 3 com 6 callbacks a serem executados significa que cada thread executará 2 callbacks. Uma concorrência de 1 com 6 callbacks significa que uma única thread executará todos os 6 callbacks. Com uma concorrência de 6 e 1 callback, uma única thread executará o callback. Padrão: 3.

Em caso de sucesso, este método retorna res, uma tabela contendo os resultados de cada busca, e nenhum erro.

Em caso de falha, este método retorna nil mais uma string descrevendo o erro.

Todas as operações de busca realizadas por este método se integrarão totalmente a outras operações que estão sendo realizadas de forma concorrente por outros métodos e trabalhadores Nginx (por exemplo, armazenamento de acertos/erros L1/L2, mutex de callback L3, etc...).

O argumento bulk é uma tabela que deve ter um layout particular (documentado no exemplo abaixo). Pode ser construída manualmente ou via o método auxiliar new_bulk().

Da mesma forma, a tabela res também tem um layout particular. Pode ser iterada manualmente ou via o auxiliar iterador each_bulk_res.

Exemplo:

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict")

cache:get("key_c", nil, function() return nil end)

local res, err = cache:get_bulk({
  -- layout em massa:
  -- chave     opts          callback L3                    argumento do callback

    "key_a", { ttl = 60 }, function() return "hello" end, nil,
    "key_b", nil,          function() return "world" end, nil,
    "key_c", nil,          function() return "bye" end,   nil,
    n = 3 -- especifica o número de operações
}, { concurrency = 3 })
if err then
     ngx.log(ngx.ERR, "não foi possível executar busca em massa: ", err)
     return
end

-- layout de res:
-- dados, "err", hit_lvl }

for i = 1, res.n, 3 do
    local data = res[i]
    local err = res[i + 1]
    local hit_lvl = res[i + 2]

    if not err then
        ngx.say("dados: ", data, ", hit_lvl: ", hit_lvl)
    end
end

O exemplo acima produziria a seguinte saída:

dados: hello, hit_lvl: 3
dados: world, hit_lvl: 3
dados: nil, hit_lvl: 1

Note que, uma vez que key_c já estava no cache, o callback retornando "bye" nunca foi executado, uma vez que get_bulk() recuperou o valor de L1, como indicado pelo valor de hit_lvl.

Nota: ao contrário de get(), este método permite apenas especificar um único argumento para o callback de cada busca.

new_bulk

sintaxe: bulk = mlcache.new_bulk(n_lookups?)

Cria uma tabela contendo operações de busca para a função get_bulk(). Não é necessário usar esta função para construir uma tabela de busca em massa, mas ela fornece uma boa abstração.

O primeiro e único argumento n_lookups é opcional, e se especificado, é um número sugerindo a quantidade de buscas que esta massa conterá eventualmente, para que a tabela subjacente seja pré-alocada para fins de otimização.

Esta função retorna uma tabela bulk, que ainda não contém operações de busca. Buscas são adicionadas a uma tabela bulk invocando bulk:add(key, opts?, cb, arg?):

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict")

local bulk = mlcache.new_bulk(3)

bulk:add("key_a", { ttl = 60 }, function(n) return n * n, 42)
bulk:add("key_b", nil, function(str) return str end, "hello")
bulk:add("key_c", nil, function() return nil end)

local res, err = cache:get_bulk(bulk)

each_bulk_res

sintaxe: iter, res, i = mlcache.each_bulk_res(res)

Fornece uma abstração para iterar sobre uma tabela de retorno res de get_bulk(). Não é necessário usar este método para iterar sobre uma tabela res, mas fornece uma boa abstração.

Este método pode ser invocado como um iterador Lua:

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict")

local res, err = cache:get_bulk(bulk)

for i, data, err, hit_lvl in mlcache.each_bulk_res(res) do
    if not err then
        ngx.say("busca ", i, ": ", data)
    end
end

peek

sintaxe: ttl, err, value = cache:peek(key, stale?)

Dá uma olhada no cache L2 (lua_shared_dict).

O primeiro argumento key é uma string que é a chave a ser buscada no cache.

O segundo argumento stale é opcional. Se true, então peek() considerará valores obsoletos como valores em cache. Se não fornecido, peek() considerará valores obsoletos, como se não estivessem no cache.

Este método retorna nil e uma string descrevendo o erro em caso de falha.

Se não houver valor para a key consultada, ele retorna nil e nenhum erro.

Se houver um valor para a key consultada, ele retorna um número indicando o TTL restante do valor em cache (em segundos) e nenhum erro. Se o valor para key expirou, mas ainda está no cache L2, o valor TTL retornado será negativo. O valor de retorno TTL restante será 0 apenas se a key consultada tiver um ttl indefinido (ttl=0). Caso contrário, este valor de retorno pode ser positivo (a key ainda é válida) ou negativo (a key está obsoleta).

O terceiro valor retornado será o valor em cache conforme armazenado no cache L2, se ainda estiver disponível.

Este método é útil quando você deseja determinar se um valor está em cache. Um valor armazenado no cache L2 é considerado em cache independentemente de estar ou não definido também no cache L1 do trabalhador. Isso porque o cache L1 é considerado volátil (já que sua unidade de tamanho é um número de slots), e o cache L2 ainda é várias ordens de magnitude mais rápido do que o callback L3 de qualquer forma.

Como sua única intenção é dar uma "espiada" no cache para determinar sua temperatura para um determinado valor, peek() não conta como uma consulta como get() e não promove o valor para o cache L1.

Exemplo:

local mlcache = require "mlcache"

local cache = mlcache.new("my_cache", "cache_shared_dict")

local ttl, err, value = cache:peek("key")
if err then
    ngx.log(ngx.ERR, "não foi possível espiar o cache: ", err)
    return
end

ngx.say(ttl)   -- nil porque `key` ainda não tem valor
ngx.say(value) -- nil

-- armazene o valor

cache:get("key", { ttl = 5 }, function() return "some value" end)

-- aguarde 2 segundos

ngx.sleep(2)

local ttl, err, value = cache:peek("key")
if err then
    ngx.log(ngx.ERR, "não foi possível espiar o cache: ", err)
    return
end

ngx.say(ttl)   -- 3
ngx.say(value) -- "some value"

Nota: desde o mlcache 2.5.0, também é possível chamar get() sem uma função de callback para "consultar" o cache. Ao contrário de peek(), uma chamada get() sem callback promoverá o valor para o cache L1 e não retornará seu TTL.

set

sintaxe: ok, err = cache:set(key, opts?, value)

Define incondicionalmente um valor no cache L2 e publica um evento para outros trabalhadores para que possam atualizar o valor de seu cache L1.

O primeiro argumento key é uma string, e é a chave sob a qual armazenar o valor.

O segundo argumento opts é opcional, e se fornecido, é idêntico ao de get().

O terceiro argumento value é o valor a ser armazenado em cache, semelhante ao valor de retorno do callback L3. Assim como o valor de retorno do callback, deve ser um escalar Lua, uma tabela ou nil. Se um l1_serializer for fornecido, seja a partir do construtor ou no argumento opts, ele será chamado com value se value não for nil.

Em caso de sucesso, o primeiro valor de retorno será true.

Em caso de falha, este método retorna nil e uma string descrevendo o erro.

Nota: por sua natureza, set() requer que outras instâncias de mlcache (de outros trabalhadores) atualizem seu cache L1. Se set() for chamado de um único trabalhador, as instâncias de mlcache de outros trabalhadores que possuem o mesmo name devem chamar update() antes que seu cache seja solicitado durante a próxima requisição, para garantir que atualizaram seu cache L1.

Nota bis: geralmente é considerado ineficiente chamar set() em um caminho de código quente (como em uma requisição atendida pelo OpenResty). Em vez disso, deve-se confiar em get() e seu mutex embutido no callback L3. set() é mais adequado quando chamado ocasionalmente de um único trabalhador, por exemplo, em um evento particular que aciona uma atualização de valor em cache. Uma vez que set() atualiza o cache L2 com o valor fresco, outros trabalhadores confiarão em update() para consultar o evento de invalidação e invalidar seu cache L1, o que os fará buscar o valor (fresco) no L2.

Veja: update()

delete

sintaxe: ok, err = cache:delete(key)

Deleta um valor no cache L2 e publica um evento para outros trabalhadores para que possam expulsar o valor de seu cache L1.

O primeiro e único argumento key é a string sob a qual o valor está armazenado.

Em caso de sucesso, o primeiro valor de retorno será true.

Em caso de falha, este método retorna nil e uma string descrevendo o erro.

Nota: por sua natureza, delete() requer que outras instâncias de mlcache (de outros trabalhadores) atualizem seu cache L1. Se delete() for chamado de um único trabalhador, as instâncias de mlcache de outros trabalhadores que possuem o mesmo name devem chamar update() antes que seu cache seja solicitado durante a próxima requisição, para garantir que atualizaram seu cache L1.

Veja: update()

purge

sintaxe: ok, err = cache:purge(flush_expired?)

Purga o conteúdo do cache, tanto nos níveis L1 quanto L2. Em seguida, publica um evento para outros trabalhadores para que possam purgar seu cache L1 também.

Este método recicla a instância lua-resty-lrucache e chama ngx.shared.DICT:flush_all, portanto, pode ser bastante caro.

O primeiro e único argumento flush_expired é opcional, mas se dado como true, este método também chamará ngx.shared.DICT:flush_expired (sem argumentos). Isso é útil para liberar memória reivindicada pelo cache L2 (shm), se necessário.

Em caso de sucesso, o primeiro valor de retorno será true.

Em caso de falha, este método retorna nil e uma string descrevendo o erro.

Nota: não é possível chamar purge() ao usar um cache LRU personalizado no OpenResty 1.13.6.1 e abaixo. Esta limitação não se aplica ao OpenResty 1.13.6.2 e acima.

Nota: por sua natureza, purge() requer que outras instâncias de mlcache (de outros trabalhadores) atualizem seu cache L1. Se purge() for chamado de um único trabalhador, as instâncias de mlcache de outros trabalhadores que possuem o mesmo name devem chamar update() antes que seu cache seja solicitado durante a próxima requisição, para garantir que atualizaram seu cache L1.

Veja: update()

update

sintaxe: ok, err = cache:update(timeout?)

Consulta e executa eventos pendentes de invalidação de cache publicados por outros trabalhadores.

Os métodos set(), delete() e purge() requerem que outras instâncias de mlcache (de outros trabalhadores) atualizem seu cache L1. Como o OpenResty atualmente não possui um mecanismo embutido para comunicação inter-trabalhadores, este módulo agrupa uma biblioteca IPC "pronta para uso" para propagar eventos inter-trabalhadores. Se a biblioteca IPC agrupada for usada, o lua_shared_dict especificado na opção ipc_shm não deve ser usado por outros atores além do próprio mlcache.

Este método permite que um trabalhador atualize seu cache L1 (purificando valores considerados obsoletos devido a outro trabalhador chamando set(), delete() ou purge()) antes de processar uma requisição.

Este método aceita um argumento timeout cuja unidade é segundos e que tem como padrão 0.3 (300ms). A operação de atualização terá timeout se não for concluída quando este limite for alcançado. Isso evita que update() permaneça na CPU por muito tempo caso haja muitos eventos a serem processados. Em um sistema eventualmente consistente, eventos adicionais podem esperar pela próxima chamada a ser processada.

Um padrão de design típico é chamar update() apenas uma vez antes de cada processamento de requisição. Isso permite que seus caminhos de código quentes realizem um único acesso shm no melhor cenário: nenhum evento de invalidação foi recebido, todas as chamadas get() acertarão no cache L1. Somente em um cenário de pior caso (n valores foram expulsos por outro trabalhador) get() acessará o cache L2 ou L3 n vezes. Requisições subsequentes acertarão novamente no melhor cenário, porque get() populou o cache L1.

Por exemplo, se seus trabalhadores utilizam set(), delete() ou purge() em qualquer lugar de sua aplicação, chame update() na entrada do seu caminho de código quente, antes de usar get():

http {
    listen 9000;

    location / {
        content_by_lua_block {
            local cache = ... -- recuperar instância mlcache

            -- certifique-se de que o cache L1 está livre de valores obsoletos
            -- antes de chamar get()
            local ok, err = cache:update()
            if not ok then
                ngx.log(ngx.ERR, "falha ao consultar eventos de expulsão: ", err)
                -- /!\ podemos obter dados obsoletos de get()
            end

            -- busca L1/L2/L3 (melhor caso: L1)
            local value, err = cache:get("key_1", nil, cb1)

            -- busca L1/L2/L3 (melhor caso: L1)
            local other_value, err = cache:get("key_2", nil, cb2)

            -- value e other_value estão atualizados porque:
            -- ou não estavam obsoletos e vieram diretamente do L1 (cenário do melhor caso)
            -- ou estavam obsoletos e foram expulsos do L1, e vieram do L2
            -- ou não estavam nem no L1 nem no L2, e vieram do L3 (cenário do pior caso)
        }
    }

    location /delete {
        content_by_lua_block {
            local cache = ... -- recuperar instância mlcache

            -- deleta algum valor
            local ok, err = cache:delete("key_1")
            if not ok then
                ngx.log(ngx.ERR, "falha ao deletar valor do cache: ", err)
                return ngx.exit(500)
            end

            ngx.exit(204)
        }
    }

    location /set {
        content_by_lua_block {
            local cache = ... -- recuperar instância mlcache

            -- atualiza algum valor
            local ok, err = cache:set("key_1", nil, 123)
            if not ok then
                ngx.log(ngx.ERR, "falha ao definir valor no cache: ", err)
                return ngx.exit(500)
            end

            ngx.exit(200)
        }
    }
}

Nota: você não precisa chamar update() para atualizar seus trabalhadores se eles nunca chamarem set(), delete(), ou purge(). Quando os trabalhadores dependem apenas de get(), os valores expiram naturalmente dos caches L1/L2 de acordo com seu TTL.

Nota bis: esta biblioteca foi construída com a intenção de usar uma solução melhor para comunicação inter-trabalhadores assim que uma surgir. Em versões futuras desta biblioteca, se uma biblioteca IPC puder evitar a abordagem de polling, esta biblioteca também o fará. update() é apenas um mal necessário devido às "limitações" atuais do Nginx/OpenResty. Você pode, no entanto, usar sua própria biblioteca IPC utilizando a opção opts.ipc ao criar sua instância de mlcache.

Recursos

Em novembro de 2018, esta biblioteca foi apresentada na OpenResty Con em Hangzhou, China.

Os slides e uma gravação da palestra (cerca de 40 minutos de duração) podem ser vistos [aqui][talk].

Changelog

Veja CHANGELOG.md.

GitHub

Você pode encontrar dicas adicionais de configuração e documentação para este módulo no repositório GitHub para nginx-module-mlcache.