Magia de comunicación entre los workers de NGINX: una de las estructuras de datos más importantes, `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

Como mencionamos en el artículo anterior, la table es la única estructura de datos en Lua. Esto corresponde al shared dict, que es la estructura de datos más importante que puedes usar en la programación de OpenResty. Soporta almacenamiento de datos, lectura, conteo atómico y operaciones de cola.

Basado en el shared dict, puedes implementar almacenamiento en caché y comunicación entre múltiples Workers, limitación de tasa, estadísticas de tráfico y otras funciones. Puedes usar shared dict como un Redis simple, excepto que los datos en shared dict no son persistentes, por lo que debes considerar la pérdida de datos almacenados.

Varias formas de compartir datos

Al escribir el código Lua de OpenResty, inevitablemente te encontrarás con la necesidad de compartir datos entre diferentes Workers en diferentes fases de la solicitud. También podrías necesitar compartir datos entre código Lua y C.

Por lo tanto, antes de presentar formalmente las APIs de shared dict, primero entendamos los métodos comunes de compartir datos en OpenResty y aprendamos cómo elegir un método más apropiado según la situación actual.

El primero son las variables en NGINX. Puede compartir datos entre módulos C de NGINX. Naturalmente, también puede compartir datos entre módulos C y el lua-nginx-module proporcionado por OpenResty, como en el siguiente código.

location /foo {
     set $my_var ''; # esta línea es necesaria para crear $my_var en tiempo de configuración
     content_by_lua_block {
         ngx.var.my_var = 123;
         ...
     }
 }

Sin embargo, usar variables de NGINX para compartir datos es lento porque implica búsquedas hash y asignación de memoria. Además, este enfoque tiene la limitación de que solo puede usarse para almacenar cadenas y no admite tipos complejos de Lua.

El segundo es ngx.ctx, que puede compartir datos entre diferentes fases de la misma solicitud. Es una table Lua normal, por lo que es rápida y puede almacenar varios objetos Lua. Su ciclo de vida es a nivel de solicitud; cuando la solicitud termina, ngx.ctx se destruye.

El siguiente es un escenario de uso típico donde usamos ngx.ctx para almacenar llamadas costosas como variables de NGINX y usarlo en varias 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)
     }
 }

En este caso, si usas curl para acceder.

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

Entonces imprimirá test.com, mostrando que ngx.ctx está compartiendo datos en diferentes etapas. Por supuesto, también puedes modificar el ejemplo anterior guardando objetos más complejos como tables en lugar de cadenas simples para ver si cumple con tus expectativas.

Sin embargo, una nota especial aquí es que, dado que el ciclo de vida de ngx.ctx es a nivel de solicitud, no se almacena en caché a nivel de módulo. Por ejemplo, cometí el error de usar esto en mi archivo foo.lua.

local ngx_ctx = ngx.ctx

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

Deberíamos llamar y almacenar en caché a nivel de función.

local ngx = ngx

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

Hay muchos más detalles sobre ngx.ctx, que continuaremos explorando más adelante en la sección de optimización de rendimiento.

El tercer enfoque utiliza variables a nivel de módulo para compartir datos entre todas las solicitudes dentro del mismo Worker. A diferencia de las variables de NGINX y ngx.ctx anteriores, este enfoque es un poco menos comprensible. Pero no te preocupes, el concepto es abstracto, y el código viene primero, así que veamos un ejemplo para entender una variable a nivel 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

La configuración en nginx.conf es la siguiente.

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

En este ejemplo, mydata es un módulo que se carga solo una vez por el proceso Worker, y todas las solicitudes procesadas por el Worker después de eso comparten el código y los datos del módulo mydata.

Naturalmente, la variable data en el módulo mydata es una variable a nivel de módulo ubicada en la parte superior del módulo, es decir, al principio del módulo, y es accesible para todas las funciones.

Por lo tanto, puedes poner datos que necesiten ser compartidos entre solicitudes en la variable de nivel superior del módulo. Sin embargo, es esencial tener en cuenta que generalmente solo usamos esta forma para almacenar datos de solo lectura. Si se involucran operaciones de escritura, debes tener mucho cuidado porque puede haber una condición de carrera, que es un error difícil de localizar.

Podemos experimentar esto con el siguiente ejemplo más 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

En el módulo, agregamos la función incr_age, que modifica los datos en la tabla data.

Luego, en el código de llamada, agregamos la línea más crítica ngx.sleep(5), donde sleep es una operación de yield.

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

Sin esta línea de código sleep (u otras operaciones de IO no bloqueantes, como acceder a Redis, etc.), no habría operación de yield, no habría competencia, y la salida final sería secuencial.

Pero cuando agregamos esta línea de código, incluso si es solo dentro de 5 segundos de sueño, es probable que otra solicitud llame a la función mydata.incr_age y modifique el valor de la variable, lo que causará que los números finales de salida sean discontinuos. La lógica no es tan simple en el código real, y el error es mucho más difícil de localizar.

Por lo tanto, a menos que estés seguro de que no hay una operación de yield en el medio que le dará control al bucle de eventos de NGINX, recomiendo mantener tus variables a nivel de módulo de solo lectura.

El cuarto y último enfoque utiliza shared dict para compartir datos que pueden ser compartidos entre múltiples workers.

Este enfoque se basa en una implementación de árbol rojo-negro, que tiene un buen rendimiento. Sin embargo, tiene sus limitaciones: debes declarar el tamaño de la memoria compartida en el archivo de configuración de NGINX de antemano, y esto no se puede cambiar en tiempo de ejecución:

lua_shared_dict dogs 10m;

El shared dict también solo almacena en caché datos de tipo string y no admite tipos de datos complejos de Lua. Esto significa que cuando necesito almacenar tipos de datos complejos como tables, tendré que usar JSON u otros métodos para serializarlos y deserializarlos, lo que naturalmente causará una gran pérdida de rendimiento.

De todos modos, no hay una bala de plata aquí, y no hay una forma perfecta de compartir datos. Debes combinar múltiples métodos según tus necesidades y escenarios.

Shared dict

Hemos pasado mucho tiempo aprendiendo sobre la parte de compartir datos anteriormente, y algunos de ustedes pueden preguntarse: parece que no están directamente relacionados con shared dict. ¿No es eso fuera de tema?

En realidad, no. Por favor, piénsalo: ¿por qué existe un shared dict en OpenResty? Recuerda que los primeros tres métodos de compartir datos son todos a nivel de solicitud o a nivel de Worker individual. Por lo tanto, en la implementación actual de OpenResty, solo shared dict puede lograr el compartir datos entre Workers, permitiendo la comunicación entre Workers, que es el valor de su existencia.

En mi opinión, entender por qué existe la tecnología y descubrir sus diferencias y ventajas en comparación con otras tecnologías similares es mucho más importante que solo ser experto en llamar a las APIs que proporciona. Esta visión técnica te da un grado de previsión y perspicacia y es, sin duda, una diferencia importante entre ingenieros y arquitectos.

Volviendo al shared dict, que proporciona más de 20 APIs Lua al público, todas las cuales son atómicas, por lo que no tienes que preocuparte por la competencia en el caso de múltiples Workers y alta concurrencia.

Estas APIs tienen documentación oficial detallada, por lo que no entraré en todas ellas. Quiero enfatizar nuevamente que ningún curso técnico puede reemplazar una lectura cuidadosa de la documentación oficial. Nadie puede saltarse estos procedimientos que consumen tiempo y son estúpidos.

A continuación, sigamos viendo las APIs de shared dict, que se pueden dividir en tres categorías: lectura/escritura de dict, operación de cola y gestión.

Lectura/escritura de dict

Primero veamos las clases de lectura y escritura de dict. En la versión original, solo había APIs para las clases de lectura y escritura de dict, las características más comunes de los diccionarios compartidos. Aquí está el ejemplo más simple.

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

Además de set, OpenResty también proporciona cuatro métodos de escritura: safe_set, add, safe_add y replace. El significado del prefijo safe aquí es que si la memoria está llena, en lugar de eliminar los datos antiguos según LRU, la escritura fallará y devolverá un error de no memory.

Además de get, OpenResty también proporciona el método get_stale para leer datos, que tiene un valor de retorno adicional para datos caducados en comparación con el método get.

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

También puedes llamar al método delete para eliminar la clave especificada, que es equivalente a set(key, nil).

Operación de cola

Pasando a las operaciones de cola, es una adición posterior a OpenResty que proporciona una interfaz similar a Redis. Cada elemento en una cola se describe mediante 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;

He publicado el PR de estas APIs de cola en el artículo. Si estás interesado en esto, puedes seguir la documentación, los casos de prueba y el código fuente para analizar la implementación específica.

Sin embargo, no hay ejemplos de código correspondientes para las siguientes cinco APIs de cola en la documentación, así que las presentaré brevemente aquí.

  • lpush``/``rpush significa agregar elementos en ambos extremos de la cola.
  • lpop``/``rpop, que saca elementos en ambos extremos de la cola.
  • llen, que indica el número de elementos devueltos a la cola.

No olvidemos otra herramienta útil que discutimos en el último artículo: los casos de prueba. Por lo general, podemos encontrar el código correspondiente en un caso de prueba si no está en la documentación. Las pruebas relacionadas con colas están precisamente en el archivo 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]

Gestión

La API de gestión final también es una adición posterior y es una necesidad popular en la comunidad. Uno de los ejemplos más típicos es el uso de la memoria compartida. Por ejemplo, si un usuario solicita 100M de espacio como un shared dict, ¿es suficiente este 100M? ¿Cuántas claves se almacenan en él y cuáles son? Estas son preguntas auténticas.

Para este tipo de problema, el equipo oficial de OpenResty espera que los usuarios usen gráficos de llamadas para resolverlos, es decir, de una manera no invasiva, manteniendo la base de código eficiente y ordenada, en lugar de proporcionar una API invasiva para devolver los resultados directamente.

Pero desde una perspectiva amigable para el usuario, estas APIs de gestión siguen siendo esenciales. Después de todo, los proyectos de código abierto están diseñados para resolver requisitos de productos, no para mostrar la tecnología en sí. Entonces, veamos las siguientes APIs de gestión que se agregarán más adelante.

Primero está get_keys(max_count?), que por defecto solo devuelve las primeras 1024 claves; si configuras max_count a 0, devolverá todas las claves. Luego vienen capacity y free_space, ambas son parte del repositorio lua-resty-core, por lo que necesitas require antes de usarlas.

require "resty.core.shdict"

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

Devuelven el tamaño de la memoria compartida (el tamaño configurado en lua_shared_dict) y el número de bytes de páginas libres. Dado que el shared dict se asigna por páginas, incluso si free_space devuelve 0, puede haber espacio en las páginas asignadas. Por lo tanto, su valor de retorno no representa cuánta memoria compartida está ocupada.

Resumen

En la práctica, a menudo usamos almacenamiento en caché de múltiples niveles, y el proyecto oficial de OpenResty también tiene un paquete de almacenamiento en caché. ¿Puedes averiguar qué proyectos son? ¿O conoces algunas otras bibliotecas lua-resty que encapsulan el almacenamiento en caché?

Te invitamos a compartir este artículo con tus colegas y amigos para que podamos comunicarnos y mejorar juntos.