Stratégies de mise en cache et gestion de la pression sur la base de données en Go

Dans les scénarios à haute concurrence, la mise en cache est un outil essentiel pour améliorer les performances du système. Cependant, des problèmes tels que la pénétration du cache, le passage à travers le cache et l'avalanche de cache peuvent surcharger le système. Cet article explore ces problèmes en détail et propose des solutions efficaces, illustrées par des exemples de code en Go.

1. Pénétration du cache (Cache Penetration)

Description du problème

La pénétration du cache se produit lorsque chaque requête ne parvient pas à trouver de correspondance dans le cache, ce qui oblige à interroger la base de données à chaque fois. Cela peut entraîner une augmentation significative de la pression sur la base de données.

Solution

Pour les données inexistantes, stockez une valeur nulle dans le cache pour éviter les requêtes répétées et inutiles vers la base de données. Il est également crucial de définir une durée d'expiration pour ces valeurs nulles mises en cache afin d'éviter une consommation inutile de mémoire.


// Exemple de code
func fetchDataFromCacheOrDB(dataKey string) (string, error) {
	// Tenter de récupérer depuis le cache
	cachedData, cacheErr := distributedCache.Get(dataKey)
	if cacheErr == nil {
		return cachedData, nil
	}

	// Si le cache est vide ou expire, récupérer depuis la base de données
	dbData := queryFromDatabase(dataKey)

	// Enregistrer les données récupérées dans le cache avec une durée d'expiration
	if dbData != "" {
		distributedCache.Set(dataKey, dbData, cacheExpiryDuration)
	} else {
		// Pour les données inexistantes, enregistrer une valeur nulle avec une courte durée d'expiration
		distributedCache.Set(dataKey, "", shortCacheExpiry)
	}

	return dbData, nil
}

2. Passage à travers le cache (Cache Breakdown)

Description du problème

Dans des conditions de haute concurrence, si plusieurs requêtes interrogent simultanément la même clé de cache et que cette clé expire juste à ce moment-là, cela peut entraîner une surcharge de la base de données car de nombreuses requêtes accèdent directement à la base de données.

Solution

Mettez en œuvre un mécanisme de verrouillage. Seul le premier thread à obtenir le verrou interrogera la base de données. Après avoir reçu la réponse de la base de données, il mettra à jour le cache. Les autres threads, après avoir acquis le verrou, devront interroger à nouveau le cache pour éviter les accès répétés à la base de données.


// Exemple de code
var mutexMap = sync.Map{} // Pour stocker les verrous par clé

func fetchDataWithLocking(dataKey string) (string, error) {
	if val, ok := mutexMap.Load(dataKey); ok {
		// Si un verrou existe déjà, attendez qu'il soit libéré
		lock, ok := val.(*sync.Mutex)
		if ok {
			lock.Lock()
			defer lock.Unlock()
		}
	} else {
		// Créez et acquérez un nouveau verrou pour cette clé
		newMutex := &sync.Mutex{}
		newMutex.Lock()
		if _, loaded := mutexMap.LoadOrStore(dataKey, newMutex); loaded {
			// Si le verrou a été créé par un autre thread pendant notre tentative, libérez le nouveau verrou et attendez le verrou existant
			newMutex.Unlock()
			if val, ok := mutexMap.Load(dataKey); ok {
				if lock, ok := val.(*sync.Mutex); ok {
					lock.Lock()
					defer lock.Unlock()
				}
			}
		} else {
			// Nous avons réussi à stocker le nouveau verrou
			defer mutexMap.Delete(dataKey) // Supprimez le verrou une fois l'opération terminée
			defer newMutex.Unlock()
		}
	}

	// Tenter de récupérer depuis le cache
	cachedData, cacheErr := distributedCache.Get(dataKey)
	if cacheErr == nil {
		return cachedData, nil
	}

	// Récupérer depuis la base de données
	dbData := queryFromDatabase(dataKey)

	// Enregistrer les données récupérées dans le cache avec une durée d'expiration
	if dbData != "" {
		distributedCache.Set(dataKey, dbData, cacheExpiryDuration)
	}

	return dbData, nil
}

3. Avalanche de cache (Cache Avalanche)

Description du problème

Lorsque de nombreuses données du cache expirent simultanément, un grand nombre de requêtes accèdent directement à la base de données backend, ce qui peut potentiellement entraîner la défaillance de la base de données.

Solutions

  • Utiliser des clusters pour réduire la probabilité de défaillance.
  • Implémenter des limites de débit (rate limiting) et des dégradations (circuit breakers) pour protéger les services backend.
  • Définir des durées d'expiration de cache raisonnables et aléatoires pour disperser les moments d'expiration.
  • Pré-charger les données fréquemment consultées (hot data) pour rafraîchir le cache à l'vaance.
  • Ajouter une composante aléatoire à l'expiratoin du cache pour éviter les expirations simultanées.
  • Utiliser un système de cache à plusieurs niveaux (par exemple, cache local et cache distribué).
  • Mettre en place une surveillance en temps réel et des alertes pour détecter les anomalies rapidement et prendre des mesures.

// Exemple de code
import (
	"math/rand"
	"time"
)

func calculateRandomizedExpiration() time.Duration {
	// Ajouter une variation aléatoire à la durée d'expiration par défaut
	randomOffset := time.Duration(rand.Intn(300)) * time.Second // Variation jusqu'à 5 minutes
	return cacheExpiryDuration + randomOffset
}

func fetchDataWithRandomExpiration(dataKey string) (string, error) {
	// Tenter de récupérer depuis le cache
	cachedData, cacheErr := distributedCache.Get(dataKey)
	if cacheErr == nil {
		return cachedData, nil
	}

	// Récupérer depuis la base de données
	dbData := queryFromDatabase(dataKey)

	// Enregistrer les données récupérées dans le cache avec une expiration aléatoire
	if dbData != "" {
		distributedCache.Set(dataKey, dbData, calculateRandomizedExpiration())
	} else {
		// Pour les données inexistantes, enregistrer une valeur nulle avec une courte durée d'expiration
		distributedCache.Set(dataKey, "", shortCacheExpiry)
	}

	return dbData, nil
}

4. Résolution du problème d'expiration concentrée des données "chaudes"

Description du problème

Lorsque les données "chaudes" expirent de manière concentrée, cela peut entraîner un grand nombre de requêtes accédant simultanément à la base de données, provoquant un pic de charge sur celle-ci.

Solutions

  • Définir des durées d'expiration différentes pour disperser les moments d'expiration.
  • Utiliser un mécanisme de verrouillage pour garantir qu'un seul thread met à jour le cache.
  • Implémenter une stratégie de "ne jamais expirer" pour les données critiques, où un travailleur en arrière-plan met à jour le cache avant son expiration.

// Exemple de code
const neverExpire = 0 // Une valeur spéciale pour indiquer une expiration indéfinie

func fetchHotDataWithUpdate(dataKey string) (string, error) {
	// Tenter de récupérer depuis le cache
	cachedData, cacheErr := distributedCache.Get(dataKey)
	if cacheErr == nil {
		return cachedData, nil
	}

	// Utiliser le même mécanisme de verrouillage que pour le passage à travers le cache
	if val, ok := mutexMap.Load(dataKey); ok {
		lock, ok := val.(*sync.Mutex)
		if ok {
			lock.Lock()
			defer lock.Unlock()
		}
	} else {
		newMutex := &sync.Mutex{}
		newMutex.Lock()
		if _, loaded := mutexMap.LoadOrStore(dataKey, newMutex); loaded {
			newMutex.Unlock()
			if val, ok := mutexMap.Load(dataKey); ok {
				if lock, ok := val.(*sync.Mutex); ok {
					lock.Lock()
					defer lock.Unlock()
				}
			}
		} else {
			defer mutexMap.Delete(dataKey)
			defer newMutex.Unlock()
		}
	}

	// Vérifier à nouveau le cache après avoir acquis le verrou
	cachedData, cacheErr = distributedCache.Get(dataKey)
	if cacheErr == nil {
		return cachedData, nil
	}

	// Récupérer depuis la base de données
	dbData := queryFromDatabase(dataKey)

	// Enregistrer les données récupérées dans le cache avec une expiration indéfinie
	if dbData != "" {
		distributedCache.Set(dataKey, dbData, neverExpire)
		// Planifier une tâche pour mettre à jour périodiquement ce cache
		go scheduleCacheRefresh(dataKey, dbData)
	}

	return dbData, nil
}

// Fonction fictive pour planifier le rafraîchissement du cache
func scheduleCacheRefresh(dataKey string, currentData string) {
	// Logique pour mettre à jour le cache avant son expiration (par exemple, après 23 heures si l'expiration est de 24 heures)
	// Cette fonction serait implémentée avec un mécanisme de planification plus robuste.
}

En appliquant ces stratégies, vous pouvez mieux gérer les problèmes liés à la mise en cache, garantissant ainsi la stabilité et les performances de votre système. Le choix de la solution appropriée dépendra de votre scénario métier spécifique et de vos exigences.

Étiquettes: Go Cache database performance Concurrency

Publié le 9 juillet à 08h53