Implementación de caché con Redis para optimizar el acceso a datos de manera integral

Implementación de caché con Redis para optimizar el acceso a datos de manera integral

En aplicaciones de alto tráfico como plataformas de comercio electrónico, la velocidad de respuesta es esencial para garantizar una experiencia de usuario fluida. Redis, una base de datos en memoria de alto rendimiento, proporciona mecanismos de caché que reducen significativamente la latencia. Este artículo explora cómo aplicar Redis en escenarios de caché, tomando como ejemplo una página de detalles de producto en una tienda en línea, y cubre desde la selección de datos hasta estrategias para mitigar problemas comunes, con ejemplos en Go para ilustrar las implementaciones.

Selección adecuada de datos para caché

No todos los datos son candidatos ideales para almacenar en caché. En el contexto de productos de e-commerce, es crucial distinguir entre información que se beneficia del caché y aquella que no.

Datos recomendados para caché

Información de productos populares: Los detalles de productos de alta demanda, como nombre, precio e imagen, se acceden con frecuencia. Almacenarlos en Redis evita consultas repetidas a la base de datos. Por ejemplo, durante eventos de ventas como Black Friday, el caché de estos datos acelera la carga de páginas. Se puede usar una estructura hash en Redis para agrupar atributos del producto.

package main

import (
    "context"
    "github.com/go-redis/redis/v8"
)

var ctx = context.Background()

func guardarDatosProducto(rdb *redis.Client, idProducto, nombre, precio, urlImagen string) error {
    _, err := rdb.HSet(ctx, idProducto,
        "nombre", nombre,
        "precio", precio,
        "imagen", urlImagen).Result()
    return err
}

func obtenerDatosProducto(rdb *redis.Client, idProducto string) (map[string]string, error) {
    return rdb.HGetAll(ctx, idProducto).Result()
}

Datos de promociones: Las reglas de ofertas temporales y listas de productos en promoción son relativamente estáticas durante su vigencia. Ccahearlas en Redis permite un acceso rápido durante picos de tráfico, clasificándolas por tipo o duración para mejorar la eficiencia.

func guardarPromocion(rdb *redis.Client, idPromocion, regla string, idsProductos []string) error {
    err := rdb.HSet(ctx, idPromocion, "regla", regla).Err()
    if err != nil {
        return err
    }
    for indice, idProd := range idsProductos {
        err = rdb.HSet(ctx, idPromocion, fmt.Sprintf("producto_%d", indice), idProd).Err()
        if err != nil {
            return err
        }
    }
    return nil
}

func recuperarPromocion(rdb *redis.Client, idPromocion string) (map[string]string, error) {
    return rdb.HGetAll(ctx, idPromocion).Result()
}

Datos no recomendados para caché

Inventario en tiempo real: Los niveles de stock cambian constantemente y requieren alta consistencia. Cachearlos puede llevar a discrepancias, como sobreventas. En su lugar, se puede usar un caché a corto plazo en Redis para consultas no críticas, combinado con operaciones directas a la base de datos para actualizaciones.

func actualizarStock(rdb *redis.Client, idProducto string, cantidad int) error {
    // Simulación de actualización en base de datos con transacción
    return rdb.Set(ctx, fmt.Sprintf("stock:%s", idProducto), cantidad, 10*60).Err()
}

func consultarStock(rdb *redis.Client, idProducto string) (int, error) {
    valor, err := rdb.Get(ctx, fmt.Sprintf("stock:%s", idProducto)).Int64()
    return int(valor), err
}

Datos de recomendación personalizada: Estos datos se generan dinámicamente según el comportamiento del usuario y varían individualmente. Cachearlos ocuparía demasiado espacio y no garantizaría frescura, por lo que se calculan en cada solicitud.

Configuración de tiempos de expiración en caché

Establecer períodos de expiración adecuados asegura un equilibrio entre frescura de datos y eficiencia en actualizaciones.

Basado en características de los datos

Datos estáticos: Información como descripción o marca de un producto, que rara vez cambia, puede tener tiempos de expiración largos, como un mes, para reducir sobrecarga en actualizaciones.

func guardarProductoEstatico(rdb *redis.Client, idProducto, nombre, marca, descripcion string) error {
    return rdb.Set(ctx, fmt.Sprintf("info:%s", idProducto), fmt.Sprintf("%s|%s|%s", nombre, marca, descripcion), 30*24*3600).Err()
}

func obtenerProductoEstatico(rdb *redis.Client, idProducto string) (string, error) {
    return rdb.Get(ctx, fmt.Sprintf("info:%s", idProducto)).Result()
}

Datos dinámicos: Precios o promociones temporales requieren tiempos de expiración cortos, alineados con su duración, para reflejar cambios oportunamente.

func guardarPrecioDinamico(rdb *redis.Client, idProducto, precio string, duracionSeg int) error {
    return rdb.Set(ctx, fmt.Sprintf("tarifa:%s", idProducto), precio, duracionSeg).Err()
}

func obtenerPrecioDinamico(rdb *redis.Client, idProducto string) (string, error) {
    return rdb.Get(ctx, fmt.Sprintf("tarifa:%s", idProducto)).Result()
}

Estrategias de actualización de caché

Mantener la coherencia entre caché y base de datos es vital para la integridad de los datos.

Actualización programada

Este método renueva el caché a intervalos fijos, adecuado para datos con cambios predecibles, como comentarios de productos. Se puede ejecutar de forma asíncrona durante periodos de baja carga.

func programarActualizacionComentarios(rdb *redis.Client, idProducto string) {
    go func() {
        for {
            // Simulación de obtención de nuevos comentarios
            nuevoComentario := "comentario actualizado"
            err := rdb.Set(ctx, fmt.Sprintf("comentarios:%s", idProducto), nuevoComentario, 0).Err()
            if err != nil {
                // Manejo de errores
            }
            time.Sleep(1 * time.Hour)
        }
    }()
}

Actualización basada en eventos

Aquí, cambios en la base de datos desencadenan actualizaciones inmediatas en caché, por ejemplo, mediante colas de mensajes. Es ideal para datos con alta variabilidad, como precios, asegurando consistencia en tiempo real.

func actualizarPrecioPorEvento(rdb *redis.Client, idProducto, nuevoPrecio string) error {
    return rdb.Set(ctx, fmt.Sprintf("tarifa:%s", idProducto), nuevoPrecio, 0).Err()
}

Problema de quiebre de caché y soluciones

Decsripción

El quiebre ocurre cuando múltiples solicitudes concurrentes acceden a una clave expirada, sobrecargando la base de datos. En escenarios de alto tráfico, como lanzamientos de productos, esto puede causar tiempos de respuesta elevados.

Solución con bloqueo mutuo

Se emplea un mecanismo de bloqueo en Redis para controlar el acceso a la base de datos. Solo un hilo puede reconstruir el caché mientras otros esperan, usando comandos como SETNX.

func obtenerProductoConBloqueo(rdb *redis.Client, idProducto string) (string, error) {
    valor, err := rdb.Get(ctx, idProducto).Result()
    if err == nil {
        return valor, nil
    }
    claveBloqueo := fmt.Sprintf("bloqueo:%s", idProducto)
    adquirido, err := rdb.SetNX(ctx, claveBloqueo, 1, 10*time.Second).Result()
    if err != nil {
        return "", err
    }
    if adquirido {
        defer rdb.Del(ctx, claveBloqueo)
        // Simulación de consulta a base de datos
        datos := "datos del producto desde la base de datos"
        err = rdb.Set(ctx, idProducto, datos, 0).Err()
        if err != nil {
            return "", err
        }
        return datos, nil
    } else {
        time.Sleep(100 * time.Millisecond)
        return obtenerProductoConBloqueo(rdb, idProducto)
    }
}

Problema de avalancha de caché y soluciones

Descripción

Una avalancha sucede cuando muchas claves de caché expiran simultáneamente, inundando la base de datos con solicitudes. Esto es común en eventos promocionales donde se configuran tiempos de expiración idénticos.

Solución con tiempos de expiración aleatorios

Al añadir un valor aleatorio al tiempo de expiración, se dispersan las renovaciones, reduciendo el impacto. Por ejemplo, se puede calcular el tiempo base más un offset aleatorio dentro de un rango limitado.

func guardarConExpiracionAleatoria(rdb *redis.Client, clave, valor string, tiempoBase time.Duration) {
    offset := time.Duration(rand.Intn(10)) * time.Minute
    rdb.Set(ctx, clave, valor, tiempoBase+offset)
}

Arquitectura de caché multinivel

Combinar caché local con Redis ofrece capas adicionales de protección. Se consulta primero el caché local, luego Redis, y finalmente la base de datos, mitigando la sobrecarga.

package main

import (
    "context"
    "github.com/go-redis/redis/v8"
    "github.com/patrickmn/go-cache"
    "time"
)

var ctx = context.Background()
var cachéLocal = cache.New(5*time.Minute, 10*time.Minute)

func obtenerConCachéMultinivel(rdb *redis.Client, idProducto string) (string, error) {
    if valor, encontrado := cachéLocal.Get(idProducto); encontrado {
        return valor.(string), nil
    }
    valor, err := rdb.Get(ctx, idProducto).Result()
    if err == nil {
        cachéLocal.Set(idProducto, valor, cache.DefaultExpiration)
        return valor, nil
    }
    return "", err
}

Mecanismo de circuit breaker

Implementar un disyuntor que suspenda solicitudes a la base de datos bajo alta carga, devolviendo datos predeterminados para mantener la estabilidad del sistema.

package main

import (
    "context"
    "github.com/afex/hystrix-go/hystrix"
    "github.com/go-redis/redis/v8"
)

var ctx = context.Background()

func obtenerConDisyuntor(rdb *redis.Client, idProducto string) (string, error) {
    var resultado string
    err := hystrix.Do("consulta_producto", func() error {
        var err error
        resultado, err = rdb.Get(ctx, idProducto).Result()
        return err
    }, func(err error) error {
        resultado = "datos predeterminados"
        return nil
    })
    return resultado, err
}

Problema de penetración de caché y soluciones

Descripción

La penetración ocurre cuando se consultan datos inexistentes en caché y base de datos, como IDs de productos inválidos, lo que puede saturar el sistema con solicitudes inútiles.

Solución con caché de valores nulos

Almacenar valores vacíos en caché para claves no encontradas, con tiempos de expiración cortos, evita consultas repetidas a la base de datos.

func obtenerConCachéNulo(rdb *redis.Client, idProducto string) (string, error) {
    valor, err := rdb.Get(ctx, idProducto).Result()
    if err == nil {
        if valor == "" {
            return "", fmt.Errorf("producto no existe")
        }
        return valor, nil
    }
    // Simulación de consulta a base de datos
    valorBD := ""
    if valorBD == "" {
        err = rdb.Set(ctx, idProducto, "", 1*time.Minute).Err()
        if err != nil {
            return "", err
        }
        return "", fmt.Errorf("producto no existe")
    }
    err = rdb.Set(ctx, idProducto, valorBD, 0).Err()
    if err != nil {
        return "", err
    }
    return valorBD, nil
}

Filtro de Bloom

Un filtro de Bloom puede pre-filtrar solicitudes, rechazando claves que seguramente no existen antes de acceder al caché o la base de datos, mejorando la eficiencia.

package main

import (
    "context"
    "github.com/go-redis/redis/v8"
    "github.com/willf/bitset"
)

var ctx = context.Background()

type FiltroBloom struct {
    bits  *bitset.BitSet
    semillas []int
}

func NuevoFiltroBloom(tamano, numFuncionesHash int) *FiltroBloom {
    return &FiltroBloom{
        bits:  bitset.New(uint(tamano)),
        semillas: []int{3, 5, 7},
    }
}

func (fb *FiltroBloom) Agregar(clave string) {
    for _, semilla := range fb.semillas {
        hash := calcularHash(clave, uint32(semilla))
        fb.bits.Set(uint(hash))
    }
}

func (fb *FiltroBloom) PodriaContener(clave string) bool {
    for _, semilla := range fb.semillas {
        hash := calcularHash(clave, uint32(semilla))
        if !fb.bits.Test(uint(hash)) {
            return false
        }
    }
    return true
}

func calcularHash(clave string, semilla uint32) uint32 {
    // Implementación simplificada de función hash
    var hash uint32 = 2166136261
    for i := 0; i < len(clave); i++ {
        hash ^= uint32(clave[i])
        hash *= 16777619
    }
    return hash ^ semilla
}

func obtenerConFiltroBloom(rdb *redis.Client, fb *FiltroBloom, idProducto string) (string, error) {
    if !fb.PodriaContener(idProducto) {
        return "", fmt.Errorf("ID de producto no válido")
    }
    return rdb.Get(ctx, idProducto).Result()
}

Comparación de rendimiento con caché Redis

Para demostrar el impacto de Redis, se pueden realizar pruebas comparando escenarios con y sin caché, midiendo métricas como tiempo de respuesta. A continuación, ejemplos simplificados en Go.

Escenario sin caché

func manejadorSinCaché(w http.ResponseWriter, r *http.Request) {
    inicio := time.Now()
    // Simulación de consulta a base de datos
    time.Sleep(200 * time.Millisecond)
    tiempoTotal := time.Since(inicio)
    fmt.Fprintf(w, "Sin caché, tiempo de respuesta: %v", tiempoTotal)
}

Escenario con caché Redis

func manejadorConCaché(w http.ResponseWriter, r *http.Request) {
    inicio := time.Now()
    valor, err := rdb.Get(ctx, "producto_1").Result()
    if err == nil {
        tiempoTotal := time.Since(inicio)
        fmt.Fprintf(w, "Caché acertado, tiempo de respuesta: %v", tiempoTotal)
        return
    }
    // Simulación de consulta a base de datos
    time.Sleep(200 * time.Millisecond)
    valorBD := "datos del producto"
    rdb.Set(ctx, "producto_1", valorBD, 0)
    tiempoTotal := time.Since(inicio)
    fmt.Fprintf(w, "Caché fallido, tiempo de respuesta: %v", tiempoTotal)
}

Estas pruebas ilustran cómo el caché Redis reduce drásticamente los tiempos de respuesta, optimizando el rendimiento genarel del sistema.

Etiquetas: Redis cache Go Bases de datos en memoria Optimización de Rendimiento

Publicado el 7-13 17:24