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
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 Worker
s, 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 Worker
s à 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 table
s 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 table
s, 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 Worker
s, permettant la communication entre Worker
s, 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.