Conseils pour une amélioration des performances par 10x dans OpenResty : Structure de données `Table`
API7.ai
December 9, 2022
Dans OpenResty, en plus des problèmes de performance fréquents liés aux opérations sur les string
, les opérations sur les table
constituent également un obstacle en termes de performance. Dans les articles précédents, nous avons abordé les fonctions liées aux table
de manière sporadique, mais pas spécifiquement en termes d'amélioration des performances. Aujourd'hui, je vais vous expliquer l'impact des opérations sur les table
en termes de performance.
Il y a deux raisons principales pour lesquelles les développeurs connaissent peu les optimisations de performance liées aux table
par rapport aux opérations sur les string
.
- Le Lua utilisé dans OpenResty est une branche auto-maintenue de LuaJIT, plutôt que le LuaJIT standard ou le Lua standard. La plupart des développeurs ne sont pas conscients de cette différence et ont tendance à utiliser la bibliothèque
table
standard de Lua pour écrire du code OpenResty. - Que ce soit dans le LuaJIT standard ou dans la branche LuaJIT maintenue par OpenResty elle-même, la documentation liée aux opérations sur les
table
est cachée en profondeur et difficile à trouver pour les développeurs. De plus, il n'y a pas d'exemples de code dans la documentation, ce qui oblige les développeurs à chercher des exemples dans des projets open source.
Ces deux points rendent l'apprentissage d'OpenResty relativement difficile en termes de barrière cognitive, conduisant à des résultats polarisés - les développeurs expérimentés d'OpenResty peuvent écrire du code très performant, tandis que ceux qui débutent se demanderont si la haute performance d'OpenResty est réelle. Mais après avoir appris cette leçon, vous pourrez facilement franchir la barrière de perception et obtenir une amélioration de performance de 10x.
Avant d'entrer dans les détails de l'optimisation des table
, je voudrais souligner un principe simple lié à l'optimisation des table
.
Essayez de réutiliser les tables et évitez de créer des tables inutiles.
Nous aborderons l'optimisation en termes de création de table
, d'insertion d'éléments, de vidage et d'utilisation en boucle.
Tableau pré-généré
La première étape consiste à créer un tableau. En Lua, la manière de créer un tableau est simple.
local t = {}
La ligne de code ci-dessus crée un tableau vide. Vous pouvez également ajouter les données initialisées lors de la création :
local color = {first = "red", "blue", third = "green", "yellow"}
Cependant, la deuxième méthode est plus coûteuse en termes de performance car elle implique une allocation d'espace, un resize
et un rehash
du tableau à chaque fois qu'un élément est ajouté ou supprimé.
Alors, comment l'optimiser ? L'idée d'optimisation courante est d'échanger de l'espace contre du temps. Comme le goulot d'étranglement ici est l'allocation dynamique de l'espace du tableau, nous pouvons pré-générer un tableau de taille spécifiée. Bien que cela puisse gaspiller un peu de mémoire, les multiples allocations d'espace, resize
et rehash
peuvent être combinés en une seule opération, ce qui est beaucoup plus efficace.
La fonction table.new(narray, nhash)
dans LuaJIT a été ajoutée.
Cette fonction pré-alloue la taille spécifiée pour l'espace du tableau et de la table de hachage au lieu de s'agrandir lors de l'insertion d'éléments. C'est la signification de ses deux paramètres, narray
et nhash
.
Voici un exemple simple pour voir comment l'utiliser. Comme cette fonction est une extension de LuaJIT, nous devons d'abord la requérir pour pouvoir l'utiliser.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
De plus, comme les versions précédentes d'OpenResty ne liaient pas complètement LuaJIT et supportaient encore le Lua standard, certains codes plus anciens feront ceci pour la compatibilité. Si la fonction table.new
n'est pas trouvée, une fonction vide sera simulée pour assurer l'uniformité de l'appelant.
local ok, new_tab = pcall(require, "table.new")
if not ok then
new_tab = function (narr, nrec) return {} end
end
Calcul manuel de l'indice de table
Une fois que vous avez un objet table
, l'étape suivante consiste à y ajouter des éléments. La manière la plus directe d'insérer un élément est d'appeler la fonction table.insert
:
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
table.insert(t, i)
end
Alternativement, vous pouvez d'abord obtenir la longueur du tableau actuel et insérer des éléments en utilisant des indices :
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[#t + 1] = i
end
Cependant, les deux méthodes nécessitent de calculer d'abord la longueur du tableau avant d'ajouter de nouveaux éléments. Cette opération a une complexité temporelle de O(n)
. Dans l'exemple de code ci-dessus, la boucle for
calculera la longueur du tableau 100
fois, donc la performance n'est pas bonne, et plus le tableau est grand, plus la performance sera faible.
Voyons comment la bibliothèque officielle lua-resty-redis
résout ce problème.
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
Cette fonction pré-génère le tableau req
, dont la taille est déterminée par les paramètres d'entrée de la fonction, pour s'assurer que le moins d'espace possible est gaspillé.
Elle utilise la variable nbits
pour maintenir manuellement l'indice du req
, au lieu d'utiliser la fonction intégrée table.insert
de Lua et l'opérateur #
pour obtenir la longueur.
Vous pouvez voir que dans la boucle for, certaines opérations comme nbits + 1
insèrent directement des éléments en tant qu'indices et maintiennent l'indice à la bonne valeur à la fin avec nbits = nbits + 5
.
L'avantage est évident, cela évite l'opération O(n)
pour obtenir la taille du tableau et accède directement avec l'indice, et la complexité temporelle devient O(1)
. L'inconvénient est également évident, cela réduit la lisibilité du code, et la probabilité d'erreur est grandement augmentée, donc c'est une épée à double tranchant.
Réutilisation d'une seule table
Comme le coût de création d'une table
est si élevé, nous voulons naturellement la réutiliser autant que possible. Cependant, il y a des conditions pour la réutilisation. Nous devons d'abord nettoyer les données originales dans la table
pour éviter d'affecter l'utilisateur suivant.
C'est là que la fonction table.clear
est utile. D'après son nom, vous pouvez voir ce qu'elle fait, elle efface toutes les données du tableau, mais la longueur du tableau ne change pas. C'est-à-dire que si vous utilisez table.new(narray, nhash)
pour générer un tableau de longueur 100, après l'avoir effacé, la longueur est toujours 100
.
Pour vous donner une meilleure idée de son implémentation, j'ai donné un exemple de code ci-dessous qui est compatible avec le Lua standard :
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
Comme vous pouvez le voir, la fonction clear
définit chaque élément à nil
.
Généralement, nous plaçons ce type de table
cyclique au niveau supérieur d'un module. De cette façon, lorsque vous utilisez les fonctions du module, vous pouvez décider de les utiliser directement ou après les avoir effacées, selon votre situation.
Regardons un exemple d'application pratique. Le pseudo-code suivant est tiré de la passerelle API de microservices open source Apache APISIX, et c'est sa logique lors du chargement du 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
Vous pouvez voir que le tableau local_plugins
est la variable de niveau supérieur pour le module plugin
. Au début de la fonction load, la table
est effacée, et une nouvelle liste de plugins est générée en fonction de la situation actuelle.
tablepool
À ce stade, vous maîtrisez l'optimisation du cycle d'une seule table. Ensuite, vous pouvez aller plus loin et utiliser un pool de cache pour garder plusieurs tables disponibles à tout moment, c'est la fonction du lua-tablepool
officiel.
Le code suivant montre l'utilisation de base d'un pool de tables. Nous pouvons récupérer une table d'un pool spécifié et la libérer lorsque nous avons fini de l'utiliser :
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
utilise plusieurs des méthodes que nous avons introduites précédemment, et il a moins de cent lignes de code, donc je vous recommande fortement de le rechercher et de l'étudier vous-même. Ici, je vais principalement présenter ses deux API.
Le premier est la méthode fetch
, qui prend les mêmes arguments que table.new
, mais avec un pool_name
en plus. fetch
appellera table.new
pour créer un nouveau tableau s'il n'y a pas de tableau libre dans le pool.
tablepool.fetch(pool_name, narr, nrec)
Le second est release
, une fonction qui remet la table dans le pool. Parmi ses arguments, le dernier, no_clear
, est utilisé pour configurer s'il faut appeler table.clear
pour effacer le tableau.
tablepool.release(pool_name, tb, [no_clear])
Voyez comment toutes les méthodes que nous avons introduites précédemment sont maintenant corrélées ? Cependant, faites attention à ne pas abuser de tablepool
pour cette raison. tablepool
n'est pas beaucoup utilisé dans les projets réels. Par exemple, il n'est pas utilisé par Kong, et APISIX n'a que quelques appels liés à cela. Dans la plupart des cas, même sans l'encapsulation de cette couche tablepool
, cela nous suffit.
Résumé
L'optimisation des performances, un domaine difficile dans OpenResty, est un point chaud. Aujourd'hui, j'ai présenté des astuces d'optimisation des performances liées aux table
. J'espère qu'elles pourront vous aider dans votre projet réel.
Enfin, pensez à une question : Pouvez-vous faire un test de performance vous-même et comparer la différence de performance avant et après avoir utilisé les techniques d'optimisation liées aux table
? Encore une fois, n'hésitez pas à laisser un commentaire et à communiquer avec moi, car j'aimerais connaître votre pratique et vos points de vue. N'hésitez pas à partager cet article pour que plus de personnes puissent y participer ensemble.