Clés de la haute performance : `shared dict` et cache `lru`

API7.ai

December 22, 2022

OpenResty (NGINX + Lua)

Dans l'article précédent, j'ai présenté les techniques d'optimisation d'OpenResty et les outils de réglage des performances, qui impliquent string, table, Lua API, LuaJIT, SystemTap, flame graphs, etc.

Ce sont les fondements de l'optimisation du système, et vous devez les maîtriser. Cependant, les connaître uniquement ne suffit pas pour faire face à des scénarios commerciaux réels. Dans un contexte commercial plus complexe, maintenir des performances élevées est un travail systématique, pas seulement une optimisation au niveau du code et de la passerelle. Cela impliquera divers aspects tels que la base de données, le réseau, le protocole, le cache, le disque, etc., ce qui est la raison d'être d'un architecte.

Dans l'article d'aujourd'hui, examinons le composant qui joue un rôle très critique dans l'optimisation des performances - le cache, et voyons comment il est utilisé et optimisé dans OpenResty.

Cache

Au niveau matériel, la plupart des matériels informatiques utilisent des caches pour améliorer la vitesse. Par exemple, les CPU ont des caches multi-niveaux, et les cartes RAID ont des caches de lecture et d'écriture. Au niveau logiciel, la base de données que nous utilisons est un très bon exemple de conception de cache. Il y a des caches dans l'optimisation des instructions SQL, la conception des index, et la lecture et l'écriture sur disque.

Ici, je vous suggère d'apprendre sur les différents mécanismes de cache de MySQL avant de concevoir votre propre cache. Le matériel que je vous recommande est l'excellent livre High Performance MySQL: Optimization, Backups, and Replication. Lorsque j'étais responsable de la base de données il y a de nombreuses années, j'ai beaucoup bénéficié de ce livre, et de nombreux autres scénarios d'optimisation ont ensuite emprunté à la conception de MySQL.

Revenons au cache, nous savons qu'un système de cache dans un environnement de production doit trouver la meilleure solution en fonction de ses scénarios commerciaux et des goulots d'étranglement du système. C'est un art de l'équilibre.

En général, le cache a deux principes.

  • Le premier est que plus on est proche de la demande de l'utilisateur, mieux c'est. Par exemple, n'envoyez pas de requêtes HTTP si vous pouvez utiliser un cache local. Envoyez-la au site d'origine si vous pouvez utiliser un CDN, et ne l'envoyez pas à la base de données si vous pouvez utiliser le cache d'OpenResty.
  • Le second est d'essayer d'utiliser ce processus et le cache local pour le résoudre. Parce qu'à travers les processus, les machines, et même les salles de serveurs, la surcharge réseau du cache sera très importante, ce qui sera très évident dans les scénarios à haute concurrence.

Dans OpenResty, la conception et l'utilisation du cache suivent également ces deux principes. Il y a deux composants de cache dans OpenResty : le cache shared dict et le cache lru. Le premier ne peut cacher que des objets de type string, et il n'y a qu'une seule copie des données cachées, qui peut être accédée par chaque worker, donc il est souvent utilisé pour la communication de données entre workers. Le second peut cacher tous les objets Lua, mais ils ne peuvent être accédés que dans un seul processus worker. Il y a autant de données cachées que de workers.

Les deux tableaux simples suivants peuvent illustrer la différence entre shared dict et lru cache :

Nom du composant de cachePortée d'accèsType de données cachéesStructure de donnéesLes données obsolètes peuvent être obtenuesNombre d'APIsUtilisation de la mémoire
shared dictEntre plusieurs workersobjets stringdict,queueoui20+une seule copie des données
lru cachedans un seul workertous les objets Luadictnon4n copies des données (N = nombre de workers)

shared dict et lru cache ne sont pas bons ou mauvais. Ils doivent être utilisés ensemble selon votre scénario.

  • Si vous n'avez pas besoin de partager des données entre workers, alors lru peut cacher des types de données complexes comme les tableaux et les fonctions et a les meilleures performances, donc c'est le premier choix.
  • Mais si vous avez besoin de partager des données entre workers, vous pouvez ajouter un cache shared dict basé sur le cache lru pour former une architecture de cache à deux niveaux.

Ensuite, examinons en détail ces deux méthodes de cache.

Cache Shared dict

Dans l'article sur Lua, nous avons fait une introduction spécifique à shared dict, voici un bref rappel de son utilisation :

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

Vous devez déclarer la zone mémoire dogs dans le fichier de configuration NGINX à l'avance, puis elle peut être utilisée dans le code Lua. Si vous constatez que l'espace alloué à dogs n'est pas suffisant pendant l'utilisation, vous devez d'abord modifier le fichier de configuration NGINX, puis recharger NGINX pour que cela prenne effet. Parce que nous ne pouvons pas étendre et réduire à l'exécution.

Ensuite, concentrons-nous sur plusieurs problèmes liés aux performances dans le cache shared dict.

Sérialisation des données cachées

Le premier problème est la sérialisation des données cachées. Puisque seuls les objets string peuvent être cachés dans le shared dict, si vous voulez cacher un tableau, vous devez sérialiser une fois lors de la mise en cache et désérialiser une fois lors de la récupération :

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                        dict:set("Tom", require("cjson").encode({a=111}))
                        print(require("cjson").decode(dict:get("Tom")).a)'

Cependant, de telles opérations de sérialisation et désérialisation sont très gourmandes en CPU. Si autant d'opérations sont effectuées par requête, vous pouvez voir leur consommation sur le flame graph.

Alors, comment éviter cette consommation dans les dictionnaires partagés ? Il n'y a pas de bonne méthode ici, soit éviter de mettre le tableau dans le dictionnaire partagé au niveau commercial ; soit assembler manuellement les chaînes au format JSON vous-même. Bien sûr, cela apportera également une consommation de performance due à l'assemblage de chaînes et pourrait cacher plus de bugs.

La plupart de la sérialisation peut être démontée au niveau commercial. Vous pouvez décomposer le contenu du tableau et le stocker dans le dictionnaire partagé sous forme de chaînes. Si cela ne fonctionne pas, vous pouvez également cacher le tableau dans lru, et utiliser l'espace mémoire en échange de la commodité et de la performance du programme.

De plus, la clé dans le cache doit également être aussi courte et significative que possible, économisant de l'espace et facilitant le débogage ultérieur.

Données obsolètes

Il y a aussi une méthode get_stale pour lire les données dans le shared dict. Comparée à la méthode get, elle a une valeur de retour supplémentaire pour les données expirées :

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                            dict:set("Tom", 56, 0.01)
                            ngx.sleep(0.02)
                             local val, flags, stale = dict:get_stale("Tom")
                            print(val)'

Dans l'exemple ci-dessus, les données ne sont cachées dans le shared dict que pendant 0.01 secondes, et les données ont expiré 0.02 secondes après la mise en cache. À ce moment, les données ne seront pas obtenues via l'interface get, mais des données expirées peuvent également être obtenues via get_stale. La raison pour laquelle j'utilise le mot "possible" ici est que l'espace occupé par les données expirées a une certaine chance d'être recyclé puis utilisé pour d'autres données. C'est l'algorithme LRU.

En voyant cela, vous pourriez avoir des doutes : à quoi sert-il d'obtenir des données expirées ? N'oubliez pas que ce que nous stockons dans le shared dict est des données cachées. Même si les données cachées expirent, cela ne signifie pas que les données sources doivent être mises à jour.

Par exemple, la source de données est stockée dans MySQL. Après avoir obtenu les données de MySQL, nous avons défini un délai d'expiration de cinq secondes dans le shared dict. Ensuite, lorsque les données expirent, nous avons deux options :

  • Lorsque les données n'existent pas, allez à nouveau interroger MySQL, et mettez le résultat dans le cache.
  • Déterminez si les données MySQL ont changé. S'il n'y a pas de changement, lisez les données expirées dans le cache, modifiez leur délai d'expiration, et faites-les continuer à être efficaces.

La seconde est une solution plus optimisée qui peut interagir avec MySQL aussi peu que possible afin que toutes les demandes des clients obtiennent des données du cache le plus rapide.

À ce moment, comment juger si les données dans la source de données ont changé devient un problème que nous devons considérer et résoudre. Ensuite, prenons lru cache comme exemple pour voir comment un projet réel résout ce problème.

Cache lru

Il n'y a que 5 interfaces pour lru cache : new, set, get, delete, et flush_all. Seule l'interface get est liée au problème ci-dessus. Comprenons d'abord comment cette interface est utilisée :

resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'

Vous pouvez voir que dans le cache lru, la deuxième valeur de retour de l'interface get est directement stale_data, au lieu d'être divisée en deux APIs différentes, get et get_stale, comme dans shared dict. Une telle encapsulation d'interface est plus conviviale pour l'utilisation de données expirées.

Nous recommandons généralement d'utiliser des numéros de version pour distinguer différentes données dans les projets réels. De cette façon, son numéro de version changera également après que les données auront changé. Par exemple, un index modifié dans etcd peut être utilisé comme numéro de version pour marquer si les données ont changé. Avec le concept de numéro de version, nous pouvons faire une simple encapsulation secondaire du cache lru. Par exemple, regardez le pseudo-code suivant, tiré de lrucache

local function (key, version, create_obj_fun, ...)
    local obj, stale_obj = lru_obj:get(key)
    -- Si les données n'ont pas expiré et que la version n'a pas changé, retournez directement les données cachées
    if obj and obj._cache_ver == version then
        return obj
    end

    -- Si les données ont expiré, mais peuvent encore être obtenues, et que la version n'a pas changé, retournez directement les données expirées dans le cache
    if stale_obj and stale_obj._cache_ver == version then
        lru_obj:set(key, obj, item_ttl)
        return stale_obj
    end

    -- Si aucune donnée expirée n'est trouvée, ou si le numéro de version a changé, obtenez les données de la source de données
    local obj, err = create_obj_fun(...)
    obj._cache_ver = version
    lru_obj:set(key, obj, item_ttl)
    return obj, err
end

De ce code, vous pouvez voir qu'en introduisant le concept de numéro de version, nous utilisons pleinement les données expirées pour réduire la pression sur la source de données et atteindre des performances optimales lorsque le numéro de version ne change pas.

De plus, dans la solution ci-dessus, il y a une grande optimisation potentielle dans le fait que nous séparons la clé et le numéro de version et utilisons le numéro de version comme un attribut de la valeur.

Nous savons que l'approche plus conventionnelle est d'écrire le numéro de version dans la clé. Par exemple, la valeur de la clé est key_1234. Cette pratique est très courante, mais dans l'environnement OpenResty, c'est un gaspillage. Pourquoi dites-vous cela ?

Donnez un exemple, et vous comprendrez. Si le numéro de version change toutes les minutes, alors key_1234 deviendra key_1235 après une minute, et 60 clés différentes et 60 valeurs seront régénérées en une heure. Cela signifie également que Lua GC doit recycler les objets Lua derrière 59 paires clé-valeur. La création d'objets et le GC consommeront plus de ressources si vous mettez à jour plus fréquemment.

Bien sûr, ces consommations peuvent également être évitées simplement en déplaçant le numéro de version de la clé vers la valeur. Peu importe la fréquence de mise à jour d'une clé, il n'existe que deux objets Lua fixes. On peut voir que de telles techniques d'optimisation sont très ingénieuses. Cependant, derrière les techniques simples et ingénieuses, vous devez comprendre profondément l'API d'OpenResty et le mécanisme de cache.

Résumé

Bien que la documentation d'OpenResty soit relativement détaillée, vous devez expérimenter et comprendre comment la combiner avec le commerce pour produire le plus grand effet d'optimisation. Dans de nombreux cas, il n'y a qu'une ou deux phrases dans le document, comme les données obsolètes, mais cela fera une énorme différence de performance.

Alors, avez-vous eu une expérience similaire lors de l'utilisation d'OpenResty ? N'hésitez pas à laisser un message pour partager avec nous, et vous êtes invités à partager cet article, apprenons et progressons ensemble.