Aller au contenu

mlcache: Bibliothèque de mise en cache en couches pour nginx-module-lua

Installation

Si vous n'avez pas configuré l'abonnement au dépôt RPM, inscrivez-vous. Ensuite, vous pouvez procéder avec les étapes suivantes.

CentOS/RHEL 7 ou Amazon Linux 2

yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install lua-resty-mlcache

CentOS/RHEL 8+, Fedora Linux, Amazon Linux 2023

dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install lua5.1-resty-mlcache

Pour utiliser cette bibliothèque Lua avec NGINX, assurez-vous que nginx-module-lua est installé.

Ce document décrit lua-resty-mlcache v2.7.0 publié le 14 février 2024.


CI

Mise en cache en couches rapide et automatisée pour OpenResty.

Cette bibliothèque peut être manipulée comme un magasin de cache clé/valeur pour les types scalaires Lua et les tables, combinant la puissance de l'API [lua_shared_dict] et de [lua-resty-lrucache], ce qui donne une solution de mise en cache extrêmement performante et flexible.

Fonctionnalités :

  • Mise en cache et mise en cache négative avec TTL.
  • Mutex intégré via [lua-resty-lock] pour éviter les effets de pile sur votre base de données/backend lors des échecs de cache.
  • Communication inter-travailleurs intégrée pour propager les invalidations de cache et permettre aux travailleurs de mettre à jour leurs caches L1 (lua-resty-lrucache) lors des changements (set(), delete()).
  • Support pour les files d'attente de mise en cache des frappes et des échecs séparés.
  • Plusieurs instances isolées peuvent être créées pour contenir divers types de données tout en s'appuyant sur le même cache L2 lua_shared_dict.

Illustration des différents niveaux de mise en cache intégrés dans cette bibliothèque :

┌─────────────────────────────────────────────────┐
│ Nginx                                           │
│       ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│       │worker     │ │worker     │ │worker     │ │
│ L1    │           │ │           │ │           │ │
│       │ Lua cache │ │ Lua cache │ │ Lua cache │ │
│       └───────────┘ └───────────┘ └───────────┘ │
│             │             │             │       │
│             ▼             ▼             ▼       │
│       ┌───────────────────────────────────────┐ │
│       │                                       │ │
│ L2    │           lua_shared_dict             │ │
│       │                                       │ │
│       └───────────────────────────────────────┘ │
│                           │ mutex               │
│                           ▼                     │
│                  ┌──────────────────┐           │
│                  │     callback     │           │
│                  └────────┬─────────┘           │
└───────────────────────────┼─────────────────────┘
                            │
  L3                        │   I/O fetch
                            ▼

                   Base de données, API, DNS, Disque, tout I/O...

La hiérarchie des niveaux de cache est : - L1 : Cache Lua VM de type Least-Recently-Used utilisant [lua-resty-lrucache]. Fournit la recherche la plus rapide si peuplé, et évite d'épuiser la mémoire de la VM Lua des travailleurs. - L2 : Zone de mémoire lua_shared_dict partagée par tous les travailleurs. Ce niveau n'est accessible que si L1 était un échec, et empêche les travailleurs de demander le cache L3. - L3 : une fonction personnalisée qui ne sera exécutée que par un seul travailleur pour éviter l'effet de pile sur votre base de données/backend (via [lua-resty-lock]). Les valeurs récupérées via L3 seront définies dans le cache L2 pour que d'autres travailleurs puissent les récupérer.

Cette bibliothèque a été présentée à OpenResty Con 2018. Voir la section Ressources pour un enregistrement de la présentation.

Synopsis

## nginx.conf

http {
    # vous n'avez pas besoin de configurer la ligne suivante lorsque vous
    # utilisez LuaRocks ou opm.
    # 'on' est déjà la valeur par défaut pour cette directive. Si 'off', le cache L1
    # sera inefficace puisque la VM Lua sera recréée pour chaque
    # requête. Cela est acceptable pendant le développement, mais assurez-vous que la production est 'on'.
    lua_code_cache on;

    lua_shared_dict cache_dict 1m;

    init_by_lua_block {
        local mlcache = require "resty.mlcache"

        local cache, err = mlcache.new("my_cache", "cache_dict", {
            lru_size = 500,    -- taille du cache L1 (Lua VM)
            ttl      = 3600,   -- TTL de 1h pour les frappes
            neg_ttl  = 30,     -- TTL de 30s pour les échecs
        })
        if err then

        end

        -- nous mettons notre instance dans la table globale pour la brièveté dans
        -- cet exemple, mais préférez une valeur de montée à l'un de vos modules
        -- comme recommandé par ngx_lua
        _G.cache = cache
    }

    server {
        listen 8080;

        location / {
            content_by_lua_block {
                local function callback(username)
                    -- cela ne s'exécute *qu'une seule fois* jusqu'à ce que la clé expire, donc
                    -- faites des opérations coûteuses comme se connecter à un
                    -- backend distant ici. c'est-à-dire : appeler un serveur MySQL dans ce callback
                    return db:get_user(username) -- { name = "John Doe", email = "[email protected]" }
                end

                -- cet appel essaiera L1 et L2 avant d'exécuter le callback (L3)
                -- la valeur retournée sera ensuite stockée dans L2 et L1
                -- pour la prochaine requête.
                local user, err = cache:get("my_key", nil, callback, "jdoe")

                ngx.say(user.name) -- "John Doe"
            }
        }
    }
}

Méthodes

new

syntax: cache, err = mlcache.new(name, shm, opts?)

Crée une nouvelle instance de mlcache. Si cela échoue, retourne nil et une chaîne décrivant l'erreur.

Le premier argument name est un nom arbitraire de votre choix pour ce cache, et doit être une chaîne. Chaque instance de mlcache namespace les valeurs qu'elle contient selon son nom, donc plusieurs instances avec le même nom partageront les mêmes données.

Le deuxième argument shm est le nom de la zone de mémoire partagée lua_shared_dict. Plusieurs instances de mlcache peuvent utiliser le même shm (les valeurs seront namespaced).

Le troisième argument opts est optionnel. Si fourni, il doit s'agir d'une table contenant les options souhaitées pour cette instance. Les options possibles sont :

  • lru_size : un nombre définissant la taille du cache L1 sous-jacent (instance lua-resty-lrucache). Cette taille est le nombre maximal d'éléments que le cache L1 peut contenir. Par défaut : 100.
  • ttl : un nombre spécifiant la période d'expiration des valeurs mises en cache. L'unité est en secondes, mais accepte des parties décimales, comme 0.3. Un ttl de 0 signifie que les valeurs mises en cache ne périront jamais. Par défaut : 30.
  • neg_ttl : un nombre spécifiant la période d'expiration des échecs de cache (lorsque le callback L3 retourne nil). L'unité est en secondes, mais accepte des parties décimales, comme 0.3. Un neg_ttl de 0 signifie que les échecs de cache ne périront jamais. Par défaut : 5.
  • resurrect_ttl : nombre optionnel. Lorsqu'il est spécifié, l'instance mlcache tentera de ressusciter les valeurs périmées lorsque le callback L3 retourne nil, err (erreurs douces). Plus de détails sont disponibles pour cette option dans la section get(). L'unité est en secondes, mais accepte des parties décimales, comme 0.3.
  • lru : optionnel. Une instance lua-resty-lrucache de votre choix. Si spécifié, mlcache ne créera pas de LRU. On peut utiliser cette valeur pour utiliser l'implémentation resty.lrucache.pureffi de lua-resty-lrucache si désiré.
  • shm_set_tries : le nombre de tentatives pour l'opération set() de lua_shared_dict. Lorsque le lua_shared_dict est plein, il tente de libérer jusqu'à 30 éléments de sa file d'attente. Lorsque la valeur à définir est beaucoup plus grande que l'espace libéré, cette option permet à mlcache de réessayer l'opération (et de libérer plus de slots) jusqu'à ce que le nombre maximum de tentatives soit atteint ou qu'assez de mémoire ait été libérée pour que la valeur puisse s'adapter. Par défaut : 3.
  • shm_miss : chaîne optionnelle. Le nom d'un lua_shared_dict. Lorsqu'il est spécifié, les échecs (callbacks retournant nil) seront mis en cache dans ce lua_shared_dict séparé. Cela est utile pour s'assurer qu'un grand nombre d'échecs de cache (par exemple, déclenchés par des clients malveillants) ne supprime pas trop d'éléments mis en cache (frappes) du lua_shared_dict spécifié dans shm.
  • shm_locks : chaîne optionnelle. Le nom d'un lua_shared_dict. Lorsqu'il est spécifié, lua-resty-lock utilisera ce dictionnaire partagé pour stocker ses verrous. Cette option peut aider à réduire le changement de cache : lorsque le cache L2 (shm) est plein, chaque insertion (comme les verrous créés par des accès concurrents déclenchant des callbacks L3) purge les 30 éléments les plus anciens accédés. Ces éléments purgés sont très probablement des valeurs mises en cache précédemment (et précieuses). En isolant les verrous dans un dictionnaire partagé séparé, les charges de travail subissant un changement de cache peuvent atténuer cet effet.
  • resty_lock_opts : table optionnelle. Options pour les instances [lua-resty-lock]. Lorsque mlcache exécute le callback L3, il utilise lua-resty-lock pour s'assurer qu'un seul travailleur exécute le callback fourni.
  • ipc_shm : chaîne optionnelle. Si vous souhaitez utiliser set(), delete() ou purge(), vous devez fournir un mécanisme IPC (Inter-Process Communication) pour que les travailleurs puissent synchroniser et invalider leurs caches L1. Ce module regroupe une bibliothèque IPC "clé en main", et vous pouvez l'activer en spécifiant un lua_shared_dict dédié dans cette option. Plusieurs instances de mlcache peuvent utiliser le même dictionnaire partagé (les événements seront namespaced), mais aucun autre acteur que mlcache ne doit y toucher.
  • ipc : table optionnelle. Comme l'option ipc_shm ci-dessus, mais vous permet d'utiliser la bibliothèque IPC de votre choix pour propager les événements inter-travailleurs.
  • l1_serializer : fonction optionnelle. Sa signature et les valeurs acceptées sont documentées sous la méthode get(), avec un exemple. Si spécifié, cette fonction sera appelée chaque fois qu'une valeur est promue du cache L2 dans le L1 (worker Lua VM). Cette fonction peut effectuer une sérialisation arbitraire de l'élément mis en cache pour le transformer en tout objet Lua avant de le stocker dans le cache L1. Elle peut ainsi éviter à votre application de devoir répéter de telles transformations à chaque requête, comme créer des tables, des objets cdata, charger du nouveau code Lua, etc...

Exemple :

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict", {
    lru_size = 1000, -- conserver jusqu'à 1000 éléments dans le cache L1 (Lua VM)
    ttl      = 3600, -- met en cache des types scalaires et des tables pendant 1h
    neg_ttl  = 60    -- met en cache des valeurs nil pendant 60s
})
if not cache then
    error("impossible de créer mlcache : " .. err)
end

Vous pouvez créer plusieurs instances de mlcache s'appuyant sur la même zone de mémoire partagée lua_shared_dict :

local mlcache = require "mlcache"

local cache_1 = mlcache.new("cache_1", "cache_shared_dict", { lru_size = 100 })
local cache_2 = mlcache.new("cache_2", "cache_shared_dict", { lru_size = 1e5 })

Dans l'exemple ci-dessus, cache_1 est idéal pour contenir quelques valeurs très grandes. cache_2 peut être utilisé pour contenir un grand nombre de petites valeurs. Les deux instances s'appuieront sur le même shm : lua_shared_dict cache_shared_dict 2048m;. Même si vous utilisez des clés identiques dans les deux caches, elles ne seront pas en conflit car chacune a un namespace différent.

Cet autre exemple instancie un mlcache en utilisant le module IPC intégré pour les événements d'invalidation inter-travailleurs (afin que nous puissions utiliser set(), delete() et purge()) :

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_cache_with_ipc", "cache_shared_dict", {
    lru_size = 1000,
    ipc_shm = "ipc_shared_dict"
})

Remarque : pour que le cache L1 soit efficace, assurez-vous que lua_code_cache est activé (ce qui est la valeur par défaut). Si vous désactivez cette directive pendant le développement, mlcache fonctionnera, mais la mise en cache L1 sera inefficace puisque une nouvelle VM Lua sera créée pour chaque requête.

get

syntax: value, err, hit_level = cache:get(key, opts?, callback?, ...)

Effectue une recherche dans le cache. C'est la méthode principale et la plus efficace de ce module. Un modèle typique est de ne pas appeler set(), et de laisser get() effectuer tout le travail.

Lorsque cette méthode réussit, elle retourne value et err est défini sur nil. Parce que les valeurs nil du callback L3 peuvent être mises en cache (c'est-à-dire "mise en cache négative"), value peut être nil bien qu'elle soit déjà mise en cache. Par conséquent, il faut noter qu'il faut vérifier la deuxième valeur de retour err pour déterminer si cette méthode a réussi ou non.

La troisième valeur de retour est un nombre qui est défini si aucune erreur n'a été rencontrée. Il indique le niveau auquel la valeur a été récupérée : 1 pour L1, 2 pour L2, et 3 pour L3.

Si, cependant, une erreur est rencontrée, cette méthode retourne nil dans value et une chaîne décrivant l'erreur dans err.

Le premier argument key est une chaîne. Chaque valeur doit être stockée sous une clé unique.

Le deuxième argument opts est optionnel. S'il est fourni, il doit s'agir d'une table contenant les options souhaitées pour cette clé. Ces options prévaudront sur les options de l'instance :

  • ttl : un nombre spécifiant la période d'expiration des valeurs mises en cache. L'unité est en secondes, mais accepte des parties décimales, comme 0.3. Un ttl de 0 signifie que les valeurs mises en cache ne périront jamais. Par défaut : hérité de l'instance.
  • neg_ttl : un nombre spécifiant la période d'expiration des échecs de cache (lorsque le callback L3 retourne nil). L'unité est en secondes, mais accepte des parties décimales, comme 0.3. Un neg_ttl de 0 signifie que les échecs de cache ne périront jamais. Par défaut : hérité de l'instance.
  • resurrect_ttl : nombre optionnel. Lorsqu'il est spécifié, get() tentera de ressusciter les valeurs périmées lorsqu'une erreur est rencontrée. Les erreurs retournées par le callback L3 (nil, err) sont considérées comme des échecs pour récupérer/rafraîchir une valeur. Lorsque de telles valeurs de retour du callback sont vues par get(), et si la valeur périmée est toujours en mémoire, alors get() ressuscitera la valeur périmée pendant resurrect_ttl secondes. L'erreur retournée par get() sera enregistrée au niveau WARN, mais ne sera pas retournée à l'appelant. Enfin, la valeur de retour hit_level sera 4 pour signifier que l'élément servi est périmé. Lorsque resurrect_ttl est atteint, get() tentera à nouveau d'exécuter le callback. Si d'ici là, le callback retourne à nouveau une erreur, la valeur est ressuscitée une fois de plus, et ainsi de suite. Si le callback réussit, la valeur est rafraîchie et n'est plus marquée comme périmée. En raison des limitations actuelles du module de cache LRU, hit_level sera 1 lorsque des valeurs périmées sont promues dans le cache L1 et récupérées à partir de là. Les erreurs Lua lancées par le callback ne déclenchent pas une résurrection, et sont retournées par get() comme d'habitude (nil, err). Lorsque plusieurs travailleurs expirent en attendant le travailleur exécutant le callback (par exemple, parce que le magasin de données expire), alors les utilisateurs de cette option verront une légère différence par rapport au comportement traditionnel de get(). Au lieu de retourner nil, err (indiquant un délai d'attente de verrou), get() retournera la valeur périmée (si disponible), aucune erreur, et hit_level sera 4. Cependant, la valeur ne sera pas ressuscitée (puisqu'un autre travailleur exécute toujours le callback). L'unité pour cette option est en secondes, mais elle accepte des parties décimales, comme 0.3. Cette option doit être supérieure à 0, pour éviter que des valeurs périmées soient mises en cache indéfiniment. Par défaut : hérité de l'instance.
  • shm_set_tries : le nombre de tentatives pour l'opération set() de lua_shared_dict. Lorsque le lua_shared_dict est plein, il tente de libérer jusqu'à 30 éléments de sa file d'attente. Lorsque la valeur à définir est beaucoup plus grande que l'espace libéré, cette option permet à mlcache de réessayer l'opération (et de libérer plus de slots) jusqu'à ce que le nombre maximum de tentatives soit atteint ou qu'assez de mémoire ait été libérée pour que la valeur puisse s'adapter. Par défaut : hérité de l'instance.
  • l1_serializer : fonction optionnelle. Sa signature et les valeurs acceptées sont documentées sous la méthode get(), avec un exemple. Si spécifié, cette fonction sera appelée chaque fois qu'une valeur est promue du cache L2 dans le L1 (worker Lua VM). Cette fonction peut effectuer une sérialisation arbitraire de l'élément mis en cache pour le transformer en tout objet Lua avant de le stocker dans le cache L1. Elle peut ainsi éviter à votre application de devoir répéter de telles transformations à chaque requête, comme créer des tables, des objets cdata, charger du nouveau code Lua, etc... Par défaut : hérité de l'instance.
  • resty_lock_opts : table optionnelle. Si spécifié, remplace les resty_lock_opts de l'instance pour la recherche get() actuelle. Par défaut : hérité de l'instance.

Le troisième argument callback est optionnel. S'il est fourni, il doit s'agir d'une fonction dont la signature et les valeurs de retour sont documentées dans l'exemple suivant :

-- arg1, arg2 et arg3 sont des arguments transmis au callback depuis les
-- arguments variadiques de `get()`, comme ceci :
-- cache:get(key, opts, callback, arg1, arg2, arg3)

local function callback(arg1, arg2, arg3)
    -- logique de recherche I/O
    -- ...

    -- value : la valeur à mettre en cache (scalaire Lua ou table)
    -- err : si pas `nil`, arrêtera get(), qui retournera `value` et `err`
    -- ttl : remplacer ttl pour cette valeur
    --      Si retourné comme `ttl >= 0`, cela remplacera l'instance
    --      (ou l'option) `ttl` ou `neg_ttl`.
    --      Si retourné comme `ttl < 0`, `value` sera retourné par get(),
    --      mais ne sera pas mis en cache. Cette valeur de retour sera ignorée si ce n'est pas un nombre.
    return value, err, ttl
end

La fonction callback fournie est autorisée à lancer des erreurs Lua car elle s'exécute en mode protégé. De telles erreurs lancées depuis le callback seront retournées sous forme de chaînes dans la deuxième valeur de retour err.

Si callback n'est pas fourni, get() recherchera tout de même la clé demandée dans les caches L1 et L2 et la retournera si trouvée. Dans le cas où aucune valeur n'est trouvée dans le cache et aucun callback n'est fourni, get() retournera nil, nil, -1, où -1 signifie un échec de cache (aucune valeur). Cela ne doit pas être confondu avec des valeurs de retour telles que nil, nil, 1, où 1 signifie un élément mis en cache négatif trouvé dans L1 (cached nil).

Ne pas fournir de fonction callback permet d'implémenter des modèles de recherche dans le cache qui sont garantis d'être sur le CPU pour une latence plus constante et plus fluide (par exemple, avec des valeurs rafraîchies dans des temporisateurs d'arrière-plan via set()).

local value, err, hit_lvl = cache:get("key")
if value == nil then
    if err ~= nil then
        -- erreur
    elseif hit_lvl == -1 then
        -- échec (aucune valeur)
    else
        -- frappe négative (valeur `nil` mise en cache)
    end
end

Lorsqu'un callback est fourni, get() suit la logique ci-dessous :

  1. interroger le cache L1 (instance lua-resty-lrucache). Ce cache vit dans la VM Lua, et en tant que tel, c'est le plus efficace à interroger.
    1. si le cache L1 a la valeur, la retourner.
    2. si le cache L1 n'a pas la valeur (échec L1), continuer.
  2. interroger le cache L2 (zone de mémoire lua_shared_dict). Ce cache est partagé par tous les travailleurs, et est presque aussi efficace que le cache L1. Il nécessite cependant la sérialisation des tables Lua stockées.
    1. si le cache L2 a la valeur, la retourner.
      1. si l1_serializer est défini, l'exécuter, et promouvoir la valeur résultante dans le cache L1.
      2. si non, promouvoir directement la valeur telle quelle dans le cache L1.
    2. si le cache L2 n'a pas la valeur (échec L2), continuer.
  3. créer un [lua-resty-lock], et s'assurer qu'un seul travailleur exécutera le callback (d'autres travailleurs essayant d'accéder à la même valeur attendront).
  4. un seul travailleur exécute le callback L3 (par exemple, effectue une requête de base de données)
  5. le callback réussit et retourne une valeur : la valeur est définie dans le cache L2, puis dans le cache L1 (telle quelle par défaut, ou comme retournée par l1_serializer si spécifié).
  6. le callback a échoué et retourné nil, err : a. si resurrect_ttl est spécifié, et si la valeur périmée est toujours disponible, ressuscitez-la dans le cache L2 et promouvez-la dans le L1. b. sinon, get() retourne nil, err.
  7. d'autres travailleurs qui essayaient d'accéder à la même valeur mais attendaient sont déverrouillés et lisent la valeur du cache L2 (ils n'exécutent pas le L3 callback) et la retournent.

Lorsqu'aucun callback n'est fourni, get() n'exécutera que les étapes 1. et 2.

Voici un exemple complet d'utilisation :

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict", {
    lru_size = 1000,
    ttl      = 3600,
    neg_ttl  = 60
})

local function fetch_user(user_id)
    local user, err = db:query_user(user_id)
    if err then
        -- dans ce cas, get() retournera `nil` + `err`
        return nil, err
    end

    return user -- table ou nil
end

local user_id = 3

local user, err = cache:get("users:" .. user_id, nil, fetch_user, user_id)
if err then
    ngx.log(ngx.ERR, "impossible de récupérer l'utilisateur : ", err)
    return
end

-- `user` pourrait être une table, mais pourrait aussi être `nil` (n'existe pas)
-- de toute façon, il sera mis en cache et les appels suivants à get() retourneront
-- la valeur mise en cache, pendant jusqu'à `ttl` ou `neg_ttl`.
if user then
    ngx.say("l'utilisateur existe : ", user.name)
else
    ngx.say("l'utilisateur n'existe pas")
end

Ce deuxième exemple est similaire à celui ci-dessus, mais ici nous appliquons une transformation à l'enregistrement user récupéré avant de le mettre en cache via le callback l1_serializer :

-- Notre l1_serializer, appelé lorsque une valeur est promue de L2 à L1
--
-- Sa signature reçoit un seul argument : l'élément tel que retourné depuis
-- un hit L2. Par conséquent, cet argument ne peut jamais être `nil`. Le résultat sera
-- conservé dans le cache L1, mais il ne peut pas être `nil`.
--
-- Cette fonction peut retourner `nil` et une chaîne décrivant une erreur, qui
-- remontera à l'appelant de `get()`. Elle s'exécute également en mode protégé
-- et signalera toute erreur Lua.
local function load_code(user_row)
    if user_row.custom_code ~= nil then
        local f, err = loadstring(user_row.raw_lua_code)
        if not f then
            -- dans ce cas, rien ne sera stocké dans le cache (comme si le L3
            -- callback avait échoué)
            return nil, "échec de la compilation du code personnalisé : " .. err
        end

        user_row.f = f
    end

    return user_row
end

local user, err = cache:get("users:" .. user_id,
                            { l1_serializer = load_code },
                            fetch_user, user_id)
if err then
     ngx.log(ngx.ERR, "impossible de récupérer l'utilisateur : ", err)
     return
end

-- maintenant nous pouvons appeler une fonction qui a déjà été chargée une fois, lors de l'entrée
-- dans le cache L1 (Lua VM)
user.f()

get_bulk

syntax: res, err = cache:get_bulk(bulk, opts?)

Effectue plusieurs recherches get() à la fois (en masse). Chacune de ces recherches requérant un appel de callback L3 sera exécutée en parallèle, dans un pool de ngx.thread.

Le premier argument bulk est une table contenant n opérations.

Le deuxième argument opts est optionnel. S'il est fourni, il doit s'agir d'une table contenant les options pour cette recherche en masse. Les options possibles sont :

  • concurrency : un nombre supérieur à 0. Spécifie le nombre de threads qui exécuteront simultanément les callbacks L3 pour cette recherche en masse. Une concurrence de 3 avec 6 callbacks à exécuter signifie que chaque thread exécutera 2 callbacks. Une concurrence de 1 avec 6 callbacks signifie qu'un seul thread exécutera tous les 6 callbacks. Avec une concurrence de 6 et 1 callback, un seul thread exécutera le callback. Par défaut : 3.

En cas de succès, cette méthode retourne res, une table contenant les résultats de chaque recherche, et aucune erreur.

En cas d'échec, cette méthode retourne nil plus une chaîne décrivant l'erreur.

Toutes les opérations de recherche effectuées par cette méthode s'intégreront pleinement dans d'autres opérations effectuées simultanément par d'autres méthodes et travailleurs Nginx (par exemple, stockage des frappes/échecs L1/L2, mutex de callback L3, etc...).

L'argument bulk est une table qui doit avoir une disposition particulière (documentée dans l'exemple ci-dessous). Elle peut être construite manuellement, ou via la méthode d'assistance new_bulk().

De même, la table res a également une disposition particulière. Elle peut être itérée manuellement, ou via l'itérateur d'assistance each_bulk_res.

Exemple :

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict")

cache:get("key_c", nil, function() return nil end)

local res, err = cache:get_bulk({
  -- disposition en masse :
  -- clé     opts          callback L3                    argument du callback

    "key_a", { ttl = 60 }, function() return "hello" end, nil,
    "key_b", nil,          function() return "world" end, nil,
    "key_c", nil,          function() return "bye" end,   nil,
    n = 3 -- spécifiez le nombre d'opérations
}, { concurrency = 3 })
if err then
     ngx.log(ngx.ERR, "impossible d'exécuter la recherche en masse : ", err)
     return
end

-- disposition de res :
-- data, "err", hit_lvl }

for i = 1, res.n, 3 do
    local data = res[i]
    local err = res[i + 1]
    local hit_lvl = res[i + 2]

    if not err then
        ngx.say("data : ", data, ", hit_lvl : ", hit_lvl)
    end
end

L'exemple ci-dessus produirait la sortie suivante :

data: hello, hit_lvl: 3
data: world, hit_lvl: 3
data: nil, hit_lvl: 1

Notez que puisque key_c était déjà dans le cache, le callback retournant "bye" n'a jamais été exécuté, puisque get_bulk() a récupéré la valeur de L1, comme indiqué par la valeur hit_lvl.

Remarque : contrairement à get(), cette méthode ne permet de spécifier qu'un seul argument pour le callback de chaque recherche.

new_bulk

syntax: bulk = mlcache.new_bulk(n_lookups?)

Crée une table contenant des opérations de recherche pour la fonction get_bulk(). Il n'est pas nécessaire d'utiliser cette fonction pour construire une table de recherche en masse, mais elle fournit une belle abstraction.

Le premier et unique argument n_lookups est optionnel, et s'il est spécifié, c'est un nombre indiquant la quantité de recherches que cette masse contiendra finalement afin que la table sous-jacente soit pré-allouée à des fins d'optimisation.

Cette fonction retourne une table bulk, qui ne contient encore aucune opération de recherche. Les recherches sont ajoutées à une table bulk en invoquant bulk:add(key, opts?, cb, arg?) :

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict")

local bulk = mlcache.new_bulk(3)

bulk:add("key_a", { ttl = 60 }, function(n) return n * n, 42)
bulk:add("key_b", nil, function(str) return str end, "hello")
bulk:add("key_c", nil, function() return nil end)

local res, err = cache:get_bulk(bulk)

each_bulk_res

syntax: iter, res, i = mlcache.each_bulk_res(res)

Fournit une abstraction pour itérer sur une table de retour res de get_bulk(). Il n'est pas nécessaire d'utiliser cette méthode pour itérer sur une table res, mais elle fournit une belle abstraction.

Cette méthode peut être invoquée comme un itérateur Lua :

local mlcache = require "mlcache"

local cache, err = mlcache.new("my_cache", "cache_shared_dict")

local res, err = cache:get_bulk(bulk)

for i, data, err, hit_lvl in mlcache.each_bulk_res(res) do
    if not err then
        ngx.say("recherche ", i, ": ", data)
    end
end

peek

syntax: ttl, err, value = cache:peek(key, stale?)

Jetez un œil dans le cache L2 (lua_shared_dict).

Le premier argument key est une chaîne qui est la clé à rechercher dans le cache.

Le deuxième argument stale est optionnel. Si true, alors peek() considérera les valeurs périmées comme des valeurs mises en cache. Si non fourni, peek() considérera les valeurs périmées, comme si elles n'étaient pas dans le cache.

Cette méthode retourne nil et une chaîne décrivant l'erreur en cas d'échec.

S'il n'y a pas de valeur pour la clé interrogée, elle retourne nil et aucune erreur.

S'il y a une valeur pour la clé interrogée, elle retourne un nombre indiquant le TTL restant de la valeur mise en cache (en secondes) et aucune erreur. Si la valeur pour key a expiré mais est toujours dans le cache L2, la valeur TTL retournée sera négative. La valeur de retour TTL restante ne sera que 0 si la clé interrogée a un ttl indéfini (ttl=0). Sinon, cette valeur de retour peut être positive (clé toujours valide), ou négative (clé périmée).

La troisième valeur retournée sera la valeur mise en cache telle que stockée dans le cache L2, si elle est toujours disponible.

Cette méthode est utile lorsque vous souhaitez déterminer si une valeur est mise en cache. Une valeur stockée dans le cache L2 est considérée comme mise en cache indépendamment de son stockage dans le cache L1 du travailleur. Cela est dû au fait que le cache L1 est considéré comme volatile (car sa taille est un nombre de slots), et le cache L2 est de toute façon plusieurs ordres de grandeur plus rapide que le callback L3.

Comme son seul but est de jeter un "coup d'œil" dans le cache pour déterminer sa chaleur pour une valeur donnée, peek() ne compte pas comme une requête comme get(), et ne promeut pas la valeur dans le cache L1.

Exemple :

local mlcache = require "mlcache"

local cache = mlcache.new("my_cache", "cache_shared_dict")

local ttl, err, value = cache:peek("key")
if err then
    ngx.log(ngx.ERR, "impossible de jeter un œil dans le cache : ", err)
    return
end

ngx.say(ttl)   -- nil car `key` n'a pas encore de valeur
ngx.say(value) -- nil

-- mettre en cache la valeur

cache:get("key", { ttl = 5 }, function() return "some value" end)

-- attendre 2 secondes

ngx.sleep(2)

local ttl, err, value = cache:peek("key")
if err then
    ngx.log(ngx.ERR, "impossible de jeter un œil dans le cache : ", err)
    return
end

ngx.say(ttl)   -- 3
ngx.say(value) -- "some value"

Remarque : depuis mlcache 2.5.0, il est également possible d'appeler get() sans fonction callback afin de "interroger" le cache. Contrairement à peek(), un appel get() sans callback promouvra la valeur dans le cache L1, et ne retournera pas son TTL.

set

syntax: ok, err = cache:set(key, opts?, value)

Définit inconditionnellement une valeur dans le cache L2 et diffuse un événement aux autres travailleurs afin qu'ils puissent rafraîchir la valeur de leur cache L1.

Le premier argument key est une chaîne, et est la clé sous laquelle stocker la valeur.

Le deuxième argument opts est optionnel, et s'il est fourni, il est identique à celui de get().

Le troisième argument value est la valeur à mettre en cache, similaire à la valeur de retour du callback L3. Tout comme la valeur de retour du callback, elle doit être un scalaire Lua, une table, ou nil. Si un l1_serializer est fourni soit depuis le constructeur soit dans l'argument opts, il sera appelé avec value si value n'est pas nil.

En cas de succès, la première valeur de retour sera true.

En cas d'échec, cette méthode retourne nil et une chaîne décrivant l'erreur.

Remarque : par sa nature, set() nécessite que d'autres instances de mlcache (provenant d'autres travailleurs) rafraîchissent leur cache L1. Si set() est appelé depuis un seul travailleur, les instances mlcache d'autres travailleurs portant le même name doivent appeler update() avant que leur cache ne soit demandé lors de la prochaine requête, pour s'assurer qu'elles aient rafraîchi leur cache L1.

Remarque bis : il est généralement considéré comme inefficace d'appeler set() sur un chemin de code chaud (tel que dans une requête servie par OpenResty). Au lieu de cela, il faut s'appuyer sur get() et son mutex intégré dans le callback L3. set() est mieux adapté lorsqu'il est appelé occasionnellement depuis un seul travailleur, par exemple lors d'un événement particulier qui déclenche une mise à jour d'une valeur mise en cache. Une fois que set() met à jour le cache L2 avec la valeur fraîche, d'autres travailleurs s'appuieront sur update() pour interroger l'événement d'invalidation et invalider leur cache L1, ce qui les fera récupérer la valeur (fraîche) dans L2.

Voir : update()

delete

syntax: ok, err = cache:delete(key)

Supprime une valeur dans le cache L2 et publie un événement aux autres travailleurs afin qu'ils puissent évincer la valeur de leur cache L1.

Le premier et unique argument key est la chaîne à laquelle la valeur est stockée.

En cas de succès, la première valeur de retour sera true.

En cas d'échec, cette méthode retourne nil et une chaîne décrivant l'erreur.

Remarque : par sa nature, delete() nécessite que d'autres instances de mlcache (provenant d'autres travailleurs) rafraîchissent leur cache L1. Si delete() est appelé depuis un seul travailleur, les instances mlcache d'autres travailleurs portant le même name doivent appeler update() avant que leur cache ne soit demandé lors de la prochaine requête, pour s'assurer qu'elles aient rafraîchi leur cache L1.

Voir : update()

purge

syntax: ok, err = cache:purge(flush_expired?)

Purge le contenu du cache, à la fois aux niveaux L1 et L2. Puis publie un événement aux autres travailleurs afin qu'ils puissent également purger leur cache L1.

Cette méthode recycle l'instance lua-resty-lrucache, et appelle ngx.shared.DICT:flush_all, donc elle peut être plutôt coûteuse.

Le premier et unique argument flush_expired est optionnel, mais s'il est donné true, cette méthode appellera également ngx.shared.DICT:flush_expired (sans arguments). Cela est utile pour libérer la mémoire réclamée par le cache L2 (shm) si nécessaire.

En cas de succès, la première valeur de retour sera true.

En cas d'échec, cette méthode retourne nil et une chaîne décrivant l'erreur.

Remarque : il n'est pas possible d'appeler purge() lors de l'utilisation d'un cache LRU personnalisé dans OpenResty 1.13.6.1 et inférieur. Cette limitation ne s'applique pas à OpenResty 1.13.6.2 et supérieur.

Remarque : par sa nature, purge() nécessite que d'autres instances de mlcache (provenant d'autres travailleurs) rafraîchissent leur cache L1. Si purge() est appelé depuis un seul travailleur, les instances mlcache d'autres travailleurs portant le même name doivent appeler update() avant que leur cache ne soit demandé lors de la prochaine requête, pour s'assurer qu'elles aient rafraîchi leur cache L1.

Voir : update()

update

syntax: ok, err = cache:update(timeout?)

Interroge et exécute les événements d'invalidation de cache en attente publiés par d'autres travailleurs.

Les méthodes set(), delete() et purge() nécessitent que d'autres instances de mlcache (provenant d'autres travailleurs) rafraîchissent leur cache L1. Puisque OpenResty n'a actuellement aucun mécanisme intégré pour la communication inter-travailleurs, ce module regroupe une bibliothèque IPC "clé en main" pour propager les événements inter-travailleurs. Si la bibliothèque IPC intégrée est utilisée, le lua_shared_dict spécifié dans l'option ipc_shm ne doit pas être utilisé par d'autres acteurs que mlcache lui-même.

Cette méthode permet à un travailleur de mettre à jour son cache L1 (en purgeant les valeurs considérées périmées en raison d'un autre travailleur appelant set(), delete(), ou purge()) avant de traiter une requête.

Cette méthode accepte un argument timeout dont l'unité est en secondes et qui par défaut est 0.3 (300ms). L'opération de mise à jour expirera si elle n'est pas effectuée lorsque ce seuil est atteint. Cela évite que update() ne reste trop longtemps sur le CPU en cas d'un trop grand nombre d'événements à traiter. Dans un système finalement cohérent, des événements supplémentaires peuvent attendre que le prochain appel soit traité.

Un modèle de conception typique est d'appeler update() une seule fois avant chaque traitement de requête. Cela permet à vos chemins de code chauds de réaliser un seul accès shm dans le meilleur des cas : aucun événement d'invalidation n'a été reçu, tous les appels get() frapperont dans le cache L1. Ce n'est que dans le pire des cas (n valeurs ont été évincées par un autre travailleur) que get() accédera au cache L2 ou L3 n fois. Les requêtes suivantes frapperont à nouveau le meilleur des cas, car get() a peuplé le cache L1.

Par exemple, si vos travailleurs utilisent set(), delete() ou purge() n'importe où dans votre application, appelez update() à l'entrée de votre chemin de code chaud, avant d'utiliser get() :

http {
    listen 9000;

    location / {
        content_by_lua_block {
            local cache = ... -- récupérer l'instance mlcache

            -- assurez-vous que le cache L1 est évincé des valeurs périmées
            -- avant d'appeler get()
            local ok, err = cache:update()
            if not ok then
                ngx.log(ngx.ERR, "échec de l'interrogation des événements d'éviction : ", err)
                -- /!\ nous pourrions obtenir des données périmées de get()
            end

            -- recherche L1/L2/L3 (meilleur cas : L1)
            local value, err = cache:get("key_1", nil, cb1)

            -- recherche L1/L2/L3 (meilleur cas : L1)
            local other_value, err = cache:get("key_2", nil, cb2)

            -- value et other_value sont à jour parce que :
            -- soit elles n'étaient pas périmées et sont venues directement de L1 (meilleur scénario)
            -- soit elles étaient périmées et évincées de L1, et sont venues de L2
            -- soit elles n'étaient ni dans L1 ni dans L2, et sont venues de L3 (pire scénario)
        }
    }

    location /delete {
        content_by_lua_block {
            local cache = ... -- récupérer l'instance mlcache

            -- supprimer une valeur
            local ok, err = cache:delete("key_1")
            if not ok then
                ngx.log(ngx.ERR, "échec de la suppression de la valeur du cache : ", err)
                return ngx.exit(500)
            end

            ngx.exit(204)
        }
    }

    location /set {
        content_by_lua_block {
            local cache = ... -- récupérer l'instance mlcache

            -- mettre à jour une valeur
            local ok, err = cache:set("key_1", nil, 123)
            if not ok then
                ngx.log(ngx.ERR, "échec de la définition de la valeur dans le cache : ", err)
                return ngx.exit(500)
            end

            ngx.exit(200)
        }
    }
}

Remarque : vous n'avez pas besoin d'appeler update() pour rafraîchir vos travailleurs s'ils n'appellent jamais set(), delete(), ou purge(). Lorsque les travailleurs s'appuient uniquement sur get(), les valeurs expirent naturellement des caches L1/L2 selon leur TTL.

Remarque bis : cette bibliothèque a été construite avec l'intention d'utiliser une meilleure solution pour la communication inter-travailleurs dès qu'elle émergera. Dans les futures versions de cette bibliothèque, si une bibliothèque IPC peut éviter l'approche de polling, cette bibliothèque le fera également. update() est seulement un mal nécessaire en raison des "limitations" actuelles de Nginx/OpenResty. Vous pouvez cependant utiliser votre propre bibliothèque IPC en utilisant l'option opts.ipc lors de la création de votre instance mlcache.

Ressources

En novembre 2018, cette bibliothèque a été présentée à OpenResty Con à Hangzhou, Chine.

Les diapositives et un enregistrement de la présentation (d'environ 40 minutes) peuvent être visionnés [ici][talk].

Changelog

Voir CHANGELOG.md.

GitHub

Vous pouvez trouver des conseils de configuration supplémentaires et de la documentation pour ce module dans le dépôt GitHub pour nginx-module-mlcache.