Mejora el rendimiento utilizando una caché con Servidor flexible de Azure Database for PostgreSQL.

Al compilar una aplicación en Azure Database for PostgreSQL servidor flexible, agregar una capa de almacenamiento en caché es una de las formas más eficaces de mejorar los tiempos de respuesta, reducir la carga en la base de datos y aumentar la resistencia. Al servir datos de lectura frecuente de una memoria caché en memoria, la aplicación envía menos consultas a PostgreSQL. Esto significa un menor consumo de CPU e IOPS, por lo que puede ejecutarse en un nivel de proceso más pequeño, escalar lecturas sin escalar verticalmente el servidor y absorber picos de tráfico. Una memoria caché también puede agregar resistencia. Si PostgreSQL sufre una breve interrupción, las solicitudes que acceden a datos ya presentes en la memoria caché pueden seguir completándose correctamente, por lo que las rutas de lectura siguen estando disponibles mientras la base de datos se recupera.

Este artículo le ayuda a decidir cuándo ayuda el almacenamiento en caché y qué patrón se ajusta a la aplicación. A continuación, implementa cuatro patrones de almacenamiento en caché en Python (cache-aside, precarga de datos de referencia, write-through y basada en eventos) utilizando Azure Managed Redis, las bibliotecas redis y psycopg, y la autenticación de Microsoft Entra ID.

Cuándo agregar una memoria caché

PostgreSQL ya almacena en caché las páginas de datos a las que se accede con frecuencia en su caché del búfer y también se beneficia de la caché de archivos del sistema operativo. Estas memorias caché hacen que el acceso repetido sea más rápido, pero comparten la memoria asignada al servidor de bases de datos con la ejecución de consultas y otros procesos. Su contenido también necesita volver a calentarse tras algunas operaciones de mantenimiento y conmutación por error.

Puede obtener más capacidad de caché de base de datos mediante el escalado a una opción de proceso con más memoria. También puede ajustar la configuración de memoria de PostgreSQL, pero asignar más memoria a la caché del búfer deja menos para la ejecución de consultas y el sistema operativo. Pruebe con cuidado los cambios en la memoria para evitar situaciones de falta de memoria.

Azure Managed Redis complementa estas cachés nativas. Almacena los datos de la aplicación seleccionados y los resultados de las consultas fuera del servidor de bases de datos, proporciona acceso de menor latencia para rutas de acceso de lectura sensibles al tiempo y puede mantener las lecturas almacenadas en caché mientras PostgreSQL recupera o activa su caché. Úsalo cuando estas ventajas justifiquen la lógica de aplicación añadida y la administración de otro servicio. No reemplaza PostgreSQL como origen de verdad.

Agregar una caché externa como Azure Managed Redis ayuda a la mayoría cuando la carga de trabajo tiene estas características:

  • Patrones de acceso con predominio de lectura. Las mismas filas se leen con mucha más frecuencia de la que se modifican, por ejemplo en catálogos de productos, perfiles de usuario, datos de configuración o tablas de referencia.
  • Consultas costosas o repetidas. Agregaciones, combinaciones o resultados calculados que son costosos de generar, pero estables, en breves ventanas de tiempo.
  • Puntos de conexión sensibles a la latencia. Operaciones de cara al usuario en las que una lectura en memoria (de menos de un milisegundo) es preferible a una ida y vuelta a la base de datos.
  • Picos predecibles. Tráfico estacional o provocado por eventos, en el que la caché absorbe la carga que, de otro modo, obligaría a escalar los recursos de cómputo.
  • Sensibilidad al mantenimiento y a la conmutación por error. Rutas de lectura que necesitan tiempos de respuesta constantes mientras una instancia de PostgreSQL se recupera o calienta su caché tras una operación de mantenimiento o conmutación por error.

El almacenamiento en caché resulta menos útil para cargas de trabajo con muchas operaciones de escritura, datos que deban ser siempre coherentes a nivel transaccional o consultas que ya son rápidas y que rara vez se repiten.

Patrones de almacenamiento en caché

En este artículo se usa una tienda minorista como ejemplo a lo largo del texto. Las distintas partes de la aplicación se benefician de diferentes patrones de almacenamiento en caché. En las secciones siguientes se implementan los cuatro primeros patrones en Python. En el artículo se describen los patrones de descarga de sesión y estado y almacenamiento en caché de varias regiones, pero no proporciona implementaciones de código para ellos.

Pattern Cómo funciona En el escaparate comercial
Cache-aside (carga diferida) La aplicación comprueba primero la memoria caché. En caso de error, lee de PostgreSQL y, a continuación, rellena la memoria caché. Catálogo de productos y páginas de detalles, donde algunos elementos populares impulsan la mayoría de las lecturas.
Precarga de datos de referencia Los datos estables se cargan en la caché por adelantado y se actualizan cuando cambia la fuente, en lugar de cuando se produce una falta de acierto. Categorías, marcas y configuración de envío.
Escritura directa La aplicación escribe en la memoria caché y PostgreSQL en la misma operación, lo que las mantiene coherentes. Actualizaciones de precios e inventario que deben estar visibles inmediatamente.
Invalidación basada en eventos Las entradas de caché se actualizan o invalidan en respuesta a eventos de cambios en los datos, en lugar de hacerlo mediante un temporizador. Estado del pedido a medida que avanza por el proceso de tramitación.
Descarga de sesiones y estado El estado transitorio reside en la memoria caché en lugar de en la base de datos. Carritos de compra y sesiones de usuario.
Almacenamiento en caché multirregional Una caché en cada región sirve lecturas locales, que se mantienen sincronizadas con la replicación geográfica activa. Un escaparate global que sirve a los compradores en varias regiones.

Prerequisites

Tip

Para obtener la versión completa e implementable de este ejemplo, incluida la infraestructura como código y los cuatro patrones, consulte el repositorio amr-caching-pattern-samples en GitHub.

Paso 1: Instalar las bibliotecas cliente

Instale las bibliotecas cliente de Redis y PostgreSQL, junto con la biblioteca de identidades de Azure para la autenticación de Microsoft Entra.

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

Paso 2: Conexión con Microsoft Entra ID

Use la autenticación de Microsoft Entra ID en lugar de las claves de acceso. Microsoft Entra ID elimina la necesidad de almacenar secretos en la aplicación y le permite administrar el acceso de forma centralizada.

El código siguiente crea un cliente de Redis y una conexión de PostgreSQL, ambos autenticados con una identidad administrada o una credencial de desarrollador a través de DefaultAzureCredential. El ejemplo usa el cliente con reconocimiento de clúster RedisCluster, que coincide con la política de clustering de OSS que utiliza este ejemplo. Si la memoria caché usa la directiva de agrupación en clústeres enterprise, use el cliente estándar redis.Redis en su lugar.

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

Los tokens de acceso de Microsoft Entra expiran normalmente al cabo de aproximadamente una hora. En el caso de las aplicaciones de larga duración, actualice el token antes de que expire y vuelva a conectarse para Azure Managed Redis y Azure Database for PostgreSQL, o use un asistente que vuelva a adquirir tokens de forma transparente. Para más información, consulte Uso de Microsoft Entra ID para la autenticación con Azure Redis administrado.

Paso 3: Cache-aside

Cache-aside es el patrón más común y se utiliza para las lecturas de productos en este ejemplo. La aplicación comprueba primero Redis y, en caso de no encontrarlo, consulta PostgreSQL y rellena la caché con un tiempo de vida (TTL). Algunos productos populares impulsan la mayoría de las lecturas, por lo que la tasa de aciertos es alta.

Dado que la mayoría de las lecturas se atienden desde la memoria, cache-aside reduce la carga sostenida de lectura en PostgreSQL. Esta reducción significa menos conexiones, menos rotación de la caché de búfer y menor uso de CPU y menos IOPS. De este modo, se pueden absorber los picos de lectura sin necesidad de ampliar el servidor ni añadir réplicas de lectura. Solo se consulta PostgreSQL en caso de no encontrarlo (en el primer acceso o tras la expiración del TTL). Consulte Búsqueda de qué almacenar en caché para identificar las consultas que merece la pena almacenar en caché.

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

Paso 4: Precarga de datos de referencia

Los datos estables que se leen constantemente, pero que cambian rara vez (por ejemplo, categorías, marcas o configuración de envío) no necesitan esperar a que se produzca un error en la memoria caché. Cárgelo en la memoria caché por adelantado y actualízelo cuando cambie el origen. En un esquema de PostgreSQL, suelen ser las pequeñas tablas de consulta y de dimensiones que se unen en muchas consultas. Servirlos desde la memoria quita un gran volumen de combinaciones repetidas y búsquedas de la base de datos. A diferencia de cache-aside, no hay fallos por solicitud ni conflictos de TTL. La actualización se realiza al producirse un cambio, por lo que las lecturas siempre están calientes.

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 []

Paso 5: Escritura directa

Cuando un cambio debe estar visible inmediatamente, escriba PostgreSQL y la memoria caché en la misma operación en lugar de esperar a que un TTL expire o invalide la clave. Por ejemplo, este patrón se usa para actualizar la información de precios en el ejemplo. PostgreSQL sigue siendo el origen de la verdad. El cambio se confirma allí primero y, a continuación, se actualiza la memoria caché, por lo que una lectura después de que la escritura devuelva el nuevo valor.

Al escribir en dos sistemas que no comparten una transacción, la confirmación de la base de datos puede realizarse correctamente mientras se produce un error en la actualización de la memoria caché y no hay ninguna corrección sencilla. En el siguiente fragmento de código se muestra el caso ideal y se omite el manejo de errores. En producción, debes decidir cómo resolver una actualización fallida, por ejemplo, reintentándola cuando el fallo parezca transitorio o invalidando la clave para que la siguiente lectura se recargue desde PostgreSQL. En cualquier caso, PostgreSQL contiene el valor correcto, por lo que siempre se puede recuperar una entrada de caché obsoleta o que falta. Cuando la actualización de la caché deba aplicarse de forma fiable, haz que se impulse desde el flujo de cambios de la base de datos (véase invalidación impulsada por eventos).

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)

Paso 6: Invalidación controlada por eventos

La invalidación controlada por eventos mantiene la memoria caché coherente con la base de datos mediante la reacción a los eventos de cambio de datos. Actualiza o invalida las entradas a medida que cambian en lugar de expirarlas en un temporizador. Los escritores añaden eventos a un flujo duradero de Redis, un registro de solo adición, y uno o más consumidores leen esos eventos y actualizan la caché. Dado que el flujo persiste, los eventos sobreviven al reinicio de un consumidor. Un grupo de consumidores entrega cada evento a un único trabajador, realiza un seguimiento de las confirmaciones, por lo que nada se pierde o se procesa dos veces, y le permite escalar el procesamiento entre los trabajadores.

Use este patrón cuando un valor almacenado en caché se derive de datos que cambien en otro lugar (un estado, una proyección o un agregado), donde un TTL serviría datos obsoletos o forzaría la recomputación constante. En la tienda, este patrón hace avanzar el estado del pedido a lo largo del proceso de tramitación. Al realizar un pedido, se escribe el pedido en PostgreSQL, se almacena en caché su estado inicial y se anexa un placed evento a la secuencia:

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 trabajador de cumplimiento ejecuta un grupo de consumidores: lee nuevos eventos, avanza cada orden en PostgreSQL, actualiza la proyección order:{id}:status almacenada en caché y confirma el evento. La página de pedidos lee esa proyección, por lo que las comprobaciones de estado permanecen rápidas y nunca tocan la base de datos. El valor permanece correcto porque los eventos lo mantienen al día.

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)

El origen de eventos de este ejemplo es la aplicación, que escribe en PostgreSQL y añade el evento en la misma ruta. PostgreSQL también puede emitir cambios: LISTEN/NOTIFY para notificaciones ligeras o descodificación lógica (captura de datos modificados) para un flujo de cambios duradero y de nivel de fila. Hacer que la caché se alimente del propio flujo de cambios de PostgreSQL significa que reacciona a todos los cambios confirmados, incluso a las operaciones de escritura que eluden la aplicación.

procedimientos recomendados

Siga estos procedimientos para mantener la memoria caché correcta, eficaz y rentable.

  • Establezca un TTL siempre que la obsolescencia sea un riesgo. Un TTL limita la obsolescencia si se produce un error en la invalidación. Haga coincidir el TTL con la obsolescencia máxima que puede aceptar la aplicación. Use un TTL largo o ningún TTL para los datos de referencia solo cuando existan procesos de invalidación y actualización confiables.
  • Use un esquema de nomenclatura de claves coherente. Claves de espacio de nombres por servicio, entidad e identificador, como product:42 o user:1001:profile. Agregue una versión cuando el formato de clave o el esquema de valor pueda cambiar.
  • Almacena en caché con la granularidad adecuada. Almacenar en caché entidades individuales o conjuntos de resultados pequeños cuando se reutilizan a menudo y son fáciles de invalidar. No almacene en caché los datos que tengan poca reutilización. El almacenamiento en caché excesivo desperdicia memoria y puede reducir la tasa de aciertos.
  • Gestione los fallos de caché y las caídas del servicio de forma adecuada. Trate la memoria caché como una optimización, no como una fuente de verdad. Si Redis no está disponible, utilice un mecanismo alternativo limitado en PostgreSQL. Agregue tiempos de espera, disyuntores, retrocesos y límites de solicitud para proteger PostgreSQL. Si PostgreSQL tiene una breve interrupción, puede seguir sirviendo lecturas para los datos que ya están en la memoria caché mientras las escrituras esperan a que la base de datos se recupere.
  • Evita las estampidas en la caché. Cuando expira una clave popular, muchas solicitudes pueden alcanzar la base de datos a la vez. Utiliza fluctuación de TTL, agrupación de solicitudes, stale-while-revalidate o un bloqueo distribuido breve para permitir que una solicitud vuelva a rellenar la entrada.
  • Dimensione correctamente la memoria caché. Supervise la tasa de aciertos, el uso de memoria, la tasa de expulsión, la tasa de expiración, la latencia, las claves de acceso frecuente y la cardinalidad de claves. Una tasa de aciertos baja puede indicar una memoria caché demasiado pequeña, expulsión excesiva, selección de clave deficiente o un patrón de acceso deficiente. Para obtener orientación sobre el dimensionamiento, consulte la guía para seleccionar el nivel de Azure Managed Redis.
  • Elige una directiva de expulsión que se adapte a tus claves. Para una base de datos de solo caché, comience con allkeys-lru o allkeys-lfu. Use una directiva volatile-* solo cuando la misma base de datos contenga claves de caché que expiran y claves protegidas sin expirar. Una directiva volátil puede dejar de expulsar elementos cuando ninguna clave tenga un TTL. Separe los datos de caché de los datos protegidos siempre que sea posible.
  • Serialice de forma eficaz. JSON es legible y portátil. Para rutas de acceso de alto rendimiento, pruebe un formato binario compacto para reducir la sobrecarga de memoria y red. Evalúa la memoria, la CPU, la latencia, la evolución del esquema y el impacto en la depuración antes de cambiar el formato.

Buscar qué almacenar en caché

Los destinos de caché más eficaces son las consultas que la aplicación ejecuta con más frecuencia en los datos que cambian menos. En lugar de adivinar qué consultas se ajustan a esta descripción, compare las vistas históricas y actuales de la telemetría de consulta que Azure Database for PostgreSQL puede recopilar al habilitar las características pertinentes:

  • Almacén de consultas conserva las estadísticas de ejecución de consultas para el análisis histórico. Use recuentos de llamadas y tiempo total y medio de ejecución para buscar consultas que dominan de forma coherente la carga de la base de datos durante períodos más largos. Consulte Supervisión del rendimiento con Almacén de consultas.
  • Query Performance Insight visualiza Almacén de consultas datos en el portal de Azure, por lo que puede detectar consultas frecuentes y intensivas en recursos y comparar su comportamiento con el tiempo. Consulte Información de rendimiento de consultas.
  • pg_stat_statements expone estadísticas acumulativas por instrucción dentro de la base de datos para ofrecer una visión directa de la ventana de observación actual. Sus estadísticas se pueden restablecer, por lo que use Almacén de consultas cuando necesite conservar el historial en las ventanas de observación.

Use ambas vistas antes de elegir un candidato de caché. Un pico a corto plazo podría no representar el comportamiento normal de la carga de trabajo, mientras que un promedio histórico puede ocultar una regresión actual. Dé prioridad a las consultas que son frecuentes, costosas y estables, lo que significa un recuento elevado de llamadas, un tiempo de ejecución total elevado y resultados que no cambian en todas las solicitudes. Esas consultas proporcionan la tasa de aciertos de caché más alta y la mayor caída de la carga de la base de datos. Una consulta que se ejecuta constantemente, pero devuelve las mismas filas durante minutos, como una lista de productos, un árbol de categorías o una tabla de precios, es un candidato ideal.