Tienes una consulta que tarda 900 ms y devuelve lo mismo durante horas. Antes de montar un Redis, prueba lo que ya tienes instalado: Symfony trae un componente de caché con adaptador de sistema de ficheros configurado por defecto. Cero dependencias nuevas, cero infraestructura, y funciona igual en tu portátil que en el servidor.
El problema
Un servicio que recalcula lo mismo en cada petición:
public function getHomeStats(): array
{
return [
'users' => $this->users->countActive(), // ~300 ms
'sales' => $this->sales->monthlyTotals(), // ~600 ms
'weather' => $this->weatherApi->current(), // API externa, impredecible
];
}
Esos datos cambian como mucho cada hora, pero los pagas en cada visita. Y si la API externa se cae, se cae tu portada con ella.
No hay nada que instalar
El componente symfony/cache viene con
symfony/framework-bundle, así que ya está en tu proyecto. El pool por defecto
(cache.app) usa el adaptador de sistema de ficheros y guarda los datos en
var/cache/<entorno>/pools/. Si quieres dejarlo explícito:
# config/packages/cache.yaml
framework:
cache:
app: cache.adapter.filesystem
Puedes comprobar qué pools tienes con:
php bin/console cache:pool:list
1. Cachear algo, de verdad
Inyecta CacheInterface y usa get(): si la clave existe, devuelve lo
guardado; si no, ejecuta la función, guarda el resultado y te lo devuelve. Un solo método
para las dos ramas, sin if de por medio.
namespace App\Service;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class StatsProvider
{
public function __construct(
private CacheInterface $cache,
private StatsRepository $stats,
) {
}
public function getHomeStats(): array
{
return $this->cache->get('home_stats', function (ItemInterface $item): array {
$item->expiresAfter(3600); // una hora
return $this->stats->compute();
});
}
}
La función solo se ejecuta cuando hay fallo de caché. Y ojo: si no llamas a
expiresAfter(), el valor se guarda para siempre, que es la fuente de
la mitad de los sustos con caché.
Para expirar en un momento concreto en lugar de tras un intervalo:
$item->expiresAt(new \DateTimeImmutable('tomorrow 03:00'));
2. Claves: donde más se falla
La clave identifica el dato, así que tiene que incluir todo lo que lo hace distinto: el idioma, el id, la página, el rol del usuario. Si se te olvida algo, servirás a unos lo que era de otros.
$key = sprintf('product_%d_%s', $product->getId(), $locale);
return $this->cache->get($key, function (ItemInterface $item) use ($product, $locale) {
$item->expiresAfter(600);
return $this->renderer->describe($product, $locale);
});
Los caracteres {}()/\@: están reservados y no valen en una clave. Si la generas
a partir de algo que no controlas (una URL, un email), pásala antes por un hash:
$key = 'api_' . hash('xxh128', $url);
3. Un pool propio
Meterlo todo en cache.app funciona hasta que quieres vaciar solo una parte.
Definir un pool aparte cuesta tres líneas y te deja borrar por separado:
# config/packages/cache.yaml
framework:
cache:
pools:
app.cache.products:
adapter: cache.adapter.filesystem
default_lifetime: 3600
Cada pool guarda en su propia carpeta y se inyecta por su nombre de servicio:
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
public function __construct(
#[Autowire(service: 'app.cache.products')]
private CacheInterface $productsCache,
) {
}
Con default_lifetime ya no hace falta repetir el expiresAfter() en
cada llamada, aunque sigue mandando el del item si lo pones.
4. Invalidar cuando el dato cambia
Lo más directo es borrar la clave al guardar la entidad:
$this->productsCache->delete('product_' . $product->getId() . '_es');
El problema aparece cuando un mismo dato alimenta veinte claves distintas. Para eso están las etiquetas: marcas las entradas y luego invalidas por etiqueta, sin saber qué claves había. Hay que activarlas en el pool:
pools:
app.cache.products:
adapter: cache.adapter.filesystem
default_lifetime: 3600
tags: true
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
public function describe(Product $product, string $locale): string
{
return $this->productsCache->get(
sprintf('product_%d_%s', $product->getId(), $locale),
function (ItemInterface $item) use ($product, $locale): string {
$item->tag(['products', 'product_' . $product->getId()]);
return $this->renderer->describe($product, $locale);
}
);
}
// al guardar el producto, se van todos sus idiomas de golpe
$this->productsCache->invalidateTags(['product_' . $product->getId()]);
El tipo del argumento pasa a ser TagAwareCacheInterface, que extiende
CacheInterface: el resto del código no cambia.
5. Los comandos que vas a necesitar
php bin/console cache:pool:list # qué pools existen
php bin/console cache:pool:clear app.cache.products # vaciar uno
php bin/console cache:pool:clear --all # vaciar todos
php bin/console cache:pool:prune # borrar solo lo caducado
cache:clear es otra cosa: recompila el contenedor, no vacía tus pools de
aplicación. No los confundas cuando estés depurando.
El adaptador de ficheros no limpia solo lo caducado: los ficheros siguen ocupando disco aunque su contenido ya no valga. Un cron diario lo arregla:
0 4 * * * cd /var/www/app && php bin/console cache:pool:prune
Lo que se rompe si no lo sabes
- Sin
expiresAfter()el dato es eterno. Pon siempre un TTL, aunque sea largo, o undefault_lifetimeen el pool. - No caches entidades de Doctrine. Se serializan con sus relaciones y al recuperarlas están desconectadas del EntityManager. Guarda arrays o DTOs.
- La caché no es tu base de datos. Todo lo que metas tiene que poder
recalcularse; un
cache:pool:clearno puede costarte datos. - Ficheros no se comparten entre servidores. Con dos máquinas detrás de un balanceador tendrás dos cachés distintas: aceptable para datos recalculables, un problema para sesiones o contadores.
- Cuidado con lo que depende del usuario. Si el resultado cambia según quién mira, el identificador del usuario o su rol va en la clave. Si no, filtras datos de unos a otros.
- El despliegue no vacía la caché de aplicación.
var/cache/sobrevive si no lo borras: si cambias el formato de lo que guardas, cambia también el nombre de la clave o vacía el pool al desplegar.
Cuándo dejar el sistema de ficheros
El adaptador de ficheros aguanta perfectamente un servidor único con tráfico normal. Cámbialo
cuando tengas más de una máquina, cuando var/ esté en un disco de red lento o
cuando necesites que la caché sobreviva a un despliegue que borra el directorio. La gracia
es que la migración es una línea:
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'
Tu código no se entera: sigue siendo CacheInterface y el mismo
get().
Y ya está
Inyectar CacheInterface, envolver la parte lenta en un get() con su
TTL y elegir bien la clave. Con eso quitas las llamadas repetidas sin instalar nada, y el
día que necesites Redis solo cambias una línea de configuración.