Consejos para una mejora de rendimiento 10x en OpenResty: Estructura de datos `Table`
API7.ai
December 9, 2022
En OpenResty, además de los frecuentes problemas de rendimiento con las operaciones de string
, las operaciones con table
también son un obstáculo para el rendimiento. En artículos anteriores, hemos cubierto funciones relacionadas con table
de manera esporádica, pero no específicamente en términos de mejoras de rendimiento. Hoy, te llevaré a través del impacto en el rendimiento de las operaciones con table
.
Hay dos razones principales por las que los desarrolladores saben poco sobre la optimización de rendimiento relacionada con table
en comparación con las operaciones de string
.
- El Lua utilizado en OpenResty es una rama de LuaJIT mantenida por ellos mismos, en lugar de LuaJIT estándar o Lua estándar. La mayoría de los desarrolladores no son conscientes de esta diferencia y tienden a usar la biblioteca
table
de Lua estándar para escribir código en OpenResty. - Ya sea en LuaJIT estándar o en la rama de LuaJIT mantenida por OpenResty, la documentación relacionada con las operaciones de
table
está oculta y es difícil de encontrar para los desarrolladores. Además, no hay código de ejemplo en la documentación, por lo que los desarrolladores necesitan buscar ejemplos en proyectos de código abierto.
Estos dos puntos hacen que aprender OpenResty tenga una barrera cognitiva relativamente alta, lo que lleva a resultados polarizados: los desarrolladores veteranos de OpenResty pueden escribir código de muy alto rendimiento, mientras que aquellos que recién comienzan se preguntarán si el alto rendimiento de OpenResty es real. Pero después de aprender esta lección, podrás cruzar fácilmente la barrera de percepción y lograr una mejora de rendimiento de 10 veces.
Antes de entrar en detalles sobre la optimización de table
, me gustaría enfatizar un principio simple relacionado con la optimización de table
.
Intenta reutilizar las tablas y evita la creación innecesaria de tablas.
Introduciremos la optimización en términos de creación de table
, inserción de elementos, vaciado y uso de bucles.
Array pre-generado
El primer paso es crear un array. En Lua, la forma en que creamos un array es simple.
local t = {}
La línea de código anterior crea un array vacío. También puedes agregar los datos inicializados al crearlo:
local color = {first = "red", "blue", third = "green", "yellow"}
Sin embargo, la segunda forma de escribirlo es más costosa en términos de rendimiento, ya que implica la asignación de espacio, resize
y rehash
del array cada vez que se agrega o elimina un elemento.
Entonces, ¿cómo debería optimizarse? La idea común de optimización es intercambiar espacio por tiempo. Dado que el cuello de botella aquí es la asignación dinámica de espacio en el array, podemos pre-generar un array de un tamaño especificado. Aunque esto puede desperdiciar algo de espacio en memoria, las múltiples asignaciones de espacio, resize
y rehash
se pueden combinar en una sola, lo que es mucho más eficiente.
La función table.new(narray, nhash)
en LuaJIT fue agregada.
Esta función pre-asigna el tamaño especificado del array y del espacio hash en lugar de crecer automáticamente al insertar elementos. Este es el significado de sus dos parámetros, narray
y nhash
.
Aquí hay un ejemplo simple para ver cómo usarla. Dado que esta función es una extensión de LuaJIT, necesitamos requerirla antes de poder usarla.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
Además, debido a que OpenResty anterior no estaba completamente vinculado a LuaJIT y aún soporta Lua estándar, algunos códigos antiguos harán esto para compatibilidad. Si no se encuentra la función table.new
, se simulará una función vacía para garantizar la uniformidad del llamador.
local ok, new_tab = pcall(require, "table.new")
if not ok then
new_tab = function (narr, nrec) return {} end
end
Cálculo manual del subíndice de table
Una vez que tienes un objeto table
, el siguiente paso es agregarle elementos. La forma más directa de insertar un elemento es llamar a la función table.insert
:
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
table.insert(t, i)
end
Alternativamente, primero obtén la longitud del array actual e inserta elementos usando índices:
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[#t + 1] = i
end
Sin embargo, ambos métodos necesitan calcular primero la longitud del array y luego agregar nuevos elementos. Esta operación tiene una complejidad de tiempo de O(n)
. En el ejemplo de código anterior, el bucle for
calculará la longitud del array 100
veces, por lo que el rendimiento no es bueno, y cuanto más grande sea el array, menor será el rendimiento.
Veamos cómo la biblioteca oficial lua-resty-redis
resuelve este problema.
local function _gen_req(args)
local nargs = #args
local req = new_tab(nargs * 5 + 1, 0)
req[1] = "*" .. nargs .. "\r\n"
local nbits = 2
for i = 1, nargs do
local arg = args[i]
req[nbits] = "$"
req[nbits + 1] = #arg
req[nbits + 2] = "\r\n"
req[nbits + 3] = arg
req[nbits + 4] = "\r\n"
nbits = nbits + 5
end
return req
end
Esta función pre-genera el array req
, cuyo tamaño está determinado por los parámetros de entrada de la función, para garantizar que se desperdicie la menor cantidad de espacio posible.
Utiliza la variable nbits
para mantener manualmente el subíndice de req
, en lugar de usar la función table.insert
incorporada en Lua y el operador #
para obtener la longitud.
Puedes ver que en el bucle for
, algunas operaciones como nbits + 1
están insertando elementos directamente como subíndices y manteniendo el subíndice en el valor correcto al final con nbits = nbits + 5
.
La ventaja de esto es obvia, omite la operación O(n)
de obtener el tamaño del array y en su lugar accede directamente con el índice, y la complejidad del tiempo se convierte en O(1)
. La desventaja también es obvia, reduce la legibilidad del código y la probabilidad de error aumenta enormemente, por lo que es una espada de doble filo.
Reutilización de una sola table
Dado que el costo de crear una table
es tan alto, naturalmente queremos reutilizarla tanto como sea posible. Sin embargo, hay condiciones para la reutilización. Primero necesitamos limpiar los datos originales en la table
para evitar impactos en el siguiente usuario.
Aquí es donde la función table.clear
es útil. Por su nombre puedes ver lo que hace, limpia todos los datos en el array, pero la longitud del array no cambia. Es decir, si usas table.new(narray, nhash)
para generar un array de longitud 100, después de limpiarlo, la longitud sigue siendo 100
.
Para que tengas una mejor idea de su implementación, he dado un ejemplo de código a continuación que es compatible con Lua estándar:
local ok, clear_tab = pcall(require, "table.clear")
if not ok then
clear_tab = function (tab)
for k, _ in pairs(tab) do
tab[k] = nil
end
end
end
Como puedes ver, la función clear
establece cada elemento en nil
.
Generalmente, colocamos este tipo de table
circular en el nivel superior de un módulo. De esta manera, cuando usas las funciones en el módulo, puedes decidir si usarlas directamente o después de limpiarlas, dependiendo de tu situación.
Veamos un ejemplo práctico de aplicación. El siguiente pseudo-código está tomado del gateway de API de microservicios de código abierto Apache APISIX, y esta es su lógica al cargar el plugin.
local local_plugins = core.table.new(32, 0)
local function load(plugin_names, wasm_plugin_names)
local processed = {}
for _, name in ipairs(plugin_names) do
if processed[name] == nil then
processed[name] = true
end
end
for _, attrs in ipairs(wasm_plugin_names) do
if processed[attrs.name] == nil then
processed[attrs.name] = attrs
end
end
core.log.warn("new plugins: ", core.json.delay_encode(processed))
for name, plugin in pairs(local_plugins_hash) do
local ty = PLUGIN_TYPE_HTTP
if plugin.type == "wasm" then
ty = PLUGIN_TYPE_HTTP_WASM
end
unload_plugin(name, ty)
end
core.table.clear(local_plugins)
core.table.clear(local_plugins_hash)
for name, value in pairs(processed) do
local ty = PLUGIN_TYPE_HTTP
if type(value) == "table" then
ty = PLUGIN_TYPE_HTTP_WASM
name = value
end
load_plugin(name, local_plugins, ty)
end
-- sort by plugin's priority
if #local_plugins > 1 then
sort_tab(local_plugins, sort_plugin)
end
for i, plugin in ipairs(local_plugins) do
local_plugins_hash[plugin.name] = plugin
if enable_debug() then
core.log.warn("loaded plugin and sort by priority:",
" ", plugin.priority,
" name: ", plugin.name)
end
end
_M.load_times = _M.load_times + 1
core.log.info("load plugin times: ", _M.load_times)
return true
end
Puedes ver que el array local_plugins
es la variable de nivel superior para el módulo plugin
. Al inicio de la función load
, se limpia la table
, y se genera una nueva lista de plugins basada en la situación actual.
tablepool
Hasta ahora, has dominado la optimización de recorrer una sola tabla. Luego puedes ir un paso más allá y usar un pool de caché para mantener múltiples tablas disponibles para su acceso en cualquier momento, que es la función del lua-tablepool
oficial.
El siguiente código muestra el uso básico de un pool de tablas. Podemos obtener una tabla de un pool especificado y liberarla de nuevo cuando terminemos de usarla:
local tablepool = require "tablepool"
local tablepool_fetch = tablepool.fetch
local tablepool_release = tablepool.release
local pool_name = "some_tag"
local function do_sth()
local t = tablepool_fetch(pool_name, 10, 0)
-- -- using t for some purposes
tablepool_release(pool_name, t)
end
tablepool
utiliza varios de los métodos que presentamos anteriormente, y tiene menos de cien líneas de código, por lo que recomiendo buscarlo y estudiarlo tú mismo. Aquí, principalmente presentaré sus dos APIs.
El primero es el método fetch
, que toma los mismos argumentos que table.new
, pero con un pool_name
adicional. fetch
llamará a table.new
para crear un nuevo array si no hay un array libre en el pool.
tablepool.fetch(pool_name, narr, nrec)
El segundo es release
, una función que devuelve la tabla al pool. Entre sus argumentos, el último, no_clear
, se usa para configurar si se llama a table.clear
para limpiar el array.
tablepool.release(pool_name, tb, [no_clear])
¿Ves cómo todos los métodos que presentamos anteriormente ahora están relacionados? Sin embargo, ten cuidado de no abusar de tablepool
por esta razón. tablepool
no se usa mucho en proyectos reales. Por ejemplo, no lo usa Kong, y APISIX tiene solo unas pocas llamadas relacionadas con él. En la mayoría de los casos, incluso sin la encapsulación de esta capa tablepool
, es suficiente para que nosotros usemos.
Resumen
La optimización de rendimiento, un área difícil en OpenResty, es un punto caliente. Hoy te presenté consejos de optimización de rendimiento relacionados con table
. Espero que puedan ayudarte con tu proyecto real.
Finalmente, piensa en una pregunta: ¿Puedes hacer una prueba de rendimiento tú mismo y comparar la diferencia de rendimiento antes y después de usar técnicas de optimización relacionadas con table
? Nuevamente, bienvenido a dejar un comentario y comunicarte conmigo, ya que me encantaría conocer tu práctica y puntos de vista. Bienvenido a compartir este artículo para que más personas puedan participar juntas.