kb.erickguedes.com
Redis: Cache, Fila e Tempo Real

Sessões e Rate Limiting

Aula 4 de 6

Session Storage

Redis + express-session (Node.js)

const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');

const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();

const app = express();

app.use(session({
    store: new RedisStore({ client: redisClient }),
    secret: 'seu-segredo-aqui',
    resave: false,
    saveUninitialized: false,
    name: 'sid',                    // nome do cookie
    cookie: {
        httpOnly: true,             // não acessível via JS
        secure: true,               // apenas HTTPS
        maxAge: 1800000,            // 30 minutos
        sameSite: 'strict'
    },
    rolling: true                   // renovar TTL a cada request
}));

Sessão no Redis

# Estrutura real no Redis
# session:{sid} = { dados da sessão em JSON }

# Criar sessão manualmente
SET session:abc123 '{"userId":1001,"role":"admin","loginAt":"2024-01-15T10:00:00Z"}' EX 1800

# Sliding expiration (renovar a cada acesso)
EXPIRE session:abc123 1800

# Ver sessão
GET session:abc123
TTL session:abc123

# Remover sessão (logout)
DEL session:abc123

# Listar sessões ativas (SCAN para produção)
SCAN 0 MATCH session:* COUNT 100

Cookie-based vs Server-side

# Cookie-based (JWT): sessão no cliente
# - Prós: sem armazenamento server-side, escalável horizontalmente
# - Contras: difícil invalidar, payload visível (assinado, não criptografado)
# - Uso: APIs REST stateless

# Server-side (Redis): sessão no servidor
# - Prós: fácil invalidar, controle total, mais seguro
# - Contras: dependência de Redis, latência adicional
# - Uso: aplicações web tradicionais, sessões com estado

# Session Rotation: trocar session ID periodicamente
# Previne session fixation attacks
# Após login, gerar novo SID e deletar o antigo

Rate Limiting

Sliding Window (ZREMRANGEBYSCORE + ZCARD)

# Sliding window com Sorted Set
# Janela de tempo deslizante, precisa, consistente

# Função Lua para rate limit
cat << 'EOF' > sliding-window.lua
-- KEYS[1] = rate limit key
-- ARGV[1] = window size (ms)
-- ARGV[2] = max requests
-- ARGV[3] = current timestamp

local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
local window_start = now - window

-- Remover entradas fora da janela
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)

-- Contar requisições na janela
local count = redis.call('ZCARD', KEYS[1])

if count >= max then
    return 0  -- limit exceeded
end

-- Adicionar requisição atual
redis.call('ZADD', KEYS[1], now, now)
redis.call('EXPIRE', KEYS[1], window / 1000)

return max - count  -- remaining
EOF

# Testar sliding window
echo "=== SLIDING WINDOW ==="
redis-cli << 'REDIS'
-- Limpar
DEL rate:user:1001

-- Simular 5 requisições
EVAL "
local now = tonumber(ARGV[1])
local window = 60000
local max = 5
local window_start = now - window
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)
local count = redis.call('ZCARD', KEYS[1])
if count >= max then return 0 end
redis.call('ZADD', KEYS[1], now, now)
redis.call('EXPIRE', KEYS[1], 60)
return max - count
" 1 rate:user:1001 1000 60000 5

-- Verificar (deve retornar 4, 3, 2, 1, 0)
REDIS

Token Bucket (Lua Script)

# Token Bucket: tokens são adicionados a uma taxa constante
# Permite bursts até o bucket capacity

cat << 'EOF' > token-bucket.lua
-- KEYS[1] = bucket key
-- ARGV[1] = capacity (max tokens)
-- ARGV[2] = refill rate (tokens per second)
-- ARGV[3] = request cost (default 1)
-- ARGV[4] = current timestamp

local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3]) or 1
local now = tonumber(ARGV[4])

-- Buscar estado atual do bucket
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- Refill baseado no tempo passado
local elapsed = math.max(0, now - last_refill)
local refill = math.floor(elapsed * rate / 1000)

if refill > 0 then
    tokens = math.min(capacity, tokens + refill)
    last_refill = now
end

if tokens < cost then
    -- Taxa excedida
    return -1
end

-- Consumir tokens
tokens = tokens - cost
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate * 2))

return tokens  -- tokens restantes
EOF

Fixed Window (INCR + EXPIRE)

# Fixed Window: contador que reseta a cada janela fixa
# Simples, mas permite picos no final de uma janela + início da próxima

# Implementação
cat << 'EOF' > fixed-window.sh
#!/bin/bash
# Uso: ./fixed-window.sh <key> <limit> <window_seconds>

KEY="$1"
LIMIT="${2:-100}"
WINDOW="${3:-60}"

# INCR + EXPIRE usando Redis
# Se chave não existir, setar EXPIRE junto com INCR
current=$(redis-cli INCR "$KEY")

if [[ $current -eq 1 ]]; then
    redis-cli EXPIRE "$KEY" "$WINDOW"
fi

if [[ $current -gt $LIMIT ]]; then
    echo "RATE_LIMITED (${current}/${LIMIT})"
    exit 1
fi

echo "OK (${current}/${LIMIT})"
EOF

chmod +x fixed-window.sh

# Testar fixed window
echo "=== FIXED WINDOW ==="
for i in $(seq 1 5); do
    ./fixed-window.sh test:api:1001 3 60
    sleep 0.5
done

Generic Cell Rate Algorithm (GCRA)

# GCRA: algoritmo mais preciso, usado pelo Redis Rate Limiter do Laravel
# Implementa taxa via "time until next allowed"

# Implementação Lua
cat << 'EOF' > gcra.lua
-- KEYS[1] = key
-- ARGV[1] = rate (requests per second)
-- ARGV[2] = burst size
-- ARGV[3] = now (ms)

local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local interval = 1000000 / rate  -- microssegundos entre requests
local increment = interval * burst
local emission_time = redis.call('GET', KEYS[1])

if not emission_time then
    emission_time = now * 1000 - increment
    redis.call('SET', KEYS[1], emission_time, 'PX', math.ceil(increment / 1000))
    return {1, 0}
end

emission_time = tonumber(emission_time)
local new_emission = math.max(emission_time + interval, now * 1000)
local delay = new_emission - now * 1000

if delay > increment then
    return {0, math.ceil(delay / 1000)}
end

redis.call('SET', KEYS[1], new_emission, 'PX', math.ceil((new_emission - emission_time + increment) / 1000))
return {1, math.ceil(delay / 1000)}
EOF

Distributed Locks

Redlock Algorithm

# Redlock: lock distribuído para múltiplos nós Redis
# Implementação: SET NX EX em N nós master independentes

# Lock básico (SET NX EX)
SET lock:resource:1 "server:A" NX EX 10
# Retorna OK se adquiriu, nil se já existe

# Unlock (Lua: verificar se é o owner)
cat << 'EOF' > unlock.lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
EOF

# Lock com extensão (auto-extension)
# Claude de lock: enquanto processa, estende o TTL periodicamente

# Implementação Node.js
cat << 'EOF' > distributed-lock.js
const redis = require('ioredis');
const client = new Redis();

async function acquireLock(key, ttl = 10000) {
    const lockValue = `${process.pid}-${Date.now()}`;
    const acquired = await client.set(key, lockValue, 'NX', 'PX', ttl);

    if (!acquired) return null;

    // Auto-extensão (watchdog)
    const interval = setInterval(async () => {
        await client.pexpire(key, ttl);
    }, ttl / 2);

    return {
        value: lockValue,
        release: async () => {
            clearInterval(interval);
            await client.eval(`
                if redis.call("GET", KEYS[1]) == ARGV[1] then
                    return redis.call("DEL", KEYS[1])
                end
            `, 1, key, lockValue);
        }
    };
}

// Uso
const lock = await acquireLock('resource:process-order');
if (lock) {
    try {
        await processOrder();
    } finally {
        await lock.release();
    }
}
EOF

Redlock Controversy

# Redlock é controverso (Martin Kleppmann vs Antirez)

# Críticas principais:
# - Depende de timing (clock drift pode quebrar garantias)
# - F Jepsen encontrou problemas com GC pause
# - Para a maioria dos casos, SET NX EX simples é suficiente
# - Alternativas:
#   - Redisson (Redis-based, mature)
#   - etcd / ZooKeeper (strong consistency)
#   - PostgreSQL advisory locks
#   - SET NX EX (single node, suficiente para 99% dos casos)

# ⚠️ Redlock é útil apenas quando:
#   - Múltiplos nós Redis independentes
#   - Garantia de exclusão mútua crítica
#   - Não pode usar sistemas externos (etcd, ZK)

Counters

Contadores Simples

# INCR / DECR atômicos
INCR page:views:home          # 1
INCR page:views:home          # 2
GET page:views:home           # 2

# INCRBY / DECRBY
INCRBY user:1001:points 50    # +50
INCRBY user:1001:points -10   # -10

# Com TTL (cache counter)
INCR api:calls:2024-01-15
EXPIRE api:calls:2024-01-15 86400

# Reset periódico
SET daily:signups "0" EX 86400
INCR daily:signups

Leaderboard com ZINCRBY

# Sorted Set para ranking em tempo real
ZADD leaderboard:global 0 "player:1001"
ZADD leaderboard:global 0 "player:1002"

# Incrementar score
ZINCRBY leaderboard:global 100 "player:1001"    # +100 pontos
ZINCRBY leaderboard:global 50 "player:1002"

# Top 10
ZREVRANGE leaderboard:global 0 9 WITHSCORES

# Posição do jogador
ZREVRANK leaderboard:global "player:1001"

# Score do jogador
ZSCORE leaderboard:global "player:1001"

# Rangos em volta
ZREVRANGEBYSCORE leaderboard:global +inf -inf WITHSCORES LIMIT 0 10

Daily/Weekly/Monthly Counters

# Estratégia de partição temporal

# Chaves diárias
INCR stats:daily:2024-01-15:signups
INCR stats:daily:2024-01-15:orders

# Chaves semanais (ISO week)
INCR stats:weekly:2024-W03:revenue
INCR stats:weekly:2024-W03:active_users

# Chaves mensais
INCR stats:monthly:2024-01:page_views

# Buscar agregado (MGET)
MGET stats:daily:2024-01-15:signups stats:daily:2024-01-16:signups

# Somar com Lua
EVAL "
local total = 0
for _, key in ipairs(KEYS) do
    total = total + (redis.call('GET', key) or 0)
end
return total
" 3 stats:daily:2024-01-13:signups stats:daily:2024-01-14:signups stats:daily:2024-01-15:signups

Lab: API Rate Limiter Completo

cat > rate-limiter.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

echo "=== API RATE LIMITER ==="
LIMIT=5
WINDOW=60
API_KEY="12345"

check_rate() {
    local key="rate:api:${API_KEY}"
    local current

    current=$(redis-cli INCR "$key")
    [[ $current -eq 1 ]] && redis-cli EXPIRE "$key" "$WINDOW" > /dev/null

    if [[ $current -gt $LIMIT ]]; then
        local ttl
        ttl=$(redis-cli TTL "$key")
        echo "429 Too Many Requests - Reset in ${ttl}s"
        return 1
    fi

    local remaining=$((LIMIT - current))
    echo "200 OK - ${remaining}/${LIMIT} requests remaining"
    return 0
}

echo ""
echo "--- Teste: ${LIMIT} requests em ${WINDOW}s ---"
for i in $(seq 1 7); do
    echo "Request $i: $(check_rate)"
    sleep 0.3
done

echo ""
echo "--- Contador temporal + scoreboard ---"
redis-cli << 'REDIS'
-- Scoreboard diário
INCR stats:api:calls:today
INCR stats:api:users:today

-- Top API keys por uso
ZINCRBY stats:api:topkeys 1 "api_key:12345"
ZINCRBY stats:api:topkeys 1 "api_key:67890"
ZINCRBY stats:api:topkeys 1 "api_key:12345"

-- Leaderboard
ZREVRANGE stats:api:topkeys 0 -1 WITHSCORES
REDIS

echo ""
echo "--- Sliding Window (Lua) ---"
redis-cli --eval sliding-window.lua rate:sliding:test , 60000 5 "$(date +%s%N | cut -b1-13)"
redis-cli --eval sliding-window.lua rate:sliding:test , 60000 5 "$(date +%s%N | cut -b1-13)"

echo ""
echo "--- Limpeza ---"
redis-cli EVAL "return redis.call('DEL', unpack(redis.call('KEYS', 'rate:*')))" 0 2>/dev/null || true
redis-cli EVAL "return redis.call('DEL', unpack(redis.call('KEYS', 'stats:*')))" 0 2>/dev/null || true
echo "Dados limpos."
SCRIPT

chmod +x rate-limiter.sh

Sessões no Redis com sliding TTL (rolling). Rate limiting: fixed window (INCR+EXPIRE) é simples, sliding window (Sorted Set) é preciso, token bucket permite bursts. Locks com SET NX EX e Lua garantem atomicidade. Counters com INCR e ZINCRBY são atômicos e performáticos.