Claves para el Alto Rendimiento: `shared dict` y Caché `lru`
API7.ai
December 22, 2022
En el artículo anterior, introduje las técnicas de optimización de OpenResty y las herramientas de ajuste de rendimiento, que involucran string
, table
, Lua API
, LuaJIT
, SystemTap
, gráficos de llamadas
, etc.
Estos son los pilares de la optimización del sistema, y es necesario dominarlos bien. Sin embargo, solo conocerlos no es suficiente para enfrentar escenarios comerciales reales. En un entorno comercial más complejo, mantener un alto rendimiento es un trabajo sistemático, no solo optimización a nivel de código y puerta de enlace. Involucrará varios aspectos como la base de datos, la red, el protocolo, la caché, el disco, etc., que es el significado de la existencia de un arquitecto.
En el artículo de hoy, echemos un vistazo al componente que juega un papel muy crítico en la optimización del rendimiento: la caché, y veamos cómo se usa y optimiza en OpenResty.
Caché
A nivel de hardware, la mayoría del hardware de las computadoras utiliza cachés para mejorar la velocidad. Por ejemplo, las CPU tienen cachés de varios niveles, y las tarjetas RAID tienen cachés de lectura y escritura. A nivel de software, la base de datos que utilizamos es un muy buen ejemplo de diseño de caché. Hay cachés en la optimización de sentencias SQL, el diseño de índices y la lectura y escritura en disco.
Aquí, sugiero que aprendas sobre los diversos mecanismos de caché de MySQL antes de diseñar tu propia caché. El material que te recomiendo es el excelente libro High Performance MySQL: Optimization, Backups, and Replication. Cuando estaba a cargo de la base de datos hace muchos años, este libro me benefició mucho, y muchos otros escenarios de optimización posteriores también tomaron prestado el diseño de MySQL.
Volviendo a la caché, sabemos que un sistema de caché en un entorno de producción necesita encontrar la mejor solución basada en sus escenarios comerciales y cuellos de botella del sistema. Es un arte de equilibrio.
En general, la caché tiene dos principios.
- Uno es que cuanto más cerca esté de la solicitud del usuario, mejor. Por ejemplo, no envíes solicitudes HTTP si puedes usar una caché local. Envíala al sitio de origen si puedes usar CDN, y no la envíes a la base de datos si puedes usar la caché de OpenResty.
- El segundo es intentar resolverlo con este proceso y la caché local. Porque a través de procesos, máquinas e incluso salas de servidores, la sobrecarga de red de la caché será muy grande, lo que será muy evidente en escenarios de alta concurrencia.
En OpenResty, el diseño y uso de la caché también siguen estos dos principios. Hay dos componentes de caché en OpenResty: caché shared dict
y caché lru
. El primero solo puede almacenar objetos de tipo string
, y solo hay una copia de los datos en caché, que puede ser accedida por cada worker, por lo que a menudo se usa para la comunicación de datos entre workers. El segundo puede almacenar en caché todos los objetos Lua, pero solo pueden ser accedidos dentro de un solo proceso worker. Hay tantos datos en caché como workers.
Las siguientes dos tablas simples pueden ilustrar la diferencia entre shared dict
y lru
cache:
Nombre del componente de caché | Alcance de acceso | Tipo de datos en caché | Estructura de datos | Se pueden obtener datos obsoletos | Número de APIs | Uso de memoria |
---|---|---|---|---|---|---|
shared dict | Entre múltiples workers | objetos string | dict, queue | sí | 20+ | una pieza de datos |
lru cache | dentro de un solo worker | todos los objetos Lua | dict | no | 4 | n copias de datos (N = número de workers) |
shared dict
y lru
cache no son buenos ni malos. Deben usarse juntos según tu escenario.
- Si no necesitas compartir datos entre workers, entonces
lru
puede almacenar en caché tipos de datos complejos como arrays y funciones y tiene el mayor rendimiento, por lo que es la primera opción. - Pero si necesitas compartir datos entre workers, puedes agregar una caché
shared dict
basada en la cachélru
para formar una arquitectura de caché de dos niveles.
A continuación, veamos estos dos métodos de almacenamiento en caché en detalle.
Caché Shared dict
En el artículo de Lua, hemos hecho una introducción específica a shared dict
, aquí hay una breve revisión de su uso:
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
Necesitas declarar la zona de memoria dogs
en el archivo de configuración de NGINX con anticipación, y luego se puede usar en el código Lua. Si descubres que el espacio asignado a dogs no es suficiente durante el uso, necesitas modificar primero el archivo de configuración de NGINX y luego recargar NGINX para que surta efecto. Porque no podemos expandir y reducir en tiempo de ejecución.
A continuación, centrémonos en varios problemas relacionados con el rendimiento en la caché shared dict
.
Serialización de datos en caché
El primer problema es la serialización de los datos en caché. Dado que solo se pueden almacenar objetos string
en el shared dict
, si deseas almacenar un array, debes serializar una vez al establecer y deserializar una vez al obtener:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", require("cjson").encode({a=111}))
print(require("cjson").decode(dict:get("Tom")).a)'
Sin embargo, tales operaciones de serialización y deserialización son muy intensivas en CPU. Si tantas operaciones son por solicitud, puedes ver su consumo en el gráfico de llamadas.
Entonces, ¿cómo evitar este consumo en los diccionarios compartidos? No hay una buena manera aquí, ya sea evitar poner el array en el diccionario compartido a nivel comercial; o concatenar manualmente las cadenas en formato JSON por ti mismo. Por supuesto, esto también traerá un consumo de rendimiento de concatenación de cadenas y puede ocultar más errores.
La mayor parte de la serialización se puede desmontar a nivel comercial. Puedes desglosar el contenido del array y almacenarlo en el diccionario compartido como cadenas. Si no funciona, también puedes almacenar el array en lru
, y usar el espacio de memoria a cambio de la conveniencia y el rendimiento del programa.
Además, la clave en la caché también debe ser lo más corta y significativa posible, ahorrando espacio y facilitando la depuración posterior.
Datos obsoletos
También hay un método get_stale
para leer datos en el shared dict
. En comparación con el método get
, tiene un valor de retorno adicional para datos expirados:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56, 0.01)
ngx.sleep(0.02)
local val, flags, stale = dict:get_stale("Tom")
print(val)'
En el ejemplo anterior, los datos solo se almacenan en el shared dict
durante 0.01
segundos, y los datos han expirado 0.02
segundos después de la configuración. En este momento, los datos no se obtendrán a través de la interfaz get
, pero también se pueden obtener datos expirados a través de get_stale
. La razón por la que uso la palabra "posible" aquí es porque el espacio ocupado por los datos expirados tiene una cierta probabilidad de ser reciclado y luego utilizado para otros datos. Este es el algoritmo LRU
.
Al ver esto, puedes tener dudas: ¿de qué sirve obtener datos expirados? No olvides que lo que almacenamos en el shared dict
son datos en caché. Incluso si los datos en caché expiran, no significa que los datos de origen deban actualizarse.
Por ejemplo, la fuente de datos se almacena en MySQL. Después de obtener los datos de MySQL, establecemos un tiempo de espera de cinco segundos en el shared dict
. Luego, cuando los datos expiran, tenemos dos opciones:
- Cuando los datos no existen, ir a MySQL para consultar nuevamente y poner el resultado en la caché.
- Determinar si los datos de MySQL han cambiado. Si no han cambiado, leer los datos expirados en la caché, modificar su tiempo de expiración y hacer que continúe siendo efectivo.
La segunda es una solución más optimizada que puede interactuar con MySQL lo menos posible para que todas las solicitudes de los clientes obtengan datos de la caché más rápida.
En este momento, cómo juzgar si los datos en la fuente de datos han cambiado se convierte en un problema que debemos considerar y resolver. A continuación, tomemos la caché lru
como ejemplo para ver cómo un proyecto real resuelve este problema.
Caché lru
Solo hay 5 interfaces para la caché lru
: new
, set
, get
, delete
y flush_all
. Solo la interfaz get
está relacionada con el problema anterior. Primero entendamos cómo se usa esta interfaz:
resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'
Puedes ver que en la caché lru
, el segundo valor de retorno de la interfaz get es directamente stale_data
, en lugar de dividirse en dos APIs diferentes, get
y get_stale
, como en shared dict
. Tal encapsulación de interfaz es más amigable para usar datos expirados.
Generalmente recomendamos usar números de versión para distinguir diferentes datos en proyectos reales. De esta manera, su número de versión también cambiará después de que los datos cambien. Por ejemplo, un índice modificado en etcd puede usarse como un número de versión para marcar si los datos han cambiado. Con el concepto del número de versión, podemos hacer una simple encapsulación secundaria de la caché lru
. Por ejemplo, mira el siguiente pseudocódigo, tomado de lrucache
local function (key, version, create_obj_fun, ...)
local obj, stale_obj = lru_obj:get(key)
-- Si los datos no han expirado y la versión no ha cambiado, devuelve los datos en caché directamente
if obj and obj._cache_ver == version then
return obj
end
-- Si los datos han expirado, pero aún se pueden obtener, y la versión no ha cambiado, devuelve directamente los datos expirados en la caché
if stale_obj and stale_obj._cache_ver == version then
lru_obj:set(key, obj, item_ttl)
return stale_obj
end
-- Si no se encuentran datos expirados, o el número de versión ha cambiado, obtén los datos de la fuente de datos
local obj, err = create_obj_fun(...)
obj._cache_ver = version
lru_obj:set(key, obj, item_ttl)
return obj, err
end
Desde este código, puedes ver que al introducir el concepto del número de versión, usamos completamente los datos expirados para reducir la presión en la fuente de datos y lograr un rendimiento óptimo cuando el número de versión no cambia.
Además, en la solución anterior, hay una gran optimización potencial en que separamos la clave y el número de versión y usamos el número de versión como un atributo del valor.
Sabemos que el enfoque más convencional es escribir el número de versión en la clave. Por ejemplo, el valor de la clave es key_1234
. Esta práctica es muy común, pero en el entorno de OpenResty, esto es un desperdicio. ¿Por qué dices eso?
Déjame darte un ejemplo, y lo entenderás. Si el número de versión cambia cada minuto, entonces key_1234
se convertirá en key_1235
después de un minuto, y se regenerarán 60 claves diferentes y 60 valores en una hora. Esto también significa que Lua GC necesita reciclar los objetos Lua detrás de 59 pares clave-valor. La creación de objetos y GC consumirá más recursos si actualizas con más frecuencia.
Por supuesto, estos consumos también se pueden evitar simplemente moviendo el número de versión de la clave al valor. No importa con qué frecuencia se actualice una clave, solo existirán dos objetos Lua fijos. Se puede ver que tales técnicas de optimización son muy ingeniosas. Sin embargo, detrás de las técnicas simples e ingeniosas, necesitas comprender profundamente la API y el mecanismo de caché de OpenResty.
Resumen
Aunque la documentación de OpenResty es relativamente detallada, necesitas experimentar y comprender cómo combinarla con el negocio para producir el mayor efecto de optimización. En muchos casos, solo hay una o dos frases en el documento, como datos obsoletos, pero tendrá una gran diferencia de rendimiento.
Entonces, ¿has tenido una experiencia similar al usar OpenResty? Bienvenido a dejar un mensaje para compartir con nosotros, y te invitamos a compartir este artículo, aprendamos y progresemos juntos.