`lua-resty-*` Encapsulación Libera a los Desarrolladores del Caché Multinivel
API7.ai
December 30, 2022
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:
- Primero, no tiene cobertura de casos de prueba, lo que significa que la calidad del código no puede garantizarse consistentemente.
- 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.
- Usar
lua-resty-lrucache
para aumentar el caché a nivel deWorker
, en lugar de solo el caché a nivel deserver
conshared dict
. - 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.
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
Worker
s, 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 Worker
s 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 delWorker
y devolverá directamente siL1
acierta. - Si
L1
no acierta o el caché falla, consulta el cachéL2
entreWorker
s. SiL2
acierta, devuelve y almacena el resultado enL1
. - 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 datosL3
.
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.