La magie de la communication entre les workers NGINX : l'une des structures de données les plus importantes, le `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

Comme nous l'avons dit dans l'article précédent, la table est la seule structure de données en Lua. Cela correspond au shared dict, qui est la structure de données la plus importante que vous pouvez utiliser dans la programmation OpenResty. Il prend en charge le stockage de données, la lecture, le comptage atomique et les opérations de file d'attente.

Basé sur le shared dict, vous pouvez implémenter la mise en cache et la communication entre plusieurs Workers, ainsi que la limitation de débit, les statistiques de trafic et d'autres fonctions. Vous pouvez utiliser shared dict comme un simple Redis, sauf que les données dans shared dict ne sont pas persistantes, donc vous devez considérer la perte des données stockées.

Plusieurs méthodes de partage de données

En écrivant le code Lua OpenResty, vous rencontrerez inévitablement le partage de données entre différents Workers à différentes phases de la requête. Vous pourriez également avoir besoin de partager des données entre le code Lua et le code C.

Donc, avant de présenter formellement les API de shared dict, comprenons d'abord les méthodes courantes de partage de données dans OpenResty et apprenons comment choisir une méthode de partage de données plus appropriée selon la situation actuelle.

La première est les variables dans NGINX. Elle peut partager des données entre les modules C de NGINX. Naturellement, elle peut également partager des données entre les modules C et le lua-nginx-module fourni par OpenResty, comme dans le code suivant.

location /foo {
     set $my_var ''; # cette ligne est nécessaire pour créer $my_var au moment de la configuration
     content_by_lua_block {
         ngx.var.my_var = 123;
         ...
     }
 }

Cependant, utiliser les variables NGINX pour partager des données est lent car cela implique des recherches de hachage et des allocations de mémoire. De plus, cette approche a la limitation qu'elle ne peut être utilisée que pour stocker des chaînes de caractères et ne peut pas supporter des types Lua complexes.

La deuxième est ngx.ctx, qui peut partager des données entre différentes phases de la même requête. C'est une table Lua normale, donc elle est rapide et peut stocker divers objets Lua. Son cycle de vie est au niveau de la requête ; lorsque la requête se termine, ngx.ctx est détruit.

Voici un scénario d'utilisation typique où nous utilisons ngx.ctx pour mettre en cache des appels coûteux comme les variables NGINX et l'utiliser à différentes étapes.

location /test {
     rewrite_by_lua_block {
         ngx.ctx.host = ngx.var.host
     }
     access_by_lua_block {
        if (ngx.ctx.host == 'api7.ai') then
            ngx.ctx.host = 'test.com'
        end
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.host)
     }
 }

Dans ce cas, si vous utilisez curl pour y accéder.

curl -i 127.0.0.1:8080/test -H 'host:api7.ai'

Il affichera alors test.com, montrant que ngx.ctx partage des données à différentes étapes. Bien sûr, vous pouvez également modifier l'exemple ci-dessus en sauvegardant des objets plus complexes comme des tables au lieu de simples chaînes de caractères pour voir si cela répond à vos attentes.

Cependant, une note spéciale ici est que, comme le cycle de vie de ngx.ctx est au niveau de la requête, il ne met pas en cache au niveau du module. Par exemple, j'ai fait l'erreur d'utiliser ceci dans mon fichier foo.lua.

local ngx_ctx = ngx.ctx

local function bar()
    ngx_ctx.host =  'test.com'
end

Nous devrions appeler et mettre en cache au niveau de la fonction.

local ngx = ngx

local function bar()
    ngx_ctx.host =  'test.com'
end

Il y a beaucoup plus de détails sur ngx.ctx, que nous explorerons plus tard dans la section d'optimisation des performances.

La troisième approche utilise des variables au niveau du module pour partager des données entre toutes les requêtes au sein du même Worker. Contrairement aux variables NGINX et ngx.ctx précédentes, cette approche est un peu moins compréhensible. Mais ne vous inquiétez pas, le concept est abstrait, et le code vient en premier, donc regardons un exemple pour comprendre une variable au niveau du module.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.get_age(name)
    return data[name]
end

return _M

La configuration dans nginx.conf est la suivante.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata.get_age("dog"))
     }
 }

Dans cet exemple, mydata est un module qui n'est chargé qu'une seule fois par le processus Worker, et toutes les requêtes traitées par le Worker après cela partagent le code et les données du module mydata.

Naturellement, la variable data dans le module mydata est une variable au niveau du module située au sommet du module, c'est-à-dire au début du module, et est accessible à toutes les fonctions.

Donc, vous pouvez mettre les données qui doivent être partagées entre les requêtes dans la variable de niveau supérieur du module. Cependant, il est essentiel de noter que nous n'utilisons généralement cette méthode que pour stocker des données en lecture seule. Si des opérations d'écriture sont impliquées, vous devez être très prudent car il pourrait y avoir une condition de concurrence, qui est un bug difficile à localiser.

Nous pouvons expérimenter cela avec l'exemple le plus simplifié suivant.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.incr_age(name)
    data[name]  = data[name] + 1
    return data[name]
end

return _M

Dans le module, nous ajoutons la fonction incr_age, qui modifie les données dans la table data.

Ensuite, dans le code d'appel, nous ajoutons la ligne la plus critique ngx.sleep(5), où sleep est une opération yield.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata. incr_age("dog"))
         ngx.sleep(5) -- API yield
         ngx.say(mydata. incr_age("dog"))
     }
 }

Sans cette ligne de code sleep (ou d'autres opérations d'IO non bloquantes, comme l'accès à Redis, etc.), il n'y aurait pas d'opération de yield, pas de concurrence, et la sortie finale serait séquentielle.

Mais lorsque nous ajoutons cette ligne de code, même si ce n'est que pendant 5 secondes de sommeil, une autre requête appellera probablement la fonction mydata.incr_age et modifiera la valeur de la variable, ce qui entraînera des nombres de sortie finaux discontinus. La logique n'est pas si simple dans le code réel, et le bug est beaucoup plus difficile à localiser.

Donc, à moins que vous ne soyez sûr qu'il n'y a pas d'opération yield entre les deux qui donnera le contrôle à la boucle d'événements NGINX, je recommande de garder vos variables au niveau du module en lecture seule.

La quatrième et dernière approche utilise shared dict pour partager des données qui peuvent être partagées entre plusieurs workers.

Cette approche est basée sur une implémentation d'arbre rouge-noir, qui fonctionne bien. Mais elle a ses limites : vous devez déclarer la taille de la mémoire partagée dans le fichier de configuration NGINX à l'avance, et cela ne peut pas être modifié au moment de l'exécution :

lua_shared_dict dogs 10m;

Le shared dict ne met également en cache que les données de type string et ne supporte pas les types de données Lua complexes. Cela signifie que lorsque je dois stocker des types de données complexes comme des tables, je devrai utiliser JSON ou d'autres méthodes pour les sérialiser et les désérialiser, ce qui entraînera naturellement une perte de performance importante.

Quoi qu'il en soit, il n'y a pas de solution miracle ici, et il n'y a pas de méthode parfaite pour partager des données. Vous devez combiner plusieurs méthodes selon vos besoins et scénarios.

Shared dict

Nous avons passé beaucoup de temps à apprendre la partie sur le partage de données ci-dessus, et certains d'entre vous pourraient se demander : il semble qu'ils ne soient pas directement liés au shared dict. N'est-ce pas hors sujet ?

En fait, non. Réfléchissez-y : pourquoi y a-t-il un shared dict dans OpenResty ? Rappelez-vous que les trois premières méthodes de partage de données sont toutes au niveau de la requête ou au niveau du Worker individuel. Par conséquent, dans l'implémentation actuelle d'OpenResty, seul shared dict peut accomplir le partage de données entre Workers, permettant la communication entre Workers, ce qui est la raison de son existence.

À mon avis, comprendre pourquoi une technologie existe et comprendre ses différences et avantages par rapport à d'autres technologies similaires est bien plus important que de simplement être compétent dans l'appel des API qu'elle fournit. Cette vision technique vous donne un degré de prévoyance et de perspicacité et est sans doute une différence importante entre les ingénieurs et les architectes.

Revenons au shared dict, qui fournit plus de 20 API Lua au public, toutes atomiques, donc vous n'avez pas à vous soucier de la concurrence dans le cas de plusieurs Workers et de haute concurrence.

Ces API ont toutes des documents officiels détaillés, donc je ne vais pas toutes les aborder. Je veux souligner à nouveau qu'aucun cours technique ne peut remplacer une lecture attentive de la documentation officielle. Personne ne peut sauter ces procédures chronophages et stupides.

Ensuite, continuons à regarder les API de shared dict, qui peuvent être divisées en trois catégories : lecture/écriture de dict, opération de file d'attente et gestion.

Lecture/écriture de dict

Regardons d'abord les classes de lecture et d'écriture de dict. Dans la version originale, il n'y avait que des API pour les classes de lecture et d'écriture de dict, les fonctionnalités les plus courantes des dictionnaires partagés. Voici l'exemple le plus simple.

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                               dict:set("Tom", 56)
                               print(dict:get("Tom"))'

En plus de set, OpenResty fournit également quatre méthodes d'écriture : safe_set, add, safe_add et replace. La signification du préfixe safe ici est que si la mémoire est pleine, au lieu d'éliminer les anciennes données selon LRU, l'écriture échouera et retournera une erreur no memory.

En plus de get, OpenResty fournit également la méthode get_stale pour lire les données, qui a une valeur de retour supplémentaire pour les données expirées par rapport à la méthode get.

value, flags, stale = ngx.shared.DICT:get_stale(key)

Vous pouvez également appeler la méthode delete pour supprimer la clé spécifiée, ce qui équivaut à set(key, nil).

Opération de file d'attente

Passons aux opérations de file d'attente, c'est un ajout ultérieur à OpenResty qui fournit une interface similaire à Redis. Chaque élément dans une file d'attente est décrit par ngx_http_lua_shdict_list_node_t.

typedef struct {
    ngx_queue_t queue;
    uint32_t value_len;
    uint8_t value_type;
    u_char data[1];
} ngx_http_lua_shdict_list_node_t;

J'ai posté le PR de ces API de file d'attente dans l'article. Si cela vous intéresse, vous pouvez suivre la documentation, les cas de test et le code source pour analyser l'implémentation spécifique.

Cependant, il n'y a pas d'exemples de code correspondants pour les cinq API de file d'attente suivantes dans la documentation, donc je vais les présenter brièvement ici.

  • lpush``/``rpush signifie ajouter des éléments aux deux extrémités de la file d'attente.
  • lpop``/``rpop, qui retire des éléments aux deux extrémités de la file d'attente.
  • llen, qui indique le nombre d'éléments retournés à la file d'attente.

N'oublions pas un autre outil utile dont nous avons parlé dans le dernier article : les cas de test. Nous pouvons généralement trouver le code correspondant dans un cas de test s'il n'est pas dans la documentation. Les tests liés à la file d'attente sont précisément dans le fichier 145-shdict-list.t.

=== TEST 1: lpush & lpop
--- http_config
    lua_shared_dict dogs 1m;
--- config
    location = /test {
        content_by_lua_block {
            local dogs = ngx.shared.dogs

            local len, err = dogs:lpush("foo", "bar")
            if len then
                ngx.say("push success")
            else
                ngx.say("push err: ", err)
            end

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
        }
    }
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]

Gestion

L'API de gestion finale est également un ajout ultérieur et est une demande populaire dans la communauté. L'un des exemples les plus typiques est l'utilisation de la mémoire partagée. Par exemple, si un utilisateur demande 100M d'espace comme shared dict, est-ce que ce 100M est suffisant ? Combien de clés sont stockées dedans, et quelles sont ces clés ? Ce sont toutes des questions authentiques.

Pour ce genre de problème, l'équipe officielle d'OpenResty espère que les utilisateurs utilisent les graphiques de flamme pour les résoudre, c'est-à-dire de manière non invasive, en gardant la base de code efficace et propre, au lieu de fournir une API invasive pour retourner les résultats directement.

Mais d'un point de vue convivial pour l'utilisateur, ces API de gestion sont toujours essentielles. Après tout, les projets open source sont conçus pour répondre aux besoins des produits, pas pour montrer la technologie elle-même. Alors, regardons les API de gestion suivantes qui seront ajoutées plus tard.

La première est get_keys(max_count?), qui par défaut ne retourne que les premières 1024 clés ; si vous définissez max_count à 0, elle retournera toutes les clés. Ensuite viennent capacity et free_space, qui font tous deux partie du dépôt lua-resty-core, donc vous devez les require avant de les utiliser.

require "resty.core.shdict"

local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()

Ils retournent la taille de la mémoire partagée (la taille configurée dans lua_shared_dict) et le nombre d'octets de pages libres. Comme le shared dict est alloué par page, même si free_space retourne 0, il peut y avoir de l'espace dans les pages allouées. Par conséquent, sa valeur de retour ne représente pas combien de mémoire partagée est occupée.

Résumé

En pratique, nous utilisons souvent la mise en cache à plusieurs niveaux, et le projet officiel OpenResty a également un package de mise en cache. Pouvez-vous trouver quels projets ils sont ? Ou connaissez-vous d'autres bibliothèques lua-resty qui encapsulent la mise en cache ?

Vous êtes invités à partager cet article avec vos collègues et amis afin que nous puissions communiquer et nous améliorer ensemble.