Caching com Redis e Predis

1. Introdução ao Redis como Sistema de Cache

Redis é um armazenamento de estrutura de dados em memória, de código aberto, que pode ser usado como banco de dados, cache e message broker. Quando falamos de caching em PHP, Redis oferece vantagens significativas sobre cache em arquivo ou banco de dados relacional:

  • Velocidade: opera em memória RAM, com latência de microssegundos
  • Estruturas de dados ricas: strings, hashes, lists, sets, sorted sets
  • Persistência opcional: pode salvar dados em disco sem perder performance
  • Escalabilidade: suporte a clustering e replicação
  • TTL nativo: expiração automática de chaves

Para instalar o Redis no Ubuntu/Debian:

sudo apt update
sudo apt install redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server

Verifique a instalação:

redis-cli ping
# Resposta esperada: PONG

2. Configurando o Predis no Projeto PHP

Predis é um cliente Redis completo escrito em PHP. Instale via Composer:

composer require predis/predis

Configuração básica de conexão:

<?php
require 'vendor/autoload.php';

use Predis\Client;

// Configuração simples
$redis = new Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
]);

// Com timeout e reconexão automática
$redis = new Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
    'timeout' => 5.0,
    'read_write_timeout' => 5.0,
    'replication' => 'sentinel',
], [
    'prefix' => 'myapp:',
    'exceptions' => true,
]);

// Tratamento de erros
try {
    $redis->ping();
} catch (Predis\Connection\ConnectionException $e) {
    error_log("Redis indisponível: " . $e->getMessage());
    // Fallback para cache alternativo
}

3. Operações Básicas de Cache com Predis

Set e Get com TTL

<?php
// Armazenar valor por 3600 segundos (1 hora)
$redis->setex('user:1001:profile', 3600, json_encode([
    'id' => 1001,
    'name' => 'João Silva',
    'email' => 'joao@example.com'
]));

// Recuperar valor
$profile = $redis->get('user:1001:profile');
if ($profile) {
    $data = json_decode($profile, true);
    echo $data['name']; // João Silva
}

// Verificar se chave existe
if ($redis->exists('user:1001:profile')) {
    echo "Cache hit!";
}

// Remover chave
$redis->del('user:1001:profile');

Operações em Massa

<?php
// MSET - definir múltiplas chaves
$redis->mset([
    'product:1:price' => 29.90,
    'product:2:price' => 49.90,
    'product:3:price' => 99.90,
]);

// MGET - recuperar múltiplas chaves
$prices = $redis->mget(['product:1:price', 'product:2:price', 'product:3:price']);
// ['29.90', '49.90', '99.90']

// DEL em massa
$redis->del(['product:1:price', 'product:2:price']);

4. Estratégias de Cache para Aplicações PHP

Cache de Consultas ao Banco de Dados

<?php
function getRecentPosts(PDO $pdo, Client $redis, int $limit = 10): array {
    $cacheKey = "posts:recent:{$limit}";

    // Tentar cache primeiro
    $cached = $redis->get($cacheKey);
    if ($cached !== null) {
        return json_decode($cached, true);
    }

    // Cache miss - buscar do banco
    $stmt = $pdo->prepare("SELECT * FROM posts ORDER BY created_at DESC LIMIT ?");
    $stmt->execute([$limit]);
    $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);

    // Armazenar em cache por 5 minutos
    $redis->setex($cacheKey, 300, json_encode($posts));

    return $posts;
}

Cache de Resultados de APIs Externas

<?php
function getWeatherData(string $city): array {
    $redis = new Client();
    $cacheKey = "weather:{$city}";

    // Verificar cache
    $cached = $redis->get($cacheKey);
    if ($cached) {
        return json_decode($cached, true);
    }

    // API externa
    $apiKey = getenv('WEATHER_API_KEY');
    $url = "https://api.weather.com/v1/{$city}?apikey={$apiKey}";
    $response = file_get_contents($url);
    $data = json_decode($response, true);

    // Cache por 30 minutos (dados climáticos mudam com frequência)
    $redis->setex($cacheKey, 1800, json_encode($data));

    return $data;
}

Cache de Sessões de Usuário

<?php
// Configurar sessão com Redis
session_set_save_handler(
    function ($savePath, $sessionName) use ($redis) {
        return true;
    },
    function () {
        return true;
    },
    function ($sessionId) use ($redis) {
        return $redis->get("session:{$sessionId}") ?: '';
    },
    function ($sessionId, $data) use ($redis) {
        $redis->setex("session:{$sessionId}", 3600, $data);
        return true;
    },
    function ($sessionId) use ($redis) {
        $redis->del("session:{$sessionId}");
        return true;
    },
    function ($maxLifetime) use ($redis) {
        // Redis gerencia TTL automaticamente
        return true;
    }
);

session_start();

5. Tipos de Dados do Redis para Caching Avançado

Hash: Armazenar Objetos Estruturados

<?php
// Armazenar usuário como hash
$redis->hmset('user:500', [
    'name' => 'Maria Santos',
    'email' => 'maria@example.com',
    'age' => 28,
    'city' => 'São Paulo'
]);

// Acessar campos específicos
echo $redis->hget('user:500', 'name'); // Maria Santos

// Incrementar campo numérico
$redis->hincrby('user:500', 'login_count', 1);

List: Filas e Cache de Listagens Paginadas

<?php
// Cache de últimas notícias (mantém apenas 100 itens)
$newsKey = 'news:latest';

// Adicionar nova notícia ao início
$redis->lpush($newsKey, json_encode([
    'id' => 1500,
    'title' => 'Nova atualização do PHP 8.3',
    'date' => '2024-01-15'
]));

// Manter apenas 100 itens mais recentes
$redis->ltrim($newsKey, 0, 99);

// Obter página 2 (itens 10-19)
$page = $redis->lrange($newsKey, 10, 19);

Sorted Sets: Ranking e Cache Ordenado

<?php
// Ranking de jogadores
$redis->zadd('game:leaderboard', [
    'player:100' => 1500,
    'player:200' => 2300,
    'player:300' => 1800,
]);

// Top 10 jogadores
$topPlayers = $redis->zrevrange('game:leaderboard', 0, 9, 'WITHSCORES');

// Atualizar pontuação
$redis->zincrby('game:leaderboard', 100, 'player:100');

// Obter rank de um jogador
$rank = $redis->zrevrank('game:leaderboard', 'player:100');

6. Padrões de Cache e Invalidação

Cache-Aside (Lazy Loading)

<?php
function getProduct(int $productId): array {
    $redis = new Client();
    $cacheKey = "product:{$productId}";

    // Tentar cache primeiro
    $cached = $redis->get($cacheKey);
    if ($cached !== null) {
        return json_decode($cached, true);
    }

    // Buscar do banco de dados
    $product = $db->query("SELECT * FROM products WHERE id = ?", [$productId]);

    if ($product) {
        // Armazenar em cache com TTL
        $redis->setex($cacheKey, 3600, json_encode($product));
    }

    return $product;
}

Invalidação com Chaves Versionadas

<?php
$version = $redis->get('product:version') ?: 1;

// Chave inclui versão para invalidação forçada
$cacheKey = "product:{$productId}:v{$version}";

// Quando produto é atualizado
function invalidateProductCache(int $productId): void {
    $redis = new Client();
    $redis->incr('product:version'); // Nova versão invalida todos os caches
}

7. Monitoramento e Otimização do Cache

Monitoramento

<?php
// Informações do servidor Redis
$info = $redis->info();
echo "Memória usada: " . $info['used_memory_human'];
echo "Total de chaves: " . $info['db0']['keys'];
echo "Hit ratio: " . ($info['keyspace_hits'] / ($info['keyspace_hits'] + $info['keyspace_misses'])) * 100 . "%";

// Escanear chaves por padrão
$iterator = null;
$keys = $redis->scan($iterator, 'user:*', 100);

Cache Warming

<?php
function warmupProductCache(array $productIds): void {
    $redis = new Client();

    foreach ($productIds as $id) {
        $cacheKey = "product:{$id}";

        if (!$redis->exists($cacheKey)) {
            $product = getProductFromDatabase($id);
            $redis->setex($cacheKey, 3600, json_encode($product));
        }
    }
}

// Pré-carregar produtos mais populares
warmupProductCache([100, 200, 300, 400, 500]);

Compressão de Dados

<?php
// Comprimir dados grandes antes de armazenar
function cacheSetCompressed(Client $redis, string $key, $data, int $ttl = 3600): void {
    $serialized = serialize($data);
    $compressed = gzcompress($serialized, 9);
    $redis->setex($key, $ttl, base64_encode($compressed));
}

function cacheGetCompressed(Client $redis, string $key) {
    $cached = $redis->get($key);
    if ($cached === null) return null;

    $compressed = base64_decode($cached);
    $serialized = gzuncompress($compressed);
    return unserialize($serialized);
}

8. Boas Práticas e Armadilhas Comuns

Evitando Cache Stampede (Thundering Herd)

<?php
function getExpensiveData(Client $redis, string $cacheKey): array {
    // Usar lock para evitar múltiplas requisições simultâneas
    $lockKey = "lock:{$cacheKey}";

    // Tentar adquirir lock com timeout de 5 segundos
    if ($redis->set($lockKey, 1, 'NX', 'EX', 5)) {
        try {
            // Apenas esta requisição buscará os dados
            $data = fetchExpensiveDataFromDatabase();
            $redis->setex($cacheKey, 300, json_encode($data));
            return $data;
        } finally {
            $redis->del($lockKey);
        }
    }

    // Outras requisições esperam e tentam novamente
    usleep(100000); // 100ms
    $cached = $redis->get($cacheKey);
    if ($cached) {
        return json_decode($cached, true);
    }

    // Fallback: buscar dados mesmo sem lock
    return fetchExpensiveDataFromDatabase();
}

Segurança: Protegendo Chaves e Conexões

<?php
// Usar prefixo para evitar colisão de chaves
$redis = new Client(null, ['prefix' => 'app:prod:']);

// Conexão com autenticação
$redis = new Client([
    'scheme' => 'tcp',
    'host' => '127.0.0.1',
    'port' => 6379,
    'password' => getenv('REDIS_PASSWORD'),
]);

// Sanitizar chaves que vêm de input do usuário
function sanitizeCacheKey(string $input): string {
    return preg_replace('/[^a-zA-Z0-9_:.-]/', '_', $input);
}

$userId = sanitizeCacheKey($_GET['user_id']);
$redis->get("user:{$userId}:profile");

Testes Unitários com Mock do Predis

<?php
use PHPUnit\Framework\TestCase;
use Predis\Client;

class CacheServiceTest extends TestCase
{
    private $redis;
    private $cacheService;

    protected function setUp(): void
    {
        // Mock do Predis
        $this->redis = $this->createMock(Client::class);
        $this->cacheService = new CacheService($this->redis);
    }

    public function testGetCachedData(): void
    {
        $this->redis
            ->expects($this->once())
            ->method('get')
            ->with('test:key')
            ->willReturn(json_encode(['name' => 'Test']));

        $result = $this->cacheService->get('test:key');
        $this->assertEquals(['name' => 'Test'], $result);
    }

    public function testSetCachedDataWithTTL(): void
    {
        $this->redis
            ->expects($this->once())
            ->method('setex')
            ->with('test:key', 3600, json_encode(['data' => 'value']));

        $this->cacheService->set('test:key', ['data' => 'value'], 3600);
    }
}

Referências