Améliorer les performances à l’aide d’un cache avec Azure Database pour PostgreSQL serveur flexible

Lorsque vous créez une application sur Azure Database pour PostgreSQL serveur flexible, l’ajout d’une couche de mise en cache est l’une des méthodes les plus efficaces pour améliorer les temps de réponse, réduire la charge sur votre base de données et augmenter la résilience. En servant fréquemment des données lues à partir d’un cache en mémoire, votre application envoie moins de requêtes à PostgreSQL. Cela signifie une consommation plus faible de CPU et d’IOPS, ce qui vous permet d’utiliser un niveau de calcul inférieur, d’augmenter la capacité de lecture sans augmenter la capacité du serveur et d’absorber les pics de trafic. Un cache peut également ajouter une résilience. Si PostgreSQL a une brève interruption, les demandes qui atteignent des données déjà dans le cache peuvent continuer à réussir, de sorte que les chemins de lecture restent disponibles pendant la récupération de la base de données.

Cet article vous aide à déterminer quand la mise en cache est utile et quel modèle correspond à votre application. Il implémente ensuite quatre modèles de mise en cache dans Python (cache-aside, prérécupération des données de référence, écriture directe et pilotée par les événements) à l’aide de Azure Redis managé, des redispsycopg bibliothèques et de l’authentification Microsoft Entra ID.

Quand ajouter un cache

PostgreSQL met déjà en cache les pages de données fréquemment sollicitées dans son cache de mémoire tampon et bénéficie également du cache de fichiers du système d’exploitation. Ces caches accélèrent l’accès répété, mais partagent la mémoire allouée au serveur de base de données avec l’exécution des requêtes et d’autres processus. Leur contenu doit également être réchauffé après certaines opérations de maintenance et de basculement.

Vous pouvez obtenir davantage de capacité de cache de base de données en effectuant une mise à l’échelle vers une option de calcul avec plus de mémoire. Vous pouvez également régler les paramètres de mémoire PostgreSQL, mais l’allocation de plus de mémoire au cache de mémoire tampon laisse moins de temps pour l’exécution des requêtes et le système d’exploitation. Testez soigneusement les modifications apportées à la mémoire afin d’éviter les situations de mémoire insuffisante.

Azure Managed Redis complète ces caches natifs. Il stocke les données d’application sélectionnées et les résultats de requête en dehors du serveur de base de données, fournit un accès à faible latence pour les chemins de lecture sensibles au temps et peut conserver les lectures mises en cache disponibles pendant que PostgreSQL récupère ou réchauffe son cache. Utilisez-la lorsque ces avantages justifient la logique d’application ajoutée et un autre service à utiliser. Il ne remplace pas PostgreSQL comme source de vérité.

L’ajout d’un cache externe comme Azure Managed Redis aide le plus lorsque votre charge de travail présente ces caractéristiques :

  • Modèles d’accès à forte intensité de lecture. Les mêmes lignes sont lues beaucoup plus souvent qu’elles ne changent, telles que les catalogues de produits, les profils utilisateur, les données de configuration ou les tables de référence.
  • Requêtes coûteuses ou répétées. Agrégations, jointures ou résultats calculés dont la production est coûteuse, mais qui restent stables sur de courtes périodes.
  • Points de terminaison sensibles à la latence. Les opérations destinées aux utilisateurs pour lesquelles une lecture en mémoire (en moins d’une milliseconde) est préférable à un aller-retour vers la base de données.
  • Pics anticipés. Trafic saisonnier ou lié à des événements, pour lequel la mise en cache absorbe une charge qui vous obligerait autrement à augmenter les ressources de calcul.
  • Sensibilité à la maintenance et au basculement. Lit les chemins qui nécessitent des temps de réponse stables pendant qu’une instance PostgreSQL récupère ou réchauffe son cache après une opération de maintenance ou de basculement.

La mise en cache permet de réduire les charges de travail lourdes en écriture, les données qui doivent toujours être cohérentes de manière transactionnelle ou les requêtes déjà rapides et rarement répétées.

Modèles de mise en cache

Cet article prend comme fil conducteur une boutique de vente au détail. Différentes parties de l’application bénéficient de différents modèles de mise en cache. Les sections suivantes implémentent les quatre premiers modèles dans Python. L’article décrit le déchargement de session et d’état et les modèles de mise en cache multirégion, mais ne fournit pas d’implémentations de code pour eux.

Modèle Fonctionnement Dans la vitrine de vente au détail
Cache-aside (chargement différé) L’application vérifie d’abord le cache. En cas d’absence, il lit à partir de PostgreSQL, puis remplit le cache. Pages de catalogue produit et pages de détail produit, où quelques produits populaires génèrent la majorité des consultations.
Préchargement des données de référence Les données stables sont chargées d'emblée dans le cache et actualisées dès que la source change, et non pas uniquement en cas d'échec. Catégories, marques et configuration de l’expédition.
Write-through L’application écrit dans le cache et PostgreSQL dans la même opération, en les conservant cohérentes. Mises à jour de prix et d’inventaire qui doivent être visibles immédiatement.
Invalidation déclenchée par événement Les entrées de cache sont mises à jour ou invalidées en réponse à des événements de modification des données plutôt que selon un minuteur. L’état de la commande à mesure qu’elle progresse dans le processus d’exécution.
Déchargement de session et d’état L’état temporaire réside dans le cache au lieu de la base de données. Paniers d’achat et sessions utilisateur.
Mise en cache multirégion Un cache dans chaque région fournit des lectures locales, conservées en synchronisation avec la géoréplication active. Vitrine mondiale qui sert les acheteurs dans plusieurs régions.

Prerequisites

Tip

Pour obtenir la version complète et déployable de cet exemple, notamment l’infrastructure en tant que code et les quatre modèles, consultez le référentiel amr-caching-pattern-samples sur GitHub.

Étape 1 : Installer les bibliothèques clientes

Installez les bibliothèques clientes Redis et PostgreSQL, ainsi que la bibliothèque Azure Identity pour l’authentification Microsoft Entra.

pip install "redis>=5.0,<6.0" "psycopg[binary]>=3.1,<4.0" "azure-identity>=1.17,<2.0"

Étape 2 : Se connecter avec Microsoft Entra ID

Utilisez Microsoft Entra ID authentification au lieu des clés d’accès. Microsoft Entra ID supprime la nécessité de stocker des secrets dans votre application et vous permet de gérer l’accès de manière centralisée.

Le code suivant crée un client Redis et une connexion PostgreSQL, tous deux authentifiés avec une identité managée ou des informations d’identification de développeur via DefaultAzureCredential. L’exemple utilise le client RedisCluster compatible avec les clusters, conformément à la stratégie de clustering OSS qu’utilise cet exemple. Si votre cache utilise la stratégie de clustering Entreprise, utilisez plutôt le client standard redis.Redis .

import os
import json
import redis
from redis.cluster import RedisCluster
import psycopg
from azure.identity import DefaultAzureCredential

REDIS_HOST = os.environ["REDIS_HOST"]  # for example, mycache.eastus.redis.azure.net
REDIS_PORT = 10000
PG_HOST = os.environ["PG_HOST"]        # for example, myserver.postgres.database.azure.com
PG_DATABASE = os.environ["PG_DATABASE"]

credential = DefaultAzureCredential()

# Acquire a token for Azure Managed Redis and use it as the password.
redis_token = credential.get_token("https://redis.azure.com/.default")

# The username is the object ID of the Microsoft Entra identity.
redis_client = RedisCluster(
    host=REDIS_HOST,
    port=REDIS_PORT,
    ssl=True,
    ssl_check_hostname=False,  # cluster nodes are reached by IP; the certificate chain is still validated
    username=os.environ["REDIS_USER_OBJECT_ID"],
    password=redis_token.token,
    decode_responses=True,
)

# Acquire a token for Azure Database for PostgreSQL and use it as the password.
pg_token = credential.get_token("https://ossrdbms-aad.database.windows.net/.default")

pg_conn = psycopg.connect(
    host=PG_HOST,
    dbname=PG_DATABASE,
    user=os.environ["PG_USER"],
    password=pg_token.token,
    sslmode="require",
)

Note

Microsoft Entra jetons d’accès expirent généralement après environ une heure. Pour les applications de longue durée d’exécution, actualisez le jeton avant son expiration et reconnectez-vous pour Azure Managed Redis et Azure Database pour PostgreSQL, ou utilisez un utilitaire qui réacquiert le jeton de façon transparente. Pour plus d’informations, consultez Utiliser Microsoft Entra ID pour l’authentification avec Azure Redis managé.

Étape 3 : Mise en cache

Le cache-aside est le plus courant et il est utilisé dans cet exemple pour les opérations de lecture des produits. L’application vérifie d’abord Redis et, en cas de défaut de cache, interroge PostgreSQL, puis alimente le cache en définissant une durée de vie (TTL). Quelques produits populaires génèrent la plupart des lectures, donc le taux de succès est élevé.

Comme la plupart des lectures sont servies depuis la mémoire, la stratégie cache-aside décharge PostgreSQL d’une charge de lecture soutenue. Cette réduction signifie moins de connexions, moins d’attrition du cache de mémoire tampon et un processeur et des IOPS inférieurs. Vous pouvez absorber les pics de lecture sans augmenter la capacité du serveur ni ajouter de réplicas en lecture. Vous n’interrogez PostgreSQL qu’en cas de défaut dans le cache (lors du premier accès ou après expiration du TTL). Consultez Rechercher les éléments à mettre en cache pour identifier les requêtes qui valent la mise en cache.

CACHE_TTL_SECONDS = 300  # 5 minutes

def get_product(product_id: int) -> dict | None:
    cache_key = f"product:{product_id}"

    # 1. Try the cache first.
    cached = redis_client.get(cache_key)
    if cached is not None:
        return json.loads(cached)

    # 2. On a miss, read from PostgreSQL.
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
        row = cursor.fetchone()
    if row is None:
        return None

    product = {"id": row[0], "name": row[1], "price": float(row[2])}

    # 3. Populate the cache with a TTL, then return.
    redis_client.set(cache_key, json.dumps(product), ex=CACHE_TTL_SECONDS)
    return product

Étape 4 : Prérécupération des données de référence

Les données stables, consultées en permanence mais rarement modifiées (par exemple, les catégories, les marques ou la configuration d’expédition), n’ont pas besoin d’attendre un défaut de cache. Chargez-le dans le cache avant et actualisez-le lorsque la source change. Dans un schéma PostgreSQL, il s’agit généralement des petites tables de recherche et de dimension qui sont jointes à de nombreuses requêtes. Le fait de les servir depuis la mémoire supprime un grand nombre de jointures répétées et de recherches dans la base de données. Contrairement au cache-aside, il n’y a ni défaut de cache à chaque requête, ni condition de concurrence liée au TTL. Vous actualisez le changement, de sorte que les lectures sont toujours chaudes.

def prefetch_categories() -> None:
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name FROM categories ORDER BY name")
        categories = [{"id": r[0], "name": r[1]} for r in cursor.fetchall()]
    redis_client.set("ref:categories", json.dumps(categories))  # no TTL; refreshed on change

def get_categories() -> list[dict]:
    cached = redis_client.get("ref:categories")
    return json.loads(cached) if cached else []

Étape 5 : write-through

Lorsqu’une modification doit être visible immédiatement, écrivez dans PostgreSQL et dans le cache au cours de la même opération, plutôt que d’attendre l’expiration du TTL ou d’invalider la clé. Par exemple, ce modèle est utilisé pour mettre à jour les informations de tarification dans l’exemple. PostgreSQL reste la source de vérité. La modification s’y valide d’abord, puis le cache est actualisé. Par conséquent, une lecture après que l’écriture retourne la nouvelle valeur.

Lorsque vous écrivez dans deux systèmes qui ne partagent pas la même transaction, la validation en base de données peut réussir tandis que l’actualisation du cache échoue, et il n’existe pas de solution simple. L’extrait de code suivant montre le scénario nominal et ne traite pas la gestion des échecs. En production, c’est à vous de décider comment gérer un échec d’actualisation, par exemple en réessayant lorsque l’échec semble temporaire, ou en invalidant la clé afin que la lecture suivante recharge les données depuis PostgreSQL. Dans les deux cas, PostgreSQL contient la valeur correcte. Par conséquent, une entrée de cache obsolète ou manquante est toujours récupérable. Lorsque la mise à jour du cache doit atterrir de manière fiable, dirigez-la à partir du flux de modification de la base de données à la place (voir l’invalidation pilotée par les événements).

def update_price(product_id: int, new_price: float) -> None:
    # 1. Write to PostgreSQL, the source of truth.
    with pg_conn.cursor() as cursor:
        cursor.execute("UPDATE products SET price = %s WHERE id = %s", (new_price, product_id))
    pg_conn.commit()

    # 2. Refresh the cached entry so reads see the new price right away.
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
        row = cursor.fetchone()
    if row is not None:
        product = {"id": row[0], "name": row[1], "price": float(row[2])}
        redis_client.set(f"product:{product_id}", json.dumps(product), ex=CACHE_TTL_SECONDS)

Étape 6 : Invalidation pilotée par les événements

L’invalidation pilotée par les événements maintient la cohérence du cache avec la base de données en réagissant aux événements de modification des données. Il met à jour ou invalide les entrées à mesure qu’elles changent au lieu de les expirer sur un minuteur. Les enregistreurs ajoutent des événements à un flux Redis durable, un journal d’ajout uniquement et un ou plusieurs consommateurs lisent ces événements et mettent à jour le cache. Étant donné que le flux persiste, les événements survivent à un redémarrage du consommateur. Un groupe de consommateurs attribue chaque événement à un seul collaborateur, suit les acquittements afin que rien ne soit perdu ni traité deux fois, et permet de répartir le traitement entre plusieurs collaborateurs.

Utilisez ce modèle lorsqu’une valeur mise en cache est dérivée de données qui changent ailleurs (un état, une projection ou un agrégat), dans les cas où un TTL entraînerait soit la fourniture de données obsolètes, soit un recalcul constant. Dans la boutique en ligne, ce schéma gère le statut des commandes jusqu’à leur exécution. Le placement d’une commande écrit l’ordre dans PostgreSQL, met en cache son état initial et ajoute un placed événement au flux :

ORDER_STREAM = "orders:events"

def place_order(product_id: int, quantity: int) -> int:
    with pg_conn.cursor() as cursor:
        cursor.execute(
            "INSERT INTO orders (product_id, quantity, status) VALUES (%s, %s, 'placed') RETURNING id",
            (product_id, quantity),
        )
        order_id = cursor.fetchone()[0]
    pg_conn.commit()

    redis_client.set(f"order:{order_id}:status", "placed", ex=86400)
    redis_client.xadd(ORDER_STREAM, {"order_id": order_id, "status": "placed"}, maxlen=10000, approximate=True)
    return order_id

Un collaborateur de traitement exécute un groupe de consommateurs : il lit de nouveaux événements, avance chaque commande dans PostgreSQL, actualise la projection order:{id}:status mise en cache et reconnaît l’événement. La page de commande lit cette projection, de sorte que les vérifications d’état restent rapides et ne touchent jamais la base de données. La valeur reste correcte, car les événements la maintiennent à jour.

GROUP = "fulfillment"

def process_orders() -> None:
    try:
        redis_client.xgroup_create(ORDER_STREAM, GROUP, id="0", mkstream=True)
    except redis.exceptions.ResponseError:
        pass  # group already exists

    while True:
        events = redis_client.xreadgroup(GROUP, "worker-1", {ORDER_STREAM: ">"}, count=10, block=5000)
        for _stream, entries in events or []:
            for event_id, fields in entries:
                order_id = int(fields["order_id"])
                with pg_conn.cursor() as cursor:
                    cursor.execute("UPDATE orders SET status = 'shipped' WHERE id = %s", (order_id,))
                pg_conn.commit()
                redis_client.set(f"order:{order_id}:status", "shipped", ex=86400)
                redis_client.xack(ORDER_STREAM, GROUP, event_id)

La source d’événement de cet exemple est l’application, qui écrit PostgreSQL et ajoute l’événement dans le même chemin d’accès. PostgreSQL peut également émettre des modifications elle-même : LISTEN/NOTIFY pour les notifications légères ou le décodage logique (capture de données modifiées) pour un flux de modification durable au niveau des lignes. La conduite du cache à partir du propre flux de modification de PostgreSQL signifie qu’elle réagit à chaque modification validée, même les écritures qui contournent l’application.

Bonnes pratiques

Suivez ces bonnes pratiques pour maintenir votre cache cohérent, efficace et rentable.

  • Définissez un TTL partout où la péremption est un risque. Un TTL limite l’obsolescence des données si l’invalidation échoue. Ajustez le TTL en fonction du niveau maximal d’obsolescence que l’application peut accepter. Utilisez un TTL long ou aucun TTL pour les données de référence uniquement lorsque des processus fiables d’invalidation et d’actualisation existent.
  • Utilisez une convention de nommage des clés cohérente. Clés d’espace de noms par service, entité et identificateur, telles que product:42 ou user:1001:profile. Ajoutez une version lorsque le format de clé ou le schéma de valeur peut changer.
  • Mettez en cache la granularité appropriée. Mettez en cache des entités individuelles ou de petits jeux de résultats lorsqu’elles sont réutilisées souvent et sont faciles à invalider. Ne mettez pas en cache des données peu susceptibles d’être réutilisées. La mise en cache excessive perd de la mémoire et peut réduire le taux d’accès.
  • Gérez correctement les défauts de cache et les pannes. Traitez le cache comme une optimisation, et non comme une source de vérité. Si Redis n’est pas disponible, utilisez un secours limité à PostgreSQL. Ajoutez des délais d’expiration, des disjoncteurs, des interruptions et des limites de requête pour protéger PostgreSQL. Si PostgreSQL a une brève interruption, vous pouvez continuer à traiter les lectures des données déjà dans le cache pendant que les écritures attendent que la base de données puisse être récupérée.
  • Empêchez les tamponnements de cache. Lorsqu’une clé populaire expire, de nombreuses requêtes peuvent atteindre la base de données à la fois. Utilisez l'instabilité TTL, le regroupement des requêtes, stale-while-revalidate ou un verrou distribué de courte durée pour qu’une seule requête repeuple l’entrée.
  • Ajustez correctement la taille du cache. Surveillez le taux de succès, l’utilisation de la mémoire, le taux d’éviction, le taux d’expiration, la latence, les clés chaudes et la cardinalité des clés. Un faible taux d’accès peut indiquer un cache trop petit, des évictions excessives, une sélection de clé médiocre ou un modèle d’accès médiocre. Pour des conseils de dimensionnement, consultez le guide de sélection du niveau de service Azure Managed Redis.
  • Choisissez une stratégie d’éviction qui correspond à vos clés. Pour une base de données en cache uniquement, commencez par allkeys-lru ou allkeys-lfu. Utilisez une stratégie volatile*uniquement lorsque la même base de données contient des clés de cache arrivant à expiration et des clés non expirées protégées. Une stratégie volatile peut arrêter l’expulsion lorsqu’aucune clé n’a de durée de vie. Séparez les données de cache des données protégées lorsque cela est possible.
  • Sérialiser efficacement. JSON est lisible et portable. Pour les chemins à débit élevé, testez un format binaire compact pour réduire la mémoire et la surcharge réseau. Évaluez la mémoire, le processeur, la latence, l’évolution du schéma et l’impact sur le débogage avant de modifier le format.

Rechercher les éléments à mettre en cache

Les cibles de cache les plus efficaces sont les requêtes que votre application exécute le plus souvent sur les données qui changent le moins. Au lieu de deviner quelles requêtes correspondent à cette description, comparez les vues historiques et actuelles dans les données de télémétrie de requête que Azure Database pour PostgreSQL pouvez collecter lorsque vous activez les fonctionnalités pertinentes :

  • Magasin des requêtes conserve les statistiques d’exécution des requêtes pour l’analyse historique. Utilisez les nombres d’appels et le temps d’exécution total et moyen pour rechercher des requêtes qui dominent constamment la charge de base de données sur des périodes plus longues. Consultez Surveiller les performances avec Magasin des requêtes.
  • Query Performance Insight visualise les données Magasin des requêtes dans le portail Azure, ce qui vous permet de repérer les requêtes fréquentes et gourmandes en ressources et de comparer leur comportement au fil du temps. Consultez Query Performance Insight.
  • pg_stat_statements fournit des statistiques cumulatives pour chaque instruction dans la base de données, offrant une visibilité directe sur la fenêtre d’observation en cours. Ses statistiques peuvent être réinitialisées. Utilisez donc Magasin des requêtes lorsque vous avez besoin d’un historique conservé dans les fenêtres d’observation.

Utilisez les deux vues avant de choisir un candidat de cache. Un pic à court terme peut ne pas représenter le comportement normal de la charge de travail, tandis qu’une moyenne historique peut masquer une régression actuelle. Hiérarchiser les requêtes fréquentes, coûteuses et stables, ce qui signifie le nombre d’appels élevés, le temps d’exécution total élevé et les résultats qui ne changent pas sur chaque requête. Ces requêtes fournissent le taux d’accès au cache le plus élevé et la plus grande baisse de la charge de base de données. Une requête qui s’exécute constamment, mais retourne les mêmes lignes pendant des minutes, telles qu’une liste de produits, une arborescence de catégories ou une table tarifaire, est un candidat idéal.