Comment éviter le Cache Stampede ?

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

Dans l'article précédent, nous avons appris quelques techniques d'optimisation haute performance avec shared dict et lru cache. Cependant, nous avons laissé de côté un problème important qui mérite son propre article aujourd'hui, "Cache Stampede".

Qu'est-ce qu'un Cache Stampede ?

Imaginons un scénario.

La source de données se trouve dans une base de données MySQL, les données en cache sont dans un shared dict, et le délai d'expiration est de 60 secondes. Pendant les 60 secondes où les données sont en cache, toutes les requêtes récupèrent les données du cache plutôt que de MySQL. Mais une fois les 60 secondes écoulées, les données en cache expirent. S'il y a un grand nombre de requêtes simultanées, aucune donnée en cache ne peut être interrogée. Ensuite, la fonction de requête de la source de données sera déclenchée, et toutes ces requêtes iront vers la base de données MySQL, ce qui provoquera directement un blocage ou même un plantage du serveur de base de données.

Ce phénomène peut être appelé "Cache Stampede", et il est parfois appelé Dog-Piling. Aucun des codes liés au cache qui sont apparus dans les sections précédentes n'a de traitement correspondant. Voici un exemple de pseudo-code qui a le potentiel de provoquer un cache stampede.

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

Le pseudo-code semble avoir une logique correcte, et vous ne déclencherez pas de cache stampede en utilisant des tests unitaires ou des tests de bout en bout. Seul un test de stress prolongé révélera le problème. Toutes les 60 secondes, la base de données subira une augmentation régulière des requêtes. Mais si vous définissez un délai d'expiration du cache plus long ici, les chances de détecter le problème de tempête de cache sont réduites.

Comment l'éviter ?

Divisons la discussion en plusieurs cas différents.

1. Mise à jour proactive du cache

Dans le pseudo-code ci-dessus, le cache est mis à jour passivement et ne va interroger la base de données pour de nouvelles données que lorsqu'une requête est faite mais qu'un échec du cache est détecté. Par conséquent, changer la manière dont le cache est mis à jour, de passif à actif, peut contourner le problème de cache stampede.

Dans OpenResty, nous pouvons l'implémenter comme suit.

D'abord, nous utilisons ngx.timer.every pour créer une tâche de minuterie qui s'exécute toutes les minutes pour récupérer les dernières données de la base de données MySQL et les mettre dans le shared dict :

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

Ensuite, dans la logique du code qui traite la requête, nous devons supprimer la partie qui interroge MySQL et ne garder que la partie du code qui récupère le cache du shared dict.

local value = get_from_cache(key)
return value

Les deux extraits de pseudo-code ci-dessus peuvent nous aider à contourner le problème de cache stampede. Mais cette approche n'est pas parfaite, chaque cache doit correspondre à une tâche périodique (il y a une limite supérieure au nombre de minuteries dans OpenResty), et le délai d'expiration du cache et le temps de cycle de la tâche planifiée doivent correspondre parfaitement. Si une erreur se produit pendant cette période, la requête peut continuer à obtenir des données vides.

Ainsi, dans les projets réels, nous utilisons généralement le verrouillage pour résoudre le problème de cache stampede. Voici quelques méthodes de verrouillage différentes, vous pouvez choisir celle qui vous convient selon vos besoins.

2. lua-resty-lock

Lorsqu'il s'agit d'ajouter des verrous, vous pouvez vous sentir mal à l'aise, pensant que c'est une opération lourde, et que faire si un interblocage se produit et que vous devez gérer un certain nombre d'exceptions.

Nous pouvons atténuer cette préoccupation en utilisant la bibliothèque lua-resty-lock dans OpenResty pour ajouter des verrous. lua-resty-lock est une bibliothèque resty d'OpenResty, qui est basée sur un shared dict et fournit une API de verrouillage non bloquant. Regardons un exemple simple.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

Puisque lua-resty-lock est implémenté en utilisant un shared dict, nous devons d'abord déclarer le nom et la taille du shdict, puis utiliser la méthode new pour créer un nouvel objet lock. Dans l'extrait de code ci-dessus, nous ne passons que le premier paramètre, le nom de shdict. La méthode new a un deuxième paramètre, qui peut être utilisé pour spécifier le délai d'expiration, le temps d'attente pour le verrou, et de nombreux autres paramètres. Ici, nous gardons les valeurs par défaut. Ces paramètres sont utilisés pour éviter les interblocages et autres exceptions.

Ensuite, nous pouvons appeler la méthode lock pour essayer d'obtenir un verrou. Si nous réussissons à acquérir le verrou, nous pouvons nous assurer qu'une seule requête va à la source de données pour mettre à jour les données au même moment. Mais si le verrouillage échoue en raison de la préemption, du délai d'attente, etc., alors les données sont récupérées à partir du cache périmé et renvoyées au demandeur. Cela nous amène à l'API get_stale introduite dans la leçon précédente.

local elapsed, err = lock:lock("my_key")
# elapsed à nil signifie que le verrouillage a échoué. La valeur de retour de err est l'une des suivantes : timeout, locked
if not elapsed and err then
    dict:get_stale("my_key")
end

Si lock réussit, alors il est sûr d'interroger la base de données et de mettre à jour les résultats dans le cache, et enfin, nous appelons l'interface unlock pour libérer le verrou.

En combinant lua-resty-lock et get_stale, nous avons la solution parfaite au problème de cache stampede. La documentation de lua-resty-lock donne un code très complet pour le gérer. Si vous êtes intéressé, vous pouvez le consulter ici.

Allons plus loin et voyons comment l'interface lock implémente le verrouillage. Lorsque nous rencontrons une implémentation intéressante, nous voulons toujours voir comment elle est implémentée dans le code source, ce qui est l'un des avantages de l'open source.

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

Comme mentionné dans l'article sur le shared dict, toutes les API du shared dict sont des opérations atomiques et il n'y a pas besoin de s'inquiéter de la contention. Il est donc judicieux d'utiliser le shared dict pour marquer l'état des verrous.

L'implémentation de lock ci-dessus utilise dict:add pour essayer de définir la clé : si la clé n'existe pas dans la mémoire partagée, add retournera un succès, indiquant que le verrouillage a réussi ; les autres requêtes concurrentes retourneront un échec lorsqu'elles atteindront la logique de la ligne de code dict:add, et ensuite le code peut choisir de retourner directement ou de réessayer plusieurs fois en fonction des informations err retournées.

3. lua-resty-shcache

Dans l'implémentation ci-dessus de lua-resty-lock, vous devez gérer le verrouillage, le déverrouillage, la récupération des données expirées, les réessais, la gestion des exceptions, et d'autres problèmes, ce qui est encore assez fastidieux.

Voici un wrapper simple pour vous : lua-resty-shcache, qui est une bibliothèque lua-resty de Cloudflare, elle fait une couche d'encapsulation sur les dictionnaires partagés et le stockage externe et fournit des fonctions supplémentaires pour la sérialisation et la désérialisation, afin que vous n'ayez pas à vous soucier des détails ci-dessus :

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- cache good data for 10s
          negative_ttl = 3,            -- cache failed lookup for 3s
          name = 'my_cache',     -- "named" cache, useful for debug / report
        }
    )

local my_table, from_cache = my_cache_table:load(key)

Cet exemple de code est extrait de l'exemple officiel et a masqué tous les détails. Cette bibliothèque d'encapsulation de cache n'est pas le meilleur choix, mais c'est un bon matériel d'apprentissage pour les débutants. L'article suivant présentera quelques autres encapsulations meilleures et plus couramment utilisées.

4. Directives NGINX

Si vous n'utilisez pas la bibliothèque lua-resty d'OpenResty, vous pouvez également utiliser les directives de configuration NGINX pour le verrouillage et la récupération des données expirées : proxy_cache_lock et proxy_cache_use_stale. Cependant, nous ne recommandons pas d'utiliser la directive NGINX ici, car elle n'est pas assez flexible et ses performances ne sont pas aussi bonnes que le code Lua.

Résumé

Les cache stampedes, comme le problème de course que nous avons mentionné à plusieurs reprises auparavant, sont difficiles à détecter par des revues de code et des tests. La meilleure façon de les résoudre est d'améliorer votre codage ou d'utiliser une bibliothèque d'encapsulation.

Une dernière question : Comment gérez-vous les cache stampedes et autres dans les langages et plates-formes que vous connaissez ? Y a-t-il une meilleure façon qu'OpenResty ? N'hésitez pas à partager avec moi dans les commentaires.