`lua-resty-*` Encapsulación Libera a los Desarrolladores del Caché Multinivel

API7.ai

December 30, 2022

OpenResty (NGINX + Lua)

En los dos artículos anteriores, hemos aprendido sobre el almacenamiento en caché en OpenResty y el problema de la estampida de caché, que son aspectos básicos. En el desarrollo real de proyectos, los desarrolladores prefieren una biblioteca lista para usar que maneje y oculte todos los detalles, y que pueda utilizarse directamente para desarrollar código de negocio.

Este es un beneficio de la división del trabajo: los desarrolladores de componentes básicos se enfocan en una arquitectura flexible, buen rendimiento y estabilidad del código, sin preocuparse por la lógica de negocio de nivel superior; mientras que los ingenieros de aplicaciones se preocupan más por la implementación del negocio y la iteración rápida, esperando no distraerse con los diversos detalles técnicos del nivel inferior. El espacio entre ambos puede ser llenado por bibliotecas de envoltura.

El almacenamiento en caché en OpenResty enfrenta el mismo problema. shared dict y lru caches son lo suficientemente estables y eficientes, pero hay demasiados detalles que manejar. La "última milla" para los ingenieros de desarrollo de aplicaciones puede ser ardua sin una encapsulación útil. Aquí es donde entra en juego la importancia de la comunidad. Una comunidad activa tomará la iniciativa de encontrar los vacíos y llenarlos rápidamente.

lua-resty-memcached-shdict

Volvamos a la encapsulación de caché. lua-resty-memcached-shdict es un proyecto oficial de OpenResty que utiliza shared dict para hacer una capa de encapsulación para memcached, manejando detalles como la estampida de caché y los datos caducados. Si tus datos en caché están almacenados en memcached en el backend, entonces puedes probar esta biblioteca.

Es una biblioteca desarrollada oficialmente por OpenResty, pero no está incluida en el paquete de OpenResty por defecto. Si deseas probarla localmente, necesitas descargar su código fuente a la ruta de búsqueda local de OpenResty primero.

Esta biblioteca de encapsulación es la misma solución que mencionamos en el artículo anterior. Utiliza lua-resty-lock para ser mutuamente excluyente. En caso de un fallo de caché, solo una solicitud va a memcached a buscar los datos y evita las tormentas de caché. Los datos caducados se devuelven al endpoint si no se obtienen los datos más recientes.

Sin embargo, esta biblioteca lua-resty, aunque es un proyecto oficial de OpenResty, no es perfecta:

  1. Primero, no tiene cobertura de casos de prueba, lo que significa que la calidad del código no puede garantizarse consistentemente.
  2. Segundo, expone demasiados parámetros de interfaz, con 11 parámetros requeridos y 7 opcionales.
local memc_fetch, memc_store =
    shdict_memc.gen_memc_methods{
        tag = "my memcached server tag",
        debug_logger = dlog,
        warn_logger = warn,
        error_logger = error_log,

        locks_shdict_name = "some_lua_shared_dict_name",

        shdict_set = meta_shdict_set,  
        shdict_get = meta_shdict_get,  

        disable_shdict = false,  -- optional, default false

        memc_host = "127.0.0.1",
        memc_port = 11211,
        memc_timeout = 200,  -- in ms
        memc_conn_pool_size = 5,
        memc_fetch_retries = 2,  -- optional, default 1
        memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)

        memc_conn_max_idle_time = 10 * 1000,  -- in ms, for in-pool connections,optional, default to nil

        memc_store_retries = 2,  -- optional, default to 1
        memc_store_retry_delay = 100,  -- in ms, optional, default to 100 (ms)

        store_ttl = 1,  -- in seconds, optional, default to 0 (i.e., never expires)
    }

La mayoría de los parámetros expuestos podrían simplificarse "creando un nuevo manejador de memcached". La forma actual de encapsular todos los parámetros lanzándolos al usuario no es amigable, por lo que daría la bienvenida a desarrolladores interesados en contribuir con PRs para optimizar esto.

Además, en la documentación de esta biblioteca de encapsulación se mencionan más optimizaciones en las siguientes direcciones.

  1. Usar lua-resty-lrucache para aumentar el caché a nivel de Worker, en lugar de solo el caché a nivel de server con shared dict.
  2. Usar ngx.timer para realizar operaciones asíncronas de actualización de caché.

La primera dirección es una sugerencia muy buena, ya que el rendimiento del caché dentro del worker es mejor; la segunda sugerencia es algo que debes considerar según tu escenario real. Sin embargo, no recomiendo generalmente la segunda, no solo porque hay un límite en el número de temporizadores, sino también porque si la lógica de actualización aquí falla, el caché nunca se actualizará de nuevo, lo que tiene un gran impacto.

lua-resty-mlcache

A continuación, presentemos una encapsulación de caché comúnmente utilizada en OpenResty: lua-resty-mlcache, que utiliza shared dict y lua-resty-lrucache para implementar un mecanismo de caché de múltiples capas. Veamos cómo se usa esta biblioteca en los siguientes dos ejemplos de código.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("cache_name", "cache_dict", {
    lru_size = 500,    -- tamaño del caché L1 (Lua VM)
    ttl = 3600,   -- 1h ttl para aciertos
    neg_ttl  = 30,     -- 30s ttl para fallos
})
if not cache then
    error("failed to create mlcache: " .. err)
end

Veamos el primer fragmento de código. El comienzo de este código introduce la biblioteca mlcache y establece los parámetros para la inicialización. Normalmente pondríamos este código en la fase init y solo necesitaríamos hacerlo una vez.

Además de los dos parámetros requeridos, el nombre del caché y el nombre del diccionario, un tercer parámetro es un diccionario con 12 opciones que son opcionales y usan valores predeterminados si no se llenan. Esto es mucho más elegante que lua-resty-memcached-shdict. Si fuéramos a diseñar la interfaz nosotros mismos, sería mejor adoptar el enfoque de mlcache: mantener la interfaz lo más simple posible mientras se retiene suficiente flexibilidad.

Aquí está el segundo fragmento de código, que es el código lógico cuando se procesa la solicitud.

local function fetch_user(id)
    return db:query_user(id)
end

local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
    ngx.log(ngx.ERR , "failed to fetch user: ", err)
    return
end

if user then
    print(user.id) -- 123
end

Como puedes ver, el caché de múltiples capas está oculto, por lo que necesitas usar el objeto mlcache para obtener el caché y establecer la función de callback cuando el caché caduca. La lógica compleja detrás de esto puede ocultarse completamente.

Puede que te preguntes cómo se implementa internamente esta biblioteca. A continuación, echemos otro vistazo a la arquitectura e implementación de esta biblioteca. La siguiente imagen es una diapositiva de una charla dada por Thibault Charbonnier, el autor de mlcache, en OpenResty Con 2018.

Arquitectura de mlcache

Como puedes ver en el diagrama, mlcache divide los datos en tres capas, a saber, L1, L2 y L3.

El caché L1 es lua-resty-lrucache, donde cada Worker tiene su propia copia, y con N Workers, hay N copias de datos, por lo que hay redundancia de datos. Dado que operar lrucache dentro de un solo Worker no activa bloqueos, tiene un mayor rendimiento y es adecuado como un caché de primer nivel.

El caché L2 es un shared dict. Todos los Workers comparten una sola copia de los datos en caché y consultarán el caché L2 si el caché L1 no acierta. ngx.shared.DICT proporciona una API que utiliza bloqueos de giro para garantizar la atomicidad de las operaciones, por lo que no tenemos que preocuparnos por las condiciones de carrera aquí.

El L3 es el caso en el que el caché L2 tampoco acierta, y se necesita ejecutar la función de callback para consultar la fuente de datos, como una base de datos externa, y luego almacenarla en L2. Aquí, para evitar tormentas de caché, utiliza lua-resty-lock para garantizar que solo un Worker vaya a la fuente de datos a obtener los datos.

Desde la perspectiva de una solicitud:

  • Primero, consultará el caché L1 dentro del Worker y devolverá directamente si L1 acierta.
  • Si L1 no acierta o el caché falla, consulta el caché L2 entre Workers. Si L2 acierta, devuelve y almacena el resultado en L1.
  • Si L2 también falla o el caché está invalidado, se llama a una función de callback para buscar los datos desde la fuente de datos y escribirlos en el caché L2, que es la función de la capa de datos L3.

También puedes ver en este proceso que las actualizaciones del caché se activan pasivamente por las solicitudes de los endpoints. Incluso si una solicitud no obtiene el caché, las solicitudes posteriores aún pueden activar la lógica de actualización para maximizar la seguridad del caché.

Sin embargo, aunque mlcache ha sido implementado perfectamente, todavía hay un punto doloroso: la serialización y deserialización de datos. Esto no es un problema de mlcache, sino la diferencia entre lrucache y shared dict, que mencionamos repetidamente. En lrucache, podemos almacenar varios tipos de datos Lua, incluyendo table; pero en shared dict, solo podemos almacenar cadenas.

El caché L1, el lrucache, es la capa de datos que tocan los usuarios, y queremos almacenar todo tipo de datos en él, incluyendo string, table, cdata, etc. El problema es que L2 solo puede almacenar cadenas, y cuando los datos se elevan de L2 a L1, necesitamos hacer una capa de conversión de cadenas a tipos de datos que podamos dar directamente al usuario.

Afortunadamente, mlcache ha tenido en cuenta esta situación y proporciona funciones opcionales l1_serializer en las interfaces new y get, específicamente diseñadas para manejar el procesamiento de datos cuando L2 se eleva a L1. Podemos ver el siguiente código de ejemplo, que extraje de mi conjunto de casos de prueba.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
    return i + 2
end,
})

local function callback()
    return 123456
end

local data = assert(cache:get("number", nil, callback))
assert(data == 123458)

Permíteme explicarlo rápidamente. En este caso, la función de callback devuelve el número 123456; en new, la función l1_serializer que establecimos agregará 2 al número entrante antes de establecer el caché L1, que se convierte en 123458. Con una función de serialización como esta, los datos pueden ser más flexibles al convertirse entre L1 y L2.

Resumen

Con múltiples capas de caché, se puede maximizar el rendimiento del servidor, y muchos detalles se ocultan en el medio. En este punto, una biblioteca de envoltura estable y eficiente nos ahorra mucho esfuerzo. También espero que estas dos bibliotecas de envoltura presentadas hoy te ayuden a comprender mejor el almacenamiento en caché.

Finalmente, piensa en esta pregunta: ¿Es necesaria la capa de diccionario compartido del caché? ¿Es posible usar solo lrucache? No dudes en dejar un comentario y compartir tu opinión conmigo, y también te invito a compartir este artículo con más personas para comunicarnos y progresar juntos.