Estratégias de cache
LidoEsta aula explora estratégias de cache em PHP, abordando cache em arquivo, Redis/Memcached, cache de queries e invalidação. Você aprenderá a implementar cada técnica, suas vantagens e desvantagens, e boas práticas para melhorar o desempenho de aplicações web.
Cache é uma técnica fundamental para otimizar o desempenho de aplicações web, reduzindo o tempo de resposta e a carga no servidor. Em PHP, o cache pode ser implementado de várias formas, desde simples arquivos em disco até sistemas distribuídos como Redis e Memcached. Nesta aula, você vai entender as principais estratégias de cache, quando usar cada uma e como implementá-las corretamente.
Vamos abordar desde o cache em arquivo, que é simples e eficaz para dados pouco voláteis, até soluções mais robustas como Redis e Memcached, ideais para ambientes de alta concorrência. Também discutiremos o cache de queries, que pode reduzir drasticamente o tempo de acesso ao banco de dados, e as estratégias de invalidação, essenciais para manter os dados sempre atualizados.
Cache em arquivo
O cache em arquivo é uma das formas mais simples de armazenar dados temporários. Consiste em salvar o resultado de uma operação custosa em um arquivo no sistema de arquivos do servidor. Quando a mesma operação é solicitada novamente, o PHP verifica se o arquivo de cache existe e se ainda é válido (com base no tempo de expiração). Se for válido, retorna o conteúdo do arquivo; caso contrário, refaz a operação e atualiza o cache.
Vantagens: implementação fácil, não requer infraestrutura adicional, e funciona bem em ambientes com um único servidor. Desvantagens: não é adequado para dados dinâmicos ou para aplicações distribuídas, pois cada servidor tem seu próprio sistema de arquivos. Além disso, o acesso a arquivos pode ser mais lento que a memória, especialmente com muitos arquivos.
Aqui está um exemplo de uma classe simples para cache em arquivo:
class FileCache {
private $cacheDir;
public function __construct($cacheDir = __DIR__ . '/cache') {
$this->cacheDir = rtrim($cacheDir, '/') . '/';
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
public function get($key) {
$file = $this->getFileName($key);
if (!file_exists($file)) {
return null;
}
$data = file_get_contents($file);
$data = unserialize($data);
if ($data['expires'] < time()) {
unlink($file);
return null;
}
return $data['value'];
}
public function set($key, $value, $ttl = 3600) {
$file = $this->getFileName($key);
$data = [
'expires' => time() + $ttl,
'value' => $value,
];
file_put_contents($file, serialize($data));
}
private function getFileName($key) {
return $this->cacheDir . md5($key) . '.cache';
}
}
// Uso:
$cache = new FileCache();
$key = 'user_123';
$user = $cache->get($key);
if ($user === null) {
// Buscar do banco de dados
$user = ['id' => 123, 'name' => 'João'];
$cache->set($key, $user, 600); // 10 minutos
}
echo $user['name'];No exemplo, a classe armazena dados serializados com um timestamp de expiração. O método get verifica se o arquivo existe e se não está expirado. Se estiver expirado, remove o arquivo e retorna null, indicando que o cache deve ser atualizado.
Redis/Memcached (visão geral)
Redis e Memcached são sistemas de armazenamento em memória amplamente utilizados para cache em aplicações web. Eles oferecem acesso extremamente rápido aos dados, pois tudo é mantido em RAM. Ambos são clientes-servidor: sua aplicação PHP se conecta a um servidor externo (ou várias instâncias) para armazenar e recuperar dados.
Memcached é um cache de chave-valor distribuído, simples e veloz. Ele não oferece persistência, ou seja, os dados são perdidos se o servidor reiniciar. Por outro lado, Redis é mais robusto: além de cache, ele suporta estruturas de dados como listas, conjuntos e hashes, e oferece opções de persistência em disco. Redis também permite recursos como expiração automática de chaves (TTL) e operações atômicas.
A escolha entre Redis e Memcached depende das necessidades do projeto. Se você precisa apenas de cache simples, Memcached pode ser suficiente. Se você precisa de funcionalidades avançadas, como filas, pub/sub ou persistência, Redis é a melhor escolha. Ambos são suportados por bibliotecas PHP como PhpRedis e Memcached.
Exemplo com Redis usando a extensão PhpRedis:
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// Definir um valor com expiração de 10 minutos
$redis->setex('user:123', 600, serialize(['id' => 123, 'name' => 'João']));
// Recuperar o valor
$user = unserialize($redis->get('user:123'));
if ($user === false) {
// Cache miss: buscar do banco e armazenar
$user = ['id' => 123, 'name' => 'João'];
$redis->setex('user:123', 600, serialize($user));
}
echo $user['name'];Com Memcached, a sintaxe é semelhante:
$memcached = new Memcached();
$memcached->addServer('127.0.0.1', 11211);
// Definir com expiração de 600 segundos
$memcached->set('user:123', serialize(['id' => 123, 'name' => 'João']), 600);
// Recuperar
$user = unserialize($memcached->get('user:123'));
if ($user === false) {
// Cache miss
$user = ['id' => 123, 'name' => 'João'];
$memcached->set('user:123', serialize($user), 600);
}
echo $user['name'];É importante notar que o Memcached retorna false tanto para chave inexistente quanto para valor false; por isso, muitas vezes se usa um valor sentinela. No exemplo, serializamos o array, que nunca será false.
Cache de queries
O cache de queries é uma técnica que armazena o resultado de consultas SQL para evitar execuções repetidas. Isso é especialmente útil quando uma consulta é complexa e demorada, mas os resultados não mudam com frequência. O cache pode ser feito em nível de aplicação (usando as estruturas de cache já vistas) ou em nível de banco de dados (como o query cache do MySQL, que está obsoleto).
Para implementar o cache de queries em PHP, você pode criar um wrapper para a execução de consultas que verifica se o resultado já está no cache. A chave do cache pode ser a própria consulta SQL (ou um hash dela) e parâmetros. É crucial definir um TTL adequado ou uma estratégia de invalidação para evitar dados desatualizados.
Exemplo com PDO e cache em arquivo:
class QueryCache {
private $pdo;
private $cache;
public function __construct(PDO $pdo, FileCache $cache) {
$this->pdo = $pdo;
$this->cache = $cache;
}
public function query($sql, $params = [], $ttl = 300) {
$key = md5($sql . serialize($params));
$result = $this->cache->get($key);
if ($result !== null) {
return $result;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->cache->set($key, $result, $ttl);
return $result;
}
}
// Uso:
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$cache = new FileCache();
$queryCache = new QueryCache($pdo, $cache);
$users = $queryCache->query('SELECT * FROM users WHERE status = ?', ['active']);
foreach ($users as $user) {
echo $user['name'];
}Nesse exemplo, a chave do cache é baseada na consulta e nos parâmetros. Se o resultado estiver no cache, ele é retornado sem executar a consulta. Caso contrário, a consulta é executada e o resultado é armazenado por 5 minutos.
Invalidação
A invalidação de cache é o processo de remover ou atualizar dados do cache quando eles se tornam obsoletos. Uma das maiores dificuldades do cache é garantir que os dados não fiquem desatualizados. Existem várias estratégias de invalidação, cada uma com seus prós e contras.
As principais estratégias são:
- Expiração baseada em tempo (TTL): define um tempo de vida para cada entrada do cache. Após esse período, o cache é considerado inválido e é recriado na próxima leitura. É simples, mas pode servir dados desatualizados durante o TTL.
- Invalidação explícita: quando ocorre uma operação de escrita (insert, update, delete), você remove ou atualiza as chaves de cache relacionadas. Isso garante consistência imediata, mas exige que o código identifique quais chaves afetar.
- Versão de chave: incluir uma versão ou timestamp na chave do cache. Quando os dados mudam, a versão é incrementada, invalidando todas as chaves antigas. Isso é útil quando há muitos dados relacionados.
- Eventos/notificações: em sistemas mais complexos, você pode usar eventos (como mensageria) para notificar outros serviços sobre mudanças nos dados, permitindo que eles invalidem seus caches.
Exemplo de invalidação explícita em um sistema de usuários:
// Ao atualizar um usuário
function updateUser($pdo, $cache, $userId, $data) {
$stmt = $pdo->prepare('UPDATE users SET name = ? WHERE id = ?');
$stmt->execute([$data['name'], $userId]);
// Invalidar cache do usuário
$cache->delete('user:' . $userId);
// Se houver listas que incluem o usuário, invalidar também
$cache->delete('users:active');
}
// Na classe FileCache, adicione:
public function delete($key) {
$file = $this->getFileName($key);
if (file_exists($file)) {
unlink($file);
}
}Outra abordagem é usar o TTL como uma rede de segurança. Mesmo com invalidação explícita, é recomendável definir um TTL máximo para evitar que dados fiquem em cache por muito tempo caso alguma invalidação falhe.
Boas práticas e observações finais
Ao implementar cache, considere as seguintes boas práticas:
- Comece simples: use cache em arquivo para dados estáticos ou de baixa frequência de atualização.
- Monitore a taxa de acerto (hit rate) do cache. Se for baixa, ajuste o TTL ou a estratégia de invalidação.
- Não armazene informações sensíveis em cache sem criptografia, especialmente se o cache for compartilhado.
- Em aplicações distribuídas, prefira Redis ou Memcached para consistência entre servidores.
- Tenha cuidado com a serialização de objetos: ela pode ser custosa e causar problemas de compatibilidade.
- Use chaves consistentes e significativas, como
user:123ouproduct:456.
O cache é uma ferramenta poderosa, mas deve ser usado com critério. Avalie o custo-benefício: nem tudo precisa ser armazenado em cache. Dados que mudam com muita frequência ou que são específicos de cada usuário podem não se beneficiar do cache. Teste e meça o impacto para garantir que o cache está realmente melhorando o desempenho.
Referências
- PHP: File System Functions
- Redis Documentation
- PHP: Redis Extension
- PHP: Memcached Extension
- MySQL Query Cache (obsoleto)
- Martin Fowler: Two Hard Things
Exercícios
- Implemente uma classe de cache em arquivo que armazene dados serializados com TTL. Crie um teste que armazene um valor, espere o TTL expirar e verifique que o valor é recriado.
class FileCache {
private $dir;
public function __construct($dir) {
$this->dir = rtrim($dir, '/') . '/';
if (!is_dir($this->dir)) mkdir($this->dir, 0755, true);
}
public function get($key) {
$file = $this->dir . md5($key) . '.cache';
if (!file_exists($file)) return null;
$data = unserialize(file_get_contents($file));
if ($data['expires'] < time()) {
unlink($file);
return null;
}
return $data['value'];
}
public function set($key, $value, $ttl = 3600) {
$file = $this->dir . md5($key) . '.cache';
$data = ['expires' => time() + $ttl, 'value' => $value];
file_put_contents($file, serialize($data));
}
}
// Teste:
$cache = new FileCache('/tmp/cache');
$cache->set('foo', 'bar', 2);
echo $cache->get('foo'); // bar
sleep(3);
echo $cache->get('foo'); // null
function cachedQuery(PDO $pdo, Redis $redis, $sql, $params = [], $ttl = 600) {
$key = 'query:' . md5($sql . serialize($params));
$result = $redis->get($key);
if ($result !== false) {
return unserialize($result);
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
$redis->setex($key, $ttl, serialize($result));
return $result;
}
function updateProduct($pdo, $cache, $productId, $data) {
$stmt = $pdo->prepare('UPDATE products SET name = ?, category_id = ? WHERE id = ?');
$stmt->execute([$data['name'], $data['category_id'], $productId]);
// Invalidar cache do produto
$cache->delete('product:' . $productId);
// Invalidar lista de produtos da categoria antiga e nova
$oldCategoryId = $data['old_category_id'] ?? null;
if ($oldCategoryId) {
$cache->delete('category:products:' . $oldCategoryId);
}
$cache->delete('category:products:' . $data['category_id']);
}