Redis como cache: estratégias e invalidação

1. Fundamentos do Cache com Redis

Bancos de dados SQL relacionais são excelentes para garantir consistência e integridade dos dados, mas enfrentam limitações de desempenho sob alta carga de leitura. O Redis surge como uma camada de cache distribuído que reduz a latência e alivia a pressão sobre o banco principal.

Diferença entre cache local e distribuído:
- Cache local (in-memory): armazenado na memória do próprio servidor da aplicação. Rápido, mas não compartilhado entre instâncias.
- Cache distribuído (Redis): centralizado e acessível por múltiplos servidores. Ideal para arquiteturas escaláveis.

Casos de uso típicos:
- Consultas SQL repetitivas (relatórios, dashboards)
- Sessões de usuário
- Resultados de agregações complexas (COUNT, SUM, GROUP BY)
- Listas de produtos, categorias e configurações

2. Estratégias de População do Cache

Cache-aside (Lazy Loading)

A aplicação verifica primeiro o cache. Se não encontrar, busca no banco SQL e armazena no Redis.

function getUserById(id):
    user = redis.get("user:" + id)
    if user is None:
        user = sql.query("SELECT * FROM users WHERE id = ?", id)
        redis.set("user:" + id, user, ttl=3600)
    return user

Write-through

Toda gravação no banco SQL é replicada imediatamente para o Redis.

function updateUser(id, data):
    sql.execute("UPDATE users SET name = ? WHERE id = ?", data.name, id)
    redis.set("user:" + id, data, ttl=3600)

Write-behind (Write-back)

As atualizações são acumuladas em filas (Redis Streams) e processadas em lote no banco SQL.

function batchUpdateUsers(updates):
    for update in updates:
        redis.xadd("user_updates", update)
    # Processo assíncrono lê a fila e persiste no SQL

3. Políticas de Expiração de Chaves no Redis

TTL fixo e expiração por janela de tempo

O Redis permite expirar chaves automaticamente após um tempo definido.

# TTL fixo de 10 minutos
redis.set("cache:report:2024", data, ex=600)

# Expiração em timestamp específico
redis.expireat("cache:report:2024", 1700000000)

Comandos de expiração

# Define expiração em segundos
EXPIRE chave 3600

# Define expiração em milissegundos
PEXPIRE chave 3600000

# Expiração em lote com SCAN
cursor = 0
loop:
    cursor, keys = redis.scan(cursor, match="cache:temp:*")
    for key in keys:
        redis.expire(key, 300)
    if cursor == 0:
        break

Estratégias de expiração

  • Lazy: Remove chaves expiradas apenas quando acessadas
  • Active: O Redis verifica periodicamente chaves expiradas
  • Volatile: Remove apenas chaves com TTL definido

4. Estratégias de Invalidação de Cache

Invalidação manual

Ao alterar dados no banco SQL, removemos explicitamente a chave no Redis.

function deleteUser(id):
    sql.execute("DELETE FROM users WHERE id = ?", id)
    redis.delete("user:" + id)

Invalidação por padrão de chave

Útil para invalidar múltiplas chaves relacionadas.

# Usando SCAN para evitar bloqueio
cursor = 0
loop:
    cursor, keys = redis.scan(cursor, match="user:*")
    for key in keys:
        redis.delete(key)
    if cursor == 0:
        break

Versionamento de chaves

Cada versão dos dados recebe um sufixo numérico, facilitando a invalidação.

# Ao atualizar dados
version = redis.incr("user:version:123")
redis.set("user:123:v" + version, data, ttl=3600)

# Consulta sempre busca a versão mais recente
current_version = redis.get("user:version:123")
data = redis.get("user:123:v" + current_version)

5. Consistência entre Cache e Banco de Dados

Problemas de consistência

  • Stale data: dados desatualizados no cache após alteração no SQL
  • Cache stampede: múltiplas requisições simultâneas recalculam o cache

Técnicas para evitar dados obsoletos

  1. Locking distribuído: apenas uma requisição recalcula o cache
  2. Dupla gravação: atualizar cache e banco na mesma transação
  3. TTL curto: aceitar inconsistência temporária em troca de performance

Uso de filas para invalidação em cascata

# Gatilho no banco SQL envia evento para Redis Stream
sql.execute("INSERT INTO audit_log (action, entity_id) VALUES ('update', ?)", id)
redis.xadd("invalidation_queue", {"entity": "user", "id": id})

# Worker processa a fila e invalida caches relacionados
stream = redis.xread({"invalidation_queue": "0"}, block=0)
for event in stream:
    entity = event["entity"]
    id = event["id"]
    redis.delete(f"{entity}:{id}")
    redis.delete(f"{entity}:{id}:details")

6. Monitoramento e Otimização do Cache

Métricas essenciais

# Taxa de acerto (hit rate)
INFO stats
# keyspace_hits / (keyspace_hits + keyspace_misses)

# Memória utilizada
INFO memory
# used_memory_human

# Comandos lentos
SLOWLOG GET 10

Diagnóstico com comandos nativos

# Verificar memória de uma chave específica
MEMORY USAGE user:123

# Tamanho total do cache
DBSIZE

# Padrões de acesso
SCAN 0 MATCH user:* COUNT 1000

Ajuste dinâmico de TTL

Baseado em frequência de acesso, podemos aumentar ou diminuir o TTL.

# Cache frio: TTL curto
if access_count < 10:
    redis.expire(key, 60)

# Cache quente: TTL longo
if access_count > 100:
    redis.expire(key, 3600)

7. Casos Práticos com SQL e Redis

Cache de consultas SQL complexas

# Consulta pesada com JOINs
SELECT p.id, p.name, c.name as category, AVG(r.rating) as avg_rating
FROM products p
JOIN categories c ON p.category_id = c.id
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id;

# Cache do resultado
cache_key = "product:report:top_sellers"
result = redis.get(cache_key)

if result is None:
    result = sql.execute(query)
    redis.set(cache_key, result, ex=300)  # Expira em 5 minutos

Cache de sessão de usuário com expiração por inatividade

# Ao fazer login
session_id = generate_uuid()
redis.set("session:" + session_id, {"user_id": 123, "role": "admin"}, ex=1800)

# A cada requisição, renova o TTL
redis.expire("session:" + session_id, 1800)

# Ao fazer logout
redis.delete("session:" + session_id)

Pipeline de invalidação com gatilhos no banco

# Trigger no PostgreSQL
CREATE OR REPLACE FUNCTION invalidate_cache()
RETURNS trigger AS $$
BEGIN
    PERFORM pg_notify('cache_invalidation', 
                      json_build_object('table', TG_TABLE_NAME, 
                                        'id', NEW.id)::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER user_update_trigger
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION invalidate_cache();

# Aplicação escuta o canal e invalida o Redis
import psycopg2
conn = psycopg2.connect("dbname=test")
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
cursor.execute("LISTEN cache_invalidation;")

while True:
    conn.poll()
    while conn.notifies:
        notify = conn.notifies.pop()
        data = json.loads(notify.payload)
        redis.delete(f"{data['table']}:{data['id']}")

Conclusão

O Redis como cache ao lado de bancos SQL oferece ganhos significativos de performance quando implementado com estratégias adequadas de população, expiração e invalidação. A escolha entre cache-aside, write-through ou write-behind depende do nível de consistência necessário. Monitoramento constante de hit rate e uso de memória permite ajustes finos no TTL e nas políticas de expiração, garantindo que o cache permaneça eficiente sem comprometer a integridade dos dados.

Referências