Conseils pour une amélioration des performances par 10x dans OpenResty : Structure de données `Table`

API7.ai

December 9, 2022

OpenResty (NGINX + Lua)

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.

  1. 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.
  2. 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.