L'architecture microservices de DoorDash s'est développée, tout comme le volume du trafic interservices. Chaque équipe gère ses propres données et y accède par le biais de services gRPC, un cadre d'appel de procédure à distance open-source utilisé pour créer des API évolutives. La plupart des logiques d'entreprise sont liées aux E/S en raison des appels aux services en aval. La mise en cache est depuis longtemps une stratégie de choix pour améliorer les performances et réduire les coûts. Cependant, l'absence d'une approche uniforme de la mise en cache a entraîné des complications. Nous expliquons ici comment nous avons rationalisé la mise en cache par le biais d'une bibliothèque Kotlin, offrant aux développeurs de backend un moyen rapide, sûr et efficace d'introduire de nouvelles mises en cache.
Améliorer les performances tout en soutenant la logique d'entreprise
Dans le monde des microservices de DoorDash, l'accent est davantage mis sur la mise en œuvre de la logique commerciale que sur l'optimisation des performances. Si l'optimisation des schémas d'E/S dans le code peut améliorer les performances, la réécriture de la logique métier pour ce faire prendrait beaucoup de temps et nécessiterait de nombreuses ressources. Le problème est donc de savoir comment améliorer les performances sans remanier le code existant.
Une solution orthodoxe est la mise en cache, qui consiste à stocker des copies des données fréquemment consultées à proximité de l'endroit où elles sont nécessaires, afin d'améliorer la vitesse et les performances des requêtes ultérieures. La mise en cache peut être ajoutée de manière transparente au code de la logique d'entreprise, simplement en surchargeant les méthodes utilisées pour récupérer les données.
Les caches les plus courants chez DoorDash sont Caffeine pour la mise en cache locale et Redis Lettuce pour la mise en cache distribuée. La plupart des équipes utilisent les clients Caffeine et Redis Lettuce directement dans leur code.
Comme il existe des problèmes communs avec la mise en cache, de nombreuses équipes ont rencontré des problèmes similaires lors de la mise en œuvre de leurs propres approches indépendantes.
Problèmes :
- Stabilité du cache : Si la mise en place d'un cache pour une méthode est simple, il est difficile de s'assurer que le cache reste à jour par rapport à la source de données d'origine. La résolution des problèmes liés à des entrées de cache obsolètes peut s'avérer complexe et prendre beaucoup de temps.
- Forte dépendance à l'égard de Redis: les services rencontraient fréquemment un taux d'échec élevé lorsque Redis était en panne ou rencontrait des problèmes.
- Pas de contrôle en temps réel: L'introduction d'un nouveau cache peut être risquée en raison de l'absence d'ajustements en temps réel. Si le cache rencontre des problèmes ou doit être ajusté, les changements nécessitent un nouveau déploiement ou un retour en arrière, ce qui prend du temps et des ressources de développement. En outre, un déploiement séparé est nécessaire pour régler les paramètres du cache tels que TTL
- Schéma de clé incohérent: L'absence d'une approche standardisée pour les clés de cache complique les efforts de débogage. En particulier, il est difficile de déterminer comment une clé dans le cache Redis correspond à son utilisation dans le code Kotlin.
- Des mesures et une observabilité inadéquates: L'absence de mesures uniformes entre les équipes a entraîné un manque de données critiques, telles que les taux d'accès au cache, le nombre de requêtes et les taux d'erreur.
Difficulté de mise en œuvre de la mise en cache multicouche: la configuration précédente ne permettait pas facilement l'utilisation de plusieurs couches de mise en cache pour la même méthode. La combinaison d'un cache local et d'un cache Redis plus gourmand en ressources permettrait d'optimiser les résultats avant de recourir à une solution de repli.
Rêver grand, commencer petit
Bien que nous ayons finalement créé une bibliothèque de mise en cache partagée pour l'ensemble de DoorDash, nous avons commencé par un programme pilote visant à résoudre les problèmes de mise en cache pour un seul service - le backend DashPass. Nous voulions tester notre solution avant de l'adopter ailleurs.
À l'époque, DashPass était confronté à des problèmes d'échelle et à de fréquentes pannes de courant. DoorDash se développait rapidement et voyait son trafic augmenter chaque semaine. DashPass était l'un des plus grands utilisateurs de notre base de données Postgres partagée, une base de données sur laquelle presque tout DoorDash s'appuyait ; si elle tombait en panne, les clients ne seraient pas en mesure de passer des commandes.
Simultanément, nous développions rapidement de nouvelles fonctionnalités et de nouveaux cas d'utilisation pour DashPass, de sorte que la bande passante des développeurs pour l'optimisation des performances était faible.
Avec toute cette activité critique se produisant en même temps que la pression pour stabiliser le service - alors même que la plupart des ingénieurs étaient occupés à gérer des fonctionnalités liées à l'entreprise - nous avons décidé de développer une bibliothèque de mise en cache simple qui pourrait être intégrée de manière transparente et avec un minimum d'interruption.
Une seule interface pour tous
Chaque équipe utilisant des clients de mise en cache différents, notamment Caffeine, Redis Lettuce ou HashMaps, il y avait peu de cohérence dans les signatures de fonctions et les API. Pour normaliser cette situation, nous avons introduit une interface simplifiée que les développeurs d'applications peuvent utiliser pour configurer de nouveaux caches, comme le montre l'extrait de code suivant :
interface CacheManager {
/**
* Wraps fallback in Cache.
* key: Instance of CacheKey.
* Subclasses of CacheKey define a unique cache with a unique
* name, which can be configured via runtime.
* fallback: Invoked on a cache miss. The return value is then cached and
* returned to the caller.
*/
suspend fun <V> withCache(
key: CacheKey<V>,
fallback: suspend () -> V?
): Result<V?>
}
/**
* Each unique cache is tied to a particular implementation of the key.
*
* CacheKey controls the cache name and the type of unique ID.
*
* Name of the cache is the class name of the implementing class.
* all implementations should use a unique class name.
*/
abstract class CacheKey<V>(
val cacheKeyType: String,
val id: String,
val config: CacheKeyConfig<V>
)
/**
* Cache specific config.
*/
class CacheKeyConfig<V>( /**
* Kotlin serializer for the return value. This is used to store values in Redis.
*/
val serializer: KSerializer<V>
)
Cela nous permet d'utiliser l'injection de dépendances et le polymorphisme pour injecter une logique arbitraire dans les coulisses tout en maintenant des appels de cache uniformes à partir de la logique d'entreprise.
Caches superposés
Nous voulions adopter une interface simplifiée pour la gestion du cache afin de permettre aux équipes qui n'utilisaient auparavant qu'une seule couche d'améliorer les performances grâce à un système de cache multicouche. Contrairement à une seule couche, plusieurs couches peuvent améliorer les performances car certaines couches, comme le cache local, sont beaucoup plus rapides que les couches impliquant des appels réseau - par exemple, Redis - qui est déjà plus rapide que la plupart des appels de service.
Dans un cache multicouche, une demande de clé progresse à travers les couches jusqu'à ce que la clé soit trouvée ou jusqu'à ce qu'elle atteigne la fonction de repli de la source de vérité finale (SoT). Si la valeur est récupérée dans une couche ultérieure, elle est ensuite stockée dans les couches précédentes pour un accès plus rapide lors des demandes ultérieures pour la même clé. Ce mécanisme d'extraction et de stockage en couches optimise les performances en réduisant la nécessité d'atteindre la SoT.
Nous avons mis en œuvre trois couches derrière une interface commune, comme le montre la figure 1 :
- Cache local de la demande: Ne vit que pendant la durée de vie de la demande ; utilise une simple table de hachage (HashMap).
- Cache local: Visible par tous les travailleurs au sein d'une même machine virtuelle Java ; utilise un cache Caffeine pour les tâches lourdes.
- Cache Redis: Visible par tous les pods partageant le même cluster Redis ; utilise le client Lettuce.
Figure 1 : Flux de demande de cache multicouche
Contrôle de l'indicateur de fonctionnalité en cours d'exécution
Différents cas d'utilisation peuvent nécessiter des configurations différentes ou la désactivation de couches entières de mise en cache. Pour rendre cela beaucoup plus rapide et plus facile, nous avons ajouté le contrôle de l'exécution. Cela nous permet d'intégrer de nouveaux cas d'utilisation de la mise en cache une fois dans le code, puis d'effectuer un suivi via la durée d'exécution pour le déploiement et le réglage.
Chaque cache unique peut être contrôlé individuellement via le système d'exécution de DoorDash. Chaque cache peut être :
- Activé ou désactivé. Cela peut être pratique si une stratégie de cache nouvellement introduite présente un bogue. Au lieu d'effectuer un déploiement de retour en arrière, nous pouvons simplement désactiver le cache. En mode désactivé, la bibliothèque invoque le mode fallback, en sautant toutes les couches de cache.
- Reconfiguré pour une durée de vie individuelle (TTL). Si le TTL d'une couche est fixé à zéro, elle sera entièrement ignorée.
- Ombragé à un pourcentage spécifié. En mode "shadow", un pourcentage des demandes de mise en cache comparera également la valeur mise en cache à la SoT.
Observabilité et cache shadowing
Pour mesurer les performances des caches, nous recueillons des données sur le nombre de fois qu'un cache est demandé et sur le nombre de fois que les demandes aboutissent à un succès ou à un échec. Le taux de réussite de la mémoire cache est la principale mesure de performance ; notre bibliothèque recueille des mesures de taux de réussite pour chaque mémoire cache et chaque couche.
Une autre mesure importante est la fraîcheur des entrées du cache par rapport à la date d'expiration. Notre bibliothèque fournit un mécanisme de shadowing pour mesurer cela. Si le shadowing est activé, un pourcentage de lectures de cache invoquera également le fallback et comparera les valeurs du cache et du fallback pour vérifier l'égalité. Les mesures sur les correspondances réussies et non réussies peuvent être représentées graphiquement et faire l'objet d'alertes. Il est également possible de mesurer la stagnation du cache, c'est-à-dire le temps de latence entre la création d'une entrée de cache et la mise à jour de la SoT. Il est essentiel de mesurer l'obsolescence du cache, car chaque cas d'utilisation aura une tolérance différente à cet égard.
En plus des métriques, tout échec génère également des journaux d'erreurs, qui détaillent le chemin dans les objets qui diffère entre les valeurs mises en cache et les valeurs d'origine. Cela peut être utile lors du débogage des caches périmés.
La possibilité d'observer l'état de stagnation du cache est essentielle pour valider empiriquement une stratégie d'invalidation du cache.
Exemple d'utilisation
Prenons un exemple et plongeons dans l'API de la bibliothèque.
Chaque clé de cache comporte trois éléments principaux :
- Nom unique du cache, utilisé comme référence dans les contrôles d'exécution.
- Type de clé de cache, une chaîne représentant le type d'entité de la clé pour permettre la catégorisation des clés de cache.
- ID, une chaîne de caractères qui fait référence à une entité unique de type clé de cache.
- Configuration, qui inclut les TTL par défaut et un sérialiseur Kotlin.
Pour normaliser le schéma des clés, nous avons choisi le formatURN (uniform resource name) :
urn:doordash:<cache key type>:<id>#<cache name>
La bibliothèque fournit une instance de CacheManager, qui est injectée et dispose d'une méthode `withCache` qui encapsule un fallback ou une autre fonction de suspension Kotlin à mettre en cache.
Par exemple, si nous avons un référentiel UserProfileRepository avec une méthode GetUserProfile que nous voulons mettre en cache, nous pourrions ajouter la clé suivante :
class UserProfileRepositoryGetUserProfileKey(userId: String): CacheKey<UserProfile>(
cacheKeyType = "user",
id = userId,
config = CacheKeyConfig(serializer = UserProfile.serializer())
)
...
suspend fun getUserProfile(userId: String): UserProfile = CacheManager.withCache(UserProfileRepositoryGetUserProfileKey(userId)) {
... <Fetch user profile> ...
}.getOrThrow()
Une clé pour l'utilisateur dont l'identifiant est "123" serait représentée sous la forme d'un URN comme suit :
urn:doordash:user:123#UserProfileRepositoryGetUserProfileKey
Notez que toute autre clé de cache qui utilise "user" comme type de clé de cache aura le même préfixe que UserProfileRepositoryGetUserProfileKey.
La normalisation de la représentation des clés est très utile pour le débogage de l'observabilité et offre des possibilités uniques de comparaison des clés.
Guide des cas d'utilisation
Une fois la bibliothèque créée et testée dans DashPass, l'étape suivante consistait à la proposer aux développeurs et à les aider à l'intégrer dans leur travail de la manière la plus transparente possible. Pour ce faire, nous avons donné des conseils de haut niveau sur quand et comment utiliser la mise en cache - et, tout aussi important, quand ne pas l'utiliser.
Quand utiliser la mise en cache ?
Nous pouvons décomposer les cas d'utilisation de la mise en cache en fonction d'éventuelles contraintes de cohérence.
Catégorie 1 : Peut tolérer un cache périmé
Dans certains cas d'utilisation, il est acceptable d'avoir un délai de quelques minutes pour que les mises à jour prennent effet. Dans ce cas, il est prudent d'utiliser les trois couches de mise en cache : le cache local des requêtes, le cache local et la couche Redis. Vous pouvez définir le TTL de chaque couche pour qu'il expire dans plusieurs minutes. Le paramètre TTL le plus long pour l'ensemble des couches déterminera le temps maximum nécessaire pour que le cache soit cohérent avec la source de données.
Le contrôle des taux de réussite du cache est crucial pour l'optimisation des performances ; l'ajustement des paramètres TTL peut contribuer à améliorer cette mesure.
Dans ce scénario, il n'est pas nécessaire de mettre en œuvre le shadowing pour contrôler la précision du cache.
Catégorie 2 : Ne peut tolérer un cache périmé
Lorsque les données sont soumises à des changements fréquents, les informations périmées peuvent avoir un impact négatif sur les indicateurs de l'entreprise ou sur l'expérience de l'utilisateur. Il devient crucial de limiter le délai de péremption maximal tolérable à quelques secondes, voire à quelques millisecondes.
La mise en cache locale doit généralement être évitée dans un tel scénario, car elle ne peut pas être invalidée facilement. Toutefois, la mise en cache au niveau des requêtes peut toujours convenir pour le stockage temporaire.
Bien qu'il soit possible de définir un TTL plus long pour la couche Redis, il est essentiel d'invalider le cache dès que les données sous-jacentes changent. L'invalidation du cache peut être mise en œuvre de différentes manières, par exemple en supprimant les clés Redis concernées lors de la mise à jour des données ou en utilisant une approche de marquage pour supprimer les caches lorsque la recherche de motifs est difficile.
Il existe deux options principales pour les déclencheurs d'invalidation. La méthode préférée consiste à utiliser les événements de capture des données de modification émis lorsque les tables de la base de données sont mises à jour, bien que cette approche puisse impliquer un certain temps de latence. Une autre solution consiste à invalider le cache directement dans le code de l'application lorsque les données changent. Cette méthode est plus rapide, mais potentiellement plus complexe, car plusieurs emplacements de code peuvent potentiellement introduire de nouvelles modifications.
Il est essentiel d'activer le shadowing du cache pour surveiller la staleness, car cette visibilité est vitale pour vérifier l'efficacité de la stratégie d'invalidation du cache.
Quand ne pas utiliser la mise en cache
Flux d'écriture ou de mutation
C'est une bonne idée de réutiliser le code autant que possible, de sorte que votre point final d'écriture puisse réutiliser la même fonction mise en cache que vos points finaux de lecture. Mais cela pose un problème potentiel de staleness lorsque vous écrivez dans la base de données et que vous lisez ensuite la valeur en retour. La relecture d'une valeur périmée peut rompre la logique de l'entreprise. Au lieu de cela, il est plus sûr de désactiver complètement la mise en cache pour ces flux tout en réutilisant la même fonction mise en cache en dehors du CacheContext.
En tant que source de vérité
N'utilisez pas le cache comme une base de données et ne le considérez pas comme une source de vérité. Soyez toujours attentif à l'expiration des couches de cache et prévoyez une solution de repli qui interroge la bonne source de vérité.
Conclusion
Les microservices de DoorDash étaient confrontés à des défis importants en raison de pratiques de mise en cache fragmentées. En centralisant ces pratiques dans une bibliothèque complète, nous avons considérablement rationalisé notre évolutivité et renforcé la sécurité de nos services. Avec l'introduction d'une interface standardisée, de mesures cohérentes, d'un schéma de clé unifié et de configurations adaptables, nous avons maintenant renforcé le processus d'introduction de nouveaux caches. De plus, en offrant des conseils clairs sur le moment et la manière d'utiliser les caches, nous avons réussi à éviter les pièges et les inefficacités potentiels. Cette refonte stratégique nous a permis d'exploiter pleinement le potentiel de la mise en cache tout en évitant les écueils les plus courants.
Restez informé grâce aux mises à jour hebdomadaires
Abonnez-vous à notre blog d'ingénierie pour recevoir régulièrement des informations sur les projets les plus intéressants sur lesquels notre équipe travaille.