`lua-resty-*` : une encapsulation pour éviter la complexité de la mise en cache multi-niveaux
API7.ai
December 30, 2022
Dans les deux articles précédents, nous avons appris la mise en cache dans OpenResty et le problème de l'effondrement du cache, qui sont tous des aspects de base. Dans le développement réel de projets, les développeurs préfèrent une bibliothèque prête à l'emploi avec tous les détails gérés et cachés, et qui peut être utilisée directement pour développer le code métier.
C'est un avantage de la division du travail, les développeurs de composants de base se concentrent sur une architecture flexible, de bonnes performances et la stabilité du code sans se soucier de la logique métier supérieure; tandis que les ingénieurs d'application se préoccupent davantage de la mise en œuvre métier et de l'itération rapide, espérant ne pas être distraits par divers détails techniques de la couche inférieure. Le fossé entre les deux peut être comblé par des bibliothèques d'encapsulation.
La mise en cache dans OpenResty fait face au même problème. shared dict
et lru caches
sont suffisamment stables et efficaces, mais il y a trop de détails à gérer. Le "dernier kilomètre" pour les ingénieurs de développement d'applications peut être ardu sans une encapsulation utile. C'est là que l'importance de la communauté entre en jeu. Une communauté active prendra l'initiative de trouver les lacunes et de les combler rapidement.
lua-resty-memcached-shdict
Revenons à l'encapsulation du cache. lua-resty-memcached-shdict
est un projet officiel d'OpenResty qui utilise shared dict
pour faire une couche d'encapsulation pour memcached
, gérant des détails comme l'effondrement du cache et les données expirées. Si vos données en cache se trouvent être stockées dans memcached
en backend, alors vous pouvez essayer d'utiliser cette bibliothèque.
C'est une bibliothèque développée officiellement par OpenResty, mais elle n'est pas incluse par défaut dans le package OpenResty. Si vous voulez la tester localement, vous devez d'abord télécharger son code source dans le chemin de recherche local d'OpenResty.
Cette bibliothèque d'encapsulation est la même solution que nous avons mentionnée dans l'article précédent. Elle utilise lua-resty-lock
pour être mutuellement exclusive. En cas d'échec du cache, une seule requête va chercher les données dans memcached
et évite les tempêtes de cache. Les données obsolètes sont retournées au point final si les dernières données ne sont pas récupérées.
Cependant, cette bibliothèque lua-resty
, bien qu'étant un projet officiel d'OpenResty, n'est pas parfaite:
- Premièrement, elle n'a pas de couverture de cas de test, ce qui signifie que la qualité du code ne peut pas être garantie de manière cohérente.
- Deuxièmement, elle expose trop de paramètres d'interface, avec 11 paramètres requis et 7 paramètres optionnels.
local memc_fetch, memc_store =
shdict_memc.gen_memc_methods{
tag = "my memcached server tag",
debug_logger = dlog,
warn_logger = warn,
error_logger = error_log,
locks_shdict_name = "some_lua_shared_dict_name",
shdict_set = meta_shdict_set,
shdict_get = meta_shdict_get,
disable_shdict = false, -- optional, default false
memc_host = "127.0.0.1",
memc_port = 11211,
memc_timeout = 200, -- in ms
memc_conn_pool_size = 5,
memc_fetch_retries = 2, -- optional, default 1
memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)
memc_conn_max_idle_time = 10 * 1000, -- in ms, for in-pool connections,optional, default to nil
memc_store_retries = 2, -- optional, default to 1
memc_store_retry_delay = 100, -- in ms, optional, default to 100 (ms)
store_ttl = 1, -- in seconds, optional, default to 0 (i.e., never expires)
}
La plupart des paramètres exposés peuvent être simplifiés en "créant un nouveau gestionnaire memcached
". La manière actuelle d'encapsuler tous les paramètres en les jetant à l'utilisateur n'est pas conviviale, donc j'accueillerais avec plaisir les développeurs intéressés à contribuer des PRs pour optimiser cela.
De plus, des optimisations supplémentaires sont mentionnées dans les directions suivantes dans la documentation de cette bibliothèque d'encapsulation.
- Utiliser
lua-resty-lrucache
pour augmenter le cache au niveauWorker
, plutôt que seulement le cacheshared dict
au niveauserver
. - Utiliser
ngx.timer
pour faire des opérations de mise à jour de cache asynchrones.
La première direction est une très bonne suggestion, car la performance du cache au sein du worker est meilleure; la deuxième suggestion est quelque chose que vous devez considérer en fonction de votre scénario réel. Cependant, je ne recommande généralement pas la deuxième, non seulement parce qu'il y a une limite au nombre de timers, mais aussi parce que si la logique de mise à jour ici se trompe, le cache ne sera plus jamais mis à jour, ce qui a un impact important.
lua-resty-mlcache
Ensuite, introduisons une encapsulation de cache couramment utilisée dans OpenResty: lua-resty-mlcache
, qui utilise shared dict
et lua-resty-lrucache
pour implémenter un mécanisme de cache multi-couches. Regardons comment cette bibliothèque est utilisée dans les deux exemples de code suivants.
local mlcache = require "resty.mlcache"
local cache, err = mlcache.new("cache_name", "cache_dict", {
lru_size = 500, -- taille du cache L1 (Lua VM)
ttl = 3600, -- 1h ttl pour les hits
neg_ttl = 30, -- 30s ttl pour les misses
})
if not cache then
error("failed to create mlcache: " .. err)
end
Regardons le premier morceau de code. Le début de ce code introduit la bibliothèque mlcache
et définit les paramètres pour l'initialisation. Nous mettrions normalement ce code dans la phase init
et n'aurions besoin de le faire qu'une seule fois.
En plus des deux paramètres requis, le nom du cache et le nom du dictionnaire, un troisième paramètre est un dictionnaire avec 12 options qui sont optionnelles et utilisent les valeurs par défaut si elles ne sont pas remplies. C'est beaucoup plus élégant que lua-resty-memcached-shdict
. Si nous devions concevoir l'interface nous-mêmes, il serait préférable d'adopter l'approche de mlcache
- garder l'interface aussi simple que possible tout en conservant suffisamment de flexibilité.
Voici le deuxième morceau de code, qui est le code logique lorsque la requête est traitée.
local function fetch_user(id)
return db:query_user(id)
end
local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
ngx.log(ngx.ERR , "failed to fetch user: ", err)
return
end
if user then
print(user.id) -- 123
end
Comme vous pouvez le voir, le cache multi-couches est caché, donc vous devez utiliser l'objet mlcache
pour récupérer le cache et définir la fonction de rappel lorsque le cache expire. La logique complexe derrière cela peut être complètement cachée.
Vous pourriez être curieux de savoir comment cette bibliothèque est implémentée en interne. Ensuite, jetons un autre coup d'œil à l'architecture et à l'implémentation de cette bibliothèque. L'image suivante est une diapositive d'une présentation donnée par Thibault Charbonnier, l'auteur de mlcache
, à OpenResty Con 2018.
Comme vous pouvez le voir sur le diagramme, mlcache
divise les données en trois couches, à savoir L1
, L2
et L3
.
Le cache L1
est lua-resty-lrucache
, où chaque Worker
a sa propre copie, et avec N
Worker
s, il y a N
copies de données, donc il y a une redondance de données. Comme l'opération de lrucache
au sein d'un seul Worker
ne déclenche pas de verrous, il a une performance plus élevée et est adapté comme cache de premier niveau.
Le cache L2
est un shared dict
. Tous les Worker
s partagent une seule copie des données en cache et interrogeront le cache L2
si le cache L1
ne trouve pas. ngx.shared
.DICT fournit une API qui utilise des spinlocks pour assurer l'atomicité des opérations, donc nous n'avons pas à nous soucier des conditions de course ici.
Le L3
est le cas où le cache L2
ne trouve pas non plus, et la fonction de rappel doit être exécutée pour interroger la source de données, comme une base de données externe, puis la mettre en cache dans L2
. Ici, pour éviter les tempêtes de cache, il utilise lua-resty-lock
pour s'assurer qu'un seul Worker
va chercher les données dans la source de données.
Du point de vue d'une requête:
- D'abord, il interrogera le cache L1 au sein du
Worker
et retournera directement si leL1
trouve. - Si
L1
ne trouve pas ou que le cache échoue, il interroge le cacheL2
entre lesWorker
s. SiL2
trouve, il retourne et met en cache le résultat dansL1
. - Si
L2
ne trouve pas non plus ou que le cache est invalidé, une fonction de rappel est appelée pour chercher les données dans la source de données et les écrire dans le cacheL2
, qui est la fonction de la couche de donnéesL3
.
Vous pouvez également voir à partir de ce processus que les mises à jour du cache sont déclenchées passivement par les requêtes des points finaux. Même si une requête ne parvient pas à récupérer le cache, les requêtes suivantes peuvent toujours déclencher la logique de mise à jour pour maximiser la sécurité du cache.
Cependant, bien que mlcache
ait été implémenté de manière parfaite, il y a encore un point douloureux - la sérialisation et la désérialisation des données. Ce n'est pas un problème avec mlcache
, mais la différence entre lrucache
et shared dict
, que nous avons mentionnée à plusieurs reprises. Dans lrucache
, nous pouvons stocker divers types de données Lua, y compris table
; mais dans shared dict
, nous ne pouvons stocker que des chaînes de caractères.
L1, le cache lrucache
, est la couche de données que les utilisateurs touchent, et nous voulons y mettre en cache toutes sortes de données, y compris string
, table
, cdata
, etc. Le problème est que L2
ne peut stocker que des chaînes, et lorsque les données sont élevées de L2
à L1
, nous devons faire une couche de conversion des chaînes aux types de données que nous pouvons donner directement à l'utilisateur.
Heureusement, mlcache
a pris cette situation en compte et fournit des fonctions optionnelles l1_serializer
dans les interfaces new
et get
, spécifiquement conçues pour gérer le traitement des données lorsque L2
est élevé à L1
. Nous pouvons voir l'exemple de code suivant, que j'ai extrait de mon ensemble de cas de test.
local mlcache = require "resty.mlcache"
local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
return i + 2
end,
})
local function callback()
return 123456
end
local data = assert(cache:get("number", nil, callback))
assert(data == 123458)
Permettez-moi de l'expliquer rapidement. Dans ce cas, la fonction de rappel retourne le nombre 123456
; dans new
, la fonction l1_serializer
que nous avons définie ajoutera 2
au nombre entrant avant de définir le cache L1
, qui devient 123458
. Avec une telle fonction de sérialisation, les données peuvent être plus flexibles lors de la conversion entre L1
et L2
.
Résumé
Avec plusieurs couches de cache, la performance côté serveur peut être maximisée, et de nombreux détails sont cachés entre les deux. À ce stade, une bibliothèque d'encapsulation stable et efficace nous fait gagner beaucoup d'efforts. J'espère également que ces deux bibliothèques d'encapsulation introduites aujourd'hui vous aideront à mieux comprendre la mise en cache.
Enfin, pensez à cette question: La couche de dictionnaire partagé du cache est-elle nécessaire? Est-il possible d'utiliser uniquement lrucache
? N'hésitez pas à laisser un commentaire et à partager votre opinion avec moi, et vous êtes également invités à partager cet article avec plus de personnes pour communiquer et progresser ensemble.